diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 000000000..26e4786a3 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,8 @@ +{ + "name": "listmonk", + "dockerComposeFile": "../dev/docker-compose.yml", + "service": "backend", + "workspaceFolder": "/app", + "forwardPorts": [9000], + "postStartCommand": "make dist && ./listmonk --install --idempotent --yes --config dev/config.toml" +} diff --git a/.gitattributes b/.gitattributes index 16225b49f..19f3aff3e 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,3 @@ frontend/* linguist-vendored VERSION export-subst +* text=auto eol=lf diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/confirmed-bug.md similarity index 75% rename from .github/ISSUE_TEMPLATE/bug_report.md rename to .github/ISSUE_TEMPLATE/confirmed-bug.md index cd326db49..fcb5f3ebc 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/confirmed-bug.md @@ -1,6 +1,6 @@ --- -name: Bug report -about: Report an issue that you have identified to be a bug +name: Confirmed bug +about: Report an issue that you have definititely confirmed to be a bug title: '' labels: bug assignees: '' diff --git a/.github/ISSUE_TEMPLATE/general-question.md b/.github/ISSUE_TEMPLATE/general-question.md index 696dde72f..07a254060 100644 --- a/.github/ISSUE_TEMPLATE/general-question.md +++ b/.github/ISSUE_TEMPLATE/general-question.md @@ -2,9 +2,9 @@ name: General question about: You have a question about something or want to start a general discussion title: '' -labels: '' +labels: 'question' assignees: '' --- - +Note: Please refrain from posting questions about Docker and docker-compose related matters. Please search and refer to the numerous closed issues on these topics. Docker related questions are outside of the purview of this forum and will be closed. Thank you for your understanding. diff --git a/.github/ISSUE_TEMPLATE/general.md b/.github/ISSUE_TEMPLATE/general.md deleted file mode 100644 index 1dd61216b..000000000 --- a/.github/ISSUE_TEMPLATE/general.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -name: General -about: General questions and discussions -title: '' -labels: '' -assignees: '' - ---- - - diff --git a/.github/ISSUE_TEMPLATE/possible-bug--needs-investigation-.md b/.github/ISSUE_TEMPLATE/possible-bug--needs-investigation-.md new file mode 100644 index 000000000..7caba83a9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/possible-bug--needs-investigation-.md @@ -0,0 +1,18 @@ +--- +name: Possible bug. Needs investigation. +about: Report an issue that could be a bug but is not confirmed yet and needs investigation. +title: '' +labels: '' +assignees: '' + +--- + +**Version:** + - listmonk: [eg: v1.0.0] + - OS: [e.g. Fedora] + +**Description of the bug and steps to reproduce:** +A clear and concise description of what the bug is. + +**Screenshots:** +If applicable, add screenshots to help explain your problem. diff --git a/.github/workflows/build-sanity.yml b/.github/workflows/build-sanity.yml new file mode 100644 index 000000000..7e1430220 --- /dev/null +++ b/.github/workflows/build-sanity.yml @@ -0,0 +1,22 @@ +name: Build Sanity Check + +on: + pull_request: + types: + - opened + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: "1.22" + + - name: Prepare Dependencies and Build + run: make dist diff --git a/.github/workflows/github-pages.yml b/.github/workflows/github-pages.yml new file mode 100644 index 000000000..2519d50f2 --- /dev/null +++ b/.github/workflows/github-pages.yml @@ -0,0 +1,62 @@ +name: publish-github-pages + +on: + push: + branches: + - master + paths: + - 'docs/**' + workflow_dispatch: + +permissions: + contents: write + +jobs: + deploy: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v2 + with: + submodules: true # Fetch Hugo themes + fetch-depth: 0 # Fetch all history for .GitInfo and .Lastmod + + - uses: actions/setup-python@v2 + with: + python-version: 3.x + - run: pip install mkdocs-material + + - name: Setup Hugo + uses: peaceiris/actions-hugo@v2 + with: + hugo-version: '0.68.3' + + # Build the main site to the docs/publish directory. This will be the root (/) in gh-pages. + # The -d (output) path is relative to the -s (source) path + - name: Build main site + run: hugo -s docs/site -d ../publish --gc --minify + + # Build the mkdocs documentation in the docs/publish/docs dir. This will be at (/docs) + # The -d (output) path is relative to the -f (source) path + - name: Build docs site + run: mkdocs build -f docs/docs/mkdocs.yml -d ../publish/docs + + # Copy the static i18n app to the publish directory. This will be at (/i18n) + - name: Copy i18n site + run: cp -R docs/i18n docs/publish + + - name: Generate Swagger UI + uses: Legion2/swagger-ui-action@v1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + spec-file: ./docs/swagger/collections.yaml + output: ./docs/publish/docs/swagger + + - name: Deploy + uses: peaceiris/actions-gh-pages@v3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_branch: gh-pages + publish_dir: ./docs/publish + cname: listmonk.app + user_name: 'github-actions[bot]' + user_email: 'github-actions[bot]@users.noreply.github.com' diff --git a/.github/workflows/issues.yml b/.github/workflows/issues.yml new file mode 100644 index 000000000..c2b9df2bc --- /dev/null +++ b/.github/workflows/issues.yml @@ -0,0 +1,20 @@ +name: "close-stale-issues-and-prs" +on: + schedule: + - cron: "30 1 * * *" + workflow_dispatch: + +jobs: + stale: + runs-on: ubuntu-latest + steps: + - uses: actions/stale@v9 + with: + days-before-stale: 90 + stale-issue-label: "stale" + stale-pr-label: "stale" + debug-only: false + exempt-all-assignees: true + operations-per-run: 1000 + stale-issue-message: "This issue has been marked 'stale' after 90 days of inactivity. If there is no further activity, it will be closed in 7 days." + stale-pr-message: "This PR has been marked 'stale' after 90 days of inactivity. If there is no further activity, it will be closed in 7 days." diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3bf7d35b7..283c9554e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,34 +5,50 @@ on: tags: - "v*" # Will trigger only if tag is pushed matching pattern `v*` (Eg: `v0.1.0`) +permissions: write-all + jobs: goreleaser: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: fetch-depth: 0 + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + - name: Set up Go - uses: actions/setup-go@v2 + uses: actions/setup-go@v4 with: - go-version: 1.16 + go-version: "1.23.2" - name: Login to Docker Registry - uses: docker/login-action@v1 + uses: docker/login-action@v2 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Login to GitHub Docker Registry + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Prepare Dependencies run: | - make deps + make dist + + - name: Check Docker Version + run: | + docker version - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v2 + uses: goreleaser/goreleaser-action@v4 with: version: latest - args: --parallelism 1 --rm-dist --skip-validate + args: release --parallelism 1 --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.goreleaser.yml b/.goreleaser.yml index 5d108f3b1..7fb66ac50 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -1,6 +1,8 @@ env: - GO111MODULE=on - CGO_ENABLED=0 + - GITHUB_ORG=knadh + - DOCKER_ORG=listmonk before: hooks: @@ -10,16 +12,21 @@ builds: - binary: listmonk main: ./cmd goos: + - linux - windows - darwin - - linux - freebsd - openbsd - netbsd goarch: - amd64 + - arm64 + - arm + goarm: + - 6 + - 7 ldflags: - - -s -w -X "main.buildString={{ .Tag }} ({{ .ShortCommit }} {{ .Date }})" -X "main.versionString={{ .Tag }}" + - -s -w -X "main.buildString={{ .Tag }} ({{ .ShortCommit }} {{ .Date }}, {{ .Os }}/{{ .Arch }})" -X "main.versionString={{ .Tag }}" hooks: # stuff executables with static assets. @@ -32,15 +39,127 @@ archives: - LICENSE dockers: - - + - use: buildx goos: linux goarch: amd64 ids: - - listmonk + - listmonk + image_templates: + - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-amd64" + - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-amd64" + - "ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:latest-amd64" + - "ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:{{ .Tag }}-amd64" + build_flag_templates: + - --platform=linux/amd64 + - --label=org.opencontainers.image.title={{ .ProjectName }} + - --label=org.opencontainers.image.description={{ .ProjectName }} + - --label=org.opencontainers.image.url=https://github.com/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }} + - --label=org.opencontainers.image.source=https://github.com/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }} + - --label=org.opencontainers.image.version={{ .Version }} + - --label=org.opencontainers.image.created={{ time "2006-01-02T15:04:05Z07:00" }} + - --label=org.opencontainers.image.revision={{ .FullCommit }} + - --label=org.opencontainers.image.licenses=AGPL-3.0 + dockerfile: Dockerfile + extra_files: + - config.toml.sample + - docker-entrypoint.sh + - use: buildx + goos: linux + goarch: arm64 + ids: + - listmonk + image_templates: + - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-arm64v8" + - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-arm64v8" + - "ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:latest-arm64v8" + - "ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:{{ .Tag }}-arm64v8" + build_flag_templates: + - --platform=linux/arm64/v8 + - --label=org.opencontainers.image.title={{ .ProjectName }} + - --label=org.opencontainers.image.description={{ .ProjectName }} + - --label=org.opencontainers.image.url=https://github.com/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }} + - --label=org.opencontainers.image.source=https://github.com/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }} + - --label=org.opencontainers.image.version={{ .Version }} + - --label=org.opencontainers.image.created={{ time "2006-01-02T15:04:05Z07:00" }} + - --label=org.opencontainers.image.revision={{ .FullCommit }} + - --label=org.opencontainers.image.licenses=AGPL-3.0 + dockerfile: Dockerfile + extra_files: + - config.toml.sample + - docker-entrypoint.sh + - use: buildx + goos: linux + goarch: arm + goarm: 6 + ids: + - listmonk + image_templates: + - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-armv6" + - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-armv6" + - "ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:latest-armv6" + - "ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:{{ .Tag }}-armv6" + build_flag_templates: + - --platform=linux/arm/v6 + - --label=org.opencontainers.image.title={{ .ProjectName }} + - --label=org.opencontainers.image.description={{ .ProjectName }} + - --label=org.opencontainers.image.url=https://github.com/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }} + - --label=org.opencontainers.image.source=https://github.com/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }} + - --label=org.opencontainers.image.version={{ .Version }} + - --label=org.opencontainers.image.created={{ time "2006-01-02T15:04:05Z07:00" }} + - --label=org.opencontainers.image.revision={{ .FullCommit }} + - --label=org.opencontainers.image.licenses=AGPL-3.0 + dockerfile: Dockerfile + extra_files: + - config.toml.sample + - docker-entrypoint.sh + - use: buildx + goos: linux + goarch: arm + goarm: 7 + ids: + - listmonk image_templates: - - "listmonk/listmonk:latest" - - "listmonk/listmonk:{{ .Tag }}" + - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-armv7" + - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-armv7" + - "ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:latest-armv7" + - "ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:{{ .Tag }}-armv7" + build_flag_templates: + - --platform=linux/arm/v7 + - --label=org.opencontainers.image.title={{ .ProjectName }} + - --label=org.opencontainers.image.description={{ .ProjectName }} + - --label=org.opencontainers.image.url=https://github.com/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }} + - --label=org.opencontainers.image.source=https://github.com/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }} + - --label=org.opencontainers.image.version={{ .Version }} + - --label=org.opencontainers.image.created={{ time "2006-01-02T15:04:05Z07:00" }} + - --label=org.opencontainers.image.revision={{ .FullCommit }} + - --label=org.opencontainers.image.licenses=AGPL-3.0 dockerfile: Dockerfile extra_files: - - config.toml.sample - - config-demo.toml + - config.toml.sample + - docker-entrypoint.sh + +docker_manifests: + - name_template: "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest" + image_templates: + - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-amd64" + - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-arm64v8" + - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-armv6" + - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-armv7" + - name_template: "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}" + image_templates: + - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-amd64" + - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-arm64v8" + - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-armv6" + - "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-armv7" + - name_template: ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:latest + image_templates: + - ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:latest-amd64 + - ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:latest-arm64v8 + - ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:latest-armv6 + - ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:latest-armv7 + - name_template: ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:{{ .Tag }} + image_templates: + - ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:{{ .Tag }}-amd64 + - ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:{{ .Tag }}-arm64v8 + - ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:{{ .Tag }}-armv6 + - ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:{{ .Tag }}-armv7 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..9a1c371cd --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,49 @@ +# 1. Contributing + +Welcome to listmonk! You can contribute to the project in the following ways: + +1. **Bug reports:** One liner reports are difficult to understand and review. + 1. Follow the bug reporting issue template and provide clear, concise descriptions and steps to reproduce the bug. + 2. Ensure that you have searched the existing issues to avoid duplicates. + 3. Maintainers may close unclear issues that lack enough information to reproduce a bug. [Report a bug here](https://github.com/knadh/listmonk/issues/new?assignees=&labels=bug&template=bug_report.md). + +2. **Feature suggestions:** If you feel there is a nice enhancement or feature that can benefit many users, please open a feature request issue. + 1. Ensure that you have searched the existing issues to avoid duplicates. + 2. What makes sense for the project, what suits its scope and goals, and its future direction are at the discretion of the maintainers who put in the time, effort, and energy in building and maintaining the project for free. Please be respectful of this and keep discussions friendly and fruitful. + 3. It is the responsibility of the requester to clearly explain and justify why a change is warranted. It is not the responsibility of the maintainers to coax this information out of a requester. So, please post well researched, well thought out, and detailed feature requests saving everyone time. + 4. Maintainers may close unclear feature requests that lack enough information. [Suggest a feature here](https://github.com/knadh/listmonk/issues/new?assignees=&labels=enhancement&template=feature-or-change-request.md&title=). + +3. **Improving docs:** You can submit corrections and improvements to the [documentation](https://listmonk.app/docs) website on the [docs repo](https://github.com/knadh/listmonk/tree/master/docs). + +4. **i18n translations:** The project is available in many languages thanks to user contributions. You can create a new language pack or submit corrections to existing ones. There is a UI available for making translations easy. [More info here](https://listmonk.app/docs/i18n/). + + +# 2. Pull requests + +This is a tricky one for many reasons. A PR, be it a new feature or a small enhancement, has to make sense to the project's overall scope, goals, and technical aspects. The quality, style, and conventions of the code have to conform to that of the project's. Performance, usability, stability and other kinds of impacts of a PR should be well understood. + +This makes reviewing PRs a difficult and time consuming task. The bigger a PR, the more difficult it is to understand. Reviewing a PR in detail, engaging in back and forth discussions to improve it, and deciding that it is meaningful and safe to merge can often require more time and effort than what has gone into creating a PR. Thus, ultimately, whether a PR gets accepted or not, for whatever reason, is at the discretion of the maintainers. Please be respectful of the fact that maintainers have a much deeper understanding of the overall project. So, nitpicking on micro aspects may not be meaningful. + +To keep the process smooth: + +1. **Send a proposal first:** Open an issue describing what you aim to accomplish, how it makes sense to the project, and how you plan on implementing it (with useful technical details), before committing time and effort to writing code. This saves everyone time. + +2. **Send small PRs:** Whenever possible, send small PRs with well defined scopes. The smaller the PR, the easier it is to review and test. Bundling multiple features into a single PR is highly discouraged. + +3. **PRs will be squashed in the end:** A PR may change considerably with multiple commits before it is approved. Once a PR is approved, if there are multiple commits, they will be squashed into a single commit during merging. + + +# 3. Be respectful + +Remember, most FOSS projects are fruits of love and labour of maintainers who share them with the world for free with no expectations of any returns. Free as in freedom, and free as in beer too. Really, *some people just want to watch the world turn*. + +So: + +1. Please be respectful and refrain from using aggressive or snarky language. It wastes time, cognitive bandwidth, and goodwill. +2. Please refrain from demanding. How badly you want a feature has no bearing on whether it warrants a maintainer's time or attention. It is entirely up to the maintainers, if, how, and when they want to implement something. +3. Please do not nitpick and generate unnecessary discussions that waste time. +4. Please make sure you have searched the docs and issues before asking support questions. +5. **Please remember, FOSS project maintainers owe you nothing** (unless you have an explicit agreement with them, of course) including their time in responding to your messages or providing free customer support. If you want to be heard, please be respectful and establish goodwill. +6. If these are unacceptable to you a) you don't have to use the project b) you can always fork the project and change it to your liking while adhering to the terms of the license. That is the beauty of FOSS, afterall. + +Thank you! diff --git a/Dockerfile b/Dockerfile index 9077201c3..783f4287b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,26 @@ FROM alpine:latest -RUN apk --no-cache add ca-certificates + +# Install dependencies +RUN apk --no-cache add ca-certificates tzdata shadow su-exec + +# Set the working directory WORKDIR /listmonk + +# Copy only the necessary files COPY listmonk . COPY config.toml.sample config.toml -COPY config-demo.toml . -CMD ["./listmonk"] + +# Copy the entrypoint script +COPY docker-entrypoint.sh /usr/local/bin/ + +# Make the entrypoint script executable +RUN chmod +x /usr/local/bin/docker-entrypoint.sh + +# Expose the application port EXPOSE 9000 + +# Set the entrypoint +ENTRYPOINT ["docker-entrypoint.sh"] + +# Define the command to run the application +CMD ["./listmonk"] diff --git a/Makefile b/Makefile index ff8f1c545..3ffc909cc 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,8 @@ # Try to get the commit hash from 1) git 2) the VERSION file 3) fallback. -LAST_COMMIT := $(or $(shell git rev-parse --short HEAD 2> /dev/null),$(shell head -n 1 VERSION | grep -oP -m 1 "^[a-z0-9]+$$"),"UNKNOWN") +LAST_COMMIT := $(or $(shell git rev-parse --short HEAD 2> /dev/null),$(shell head -n 1 VERSION | grep -oP -m 1 "^[a-z0-9]+$$"),"") -# Try to get the semver from 1) git 2) the VERSION file 3) fallbakc. -VERSION := $(or $(shell git describe --tags --abbrev=0 2> /dev/null),$(shell grep -oP "tag: \K(.*)(?=,)" VERSION),"v0.0.0") +# Try to get the semver from 1) git 2) the VERSION file 3) fallback. +VERSION := $(or $(LISTMONK_VERSION),$(shell git describe --tags --abbrev=0 2> /dev/null),$(shell grep -oP 'tag: \Kv\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?' VERSION),"v0.0.0") BUILDSTR := ${VERSION} (\#${LAST_COMMIT} $(shell date -u +"%Y-%m-%dT%H:%M:%S%z")) @@ -13,42 +13,43 @@ FRONTEND_YARN_MODULES = frontend/node_modules FRONTEND_DIST = frontend/dist FRONTEND_DEPS = \ $(FRONTEND_YARN_MODULES) \ + frontend/index.html \ frontend/package.json \ - frontend/vue.config.js \ - frontend/babel.config.js \ + frontend/vite.config.js \ + frontend/.eslintrc.js \ $(shell find frontend/fontello frontend/public frontend/src -type f) BIN := listmonk STATIC := config.toml.sample \ - schema.sql queries.sql \ + schema.sql queries.sql permissions.json \ static/public:/public \ static/email-templates \ - frontend/dist/frontend:/frontend \ + frontend/dist:/admin \ i18n:/i18n .PHONY: build build: $(BIN) $(STUFFBIN): - go get -u github.com/knadh/stuffbin/... + go install github.com/knadh/stuffbin/... $(FRONTEND_YARN_MODULES): frontend/package.json frontend/yarn.lock cd frontend && $(YARN) install - touch --no-create $(FRONTEND_YARN_MODULES) + touch -c $(FRONTEND_YARN_MODULES) # Build the backend to ./listmonk. -$(BIN): $(shell find . -type f -name "*.go") +$(BIN): $(shell find . -type f -name "*.go") go.mod go.sum schema.sql queries.sql permissions.json CGO_ENABLED=0 go build -o ${BIN} -ldflags="-s -w -X 'main.buildString=${BUILDSTR}' -X 'main.versionString=${VERSION}'" cmd/*.go -# Run the backend in dev mode. The frontend assets in dev mode are loaded from disk from frontend/dist/frontend. +# Run the backend in dev mode. The frontend assets in dev mode are loaded from disk from frontend/dist. .PHONY: run run: - CGO_ENABLED=0 go run -ldflags="-s -w -X 'main.buildString=${BUILDSTR}' -X 'main.versionString=${VERSION}' -X 'main.frontendDir=frontend/dist/frontend'" cmd/*.go + CGO_ENABLED=0 go run -ldflags="-s -w -X 'main.buildString=${BUILDSTR}' -X 'main.versionString=${VERSION}' -X 'main.frontendDir=frontend/dist'" cmd/*.go # Build the JS frontend into frontend/dist. $(FRONTEND_DIST): $(FRONTEND_DEPS) - export VUE_APP_VERSION="${VERSION}" && cd frontend && $(YARN) build && mv dist/favicon.png dist/frontend/favicon.png - touch --no-create $(FRONTEND_DIST) + export VUE_APP_VERSION="${VERSION}" && cd frontend && $(YARN) build + touch -c $(FRONTEND_DIST) .PHONY: build-frontend @@ -57,7 +58,7 @@ build-frontend: $(FRONTEND_DIST) # Run the JS frontend server in dev mode. .PHONY: run-frontend run-frontend: - export VUE_APP_VERSION="${VERSION}" && cd frontend && $(YARN) serve + export VUE_APP_VERSION="${VERSION}" && cd frontend && $(YARN) dev # Run Go tests. .PHONY: test @@ -67,21 +68,49 @@ test: # Bundle all static assets including the JS frontend into the ./listmonk binary # using stuffbin (installed with make deps). .PHONY: dist -dist: $(STUFFBIN) build build-frontend - $(STUFFBIN) -a stuff -in ${BIN} -out ${BIN} ${STATIC} +dist: $(STUFFBIN) build build-frontend pack-bin # pack-releases runns stuffbin packing on the given binary. This is used # in the .goreleaser post-build hook. .PHONY: pack-bin -pack-bin: $(STUFFBIN) +pack-bin: build-frontend $(BIN) $(STUFFBIN) $(STUFFBIN) -a stuff -in ${BIN} -out ${BIN} ${STATIC} # Use goreleaser to do a dry run producing local builds. .PHONY: release-dry release-dry: - goreleaser --parallelism 1 --rm-dist --snapshot --skip-validate --skip-publish + goreleaser release --parallelism 1 --clean --snapshot --skip=publish # Use goreleaser to build production releases and publish them. .PHONY: release release: - goreleaser --parallelism 1 --rm-dist --skip-validate + goreleaser release --parallelism 1 --clean + +# Build local docker images for development. +.PHONY: build-dev-docker +build-dev-docker: build ## Build docker containers for the entire suite (Front/Core/PG). + cd dev; \ + docker compose build ; \ + +# Spin a local docker suite for local development. +.PHONY: dev-docker +dev-docker: build-dev-docker ## Build and spawns docker containers for the entire suite (Front/Core/PG). + cd dev; \ + docker compose up + +# Run the backend in docker-dev mode. The frontend assets in dev mode are loaded from disk from frontend/dist. +.PHONY: run-backend-docker +run-backend-docker: + CGO_ENABLED=0 go run -ldflags="-s -w -X 'main.buildString=${BUILDSTR}' -X 'main.versionString=${VERSION}' -X 'main.frontendDir=frontend/dist'" cmd/*.go --config=dev/config.toml + +# Tear down the complete local development docker suite. +.PHONY: rm-dev-docker +rm-dev-docker: build ## Delete the docker containers including DB volumes. + cd dev; \ + docker compose down -v ; \ + +# Setup the db for local dev docker suite. +.PHONY: init-dev-docker +init-dev-docker: build-dev-docker ## Delete the docker containers including DB volumes. + cd dev; \ + docker compose run --rm backend sh -c "make dist && ./listmonk --install --idempotent --yes --config dev/config.toml" diff --git a/README.md b/README.md index dc0dafec9..1aaa5b498 100644 --- a/README.md +++ b/README.md @@ -1,75 +1,46 @@ -![listmonk](https://user-images.githubusercontent.com/547147/89733021-43fbf700-da70-11ea-82e4-e98cb5010257.png) +[![listmonk-logo](https://user-images.githubusercontent.com/547147/231084896-835dba66-2dfe-497c-ba0f-787564c0819e.png)](https://listmonk.app) -listmonk is a standalone, self-hosted, newsletter and mailing list manager. It is fast, feature-rich, and packed into a single binary. It uses a PostgreSQL database as its data store. +listmonk is a standalone, self-hosted, newsletter and mailing list manager. It is fast, feature-rich, and packed into a single binary. It uses a PostgreSQL (⩾ 12) database as its data store. -[![listmonk-dashboard](https://user-images.githubusercontent.com/547147/89733057-87566580-da70-11ea-8160-855f6f046a55.png)](https://listmonk.app) -Visit [listmonk.app](https://listmonk.app) +[![listmonk-dashboard](https://user-images.githubusercontent.com/547147/134939475-e0391111-f762-44cb-b056-6cb0857755e3.png)](https://listmonk.app) + +Visit [listmonk.app](https://listmonk.app) for more info. Check out the [**live demo**](https://demo.listmonk.app). ## Installation ### Docker -The latest image is available on DockerHub at `listmonk/listmonk:latest`. Use the sample [docker-compose.yml](https://github.com/knadh/listmonk/blob/master/docker-compose.yml) to run listmonk and Postgres DB with docker-compose as follows: - -#### Demo - -```bash -mkdir listmonk-demo -sh -c "$(curl -fsSL https://raw.githubusercontent.com/knadh/listmonk/master/install-demo.sh)" -``` +The latest image is available on DockerHub at [`listmonk/listmonk:latest`](https://hub.docker.com/r/listmonk/listmonk/tags?page=1&ordering=last_updated&name=latest). +Download and use the sample [docker-compose.yml](https://github.com/knadh/listmonk/blob/master/docker-compose.yml). -The demo does not persist Postgres after the containers are removed. DO NOT use this demo setup in production. -#### Production +```shell +# Download the compose file to the current directory. +curl -LO https://github.com/knadh/listmonk/raw/master/docker-compose.yml -##### Easy Docker install - -This setup is recommended if you want to _quickly_ setup `listmonk` in production. - -```bash -mkdir listmonk -sh -c "$(curl -fsSL https://raw.githubusercontent.com/knadh/listmonk/master/install-prod.sh)" +# Run the services in the background. +docker compose up -d ``` +Visit `http://localhost:9000` -The above shell script performs the following actions: - -- Downloads `docker-compose.yml` and generates a `config.toml`. -- Runs a Postgres container and installs the database schema. -- Runs the `listmonk` container. - -**NOTE**: It's recommended to examine the contents of the shell script, before running in your environment. - -##### Manual Docker install - -The following workflow is recommended to setup `listmonk` manually using `docker-compose`. You are encouraged to customise the contents of `docker-compose.yml` to your needs. The overall setup looks like: - -- `docker-compose up db` to run the Postgres DB. -- `docker-compose run --rm app ./listmonk --install` to setup the DB (or `--upgrade` to upgrade an existing DB). -- Copy `config.toml.sample` to your directory and make the following changes: - - `app.address` => `0.0.0.0:9000` (Port forwarding on Docker will work only if the app is advertising on all interfaces.) - - `db.host` => `listmonk_db` (Container Name of the DB container) -- Run `docker-compose up app` and visit `http://localhost:9000`. - -More information on [docs](https://listmonk.app/docs). +See [installation docs](https://listmonk.app/docs/installation) __________________ ### Binary - Download the [latest release](https://github.com/knadh/listmonk/releases) and extract the listmonk binary. -- `./listmonk --new-config` to generate config.toml. Then, edit the file. +- `./listmonk --new-config` to generate config.toml. Edit it. - `./listmonk --install` to setup the Postgres DB (or `--upgrade` to upgrade an existing DB. Upgrades are idempotent and running them multiple times have no side effects). -- Run `./listmonk` and visit `http://localhost:9000`. +- Run `./listmonk` and visit `http://localhost:9000` +See [installation docs](https://listmonk.app/docs/installation) __________________ -### Heroku -[![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy?template=https://github.com/knadh/listmonk-heroku) - ## Developers -listmonk is a free and open source software licensed under AGPLv3. If you are interested in contributing, refer to the [developer setup](https://listmonk.app/docs/developer-setup). The backend is written in Go and the frontend is Vue with Buefy for UI. +listmonk is free and open source software licensed under AGPLv3. If you are interested in contributing, refer to the [developer setup](https://listmonk.app/docs/developer-setup). The backend is written in Go and the frontend is Vue with Buefy for UI. ## License diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 0e5e53f91..000000000 --- a/TODO.md +++ /dev/null @@ -1,8 +0,0 @@ -- [ ] Add a "running campaigns" widget on the dashboard -- [ ] Add more analytics and stats -- [ ] Add bounce tracking -- [ ] Pause campaigns on % errors in addition to an absolute numbers -- [ ] Support DB migrations for easy upgrades -- [ ] Add materialized views for analytics and stats (and more?) -- [ ] Add user management and permissions -- [ ] Add tests diff --git a/cmd/admin.go b/cmd/admin.go index 618ae19fe..2541dbfb5 100644 --- a/cmd/admin.go +++ b/cmd/admin.go @@ -1,31 +1,40 @@ package main import ( + "encoding/json" "fmt" "net/http" - "sort" "syscall" "time" - "github.com/jmoiron/sqlx/types" - "github.com/labstack/echo" + "github.com/labstack/echo/v4" ) type serverConfig struct { - Messengers []string `json:"messengers"` - Langs []i18nLang `json:"langs"` - Lang string `json:"lang"` - Update *AppUpdate `json:"update"` - NeedsRestart bool `json:"needs_restart"` - Version string `json:"version"` + RootURL string `json:"root_url"` + FromEmail string `json:"from_email"` + Messengers []string `json:"messengers"` + Langs []i18nLang `json:"langs"` + Lang string `json:"lang"` + Permissions json.RawMessage `json:"permissions"` + Update *AppUpdate `json:"update"` + NeedsRestart bool `json:"needs_restart"` + HasLegacyUser bool `json:"has_legacy_user"` + Version string `json:"version"` } // handleGetServerConfig returns general server config. func handleGetServerConfig(c echo.Context) error { var ( app = c.Get("app").(*App) - out = serverConfig{} ) + out := serverConfig{ + RootURL: app.constants.RootURL, + FromEmail: app.constants.FromEmail, + Lang: app.constants.Lang, + Permissions: app.constants.PermissionsRaw, + HasLegacyUser: app.constants.HasLegacyUser, + } // Language list. langList, err := getI18nLangList(app.constants.Lang, app) @@ -34,19 +43,11 @@ func handleGetServerConfig(c echo.Context) error { fmt.Sprintf("Error loading language list: %v", err)) } out.Langs = langList - out.Lang = app.constants.Lang - // Sort messenger names with `email` always as the first item. - var names []string - for name := range app.messengers { - if name == emailMsgr { - continue - } - names = append(names, name) + out.Messengers = make([]string, 0, len(app.messengers)) + for _, m := range app.messengers { + out.Messengers = append(out.Messengers, m.Name()) } - sort.Strings(names) - out.Messengers = append(out.Messengers, emailMsgr) - out.Messengers = append(out.Messengers, names...) app.Lock() out.NeedsRestart = app.needsRestart @@ -61,12 +62,11 @@ func handleGetServerConfig(c echo.Context) error { func handleGetDashboardCharts(c echo.Context) error { var ( app = c.Get("app").(*App) - out types.JSONText ) - if err := app.queries.GetDashboardCharts.Get(&out); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorFetching", "name", "dashboard charts", "error", pqErrMsg(err))) + out, err := app.core.GetDashboardCharts() + if err != nil { + return err } return c.JSON(http.StatusOK, okResp{out}) @@ -76,12 +76,11 @@ func handleGetDashboardCharts(c echo.Context) error { func handleGetDashboardCounts(c echo.Context) error { var ( app = c.Get("app").(*App) - out types.JSONText ) - if err := app.queries.GetDashboardCounts.Get(&out); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorFetching", "name", "dashboard stats", "error", pqErrMsg(err))) + out, err := app.core.GetDashboardCounts() + if err != nil { + return err } return c.JSON(http.StatusOK, okResp{out}) @@ -92,7 +91,7 @@ func handleReloadApp(c echo.Context) error { app := c.Get("app").(*App) go func() { <-time.After(time.Millisecond * 500) - app.sigChan <- syscall.SIGHUP + app.chReload <- syscall.SIGHUP }() return c.JSON(http.StatusOK, okResp{true}) } diff --git a/cmd/archive.go b/cmd/archive.go new file mode 100644 index 000000000..173dbf402 --- /dev/null +++ b/cmd/archive.go @@ -0,0 +1,276 @@ +package main + +import ( + "bytes" + "encoding/json" + "html/template" + "net/http" + "net/url" + + "github.com/gorilla/feeds" + "github.com/knadh/listmonk/internal/manager" + "github.com/knadh/listmonk/models" + "github.com/labstack/echo/v4" + null "gopkg.in/volatiletech/null.v6" +) + +type campArchive struct { + UUID string `json:"uuid"` + Subject string `json:"subject"` + Content string `json:"content"` + CreatedAt null.Time `json:"created_at"` + SendAt null.Time `json:"send_at"` + URL string `json:"url"` +} + +// handleGetCampaignArchives renders the public campaign archives page. +func handleGetCampaignArchives(c echo.Context) error { + var ( + app = c.Get("app").(*App) + pg = app.paginator.NewFromURL(c.Request().URL.Query()) + ) + + camps, total, err := getCampaignArchives(pg.Offset, pg.Limit, false, app) + if err != nil { + return err + } + + var out models.PageResults + if len(camps) == 0 { + out.Results = []campArchive{} + return c.JSON(http.StatusOK, okResp{out}) + } + + // Meta. + out.Results = camps + out.Total = total + out.Page = pg.Page + out.PerPage = pg.PerPage + + return c.JSON(200, okResp{out}) +} + +// handleGetCampaignArchivesFeed renders the public campaign archives RSS feed. +func handleGetCampaignArchivesFeed(c echo.Context) error { + var ( + app = c.Get("app").(*App) + pg = app.paginator.NewFromURL(c.Request().URL.Query()) + showFullContent = app.constants.EnablePublicArchiveRSSContent + ) + + camps, _, err := getCampaignArchives(pg.Offset, pg.Limit, showFullContent, app) + if err != nil { + return err + } + + out := make([]*feeds.Item, 0, len(camps)) + for _, c := range camps { + pubDate := c.CreatedAt.Time + + if c.SendAt.Valid { + pubDate = c.SendAt.Time + } + + out = append(out, &feeds.Item{ + Title: c.Subject, + Link: &feeds.Link{Href: c.URL}, + Content: c.Content, + Created: pubDate, + }) + } + + feed := &feeds.Feed{ + Title: app.constants.SiteName, + Link: &feeds.Link{Href: app.constants.RootURL}, + Description: app.i18n.T("public.archiveTitle"), + Items: out, + } + + if err := feed.WriteRss(c.Response().Writer); err != nil { + app.log.Printf("error generating archive RSS feed: %v", err) + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("public.errorProcessingRequest")) + } + + return nil +} + +// handleCampaignArchivesPage renders the public campaign archives page. +func handleCampaignArchivesPage(c echo.Context) error { + var ( + app = c.Get("app").(*App) + pg = app.paginator.NewFromURL(c.Request().URL.Query()) + ) + + out, total, err := getCampaignArchives(pg.Offset, pg.Limit, false, app) + if err != nil { + return err + } + pg.SetTotal(total) + + title := app.i18n.T("public.archiveTitle") + return c.Render(http.StatusOK, "archive", struct { + Title string + Description string + Campaigns []campArchive + TotalPages int + Pagination template.HTML + }{title, title, out, pg.TotalPages, template.HTML(pg.HTML("?page=%d"))}) +} + +// handleCampaignArchivePage renders the public campaign archives page. +func handleCampaignArchivePage(c echo.Context) error { + var ( + app = c.Get("app").(*App) + id = c.Param("id") + uuid = "" + slug = "" + ) + + // ID can be the UUID or slug. + if reUUID.MatchString(id) { + uuid = id + } else { + slug = id + } + + pubCamp, err := app.core.GetArchivedCampaign(0, uuid, slug) + if err != nil || pubCamp.Type != models.CampaignTypeRegular { + notFound := false + if er, ok := err.(*echo.HTTPError); ok { + if er.Code == http.StatusBadRequest { + notFound = true + } + } else if pubCamp.Type != models.CampaignTypeRegular { + notFound = true + } + + if notFound { + return c.Render(http.StatusNotFound, tplMessage, + makeMsgTpl(app.i18n.T("public.notFoundTitle"), "", app.i18n.T("public.campaignNotFound"))) + } + + return c.Render(http.StatusInternalServerError, tplMessage, + makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorFetchingCampaign"))) + } + + out, err := compileArchiveCampaigns([]models.Campaign{pubCamp}, app) + if err != nil { + return c.Render(http.StatusInternalServerError, tplMessage, + makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorFetchingCampaign"))) + } + + // Render the message body. + camp := out[0].Campaign + msg, err := app.manager.NewCampaignMessage(camp, out[0].Subscriber) + if err != nil { + app.log.Printf("error rendering message: %v", err) + return c.Render(http.StatusInternalServerError, tplMessage, + makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorFetchingCampaign"))) + } + + return c.HTML(http.StatusOK, string(msg.Body())) +} + +// handleCampaignArchivePageLatest renders the latest public campaign. +func handleCampaignArchivePageLatest(c echo.Context) error { + var ( + app = c.Get("app").(*App) + ) + + camps, _, err := getCampaignArchives(0, 1, true, app) + if err != nil { + return err + } + + if len(camps) == 0 { + return c.Render(http.StatusNotFound, tplMessage, + makeMsgTpl(app.i18n.T("public.notFoundTitle"), "", app.i18n.T("public.campaignNotFound"))) + } + + camp := camps[0] + + return c.HTML(http.StatusOK, camp.Content) +} + +func getCampaignArchives(offset, limit int, renderBody bool, app *App) ([]campArchive, int, error) { + pubCamps, total, err := app.core.GetArchivedCampaigns(offset, limit) + if err != nil { + return []campArchive{}, total, echo.NewHTTPError(http.StatusInternalServerError, app.i18n.T("public.errorFetchingCampaign")) + } + + msgs, err := compileArchiveCampaigns(pubCamps, app) + if err != nil { + return []campArchive{}, total, err + } + + out := make([]campArchive, 0, len(msgs)) + for _, m := range msgs { + camp := m.Campaign + + archive := campArchive{ + UUID: camp.UUID, + Subject: camp.Subject, + CreatedAt: camp.CreatedAt, + SendAt: camp.SendAt, + } + + if camp.ArchiveSlug.Valid { + archive.URL, _ = url.JoinPath(app.constants.ArchiveURL, camp.ArchiveSlug.String) + } else { + archive.URL, _ = url.JoinPath(app.constants.ArchiveURL, camp.UUID) + } + + if renderBody { + msg, err := app.manager.NewCampaignMessage(camp, m.Subscriber) + if err != nil { + return []campArchive{}, total, err + } + archive.Content = string(msg.Body()) + } + + out = append(out, archive) + } + + return out, total, nil +} + +func compileArchiveCampaigns(camps []models.Campaign, app *App) ([]manager.CampaignMessage, error) { + var ( + b = bytes.Buffer{} + ) + + out := make([]manager.CampaignMessage, 0, len(camps)) + for _, c := range camps { + camp := c + if err := camp.CompileTemplate(app.manager.TemplateFuncs(&camp)); err != nil { + app.log.Printf("error compiling template: %v", err) + return nil, echo.NewHTTPError(http.StatusInternalServerError, app.i18n.T("public.errorFetchingCampaign")) + } + + // Load the dummy subscriber meta. + var sub models.Subscriber + if err := json.Unmarshal([]byte(camp.ArchiveMeta), &sub); err != nil { + app.log.Printf("error unmarshalling campaign archive meta: %v", err) + return nil, echo.NewHTTPError(http.StatusInternalServerError, app.i18n.T("public.errorFetchingCampaign")) + } + + m := manager.CampaignMessage{ + Campaign: &camp, + Subscriber: sub, + } + + // Render the subject if it's a template. + if camp.SubjectTpl != nil { + if err := camp.SubjectTpl.ExecuteTemplate(&b, models.ContentTpl, m); err != nil { + return nil, err + } + camp.Subject = b.String() + b.Reset() + + } + + out = append(out, m) + } + + return out, nil +} diff --git a/cmd/auth.go b/cmd/auth.go new file mode 100644 index 000000000..2d368ba7d --- /dev/null +++ b/cmd/auth.go @@ -0,0 +1,404 @@ +package main + +import ( + "encoding/base64" + "encoding/json" + "errors" + "net/http" + "net/mail" + "net/url" + "strings" + "time" + + "github.com/knadh/listmonk/internal/auth" + "github.com/knadh/listmonk/internal/utils" + "github.com/knadh/listmonk/models" + "github.com/labstack/echo/v4" + "github.com/zerodha/simplesessions/v3" + "gopkg.in/volatiletech/null.v6" +) + +type loginTpl struct { + Title string + Description string + + NextURI string + Nonce string + PasswordEnabled bool + OIDCProvider string + OIDCProviderLogo string + Error string +} + +type oidcState struct { + Nonce string `json:"nonce"` + Next string `json:"next"` +} + +var oidcProviders = map[string]bool{ + "google.com": true, + "microsoftonline.com": true, + "auth0.com": true, + "github.com": true, +} + +// handleLoginPage renders the login page and handles the login form. +func handleLoginPage(c echo.Context) error { + app := c.Get("app").(*App) + + // Has the user been setup? + app.Lock() + needsUserSetup := app.needsUserSetup + app.Unlock() + + if needsUserSetup { + return handleLoginSetupPage(c) + } + + // Process POST login request. + var loginErr error + if c.Request().Method == http.MethodPost { + loginErr = doLogin(c) + if loginErr == nil { + return c.Redirect(http.StatusFound, utils.SanitizeURI(c.FormValue("next"))) + } + } + + return renderLoginPage(c, loginErr) +} + +// handleLoginSetupPage renders the first time user login page and handles the login form. +func handleLoginSetupPage(c echo.Context) error { + app := c.Get("app").(*App) + + // Process POST login request. + var loginErr error + + if c.Request().Method == http.MethodPost { + loginErr = doLoginSetup(c) + if loginErr == nil { + app.Lock() + app.needsUserSetup = false + app.Unlock() + return c.Redirect(http.StatusFound, utils.SanitizeURI(c.FormValue("next"))) + } + } + + return renderLoginSetupPage(c, loginErr) +} + +// handleLogout logs a user out. +func handleLogout(c echo.Context) error { + var ( + sess = c.Get(auth.SessionKey).(*simplesessions.Session) + ) + + // Clear the session. + _ = sess.Destroy() + + return c.JSON(http.StatusOK, okResp{true}) +} + +// handleOIDCLogin initializes an OIDC request and redirects to the OIDC provider for login. +func handleOIDCLogin(c echo.Context) error { + app := c.Get("app").(*App) + + // Verify that the request came from the login page (CSRF). + nonce, err := c.Cookie("nonce") + if err != nil || nonce.Value == "" || nonce.Value != c.FormValue("nonce") { + return echo.NewHTTPError(http.StatusUnauthorized, app.i18n.T("users.invalidRequest")) + } + + next := utils.SanitizeURI(c.FormValue("next")) + if next == "/" { + next = uriAdmin + } + + state := oidcState{ + Nonce: nonce.Value, + Next: next, + } + + stateJSON, err := json.Marshal(state) + if err != nil { + app.log.Printf("error marshalling OIDC state: %v", err) + return echo.NewHTTPError(http.StatusInternalServerError, app.i18n.T("globals.messages.internalError")) + } + + return c.Redirect(http.StatusFound, app.auth.GetOIDCAuthURL(base64.URLEncoding.EncodeToString(stateJSON), nonce.Value)) +} + +// handleOIDCFinish receives the redirect callback from the OIDC provider and completes the handshake. +func handleOIDCFinish(c echo.Context) error { + app := c.Get("app").(*App) + + nonce, err := c.Cookie("nonce") + if err != nil || nonce.Value == "" { + return renderLoginPage(c, echo.NewHTTPError(http.StatusUnauthorized, app.i18n.T("users.invalidRequest"))) + } + + // Validate the OIDC token. + oidcToken, claims, err := app.auth.ExchangeOIDCToken(c.Request().URL.Query().Get("code"), nonce.Value) + if err != nil { + return renderLoginPage(c, err) + } + + // Validate the state. + var state oidcState + stateB, err := base64.URLEncoding.DecodeString(c.QueryParam("state")) + if err != nil { + app.log.Printf("error decoding OIDC state: %v", err) + return echo.NewHTTPError(http.StatusInternalServerError, app.i18n.T("globals.messages.internalError")) + } + if err := json.Unmarshal(stateB, &state); err != nil { + app.log.Printf("error unmarshalling OIDC state: %v", err) + return echo.NewHTTPError(http.StatusInternalServerError, app.i18n.T("globals.messages.internalError")) + } + if state.Nonce != nonce.Value { + return renderLoginPage(c, echo.NewHTTPError(http.StatusUnauthorized, app.i18n.T("users.invalidRequest"))) + } + + // Validate e-mail from the claim. + email := strings.TrimSpace(claims.Email) + if email == "" { + return renderLoginPage(c, errors.New(app.i18n.Ts("globals.messages.invalidFields", "name", "email"))) + } + + em, err := mail.ParseAddress(email) + if err != nil { + return renderLoginPage(c, err) + } + email = strings.ToLower(em.Address) + + // Get the user by e-mail received from OIDC. + user, err := app.core.GetUser(0, "", email) + if err != nil { + return renderLoginPage(c, err) + } + + // Update user login. + if err := app.core.UpdateUserLogin(user.ID, claims.Picture); err != nil { + return renderLoginPage(c, err) + } + + // Set the session. + if err := app.auth.SaveSession(user, oidcToken, c); err != nil { + return renderLoginPage(c, err) + } + + return c.Redirect(http.StatusFound, utils.SanitizeURI(state.Next)) +} + +// renderLoginPage renders the login page and handles the login form. +func renderLoginPage(c echo.Context, loginErr error) error { + var ( + app = c.Get("app").(*App) + next = utils.SanitizeURI(c.FormValue("next")) + ) + + if next == "/" { + next = uriAdmin + } + + oidcProvider := "" + oidcProviderLogo := "" + if app.constants.Security.OIDC.Enabled { + oidcProviderLogo = "oidc.png" + u, err := url.Parse(app.constants.Security.OIDC.Provider) + if err == nil { + h := strings.Split(u.Hostname(), ".") + + // Get the last two h for the root domain + if len(h) >= 2 { + oidcProvider = h[len(h)-2] + "." + h[len(h)-1] + } else { + oidcProvider = u.Hostname() + } + + if _, ok := oidcProviders[oidcProvider]; ok { + oidcProviderLogo = oidcProvider + ".png" + } + } + } + + out := loginTpl{ + Title: app.i18n.T("users.login"), + PasswordEnabled: true, + OIDCProvider: oidcProvider, + OIDCProviderLogo: oidcProviderLogo, + NextURI: next, + } + + if loginErr != nil { + if e, ok := loginErr.(*echo.HTTPError); ok { + out.Error = e.Message.(string) + } else { + out.Error = loginErr.Error() + } + } + + // Generate and set a nonce for preventing CSRF requests. + nonce, err := utils.GenerateRandomString(16) + if err != nil { + app.log.Printf("error generating OIDC nonce: %v", err) + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.internalError")) + } + c.SetCookie(&http.Cookie{ + Name: "nonce", + Value: nonce, + HttpOnly: true, + Path: "/", + SameSite: http.SameSiteLaxMode, + }) + out.Nonce = nonce + + return c.Render(http.StatusOK, "admin-login", out) +} + +// renderLoginSetupPage renders the first time user setup page. +func renderLoginSetupPage(c echo.Context, loginErr error) error { + var ( + app = c.Get("app").(*App) + next = utils.SanitizeURI(c.FormValue("next")) + ) + + if next == "/" { + next = uriAdmin + } + + out := loginTpl{ + Title: app.i18n.T("users.login"), + PasswordEnabled: true, + NextURI: next, + } + + if loginErr != nil { + if e, ok := loginErr.(*echo.HTTPError); ok { + out.Error = e.Message.(string) + } else { + out.Error = loginErr.Error() + } + } + + return c.Render(http.StatusOK, "admin-login-setup", out) +} + +// doLogin logs a user in with a username and password. +func doLogin(c echo.Context) error { + var ( + app = c.Get("app").(*App) + ) + + // Verify that the request came from the login page (CSRF). + // nonce, err := c.Cookie("nonce") + // if err != nil || nonce.Value == "" || nonce.Value != c.FormValue("nonce") { + // return echo.NewHTTPError(http.StatusUnauthorized, app.i18n.T("users.invalidRequest")) + // } + + var ( + username = strings.TrimSpace(c.FormValue("username")) + password = strings.TrimSpace(c.FormValue("password")) + ) + + if !strHasLen(username, 3, stdInputMaxLen) { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "username")) + } + + if !strHasLen(password, 8, stdInputMaxLen) { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "password")) + } + + start := time.Now() + + user, err := app.core.LoginUser(username, password) + if err != nil { + return err + } + + // Resist potential constant-time-comparison attacks with a min response time. + if ms := time.Now().Sub(start).Milliseconds(); ms < 100 { + time.Sleep(time.Duration(ms)) + } + + // Set the session. + if err := app.auth.SaveSession(user, "", c); err != nil { + return err + } + + return nil +} + +// doLoginSetup sets a user up for the first time. +func doLoginSetup(c echo.Context) error { + var ( + app = c.Get("app").(*App) + ) + + // Verify that the request came from the login page (CSRF). + // nonce, err := c.Cookie("nonce") + // if err != nil || nonce.Value == "" || nonce.Value != c.FormValue("nonce") { + // return echo.NewHTTPError(http.StatusUnauthorized, app.i18n.T("users.invalidRequest")) + // } + + var ( + email = strings.TrimSpace(c.FormValue("email")) + username = strings.TrimSpace(c.FormValue("username")) + password = strings.TrimSpace(c.FormValue("password")) + password2 = strings.TrimSpace(c.FormValue("password2")) + ) + + if !utils.ValidateEmail(email) { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "email")) + } + if !strHasLen(username, 3, stdInputMaxLen) { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "username")) + } + if !strHasLen(password, 8, stdInputMaxLen) { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "password")) + } + if password != password2 { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("users.passwordMismatch")) + } + + // Create the default "Super Admin". + r := models.Role{ + Type: models.RoleTypeUser, + Name: null.NewString("Super Admin", true), + } + for p := range app.constants.Permissions { + r.Permissions = append(r.Permissions, p) + } + role, err := app.core.CreateRole(r) + if err != nil { + return err + } + + // Create the super admin user. + u := models.User{ + Type: models.UserTypeUser, + HasPassword: true, + PasswordLogin: true, + Username: username, + Name: username, + Password: null.NewString(password, true), + Email: null.NewString(email, true), + UserRoleID: role.ID, + Status: models.UserStatusEnabled, + } + if _, err := app.core.CreateUser(u); err != nil { + return err + } + + // Log the user in. + user, err := app.core.LoginUser(username, password) + if err != nil { + return err + } + + // Set the session. + if err := app.auth.SaveSession(user, "", c); err != nil { + return err + } + + return nil +} diff --git a/cmd/bounce.go b/cmd/bounce.go index f32ec5091..33b7b5491 100644 --- a/cmd/bounce.go +++ b/cmd/bounce.go @@ -2,33 +2,20 @@ package main import ( "encoding/json" - "fmt" - "io/ioutil" + "io" "net/http" "strconv" - "strings" "time" - "github.com/knadh/listmonk/internal/subimporter" "github.com/knadh/listmonk/models" - "github.com/labstack/echo" - "github.com/lib/pq" + "github.com/labstack/echo/v4" ) -type bouncesWrap struct { - Results []models.Bounce `json:"results"` - - Total int `json:"total"` - PerPage int `json:"per_page"` - Page int `json:"page"` -} - // handleGetBounces handles retrieval of bounce records. func handleGetBounces(c echo.Context) error { var ( app = c.Get("app").(*App) - pg = getPagination(c.QueryParams(), 50) - out bouncesWrap + pg = app.paginator.NewFromURL(c.Request().URL.Query()) id, _ = strconv.Atoi(c.Param("id")) campID, _ = strconv.Atoi(c.QueryParam("campaign_id")) @@ -37,38 +24,30 @@ func handleGetBounces(c echo.Context) error { order = c.FormValue("order") ) - // Fetch one list. - single := false + // Fetch one bounce. if id > 0 { - single = true + out, err := app.core.GetBounce(id) + if err != nil { + return err + } + return c.JSON(http.StatusOK, okResp{out}) } - // Sort params. - if !strSliceContains(orderBy, bounceQuerySortFields) { - orderBy = "created_at" - } - if order != sortAsc && order != sortDesc { - order = sortDesc + res, total, err := app.core.QueryBounces(campID, 0, source, orderBy, order, pg.Offset, pg.Limit) + if err != nil { + return err } - stmt := fmt.Sprintf(app.queries.QueryBounces, orderBy, order) - if err := db.Select(&out.Results, stmt, id, campID, 0, source, pg.Offset, pg.Limit); err != nil { - app.log.Printf("error fetching bounces: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorFetching", - "name", "{globals.terms.bounce}", "error", pqErrMsg(err))) - } - if len(out.Results) == 0 { + // No results. + var out models.PageResults + if len(res) == 0 { out.Results = []models.Bounce{} return c.JSON(http.StatusOK, okResp{out}) } - if single { - return c.JSON(http.StatusOK, okResp{out.Results[0]}) - } - // Meta. - out.Total = out.Results[0].Total + out.Results = res + out.Total = total out.Page = pg.Page out.PerPage = pg.PerPage @@ -78,22 +57,17 @@ func handleGetBounces(c echo.Context) error { // handleGetSubscriberBounces retrieves a subscriber's bounce records. func handleGetSubscriberBounces(c echo.Context) error { var ( - app = c.Get("app").(*App) - subID = c.Param("id") + app = c.Get("app").(*App) + subID, _ = strconv.Atoi(c.Param("id")) ) - id, _ := strconv.ParseInt(subID, 10, 64) - if id < 1 { + if subID < 1 { return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) } - out := []models.Bounce{} - stmt := fmt.Sprintf(app.queries.QueryBounces, "created_at", "ASC") - if err := db.Select(&out, stmt, 0, 0, subID, "", 0, 1000); err != nil { - app.log.Printf("error fetching bounces: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorFetching", - "name", "{globals.terms.bounce}", "error", pqErrMsg(err))) + out, _, err := app.core.QueryBounces(0, subID, "", "", "", 0, 1000) + if err != nil { + return err } return c.JSON(http.StatusOK, okResp{out}) @@ -105,12 +79,12 @@ func handleDeleteBounces(c echo.Context) error { app = c.Get("app").(*App) pID = c.Param("id") all, _ = strconv.ParseBool(c.QueryParam("all")) - IDs = pq.Int64Array{} + IDs = []int{} ) // Is it an /:id call? if pID != "" { - id, _ := strconv.ParseInt(pID, 10, 64) + id, _ := strconv.Atoi(pID) if id < 1 { return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) } @@ -130,11 +104,8 @@ func handleDeleteBounces(c echo.Context) error { IDs = i } - if _, err := app.queries.DeleteBounces.Exec(IDs); err != nil { - app.log.Printf("error deleting bounces: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorDeleting", - "name", "{globals.terms.bounce}", "error", pqErrMsg(err))) + if err := app.core.DeleteBounces(IDs); err != nil { + return err } return c.JSON(http.StatusOK, okResp{true}) @@ -149,8 +120,8 @@ func handleBounceWebhook(c echo.Context) error { bounces []models.Bounce ) - // Read the request body instead of using using c.Bind() to read to save the entire raw request as meta. - rawReq, err := ioutil.ReadAll(c.Request().Body) + // Read the request body instead of using c.Bind() to read to save the entire raw request as meta. + rawReq, err := io.ReadAll(c.Request().Body) if err != nil { app.log.Printf("error reading ses notification body: %v", err) return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.internalError")) @@ -161,15 +132,15 @@ func handleBounceWebhook(c echo.Context) error { case service == "": var b models.Bounce if err := json.Unmarshal(rawReq, &b); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidData")) + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidData")+":"+err.Error()) } - if err := validateBounceFields(b, app); err != nil { + if bv, err := validateBounceFields(b, app); err != nil { return err + } else { + b = bv } - b.Email = strings.ToLower(b.Email) - if len(b.Meta) == 0 { b.Meta = json.RawMessage("{}") } @@ -220,6 +191,36 @@ func handleBounceWebhook(c echo.Context) error { } bounces = append(bounces, bs...) + // Postmark. + case service == "postmark" && app.constants.BouncePostmarkEnabled: + bs, err := app.bounce.Postmark.ProcessBounce(rawReq, c) + if err != nil { + app.log.Printf("error processing postmark notification: %v", err) + if _, ok := err.(*echo.HTTPError); ok { + return err + } + + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidData")) + } + bounces = append(bounces, bs...) + + // ForwardEmail. + case service == "forwardemail" && app.constants.BounceForwardemailEnabled: + var ( + sig = c.Request().Header.Get("X-Webhook-Signature") + ) + + bs, err := app.bounce.Forwardemail.ProcessBounce(sig, rawReq) + if err != nil { + app.log.Printf("error processing forwardemail notification: %v", err) + if _, ok := err.(*echo.HTTPError); ok { + return err + } + + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidData")) + } + bounces = append(bounces, bs...) + default: return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("bounces.unknownService")) } @@ -234,22 +235,26 @@ func handleBounceWebhook(c echo.Context) error { return c.JSON(http.StatusOK, okResp{true}) } -func validateBounceFields(b models.Bounce, app *App) error { +func validateBounceFields(b models.Bounce, app *App) (models.Bounce, error) { if b.Email == "" && b.SubscriberUUID == "" { - return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidData")) + return b, echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "email / subscriber_uuid")) } - if b.Type != models.BounceTypeHard && b.Type != models.BounceTypeSoft { - return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidData")) + if b.SubscriberUUID != "" && !reUUID.MatchString(b.SubscriberUUID) { + return b, echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "subscriber_uuid")) } - if b.Email != "" && !subimporter.IsEmail(b.Email) { - return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidEmail")) + if b.Email != "" { + em, err := app.importer.SanitizeEmail(b.Email) + if err != nil { + return b, echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + b.Email = em } - if b.SubscriberUUID != "" && !reUUID.MatchString(b.SubscriberUUID) { - return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidUUID")) + if b.Type != models.BounceTypeHard && b.Type != models.BounceTypeSoft && b.Type != models.BounceTypeComplaint { + return b, echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "type")) } - return nil + return b, nil } diff --git a/cmd/campaigns.go b/cmd/campaigns.go index 312f79980..0514dd79b 100644 --- a/cmd/campaigns.go +++ b/cmd/campaigns.go @@ -2,7 +2,7 @@ package main import ( "bytes" - "database/sql" + "encoding/json" "errors" "fmt" "html/template" @@ -13,32 +13,27 @@ import ( "strings" "time" - "github.com/gofrs/uuid" - "github.com/knadh/listmonk/internal/subimporter" "github.com/knadh/listmonk/models" - "github.com/labstack/echo" + "github.com/labstack/echo/v4" "github.com/lib/pq" - null "gopkg.in/volatiletech/null.v6" + "gopkg.in/volatiletech/null.v6" ) // campaignReq is a wrapper over the Campaign model for receiving -// campaign creation and updation data from APIs. +// campaign creation and update data from APIs. type campaignReq struct { models.Campaign - // Indicates if the "send_at" date should be written or set to null. - SendLater bool `db:"-" json:"send_later"` - // This overrides Campaign.Lists to receive and // write a list of int IDs during creation and updation. // Campaign.Lists is JSONText for sending lists children // to the outside world. - ListIDs pq.Int64Array `db:"-" json:"lists"` + ListIDs []int `json:"lists"` + + MediaIDs []int `json:"media"` // This is only relevant to campaign test requests. SubscriberEmails pq.StringArray `json:"subscribers"` - - Type string `json:"type"` } // campaignContentReq wraps params coming from API requests for converting @@ -49,111 +44,68 @@ type campaignContentReq struct { To string `json:"to"` } -type campaignStats struct { - ID int `db:"id" json:"id"` - Status string `db:"status" json:"status"` - ToSend int `db:"to_send" json:"to_send"` - Sent int `db:"sent" json:"sent"` - Started null.Time `db:"started_at" json:"started_at"` - UpdatedAt null.Time `db:"updated_at" json:"updated_at"` - Rate float64 `json:"rate"` -} - -type campsWrap struct { - Results models.Campaigns `json:"results"` - - Query string `json:"query"` - Total int `json:"total"` - PerPage int `json:"per_page"` - Page int `json:"page"` -} - var ( - regexFromAddress = regexp.MustCompile(`(.+?)\s<(.+?)@(.+?)>`) - regexFullTextQuery = regexp.MustCompile(`\s+`) - - campaignQuerySortFields = []string{"name", "status", "created_at", "updated_at"} - bounceQuerySortFields = []string{"email", "campaign_name", "source", "created_at"} + regexFromAddress = regexp.MustCompile(`((.+?)\s)?<(.+?)@(.+?)>`) + regexSlug = regexp.MustCompile(`[^\p{L}\p{M}\p{N}]`) ) // handleGetCampaigns handles retrieval of campaigns. func handleGetCampaigns(c echo.Context) error { var ( app = c.Get("app").(*App) - pg = getPagination(c.QueryParams(), 20) - out campsWrap + pg = app.paginator.NewFromURL(c.Request().URL.Query()) - id, _ = strconv.Atoi(c.Param("id")) status = c.QueryParams()["status"] + tags = c.QueryParams()["tag"] query = strings.TrimSpace(c.FormValue("query")) orderBy = c.FormValue("order_by") order = c.FormValue("order") noBody, _ = strconv.ParseBool(c.QueryParam("no_body")) ) - // Fetch one list. - single := false - if id > 0 { - single = true - } - if query != "" { - query = `%` + - string(regexFullTextQuery.ReplaceAll([]byte(query), []byte("&"))) + `%` + res, total, err := app.core.QueryCampaigns(query, status, tags, orderBy, order, pg.Offset, pg.Limit) + if err != nil { + return err } - // Sort params. - if !strSliceContains(orderBy, campaignQuerySortFields) { - orderBy = "created_at" - } - if order != sortAsc && order != sortDesc { - order = sortDesc + if noBody { + for i := 0; i < len(res); i++ { + res[i].Body = "" + } } - stmt := fmt.Sprintf(app.queries.QueryCampaigns, orderBy, order) - - // Unsafe to ignore scanning fields not present in models.Campaigns. - if err := db.Select(&out.Results, stmt, id, pq.StringArray(status), query, pg.Offset, pg.Limit); err != nil { - app.log.Printf("error fetching campaigns: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorFetching", - "name", "{globals.terms.campaign}", "error", pqErrMsg(err))) - } - if single && len(out.Results) == 0 { - return echo.NewHTTPError(http.StatusBadRequest, - app.i18n.Ts("campaigns.notFound", "name", "{globals.terms.campaign}")) - } - if len(out.Results) == 0 { + var out models.PageResults + if len(res) == 0 { out.Results = []models.Campaign{} return c.JSON(http.StatusOK, okResp{out}) } - for i := 0; i < len(out.Results); i++ { - // Replace null tags. - if out.Results[i].Tags == nil { - out.Results[i].Tags = make(pq.StringArray, 0) - } + // Meta. + out.Query = query + out.Results = res + out.Total = total + out.Page = pg.Page + out.PerPage = pg.PerPage - if noBody { - out.Results[i].Body = "" - } - } + return c.JSON(http.StatusOK, okResp{out}) +} - // Lazy load stats. - if err := out.Results.LoadStats(app.queries.GetCampaignStats); err != nil { - app.log.Printf("error fetching campaign stats: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorFetching", - "name", "{globals.terms.campaign}", "error", pqErrMsg(err))) - } +// handleGetCampaign handles retrieval of campaigns. +func handleGetCampaign(c echo.Context) error { + var ( + app = c.Get("app").(*App) + id, _ = strconv.Atoi(c.Param("id")) + noBody, _ = strconv.ParseBool(c.QueryParam("no_body")) + ) - if single { - return c.JSON(http.StatusOK, okResp{out.Results[0]}) + out, err := app.core.GetCampaign(id, "", "") + if err != nil { + return err } - // Meta. - out.Total = out.Results[0].Total - out.Page = pg.Page - out.PerPage = pg.PerPage + if noBody { + out.Body = "" + } return c.JSON(http.StatusOK, okResp{out}) } @@ -161,26 +113,18 @@ func handleGetCampaigns(c echo.Context) error { // handlePreviewCampaign renders the HTML preview of a campaign body. func handlePreviewCampaign(c echo.Context) error { var ( - app = c.Get("app").(*App) - id, _ = strconv.Atoi(c.Param("id")) + app = c.Get("app").(*App) + id, _ = strconv.Atoi(c.Param("id")) + tplID, _ = strconv.Atoi(c.FormValue("template_id")) ) if id < 1 { return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) } - var camp models.Campaign - err := app.queries.GetCampaignForPreview.Get(&camp, id) + camp, err := app.core.GetCampaignForPreview(id, tplID) if err != nil { - if err == sql.ErrNoRows { - return echo.NewHTTPError(http.StatusBadRequest, - app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.campaign}")) - } - - app.log.Printf("error fetching campaign: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorFetching", - "name", "{globals.terms.campaign}", "error", pqErrMsg(err))) + return err } // There's a body in the request to preview instead of the body in the DB. @@ -206,6 +150,10 @@ func handlePreviewCampaign(c echo.Context) error { app.i18n.Ts("templates.errorRendering", "error", err.Error())) } + if camp.ContentType == models.CampaignContentTypePlain { + return c.String(http.StatusOK, string(msg.Body())) + } + return c.HTML(http.StatusOK, string(msg.Body())) } @@ -252,6 +200,15 @@ func handleCreateCampaign(c echo.Context) error { return err } o = op + } else if o.Type == "" { + o.Type = models.CampaignTypeRegular + } + + if o.ContentType == "" { + o.ContentType = models.CampaignContentTypeRichtext + } + if o.Messenger == "" { + o.Messenger = "email" } // Validate. @@ -261,44 +218,16 @@ func handleCreateCampaign(c echo.Context) error { o = c } - uu, err := uuid.NewV4() - if err != nil { - app.log.Printf("error generating UUID: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorUUID", "error", err.Error())) - } - - // Insert and read ID. - var newID int - if err := app.queries.CreateCampaign.Get(&newID, - uu, - o.Type, - o.Name, - o.Subject, - o.FromEmail, - o.Body, - o.AltBody, - o.ContentType, - o.SendAt, - pq.StringArray(normalizeTags(o.Tags)), - o.Messenger, - o.TemplateID, - o.ListIDs, - ); err != nil { - if err == sql.ErrNoRows { - return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("campaigns.noSubs")) - } + if o.ArchiveTemplateID == 0 { + o.ArchiveTemplateID = o.TemplateID + } - app.log.Printf("error creating campaign: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorCreating", - "name", "{globals.terms.campaign}", "error", pqErrMsg(err))) + out, err := app.core.CreateCampaign(o.Campaign, o.ListIDs, o.MediaIDs) + if err != nil { + return err } - // Hand over to the GET handler to return the last insertion. - return handleGetCampaigns(copyEchoCtx(c, map[string]string{ - "id": fmt.Sprintf("%d", newID), - })) + return c.JSON(http.StatusOK, okResp{out}) } // handleUpdateCampaign handles campaign modification. @@ -314,25 +243,17 @@ func handleUpdateCampaign(c echo.Context) error { } - var cm models.Campaign - if err := app.queries.GetCampaign.Get(&cm, id, nil); err != nil { - if err == sql.ErrNoRows { - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.campaign}")) - } - - app.log.Printf("error fetching campaign: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorFetching", - "name", "{globals.terms.campaign}", "error", pqErrMsg(err))) + cm, err := app.core.GetCampaign(id, "", "") + if err != nil { + return err } - if isCampaignalMutable(cm.Status) { + if !canEditCampaign(cm.Status) { return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("campaigns.cantUpdate")) } // Read the incoming params into the existing campaign fields from the DB. - // This allows updating of values that have been sent where as fields + // This allows updating of values that have been sent whereas fields // that are not in the request retain the old values. o := campaignReq{Campaign: cm} if err := c.Bind(&o); err != nil { @@ -345,27 +266,12 @@ func handleUpdateCampaign(c echo.Context) error { o = c } - _, err := app.queries.UpdateCampaign.Exec(cm.ID, - o.Name, - o.Subject, - o.FromEmail, - o.Body, - o.AltBody, - o.ContentType, - o.SendAt, - o.SendLater, - pq.StringArray(normalizeTags(o.Tags)), - o.Messenger, - o.TemplateID, - o.ListIDs) + out, err := app.core.UpdateCampaign(id, o.Campaign, o.ListIDs, o.MediaIDs) if err != nil { - app.log.Printf("error updating campaign: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorUpdating", - "name", "{globals.terms.campaign}", "error", pqErrMsg(err))) + return err } - return handleGetCampaigns(c) + return c.JSON(http.StatusOK, okResp{out}) } // handleUpdateCampaignStatus handles campaign status modification. @@ -379,73 +285,58 @@ func handleUpdateCampaignStatus(c echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) } - var cm models.Campaign - if err := app.queries.GetCampaign.Get(&cm, id, nil); err != nil { - if err == sql.ErrNoRows { - return echo.NewHTTPError(http.StatusBadRequest, - app.i18n.Ts("globals.message.notFound", "name", "{globals.terms.campaign}")) - } - - app.log.Printf("error fetching campaign: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorFetching", - "name", "{globals.terms.campaign}", "error", pqErrMsg(err))) + var o struct { + Status string `json:"status"` } - // Incoming params. - var o campaignReq if err := c.Bind(&o); err != nil { return err } - errMsg := "" - switch o.Status { - case models.CampaignStatusDraft: - if cm.Status != models.CampaignStatusScheduled { - errMsg = app.i18n.T("campaigns.onlyScheduledAsDraft") - } - case models.CampaignStatusScheduled: - if cm.Status != models.CampaignStatusDraft { - errMsg = app.i18n.T("campaigns.onlyDraftAsScheduled") - } - if !cm.SendAt.Valid { - errMsg = app.i18n.T("campaigns.needsSendAt") - } - - case models.CampaignStatusRunning: - if cm.Status != models.CampaignStatusPaused && cm.Status != models.CampaignStatusDraft { - errMsg = app.i18n.T("campaigns.onlyPausedDraft") - } - case models.CampaignStatusPaused: - if cm.Status != models.CampaignStatusRunning { - errMsg = app.i18n.T("campaigns.onlyActivePause") - } - case models.CampaignStatusCancelled: - if cm.Status != models.CampaignStatusRunning && cm.Status != models.CampaignStatusPaused { - errMsg = app.i18n.T("campaigns.onlyActiveCancel") - } + out, err := app.core.UpdateCampaignStatus(id, o.Status) + if err != nil { + return err } - if len(errMsg) > 0 { - return echo.NewHTTPError(http.StatusBadRequest, errMsg) + if o.Status == models.CampaignStatusPaused || o.Status == models.CampaignStatusCancelled { + app.manager.StopCampaign(id) } - res, err := app.queries.UpdateCampaignStatus.Exec(cm.ID, o.Status) - if err != nil { - app.log.Printf("error updating campaign status: %v", err) + return c.JSON(http.StatusOK, okResp{out}) +} - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorUpdating", - "name", "{globals.terms.campaign}", "error", pqErrMsg(err))) +// handleUpdateCampaignArchive handles campaign status modification. +func handleUpdateCampaignArchive(c echo.Context) error { + var ( + app = c.Get("app").(*App) + id, _ = strconv.Atoi(c.Param("id")) + ) + + req := struct { + Archive bool `json:"archive"` + TemplateID int `json:"archive_template_id"` + Meta models.JSON `json:"archive_meta"` + ArchiveSlug string `json:"archive_slug"` + }{} + + // Get and validate fields. + if err := c.Bind(&req); err != nil { + return err } - if n, _ := res.RowsAffected(); n == 0 { - return echo.NewHTTPError(http.StatusBadRequest, - app.i18n.Ts("globals.messages.notFound", - "name", "{globals.terms.campaign}", "error", pqErrMsg(err))) + if req.ArchiveSlug != "" { + // Format the slug to be alpha-numeric-dash. + s := strings.ToLower(req.ArchiveSlug) + s = strings.TrimSpace(regexSlug.ReplaceAllString(s, " ")) + s = regexpSpaces.ReplaceAllString(s, "-") + req.ArchiveSlug = s + } + + if err := app.core.UpdateCampaignArchive(id, req.Archive, req.TemplateID, req.Meta, req.ArchiveSlug); err != nil { + return err } - return handleGetCampaigns(c) + return c.JSON(http.StatusOK, okResp{req}) } // handleDeleteCampaign handles campaign deletion. @@ -460,26 +351,8 @@ func handleDeleteCampaign(c echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) } - var cm models.Campaign - if err := app.queries.GetCampaign.Get(&cm, id, nil); err != nil { - if err == sql.ErrNoRows { - return echo.NewHTTPError(http.StatusBadRequest, - app.i18n.Ts("globals.messages.notFound", - "name", "{globals.terms.campaign}", "error", pqErrMsg(err))) - } - - app.log.Printf("error fetching campaign: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorFetching", - "name", "{globals.terms.campaign}", "error", pqErrMsg(err))) - } - - if _, err := app.queries.DeleteCampaign.Exec(cm.ID); err != nil { - app.log.Printf("error deleting campaign: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorDeleting", - "name", "{globals.terms.campaign}", "error", pqErrMsg(err))) - + if err := app.core.DeleteCampaign(id); err != nil { + return err } return c.JSON(http.StatusOK, okResp{true}) @@ -489,36 +362,35 @@ func handleDeleteCampaign(c echo.Context) error { func handleGetRunningCampaignStats(c echo.Context) error { var ( app = c.Get("app").(*App) - out []campaignStats ) - if err := app.queries.GetCampaignStatus.Select(&out, models.CampaignStatusRunning); err != nil { - if err == sql.ErrNoRows { - return c.JSON(http.StatusOK, okResp{[]struct{}{}}) - } + out, err := app.core.GetRunningCampaignStats() + if err != nil { + return err + } - app.log.Printf("error fetching campaign stats: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorFetching", - "name", "{globals.terms.campaign}", "error", pqErrMsg(err))) - } else if len(out) == 0 { + if len(out) == 0 { return c.JSON(http.StatusOK, okResp{[]struct{}{}}) } // Compute rate. for i, c := range out { if c.Started.Valid && c.UpdatedAt.Valid { - diff := c.UpdatedAt.Time.Sub(c.Started.Time).Minutes() - if diff > 0 { - var ( - sent = float64(c.Sent) - rate = sent / diff - ) - if rate > sent || rate > float64(c.ToSend) { - rate = sent - } - out[i].Rate = rate + diff := int(c.UpdatedAt.Time.Sub(c.Started.Time).Minutes()) + if diff < 1 { + diff = 1 + } + + rate := c.Sent / diff + if rate > c.Sent || rate > c.ToSend { + rate = c.Sent } + + // Rate since the starting of the campaign. + out[i].NetRate = rate + + // Realtime running rate over the last minute. + out[i].Rate = app.manager.GetCampaignStats(c.ID).SendRate } } @@ -531,6 +403,7 @@ func handleTestCampaign(c echo.Context) error { var ( app = c.Get("app").(*App) campID, _ = strconv.Atoi(c.Param("id")) + tplID, _ = strconv.Atoi(c.FormValue("template_id")) req campaignReq ) @@ -557,29 +430,16 @@ func handleTestCampaign(c echo.Context) error { for i := 0; i < len(req.SubscriberEmails); i++ { req.SubscriberEmails[i] = strings.ToLower(strings.TrimSpace(req.SubscriberEmails[i])) } - var subs models.Subscribers - if err := app.queries.GetSubscribersByEmails.Select(&subs, req.SubscriberEmails); err != nil { - app.log.Printf("error fetching subscribers: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorFetching", - "name", "{globals.terms.subscribers}", "error", pqErrMsg(err))) - } else if len(subs) == 0 { - return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("campaigns.noKnownSubsToTest")) + + subs, err := app.core.GetSubscribersByEmail(req.SubscriberEmails) + if err != nil { + return err } // The campaign. - var camp models.Campaign - if err := app.queries.GetCampaignForPreview.Get(&camp, campID); err != nil { - if err == sql.ErrNoRows { - return echo.NewHTTPError(http.StatusBadRequest, - app.i18n.Ts("globals.messages.notFound", - "name", "{globals.terms.campaign}")) - } - - app.log.Printf("error fetching campaign: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorFetching", - "name", "{globals.terms.campaign}", "error", pqErrMsg(err))) + camp, err := app.core.GetCampaignForPreview(campID, tplID) + if err != nil { + return err } // Override certain values from the DB with incoming values. @@ -590,12 +450,19 @@ func handleTestCampaign(c echo.Context) error { camp.AltBody = req.AltBody camp.Messenger = req.Messenger camp.ContentType = req.ContentType + camp.Headers = req.Headers camp.TemplateID = req.TemplateID + for _, id := range req.MediaIDs { + if id > 0 { + camp.MediaIDs = append(camp.MediaIDs, int64(id)) + } + } // Send the test messages. for _, s := range subs { sub := s - if err := sendTestMessage(sub, &camp, app); err != nil { + c := camp + if err := sendTestMessage(sub, &c, app); err != nil { app.log.Printf("error sending test message: %v", err) return echo.NewHTTPError(http.StatusInternalServerError, app.i18n.Ts("campaigns.errorSendTest", "error", err.Error())) @@ -605,7 +472,51 @@ func handleTestCampaign(c echo.Context) error { return c.JSON(http.StatusOK, okResp{true}) } -// sendTestMessage takes a campaign and a subsriber and sends out a sample campaign message. +// handleGetCampaignViewAnalytics retrieves view counts for a campaign. +func handleGetCampaignViewAnalytics(c echo.Context) error { + var ( + app = c.Get("app").(*App) + + typ = c.Param("type") + from = c.QueryParams().Get("from") + to = c.QueryParams().Get("to") + ) + + ids, err := parseStringIDs(c.Request().URL.Query()["id"]) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, + app.i18n.Ts("globals.messages.errorInvalidIDs", "error", err.Error())) + } + + if len(ids) == 0 { + return echo.NewHTTPError(http.StatusBadRequest, + app.i18n.Ts("globals.messages.missingFields", "name", "`id`")) + } + + if !strHasLen(from, 10, 30) || !strHasLen(to, 10, 30) { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("analytics.invalidDates")) + } + + // Campaign link stats. + if typ == "links" { + out, err := app.core.GetCampaignAnalyticsLinks(ids, typ, from, to) + if err != nil { + return err + } + + return c.JSON(http.StatusOK, okResp{out}) + } + + // View, click, bounce stats. + out, err := app.core.GetCampaignAnalyticsCounts(ids, typ, from, to) + if err != nil { + return err + } + + return c.JSON(http.StatusOK, okResp{out}) +} + +// sendTestMessage takes a campaign and a subscriber and sends out a sample campaign message. func sendTestMessage(sub models.Subscriber, camp *models.Campaign, app *App) error { if err := camp.CompileTemplate(app.manager.TemplateFuncs(camp)); err != nil { app.log.Printf("error compiling template: %v", err) @@ -629,7 +540,7 @@ func validateCampaignFields(c campaignReq, app *App) (campaignReq, error) { if c.FromEmail == "" { c.FromEmail = app.constants.FromEmail } else if !regexFromAddress.Match([]byte(c.FromEmail)) { - if !subimporter.IsEmail(c.FromEmail) { + if _, err := app.importer.SanitizeEmail(c.FromEmail); err != nil { return c, errors.New(app.i18n.T("campaigns.fieldInvalidFromEmail")) } } @@ -637,14 +548,12 @@ func validateCampaignFields(c campaignReq, app *App) (campaignReq, error) { if !strHasLen(c.Name, 1, stdInputMaxLen) { return c, errors.New(app.i18n.T("campaigns.fieldInvalidName")) } - if !strHasLen(c.Subject, 1, stdInputMaxLen) { + + // Larger char limit for subject as it can contain {{ go templating }} logic. + if !strHasLen(c.Subject, 1, 5000) { return c, errors.New(app.i18n.T("campaigns.fieldInvalidSubject")) } - // if !hasLen(c.Body, 1, bodyMaxLen) { - // return c,errors.New("invalid length for `body`") - // } - // If there's a "send_at" date, it should be in the future. if c.SendAt.Valid { if c.SendAt.Time.Before(time.Now()) { @@ -665,15 +574,35 @@ func validateCampaignFields(c campaignReq, app *App) (campaignReq, error) { return c, errors.New(app.i18n.Ts("campaigns.fieldInvalidBody", "error", err.Error())) } + if len(c.Headers) == 0 { + c.Headers = make([]map[string]string, 0) + } + + if len(c.ArchiveMeta) == 0 { + c.ArchiveMeta = json.RawMessage("{}") + } + + if c.ArchiveSlug.String != "" { + // Format the slug to be alpha-numeric-dash. + s := strings.ToLower(c.ArchiveSlug.String) + s = strings.TrimSpace(regexSlug.ReplaceAllString(s, " ")) + s = regexpSpaces.ReplaceAllString(s, "-") + + c.ArchiveSlug = null.NewString(s, true) + } else { + // If there's no slug set, set it to NULL in the DB. + c.ArchiveSlug.Valid = false + } + return c, nil } -// isCampaignalMutable tells if a campaign's in a state where it's -// properties can be mutated. -func isCampaignalMutable(status string) bool { - return status == models.CampaignStatusRunning || - status == models.CampaignStatusCancelled || - status == models.CampaignStatusFinished +// canEditCampaign returns true if a campaign is in a status where updating +// its properties is allowed. +func canEditCampaign(status string) bool { + return status == models.CampaignStatusDraft || + status == models.CampaignStatusPaused || + status == models.CampaignStatusScheduled } // makeOptinCampaignMessage makes a default opt-in campaign message body. @@ -683,13 +612,9 @@ func makeOptinCampaignMessage(o campaignReq, app *App) (campaignReq, error) { } // Fetch double opt-in lists from the given list IDs. - var lists []models.List - err := app.queries.GetListsByOptin.Select(&lists, models.ListOptinDouble, pq.Int64Array(o.ListIDs), nil) + lists, err := app.core.GetListsByOptin(o.ListIDs, models.ListOptinDouble) if err != nil { - app.log.Printf("error fetching lists for opt-in: %s", pqErrMsg(err)) - return o, echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorFetching", - "name", "{globals.terms.list}", "error", pqErrMsg(err))) + return o, err } // No opt-in lists. @@ -707,7 +632,7 @@ func makeOptinCampaignMessage(o campaignReq, app *App) (campaignReq, error) { // Prepare sample opt-in message for the campaign. var b bytes.Buffer - if err := app.notifTpls.ExecuteTemplate(&b, "optin-campaign", struct { + if err := app.notifTpls.tpls.ExecuteTemplate(&b, "optin-campaign", struct { Lists []models.List OptinURLAttr template.HTMLAttr }{lists, optinURLAttr}); err != nil { diff --git a/cmd/events.go b/cmd/events.go new file mode 100644 index 000000000..33dda5374 --- /dev/null +++ b/cmd/events.go @@ -0,0 +1,51 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "time" + + "github.com/labstack/echo/v4" +) + +// handleEventStream serves an endpoint that never closes and pushes a +// live event stream (text/event-stream) such as a error messages. +func handleEventStream(c echo.Context) error { + var ( + app = c.Get("app").(*App) + ) + + h := c.Response().Header() + h.Set(echo.HeaderContentType, "text/event-stream") + h.Set(echo.HeaderCacheControl, "no-store") + h.Set(echo.HeaderConnection, "keep-alive") + + // Subscribe to the event stream with a random ID. + id := fmt.Sprintf("api:%v", time.Now().UnixNano()) + sub, err := app.events.Subscribe(id) + if err != nil { + log.Fatalf("error subscribing to events: %v", err) + } + + ctx := c.Request().Context() + for { + select { + case e := <-sub: + b, err := json.Marshal(e) + if err != nil { + app.log.Printf("error marshalling event: %v", err) + continue + } + + c.Response().Write([]byte(fmt.Sprintf("retry: 3000\ndata: %s\n\n", b))) + c.Response().Flush() + + case <-ctx.Done(): + // On HTTP connection close, unsubscribe. + app.events.Unsubscribe(id) + return nil + } + } + +} diff --git a/cmd/handlers.go b/cmd/handlers.go index 7e777ba0f..0c38adb2a 100644 --- a/cmd/handlers.go +++ b/cmd/handlers.go @@ -1,22 +1,29 @@ package main import ( - "crypto/subtle" + "bytes" "net/http" "net/url" + "path" "regexp" - "strconv" - "github.com/labstack/echo" - "github.com/labstack/echo/middleware" + "github.com/knadh/listmonk/internal/auth" + "github.com/knadh/paginator" + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" ) const ( // stdInputMaxLen is the maximum allowed length for a standard input field. - stdInputMaxLen = 200 + stdInputMaxLen = 2000 sortAsc = "asc" sortDesc = "desc" + + basicAuthd = "basicauthd" + + // URIs. + uriAdmin = "/admin" ) type okResp struct { @@ -34,153 +41,270 @@ type pagination struct { var ( reUUID = regexp.MustCompile("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") reLangCode = regexp.MustCompile("[^a-zA-Z_0-9\\-]") + + paginate = paginator.New(paginator.Opt{ + DefaultPerPage: 20, + MaxPerPage: 50, + NumPageNums: 10, + PageParam: "page", + PerPageParam: "per_page", + }) ) // registerHandlers registers HTTP handlers. -func registerHTTPHandlers(e *echo.Echo, app *App) { - // Group of private handlers with BasicAuth. - var g *echo.Group - - if len(app.constants.AdminUsername) == 0 || - len(app.constants.AdminPassword) == 0 { - g = e.Group("") - } else { - g = e.Group("", middleware.BasicAuth(basicAuth)) +func initHTTPHandlers(e *echo.Echo, app *App) { + // Default error handler. + e.HTTPErrorHandler = func(err error, c echo.Context) { + // Generic, non-echo error. Log it. + if _, ok := err.(*echo.HTTPError); !ok { + app.log.Println(err.Error()) + } + e.DefaultHTTPErrorHandler(err, c) } - g.GET("/", handleIndexPage) - g.GET("/api/health", handleHealthCheck) - g.GET("/api/config", handleGetServerConfig) - g.GET("/api/lang/:lang", handleGetI18nLang) - g.GET("/api/dashboard/charts", handleGetDashboardCharts) - g.GET("/api/dashboard/counts", handleGetDashboardCounts) - - g.GET("/api/settings", handleGetSettings) - g.PUT("/api/settings", handleUpdateSettings) - g.POST("/api/admin/reload", handleReloadApp) - g.GET("/api/logs", handleGetLogs) - - g.GET("/api/subscribers/:id", handleGetSubscriber) - g.GET("/api/subscribers/:id/export", handleExportSubscriberData) - g.GET("/api/subscribers/:id/bounces", handleGetSubscriberBounces) - g.DELETE("/api/subscribers/:id/bounces", handleDeleteSubscriberBounces) - g.POST("/api/subscribers", handleCreateSubscriber) - g.PUT("/api/subscribers/:id", handleUpdateSubscriber) - g.POST("/api/subscribers/:id/optin", handleSubscriberSendOptin) - g.PUT("/api/subscribers/blocklist", handleBlocklistSubscribers) - g.PUT("/api/subscribers/:id/blocklist", handleBlocklistSubscribers) - g.PUT("/api/subscribers/lists/:id", handleManageSubscriberLists) - g.PUT("/api/subscribers/lists", handleManageSubscriberLists) - g.DELETE("/api/subscribers/:id", handleDeleteSubscribers) - g.DELETE("/api/subscribers", handleDeleteSubscribers) - - g.GET("/api/bounces", handleGetBounces) - g.DELETE("/api/bounces", handleDeleteBounces) - g.DELETE("/api/bounces/:id", handleDeleteBounces) + var ( + // Authenticated /api/* handlers. + api = e.Group("", app.auth.Middleware, func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + u := c.Get(auth.UserKey) + + // On no-auth, respond with a JSON error. + if err, ok := u.(*echo.HTTPError); ok { + return err + } + + return next(c) + } + }) + + // Authenticated non /api handlers. + a = e.Group("", app.auth.Middleware, func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + u := c.Get(auth.UserKey) + // On no-auth, redirect to login page + if _, ok := u.(*echo.HTTPError); ok { + u, _ := url.Parse(app.constants.LoginURL) + q := url.Values{} + q.Set("next", c.Request().RequestURI) + u.RawQuery = q.Encode() + return c.Redirect(http.StatusTemporaryRedirect, u.String()) + } + + return next(c) + } + }) + + // Public unauthenticated endpoints. + p = e.Group("") + ) + + // Authenticated endpoints. + a.GET(path.Join(uriAdmin, ""), handleAdminPage) + a.GET(path.Join(uriAdmin, "/custom.css"), serveCustomAppearance("admin.custom_css")) + a.GET(path.Join(uriAdmin, "/custom.js"), serveCustomAppearance("admin.custom_js")) + a.GET(path.Join(uriAdmin, "/*"), handleAdminPage) + + pm := app.auth.Perm + + // API endpoints. + api.GET("/api/health", handleHealthCheck) + api.GET("/api/config", handleGetServerConfig) + api.GET("/api/lang/:lang", handleGetI18nLang) + api.GET("/api/dashboard/charts", handleGetDashboardCharts) + api.GET("/api/dashboard/counts", handleGetDashboardCounts) + + api.GET("/api/settings", pm(handleGetSettings, "settings:get")) + api.PUT("/api/settings", pm(handleUpdateSettings, "settings:manage")) + api.POST("/api/settings/smtp/test", pm(handleTestSMTPSettings, "settings:manage")) + api.POST("/api/admin/reload", pm(handleReloadApp, "settings:manage")) + api.GET("/api/logs", pm(handleGetLogs, "settings:get")) + api.GET("/api/events", pm(handleEventStream, "settings:get")) + api.GET("/api/about", handleGetAboutInfo) + + api.GET("/api/subscribers", pm(handleQuerySubscribers, "subscribers:get_all", "subscribers:get")) + api.GET("/api/subscribers/:id", pm(handleGetSubscriber, "subscribers:get_all", "subscribers:get")) + api.GET("/api/subscribers/:id/export", pm(handleExportSubscriberData, "subscribers:get_all", "subscribers:get")) + api.GET("/api/subscribers/:id/bounces", pm(handleGetSubscriberBounces, "bounces:get")) + api.DELETE("/api/subscribers/:id/bounces", pm(handleDeleteSubscriberBounces, "bounces:manage")) + api.POST("/api/subscribers", pm(handleCreateSubscriber, "subscribers:manage")) + api.PUT("/api/subscribers/:id", pm(handleUpdateSubscriber, "subscribers:manage")) + api.POST("/api/subscribers/:id/optin", pm(handleSubscriberSendOptin, "subscribers:manage")) + api.PUT("/api/subscribers/blocklist", pm(handleBlocklistSubscribers, "subscribers:manage")) + api.PUT("/api/subscribers/:id/blocklist", pm(handleBlocklistSubscribers, "subscribers:manage")) + api.PUT("/api/subscribers/lists/:id", pm(handleManageSubscriberLists, "subscribers:manage")) + api.PUT("/api/subscribers/lists", pm(handleManageSubscriberLists, "subscribers:manage")) + api.DELETE("/api/subscribers/:id", pm(handleDeleteSubscribers, "subscribers:manage")) + api.DELETE("/api/subscribers", pm(handleDeleteSubscribers, "subscribers:manage")) + + api.GET("/api/bounces", pm(handleGetBounces, "bounces:get")) + api.GET("/api/bounces/:id", pm(handleGetBounces, "bounces:get")) + api.DELETE("/api/bounces", pm(handleDeleteBounces, "bounces:manage")) + api.DELETE("/api/bounces/:id", pm(handleDeleteBounces, "bounces:manage")) // Subscriber operations based on arbitrary SQL queries. // These aren't very REST-like. - g.POST("/api/subscribers/query/delete", handleDeleteSubscribersByQuery) - g.PUT("/api/subscribers/query/blocklist", handleBlocklistSubscribersByQuery) - g.PUT("/api/subscribers/query/lists", handleManageSubscriberListsByQuery) - g.GET("/api/subscribers", handleQuerySubscribers) - g.GET("/api/subscribers/export", - middleware.GzipWithConfig(middleware.GzipConfig{Level: 9})(handleExportSubscribers)) - - g.GET("/api/import/subscribers", handleGetImportSubscribers) - g.GET("/api/import/subscribers/logs", handleGetImportSubscriberStats) - g.POST("/api/import/subscribers", handleImportSubscribers) - g.DELETE("/api/import/subscribers", handleStopImportSubscribers) - - g.GET("/api/lists", handleGetLists) - g.GET("/api/lists/:id", handleGetLists) - g.POST("/api/lists", handleCreateList) - g.PUT("/api/lists/:id", handleUpdateList) - g.DELETE("/api/lists/:id", handleDeleteLists) - - g.GET("/api/campaigns", handleGetCampaigns) - g.GET("/api/campaigns/running/stats", handleGetRunningCampaignStats) - g.GET("/api/campaigns/:id", handleGetCampaigns) - g.GET("/api/campaigns/:id/preview", handlePreviewCampaign) - g.POST("/api/campaigns/:id/preview", handlePreviewCampaign) - g.POST("/api/campaigns/:id/content", handleCampaignContent) - g.POST("/api/campaigns/:id/text", handlePreviewCampaign) - g.POST("/api/campaigns/:id/test", handleTestCampaign) - g.POST("/api/campaigns", handleCreateCampaign) - g.PUT("/api/campaigns/:id", handleUpdateCampaign) - g.PUT("/api/campaigns/:id/status", handleUpdateCampaignStatus) - g.DELETE("/api/campaigns/:id", handleDeleteCampaign) - - g.GET("/api/media", handleGetMedia) - g.POST("/api/media", handleUploadMedia) - g.DELETE("/api/media/:id", handleDeleteMedia) - - g.GET("/api/templates", handleGetTemplates) - g.GET("/api/templates/:id", handleGetTemplates) - g.GET("/api/templates/:id/preview", handlePreviewTemplate) - g.POST("/api/templates/preview", handlePreviewTemplate) - g.POST("/api/templates", handleCreateTemplate) - g.PUT("/api/templates/:id", handleUpdateTemplate) - g.PUT("/api/templates/:id/default", handleTemplateSetDefault) - g.DELETE("/api/templates/:id", handleDeleteTemplate) - - // Static admin views. - g.GET("/lists", handleIndexPage) - g.GET("/lists/forms", handleIndexPage) - g.GET("/subscribers", handleIndexPage) - g.GET("/subscribers/lists/:listID", handleIndexPage) - g.GET("/subscribers/import", handleIndexPage) - g.GET("/subscribers/bounces", handleIndexPage) - g.GET("/campaigns", handleIndexPage) - g.GET("/campaigns/new", handleIndexPage) - g.GET("/campaigns/media", handleIndexPage) - g.GET("/campaigns/templates", handleIndexPage) - g.GET("/campaigns/:campignID", handleIndexPage) - g.GET("/settings", handleIndexPage) - g.GET("/settings/logs", handleIndexPage) + api.POST("/api/subscribers/query/delete", pm(handleDeleteSubscribersByQuery, "subscribers:manage")) + api.PUT("/api/subscribers/query/blocklist", pm(handleBlocklistSubscribersByQuery, "subscribers:manage")) + api.PUT("/api/subscribers/query/lists", pm(handleManageSubscriberListsByQuery, "subscribers:manage")) + api.GET("/api/subscribers/export", + pm(middleware.GzipWithConfig(middleware.GzipConfig{Level: 9})(handleExportSubscribers), "subscribers:get_all", "subscribers:get")) + + api.GET("/api/import/subscribers", pm(handleGetImportSubscribers, "subscribers:import")) + api.GET("/api/import/subscribers/logs", pm(handleGetImportSubscriberStats, "subscribers:import")) + api.POST("/api/import/subscribers", pm(handleImportSubscribers, "subscribers:import")) + api.DELETE("/api/import/subscribers", pm(handleStopImportSubscribers, "subscribers:import")) + + // Individual list permissions are applied directly within handleGetLists. + api.GET("/api/lists", handleGetLists) + api.GET("/api/lists/:id", listPerm(handleGetList)) + api.POST("/api/lists", pm(handleCreateList, "lists:manage_all")) + api.PUT("/api/lists/:id", listPerm(handleUpdateList)) + api.DELETE("/api/lists/:id", listPerm(handleDeleteLists)) + + api.GET("/api/campaigns", pm(handleGetCampaigns, "campaigns:get")) + api.GET("/api/campaigns/running/stats", pm(handleGetRunningCampaignStats, "campaigns:get")) + api.GET("/api/campaigns/:id", pm(handleGetCampaign, "campaigns:get")) + api.GET("/api/campaigns/analytics/:type", pm(handleGetCampaignViewAnalytics, "campaigns:get_analytics")) + api.GET("/api/campaigns/:id/preview", pm(handlePreviewCampaign, "campaigns:get")) + api.POST("/api/campaigns/:id/preview", pm(handlePreviewCampaign, "campaigns:get")) + api.POST("/api/campaigns/:id/content", pm(handleCampaignContent, "campaigns:manage")) + api.POST("/api/campaigns/:id/text", pm(handlePreviewCampaign, "campaigns:manage")) + api.POST("/api/campaigns/:id/test", pm(handleTestCampaign, "campaigns:manage")) + api.POST("/api/campaigns", pm(handleCreateCampaign, "campaigns:manage")) + api.PUT("/api/campaigns/:id", pm(handleUpdateCampaign, "campaigns:manage")) + api.PUT("/api/campaigns/:id/status", pm(handleUpdateCampaignStatus, "campaigns:manage")) + api.PUT("/api/campaigns/:id/archive", pm(handleUpdateCampaignArchive, "campaigns:manage")) + api.DELETE("/api/campaigns/:id", pm(handleDeleteCampaign, "campaigns:manage")) + + api.GET("/api/media", pm(handleGetMedia, "media:get")) + api.GET("/api/media/:id", pm(handleGetMedia, "media:get")) + api.POST("/api/media", pm(handleUploadMedia, "media:manage")) + api.DELETE("/api/media/:id", pm(handleDeleteMedia, "media:manage")) + + api.GET("/api/templates", pm(handleGetTemplates, "templates:get")) + api.GET("/api/templates/:id", pm(handleGetTemplates, "templates:get")) + api.GET("/api/templates/:id/preview", pm(handlePreviewTemplate, "templates:get")) + api.POST("/api/templates/preview", pm(handlePreviewTemplate, "templates:get")) + api.POST("/api/templates", pm(handleCreateTemplate, "templates:manage")) + api.PUT("/api/templates/:id", pm(handleUpdateTemplate, "templates:manage")) + api.PUT("/api/templates/:id/default", pm(handleTemplateSetDefault, "templates:manage")) + api.DELETE("/api/templates/:id", pm(handleDeleteTemplate, "templates:manage")) + + api.DELETE("/api/maintenance/subscribers/:type", pm(handleGCSubscribers, "settings:maintain")) + api.DELETE("/api/maintenance/analytics/:type", pm(handleGCCampaignAnalytics, "settings:maintain")) + api.DELETE("/api/maintenance/subscriptions/unconfirmed", pm(handleGCSubscriptions, "settings:maintain")) + + api.POST("/api/tx", pm(handleSendTxMessage, "tx:send")) + + api.GET("/api/profile", handleGetUserProfile) + api.PUT("/api/profile", handleUpdateUserProfile) + api.GET("/api/users", pm(handleGetUsers, "users:get")) + api.GET("/api/users/:id", pm(handleGetUsers, "users:get")) + api.POST("/api/users", pm(handleCreateUser, "users:manage")) + api.PUT("/api/users/:id", pm(handleUpdateUser, "users:manage")) + api.DELETE("/api/users", pm(handleDeleteUsers, "users:manage")) + api.DELETE("/api/users/:id", pm(handleDeleteUsers, "users:manage")) + api.POST("/api/logout", handleLogout) + + api.GET("/api/roles/users", pm(handleGetUserRoles, "roles:get")) + api.GET("/api/roles/lists", pm(handleGeListRoles, "roles:get")) + api.POST("/api/roles/users", pm(handleCreateUserRole, "roles:manage")) + api.POST("/api/roles/lists", pm(handleCreateListRole, "roles:manage")) + api.PUT("/api/roles/users/:id", pm(handleUpdateUserRole, "roles:manage")) + api.PUT("/api/roles/lists/:id", pm(handleUpdateListRole, "roles:manage")) + api.DELETE("/api/roles/:id", pm(handleDeleteRole, "roles:manage")) if app.constants.BounceWebhooksEnabled { // Private authenticated bounce endpoint. - g.POST("/webhooks/bounce", handleBounceWebhook) + api.POST("/webhooks/bounce", pm(handleBounceWebhook, "webhooks:post_bounce")) // Public bounce endpoints for webservices like SES. - e.POST("/webhooks/service/:service", handleBounceWebhook) + p.POST("/webhooks/service/:service", handleBounceWebhook) } + // ================================================================= + // Public API endpoints. + + // Landing page. + p.GET("/", func(c echo.Context) error { + return c.Render(http.StatusOK, "home", publicTpl{Title: "listmonk"}) + }) + + // Public admin endpoints (login page, OIDC endpoints). + p.GET(path.Join(uriAdmin, "/login"), handleLoginPage) + p.POST(path.Join(uriAdmin, "/login"), handleLoginPage) + + if app.constants.Security.OIDC.Enabled { + p.POST("/auth/oidc", handleOIDCLogin) + p.GET("/auth/oidc", handleOIDCFinish) + } + + // Public APIs. + p.GET("/api/public/lists", handleGetPublicLists) + p.POST("/api/public/subscription", handlePublicSubscription) + if app.constants.EnablePublicArchive { + p.GET("/api/public/archive", handleGetCampaignArchives) + } + + // /public/static/* file server is registered in initHTTPServer(). // Public subscriber facing views. - e.GET("/subscription/form", handleSubscriptionFormPage) - e.POST("/subscription/form", handleSubscriptionForm) - e.GET("/subscription/:campUUID/:subUUID", noIndex(validateUUID(subscriberExists(handleSubscriptionPage), + p.GET("/subscription/form", handleSubscriptionFormPage) + p.POST("/subscription/form", handleSubscriptionForm) + p.GET("/subscription/:campUUID/:subUUID", noIndex(validateUUID(subscriberExists(handleSubscriptionPage), "campUUID", "subUUID"))) - e.POST("/subscription/:campUUID/:subUUID", validateUUID(subscriberExists(handleSubscriptionPage), + p.POST("/subscription/:campUUID/:subUUID", validateUUID(subscriberExists(handleSubscriptionPrefs), "campUUID", "subUUID")) - e.GET("/subscription/optin/:subUUID", noIndex(validateUUID(subscriberExists(handleOptinPage), "subUUID"))) - e.POST("/subscription/optin/:subUUID", validateUUID(subscriberExists(handleOptinPage), "subUUID")) - e.POST("/subscription/export/:subUUID", validateUUID(subscriberExists(handleSelfExportSubscriberData), + p.GET("/subscription/optin/:subUUID", noIndex(validateUUID(subscriberExists(handleOptinPage), "subUUID"))) + p.POST("/subscription/optin/:subUUID", validateUUID(subscriberExists(handleOptinPage), "subUUID")) + p.POST("/subscription/export/:subUUID", validateUUID(subscriberExists(handleSelfExportSubscriberData), "subUUID")) - e.POST("/subscription/wipe/:subUUID", validateUUID(subscriberExists(handleWipeSubscriberData), + p.POST("/subscription/wipe/:subUUID", validateUUID(subscriberExists(handleWipeSubscriberData), "subUUID")) - e.GET("/link/:linkUUID/:campUUID/:subUUID", noIndex(validateUUID(handleLinkRedirect, + p.GET("/link/:linkUUID/:campUUID/:subUUID", noIndex(validateUUID(handleLinkRedirect, "linkUUID", "campUUID", "subUUID"))) - e.GET("/campaign/:campUUID/:subUUID", noIndex(validateUUID(handleViewCampaignMessage, + p.GET("/campaign/:campUUID/:subUUID", noIndex(validateUUID(handleViewCampaignMessage, "campUUID", "subUUID"))) - e.GET("/campaign/:campUUID/:subUUID/px.png", noIndex(validateUUID(handleRegisterCampaignView, + p.GET("/campaign/:campUUID/:subUUID/px.png", noIndex(validateUUID(handleRegisterCampaignView, "campUUID", "subUUID"))) + + if app.constants.EnablePublicArchive { + p.GET("/archive", handleCampaignArchivesPage) + p.GET("/archive.xml", handleGetCampaignArchivesFeed) + p.GET("/archive/:id", handleCampaignArchivePage) + p.GET("/archive/latest", handleCampaignArchivePageLatest) + } + + p.GET("/public/custom.css", serveCustomAppearance("public.custom_css")) + p.GET("/public/custom.js", serveCustomAppearance("public.custom_js")) + // Public health API endpoint. - e.GET("/health", handleHealthCheck) + p.GET("/health", handleHealthCheck) + + // 404 pages. + p.RouteNotFound("/*", func(c echo.Context) error { + return c.Render(http.StatusNotFound, tplMessage, + makeMsgTpl("404 - "+app.i18n.T("public.notFoundTitle"), "", "")) + }) + p.RouteNotFound("/api/*", func(c echo.Context) error { + return echo.NewHTTPError(http.StatusNotFound, "404 unknown endpoint") + }) + p.RouteNotFound("/admin/*", func(c echo.Context) error { + return echo.NewHTTPError(http.StatusNotFound, "404 page not found") + }) } -// handleIndex is the root handler that renders the Javascript frontend. -func handleIndexPage(c echo.Context) error { +// handleAdminPage is the root handler that renders the Javascript admin frontend. +func handleAdminPage(c echo.Context) error { app := c.Get("app").(*App) - b, err := app.fs.Read("/frontend/index.html") + b, err := app.fs.Read(path.Join(uriAdmin, "/index.html")) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } - c.Response().Header().Set("Content-Type", "text/html") - return c.String(http.StatusOK, string(b)) + b = bytes.ReplaceAll(b, []byte("asset_version"), []byte(app.constants.AssetVersion)) + + return c.HTMLBlob(http.StatusOK, b) } // handleHealthCheck is a healthcheck endpoint that returns a 200 response. @@ -188,21 +312,37 @@ func handleHealthCheck(c echo.Context) error { return c.JSON(http.StatusOK, okResp{true}) } -// basicAuth middleware does an HTTP BasicAuth authentication for admin handlers. -func basicAuth(username, password string, c echo.Context) (bool, error) { - app := c.Get("app").(*App) +// serveCustomAppearance serves the given custom CSS/JS appearance blob +// meant for customizing public and admin pages from the admin settings UI. +func serveCustomAppearance(name string) echo.HandlerFunc { + return func(c echo.Context) error { + var ( + app = c.Get("app").(*App) - // Auth is disabled. - if len(app.constants.AdminUsername) == 0 && - len(app.constants.AdminPassword) == 0 { - return true, nil - } + out []byte + hdr string + ) + + switch name { + case "admin.custom_css": + out = app.constants.Appearance.AdminCSS + hdr = "text/css; charset=utf-8" + + case "admin.custom_js": + out = app.constants.Appearance.AdminJS + hdr = "application/javascript; charset=utf-8" + + case "public.custom_css": + out = app.constants.Appearance.PublicCSS + hdr = "text/css; charset=utf-8" + + case "public.custom_js": + out = app.constants.Appearance.PublicJS + hdr = "application/javascript; charset=utf-8" + } - if subtle.ConstantTimeCompare([]byte(username), app.constants.AdminUsername) == 1 && - subtle.ConstantTimeCompare([]byte(password), app.constants.AdminPassword) == 1 { - return true, nil + return c.Blob(http.StatusOK, hdr, out) } - return false, nil } // validateUUID middleware validates the UUID string format for a given set of params. @@ -230,19 +370,17 @@ func subscriberExists(next echo.HandlerFunc, params ...string) echo.HandlerFunc subUUID = c.Param("subUUID") ) - var exists bool - if err := app.queries.SubscriberExists.Get(&exists, 0, subUUID); err != nil { + if _, err := app.core.GetSubscriber(0, subUUID, ""); err != nil { + if er, ok := err.(*echo.HTTPError); ok && er.Code == http.StatusBadRequest { + return c.Render(http.StatusNotFound, tplMessage, + makeMsgTpl(app.i18n.T("public.notFoundTitle"), "", er.Message.(string))) + } + app.log.Printf("error checking subscriber existence: %v", err) return c.Render(http.StatusInternalServerError, tplMessage, - makeMsgTpl(app.i18n.T("public.errorTitle"), "", - app.i18n.T("public.errorProcessingRequest"))) + makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.T("public.errorProcessingRequest"))) } - if !exists { - return c.Render(http.StatusNotFound, tplMessage, - makeMsgTpl(app.i18n.T("public.notFoundTitle"), "", - app.i18n.T("public.subNotFound"))) - } return next(c) } } @@ -254,54 +392,3 @@ func noIndex(next echo.HandlerFunc, params ...string) echo.HandlerFunc { return next(c) } } - -// getPagination takes form values and extracts pagination values from it. -func getPagination(q url.Values, perPage int) pagination { - var ( - page, _ = strconv.Atoi(q.Get("page")) - pp = q.Get("per_page") - ) - - if pp == "all" { - // No limit. - perPage = 0 - } else { - ppi, _ := strconv.Atoi(pp) - if ppi > 0 { - perPage = ppi - } - } - - if page < 1 { - page = 0 - } else { - page-- - } - - return pagination{ - Page: page + 1, - PerPage: perPage, - Offset: page * perPage, - Limit: perPage, - } -} - -// copyEchoCtx returns a copy of the the current echo.Context in a request -// with the given params set for the active handler to proxy the request -// to another handler without mutating its context. -func copyEchoCtx(c echo.Context, params map[string]string) echo.Context { - var ( - keys = make([]string, 0, len(params)) - vals = make([]string, 0, len(params)) - ) - for k, v := range params { - keys = append(keys, k) - vals = append(vals, v) - } - - b := c.Echo().NewContext(c.Request(), c.Response()) - b.Set("app", c.Get("app").(*App)) - b.SetParamNames(keys...) - b.SetParamValues(vals...) - return b -} diff --git a/cmd/i18n.go b/cmd/i18n.go index fc963d1ba..8b9dc74e9 100644 --- a/cmd/i18n.go +++ b/cmd/i18n.go @@ -8,7 +8,7 @@ import ( "github.com/knadh/listmonk/internal/i18n" "github.com/knadh/stuffbin" - "github.com/labstack/echo" + "github.com/labstack/echo/v4" ) type i18nLang struct { diff --git a/cmd/import.go b/cmd/import.go index a3fcf83d2..f4ae7eca0 100644 --- a/cmd/import.go +++ b/cmd/import.go @@ -3,13 +3,13 @@ package main import ( "encoding/json" "io" - "io/ioutil" "net/http" + "os" "strings" "github.com/knadh/listmonk/internal/subimporter" "github.com/knadh/listmonk/models" - "github.com/labstack/echo" + "github.com/labstack/echo/v4" ) // handleImportSubscribers handles the uploading and bulk importing of @@ -22,7 +22,7 @@ func handleImportSubscribers(c echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("import.alreadyRunning")) } - // Unmarsal the JSON params. + // Unmarshal the JSON params. var opt subimporter.SessionOpt if err := json.Unmarshal([]byte(c.FormValue("params")), &opt); err != nil { return echo.NewHTTPError(http.StatusBadRequest, @@ -66,7 +66,7 @@ func handleImportSubscribers(c echo.Context) error { } defer src.Close() - out, err := ioutil.TempFile("", "listmonk") + out, err := os.CreateTemp("", "listmonk") if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, app.i18n.Ts("import.errorCopyingFile", "error", err.Error())) @@ -95,7 +95,7 @@ func handleImportSubscribers(c echo.Context) error { // keeping the global import state (failed / successful) etc. across // multiple files becomes complex. Instead, it's just easier for the // end user to concat multiple CSVs (if there are multiple in the first) - // place and uploada as one in the first place. + // place and upload as one in the first place. dir, files, err := impSess.ExtractZIP(out.Name(), 1) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, diff --git a/cmd/init.go b/cmd/init.go index 06a1c19ca..cdd94009d 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -1,40 +1,53 @@ package main import ( + "bytes" + "crypto/md5" + "database/sql" "encoding/json" + "errors" "fmt" "html/template" + "net/http" "os" "path" "path/filepath" + "runtime" "strings" "syscall" "time" + "github.com/Masterminds/sprig/v3" + "github.com/gdgvda/cron" "github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx/types" "github.com/knadh/goyesql/v2" goyesqlx "github.com/knadh/goyesql/v2/sqlx" - "github.com/knadh/koanf" "github.com/knadh/koanf/maps" "github.com/knadh/koanf/parsers/toml" "github.com/knadh/koanf/providers/confmap" "github.com/knadh/koanf/providers/file" "github.com/knadh/koanf/providers/posflag" + "github.com/knadh/koanf/v2" + "github.com/knadh/listmonk/internal/auth" "github.com/knadh/listmonk/internal/bounce" "github.com/knadh/listmonk/internal/bounce/mailbox" + "github.com/knadh/listmonk/internal/captcha" + "github.com/knadh/listmonk/internal/core" "github.com/knadh/listmonk/internal/i18n" "github.com/knadh/listmonk/internal/manager" "github.com/knadh/listmonk/internal/media" "github.com/knadh/listmonk/internal/media/providers/filesystem" "github.com/knadh/listmonk/internal/media/providers/s3" - "github.com/knadh/listmonk/internal/messenger" "github.com/knadh/listmonk/internal/messenger/email" "github.com/knadh/listmonk/internal/messenger/postback" "github.com/knadh/listmonk/internal/subimporter" + "github.com/knadh/listmonk/models" "github.com/knadh/stuffbin" - "github.com/labstack/echo" + "github.com/labstack/echo/v4" + "github.com/lib/pq" flag "github.com/spf13/pflag" + "gopkg.in/volatiletech/null.v6" ) const ( @@ -43,34 +56,77 @@ const ( // constants contains static, constant config values required by the app. type constants struct { - RootURL string `koanf:"root_url"` - LogoURL string `koanf:"logo_url"` - FaviconURL string `koanf:"favicon_url"` - FromEmail string `koanf:"from_email"` - NotifyEmails []string `koanf:"notify_emails"` - EnablePublicSubPage bool `koanf:"enable_public_subscription_page"` - Lang string `koanf:"lang"` - DBBatchSize int `koanf:"batch_size"` - Privacy struct { + SiteName string `koanf:"site_name"` + RootURL string `koanf:"root_url"` + LogoURL string `koanf:"logo_url"` + FaviconURL string `koanf:"favicon_url"` + LoginURL string `koanf:"login_url"` + FromEmail string `koanf:"from_email"` + NotifyEmails []string `koanf:"notify_emails"` + EnablePublicSubPage bool `koanf:"enable_public_subscription_page"` + EnablePublicArchive bool `koanf:"enable_public_archive"` + EnablePublicArchiveRSSContent bool `koanf:"enable_public_archive_rss_content"` + SendOptinConfirmation bool `koanf:"send_optin_confirmation"` + Lang string `koanf:"lang"` + DBBatchSize int `koanf:"batch_size"` + Privacy struct { IndividualTracking bool `koanf:"individual_tracking"` + AllowPreferences bool `koanf:"allow_preferences"` AllowBlocklist bool `koanf:"allow_blocklist"` AllowExport bool `koanf:"allow_export"` AllowWipe bool `koanf:"allow_wipe"` + RecordOptinIP bool `koanf:"record_optin_ip"` + UnsubHeader bool `koanf:"unsubscribe_header"` Exportable map[string]bool `koanf:"-"` + DomainBlocklist []string `koanf:"-"` } `koanf:"privacy"` - AdminUsername []byte `koanf:"admin_username"` - AdminPassword []byte `koanf:"admin_password"` - + Security struct { + OIDC struct { + Enabled bool `koanf:"enabled"` + Provider string `koanf:"provider_url"` + ClientID string `koanf:"client_id"` + ClientSecret string `koanf:"client_secret"` + } `koanf:"oidc"` + + EnableCaptcha bool `koanf:"enable_captcha"` + CaptchaKey string `koanf:"captcha_key"` + CaptchaSecret string `koanf:"captcha_secret"` + } `koanf:"security"` + + Appearance struct { + AdminCSS []byte `koanf:"admin.custom_css"` + AdminJS []byte `koanf:"admin.custom_js"` + PublicCSS []byte `koanf:"public.custom_css"` + PublicJS []byte `koanf:"public.custom_js"` + } + + HasLegacyUser bool UnsubURL string LinkTrackURL string ViewTrackURL string OptinURL string MessageURL string - MediaProvider string + ArchiveURL string + AssetVersion string + + MediaUpload struct { + Provider string + Extensions []string + } + + BounceWebhooksEnabled bool + BounceSESEnabled bool + BounceSendgridEnabled bool + BouncePostmarkEnabled bool + BounceForwardemailEnabled bool + + PermissionsRaw json.RawMessage + Permissions map[string]struct{} +} - BounceWebhooksEnabled bool - BounceSESEnabled bool - BounceSendgridEnabled bool +type notifTpls struct { + tpls *template.Template + contentType string } func initFlags() { @@ -85,13 +141,14 @@ func initFlags() { f.StringSlice("config", []string{"config.toml"}, "path to one or more config files (will be merged in order)") f.Bool("install", false, "setup database (first time)") - f.Bool("idempotent", false, "make --install run only if the databse isn't already setup") + f.Bool("idempotent", false, "make --install run only if the database isn't already setup") f.Bool("upgrade", false, "upgrade database to the current version") - f.Bool("version", false, "current version of the build") + f.Bool("version", false, "show current version of the build") f.Bool("new-config", false, "generate sample config file") f.String("static-dir", "", "(optional) path to directory with static files") f.String("i18n-dir", "", "(optional) path to directory with i18n language files") f.Bool("yes", false, "assume 'yes' to prompts during --install/upgrade") + f.Bool("passive", false, "run in passive mode where campaigns are not processed") if err := f.Parse(os.Args[1:]); err != nil { lo.Fatalf("error loading flags: %v", err) } @@ -109,13 +166,13 @@ func initConfigFiles(files []string, ko *koanf.Koanf) { if os.IsNotExist(err) { lo.Fatal("config file not found. If there isn't one yet, run --new-config to generate one.") } - lo.Fatalf("error loadng config from file: %v.", err) + lo.Fatalf("error loading config from file: %v.", err) } } } // initFileSystem initializes the stuffbin FileSystem to provide -// access to bunded static assets to the app. +// access to bundled static assets to the app. func initFS(appDir, frontendDir, staticDir, i18nDir string) stuffbin.FileSystem { var ( // stuffbin real_path:virtual_alias paths to map local assets on disk @@ -126,12 +183,13 @@ func initFS(appDir, frontendDir, staticDir, i18nDir string) stuffbin.FileSystem "./config.toml.sample:config.toml.sample", "./queries.sql:queries.sql", "./schema.sql:schema.sql", + "./permissions.json:permissions.json", } frontendFiles = []string{ - // The app's frontend assets are accessible at /frontend/js/* during runtime. - // These paths are joined with frontendDir. - "./:/frontend", + // Admin frontend's static assets accessible at /admin/* during runtime. + // These paths are sourced from frontendDir. + "./:/admin", } staticFiles = []string{ @@ -146,15 +204,15 @@ func initFS(appDir, frontendDir, staticDir, i18nDir string) stuffbin.FileSystem } ) - // Get the executable's path. - path, err := os.Executable() + // Get the executable's execPath. + execPath, err := os.Executable() if err != nil { lo.Fatalf("error getting executable path: %v", err) } // Load embedded files in the executable. hasEmbed := true - fs, err := stuffbin.UnStuff(path) + fs, err := stuffbin.UnStuff(execPath) if err != nil { hasEmbed = false @@ -182,7 +240,7 @@ func initFS(appDir, frontendDir, staticDir, i18nDir string) stuffbin.FileSystem // Default dir in cwd. i18nDir = "i18n" } - lo.Printf("will load i18n files from: %v", i18nDir) + lo.Printf("loading i18n files from: %v", i18nDir) files = append(files, joinFSPaths(i18nDir, i18nFiles)...) } @@ -190,8 +248,19 @@ func initFS(appDir, frontendDir, staticDir, i18nDir string) stuffbin.FileSystem if staticDir == "" { // Default dir in cwd. staticDir = "static" + } else { + // There is a custom static directory. Any paths that aren't in it, exclude. + sf := []string{} + for _, def := range staticFiles { + s := strings.Split(def, ":")[0] + if _, err := os.Stat(path.Join(staticDir, s)); err == nil { + sf = append(sf, def) + } + } + staticFiles = sf } - lo.Printf("will load static files from: %v", staticDir) + + lo.Printf("loading static files from: %v", staticDir) files = append(files, joinFSPaths(staticDir, staticFiles)...) } @@ -216,22 +285,38 @@ func initFS(appDir, frontendDir, staticDir, i18nDir string) stuffbin.FileSystem // initDB initializes the main DB connection pool and parse and loads the app's // SQL queries into a prepared query map. func initDB() *sqlx.DB { - var dbCfg dbConf - if err := ko.Unmarshal("db", &dbCfg); err != nil { + var c struct { + Host string `koanf:"host"` + Port int `koanf:"port"` + User string `koanf:"user"` + Password string `koanf:"password"` + DBName string `koanf:"database"` + SSLMode string `koanf:"ssl_mode"` + Params string `koanf:"params"` + MaxOpen int `koanf:"max_open"` + MaxIdle int `koanf:"max_idle"` + MaxLifetime time.Duration `koanf:"max_lifetime"` + } + if err := ko.Unmarshal("db", &c); err != nil { lo.Fatalf("error loading db config: %v", err) } - lo.Printf("connecting to db: %s:%d/%s", dbCfg.Host, dbCfg.Port, dbCfg.DBName) - db, err := connectDB(dbCfg) + lo.Printf("connecting to db: %s:%d/%s", c.Host, c.Port, c.DBName) + db, err := sqlx.Connect("postgres", + fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s %s", c.Host, c.Port, c.User, c.Password, c.DBName, c.SSLMode, c.Params)) if err != nil { lo.Fatalf("error connecting to DB: %v", err) } + + db.SetMaxOpenConns(c.MaxOpen) + db.SetMaxIdleConns(c.MaxIdle) + db.SetConnMaxLifetime(c.MaxLifetime) + return db } -// initQueries loads named SQL queries from the queries file and optionally -// prepares them. -func initQueries(sqlFile string, db *sqlx.DB, fs stuffbin.FileSystem, prepareQueries bool) (goyesql.Queries, *Queries) { +// readQueries reads named SQL queries from the SQL queries file into a query map. +func readQueries(sqlFile string, db *sqlx.DB, fs stuffbin.FileSystem) goyesql.Queries { // Load SQL queries. qB, err := fs.Read(sqlFile) if err != nil { @@ -242,24 +327,52 @@ func initQueries(sqlFile string, db *sqlx.DB, fs stuffbin.FileSystem, prepareQue lo.Fatalf("error parsing SQL queries: %v", err) } - if !prepareQueries { - return qMap, nil + return qMap +} + +// prepareQueries queries prepares a query map and returns a *Queries +func prepareQueries(qMap goyesql.Queries, db *sqlx.DB, ko *koanf.Koanf) *models.Queries { + var ( + countQuery = "get-campaign-analytics-counts" + linkSel = "*" + ) + if ko.Bool("privacy.individual_tracking") { + countQuery = "get-campaign-analytics-unique-counts" + linkSel = "DISTINCT subscriber_id" } - // Prepare queries. - var q Queries + // These don't exist in the SQL file but are in the queries struct to be prepared. + qMap["get-campaign-view-counts"] = &goyesql.Query{ + Query: fmt.Sprintf(qMap[countQuery].Query, "campaign_views"), + Tags: map[string]string{"name": "get-campaign-view-counts"}, + } + qMap["get-campaign-click-counts"] = &goyesql.Query{ + Query: fmt.Sprintf(qMap[countQuery].Query, "link_clicks"), + Tags: map[string]string{"name": "get-campaign-click-counts"}, + } + qMap["get-campaign-link-counts"].Query = fmt.Sprintf(qMap["get-campaign-link-counts"].Query, linkSel) + + // Scan and prepare all queries. + var q models.Queries if err := goyesqlx.ScanToStruct(&q, qMap, db.Unsafe()); err != nil { lo.Fatalf("error preparing SQL queries: %v", err) } - return qMap, &q + return &q } -// initSettings loads settings from the DB. -func initSettings(q *sqlx.Stmt) { +// initSettings loads settings from the DB into the given Koanf map. +func initSettings(query string, db *sqlx.DB, ko *koanf.Koanf) { var s types.JSONText - if err := q.Get(&s); err != nil { - lo.Fatalf("error reading settings from DB: %s", pqErrMsg(err)) + if err := db.Get(&s, query); err != nil { + msg := err.Error() + if err, ok := err.(*pq.Error); ok { + if err.Detail != "" { + msg = fmt.Sprintf("%s. %s", err, err.Detail) + } + } + + lo.Fatalf("error reading settings from DB: %s", msg) } // Setting keys are dot separated, eg: app.favicon_url. Unflatten them into @@ -280,13 +393,23 @@ func initConstants() *constants { lo.Fatalf("error loading app config: %v", err) } if err := ko.Unmarshal("privacy", &c.Privacy); err != nil { - lo.Fatalf("error loading app config: %v", err) + lo.Fatalf("error loading app.privacy config: %v", err) + } + if err := ko.Unmarshal("security", &c.Security); err != nil { + lo.Fatalf("error loading app.security config: %v", err) + } + + if err := ko.UnmarshalWithConf("appearance", &c.Appearance, koanf.UnmarshalConf{FlatPaths: true}); err != nil { + lo.Fatalf("error loading app.appearance config: %v", err) } c.RootURL = strings.TrimRight(c.RootURL, "/") + c.LoginURL = path.Join(uriAdmin, "/login") c.Lang = ko.String("app.lang") c.Privacy.Exportable = maps.StringSliceToLookupMap(ko.Strings("privacy.exportable")) - c.MediaProvider = ko.String("upload.provider") + c.MediaUpload.Provider = ko.String("upload.provider") + c.MediaUpload.Extensions = ko.Strings("upload.extensions") + c.Privacy.DomainBlocklist = ko.Strings("privacy.domain_blocklist") // Static URLS. // url.com/subscription/{campaign_uuid}/{subscriber_uuid} @@ -301,12 +424,44 @@ func initConstants() *constants { // url.com/link/{campaign_uuid}/{subscriber_uuid} c.MessageURL = fmt.Sprintf("%s/campaign/%%s/%%s", c.RootURL) + // url.com/archive + c.ArchiveURL = c.RootURL + "/archive" + // url.com/campaign/{campaign_uuid}/{subscriber_uuid}/px.png c.ViewTrackURL = fmt.Sprintf("%s/campaign/%%s/%%s/px.png", c.RootURL) c.BounceWebhooksEnabled = ko.Bool("bounce.webhooks_enabled") c.BounceSESEnabled = ko.Bool("bounce.ses_enabled") c.BounceSendgridEnabled = ko.Bool("bounce.sendgrid_enabled") + c.BouncePostmarkEnabled = ko.Bool("bounce.postmark.enabled") + c.BounceForwardemailEnabled = ko.Bool("bounce.forwardemail.enabled") + c.HasLegacyUser = ko.Exists("app.admin_username") || ko.Exists("app.admin_password") + + b := md5.Sum([]byte(time.Now().String())) + c.AssetVersion = fmt.Sprintf("%x", b)[0:10] + + pm, err := fs.Read("/permissions.json") + if err != nil { + lo.Fatalf("error reading permissions file: %v", err) + } + c.PermissionsRaw = pm + + // Make a lookup map of permissions. + permGroups := []struct { + Group string `json:"group"` + Permissions []string `json:"permissions"` + }{} + if err := json.Unmarshal(pm, &permGroups); err != nil { + lo.Fatalf("error loading permissions file: %v", err) + } + + c.Permissions = map[string]struct{}{} + for _, group := range permGroups { + for _, g := range group.Permissions { + c.Permissions[g] = struct{}{} + } + } + return &c } @@ -327,16 +482,13 @@ func initI18n(lang string, fs stuffbin.FileSystem) *i18n.I18n { } // initCampaignManager initializes the campaign manager. -func initCampaignManager(q *Queries, cs *constants, app *App) *manager.Manager { +func initCampaignManager(q *models.Queries, cs *constants, app *App) *manager.Manager { campNotifCB := func(subject string, data interface{}) error { - return app.sendNotification(cs.NotifyEmails, subject, notifTplCampaign, data) + return app.sendNotification(cs.NotifyEmails, subject, notifTplCampaign, data, nil) } - if ko.Int("app.concurrency") < 1 { - lo.Fatal("app.concurrency should be at least 1") - } - if ko.Int("app.message_rate") < 1 { - lo.Fatal("app.message_rate should be at least 1") + if ko.Bool("passive") { + lo.Println("running in passive mode. won't process campaigns.") } return manager.New(manager.Config{ @@ -351,41 +503,60 @@ func initCampaignManager(q *Queries, cs *constants, app *App) *manager.Manager { LinkTrackURL: cs.LinkTrackURL, ViewTrackURL: cs.ViewTrackURL, MessageURL: cs.MessageURL, + ArchiveURL: cs.ArchiveURL, + RootURL: cs.RootURL, UnsubHeader: ko.Bool("privacy.unsubscribe_header"), SlidingWindow: ko.Bool("app.message_sliding_window"), SlidingWindowDuration: ko.Duration("app.message_sliding_window_duration"), SlidingWindowRate: ko.Int("app.message_sliding_window_rate"), - }, newManagerStore(q), campNotifCB, app.i18n, lo) + ScanInterval: time.Second * 5, + ScanCampaigns: !ko.Bool("passive"), + }, newManagerStore(q, app.core, app.media), campNotifCB, app.i18n, lo) +} + +func initTxTemplates(m *manager.Manager, app *App) { + tpls, err := app.core.GetTemplates(models.TemplateTypeTx, false) + if err != nil { + lo.Fatalf("error loading transactional templates: %v", err) + } + + for _, t := range tpls { + tpl := t + if err := tpl.Compile(app.manager.GenericTemplateFuncs()); err != nil { + lo.Printf("error compiling transactional template %d: %v", tpl.ID, err) + continue + } + m.CacheTpl(tpl.ID, &tpl) + } } // initImporter initializes the bulk subscriber importer. -func initImporter(q *Queries, db *sqlx.DB, app *App) *subimporter.Importer { +func initImporter(q *models.Queries, db *sqlx.DB, core *core.Core, app *App) *subimporter.Importer { return subimporter.New( subimporter.Options{ + DomainBlocklist: app.constants.Privacy.DomainBlocklist, UpsertStmt: q.UpsertSubscriber.Stmt, BlocklistStmt: q.UpsertBlocklistSubscriber.Stmt, UpdateListDateStmt: q.UpdateListsDate.Stmt, NotifCB: func(subject string, data interface{}) error { - app.sendNotification(app.constants.NotifyEmails, subject, notifTplImport, data) + // Refresh cached subscriber counts and stats. + core.RefreshMatViews(true) + + app.sendNotification(app.constants.NotifyEmails, subject, notifTplImport, data, nil) return nil }, - }, db.DB) + }, db.DB, app.i18n) } -// initSMTPMessenger initializes the SMTP messenger. -func initSMTPMessenger(m *manager.Manager) messenger.Messenger { +// initSMTPMessenger initializes the combined and individual SMTP messengers. +func initSMTPMessengers() []manager.Messenger { var ( - mapKeys = ko.MapKeys("smtp") - servers = make([]email.Server, 0, len(mapKeys)) + servers = []email.Server{} + out = []manager.Messenger{} ) - items := ko.Slices("smtp") - if len(items) == 0 { - lo.Fatalf("no SMTP servers found in config") - } - - // Load the config for multipme SMTP servers. - for _, item := range items { + // Load the config for multiple SMTP servers. + for _, item := range ko.Slices("smtp") { if !item.Bool("enabled") { continue } @@ -397,31 +568,45 @@ func initSMTPMessenger(m *manager.Manager) messenger.Messenger { } servers = append(servers, s) - lo.Printf("loaded email (SMTP) messenger: %s@%s", - item.String("username"), item.String("host")) - } - if len(servers) == 0 { - lo.Fatalf("no SMTP servers enabled in settings") + lo.Printf("initialized email (SMTP) messenger: %s@%s", item.String("username"), item.String("host")) + + // If the server has a name, initialize it as a standalone e-mail messenger + // allowing campaigns to select individual SMTPs. In the UI and config, it'll appear as `email / $name`. + if s.Name != "" { + msgr, err := email.New(s.Name, s) + if err != nil { + lo.Fatalf("error initializing e-mail messenger: %v", err) + } + out = append(out, msgr) + } } - // Initialize the e-mail messenger with multiple SMTP servers. - msgr, err := email.New(servers...) + // Initialize the 'email' messenger with all SMTP servers. + msgr, err := email.New(email.MessengerName, servers...) if err != nil { - lo.Fatalf("error loading e-mail messenger: %v", err) + lo.Fatalf("error initializing e-mail messenger: %v", err) } - return msgr + // If it's just one server, return the default "email" messenger. + if len(servers) == 1 { + return []manager.Messenger{msgr} + } + + // If there are multiple servers, prepend the group "email" to be the first one. + out = append([]manager.Messenger{msgr}, out...) + + return out } // initPostbackMessengers initializes and returns all the enabled // HTTP postback messenger backends. -func initPostbackMessengers(m *manager.Manager) []messenger.Messenger { +func initPostbackMessengers() []manager.Messenger { items := ko.Slices("messengers") if len(items) == 0 { return nil } - var out []messenger.Messenger + var out []manager.Messenger for _, item := range items { if !item.Bool("enabled") { continue @@ -455,6 +640,7 @@ func initMediaStore() media.Store { case "s3": var o s3.Opt ko.Unmarshal("upload.s3", &o) + up, err := s3.NewS3Store(o) if err != nil { lo.Fatalf("error initializing s3 upload provider %s", err) @@ -469,7 +655,7 @@ func initMediaStore() media.Store { o.RootURL = ko.String("app.root_url") o.UploadPath = filepath.Clean(o.UploadPath) o.UploadURI = filepath.Clean(o.UploadURI) - up, err := filesystem.NewDiskStore(o) + up, err := filesystem.New(o) if err != nil { lo.Fatalf("error initializing filesystem upload provider %s", err) } @@ -484,37 +670,64 @@ func initMediaStore() media.Store { // initNotifTemplates compiles and returns e-mail notification templates that are // used for sending ad-hoc notifications to admins and subscribers. -func initNotifTemplates(path string, fs stuffbin.FileSystem, i *i18n.I18n, cs *constants) *template.Template { - // Register utility functions that the e-mail templates can use. - funcs := template.FuncMap{ - "RootURL": func() string { - return cs.RootURL - }, - "LogoURL": func() string { - return cs.LogoURL - }, - "L": func() *i18n.I18n { - return i - }, +func initNotifTemplates(path string, fs stuffbin.FileSystem, i *i18n.I18n, cs *constants) *notifTpls { + tpls, err := stuffbin.ParseTemplatesGlob(initTplFuncs(i, cs), fs, "/static/email-templates/*.html") + if err != nil { + lo.Fatalf("error parsing e-mail notif templates: %v", err) } - tpl, err := stuffbin.ParseTemplatesGlob(funcs, fs, "/static/email-templates/*.html") + html, err := fs.Read("/static/email-templates/base.html") if err != nil { - lo.Fatalf("error parsing e-mail notif templates: %v", err) + lo.Fatalf("error reading static/email-templates/base.html: %v", err) + } + + out := ¬ifTpls{ + tpls: tpls, + contentType: models.CampaignContentTypeHTML, + } + + // Determine whether the notification templates are HTML or plaintext. + // Copy the first few (arbitrary) bytes of the template and check if has the tag. + ln := 256 + if len(html) < ln { + ln = len(html) + } + h := make([]byte, ln) + copy(h, html[0:ln]) + + if !bytes.Contains(bytes.ToLower(h), []byte(" 2 && len(password) > 6 { + u := models.User{ + Username: username, + Password: null.String{Valid: true, String: password}, + PasswordLogin: true, + HasPassword: true, + Status: models.UserStatusEnabled, + Type: models.UserTypeAPI, + } + u.UserRole.ID = auth.SuperAdminRoleID + a.CacheAPIUser(u) + + lo.Println(`WARNING: Remove the admin_username and admin_password fields from the TOML configuration file. If you are using APIs, create and use new credentials. Users are now managed via the Admin -> Settings -> Users dashboard.`) + } + + return hasUsers, a +} diff --git a/cmd/install.go b/cmd/install.go index 0596b0d1c..3d61ca2a6 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -1,24 +1,21 @@ package main import ( + "encoding/json" "fmt" - "io/ioutil" "os" - "regexp" "strings" - "github.com/gofrs/uuid" + "github.com/gofrs/uuid/v5" "github.com/jmoiron/sqlx" - goyesqlx "github.com/knadh/goyesql/v2/sqlx" "github.com/knadh/listmonk/models" "github.com/knadh/stuffbin" "github.com/lib/pq" ) -// install runs the first time setup of creating and -// migrating the database and creating the super user. +// install runs the first time setup of setting up the database. func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempotent bool) { - qMap, _ := initQueries(queryFilePath, db, fs, false) + qMap := readQueries(queryFilePath, db, fs) fmt.Println("") if !idempotent { @@ -32,7 +29,7 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempo if prompt { var ok string - fmt.Print("continue (y/n)? ") + fmt.Print("continue (y/N)? ") if _, err := fmt.Scanf("%s", &ok); err != nil { lo.Fatalf("error reading value from terminal: %v", err) } @@ -61,12 +58,56 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempo } // Load the queries. - var q Queries - if err := goyesqlx.ScanToStruct(&q, qMap, db.Unsafe()); err != nil { - lo.Fatalf("error loading SQL queries: %v", err) - } + q := prepareQueries(qMap, db, ko) // Sample list. + defList, optinList := installLists(q) + + // Sample subscribers. + installSubs(defList, optinList, q) + + // Templates. + campTplID, archiveTplID := installTemplates(q) + + // Sample campaign. + installCampaign(campTplID, archiveTplID, q) + + // Setup the user optionally. + var ( + user = os.Getenv("LISTMONK_ADMIN_USER") + password = os.Getenv("LISTMONK_ADMIN_PASSWORD") + ) + if user != "" && password != "" { + if len(user) < 3 || len(password) < 8 { + lo.Fatal("LISTMONK_ADMIN_USER should be min 3 chars and LISTMONK_ADMIN_PASSWORD should be min 8 chars") + } + + lo.Printf("creating Super Admin user '%s'", user) + installUser(user, password, q) + } else { + lo.Printf("no Super Admin user created. Visit webpage to create user.") + } + + lo.Printf("setup complete") + lo.Printf(`run the program and access the dashboard at %s`, ko.MustString("app.address")) +} + +// installSchema executes the SQL schema and creates the necessary tables and types. +func installSchema(curVer string, db *sqlx.DB, fs stuffbin.FileSystem) error { + q, err := fs.Read("/schema.sql") + if err != nil { + return err + } + + if _, err := db.Exec(string(q)); err != nil { + return err + } + + // Insert the current migration version. + return recordMigrationVersion(curVer, db) +} + +func installLists(q *models.Queries) (int, int) { var ( defList int optinList int @@ -77,6 +118,7 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempo models.ListTypePrivate, models.ListOptinSingle, pq.StringArray{"test"}, + "", ); err != nil { lo.Fatalf("error creating list: %v", err) } @@ -86,17 +128,22 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempo models.ListTypePublic, models.ListOptinDouble, pq.StringArray{"test"}, + "", ); err != nil { lo.Fatalf("error creating list: %v", err) } + return defList, optinList +} + +func installSubs(defListID, optinListID int, q *models.Queries) { // Sample subscriber. if _, err := q.UpsertSubscriber.Exec( uuid.Must(uuid.NewV4()), "john@example.com", "John Doe", `{"type": "known", "good": true, "city": "Bengaluru"}`, - pq.Int64Array{int64(defList)}, + pq.Int64Array{int64(defListID)}, models.SubscriptionStatusUnconfirmed, true); err != nil { lo.Fatalf("Error creating subscriber: %v", err) @@ -106,29 +153,53 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempo "anon@example.com", "Anon Doe", `{"type": "unknown", "good": true, "city": "Bengaluru"}`, - pq.Int64Array{int64(optinList)}, + pq.Int64Array{int64(optinListID)}, models.SubscriptionStatusUnconfirmed, true); err != nil { lo.Fatalf("error creating subscriber: %v", err) } +} - // Default template. - tplBody, err := fs.Get("/static/email-templates/default.tpl") +func installTemplates(q *models.Queries) (int, int) { + // Default campaign template. + campTpl, err := fs.Get("/static/email-templates/default.tpl") if err != nil { lo.Fatalf("error reading default e-mail template: %v", err) } - var tplID int - if err := q.CreateTemplate.Get(&tplID, - "Default template", - string(tplBody.ReadBytes()), - ); err != nil { - lo.Fatalf("error creating default template: %v", err) + var campTplID int + if err := q.CreateTemplate.Get(&campTplID, "Default campaign template", models.TemplateTypeCampaign, "", campTpl.ReadBytes()); err != nil { + lo.Fatalf("error creating default campaign template: %v", err) } - if _, err := q.SetDefaultTemplate.Exec(tplID); err != nil { + if _, err := q.SetDefaultTemplate.Exec(campTplID); err != nil { lo.Fatalf("error setting default template: %v", err) } + // Default campaign archive template. + archiveTpl, err := fs.Get("/static/email-templates/default-archive.tpl") + if err != nil { + lo.Fatalf("error reading default archive template: %v", err) + } + + var archiveTplID int + if err := q.CreateTemplate.Get(&archiveTplID, "Default archive template", models.TemplateTypeCampaign, "", archiveTpl.ReadBytes()); err != nil { + lo.Fatalf("error creating default campaign template: %v", err) + } + + // Sample tx template. + txTpl, err := fs.Get("/static/email-templates/sample-tx.tpl") + if err != nil { + lo.Fatalf("error reading default e-mail template: %v", err) + } + + if _, err := q.CreateTemplate.Exec("Sample transactional template", models.TemplateTypeTx, "Welcome {{ .Subscriber.Name }}", txTpl.ReadBytes()); err != nil { + lo.Fatalf("error creating sample transactional template: %v", err) + } + + return campTplID, archiveTplID +} + +func installCampaign(campTplID, archiveTplID int, q *models.Queries) { // Sample campaign. if _, err := q.CreateCampaign.Exec(uuid.Must(uuid.NewV4()), models.CampaignTypeRegular, @@ -136,35 +207,30 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempo "Welcome to listmonk", "No Reply ", `

Hi {{ .Subscriber.FirstName }}!

- This is a test e-mail campaign. Your second name is {{ .Subscriber.LastName }} and you are from {{ .Subscriber.Attribs.city }}.`, +

This is a test e-mail campaign. Your second name is {{ .Subscriber.LastName }} and you are from {{ .Subscriber.Attribs.city }}.

+

Here is a tracked link.

+

Use the link icon in the editor toolbar or when writing raw HTML or Markdown, + simply suffix @TrackLink to the end of a URL to turn it into a tracking link. Example:

+
<a href="https:/‌/listmonk.app@TrackLink"></a>
+

For help, refer to the documentation.

+ `, nil, "richtext", nil, + json.RawMessage("[]"), pq.StringArray{"test-campaign"}, emailMsgr, - 1, + campTplID, pq.Int64Array{1}, + false, + "welcome-to-listmonk", + archiveTplID, + `{"name": "Subscriber"}`, + nil, ); err != nil { lo.Fatalf("error creating sample campaign: %v", err) } - lo.Printf("setup complete") - lo.Printf(`run the program and access the dashboard at %s`, ko.MustString("app.address")) -} - -// installSchema executes the SQL schema and creates the necessary tables and types. -func installSchema(curVer string, db *sqlx.DB, fs stuffbin.FileSystem) error { - q, err := fs.Read("/schema.sql") - if err != nil { - return err - } - - if _, err := db.Exec(string(q)); err != nil { - return err - } - - // Insert the current migration version. - return recordMigrationVersion(curVer, db) } // recordMigrationVersion inserts the given version (of DB migration) into the @@ -189,14 +255,7 @@ func newConfigFile(path string) error { return fmt.Errorf("error reading sample config (is binary stuffed?): %v", err) } - // Generate a random admin password. - pwd, err := generateRandomString(16) - if err == nil { - b = regexp.MustCompile(`admin_password\s+?=\s+?(.*)`). - ReplaceAll(b, []byte(fmt.Sprintf(`admin_password = "%s"`, pwd))) - } - - return ioutil.WriteFile(path, b, 0644) + return os.WriteFile(path, b, 0644) } // checkSchema checks if the DB schema is installed. @@ -209,3 +268,21 @@ func checkSchema(db *sqlx.DB) (bool, error) { } return true, nil } + +func installUser(username, password string, q *models.Queries) { + consts := initConstants() + + // Super admin role. + perms := []string{} + for p := range consts.Permissions { + perms = append(perms, p) + } + + if _, err := q.CreateRole.Exec("Super Admin", "user", pq.Array(perms)); err != nil { + lo.Fatalf("error creating super admin role: %v", err) + } + + if _, err := q.CreateUser.Exec(username, true, password, username+"@listmonk", username, "user", 1, nil, "enabled"); err != nil { + lo.Fatalf("error creating superadmin user: %v", err) + } +} diff --git a/cmd/lists.go b/cmd/lists.go index d965b24ec..8b84da002 100644 --- a/cmd/lists.go +++ b/cmd/lists.go @@ -1,84 +1,89 @@ package main import ( - "fmt" "net/http" "strconv" + "strings" - "github.com/gofrs/uuid" + "github.com/knadh/listmonk/internal/auth" "github.com/knadh/listmonk/models" - "github.com/lib/pq" - - "github.com/labstack/echo" + "github.com/labstack/echo/v4" ) -type listsWrap struct { - Results []models.List `json:"results"` - - Total int `json:"total"` - PerPage int `json:"per_page"` - Page int `json:"page"` -} - -var ( - listQuerySortFields = []string{"name", "type", "subscriber_count", "created_at", "updated_at"} -) - -// handleGetLists handles retrieval of lists. +// handleGetLists retrieves lists with additional metadata like subscriber counts. func handleGetLists(c echo.Context) error { var ( - app = c.Get("app").(*App) - out listsWrap - - pg = getPagination(c.QueryParams(), 20) - orderBy = c.FormValue("order_by") - order = c.FormValue("order") - listID, _ = strconv.Atoi(c.Param("id")) - single = false + app = c.Get("app").(*App) + user = c.Get(auth.UserKey).(models.User) + pg = app.paginator.NewFromURL(c.Request().URL.Query()) + + query = strings.TrimSpace(c.FormValue("query")) + tags = c.QueryParams()["tag"] + orderBy = c.FormValue("order_by") + typ = c.FormValue("type") + optin = c.FormValue("optin") + order = c.FormValue("order") + minimal, _ = strconv.ParseBool(c.FormValue("minimal")) + + out models.PageResults ) - // Fetch one list. - if listID > 0 { - single = true + var ( + permittedIDs []int + getAll = false + ) + if _, ok := user.PermissionsMap[models.PermListGetAll]; ok { + getAll = true + } else { + permittedIDs = user.GetListIDs } - // Sort params. - if !strSliceContains(orderBy, listQuerySortFields) { - orderBy = "created_at" - } - if order != sortAsc && order != sortDesc { - order = sortAsc - } + // Minimal query simply returns the list of all lists without JOIN subscriber counts. This is fast. + if minimal { + res, err := app.core.GetLists("", getAll, permittedIDs) + if err != nil { + return err + } + if len(res) == 0 { + return c.JSON(http.StatusOK, okResp{[]struct{}{}}) + } - if err := db.Select(&out.Results, fmt.Sprintf(app.queries.QueryLists, orderBy, order), listID, pg.Offset, pg.Limit); err != nil { - app.log.Printf("error fetching lists: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorFetching", - "name", "{globals.terms.lists}", "error", pqErrMsg(err))) - } - if single && len(out.Results) == 0 { - return echo.NewHTTPError(http.StatusBadRequest, - app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.list}")) - } - if len(out.Results) == 0 { - return c.JSON(http.StatusOK, okResp{[]struct{}{}}) - } + // Meta. + out.Results = res + out.Total = len(res) + out.Page = 1 + out.PerPage = out.Total - // Replace null tags. - for i, v := range out.Results { - if v.Tags == nil { - out.Results[i].Tags = make(pq.StringArray, 0) - } + return c.JSON(http.StatusOK, okResp{out}) } - if single { - return c.JSON(http.StatusOK, okResp{out.Results[0]}) + // Full list query. + res, total, err := app.core.QueryLists(query, typ, optin, tags, orderBy, order, getAll, permittedIDs, pg.Offset, pg.Limit) + if err != nil { + return err } - // Meta. - out.Total = out.Results[0].Total + out.Query = query + out.Results = res + out.Total = total out.Page = pg.Page out.PerPage = pg.PerPage + + return c.JSON(http.StatusOK, okResp{out}) +} + +// handleGetList retrieves a single list by id. +func handleGetList(c echo.Context) error { + var ( + app = c.Get("app").(*App) + listID, _ = strconv.Atoi(c.Param("id")) + ) + + out, err := app.core.GetList(listID, "") + if err != nil { + return err + } + return c.JSON(http.StatusOK, okResp{out}) } @@ -86,44 +91,24 @@ func handleGetLists(c echo.Context) error { func handleCreateList(c echo.Context) error { var ( app = c.Get("app").(*App) - o = models.List{} + l = models.List{} ) - if err := c.Bind(&o); err != nil { + if err := c.Bind(&l); err != nil { return err } // Validate. - if !strHasLen(o.Name, 1, stdInputMaxLen) { + if !strHasLen(l.Name, 1, stdInputMaxLen) { return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("lists.invalidName")) } - uu, err := uuid.NewV4() + out, err := app.core.CreateList(l) if err != nil { - app.log.Printf("error generating UUID: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorUUID", "error", err.Error())) - } - - // Insert and read ID. - var newID int - o.UUID = uu.String() - if err := app.queries.CreateList.Get(&newID, - o.UUID, - o.Name, - o.Type, - o.Optin, - pq.StringArray(normalizeTags(o.Tags))); err != nil { - app.log.Printf("error creating list: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorCreating", - "name", "{globals.terms.list}", "error", pqErrMsg(err))) + return err } - // Hand over to the GET handler to return the last insertion. - return handleGetLists(copyEchoCtx(c, map[string]string{ - "id": fmt.Sprintf("%d", newID), - })) + return c.JSON(http.StatusOK, okResp{out}) } // handleUpdateList handles list modification. @@ -138,26 +123,22 @@ func handleUpdateList(c echo.Context) error { } // Incoming params. - var o models.List - if err := c.Bind(&o); err != nil { + var l models.List + if err := c.Bind(&l); err != nil { return err } - res, err := app.queries.UpdateList.Exec(id, - o.Name, o.Type, o.Optin, pq.StringArray(normalizeTags(o.Tags))) - if err != nil { - app.log.Printf("error updating list: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorUpdating", - "name", "{globals.terms.list}", "error", pqErrMsg(err))) + // Validate. + if !strHasLen(l.Name, 1, stdInputMaxLen) { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("lists.invalidName")) } - if n, _ := res.RowsAffected(); n == 0 { - return echo.NewHTTPError(http.StatusBadRequest, - app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.list}")) + out, err := app.core.UpdateList(id, l) + if err != nil { + return err } - return handleGetLists(c) + return c.JSON(http.StatusOK, okResp{out}) } // handleDeleteLists handles list deletion, either a single one (ID in the URI), or a list. @@ -165,7 +146,7 @@ func handleDeleteLists(c echo.Context) error { var ( app = c.Get("app").(*App) id, _ = strconv.ParseInt(c.Param("id"), 10, 64) - ids pq.Int64Array + ids []int ) if id < 1 && len(ids) == 0 { @@ -173,15 +154,46 @@ func handleDeleteLists(c echo.Context) error { } if id > 0 { - ids = append(ids, id) + ids = append(ids, int(id)) } - if _, err := app.queries.DeleteLists.Exec(ids); err != nil { - app.log.Printf("error deleting lists: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorDeleting", - "name", "{globals.terms.list}", "error", pqErrMsg(err))) + if err := app.core.DeleteLists(ids); err != nil { + return err } return c.JSON(http.StatusOK, okResp{true}) } + +// listPerm is a middleware for wrapping /list/* API calls that take a +// list :id param for validating the list ID against the user's list perms. +func listPerm(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + var ( + app = c.Get("app").(*App) + user = c.Get(auth.UserKey).(models.User) + id, _ = strconv.Atoi(c.Param("id")) + ) + + // Define permissions based on HTTP read/write. + var ( + permAll = models.PermListManageAll + perm = models.PermListManage + ) + if c.Request().Method == http.MethodGet { + permAll = models.PermListGetAll + perm = models.PermListGet + } + + // Check if the user has permissions for all lists or the specific list. + if _, ok := user.PermissionsMap[permAll]; ok { + return next(c) + } + if id > 0 { + if _, ok := user.ListPermissionsMap[id][perm]; ok { + return next(c) + } + } + + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.permissionDenied", "name", "list")) + } +} diff --git a/cmd/main.go b/cmd/main.go index 8c7274059..e7de40926 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -3,7 +3,6 @@ package main import ( "context" "fmt" - "html/template" "io" "log" "os" @@ -14,15 +13,20 @@ import ( "time" "github.com/jmoiron/sqlx" - "github.com/knadh/koanf" "github.com/knadh/koanf/providers/env" + "github.com/knadh/koanf/v2" + "github.com/knadh/listmonk/internal/auth" "github.com/knadh/listmonk/internal/bounce" "github.com/knadh/listmonk/internal/buflog" + "github.com/knadh/listmonk/internal/captcha" + "github.com/knadh/listmonk/internal/core" + "github.com/knadh/listmonk/internal/events" "github.com/knadh/listmonk/internal/i18n" "github.com/knadh/listmonk/internal/manager" "github.com/knadh/listmonk/internal/media" - "github.com/knadh/listmonk/internal/messenger" "github.com/knadh/listmonk/internal/subimporter" + "github.com/knadh/listmonk/models" + "github.com/knadh/paginator" "github.com/knadh/stuffbin" ) @@ -33,27 +37,37 @@ const ( // App contains the "global" components that are // passed around, especially through HTTP handlers. type App struct { - fs stuffbin.FileSystem - db *sqlx.DB - queries *Queries - constants *constants - manager *manager.Manager - importer *subimporter.Importer - messengers map[string]messenger.Messenger - media media.Store - i18n *i18n.I18n - bounce *bounce.Manager - notifTpls *template.Template - log *log.Logger - bufLog *buflog.BufLog + core *core.Core + fs stuffbin.FileSystem + db *sqlx.DB + queries *models.Queries + constants *constants + manager *manager.Manager + importer *subimporter.Importer + messengers []manager.Messenger + emailMessenger manager.Messenger + auth *auth.Auth + media media.Store + i18n *i18n.I18n + bounce *bounce.Manager + paginator *paginator.Paginator + captcha *captcha.Captcha + events *events.Events + notifTpls *notifTpls + about about + log *log.Logger + bufLog *buflog.BufLog // Channel for passing reload signals. - sigChan chan os.Signal + chReload chan os.Signal // Global variable that stores the state indicating that a restart is required // after a settings update. needsRestart bool + // First time installation with no user records in the DB. Needs user setup. + needsUserSetup bool + // Global state that stores data on an available remote update. update *AppUpdate sync.Mutex @@ -61,14 +75,15 @@ type App struct { var ( // Buffered log writer for storing N lines of log entries for the UI. - bufLog = buflog.New(5000) - lo = log.New(io.MultiWriter(os.Stdout, bufLog), "", - log.Ldate|log.Ltime|log.Lshortfile) + evStream = events.New() + bufLog = buflog.New(5000) + lo = log.New(io.MultiWriter(os.Stdout, bufLog, evStream.ErrWriter()), "", + log.Ldate|log.Ltime|log.Lmicroseconds|log.Lshortfile) ko = koanf.New(".") fs stuffbin.FileSystem db *sqlx.DB - queries *Queries + queries *models.Queries // Compile-time variables. buildString string @@ -78,7 +93,7 @@ var ( // are not embedded (in make dist), these paths are looked up. The default values before, when not // overridden by build flags, are relative to the CWD at runtime. appDir string = "." - frontendDir string = "frontend" + frontendDir string = "frontend/dist" ) func init() { @@ -141,11 +156,16 @@ func init() { // Before the queries are prepared, see if there are pending upgrades. checkUpgrade(db) - // Load the SQL queries from the filesystem. - _, queries := initQueries(queryFilePath, db, fs, true) + // Read the SQL queries from the queries file. + qMap := readQueries(queryFilePath, db, fs) // Load settings from DB. - initSettings(queries.GetSettings) + if q, ok := qMap["get-settings"]; ok { + initSettings(q.Query, db, ko) + } + + // Prepare queries. + queries = prepareQueries(qMap, db, ko) } func main() { @@ -156,40 +176,88 @@ func main() { db: db, constants: initConstants(), media: initMediaStore(), - messengers: make(map[string]messenger.Messenger), + messengers: []manager.Messenger{}, log: lo, bufLog: bufLog, + captcha: initCaptcha(), + events: evStream, + + paginator: paginator.New(paginator.Opt{ + DefaultPerPage: 20, + MaxPerPage: 50, + NumPageNums: 10, + PageParam: "page", + PerPageParam: "per_page", + AllowAll: true, + }), } // Load i18n language map. app.i18n = initI18n(app.constants.Lang, fs) + cOpt := &core.Opt{ + Constants: core.Constants{ + SendOptinConfirmation: app.constants.SendOptinConfirmation, + CacheSlowQueries: ko.Bool("app.cache_slow_queries"), + }, + Queries: queries, + DB: db, + I18n: app.i18n, + Log: lo, + } - _, app.queries = initQueries(queryFilePath, db, fs, true) + if err := ko.Unmarshal("bounce.actions", &cOpt.Constants.BounceActions); err != nil { + lo.Fatalf("error unmarshalling bounce config: %v", err) + } + + app.core = core.New(cOpt, &core.Hooks{ + SendOptinConfirmation: sendOptinConfirmationHook(app), + }) + + app.queries = queries app.manager = initCampaignManager(app.queries, app.constants, app) - app.importer = initImporter(app.queries, db, app) + app.importer = initImporter(app.queries, db, app.core, app) + + hasUsers, auth := initAuth(db.DB, ko, app.core) + app.auth = auth + // If there are are no users in the DB who can login, the app has to prompt + // for new user setup. + app.needsUserSetup = !hasUsers + app.notifTpls = initNotifTemplates("/email-templates/*.html", fs, app.i18n, app.constants) + initTxTemplates(app.manager, app) if ko.Bool("bounce.enabled") { app.bounce = initBounceManager(app) go app.bounce.Run() } - // Initialize the default SMTP (`email`) messenger. - app.messengers[emailMsgr] = initSMTPMessenger(app.manager) + // Initialize the SMTP messengers. + app.messengers = initSMTPMessengers() + for _, m := range app.messengers { + if m.Name() == emailMsgr { + app.emailMessenger = m + } + } // Initialize any additional postback messengers. - for _, m := range initPostbackMessengers(app.manager) { - app.messengers[m.Name()] = m - } + app.messengers = append(app.messengers, initPostbackMessengers()...) // Attach all messengers to the campaign manager. for _, m := range app.messengers { app.manager.AddMessenger(m) } + // Load system information. + app.about = initAbout(queries, db) + + // Start cronjobs. + if cOpt.Constants.CacheSlowQueries { + initCron(app.core) + } + // Start the campaign workers. The campaign batches (fetch from DB, push out // messages) get processed at the specified interval. - go app.manager.Run(time.Second * 5) + go app.manager.Run() // Start the app server. srv := initHTTPServer(app) @@ -202,11 +270,11 @@ func main() { // Wait for the reload signal with a callback to gracefully shut down resources. // The `wait` channel is passed to awaitReload to wait for the callback to finish // within N seconds, or do a force reload. - app.sigChan = make(chan os.Signal) - signal.Notify(app.sigChan, syscall.SIGHUP) + app.chReload = make(chan os.Signal) + signal.Notify(app.chReload, syscall.SIGHUP) closerWait := make(chan bool) - <-awaitReload(app.sigChan, closerWait, func() { + <-awaitReload(app.chReload, closerWait, func() { // Stop the HTTP server. ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) defer cancel() diff --git a/cmd/maintenance.go b/cmd/maintenance.go new file mode 100644 index 000000000..c11e3a854 --- /dev/null +++ b/cmd/maintenance.go @@ -0,0 +1,92 @@ +package main + +import ( + "net/http" + "time" + + "github.com/labstack/echo/v4" +) + +// handleGCSubscribers garbage collects (deletes) orphaned or blocklisted subscribers. +func handleGCSubscribers(c echo.Context) error { + var ( + app = c.Get("app").(*App) + typ = c.Param("type") + ) + + var ( + n int + err error + ) + + switch typ { + case "blocklisted": + n, err = app.core.DeleteBlocklistedSubscribers() + case "orphan": + n, err = app.core.DeleteOrphanSubscribers() + default: + err = echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidData")) + } + + if err != nil { + return err + } + + return c.JSON(http.StatusOK, okResp{struct { + Count int `json:"count"` + }{n}}) +} + +// handleGCSubscriptions garbage collects (deletes) orphaned or blocklisted subscribers. +func handleGCSubscriptions(c echo.Context) error { + var ( + app = c.Get("app").(*App) + ) + + t, err := time.Parse(time.RFC3339, c.FormValue("before_date")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidData")) + } + + n, err := app.core.DeleteUnconfirmedSubscriptions(t) + if err != nil { + return err + } + + return c.JSON(http.StatusOK, okResp{struct { + Count int `json:"count"` + }{n}}) +} + +// handleGCCampaignAnalytics garbage collects (deletes) campaign analytics. +func handleGCCampaignAnalytics(c echo.Context) error { + var ( + app = c.Get("app").(*App) + typ = c.Param("type") + ) + + t, err := time.Parse(time.RFC3339, c.FormValue("before_date")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidData")) + } + + switch typ { + case "all": + if err := app.core.DeleteCampaignViews(t); err != nil { + return err + } + err = app.core.DeleteCampaignLinkClicks(t) + case "views": + err = app.core.DeleteCampaignViews(t) + case "clicks": + err = app.core.DeleteCampaignLinkClicks(t) + default: + err = echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidData")) + } + + if err != nil { + return err + } + + return c.JSON(http.StatusOK, okResp{true}) +} diff --git a/cmd/manager_store.go b/cmd/manager_store.go index 5ff5e55d5..b1d67ef8c 100644 --- a/cmd/manager_store.go +++ b/cmd/manager_store.go @@ -1,27 +1,47 @@ package main import ( - "github.com/gofrs/uuid" + "net/http" + + "github.com/gofrs/uuid/v5" + "github.com/knadh/listmonk/internal/core" + "github.com/knadh/listmonk/internal/manager" + "github.com/knadh/listmonk/internal/media" "github.com/knadh/listmonk/models" "github.com/lib/pq" ) -// runnerDB implements runner.DataSource over the primary +// store implements DataSource over the primary // database. -type runnerDB struct { - queries *Queries +type store struct { + queries *models.Queries + core *core.Core + media media.Store + h *http.Client +} + +type runningCamp struct { + CampaignID int `db:"campaign_id"` + CampaignType string `db:"campaign_type"` + LastSubscriberID int `db:"last_subscriber_id"` + MaxSubscriberID int `db:"max_subscriber_id"` + ListID int `db:"list_id"` } -func newManagerStore(q *Queries) *runnerDB { - return &runnerDB{ +func newManagerStore(q *models.Queries, c *core.Core, m media.Store) *store { + return &store{ queries: q, + core: c, + media: m, } } -// NextCampaigns retrieves active campaigns ready to be processed. -func (r *runnerDB) NextCampaigns(excludeIDs []int64) ([]*models.Campaign, error) { +// NextCampaigns retrieves active campaigns ready to be processed excluding +// campaigns that are also being processed. Additionally, it takes a map of campaignID:sentCount +// of campaigns that are being processed and updates them in the DB. +func (s *store) NextCampaigns(currentIDs []int64, sentCounts []int64) ([]*models.Campaign, error) { var out []*models.Campaign - err := r.queries.NextCampaigns.Select(&out, pq.Int64Array(excludeIDs)) + err := s.queries.NextCampaigns.Select(&out, pq.Int64Array(currentIDs), pq.Int64Array(sentCounts)) return out, err } @@ -29,27 +49,66 @@ func (r *runnerDB) NextCampaigns(excludeIDs []int64) ([]*models.Campaign, error) // Since batches are processed sequentially, the retrieval is ordered by ID, // and every batch takes the last ID of the last batch and fetches the next // batch above that. -func (r *runnerDB) NextSubscribers(campID, limit int) ([]models.Subscriber, error) { +func (s *store) NextSubscribers(campID, limit int) ([]models.Subscriber, error) { + var camps []runningCamp + if err := s.queries.GetRunningCampaign.Select(&camps, campID); err != nil { + return nil, err + } + + var listIDs []int + for _, c := range camps { + listIDs = append(listIDs, c.ListID) + } + + if len(listIDs) == 0 { + return nil, nil + } + var out []models.Subscriber - err := r.queries.NextCampaignSubscribers.Select(&out, campID, limit) + err := s.queries.NextCampaignSubscribers.Select(&out, camps[0].CampaignID, camps[0].CampaignType, camps[0].LastSubscriberID, camps[0].MaxSubscriberID, pq.Array(listIDs), limit) return out, err } // GetCampaign fetches a campaign from the database. -func (r *runnerDB) GetCampaign(campID int) (*models.Campaign, error) { +func (s *store) GetCampaign(campID int) (*models.Campaign, error) { var out = &models.Campaign{} - err := r.queries.GetCampaign.Get(out, campID, nil) + err := s.queries.GetCampaign.Get(out, campID, nil, nil, "default") return out, err } // UpdateCampaignStatus updates a campaign's status. -func (r *runnerDB) UpdateCampaignStatus(campID int, status string) error { - _, err := r.queries.UpdateCampaignStatus.Exec(campID, status) +func (s *store) UpdateCampaignStatus(campID int, status string) error { + _, err := s.queries.UpdateCampaignStatus.Exec(campID, status) + return err +} + +// UpdateCampaignCounts updates a campaign's status. +func (s *store) UpdateCampaignCounts(campID int, toSend int, sent int, lastSubID int) error { + _, err := s.queries.UpdateCampaignCounts.Exec(campID, toSend, sent, lastSubID) return err } +// GetAttachment fetches a media attachment blob. +func (s *store) GetAttachment(mediaID int) (models.Attachment, error) { + m, err := s.core.GetMedia(mediaID, "", s.media) + if err != nil { + return models.Attachment{}, err + } + + b, err := s.media.GetBlob(m.URL) + if err != nil { + return models.Attachment{}, err + } + + return models.Attachment{ + Name: m.Filename, + Content: b, + Header: manager.MakeAttachmentHeader(m.Filename, "base64", m.ContentType), + }, nil +} + // CreateLink registers a URL with a UUID for tracking clicks and returns the UUID. -func (r *runnerDB) CreateLink(url string) (string, error) { +func (s *store) CreateLink(url string) (string, error) { // Create a new UUID for the URL. If the URL already exists in the DB // the UUID in the database is returned. uu, err := uuid.NewV4() @@ -58,7 +117,7 @@ func (r *runnerDB) CreateLink(url string) (string, error) { } var out string - if err := r.queries.CreateLink.Get(&out, uu, url); err != nil { + if err := s.queries.CreateLink.Get(&out, uu, url); err != nil { return "", err } @@ -66,13 +125,13 @@ func (r *runnerDB) CreateLink(url string) (string, error) { } // RecordBounce records a bounce event and returns the bounce count. -func (r *runnerDB) RecordBounce(b models.Bounce) (int64, int, error) { +func (s *store) RecordBounce(b models.Bounce) (int64, int, error) { var res = struct { SubscriberID int64 `db:"subscriber_id"` Num int `db:"num"` }{} - err := r.queries.UpdateCampaignStatus.Select(&res, + err := s.queries.UpdateCampaignStatus.Select(&res, b.SubscriberUUID, b.Email, b.CampaignUUID, @@ -83,12 +142,12 @@ func (r *runnerDB) RecordBounce(b models.Bounce) (int64, int, error) { return res.SubscriberID, res.Num, err } -func (r *runnerDB) BlocklistSubscriber(id int64) error { - _, err := r.queries.BlocklistSubscribers.Exec(pq.Int64Array{id}) +func (s *store) BlocklistSubscriber(id int64) error { + _, err := s.queries.BlocklistSubscribers.Exec(pq.Int64Array{id}) return err } -func (r *runnerDB) DeleteSubscriber(id int64) error { - _, err := r.queries.DeleteSubscribers.Exec(pq.Int64Array{id}) +func (s *store) DeleteSubscriber(id int64) error { + _, err := s.queries.DeleteSubscribers.Exec(pq.Int64Array{id}) return err } diff --git a/cmd/media.go b/cmd/media.go index b1c61bc05..1885d7aa1 100644 --- a/cmd/media.go +++ b/cmd/media.go @@ -6,22 +6,21 @@ import ( "net/http" "path/filepath" "strconv" + "strings" "github.com/disintegration/imaging" - "github.com/gofrs/uuid" - "github.com/knadh/listmonk/internal/media" - "github.com/labstack/echo" + "github.com/knadh/listmonk/models" + "github.com/labstack/echo/v4" ) const ( thumbPrefix = "thumb_" - thumbnailSize = 90 + thumbnailSize = 250 ) -// validMimes is the list of image types allowed to be uploaded. var ( - validMimes = []string{"image/jpg", "image/jpeg", "image/png", "image/gif"} - validExts = []string{".jpg", ".jpeg", ".png", ".gif"} + vectorExts = []string{"svg"} + imageExts = []string{"gif", "png", "jpg", "jpeg"} ) // handleUploadMedia handles media file uploads. @@ -36,23 +35,6 @@ func handleUploadMedia(c echo.Context) error { app.i18n.Ts("media.invalidFile", "error", err.Error())) } - // Validate file extension. - ext := filepath.Ext(file.Filename) - if ok := inArray(ext, validExts); !ok { - return echo.NewHTTPError(http.StatusBadRequest, - app.i18n.Ts("media.unsupportedFileType", "type", ext)) - } - - // Validate file's mime. - typ := file.Header.Get("Content-type") - if ok := inArray(typ, validMimes); !ok { - return echo.NewHTTPError(http.StatusBadRequest, - app.i18n.Ts("media.unsupportedFileType", "type", typ)) - } - - // Generate filename - fName := makeFilename(file.Filename) - // Read file contents in memory src, err := file.Open() if err != nil { @@ -61,76 +43,127 @@ func handleUploadMedia(c echo.Context) error { } defer src.Close() + var ( + // Naive check for content type and extension. + ext = strings.TrimPrefix(strings.ToLower(filepath.Ext(file.Filename)), ".") + contentType = file.Header.Get("Content-Type") + ) + if !isASCII(file.Filename) { + return echo.NewHTTPError(http.StatusUnprocessableEntity, + app.i18n.Ts("media.invalidFileName", "name", file.Filename)) + } + + // Validate file extension. + if !inArray("*", app.constants.MediaUpload.Extensions) { + if ok := inArray(ext, app.constants.MediaUpload.Extensions); !ok { + return echo.NewHTTPError(http.StatusBadRequest, + app.i18n.Ts("media.unsupportedFileType", "type", ext)) + } + } + + // Sanitize filename. + fName := makeFilename(file.Filename) + + // Add a random suffix to the filename to ensure uniqueness. + suffix, _ := generateRandomString(6) + fName = appendSuffixToFilename(fName, suffix) + // Upload the file. - fName, err = app.media.Put(fName, typ, src) + fName, err = app.media.Put(fName, contentType, src) if err != nil { app.log.Printf("error uploading file: %v", err) - cleanUp = true return echo.NewHTTPError(http.StatusInternalServerError, app.i18n.Ts("media.errorUploading", "error", err.Error())) } + var ( + thumbfName = "" + width = 0 + height = 0 + ) defer func() { // If any of the subroutines in this function fail, // the uploaded image should be removed. if cleanUp { app.media.Delete(fName) - app.media.Delete(thumbPrefix + fName) + + if thumbfName != "" { + app.media.Delete(thumbfName) + } } }() - // Create thumbnail from file. - thumbFile, err := createThumbnail(file) - if err != nil { - cleanUp = true - app.log.Printf("error resizing image: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("media.errorResizing", "error", err.Error())) + // Create thumbnail from file for non-vector formats. + isImage := inArray(ext, imageExts) + if isImage { + thumbFile, w, h, err := processImage(file) + if err != nil { + cleanUp = true + app.log.Printf("error resizing image: %v", err) + return echo.NewHTTPError(http.StatusInternalServerError, + app.i18n.Ts("media.errorResizing", "error", err.Error())) + } + width = w + height = h + + // Upload thumbnail. + tf, err := app.media.Put(thumbPrefix+fName, contentType, thumbFile) + if err != nil { + cleanUp = true + app.log.Printf("error saving thumbnail: %v", err) + return echo.NewHTTPError(http.StatusInternalServerError, + app.i18n.Ts("media.errorSavingThumbnail", "error", err.Error())) + } + thumbfName = tf } - - // Upload thumbnail. - thumbfName, err := app.media.Put(thumbPrefix+fName, typ, thumbFile) - if err != nil { - cleanUp = true - app.log.Printf("error saving thumbnail: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("media.errorSavingThumbnail", "error", err.Error())) - } - - uu, err := uuid.NewV4() - if err != nil { - app.log.Printf("error generating UUID: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorUUID", "error", err.Error())) + if inArray(ext, vectorExts) { + thumbfName = fName } // Write to the DB. - if _, err := app.queries.InsertMedia.Exec(uu, fName, thumbfName, app.constants.MediaProvider); err != nil { + meta := models.JSON{} + if isImage { + meta = models.JSON{ + "width": width, + "height": height, + } + } + m, err := app.core.InsertMedia(fName, thumbfName, contentType, meta, app.constants.MediaUpload.Provider, app.media) + if err != nil { cleanUp = true - app.log.Printf("error inserting uploaded file to db: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorCreating", - "name", "{globals.terms.media}", "error", pqErrMsg(err))) + return err } - return c.JSON(http.StatusOK, okResp{true}) + return c.JSON(http.StatusOK, okResp{m}) } // handleGetMedia handles retrieval of uploaded media. func handleGetMedia(c echo.Context) error { var ( - app = c.Get("app").(*App) - out = []media.Media{} + app = c.Get("app").(*App) + pg = app.paginator.NewFromURL(c.Request().URL.Query()) + query = c.FormValue("query") + id, _ = strconv.Atoi(c.Param("id")) ) - if err := app.queries.GetMedia.Select(&out, app.constants.MediaProvider); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorFetching", - "name", "{globals.terms.media}", "error", pqErrMsg(err))) + // Fetch one list. + if id > 0 { + out, err := app.core.GetMedia(id, "", app.media) + if err != nil { + return err + } + return c.JSON(http.StatusOK, okResp{out}) } - for i := 0; i < len(out); i++ { - out[i].URL = app.media.Get(out[i].Filename) - out[i].ThumbURL = app.media.Get(out[i].Thumb) + res, total, err := app.core.QueryMedia(app.constants.MediaUpload.Provider, app.media, query, pg.Offset, pg.Limit) + if err != nil { + return err + } + + out := models.PageResults{ + Results: res, + Total: total, + Page: pg.Page, + PerPage: pg.PerPage, } return c.JSON(http.StatusOK, okResp{out}) @@ -147,29 +180,29 @@ func handleDeleteMedia(c echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) } - var m media.Media - if err := app.queries.DeleteMedia.Get(&m, id); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorDeleting", - "name", "{globals.terms.media}", "error", pqErrMsg(err))) + fname, err := app.core.DeleteMedia(id) + if err != nil { + return err } - app.media.Delete(m.Filename) - app.media.Delete(thumbPrefix + m.Filename) + app.media.Delete(fname) + app.media.Delete(thumbPrefix + fname) + return c.JSON(http.StatusOK, okResp{true}) } -// createThumbnail reads the file object and returns a smaller image -func createThumbnail(file *multipart.FileHeader) (*bytes.Reader, error) { +// processImage reads the image file and returns thumbnail bytes and +// the original image's width, and height. +func processImage(file *multipart.FileHeader) (*bytes.Reader, int, int, error) { src, err := file.Open() if err != nil { - return nil, err + return nil, 0, 0, err } defer src.Close() img, err := imaging.Decode(src) if err != nil { - return nil, err + return nil, 0, 0, err } // Encode the image into a byte slice as PNG. @@ -178,7 +211,9 @@ func createThumbnail(file *multipart.FileHeader) (*bytes.Reader, error) { out bytes.Buffer ) if err := imaging.Encode(&out, thumb, imaging.PNG); err != nil { - return nil, err + return nil, 0, 0, err } - return bytes.NewReader(out.Bytes()), nil + + b := img.Bounds().Max + return bytes.NewReader(out.Bytes()), b.X, b.Y, nil } diff --git a/cmd/notifications.go b/cmd/notifications.go index 3fd0406c1..eece80ed2 100644 --- a/cmd/notifications.go +++ b/cmd/notifications.go @@ -2,8 +2,11 @@ package main import ( "bytes" + "net/textproto" + "regexp" + "strings" - "github.com/knadh/listmonk/internal/manager" + "github.com/knadh/listmonk/models" ) const ( @@ -13,6 +16,10 @@ const ( notifSubscriberData = "subscriber-data" ) +var ( + reTitle = regexp.MustCompile(`(?s)(.+?)`) +) + // notifData represents params commonly used across different notification // templates. type notifData struct { @@ -21,22 +28,42 @@ type notifData struct { } // sendNotification sends out an e-mail notification to admins. -func (app *App) sendNotification(toEmails []string, subject, tplName string, data interface{}) error { - var b bytes.Buffer - if err := app.notifTpls.ExecuteTemplate(&b, tplName, data); err != nil { +func (app *App) sendNotification(toEmails []string, subject, tplName string, data interface{}, headers textproto.MIMEHeader) error { + if len(toEmails) == 0 { + return nil + } + + var buf bytes.Buffer + if err := app.notifTpls.tpls.ExecuteTemplate(&buf, tplName, data); err != nil { app.log.Printf("error compiling notification template '%s': %v", tplName, err) return err } + body := buf.Bytes() + + subject, body = getTplSubject(subject, body) - m := manager.Message{} + m := models.Message{} + m.ContentType = app.notifTpls.contentType m.From = app.constants.FromEmail m.To = toEmails m.Subject = subject - m.Body = b.Bytes() + m.Body = body m.Messenger = emailMsgr + m.Headers = headers if err := app.manager.PushMessage(m); err != nil { app.log.Printf("error sending admin notification (%s): %v", subject, err) return err } return nil } + +// getTplSubject extrcts any custom i18n subject rendered in the given rendered +// template body. If it's not found, the incoming subject and body are returned. +func getTplSubject(subject string, body []byte) (string, []byte) { + m := reTitle.FindSubmatch(body) + if len(m) != 2 { + return subject, body + } + + return strings.TrimSpace(string(m[1])), reTitle.ReplaceAll(body, []byte("")) +} diff --git a/cmd/public.go b/cmd/public.go index 22e57f873..c634a487a 100644 --- a/cmd/public.go +++ b/cmd/public.go @@ -13,10 +13,9 @@ import ( "strings" "github.com/knadh/listmonk/internal/i18n" - "github.com/knadh/listmonk/internal/messenger" - "github.com/knadh/listmonk/internal/subimporter" + "github.com/knadh/listmonk/internal/manager" "github.com/knadh/listmonk/models" - "github.com/labstack/echo" + "github.com/labstack/echo/v4" "github.com/lib/pq" ) @@ -26,20 +25,30 @@ const ( // tplRenderer wraps a template.tplRenderer for echo. type tplRenderer struct { - templates *template.Template - RootURL string - LogoURL string - FaviconURL string + templates *template.Template + SiteName string + RootURL string + LogoURL string + FaviconURL string + AssetVersion string + EnablePublicSubPage bool + EnablePublicArchive bool + IndividualTracking bool } // tplData is the data container that is injected // into public templates for accessing data. type tplData struct { - RootURL string - LogoURL string - FaviconURL string - Data interface{} - L *i18n.I18n + SiteName string + RootURL string + LogoURL string + FaviconURL string + AssetVersion string + EnablePublicSubPage bool + EnablePublicArchive bool + IndividualTracking bool + Data interface{} + L *i18n.I18n } type publicTpl struct { @@ -49,10 +58,14 @@ type publicTpl struct { type unsubTpl struct { publicTpl - SubUUID string - AllowBlocklist bool - AllowExport bool - AllowWipe bool + Subscriber models.Subscriber + Subscriptions []models.Subscription + SubUUID string + AllowBlocklist bool + AllowExport bool + AllowWipe bool + AllowPreferences bool + ShowManage bool } type optinTpl struct { @@ -70,12 +83,8 @@ type msgTpl struct { type subFormTpl struct { publicTpl - Lists []models.List -} - -type subForm struct { - subimporter.SubReq - SubListUUIDs []string `form:"l"` + Lists []models.List + CaptchaKey string } var ( @@ -85,14 +94,48 @@ var ( // Render executes and renders a template for echo. func (t *tplRenderer) Render(w io.Writer, name string, data interface{}, c echo.Context) error { return t.templates.ExecuteTemplate(w, name, tplData{ - RootURL: t.RootURL, - LogoURL: t.LogoURL, - FaviconURL: t.FaviconURL, - Data: data, - L: c.Get("app").(*App).i18n, + SiteName: t.SiteName, + RootURL: t.RootURL, + LogoURL: t.LogoURL, + FaviconURL: t.FaviconURL, + AssetVersion: t.AssetVersion, + EnablePublicSubPage: t.EnablePublicSubPage, + EnablePublicArchive: t.EnablePublicArchive, + IndividualTracking: t.IndividualTracking, + Data: data, + L: c.Get("app").(*App).i18n, }) } +// handleGetPublicLists returns the list of public lists with minimal fields +// required to submit a subscription. +func handleGetPublicLists(c echo.Context) error { + var ( + app = c.Get("app").(*App) + ) + + // Get all public lists. + lists, err := app.core.GetLists(models.ListTypePublic, true, nil) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("public.errorFetchingLists")) + } + + type list struct { + UUID string `json:"uuid"` + Name string `json:"name"` + } + + out := make([]list, 0, len(lists)) + for _, l := range lists { + out = append(out, list{ + UUID: l.UUID, + Name: l.Name, + }) + } + + return c.JSON(http.StatusOK, out) +} + // handleViewCampaignMessage renders the HTML view of a campaign message. // This is the view the {{ MessageURL }} template tag links to in e-mail campaigns. func handleViewCampaignMessage(c echo.Context) error { @@ -103,40 +146,36 @@ func handleViewCampaignMessage(c echo.Context) error { ) // Get the campaign. - var camp models.Campaign - if err := app.queries.GetCampaign.Get(&camp, 0, campUUID); err != nil { - if err == sql.ErrNoRows { - return c.Render(http.StatusNotFound, tplMessage, - makeMsgTpl(app.i18n.T("public.notFoundTitle"), "", - app.i18n.T("public.campaignNotFound"))) + camp, err := app.core.GetCampaign(0, campUUID, "") + if err != nil { + if er, ok := err.(*echo.HTTPError); ok { + if er.Code == http.StatusBadRequest { + return c.Render(http.StatusNotFound, tplMessage, + makeMsgTpl(app.i18n.T("public.notFoundTitle"), "", app.i18n.T("public.campaignNotFound"))) + } } - app.log.Printf("error fetching campaign: %v", err) return c.Render(http.StatusInternalServerError, tplMessage, - makeMsgTpl(app.i18n.T("public.errorTitle"), "", - app.i18n.Ts("public.errorFetchingCampaign"))) + makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorFetchingCampaign"))) } // Get the subscriber. - sub, err := getSubscriber(0, subUUID, "", app) + sub, err := app.core.GetSubscriber(0, subUUID, "") if err != nil { if err == sql.ErrNoRows { return c.Render(http.StatusNotFound, tplMessage, - makeMsgTpl(app.i18n.T("public.notFoundTitle"), "", - app.i18n.T("public.errorFetchingEmail"))) + makeMsgTpl(app.i18n.T("public.notFoundTitle"), "", app.i18n.T("public.errorFetchingEmail"))) } return c.Render(http.StatusInternalServerError, tplMessage, - makeMsgTpl(app.i18n.T("public.errorTitle"), "", - app.i18n.Ts("public.errorFetchingCampaign"))) + makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorFetchingCampaign"))) } // Compile the template. if err := camp.CompileTemplate(app.manager.TemplateFuncs(&camp)); err != nil { app.log.Printf("error compiling template: %v", err) return c.Render(http.StatusInternalServerError, tplMessage, - makeMsgTpl(app.i18n.T("public.errorTitle"), "", - app.i18n.Ts("public.errorFetchingCampaign"))) + makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorFetchingCampaign"))) } // Render the message body. @@ -144,8 +183,7 @@ func handleViewCampaignMessage(c echo.Context) error { if err != nil { app.log.Printf("error rendering message: %v", err) return c.Render(http.StatusInternalServerError, tplMessage, - makeMsgTpl(app.i18n.T("public.errorTitle"), "", - app.i18n.Ts("public.errorFetchingCampaign"))) + makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorFetchingCampaign"))) } return c.HTML(http.StatusOK, string(msg.Body())) @@ -156,39 +194,148 @@ func handleViewCampaignMessage(c echo.Context) error { // campaigns link to. func handleSubscriptionPage(c echo.Context) error { var ( - app = c.Get("app").(*App) - campUUID = c.Param("campUUID") - subUUID = c.Param("subUUID") - unsub = c.Request().Method == http.MethodPost - blocklist, _ = strconv.ParseBool(c.FormValue("blocklist")) - out = unsubTpl{} + app = c.Get("app").(*App) + subUUID = c.Param("subUUID") + showManage, _ = strconv.ParseBool(c.FormValue("manage")) + out = unsubTpl{} ) out.SubUUID = subUUID out.Title = app.i18n.T("public.unsubscribeTitle") out.AllowBlocklist = app.constants.Privacy.AllowBlocklist out.AllowExport = app.constants.Privacy.AllowExport out.AllowWipe = app.constants.Privacy.AllowWipe + out.AllowPreferences = app.constants.Privacy.AllowPreferences - // Unsubscribe. - if unsub { - // Is blocklisting allowed? - if !app.constants.Privacy.AllowBlocklist { - blocklist = false + s, err := app.core.GetSubscriber(0, subUUID, "") + if err != nil { + return c.Render(http.StatusInternalServerError, tplMessage, + makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorProcessingRequest"))) + } + out.Subscriber = s + + if s.Status == models.SubscriberStatusBlockListed { + return c.Render(http.StatusOK, tplMessage, + makeMsgTpl(app.i18n.T("public.noSubTitle"), "", app.i18n.Ts("public.blocklisted"))) + } + + // Only show preference management if it's enabled in settings. + if app.constants.Privacy.AllowPreferences { + out.ShowManage = showManage + } + if out.ShowManage { + // Get the subscriber's lists. + subs, err := app.core.GetSubscriptions(0, subUUID, false) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("public.errorFetchingLists")) } - if _, err := app.queries.Unsubscribe.Exec(campUUID, subUUID, blocklist); err != nil { - app.log.Printf("error unsubscribing: %v", err) + out.Subscriptions = make([]models.Subscription, 0, len(subs)) + for _, s := range subs { + if s.Type == models.ListTypePrivate { + continue + } + + out.Subscriptions = append(out.Subscriptions, s) + } + } + + return c.Render(http.StatusOK, "subscription", out) +} + +// handleSubscriptionPrefs renders the subscription management page and +// handles unsubscriptions. This is the view that {{ UnsubscribeURL }} in +// campaigns link to. +func handleSubscriptionPrefs(c echo.Context) error { + var ( + app = c.Get("app").(*App) + campUUID = c.Param("campUUID") + subUUID = c.Param("subUUID") + + req struct { + Name string `form:"name" json:"name"` + ListUUIDs []string `form:"l" json:"list_uuids"` + Blocklist bool `form:"blocklist" json:"blocklist"` + Manage bool `form:"manage" json:"manage"` + } + ) + + // Read the form. + if err := c.Bind(&req); err != nil { + return c.Render(http.StatusBadRequest, tplMessage, + makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.T("globals.messages.invalidData"))) + } + + // Simple unsubscribe. + blocklist := app.constants.Privacy.AllowBlocklist && req.Blocklist + if !req.Manage || blocklist { + if err := app.core.UnsubscribeByCampaign(subUUID, campUUID, blocklist); err != nil { return c.Render(http.StatusInternalServerError, tplMessage, - makeMsgTpl(app.i18n.T("public.errorTitle"), "", - app.i18n.Ts("public.errorProcessingRequest"))) + makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.T("public.errorProcessingRequest"))) } return c.Render(http.StatusOK, tplMessage, - makeMsgTpl(app.i18n.T("public.unsubbedTitle"), "", - app.i18n.T("public.unsubbedInfo"))) + makeMsgTpl(app.i18n.T("public.unsubbedTitle"), "", app.i18n.T("public.unsubbedInfo"))) } - return c.Render(http.StatusOK, "subscription", out) + // Is preference management enabled? + if !app.constants.Privacy.AllowPreferences { + return c.Render(http.StatusBadRequest, tplMessage, + makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.T("public.invalidFeature"))) + } + + // Manage preferences. + req.Name = strings.TrimSpace(req.Name) + if req.Name == "" || len(req.Name) > 256 { + return c.Render(http.StatusBadRequest, tplMessage, + makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.T("subscribers.invalidName"))) + } + + // Get the subscriber from the DB. + sub, err := app.core.GetSubscriber(0, subUUID, "") + if err != nil { + return c.Render(http.StatusInternalServerError, tplMessage, + makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("globals.messages.pFound", + "name", app.i18n.T("globals.terms.subscriber")))) + } + sub.Name = req.Name + + // Update name. + if _, err := app.core.UpdateSubscriber(sub.ID, sub); err != nil { + return c.Render(http.StatusInternalServerError, tplMessage, + makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.T("public.errorProcessingRequest"))) + } + + // Get the subscriber's lists and whatever is not sent in the request (unchecked), + // unsubscribe them. + reqUUIDs := make(map[string]struct{}) + for _, u := range req.ListUUIDs { + reqUUIDs[u] = struct{}{} + } + + subs, err := app.core.GetSubscriptions(0, subUUID, false) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("public.errorFetchingLists")) + } + + unsubUUIDs := make([]string, 0, len(req.ListUUIDs)) + for _, s := range subs { + if s.Type == models.ListTypePrivate { + continue + } + if _, ok := reqUUIDs[s.UUID]; !ok { + unsubUUIDs = append(unsubUUIDs, s.UUID) + } + } + + // Unsubscribe from lists. + if err := app.core.UnsubscribeLists([]int{sub.ID}, nil, unsubUUIDs); err != nil { + return c.Render(http.StatusInternalServerError, tplMessage, + makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.T("public.errorProcessingRequest"))) + + } + + return c.Render(http.StatusOK, tplMessage, + makeMsgTpl(app.i18n.T("globals.messages.done"), "", app.i18n.T("public.prefsSaved"))) } // handleOptinPage renders the double opt-in confirmation page that subscribers @@ -215,41 +362,44 @@ func handleOptinPage(c echo.Context) error { for _, l := range out.ListUUIDs { if !reUUID.MatchString(l) { return c.Render(http.StatusBadRequest, tplMessage, - makeMsgTpl(app.i18n.T("public.errorTitle"), "", - app.i18n.T("globals.messages.invalidUUID"))) + makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.T("globals.messages.invalidUUID"))) } } } // Get the list of subscription lists where the subscriber hasn't confirmed. - if err := app.queries.GetSubscriberLists.Select(&out.Lists, 0, subUUID, - nil, pq.StringArray(out.ListUUIDs), models.SubscriptionStatusUnconfirmed, nil); err != nil { - app.log.Printf("error fetching lists for opt-in: %s", pqErrMsg(err)) - + lists, err := app.core.GetSubscriberLists(0, subUUID, nil, out.ListUUIDs, models.SubscriptionStatusUnconfirmed, "") + if err != nil { return c.Render(http.StatusInternalServerError, tplMessage, - makeMsgTpl(app.i18n.T("public.errorTitle"), "", - app.i18n.Ts("public.errorFetchingLists"))) + makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorFetchingLists"))) } // There are no lists to confirm. - if len(out.Lists) == 0 { - return c.Render(http.StatusInternalServerError, tplMessage, - makeMsgTpl(app.i18n.T("public.noSubTitle"), "", - app.i18n.Ts("public.noSubInfo"))) + if len(lists) == 0 { + return c.Render(http.StatusOK, tplMessage, + makeMsgTpl(app.i18n.T("public.noSubTitle"), "", app.i18n.Ts("public.noSubInfo"))) } + out.Lists = lists // Confirm. if confirm { - if _, err := app.queries.ConfirmSubscriptionOptin.Exec(subUUID, pq.StringArray(out.ListUUIDs)); err != nil { + meta := models.JSON{} + if app.constants.Privacy.RecordOptinIP { + if h := c.Request().Header.Get("X-Forwarded-For"); h != "" { + meta["optin_ip"] = h + } else if h := c.Request().RemoteAddr; h != "" { + meta["optin_ip"] = strings.Split(h, ":")[0] + } + } + + if err := app.core.ConfirmOptionSubscription(subUUID, out.ListUUIDs, meta); err != nil { app.log.Printf("error unsubscribing: %v", err) return c.Render(http.StatusInternalServerError, tplMessage, - makeMsgTpl(app.i18n.T("public.errorTitle"), "", - app.i18n.Ts("public.errorProcessingRequest"))) + makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorProcessingRequest"))) } return c.Render(http.StatusOK, tplMessage, - makeMsgTpl(app.i18n.T("public.subConfirmedTitle"), "", - app.i18n.Ts("public.subConfirmed"))) + makeMsgTpl(app.i18n.T("public.subConfirmedTitle"), "", app.i18n.Ts("public.subConfirmed"))) } return c.Render(http.StatusOK, "optin", out) @@ -264,29 +414,29 @@ func handleSubscriptionFormPage(c echo.Context) error { if !app.constants.EnablePublicSubPage { return c.Render(http.StatusNotFound, tplMessage, - makeMsgTpl(app.i18n.T("public.errorTitle"), "", - app.i18n.Ts("public.invalidFeature"))) + makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.invalidFeature"))) } // Get all public lists. - var lists []models.List - if err := app.queries.GetLists.Select(&lists, models.ListTypePublic); err != nil { - app.log.Printf("error fetching public lists for form: %s", pqErrMsg(err)) + lists, err := app.core.GetLists(models.ListTypePublic, true, nil) + if err != nil { return c.Render(http.StatusInternalServerError, tplMessage, - makeMsgTpl(app.i18n.T("public.errorTitle"), "", - app.i18n.Ts("public.errorFetchingLists"))) + makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorFetchingLists"))) } if len(lists) == 0 { return c.Render(http.StatusInternalServerError, tplMessage, - makeMsgTpl(app.i18n.T("public.errorTitle"), "", - app.i18n.Ts("public.noListsAvailable"))) + makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.noListsAvailable"))) } out := subFormTpl{} out.Title = app.i18n.T("public.sub") out.Lists = lists + if app.constants.Security.EnableCaptcha { + out.CaptchaKey = app.constants.Security.CaptchaKey + } + return c.Render(http.StatusOK, "subscription-form", out) } @@ -295,47 +445,35 @@ func handleSubscriptionFormPage(c echo.Context) error { func handleSubscriptionForm(c echo.Context) error { var ( app = c.Get("app").(*App) - req subForm ) - // Get and validate fields. - if err := c.Bind(&req); err != nil { - return err - } - // If there's a nonce value, a bot could've filled the form. if c.FormValue("nonce") != "" { - return c.Render(http.StatusOK, tplMessage, - makeMsgTpl(app.i18n.T("public.errorTitle"), "", - app.i18n.T("public.invalidFeature"))) - - } - - if len(req.SubListUUIDs) == 0 { - return c.Render(http.StatusBadRequest, tplMessage, - makeMsgTpl(app.i18n.T("public.errorTitle"), "", - app.i18n.T("public.noListsSelected"))) + return echo.NewHTTPError(http.StatusBadGateway, app.i18n.T("public.invalidFeature")) } - // If there's no name, use the name bit from the e-mail. - req.Email = strings.ToLower(req.Email) - if req.Name == "" { - req.Name = strings.Split(req.Email, "@")[0] - } + // Process CAPTCHA. + if app.constants.Security.EnableCaptcha { + err, ok := app.captcha.Verify(c.FormValue("h-captcha-response")) + if err != nil { + app.log.Printf("Captcha request failed: %v", err) + } - // Validate fields. - if err := subimporter.ValidateFields(req.SubReq); err != nil { - return c.Render(http.StatusInternalServerError, tplMessage, - makeMsgTpl(app.i18n.T("public.errorTitle"), "", err.Error())) + if !ok { + return c.Render(http.StatusBadRequest, tplMessage, + makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.T("public.invalidCaptcha"))) + } } - // Insert the subscriber into the DB. - req.Status = models.SubscriberStatusEnabled - req.ListUUIDs = pq.StringArray(req.SubListUUIDs) - _, _, hasOptin, err := insertSubscriber(req.SubReq, app) + hasOptin, err := processSubForm(c) if err != nil { - return c.Render(http.StatusInternalServerError, tplMessage, - makeMsgTpl(app.i18n.T("public.errorTitle"), "", fmt.Sprintf("%s", err.(*echo.HTTPError).Message))) + e, ok := err.(*echo.HTTPError) + if !ok { + return e + } + + return c.Render(e.Code, tplMessage, + makeMsgTpl(app.i18n.T("public.errorTitle"), "", fmt.Sprintf("%s", e.Message))) } msg := "public.subConfirmed" @@ -346,6 +484,27 @@ func handleSubscriptionForm(c echo.Context) error { return c.Render(http.StatusOK, tplMessage, makeMsgTpl(app.i18n.T("public.subTitle"), "", app.i18n.Ts(msg))) } +// handlePublicSubscription handles subscription requests coming from public +// API calls. +func handlePublicSubscription(c echo.Context) error { + var ( + app = c.Get("app").(*App) + ) + + if !app.constants.EnablePublicSubPage { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("public.invalidFeature")) + } + + hasOptin, err := processSubForm(c) + if err != nil { + return err + } + + return c.JSON(http.StatusOK, okResp{struct { + HasOptin bool `json:"has_optin"` + }{hasOptin}}) +} + // handleLinkRedirect redirects a link UUID to its original underlying link // after recording the link click for a particular subscriber in the particular // campaign. These links are generated by {{ TrackLink }} tags in campaigns. @@ -362,18 +521,10 @@ func handleLinkRedirect(c echo.Context) error { subUUID = "" } - var url string - if err := app.queries.RegisterLinkClick.Get(&url, linkUUID, campUUID, subUUID); err != nil { - if pqErr, ok := err.(*pq.Error); ok && pqErr.Column == "link_id" { - return c.Render(http.StatusNotFound, tplMessage, - makeMsgTpl(app.i18n.T("public.errorTitle"), "", - app.i18n.Ts("public.invalidLink"))) - } - - app.log.Printf("error fetching redirect link: %s", err) - return c.Render(http.StatusInternalServerError, tplMessage, - makeMsgTpl(app.i18n.T("public.errorTitle"), "", - app.i18n.Ts("public.errorProcessingRequest"))) + url, err := app.core.RegisterCampaignLinkClick(linkUUID, campUUID, subUUID) + if err != nil { + e := err.(*echo.HTTPError) + return c.Render(e.Code, tplMessage, makeMsgTpl(app.i18n.T("public.errorTitle"), "", e.Error())) } return c.Redirect(http.StatusTemporaryRedirect, url) @@ -381,7 +532,7 @@ func handleLinkRedirect(c echo.Context) error { // handleRegisterCampaignView registers a campaign view which comes in // the form of an pixel image request. Regardless of errors, this handler -// should always render the pixel image bytes. The pixel URL is is generated by +// should always render the pixel image bytes. The pixel URL is generated by // the {{ TrackView }} template tag in campaigns. func handleRegisterCampaignView(c echo.Context) error { var ( @@ -397,7 +548,7 @@ func handleRegisterCampaignView(c echo.Context) error { // Exclude dummy hits from template previews. if campUUID != dummyUUID && subUUID != dummyUUID { - if _, err := app.queries.RegisterCampaignView.Exec(campUUID, subUUID); err != nil { + if err := app.core.RegisterCampaignView(campUUID, subUUID); err != nil { app.log.Printf("error registering campaign view: %s", err) } } @@ -418,8 +569,7 @@ func handleSelfExportSubscriberData(c echo.Context) error { // Is export allowed? if !app.constants.Privacy.AllowExport { return c.Render(http.StatusBadRequest, tplMessage, - makeMsgTpl(app.i18n.T("public.errorTitle"), "", - app.i18n.Ts("public.invalidFeature"))) + makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.invalidFeature"))) } // Get the subscriber's data. A single query that gets the profile, @@ -429,43 +579,46 @@ func handleSelfExportSubscriberData(c echo.Context) error { if err != nil { app.log.Printf("error exporting subscriber data: %s", err) return c.Render(http.StatusInternalServerError, tplMessage, - makeMsgTpl(app.i18n.T("public.errorTitle"), "", - app.i18n.Ts("public.errorProcessingRequest"))) + makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorProcessingRequest"))) } // Prepare the attachment e-mail. var msg bytes.Buffer - if err := app.notifTpls.ExecuteTemplate(&msg, notifSubscriberData, data); err != nil { + if err := app.notifTpls.tpls.ExecuteTemplate(&msg, notifSubscriberData, data); err != nil { app.log.Printf("error compiling notification template '%s': %v", notifSubscriberData, err) return c.Render(http.StatusInternalServerError, tplMessage, - makeMsgTpl(app.i18n.T("public.errorTitle"), "", - app.i18n.Ts("public.errorProcessingRequest"))) + makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorProcessingRequest"))) } + var ( + subject = app.i18n.Ts("email.data.title") + body = msg.Bytes() + ) + subject, body = getTplSubject(subject, body) + // Send the data as a JSON attachment to the subscriber. const fname = "data.json" - if err := app.messengers[emailMsgr].Push(messenger.Message{ - From: app.constants.FromEmail, - To: []string{data.Email}, - Subject: "Your data", - Body: msg.Bytes(), - Attachments: []messenger.Attachment{ + if err := app.emailMessenger.Push(models.Message{ + ContentType: app.notifTpls.contentType, + From: app.constants.FromEmail, + To: []string{data.Email}, + Subject: subject, + Body: body, + Attachments: []models.Attachment{ { Name: fname, Content: b, - Header: messenger.MakeAttachmentHeader(fname, "base64"), + Header: manager.MakeAttachmentHeader(fname, "base64", "application/json"), }, }, }); err != nil { app.log.Printf("error e-mailing subscriber profile: %s", err) return c.Render(http.StatusInternalServerError, tplMessage, - makeMsgTpl(app.i18n.T("public.errorTitle"), "", - app.i18n.Ts("public.errorProcessingRequest"))) + makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorProcessingRequest"))) } return c.Render(http.StatusOK, tplMessage, - makeMsgTpl(app.i18n.T("public.dataSentTitle"), "", - app.i18n.T("public.dataSent"))) + makeMsgTpl(app.i18n.T("public.dataSentTitle"), "", app.i18n.T("public.dataSent"))) } // handleWipeSubscriberData allows a subscriber to delete their data. The @@ -480,20 +633,17 @@ func handleWipeSubscriberData(c echo.Context) error { // Is wiping allowed? if !app.constants.Privacy.AllowWipe { return c.Render(http.StatusBadRequest, tplMessage, - makeMsgTpl(app.i18n.T("public.errorTitle"), "", - app.i18n.Ts("public.invalidFeature"))) + makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.invalidFeature"))) } - if _, err := app.queries.DeleteSubscribers.Exec(nil, pq.StringArray{subUUID}); err != nil { + if err := app.core.DeleteSubscribers(nil, []string{subUUID}); err != nil { app.log.Printf("error wiping subscriber data: %s", err) return c.Render(http.StatusInternalServerError, tplMessage, - makeMsgTpl(app.i18n.T("public.errorTitle"), "", - app.i18n.Ts("public.errorProcessingRequest"))) + makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorProcessingRequest"))) } return c.Render(http.StatusOK, tplMessage, - makeMsgTpl(app.i18n.T("public.dataRemovedTitle"), "", - app.i18n.T("public.dataRemoved"))) + makeMsgTpl(app.i18n.T("public.dataRemovedTitle"), "", app.i18n.T("public.dataRemoved"))) } // drawTransparentImage draws a transparent PNG of given dimensions @@ -506,3 +656,77 @@ func drawTransparentImage(h, w int) []byte { _ = png.Encode(out, img) return out.Bytes() } + +// processSubForm processes an incoming form/public API subscription request. +// The bool indicates whether there was subscription to an optin list so that +// an appropriate message can be shown. +func processSubForm(c echo.Context) (bool, error) { + var ( + app = c.Get("app").(*App) + req struct { + Name string `form:"name" json:"name"` + Email string `form:"email" json:"email"` + FormListUUIDs []string `form:"l" json:"list_uuids"` + } + ) + + // Get and validate fields. + if err := c.Bind(&req); err != nil { + return false, err + } + + if len(req.FormListUUIDs) == 0 { + return false, echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("public.noListsSelected")) + } + + // If there's no name, use the name bit from the e-mail. + req.Name = strings.TrimSpace(req.Name) + if req.Name == "" { + req.Name = strings.Split(req.Email, "@")[0] + } + + // Validate fields. + if len(req.Email) > 1000 { + return false, echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.invalidEmail")) + } + + em, err := app.importer.SanitizeEmail(req.Email) + if err != nil { + return false, echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + req.Email = em + + req.Name = strings.TrimSpace(req.Name) + if len(req.Name) == 0 || len(req.Name) > stdInputMaxLen { + return false, echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.invalidName")) + } + + listUUIDs := pq.StringArray(req.FormListUUIDs) + + // Insert the subscriber into the DB. + _, hasOptin, err := app.core.InsertSubscriber(models.Subscriber{ + Name: req.Name, + Email: req.Email, + Status: models.SubscriberStatusEnabled, + }, nil, listUUIDs, false) + if err != nil { + // Subscriber already exists. Update subscriptions. + if e, ok := err.(*echo.HTTPError); ok && e.Code == http.StatusConflict { + sub, err := app.core.GetSubscriber(0, "", req.Email) + if err != nil { + return false, err + } + + _, hasOptin, err := app.core.UpdateSubscriberWithLists(sub.ID, sub, nil, listUUIDs, false, false) + if err != nil { + return false, err + } + + return hasOptin, nil + } + + return false, echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("%s", err.(*echo.HTTPError).Message)) + } + + return hasOptin, nil +} diff --git a/cmd/queries.go b/cmd/queries.go deleted file mode 100644 index 3fdded4dd..000000000 --- a/cmd/queries.go +++ /dev/null @@ -1,163 +0,0 @@ -package main - -import ( - "context" - "database/sql" - "fmt" - "time" - - "github.com/jmoiron/sqlx" - "github.com/lib/pq" -) - -// Queries contains all prepared SQL queries. -type Queries struct { - GetDashboardCharts *sqlx.Stmt `query:"get-dashboard-charts"` - GetDashboardCounts *sqlx.Stmt `query:"get-dashboard-counts"` - - InsertSubscriber *sqlx.Stmt `query:"insert-subscriber"` - UpsertSubscriber *sqlx.Stmt `query:"upsert-subscriber"` - UpsertBlocklistSubscriber *sqlx.Stmt `query:"upsert-blocklist-subscriber"` - GetSubscriber *sqlx.Stmt `query:"get-subscriber"` - GetSubscribersByEmails *sqlx.Stmt `query:"get-subscribers-by-emails"` - GetSubscriberLists *sqlx.Stmt `query:"get-subscriber-lists"` - GetSubscriberListsLazy *sqlx.Stmt `query:"get-subscriber-lists-lazy"` - SubscriberExists *sqlx.Stmt `query:"subscriber-exists"` - UpdateSubscriber *sqlx.Stmt `query:"update-subscriber"` - BlocklistSubscribers *sqlx.Stmt `query:"blocklist-subscribers"` - AddSubscribersToLists *sqlx.Stmt `query:"add-subscribers-to-lists"` - DeleteSubscriptions *sqlx.Stmt `query:"delete-subscriptions"` - ConfirmSubscriptionOptin *sqlx.Stmt `query:"confirm-subscription-optin"` - UnsubscribeSubscribersFromLists *sqlx.Stmt `query:"unsubscribe-subscribers-from-lists"` - DeleteSubscribers *sqlx.Stmt `query:"delete-subscribers"` - Unsubscribe *sqlx.Stmt `query:"unsubscribe"` - ExportSubscriberData *sqlx.Stmt `query:"export-subscriber-data"` - - // Non-prepared arbitrary subscriber queries. - QuerySubscribers string `query:"query-subscribers"` - QuerySubscribersForExport string `query:"query-subscribers-for-export"` - QuerySubscribersTpl string `query:"query-subscribers-template"` - DeleteSubscribersByQuery string `query:"delete-subscribers-by-query"` - AddSubscribersToListsByQuery string `query:"add-subscribers-to-lists-by-query"` - BlocklistSubscribersByQuery string `query:"blocklist-subscribers-by-query"` - DeleteSubscriptionsByQuery string `query:"delete-subscriptions-by-query"` - UnsubscribeSubscribersFromListsByQuery string `query:"unsubscribe-subscribers-from-lists-by-query"` - - CreateList *sqlx.Stmt `query:"create-list"` - QueryLists string `query:"query-lists"` - GetLists *sqlx.Stmt `query:"get-lists"` - GetListsByOptin *sqlx.Stmt `query:"get-lists-by-optin"` - UpdateList *sqlx.Stmt `query:"update-list"` - UpdateListsDate *sqlx.Stmt `query:"update-lists-date"` - DeleteLists *sqlx.Stmt `query:"delete-lists"` - - CreateCampaign *sqlx.Stmt `query:"create-campaign"` - QueryCampaigns string `query:"query-campaigns"` - GetCampaign *sqlx.Stmt `query:"get-campaign"` - GetCampaignForPreview *sqlx.Stmt `query:"get-campaign-for-preview"` - GetCampaignStats *sqlx.Stmt `query:"get-campaign-stats"` - GetCampaignStatus *sqlx.Stmt `query:"get-campaign-status"` - NextCampaigns *sqlx.Stmt `query:"next-campaigns"` - NextCampaignSubscribers *sqlx.Stmt `query:"next-campaign-subscribers"` - GetOneCampaignSubscriber *sqlx.Stmt `query:"get-one-campaign-subscriber"` - UpdateCampaign *sqlx.Stmt `query:"update-campaign"` - UpdateCampaignStatus *sqlx.Stmt `query:"update-campaign-status"` - UpdateCampaignCounts *sqlx.Stmt `query:"update-campaign-counts"` - RegisterCampaignView *sqlx.Stmt `query:"register-campaign-view"` - DeleteCampaign *sqlx.Stmt `query:"delete-campaign"` - - InsertMedia *sqlx.Stmt `query:"insert-media"` - GetMedia *sqlx.Stmt `query:"get-media"` - DeleteMedia *sqlx.Stmt `query:"delete-media"` - - CreateTemplate *sqlx.Stmt `query:"create-template"` - GetTemplates *sqlx.Stmt `query:"get-templates"` - UpdateTemplate *sqlx.Stmt `query:"update-template"` - SetDefaultTemplate *sqlx.Stmt `query:"set-default-template"` - DeleteTemplate *sqlx.Stmt `query:"delete-template"` - - CreateLink *sqlx.Stmt `query:"create-link"` - RegisterLinkClick *sqlx.Stmt `query:"register-link-click"` - - GetSettings *sqlx.Stmt `query:"get-settings"` - UpdateSettings *sqlx.Stmt `query:"update-settings"` - - // GetStats *sqlx.Stmt `query:"get-stats"` - RecordBounce *sqlx.Stmt `query:"record-bounce"` - QueryBounces string `query:"query-bounces"` - DeleteBounces *sqlx.Stmt `query:"delete-bounces"` - DeleteBouncesBySubscriber *sqlx.Stmt `query:"delete-bounces-by-subscriber"` -} - -// dbConf contains database config required for connecting to a DB. -type dbConf struct { - Host string `koanf:"host"` - Port int `koanf:"port"` - User string `koanf:"user"` - Password string `koanf:"password"` - DBName string `koanf:"database"` - SSLMode string `koanf:"ssl_mode"` - MaxOpen int `koanf:"max_open"` - MaxIdle int `koanf:"max_idle"` - MaxLifetime time.Duration `koanf:"max_lifetime"` -} - -// connectDB initializes a database connection. -func connectDB(c dbConf) (*sqlx.DB, error) { - db, err := sqlx.Connect("postgres", - fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s", - c.Host, c.Port, c.User, c.Password, c.DBName, c.SSLMode)) - if err != nil { - return nil, err - } - db.SetMaxOpenConns(c.MaxOpen) - db.SetMaxIdleConns(c.MaxIdle) - db.SetConnMaxLifetime(c.MaxLifetime) - return db, nil -} - -// compileSubscriberQueryTpl takes a arbitrary WHERE expressions -// to filter subscribers from the subscribers table and prepares a query -// out of it using the raw `query-subscribers-template` query template. -// While doing this, a readonly transaction is created and the query is -// dry run on it to ensure that it is indeed readonly. -func (q *Queries) compileSubscriberQueryTpl(exp string, db *sqlx.DB) (string, error) { - tx, err := db.BeginTxx(context.Background(), &sql.TxOptions{ReadOnly: true}) - if err != nil { - return "", err - } - defer tx.Rollback() - - // Perform the dry run. - if exp != "" { - exp = " AND " + exp - } - stmt := fmt.Sprintf(q.QuerySubscribersTpl, exp) - if _, err := tx.Exec(stmt, true, pq.Int64Array{}); err != nil { - return "", err - } - - return stmt, nil -} - -// compileSubscriberQueryTpl takes a arbitrary WHERE expressions and a subscriber -// query template that depends on the filter (eg: delete by query, blocklist by query etc.) -// combines and executes them. -func (q *Queries) execSubscriberQueryTpl(exp, tpl string, listIDs []int64, db *sqlx.DB, args ...interface{}) error { - // Perform a dry run. - filterExp, err := q.compileSubscriberQueryTpl(exp, db) - if err != nil { - return err - } - - if len(listIDs) == 0 { - listIDs = pq.Int64Array{} - } - // First argument is the boolean indicating if the query is a dry run. - a := append([]interface{}{false, pq.Int64Array(listIDs)}, args...) - if _, err := db.Exec(fmt.Sprintf(tpl, filterExp), a...); err != nil { - return err - } - - return nil -} diff --git a/cmd/roles.go b/cmd/roles.go new file mode 100644 index 000000000..c28423f6e --- /dev/null +++ b/cmd/roles.go @@ -0,0 +1,216 @@ +package main + +import ( + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/knadh/listmonk/models" + "github.com/labstack/echo/v4" +) + +// handleGetUserRoles retrieves roles. +func handleGetUserRoles(c echo.Context) error { + var ( + app = c.Get("app").(*App) + ) + + // Get all roles. + out, err := app.core.GetRoles() + if err != nil { + return err + } + + return c.JSON(http.StatusOK, okResp{out}) +} + +// handleGeListRoles retrieves roles. +func handleGeListRoles(c echo.Context) error { + var ( + app = c.Get("app").(*App) + ) + + // Get all roles. + out, err := app.core.GetListRoles() + if err != nil { + return err + } + + return c.JSON(http.StatusOK, okResp{out}) +} + +// handleCreateUserRole handles role creation. +func handleCreateUserRole(c echo.Context) error { + var ( + app = c.Get("app").(*App) + r = models.Role{} + ) + + if err := c.Bind(&r); err != nil { + return err + } + + if err := validateUserRole(r, app); err != nil { + return err + } + + out, err := app.core.CreateRole(r) + if err != nil { + return err + } + + return c.JSON(http.StatusOK, okResp{out}) +} + +// handleCreateListRole handles role creation. +func handleCreateListRole(c echo.Context) error { + var ( + app = c.Get("app").(*App) + r = models.ListRole{} + ) + + if err := c.Bind(&r); err != nil { + return err + } + + if err := validateListRole(r, app); err != nil { + return err + } + + out, err := app.core.CreateListRole(r) + if err != nil { + return err + } + + return c.JSON(http.StatusOK, okResp{out}) +} + +// handleUpdateUserRole handles role modification. +func handleUpdateUserRole(c echo.Context) error { + var ( + app = c.Get("app").(*App) + id, _ = strconv.Atoi(c.Param("id")) + ) + + if id < 2 { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) + } + + // Incoming params. + var r models.Role + if err := c.Bind(&r); err != nil { + return err + } + + if err := validateUserRole(r, app); err != nil { + return err + } + + // Validate. + r.Name.String = strings.TrimSpace(r.Name.String) + + out, err := app.core.UpdateUserRole(id, r) + if err != nil { + return err + } + + // Cache the API token for validating API queries without hitting the DB every time. + if _, err := cacheUsers(app.core, app.auth); err != nil { + return err + } + + return c.JSON(http.StatusOK, okResp{out}) +} + +// handleUpdateListRole handles role modification. +func handleUpdateListRole(c echo.Context) error { + var ( + app = c.Get("app").(*App) + id, _ = strconv.Atoi(c.Param("id")) + ) + + if id < 2 { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) + } + + // Incoming params. + var r models.ListRole + if err := c.Bind(&r); err != nil { + return err + } + + if err := validateListRole(r, app); err != nil { + return err + } + + // Validate. + r.Name.String = strings.TrimSpace(r.Name.String) + + out, err := app.core.UpdateListRole(id, r) + if err != nil { + return err + } + + // Cache the API token for validating API queries without hitting the DB every time. + if _, err := cacheUsers(app.core, app.auth); err != nil { + return err + } + + return c.JSON(http.StatusOK, okResp{out}) +} + +// handleDeleteRole handles role deletion. +func handleDeleteRole(c echo.Context) error { + var ( + app = c.Get("app").(*App) + id, _ = strconv.ParseInt(c.Param("id"), 10, 64) + ) + + if id < 1 { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) + } + + if err := app.core.DeleteRole(int(id)); err != nil { + return err + } + + // Cache the API token for validating API queries without hitting the DB every time. + if _, err := cacheUsers(app.core, app.auth); err != nil { + return err + } + + return c.JSON(http.StatusOK, okResp{true}) +} + +func validateUserRole(r models.Role, app *App) error { + // Validate fields. + if !strHasLen(r.Name.String, 1, stdInputMaxLen) { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "name")) + } + + for _, p := range r.Permissions { + if _, ok := app.constants.Permissions[p]; !ok { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", fmt.Sprintf("permission: %s", p))) + } + } + + return nil +} + +func validateListRole(r models.ListRole, app *App) error { + // Validate fields. + if !strHasLen(r.Name.String, 1, stdInputMaxLen) { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "name")) + } + + for _, l := range r.Lists { + for _, p := range l.Permissions { + if p != "list:get" && p != "list:manage" { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", fmt.Sprintf("list permission: %s", p))) + } + } + } + + return nil +} diff --git a/cmd/settings.go b/cmd/settings.go index d2a061792..519f84c2d 100644 --- a/cmd/settings.go +++ b/cmd/settings.go @@ -1,108 +1,47 @@ package main import ( - "encoding/json" + "bytes" + "io" "net/http" "regexp" + "runtime" "strings" "syscall" "time" + "unicode/utf8" - "github.com/gofrs/uuid" + "github.com/gdgvda/cron" + "github.com/gofrs/uuid/v5" "github.com/jmoiron/sqlx/types" - "github.com/labstack/echo" + "github.com/knadh/koanf/parsers/json" + "github.com/knadh/koanf/providers/rawbytes" + "github.com/knadh/koanf/v2" + "github.com/knadh/listmonk/internal/messenger/email" + "github.com/knadh/listmonk/models" + "github.com/labstack/echo/v4" ) -type settings struct { - AppRootURL string `json:"app.root_url"` - AppLogoURL string `json:"app.logo_url"` - AppFaviconURL string `json:"app.favicon_url"` - AppFromEmail string `json:"app.from_email"` - AppNotifyEmails []string `json:"app.notify_emails"` - EnablePublicSubPage bool `json:"app.enable_public_subscription_page"` - CheckUpdates bool `json:"app.check_updates"` - AppLang string `json:"app.lang"` - - AppBatchSize int `json:"app.batch_size"` - AppConcurrency int `json:"app.concurrency"` - AppMaxSendErrors int `json:"app.max_send_errors"` - AppMessageRate int `json:"app.message_rate"` - - AppMessageSlidingWindow bool `json:"app.message_sliding_window"` - AppMessageSlidingWindowDuration string `json:"app.message_sliding_window_duration"` - AppMessageSlidingWindowRate int `json:"app.message_sliding_window_rate"` - - PrivacyIndividualTracking bool `json:"privacy.individual_tracking"` - PrivacyUnsubHeader bool `json:"privacy.unsubscribe_header"` - PrivacyAllowBlocklist bool `json:"privacy.allow_blocklist"` - PrivacyAllowExport bool `json:"privacy.allow_export"` - PrivacyAllowWipe bool `json:"privacy.allow_wipe"` - PrivacyExportable []string `json:"privacy.exportable"` - - UploadProvider string `json:"upload.provider"` - UploadFilesystemUploadPath string `json:"upload.filesystem.upload_path"` - UploadFilesystemUploadURI string `json:"upload.filesystem.upload_uri"` - UploadS3URL string `json:"upload.s3.url"` - UploadS3AwsAccessKeyID string `json:"upload.s3.aws_access_key_id"` - UploadS3AwsDefaultRegion string `json:"upload.s3.aws_default_region"` - UploadS3AwsSecretAccessKey string `json:"upload.s3.aws_secret_access_key,omitempty"` - UploadS3Bucket string `json:"upload.s3.bucket"` - UploadS3BucketDomain string `json:"upload.s3.bucket_domain"` - UploadS3BucketPath string `json:"upload.s3.bucket_path"` - UploadS3BucketType string `json:"upload.s3.bucket_type"` - UploadS3Expiry string `json:"upload.s3.expiry"` - - SMTP []struct { - UUID string `json:"uuid"` - Enabled bool `json:"enabled"` - Host string `json:"host"` - HelloHostname string `json:"hello_hostname"` - Port int `json:"port"` - AuthProtocol string `json:"auth_protocol"` - Username string `json:"username"` - Password string `json:"password,omitempty"` - EmailHeaders []map[string]string `json:"email_headers"` - MaxConns int `json:"max_conns"` - MaxMsgRetries int `json:"max_msg_retries"` - IdleTimeout string `json:"idle_timeout"` - WaitTimeout string `json:"wait_timeout"` - TLSEnabled bool `json:"tls_enabled"` - TLSSkipVerify bool `json:"tls_skip_verify"` - } `json:"smtp"` - - Messengers []struct { - UUID string `json:"uuid"` - Enabled bool `json:"enabled"` - Name string `json:"name"` - RootURL string `json:"root_url"` - Username string `json:"username"` - Password string `json:"password,omitempty"` - MaxConns int `json:"max_conns"` - Timeout string `json:"timeout"` - MaxMsgRetries int `json:"max_msg_retries"` - } `json:"messengers"` - - BounceEnabled bool `json:"bounce.enabled"` - BounceEnableWebhooks bool `json:"bounce.webhooks_enabled"` - BounceCount int `json:"bounce.count"` - BounceAction string `json:"bounce.action"` - SESEnabled bool `json:"bounce.ses_enabled"` - SendgridEnabled bool `json:"bounce.sendgrid_enabled"` - SendgridKey string `json:"bounce.sendgrid_key"` - BounceBoxes []struct { - UUID string `json:"uuid"` - Enabled bool `json:"enabled"` - Type string `json:"type"` - Host string `json:"host"` - Port int `json:"port"` - AuthProtocol string `json:"auth_protocol"` - ReturnPath string `json:"return_path"` - Username string `json:"username"` - Password string `json:"password,omitempty"` - TLSEnabled bool `json:"tls_enabled"` - TLSSkipVerify bool `json:"tls_skip_verify"` - ScanInterval string `json:"scan_interval"` - } `json:"bounce.mailboxes"` +const pwdMask = "•" + +type aboutHost struct { + OS string `json:"os"` + Machine string `json:"arch"` + Hostname string `json:"hostname"` +} +type aboutSystem struct { + NumCPU int `json:"num_cpu"` + AllocMB uint64 `json:"memory_alloc_mb"` + OSMB uint64 `json:"memory_from_os_mb"` +} +type about struct { + Version string `json:"version"` + Build string `json:"build"` + GoVersion string `json:"go_version"` + GoArch string `json:"go_arch"` + Database types.JSONText `json:"database"` + System aboutSystem `json:"system"` + Host aboutHost `json:"host"` } var ( @@ -113,23 +52,28 @@ var ( func handleGetSettings(c echo.Context) error { app := c.Get("app").(*App) - s, err := getSettings(app) + s, err := app.core.GetSettings() if err != nil { return err } // Empty out passwords. for i := 0; i < len(s.SMTP); i++ { - s.SMTP[i].Password = "" + s.SMTP[i].Password = strings.Repeat(pwdMask, utf8.RuneCountInString(s.SMTP[i].Password)) } for i := 0; i < len(s.BounceBoxes); i++ { - s.BounceBoxes[i].Password = "" + s.BounceBoxes[i].Password = strings.Repeat(pwdMask, utf8.RuneCountInString(s.BounceBoxes[i].Password)) } for i := 0; i < len(s.Messengers); i++ { - s.Messengers[i].Password = "" + s.Messengers[i].Password = strings.Repeat(pwdMask, utf8.RuneCountInString(s.Messengers[i].Password)) } - s.UploadS3AwsSecretAccessKey = "" - s.SendgridKey = "" + + s.UploadS3AwsSecretAccessKey = strings.Repeat(pwdMask, utf8.RuneCountInString(s.UploadS3AwsSecretAccessKey)) + s.SendgridKey = strings.Repeat(pwdMask, utf8.RuneCountInString(s.SendgridKey)) + s.BouncePostmark.Password = strings.Repeat(pwdMask, utf8.RuneCountInString(s.BouncePostmark.Password)) + s.BounceForwardEmail.Key = strings.Repeat(pwdMask, utf8.RuneCountInString(s.BounceForwardEmail.Key)) + s.SecurityCaptchaSecret = strings.Repeat(pwdMask, utf8.RuneCountInString(s.SecurityCaptchaSecret)) + s.OIDC.ClientSecret = strings.Repeat(pwdMask, utf8.RuneCountInString(s.OIDC.ClientSecret)) return c.JSON(http.StatusOK, okResp{s}) } @@ -138,7 +82,7 @@ func handleGetSettings(c echo.Context) error { func handleUpdateSettings(c echo.Context) error { var ( app = c.Get("app").(*App) - set settings + set models.Settings ) // Unmarshal and marshal the fields once to sanitize the settings blob. @@ -147,11 +91,16 @@ func handleUpdateSettings(c echo.Context) error { } // Get the existing settings. - cur, err := getSettings(app) + cur, err := app.core.GetSettings() if err != nil { return err } + // Validate and sanitize postback Messenger names along with SMTP names + // (where each SMTP is also considered as a standalone messenger). + // Duplicates are disallowed and "email" is a reserved name. + names := map[string]bool{emailMsgr: true} + // There should be at least one SMTP block that's enabled. has := false for i, s := range set.SMTP { @@ -159,7 +108,23 @@ func handleUpdateSettings(c echo.Context) error { has = true } - // Assign a UUID. The frontend only sends a password when the user explictly + // Sanitize and normalize the SMTP server name. + name := reAlphaNum.ReplaceAllString(strings.ToLower(strings.TrimSpace(s.Name)), "-") + if name != "" { + if !strings.HasPrefix(name, "email-") { + name = "email-" + name + } + + if _, ok := names[name]; ok { + return echo.NewHTTPError(http.StatusBadRequest, + app.i18n.Ts("settings.duplicateMessengerName", "name", name)) + } + + names[name] = true + } + set.SMTP[i].Name = name + + // Assign a UUID. The frontend only sends a password when the user explicitly // changes the password. In other cases, the existing password in the DB // is copied while updating the settings and the UUID is used to match // the incoming array of SMTP blocks with the array in the DB. @@ -167,6 +132,10 @@ func handleUpdateSettings(c echo.Context) error { set.SMTP[i].UUID = uuid.Must(uuid.NewV4()).String() } + // Ensure the HOST is trimmed of any whitespace. + // This is a common mistake when copy-pasting SMTP settings. + set.SMTP[i].Host = strings.TrimSpace(s.Host) + // If there's no password coming in from the frontend, copy the existing // password by matching the UUID. if s.Password == "" { @@ -181,9 +150,11 @@ func handleUpdateSettings(c echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("settings.errorNoSMTP")) } + set.AppRootURL = strings.TrimRight(set.AppRootURL, "/") + // Bounce boxes. for i, s := range set.BounceBoxes { - // Assign a UUID. The frontend only sends a password when the user explictly + // Assign a UUID. The frontend only sends a password when the user explicitly // changes the password. In other cases, the existing password in the DB // is copied while updating the settings and the UUID is used to match // the incoming array of blocks with the array in the DB. @@ -191,6 +162,10 @@ func handleUpdateSettings(c echo.Context) error { set.BounceBoxes[i].UUID = uuid.Must(uuid.NewV4()).String() } + // Ensure the HOST is trimmed of any whitespace. + // This is a common mistake when copy-pasting SMTP settings. + set.BounceBoxes[i].Host = strings.TrimSpace(s.Host) + if d, _ := time.ParseDuration(s.ScanInterval); d.Minutes() < 1 { return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("settings.bounces.invalidScanInterval")) } @@ -206,10 +181,6 @@ func handleUpdateSettings(c echo.Context) error { } } - // Validate and sanitize postback Messenger names. Duplicates are disallowed - // and "email" is a reserved name. - names := map[string]bool{emailMsgr: true} - for i, m := range set.Messengers { // UUID to keep track of password changes similar to the SMTP logic above. if m.UUID == "" { @@ -244,19 +215,43 @@ func handleUpdateSettings(c echo.Context) error { if set.SendgridKey == "" { set.SendgridKey = cur.SendgridKey } + if set.BouncePostmark.Password == "" { + set.BouncePostmark.Password = cur.BouncePostmark.Password + } + if set.BounceForwardEmail.Key == "" { + set.BounceForwardEmail.Key = cur.BounceForwardEmail.Key + } + if set.SecurityCaptchaSecret == "" { + set.SecurityCaptchaSecret = cur.SecurityCaptchaSecret + } + if set.OIDC.ClientSecret == "" { + set.OIDC.ClientSecret = cur.OIDC.ClientSecret + } - // Marshal settings. - b, err := json.Marshal(set) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("settings.errorEncoding", "error", err.Error())) + for n, v := range set.UploadExtensions { + set.UploadExtensions[n] = strings.ToLower(strings.TrimPrefix(strings.TrimSpace(v), ".")) + } + + // Domain blocklist. + doms := make([]string, 0) + for _, d := range set.DomainBlocklist { + d = strings.TrimSpace(strings.ToLower(d)) + if d != "" { + doms = append(doms, d) + } + } + set.DomainBlocklist = doms + + // Validate slow query caching cron. + if set.CacheSlowQueries { + if _, err := cron.ParseStandard(set.CacheSlowQueriesInterval); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidData")+": slow query cron: "+err.Error()) + } } // Update the settings in the DB. - if _, err := app.queries.UpdateSettings.Exec(b); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorUpdating", - "name", "{globals.terms.settings}", "error", pqErrMsg(err))) + if err := app.core.UpdateSettings(set); err != nil { + return err } // If there are any active campaigns, don't do an auto reload and @@ -274,7 +269,7 @@ func handleUpdateSettings(c echo.Context) error { // No running campaigns. Reload the app. go func() { <-time.After(time.Millisecond * 500) - app.sigChan <- syscall.SIGHUP + app.chReload <- syscall.SIGHUP }() return c.JSON(http.StatusOK, okResp{true}) @@ -286,23 +281,75 @@ func handleGetLogs(c echo.Context) error { return c.JSON(http.StatusOK, okResp{app.bufLog.Lines()}) } -func getSettings(app *App) (settings, error) { - var ( - b types.JSONText - out settings - ) +// handleTestSMTPSettings returns the log entries stored in the log buffer. +func handleTestSMTPSettings(c echo.Context) error { + app := c.Get("app").(*App) + + // Copy the raw JSON post body. + reqBody, err := io.ReadAll(c.Request().Body) + if err != nil { + app.log.Printf("error reading SMTP test: %v", err) + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.internalError")) + } + + // Load the JSON into koanf to parse SMTP settings properly including timestrings. + ko := koanf.New(".") + if err := ko.Load(rawbytes.Provider(reqBody), json.Parser()); err != nil { + app.log.Printf("error unmarshalling SMTP test request: %v", err) + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.internalError")) + } - if err := app.queries.GetSettings.Get(&b); err != nil { - return out, echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorFetching", - "name", "{globals.terms.settings}", "error", pqErrMsg(err))) + req := email.Server{} + if err := ko.UnmarshalWithConf("", &req, koanf.UnmarshalConf{Tag: "json"}); err != nil { + app.log.Printf("error scanning SMTP test request: %v", err) + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.internalError")) } - // Unmarshall the settings and filter out sensitive fields. - if err := json.Unmarshal([]byte(b), &out); err != nil { - return out, echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("settings.errorEncoding", "error", err.Error())) + to := ko.String("email") + if to == "" { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.missingFields", "name", "email")) } - return out, nil + // Initialize a new SMTP pool. + req.MaxConns = 1 + req.IdleTimeout = time.Second * 2 + req.PoolWaitTimeout = time.Second * 2 + msgr, err := email.New("", req) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, + app.i18n.Ts("globals.messages.errorCreating", "name", "SMTP", "error", err.Error())) + } + + var b bytes.Buffer + if err := app.notifTpls.tpls.ExecuteTemplate(&b, "smtp-test", nil); err != nil { + app.log.Printf("error compiling notification template '%s': %v", "smtp-test", err) + return err + } + + m := models.Message{} + m.ContentType = app.notifTpls.contentType + m.From = app.constants.FromEmail + m.To = []string{to} + m.Subject = app.i18n.T("settings.smtp.testConnection") + m.Body = b.Bytes() + if err := msgr.Push(m); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + + return c.JSON(http.StatusOK, okResp{app.bufLog.Lines()}) +} + +func handleGetAboutInfo(c echo.Context) error { + var ( + app = c.Get("app").(*App) + mem runtime.MemStats + ) + + runtime.ReadMemStats(&mem) + + out := app.about + out.System.AllocMB = mem.Alloc / 1024 / 1024 + out.System.OSMB = mem.Sys / 1024 / 1024 + + return c.JSON(http.StatusOK, out) } diff --git a/cmd/subscribers.go b/cmd/subscribers.go index a4a628857..d15dd2b7a 100644 --- a/cmd/subscribers.go +++ b/cmd/subscribers.go @@ -1,22 +1,20 @@ package main import ( - "context" - "database/sql" "encoding/csv" "encoding/json" "errors" "fmt" "net/http" + "net/textproto" "net/url" "strconv" "strings" - "github.com/gofrs/uuid" + "github.com/knadh/listmonk/internal/auth" "github.com/knadh/listmonk/internal/subimporter" "github.com/knadh/listmonk/models" - "github.com/labstack/echo" - "github.com/lib/pq" + "github.com/labstack/echo/v4" ) const ( @@ -26,28 +24,14 @@ const ( // subQueryReq is a "catch all" struct for reading various // subscriber related requests. type subQueryReq struct { - Query string `json:"query"` - ListIDs pq.Int64Array `json:"list_ids"` - TargetListIDs pq.Int64Array `json:"target_list_ids"` - SubscriberIDs pq.Int64Array `json:"ids"` - Action string `json:"action"` -} - -type subsWrap struct { - Results models.Subscribers `json:"results"` - - Query string `json:"query"` - Total int `json:"total"` - PerPage int `json:"per_page"` - Page int `json:"page"` -} - -type subUpdateReq struct { - models.Subscriber - RawAttribs json.RawMessage `json:"attribs"` - Lists pq.Int64Array `json:"lists"` - ListUUIDs pq.StringArray `json:"list_uuids"` - PreconfirmSubs bool `json:"preconfirm_subscriptions"` + Query string `json:"query"` + ListIDs []int `json:"list_ids"` + TargetListIDs []int `json:"target_list_ids"` + SubscriberIDs []int `json:"ids"` + Action string `json:"action"` + Status string `json:"status"` + SubscriptionStatus string `json:"subscription_status"` + All bool `json:"all"` } // subProfileData represents a subscriber's collated data in JSON @@ -62,17 +46,19 @@ type subProfileData struct { // subOptin contains the data that's passed to the double opt-in e-mail template. type subOptin struct { - *models.Subscriber + models.Subscriber OptinURL string + UnsubURL string Lists []models.List } var ( dummySubscriber = models.Subscriber{ - Email: "demo@listmonk.app", - Name: "Demo Subscriber", - UUID: dummyUUID, + Email: "demo@listmonk.app", + Name: "Demo Subscriber", + UUID: dummyUUID, + Attribs: models.JSON{"city": "Bengaluru"}, } subQuerySortFields = []string{"email", "name", "created_at", "updated_at"} @@ -85,91 +71,54 @@ func handleGetSubscriber(c echo.Context) error { var ( app = c.Get("app").(*App) id, _ = strconv.Atoi(c.Param("id")) + user = c.Get(auth.UserKey).(models.User) ) if id < 1 { return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) } - sub, err := getSubscriber(id, "", "", app) + if err := hasSubPerm(user, []int{id}, app); err != nil { + return err + } + + out, err := app.core.GetSubscriber(id, "", "") if err != nil { return err } - return c.JSON(http.StatusOK, okResp{sub}) + return c.JSON(http.StatusOK, okResp{out}) } // handleQuerySubscribers handles querying subscribers based on an arbitrary SQL expression. func handleQuerySubscribers(c echo.Context) error { var ( - app = c.Get("app").(*App) - pg = getPagination(c.QueryParams(), 30) - - // Limit the subscribers to a particular list? - listID, _ = strconv.Atoi(c.FormValue("list_id")) + app = c.Get("app").(*App) + user = c.Get(auth.UserKey).(models.User) + pg = app.paginator.NewFromURL(c.Request().URL.Query()) // The "WHERE ?" bit. - query = sanitizeSQLExp(c.FormValue("query")) - orderBy = c.FormValue("order_by") - order = c.FormValue("order") - out subsWrap + query = sanitizeSQLExp(c.FormValue("query")) + subStatus = c.FormValue("subscription_status") + orderBy = c.FormValue("order_by") + order = c.FormValue("order") + out models.PageResults ) - listIDs := pq.Int64Array{} - if listID < 0 { - return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.errorID")) - } else if listID > 0 { - listIDs = append(listIDs, int64(listID)) - } - - // There's an arbitrary query condition. - cond := "" - if query != "" { - cond = " AND " + query - } - - // Sort params. - if !strSliceContains(orderBy, subQuerySortFields) { - orderBy = "updated_at" - } - if order != sortAsc && order != sortDesc { - order = sortAsc - } - - stmt := fmt.Sprintf(app.queries.QuerySubscribers, cond, orderBy, order) - - // Create a readonly transaction to prevent mutations. - tx, err := app.db.BeginTxx(context.Background(), &sql.TxOptions{ReadOnly: true}) + // Filter list IDs by permission. + listIDs, err := filterListQeryByPerm(c.QueryParams(), user, app) if err != nil { - app.log.Printf("error preparing subscriber query: %v", err) - return echo.NewHTTPError(http.StatusBadRequest, - app.i18n.Ts("subscribers.errorPreparingQuery", "error", pqErrMsg(err))) - } - defer tx.Rollback() - - // Run the query. stmt is the raw SQL query. - if err := tx.Select(&out.Results, stmt, listIDs, pg.Offset, pg.Limit); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorFetching", - "name", "{globals.terms.subscribers}", "error", pqErrMsg(err))) + return err } - // Lazy load lists for each subscriber. - if err := out.Results.LoadLists(app.queries.GetSubscriberListsLazy); err != nil { - app.log.Printf("error fetching subscriber lists: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorFetching", - "name", "{globals.terms.subscribers}", "error", pqErrMsg(err))) + res, total, err := app.core.QuerySubscribers(query, listIDs, subStatus, order, orderBy, pg.Offset, pg.Limit) + if err != nil { + return err } out.Query = query - if len(out.Results) == 0 { - out.Results = make(models.Subscribers, 0) - return c.JSON(http.StatusOK, okResp{out}) - } - - // Meta. - out.Total = out.Results[0].Total + out.Results = res + out.Total = total out.Page = pg.Page out.PerPage = pg.PerPage @@ -179,57 +128,35 @@ func handleQuerySubscribers(c echo.Context) error { // handleExportSubscribers handles querying subscribers based on an arbitrary SQL expression. func handleExportSubscribers(c echo.Context) error { var ( - app = c.Get("app").(*App) - - // Limit the subscribers to a particular list? - listID, _ = strconv.Atoi(c.FormValue("list_id")) + app = c.Get("app").(*App) + user = c.Get(auth.UserKey).(models.User) // The "WHERE ?" bit. query = sanitizeSQLExp(c.FormValue("query")) ) - listIDs := pq.Int64Array{} - if listID < 0 { - return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.errorID")) - } else if listID > 0 { - listIDs = append(listIDs, int64(listID)) + // Filter list IDs by permission. + listIDs, err := filterListQeryByPerm(c.QueryParams(), user, app) + if err != nil { + return err } - // There's an arbitrary query condition. - cond := "" - if query != "" { - cond = " AND " + query + // Export only specific subscriber IDs? + subIDs, err := getQueryInts("id", c.QueryParams()) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) } - stmt := fmt.Sprintf(app.queries.QuerySubscribersForExport, cond) + // Filter by subscription status + subStatus := c.QueryParam("subscription_status") - // Verify that the arbitrary SQL search expression is read only. - if cond != "" { - tx, err := app.db.Unsafe().BeginTxx(context.Background(), &sql.TxOptions{ReadOnly: true}) - if err != nil { - app.log.Printf("error preparing subscriber query: %v", err) - return echo.NewHTTPError(http.StatusBadRequest, - app.i18n.Ts("subscribers.errorPreparingQuery", "error", pqErrMsg(err))) - } - defer tx.Rollback() - - if _, err := tx.Query(stmt, nil, 0, 1); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, - app.i18n.Ts("subscribers.errorPreparingQuery", "error", pqErrMsg(err))) - } - } - - // Prepare the actual query statement. - tx, err := db.Preparex(stmt) + // Get the batched export iterator. + exp, err := app.core.ExportSubscribers(query, subIDs, listIDs, subStatus, app.constants.DBBatchSize) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, - app.i18n.Ts("subscribers.errorPreparingQuery", "error", pqErrMsg(err))) + return err } - // Run the query until all rows are exhausted. var ( - id = 0 - h = c.Response().Header() wr = csv.NewWriter(c.Response()) ) @@ -242,15 +169,14 @@ func handleExportSubscribers(c echo.Context) error { wr.Write([]string{"uuid", "email", "name", "attributes", "status", "created_at", "updated_at"}) loop: + // Iterate in batches until there are no more subscribers to export. for { - var out []models.SubscriberExport - if err := tx.Select(&out, listIDs, id, app.constants.DBBatchSize); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorFetching", - "name", "{globals.terms.subscribers}", "error", pqErrMsg(err))) + out, err := exp() + if err != nil { + return err } - if len(out) == 0 { - break loop + if out == nil || len(out) == 0 { + break } for _, r := range out { @@ -260,9 +186,9 @@ loop: break loop } } - wr.Flush() - id = out[len(out)-1].ID + // Flush CSV to stream after each batch. + wr.Flush() } return nil @@ -271,7 +197,9 @@ loop: // handleCreateSubscriber handles the creation of a new subscriber. func handleCreateSubscriber(c echo.Context) error { var ( - app = c.Get("app").(*App) + app = c.Get("app").(*App) + user = c.Get(auth.UserKey).(models.User) + req subimporter.SubReq ) @@ -279,19 +207,21 @@ func handleCreateSubscriber(c echo.Context) error { if err := c.Bind(&req); err != nil { return err } - req.Email = strings.ToLower(strings.TrimSpace(req.Email)) - if err := subimporter.ValidateFields(req); err != nil { + + // Validate fields. + req, err := app.importer.ValidateFields(req) + if err != nil { return echo.NewHTTPError(http.StatusBadRequest, err.Error()) } + // Filter lists against the current user's permitted lists. + listIDs := user.FilterListsByPerm(req.Lists, false, true) + // Insert the subscriber into the DB. - sub, isNew, _, err := insertSubscriber(req, app) + sub, _, err := app.core.InsertSubscriber(req.Subscriber, listIDs, nil, req.PreconfirmSubs) if err != nil { return err } - if !isNew { - return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.emailExists")) - } return c.JSON(http.StatusOK, okResp{sub}) } @@ -299,10 +229,17 @@ func handleCreateSubscriber(c echo.Context) error { // handleUpdateSubscriber handles modification of a subscriber. func handleUpdateSubscriber(c echo.Context) error { var ( - app = c.Get("app").(*App) - id, _ = strconv.ParseInt(c.Param("id"), 10, 64) - req subUpdateReq + app = c.Get("app").(*App) + user = c.Get(auth.UserKey).(models.User) + + id, _ = strconv.Atoi(c.Param("id")) + req struct { + models.Subscriber + Lists []int `json:"lists"` + PreconfirmSubs bool `json:"preconfirm_subscriptions"` + } ) + // Get and validate fields. if err := c.Bind(&req); err != nil { return err @@ -311,56 +248,29 @@ func handleUpdateSubscriber(c echo.Context) error { if id < 1 { return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) } - if req.Email != "" && !subimporter.IsEmail(req.Email) { - return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.invalidEmail")) - } - if req.Name != "" && !strHasLen(req.Name, 1, stdInputMaxLen) { - return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.invalidName")) - } - // If there's an attribs value, validate it. - if len(req.RawAttribs) > 0 { - var a models.SubscriberAttribs - if err := json.Unmarshal(req.RawAttribs, &a); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorUpdating", - "name", "{globals.terms.subscriber}", "error", err.Error())) - } + if em, err := app.importer.SanitizeEmail(req.Email); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } else { + req.Email = em } - subStatus := models.SubscriptionStatusUnconfirmed - if req.PreconfirmSubs { - subStatus = models.SubscriptionStatusConfirmed + if req.Name != "" && !strHasLen(req.Name, 1, stdInputMaxLen) { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.invalidName")) } - _, err := app.queries.UpdateSubscriber.Exec(id, - strings.ToLower(strings.TrimSpace(req.Email)), - strings.TrimSpace(req.Name), - req.Status, - req.RawAttribs, - req.Lists, - subStatus) - if err != nil { - app.log.Printf("error updating subscriber: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorUpdating", - "name", "{globals.terms.subscriber}", "error", pqErrMsg(err))) - } + // Filter lists against the current user's permitted lists. + listIDs := user.FilterListsByPerm(req.Lists, false, true) - // Send a confirmation e-mail (if there are any double opt-in lists). - sub, err := getSubscriber(int(id), "", "", app) + out, _, err := app.core.UpdateSubscriberWithLists(id, req.Subscriber, listIDs, nil, req.PreconfirmSubs, true) if err != nil { return err } - if !req.PreconfirmSubs { - _, _ = sendOptinConfirmation(sub, []int64(req.Lists), app) - } - - return c.JSON(http.StatusOK, okResp{sub}) + return c.JSON(http.StatusOK, okResp{out}) } -// handleGetSubscriberSendOptin sends an optin confirmation e-mail to a subscriber. +// handleSubscriberSendOptin sends an optin confirmation e-mail to a subscriber. func handleSubscriberSendOptin(c echo.Context) error { var ( app = c.Get("app").(*App) @@ -372,17 +282,13 @@ func handleSubscriberSendOptin(c echo.Context) error { } // Fetch the subscriber. - out, err := getSubscriber(id, "", "", app) + out, err := app.core.GetSubscriber(id, "", "") if err != nil { - app.log.Printf("error fetching subscriber: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorFetching", - "name", "{globals.terms.subscribers}", "error", pqErrMsg(err))) + return err } - if _, err := sendOptinConfirmation(out, nil, app); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.T("subscribers.errorSendingOptin")) + if _, err := sendOptinConfirmationHook(app)(out, nil); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, app.i18n.T("subscribers.errorSendingOptin")) } return c.JSON(http.StatusOK, okResp{true}) @@ -392,36 +298,36 @@ func handleSubscriberSendOptin(c echo.Context) error { // It takes either an ID in the URI, or a list of IDs in the request body. func handleBlocklistSubscribers(c echo.Context) error { var ( - app = c.Get("app").(*App) - pID = c.Param("id") - IDs pq.Int64Array + app = c.Get("app").(*App) + pID = c.Param("id") + subIDs []int ) // Is it a /:id call? if pID != "" { - id, _ := strconv.ParseInt(pID, 10, 64) + id, _ := strconv.Atoi(pID) if id < 1 { return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) } - IDs = append(IDs, id) + + subIDs = append(subIDs, id) } else { // Multiple IDs. var req subQueryReq if err := c.Bind(&req); err != nil { return echo.NewHTTPError(http.StatusBadRequest, - app.i18n.Ts("subscribers.errorInvalidIDs", "error", err.Error())) + app.i18n.Ts("globals.messages.errorInvalidIDs", "error", err.Error())) } if len(req.SubscriberIDs) == 0 { return echo.NewHTTPError(http.StatusBadRequest, "No IDs given.") } - IDs = req.SubscriberIDs + + subIDs = req.SubscriberIDs } - if _, err := app.queries.BlocklistSubscribers.Exec(IDs); err != nil { - app.log.Printf("error blocklisting subscribers: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("subscribers.errorBlocklisting", "error", err.Error())) + if err := app.core.BlocklistSubscribers(subIDs); err != nil { + return err } return c.JSON(http.StatusOK, okResp{true}) @@ -432,53 +338,55 @@ func handleBlocklistSubscribers(c echo.Context) error { // It takes either an ID in the URI, or a list of IDs in the request body. func handleManageSubscriberLists(c echo.Context) error { var ( - app = c.Get("app").(*App) - pID = c.Param("id") - IDs pq.Int64Array + app = c.Get("app").(*App) + user = c.Get(auth.UserKey).(models.User) + + pID = c.Param("id") + subIDs []int ) - // Is it a /:id call? + // Is it an /:id call? if pID != "" { - id, _ := strconv.ParseInt(pID, 10, 64) + id, _ := strconv.Atoi(pID) if id < 1 { return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) } - IDs = append(IDs, id) + subIDs = append(subIDs, id) } var req subQueryReq if err := c.Bind(&req); err != nil { return echo.NewHTTPError(http.StatusBadRequest, - app.i18n.Ts("subscribers.errorInvalidIDs", "error", err.Error())) + app.i18n.Ts("globals.messages.errorInvalidIDs", "error", err.Error())) } if len(req.SubscriberIDs) == 0 { return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.errorNoIDs")) } - if len(IDs) == 0 { - IDs = req.SubscriberIDs + if len(subIDs) == 0 { + subIDs = req.SubscriberIDs } if len(req.TargetListIDs) == 0 { return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.errorNoListsGiven")) } + // Filter lists against the current user's permitted lists. + listIDs := user.FilterListsByPerm(req.TargetListIDs, false, true) + // Action. var err error switch req.Action { case "add": - _, err = app.queries.AddSubscribersToLists.Exec(IDs, req.TargetListIDs) + err = app.core.AddSubscriptions(subIDs, listIDs, req.Status) case "remove": - _, err = app.queries.DeleteSubscriptions.Exec(IDs, req.TargetListIDs) + err = app.core.DeleteSubscriptions(subIDs, listIDs) case "unsubscribe": - _, err = app.queries.UnsubscribeSubscribersFromLists.Exec(IDs, req.TargetListIDs) + err = app.core.UnsubscribeLists(subIDs, listIDs, nil) default: return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.invalidAction")) } if err != nil { - app.log.Printf("error updating subscriptions: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorUpdating", - "name", "{globals.terms.subscribers}", "error", err.Error())) + return err } return c.JSON(http.StatusOK, okResp{true}) @@ -488,37 +396,34 @@ func handleManageSubscriberLists(c echo.Context) error { // It takes either an ID in the URI, or a list of IDs in the request body. func handleDeleteSubscribers(c echo.Context) error { var ( - app = c.Get("app").(*App) - pID = c.Param("id") - IDs pq.Int64Array + app = c.Get("app").(*App) + pID = c.Param("id") + subIDs []int ) // Is it an /:id call? if pID != "" { - id, _ := strconv.ParseInt(pID, 10, 64) + id, _ := strconv.Atoi(pID) if id < 1 { return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) } - IDs = append(IDs, id) + subIDs = append(subIDs, id) } else { // Multiple IDs. i, err := parseStringIDs(c.Request().URL.Query()["id"]) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, - app.i18n.Ts("subscribers.errorInvalidIDs", "error", err.Error())) + app.i18n.Ts("globals.messages.errorInvalidIDs", "error", err.Error())) } if len(i) == 0 { return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("subscribers.errorNoIDs", "error", err.Error())) } - IDs = i + subIDs = i } - if _, err := app.queries.DeleteSubscribers.Exec(IDs, nil); err != nil { - app.log.Printf("error deleting subscribers: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorDeleting", - "name", "{globals.terms.subscribers}", "error", pqErrMsg(err))) + if err := app.core.DeleteSubscribers(subIDs, nil); err != nil { + return err } return c.JSON(http.StatusOK, okResp{true}) @@ -536,14 +441,14 @@ func handleDeleteSubscribersByQuery(c echo.Context) error { return err } - err := app.queries.execSubscriberQueryTpl(sanitizeSQLExp(req.Query), - app.queries.DeleteSubscribersByQuery, - req.ListIDs, app.db) - if err != nil { - app.log.Printf("error deleting subscribers: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorDeleting", - "name", "{globals.terms.subscribers}", "error", pqErrMsg(err))) + if req.All { + req.Query = "" + } else if req.Query == "" { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "query")) + } + + if err := app.core.DeleteSubscribersByQuery(req.Query, req.ListIDs, req.SubscriptionStatus); err != nil { + return err } return c.JSON(http.StatusOK, okResp{true}) @@ -561,23 +466,24 @@ func handleBlocklistSubscribersByQuery(c echo.Context) error { return err } - err := app.queries.execSubscriberQueryTpl(sanitizeSQLExp(req.Query), - app.queries.BlocklistSubscribersByQuery, - req.ListIDs, app.db) - if err != nil { - app.log.Printf("error blocklisting subscribers: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("subscribers.errorBlocklisting", "error", pqErrMsg(err))) + if req.Query == "" { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "query")) + } + + if err := app.core.BlocklistSubscribersByQuery(req.Query, req.ListIDs, req.SubscriptionStatus); err != nil { + return err } return c.JSON(http.StatusOK, okResp{true}) } -// handleManageSubscriberListsByQuery bulk adds/removes/unsubscribers subscribers +// handleManageSubscriberListsByQuery bulk adds/removes/unsubscribes subscribers // from one or more lists based on an arbitrary SQL expression. func handleManageSubscriberListsByQuery(c echo.Context) error { var ( - app = c.Get("app").(*App) + app = c.Get("app").(*App) + user = c.Get(auth.UserKey).(models.User) + req subQueryReq ) @@ -589,26 +495,25 @@ func handleManageSubscriberListsByQuery(c echo.Context) error { app.i18n.T("subscribers.errorNoListsGiven")) } + // Filter lists against the current user's permitted lists. + sourceListIDs := user.FilterListsByPerm(req.ListIDs, false, true) + targetListIDs := user.FilterListsByPerm(req.TargetListIDs, false, true) + // Action. - var stmt string + var err error switch req.Action { case "add": - stmt = app.queries.AddSubscribersToListsByQuery + err = app.core.AddSubscriptionsByQuery(req.Query, sourceListIDs, targetListIDs, req.Status, req.SubscriptionStatus) case "remove": - stmt = app.queries.DeleteSubscriptionsByQuery + err = app.core.DeleteSubscriptionsByQuery(req.Query, sourceListIDs, targetListIDs, req.SubscriptionStatus) case "unsubscribe": - stmt = app.queries.UnsubscribeSubscribersFromListsByQuery + err = app.core.UnsubscribeListsByQuery(req.Query, sourceListIDs, targetListIDs, req.SubscriptionStatus) default: return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.invalidAction")) } - err := app.queries.execSubscriberQueryTpl(sanitizeSQLExp(req.Query), - stmt, req.ListIDs, app.db, req.TargetListIDs) if err != nil { - app.log.Printf("error updating subscriptions: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorUpdating", - "name", "{globals.terms.subscribers}", "error", pqErrMsg(err))) + return err } return c.JSON(http.StatusOK, okResp{true}) @@ -621,16 +526,13 @@ func handleDeleteSubscriberBounces(c echo.Context) error { pID = c.Param("id") ) - id, _ := strconv.ParseInt(pID, 10, 64) + id, _ := strconv.Atoi(pID) if id < 1 { return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) } - if _, err := app.queries.DeleteBouncesBySubscriber.Exec(id, nil); err != nil { - app.log.Printf("error deleting bounces: %v", err) - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorDeleting", - "name", "{globals.terms.bounces}", "error", pqErrMsg(err))) + if err := app.core.DeleteSubscriberBounces(id, ""); err != nil { + return err } return c.JSON(http.StatusOK, okResp{true}) @@ -645,7 +547,8 @@ func handleExportSubscriberData(c echo.Context) error { app = c.Get("app").(*App) pID = c.Param("id") ) - id, _ := strconv.ParseInt(pID, 10, 64) + + id, _ := strconv.Atoi(pID) if id < 1 { return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) } @@ -666,102 +569,13 @@ func handleExportSubscriberData(c echo.Context) error { return c.Blob(http.StatusOK, "application/json", b) } -// insertSubscriber inserts a subscriber and returns the ID. The first bool indicates if -// it was a new subscriber, and the second bool indicates if the subscriber was sent an optin confirmation. -func insertSubscriber(req subimporter.SubReq, app *App) (models.Subscriber, bool, bool, error) { - uu, err := uuid.NewV4() - if err != nil { - return req.Subscriber, false, false, err - } - req.UUID = uu.String() - - var ( - isNew = true - subStatus = models.SubscriptionStatusUnconfirmed - ) - if req.PreconfirmSubs { - subStatus = models.SubscriptionStatusConfirmed - } - - if err = app.queries.InsertSubscriber.Get(&req.ID, - req.UUID, - req.Email, - strings.TrimSpace(req.Name), - req.Status, - req.Attribs, - req.Lists, - req.ListUUIDs, - subStatus); err != nil { - if pqErr, ok := err.(*pq.Error); ok && pqErr.Constraint == "subscribers_email_key" { - isNew = false - } else { - // return req.Subscriber, errSubscriberExists - app.log.Printf("error inserting subscriber: %v", err) - return req.Subscriber, false, false, echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorCreating", - "name", "{globals.terms.subscriber}", "error", pqErrMsg(err))) - } - } - - // Fetch the subscriber's full data. If the subscriber already existed and wasn't - // created, the id will be empty. Fetch the details by e-mail then. - sub, err := getSubscriber(req.ID, "", strings.ToLower(req.Email), app) - if err != nil { - return sub, false, false, err - } - - hasOptin := false - if !req.PreconfirmSubs { - // Send a confirmation e-mail (if there are any double opt-in lists). - num, _ := sendOptinConfirmation(sub, []int64(req.Lists), app) - hasOptin = num > 0 - } - return sub, isNew, hasOptin, nil -} - -// getSubscriber gets a single subscriber by ID, uuid, or e-mail in that order. -// Only one of these params should have a value. -func getSubscriber(id int, uuid, email string, app *App) (models.Subscriber, error) { - var out models.Subscribers - - if err := app.queries.GetSubscriber.Select(&out, id, uuid, email); err != nil { - app.log.Printf("error fetching subscriber: %v", err) - return models.Subscriber{}, echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorFetching", - "name", "{globals.terms.subscriber}", "error", pqErrMsg(err))) - } - if len(out) == 0 { - return models.Subscriber{}, echo.NewHTTPError(http.StatusBadRequest, - app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.subscriber}")) - } - if err := out.LoadLists(app.queries.GetSubscriberListsLazy); err != nil { - app.log.Printf("error loading subscriber lists: %v", err) - return models.Subscriber{}, echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorFetching", - "name", "{globals.terms.lists}", "error", pqErrMsg(err))) - } - - return out[0], nil -} - // exportSubscriberData collates the data of a subscriber including profile, // subscriptions, campaign_views, link_clicks (if they're enabled in the config) // and returns a formatted, indented JSON payload. Either takes a numeric id // and an empty subUUID or takes 0 and a string subUUID. -func exportSubscriberData(id int64, subUUID string, exportables map[string]bool, app *App) (subProfileData, []byte, error) { - // Get the subscriber's data. A single query that gets the profile, - // list subscriptions, campaign views, and link clicks. Names of - // private lists are replaced with "Private list". - var ( - data subProfileData - uu interface{} - ) - // UUID should be a valid value or a nil. - if subUUID != "" { - uu = subUUID - } - if err := app.queries.ExportSubscriberData.Get(&data, id, uu); err != nil { - app.log.Printf("error fetching subscriber export data: %v", err) +func exportSubscriberData(id int, subUUID string, exportables map[string]bool, app *App) (models.SubscriberExportProfile, []byte, error) { + data, err := app.core.GetSubscriberProfileForExport(id, subUUID) + if err != nil { return data, nil, err } @@ -785,45 +599,8 @@ func exportSubscriberData(id int64, subUUID string, exportables map[string]bool, app.log.Printf("error marshalling subscriber export data: %v", err) return data, nil, err } - return data, b, nil -} -// sendOptinConfirmation sends a double opt-in confirmation e-mail to a subscriber -// if at least one of the given listIDs is set to optin=double. It returns the number of -// opt-in lists that were found. -func sendOptinConfirmation(sub models.Subscriber, listIDs []int64, app *App) (int, error) { - var lists []models.List - - // Fetch double opt-in lists from the given list IDs. - // Get the list of subscription lists where the subscriber hasn't confirmed. - if err := app.queries.GetSubscriberLists.Select(&lists, sub.ID, nil, - pq.Int64Array(listIDs), nil, models.SubscriptionStatusUnconfirmed, models.ListOptinDouble); err != nil { - app.log.Printf("error fetching lists for opt-in: %s", pqErrMsg(err)) - return 0, err - } - - // None. - if len(lists) == 0 { - return 0, nil - } - - var ( - out = subOptin{Subscriber: &sub, Lists: lists} - qListIDs = url.Values{} - ) - // Construct the opt-in URL with list IDs. - for _, l := range out.Lists { - qListIDs.Add("l", l.UUID) - } - out.OptinURL = fmt.Sprintf(app.constants.OptinURL, sub.UUID, qListIDs.Encode()) - - // Send the e-mail. - if err := app.sendNotification([]string{sub.Email}, - app.i18n.T("subscribers.optinSubject"), notifSubscriberOptin, out); err != nil { - app.log.Printf("error sending opt-in e-mail: %s", err) - return 0, err - } - return len(lists), nil + return data, b, nil } // sanitizeSQLExp does basic sanitisation on arbitrary @@ -840,3 +617,124 @@ func sanitizeSQLExp(q string) string { } return q } + +func getQueryInts(param string, qp url.Values) ([]int, error) { + var out []int + if vals, ok := qp[param]; ok { + for _, v := range vals { + if v == "" { + continue + } + + listID, err := strconv.Atoi(v) + if err != nil { + return nil, err + } + out = append(out, listID) + } + } + + return out, nil +} + +// sendOptinConfirmationHook returns an enclosed callback that sends optin confirmation e-mails. +// This is plugged into the 'core' package to send optin confirmations when a new subscriber is +// created via `core.CreateSubscriber()`. +func sendOptinConfirmationHook(app *App) func(sub models.Subscriber, listIDs []int) (int, error) { + return func(sub models.Subscriber, listIDs []int) (int, error) { + lists, err := app.core.GetSubscriberLists(sub.ID, "", listIDs, nil, models.SubscriptionStatusUnconfirmed, models.ListOptinDouble) + if err != nil { + return 0, err + } + + // None. + if len(lists) == 0 { + return 0, nil + } + + var ( + out = subOptin{Subscriber: sub, Lists: lists} + qListIDs = url.Values{} + ) + + // Construct the opt-in URL with list IDs. + for _, l := range out.Lists { + qListIDs.Add("l", l.UUID) + } + out.OptinURL = fmt.Sprintf(app.constants.OptinURL, sub.UUID, qListIDs.Encode()) + out.UnsubURL = fmt.Sprintf(app.constants.UnsubURL, dummyUUID, sub.UUID) + + // Unsub headers. + h := textproto.MIMEHeader{} + h.Set(models.EmailHeaderSubscriberUUID, sub.UUID) + + // Attach List-Unsubscribe headers? + if app.constants.Privacy.UnsubHeader { + unsubURL := fmt.Sprintf(app.constants.UnsubURL, dummyUUID, sub.UUID) + h.Set("List-Unsubscribe-Post", "List-Unsubscribe=One-Click") + h.Set("List-Unsubscribe", `<`+unsubURL+`>`) + } + + // Send the e-mail. + if err := app.sendNotification([]string{sub.Email}, app.i18n.T("subscribers.optinSubject"), notifSubscriberOptin, out, h); err != nil { + app.log.Printf("error sending opt-in e-mail for subscriber %d (%s): %s", sub.ID, sub.UUID, err) + return 0, err + } + + return len(lists), nil + } +} + +// hasSubPerm checks whether the current user has permission to access the given list +// of subscriber IDs. +func hasSubPerm(u models.User, subIDs []int, app *App) error { + if u.UserRoleID == auth.SuperAdminRoleID { + return nil + } + + if _, ok := u.PermissionsMap[models.PermSubscribersGetAll]; ok { + return nil + } + + res, err := app.core.HasSubscriberLists(subIDs, u.GetListIDs) + if err != nil { + return err + } + + for id, has := range res { + if !has { + return echo.NewHTTPError(http.StatusForbidden, app.i18n.Ts("globals.messages.permissionDenied", "name", fmt.Sprintf("subscriber: %d", id))) + } + } + + return nil +} + +func filterListQeryByPerm(qp url.Values, user models.User, app *App) ([]int, error) { + var listIDs []int + + // If there are incoming list query params, filter them by permission. + if qp.Has("list_id") { + ids, err := getQueryInts("list_id", qp) + if err != nil { + return nil, echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) + } + + listIDs = user.FilterListsByPerm(ids, true, true) + } + + // There are no incoming params. If the user doesn't have permission to get all subscribers, + // filter by the lists they have access to. + if len(listIDs) == 0 { + if _, ok := user.PermissionsMap[models.PermSubscribersGetAll]; !ok { + if len(user.GetListIDs) > 0 { + listIDs = user.GetListIDs + } else { + // User doesn't have access to any lists. + listIDs = []int{-1} + } + } + } + + return listIDs, nil +} diff --git a/cmd/templates.go b/cmd/templates.go index 7ed014534..392e2471a 100644 --- a/cmd/templates.go +++ b/cmd/templates.go @@ -2,13 +2,14 @@ package main import ( "errors" - "fmt" + "html/template" "net/http" "regexp" "strconv" + "strings" "github.com/knadh/listmonk/models" - "github.com/labstack/echo" + "github.com/labstack/echo/v4" ) const ( @@ -34,33 +35,24 @@ var ( func handleGetTemplates(c echo.Context) error { var ( app = c.Get("app").(*App) - out []models.Template id, _ = strconv.Atoi(c.Param("id")) - single = false noBody, _ = strconv.ParseBool(c.QueryParam("no_body")) ) // Fetch one list. if id > 0 { - single = true - } + out, err := app.core.GetTemplate(id, noBody) + if err != nil { + return err + } - err := app.queries.GetTemplates.Select(&out, id, noBody) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorFetching", - "name", "{globals.terms.templates}", "error", pqErrMsg(err))) - } - if single && len(out) == 0 { - return echo.NewHTTPError(http.StatusBadRequest, - app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.template}")) + return c.JSON(http.StatusOK, okResp{out}) } - if len(out) == 0 { - return c.JSON(http.StatusOK, okResp{[]struct{}{}}) - } else if single { - return c.JSON(http.StatusOK, okResp{out[0]}) + out, err := app.core.GetTemplates("", noBody) + if err != nil { + return err } return c.JSON(http.StatusOK, okResp{out}) @@ -71,58 +63,79 @@ func handlePreviewTemplate(c echo.Context) error { var ( app = c.Get("app").(*App) id, _ = strconv.Atoi(c.Param("id")) - body = c.FormValue("body") - - tpls []models.Template ) - if body != "" { - if !regexpTplTag.MatchString(body) { + tpl := models.Template{ + Type: c.FormValue("template_type"), + Body: c.FormValue("body"), + } + + // Body is posted. + if tpl.Body != "" { + if tpl.Type == "" { + tpl.Type = models.TemplateTypeCampaign + } + + if tpl.Type == models.TemplateTypeCampaign && !regexpTplTag.MatchString(tpl.Body) { return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("templates.placeholderHelp", "placeholder", tplTag)) } } else { + // There is no body. Fetch the template. if id < 1 { return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) } - err := app.queries.GetTemplates.Select(&tpls, id, false) + t, err := app.core.GetTemplate(id, false) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorFetching", - "name", "{globals.terms.templates}", "error", pqErrMsg(err))) + return err + } + + tpl = t + } + + // Compile the campaign template. + var out []byte + if tpl.Type == models.TemplateTypeCampaign { + camp := models.Campaign{ + UUID: dummyUUID, + Name: app.i18n.T("templates.dummyName"), + Subject: app.i18n.T("templates.dummySubject"), + FromEmail: "dummy-campaign@listmonk.app", + TemplateBody: tpl.Body, + Body: dummyTpl, } - if len(tpls) == 0 { + if err := camp.CompileTemplate(app.manager.TemplateFuncs(&camp)); err != nil { return echo.NewHTTPError(http.StatusBadRequest, - app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.template}")) + app.i18n.Ts("templates.errorCompiling", "error", err.Error())) } - body = tpls[0].Body - } - // Compile the template. - camp := models.Campaign{ - UUID: dummyUUID, - Name: app.i18n.T("templates.dummyName"), - Subject: app.i18n.T("templates.dummySubject"), - FromEmail: "dummy-campaign@listmonk.app", - TemplateBody: body, - Body: dummyTpl, - } + // Render the message body. + msg, err := app.manager.NewCampaignMessage(&camp, dummySubscriber) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, + app.i18n.Ts("templates.errorRendering", "error", err.Error())) + } + out = msg.Body() + } else { + // Compile transactional template. + if err := tpl.Compile(app.manager.GenericTemplateFuncs()); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } - if err := camp.CompileTemplate(app.manager.TemplateFuncs(&camp)); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, - app.i18n.Ts("templates.errorCompiling", "error", err.Error())) - } + m := models.TxMessage{ + Subject: tpl.Subject, + } - // Render the message body. - msg, err := app.manager.NewCampaignMessage(&camp, dummySubscriber) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, - app.i18n.Ts("templates.errorRendering", "error", err.Error())) + // Render the message. + if err := m.Render(dummySubscriber, &tpl); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + out = m.Body } - return c.HTML(http.StatusOK, string(msg.Body())) + return c.HTML(http.StatusOK, string(out)) } // handleCreateTemplate handles template creation. @@ -137,23 +150,38 @@ func handleCreateTemplate(c echo.Context) error { } if err := validateTemplate(o, app); err != nil { + return err + } + + var f template.FuncMap + + // Subject is only relevant for fixed tx templates. For campaigns, + // the subject changes per campaign and is on models.Campaign. + if o.Type == models.TemplateTypeCampaign { + o.Subject = "" + f = app.manager.TemplateFuncs(nil) + } else { + f = app.manager.GenericTemplateFuncs() + } + + // Compile the template and validate. + if err := o.Compile(f); err != nil { return echo.NewHTTPError(http.StatusBadRequest, err.Error()) } - // Insert and read ID. - var newID int - if err := app.queries.CreateTemplate.Get(&newID, - o.Name, - o.Body); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorCreating", - "name", "{globals.terms.template}", "error", pqErrMsg(err))) + // Create the template the in the DB. + out, err := app.core.CreateTemplate(o.Name, o.Type, o.Subject, []byte(o.Body)) + if err != nil { + return err + } + + // If it's a transactional template, cache it in the manager + // to be used for arbitrary incoming tx message pushes. + if o.Type == models.TemplateTypeTx { + app.manager.CacheTpl(out.ID, &o) } - // Hand over to the GET handler to return the last insertion. - return handleGetTemplates(copyEchoCtx(c, map[string]string{ - "id": fmt.Sprintf("%d", newID), - })) + return c.JSON(http.StatusOK, okResp{out}) } // handleUpdateTemplate handles template modification. @@ -173,22 +201,37 @@ func handleUpdateTemplate(c echo.Context) error { } if err := validateTemplate(o, app); err != nil { + return err + } + + var f template.FuncMap + + // Subject is only relevant for fixed tx templates. For campaigns, + // the subject changes per campaign and is on models.Campaign. + if o.Type == models.TemplateTypeCampaign { + o.Subject = "" + f = app.manager.TemplateFuncs(nil) + } else { + f = app.manager.GenericTemplateFuncs() + } + + // Compile the template and validate. + if err := o.Compile(f); err != nil { return echo.NewHTTPError(http.StatusBadRequest, err.Error()) } - res, err := app.queries.UpdateTemplate.Exec(id, o.Name, o.Body) + out, err := app.core.UpdateTemplate(id, o.Name, o.Subject, []byte(o.Body)) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorUpdating", - "name", "{globals.terms.template}", "error", pqErrMsg(err))) + return err } - if n, _ := res.RowsAffected(); n == 0 { - return echo.NewHTTPError(http.StatusBadRequest, - app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.template}")) + // If it's a transactional template, cache it. + if out.Type == models.TemplateTypeTx { + app.manager.CacheTpl(out.ID, &o) } - return handleGetTemplates(c) + return c.JSON(http.StatusOK, okResp{out}) + } // handleTemplateSetDefault handles template modification. @@ -202,11 +245,8 @@ func handleTemplateSetDefault(c echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) } - _, err := app.queries.SetDefaultTemplate.Exec(id) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorUpdating", - "name", "{globals.terms.template}", "error", pqErrMsg(err))) + if err := app.core.SetDefaultTemplate(id); err != nil { + return err } return handleGetTemplates(c) @@ -223,31 +263,31 @@ func handleDeleteTemplate(c echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) } - var delID int - err := app.queries.DeleteTemplate.Get(&delID, id) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("globals.messages.errorDeleting", - "name", "{globals.terms.template}", "error", pqErrMsg(err))) - } - if delID == 0 { - return echo.NewHTTPError(http.StatusBadRequest, - app.i18n.T("templates.cantDeleteDefault")) + if err := app.core.DeleteTemplate(id); err != nil { + return err } + // Delete cached template. + app.manager.DeleteTpl(id) + return c.JSON(http.StatusOK, okResp{true}) } -// validateTemplate validates template fields. +// compileTemplate validates template fields. func validateTemplate(o models.Template, app *App) error { if !strHasLen(o.Name, 1, stdInputMaxLen) { return errors.New(app.i18n.T("campaigns.fieldInvalidName")) } - if !regexpTplTag.MatchString(o.Body) { + if o.Type == models.TemplateTypeCampaign && !regexpTplTag.MatchString(o.Body) { return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("templates.placeholderHelp", "placeholder", tplTag)) } + if o.Type == models.TemplateTypeTx && strings.TrimSpace(o.Subject) == "" { + return echo.NewHTTPError(http.StatusBadRequest, + app.i18n.Ts("globals.messages.missingFields", "name", "subject")) + } + return nil } diff --git a/cmd/tx.go b/cmd/tx.go new file mode 100644 index 000000000..743fcba24 --- /dev/null +++ b/cmd/tx.go @@ -0,0 +1,207 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/textproto" + "strings" + + "github.com/knadh/listmonk/internal/manager" + "github.com/knadh/listmonk/models" + "github.com/labstack/echo/v4" +) + +// handleSendTxMessage handles the sending of a transactional message. +func handleSendTxMessage(c echo.Context) error { + var ( + app = c.Get("app").(*App) + m models.TxMessage + ) + + // If it's a multipart form, there may be file attachments. + if strings.HasPrefix(c.Request().Header.Get("Content-Type"), "multipart/form-data") { + form, err := c.MultipartForm() + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, + app.i18n.Ts("globals.messages.invalidFields", "name", err.Error())) + } + + data, ok := form.Value["data"] + if !ok || len(data) != 1 { + return echo.NewHTTPError(http.StatusBadRequest, + app.i18n.Ts("globals.messages.invalidFields", "name", "data")) + } + + // Parse the JSON data. + if err := json.Unmarshal([]byte(data[0]), &m); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, + app.i18n.Ts("globals.messages.invalidFields", "name", fmt.Sprintf("data: %s", err.Error()))) + } + + // Attach files. + for _, f := range form.File["file"] { + file, err := f.Open() + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, + app.i18n.Ts("globals.messages.invalidFields", "name", fmt.Sprintf("file: %s", err.Error()))) + } + defer file.Close() + + b, err := io.ReadAll(file) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, + app.i18n.Ts("globals.messages.invalidFields", "name", fmt.Sprintf("file: %s", err.Error()))) + } + + m.Attachments = append(m.Attachments, models.Attachment{ + Name: f.Filename, + Header: manager.MakeAttachmentHeader(f.Filename, "base64", f.Header.Get("Content-Type")), + Content: b, + }) + } + + } else if err := c.Bind(&m); err != nil { + return err + } + + // Validate input. + if r, err := validateTxMessage(m, app); err != nil { + return err + } else { + m = r + } + + // Get the cached tx template. + tpl, err := app.manager.GetTpl(m.TemplateID) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, + app.i18n.Ts("globals.messages.notFound", "name", fmt.Sprintf("template %d", m.TemplateID))) + } + + var ( + num = len(m.SubscriberEmails) + isEmails = true + ) + if len(m.SubscriberIDs) > 0 { + num = len(m.SubscriberIDs) + isEmails = false + } + + notFound := []string{} + for n := 0; n < num; n++ { + var ( + subID int + subEmail string + ) + + if !isEmails { + subID = m.SubscriberIDs[n] + } else { + subEmail = m.SubscriberEmails[n] + } + + // Get the subscriber. + sub, err := app.core.GetSubscriber(subID, "", subEmail) + if err != nil { + // If the subscriber is not found, log that error and move on without halting on the list. + if er, ok := err.(*echo.HTTPError); ok && er.Code == http.StatusBadRequest { + notFound = append(notFound, fmt.Sprintf("%v", er.Message)) + continue + } + + return err + } + + // Render the message. + if err := m.Render(sub, tpl); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, + app.i18n.Ts("globals.messages.errorFetching", "name")) + } + + // Prepare the final message. + msg := models.Message{} + msg.Subscriber = sub + msg.To = []string{sub.Email} + msg.From = m.FromEmail + msg.Subject = m.Subject + msg.ContentType = m.ContentType + msg.Messenger = m.Messenger + msg.Body = m.Body + for _, a := range m.Attachments { + msg.Attachments = append(msg.Attachments, models.Attachment{ + Name: a.Name, + Header: a.Header, + Content: a.Content, + }) + } + + // Optional headers. + if len(m.Headers) != 0 { + msg.Headers = make(textproto.MIMEHeader, len(m.Headers)) + for _, set := range m.Headers { + for hdr, val := range set { + msg.Headers.Add(hdr, val) + } + } + } + + if err := app.manager.PushMessage(msg); err != nil { + app.log.Printf("error sending message (%s): %v", msg.Subject, err) + return err + } + } + + if len(notFound) > 0 { + return echo.NewHTTPError(http.StatusBadRequest, strings.Join(notFound, "; ")) + } + + return c.JSON(http.StatusOK, okResp{true}) +} + +func validateTxMessage(m models.TxMessage, app *App) (models.TxMessage, error) { + if len(m.SubscriberEmails) > 0 && m.SubscriberEmail != "" { + return m, echo.NewHTTPError(http.StatusBadRequest, + app.i18n.Ts("globals.messages.invalidFields", "name", "do not send `subscriber_email`")) + } + if len(m.SubscriberIDs) > 0 && m.SubscriberID != 0 { + return m, echo.NewHTTPError(http.StatusBadRequest, + app.i18n.Ts("globals.messages.invalidFields", "name", "do not send `subscriber_id`")) + } + + if m.SubscriberEmail != "" { + m.SubscriberEmails = append(m.SubscriberEmails, m.SubscriberEmail) + } + + if m.SubscriberID != 0 { + m.SubscriberIDs = append(m.SubscriberIDs, m.SubscriberID) + } + + if (len(m.SubscriberEmails) == 0 && len(m.SubscriberIDs) == 0) || (len(m.SubscriberEmails) > 0 && len(m.SubscriberIDs) > 0) { + return m, echo.NewHTTPError(http.StatusBadRequest, + app.i18n.Ts("globals.messages.invalidFields", "name", "send subscriber_emails OR subscriber_ids")) + } + + for n, email := range m.SubscriberEmails { + if m.SubscriberEmail != "" { + em, err := app.importer.SanitizeEmail(email) + if err != nil { + return m, echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + m.SubscriberEmails[n] = em + } + } + + if m.FromEmail == "" { + m.FromEmail = app.constants.FromEmail + } + + if m.Messenger == "" { + m.Messenger = emailMsgr + } else if !app.manager.HasMessenger(m.Messenger) { + return m, echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("campaigns.fieldInvalidMessenger", "name", m.Messenger)) + } + + return m, nil +} diff --git a/cmd/updates.go b/cmd/updates.go index af52b6738..cc092c36f 100644 --- a/cmd/updates.go +++ b/cmd/updates.go @@ -2,7 +2,7 @@ package main import ( "encoding/json" - "io/ioutil" + "io" "net/http" "regexp" "time" @@ -10,18 +10,25 @@ import ( "golang.org/x/mod/semver" ) -const updateCheckURL = "https://api.github.com/repos/knadh/listmonk/releases/latest" +const updateCheckURL = "https://update.listmonk.app/update.json" -type remoteUpdateResp struct { - Version string `json:"tag_name"` - URL string `json:"html_url"` -} - -// AppUpdate contains information of a new update available to the app that -// is sent to the frontend. type AppUpdate struct { - Version string `json:"version"` - URL string `json:"url"` + Update struct { + ReleaseVersion string `json:"release_version"` + ReleaseDate string `json:"release_date"` + URL string `json:"url"` + Description string `json:"description"` + + // This is computed and set locally based on the local version. + IsNew bool `json:"is_new"` + } `json:"update"` + Messages []struct { + Date string `json:"date"` + Title string `json:"title"` + Description string `json:"description"` + URL string `json:"url"` + Priority string `json:"priority"` + } `json:"messages"` } var reSemver = regexp.MustCompile(`-(.*)`) @@ -32,48 +39,56 @@ var reSemver = regexp.MustCompile(`-(.*)`) func checkUpdates(curVersion string, interval time.Duration, app *App) { // Strip -* suffix. curVersion = reSemver.ReplaceAllString(curVersion, "") - time.Sleep(time.Second * 1) - ticker := time.NewTicker(interval) - defer ticker.Stop() - for range ticker.C { + fnCheck := func() { resp, err := http.Get(updateCheckURL) if err != nil { app.log.Printf("error checking for remote update: %v", err) - continue + return } if resp.StatusCode != 200 { app.log.Printf("non 200 response on remote update check: %d", resp.StatusCode) - continue + return } - b, err := ioutil.ReadAll(resp.Body) + b, err := io.ReadAll(resp.Body) if err != nil { app.log.Printf("error reading remote update payload: %v", err) - continue + return } resp.Body.Close() - var up remoteUpdateResp - if err := json.Unmarshal(b, &up); err != nil { + var out AppUpdate + if err := json.Unmarshal(b, &out); err != nil { app.log.Printf("error unmarshalling remote update payload: %v", err) - continue + return } // There is an update. Set it on the global app state. - if semver.IsValid(up.Version) { - v := reSemver.ReplaceAllString(up.Version, "") + if semver.IsValid(out.Update.ReleaseVersion) { + v := reSemver.ReplaceAllString(out.Update.ReleaseVersion, "") if semver.Compare(v, curVersion) > 0 { - app.Lock() - app.update = &AppUpdate{ - Version: up.Version, - URL: up.URL, - } - app.Unlock() - - app.log.Printf("new update %s found", up.Version) + out.Update.IsNew = true + app.log.Printf("new update %s found", out.Update.ReleaseVersion) } } + + app.Lock() + app.update = &out + app.Unlock() + } + + // Give a 15 minute buffer after app start in case the admin wants to disable + // update checks entirely and not make a request to upstream. + time.Sleep(time.Minute * 15) + fnCheck() + + // Thereafter, check every $interval. + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for range ticker.C { + fnCheck() } } diff --git a/cmd/upgrade.go b/cmd/upgrade.go index a27bc9179..414c5178c 100644 --- a/cmd/upgrade.go +++ b/cmd/upgrade.go @@ -2,10 +2,11 @@ package main import ( "fmt" + "log" "strings" "github.com/jmoiron/sqlx" - "github.com/knadh/koanf" + "github.com/knadh/koanf/v2" "github.com/knadh/listmonk/internal/migrations" "github.com/knadh/stuffbin" "github.com/lib/pq" @@ -18,7 +19,7 @@ import ( // of logic to be performed before executing upgrades. fn is idempotent. type migFunc struct { version string - fn func(*sqlx.DB, stuffbin.FileSystem, *koanf.Koanf) error + fn func(*sqlx.DB, stuffbin.FileSystem, *koanf.Koanf, *log.Logger) error } // migList is the list of available migList ordered by the semver. @@ -31,6 +32,15 @@ var migList = []migFunc{ {"v0.9.0", migrations.V0_9_0}, {"v1.0.0", migrations.V1_0_0}, {"v2.0.0", migrations.V2_0_0}, + {"v2.1.0", migrations.V2_1_0}, + {"v2.2.0", migrations.V2_2_0}, + {"v2.3.0", migrations.V2_3_0}, + {"v2.4.0", migrations.V2_4_0}, + {"v2.5.0", migrations.V2_5_0}, + {"v3.0.0", migrations.V3_0_0}, + {"v4.0.0", migrations.V4_0_0}, + {"v4.1.0", migrations.V4_1_0}, + {"v5.0.0", migrations.V5_0_0}, } // upgrade upgrades the database to the current version by running SQL migration files @@ -63,7 +73,7 @@ func upgrade(db *sqlx.DB, fs stuffbin.FileSystem, prompt bool) { // Execute migrations in succession. for _, m := range toRun { lo.Printf("running migration %s", m.version) - if err := m.fn(db, fs, ko); err != nil { + if err := m.fn(db, fs, ko, lo); err != nil { lo.Fatalf("error running migration %s: %v", m.version, err) } @@ -111,7 +121,7 @@ func getPendingMigrations(db *sqlx.DB) (string, []migFunc, error) { } // Iterate through the migration versions and get everything above the last - // last upgraded semver. + // upgraded semver. var toRun []migFunc for i, m := range migList { if semver.Compare(m.version, lastVer) > 0 { @@ -139,7 +149,7 @@ func getLastMigrationVersion() (string, error) { return v, nil } -// isPqNoTableErr checks if the given error represents a Postgres/pq +// isTableNotExistErr checks if the given error represents a Postgres/pq // "table does not exist" error. func isTableNotExistErr(err error) bool { if p, ok := err.(*pq.Error); ok { diff --git a/cmd/users.go b/cmd/users.go new file mode 100644 index 000000000..9c259cce8 --- /dev/null +++ b/cmd/users.go @@ -0,0 +1,296 @@ +package main + +import ( + "net/http" + "regexp" + "strconv" + "strings" + + "github.com/knadh/listmonk/internal/auth" + "github.com/knadh/listmonk/internal/core" + "github.com/knadh/listmonk/internal/utils" + "github.com/knadh/listmonk/models" + "github.com/labstack/echo/v4" + "gopkg.in/volatiletech/null.v6" +) + +var ( + reUsername = regexp.MustCompile("^[a-zA-Z0-9_\\-\\.]+$") +) + +// handleGetUsers retrieves users. +func handleGetUsers(c echo.Context) error { + var ( + app = c.Get("app").(*App) + userID, _ = strconv.Atoi(c.Param("id")) + ) + + // Fetch one. + single := false + if userID > 0 { + single = true + } + + if single { + out, err := app.core.GetUser(userID, "", "") + if err != nil { + return err + } + + out.Password = null.String{} + + return c.JSON(http.StatusOK, okResp{out}) + } + + // Get all users. + out, err := app.core.GetUsers() + if err != nil { + return err + } + + for n := range out { + out[n].Password = null.String{} + } + + return c.JSON(http.StatusOK, okResp{out}) +} + +// handleCreateUser handles user creation. +func handleCreateUser(c echo.Context) error { + var ( + app = c.Get("app").(*App) + u = models.User{} + ) + + if err := c.Bind(&u); err != nil { + return err + } + + u.Username = strings.TrimSpace(u.Username) + u.Name = strings.TrimSpace(u.Name) + email := strings.TrimSpace(u.Email.String) + + // Validate fields. + if !strHasLen(u.Username, 3, stdInputMaxLen) { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "username")) + } + if !reUsername.MatchString(u.Username) { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "username")) + } + if u.Type != models.UserTypeAPI { + if !utils.ValidateEmail(email) { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "email")) + } + if u.PasswordLogin { + if !strHasLen(u.Password.String, 8, stdInputMaxLen) { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "password")) + } + } + + u.Email = null.String{String: email, Valid: true} + } + + if u.Name == "" { + u.Name = u.Username + } + + // Create the user in the database. + user, err := app.core.CreateUser(u) + if err != nil { + return err + } + if user.Type != models.UserTypeAPI { + user.Password = null.String{} + } + + // Cache the API token for validating API queries without hitting the DB every time. + if _, err := cacheUsers(app.core, app.auth); err != nil { + return err + } + + return c.JSON(http.StatusOK, okResp{user}) +} + +// handleUpdateUser handles user modification. +func handleUpdateUser(c echo.Context) error { + var ( + app = c.Get("app").(*App) + id, _ = strconv.Atoi(c.Param("id")) + ) + + if id < 1 { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) + } + + // Incoming params. + var u models.User + if err := c.Bind(&u); err != nil { + return err + } + + // Validate. + u.Username = strings.TrimSpace(u.Username) + u.Name = strings.TrimSpace(u.Name) + email := strings.TrimSpace(u.Email.String) + + // Validate fields. + if !strHasLen(u.Username, 3, stdInputMaxLen) { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "username")) + } + if !reUsername.MatchString(u.Username) { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "username")) + } + + if u.Type != models.UserTypeAPI { + if !utils.ValidateEmail(email) { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "email")) + } + if u.PasswordLogin && u.Password.String != "" { + if !strHasLen(u.Password.String, 8, stdInputMaxLen) { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "password")) + } + + if u.Password.String != "" { + if !strHasLen(u.Password.String, 8, stdInputMaxLen) { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "password")) + } + } else { + // Get the existing user for password validation. + user, err := app.core.GetUser(id, "", "") + if err != nil { + return err + } + + // If password login is enabled, but there's no password in the DB and there's no incoming + // password, throw an error. + if !user.HasPassword { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "password")) + } + } + } + + u.Email = null.String{String: email, Valid: true} + } + + if u.Name == "" { + u.Name = u.Username + } + + // Update the user in the DB. + user, err := app.core.UpdateUser(id, u) + if err != nil { + return err + } + + // Clear the pasword before sending outside. + user.Password = null.String{} + + // Cache the API token for validating API queries without hitting the DB every time. + if _, err := cacheUsers(app.core, app.auth); err != nil { + return err + } + + return c.JSON(http.StatusOK, okResp{user}) +} + +// handleDeleteUsers handles user deletion, either a single one (ID in the URI), or a list. +func handleDeleteUsers(c echo.Context) error { + var ( + app = c.Get("app").(*App) + id, _ = strconv.ParseInt(c.Param("id"), 10, 64) + ids []int + ) + + if id < 1 && len(ids) == 0 { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) + } + + if id > 0 { + ids = append(ids, int(id)) + } + + if err := app.core.DeleteUsers(ids); err != nil { + return err + } + + // Cache the API token for validating API queries without hitting the DB every time. + if _, err := cacheUsers(app.core, app.auth); err != nil { + return err + } + + return c.JSON(http.StatusOK, okResp{true}) +} + +// handleGetUserProfile fetches the uesr profile for the currently logged in user. +func handleGetUserProfile(c echo.Context) error { + var ( + user = c.Get(auth.UserKey).(models.User) + ) + user.Password.String = "" + user.Password.Valid = false + + return c.JSON(http.StatusOK, okResp{user}) +} + +// handleUpdateUserProfile update's the current user's profile. +func handleUpdateUserProfile(c echo.Context) error { + var ( + app = c.Get("app").(*App) + user = c.Get(auth.UserKey).(models.User) + ) + + u := models.User{} + if err := c.Bind(&u); err != nil { + return err + } + u.PasswordLogin = user.PasswordLogin + u.Name = strings.TrimSpace(u.Name) + email := strings.TrimSpace(u.Email.String) + + // Validate fields. + if user.PasswordLogin { + if !utils.ValidateEmail(email) { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "email")) + } + u.Email = null.String{String: email, Valid: true} + } + + if u.PasswordLogin && u.Password.String != "" { + if !strHasLen(u.Password.String, 8, stdInputMaxLen) { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "password")) + } + } + + out, err := app.core.UpdateUserProfile(user.ID, u) + if err != nil { + return err + } + out.Password = null.String{} + + return c.JSON(http.StatusOK, okResp{out}) +} + +// cacheUsers fetches (API) users and caches them in the auth module. +// It also returns a bool indicating whether there are any actual users in the DB at all, +// which if there aren't, the first time user setup needs to be run. +func cacheUsers(co *core.Core, a *auth.Auth) (bool, error) { + allUsers, err := co.GetUsers() + if err != nil { + return false, err + } + + hasUser := false + apiUsers := make([]models.User, 0, len(allUsers)) + for _, u := range allUsers { + if u.Type == models.UserTypeAPI && u.Status == models.UserStatusEnabled { + apiUsers = append(apiUsers, u) + } + + if u.Type == models.UserTypeUser { + hasUser = true + } + } + + a.CacheAPIUsers(apiUsers) + return hasUser, nil +} diff --git a/cmd/utils.go b/cmd/utils.go index 7ded53df9..04859e41b 100644 --- a/cmd/utils.go +++ b/cmd/utils.go @@ -8,12 +8,14 @@ import ( "regexp" "strconv" "strings" + "unicode" - "github.com/lib/pq" + "golang.org/x/text/cases" + "golang.org/x/text/language" ) var ( - tagRegexpSpaces = regexp.MustCompile(`[\s]+`) + regexpSpaces = regexp.MustCompile(`[\s]+`) ) // inArray checks if a string is present in a list of strings. @@ -32,40 +34,20 @@ func makeFilename(fName string) string { if name == "" { name, _ = generateRandomString(10) } + // replace whitespace with "-" + name = regexpSpaces.ReplaceAllString(name, "-") return filepath.Base(name) } -// Given an error, pqErrMsg will try to return pq error details -// if it's a pq error. -func pqErrMsg(err error) string { - if err, ok := err.(*pq.Error); ok { - if err.Detail != "" { - return fmt.Sprintf("%s. %s", err, err.Detail) - } - } - return err.Error() -} - -// normalizeTags takes a list of string tags and normalizes them by -// lowercasing and removing all special characters except for dashes. -func normalizeTags(tags []string) []string { - var ( - out []string - dash = []byte("-") - ) - - for _, t := range tags { - rep := tagRegexpSpaces.ReplaceAll(bytes.TrimSpace([]byte(t)), dash) - - if len(rep) > 0 { - out = append(out, string(rep)) - } - } - return out +// appendSuffixToFilename adds a string suffix to the filename while keeping the file extension. +func appendSuffixToFilename(filename, suffix string) string { + ext := filepath.Ext(filename) + name := strings.TrimSuffix(filename, ext) + return fmt.Sprintf("%s_%s%s", name, suffix, ext) } // makeMsgTpl takes a page title, heading, and message and returns -// a msgTpl that can be rendered as a HTML view. This is used for +// a msgTpl that can be rendered as an HTML view. This is used for // rendering arbitrary HTML views with error and success messages. func makeMsgTpl(pageTitle, heading, msg string) msgTpl { if heading == "" { @@ -81,10 +63,10 @@ func makeMsgTpl(pageTitle, heading, msg string) msgTpl { // parseStringIDs takes a slice of numeric string IDs and // parses each number into an int64 and returns a slice of the // resultant values. -func parseStringIDs(s []string) ([]int64, error) { - vals := make([]int64, 0, len(s)) +func parseStringIDs(s []string) ([]int, error) { + vals := make([]int, 0, len(s)) for _, v := range s { - i, err := strconv.ParseInt(v, 10, 64) + i, err := strconv.Atoi(v) if err != nil { return nil, err } @@ -129,3 +111,25 @@ func strSliceContains(str string, sl []string) bool { return false } + +func trimNullBytes(b []byte) string { + return string(bytes.Trim(b, "\x00")) +} + +func titleCase(input string) string { + parts := strings.Fields(input) + for n, p := range parts { + parts[n] = cases.Title(language.Und).String(p) + } + + return strings.Join(parts, " ") +} + +func isASCII(s string) bool { + for _, c := range s { + if c > unicode.MaxASCII { + return false + } + } + return true +} diff --git a/config-demo.toml b/config-demo.toml deleted file mode 100644 index f61a22ad7..000000000 --- a/config-demo.toml +++ /dev/null @@ -1,15 +0,0 @@ -[app] -# Interface and port where the app will run its webserver. -address = "0.0.0.0:9000" - -# Database. -[db] -host = "demo-db" -port = 5432 -user = "listmonk" -password = "listmonk" -database = "listmonk" -ssl_mode = "disable" -max_open = 25 -max_idle = 25 -max_lifetime = "300s" diff --git a/config.toml.sample b/config.toml.sample index 4b178e202..80d53a3f3 100644 --- a/config.toml.sample +++ b/config.toml.sample @@ -5,21 +5,20 @@ # port, use port 80 (this will require running with elevated permissions). address = "localhost:9000" -# BasicAuth authentication for the admin dashboard. This will eventually -# be replaced with a better multi-user, role-based authentication system. -# IMPORTANT: Leave both values empty to disable authentication on admin -# only where an external authentication is already setup. -admin_username = "listmonk" -admin_password = "listmonk" - # Database. [db] host = "localhost" port = 5432 user = "listmonk" password = "listmonk" + +# Ensure that this database has been created in Postgres. database = "listmonk" + ssl_mode = "disable" max_open = 25 max_idle = 25 max_lifetime = "300s" + +# Optional space separated Postgres DSN params. eg: "application_name=listmonk gssencmode=disable" +params = "" diff --git a/dev/.gitignore b/dev/.gitignore new file mode 100644 index 000000000..30bfc4d1a --- /dev/null +++ b/dev/.gitignore @@ -0,0 +1 @@ +!config.toml diff --git a/dev/README.md b/dev/README.md new file mode 100644 index 000000000..4d41ba47b --- /dev/null +++ b/dev/README.md @@ -0,0 +1,62 @@ +# Docker suite for development + +**NOTE**: This exists only for local development. If you're interested in using +Docker for a production setup, visit the +[docs](https://listmonk.app/docs/installation/#docker) instead. + +### Objective + +The purpose of this Docker suite for local development is to isolate all the dev +dependencies in a Docker environment. The containers have a host volume mounted +inside for the entire app directory. This helps us to not do a full +`docker build` for every single local change, only restarting the Docker +environment is enough. + +## Setting up a dev suite + +To spin up a local suite of: + +- PostgreSQL +- Mailhog +- Node.js frontend app +- Golang backend app + +### Verify your config file + +The config file provided at `dev/config.toml` will be used when running the +containerized development stack. Make sure the values set within are suitable +for the feature you're trying to develop. + +### Setup DB + +Running this will build the appropriate images and initialize the database. + +```bash +make init-dev-docker +``` + +### Start frontend and backend apps + +Running this start your local development stack. + +```bash +make dev-docker +``` + +Visit `http://localhost:8080` on your browser. + +### Tear down + +This will tear down all the data, including DB. + +```bash +make rm-dev-docker +``` + +### See local changes in action + +- Backend: Anytime you do a change to the Go app, it needs to be compiled. Just + run `make dev-docker` again and that should automatically handle it for you. +- Frontend: Anytime you change the frontend code, you don't need to do anything. + Since `yarn` is watching for all the changes and we have mounted the code + inside the docker container, `yarn` server automatically restarts. diff --git a/dev/app.Dockerfile b/dev/app.Dockerfile new file mode 100644 index 000000000..21f8bbe75 --- /dev/null +++ b/dev/app.Dockerfile @@ -0,0 +1,11 @@ +FROM golang:1.20 AS go + +FROM node:16 AS node + +COPY --from=go /usr/local/go /usr/local/go +ENV GOPATH /go +ENV CGO_ENABLED=0 +ENV PATH $GOPATH/bin:/usr/local/go/bin:$PATH + +WORKDIR /app +CMD [ "sleep infinity" ] diff --git a/dev/config.toml b/dev/config.toml new file mode 100644 index 000000000..42e87da2b --- /dev/null +++ b/dev/config.toml @@ -0,0 +1,24 @@ +[app] +# Interface and port where the app will run its webserver. The default value +# of localhost will only listen to connections from the current machine. To +# listen on all interfaces use '0.0.0.0'. To listen on the default web address +# port, use port 80 (this will require running with elevated permissions). +address = "0.0.0.0:9000" + +# Database. +[db] +host = "db" +port = 5432 +user = "listmonk-dev" +password = "listmonk-dev" + +# Ensure that this database has been created in Postgres. +database = "listmonk-dev" + +ssl_mode = "disable" +max_open = 25 +max_idle = 25 +max_lifetime = "300s" + +# Optional space separated Postgres DSN params. eg: "application_name=listmonk gssencmode=disable" +params = "" diff --git a/dev/docker-compose.yml b/dev/docker-compose.yml new file mode 100644 index 000000000..d378ac3b0 --- /dev/null +++ b/dev/docker-compose.yml @@ -0,0 +1,71 @@ +version: "3" + +services: + adminer: + image: adminer:4.8.1-standalone + restart: always + ports: + - 8070:8080 + networks: + - listmonk-dev + + mailhog: + image: mailhog/mailhog:v1.0.1 + ports: + - "1025:1025" # SMTP + - "8025:8025" # UI + networks: + - listmonk-dev + + db: + image: postgres:13 + ports: + - "5432:5432" + networks: + - listmonk-dev + environment: + - POSTGRES_PASSWORD=listmonk-dev + - POSTGRES_USER=listmonk-dev + - POSTGRES_DB=listmonk-dev + restart: unless-stopped + volumes: + - type: volume + source: listmonk-dev-db + target: /var/lib/postgresql/data + + front: + build: + context: ../ + dockerfile: dev/app.Dockerfile + command: ["make", "run-frontend"] + ports: + - "8080:8080" + environment: + - LISTMONK_API_URL=http://backend:9000 + depends_on: + - db + volumes: + - ../:/app + networks: + - listmonk-dev + + backend: + build: + context: ../ + dockerfile: dev/app.Dockerfile + command: ["make", "run-backend-docker"] + ports: + - "9000:9000" + depends_on: + - db + volumes: + - ../:/app + - $GOPATH/pkg/mod/cache:/go/pkg/mod/cache + networks: + - listmonk-dev + +volumes: + listmonk-dev-db: + +networks: + listmonk-dev: diff --git a/docker-compose.yml b/docker-compose.yml index 2fe8a81b8..2dbae4415 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,62 +1,65 @@ -# NOTE: This docker-compose.yml is meant to be just an example guideline -# on how you can achieve the same. It is not intented to run out of the box -# and you must edit the below configurations to suit your needs. +x-db-credentials: &db-credentials # Use the default POSTGRES_ credentials if they're available or simply default to "listmonk" + POSTGRES_USER: &db-user listmonk # for database user, password, and database name + POSTGRES_PASSWORD: &db-password listmonk + POSTGRES_DB: &db-name listmonk -version: "3.7" - -x-app-defaults: &app-defaults - restart: unless-stopped - image: listmonk/listmonk:latest - ports: - - "9000:9000" - networks: - - listmonk +services: + # listmonk app + app: + image: listmonk/listmonk:latest + container_name: listmonk_app + restart: unless-stopped + ports: + - "9000:9000" # To change the externally exposed port, change to: $custom_port:9000 + networks: + - listmonk + hostname: listmonk.example.com # Recommend using FQDN for hostname + depends_on: + - db + command: [sh, -c, "./listmonk --install --idempotent --yes --config '' && ./listmonk --upgrade --yes --config '' && ./listmonk --config ''"] + # --config (file) param is set to empty so that listmonk only uses the env vars (below) for config. + # --install --idempotent ensures that DB installation happens only once on an empty DB, on the first ever start. + # --upgrade automatically runs any DB migrations when a new image is pulled. -x-db-defaults: &db-defaults - image: postgres:11 - ports: - - "9432:5432" - networks: - - listmonk - environment: - - POSTGRES_PASSWORD=listmonk - - POSTGRES_USER=listmonk - - POSTGRES_DB=listmonk - restart: unless-stopped - healthcheck: - test: ["CMD-SHELL", "pg_isready -U listmonk"] - interval: 10s - timeout: 5s - retries: 6 + environment: # The same params as in config.toml are passed as env vars here. + LISTMONK_app__address: 0.0.0.0:9000 + LISTMONK_db__user: *db-user + LISTMONK_db__password: *db-password + LISTMONK_db__database: *db-name + LISTMONK_db__host: listmonk_db + LISTMONK_db__port: 5432 + LISTMONK_db__ssl_mode: disable + LISTMONK_db__max_open: 25 + LISTMONK_db__max_idle: 25 + LISTMONK_db__max_lifetime: 300s + TZ: Etc/UTC + LISTMONK_ADMIN_USER: ${LISTMONK_ADMIN_USER:-} # If these (optional) are set during the first `docker compose up`, then the Super Admin user is automatically created. + LISTMONK_ADMIN_PASSWORD: ${LISTMONK_ADMIN_PASSWORD:-} # Otherwise, the user can be setup on the web app after the first visit to http://localhost:9000 + volumes: + - ./uploads:/listmonk/uploads:rw # Mount an uploads directory on the host to /listmonk/uploads inside the container. + # To use this, change directory path in Admin -> Settings -> Media to /listmonk/uploads -services: + # Postgres database db: - <<: *db-defaults + image: postgres:17-alpine container_name: listmonk_db + restart: unless-stopped + ports: + - "5432:5432" + networks: + - listmonk + environment: + <<: *db-credentials + healthcheck: + test: ["CMD-SHELL", "pg_isready -U listmonk"] + interval: 10s + timeout: 5s + retries: 6 volumes: - type: volume source: listmonk-data target: /var/lib/postgresql/data - app: - <<: *app-defaults - container_name: listmonk_app - depends_on: - - db - volumes: - - ./config.toml:/listmonk/config.toml - - demo-db: - container_name: listmonk_demo_db - <<: *db-defaults - - demo-app: - <<: *app-defaults - container_name: listmonk_demo_app - command: [sh, -c, "yes | ./listmonk --install --config config-demo.toml && ./listmonk --config config-demo.toml"] - depends_on: - - demo-db - networks: listmonk: diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 000000000..15e0aa61f --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,50 @@ +#!/bin/sh + +set -e + +export PUID=${PUID:-0} +export PGID=${PGID:-0} +export GROUP_NAME="app" +export USER_NAME="app" + +# This function evaluates if the supplied PGID is already in use +# if it is not in use, it creates the group with the PGID +# if it is in use, it sets the GROUP_NAME to the existing group +create_group() { + if ! getent group ${PGID} > /dev/null 2>&1; then + addgroup -g ${PGID} ${GROUP_NAME} + else + existing_group=$(getent group ${PGID} | cut -d: -f1) + export GROUP_NAME=${existing_group} + fi +} + +# This function evaluates if the supplied PUID is already in use +# if it is not in use, it creates the user with the PUID and PGID +create_user() { + if ! getent passwd ${PUID} > /dev/null 2>&1; then + adduser -u ${PUID} -G ${GROUP_NAME} -s /bin/sh -D ${USER_NAME} + else + existing_user=$(getent passwd ${PUID} | cut -d: -f1) + export USER_NAME=${existing_user} + fi +} + +# Run the needed functions to create the user and group +create_group +create_user + +# Try to set the ownership of the app directory to the app user. +if ! chown -R ${PUID}:${PGID} /listmonk 2>/dev/null; then + echo "Warning: Failed to change ownership of /listmonk. Readonly volume?" +fi + +echo "Launching listmonk with user=[${USER_NAME}] group=[${GROUP_NAME}] PUID=[${PUID}] PGID=[${PGID}]" + +# If running as root and PUID is not 0, then execute command as PUID +# this allows us to run the container as a non-root user +if [ "$(id -u)" = "0" ] && [ "${PUID}" != "0" ]; then + su-exec ${PUID}:${PGID} "$@" +else + exec "$@" +fi diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 000000000..80c2efce7 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,9 @@ +# Static website and docs + +This repository contains the source for the static website https://listmonk.app + +- The website is in `site` and is built with hugo (run `hugo serve` inside `site` to preview). + +- Documentation is in `docs` and is built with mkdocs (inside `docs`, run `mkdocs serve` to preview after running `pip install -r requirements.txt`) + +- `i18n` directory has the static UI for i18n translations: https://listmonk.app/i18n diff --git a/docs/docs/content/apis/apis.md b/docs/docs/content/apis/apis.md new file mode 100644 index 000000000..ad1761491 --- /dev/null +++ b/docs/docs/content/apis/apis.md @@ -0,0 +1,85 @@ +# APIs + +All features that are available on the listmonk dashboard are also available as REST-like HTTP APIs that can be interacted with directly. Request and response bodies are JSON. This allows easy scripting of listmonk and integration with other systems, for instance, synchronisation with external subscriber databases. + +!!! note + If you come across API calls that are yet to be documented, please consider contributing to docs. + + +## Auth +HTTP API requests support BasicAuth and a Authorization `token` headers. API users and tokens with the required permissions can be created and managed on the admin UI (Admin -> Users). + +##### BasicAuth example +```shell +curl -u "api_user:token" http://localhost:9000/api/lists +``` + +##### Authorization token example +```shell +curl -H "Authorization: token api_user:token" http://localhost:9000/api/lists +``` + +## Permissions +**User role**: Permissions allowed for a user are defined as a *User role* (Admin -> User roles) and then attached to a user. + +**List role**: Read / write permissions per-list can be defined as a *List role* (Admin -> User roles) and then attached to a user. + +In a *User role*, `lists:get_all` or `lists:manage_all` permission supercede and override any list specific permissions for a user defined in a *List role*. + +To manage lists and subscriber list subscriptions via API requests, ensure that the appropriate permissions are attached to the API user. + +______________________________________________________________________ + +## Response structure + +### Successful request + +```http +HTTP/1.1 200 OK +Content-Type: application/json + +{ + "data": {} +} +``` + +All responses from the API server are JSON with the content-type application/json unless explicitly stated otherwise. A successful 200 OK response always has a JSON response body with a status key with the value success. The data key contains the full response payload. + +### Failed request + +```http +HTTP/1.1 500 Server error +Content-Type: application/json + +{ + "message": "Error message" +} +``` + +A failure response is preceded by the corresponding 40x or 50x HTTP header. There may be an optional `data` key with additional payload. + +### Timestamps + +All timestamp fields are in the format `2019-01-01T09:00:00.000000+05:30`. The seconds component is suffixed by the milliseconds, followed by the `+` and the timezone offset. + +### Common HTTP error codes + +| Code | | +| ----- | ----------------------------------------------------------------------------| +| 400 | Missing or bad request parameters or values | +| 403 | Session expired or invalidate. Must relogin | +| 404 | Request resource was not found | +| 405 | Request method (GET, POST etc.) is not allowed on the requested endpoint | +| 410 | The requested resource is gone permanently | +| 422 | Unprocessable entity. Unable to process request as it contains invalid data | +| 429 | Too many requests to the API (rate limiting) | +| 500 | Something unexpected went wrong | +| 502 | The backend OMS is down and the API is unable to communicate with it | +| 503 | Service unavailable; the API is down | +| 504 | Gateway timeout; the API is unreachable | + + +## OpenAPI (Swagger) spec + +The auto-generated OpenAPI (Swagger) specification site for the APIs are available at [**listmonk.app/docs/swagger**](https://listmonk.app/docs/swagger/) + diff --git a/docs/docs/content/apis/bounces.md b/docs/docs/content/apis/bounces.md new file mode 100644 index 000000000..9823c992a --- /dev/null +++ b/docs/docs/content/apis/bounces.md @@ -0,0 +1,152 @@ +# API / Bounces + +Method | Endpoint | Description +---------|---------------------------------------------------------|------------------------------------------------ +GET | [/api/bounces](#get-apibounces) | Retrieve bounce records. +DELETE | [/api/bounces](#delete-apibounces) | Delete all/multiple bounce records. +DELETE | [/api/bounces/{bounce_id}](#delete-apibouncesbounce_id) | Delete specific bounce record. + + +______________________________________________________________________ + +#### GET /api/bounces + +Retrieve the bounce records. + +##### Parameters + +| Name | Type | Required | Description | +|:-----------|:---------|:---------|:-----------------------------------------------------------------| +| campaign_id| number | | Bounce record retrieval for particular campaign id | +| page | number | | Page number for pagination. | +| per_page | number | | Results per page. Set to 'all' to return all results. | +| source | string | | | +| order_by | string | | Fields by which bounce records are ordered. Options:"email", "campaign_name", "source", "created_at". | +| order | number | | Sorts the result. Allowed values: 'asc','desc' | + +##### Example Request + +```shell +curl -u "api_user:token" -X GET 'http://localhost:9000/api/bounces?campaign_id=1&page=1&per_page=2' \ + -H 'accept: application/json' -H 'Content-Type: application/x-www-form-urlencoded' \ + --data '{"source":"demo","order_by":"created_at","order":"asc"}' +``` + +##### Example Response + +```json +{ + "data": { + "results": [ + { + "id": 839971, + "type": "hard", + "source": "demo", + "meta": { + "some": "parameter" + }, + "created_at": "2024-08-20T23:54:22.851858Z", + "email": "gilles.deleuze@example.app", + "subscriber_uuid": "32ca1f3e-1a1d-42e1-af04-df0757f420f3", + "subscriber_id": 60, + "campaign": { + "id": 1, + "name": "Test campaign" + } + }, + { + "id": 839725, + "type": "hard", + "source": "demo", + "meta": { + "some": "parameter" + }, + "created_at": "2024-08-20T22:46:36.393547Z", + "email": "gottfried.leibniz@example.app", + "subscriber_uuid": "5911d3f4-2346-4bfc-aad2-eb319ab0e879", + "subscriber_id": 13, + "campaign": { + "id": 1, + "name": "Test campaign" + } + } + ], + "query": "", + "total": 528, + "per_page": 2, + "page": 1 + } +} +``` + +______________________________________________________________________ + +#### DELETE /api/bounces + +To delete all bounces. + +##### Parameters + +| Name | Type | Required | Description | +|:--------|:----------|:---------|:-------------------------------------| +| all | bool | Yes | Bool to confirm deleting all bounces | + +##### Example Request + +```shell +curl -u 'api_username:access_token' -X DELETE 'http://localhost:9000/api/bounces?all=true' +``` + +##### Example Response + +```json +{ + "data": true +} +``` + +______________________________________________________________________ + +#### DELETE /api/bounces + +To delete multiple bounce records. + +##### Parameters + +| Name | Type | Required | Description | +|:--------|:----------|:---------|:-------------------------------------| +| id | number | Yes | Id's of bounce records to delete. | + +##### Example Request + +```shell +curl -u 'api_username:access_token' -X DELETE 'http://localhost:9000/api/bounces?id=840965&id=840168&id=840879' +``` + +##### Example Response + +```json +{ + "data": true +} +``` + +______________________________________________________________________ + +#### DELETE /api/bounces/{bounce_id} + +To delete specific bounce id. + +##### Example Request + +```shell +curl -u 'api_username:access_token' -X DELETE 'http://localhost:9000/api/bounces/840965' +``` + +##### Example Response + +```json +{ + "data": true +} +``` \ No newline at end of file diff --git a/docs/docs/content/apis/campaigns.md b/docs/docs/content/apis/campaigns.md new file mode 100644 index 000000000..1c7d7bbd6 --- /dev/null +++ b/docs/docs/content/apis/campaigns.md @@ -0,0 +1,504 @@ +# API / Campaigns + +| Method | Endpoint | Description | +|:-------|:----------------------------------------------------------------------------|:------------------------------------------| +| GET | [/api/campaigns](#get-apicampaigns) | Retrieve all campaigns. | +| GET | [/api/campaigns/{campaign_id}](#get-apicampaignscampaign_id) | Retrieve a specific campaign. | +| GET | [/api/campaigns/{campaign_id}/preview](#get-apicampaignscampaign_idpreview) | Retrieve preview of a campaign. | +| GET | [/api/campaigns/running/stats](#get-apicampaignsrunningstats) | Retrieve stats of specified campaigns. | +| GET | [/api/campaigns/analytics/{type}](#get-apicampaignsanalyticstype) | Retrieve view counts for a campaign. | +| POST | [/api/campaigns](#post-apicampaigns) | Create a new campaign. | +| POST | [/api/campaigns/{campaign_id}/test](#post-apicampaignscampaign_idtest) | Test campaign with arbitrary subscribers. | +| PUT | [/api/campaigns/{campaign_id}](#put-apicampaignscampaign_id) | Update a campaign. | +| PUT | [/api/campaigns/{campaign_id}/status](#put-apicampaignscampaign_idstatus) | Change status of a campaign. | +| PUT | [/api/campaigns/{campaign_id}/archive](#put-apicampaignscampaign_idarchive) | Publish campaign to public archive. | +| DELETE | [/api/campaigns/{campaign_id}](#delete-apicampaignscampaign_id) | Delete a campaign. | + +____________________________________________________________________________________________________________________________________ + +#### GET /api/campaigns + +Retrieve all campaigns. + +##### Example Request + +```shell + curl -u "api_user:token" -X GET 'http://localhost:9000/api/campaigns?page=1&per_page=100' +``` + +##### Parameters + +| Name | Type | Required | Description | +|:---------|:---------|:---------|:---------------------------------------------------------------------| +| order | string | | Sorting order: ASC for ascending, DESC for descending. | +| order_by | string | | Result sorting field. Options: name, status, created_at, updated_at. | +| query | string | | SQL query expression to filter campaigns. | +| status | []string | | Status to filter campaigns. Repeat in the query for multiple values. | +| tags | []string | | Tags to filter campaigns. Repeat in the query for multiple values. | +| page | number | | Page number for paginated results. | +| per_page | number | | Results per page. Set as 'all' for all results. | +| no_body | boolean | | When set to true, returns response without body content. | + +##### Example Response + +```json +{ + "data": { + "results": [ + { + "id": 1, + "created_at": "2020-03-14T17:36:41.29451+01:00", + "updated_at": "2020-03-14T17:36:41.29451+01:00", + "views": 0, + "clicks": 0, + "lists": [ + { + "id": 1, + "name": "Default list" + } + ], + "started_at": null, + "to_send": 0, + "sent": 0, + "uuid": "57702beb-6fae-4355-a324-c2fd5b59a549", + "type": "regular", + "name": "Test campaign", + "subject": "Welcome to listmonk", + "from_email": "No Reply ", + "body": "

Hi {{ .Subscriber.FirstName }}!

\n\t\t\tThis is a test e-mail campaign. Your second name is {{ .Subscriber.LastName }} and you are from {{ .Subscriber.Attribs.city }}.", + "send_at": "2020-03-15T17:36:41.293233+01:00", + "status": "draft", + "content_type": "richtext", + "tags": [ + "test-campaign" + ], + "template_id": 1, + "messenger": "email" + } + ], + "query": "", + "total": 1, + "per_page": 20, + "page": 1 + } +} +``` + +______________________________________________________________________ + +#### GET /api/campaigns/{campaign_id} + +Retrieve a specific campaign. + +##### Parameters + +| Name | Type | Required | Description | +|:------------|:----------|:---------|:-------------| +| campaign_id | number | Yes | Campaign ID. | +| no_body | boolean | | When set to true, returns response without body content. | + +##### Example Request + +```shell +curl -u "api_user:token" -X GET 'http://localhost:9000/api/campaigns/1' +``` + +##### Example Response + +```json +{ + "data": { + "id": 1, + "created_at": "2020-03-14T17:36:41.29451+01:00", + "updated_at": "2020-03-14T17:36:41.29451+01:00", + "views": 0, + "clicks": 0, + "lists": [ + { + "id": 1, + "name": "Default list" + } + ], + "started_at": null, + "to_send": 0, + "sent": 0, + "uuid": "57702beb-6fae-4355-a324-c2fd5b59a549", + "type": "regular", + "name": "Test campaign", + "subject": "Welcome to listmonk", + "from_email": "No Reply ", + "body": "

Hi {{ .Subscriber.FirstName }}!

\n\t\t\tThis is a test e-mail campaign. Your second name is {{ .Subscriber.LastName }} and you are from {{ .Subscriber.Attribs.city }}.", + "send_at": "2020-03-15T17:36:41.293233+01:00", + "status": "draft", + "content_type": "richtext", + "tags": [ + "test-campaign" + ], + "template_id": 1, + "messenger": "email" + } +} +``` + +______________________________________________________________________ + +#### GET /api/campaigns/{campaign_id}/preview + +Preview a specific campaign. + +##### Parameters + +| Name | Type | Required | Description | +|:------------|:----------|:---------|:------------------------| +| campaign_id | number | Yes | Campaign ID to preview. | + +##### Example Request + +```shell +curl -u "api_user:token" -X GET 'http://localhost:9000/api/campaigns/1/preview' +``` + +##### Example Response + +```html +

Hi John!

+This is a test e-mail campaign. Your second name is Doe and you are from Bengaluru. +``` + +______________________________________________________________________ + +#### GET /api/campaigns/running/stats + +Retrieve stats of specified campaigns. + +##### Parameters + +| Name | Type | Required | Description | +|:------------|:----------|:---------|:-------------------------------| +| campaign_id | number | Yes | Campaign IDs to get stats for. | + +##### Example Request + +```shell +curl -u "api_user:token" -X GET 'http://localhost:9000/api/campaigns/running/stats?campaign_id=1' +``` + +##### Example Response + +```json +{ + "data": [] +} +``` + +______________________________________________________________________ + +#### GET /api/campaigns/analytics/{type} + +Retrieve stats of specified campaigns. + +##### Parameters + +| Name | Type | Required | Description | +|:------------|:----------|:---------|:----------------------------------------------| +| id |number\[\] | Yes | Campaign IDs to get stats for. | +| type |string | Yes | Analytics type: views, links, clicks, bounces | +| from |string | Yes | Start value of date range. | +| to |string | Yes | End value of date range. | + + +##### Example Request + +```shell +curl -u "api_user:token" -X GET 'http://localhost:9000/api/campaigns/analytics/views?id=1&from=2024-08-04&to=2024-08-12' +``` + +##### Example Response + +```json +{ + "data": [ + { + "campaign_id": 1, + "count": 10, + "timestamp": "2024-08-04T00:00:00Z" + }, + { + "campaign_id": 1, + "count": 14, + "timestamp": "2024-08-08T00:00:00Z" + }, + { + "campaign_id": 1, + "count": 20, + "timestamp": "2024-08-09T00:00:00Z" + }, + { + "campaign_id": 1, + "count": 21, + "timestamp": "2024-08-10T00:00:00Z" + }, + { + "campaign_id": 1, + "count": 21, + "timestamp": "2024-08-11T00:00:00Z" + } + ] +} +``` + +##### Example Request + +```shell +curl -u "api_user:token" -X GET 'http://localhost:9000/api/campaigns/analytics/links?id=1&from=2024-08-04T18%3A30%3A00.624Z&to=2024-08-12T18%3A29%3A00.624Z' +``` + +##### Example Response + +```json +{ + "data": [ + { + "url": "https://freethebears.org", + "count": 294 + }, + { + "url": "https://calmcode.io", + "count": 278 + }, + { + "url": "https://climate.nasa.gov", + "count": 261 + }, + { + "url": "https://www.storybreathing.com", + "count": 260 + } + ] +} +``` + +______________________________________________________________________ + +#### POST /api/campaigns + +Create a new campaign. + +##### Parameters + +| Name | Type | Required | Description | +|:-------------|:----------|:---------|:----------------------------------------------------------------------------------------| +| name | string | Yes | Campaign name. | +| subject | string | Yes | Campaign email subject. | +| lists | number\[\] | Yes | List IDs to send campaign to. | +| from_email | string | | 'From' email in campaign emails. Defaults to value from settings if not provided. | +| type | string | Yes | Campaign type: 'regular' or 'optin'. | +| content_type | string | Yes | Content type: 'richtext', 'html', 'markdown', 'plain'. | +| body | string | Yes | Content body of campaign. | +| altbody | string | | Alternate plain text body for HTML (and richtext) emails. | +| send_at | string | | Timestamp to schedule campaign. Format: 'YYYY-MM-DDTHH:MM:SSZ'. | +| messenger | string | | 'email' or a custom messenger defined in settings. Defaults to 'email' if not provided. | +| template_id | number | | Template ID to use. Defaults to default template if not provided. | +| tags | string\[\] | | Tags to mark campaign. | +| headers | JSON | | Key-value pairs to send as SMTP headers. Example: \[{"x-custom-header": "value"}\]. | + +##### Example request + +```shell +curl -u "api_user:token" 'http://localhost:9000/api/campaigns' -X POST -H 'Content-Type: application/json;charset=utf-8' --data-raw '{"name":"Test campaign","subject":"Hello, world","lists":[1],"from_email":"listmonk ","content_type":"richtext","messenger":"email","type":"regular","tags":["test"],"template_id":1}' +``` + +##### Example response + +```json +{ + "data": { + "id": 1, + "created_at": "2021-12-27T11:50:23.333485Z", + "updated_at": "2021-12-27T11:50:23.333485Z", + "views": 0, + "clicks": 0, + "bounces": 0, + "lists": [{ + "id": 1, + "name": "Default list" + }], + "started_at": null, + "to_send": 1, + "sent": 0, + "uuid": "90c889cc-3728-4064-bbcb-5c1c446633b3", + "type": "regular", + "name": "Test campaign", + "subject": "Hello, world", + "from_email": "listmonk \u003cnoreply@listmonk.yoursite.com\u003e", + "body": "", + "altbody": null, + "send_at": null, + "status": "draft", + "content_type": "richtext", + "tags": ["test"], + "template_id": 1, + "messenger": "email" + } +} +``` + +______________________________________________________________________ + +#### POST /api/campaigns/{campaign_id}/test + +Test campaign with arbitrary subscribers. + +Use the same parameters in [POST /api/campaigns](#post-apicampaigns) in addition to the below parameters. + +##### Parameters + +| Name | Type | Required | Description | +|:------------|:---------|:---------|:---------------------------------------------------| +| subscribers | string\[\] | Yes | List of subscriber e-mails to send the message to. | + +______________________________________________________________________ + +#### PUT /api/campaigns/{campaign_id} + +Update a campaign. + +> Refer to parameters from [POST /api/campaigns](#post-apicampaigns) + +______________________________________________________________________ + +#### PUT /api/campaigns/{campaign_id} + +Update a specific campaign. + +> Refer to parameters from [POST /api/campaigns](#post-apicampaigns) + +______________________________________________________________________ + +#### PUT /api/campaigns/{campaign_id}/status + +Change status of a campaign. + +##### Parameters + +| Name | Type | Required | Description | +|:------------|:----------|:---------|:------------------------------------------------------------------------| +| campaign_id | number | Yes | Campaign ID to change status. | +| status | string | Yes | New status for campaign: 'scheduled', 'running', 'paused', 'cancelled'. | + +##### Note + +> - Only 'scheduled' campaigns can change status to 'draft'. +> - Only 'draft' campaigns can change status to 'scheduled'. +> - Only 'paused' and 'draft' campaigns can start ('running' status). +> - Only 'running' campaigns can change status to 'cancelled' and 'paused'. + +##### Example Request + +```shell +curl -u "api_user:token" -X PUT 'http://localhost:9000/api/campaigns/1/status' \ +--header 'Content-Type: application/json' \ +--data-raw '{"status":"scheduled"}' +``` + +##### Example Response + +```json +{ + "data": { + "id": 1, + "created_at": "2020-03-14T17:36:41.29451+01:00", + "updated_at": "2020-04-08T19:35:17.331867+01:00", + "views": 0, + "clicks": 0, + "lists": [ + { + "id": 1, + "name": "Default list" + } + ], + "started_at": null, + "to_send": 0, + "sent": 0, + "uuid": "57702beb-6fae-4355-a324-c2fd5b59a549", + "type": "regular", + "name": "Test campaign", + "subject": "Welcome to listmonk", + "from_email": "No Reply ", + "body": "

Hi {{ .Subscriber.FirstName }}!

\n\t\t\tThis is a test e-mail campaign. Your second name is {{ .Subscriber.LastName }} and you are from {{ .Subscriber.Attribs.city }}.", + "send_at": "2020-03-15T17:36:41.293233+01:00", + "status": "scheduled", + "content_type": "richtext", + "tags": [ + "test-campaign" + ], + "template_id": 1, + "messenger": "email" + } +} +``` + +______________________________________________________________________ + +#### PUT /api/campaigns/{campaign_id}/archive + +Publish campaign to public archive. + +##### Parameters + +| Name | Type | Required | Description | +|:-------------------|:-----------|:---------|:-------------------------------------------------------------------------| +| campaign_id | number | Yes | Campaign ID to publish to public archive. | +| archive | bool | Yes | State of the public archive. | +| archive_template_id| number | No | Archive template id. Defaults to 0. | +| archive_meta | JSON string| No | Optional Metadata to use in campaign message or template.Eg: name, email.| +| archive_slug | string | No | Name for page to be used in public archive URL | + + +##### Example Request + +```shell + +curl -u "api_user:token" -X PUT 'http://localhost:8080/api/campaigns/33/archive' +--header 'Content-Type: application/json' +--data-raw '{"archive":true,"archive_template_id":1,"archive_meta":{},"archive_slug":"my-newsletter-old-edition"}' +``` + +##### Example Response + +```json +{ + "data": { + "archive": true, + "archive_template_id": 1, + "archive_meta": {}, + "archive_slug": "my-newsletter-old-edition" + } +} +``` + +______________________________________________________________________ + +#### DELETE /api/campaigns/{campaign_id} + +Delete a campaign. + +##### Parameters + +| Name | Type | Required | Description | +|:------------|:----------|:---------|:-----------------------| +| campaign_id | number | Yes | Campaign ID to delete. | + +##### Example Request + +```shell +curl -u "api_user:token" -X DELETE 'http://localhost:9000/api/campaigns/34' +``` + +##### Example Response + +```json +{ + "data": true +} +``` diff --git a/docs/docs/content/apis/import.md b/docs/docs/content/apis/import.md new file mode 100644 index 000000000..e565e6a50 --- /dev/null +++ b/docs/docs/content/apis/import.md @@ -0,0 +1,119 @@ +# API / Import + +Method | Endpoint | Description +---------|-------------------------------------------------|------------------------------------------------ +GET | [/api/import/subscribers](#get-apiimportsubscribers) | Retrieve import statistics. +GET | [/api/import/subscribers/logs](#get-apiimportsubscriberslogs) | Retrieve import logs. +POST | [/api/import/subscribers](#post-apiimportsubscribers) | Upload a file for bulk subscriber import. +DELETE | [/api/import/subscribers](#delete-apiimportsubscribers) | Stop and remove an import. + +______________________________________________________________________ + +#### GET /api/import/subscribers + +Retrieve the status of an ongoing import. + +##### Example Request + +```shell +curl -u "api_user:token" -X GET 'http://localhost:9000/api/import/subscribers' +``` + +##### Example Response + +```json +{ + "data": { + "name": "", + "total": 0, + "imported": 0, + "status": "none" + } +} +``` + +______________________________________________________________________ + +#### GET /api/import/subscribers/logs + +Retrieve logs from an ongoing import. + +##### Example Request + +```shell +curl -u "api_user:token" -X GET 'http://localhost:9000/api/import/subscribers/logs' +``` + +##### Example Response + +```json +{ + "data": "2020/04/08 21:55:20 processing 'import.csv'\n2020/04/08 21:55:21 imported finished\n" +} +``` + +______________________________________________________________________ + +#### POST /api/import/subscribers + +Send a CSV (optionally ZIP compressed) file to import subscribers. Use a multipart form POST. + +##### Parameters + +| Name | Type | Required | Description | +|:-------|:------------|:---------|:-----------------------------------------| +| params | JSON string | Yes | Stringified JSON with import parameters. | +| file | file | Yes | File for upload. | + + +#### `params` (JSON string) +| Name | Type | Required | Description | +|:----------|:---------|:---------|:-----------------------------------------------------------------------------------------------------------------------------------| +| mode | string | Yes | `subscribe` or `blocklist` | +| delim | string | Yes | Single character indicating delimiter used in the CSV file, eg: `,` | +| lists | []number | Yes | Single character indicating delimiter used in the CSV file, eg: `,` | +| overwrite | bool | Yes | Whether to overwrite the subscriber parameters including subscriptions or ignore records that are already present in the database. | + +##### Example Request + +```shell +curl -u "api_user:token" -X POST 'http://localhost:9000/api/import/subscribers' \ + -F 'params={"mode":"subscribe", "subscription_status":"confirmed", "delim":",", "lists":[1, 2], "overwrite": true}' \ + -F "file=@/path/to/subs.csv" +``` + +##### Example Response + +```json + { + "mode": "subscribe", // subscribe or blocklist + "delim": ",", // delimiter in the uploaded file + "lists":[1], // array of list IDs to import into + "overwrite": true // overwrite existing entries or skip them? + } +``` + +______________________________________________________________________ + +#### DELETE /api/import/subscribers + +Stop and delete an ongoing import. + +##### Example Request + +```shell +curl -u "api_user:token" -X DELETE 'http://localhost:9000/api/import/subscribers' +``` + +##### Example Response + +```json +{ + "data": { + "name": "", + "total": 0, + "imported": 0, + "status": "none" + } +} +``` diff --git a/docs/docs/content/apis/lists.md b/docs/docs/content/apis/lists.md new file mode 100644 index 000000000..ef62da22b --- /dev/null +++ b/docs/docs/content/apis/lists.md @@ -0,0 +1,241 @@ +# API / Lists + +| Method | Endpoint | Description | +|:-------|:------------------------------------------------|:--------------------------| +| GET | [/api/lists](#get-apilists) | Retrieve all lists. | +| GET | [/api/public/lists](#get-public-apilists) | Retrieve public lists.| +| GET | [/api/lists/{list_id}](#get-apilistslist_id) | Retrieve a specific list. | +| POST | [/api/lists](#post-apilists) | Create a new list. | +| PUT | [/api/lists/{list_id}](#put-apilistslist_id) | Update a list. | +| DELETE | [/api/lists/{list_id}](#delete-apilistslist_id) | Delete a list. | + +______________________________________________________________________ + +#### GET /api/lists + +Retrieve lists. + +##### Parameters + +| Name | Type | Required | Description | +|:---------|:---------|:---------|:-----------------------------------------------------------------| +| query | string | | string for list name search. | +| status | []string | | Status to filter lists. Repeat in the query for multiple values. | +| tag | []string | | Tags to filter lists. Repeat in the query for multiple values. | +| order_by | string | | Sort field. Options: name, status, created_at, updated_at. | +| order | string | | Sorting order. Options: ASC, DESC. | +| page | number | | Page number for pagination. | +| per_page | number | | Results per page. Set to 'all' to return all results. | + +##### Example Request + +```shell +curl -u "api_user:token" -X GET 'http://localhost:9000/api/lists?page=1&per_page=100' +``` + +##### Example Response + +```json +{ + "data": { + "results": [ + { + "id": 1, + "created_at": "2020-02-10T23:07:16.194843+01:00", + "updated_at": "2020-03-06T22:32:01.118327+01:00", + "uuid": "ce13e971-c2ed-4069-bd0c-240e9a9f56f9", + "name": "Default list", + "type": "public", + "optin": "double", + "tags": [ + "test" + ], + "subscriber_count": 2 + }, + { + "id": 2, + "created_at": "2020-03-04T21:12:09.555013+01:00", + "updated_at": "2020-03-06T22:34:46.405031+01:00", + "uuid": "f20a2308-dfb5-4420-a56d-ecf0618a102d", + "name": "get", + "type": "private", + "optin": "single", + "tags": [], + "subscriber_count": 0 + } + ], + "total": 5, + "per_page": 20, + "page": 1 + } +} +``` + +______________________________________________________________________ + +#### GET /api/public/lists + +Retrieve public lists with name and uuid to submit a subscription. This is an unauthenticated call to enable scripting to subscription form. + +##### Example Request + +```shell +curl -X GET 'http://localhost:9000/api/public/lists' +``` + +##### Example Response + +```json +[ + { + "uuid": "55e243af-80c6-4169-8d7f-bc571e0269e9", + "name": "Opt-in list" + } +] +``` +______________________________________________________________________ + +#### GET /api/lists/{list_id} + +Retrieve a specific list. + +##### Parameters + +| Name | Type | Required | Description | +|:--------|:----------|:---------|:----------------------------| +| list_id | number | Yes | ID of the list to retrieve. | + +##### Example Request + +```shell +curl -u "api_user:token" -X GET 'http://localhost:9000/api/lists/5' +``` + +##### Example Response + +```json +{ + "data": { + "id": 5, + "created_at": "2020-03-07T06:31:06.072483+01:00", + "updated_at": "2020-03-07T06:31:06.072483+01:00", + "uuid": "1bb246ab-7417-4cef-bddc-8fc8fc941d3a", + "name": "Test list", + "type": "public", + "optin": "double", + "tags": [], + "subscriber_count": 0 + } +} +``` + +______________________________________________________________________ + +#### POST /api/lists + +Create a new list. + +##### Parameters + +| Name | Type | Required | Description | +|:------|:----------|:---------|:----------------------------------------| +| name | string | Yes | Name of the new list. | +| type | string | Yes | Type of list. Options: private, public. | +| optin | string | Yes | Opt-in type. Options: single, double. | +| tags | string\[\] | | Associated tags for a list. | +| description | string | No | Description of the new list. | + +##### Example Request + +```shell +curl -u "api_user:token" -X POST 'http://localhost:9000/api/lists' +``` + +##### Example Response + +```json +{ + "data": { + "id": 5, + "created_at": "2020-03-07T06:31:06.072483+01:00", + "updated_at": "2020-03-07T06:31:06.072483+01:00", + "uuid": "1bb246ab-7417-4cef-bddc-8fc8fc941d3a", + "name": "Test list", + "type": "public", + "tags": [], + "subscriber_count": 0, + "description": "This is a test list" + } +} +null +``` + +______________________________________________________________________ + +#### PUT /api/lists/{list_id} + +Update a list. + +##### Parameters + +| Name | Type | Required | Description | +|:--------|:----------|:---------|:----------------------------------------| +| list_id | number | Yes | ID of the list to update. | +| name | string | | New name for the list. | +| type | string | | Type of list. Options: private, public. | +| optin | string | | Opt-in type. Options: single, double. | +| tags | string\[\] | | Associated tags for the list. | +| description | string | | Description of the new list. | + +##### Example Request + +```shell +curl -u "api_user:token" -X PUT 'http://localhost:9000/api/lists/5' \ +--form 'name=modified test list' \ +--form 'type=private' +``` + +##### Example Response + +```json +{ + "data": { + "id": 5, + "created_at": "2020-03-07T06:31:06.072483+01:00", + "updated_at": "2020-03-07T06:52:15.208075+01:00", + "uuid": "1bb246ab-7417-4cef-bddc-8fc8fc941d3a", + "name": "modified test list", + "type": "private", + "optin": "single", + "tags": [], + "subscriber_count": 0, + "description": "This is a test list" + } +} +``` + +______________________________________________________________________ + +#### DELETE /api/lists/{list_id} + +Delete a specific subscriber. + +##### Parameters + +| Name | Type | Required | Description | +|:--------|:----------|:---------|:--------------------------| +| list_id | Number | Yes | ID of the list to delete. | + +##### Example Request + +```shell +curl -u 'api_username:access_token' -X DELETE 'http://localhost:9000/api/lists/1' +``` + +##### Example Response + +```json +{ + "data": true +} +``` diff --git a/docs/docs/content/apis/media.md b/docs/docs/content/apis/media.md new file mode 100644 index 000000000..6f2654547 --- /dev/null +++ b/docs/docs/content/apis/media.md @@ -0,0 +1,134 @@ +# API / Media + +Method | Endpoint | Description +-------|------------------------------------------------------|--------------------------------- +GET | [/api/media](#get-apimedia) | Get uploaded media file +GET | [/api/media/{media_id}](#get-apimediamedia_id) | Get specific uploaded media file +POST | [/api/media](#post-apimedia) | Upload media file +DELETE | [/api/media/{media_id}](#delete-apimediamedia_id) | Delete uploaded media file + +______________________________________________________________________ + +#### GET /api/media + +Get an uploaded media file. + +##### Example Request + +```shell +curl -u "api_user:token" -X GET 'http://localhost:9000/api/media' \ +--header 'Content-Type: multipart/form-data; boundary=--------------------------093715978792575906250298' +``` + +##### Example Response + +```json +{ + "data": [ + { + "id": 1, + "uuid": "ec7b45ce-1408-4e5c-924e-965326a20287", + "filename": "Media file", + "created_at": "2020-04-08T22:43:45.080058+01:00", + "thumb_url": "/uploads/image_thumb.jpg", + "uri": "/uploads/image.jpg" + } + ] +} +``` +______________________________________________________________________ + +#### GET /api/media/{media_id} + +Retrieve a specific media. + +##### Parameters + +| Name | Type | Required | Description | +|:--------------|:----------|:---------|:-----------------| +| media_id | Number | Yes | Media ID. | + +##### Example Request + +```shell +curl -u 'api_username:access_token' 'http://localhost:9000/api/media/7' +``` + +##### Example Response + +```json +{ + "data": + { + "id": 7, + "uuid": "62e32e97-d6ca-4441-923f-b62607000dd1", + "filename": "ResumeB.pdf", + "content_type": "application/pdf", + "created_at": "2024-08-06T11:28:53.888257+05:30", + "thumb_url": null, + "provider": "filesystem", + "meta": {}, + "url": "http://localhost:9000/uploads/ResumeB.pdf" + } +} +``` +______________________________________________________________________ + +#### POST /api/media + +Upload a media file. + +##### Parameters + +| Field | Type | Required | Description | +|-------|-----------|----------|---------------------| +| file | File | Yes | Media file to upload| + +##### Example Request + +```shell +curl -u "api_user:token" -X POST 'http://localhost:9000/api/media' \ +--header 'Content-Type: multipart/form-data; boundary=--------------------------183679989870526937212428' \ +--form 'file=@/path/to/image.jpg' +``` + +##### Example Response + +```json +{ + "data": { + "id": 1, + "uuid": "ec7b45ce-1408-4e5c-924e-965326a20287", + "filename": "Media file", + "created_at": "2020-04-08T22:43:45.080058+01:00", + "thumb_uri": "/uploads/image_thumb.jpg", + "uri": "/uploads/image.jpg" + } +} +``` + +______________________________________________________________________ + +#### DELETE /api/media/{media_id} + +Delete an uploaded media file. + +##### Parameters + +| Field | Type | Required | Description | +|----------|-----------|----------|-------------------------| +| media_id | number | Yes | ID of media file to delete | + +##### Example Request + +```shell +curl -u "api_user:token" -X DELETE 'http://localhost:9000/api/media/1' +``` + +##### Example Response + +```json +{ + "data": true +} +``` diff --git a/docs/docs/content/apis/sdks.md b/docs/docs/content/apis/sdks.md new file mode 100644 index 000000000..167dc4c8c --- /dev/null +++ b/docs/docs/content/apis/sdks.md @@ -0,0 +1,20 @@ +# SDKs and client libraries + +A list of 3rd party client libraries and SDKs that have been written for listmonk APIs. + +!!! note + The list is community sourced. They have not been verified and are not officially supported. + +- [WordPress - WooCommerce plugin](https://github.com/post-duif/integration-listmonk-wordpress-plugin) integration for listmonk +- [listmonk ](https://github.com/mikeckennedy/listmonk) — Python API client +- [listmonk-api](https://github.com/Knuckles-Team/listmonk-api) — Python API client +- [frappe_listmonk](https://github.com/anandology/frappe_listmonk) — Frappe framework integration for listmonk +- [auto-newsletter-listmonk](https://github.com/chaddyc/auto-newsletter-listmonk) — Ghost CMS integration +- [listmonk-newsletter](https://github.com/iloveitaly/listmonk-newsletter) - RSS to listmonk integration for email newsletters +- [listmonk-crysctal](https://github.com/russ/listmonk-crystal) — Crystal lang API client +- [terraform-provider-listmonk](https://github.com/Muravlev/terraform-provider-listmonk) — Manage listmonk templates in Terraform +- [listmonk-php-client](https://github.com/arunnabraham/listmonk-php-client) — PHP API client +- [php-listmonk](https://github.com/junisan/php-listmonk) — PHP API client +- [go-listmonk](https://github.com/EzeXchange-API/go-listmonk) — Go API client +- [listmonk-nodejs-api](https://github.com/mihairaulea/listmonk-nodejs-api) — NodeJS API client +- - [listmonk-laravel](https://github.com/theafolayan/listmonk-laravel) — Laravel API Client diff --git a/docs/docs/content/apis/subscribers.md b/docs/docs/content/apis/subscribers.md new file mode 100644 index 000000000..492b3a6c9 --- /dev/null +++ b/docs/docs/content/apis/subscribers.md @@ -0,0 +1,628 @@ +# API / Subscribers + +| Method | Endpoint | Description | +| ------ | --------------------------------------------------------------------------------------- | ---------------------------------------------- | +| GET | [/api/subscribers](#get-apisubscribers) | Query and retrieve subscribers. | +| GET | [/api/subscribers/{subscriber_id}](#get-apisubscriberssubscriber_id) | Retrieve a specific subscriber. | +| GET | [/api/subscribers/{subscriber_id}/export](#get-apisubscriberssubscriber_idexport) | Export a specific subscriber. | +| GET | [/api/subscribers/{subscriber_id}/bounces](#get-apisubscriberssubscriber_idbounces) | Retrieve a subscriber bounce records. | +| POST | [/api/subscribers](#post-apisubscribers) | Create a new subscriber. | +| POST | [/api/subscribers/{subscriber_id}/optin](#post-apisubscriberssubscriber_idoptin) | Sends optin confirmation email to subscribers. | +| POST | [/api/public/subscription](#post-apipublicsubscription) | Create a public subscription. | +| PUT | [/api/subscribers/lists](#put-apisubscriberslists) | Modify subscriber list memberships. | +| PUT | [/api/subscribers/{subscriber_id}](#put-apisubscriberssubscriber_id) | Update a specific subscriber. | +| PUT | [/api/subscribers/{subscriber_id}/blocklist](#put-apisubscriberssubscriber_idblocklist) | Blocklist a specific subscriber. | +| PUT | [/api/subscribers/blocklist](#put-apisubscribersblocklist) | Blocklist one or many subscribers. | +| PUT | [/api/subscribers/query/blocklist](#put-apisubscribersqueryblocklist) | Blocklist subscribers based on SQL expression. | +| DELETE | [/api/subscribers/{subscriber_id}](#delete-apisubscriberssubscriber_id) | Delete a specific subscriber. | +| DELETE | [/api/subscribers/{subscriber_id}/bounces](#delete-apisubscriberssubscriber_idbounces) | Delete a specific subscriber's bounce records. | +| DELETE | [/api/subscribers](#delete-apisubscribers) | Delete one or more subscribers. | +| POST | [/api/subscribers/query/delete](#post-apisubscribersquerydelete) | Delete subscribers based on SQL expression. | + +______________________________________________________________________ + +#### GET /api/subscribers + +Retrieve all subscribers. + +##### Query parameters + +| Name | Type | Required | Description | +|:--------------------|:-------|:---------|:----------------------------------------------------------------------| +| query | string | | Subscriber search by SQL expression. | +| list_id | int[] | | ID of lists to filter by. Repeat in the query for multiple values. | +| subscription_status | string | | Subscription status to filter by if there are one or more `list_id`s. | +| order_by | string | | Result sorting field. Options: name, status, created_at, updated_at. | +| order | string | | Sorting order: ASC for ascending, DESC for descending. | +| page | number | | Page number for paginated results. | +| per_page | number | | Results per page. Set as 'all' for all results. | + +##### Example Request + +```shell +curl -u 'api_username:access_token' 'http://localhost:9000/api/subscribers?page=1&per_page=100' +``` + +```shell +curl -u 'api_username:access_token' 'http://localhost:9000/api/subscribers?list_id=1&list_id=2&page=1&per_page=100' +``` + +```shell +curl -u 'api_username:access_token' -X GET 'http://localhost:9000/api/subscribers' \ + --url-query 'page=1' \ + --url-query 'per_page=100' \ + --url-query "query=subscribers.name LIKE 'Test%' AND subscribers.attribs->>'city' = 'Bengaluru'" +``` + +##### Example Response + +```json +{ + "data": { + "results": [ + { + "id": 1, + "created_at": "2020-02-10T23:07:16.199433+01:00", + "updated_at": "2020-02-10T23:07:16.199433+01:00", + "uuid": "ea06b2e7-4b08-4697-bcfc-2a5c6dde8f1c", + "email": "john@example.com", + "name": "John Doe", + "attribs": { + "city": "Bengaluru", + "good": true, + "type": "known" + }, + "status": "enabled", + "lists": [ + { + "subscription_status": "unconfirmed", + "id": 1, + "uuid": "ce13e971-c2ed-4069-bd0c-240e9a9f56f9", + "name": "Default list", + "type": "public", + "tags": [ + "test" + ], + "created_at": "2020-02-10T23:07:16.194843+01:00", + "updated_at": "2020-02-10T23:07:16.194843+01:00" + } + ] + }, + { + "id": 2, + "created_at": "2020-02-18T21:10:17.218979+01:00", + "updated_at": "2020-02-18T21:10:17.218979+01:00", + "uuid": "ccf66172-f87f-4509-b7af-e8716f739860", + "email": "quadri@example.com", + "name": "quadri", + "attribs": {}, + "status": "enabled", + "lists": [ + { + "subscription_status": "unconfirmed", + "id": 1, + "uuid": "ce13e971-c2ed-4069-bd0c-240e9a9f56f9", + "name": "Default list", + "type": "public", + "tags": [ + "test" + ], + "created_at": "2020-02-10T23:07:16.194843+01:00", + "updated_at": "2020-02-10T23:07:16.194843+01:00" + } + ] + }, + { + "id": 3, + "created_at": "2020-02-19T19:10:49.36636+01:00", + "updated_at": "2020-02-19T19:10:49.36636+01:00", + "uuid": "5d940585-3cc8-4add-b9c5-76efba3c6edd", + "email": "sugar@example.com", + "name": "sugar", + "attribs": {}, + "status": "enabled", + "lists": [] + } + ], + "query": "", + "total": 3, + "per_page": 20, + "page": 1 + } +} +``` + +______________________________________________________________________ + +#### GET /api/subscribers/{subscriber_id} + +Retrieve a specific subscriber. + +##### Parameters + +| Name | Type | Required | Description | +|:--------------|:----------|:---------|:-----------------| +| subscriber_id | Number | Yes | Subscriber's ID. | + +##### Example Request + +```shell +curl -u 'api_username:access_token' 'http://localhost:9000/api/subscribers/1' +``` + +##### Example Response + +```json +{ + "data": { + "id": 1, + "created_at": "2020-02-10T23:07:16.199433+01:00", + "updated_at": "2020-02-10T23:07:16.199433+01:00", + "uuid": "ea06b2e7-4b08-4697-bcfc-2a5c6dde8f1c", + "email": "john@example.com", + "name": "John Doe", + "attribs": { + "city": "Bengaluru", + "good": true, + "type": "known" + }, + "status": "enabled", + "lists": [ + { + "subscription_status": "unconfirmed", + "id": 1, + "uuid": "ce13e971-c2ed-4069-bd0c-240e9a9f56f9", + "name": "Default list", + "type": "public", + "tags": [ + "test" + ], + "created_at": "2020-02-10T23:07:16.194843+01:00", + "updated_at": "2020-02-10T23:07:16.194843+01:00" + } + ] + } +} +``` +______________________________________________________________________ + +#### GET /api/subscribers/{subscriber_id}/export + +Export a specific subscriber data that gives profile, list subscriptions, campaign views and link clicks information. Names of private lists are replaced with "Private list". + +##### Parameters + +| Name | Type | Required | Description | +|:--------------|:----------|:---------|:-----------------| +| subscriber_id | Number | Yes | Subscriber's ID. | + +##### Example Request + +```shell +curl -u 'api_username:access_token' 'http://localhost:9000/api/subscribers/1/export' +``` + +##### Example Response + +```json +{ + "profile": [ + { + "id": 1, + "uuid": "c2cc0b31-b485-4d72-8ce8-b47081beadec", + "email": "john@example.com", + "name": "John Doe", + "attribs": { + "city": "Bengaluru", + "good": true, + "type": "known" + }, + "status": "enabled", + "created_at": "2024-07-29T11:01:31.478677+05:30", + "updated_at": "2024-07-29T11:01:31.478677+05:30" + } + ], + "subscriptions": [ + { + "subscription_status": "unconfirmed", + "name": "Private list", + "type": "private", + "created_at": "2024-07-29T11:01:31.478677+05:30" + } + ], + "campaign_views": [], + "link_clicks": [] +} +``` +______________________________________________________________________ + +#### GET /api/subscribers/{subscriber_id}/bounces + +Get a specific subscriber bounce records. +##### Parameters + +| Name | Type | Required | Description | +|:--------------|:----------|:---------|:-----------------| +| subscriber_id | Number | Yes | Subscriber's ID. | + +##### Example Request + +```shell +curl -u 'api_username:access_token' 'http://localhost:9000/api/subscribers/1/bounces' +``` + +##### Example Response + +```json +{ + "data": [ + { + "id": 841706, + "type": "hard", + "source": "demo", + "meta": { + "some": "parameter" + }, + "created_at": "2024-08-22T09:05:12.862877Z", + "email": "thomas.hobbes@example.com", + "subscriber_uuid": "137c0d83-8de6-44e2-a55f-d4238ab21969", + "subscriber_id": 99, + "campaign": { + "id": 2, + "name": "Welcome to listmonk" + } + }, + { + "id": 841680, + "type": "hard", + "source": "demo", + "meta": { + "some": "parameter" + }, + "created_at": "2024-08-19T14:07:53.141917Z", + "email": "thomas.hobbes@example.com", + "subscriber_uuid": "137c0d83-8de6-44e2-a55f-d4238ab21969", + "subscriber_id": 99, + "campaign": { + "id": 1, + "name": "Test campaign" + } + } + ] +} +``` + +______________________________________________________________________ + +#### POST /api/subscribers + +Create a new subscriber. + +##### Parameters + +| Name | Type | Required | Description | +|:-------------------------|:----------|:---------|:-----------------------------------------------------------------------------------------------------| +| email | string | Yes | Subscriber's email address. | +| name | string | Yes | Subscriber's name. | +| status | string | Yes | Subscriber's status: `enabled`, `blocklisted`. | +| lists | number\[\] | | List of list IDs to subscribe to. | +| attribs | JSON | | Attributes of the new subscriber. | +| preconfirm_subscriptions | bool | | If true, subscriptions are marked as confirmed and no-optin emails are sent for double opt-in lists. | + +##### Example Request + +```shell +curl -u 'api_username:access_token' 'http://localhost:9000/api/subscribers' -H 'Content-Type: application/json' \ + --data '{"email":"subscriber@domain.com","name":"The Subscriber","status":"enabled","lists":[1],"attribs":{"city":"Bengaluru","projects":3,"stack":{"languages":["go","python"]}}}' +``` + +##### Example Response + +```json +{ + "data": { + "id": 3, + "created_at": "2019-07-03T12:17:29.735507+05:30", + "updated_at": "2019-07-03T12:17:29.735507+05:30", + "uuid": "eb420c55-4cfb-4972-92ba-c93c34ba475d", + "email": "subscriber@domain.com", + "name": "The Subscriber", + "attribs": { + "city": "Bengaluru", + "projects": 3, + "stack": { "languages": ["go", "python"] } + }, + "status": "enabled", + "lists": [1] + } +} +``` + +______________________________________________________________________ + +#### POST /api/subscribers/{subscribers_id}/optin + +Sends optin confirmation email to subscribers. + +##### Example Request + +```shell +curl -u 'api_username:access_token' 'http://localhost:9000/api/subscribers/11/optin' -H 'Content-Type: application/json' \ +--data {} +``` + +##### Example Response + +```json +{ + "data": true +} +``` +______________________________________________________________________ + +#### POST /api/public/subscription + +Create a public subscription, accepts both form encoded or JSON encoded body. + +##### Parameters + +| Name | Type | Required | Description | +|:-----------|:----------|:---------|:----------------------------| +| email | string | Yes | Subscriber's email address. | +| name | string | | Subscriber's name. | +| list_uuids | string\[\] | Yes | List of list UUIDs. | + +##### Example JSON Request + +```shell +curl 'http://localhost:9000/api/public/subscription' -H 'Content-Type: application/json' \ + --data '{"email":"subscriber@domain.com","name":"The Subscriber","list_uuids": ["eb420c55-4cfb-4972-92ba-c93c34ba475d", "0c554cfb-eb42-4972-92ba-c93c34ba475d"]}' +``` + +##### Example Form Request + +```shell +curl -u 'http://localhost:9000/api/public/subscription' \ + -d 'email=subscriber@domain.com' -d 'name=The Subscriber' -d 'l=eb420c55-4cfb-4972-92ba-c93c34ba475d' -d 'l=0c554cfb-eb42-4972-92ba-c93c34ba475d' +``` + +Note: For form request, use `l` for multiple lists instead of `lists`. + +##### Example Response + +```json +{ + "data": true +} +``` + +______________________________________________________________________ + +#### PUT /api/subscribers/lists + +Modify subscriber list memberships. + +##### Parameters + +| Name | Type | Required | Description | +|:----------------|:----------|:-------------------|:------------------------------------------------------------------| +| ids | number\[\] | Yes | Array of user IDs to be modified. | +| action | string | Yes | Action to be applied: `add`, `remove`, or `unsubscribe`. | +| target_list_ids | number\[\] | Yes | Array of list IDs to be modified. | +| status | string | Required for `add` | Subscriber status: `confirmed`, `unconfirmed`, or `unsubscribed`. | + +##### Example Request + +```shell +curl -u 'api_username:access_token' -X PUT 'http://localhost:9000/api/subscribers/lists' \ +-H 'Content-Type: application/json' \ +--data-raw '{"ids": [1, 2, 3], "action": "add", "target_list_ids": [4, 5, 6], "status": "confirmed"}' +``` + +##### Example Response + +```json +{ + "data": true +} +``` + +______________________________________________________________________ + +#### PUT /api/subscribers/{subscriber_id} + +Update a specific subscriber. + +> Refer to parameters from [POST /api/subscribers](#post-apisubscribers). Note: All parameters must be set, if not, the subscriber will be removed from all previously assigned lists. + +______________________________________________________________________ + +#### PUT /api/subscribers/{subscriber_id}/blocklist + +Blocklist a specific subscriber. + +##### Parameters + +| Name | Type | Required | Description | +|:--------------|:----------|:---------|:-----------------| +| subscriber_id | Number | Yes | Subscriber's ID. | + +##### Example Request + +```shell +curl -u 'api_username:access_token' -X PUT 'http://localhost:9000/api/subscribers/9/blocklist' +``` + +##### Example Response + +```json +{ + "data": true +} +``` + +______________________________________________________________________ + +#### PUT /api/subscribers/blocklist + +Blocklist multiple subscriber. + +##### Parameters + +| Name | Type | Required | Description | +|:--------------|:----------|:---------|:-----------------| +| ids | Number | Yes | Subscriber's ID. | + +##### Example Request + +```shell +curl -u 'api_username:access_token' -X PUT 'http://localhost:8080/api/subscribers/blocklist' -H 'Content-Type: application/json' --data-raw '{"ids":[2,1]}' +``` + +##### Example Response + +```json +{ + "data": true +} +``` + +______________________________________________________________________ + +#### PUT /api/subscribers/query/blocklist + +Blocklist subscribers based on SQL expression. + +> Refer to the [querying and segmentation](../querying-and-segmentation.md#querying-and-segmenting-subscribers) section for more information on how to query subscribers with SQL expressions. + +##### Parameters + +| Name | Type | Required | Description | +|:---------|:---------|:---------|:--------------------------------------------| +| query | string | Yes | SQL expression to filter subscribers with. | +| list_ids | []number | No | Optional list IDs to limit the filtering to.| + +##### Example Request + +```shell +curl -u 'api_username:access_token' -X POST 'http://localhost:9000/api/subscribers/query/blocklist' \ +-H 'Content-Type: application/json' \ +--data-raw '{"query":"subscribers.name LIKE \'John Doe\' AND subscribers.attribs->>'\''city'\'' = '\''Bengaluru'\''"}' +``` + +##### Example Response + +```json +{ + "data": true +} +``` + +______________________________________________________________________ + +#### DELETE /api/subscribers/{subscriber_id} + +Delete a specific subscriber. + +##### Parameters + +| Name | Type | Required | Description | +|:--------------|:----------|:---------|:-----------------| +| subscriber_id | Number | Yes | Subscriber's ID. | + +##### Example Request + +```shell +curl -u 'api_username:access_token' -X DELETE 'http://localhost:9000/api/subscribers/9' +``` + +##### Example Response + +```json +{ + "data": true +} +``` + +______________________________________________________________________ + +#### DELETE /api/subscribers/{subscriber_id}/bounces + +Delete a subscriber's bounce records + +##### Parameters + +| Name | Type | Required | Description | +|:-----|:--------------|:---------|:---------------------------| +| id | subscriber_id | Yes | Subscriber's ID. | + +##### Example Request + +```shell +curl -u 'api_username:access_token' -X DELETE 'http://localhost:9000/api/subscribers/9/bounces' +``` + +##### Example Response + +```json +{ + "data": true +} +``` + +______________________________________________________________________ + +#### DELETE /api/subscribers + +Delete one or more subscribers. + +##### Parameters + +| Name | Type | Required | Description | +|:-----|:----------|:---------|:---------------------------| +| id | number\[\] | Yes | Array of subscriber's IDs. | + +##### Example Request + +```shell +curl -u 'api_username:access_token' -X DELETE 'http://localhost:9000/api/subscribers?id=10&id=11' +``` + +##### Example Response + +```json +{ + "data": true +} +``` + +______________________________________________________________________ + +#### POST /api/subscribers/query/delete + +Delete subscribers based on SQL expression. + +##### Parameters + +| Name | Type | Required | Description | +|:---------|:---------|:---------|:-------------------------------------------------------------------| +| query | string | No | SQL expression to filter subscribers with. | +| list_ids | []number | No | Optional list IDs to limit the filtering to. | +| all | bool | No | When set to `true`, ignores any query and deletes all subscribers. | + + +##### Example Request + +```shell +curl -u 'api_username:access_token' -X POST 'http://localhost:9000/api/subscribers/query/delete' \ +-H 'Content-Type: application/json' \ +--data-raw '{"query":"subscribers.name LIKE \'John Doe\' AND subscribers.attribs->>'\''city'\'' = '\''Bengaluru'\''"}' +``` + +##### Example Response + +```json +{ + "data": true +} +``` diff --git a/docs/docs/content/apis/templates.md b/docs/docs/content/apis/templates.md new file mode 100644 index 000000000..0d2a0eb23 --- /dev/null +++ b/docs/docs/content/apis/templates.md @@ -0,0 +1,225 @@ +# API / Templates + +| Method | Endpoint | Description | +|:-------|:------------------------------------------------------------------------------|:-------------------------------| +| GET | [/api/templates](#get-apitemplates) | Retrieve all templates | +| GET | [/api/templates/{template_id}](#get-apitemplates-template_id) | Retrieve a template | +| GET | [/api/templates/{template_id}/preview](#get-apitemplates-template_id-preview) | Retrieve template HTML preview | +| POST | [/api/templates](#post-apitemplates) | Create a template | +| POST | /api/templates/preview | Render and preview a template | +| PUT | [/api/templates/{template_id}](#put-apitemplatestemplate_id) | Update a template | +| PUT | [/api/templates/{template_id}/default](#put-apitemplates-template_id-default) | Set default template | +| DELETE | [/api/templates/{template_id}](#delete-apitemplates-template_id) | Delete a template | + +______________________________________________________________________ + +#### GET /api/templates + +Retrieve all templates. + +##### Example Request + +```shell +curl -u "api_user:token" -X GET 'http://localhost:9000/api/templates' +``` + +##### Example Response + +```json +{ + "data": [ + { + "id": 1, + "created_at": "2020-03-14T17:36:41.288578+01:00", + "updated_at": "2020-03-14T17:36:41.288578+01:00", + "name": "Default template", + "body": "{{ template \"content\" . }}", + "type": "campaign", + "is_default": true + } + ] +} +``` + +______________________________________________________________________ + +#### GET /api/templates/{template_id} + +Retrieve a specific template. + +##### Parameters + +| Name | Type | Required | Description | +|:------------|:----------|:---------|:-------------------------------| +| template_id | number | Yes | ID of the template to retrieve | + +##### Example Request + +```shell +curl -u "api_user:token" -X GET 'http://localhost:9000/api/templates/1' +``` + +##### Example Response + +```json +{ + "data": { + "id": 1, + "created_at": "2020-03-14T17:36:41.288578+01:00", + "updated_at": "2020-03-14T17:36:41.288578+01:00", + "name": "Default template", + "body": "{{ template \"content\" . }}", + "type": "campaign", + "is_default": true + } +} +``` + +______________________________________________________________________ + +#### GET /api/templates/{template_id}/preview + +Retrieve the HTML preview of a template. + +##### Parameters + +| Name | Type | Required | Description | +|:------------|:----------|:---------|:------------------------------| +| template_id | number | Yes | ID of the template to preview | + +##### Example Request + +```shell +curl -u "api_user:token" -X GET 'http://localhost:9000/api/templates/1/preview' +``` + +##### Example Response + +```html +

Hi there

+

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis et elit ac elit sollicitudin condimentum non a magna. + Sed tempor mauris in facilisis vehicula. Aenean nisl urna, accumsan ac tincidunt vitae, interdum cursus massa. + Interdum et malesuada fames ac ante ipsum primis in faucibus. Aliquam varius turpis et turpis lacinia placerat. + Aenean id ligula a orci lacinia blandit at eu felis. Phasellus vel lobortis lacus. Suspendisse leo elit, luctus sed + erat ut, venenatis fermentum ipsum. Donec bibendum neque quis.

+ +

Sub heading

+

Nam luctus dui non placerat mattis. Morbi non accumsan orci, vel interdum urna. Duis faucibus id nunc ut euismod. + Curabitur et eros id erat feugiat fringilla in eget neque. Aliquam accumsan cursus eros sed faucibus.

+ +

Here is a link to listmonk.

+``` + +______________________________________________________________________ + +#### POST /api/templates + +Create a template. + +##### Parameters + +| Name | Type | Required | Description | +|:--------|:----------|:---------|:----------------------------------------------| +| name | string | Yes | Name of the template | +| type | string | Yes | Type of the template (`campaign` or `tx`) | +| subject | string | | Subject line for the template (only for `tx`) | +| body | string | Yes | HTML body of the template | + +##### Example Request + +```shell +curl -u "api_user:token" -X POST 'http://localhost:9000/api/templates' \ +-H 'Content-Type: application/json' \ +-d '{ + "name": "New template", + "type": "campaign", + "subject": "Your Weekly Newsletter", + "body": "

Header

Content goes here

" +}' +``` + +##### Example Response + +```json +{ + "data": [ + { + "id": 1, + "created_at": "2020-03-14T17:36:41.288578+01:00", + "updated_at": "2020-03-14T17:36:41.288578+01:00", + "name": "Default template", + "body": "{{ template \"content\" . }}", + "type": "campaign", + "is_default": true + } + ] +} +``` + +______________________________________________________________________ + +#### PUT /api/templates/{template_id} + +Update a template. + +> Refer to parameters from [POST /api/templates](#post-apitemplates) + +______________________________________________________________________ + +#### PUT /api/templates/{template_id}/default + +Set a template as the default. + +##### Parameters + +| Name | Type | Required | Description | +|:------------|:----------|:---------|:-------------------------------------| +| template_id | number | Yes | ID of the template to set as default | + +##### Example Request + +```shell +curl -u "api_user:token" -X PUT 'http://localhost:9000/api/templates/1/default' +``` + +##### Example Response + +```json +{ + "data": { + "id": 1, + "created_at": "2020-03-14T17:36:41.288578+01:00", + "updated_at": "2020-03-14T17:36:41.288578+01:00", + "name": "Default template", + "body": "{{ template \"content\" . }}", + "type": "campaign", + "is_default": true + } +} +``` + +______________________________________________________________________ + +#### DELETE /api/templates/{template_id} + +Delete a template. + +##### Parameters + +| Name | Type | Required | Description | +|:------------|:----------|:---------|:-----------------------------| +| template_id | number | Yes | ID of the template to delete | + +##### Example Request + +```shell +curl -u "api_user:token" -X DELETE 'http://localhost:9000/api/templates/35' +``` + +##### Example Response + +```json +{ + "data": true +} +``` diff --git a/docs/docs/content/apis/transactional.md b/docs/docs/content/apis/transactional.md new file mode 100644 index 000000000..80207ec73 --- /dev/null +++ b/docs/docs/content/apis/transactional.md @@ -0,0 +1,65 @@ +# API / Transactional + +| Method | Endpoint | Description | +|:-------|:---------|:-------------------------------| +| POST | /api/tx | Send transactional messages | + +______________________________________________________________________ + +#### POST /api/tx + +Allows sending transactional messages to one or more subscribers via a preconfigured transactional template. + +##### Parameters + +| Name | Type | Required | Description | +|:------------------|:----------|:---------|:---------------------------------------------------------------------------| +| subscriber_email | string | | Email of the subscriber. Can substitute with `subscriber_id`. | +| subscriber_id | number | | Subscriber's ID can substitute with `subscriber_email`. | +| subscriber_emails | string\[\] | | Multiple subscriber emails as alternative to `subscriber_email`. | +| subscriber_ids | number\[\] | | Multiple subscriber IDs as an alternative to `subscriber_id`. | +| template_id | number | Yes | ID of the transactional template to be used for the message. | +| from_email | string | | Optional sender email. | +| data | JSON | | Optional nested JSON map. Available in the template as `{{ .Tx.Data.* }}`. | +| headers | JSON\[\] | | Optional array of email headers. | +| messenger | string | | Messenger to send the message. Default is `email`. | +| content_type | string | | Email format options include `html`, `markdown`, and `plain`. | + +##### Example + +```shell +curl -u "api_user:token" "http://localhost:9000/api/tx" -X POST \ + -H 'Content-Type: application/json; charset=utf-8' \ + --data-binary @- << EOF + { + "subscriber_email": "user@test.com", + "template_id": 2, + "data": {"order_id": "1234", "date": "2022-07-30", "items": [1, 2, 3]}, + "content_type": "html" + } +EOF +``` + +##### Example response + +```json +{ + "data": true +} +``` + +______________________________________________________________________ + +#### File Attachments + +To include file attachments in a transactional message, use the `multipart/form-data` Content-Type. Use `data` param for the parameters described above as a JSON object. Include any number of attachments via the `file` param. + +```shell +curl -u "api_user:token" "http://localhost:9000/api/tx" -X POST \ +-F 'data=\"{ + \"subscriber_email\": \"user@test.com\", + \"template_id\": 4 +}"' \ +-F 'file=@"/path/to/attachment.pdf"' \ +-F 'file=@"/path/to/attachment2.pdf"' +``` diff --git a/docs/docs/content/archives.md b/docs/docs/content/archives.md new file mode 100644 index 000000000..2c2b639b2 --- /dev/null +++ b/docs/docs/content/archives.md @@ -0,0 +1,32 @@ +# Archives + +A global public archive is maintained on the public web interface. It can be +enabled under Settings -> Settings -> General -> Enable public mailing list +archive. + +To make a campaign available in the public archive (provided it has been +enabled in the settings as described above), enable the option +'Publish to public archive' under Campaigns -> Create new -> Archive. + +When using template variables that depend on subscriber data (such as any +template variable referencing `.Subscriber`), such data must be supplied +as 'Campaign metadata', which is a JSON object that will be used in place +of `.Subscriber` when rendering the archive template and content. + +When individual subscriber tracking is enabled, TrackLink requires that a UUID +of an existing user is provided as part of the campaign metadata. Any clicks on +a TrackLink from the archived campaign will be counted towards that subscriber. + +As an example: + +```json +{ + "UUID": "5a837423-a186-5623-9a87-82691cbe3631", + "email": "example@example.com", + "name": "Reader", + "attribs": {} +} +``` + +![Archive campaign](images/archived-campaign-metadata.png) + diff --git a/docs/docs/content/bounces.md b/docs/docs/content/bounces.md new file mode 100644 index 000000000..84d76418e --- /dev/null +++ b/docs/docs/content/bounces.md @@ -0,0 +1,106 @@ +# Bounce processing + +Enable bounce processing in Settings -> Bounces. POP3 bounce scanning and APIs only become available once the setting is enabled. + +## POP3 bounce mailbox +Configure the bounce mailbox in Settings -> Bounces. Either the "From" e-mail that is set on a campaign (or in settings) should have a POP3 mailbox behind it to receive bounce e-mails, or you should configure a dedicated POP3 mailbox and add that address as the `Return-Path` (envelope sender) header in Settings -> SMTP -> Custom headers box. For example: + +``` +[ + {"Return-Path": "your-bounce-inbox@site.com"} +] + +``` + +Some mail servers may also return the bounce to the `Reply-To` address, which can also be added to the header settings. + +## Webhook API +The bounce webhook API can be used to record bounce events with custom scripting. This could be by reading a mailbox, a database, or mail server logs. + +| Method | Endpoint | Description | +| ------ | ---------------- | ---------------------- | +| `POST` | /webhooks/bounce | Record a bounce event. | + + +| Name | Type | Required | Description | +| ----------------| --------- | -----------| ------------------------------------------------------------------------------------ | +| subscriber_uuid | string | | The UUID of the subscriber. Either this or `email` is required. | +| email | string | | The e-mail of the subscriber. Either this or `subscriber_uuid` is required. | +| campaign_uuid | string | | UUID of the campaign for which the bounce happened. | +| source | string | Yes | A string indicating the source, eg: `api`, `my_script` etc. | +| type | string | Yes | `hard` or `soft` bounce. Currently, this has no effect on how the bounce is treated. | +| meta | string | | An optional escaped JSON string with arbitrary metadata about the bounce event. | + + +```shell +curl -u 'api_username:access_token' -X POST 'http://localhost:9000/webhooks/bounce' \ + -H "Content-Type: application/json" \ + --data '{"email": "user1@mail.com", "campaign_uuid": "9f86b50d-5711-41c8-ab03-bc91c43d711b", "source": "api", "type": "hard", "meta": "{\"additional\": \"info\"}}' + +``` + +## External webhooks +listmonk supports receiving bounce webhook events from the following SMTP providers. + +| Endpoint | Description | More info | +|:--------------------------------------------------------------|:---------------------------------------|:----------------------------------------------------------------------------------------------------------------------| +| `https://listmonk.yoursite.com/webhooks/service/ses` | Amazon (AWS) SES | See below | +| `https://listmonk.yoursite.com/webhooks/service/sendgrid` | Sendgrid / Twilio Signed event webhook | [More info](https://docs.sendgrid.com/for-developers/tracking-events/getting-started-event-webhook-security-features) | +| `https://listmonk.yoursite.com/webhooks/service/postmark` | Postmark webhook | [More info](https://postmarkapp.com/developer/webhooks/webhooks-overview) | +| `https://listmonk.yoursite.com/webhooks/service/forwardemail` | Forward Email webhook | [More info](https://forwardemail.net/en/faq#do-you-support-bounce-webhooks) | + +## Amazon Simple Email Service (SES) + +If using SES as your SMTP provider, automatic bounce processing is the recommended way to maintain your [sender reputation](https://docs.aws.amazon.com/ses/latest/dg/monitor-sender-reputation.html). The settings below are based on Amazon's [recommendations](https://docs.aws.amazon.com/ses/latest/dg/send-email-concepts-deliverability.html). Please note that your sending domain must be verified in SES before proceeding. + +1. In listmonk settings, go to the "Bounces" tab and configure the following: + - Enable bounce processing: `Enabled` + - Soft: + - Bounce count: `2` + - Action: `None` + - Hard: + - Bounce count: `1` + - Action: `Blocklist` + - Complaint: + - Bounce count: `1` + - Action: `Blocklist` + - Enable bounce webhooks: `Enabled` + - Enable SES: `Enabled` +2. In the AWS console, go to [Simple Notification Service](https://console.aws.amazon.com/sns/) and create a new topic with the following settings: + - Type: `Standard` + - Name: `ses-bounces` (or any other name) +3. Create a new subscription to that topic with the following settings: + - Protocol: `HTTPS` + - Endpoint: `https://listmonk.yoursite.com/webhooks/service/ses` + - Enable raw message delivery: `Disabled` (unchecked) +4. SES will then make a request to your listmonk instance to confirm the subscription. After a page refresh, the subscription should have a status of "Confirmed". If not, your endpoint may be incorrect or not publicly accessible. +5. In the AWS console, go to [Simple Email Service](https://console.aws.amazon.com/ses/) and click "Verified identities" in the left sidebar. +6. Click your domain and go to the "Notifications" tab. +7. Next to "Feedback notifications", click "Edit". +8. For both "Bounce feedback" and "Complaint feedback", use the following settings: + - SNS topic: `ses-bounces` (or whatever you named it) + - Include original email headers: `Enabled` (checked) +9. Repeat steps 6-8 for any `Email address` identities you send from using listmonk +10. Bounce processing should now be working. You can test it with [SES simulator addresses](https://docs.aws.amazon.com/ses/latest/dg/send-an-email-from-console.html#send-email-simulator). Add them as subscribers, send them campaign previews, and ensure that the appropriate action was taken after the configured bounce count was reached. + - Soft bounce: `ooto@simulator.amazonses.com` + - Hard bounce: `bounce@simulator.amazonses.com` + - Complaint: `complaint@simulator.amazonses.com` +11. You can optionally [disable email feedback forwarding](https://docs.aws.amazon.com/ses/latest/dg/monitor-sending-activity-using-notifications-email.html#monitor-sending-activity-using-notifications-email-disabling). + +## Exporting bounces + +Bounces can be exported via the JSON API: +```shell +curl -u 'username:passsword' 'http://localhost:9000/api/bounces' +``` + +Or by querying the database directly: +```sql +SELECT bounces.created_at, + bounces.subscriber_id, + subscribers.uuid AS subscriber_uuid, + subscribers.email AS email +FROM bounces +LEFT JOIN subscribers ON (subscribers.id = bounces.subscriber_id) +ORDER BY bounces.created_at DESC LIMIT 1000; +``` diff --git a/docs/docs/content/concepts.md b/docs/docs/content/concepts.md new file mode 100644 index 000000000..2064962cb --- /dev/null +++ b/docs/docs/content/concepts.md @@ -0,0 +1,72 @@ +# Concepts + +## Subscriber + +A subscriber is a recipient identified by an e-mail address and name. Subscribers receive e-mails that are sent from listmonk. A subscriber can be added to any number of lists. Subscribers who are not a part of any lists are considered *orphan* records. + +### Attributes + +Attributes are arbitrary properties attached to a subscriber in addition to their e-mail and name. They are represented as a JSON map. It is not necessary for all subscribers to have the same attributes. Subscribers can be [queried and segmented](querying-and-segmentation.md) into lists based on their attributes, and the attributes can be inserted into the e-mails sent to them. For example: + +```json +{ + "city": "Bengaluru", + "likes_tea": true, + "spoken_languages": ["English", "Malayalam"], + "projects": 3, + "stack": { + "frameworks": ["echo", "go"], + "languages": ["go", "python"], + "preferred_language": "go" + } +} +``` + +### Subscription statuses + +A subscriber can be added to one or more lists, and each such relationship can have one of these statuses. + +| Status | Description | +| ------------- | --------------------------------------------------------------------------------- | +| `unconfirmed` | The subscriber was added to the list directly without their explicit confirmation. Nonetheless, the subscriber will receive campaign messages sent to single optin campaigns. | +| `confirmed` | The subscriber confirmed their subscription by clicking on 'accept' in the confirmation e-mail. Only confirmed subscribers in opt-in lists will receive campaign messages send to the list. | +| `unsubscribed` | The subscriber is unsubscribed from the list and will not receive any campaign messages sent to the list. + + +### Segmentation + +Segmentation is the process of filtering a large list of subscribers into a smaller group based on arbitrary conditions, primarily based on their attributes. For instance, if an e-mail needs to be sent subscribers who live in a particular city, given their city is described in their attributes, it's possible to quickly filter them out into a new list and e-mail them. [Learn more](querying-and-segmentation.md). + +## List + +A list (or a _mailing list_) is a collection of subscribers grouped under a name, for instance, _clients_. Lists are used to organise subscribers and send e-mails to specific groups. A list can be single optin or double optin. Subscribers added to double optin lists have to explicitly accept the subscription by clicking on the confirmation e-mail they receive. Until then, they do not receive campaign messages. + +## Campaign + +A campaign is an e-mail (or any other kind of messages) that is sent to one or more lists. + + +## Transactional message + +A transactional message is an arbitrary message sent to a subscriber using the transactional message API. For example a welcome e-mail on signing up to a service; an order confirmation e-mail on purchasing an item; a password reset e-mail when a user initiates an online account recovery process. + + +## Template + +A template is a re-usable HTML design that can be used across campaigns and when sending arbitrary transactional messages. Most commonly, templates have standard header and footer areas with logos and branding elements, where campaign content is inserted in the middle. listmonk supports [Go template](https://gowebexamples.com/templates/) expressions that lets you create powerful, dynamic HTML templates. [Learn more](templating.md). + +## Messenger + +listmonk supports multiple custom messaging backends in additional to the default SMTP e-mail backend, enabling not just e-mail campaigns, but arbitrary message campaigns such as SMS, FCM notifications etc. A *Messenger* is a web service that accepts a campaign message pushed to it as a JSON request, which the service can in turn broadcast as SMS, FCM etc. [Learn more](messengers.md). + +## Tracking pixel + +The tracking pixel is a tiny, invisible image that is inserted into an e-mail body to track e-mail views. This allows measuring the read rate of e-mails. While this is exceedingly common in e-mail campaigns, it carries privacy implications and should be used in compliance with rules and regulations such as GDPR. It is possible to track reads anonymously without associating an e-mail read to a subscriber. + +## Click tracking + +It is possible to track the clicks on every link that is sent in an e-mail. This allows measuring the clickthrough rates of links in e-mails. While this is exceedingly common in e-mail campaigns, it carries privacy implications and should be used in compliance with rules and regulations such as GDPR. It is possible to track link clicks anonymously without associating an e-mail read to a subscriber. + +## Bounce + +A bounce occurs when an e-mail that is sent to a recipient "bounces" back for one of many reasons including the recipient address being invalid, their mailbox being full, or the recipient's e-mail service provider marking the e-mail as spam. listmonk can automatically process such bounce e-mails that land in a configured POP mailbox, or via APIs of SMTP e-mail providers such as AWS SES and Sengrid. Based on settings, subscribers returning bounced e-mails can either be blocklisted or deleted automatically. [Learn more](bounces.md). diff --git a/docs/docs/content/configuration.md b/docs/docs/content/configuration.md new file mode 100644 index 000000000..11e7244fd --- /dev/null +++ b/docs/docs/content/configuration.md @@ -0,0 +1,150 @@ +# Configuration + +### TOML Configuration file +One or more TOML files can be read by passing `--config config.toml` multiple times. Apart from a few low level configuration variables and the database configuration, all other settings can be managed from the `Settings` dashboard on the admin UI. + +To generate a new sample configuration file, run `--listmonk --new-config` + +### Environment variables +Variables in config.toml can also be provided as environment variables prefixed by `LISTMONK_` with periods replaced by `__` (double underscore). To start listmonk purely with environment variables without a configuration file, set the environment variables and pass the config flag as `--config=""`. + +Example: + +| **Environment variable** | Example value | +| ------------------------------ | -------------- | +| `LISTMONK_app__address` | "0.0.0.0:9000" | +| `LISTMONK_db__host` | db | +| `LISTMONK_db__port` | 9432 | +| `LISTMONK_db__user` | listmonk | +| `LISTMONK_db__password` | listmonk | +| `LISTMONK_db__database` | listmonk | +| `LISTMONK_db__ssl_mode` | disable | + + +### Customizing system templates +See [system templates](templating.md#system-templates). + + +### HTTP routes +When configuring auth proxies and web application firewalls, use this table. + +#### Private admin endpoints. + +| Methods | Route | Description | +| ------- | ------------------ | ----------------------- | +| `*` | `/api/*` | Admin APIs | +| `GET` | `/admin/*` | Admin UI and HTML pages | +| `POST` | `/webhooks/bounce` | Admin bounce webhook | + + +#### Public endpoints to expose to the internet. + +| Methods | Route | Description | +| ----------- | --------------------- | --------------------------------------------- | +| `GET, POST` | `/subscription/*` | HTML subscription pages | +| `GET, ` | `/link/*` | Tracked link redirection | +| `GET` | `/campaign/*` | Pixel tracking image | +| `GET` | `/public/*` | Static files for HTML subscription pages | +| `POST` | `/webhooks/service/*` | Bounce webhook endpoints for AWS and Sendgrid | +| `GET` | `/uploads/*` | The file upload path configured in media settings | + + +## Media uploads + +#### Using filesystem + +When configuring `docker` volume mounts for using filesystem media uploads, you can follow either of two approaches. [The second option may be necessary if](https://github.com/knadh/listmonk/issues/1169#issuecomment-1674475945) your setup requires you to use `sudo` for docker commands. + +After making any changes you will need to run `sudo docker compose stop ; sudo docker compose up`. + +And under `https://listmonk.mysite.com/admin/settings` you put `/listmonk/uploads`. + +#### Using volumes + +Using `docker volumes`, you can specify the name of volume and destination for the files to be uploaded inside the container. + + +```yml +app: + volumes: + - type: volume + source: listmonk-uploads + target: /listmonk/uploads + +volumes: + listmonk-uploads: +``` + +!!! note + + This volume is managed by `docker` itself, and you can see find the host path with `docker volume inspect listmonk_listmonk-uploads`. + +#### Using bind mounts + +```yml + app: + volumes: + - ./path/on/your/host/:/path/inside/container +``` +Eg: +```yml + app: + volumes: + - ./data/uploads:/listmonk/uploads +``` +The files will be available inside `/data/uploads` directory on the host machine. + +To use the default `uploads` folder: +```yml + app: + volumes: + - ./uploads:/listmonk/uploads +``` + +## Logs + +### Docker + +https://docs.docker.com/engine/reference/commandline/logs/ +``` +sudo docker logs -f +sudo docker logs listmonk_app -t +sudo docker logs listmonk_db -t +sudo docker logs --help +``` +Container info: `sudo docker inspect listmonk_listmonk` + +Docker logs to `/dev/stdout` and `/dev/stderr`. The logs are collected by the docker daemon and stored in your node's host path (by default). The same can be configured (/etc/docker/daemon.json) in your docker daemon settings to setup other logging drivers, logrotate policy and more, which you can read about [here](https://docs.docker.com/config/containers/logging/configure/). + +### Binary + +listmonk logs to `stdout`, which is usually not saved to any file. To save listmonk logs to a file use `./listmonk > listmonk.log`. + +Settings -> Logs in admin shows the last 1000 lines of the standard log output but gets erased when listmonk is restarted. + +For the [service file](https://github.com/knadh/listmonk/blob/master/listmonk%40.service), you can use `ExecStart=/bin/bash -ce "exec /usr/bin/listmonk --config /etc/listmonk/config.toml --static-dir /etc/listmonk/static >>/etc/listmonk/listmonk.log 2>&1"` to create a log file that persists after restarts. [More info](https://github.com/knadh/listmonk/issues/1462#issuecomment-1868501606). + + +## Time zone + +To change listmonk's time zone (logs, etc.) edit `docker-compose.yml`: +``` +environment: + - TZ=Etc/UTC +``` +with any Timezone listed [here](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones). Then run `sudo docker-compose stop ; sudo docker-compose up` after making changes. + +## SMTP + +### Retries +The `Settings -> SMTP -> Retries` denotes the number of times a message that fails at the moment of sending is retried silently using different connections from the SMTP pool. The messages that fail even after retries are the ones that are logged as errors and ignored. + +## SMTP ports +Some server hosts block outgoing SMTP ports (25, 465). You may have to contact your host to unblock them before being able to send e-mails. Eg: [Hetzner](https://docs.hetzner.com/cloud/servers/faq/#why-can-i-not-send-any-mails-from-my-server). + + +## Performance + +### Batch size + +The batch size parameter is useful when working with very large lists with millions of subscribers for maximising throughput. It is the number of subscribers that are fetched from the database sequentially in a single cycle (~5 seconds) when a campaign is running. Increasing the batch size uses more memory, but reduces the round trip to the database. diff --git a/docs/docs/content/developer-setup.md b/docs/docs/content/developer-setup.md new file mode 100644 index 000000000..b0e9658da --- /dev/null +++ b/docs/docs/content/developer-setup.md @@ -0,0 +1,42 @@ +# Developer setup +The app has two distinct components, the Go backend and the VueJS frontend. In the dev environment, both are run independently. + + +### Pre-requisites +- `go` +- `nodejs` (if you are working on the frontend) and `yarn` +- Postgres database. If there is no local installation, the demo docker DB can be used for development (`docker compose up demo-db`) + + +### First time setup +`git clone https://github.com/knadh/listmonk.git`. The project uses go.mod, so it's best to clone it outside the Go src path. + +1. Copy `config.toml.sample` as `config.toml` and add your config. +2. `make dist` to build the listmonk binary. Once the binary is built, run `./listmonk --install` to run the DB setup. For subsequent dev runs, use `make run`. + +> [mailhog](https://github.com/mailhog/MailHog) is an excellent standalone mock SMTP server (with a UI) for testing and dev. + + +### Running the dev environment +You can run your dev environment locally or inside containers. + +After setting up the dev environment, you can visit `http://localhost:8080`. + + +1. Locally +- Run `make run` to start the listmonk dev server on `:9000`. +- Run `make run-frontend` to start the Vue frontend in dev mode using yarn on `:8080`. All `/api/*` calls are proxied to the app running on `:9000`. Refer to the [frontend README](https://github.com/knadh/listmonk/blob/master/frontend/README.md) for an overview on how the frontend is structured. + +2. Inside containers (Using Makefile) +- Run `make init-dev-docker` to setup container for db. +- Run `make dev-docker` to setup docker container suite. +- Run `make rm-dev-docker` to clean up docker container suite. + +3. Inside containers (Using devcontainer) +- Open repo in vscode, open command palette, and select "Dev Containers: Rebuild and Reopen in Container". + +It will set up db, and start frontend/backend for you. + + +# Production build +Run `make dist` to build the Go binary, build the Javascript frontend, and embed the static assets producing a single self-contained binary, `listmonk` diff --git a/docs/docs/content/external-integration.md b/docs/docs/content/external-integration.md new file mode 100644 index 000000000..9ced11c0c --- /dev/null +++ b/docs/docs/content/external-integration.md @@ -0,0 +1,11 @@ +# Integrating with external systems + +In many environments, a mailing list manager's subscriber database is not run independently but as a part of an existing customer database or a CRM. There are multiple ways of keeping listmonk in sync with external systems. + +## Using APIs + +The [subscriber APIs](apis/subscribers.md) offers several APIs to manipulate the subscribers database, like addition, updation, and deletion. For bulk synchronisation, a CSV can be generated (and optionally zipped) and posted to the import API. + +## Interacting directly with the DB + +listmonk uses tables with simple schemas to represent subscribers (`subscribers`), lists (`lists`), and subscriptions (`subscriber_lists`). It is easy to add, update, and delete subscriber information directly with the database tables for advanced usecases. See the [table schemas](https://github.com/knadh/listmonk/blob/master/schema.sql) for more information. diff --git a/docs/docs/content/i18n.md b/docs/docs/content/i18n.md new file mode 100644 index 000000000..e8829e54d --- /dev/null +++ b/docs/docs/content/i18n.md @@ -0,0 +1,35 @@ +# Internationalization (i18n) + +listmonk comes available in multiple languages thanks to language packs contributed by volunteers. A language pack is a JSON file with a map of keys and corresponding translations. The bundled languages can be [viewed here](https://github.com/knadh/listmonk/tree/master/i18n). + +## Additional language packs +These additional language packs can be downloaded and passed to listmonk with the `--i18n-dir` flag as described in the next section. + +| Language | Description | +|------------------|--------------------------------------| +| [Deutsch (formal)](https://raw.githubusercontent.com/SvenPe/listmonk/4bbb2e5ebb2314b754cb2318f4f6683a0f854d43/i18n/de.json) | German language with formal pronouns | + + +## Customizing languages + +To customize an existing language or to load a new language, put one or more `.json` language files in a directory, and pass the directory path to listmonk with the
`--i18n-dir=/path/to/dir` flag. + + +## Contributing a new language + +### Using the basic editor + +- Visit [https://listmonk.app/i18n](https://listmonk.app/i18n) +- Click on `Createa new language`, or to make changes to an existing language, use `Load language`. +- Translate the text in the text fields on the UI. +- Once done, use the `Download raw JSON` to download the language file. +- Send a pull request to add the file to the [i18n directory on the GitHub repo](https://github.com/knadh/listmonk/tree/master/i18n). + +### Using InLang (external service) + +[![translation badge](https://inlang.com/badge?url=github.com/knadh/listmonk)](https://inlang.com/editor/github.com/knadh/listmonk?ref=badge) + +- Visit [https://inlang.com/editor/github.com/knadh/listmonk](https://inlang.com/editor/github.com/knadh/listmonk) +- To make changes and push them, you need to log in to GitHub using OAuth and fork the project from the UI. +- Translate the text in the input fields on the UI. You can use the filters to see only the necessary translations. +- Once you're done, push the changes from the UI and click on "Open a pull request." This will take you to GitHub, where you can write a PR message. diff --git a/docs/docs/content/images/2021-09-28_00-18.png b/docs/docs/content/images/2021-09-28_00-18.png new file mode 100644 index 000000000..73859654b Binary files /dev/null and b/docs/docs/content/images/2021-09-28_00-18.png differ diff --git a/docs/docs/content/images/archived-campaign-metadata.png b/docs/docs/content/images/archived-campaign-metadata.png new file mode 100644 index 000000000..1fc26c437 Binary files /dev/null and b/docs/docs/content/images/archived-campaign-metadata.png differ diff --git a/docs/docs/content/images/edit-subscriber.png b/docs/docs/content/images/edit-subscriber.png new file mode 100644 index 000000000..af7eb8af6 Binary files /dev/null and b/docs/docs/content/images/edit-subscriber.png differ diff --git a/docs/docs/content/images/favicon.png b/docs/docs/content/images/favicon.png new file mode 100644 index 000000000..0ca8f02b9 Binary files /dev/null and b/docs/docs/content/images/favicon.png differ diff --git a/docs/docs/content/images/logo.svg b/docs/docs/content/images/logo.svg new file mode 100644 index 000000000..d3d36e759 --- /dev/null +++ b/docs/docs/content/images/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/docs/content/images/query-subscribers.png b/docs/docs/content/images/query-subscribers.png new file mode 100644 index 000000000..f3cc638bc Binary files /dev/null and b/docs/docs/content/images/query-subscribers.png differ diff --git a/docs/docs/content/images/splash.png b/docs/docs/content/images/splash.png new file mode 100644 index 000000000..8ad87b5a2 Binary files /dev/null and b/docs/docs/content/images/splash.png differ diff --git a/docs/docs/content/index.md b/docs/docs/content/index.md new file mode 100644 index 000000000..7bbfa1a1f --- /dev/null +++ b/docs/docs/content/index.md @@ -0,0 +1,10 @@ +# Introduction + +[![listmonk](images/logo.svg)](https://listmonk.app) + +listmonk is a self-hosted, high performance one-way mailing list and newsletter manager. It comes as a standalone binary and the only dependency is a Postgres database. + +[![listmonk screenshot](https://user-images.githubusercontent.com/547147/134939475-e0391111-f762-44cb-b056-6cb0857755e3.png)](https://listmonk.app) + +## Developers +listmonk is a free and open source software licensed under AGPLv3. If you are interested in contributing, check out the [GitHub repository](https://github.com/knadh/listmonk) and refer to the [developer setup](developer-setup.md). The backend is written in Go and the frontend is Vue with Buefy for UI. diff --git a/docs/docs/content/installation.md b/docs/docs/content/installation.md new file mode 100644 index 000000000..6e3e5eb9b --- /dev/null +++ b/docs/docs/content/installation.md @@ -0,0 +1,135 @@ +# Installation + +listmonk is a simple binary application that requires a Postgres database instance to run. The binary can be downloaded and run manually, or it can be run as a container with Docker compose. + +## Binary +1. Download the [latest release](https://github.com/knadh/listmonk/releases) and extract the listmonk binary. `amd64` is the main one. It works for Intel and x86 CPUs. +1. `./listmonk --new-config` to generate config.toml. Edit the file. +1. `./listmonk --install` to install the tables in the Postgres DB (⩾ 12). +1. Run `./listmonk` and visit `http://localhost:9000` to create the Super Admin user and login. + +!!! Tip + To set the Super Admin username and password during installation, set the environment variables: + `LISTMONK_ADMIN_USER=myuser LISTMONK_ADMIN_PASSWORD=xxxxx ./listmonk --install` + + +## Docker + +The latest image is available on DockerHub at `listmonk/listmonk:latest` + +The recommended method is to download the [docker-compose.yml](https://github.com/knadh/listmonk/blob/master/docker-compose.yml) file, customize it for your environment and then to simply run `docker compose up -d`. + +```shell +# Download the compose file to the current directory. +curl -LO https://github.com/knadh/listmonk/raw/master/docker-compose.yml + +# Run the services in the background. +docker compose up -d +``` + +Then, visit `http://localhost:9000` to create the Super Admin user and login. + +!!! Tip + To set the Super Admin username and password during setup, set the environment variables (only the first time): + `LISTMONK_ADMIN_USER=myuser LISTMONK_ADMIN_PASSWORD=xxxxx docker compose up -d` + + +------------ + +### Mounting a custom config.toml +The docker-compose file includes all necessary listmonk configuration as environment variables, `LISTMONK_*`. +If you would like to remove those and mount a config.toml instead: + +#### 1. Save the config.toml file on the host + +```toml +[app] +address = "0.0.0.0:9000" + +# Database. +[db] +host = "listmonk_db" # Postgres container name in the compose file. +port = 5432 +user = "listmonk" +password = "listmonk" +database = "listmonk" +ssl_mode = "disable" +max_open = 25 +max_idle = 25 +max_lifetime = "300s" +``` + +#### 2. Mount the config file in docker-compose.yml + +```yaml + app: + ... + volumes: + - /path/on/your/host/config.toml:/listmonk/config.toml +``` + +#### 3. Change the `--config ''` flags in the `command:` section to point to the path + +```yaml +command: [sh, -c, "./listmonk --install --idempotent --yes --config /listmonk/config.toml && ./listmonk --upgrade --yes --config /listmonk/config.toml && ./listmonk --config /listmonk/config.toml"] +``` + + +## Compiling from source + +To compile the latest unreleased version (`master` branch): + +1. Make sure `go`, `nodejs`, and `yarn` are installed on your system. +2. `git clone git@github.com:knadh/listmonk.git` +3. `cd listmonk && make dist`. This will generate the `listmonk` binary. + +## Release candidate (RC) + +The `master` branch with bleeding edge changes is periodically built and published as `listmonk/listmonk:rc` on DockerHub. To run the latest pre-release version, replace all instances of `listmonk/listmonk:latest` with `listmonk/listmonk:rc` in the docker-compose.yml file and follow the Docker installation steps above. While it is generally safe to run release candidate versions, they may have issues that only get resolved in a general release. + +## Helm chart for Kubernetes + +![Version: 0.1.0](https://img.shields.io/badge/Version-0.1.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 3.0.0](https://img.shields.io/badge/AppVersion-3.0.0-informational?style=flat-square) + +A helm chart for easily installing listmonk on a kubernetes cluster is made available by community [here](https://github.com/th0th/helm-charts/tree/main/charts/listmonk). + +In order to use the helm chart, you can configure `values.yaml` according to your needs, and then run the following command: + +```shell +$ helm upgrade \ + --create-namespace \ + --install listmonk listmonk \ + --namespace listmonk \ + --repo https://th0th.github.io/helm-charts \ + --values values.yaml \ + --version 0.1.0 +``` + +## 3rd party hosting + +Deploy to Elestio +
+Deploy on PikaPod +
+One-click deploy on Railway +
+Deploy at RepoCloud +
+Deploy on Sealos +
+Deploy on Zeabur + +## Tutorials + +* [Informal step-by-step on how to get started with listmonk using *Railway*](https://github.com/knadh/listmonk/issues/120#issuecomment-1421838533) +* [Step-by-step tutorial for installation and all basic functions. *Amazon EC2, SES, docker & binary*](https://gist.github.com/MaximilianKohler/e5158fcfe6de80a9069926a67afcae11) +* [Step-by-step guide on how to install and set up listmonk on *AWS Lightsail with docker* (rameerez)](https://github.com/knadh/listmonk/issues/1208) +* [Quick setup on any cloud server using *docker and caddy*](https://github.com/samyogdhital/listmonk-caddy-reverse-proxy) +* [*Binary* install on Ubuntu 22.04 as a service](https://mumaritc.hashnode.dev/how-to-install-listmonk-using-binary-on-ubuntu-2204) +* [*Binary* install on Ubuntu 18.04 as a service (Apache & Plesk)](https://devgypsy.com/post/2020-08-18-installing-listmonk-newsletter-manager/) +* [*Binary and docker* on linux (techviewleo)](https://techviewleo.com/manage-mailing-list-and-newsletter-using-listmonk/) +* [*Binary* install on your PC](https://www.youtube.com/watch?v=fAOBqgR9Yfo). Discussions of limitations: [[1](https://github.com/knadh/listmonk/issues/862#issuecomment-1307328228)][[2](https://github.com/knadh/listmonk/issues/248#issuecomment-1320806990)]. +* [*Docker on Rocky Linux 8* (nginx, Let's Encrypt SSL)](https://wiki.crowncloud.net/?How_to_Install_Listmonk_with_Docker_on_Rocky_Linux_8) +* [*Docker* with nginx reverse proxy, certbot SSL, and Gmail SMTP](https://www.maketecheasier.com/create-own-newsletter-with-listmonk/) +* [Install Listmonk on Self-hosting with *Pre-Configured AMI Package at AWS* by Single Click](https://meetrix.io/articles/how-to-install-llama-2-on-aws-with-pre-configured-ami-package/) +* [Tutorial for deploying on *Fly.io*](https://github.com/paulrudy/listmonk-on-fly) -- Currently [not working](https://github.com/knadh/listmonk/issues/984#issuecomment-1694545255) diff --git a/docs/docs/content/maintenance/performance.md b/docs/docs/content/maintenance/performance.md new file mode 100644 index 000000000..fc012e5ab --- /dev/null +++ b/docs/docs/content/maintenance/performance.md @@ -0,0 +1,18 @@ +# Performance + +listmonk is built to be highly performant and can handle millions of subscribers with minimal system resources. + +However, as the Postgres database grows—with a large number of subscribers, campaign views, and click records—it can significantly slow down certain aspects of the program, particularly in counting records and aggregating various statistics. For instance, loading admin pages that do these aggregations can take tens of seconds if the database has millions of subscribers. + +- Aggregate counts, statistics, and charts on the landing dashboard. +- Subscriber count beside every list on the Lists page. +- Total subscriber count on the Subscribers page. + +However, at that scale, viewing the exact number of subscribers or statistics every time the admin panel is accessed becomes mostly unnecessary. On installations with millions of subscribers, where the above pages do not load instantly, it is highly recommended to turn on the `Settings -> Performance -> Cache slow database queries` option. + +## Slow query caching + +When this option is enabled, the subscriber counts on the Lists page, the Subscribers page, and the statistics on the dashboard, etc., are no longer counted in real-time in the database. Instead, they are updated periodically and cached, resulting in a massive performance boost. The periodicity can be configured on the Settings -> Performance page using a standard crontab expression (default: `0 3 * * *`, which means 3 AM daily). Use a tool like [crontab.guru](https://crontab.guru) for easily generating a desired crontab expression. + +## VACUUM-ing +Running [`VACUUM ANALYZE`](https://www.postgresql.org/docs/current/sql-vacuum.html) on large Postgres databases at regular intervals (for instance, once a week), is recommended. It reclaims disk space and improves Postgres' query performance. Do note that this is a blocking operation and all database queries can come to a stand-still on a large database while the operation is running (generally only a few seconds). diff --git a/docs/docs/content/messengers.md b/docs/docs/content/messengers.md new file mode 100644 index 000000000..fbbeeb0c2 --- /dev/null +++ b/docs/docs/content/messengers.md @@ -0,0 +1,47 @@ +# Messengers + +listmonk supports multiple custom messaging backends in additional to the default SMTP e-mail backend, enabling not just e-mail campaigns, but arbitrary message campaigns such as SMS, FCM notifications etc. + +A *Messenger* is a web service that accepts a campaign message pushed to it as a JSON request, which the service can in turn broadcast as SMS, FCM etc. Messengers are registered in the *Settings -> Messengers* UI, and can be selected on individual campaigns. + +Messengers support optional BasicAuth authentication. `Plain text` format for campaign content is ideal for messengers such as SMS and FCM. + +When a campaign starts, listmonk POSTs messages in the following format to the selected messenger's endpoint. The endpoint should return a `200 OK` response in case of a successful request. + +The address required to broadcast the message, for instance, a phone number or an FCM ID, is expected to be stored and relayed as [subscriber attributes](concepts.md/#attributes). + +```json +{ + "subject": "Welcome to listmonk", + "body": "The message body", + "content_type": "plain", + "recipients": [{ + "uuid": "e44b4135-1e1d-40c5-8a30-0f9a886c2884", + "email": "anon@example.com", + "name": "Anon Doe", + "attribs": { + "phone": "123123123", + "fcm_id": "2e7e4b512e7e4b512e7e4b51", + "city": "Bengaluru" + }, + "status": "enabled" + }], + "campaign": { + "uuid": "2e7e4b51-f31b-418a-a120-e41800cb689f", + "name": "Test campaign", + "tags": ["test-campaign"] + } +} +``` + +## Messenger implementations + +Following is a list of HTTP messenger servers that connect to various backends. + +| Name | Backend | +|:-------------------------------------------------------------------------------------|:-----------------| +| [listmonk-messenger](https://github.com/joeirimpan/listmonk-messenger) | AWS Pinpoint SMS | +| [listmonk-verimor-gateway](https://github.com/antandros/listmonk-verimor-gateway) | Verimor | +| [listmonk-mailersend](https://github.com/tkawczynski/listmonk-mailersend) | Mailersend | +| [listmonk-novu-messenger](https://github.com/Codepowercode/listmonk-novu-messenger) | Novu | +| [listmonk-push-messenger](https://github.com/shyamkrishna21/listmonk-push-messenger) | Google FCM | diff --git a/docs/docs/content/querying-and-segmentation.md b/docs/docs/content/querying-and-segmentation.md new file mode 100644 index 000000000..ea79ae381 --- /dev/null +++ b/docs/docs/content/querying-and-segmentation.md @@ -0,0 +1,102 @@ +# Querying and segmenting subscribers + +listmonk allows the writing of partial Postgres SQL expressions to query, filter, and segment subscribers. + +## Database fields + +These are the fields in the subscriber database that can be queried. + +| Field | Description | +| ------------------------ | --------------------------------------------------------------------------------------------------- | +| `subscribers.uuid` | The randomly generated unique ID of the subscriber | +| `subscribers.email` | E-mail ID of the subscriber | +| `subscribers.name` | Name of the subscriber | +| `subscribers.status` | Status of the subscriber (enabled, disabled, blocklisted) | +| `subscribers.attribs` | Map of arbitrary attributes represented as JSON. Accessed via the `->` and `->>` Postgres operator. | +| `subscribers.created_at` | Timestamp when the subscriber was first added | +| `subscribers.updated_at` | Timestamp when the subscriber was modified | + +## Sample attributes + +Here's a sample JSON map of attributes assigned to an imaginary subscriber. + +```json +{ + "city": "Bengaluru", + "likes_tea": true, + "spoken_languages": ["English", "Malayalam"], + "projects": 3, + "stack": { + "frameworks": ["echo", "go"], + "languages": ["go", "python"], + "preferred_language": "go" + } +} +``` + +![listmonk screenshot](images/edit-subscriber.png) + +## Sample SQL query expressions + +![listmonk](images/query-subscribers.png) + +#### Find a subscriber by e-mail + +```sql +-- Exact match +subscribers.email = 'some@domain.com' + +-- Partial match to find e-mails that end in @domain.com. +subscribers.email LIKE '%@domain.com' + +``` + +#### Find a subscriber by name + +```sql +-- Find all subscribers whose name start with John. +subscribers.email LIKE 'John%' + +``` + +#### Multiple conditions + +```sql +-- Find all Johns who have been blocklisted. +subscribers.email LIKE 'John%' AND status = 'blocklisted' +``` + +#### Querying subscribers who viewed the campaign email + +```sql +-- Find all subscribers who viewed the campaign email. +EXISTS(SELECT 1 FROM campaign_views WHERE campaign_views.subscriber_id=subscribers.id AND campaign_views.campaign_id=) +``` + +#### Querying attributes + +```sql +-- The ->> operator returns the value as text. Find all subscribers +-- who live in Bengaluru and have done more than 3 projects. +-- Here 'projects' is cast into an integer so that we can apply the +-- numerical operator > +subscribers.attribs->>'city' = 'Bengaluru' AND + (subscribers.attribs->>'projects')::INT > 3 +``` + +#### Querying nested attributes + +```sql +-- Find all blocklisted subscribers who like to drink tea, can code Python +-- and prefer coding Go. +-- +-- The -> operator returns the value as a structure. Here, the "languages" field +-- The ? operator checks for the existence of a value in a list. +subscribers.status = 'blocklisted' AND + (subscribers.attribs->>'likes_tea')::BOOLEAN = true AND + subscribers.attribs->'stack'->'languages' ? 'python' AND + subscribers.attribs->'stack'->>'preferred_language' = 'go' + +``` + +To learn how to write SQL expressions to do advancd querying on JSON attributes, refer to the Postgres [JSONB documentation](https://www.postgresql.org/docs/11/functions-json.html). diff --git a/docs/docs/content/roles-and-permissions.md b/docs/docs/content/roles-and-permissions.md new file mode 100644 index 000000000..5f17aa00b --- /dev/null +++ b/docs/docs/content/roles-and-permissions.md @@ -0,0 +1,41 @@ +listmonk supports (>= v4.0.0) creating systems users with granular permissions to various features, including list-specific permissions. Users can login with a username and password, or via an OIDC (OpenID Connect) handshake if an auth provider is connected. Various permissions can be grouped into "user roles", which can be assigned to users. List-specific permissions can be grouped into "list roles". + +## User roles + +A user role is a collection of user related permissions. User roles are attached to user accounts. User roles can be managed in `Admin -> Users -> User roles` The permissions are described below. + +| Group | Permission | Description | +| ----------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| lists | lists:get_all | Get details of all lists | +| | lists:manage_all | Create, update, and delete all lists | +| subscribers | subscribers:get | Get individual subscriber details | +| | subscribers:get_all | Get all subscribers and their details | +| | subscribers:manage | Add, update, and delete subscribers | +| | subscribers:import | Import subscribers from external files | +| | subscribers:sql_query | Run SQL queries on subscriber data. **WARNING:** This permission will allow the querying of all lists and subscribers directly from the database with SQL expressions, superceding individual list and subscriber permissions above. | +| | tx:send | Send transactional messages to subscribers | +| campaigns | campaigns:get | Get campaign details | +| | campaigns:get_analytics | Access campaign performance metrics | +| | campaigns:manage | Create, update, and delete campaigns | +| bounces | bounces:get | Get email bounce records | +| | bounces:manage | Process and handle bounced emails | +| | webhooks:post_bounce | Receive bounce notifications via webhook | +| media | media:get | Get uploaded media files | +| | media:manage | Upload, update, and delete media | +| templates | templates:get | Get email templates | +| | templates:manage | Create, update, and delete templates | +| users | users:get | Get system user accounts | +| | users:manage | Create, update, and delete user accounts | +| | roles:get | Get user roles and permissions | +| | roles:manage | Create and modify user roles | +| settings | settings:get | Get system settings | +| | settings:manage | Modify system configuration | +| | settings:maintain | Perform system maintenance tasks | + +## List roles + +A list role is a collection of permissions assigned per list. Each list can be assigned a view (read) or manage (update) permission. List roles are attached to user accounts. Only the lists defined in a list role is accessible by the user, be it on the admin UI or via API calls. Do note that the `lists:get_all` and `lists:manage_all` permissions in user roles override all per-list permissions. + +## API users + +A user account can be of two types, a regular user or an API user. API users are meant for intertacting with the listmonk APIs programmatically. Unlike regular user accounts that have custom passwords or OIDC for authentication, API users get an automatically generated secret token. diff --git a/docs/docs/content/static/style.css b/docs/docs/content/static/style.css new file mode 100644 index 000000000..3dc5f3f0e --- /dev/null +++ b/docs/docs/content/static/style.css @@ -0,0 +1,121 @@ +body[data-md-color-primary="white"] .md-header[data-md-state="shadow"] { + background: #fff; + box-shadow: none; + color: #333; + + box-shadow: 1px 1px 3px #ddd; +} + +.md-typeset .md-typeset__table table { + border: 1px solid #ddd; + box-shadow: 2px 2px 0 #f3f3f3; + overflow: inherit; +} + +body[data-md-color-primary="white"] .md-search__input { + background: #f6f6f6; + color: #333; +} + +body[data-md-color-primary="white"] + .md-sidebar--secondary + .md-sidebar__scrollwrap { + background: #f6f6f6; + padding: 10px 0; +} + +.md-nav__item--section > .md-nav__link[for] { + color: #333; +} +.md-nav__item--section { + margin-bottom: 20px; +} +.md-nav__item--nested .md-nav__list { + margin-left: 20px; + border-left: 1px solid #ddd; +} + +body[data-md-color-primary="white"] a.md-nav__link--active { + font-weight: 600; + color: inherit; + color: #0055d4; +} +body[data-md-color-primary="white"] .md-nav__item a:hover { + color: #0055d4; +} + +body[data-md-color-primary="white"] thead, +body[data-md-color-primary="white"] .md-typeset table:not([class]) th { + background: #f6f6f6; + border: 0; + color: inherit; + font-weight: 600; +} +table td span { + font-size: 0.85em; + color: #bbb; + display: block; +} + +.md-typeset h1, .md-typeset h2 { + font-weight: 500; +} + +body[data-md-color-primary="white"] .md-typeset h1 { + margin: 4rem 0 0 0; + color: inherit; + border-top: 1px solid #ddd; + padding-top: 2rem; +} +body[data-md-color-primary="white"] .md-typeset h2 { + border-top: 1px solid #eee; + padding-top: 2rem; +} + +body[data-md-color-primary="white"] .md-content h1:first-child { + margin: 0 0 3rem 0; + padding: 0; + border: 0; +} + +body[data-md-color-primary="white"] .md-typeset code { + word-break: normal; +} + +li img { + background: #fff; + border-radius: 6px; + border: 1px solid #e6e6e6; + box-shadow: 1px 1px 4px #e6e6e6; + padding: 5px; + margin-top: 10px; +} + +/* This hack places the #anchor-links correctly +by accommodating for the fixed-header's height */ +:target:before { + content: ""; + display: block; + height: 120px; + margin-top: -120px; +} + +.md-typeset a { + color: #0055d4; +} +.md-typeset a:hover { + color: #666 !important; + text-decoration: underline; +} +.md-typeset hr { + background: #f6f6f6; + margin: 60px 0; + display: block; +} +.md-header--shadow { + box-shadow: 0 4px 3px #eee; + transition: none; +} +.md-header__topic:first-child { + font-weight: normal; +} \ No newline at end of file diff --git a/docs/docs/content/templating.md b/docs/docs/content/templating.md new file mode 100644 index 000000000..43ff66d69 --- /dev/null +++ b/docs/docs/content/templating.md @@ -0,0 +1,175 @@ +# Templating + +A template is a re-usable HTML design that can be used across campaigns and transactional messages. Most commonly, templates have standard header and footer areas with logos and branding elements, where campaign content is inserted in the middle. + +listmonk supports [Go template](https://pkg.go.dev/text/template) expressions that lets you create powerful, dynamic HTML templates. It also integrates 100+ useful [Sprig template functions](https://masterminds.github.io/sprig/). + +## Campaign templates +Campaign templates are used in an e-mail campaigns. These template are created and managed on the UI under `Campaigns -> Templates`, and are selected when creating new campaigns. + +## Transactional templates +Transactional templates are used for sending arbitrary transactional messages using the transactional API. These template are created and managed on the UI under `Campaigns -> Templates`. + +## Template expressions + +There are several template functions and expressions that can be used in campaign and template bodies. They are written in the form `{{ .Subscriber.Email }}`, that is, an expression between double curly braces `{{` and `}}`. + +### Subscriber fields + +| Expression | Description | +| ----------------------------- | -------------------------------------------------------------------------------------------- | +| `{{ .Subscriber.UUID }}` | The randomly generated unique ID of the subscriber | +| `{{ .Subscriber.Email }}` | E-mail ID of the subscriber | +| `{{ .Subscriber.Name }}` | Name of the subscriber | +| `{{ .Subscriber.FirstName }}` | First name of the subscriber (automatically extracted from the name) | +| `{{ .Subscriber.LastName }}` | Last name of the subscriber (automatically extracted from the name) | +| `{{ .Subscriber.Status }}` | Status of the subscriber (enabled, disabled, blocklisted) | +| `{{ .Subscriber.Attribs }}` | Map of arbitrary attributes. Fields can be accessed with `.`, eg: `.Subscriber.Attribs.city` | +| `{{ .Subscriber.CreatedAt }}` | Timestamp when the subscriber was first added | +| `{{ .Subscriber.UpdatedAt }}` | Timestamp when the subscriber was modified | + +| Expression | Description | +| --------------------- | -------------------------------------------------------- | +| `{{ .Campaign.UUID }}` | The randomly generated unique ID of the campaign | +| `{{ .Campaign.Name }}` | Internal name of the campaign | +| `{{ .Campaign.Subject }}` | E-mail subject of the campaign | +| `{{ .Campaign.FromEmail }}` | The e-mail address from which the campaign is being sent | + +### Functions + +| Function | Description | +| ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `{{ Date "2006-01-01" }}` | Prints the current datetime for the given format expressed as a [Go date layout](https://yourbasic.org/golang/format-parse-string-time-date-example/) | +| `{{ TrackLink "https://link.com" }}` | Takes a URL and generates a tracking URL over it. For use in campaign bodies and templates. | +| `https://link.com@TrackLink` | Shorthand for `TrackLink`. Eg: `Link` | +| `{{ TrackView }}` | Inserts a single tracking pixel. Should only be used once, ideally in the template footer. | +| `{{ UnsubscribeURL }}` | Unsubscription and Manage preferences URL. Ideal for use in the template footer. | +| `{{ MessageURL }}` | URL to view the hosted version of an e-mail message. | +| `{{ OptinURL }}` | URL to the double-optin confirmation page. | +| `{{ Safe "" }}` | Add any HTML code as it is. | + +### Sprig functions +listmonk integrates the Sprig library that offers 100+ utility functions for working with strings, numbers, dates etc. that can be used in templating. Refer to the [Sprig documentation](https://masterminds.github.io/sprig/) for the full list of functions. + + +### Example template + +The expression `{{ template "content" . }}` should appear exactly once in every template denoting the spot where an e-mail's content is inserted. Here's a sample HTML e-mail that has a fixed header and footer that inserts the content in the middle. + +```html + + + + + + +
+
+ + Hi {{ .Subscriber.FirstName }}! +
+ + +
+ {{ template "content" . }} +
+ +
+ Copyright 2019. All rights Reserved. +
+ + + {{ TrackView }} +
+ + +``` + +!!! info + For use with plaintext campaigns, create a template with no HTML content and just the placeholder `{{ template "content" . }}` + +### Example campaign body + +Campaign bodies can be composed using the built-in WYSIWYG editor or as raw HTML documents. Assuming that the subscriber has a set of [attributes defined](querying-and-segmentation.md#sample-attributes), this example shows how to render those values in a campaign. + +``` +Hey, did you notice how the template showed your first name? +Your last name is {{.Subscriber.LastName }}. + +You have done {{ .Subscriber.Attribs.projects }} projects. + + +{{ if eq .Subscriber.Attribs.city "Bengaluru" }} + You live in Bangalore! +{{ else }} + Where do you live? +{{ end }} + + +Here is a link for you to click that will be tracked. +Google + +``` + +The above example uses an `if` condition to show one of two messages depending on the value of a subscriber attribute. Many such dynamic expressions are possible with Go templating expressions. + +## System templates +System templates are used for rendering public user-facing pages such as the subscription management page, and in automatically generated system e-mails such as the opt-in confirmation e-mail. These are bundled into listmonk but can be customized by copying the [static directory](https://github.com/knadh/listmonk/tree/master/static) locally, and passing its path to listmonk with the `./listmonk --static-dir=your/custom/path` flag. + +You can fetch the static files with:
+`mkdir -p /home/ubuntu/listmonk/static ; wget -O - https://github.com/knadh/listmonk/archive/master.tar.gz | tar xz -C /home/ubuntu/listmonk/static --strip=2 "listmonk-master/static"` + +[Docker example](https://yasoob.me/posts/setting-up-listmonk-opensource-newsletter-mailing/#custom-static-files), [binary example](https://github.com/knadh/listmonk/blob/master/listmonk-simple.service). + + +### Public pages + +| /static/public/ | | +|------------------------|--------------------------------------------------------------------| +| `index.html` | Base template with the header and footer that all pages use. | +| `home.html` | Landing page on the root domain with the login button. | +| `message.html` | Generic success / failure message page. | +| `optin.html` | Opt-in confirmation page. | +| `subscription.html` | Subscription management page with options for data export and wipe. | +| `subscription-form.html` | List selection and subscription form page. | + + +To edit the appearance of the public pages using CSS and Javascript, head to Settings > Appearance > Public: + +![image](https://user-images.githubusercontent.com/55474996/153739792-93074af6-d1dd-40aa-8cde-c02ea4bbb67b.png) + + + +### System e-mails + +| /static/email-templates/ | | +|----------------------------------|------------------------------------------------------------------------------------------------------------------------------------| +| `base.html` | Base template with the header and footer that all system generated e-mails use. | +| `campaign-status.html` | E-mail notification that is sent to admins on campaign start, completion etc. | +| `import-status.html` | E-mail notification that is sent to admins on finish of an import job. | +| `subscriber-data.html` | E-mail that is sent to subscribers when they request a full dump of their private data. | +| `subscriber-optin.html` | Automatic opt-in confirmation e-mail that is sent to an unconfirmed subscriber when they are added. | +| `subscriber-optin-campaign.html` | E-mail content that's inserted into a campaign body when starting an opt-in campaign from the lists page. | +| `default.tpl` | Default campaign template that is created in Campaigns -> Templates when listmonk is first installed. This is not used after that. | + +!!! info + To turn system e-mail templates to plaintext, remove `` from base.html and remove all HTML tags from the templates while retaining the Go templating code. diff --git a/docs/docs/content/upgrade.md b/docs/docs/content/upgrade.md new file mode 100644 index 000000000..c0369972d --- /dev/null +++ b/docs/docs/content/upgrade.md @@ -0,0 +1,85 @@ +# Upgrade + +!!! Warning + Always take a backup of the Postgres database before upgrading listmonk + +## Binary +- Stop the running instance of listmonk. +- Download the [latest release](https://github.com/knadh/listmonk/releases) and extract the listmonk binary and overwrite the previous version. +- `./listmonk --upgrade` to upgrade an existing database schema. Upgrades are idempotent and running them multiple times have no side effects. +- Run `./listmonk` again. + +If you installed listmonk as a service, you will need to stop it before overwriting the binary. Something like `sudo systemctl stop listmonk` or `sudo service listmonk stop` should work. Then overwrite the binary with the new version, then run `./listmonk --upgrade, and `start` it back with the same commands. + +If it's not running as a service, `pkill -9 listmonk` will stop the listmonk process. + +## Docker +**Important:** The following instructions are for the new [docker-compose.yml](https://github.com/knadh/listmonk/blob/master/docker-compose.yml) file. + +```shell +docker compose down app +docker compose pull +docker compose up app -d +``` + +If you are using an older docker-compose.yml file, you have to run the `--upgrade` step manually. + +```shell +docker-compose down +docker-compose pull && docker-compose run --rm app ./listmonk --upgrade +docker-compose up -d app db +``` + + +## Railway +- Head to your dashboard, and select your Listmonk project. +- Select the GitHub deployment service. +- In the Deployment tab, head to the latest deployment, click on the three vertical dots to the right, and select "Redeploy". + +![Railway Redeploy option](https://user-images.githubusercontent.com/55474996/226517149-6dc512d5-f862-46f7-a57d-5e55b781ff53.png) + +## Downgrade + +To restore a previous version, you have to restore the DB for that particular version. DBs that have been upgraded with a particular version shouldn't be used with older versions. There may be DB changes that a new version brings that are incompatible with previous versions. + +**General steps:** + +1. Stop listmonk. +2. Restore your pre-upgrade database. +3. If you're using `docker compose`, edit `docker-compose.yml` and change `listmonk:latest` to `listmonk:v2.4.0` _(for example)_. +4. Restart. + +**Example with docker:** + +1. Stop listmonk (app): +``` +sudo docker stop listmonk_app +``` +2. Restore your pre-upgrade db (required) _(be careful, this will wipe your existing DB)_: +``` +psql -h 127.0.0.1 -p 9432 -U listmonk +drop schema public cascade; +create schema public; +\q +psql -h 127.0.0.1 -p 9432 -U listmonk -W listmonk < listmonk-preupgrade-db.sql +``` +3. Edit the `docker-compose.yml`: +``` +x-app-defaults: &app-defaults + restart: unless-stopped + image: listmonk/listmonk:v2.4.0 +``` +4. Restart: +`sudo docker compose up -d app db nginx certbot` + + +## Upgrading to v4.x.x +v4 is a major upgrade from prior versions with significant changes to certain important features and behaviour. It is the first version to have multi-user support and full fledged user management. Prior versions only had a simple BasicAuth for both admin login (browser prompt) and API calls, with the username and password defined in the TOML configuration file. + +It is safe to upgrade an older installation with `--upgrade`, but there are a few important things to keep in mind. The upgrade automatically imports the `admin_username` and `admin_password` defined in the TOML configuration into the new user management system. + +1. **New login UI**: Once you upgrade an older installation, the admin dashboard will no longer show the native browser prompt for login. Instead, a new login UI rendered by listmonk is displayed at the URI `/admin/login`. + +1. **API credentials**: If you are using APIs to interact with listmonk, after logging in, go to Settings -> Users and create a new API user with the necessary permissions. Change existing API integrations to use these credentials instead of the old username and password defined in the legacy TOML configuration file or environment variables. + +1. **Credentials in TOML file or old environment variables**: The admin dashboard shows a warning until the `admin_username` and `admin_password` fields are removed from the configuration file or old environment variables. In v4.x.x, these are irrelevant as user credentials are stored in the database and managed from the admin UI. IMPORTANT: if you are using APIs to interact with listmonk, follow the previous step before removing the legacy credentials. diff --git a/docs/docs/mkdocs.yml b/docs/docs/mkdocs.yml new file mode 100644 index 000000000..32170a5fe --- /dev/null +++ b/docs/docs/mkdocs.yml @@ -0,0 +1,71 @@ +site_name: listmonk / Documentation +theme: + name: material + # custom_dir: "mkdocs-material/material" + logo: "images/favicon.png" + favicon: "images/favicon.png" + language: "en" + font: + text: 'Inter' + weights: 400 + direction: 'ltr' + extra: + search: + language: 'en' + feature: + tabs: true + features: + - navigation.indexes + - navigation.sections + - content.code.copy + + palette: + primary: "white" + accent: "red" + +site_dir: _out +docs_dir: content + +markdown_extensions: + - admonition + - pymdownx.highlight + - pymdownx.superfences + - toc: + permalink: true + +extra_css: + - "static/style.css" + +copyright: "CC BY-SA 4.0" + +nav: + - "Introduction": index.md + - "Getting Started": + - "Installation": installation.md + - "Configuration": configuration.md + - "Upgrade": upgrade.md + - "Using listmonk": + - "Concepts": concepts.md + - "Templating": templating.md + - "Querying and segmenting subscribers": querying-and-segmentation.md + - "Bounce processing": bounces.md + - "Messengers": "messengers.md" + - "Archives": "archives.md" + - "Internationalization": "i18n.md" + - "Integrating with external systems": external-integration.md + - "User roles and permissions": roles-and-permissions.md + - "API": + - "Introduction": apis/apis.md + - "SDKs and libs": apis/sdks.md + - "Subscribers": apis/subscribers.md + - "Lists": apis/lists.md + - "Import": apis/import.md + - "Campaigns": apis/campaigns.md + - "Media": apis/media.md + - "Templates": apis/templates.md + - "Transactional": apis/transactional.md + - "Bounces": apis/bounces.md + - "Maintenance": + - "Performance": maintenance/performance.md + - "Contributions": + - "Developer setup": developer-setup.md diff --git a/docs/i18n/index.html b/docs/i18n/index.html new file mode 100644 index 000000000..e45a573ac --- /dev/null +++ b/docs/i18n/index.html @@ -0,0 +1,106 @@ + + + + listmonk i18n translation editor + + + + + + +
+
+

{{ values["_.name"] }}

+
+ + +
+ + + +
+ +
+ Load existing language + + +     + + Create new language +
+
+
+ +

+ Changes are stored in the browser's localStorage until the cache is cleared. + To edit an existing language, load it and edit the fields. + To create a new language, load the default language and edit the fields. + Once done, copy the raw JSON and send a PR to the + repo. +

+ +
+
+

{{ k.head }}

+ +
+
{{ i + 1 }}.
+
+ {{ base[k.key] }} + + +
+
+
+
+ +
+ +
+
+

Loading ...

+ + + + + \ No newline at end of file diff --git a/docs/i18n/main.js b/docs/i18n/main.js new file mode 100644 index 000000000..985915bf0 --- /dev/null +++ b/docs/i18n/main.js @@ -0,0 +1,186 @@ +const BASEURL = "https://raw.githubusercontent.com/knadh/listmonk/master/i18n/"; +const BASELANG = "en"; + +var app = new Vue({ + el: "#app", + data: { + base: {}, + keys: [], + visibleKeys: {}, + values: {}, + view: "all", + loadLang: BASELANG, + + isRawVisible: false, + rawData: "{}" + }, + + methods: { + init() { + document.querySelector("#app").style.display = 'block'; + document.querySelector("#loading").remove(); + }, + + loadBaseLang(url) { + return fetch(url).then(response => response.json()).then(data => { + // Retain the base values. + Object.assign(this.base, data); + + // Get the sorted keys from the language map. + const keys = []; + const visibleKeys = {}; + let head = null; + Object.entries(this.base).sort((a, b) => a[0].localeCompare(b[0])).forEach((v) => { + const h = v[0].split('.')[0]; + keys.push({ + "key": v[0], + "head": (head !== h ? h : null) // eg: campaigns on `campaigns.something.else` + }); + + visibleKeys[v[0]] = true; + head = h; + }); + + this.keys = keys; + this.visibleKeys = visibleKeys; + this.values = { ...this.base }; + + // Is there cached localStorage data? + if (localStorage.data) { + try { + this.populateData(JSON.parse(localStorage.data)); + } catch (e) { + console.log("Bad JSON in localStorage: " + e.toString()); + } + return; + } + }); + }, + + populateData(data) { + // Filter out all keys from data except for the base ones + // in the base language. + const vals = this.keys.reduce((a, key) => { + a[key.key] = data.hasOwnProperty(key.key) ? data[key.key] : this.base[key.key]; + return a; + }, {}); + + this.values = vals; + this.saveData(); + }, + + loadLanguage(lang) { + return fetch(BASEURL + lang + ".json").then(response => response.json()).then(data => { + this.populateData(data); + }).catch((e) => { + console.log(e); + alert("error fetching file: " + e.toString()); + }); + }, + + saveData() { + localStorage.data = JSON.stringify(this.values); + }, + + // Has a key been translated (changed from the base)? + isDone(key) { + return this.values[key] && this.base[key] !== this.values[key]; + }, + + isItemVisible(key) { + return this.visibleKeys[key]; + }, + + onToggleRaw() { + if (!this.isRawVisible) { + this.rawData = JSON.stringify(this.values, Object.keys(this.values).sort(), 4); + } else { + try { + this.populateData(JSON.parse(this.rawData)); + } catch (e) { + alert("error parsing JSON: " + e.toString()); + return false; + } + } + + this.isRawVisible = !this.isRawVisible; + }, + + onLoadLanguage() { + if (!confirm("Loading this language will overwrite your local changes. Continue?")) { + return false; + } + + this.loadLanguage(this.loadLang); + }, + + onNewLang() { + if (!confirm("Creating a new language will overwrite your local changes. Continue?")) { + return false; + } + + let data = { ...this.base }; + data["_.code"] = "iso-code-here" + data["_.name"] = "New language" + this.populateData(data); + }, + + onDownloadJSON() { + // Create a Blob using the content, mimeType, and optional encoding + const blob = new Blob([JSON.stringify(this.values, Object.keys(this.values).sort(), 4)], { type: "" }); + + // Create an anchor element with a download attribute + const link = document.createElement('a'); + link.download = `${this.values["_.code"]}.json`; + link.href = URL.createObjectURL(blob); + + // Append the link to the DOM, click it to start the download, and remove it + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } + }, + + mounted() { + this.loadBaseLang(BASEURL + BASELANG + ".json").then(() => this.init()); + }, + + watch: { + view(v) { + // When the view changes, create a copy of the items to be filtered + // by and filter the view based on that. Otherwise, the moment the value + // in the input changes, the list re-renders making items disappear. + + const visibleKeys = {}; + this.keys.forEach((k) => { + let visible = true; + + if (v === "pending") { + visible = !this.isDone(k.key); + } else if (v === "complete") { + visible = this.isDone(k.key); + } + + if (visible) { + visibleKeys[k.key] = true; + } + }); + + this.visibleKeys = visibleKeys; + } + }, + + computed: { + completed() { + let n = 0; + + this.keys.forEach(k => { + if (this.values[k.key] !== this.base[k.key]) { + n++; + } + }); + + return n; + } + } +}); diff --git a/docs/i18n/style.css b/docs/i18n/style.css new file mode 100644 index 000000000..7d8e5e0c7 --- /dev/null +++ b/docs/i18n/style.css @@ -0,0 +1,114 @@ +* { + box-sizing: border-box; +} + +body { + font-family: Inter, "Helvetica Neue", "Segoe UI", sans-serif; + font-size: 16px; + line-height: 24px; +} + +h1, h2, h3, h4, h5 { + margin: 0 0 15px 0; +} + +a { + color: #0055d4; +} + +.container { + padding: 30px; +} + +.header { + align-items: center; + margin-bottom: 30px; +} + .header a { + display: inline-block; + margin-right: 15px; + } + .header .controls { + display: flex; + } + .header .controls .pending { + color: #ff3300; + } + .header .controls .complete { + color: #05a200; + } + .header .title { + margin: 0 0 15px 0; + } + .header .block { + margin: 0 45px 0 0; + } + .header .view label { + cursor: pointer; + margin-right: 10px; + display: inline-block; + } + +#app { + display: none; +} + +.data .key, +.data .base { + display: block; + color: #777; + display: block; +} + .data .item { + padding: 15px; + clear: both; + } + .data .item:hover { + background: #eee; + } + .data .item.done .num { + color: #05a200; + } + .data .item.done .num::after { + content: '✓'; + font-weight: bold; + } + + .data .controls { + display: flex; + } + .data .fields { + flex-grow: 1; + } + .data .num { + margin-right: 15px; + min-width: 50px; + } + .data .key { + color: #aaa; + font-size: 0.875em; + } + .data input { + width: 100%; + border: 1px solid #ddd; + padding: 5px; + display: block; + margin: 3px 0; + + } + .data input:focus { + border-color: #666; + } + .data p { + margin: 0 0 3px 0; + } + .data .head { + margin: 0 0 15px 0; + } + +.raw textarea { + border: 1px solid #ddd; + padding: 5px; + width: 100%; + height: 90vh; +} \ No newline at end of file diff --git a/docs/i18n/vue.min.js b/docs/i18n/vue.min.js new file mode 100644 index 000000000..41094e008 --- /dev/null +++ b/docs/i18n/vue.min.js @@ -0,0 +1,6 @@ +/*! + * Vue.js v2.6.12 + * (c) 2014-2020 Evan You + * Released under the MIT License. + */ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e=e||self).Vue=t()}(this,function(){"use strict";var e=Object.freeze({});function t(e){return null==e}function n(e){return null!=e}function r(e){return!0===e}function i(e){return"string"==typeof e||"number"==typeof e||"symbol"==typeof e||"boolean"==typeof e}function o(e){return null!==e&&"object"==typeof e}var a=Object.prototype.toString;function s(e){return"[object Object]"===a.call(e)}function c(e){var t=parseFloat(String(e));return t>=0&&Math.floor(t)===t&&isFinite(e)}function u(e){return n(e)&&"function"==typeof e.then&&"function"==typeof e.catch}function l(e){return null==e?"":Array.isArray(e)||s(e)&&e.toString===a?JSON.stringify(e,null,2):String(e)}function f(e){var t=parseFloat(e);return isNaN(t)?e:t}function p(e,t){for(var n=Object.create(null),r=e.split(","),i=0;i-1)return e.splice(n,1)}}var m=Object.prototype.hasOwnProperty;function y(e,t){return m.call(e,t)}function g(e){var t=Object.create(null);return function(n){return t[n]||(t[n]=e(n))}}var _=/-(\w)/g,b=g(function(e){return e.replace(_,function(e,t){return t?t.toUpperCase():""})}),$=g(function(e){return e.charAt(0).toUpperCase()+e.slice(1)}),w=/\B([A-Z])/g,C=g(function(e){return e.replace(w,"-$1").toLowerCase()});var x=Function.prototype.bind?function(e,t){return e.bind(t)}:function(e,t){function n(n){var r=arguments.length;return r?r>1?e.apply(t,arguments):e.call(t,n):e.call(t)}return n._length=e.length,n};function k(e,t){t=t||0;for(var n=e.length-t,r=new Array(n);n--;)r[n]=e[n+t];return r}function A(e,t){for(var n in t)e[n]=t[n];return e}function O(e){for(var t={},n=0;n0,Z=J&&J.indexOf("edge/")>0,G=(J&&J.indexOf("android"),J&&/iphone|ipad|ipod|ios/.test(J)||"ios"===K),X=(J&&/chrome\/\d+/.test(J),J&&/phantomjs/.test(J),J&&J.match(/firefox\/(\d+)/)),Y={}.watch,Q=!1;if(z)try{var ee={};Object.defineProperty(ee,"passive",{get:function(){Q=!0}}),window.addEventListener("test-passive",null,ee)}catch(e){}var te=function(){return void 0===B&&(B=!z&&!V&&"undefined"!=typeof global&&(global.process&&"server"===global.process.env.VUE_ENV)),B},ne=z&&window.__VUE_DEVTOOLS_GLOBAL_HOOK__;function re(e){return"function"==typeof e&&/native code/.test(e.toString())}var ie,oe="undefined"!=typeof Symbol&&re(Symbol)&&"undefined"!=typeof Reflect&&re(Reflect.ownKeys);ie="undefined"!=typeof Set&&re(Set)?Set:function(){function e(){this.set=Object.create(null)}return e.prototype.has=function(e){return!0===this.set[e]},e.prototype.add=function(e){this.set[e]=!0},e.prototype.clear=function(){this.set=Object.create(null)},e}();var ae=S,se=0,ce=function(){this.id=se++,this.subs=[]};ce.prototype.addSub=function(e){this.subs.push(e)},ce.prototype.removeSub=function(e){h(this.subs,e)},ce.prototype.depend=function(){ce.target&&ce.target.addDep(this)},ce.prototype.notify=function(){for(var e=this.subs.slice(),t=0,n=e.length;t-1)if(o&&!y(i,"default"))a=!1;else if(""===a||a===C(e)){var c=Pe(String,i.type);(c<0||s0&&(st((u=e(u,(a||"")+"_"+c))[0])&&st(f)&&(s[l]=he(f.text+u[0].text),u.shift()),s.push.apply(s,u)):i(u)?st(f)?s[l]=he(f.text+u):""!==u&&s.push(he(u)):st(u)&&st(f)?s[l]=he(f.text+u.text):(r(o._isVList)&&n(u.tag)&&t(u.key)&&n(a)&&(u.key="__vlist"+a+"_"+c+"__"),s.push(u)));return s}(e):void 0}function st(e){return n(e)&&n(e.text)&&!1===e.isComment}function ct(e,t){if(e){for(var n=Object.create(null),r=oe?Reflect.ownKeys(e):Object.keys(e),i=0;i0,a=t?!!t.$stable:!o,s=t&&t.$key;if(t){if(t._normalized)return t._normalized;if(a&&r&&r!==e&&s===r.$key&&!o&&!r.$hasNormal)return r;for(var c in i={},t)t[c]&&"$"!==c[0]&&(i[c]=pt(n,c,t[c]))}else i={};for(var u in n)u in i||(i[u]=dt(n,u));return t&&Object.isExtensible(t)&&(t._normalized=i),R(i,"$stable",a),R(i,"$key",s),R(i,"$hasNormal",o),i}function pt(e,t,n){var r=function(){var e=arguments.length?n.apply(null,arguments):n({});return(e=e&&"object"==typeof e&&!Array.isArray(e)?[e]:at(e))&&(0===e.length||1===e.length&&e[0].isComment)?void 0:e};return n.proxy&&Object.defineProperty(e,t,{get:r,enumerable:!0,configurable:!0}),r}function dt(e,t){return function(){return e[t]}}function vt(e,t){var r,i,a,s,c;if(Array.isArray(e)||"string"==typeof e)for(r=new Array(e.length),i=0,a=e.length;idocument.createEvent("Event").timeStamp&&(sn=function(){return cn.now()})}function un(){var e,t;for(an=sn(),rn=!0,Qt.sort(function(e,t){return e.id-t.id}),on=0;onon&&Qt[n].id>e.id;)n--;Qt.splice(n+1,0,e)}else Qt.push(e);nn||(nn=!0,Ye(un))}}(this)},fn.prototype.run=function(){if(this.active){var e=this.get();if(e!==this.value||o(e)||this.deep){var t=this.value;if(this.value=e,this.user)try{this.cb.call(this.vm,e,t)}catch(e){Re(e,this.vm,'callback for watcher "'+this.expression+'"')}else this.cb.call(this.vm,e,t)}}},fn.prototype.evaluate=function(){this.value=this.get(),this.dirty=!1},fn.prototype.depend=function(){for(var e=this.deps.length;e--;)this.deps[e].depend()},fn.prototype.teardown=function(){if(this.active){this.vm._isBeingDestroyed||h(this.vm._watchers,this);for(var e=this.deps.length;e--;)this.deps[e].removeSub(this);this.active=!1}};var pn={enumerable:!0,configurable:!0,get:S,set:S};function dn(e,t,n){pn.get=function(){return this[t][n]},pn.set=function(e){this[t][n]=e},Object.defineProperty(e,n,pn)}function vn(e){e._watchers=[];var t=e.$options;t.props&&function(e,t){var n=e.$options.propsData||{},r=e._props={},i=e.$options._propKeys=[];e.$parent&&$e(!1);var o=function(o){i.push(o);var a=Me(o,t,n,e);xe(r,o,a),o in e||dn(e,"_props",o)};for(var a in t)o(a);$e(!0)}(e,t.props),t.methods&&function(e,t){e.$options.props;for(var n in t)e[n]="function"!=typeof t[n]?S:x(t[n],e)}(e,t.methods),t.data?function(e){var t=e.$options.data;s(t=e._data="function"==typeof t?function(e,t){le();try{return e.call(t,t)}catch(e){return Re(e,t,"data()"),{}}finally{fe()}}(t,e):t||{})||(t={});var n=Object.keys(t),r=e.$options.props,i=(e.$options.methods,n.length);for(;i--;){var o=n[i];r&&y(r,o)||(a=void 0,36!==(a=(o+"").charCodeAt(0))&&95!==a&&dn(e,"_data",o))}var a;Ce(t,!0)}(e):Ce(e._data={},!0),t.computed&&function(e,t){var n=e._computedWatchers=Object.create(null),r=te();for(var i in t){var o=t[i],a="function"==typeof o?o:o.get;r||(n[i]=new fn(e,a||S,S,hn)),i in e||mn(e,i,o)}}(e,t.computed),t.watch&&t.watch!==Y&&function(e,t){for(var n in t){var r=t[n];if(Array.isArray(r))for(var i=0;i-1:"string"==typeof e?e.split(",").indexOf(t)>-1:(n=e,"[object RegExp]"===a.call(n)&&e.test(t));var n}function An(e,t){var n=e.cache,r=e.keys,i=e._vnode;for(var o in n){var a=n[o];if(a){var s=xn(a.componentOptions);s&&!t(s)&&On(n,o,r,i)}}}function On(e,t,n,r){var i=e[t];!i||r&&i.tag===r.tag||i.componentInstance.$destroy(),e[t]=null,h(n,t)}!function(t){t.prototype._init=function(t){var n=this;n._uid=bn++,n._isVue=!0,t&&t._isComponent?function(e,t){var n=e.$options=Object.create(e.constructor.options),r=t._parentVnode;n.parent=t.parent,n._parentVnode=r;var i=r.componentOptions;n.propsData=i.propsData,n._parentListeners=i.listeners,n._renderChildren=i.children,n._componentTag=i.tag,t.render&&(n.render=t.render,n.staticRenderFns=t.staticRenderFns)}(n,t):n.$options=De($n(n.constructor),t||{},n),n._renderProxy=n,n._self=n,function(e){var t=e.$options,n=t.parent;if(n&&!t.abstract){for(;n.$options.abstract&&n.$parent;)n=n.$parent;n.$children.push(e)}e.$parent=n,e.$root=n?n.$root:e,e.$children=[],e.$refs={},e._watcher=null,e._inactive=null,e._directInactive=!1,e._isMounted=!1,e._isDestroyed=!1,e._isBeingDestroyed=!1}(n),function(e){e._events=Object.create(null),e._hasHookEvent=!1;var t=e.$options._parentListeners;t&&qt(e,t)}(n),function(t){t._vnode=null,t._staticTrees=null;var n=t.$options,r=t.$vnode=n._parentVnode,i=r&&r.context;t.$slots=ut(n._renderChildren,i),t.$scopedSlots=e,t._c=function(e,n,r,i){return Pt(t,e,n,r,i,!1)},t.$createElement=function(e,n,r,i){return Pt(t,e,n,r,i,!0)};var o=r&&r.data;xe(t,"$attrs",o&&o.attrs||e,null,!0),xe(t,"$listeners",n._parentListeners||e,null,!0)}(n),Yt(n,"beforeCreate"),function(e){var t=ct(e.$options.inject,e);t&&($e(!1),Object.keys(t).forEach(function(n){xe(e,n,t[n])}),$e(!0))}(n),vn(n),function(e){var t=e.$options.provide;t&&(e._provided="function"==typeof t?t.call(e):t)}(n),Yt(n,"created"),n.$options.el&&n.$mount(n.$options.el)}}(wn),function(e){var t={get:function(){return this._data}},n={get:function(){return this._props}};Object.defineProperty(e.prototype,"$data",t),Object.defineProperty(e.prototype,"$props",n),e.prototype.$set=ke,e.prototype.$delete=Ae,e.prototype.$watch=function(e,t,n){if(s(t))return _n(this,e,t,n);(n=n||{}).user=!0;var r=new fn(this,e,t,n);if(n.immediate)try{t.call(this,r.value)}catch(e){Re(e,this,'callback for immediate watcher "'+r.expression+'"')}return function(){r.teardown()}}}(wn),function(e){var t=/^hook:/;e.prototype.$on=function(e,n){var r=this;if(Array.isArray(e))for(var i=0,o=e.length;i1?k(t):t;for(var n=k(arguments,1),r='event handler for "'+e+'"',i=0,o=t.length;iparseInt(this.max)&&On(a,s[0],s,this._vnode)),t.data.keepAlive=!0}return t||e&&e[0]}}};!function(e){var t={get:function(){return F}};Object.defineProperty(e,"config",t),e.util={warn:ae,extend:A,mergeOptions:De,defineReactive:xe},e.set=ke,e.delete=Ae,e.nextTick=Ye,e.observable=function(e){return Ce(e),e},e.options=Object.create(null),M.forEach(function(t){e.options[t+"s"]=Object.create(null)}),e.options._base=e,A(e.options.components,Tn),function(e){e.use=function(e){var t=this._installedPlugins||(this._installedPlugins=[]);if(t.indexOf(e)>-1)return this;var n=k(arguments,1);return n.unshift(this),"function"==typeof e.install?e.install.apply(e,n):"function"==typeof e&&e.apply(null,n),t.push(e),this}}(e),function(e){e.mixin=function(e){return this.options=De(this.options,e),this}}(e),Cn(e),function(e){M.forEach(function(t){e[t]=function(e,n){return n?("component"===t&&s(n)&&(n.name=n.name||e,n=this.options._base.extend(n)),"directive"===t&&"function"==typeof n&&(n={bind:n,update:n}),this.options[t+"s"][e]=n,n):this.options[t+"s"][e]}})}(e)}(wn),Object.defineProperty(wn.prototype,"$isServer",{get:te}),Object.defineProperty(wn.prototype,"$ssrContext",{get:function(){return this.$vnode&&this.$vnode.ssrContext}}),Object.defineProperty(wn,"FunctionalRenderContext",{value:Tt}),wn.version="2.6.12";var En=p("style,class"),Nn=p("input,textarea,option,select,progress"),jn=function(e,t,n){return"value"===n&&Nn(e)&&"button"!==t||"selected"===n&&"option"===e||"checked"===n&&"input"===e||"muted"===n&&"video"===e},Dn=p("contenteditable,draggable,spellcheck"),Ln=p("events,caret,typing,plaintext-only"),Mn=function(e,t){return Hn(t)||"false"===t?"false":"contenteditable"===e&&Ln(t)?t:"true"},In=p("allowfullscreen,async,autofocus,autoplay,checked,compact,controls,declare,default,defaultchecked,defaultmuted,defaultselected,defer,disabled,enabled,formnovalidate,hidden,indeterminate,inert,ismap,itemscope,loop,multiple,muted,nohref,noresize,noshade,novalidate,nowrap,open,pauseonexit,readonly,required,reversed,scoped,seamless,selected,sortable,translate,truespeed,typemustmatch,visible"),Fn="http://www.w3.org/1999/xlink",Pn=function(e){return":"===e.charAt(5)&&"xlink"===e.slice(0,5)},Rn=function(e){return Pn(e)?e.slice(6,e.length):""},Hn=function(e){return null==e||!1===e};function Bn(e){for(var t=e.data,r=e,i=e;n(i.componentInstance);)(i=i.componentInstance._vnode)&&i.data&&(t=Un(i.data,t));for(;n(r=r.parent);)r&&r.data&&(t=Un(t,r.data));return function(e,t){if(n(e)||n(t))return zn(e,Vn(t));return""}(t.staticClass,t.class)}function Un(e,t){return{staticClass:zn(e.staticClass,t.staticClass),class:n(e.class)?[e.class,t.class]:t.class}}function zn(e,t){return e?t?e+" "+t:e:t||""}function Vn(e){return Array.isArray(e)?function(e){for(var t,r="",i=0,o=e.length;i-1?hr(e,t,n):In(t)?Hn(n)?e.removeAttribute(t):(n="allowfullscreen"===t&&"EMBED"===e.tagName?"true":t,e.setAttribute(t,n)):Dn(t)?e.setAttribute(t,Mn(t,n)):Pn(t)?Hn(n)?e.removeAttributeNS(Fn,Rn(t)):e.setAttributeNS(Fn,t,n):hr(e,t,n)}function hr(e,t,n){if(Hn(n))e.removeAttribute(t);else{if(q&&!W&&"TEXTAREA"===e.tagName&&"placeholder"===t&&""!==n&&!e.__ieph){var r=function(t){t.stopImmediatePropagation(),e.removeEventListener("input",r)};e.addEventListener("input",r),e.__ieph=!0}e.setAttribute(t,n)}}var mr={create:dr,update:dr};function yr(e,r){var i=r.elm,o=r.data,a=e.data;if(!(t(o.staticClass)&&t(o.class)&&(t(a)||t(a.staticClass)&&t(a.class)))){var s=Bn(r),c=i._transitionClasses;n(c)&&(s=zn(s,Vn(c))),s!==i._prevClass&&(i.setAttribute("class",s),i._prevClass=s)}}var gr,_r,br,$r,wr,Cr,xr={create:yr,update:yr},kr=/[\w).+\-_$\]]/;function Ar(e){var t,n,r,i,o,a=!1,s=!1,c=!1,u=!1,l=0,f=0,p=0,d=0;for(r=0;r=0&&" "===(h=e.charAt(v));v--);h&&kr.test(h)||(u=!0)}}else void 0===i?(d=r+1,i=e.slice(0,r).trim()):m();function m(){(o||(o=[])).push(e.slice(d,r).trim()),d=r+1}if(void 0===i?i=e.slice(0,r).trim():0!==d&&m(),o)for(r=0;r-1?{exp:e.slice(0,$r),key:'"'+e.slice($r+1)+'"'}:{exp:e,key:null};_r=e,$r=wr=Cr=0;for(;!zr();)Vr(br=Ur())?Jr(br):91===br&&Kr(br);return{exp:e.slice(0,wr),key:e.slice(wr+1,Cr)}}(e);return null===n.key?e+"="+t:"$set("+n.exp+", "+n.key+", "+t+")"}function Ur(){return _r.charCodeAt(++$r)}function zr(){return $r>=gr}function Vr(e){return 34===e||39===e}function Kr(e){var t=1;for(wr=$r;!zr();)if(Vr(e=Ur()))Jr(e);else if(91===e&&t++,93===e&&t--,0===t){Cr=$r;break}}function Jr(e){for(var t=e;!zr()&&(e=Ur())!==t;);}var qr,Wr="__r",Zr="__c";function Gr(e,t,n){var r=qr;return function i(){null!==t.apply(null,arguments)&&Qr(e,i,n,r)}}var Xr=Ve&&!(X&&Number(X[1])<=53);function Yr(e,t,n,r){if(Xr){var i=an,o=t;t=o._wrapper=function(e){if(e.target===e.currentTarget||e.timeStamp>=i||e.timeStamp<=0||e.target.ownerDocument!==document)return o.apply(this,arguments)}}qr.addEventListener(e,t,Q?{capture:n,passive:r}:n)}function Qr(e,t,n,r){(r||qr).removeEventListener(e,t._wrapper||t,n)}function ei(e,r){if(!t(e.data.on)||!t(r.data.on)){var i=r.data.on||{},o=e.data.on||{};qr=r.elm,function(e){if(n(e[Wr])){var t=q?"change":"input";e[t]=[].concat(e[Wr],e[t]||[]),delete e[Wr]}n(e[Zr])&&(e.change=[].concat(e[Zr],e.change||[]),delete e[Zr])}(i),rt(i,o,Yr,Qr,Gr,r.context),qr=void 0}}var ti,ni={create:ei,update:ei};function ri(e,r){if(!t(e.data.domProps)||!t(r.data.domProps)){var i,o,a=r.elm,s=e.data.domProps||{},c=r.data.domProps||{};for(i in n(c.__ob__)&&(c=r.data.domProps=A({},c)),s)i in c||(a[i]="");for(i in c){if(o=c[i],"textContent"===i||"innerHTML"===i){if(r.children&&(r.children.length=0),o===s[i])continue;1===a.childNodes.length&&a.removeChild(a.childNodes[0])}if("value"===i&&"PROGRESS"!==a.tagName){a._value=o;var u=t(o)?"":String(o);ii(a,u)&&(a.value=u)}else if("innerHTML"===i&&qn(a.tagName)&&t(a.innerHTML)){(ti=ti||document.createElement("div")).innerHTML=""+o+"";for(var l=ti.firstChild;a.firstChild;)a.removeChild(a.firstChild);for(;l.firstChild;)a.appendChild(l.firstChild)}else if(o!==s[i])try{a[i]=o}catch(e){}}}}function ii(e,t){return!e.composing&&("OPTION"===e.tagName||function(e,t){var n=!0;try{n=document.activeElement!==e}catch(e){}return n&&e.value!==t}(e,t)||function(e,t){var r=e.value,i=e._vModifiers;if(n(i)){if(i.number)return f(r)!==f(t);if(i.trim)return r.trim()!==t.trim()}return r!==t}(e,t))}var oi={create:ri,update:ri},ai=g(function(e){var t={},n=/:(.+)/;return e.split(/;(?![^(]*\))/g).forEach(function(e){if(e){var r=e.split(n);r.length>1&&(t[r[0].trim()]=r[1].trim())}}),t});function si(e){var t=ci(e.style);return e.staticStyle?A(e.staticStyle,t):t}function ci(e){return Array.isArray(e)?O(e):"string"==typeof e?ai(e):e}var ui,li=/^--/,fi=/\s*!important$/,pi=function(e,t,n){if(li.test(t))e.style.setProperty(t,n);else if(fi.test(n))e.style.setProperty(C(t),n.replace(fi,""),"important");else{var r=vi(t);if(Array.isArray(n))for(var i=0,o=n.length;i-1?t.split(yi).forEach(function(t){return e.classList.add(t)}):e.classList.add(t);else{var n=" "+(e.getAttribute("class")||"")+" ";n.indexOf(" "+t+" ")<0&&e.setAttribute("class",(n+t).trim())}}function _i(e,t){if(t&&(t=t.trim()))if(e.classList)t.indexOf(" ")>-1?t.split(yi).forEach(function(t){return e.classList.remove(t)}):e.classList.remove(t),e.classList.length||e.removeAttribute("class");else{for(var n=" "+(e.getAttribute("class")||"")+" ",r=" "+t+" ";n.indexOf(r)>=0;)n=n.replace(r," ");(n=n.trim())?e.setAttribute("class",n):e.removeAttribute("class")}}function bi(e){if(e){if("object"==typeof e){var t={};return!1!==e.css&&A(t,$i(e.name||"v")),A(t,e),t}return"string"==typeof e?$i(e):void 0}}var $i=g(function(e){return{enterClass:e+"-enter",enterToClass:e+"-enter-to",enterActiveClass:e+"-enter-active",leaveClass:e+"-leave",leaveToClass:e+"-leave-to",leaveActiveClass:e+"-leave-active"}}),wi=z&&!W,Ci="transition",xi="animation",ki="transition",Ai="transitionend",Oi="animation",Si="animationend";wi&&(void 0===window.ontransitionend&&void 0!==window.onwebkittransitionend&&(ki="WebkitTransition",Ai="webkitTransitionEnd"),void 0===window.onanimationend&&void 0!==window.onwebkitanimationend&&(Oi="WebkitAnimation",Si="webkitAnimationEnd"));var Ti=z?window.requestAnimationFrame?window.requestAnimationFrame.bind(window):setTimeout:function(e){return e()};function Ei(e){Ti(function(){Ti(e)})}function Ni(e,t){var n=e._transitionClasses||(e._transitionClasses=[]);n.indexOf(t)<0&&(n.push(t),gi(e,t))}function ji(e,t){e._transitionClasses&&h(e._transitionClasses,t),_i(e,t)}function Di(e,t,n){var r=Mi(e,t),i=r.type,o=r.timeout,a=r.propCount;if(!i)return n();var s=i===Ci?Ai:Si,c=0,u=function(){e.removeEventListener(s,l),n()},l=function(t){t.target===e&&++c>=a&&u()};setTimeout(function(){c0&&(n=Ci,l=a,f=o.length):t===xi?u>0&&(n=xi,l=u,f=c.length):f=(n=(l=Math.max(a,u))>0?a>u?Ci:xi:null)?n===Ci?o.length:c.length:0,{type:n,timeout:l,propCount:f,hasTransform:n===Ci&&Li.test(r[ki+"Property"])}}function Ii(e,t){for(;e.length1}function Ui(e,t){!0!==t.data.show&&Pi(t)}var zi=function(e){var o,a,s={},c=e.modules,u=e.nodeOps;for(o=0;ov?_(e,t(i[y+1])?null:i[y+1].elm,i,d,y,o):d>y&&$(r,p,v)}(p,h,y,o,l):n(y)?(n(e.text)&&u.setTextContent(p,""),_(p,null,y,0,y.length-1,o)):n(h)?$(h,0,h.length-1):n(e.text)&&u.setTextContent(p,""):e.text!==i.text&&u.setTextContent(p,i.text),n(v)&&n(d=v.hook)&&n(d=d.postpatch)&&d(e,i)}}}function k(e,t,i){if(r(i)&&n(e.parent))e.parent.data.pendingInsert=t;else for(var o=0;o-1,a.selected!==o&&(a.selected=o);else if(N(Wi(a),r))return void(e.selectedIndex!==s&&(e.selectedIndex=s));i||(e.selectedIndex=-1)}}function qi(e,t){return t.every(function(t){return!N(t,e)})}function Wi(e){return"_value"in e?e._value:e.value}function Zi(e){e.target.composing=!0}function Gi(e){e.target.composing&&(e.target.composing=!1,Xi(e.target,"input"))}function Xi(e,t){var n=document.createEvent("HTMLEvents");n.initEvent(t,!0,!0),e.dispatchEvent(n)}function Yi(e){return!e.componentInstance||e.data&&e.data.transition?e:Yi(e.componentInstance._vnode)}var Qi={model:Vi,show:{bind:function(e,t,n){var r=t.value,i=(n=Yi(n)).data&&n.data.transition,o=e.__vOriginalDisplay="none"===e.style.display?"":e.style.display;r&&i?(n.data.show=!0,Pi(n,function(){e.style.display=o})):e.style.display=r?o:"none"},update:function(e,t,n){var r=t.value;!r!=!t.oldValue&&((n=Yi(n)).data&&n.data.transition?(n.data.show=!0,r?Pi(n,function(){e.style.display=e.__vOriginalDisplay}):Ri(n,function(){e.style.display="none"})):e.style.display=r?e.__vOriginalDisplay:"none")},unbind:function(e,t,n,r,i){i||(e.style.display=e.__vOriginalDisplay)}}},eo={name:String,appear:Boolean,css:Boolean,mode:String,type:String,enterClass:String,leaveClass:String,enterToClass:String,leaveToClass:String,enterActiveClass:String,leaveActiveClass:String,appearClass:String,appearActiveClass:String,appearToClass:String,duration:[Number,String,Object]};function to(e){var t=e&&e.componentOptions;return t&&t.Ctor.options.abstract?to(zt(t.children)):e}function no(e){var t={},n=e.$options;for(var r in n.propsData)t[r]=e[r];var i=n._parentListeners;for(var o in i)t[b(o)]=i[o];return t}function ro(e,t){if(/\d-keep-alive$/.test(t.tag))return e("keep-alive",{props:t.componentOptions.propsData})}var io=function(e){return e.tag||Ut(e)},oo=function(e){return"show"===e.name},ao={name:"transition",props:eo,abstract:!0,render:function(e){var t=this,n=this.$slots.default;if(n&&(n=n.filter(io)).length){var r=this.mode,o=n[0];if(function(e){for(;e=e.parent;)if(e.data.transition)return!0}(this.$vnode))return o;var a=to(o);if(!a)return o;if(this._leaving)return ro(e,o);var s="__transition-"+this._uid+"-";a.key=null==a.key?a.isComment?s+"comment":s+a.tag:i(a.key)?0===String(a.key).indexOf(s)?a.key:s+a.key:a.key;var c=(a.data||(a.data={})).transition=no(this),u=this._vnode,l=to(u);if(a.data.directives&&a.data.directives.some(oo)&&(a.data.show=!0),l&&l.data&&!function(e,t){return t.key===e.key&&t.tag===e.tag}(a,l)&&!Ut(l)&&(!l.componentInstance||!l.componentInstance._vnode.isComment)){var f=l.data.transition=A({},c);if("out-in"===r)return this._leaving=!0,it(f,"afterLeave",function(){t._leaving=!1,t.$forceUpdate()}),ro(e,o);if("in-out"===r){if(Ut(a))return u;var p,d=function(){p()};it(c,"afterEnter",d),it(c,"enterCancelled",d),it(f,"delayLeave",function(e){p=e})}}return o}}},so=A({tag:String,moveClass:String},eo);function co(e){e.elm._moveCb&&e.elm._moveCb(),e.elm._enterCb&&e.elm._enterCb()}function uo(e){e.data.newPos=e.elm.getBoundingClientRect()}function lo(e){var t=e.data.pos,n=e.data.newPos,r=t.left-n.left,i=t.top-n.top;if(r||i){e.data.moved=!0;var o=e.elm.style;o.transform=o.WebkitTransform="translate("+r+"px,"+i+"px)",o.transitionDuration="0s"}}delete so.mode;var fo={Transition:ao,TransitionGroup:{props:so,beforeMount:function(){var e=this,t=this._update;this._update=function(n,r){var i=Zt(e);e.__patch__(e._vnode,e.kept,!1,!0),e._vnode=e.kept,i(),t.call(e,n,r)}},render:function(e){for(var t=this.tag||this.$vnode.data.tag||"span",n=Object.create(null),r=this.prevChildren=this.children,i=this.$slots.default||[],o=this.children=[],a=no(this),s=0;s-1?Gn[e]=t.constructor===window.HTMLUnknownElement||t.constructor===window.HTMLElement:Gn[e]=/HTMLUnknownElement/.test(t.toString())},A(wn.options.directives,Qi),A(wn.options.components,fo),wn.prototype.__patch__=z?zi:S,wn.prototype.$mount=function(e,t){return function(e,t,n){var r;return e.$el=t,e.$options.render||(e.$options.render=ve),Yt(e,"beforeMount"),r=function(){e._update(e._render(),n)},new fn(e,r,S,{before:function(){e._isMounted&&!e._isDestroyed&&Yt(e,"beforeUpdate")}},!0),n=!1,null==e.$vnode&&(e._isMounted=!0,Yt(e,"mounted")),e}(this,e=e&&z?Yn(e):void 0,t)},z&&setTimeout(function(){F.devtools&&ne&&ne.emit("init",wn)},0);var po=/\{\{((?:.|\r?\n)+?)\}\}/g,vo=/[-.*+?^${}()|[\]\/\\]/g,ho=g(function(e){var t=e[0].replace(vo,"\\$&"),n=e[1].replace(vo,"\\$&");return new RegExp(t+"((?:.|\\n)+?)"+n,"g")});var mo={staticKeys:["staticClass"],transformNode:function(e,t){t.warn;var n=Fr(e,"class");n&&(e.staticClass=JSON.stringify(n));var r=Ir(e,"class",!1);r&&(e.classBinding=r)},genData:function(e){var t="";return e.staticClass&&(t+="staticClass:"+e.staticClass+","),e.classBinding&&(t+="class:"+e.classBinding+","),t}};var yo,go={staticKeys:["staticStyle"],transformNode:function(e,t){t.warn;var n=Fr(e,"style");n&&(e.staticStyle=JSON.stringify(ai(n)));var r=Ir(e,"style",!1);r&&(e.styleBinding=r)},genData:function(e){var t="";return e.staticStyle&&(t+="staticStyle:"+e.staticStyle+","),e.styleBinding&&(t+="style:("+e.styleBinding+"),"),t}},_o=function(e){return(yo=yo||document.createElement("div")).innerHTML=e,yo.textContent},bo=p("area,base,br,col,embed,frame,hr,img,input,isindex,keygen,link,meta,param,source,track,wbr"),$o=p("colgroup,dd,dt,li,options,p,td,tfoot,th,thead,tr,source"),wo=p("address,article,aside,base,blockquote,body,caption,col,colgroup,dd,details,dialog,div,dl,dt,fieldset,figcaption,figure,footer,form,h1,h2,h3,h4,h5,h6,head,header,hgroup,hr,html,legend,li,menuitem,meta,optgroup,option,param,rp,rt,source,style,summary,tbody,td,tfoot,th,thead,title,tr,track"),Co=/^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/,xo=/^\s*((?:v-[\w-]+:|@|:|#)\[[^=]+\][^\s"'<>\/=]*)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/,ko="[a-zA-Z_][\\-\\.0-9_a-zA-Z"+P.source+"]*",Ao="((?:"+ko+"\\:)?"+ko+")",Oo=new RegExp("^<"+Ao),So=/^\s*(\/?)>/,To=new RegExp("^<\\/"+Ao+"[^>]*>"),Eo=/^]+>/i,No=/^",""":'"',"&":"&"," ":"\n"," ":"\t","'":"'"},Io=/&(?:lt|gt|quot|amp|#39);/g,Fo=/&(?:lt|gt|quot|amp|#39|#10|#9);/g,Po=p("pre,textarea",!0),Ro=function(e,t){return e&&Po(e)&&"\n"===t[0]};function Ho(e,t){var n=t?Fo:Io;return e.replace(n,function(e){return Mo[e]})}var Bo,Uo,zo,Vo,Ko,Jo,qo,Wo,Zo=/^@|^v-on:/,Go=/^v-|^@|^:|^#/,Xo=/([\s\S]*?)\s+(?:in|of)\s+([\s\S]*)/,Yo=/,([^,\}\]]*)(?:,([^,\}\]]*))?$/,Qo=/^\(|\)$/g,ea=/^\[.*\]$/,ta=/:(.*)$/,na=/^:|^\.|^v-bind:/,ra=/\.[^.\]]+(?=[^\]]*$)/g,ia=/^v-slot(:|$)|^#/,oa=/[\r\n]/,aa=/\s+/g,sa=g(_o),ca="_empty_";function ua(e,t,n){return{type:1,tag:e,attrsList:t,attrsMap:ma(t),rawAttrsMap:{},parent:n,children:[]}}function la(e,t){Bo=t.warn||Sr,Jo=t.isPreTag||T,qo=t.mustUseProp||T,Wo=t.getTagNamespace||T;t.isReservedTag;zo=Tr(t.modules,"transformNode"),Vo=Tr(t.modules,"preTransformNode"),Ko=Tr(t.modules,"postTransformNode"),Uo=t.delimiters;var n,r,i=[],o=!1!==t.preserveWhitespace,a=t.whitespace,s=!1,c=!1;function u(e){if(l(e),s||e.processed||(e=fa(e,t)),i.length||e===n||n.if&&(e.elseif||e.else)&&da(n,{exp:e.elseif,block:e}),r&&!e.forbidden)if(e.elseif||e.else)a=e,(u=function(e){var t=e.length;for(;t--;){if(1===e[t].type)return e[t];e.pop()}}(r.children))&&u.if&&da(u,{exp:a.elseif,block:a});else{if(e.slotScope){var o=e.slotTarget||'"default"';(r.scopedSlots||(r.scopedSlots={}))[o]=e}r.children.push(e),e.parent=r}var a,u;e.children=e.children.filter(function(e){return!e.slotScope}),l(e),e.pre&&(s=!1),Jo(e.tag)&&(c=!1);for(var f=0;f]*>)","i")),p=e.replace(f,function(e,n,r){return u=r.length,Do(l)||"noscript"===l||(n=n.replace(//g,"$1").replace(//g,"$1")),Ro(l,n)&&(n=n.slice(1)),t.chars&&t.chars(n),""});c+=e.length-p.length,e=p,A(l,c-u,c)}else{var d=e.indexOf("<");if(0===d){if(No.test(e)){var v=e.indexOf("--\x3e");if(v>=0){t.shouldKeepComment&&t.comment(e.substring(4,v),c,c+v+3),C(v+3);continue}}if(jo.test(e)){var h=e.indexOf("]>");if(h>=0){C(h+2);continue}}var m=e.match(Eo);if(m){C(m[0].length);continue}var y=e.match(To);if(y){var g=c;C(y[0].length),A(y[1],g,c);continue}var _=x();if(_){k(_),Ro(_.tagName,e)&&C(1);continue}}var b=void 0,$=void 0,w=void 0;if(d>=0){for($=e.slice(d);!(To.test($)||Oo.test($)||No.test($)||jo.test($)||(w=$.indexOf("<",1))<0);)d+=w,$=e.slice(d);b=e.substring(0,d)}d<0&&(b=e),b&&C(b.length),t.chars&&b&&t.chars(b,c-b.length,c)}if(e===n){t.chars&&t.chars(e);break}}function C(t){c+=t,e=e.substring(t)}function x(){var t=e.match(Oo);if(t){var n,r,i={tagName:t[1],attrs:[],start:c};for(C(t[0].length);!(n=e.match(So))&&(r=e.match(xo)||e.match(Co));)r.start=c,C(r[0].length),r.end=c,i.attrs.push(r);if(n)return i.unarySlash=n[1],C(n[0].length),i.end=c,i}}function k(e){var n=e.tagName,c=e.unarySlash;o&&("p"===r&&wo(n)&&A(r),s(n)&&r===n&&A(n));for(var u=a(n)||!!c,l=e.attrs.length,f=new Array(l),p=0;p=0&&i[a].lowerCasedTag!==s;a--);else a=0;if(a>=0){for(var u=i.length-1;u>=a;u--)t.end&&t.end(i[u].tag,n,o);i.length=a,r=a&&i[a-1].tag}else"br"===s?t.start&&t.start(e,[],!0,n,o):"p"===s&&(t.start&&t.start(e,[],!1,n,o),t.end&&t.end(e,n,o))}A()}(e,{warn:Bo,expectHTML:t.expectHTML,isUnaryTag:t.isUnaryTag,canBeLeftOpenTag:t.canBeLeftOpenTag,shouldDecodeNewlines:t.shouldDecodeNewlines,shouldDecodeNewlinesForHref:t.shouldDecodeNewlinesForHref,shouldKeepComment:t.comments,outputSourceRange:t.outputSourceRange,start:function(e,o,a,l,f){var p=r&&r.ns||Wo(e);q&&"svg"===p&&(o=function(e){for(var t=[],n=0;nc&&(s.push(o=e.slice(c,i)),a.push(JSON.stringify(o)));var u=Ar(r[1].trim());a.push("_s("+u+")"),s.push({"@binding":u}),c=i+r[0].length}return c-1"+("true"===o?":("+t+")":":_q("+t+","+o+")")),Mr(e,"change","var $$a="+t+",$$el=$event.target,$$c=$$el.checked?("+o+"):("+a+");if(Array.isArray($$a)){var $$v="+(r?"_n("+i+")":i)+",$$i=_i($$a,$$v);if($$el.checked){$$i<0&&("+Br(t,"$$a.concat([$$v])")+")}else{$$i>-1&&("+Br(t,"$$a.slice(0,$$i).concat($$a.slice($$i+1))")+")}}else{"+Br(t,"$$c")+"}",null,!0)}(e,r,i);else if("input"===o&&"radio"===a)!function(e,t,n){var r=n&&n.number,i=Ir(e,"value")||"null";Er(e,"checked","_q("+t+","+(i=r?"_n("+i+")":i)+")"),Mr(e,"change",Br(t,i),null,!0)}(e,r,i);else if("input"===o||"textarea"===o)!function(e,t,n){var r=e.attrsMap.type,i=n||{},o=i.lazy,a=i.number,s=i.trim,c=!o&&"range"!==r,u=o?"change":"range"===r?Wr:"input",l="$event.target.value";s&&(l="$event.target.value.trim()"),a&&(l="_n("+l+")");var f=Br(t,l);c&&(f="if($event.target.composing)return;"+f),Er(e,"value","("+t+")"),Mr(e,u,f,null,!0),(s||a)&&Mr(e,"blur","$forceUpdate()")}(e,r,i);else if(!F.isReservedTag(o))return Hr(e,r,i),!1;return!0},text:function(e,t){t.value&&Er(e,"textContent","_s("+t.value+")",t)},html:function(e,t){t.value&&Er(e,"innerHTML","_s("+t.value+")",t)}},isPreTag:function(e){return"pre"===e},isUnaryTag:bo,mustUseProp:jn,canBeLeftOpenTag:$o,isReservedTag:Wn,getTagNamespace:Zn,staticKeys:function(e){return e.reduce(function(e,t){return e.concat(t.staticKeys||[])},[]).join(",")}(ba)},xa=g(function(e){return p("type,tag,attrsList,attrsMap,plain,parent,children,attrs,start,end,rawAttrsMap"+(e?","+e:""))});function ka(e,t){e&&($a=xa(t.staticKeys||""),wa=t.isReservedTag||T,function e(t){t.static=function(e){if(2===e.type)return!1;if(3===e.type)return!0;return!(!e.pre&&(e.hasBindings||e.if||e.for||d(e.tag)||!wa(e.tag)||function(e){for(;e.parent;){if("template"!==(e=e.parent).tag)return!1;if(e.for)return!0}return!1}(e)||!Object.keys(e).every($a)))}(t);if(1===t.type){if(!wa(t.tag)&&"slot"!==t.tag&&null==t.attrsMap["inline-template"])return;for(var n=0,r=t.children.length;n|^function(?:\s+[\w$]+)?\s*\(/,Oa=/\([^)]*?\);*$/,Sa=/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*|\['[^']*?']|\["[^"]*?"]|\[\d+]|\[[A-Za-z_$][\w$]*])*$/,Ta={esc:27,tab:9,enter:13,space:32,up:38,left:37,right:39,down:40,delete:[8,46]},Ea={esc:["Esc","Escape"],tab:"Tab",enter:"Enter",space:[" ","Spacebar"],up:["Up","ArrowUp"],left:["Left","ArrowLeft"],right:["Right","ArrowRight"],down:["Down","ArrowDown"],delete:["Backspace","Delete","Del"]},Na=function(e){return"if("+e+")return null;"},ja={stop:"$event.stopPropagation();",prevent:"$event.preventDefault();",self:Na("$event.target !== $event.currentTarget"),ctrl:Na("!$event.ctrlKey"),shift:Na("!$event.shiftKey"),alt:Na("!$event.altKey"),meta:Na("!$event.metaKey"),left:Na("'button' in $event && $event.button !== 0"),middle:Na("'button' in $event && $event.button !== 1"),right:Na("'button' in $event && $event.button !== 2")};function Da(e,t){var n=t?"nativeOn:":"on:",r="",i="";for(var o in e){var a=La(e[o]);e[o]&&e[o].dynamic?i+=o+","+a+",":r+='"'+o+'":'+a+","}return r="{"+r.slice(0,-1)+"}",i?n+"_d("+r+",["+i.slice(0,-1)+"])":n+r}function La(e){if(!e)return"function(){}";if(Array.isArray(e))return"["+e.map(function(e){return La(e)}).join(",")+"]";var t=Sa.test(e.value),n=Aa.test(e.value),r=Sa.test(e.value.replace(Oa,""));if(e.modifiers){var i="",o="",a=[];for(var s in e.modifiers)if(ja[s])o+=ja[s],Ta[s]&&a.push(s);else if("exact"===s){var c=e.modifiers;o+=Na(["ctrl","shift","alt","meta"].filter(function(e){return!c[e]}).map(function(e){return"$event."+e+"Key"}).join("||"))}else a.push(s);return a.length&&(i+=function(e){return"if(!$event.type.indexOf('key')&&"+e.map(Ma).join("&&")+")return null;"}(a)),o&&(i+=o),"function($event){"+i+(t?"return "+e.value+"($event)":n?"return ("+e.value+")($event)":r?"return "+e.value:e.value)+"}"}return t||n?e.value:"function($event){"+(r?"return "+e.value:e.value)+"}"}function Ma(e){var t=parseInt(e,10);if(t)return"$event.keyCode!=="+t;var n=Ta[e],r=Ea[e];return"_k($event.keyCode,"+JSON.stringify(e)+","+JSON.stringify(n)+",$event.key,"+JSON.stringify(r)+")"}var Ia={on:function(e,t){e.wrapListeners=function(e){return"_g("+e+","+t.value+")"}},bind:function(e,t){e.wrapData=function(n){return"_b("+n+",'"+e.tag+"',"+t.value+","+(t.modifiers&&t.modifiers.prop?"true":"false")+(t.modifiers&&t.modifiers.sync?",true":"")+")"}},cloak:S},Fa=function(e){this.options=e,this.warn=e.warn||Sr,this.transforms=Tr(e.modules,"transformCode"),this.dataGenFns=Tr(e.modules,"genData"),this.directives=A(A({},Ia),e.directives);var t=e.isReservedTag||T;this.maybeComponent=function(e){return!!e.component||!t(e.tag)},this.onceId=0,this.staticRenderFns=[],this.pre=!1};function Pa(e,t){var n=new Fa(t);return{render:"with(this){return "+(e?Ra(e,n):'_c("div")')+"}",staticRenderFns:n.staticRenderFns}}function Ra(e,t){if(e.parent&&(e.pre=e.pre||e.parent.pre),e.staticRoot&&!e.staticProcessed)return Ha(e,t);if(e.once&&!e.onceProcessed)return Ba(e,t);if(e.for&&!e.forProcessed)return za(e,t);if(e.if&&!e.ifProcessed)return Ua(e,t);if("template"!==e.tag||e.slotTarget||t.pre){if("slot"===e.tag)return function(e,t){var n=e.slotName||'"default"',r=qa(e,t),i="_t("+n+(r?","+r:""),o=e.attrs||e.dynamicAttrs?Ga((e.attrs||[]).concat(e.dynamicAttrs||[]).map(function(e){return{name:b(e.name),value:e.value,dynamic:e.dynamic}})):null,a=e.attrsMap["v-bind"];!o&&!a||r||(i+=",null");o&&(i+=","+o);a&&(i+=(o?"":",null")+","+a);return i+")"}(e,t);var n;if(e.component)n=function(e,t,n){var r=t.inlineTemplate?null:qa(t,n,!0);return"_c("+e+","+Va(t,n)+(r?","+r:"")+")"}(e.component,e,t);else{var r;(!e.plain||e.pre&&t.maybeComponent(e))&&(r=Va(e,t));var i=e.inlineTemplate?null:qa(e,t,!0);n="_c('"+e.tag+"'"+(r?","+r:"")+(i?","+i:"")+")"}for(var o=0;o>>0}(a):"")+")"}(e,e.scopedSlots,t)+","),e.model&&(n+="model:{value:"+e.model.value+",callback:"+e.model.callback+",expression:"+e.model.expression+"},"),e.inlineTemplate){var o=function(e,t){var n=e.children[0];if(n&&1===n.type){var r=Pa(n,t.options);return"inlineTemplate:{render:function(){"+r.render+"},staticRenderFns:["+r.staticRenderFns.map(function(e){return"function(){"+e+"}"}).join(",")+"]}"}}(e,t);o&&(n+=o+",")}return n=n.replace(/,$/,"")+"}",e.dynamicAttrs&&(n="_b("+n+',"'+e.tag+'",'+Ga(e.dynamicAttrs)+")"),e.wrapData&&(n=e.wrapData(n)),e.wrapListeners&&(n=e.wrapListeners(n)),n}function Ka(e){return 1===e.type&&("slot"===e.tag||e.children.some(Ka))}function Ja(e,t){var n=e.attrsMap["slot-scope"];if(e.if&&!e.ifProcessed&&!n)return Ua(e,t,Ja,"null");if(e.for&&!e.forProcessed)return za(e,t,Ja);var r=e.slotScope===ca?"":String(e.slotScope),i="function("+r+"){return "+("template"===e.tag?e.if&&n?"("+e.if+")?"+(qa(e,t)||"undefined")+":undefined":qa(e,t)||"undefined":Ra(e,t))+"}",o=r?"":",proxy:true";return"{key:"+(e.slotTarget||'"default"')+",fn:"+i+o+"}"}function qa(e,t,n,r,i){var o=e.children;if(o.length){var a=o[0];if(1===o.length&&a.for&&"template"!==a.tag&&"slot"!==a.tag){var s=n?t.maybeComponent(a)?",1":",0":"";return""+(r||Ra)(a,t)+s}var c=n?function(e,t){for(var n=0,r=0;r':'
',ts.innerHTML.indexOf(" ")>0}var os=!!z&&is(!1),as=!!z&&is(!0),ss=g(function(e){var t=Yn(e);return t&&t.innerHTML}),cs=wn.prototype.$mount;return wn.prototype.$mount=function(e,t){if((e=e&&Yn(e))===document.body||e===document.documentElement)return this;var n=this.$options;if(!n.render){var r=n.template;if(r)if("string"==typeof r)"#"===r.charAt(0)&&(r=ss(r));else{if(!r.nodeType)return this;r=r.innerHTML}else e&&(r=function(e){if(e.outerHTML)return e.outerHTML;var t=document.createElement("div");return t.appendChild(e.cloneNode(!0)),t.innerHTML}(e));if(r){var i=rs(r,{outputSourceRange:!1,shouldDecodeNewlines:os,shouldDecodeNewlinesForHref:as,delimiters:n.delimiters,comments:n.comments},this),o=i.render,a=i.staticRenderFns;n.render=o,n.staticRenderFns=a}}return cs.call(this,e,t)},wn.compile=rs,wn}); \ No newline at end of file diff --git a/docs/site/.hugo_build.lock b/docs/site/.hugo_build.lock new file mode 100644 index 000000000..e69de29bb diff --git a/docs/site/config.toml b/docs/site/config.toml new file mode 100644 index 000000000..e50803349 --- /dev/null +++ b/docs/site/config.toml @@ -0,0 +1,6 @@ +baseurl = "https://listmonk.app/" +languageCode = "en-us" +title = "listmonk - Free and open source self-hosted newsletter, mailing list manager, and transactional mails" + +[taxonomies] + tag = "tags" diff --git a/docs/site/content/.gitignore b/docs/site/content/.gitignore new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/docs/site/content/.gitignore @@ -0,0 +1 @@ + diff --git a/docs/site/data/github.json b/docs/site/data/github.json new file mode 100644 index 000000000..99a4fb61f --- /dev/null +++ b/docs/site/data/github.json @@ -0,0 +1 @@ +{"version":"v4.1.0","date":"2024-11-12T18:49:52Z","url":"https://github.com/knadh/listmonk/releases/tag/v4.1.0","assets":[{"name":"darwin","url":"https://github.com/knadh/listmonk/releases/download/v4.1.0/listmonk_4.1.0_darwin_amd64.tar.gz"},{"name":"freebsd","url":"https://github.com/knadh/listmonk/releases/download/v4.1.0/listmonk_4.1.0_freebsd_amd64.tar.gz"},{"name":"linux","url":"https://github.com/knadh/listmonk/releases/download/v4.1.0/listmonk_4.1.0_linux_amd64.tar.gz"},{"name":"netbsd","url":"https://github.com/knadh/listmonk/releases/download/v4.1.0/listmonk_4.1.0_netbsd_amd64.tar.gz"},{"name":"openbsd","url":"https://github.com/knadh/listmonk/releases/download/v4.1.0/listmonk_4.1.0_openbsd_amd64.tar.gz"},{"name":"windows","url":"https://github.com/knadh/listmonk/releases/download/v4.1.0/listmonk_4.1.0_windows_amd64.tar.gz"}]} diff --git a/docs/site/layouts/index.html b/docs/site/layouts/index.html new file mode 100644 index 000000000..fdf39a773 --- /dev/null +++ b/docs/site/layouts/index.html @@ -0,0 +1,217 @@ +{{ partial "header.html" . }} +
+ +
+

Self-hosted newsletter and mailing list manager

+

+ Performance and features packed into a single binary.
+ Free and open source. +

+

+ Live demo +

+
+ +
+ + + + listmonk screenshot +
+
+
+ +
+
+

Download

+

+ The latest version is {{ .Page.Site.Data.github.version }} + released on {{ .Page.Site.Data.github.date | dateFormat "02 Jan 2006" }}. + See release notes. +


+ +
+
+
+

Binary

+
    + +
  • + ./listmonk --new-config to generate config.toml. Edit it. +
  • +
  • ./listmonk --install to setup the Postgres DB or --upgrade to upgrade an existing DB.
  • +
  • Run ./listmonk and visit http://localhost:9000
  • +
+

Installation docs →

+ +
+

Hosting providers

+ One-click deploy on Railway +
+ Deploy on PikaPod +
+ Deploy on Elestio +
+ Deploy on Zeabur +

*listmonk has no affiliation with any of these providers.

+
+
+
+
+

Docker

+

listmonk/listmonk:latest

+

+ Download and use the sample docker-compose.yml +

+ +
+# Download the compose file to the current directory.
+curl -LO https://github.com/knadh/listmonk/raw/master/docker-compose.yml
+
+# Run the services in the background.
+docker compose up -d
+
+

Visit http://localhost:9000

+ +

Installation docs →

+
+
+
+
+
+ +
+
+

One-way mailing lists

+
+ Screenshot of list management feature +
+

+ Manage millions of subscribers across many single and double opt-in one-way mailing lists + with custom JSON attributes for each subscriber. + Query and segment subscribers with SQL expressions. +

+

Use the fast bulk importer (~10k records per second) or use HTTP/JSON APIs or interact with the simple + table schema to integrate external CRMs and subscriber databases. +

+
+ +
+

Transactional mails

+
+ Screenshot of transactional API +
+

+ Simple API to send arbitrary transactional messages to subscribers + using pre-defined templates. Send messages as e-mail, SMS, Whatsapp messages or any medium via Messenger interfaces. +

+
+ +
+

Analytics

+
+ Screenshot of analytics feature +
+

+ Simple analytics and visualizations. Connect external visualization programs to the database easily with the simple table structure. +

+
+ +
+

Templating

+
+ Screenshot of templating feature +
+

+ Create powerful, dynamic e-mail templates with the Go templating language. + Use template expressions, logic, and 100+ functions in subject lines and content. + Write HTML e-mails in a WYSIWYG editor, Markdown, raw syntax-highlighted HTML, or just plain text. +

+
+ +
+

Performance

+
+
+ Screenshot of performance metrics + +
+ A production listmonk instance sending a campaign of 7+ million e-mails.
+ CPU usage is a fraction of a single core with peak RAM usage of 57 MB. +
+
+
+
+

+ Multi-threaded, high-throughput, multi-SMTP e-mail queues. + Throughput and sliding window rate limiting for fine grained control. + Single binary application with nominal CPU and memory footprint that runs everywhere. + The only dependency is a Postgres (⩾ 12) database. +

+
+ +
+

Media

+
+ Screenshot of media feature +
+

Use the media manager to upload images for e-mail campaigns + on the server's filesystem, Amazon S3, or any S3 compatible (Minio) backend.

+
+ +
+

Extensible

+
+ Screenshot of Messenger feature +
+

+ More than just e-mail campaigns. Connect HTTP webhooks to send SMS, + Whatsapp, FCM notifications, or any type of messages. +

+
+ +
+

Privacy

+
+ Screenshot of privacy features +
+

+ Allow subscribers to permanently blocklist themselves, export all their data, + and to wipe all their data in a single click. +

+
+ +

and a lot more …

+ +
+
+ Download +
+ + +
+ +{{ partial "footer.html" }} diff --git a/docs/site/layouts/page/single.html b/docs/site/layouts/page/single.html new file mode 100644 index 000000000..143ac1f60 --- /dev/null +++ b/docs/site/layouts/page/single.html @@ -0,0 +1,6 @@ +{{ partial "header" . }} +
+

{{ .Title }}

+ {{ .Content }} +
+{{ partial "footer" }} \ No newline at end of file diff --git a/docs/site/layouts/partials/footer.html b/docs/site/layouts/partials/footer.html new file mode 100644 index 000000000..ec361af67 --- /dev/null +++ b/docs/site/layouts/partials/footer.html @@ -0,0 +1,10 @@ + +
+ +
+ + + + diff --git a/docs/site/layouts/partials/header.html b/docs/site/layouts/partials/header.html new file mode 100644 index 000000000..85957b1e4 --- /dev/null +++ b/docs/site/layouts/partials/header.html @@ -0,0 +1,42 @@ + + + + + {{ .Title }} + + + + + + + + + + + + {{ if .Params.thumbnail }} + + + {{ else }} + + + {{ end }} + + + +
+
+
+ + +
+
+
diff --git a/docs/site/layouts/shortcodes/centered.html b/docs/site/layouts/shortcodes/centered.html new file mode 100644 index 000000000..333f35495 --- /dev/null +++ b/docs/site/layouts/shortcodes/centered.html @@ -0,0 +1,5 @@ +
+
 
+
{{ .Inner }}
+
+
\ No newline at end of file diff --git a/docs/site/layouts/shortcodes/github.html b/docs/site/layouts/shortcodes/github.html new file mode 100644 index 000000000..292fa5602 --- /dev/null +++ b/docs/site/layouts/shortcodes/github.html @@ -0,0 +1,17 @@ +
    + {{ range .Page.Site.Data.github }} +
  • +
    + {{ dateFormat "Jan 2006" (substr .updated_at 0 10) }} +
    + +
    + {{ .description }} +
    +
    +
  • + {{ end }} +
+
\ No newline at end of file diff --git a/docs/site/layouts/shortcodes/half.html b/docs/site/layouts/shortcodes/half.html new file mode 100644 index 000000000..9a9caeac4 --- /dev/null +++ b/docs/site/layouts/shortcodes/half.html @@ -0,0 +1,4 @@ +
+
{{ .Inner }}
+
+
\ No newline at end of file diff --git a/docs/site/layouts/shortcodes/section.html b/docs/site/layouts/shortcodes/section.html new file mode 100644 index 000000000..ae8561481 --- /dev/null +++ b/docs/site/layouts/shortcodes/section.html @@ -0,0 +1,3 @@ +
+ {{ .Inner }} +
\ No newline at end of file diff --git a/docs/site/static/static/base.css b/docs/site/static/static/base.css new file mode 100644 index 000000000..31b64f001 --- /dev/null +++ b/docs/site/static/static/base.css @@ -0,0 +1,190 @@ +/** +*** SIMPLE GRID +*** (C) ZACH COLE 2016 +**/ + + +/* UNIVERSAL */ + +html, +body { + height: 100%; + width: 100%; + margin: 0; + padding: 0; + left: 0; + top: 0; + font-size: 100%; +} + +.right { + text-align: right; +} + +.center { + text-align: center; + margin-left: auto; + margin-right: auto; +} + +.justify { + text-align: justify; +} + +/* ==== GRID SYSTEM ==== */ + +.container { + margin-left: auto; + margin-right: auto; +} + +.row { + position: relative; + width: 100%; +} + +.row [class^="col"] { + float: left; + margin: 0.5rem 2%; + min-height: 0.125rem; +} + +.col-1, +.col-2, +.col-3, +.col-4, +.col-5, +.col-6, +.col-7, +.col-8, +.col-9, +.col-10, +.col-11, +.col-12 { + width: 96%; +} + +.col-1-sm { + width: 4.33%; +} + +.col-2-sm { + width: 12.66%; +} + +.col-3-sm { + width: 21%; +} + +.col-4-sm { + width: 29.33%; +} + +.col-5-sm { + width: 37.66%; +} + +.col-6-sm { + width: 46%; +} + +.col-7-sm { + width: 54.33%; +} + +.col-8-sm { + width: 62.66%; +} + +.col-9-sm { + width: 71%; +} + +.col-10-sm { + width: 79.33%; +} + +.col-11-sm { + width: 87.66%; +} + +.col-12-sm { + width: 96%; +} + +.row::after { + content: ""; + display: table; + clear: both; +} + +.hidden-sm { + display: none; +} + +@media only screen and (min-width: 33.75em) { /* 540px */ + .container { + width: 80%; + } +} + +@media only screen and (min-width: 45em) { /* 720px */ + .col-1 { + width: 4.33%; + } + + .col-2 { + width: 12.66%; + } + + .col-3 { + width: 21%; + } + + .col-4 { + width: 29.33%; + } + + .col-5 { + width: 37.66%; + } + + .col-6 { + width: 46%; + } + + .col-7 { + width: 54.33%; + } + + .col-8 { + width: 62.66%; + } + + .col-9 { + width: 71%; + } + + .col-10 { + width: 79.33%; + } + + .col-11 { + width: 87.66%; + } + + .col-12 { + width: 96%; + } + + .hidden-sm { + display: block; + } +} + +@media only screen and (min-width: 60em) { /* 960px */ + .container { + width: 75%; + max-width: 60rem; + } +} diff --git a/docs/site/static/static/images/2022-07-31_19-07.png b/docs/site/static/static/images/2022-07-31_19-07.png new file mode 100644 index 000000000..47fc48360 Binary files /dev/null and b/docs/site/static/static/images/2022-07-31_19-07.png differ diff --git a/docs/site/static/static/images/2022-07-31_19-08.png b/docs/site/static/static/images/2022-07-31_19-08.png new file mode 100644 index 000000000..a9ead3179 Binary files /dev/null and b/docs/site/static/static/images/2022-07-31_19-08.png differ diff --git a/docs/site/static/static/images/analytics.png b/docs/site/static/static/images/analytics.png new file mode 100644 index 000000000..bfa98d35e Binary files /dev/null and b/docs/site/static/static/images/analytics.png differ diff --git a/docs/site/static/static/images/favicon.png b/docs/site/static/static/images/favicon.png new file mode 100644 index 000000000..109f495b0 Binary files /dev/null and b/docs/site/static/static/images/favicon.png differ diff --git a/docs/site/static/static/images/listmonk.src.svg b/docs/site/static/static/images/listmonk.src.svg new file mode 100644 index 000000000..8441ed12d --- /dev/null +++ b/docs/site/static/static/images/listmonk.src.svg @@ -0,0 +1,233 @@ + + + + + + + + image/svg+xml + + + + + + + + + listmonk + + + + listmonk + listmonk + + + + + + + + listmonk + listmonk + + listmonk + + diff --git a/docs/site/static/static/images/lists.png b/docs/site/static/static/images/lists.png new file mode 100644 index 000000000..22b7f046c Binary files /dev/null and b/docs/site/static/static/images/lists.png differ diff --git a/docs/site/static/static/images/logo.png b/docs/site/static/static/images/logo.png new file mode 100644 index 000000000..64cf3c8a3 Binary files /dev/null and b/docs/site/static/static/images/logo.png differ diff --git a/docs/site/static/static/images/logo.svg b/docs/site/static/static/images/logo.svg new file mode 100644 index 000000000..d3d36e759 --- /dev/null +++ b/docs/site/static/static/images/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/site/static/static/images/media.png b/docs/site/static/static/images/media.png new file mode 100644 index 000000000..6ed66cf9d Binary files /dev/null and b/docs/site/static/static/images/media.png differ diff --git a/docs/site/static/static/images/messengers.png b/docs/site/static/static/images/messengers.png new file mode 100644 index 000000000..472f507e2 Binary files /dev/null and b/docs/site/static/static/images/messengers.png differ diff --git a/docs/site/static/static/images/performance.png b/docs/site/static/static/images/performance.png new file mode 100644 index 000000000..954dacd46 Binary files /dev/null and b/docs/site/static/static/images/performance.png differ diff --git a/docs/site/static/static/images/privacy.png b/docs/site/static/static/images/privacy.png new file mode 100644 index 000000000..bb358a771 Binary files /dev/null and b/docs/site/static/static/images/privacy.png differ diff --git a/docs/site/static/static/images/s1.png b/docs/site/static/static/images/s1.png new file mode 100644 index 000000000..e9f56a9fb Binary files /dev/null and b/docs/site/static/static/images/s1.png differ diff --git a/docs/site/static/static/images/s2.png b/docs/site/static/static/images/s2.png new file mode 100644 index 000000000..e387e517a Binary files /dev/null and b/docs/site/static/static/images/s2.png differ diff --git a/docs/site/static/static/images/s2.svg b/docs/site/static/static/images/s2.svg new file mode 100644 index 000000000..6348ffce2 --- /dev/null +++ b/docs/site/static/static/images/s2.svg @@ -0,0 +1,83 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/docs/site/static/static/images/s3.png b/docs/site/static/static/images/s3.png new file mode 100644 index 000000000..b0aac8c77 Binary files /dev/null and b/docs/site/static/static/images/s3.png differ diff --git a/docs/site/static/static/images/s4.png b/docs/site/static/static/images/s4.png new file mode 100644 index 000000000..40a00353d Binary files /dev/null and b/docs/site/static/static/images/s4.png differ diff --git a/docs/site/static/static/images/smtp.png b/docs/site/static/static/images/smtp.png new file mode 100644 index 000000000..d4d01606b Binary files /dev/null and b/docs/site/static/static/images/smtp.png differ diff --git a/docs/site/static/static/images/splash.png b/docs/site/static/static/images/splash.png new file mode 100644 index 000000000..83df57be1 Binary files /dev/null and b/docs/site/static/static/images/splash.png differ diff --git a/docs/site/static/static/images/templating.png b/docs/site/static/static/images/templating.png new file mode 100644 index 000000000..5e97d9551 Binary files /dev/null and b/docs/site/static/static/images/templating.png differ diff --git a/docs/site/static/static/images/thumbnail.png b/docs/site/static/static/images/thumbnail.png new file mode 100644 index 000000000..de8a32b67 Binary files /dev/null and b/docs/site/static/static/images/thumbnail.png differ diff --git a/docs/site/static/static/images/tx.png b/docs/site/static/static/images/tx.png new file mode 100644 index 000000000..a0ebd0c7d Binary files /dev/null and b/docs/site/static/static/images/tx.png differ diff --git a/docs/site/static/static/style.css b/docs/site/static/static/style.css new file mode 100644 index 000000000..fc33fdf69 --- /dev/null +++ b/docs/site/static/static/style.css @@ -0,0 +1,280 @@ +body { + background: #fdfdfd; + font-family: "Inter", "Helvetica Neue", "Segoe UI", sans-serif; + font-size: 17px; + font-weight: 400; + line-height: 30px; + color: #444; + overflow-x: hidden; +} + + +h1, +h2, +h3, +h4, +h5 { + font-weight: 600; + margin: 5px 0 15px 0; + color: #111; +} +h1 { + font-size: 2.5em; + line-height: 1.2em; + letter-spacing: -0.01em; +} +h2 { + font-size: 2em; + line-height: 1.4em; +} +h3 { + font-size: 1.6em; + line-height: 1.6em; +} +strong { + font-weight: 600; +} +section:not(:last-child) { + margin-bottom: 100px; +} +a { + color: #0055d4; + text-decoration: none; +} +a:hover { + color: #111; +} +::selection { + background: #111; + color: #fff; +} +pre { + background: #fafafa; + padding: 5px; + border-radius: 3px; + overflow-x: scroll; +} +code { + background: #fafafa; + padding: 5px; + border-radius: 3px; +} +img { + max-width: 100%; +} + +/* Helpers */ +.center { + text-align: center; +} +.small, code, pre { + font-size: 13px; + line-height: 20px; + color: #333; +} + +.box { + background: #fff; + border-radius: 6px; + border: 1px solid #e6e6e6; + box-shadow: 1px 1px 4px #e6e6e6; + padding: 30px; +} + +img.box { + display: inline-block; + padding: 0; +} + +figcaption { + color: #888; + font-size: 0.9em; +} + +.button { + background: #0055d4; + display: inline-block; + text-align: center; + font-weight: 600; + + color: #fff; + border-radius: 100px; + padding: 10px 15px; + min-width: 150px; +} +.button:hover { + background: #111; + color: #fff; +} +.notice { + background: #fafafa; + border-left: 4px solid #ddd; + color: #666; + padding: 5px 15px; +} + + +/* Layout */ +.container { + max-width: 1200px; + margin: 0 auto; +} +.header { + margin: 30px 0 60px 0; + text-align: left; +} + +.logo img { + width: 125px; + height: auto; +} + +nav { + text-align: right; +} + nav .item:not(:first-child) { + margin: 0 0 0 40px; + } + .github-btn { + min-width: 135px; + min-height: 38px; + float: right; + margin-left: 30px; + } + + +.splash .hero { + margin-bottom: 60px; +} + .splash .title { + max-width: 700px; + margin: 0 auto 30px auto; + font-size: 3em; + } + .splash .sub { + font-weight: 400; + color: #666; + } + .splash .confetti { + max-width: 1000px; + margin: 0 auto; + } + .splash .demo { + margin-top: 30px; + } + +.confetti { + position: relative; +} + .confetti .s1, .confetti .s2, .confetti .s3 { + position: absolute; + } + .confetti.light .s1, .confetti.light .s2, .confetti.light .s3 { + opacity: 0.30; + } + .confetti .s1 { + left: -35px; + top: 20%; + z-index: 10; + } + .confetti .s2 { + z-index: 30; + right: 20%; + top: -12px; + } + .confetti .s3 { + z-index: 30; + left: 15%; + bottom: 0; + } + .confetti .box { + position: relative; + z-index: 20; + } + +#download { + background: #f9f9f9; + padding: 160px 0 90px 0; + margin-top: -90px; +} + #download .install-steps li { + margin-bottom: 15px; + } + #download .download-links a:not(:last-child) { + display: inline-block; + margin-right: 15px; + } + #download .box { + min-height: 630px; + } + +.feature { +} + .feature h2 { + margin-bottom: 1em; + text-align: center; + } + .feature img { + margin-bottom: 1em; + } + .feature p { + margin-left: auto; + margin-right: auto; + max-width: 750px; + } + +.banner { + padding-top: 90px; +} + +.footer { + border-top: 1px solid #eee; + padding-top: 30px; + margin: 90px 0 30px 0; + color: #777; +} + +@media screen and (max-width: 720px) { + body { + /*font-size: 16px;*/ + } + .header { + margin-bottom: 15px; + text-align: center; + } + .header .columns { + margin-bottom: 10px; + } + + .box { + padding: 15px; + } + + .splash .title { + font-size: 2.1em; + line-height: 1.3em; + } + + .splash .sub { + font-size: 1.3em; + line-height: 1.5em; + } + + nav { + text-align: center; + } + + .github-btn { + float: none; + margin: 15px 0 0 0; + } + + section:not(:last-child) { + margin-bottom: 45px; + } +} +@media screen and (max-width: 540px) { + .container { + padding: 0 15px; + } +} \ No newline at end of file diff --git a/docs/swagger/collections.yaml b/docs/swagger/collections.yaml new file mode 100644 index 000000000..42cb7e041 --- /dev/null +++ b/docs/swagger/collections.yaml @@ -0,0 +1,4081 @@ +openapi: 3.0.0 + +servers: + - description: Listmonk Developement Server + url: http://localhost:9000/api + +info: + version: "1.0.0" + title: Listmonk + description: The API collection for listmonk + license: + name: AGPL-3.0 license + url: https://github.com/knadh/listmonk/blob/master/LICENSE + +tags: + - name: Miscellaneous + description: Misc API + - name: Settings + description: Settings API + - name: Admin + description: Admin API + - name: Logs + description: Logs API + - name: Subscribers + description: Subscribers API + externalDocs: + url: https://listmonk.app/docs/apis/subscribers/ + - name: Bounces + description: Bounce API + - name: Lists + description: Subscriber List API + externalDocs: + url: https://listmonk.app/docs/apis/lists/ + - name: Import + description: Import API + externalDocs: + url: https://listmonk.app/docs/apis/lists/ + - name: Campaigns + description: Campaign API + externalDocs: + url: https://listmonk.app/docs/apis/campaigns/ + - name: Media + description: Media API + externalDocs: + url: https://listmonk.app/docs/apis/media/ + - name: Templates + description: Templates API + externalDocs: + url: https://listmonk.app/docs/apis/templates/ + - name: Transactional + description: Transactional API + externalDocs: + url: https://listmonk.app/docs/apis/transactional/ + - name: Maintenance + description: Maintenance API + - name: Public + description: Listmonk Public API + +paths: + /health: + get: + tags: + - Miscellaneous + description: healthcheck endpoint + operationId: getHealthCheck + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: boolean + + /config: + get: + tags: + - Miscellaneous + description: returns general server config. + operationId: getServerConfig + responses: + "200": + description: A server config object + content: + application/json: + schema: + type: object + properties: + data: + $ref: "#/components/schemas/ServerConfig" + + "/lang/{lang}": + get: + tags: + - Miscellaneous + description: returns the JSON language pack given the language code + operationId: getI18nLang + parameters: + - in: path + name: lang + required: true + description: JSON language pack required + schema: + type: string + responses: + "200": + description: requested language pack + content: + application/json: + schema: + type: object + properties: + data: + $ref: "#/components/schemas/LanguagePack" + + /dashboard/charts: + get: + tags: + - Miscellaneous + description: returns chart data points to render on the dashboard. + operationId: getDashboardCharts + responses: + "200": + description: chart data points + content: + application/json: + schema: + type: object + properties: + data: + $ref: "#/components/schemas/DashboardChart" + + /dashboard/counts: + get: + tags: + - Miscellaneous + description: returns stats counts to show on the dashboard + operationId: getDashboardCounts + responses: + "200": + description: stat counts + content: + application/json: + schema: + type: object + properties: + data: + $ref: "#/components/schemas/DashboardCount" + + /settings: + get: + tags: + - Settings + description: returns settings from DB + operationId: getSettings + responses: + "200": + description: settings object + content: + application/json: + schema: + type: object + properties: + data: + $ref: "#/components/schemas/Settings" + + put: + tags: + - Settings + description: returns updated settings from the DB. + operationId: updateSettings + requestBody: + description: updated settings field values + content: + application/json: + schema: + $ref: "#/components/schemas/Settings" + responses: + "200": + description: updated settings object + content: + application/json: + schema: + type: object + properties: + data: + type: boolean + + /settings/smtp/test: + post: + tags: + - Settings + description: test smtp settings + operationId: testSMTPSettings + requestBody: + description: updated SMTP settings field values + content: + application/json: + schema: + $ref: "#/components/schemas/SMTPTest" + responses: + "200": + description: updated SMTP test settings + content: + application/json: + schema: + type: object + properties: + data: + type: boolean + + /admin/reload: + post: + tags: + - Admin + description: restarts the app + operationId: reloadApp + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: boolean + + /logs: + get: + tags: + - Logs + description: returns the log entries stored in the log buffer + operationId: getLogs + responses: + "200": + description: stored log entries + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + type: string + + /subscribers: + get: + tags: + - Subscribers + description: returns all subscribers. + operationId: getSubscribers + parameters: + - in: query + name: page + description: Page number for paginated results. + required: false + schema: + type: integer + format: int32 + - in: query + name: per_page + description: Number of items per page. Use an integer for specific page size or 'all' to retrieve all results + required: false + schema: + oneOf: + - type: integer + description: Number of items to return per page + - type: string + enum: ["all"] + description: Return all results without pagination + - in: query + name: query + description: query subscribers with an SQL expression. + required: false + schema: + type: string + - in: query + name: order_by + description: Result sorting field. Options are name, status, created_at, updated_at + required: false + schema: + type: string + enum: ["name", "status", "created_at", "updated_at"] + - in: query + name: order + description: ASC|DESC Sort by ascending or descending order. + required: false + schema: + type: string + enum: ["ASC", "DESC"] + - in: query + name: subscription_status + description: Subscription status to filter by if there are one or more list_ids. + required: false + schema: + type: string + - in: query + name: list_id + description: ID of lists to filter by. Repeat in the query for multiple values. + required: false + schema: + type: array + items: + type: integer + + responses: + "200": + description: subscribers list + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + results: + type: array + items: + $ref: "#/components/schemas/Subscriber" + query: + type: string + total: + type: integer + per_page: + type: integer + page: + type: integer + + post: + description: handles creation of new subscriber + operationId: createSubscriber + tags: + - Subscribers + requestBody: + description: new subscriber info + content: + application/json: + schema: + $ref: "#/components/schemas/NewSubscriber" + + responses: + "200": + description: subscriber object + content: + application/json: + schema: + type: object + properties: + data: + $ref: "#/components/schemas/Subscriber" + + delete: + description: handles subscribers deletion + operationId: deleteSubscriberByList + tags: + - Subscribers + parameters: + - in: query + name: id + required: true + description: subscriber id/s to be deleted + schema: + type: string + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: boolean + + "/subscribers/{id}": + get: + description: handles the retrieval of a single subscriber by ID. + operationId: getSubscriberById + tags: + - Subscribers + parameters: + - in: path + name: id + required: true + description: The id value of the subscriber you want to get. + schema: + type: integer + + responses: + "200": + description: gets a single subscriber. + content: + application/json: + schema: + type: object + properties: + data: + $ref: "#/components/schemas/Subscriber" + + put: + description: modify subscriber data + operationId: updateSubscriberById + tags: + - Subscribers + parameters: + - in: path + name: id + required: true + description: The id of subscriber to update + schema: + type: integer + requestBody: + description: new subscriber info + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateSubscriber" + responses: + "200": + description: returns updated subscriber. + content: + application/json: + schema: + type: object + properties: + data: + $ref: "#/components/schemas/Subscriber" + + delete: + description: handles subscriber deletion based on id + operationId: deleteSubscriberById + tags: + - Subscribers + parameters: + - in: path + name: id + required: true + description: The id value of the subscriber you want to get. + schema: + type: integer + + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: boolean + + /subscribers/lists: + put: + description: handles bulk addition or removal of subscribers + operationId: manageSubscriberLists + tags: + - Subscribers + requestBody: + description: The list of subscribers details to add or remove + content: + application/json: + schema: + $ref: "#/components/schemas/SubscriberQueryRequest" + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: boolean + + "/subscribers/lists/{id}": + put: + description: handles bulk addition or removal of subscribers for a specified list id + operationId: manageSubscriberListById + tags: + - Subscribers + requestBody: + description: The list of subscribers to add or remove + content: + application/json: + schema: + $ref: "#/components/schemas/SubscriberQueryRequest" + parameters: + - in: path + name: id + required: true + description: The id of list you want to update + schema: + type: integer + format: int32 + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: boolean + + "/subscribers/blocklist": + put: + description: handles blocklisting of subscriber list + operationId: manageBlocklistBySubscriberList + tags: + - Subscribers + requestBody: + description: The list of subscribers to blocklist + content: + application/json: + schema: + $ref: "#/components/schemas/SubscriberQueryRequest" + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: boolean + + "/subscribers/{id}/blocklist": + put: + description: handles the blocklisting of one or more subscribers. + operationId: manageBlocklistSubscribersById + tags: + - Subscribers + requestBody: + description: The id of subscriber to add or remove + content: + application/json: + schema: + $ref: "#/components/schemas/SubscriberQueryRequest" + parameters: + - in: path + name: id + required: true + description: The id value of the subscriber you want to blocklist. + schema: + type: integer + format: int32 + + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: boolean + + "/subscribers/{id}/export": + get: + description: retrieves a subscriber's profile + operationId: exportSubscriberDataByID + tags: + - Subscribers + parameters: + - in: path + name: id + required: true + description: The id value of subscriber profile you want to export + schema: + type: integer + responses: + "200": + headers: + Cache-Control: + schema: + type: string + Content-Disposition: + schema: + type: string + example: attachment; filename="data.json" + + description: subscriber data object + content: + application/json: + schema: + $ref: "#/components/schemas/SubscriberData" + + "/subscribers/{id}/bounces": + get: + description: retrieves a subscriber's bounce records + operationId: getSubscriberBouncesById + tags: + - Subscribers + parameters: + - in: path + name: id + required: true + description: subscriber id + schema: + type: integer + responses: + "200": + description: list of bounce records of a subscriber + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/Bounce" + + delete: + description: deletes a subscriber's bounce records + operationId: deleteSubscriberBouncesById + tags: + - Subscribers + parameters: + - in: path + name: id + required: true + description: subscriber id + schema: + type: integer + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: boolean + + "/subscribers/{id}/optin": + post: + description: sends an optin confirmation e-mail to a subscriber. + operationId: subscriberSendOptinById + tags: + - Subscribers + parameters: + - in: path + name: id + required: true + description: sends an optin confirmation e-mail to a subscriber + schema: + type: integer + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: boolean + + "/subscribers/query/delete": + post: + description: bulk deletes based on an arbitrary SQL expression. + operationId: deleteSubscriberByQuery + tags: + - Subscribers + requestBody: + description: Arbitrary SQL expression. + content: + text/plain: + schema: + type: string + application/json: + schema: + $ref: "#/components/schemas/SubscriberQueryRequest" + + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: boolean + + "/subscribers/query/blocklist": + put: + description: bulk blocklists subscribers based on an arbitrary SQL expression. + operationId: blocklistSubscribersQuery + tags: + - Subscribers + requestBody: + description: Arbitrary SQL expression. + content: + text/plain: + schema: + type: string + application/json: + schema: + $ref: "#/components/schemas/SubscriberQueryRequest" + + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: boolean + + "/subscribers/query/lists": + put: + description: bulk adds/removes/unsubscribes subscribers from one or more lists based on an arbitrary SQL expression. + operationId: manageSubscriberListsByQuery + tags: + - Subscribers + requestBody: + description: Arbitrary SQL expression. + content: + text/plain: + schema: + type: string + application/json: + schema: + $ref: "#/components/schemas/SubscriberQueryRequest" + + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: boolean + + /bounces: + get: + description: handles retrieval of bounce records. + operationId: getBounces + tags: + - Bounces + parameters: + - in: query + name: campaign_id + description: Numeric identifier for retrieving bounce records associated with a specific campaign + schema: + type: integer + - in: query + name: page + description: Page number for paginated results. Start from 1 for the first page + schema: + type: integer + - in: query + name: per_page + description: Number of items per page. Use an integer for specific page size or 'all' to retrieve all results + schema: + oneOf: + - type: integer + description: Number of items to return per page + - type: string + enum: + - "all" + description: Return all results without pagination + - in: query + name: source + description: Filter bounce records by their source of origin + schema: + type: string + - in: query + name: order_by + description: Specifies the field by which to sort the bounce records. Available options are 'email', 'campaign_name', 'source', and 'created_at' + schema: + type: string + enum: ["email", "campaign_name", "source", "created_at"] + - in: query + name: order + description: Determines the sort order of results. Use 'asc' for ascending or 'desc' for descending order + schema: + type: string + enum: ["asc", "desc"] + responses: + "200": + description: list of bounce records + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + results: + type: array + items: + $ref: "#/components/schemas/Bounce" + query: + type: string + total: + type: integer + per_page: + type: integer + page: + type: integer + delete: + description: handles retrieval of bounce records. + operationId: deleteBounces + tags: + - Bounces + parameters: + - in: query + name: all + description: flag for multiple bounce record deletion + schema: + type: boolean + - in: query + name: id + description: list of bounce ids to delete + schema: + type: string + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: boolean + + "/bounces/{id}": + get: + description: handles retrieval of bounce record by id + operationId: getBounceById + parameters: + - in: path + name: id + required: true + description: The id value of the bounce you want to retreive. + schema: + type: integer + tags: + - Bounces + responses: + "200": + description: bounce object + content: + application/json: + schema: + type: object + properties: + data: + $ref: "#/components/schemas/Bounce" + + delete: + description: handles bounce deletion, either a single one (ID in the URI), or a list. + operationId: deleteBounceById + parameters: + - in: path + name: id + required: true + description: The id value of the bounce you want to delete. + schema: + type: integer + tags: + - Bounces + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: boolean + + /lists: + get: + description: retrieves lists with additional metadata like subscriber counts. This may be slow. + operationId: getLists + tags: + - Lists + parameters: + - in: query + name: page + description: total number of pages + required: false + schema: + type: integer + - in: query + name: per_page + description: number of items per page + required: false + schema: + oneOf: + - type: integer + description: Number of items to return per page + - type: string + enum: ["all"] + description: Return all results without pagination + - in: query + name: query + description: Optional string to search a list by name. + required: false + schema: + type: string + - in: query + name: order_by + description: Field to sort results by. name|status|created_at|updated_at + required: false + schema: + type: string + enum: ["name", "status", "created_at", "updated_at"] + - in: query + name: order + description: ASC|DESC Sort by ascending or descending order. + required: false + schema: + type: string + enum: ["ASC", "DESC"] + - in: query + name: minimal + description: When set to true, returns response without body content + required: false + schema: + type: boolean + - in: query + name: tag + description: Tags to filter lists. Repeat in the query for multiple values. + required: false + schema: + type: array + items: + type: string + responses: + "200": + description: list of metadata + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + results: + type: array + items: + $ref: "#/components/schemas/List" + total: + type: integer + per_page: + type: integer + page: + type: integer + + post: + description: handles list creation + operationId: createList + tags: + - Lists + requestBody: + description: new list info + content: + application/json: + schema: + $ref: "#/components/schemas/NewList" + responses: + "200": + description: updated list object + content: + application/json: + schema: + type: object + properties: + data: + $ref: "#/components/schemas/List" + + "/lists/{list_id}": + get: + description: retrieves lists with additional metadata like subscriber counts. This may be slow. + operationId: getListById + tags: + - Lists + parameters: + - in: path + name: list_id + required: true + description: The id value of the list you want to retreive. + schema: + type: integer + + responses: + "200": + description: list object + content: + application/json: + schema: + type: object + properties: + data: + $ref: "#/components/schemas/List" + put: + description: handles list modification + operationId: updateListById + tags: + - Lists + parameters: + - in: path + name: list_id + required: true + description: The id value of the list you want to update + schema: + type: integer + requestBody: + description: updated list field values + content: + application/json: + schema: + $ref: "#/components/schemas/List" + responses: + "200": + description: updated list object + content: + application/json: + schema: + type: object + properties: + data: + $ref: "#/components/schemas/List" + delete: + description: handles list deletion, either a single one (ID in the URI), or a list. + operationId: deleteListById + tags: + - Lists + parameters: + - in: path + name: list_id + required: true + description: The id value of the lists you want to delete. + schema: + type: integer + + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: boolean + + /import/subscribers: + get: + description: returns import status. + operationId: getImportSubscribers + tags: + - Import + responses: + "200": + description: import status + content: + application/json: + schema: + type: object + properties: + data: + $ref: "#/components/schemas/ImportStatus" + + post: + description: handles the uploading and bulk importing of a ZIP file of one or more CSV files. + operationId: importSubscribers + tags: + - Import + requestBody: + description: uploads and bulk imports of compressed CSV files + content: + multipart/form-data: + schema: + type: object + properties: + params: + type: string + description: JSON string containing import parameters for more detail https://listmonk.app/docs/apis/import/#params-json-string + example: '{"mode":"subscribe", "subscription_status":"confirmed", "delim":",", "lists":[1, 2], "overwrite": true}' + file: + type: string + format: binary + description: File for upload. + responses: + "200": + description: updated import status + content: + application/json: + schema: + type: object + properties: + data: + $ref: "#/components/schemas/ImportStatus" + + delete: + description: sends a stop signal to the importer. + operationId: stopImportSubscribers + tags: + - Import + responses: + "200": + description: response + content: + application/json: + schema: + type: object + properties: + data: + $ref: "#/components/schemas/ImportStatus" + + /import/subscribers/logs: + get: + description: returns import statistics + operationId: getImportSubscriberStats + tags: + - Import + responses: + "200": + description: import statistics + content: + application/json: + schema: + type: object + properties: + data: + type: string + + /campaigns: + get: + description: handles retrieval of campaigns + operationId: getCampaigns + parameters: + - in: query + name: status + description: Filter campaigns by status. Multiple status values can be specified by repeating the parameter + required: false + schema: + type: array + items: + type: string + enum: ["scheduled", "running", "paused", "cancelled"] + - in: query + name: no_body + description: When set to true, returns response without body content + required: false + schema: + type: boolean + - in: query + name: page + description: Page number for paginated results. + required: false + schema: + type: integer + - in: query + name: per_page + description: Number of items per page. Use an integer for specific page size or 'all' to retrieve all results + required: false + schema: + oneOf: + - type: integer + description: Number of items to return per page + - type: string + enum: ["all"] + description: Return all results without pagination + - in: query + name: tags + description: Filter campaigns by tags. Multiple tags can be specified by repeating the parameter + required: false + schema: + type: array + items: + type: string + - in: query + name: order + description: Determines the sort order of results. ASC for ascending, DESC for descending order + required: false + schema: + type: string + enum: ["ASC", "DESC"] + - in: query + name: order_by + description: Specifies the field by which to sort the campaigns. Available options are 'name', 'status', 'created_at', and 'updated_at' + required: false + schema: + type: string + enum: ["name", "status", "created_at", "updated_at"] + - in: query + name: query + description: SQL query expression to filter campaigns by custom criteria + required: false + schema: + type: string + tags: + - Campaigns + responses: + "200": + description: list of campaigns + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + results: + type: array + items: + $ref: "#/components/schemas/Campaign" + query: + type: string + total: + type: integer + per_page: + type: integer + page: + type: integer + + post: + description: handles campaign creation + operationId: createCampaign + tags: + - Campaigns + requestBody: + description: new campaign info + content: + application/json: + schema: + $ref: "#/components/schemas/CampaignRequest" + + responses: + "200": + description: new campaign object + content: + application/json: + schema: + type: object + properties: + data: + $ref: "#/components/schemas/CampaignUpdate" + + "/campaigns/{id}": + get: + description: handles retrieval of campaigns. + operationId: getCampaignById + tags: + - Campaigns + parameters: + - in: path + name: id + required: true + description: The id value of the campaign you want to get. + schema: + type: integer + - in: query + name: no_body + required: false + description: boolean flag for response with/without body + schema: + type: boolean + responses: + "200": + description: campaign object + content: + application/json: + schema: + type: object + properties: + data: + $ref: "#/components/schemas/Campaign" + + put: + description: handle updation of campaign + operationId: updateCampaignById + tags: + - Campaigns + parameters: + - in: path + name: id + required: true + description: the id value of campaign you want to update + schema: + type: integer + requestBody: + description: updated campaign fields + content: + application/json: + schema: + $ref: "#/components/schemas/CampaignRequest" + responses: + "200": + description: updated campaign object + content: + application/json: + schema: + type: object + properties: + data: + $ref: "#/components/schemas/CampaignUpdate" + + delete: + description: deletes specified campaign + operationId: deleteCampaignById + tags: + - Campaigns + parameters: + - in: path + name: id + required: true + description: The id value of the campaign you want to get. + schema: + type: integer + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: boolean + + /campaigns/running/stats: + get: + description: returns stats of a given set of campaign IDs. + operationId: getRunningCampaignStats + tags: + - Campaigns + parameters: + - in: query + name: campaign_id + description: Campaign IDs to get stats for. + required: true + schema: + type: number + responses: + "200": + description: list of stats for given set of campaign ids + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/CampaignStats" + + "/campaigns/analytics/{type}": + get: + description: retrieves view counts for a campaign. + operationId: getCampaignAnalytics + tags: + - Campaigns + parameters: + - in: path + required: true + name: type + description: type of stats, either links, view, click or bounce + schema: + type: string + enum: [links, views, clicks, bounces] + - in: query + required: true + name: from + description: start value of date range + schema: + type: string + format: date + - in: query + required: true + name: to + description: end value of date range + schema: + type: string + - in: query + name: id + description: campaign id/s to retrive view counts + required: true + schema: + type: string + responses: + "200": + description: list of stats for given set of campaign ids + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/CampaignAnalyticsCount" + + "/campaigns/{id}/preview": + get: + description: renders the HTML preview of a campaign body + operationId: previewCampaignById + tags: + - Campaigns + parameters: + - in: path + name: id + required: true + description: The id value of the campaign you want to get the preview of + schema: + type: integer + responses: + "200": + description: HTML Preview of requested campaign + content: + text/html: + schema: + type: string + example:

Hi John!

This is a e-mail campaign. Your second name is Doe and you are from Bengaluru + + post: + description: renders the HTML preview of a campaign body + operationId: updatePreviewCampaignById + tags: + - Campaigns + parameters: + - in: path + name: id + required: true + description: The id value of the campaign you want to get the preview of + schema: + type: integer + requestBody: + required: true + description: template id, body and content type + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + template_id: + description: template id + type: integer + content_type: + description: content type + type: string + body: + description: template body + type: string + responses: + "200": + description: HTML Preview of requested campaign + content: + text/html: + schema: + type: string + example:

Hi John!

This is a e-mail campaign. Your second name is Doe and you are from Bengaluru + + "/campaigns/{id}/text": + post: + description: renders the HTML preview of a campaign body + operationId: previewCampaignTextById + tags: + - Campaigns + requestBody: + required: true + description: template id, content type and campaign body + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + template_id: + description: template id + type: integer + content_type: + description: content type + type: string + body: + description: campaign body + type: string + parameters: + - in: path + name: id + required: true + description: The id value of the campaign you want to get the preview of + schema: + type: integer + responses: + "200": + description: response + content: + text/html: + schema: + type: string + example:

Hi John!

This is a test e-mail campaign. Your second name is Doe and you are from Bengaluru

+ + "/campaigns/{id}/status": + put: + description: handles campaign status modification + operationId: updateCampaignStatusById + tags: + - Campaigns + parameters: + - in: path + name: id + required: true + description: The id value of the campaign you want to get the preview of + schema: + type: integer + requestBody: + description: campaign status update + content: + application/json: + schema: + type: object + properties: + status: + type: string + enum: [scheduled, running, paused, cancelled] + responses: + "200": + description: response + content: + application/json: + schema: + type: object + properties: + data: + $ref: "#/components/schemas/Campaign" + + "/campaigns/{id}/archive": + put: + description: handles campaign status modification + operationId: updateCampaignArchiveById + tags: + - Campaigns + parameters: + - in: path + name: id + required: true + description: The id value of the campaign you want to get the preview of + schema: + type: integer + requestBody: + description: archive campaign related parameters + content: + application/json: + schema: + type: object + properties: + archive: + type: boolean + archive_template_id: + type: integer + archive_meta: + type: object + responses: + "200": + description: response + content: + application/json: + schema: + type: object + properties: + data: + type: boolean + + "/campaigns/{id}/content": + post: + description: handles campaign content (body) format conversions. + operationId: createCampaignContentById + tags: + - Campaigns + requestBody: + description: updated campaign content + content: + application/json: + schema: + $ref: "#/components/schemas/CampaignContentRequest" + parameters: + - in: path + name: id + description: ID of campaign that you choose to create content + required: true + schema: + type: integer + + responses: + "200": + description: response + content: + application/json: + schema: + type: object + properties: + data: + type: string + + "/campaigns/{id}/test": + post: + description: handles sending of campaign message to arbitrary subscribers for testing + operationId: testCampaignById + tags: + - Campaigns + parameters: + - in: path + name: id + description: ID of campaign that you want to test + required: true + schema: + type: integer + requestBody: + required: true + description: template id + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + template_id: + description: template id + type: integer + application/json: + schema: + $ref: "#/components/schemas/CampaignRequest" + responses: + "200": + description: response + content: + application/json: + schema: + type: object + properties: + data: + type: boolean + + "/media": + get: + description: handles retrieval of uploaded media. + operationId: getMedia + tags: + - Media + responses: + "200": + description: response + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/MediaFileObject" + post: + description: handles media file uploads. + operationId: uploadMedia + tags: + - Media + requestBody: + description: upload media file + content: + multipart/form-data: + schema: + type: string + format: binary + responses: + "200": + description: response + content: + application/json: + schema: + type: object + properties: + data: + $ref: "#/components/schemas/MediaFileObject" + + "/media/{id}": + get: + description: handles retrieval of uploaded media. + operationId: getMediaById + tags: + - Media + parameters: + - in: path + name: id + required: true + description: media file id + schema: + type: integer + responses: + "200": + description: response + content: + application/json: + schema: + type: object + properties: + data: + $ref: "#/components/schemas/MediaFileObject" + + delete: + description: handles deletion of uploaded media. + operationId: deleteMediaById + tags: + - Media + parameters: + - in: path + name: id + required: true + description: The id value of the list you want to delete. + schema: + type: integer + responses: + "200": + description: response + content: + application/json: + schema: + type: object + properties: + data: + type: boolean + + /templates: + get: + description: handles retrieval of templates + operationId: getTemplates + tags: + - Templates + parameters: + - in: query + name: no_body + description: boolean flag for response with/without body + required: true + schema: + type: boolean + responses: + "200": + description: response + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/Template" + + "/templates/{id}": + get: + description: handles retrieval of templates + operationId: getTemplateById + tags: + - Templates + parameters: + - in: path + name: id + required: true + schema: + type: integer + description: The id value of the template you want to get. + - in: query + name: no_body + description: boolean flag for response with/without body + required: false + schema: + type: boolean + responses: + "200": + description: response + content: + application/json: + schema: + type: object + properties: + data: + $ref: "#/components/schemas/Template" + + delete: + description: handles deletion of templates + operationId: deleteTemplateById + tags: + - Templates + + parameters: + - in: path + name: id + required: true + description: The id value of the template you want to delete. + schema: + type: integer + responses: + "200": + description: response + content: + application/json: + schema: + type: object + properties: + data: + type: boolean + + /templates/preview: + post: + description: get the HTML preview of a template. + operationId: previewTemplate + tags: + - Templates + requestBody: + required: true + description: template parameters + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + template_type: + description: type of template + type: string + body: + description: template body + type: string + responses: + "200": + description: response + content: + text/html: + schema: + type: string + example:

Hi there

+ + "/templates/{id}/preview": + get: + description: renders the HTML preview of a template. + operationId: previewTemplateById + tags: + - Templates + requestBody: + description: template parameters + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + template_type: + description: type of template + type: string + body: + description: template body + type: string + parameters: + - in: path + name: id + required: true + schema: + type: integer + description: The id value of the template you want to get. + responses: + "200": + description: response + content: + text/html: + schema: + type: string + example:

Hi there

+ + "/templates/{id}/default": + put: + description: handles template modification. + operationId: updateTemplateById + tags: + - Templates + parameters: + - in: path + name: id + required: true + description: The id value of the template you want to set to the default template. + schema: + type: integer + responses: + "200": + description: response + content: + application/json: + schema: + $ref: "#/components/schemas/Template" + + /tx: + post: + tags: + - Transactional + description: send message to a subscriber + operationId: transactWithSubscriber + requestBody: + description: email message to a subscriber + content: + application/json: + schema: + $ref: "#/components/schemas/TransactionalMessage" + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + data: + type: boolean + + "/maintenance/subscribers/{type}": + delete: + description: garbage collects (deletes) orphaned or blocklisted subscribers. + operationId: deleteGCSubscribers + tags: + - Maintenance + parameters: + - in: path + name: type + description: type of GC collected subscribers + schema: + type: string + required: true + responses: + "200": + description: response + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + count: + type: integer + + "/maintenance/analytics/{type}": + delete: + description: garbage collects (deletes) campaign analytics. + operationId: deleteCampaignAnalyticsByType + tags: + - Maintenance + requestBody: + description: date parameter + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + before_date: + type: string + format: date + parameters: + - in: path + name: type + description: type of GC collected subscribers + schema: + type: string + required: true + responses: + "200": + description: response + content: + application/json: + schema: + type: object + properties: + data: + type: boolean + + "/maintenance/subscriptions/unconfirmed": + delete: + description: garbage collects (deletes) orphaned or blocklisted subscribers. + operationId: deleteUnconfirmedSubscriptions + tags: + - Maintenance + requestBody: + required: true + description: date parameter + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + before_date: + type: string + format: date + responses: + "200": + description: response + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + count: + type: integer + + "/public/lists": + get: + description: returns the list of public lists with minimal fields + operationId: getPublicLists + tags: + - Public + responses: + "200": + description: response + content: + application/json: + schema: + type: array + items: + type: object + properties: + uuid: + type: string + name: + type: string + + "/public/subscription": + post: + description: handles subscription requests coming from public API calls. + operationId: handlePublicSubscription + tags: + - Public + requestBody: + description: subscription request parameters + content: + application/json: + schema: + type: object + properties: + name: + type: string + email: + type: string + list_uuids: + type: array + items: + type: string + + responses: + "200": + description: response + content: + application/json: + schema: + type: object + properties: + has_optin: + type: boolean + +components: + schemas: + LanguagePack: + type: object + properties: + data: + type: object + properties: + _.code: + type: string + _.name: + type: string + admin.errorMarshallingConfig: + type: string + analytics.count: + type: string + analytics.fromDate: + type: string + analytics.invalidDates: + type: string + analytics.isUnique: + type: string + analytics.links: + type: string + analytics.nonUnique: + type: string + analytics.title: + type: string + analytics.toDate: + type: string + bounces.source: + type: string + bounces.unknownService: + type: string + bounces.view: + type: string + campaigns.addAltText: + type: string + campaigns.archive: + type: string + campaigns.archiveEnable: + type: string + campaigns.archiveHelp: + type: string + campaigns.archiveMeta: + type: string + campaigns.archiveMetaHelp: + type: string + campaigns.cantUpdate: + type: string + campaigns.clicks: + type: string + campaigns.confirmDelete: + type: string + campaigns.confirmSchedule: + type: string + campaigns.confirmSwitchFormat: + type: string + campaigns.content: + type: string + campaigns.contentHelp: + type: string + campaigns.continue: + type: string + campaigns.copyOf: + type: string + campaigns.customHeadersHelp: + type: string + campaigns.dateAndTime: + type: string + campaigns.ended: + type: string + campaigns.errorSendTest: + type: string + campaigns.fieldInvalidBody: + type: string + campaigns.fieldInvalidFromEmail: + type: string + campaigns.fieldInvalidListIDs: + type: string + campaigns.fieldInvalidMessenger: + type: string + campaigns.fieldInvalidName: + type: string + campaigns.fieldInvalidSendAt: + type: string + campaigns.fieldInvalidSubject: + type: string + campaigns.formatHTML: + type: string + campaigns.fromAddress: + type: string + campaigns.fromAddressPlaceholder: + type: string + campaigns.invalid: + type: string + campaigns.invalidCustomHeaders: + type: string + campaigns.markdown: + type: string + campaigns.needsSendAt: + type: string + campaigns.newCampaign: + type: string + campaigns.noKnownSubsToTest: + type: string + campaigns.noOptinLists: + type: string + campaigns.noSubs: + type: string + campaigns.noSubsToTest: + type: string + campaigns.notFound: + type: string + campaigns.onlyActiveCancel: + type: string + campaigns.onlyActivePause: + type: string + campaigns.onlyDraftAsScheduled: + type: string + campaigns.onlyPausedDraft: + type: string + campaigns.onlyScheduledAsDraft: + type: string + campaigns.pause: + type: string + campaigns.plainText: + type: string + campaigns.preview: + type: string + campaigns.progress: + type: string + campaigns.queryPlaceholder: + type: string + campaigns.rateMinuteShort: + type: string + campaigns.rawHTML: + type: string + campaigns.removeAltText: + type: string + campaigns.richText: + type: string + campaigns.schedule: + type: string + campaigns.scheduled: + type: string + campaigns.send: + type: string + campaigns.sendLater: + type: string + campaigns.sendTest: + type: string + campaigns.sendTestHelp: + type: string + campaigns.sendToLists: + type: string + campaigns.sent: + type: string + campaigns.start: + type: string + campaigns.started: + type: string + campaigns.startedAt: + type: string + campaigns.stats: + type: string + campaigns.status.cancelled: + type: string + campaigns.status.draft: + type: string + campaigns.status.finished: + type: string + campaigns.status.paused: + type: string + campaigns.status.running: + type: string + campaigns.status.scheduled: + type: string + campaigns.statusChanged: + type: string + campaigns.subject: + type: string + campaigns.testEmails: + type: string + campaigns.testSent: + type: string + campaigns.timestamps: + type: string + campaigns.trackLink: + type: string + campaigns.views: + type: string + dashboard.campaignViews: + type: string + dashboard.linkClicks: + type: string + dashboard.messagesSent: + type: string + dashboard.orphanSubs: + type: string + email.data.info: + type: string + email.data.title: + type: string + email.optin.confirmSub: + type: string + email.optin.confirmSubHelp: + type: string + email.optin.confirmSubInfo: + type: string + email.optin.confirmSubTitle: + type: string + email.optin.confirmSubWelcome: + type: string + email.optin.privateList: + type: string + email.status.campaignReason: + type: string + email.status.campaignSent: + type: string + email.status.campaignUpdateTitle: + type: string + email.status.importFile: + type: string + email.status.importRecords: + type: string + email.status.importTitle: + type: string + email.status.status: + type: string + email.unsub: + type: string + email.unsubHelp: + type: string + email.viewInBrowser: + type: string + forms.formHTML: + type: string + forms.formHTMLHelp: + type: string + forms.noPublicLists: + type: string + forms.publicLists: + type: string + forms.publicSubPage: + type: string + forms.selectHelp: + type: string + forms.title: + type: string + globals.buttons.add: + type: string + globals.buttons.addNew: + type: string + globals.buttons.back: + type: string + globals.buttons.cancel: + type: string + globals.buttons.clone: + type: string + globals.buttons.close: + type: string + globals.buttons.continue: + type: string + globals.buttons.delete: + type: string + globals.buttons.deleteAll: + type: string + globals.buttons.edit: + type: string + globals.buttons.enabled: + type: string + globals.buttons.insert: + type: string + globals.buttons.learnMore: + type: string + globals.buttons.more: + type: string + globals.buttons.new: + type: string + globals.buttons.ok: + type: string + globals.buttons.remove: + type: string + globals.buttons.save: + type: string + globals.buttons.saveChanges: + type: string + globals.days.0: + type: string + globals.days.1: + type: string + globals.days.2: + type: string + globals.days.3: + type: string + globals.days.4: + type: string + globals.days.5: + type: string + globals.days.6: + type: string + globals.days.7: + type: string + globals.fields.createdAt: + type: string + globals.fields.description: + type: string + globals.fields.id: + type: string + globals.fields.name: + type: string + globals.fields.status: + type: string + globals.fields.type: + type: string + globals.fields.updatedAt: + type: string + globals.fields.uuid: + type: string + globals.messages.confirm: + type: string + globals.messages.confirmDiscard: + type: string + globals.messages.created: + type: string + globals.messages.deleted: + type: string + globals.messages.deletedCount: + type: string + globals.messages.done: + type: string + globals.messages.emptyState: + type: string + globals.messages.errorCreating: + type: string + globals.messages.errorDeleting: + type: string + globals.messages.errorFetching: + type: string + globals.messages.errorInvalidIDs: + type: string + globals.messages.errorUUID: + type: string + globals.messages.errorUpdating: + type: string + globals.messages.internalError: + type: string + globals.messages.invalidData: + type: string + globals.messages.invalidID: + type: string + globals.messages.invalidUUID: + type: string + globals.messages.missingFields: + type: string + globals.messages.notFound: + type: string + globals.messages.passwordChange: + type: string + globals.messages.updated: + type: string + globals.months.1: + type: string + globals.months.10: + type: string + globals.months.11: + type: string + globals.months.12: + type: string + globals.months.2: + type: string + globals.months.3: + type: string + globals.months.4: + type: string + globals.months.5: + type: string + globals.months.6: + type: string + globals.months.7: + type: string + globals.months.8: + type: string + globals.months.9: + type: string + globals.states.off: + type: string + globals.terms.all: + type: string + globals.terms.analytics: + type: string + globals.terms.bounce: + type: string + globals.terms.bounces: + type: string + globals.terms.campaign: + type: string + globals.terms.campaigns: + type: string + globals.terms.dashboard: + type: string + globals.terms.day: + type: string + globals.terms.hour: + type: string + globals.terms.list: + type: string + globals.terms.lists: + type: string + globals.terms.media: + type: string + globals.terms.messenger: + type: string + globals.terms.messengers: + type: string + globals.terms.minute: + type: string + globals.terms.month: + type: string + globals.terms.second: + type: string + globals.terms.settings: + type: string + globals.terms.subscriber: + type: string + globals.terms.subscribers: + type: string + globals.terms.subscriptions: + type: string + globals.terms.tag: + type: string + globals.terms.tags: + type: string + globals.terms.template: + type: string + globals.terms.templates: + type: string + globals.terms.tx: + type: string + globals.terms.year: + type: string + import.alreadyRunning: + type: string + import.blocklist: + type: string + import.csvDelim: + type: string + import.csvDelimHelp: + type: string + import.csvExample: + type: string + import.csvFile: + type: string + import.csvFileHelp: + type: string + import.errorCopyingFile: + type: string + import.errorProcessingZIP: + type: string + import.errorStarting: + type: string + import.importDone: + type: string + import.importStarted: + type: string + import.instructions: + type: string + import.instructionsHelp: + type: string + import.invalidDelim: + type: string + import.invalidFile: + type: string + import.invalidMode: + type: string + import.invalidParams: + type: string + import.invalidSubStatus: + type: string + import.listSubHelp: + type: string + import.mode: + type: string + import.overwrite: + type: string + import.overwriteHelp: + type: string + import.recordsCount: + type: string + import.stopImport: + type: string + import.subscribe: + type: string + import.subscribeWarning: + type: string + import.title: + type: string + import.upload: + type: string + lists.confirmDelete: + type: string + lists.confirmSub: + type: string + lists.invalidName: + type: string + lists.newList: + type: string + lists.optin: + type: string + lists.optinHelp: + type: string + lists.optinTo: + type: string + lists.optins.double: + type: string + lists.optins.single: + type: string + lists.sendCampaign: + type: string + lists.sendOptinCampaign: + type: string + lists.type: + type: string + lists.typeHelp: + type: string + lists.types.private: + type: string + lists.types.public: + type: string + logs.title: + type: string + maintenance.help: + type: string + maintenance.maintenance.unconfirmedOptins: + type: string + maintenance.olderThan: + type: string + maintenance.title: + type: string + maintenance.unconfirmedSubs: + type: string + media.errorReadingFile: + type: string + media.errorResizing: + type: string + media.errorSavingThumbnail: + type: string + media.errorUploading: + type: string + media.invalidFile: + type: string + media.title: + type: string + media.unsupportedFileType: + type: string + media.upload: + type: string + media.uploadHelp: + type: string + media.uploadImage: + type: string + menu.allCampaigns: + type: string + menu.allLists: + type: string + menu.allSubscribers: + type: string + menu.dashboard: + type: string + menu.forms: + type: string + menu.import: + type: string + menu.logs: + type: string + menu.maintenance: + type: string + menu.media: + type: string + menu.newCampaign: + type: string + menu.settings: + type: string + public.archiveEmpty: + type: string + public.archiveTitle: + type: string + public.blocklisted: + type: string + public.campaignNotFound: + type: string + public.confirmOptinSubTitle: + type: string + public.confirmSub: + type: string + public.confirmSubInfo: + type: string + public.confirmSubTitle: + type: string + public.dataRemoved: + type: string + public.dataRemovedTitle: + type: string + public.dataSent: + type: string + public.dataSentTitle: + type: string + public.errorFetchingCampaign: + type: string + public.errorFetchingEmail: + type: string + public.errorFetchingLists: + type: string + public.errorProcessingRequest: + type: string + public.errorTitle: + type: string + public.invalidFeature: + type: string + public.invalidLink: + type: string + public.managePrefs: + type: string + public.managePrefsUnsub: + type: string + public.noListsAvailable: + type: string + public.noListsSelected: + type: string + public.noSubInfo: + type: string + public.noSubTitle: + type: string + public.notFoundTitle: + type: string + public.prefsSaved: + type: string + public.privacyConfirmWipe: + type: string + public.privacyExport: + type: string + public.privacyExportHelp: + type: string + public.privacyTitle: + type: string + public.privacyWipe: + type: string + public.privacyWipeHelp: + type: string + public.sub: + type: string + public.subConfirmed: + type: string + public.subConfirmedTitle: + type: string + public.subName: + type: string + public.subNotFound: + type: string + public.subOptinPending: + type: string + public.subPrivateList: + type: string + public.subTitle: + type: string + public.unsub: + type: string + public.unsubFull: + type: string + public.unsubHelp: + type: string + public.unsubTitle: + type: string + public.unsubbedInfo: + type: string + public.unsubbedTitle: + type: string + public.unsubscribeTitle: + type: string + settings.appearance.adminHelp: + type: string + settings.appearance.adminName: + type: string + settings.appearance.customCSS: + type: string + settings.appearance.customJS: + type: string + settings.appearance.name: + type: string + settings.appearance.publicHelp: + type: string + settings.appearance.publicName: + type: string + settings.bounces.action: + type: string + settings.bounces.blocklist: + type: string + settings.bounces.count: + type: string + settings.bounces.countHelp: + type: string + settings.bounces.delete: + type: string + settings.bounces.enable: + type: string + settings.bounces.enableMailbox: + type: string + settings.bounces.enableSES: + type: string + settings.bounces.enableSendgrid: + type: string + settings.bounces.enableForwardemail: + type: string + settings.bounces.enablePostmark: + type: string + settings.bounces.enableWebhooks: + type: string + settings.bounces.enabled: + type: string + settings.bounces.folder: + type: string + settings.bounces.folderHelp: + type: string + settings.bounces.invalidScanInterval: + type: string + settings.bounces.name: + type: string + settings.bounces.scanInterval: + type: string + settings.bounces.scanIntervalHelp: + type: string + settings.bounces.sendgridKey: + type: string + settings.bounces.forwardemailKey: + type: string + settings.bounces.postmarkUsername: + type: string + settings.bounces.postmarkUsernameHelp: + type: string + settings.bounces.postmarkPassword: + type: string + settings.bounces.type: + type: string + settings.bounces.username: + type: string + settings.confirmRestart: + type: string + settings.duplicateMessengerName: + type: string + settings.errorEncoding: + type: string + settings.errorNoSMTP: + type: string + settings.general.adminNotifEmails: + type: string + settings.general.adminNotifEmailsHelp: + type: string + settings.general.checkUpdates: + type: string + settings.general.checkUpdatesHelp: + type: string + settings.general.enablePublicArchive: + type: string + settings.general.enablePublicArchiveHelp: + type: string + settings.general.enablePublicSubPage: + type: string + settings.general.enablePublicSubPageHelp: + type: string + settings.general.faviconURL: + type: string + settings.general.faviconURLHelp: + type: string + settings.general.fromEmail: + type: string + settings.general.fromEmailHelp: + type: string + settings.general.language: + type: string + settings.general.logoURL: + type: string + settings.general.logoURLHelp: + type: string + settings.general.name: + type: string + settings.general.rootURL: + type: string + settings.general.rootURLHelp: + type: string + settings.general.sendOptinConfirm: + type: string + settings.general.sendOptinConfirmHelp: + type: string + settings.general.siteName: + type: string + settings.invalidMessengerName: + type: string + settings.mailserver.authProtocol: + type: string + settings.mailserver.name: + type: string + settings.mailserver.nameHelp: + type: string + settings.mailserver.host: + type: string + settings.mailserver.hostHelp: + type: string + settings.mailserver.idleTimeout: + type: string + settings.mailserver.idleTimeoutHelp: + type: string + settings.mailserver.maxConns: + type: string + settings.mailserver.maxConnsHelp: + type: string + settings.mailserver.password: + type: string + settings.mailserver.passwordHelp: + type: string + settings.mailserver.port: + type: string + settings.mailserver.portHelp: + type: string + settings.mailserver.skipTLS: + type: string + settings.mailserver.skipTLSHelp: + type: string + settings.mailserver.tls: + type: string + settings.mailserver.tlsHelp: + type: string + settings.mailserver.username: + type: string + settings.mailserver.waitTimeout: + type: string + settings.mailserver.waitTimeoutHelp: + type: string + settings.media.provider: + type: string + settings.media.s3.bucket: + type: string + settings.media.s3.bucketPath: + type: string + settings.media.s3.bucketPathHelp: + type: string + settings.media.s3.bucketType: + type: string + settings.media.s3.bucketTypePrivate: + type: string + settings.media.s3.bucketTypePublic: + type: string + settings.media.s3.key: + type: string + settings.media.s3.publicURL: + type: string + settings.media.s3.publicURLHelp: + type: string + settings.media.s3.region: + type: string + settings.media.s3.secret: + type: string + settings.media.s3.uploadExpiry: + type: string + settings.media.s3.uploadExpiryHelp: + type: string + settings.media.s3.url: + type: string + settings.media.s3.urlHelp: + type: string + settings.media.title: + type: string + settings.media.upload.path: + type: string + settings.media.upload.pathHelp: + type: string + settings.media.upload.uri: + type: string + settings.media.upload.uriHelp: + type: string + settings.messengers.maxConns: + type: string + settings.messengers.maxConnsHelp: + type: string + settings.messengers.messageSaved: + type: string + settings.messengers.name: + type: string + settings.messengers.nameHelp: + type: string + settings.messengers.password: + type: string + settings.messengers.retries: + type: string + settings.messengers.retriesHelp: + type: string + settings.messengers.skipTLSHelp: + type: string + settings.messengers.timeout: + type: string + settings.messengers.timeoutHelp: + type: string + settings.messengers.url: + type: string + settings.messengers.urlHelp: + type: string + settings.messengers.username: + type: string + settings.needsRestart: + type: string + settings.performance.batchSize: + type: string + settings.performance.batchSizeHelp: + type: string + settings.performance.concurrency: + type: string + settings.performance.concurrencyHelp: + type: string + settings.performance.maxErrThreshold: + type: string + settings.performance.maxErrThresholdHelp: + type: string + settings.performance.messageRate: + type: string + settings.performance.messageRateHelp: + type: string + settings.performance.name: + type: string + settings.performance.slidingWindow: + type: string + settings.performance.slidingWindowDuration: + type: string + settings.performance.slidingWindowDurationHelp: + type: string + settings.performance.slidingWindowHelp: + type: string + settings.performance.slidingWindowRate: + type: string + settings.performance.slidingWindowRateHelp: + type: string + settings.privacy.allowBlocklist: + type: string + settings.privacy.allowBlocklistHelp: + type: string + settings.privacy.allowExport: + type: string + settings.privacy.allowExportHelp: + type: string + settings.privacy.allowPrefs: + type: string + settings.privacy.allowPrefsHelp: + type: string + settings.privacy.allowWipe: + type: string + settings.privacy.allowWipeHelp: + type: string + settings.privacy.domainBlocklist: + type: string + settings.privacy.domainBlocklistHelp: + type: string + settings.privacy.individualSubTracking: + type: string + settings.privacy.individualSubTrackingHelp: + type: string + settings.privacy.listUnsubHeader: + type: string + settings.privacy.listUnsubHeaderHelp: + type: string + settings.privacy.name: + type: string + settings.restart: + type: string + settings.smtp.customHeaders: + type: string + settings.smtp.customHeadersHelp: + type: string + settings.smtp.enabled: + type: string + settings.smtp.heloHost: + type: string + settings.smtp.heloHostHelp: + type: string + settings.smtp.name: + type: string + settings.smtp.retries: + type: string + settings.smtp.retriesHelp: + type: string + settings.smtp.sendTest: + type: string + settings.smtp.setCustomHeaders: + type: string + settings.smtp.testConnection: + type: string + settings.smtp.testEnterEmail: + type: string + settings.smtp.toEmail: + type: string + settings.title: + type: string + settings.updateAvailable: + type: string + subscribers.advancedQuery: + type: string + subscribers.advancedQueryHelp: + type: string + subscribers.attribs: + type: string + subscribers.attribsHelp: + type: string + subscribers.blocklistedHelp: + type: string + subscribers.confirmBlocklist: + type: string + subscribers.confirmDelete: + type: string + subscribers.confirmExport: + type: string + subscribers.domainBlocklisted: + type: string + subscribers.downloadData: + type: string + subscribers.email: + type: string + subscribers.emailExists: + type: string + subscribers.errorBlocklisting: + type: string + subscribers.errorNoIDs: + type: string + subscribers.errorNoListsGiven: + type: string + subscribers.errorPreparingQuery: + type: string + subscribers.errorSendingOptin: + type: string + subscribers.export: + type: string + subscribers.invalidAction: + type: string + subscribers.invalidEmail: + type: string + subscribers.invalidJSON: + type: string + subscribers.invalidName: + type: string + subscribers.listChangeApplied: + type: string + subscribers.lists: + type: string + subscribers.listsHelp: + type: string + subscribers.listsPlaceholder: + type: string + subscribers.manageLists: + type: string + subscribers.markUnsubscribed: + type: string + subscribers.newSubscriber: + type: string + subscribers.numSelected: + type: string + subscribers.optinSubject: + type: string + subscribers.preconfirm: + type: string + subscribers.preconfirmHelp: + type: string + subscribers.query: + type: string + subscribers.queryPlaceholder: + type: string + subscribers.reset: + type: string + subscribers.selectAll: + type: string + subscribers.sendOptinConfirm: + type: string + subscribers.sentOptinConfirm: + type: string + subscribers.status.blocklisted: + type: string + subscribers.status.confirmed: + type: string + subscribers.status.enabled: + type: string + subscribers.status.subscribed: + type: string + subscribers.status.unconfirmed: + type: string + subscribers.status.unsubscribed: + type: string + subscribers.subscribersDeleted: + type: string + templates.cantDeleteDefault: + type: string + templates.default: + type: string + templates.dummyName: + type: string + templates.dummySubject: + type: string + templates.errorCompiling: + type: string + templates.errorRendering: + type: string + templates.fieldInvalidName: + type: string + templates.makeDefault: + type: string + templates.newTemplate: + type: string + templates.placeholderHelp: + type: string + templates.preview: + type: string + templates.rawHTML: + type: string + templates.subject: + type: string + users.login: + type: string + users.logout: + type: string + + ServerConfig: + type: object + properties: + data: + type: object + properties: + messengers: + type: array + items: + type: string + langs: + type: array + items: + type: object + properties: + code: + type: string + name: + type: string + lang: + type: string + update: + type: string + needs_restart: + type: boolean + version: + type: string + + DashboardChart: + type: object + properties: + link_clicks: + type: array + items: + type: object + properties: + count: + type: integer + date: + type: string + campaign_views: + type: array + items: + type: object + properties: + count: + type: integer + date: + type: string + + DashboardCount: + type: object + properties: + data: + type: object + properties: + subscribers: + type: object + properties: + total: + type: integer + blocklisted: + type: object + orphans: + type: integer + lists: + type: object + properties: + total: + type: integer + private: + type: integer + public: + type: integer + optin_single: + type: integer + optin_double: + type: integer + campaigns: + type: object + properties: + total: + type: integer + by_status: + type: object + properties: + draft: + type: integer + messages: + type: integer + + SMTPSettings: + type: object + properties: + uuid: + type: string + enabled: + type: boolean + host: + type: string + hello_hostname: + type: string + port: + type: integer + auth_protocol: + type: string + username: + type: string + email_headers: + type: array + items: + type: object + max_conns: + type: integer + max_msg_retries: + type: integer + idle_timeout: + type: string + wait_timeout: + type: string + tls_type: + type: string + tls_skip_verify: + type: boolean + + SMTPTest: + type: object + properties: + uuid: + type: string + enabled: + type: boolean + host: + type: string + hello_hostname: + type: string + port: + type: integer + auth_protocol: + type: string + username: + type: string + email_headers: + type: array + items: + type: object + max_conns: + type: integer + max_msg_retries: + type: integer + idle_timeout: + type: string + wait_timeout: + type: string + tls_type: + type: string + tls_skip_verify: + type: boolean + strEmailHeaders: + type: string + password: + type: string + email: + type: string + + MailBoxBounces: + type: object + properties: + uuid: + type: string + enabled: + type: boolean + type: + type: string + host: + type: string + port: + type: integer + auth_protocol: + type: string + return_path: + type: string + username: + type: string + tls_enabled: + type: boolean + tls_skip_verify: + type: boolean + scan_interval: + type: string + + Settings: + type: object + properties: + app.site_name: + type: string + app.root_url: + type: string + app.logo_url: + type: string + app.favicon_url: + type: string + app.from_email: + type: string + app.notify_emails: + type: array + items: + type: string + app.enable_public_subscription_page: + type: boolean + app.enable_public_archive: + type: boolean + app.send_optin_confirmation: + type: boolean + app.check_updates: + type: boolean + app.lang: + type: string + app.batch_size: + type: integer + app.concurrency: + type: integer + app.max_send_errors: + type: integer + app.message_rate: + type: integer + app.message_sliding_window: + type: boolean + app.message_sliding_window_duration: + type: string + app.message_sliding_window_rate: + type: integer + privacy.individual_tracking: + type: boolean + privacy.unsubscribe_header: + type: boolean + privacy.allow_blocklist: + type: boolean + privacy.allow_preferences: + type: boolean + privacy.allow_export: + type: boolean + privacy.allow_wipe: + type: boolean + privacy.exportable: + type: array + items: + type: string + privacy.domain_blocklist: + type: array + items: + type: object + upload.provider: + type: string + upload.filesystem.upload_path: + type: string + upload.filesystem.upload_uri: + type: string + upload.s3.url: + type: string + upload.s3.public_url: + type: string + upload.s3.aws_access_key_id: + type: string + upload.s3.aws_default_region: + type: string + upload.s3.bucket: + type: string + upload.s3.bucket_domain: + type: string + upload.s3.bucket_path: + type: string + upload.s3.bucket_type: + type: string + upload.s3.expiry: + type: string + smtp: + type: array + items: + $ref: "#/components/schemas/SMTPSettings" + messengers: + type: array + items: + type: object + bounce.enabled: + type: boolean + bounce.webhooks_enabled: + type: boolean + bounce.count: + type: integer + bounce.action: + type: string + bounce.ses_enabled: + type: boolean + bounce.sendgrid_enabled: + type: boolean + bounce.sendgrid_key: + type: string + bounce.forwardemail_enabled: + type: boolean + bounce.forwardemail_key: + type: string + bounce.postmark_enabled: + type: boolean + bounce.postmark_username: + type: string + bounce.postmark_password: + type: string + bounce.mailboxes: + type: array + items: + $ref: "#/components/schemas/MailBoxBounces" + appearance.admin.custom_css: + type: string + appearance.admin.custom_js: + type: string + appearance.public.custom_css: + type: string + appearance.public.custom_js: + type: string + + SubscriberProfile: + type: object + properties: + id: + type: integer + uuid: + type: string + email: + type: string + name: + type: string + attribs: + type: object + additionalProperties: true + status: + type: string + created_at: + type: string + updated_at: + type: string + + Subscriptions: + type: object + properties: + subscription_status: + type: string + name: + type: string + type: + type: string + created_at: + type: string + + SubscriberData: + type: object + properties: + email: + type: string + profile: + type: array + items: + $ref: "#/components/schemas/SubscriberProfile" + subscriptions: + type: array + items: + $ref: "#/components/schemas/Subscriptions" + campaign_views: + type: array + items: + type: object + link_clicks: + type: array + items: + type: object + + Subscriber: + type: object + properties: + id: + type: integer + created_at: + type: string + updated_at: + type: string + uuid: + type: string + email: + type: string + name: + type: string + attribs: + type: object + additionalProperties: true + status: + type: string + lists: + type: array + items: + type: object + properties: + subscription_status: + type: string + id: + type: integer + uuid: + type: string + name: + type: string + type: + type: string + tags: + type: array + items: + type: string + created_at: + type: string + updated_at: + type: string + + NewSubscriber: + type: object + properties: + email: + type: string + name: + type: string + status: + type: string + lists: + type: array + items: + type: integer + list_uuids: + type: array + items: + type: string + preconfirm_subscriptions: + type: boolean + attribs: + type: object + additionalProperties: true + + UpdateSubscriber: + type: object + properties: + email: + type: string + name: + type: string + status: + type: string + lists: + type: array + items: + type: integer + list_uuids: + type: array + items: + type: string + preconfirm_subscriptions: + type: boolean + attribs: + type: object + additionalProperties: true + + SubscriberQueryRequest: + type: object + properties: + query: + type: string + ids: + type: array + description: The ids of the subscribers to be modified. + items: + type: integer + action: + type: string + enum: [add, remove, unsubscribe] + description: Whether to add, remove, or unsubscribe the users. + target_list_ids: + type: integer + description: The ids of the lists to be modified. + items: + type: integer + status: + type: string + enum: [confirmed, unconfirmed, unsubscribed] + description: confirmed, unconfirmed, or unsubscribed status. + + Bounce: + type: object + properties: + results: + type: array + items: + type: object + properties: + id: + type: integer + type: + type: string + source: + type: string + meta: + type: object + created_at: + type: string + email: + type: string + subscriber_uuid: + type: string + subscriber_id: + type: integer + campaign: + type: object + properties: + id: + type: integer + name: + type: string + campaign_uuid: + type: string + total: + type: integer + + List: + type: object + properties: + id: + type: integer + created_at: + type: string + updated_at: + type: string + uuid: + type: string + name: + type: string + type: + type: string + optin: + type: string + tags: + type: array + items: + type: string + subscriber_count: + type: integer + description: + type: string + + NewList: + type: object + properties: + name: + type: string + type: + type: string + enum: [public, private] + optin: + type: string + enum: [single, double] + tags: + type: array + items: + type: string + description: + type: string + + ImportStatus: + type: object + properties: + data: + type: object + properties: + name: + type: string + total: + type: integer + imported: + type: integer + status: + type: string + + Campaign: + type: object + properties: + id: + type: integer + created_at: + type: string + updated_at: + type: string + CampaignID: + type: integer + views: + type: integer + clicks: + type: integer + lists: + type: array + items: + type: object + properties: + id: + type: integer + name: + type: string + started_at: + type: string + to_send: + type: integer + sent: + type: integer + uuid: + type: string + type: + type: string + enum: [regular, optin] + name: + type: string + subject: + type: string + from_email: + type: string + body: + type: string + send_at: + type: string + status: + type: string + content_type: + type: string + enum: [richtext, html, markdown, plain] + tags: + type: array + items: + type: string + template_id: + type: integer + messenger: + type: string + + CampaignContentRequest: + type: object + properties: + id: + type: integer + created_at: + type: string + updated_at: + type: string + CampaignID: + type: integer + views: + type: integer + clicks: + type: integer + lists: + type: array + items: + type: object + properties: + id: + type: integer + name: + type: string + started_at: + type: string + to_send: + type: integer + sent: + type: integer + uuid: + type: string + type: + type: string + enum: [regular, optin] + name: + type: string + subject: + type: string + from_email: + type: string + body: + type: string + send_at: + type: string + status: + type: string + content_type: + type: string + enum: [richtext, html, markdown, plain] + tags: + type: array + items: + type: string + template_id: + type: integer + messenger: + type: string + from: + type: string + to: + type: string + + CampaignRequest: + type: object + properties: + name: + type: string + subject: + type: string + lists: + type: array + items: + type: number + from_email: + type: string + content_type: + type: string + messenger: + type: string + type: + type: string + tags: + type: array + items: + type: string + send_later: + type: boolean + send_at: + type: object + properties: + headers: + type: array + items: + type: object + template_id: + type: number + + CampaignUpdate: + type: object + properties: + name: + type: string + subject: + type: string + lists: + type: array + items: + type: integer + from_email: + type: string + messenger: + type: string + type: + type: string + tags: + type: array + items: + type: string + send_later: + type: boolean + send_at: + type: object + headers: + type: array + items: + type: object + template_id: + type: integer + content_type: + type: string + body: + type: string + altbody: + type: string + archive: + type: boolean + archive_template_id: + type: integer + archive_meta: + type: object + + CampaignStats: + type: object + properties: + id: + type: integer + status: + type: string + to_send: + type: integer + sent: + type: integer + started_at: + type: string + format: date + updated_at: + type: string + format: date + rate: + type: integer + net_rate: + type: integer + + CampaignAnalyticsCount: + type: object + properties: + campaign_id: + type: integer + count: + type: integer + timestamp: + type: string + format: date-time + + MediaFileObject: + type: object + properties: + id: + type: integer + uuid: + type: string + filename: + type: string + created_at: + type: string + thumb_url: + type: string + uri: + type: string + + Template: + type: object + properties: + id: + type: integer + created_at: + type: string + updated_at: + type: string + name: + type: string + body: + type: string + type: + type: string + is_default: + type: boolean + + TransactionalMessage: + type: object + properties: + subscriber_email: + type: string + subscriber_id: + type: integer + template_id: + type: integer + from_email: + type: string + data: + type: object + headers: + type: array + items: + type: object + messenger: + type: string + content_type: + type: string diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js index a2ccb20bc..54e014fda 100644 --- a/frontend/.eslintrc.js +++ b/frontend/.eslintrc.js @@ -2,16 +2,30 @@ module.exports = { root: true, env: { node: true, + // es2022: true, }, + plugins: ['vue'], extends: [ + 'eslint:recommended', 'plugin:vue/essential', - '@vue/airbnb', + 'plugin:vue/strongly-recommended', + '@vue/eslint-config-airbnb', ], - parserOptions: { - parser: 'babel-eslint', - }, + parser: 'vue-eslint-parser', rules: { - 'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off', - 'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off', + 'class-methods-use-this': 'off', + 'vue/multi-word-component-names': 'off', + 'vue/quote-props': 'off', + 'vue/first-attribute-linebreak': 'off', + 'vue/no-child-content': 'off', + 'vue/max-attributes-per-line': 'off', + 'vue/html-indent': 'off', + 'vue/html-closing-bracket-newline': 'off', + 'vue/singleline-html-element-content-newline': 'off', + 'vue/max-len': ['error', { + code: 200, + template: 200, + comments: 200, + }], }, }; diff --git a/frontend/README.md b/frontend/README.md index b703d7a16..eb3ac2d79 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -18,7 +18,7 @@ The project uses a global `vuex` state to centrally store the responses to prett There is a global state `loading` (eg: loading.campaigns, loading.lists) that indicates whether an API call for that particular "model" is running. This can be used anywhere in the project to show loading spinners for instance. All the API definitions are in `api/index.js`. It also describes how each API call sets the global `loading` status alongside storing the API responses. -*IMPORTANT*: All JSON field names in GET API responses are automatically camel-cased when they're pulled for the sake of consistentcy in the frontend code and for complying with the linter spec in the project (Vue/AirBnB schema). For example, `content_type` becomes `contentType`. When sending responses to the backend, however, they should be snake-cased manually. This is overridden for certain calls such as `/api/config` and `/api/settings` using the `preserveCase: true` param in `api/index.js`. +*IMPORTANT*: All JSON field names in GET API responses are automatically camel-cased when they're pulled for the sake of consistency in the frontend code and for complying with the linter spec in the project (Vue/AirBnB schema). For example, `content_type` becomes `contentType`. When sending responses to the backend, however, they should be snake-cased manually. This is overridden for certain calls such as `/api/config` and `/api/settings` using the `preserveCase: true` param in `api/index.js`. ## Icon pack diff --git a/frontend/cypress.config.js b/frontend/cypress.config.js new file mode 100644 index 000000000..0a068027e --- /dev/null +++ b/frontend/cypress.config.js @@ -0,0 +1,25 @@ +const { defineConfig } = require('cypress'); + +module.exports = defineConfig({ + env: { + apiUrl: 'http://localhost:9000', + serverInitCmd: + 'pkill -9 listmonk | cd ../ && LISTMONK_ADMIN_USER=admin LISTMONK_ADMIN_PASSWORD=listmonk ./listmonk --install --yes && ./listmonk > /dev/null 2>/dev/null &', + serverInitBlankCmd: + 'pkill -9 listmonk | cd ../ && ./listmonk --install --yes && ./listmonk > /dev/null 2>/dev/null &', + LISTMONK_ADMIN_USER: 'admin', + LISTMONK_ADMIN_PASSWORD: 'listmonk', + }, + viewportWidth: 1400, + viewportHeight: 950, + e2e: { + testIsolation: false, + experimentalSessionAndOrigin: false, + // We've imported your old cypress plugins here. + // You may want to clean this up later by importing these. + setupNodeEvents(on, config) { + return require('./cypress/plugins/index.js')(on, config); + }, + baseUrl: 'http://localhost:9000', + }, +}); diff --git a/frontend/cypress.json b/frontend/cypress.json deleted file mode 100644 index f8db83a82..000000000 --- a/frontend/cypress.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "baseUrl": "http://localhost:9000", - "env": { - "server_init_command": "pkill -9 listmonk | cd ../ && ./listmonk --install --yes && ./listmonk > /dev/null 2>/dev/null &", - "username": "listmonk", - "password": "listmonk" - } -} diff --git a/frontend/cypress/downloads/data.json b/frontend/cypress/downloads/data.json deleted file mode 100644 index 81f86d477..000000000 --- a/frontend/cypress/downloads/data.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "profile": [ - { - "id": 2, - "uuid": "0954ba2e-50e4-4847-86f4-c2b8b72dace8", - "email": "anon@example.com", - "name": "Anon Doe", - "attribs": { - "city": "Bengaluru", - "good": true, - "type": "unknown" - }, - "status": "enabled", - "created_at": "2021-02-20T15:52:16.251648+05:30", - "updated_at": "2021-02-20T15:52:16.251648+05:30" - } - ], - "subscriptions": [ - { - "subscription_status": "unconfirmed", - "name": "Opt-in list", - "type": "public", - "created_at": "2021-02-20T15:52:16.251648+05:30" - } - ], - "campaign_views": [], - "link_clicks": [] -} \ No newline at end of file diff --git a/frontend/cypress/e2e/archive.cy.js b/frontend/cypress/e2e/archive.cy.js new file mode 100644 index 000000000..cd61f24ec --- /dev/null +++ b/frontend/cypress/e2e/archive.cy.js @@ -0,0 +1,80 @@ +const apiUrl = Cypress.env('apiUrl'); + +describe('Archive', () => { + it('Opens campaigns page', () => { + cy.resetDB(); + cy.loginAndVisit('/admin/campaigns'); + cy.wait(500); + }); + + it('Clones campaign', () => { + cy.loginAndVisit('/admin/campaigns'); + cy.get('[data-cy=btn-clone]').first().click(); + cy.get('.modal input').clear().type('clone').click(); + cy.get('.modal button.is-primary').click(); + cy.wait(250); + + cy.loginAndVisit('/admin/campaigns'); + cy.get('[data-cy=btn-clone]').first().click(); + cy.get('.modal input').clear().type('clone2').click(); + cy.get('.modal button.is-primary').click(); + cy.wait(250); + + cy.clickMenu('all-campaigns'); + }); + + it('Starts campaigns', () => { + cy.get('td[data-label=Status] a').eq(0).click(); + cy.get('[data-cy=btn-start]').click(); + cy.get('.modal button.is-primary').click(); + + cy.get('td[data-label=Status] a').eq(1).click(); + cy.get('[data-cy=btn-start]').click(); + cy.get('.modal button.is-primary').click(); + cy.wait(1000); + }); + + it('Enables archive on one campaign (no slug)', () => { + cy.loginAndVisit('/admin/campaigns'); + cy.wait(250); + cy.get('td[data-label=Status] a').eq(0).click(); + + // Switch to archive tab and enable archive. + cy.get('.b-tabs nav a').eq(2).click(); + cy.wait(500); + cy.get('[data-cy=btn-archive] .check').click(); + cy.get('[data-cy=archive-slug]').clear(); + cy.get('[data-cy=archive-meta]').clear() + .type('{"email": "archive@domain.com", "name": "Archive", "attribs": { "city": "Bengaluru"}}', { parseSpecialCharSequences: false }); + cy.get('[data-cy=btn-save]').click(); + cy.wait(250); + }); + + it('Enables archive on one campaign', () => { + cy.loginAndVisit('/admin/campaigns'); + cy.wait(250); + cy.get('td[data-label=Status] a').eq(1).click(); + + // Switch to archive tab and enable archive. + cy.get('.b-tabs nav a').eq(2).click(); + cy.wait(500); + cy.get('[data-cy=btn-archive] .check').click(); + cy.get('[data-cy=archive-slug]').clear().type('my-archived-campaign'); + cy.get('[data-cy=btn-save]').click(); + cy.wait(250); + }); + + it('Opens campaign archive page', () => { + for (let i = 0; i < 2; i++) { + cy.loginAndVisit(`${apiUrl}/archive`); + cy.get('li a').eq(i).click(); + cy.wait(250); + if (i === 0) { + cy.get('h3').contains('Hi Archive!'); + cy.get('p').eq(0).contains('Bengaluru'); + } else { + cy.get('h3').contains('Hi Subscriber!'); + } + } + }); +}); diff --git a/frontend/cypress/e2e/bounces.cy.js b/frontend/cypress/e2e/bounces.cy.js new file mode 100644 index 000000000..aadf552cf --- /dev/null +++ b/frontend/cypress/e2e/bounces.cy.js @@ -0,0 +1,61 @@ +const apiUrl = Cypress.env('apiUrl'); + +describe('Bounces', () => { + const subs = []; + + it('Enable bounces', () => { + cy.resetDB(); + + cy.loginAndVisit('/admin/settings'); + cy.get('.b-tabs nav a').eq(6).click(); + cy.get('[data-cy=btn-enable-bounce] .switch').click(); + cy.get('[data-cy=btn-enable-bounce-webhook] .switch').click(); + + cy.get('[data-cy=btn-save]').click(); + cy.wait(2000); + }); + + it('Post bounces', () => { + // Get campaign. + let camp = {}; + cy.request(`${apiUrl}/api/campaigns`).then((resp) => { + camp = resp.body.data.results[0]; + }).then(() => { + console.log('campaign is ', camp.uuid); + }); + + // Get subscribers. + let subs = []; + cy.request(`${apiUrl}/api/subscribers`).then((resp) => { + subs = resp.body.data.results; + }).then(() => { + // Register soft bounces do nothing. + let sub = {}; + cy.request('POST', `${apiUrl}/webhooks/bounce`, { source: 'api', type: 'soft', email: subs[0].email }); + cy.request('POST', `${apiUrl}/webhooks/bounce`, { source: 'api', type: 'soft', email: subs[0].email }); + cy.request(`${apiUrl}/api/subscribers/${subs[0].id}`).then((resp) => { + sub = resp.body.data; + }).then(() => { + cy.expect(sub.status).to.equal('enabled'); + }); + + // Hard bounces blocklist. + cy.request('POST', `${apiUrl}/webhooks/bounce`, { source: 'api', type: 'hard', email: subs[0].email }); + cy.request('POST', `${apiUrl}/webhooks/bounce`, { source: 'api', type: 'hard', email: subs[0].email }); + cy.request(`${apiUrl}/api/subscribers/${subs[0].id}`).then((resp) => { + sub = resp.body.data; + }).then(() => { + cy.expect(sub.status).to.equal('blocklisted'); + }); + + // Complaint bounces delete. + cy.request('POST', `${apiUrl}/webhooks/bounce`, { source: 'api', type: 'complaint', email: subs[1].email }); + cy.request('POST', `${apiUrl}/webhooks/bounce`, { source: 'api', type: 'complaint', email: subs[1].email }); + cy.request({ url: `${apiUrl}/api/subscribers/${subs[1].id}`, failOnStatusCode: false }).then((resp) => { + expect(resp.status).to.eq(400); + }); + + cy.loginAndVisit('/admin/subscribers/bounces'); + }); + }); +}); diff --git a/frontend/cypress/e2e/campaigns.cy.js b/frontend/cypress/e2e/campaigns.cy.js new file mode 100644 index 000000000..a3f254448 --- /dev/null +++ b/frontend/cypress/e2e/campaigns.cy.js @@ -0,0 +1,341 @@ +const apiUrl = Cypress.env('apiUrl'); +const headers = '[{"X-Custom": "Custom-Value"}]'; + +describe('Campaigns', () => { + it('Opens campaigns page', () => { + cy.resetDB(); + cy.loginAndVisit('/admin/campaigns'); + }); + + it('Counts campaigns', () => { + cy.get('tbody td[data-label=Status]').should('have.length', 1); + }); + + it('Creates campaign', () => { + cy.get('a[data-cy=btn-new]').click(); + + // Fill fields. + cy.get('input[name=name]').clear().type('new-attach'); + cy.get('input[name=subject]').clear().type('new-subject'); + cy.get('input[name=from_email]').clear().type('new '); + cy.get('.list-selector input').click(); + cy.get('.list-selector .autocomplete a').eq(0).click(); + + cy.get('button[data-cy=btn-continue]').click(); + cy.wait(500); + + cy.get('a[data-cy=btn-attach]').click(); + cy.get('input[type=file]').attachFile('example.json'); + cy.get('.modal button.is-primary:eq(0)').click(); + cy.wait(500); + cy.get('.modal td[data-label=Name] a.link').click(); + cy.get('button[data-cy=btn-save]').click(); + cy.wait(500); + + // Re-open and check that the file still exists. + cy.loginAndVisit('/admin/campaigns'); + cy.get('td[data-label=Status] a').eq(0).click(); + cy.get('.b-tabs nav a').eq(1).click(); + cy.get('div.field[data-cy=media]').contains('example'); + + // Start. + cy.get('button[data-cy=btn-start]').click(); + cy.get('.modal button.is-primary:eq(0)').click(); + cy.wait(500); + cy.get('tbody tr').eq(0).get('td[data-label=Status] .tag.running'); + }); + + it('Edits campaign', () => { + cy.get('td[data-label=Status] a').eq(1).click(); + + // Fill fields. + cy.get('input[name=name]').clear().type('new-name'); + cy.get('input[name=subject]').clear().type('new-subject'); + cy.get('input[name=from_email]').clear().type('new '); + + // Change the list. + cy.get('.list-selector a.delete').click(); + cy.get('.list-selector input').click(); + cy.get('.list-selector .autocomplete a').eq(0).click(); + + // Clear and redo tags. + cy.get('input[name=tags]').type('{backspace}new-tag{enter}'); + + // Enable schedule. + cy.get('[data-cy=btn-send-later] .check').click(); + cy.wait(100); + cy.get('.datepicker input').click(); + cy.wait(100); + cy.get('.datepicker-header .control:nth-child(2) select').select((new Date().getFullYear() + 1).toString()); + cy.wait(100); + cy.get('.datepicker-body a.is-selectable:first').click(); + cy.wait(100); + cy.get('body').click(1, 1); + + // Add custom headers. + cy.get('[data-cy=btn-headers]').click(); + cy.get('textarea[name=headers]').invoke('val', headers).trigger('input'); + + // Switch to content tab. + cy.get('.b-tabs nav a').eq(1).click(); + + // Switch format to plain text. + cy.get('label[data-cy=check-plain]').click(); + cy.get('.modal button.is-primary:eq(0)').click(); + + // Enter body value. + cy.get('textarea[name=content]').clear().type('new-content'); + cy.get('button[data-cy=btn-save]').click(); + + // Schedule. + cy.get('button[data-cy=btn-schedule]').click(); + cy.get('.modal button.is-primary:eq(0)').click(); + + cy.wait(250); + + // Verify the changes. + cy.request(`${apiUrl}/api/campaigns/1`).should((response) => { + const { data } = response.body; + expect(data.status).to.equal('scheduled'); + expect(data.name).to.equal('new-name'); + expect(data.subject).to.equal('new-subject'); + expect(data.content_type).to.equal('plain'); + expect(data.altbody).to.equal(null); + expect(data.send_at).to.not.equal(null); + expect(data.body).to.equal('new-content'); + + expect(data.lists.length).to.equal(1); + expect(data.lists[0].id).to.equal(1); + expect(data.tags.length).to.equal(1); + expect(data.tags[0]).to.equal('new-tag'); + expect(data.headers[0]['X-Custom']).to.equal('Custom-Value'); + }); + + cy.get('tbody td[data-label=Status] .tag.scheduled'); + }); + + it('Switches formats', () => { + cy.resetDB(); + cy.loginAndVisit('/admin/campaigns'); + const formats = ['html', 'markdown', 'plain']; + const htmlBody = 'hello \{\{ .Subscriber.Name \}\} from {\{ .Subscriber.Attribs.city \}\}'; + const plainBody = 'hello Demo Subscriber from Bengaluru'; + + // Set test content the first time. + cy.get('td[data-label=Status] a').click(); + cy.get('.b-tabs nav a').eq(1).click(); + cy.window().then((win) => { + win.tinymce.editors[0].setContent(htmlBody); + win.tinymce.editors[0].save(); + }); + cy.get('button[data-cy=btn-save]').click(); + + formats.forEach((c) => { + cy.loginAndVisit('/admin/campaigns'); + cy.get('td[data-label=Status] a').click(); + + // Switch to content tab. + cy.get('.b-tabs nav a').eq(1).click(); + + // Switch format. + cy.get(`label[data-cy=check-${c}]`).click(); + cy.get('.modal button.is-primary:eq(0)').click(); + + // Check content. + cy.get('button[data-cy=btn-preview]').click(); + cy.wait(500); + cy.get('#iframe').then(($f) => { + if (c === 'plain') { + return; + } + const doc = $f.contents(); + expect(doc.find('.wrap').text().trim().replace(/(\s|\n)+/, ' ')).equal(plainBody); + }); + cy.get('.modal-card-foot button').click(); + }); + }); + + it('Clones campaign', () => { + cy.loginAndVisit('/admin/campaigns'); + for (let n = 0; n < 3; n++) { + // Clone the campaign. + cy.get('[data-cy=btn-clone]').first().click(); + cy.get('.modal input').clear().type(`clone${n}`).click(); + cy.get('.modal button.is-primary:eq(0)').click(); + cy.wait(250); + cy.clickMenu('all-campaigns'); + cy.wait(100); + + // Verify the newly created row. + cy.get('tbody td[data-label="Name"]').first().contains(`clone${n}`); + } + }); + + it('Searches campaigns', () => { + cy.get('input[name=query]').clear().type('clone2{enter}'); + cy.get('tbody tr').its('length').should('eq', 1); + cy.get('tbody td[data-label="Name"]').first().contains('clone2'); + cy.get('input[name=query]').clear().type('{enter}'); + }); + + it('Deletes campaign', () => { + cy.wait(1000); + // Delete all visible lists. + cy.get('tbody tr').each(() => { + cy.get('tbody a[data-cy=btn-delete]').first().click(); + cy.get('.modal button.is-primary:eq(0)').click(); + }); + + // Confirm deletion. + cy.get('table tr.is-empty'); + }); + + it('Adds new campaigns', () => { + const lists = [[1], [1, 2]]; + const cTypes = ['richtext', 'html', 'markdown', 'plain']; + + let n = 0; + cTypes.forEach((c) => { + lists.forEach((l) => { + // Click the 'new button' + cy.get('[data-cy=btn-new]').click(); + cy.wait(100); + + // Fill fields. + cy.get('input[name=name]').clear().type(`name${n}`); + cy.get('input[name=subject]').clear().type(`subject${n}`); + + l.forEach(() => { + cy.get('.list-selector input').click(); + cy.get('.list-selector .autocomplete a').first().click(); + }); + + // Add tags. + for (let i = 0; i < 3; i++) { + cy.get('input[name=tags]').type(`tag${i}{enter}`); + } + + // Add headers. + cy.get('[data-cy=btn-headers]').click(); + cy.get('textarea[name=headers]').invoke('val', `[{"X-Header-${n}": "Value-${n}"}]`).trigger('input'); + + // Hit 'Continue'. + cy.get('button[data-cy=btn-continue]').click(); + cy.wait(250); + + // Verify the changes. + (function (n) { + cy.location('pathname').then((p) => { + cy.request(`${apiUrl}/api/campaigns/${p.split('/').at(-1)}`).should((response) => { + const { data } = response.body; + expect(data.status).to.equal('draft'); + expect(data.name).to.equal(`name${n}`); + expect(data.subject).to.equal(`subject${n}`); + expect(data.content_type).to.equal('richtext'); + expect(data.altbody).to.equal(null); + expect(data.send_at).to.equal(null); + expect(data.headers[0][`X-Header-${n}`]).to.equal(`Value-${n}`); + }); + }); + }(n)); + + // Select content type. + cy.get(`label[data-cy=check-${c}]`).click(); + + // Insert content. + const htmlBody = `hello${n} \{\{ .Subscriber.Name \}\} from {\{ .Subscriber.Attribs.city \}\}`; + const plainBody = `hello${n} Demo Subscriber from Bengaluru`; + const markdownBody = `**hello${n}** Demo Subscriber from Bengaluru`; + + cy.log(`format = ${c}`); + if (c === 'richtext') { + cy.window().then((win) => { + win.tinymce.editors[0].setContent(htmlBody); + win.tinymce.editors[0].save(); + }); + cy.wait(500); + } else if (c === 'html') { + cy.get('code-flask').shadow().find('.codeflask textarea').invoke('val', htmlBody) + .trigger('input'); + } else if (c === 'markdown') { + cy.get('code-flask').shadow().find('.codeflask textarea').invoke('val', markdownBody) + .trigger('input'); + } else if (c === 'plain') { + cy.get('textarea[name=content]').invoke('val', plainBody).trigger('input'); + } + + // Save. + cy.get('button[data-cy=btn-save]').click(); + + // Preview and match the body. + cy.get('button[data-cy=btn-preview]').click(); + cy.wait(1000); + cy.get('#iframe').then(($f) => { + if (c === 'plain') { + return; + } + const doc = $f.contents(); + expect(doc.find('.wrap').text().trim()).equal(plainBody); + }); + + cy.get('.modal-card-foot button').click(); + + cy.clickMenu('all-campaigns'); + cy.wait(500); + + // Verify the newly created campaign in the table. + cy.get('tbody td[data-label="Name"]').first().contains(`name${n}`); + cy.get('tbody td[data-label="Name"]').first().contains(`subject${n}`); + cy.get('tbody td[data-label="Lists"]').first().then(($el) => { + cy.wrap($el).find('li').should('have.length', l.length); + }); + + n++; + }); + }); + + // Fetch the campaigns API and verfiy the values that couldn't be verified on the table UI. + cy.request(`${apiUrl}/api/campaigns?order=asc&order_by=created_at`).should((response) => { + const { data } = response.body; + expect(data.total).to.equal(lists.length * cTypes.length); + + let n = 0; + cTypes.forEach((c) => { + lists.forEach((l) => { + expect(data.results[n].content_type).to.equal(c); + expect(data.results[n].lists.map((ls) => ls.id)).to.deep.equal(l); + n++; + }); + }); + }); + }); + + it('Starts and cancels campaigns', () => { + for (let n = 1; n <= 2; n++) { + cy.get(`tbody tr:nth-child(${n}) [data-cy=btn-start]`).click(); + cy.get('.modal button.is-primary:eq(0)').click(); + cy.wait(250); + cy.get(`tbody tr:nth-child(${n}) td[data-label=Status] .tag.running`); + + if (n > 1) { + cy.get(`tbody tr:nth-child(${n}) [data-cy=btn-cancel]`).click(); + cy.get('.modal button.is-primary:eq(0)').click(); + cy.wait(250); + cy.get(`tbody tr:nth-child(${n}) td[data-label=Status] .tag.cancelled`); + } + } + }); + + it('Sorts campaigns', () => { + const asc = [5, 6, 7, 8, 9, 10, 11, 12]; + const desc = [12, 11, 10, 9, 8, 7, 6, 5]; + const cases = ['cy-name', 'cy-timestamp']; + + cases.forEach((c) => { + cy.sortTable(`thead th.${c}`, asc); + cy.wait(250); + cy.sortTable(`thead th.${c}`, desc); + cy.wait(250); + }); + }); +}); diff --git a/frontend/cypress/integration/dashboard.js b/frontend/cypress/e2e/dashboard.cy.js similarity index 57% rename from frontend/cypress/integration/dashboard.js rename to frontend/cypress/e2e/dashboard.cy.js index a19310d9f..5d79d21fa 100644 --- a/frontend/cypress/integration/dashboard.js +++ b/frontend/cypress/e2e/dashboard.cy.js @@ -1,28 +1,27 @@ describe('Dashboard', () => { it('Opens dashboard', () => { + cy.resetDB(); cy.loginAndVisit('/'); // List counts. + cy.get('[data-cy=lists] .title').contains('2'); cy.get('[data-cy=lists]') - .should('contain', '2 Lists') .and('contain', '1 Public') .and('contain', '1 Private') .and('contain', '1 Single opt-in') .and('contain', '1 Double opt-in'); // Campaign counts. - cy.get('[data-cy=campaigns]') - .should('contain', '1 Campaign') - .and('contain', '1 draft'); + cy.get('[data-cy=campaigns] .title').contains('1'); + cy.get('[data-cy=campaigns-draft]').contains('1'); // Subscriber counts. + cy.get('[data-cy=subscribers] .title').contains('2'); cy.get('[data-cy=subscribers]') - .should('contain', '2 Subscribers') - .and('contain', '0 Blocklisted') + .should('contain', '0 Blocklisted') .and('contain', '0 Orphans'); // Message count. - cy.get('[data-cy=messages]') - .should('contain', '0 Messages sent'); + cy.get('[data-cy=messages] .title').contains('0'); }); }); diff --git a/frontend/cypress/e2e/forms.cy.js b/frontend/cypress/e2e/forms.cy.js new file mode 100644 index 000000000..ce2a29656 --- /dev/null +++ b/frontend/cypress/e2e/forms.cy.js @@ -0,0 +1,130 @@ +const apiUrl = Cypress.env('apiUrl'); + +describe('Forms', () => { + it('Opens forms page', () => { + cy.resetDB(); + cy.loginAndVisit('/admin/lists/forms'); + }); + + it('Checks public lists', () => { + cy.get('ul[data-cy=lists] li') + .should('contain', 'Opt-in list') + .its('length') + .should('eq', 1); + + cy.get('[data-cy=form] code-flask').should('not.exist'); + }); + + it('Selects public list', () => { + // Click the list checkbox. + cy.get('ul[data-cy=lists] .checkbox').click(); + + // Make sure the
 form HTML has appeared.
+    cy.get('code-flask').shadow().find('pre').then(($pre) => {
+      // Check that the ID of the list in the checkbox appears in the HTML.
+      cy.get('ul[data-cy=lists] input').then(($inp) => {
+        cy.wrap($pre).contains($inp.val());
+      });
+    });
+
+    // Click the list checkbox.
+    cy.get('ul[data-cy=lists] .checkbox').click();
+    cy.get('[data-cy=form] pre').should('not.exist');
+  });
+
+  it('Subscribes from public form page', () => {
+    // Create a public test list.
+    cy.request('POST', `${apiUrl}/api/lists`, { name: 'test-list', type: 'public', optin: 'single' });
+
+    // Open the public page and subscribe to alternating lists multiple times.
+    // There should be no errors and two new subscribers should be subscribed to two lists.
+    for (let i = 0; i < 2; i++) {
+      for (let j = 0; j < 2; j++) {
+        cy.loginAndVisit(`${apiUrl}/subscription/form`);
+        cy.get('input[name=email]').clear().type(`test${i}@test.com`);
+        cy.get('input[name=name]').clear().type(`test${i}`);
+        cy.get('input[type=checkbox]').eq(j).click();
+        cy.get('button').click();
+        cy.wait(250);
+        cy.get('.wrap').contains(/has been sent|successfully/);
+      }
+    }
+
+    // Verify form subscriptions.
+    cy.request(`${apiUrl}/api/subscribers`).should((response) => {
+      const { data } = response.body;
+
+      // Two new + two dummy subscribers that are there by default.
+      expect(data.total).to.equal(4);
+
+      // The two new subscribers should each have two list subscriptions.
+      for (let i = 0; i < 2; i++) {
+        expect(data.results.find((s) => s.email === `test${i}@test.com`).lists.length).to.equal(2);
+      }
+    });
+  });
+
+  it('Unsubscribes', () => {
+    // Add all lists to the dummy campaign.
+    cy.request('PUT', `${apiUrl}/api/campaigns/1`, { lists: [2] });
+
+    cy.request('GET', `${apiUrl}/api/subscribers`).then((response) => {
+      const subUUID = response.body.data.results[0].uuid;
+
+      cy.request('GET', `${apiUrl}/api/campaigns`).then((response) => {
+        const campUUID = response.body.data.results[0].uuid;
+        cy.loginAndVisit(`${apiUrl}/subscription/${campUUID}/${subUUID}`);
+      });
+    });
+
+    cy.wait(500);
+
+    // Unsubscribe from one list.
+    cy.get('button').click();
+    cy.request('GET', `${apiUrl}/api/subscribers`).then((response) => {
+      const { data } = response.body;
+      expect(data.results[0].lists.find((s) => s.id === 2).subscription_status).to.equal('unsubscribed');
+      expect(data.results[0].lists.find((s) => s.id === 3).subscription_status).to.equal('unconfirmed');
+    });
+
+    // Go back.
+    cy.url().then((u) => {
+      cy.loginAndVisit(u);
+    });
+
+    // Unsubscribe from all.
+    cy.get('#privacy-blocklist').click();
+    cy.get('button').click();
+
+    cy.request('GET', `${apiUrl}/api/subscribers`).then((response) => {
+      const { data } = response.body;
+      expect(data.results[0].status).to.equal('blocklisted');
+      expect(data.results[0].lists.find((s) => s.id === 2).subscription_status).to.equal('unsubscribed');
+      expect(data.results[0].lists.find((s) => s.id === 3).subscription_status).to.equal('unsubscribed');
+    });
+  });
+
+  it('Manages subscription preferences', () => {
+    cy.request('GET', `${apiUrl}/api/subscribers`).then((response) => {
+      const subUUID = response.body.data.results[1].uuid;
+
+      cy.request('GET', `${apiUrl}/api/campaigns`).then((response) => {
+        const campUUID = response.body.data.results[0].uuid;
+        cy.loginAndVisit(`${apiUrl}/subscription/${campUUID}/${subUUID}?manage=1`);
+        cy.get('a').contains('Manage').click();
+      });
+    });
+
+    // Change name and unsubscribe from one list.
+    cy.get('input[name=name]').clear().type('new-name');
+    cy.get('ul.lists input:first').click();
+    cy.get('button:first').click();
+
+    cy.request('GET', `${apiUrl}/api/subscribers`).then((response) => {
+      const { data } = response.body;
+      expect(data.results[1].name).to.equal('new-name');
+      expect(data.results[1].lists.find((s) => s.id === 2).subscription_status).to.equal('unsubscribed');
+      expect(data.results[1].lists.find((s) => s.id === 3).subscription_status).to.equal('unconfirmed');
+    });
+  });
+});
diff --git a/frontend/cypress/integration/import.js b/frontend/cypress/e2e/import.cy.js
similarity index 62%
rename from frontend/cypress/integration/import.js
rename to frontend/cypress/e2e/import.cy.js
index bc3f9eb0a..a3f5bdd14 100644
--- a/frontend/cypress/integration/import.js
+++ b/frontend/cypress/e2e/import.cy.js
@@ -1,24 +1,31 @@
-
 describe('Import', () => {
   it('Opens import page', () => {
     cy.resetDB();
-    cy.loginAndVisit('/subscribers/import');
+    cy.loginAndVisit('/admin/subscribers/import');
   });
 
   it('Imports subscribers', () => {
     const cases = [
-      { chkMode: 'subscribe', status: 'enabled', chkSubStatus: 'unconfirmed', subStatus: 'unconfirmed', overwrite: true, count: 102 },
-      { chkMode: 'subscribe', status: 'enabled', chkSubStatus: 'confirmed', subStatus: 'confirmed', overwrite: true, count: 102 },
-      { chkMode: 'subscribe', status: 'enabled', chkSubStatus: 'unconfirmed', subStatus: 'confirmed', overwrite: false, count: 102 },
-      { chkMode: 'blocklist', status: 'blocklisted', chkSubStatus: 'unsubscribed', subStatus: 'unsubscribed', overwrite: true, count: 102 },
+      {
+        chkMode: 'subscribe', status: 'enabled', chkSubStatus: 'unconfirmed', subStatus: 'unconfirmed', overwrite: true, count: 102,
+      },
+      {
+        chkMode: 'subscribe', status: 'enabled', chkSubStatus: 'confirmed', subStatus: 'confirmed', overwrite: true, count: 102,
+      },
+      {
+        chkMode: 'subscribe', status: 'enabled', chkSubStatus: 'unconfirmed', subStatus: 'confirmed', overwrite: false, count: 102,
+      },
+      {
+        chkMode: 'blocklist', status: 'blocklisted', chkSubStatus: 'unsubscribed', subStatus: 'unsubscribed', overwrite: false, count: 102,
+      },
     ];
 
     cases.forEach((c) => {
       cy.get(`[data-cy=check-${c.chkMode}] .check`).click();
       cy.get(`[data-cy=check-${c.chkSubStatus}] .check`).click();
 
-      if (!c.overwrite) {
-        cy.get(`[data-cy=overwrite]`).click();
+      if (c.overwrite) {
+        cy.get('[data-cy=overwrite]').click();
       }
 
       if (c.status === 'enabled') {
@@ -35,35 +42,38 @@ describe('Import', () => {
       });
 
       cy.get('button.is-primary').click();
+
+      // ONLY if .modal button.is-primary is present, click it.
+      if (c.overwrite) {
+        cy.get('.modal button.is-primary').click();
+      }
+
       cy.get('section.wrap .has-text-success');
       cy.get('button.is-primary').click();
       cy.wait(100);
 
       // Verify that 100 (+2 default) subs are imported.
-      cy.loginAndVisit('/subscribers');
+      cy.loginAndVisit('/admin/subscribers');
       cy.wait(100);
       cy.get('[data-cy=count]').then(($el) => {
         cy.expect(parseInt($el.text().trim())).to.equal(c.count);
       });
 
-      // Subscriber status.
-      cy.get('tbody td[data-label=Status]').each(($el) => {
-        cy.wrap($el).find(`.tag.${c.status}`);
-      });
-
       // Subscription status.
-      cy.get('tbody td[data-label=E-mail]').each(($el) => {
-        cy.wrap($el).find(`.tag.${c.subStatus}`);
-      });
+      // cy.get('tbody td[data-label=E-mail]').each(($el) => {
+      // cy.wrap($el).find(`.tag.${c.subStatus}`);
+      // });
 
-      cy.loginAndVisit('/subscribers/import');
+      cy.loginAndVisit('/admin/subscribers/import');
       cy.wait(100);
     });
   });
 
   it('Imports subscribers incorrectly', () => {
+    cy.wait(1000);
     cy.resetDB();
-    cy.loginAndVisit('/subscribers/import');
+    cy.wait(1000);
+    cy.loginAndVisit('/admin/subscribers/import');
 
     cy.get('.list-selector input').click();
     cy.get('.list-selector .autocomplete a').first().click();
diff --git a/frontend/cypress/integration/lists.js b/frontend/cypress/e2e/lists.cy.js
similarity index 69%
rename from frontend/cypress/integration/lists.js
rename to frontend/cypress/e2e/lists.cy.js
index a8210fbd9..33c31ff04 100644
--- a/frontend/cypress/integration/lists.js
+++ b/frontend/cypress/e2e/lists.cy.js
@@ -1,38 +1,34 @@
 describe('Lists', () => {
   it('Opens lists page', () => {
     cy.resetDB();
-    cy.loginAndVisit('/lists');
+    cy.loginAndVisit('/admin/lists');
   });
 
-
   it('Counts subscribers in default lists', () => {
     cy.get('tbody td[data-label=Subscribers]').contains('1');
   });
 
-
   it('Creates campaign for list', () => {
     cy.get('tbody a[data-cy=btn-campaign]').first().click();
     cy.location('pathname').should('contain', '/campaigns/new');
     cy.get('.list-tags .tag').contains('Default list');
 
     cy.clickMenu('lists', 'all-lists');
+    cy.get('.modal button.is-primary').click();
   });
 
-
   it('Creates opt-in campaign for list', () => {
     cy.get('tbody a[data-cy=btn-send-optin-campaign]').click();
     cy.get('.modal button.is-primary').click();
     cy.location('pathname').should('contain', '/campaigns/2');
-
     cy.clickMenu('lists', 'all-lists');
   });
 
-
   it('Checks individual subscribers in lists', () => {
     const subs = [{ listID: 1, email: 'john@example.com' },
       { listID: 2, email: 'anon@example.com' }];
 
-    // Click on each list on the lists page, go the the subscribers page
+    // Click on each list on the lists page, go the subscribers page
     // for that list, and check the subscriber details.
     subs.forEach((s, n) => {
       cy.get('tbody td[data-label=Subscribers] a').eq(n).click();
@@ -50,8 +46,10 @@ describe('Lists', () => {
       cy.get('input[name=name]').clear().type(`list-${n}`);
       cy.get('select[name=type]').select('public');
       cy.get('select[name=optin]').select('double');
-      cy.get('input[name=tags]').clear().type(`tag${n}`);
-      cy.get('button[type=submit]').click();
+      cy.get('input[name=tags]').clear().type(`tag${n}{enter}`);
+      cy.get('textarea[name=description]').clear().type(`desc${n}`);
+      cy.get('[data-cy=btn-save]').click();
+      cy.wait(100);
     });
     cy.wait(250);
 
@@ -64,7 +62,6 @@ describe('Lists', () => {
     });
   });
 
-
   it('Deletes lists', () => {
     // Delete all visible lists.
     cy.get('tbody tr').each(() => {
@@ -76,7 +73,6 @@ describe('Lists', () => {
     cy.get('table tr.is-empty');
   });
 
-
   // Add new lists.
   it('Adds new lists', () => {
     // Open the list form and create lists of multiple type/optin combinations.
@@ -93,23 +89,27 @@ describe('Lists', () => {
         cy.get('select[name=type]').select(t);
         cy.get('select[name=optin]').select(o);
         cy.get('input[name=tags]').type(`tag${n}{enter}${t}{enter}${o}{enter}`);
-        cy.get('button[type=submit]').click();
+        cy.get('textarea[name=description]').clear().type(`desc-${t}-${n}`);
+        cy.get('[data-cy=btn-save]').click();
+        cy.wait(200);
 
         // Confirm the addition by inspecting the newly created list row.
         const tr = `tbody tr:nth-child(${n + 1})`;
         cy.get(`${tr} td[data-label=Name]`).contains(name);
-        cy.get(`${tr} td[data-label=Type] [data-cy=type-${t}]`);
-        cy.get(`${tr} td[data-label=Type] [data-cy=optin-${o}]`);
-        cy.get(`${tr} .tags`)
-          .should('contain', `tag${n}`)
-          .and('contain', t)
-          .and('contain', o);
-
+        cy.get(`${tr} td[data-label=Type] .tag[data-cy=type-${t}]`);
+        cy.get(`${tr} td[data-label=Type] .tag[data-cy=optin-${o}]`);
         n++;
       });
     });
   });
 
+  it('Searches lists', () => {
+    cy.get('[data-cy=query]').clear().type('list-public-single-2{enter}');
+    cy.wait(200);
+    cy.get('tbody tr').its('length').should('eq', 1);
+    cy.get('tbody td[data-label="Name"]').first().contains('list-public-single-2');
+    cy.get('[data-cy=query]').clear().type('{enter}');
+  });
 
   // Sort lists by clicking on various headers. At this point, there should be four
   // lists with IDs = [3, 4, 5, 6]. Sort the items be columns and match them with
@@ -118,8 +118,8 @@ describe('Lists', () => {
     cy.sortTable('thead th.cy-name', [4, 3, 6, 5]);
     cy.sortTable('thead th.cy-name', [5, 6, 3, 4]);
 
-    cy.sortTable('thead th.cy-type', [5, 6, 4, 3]);
-    cy.sortTable('thead th.cy-type', [4, 3, 5, 6]);
+    cy.sortTable('thead th.cy-type', [3, 4, 5, 6]);
+    cy.sortTable('thead th.cy-type', [6, 5, 4, 3]);
 
     cy.sortTable('thead th.cy-created_at', [3, 4, 5, 6]);
     cy.sortTable('thead th.cy-created_at', [6, 5, 4, 3]);
@@ -127,4 +127,22 @@ describe('Lists', () => {
     cy.sortTable('thead th.cy-updated_at', [3, 4, 5, 6]);
     cy.sortTable('thead th.cy-updated_at', [6, 5, 4, 3]);
   });
+
+  it('Opens forms page', () => {
+    const apiUrl = Cypress.env('apiUrl');
+    cy.loginAndVisit(`${apiUrl}/subscription/form`);
+    cy.get('ul li').its('length').should('eq', 2);
+
+    const cases = [
+      { name: 'list-public-single-2', description: 'desc-public-2' },
+      { name: 'list-public-double-3', description: 'desc-public-3' },
+    ];
+
+    cases.forEach((c, n) => {
+      cy.get('ul li').eq(n).then(($el) => {
+        cy.wrap($el).get('label').contains(c.name);
+        cy.wrap($el).get('.description').contains(c.description);
+      });
+    });
+  });
 });
diff --git a/frontend/cypress/integration/settings.js b/frontend/cypress/e2e/settings.cy.js
similarity index 84%
rename from frontend/cypress/integration/settings.js
rename to frontend/cypress/e2e/settings.cy.js
index 28de7bf35..82a8a7ade 100644
--- a/frontend/cypress/integration/settings.js
+++ b/frontend/cypress/e2e/settings.cy.js
@@ -1,7 +1,9 @@
+const apiUrl = Cypress.env('apiUrl');
+
 describe('Templates', () => {
   it('Opens settings page', () => {
     cy.resetDB();
-    cy.loginAndVisit('/settings');
+    cy.loginAndVisit('/admin/settings');
   });
 
   it('Changes some settings', () => {
@@ -17,17 +19,17 @@ describe('Templates', () => {
       .click();
 
     // Enable / disable SMTP and delete one.
-    cy.get('.b-tabs nav a').eq(4).click();
+    cy.get('.b-tabs nav a').eq(5).click();
     cy.get('.tab-item:visible [data-cy=btn-enable-smtp]').eq(1).click();
     cy.get('.tab-item:visible [data-cy=btn-delete-smtp]').first().click();
     cy.get('.modal button.is-primary').click();
 
     cy.get('[data-cy=btn-save]').click();
 
-    cy.wait(250);
+    cy.wait(1000);
 
     // Verify the changes.
-    cy.request('/api/settings').should((response) => {
+    cy.request(`${apiUrl}/api/settings`).should((response) => {
       const { data } = response.body;
       expect(data['app.root_url']).to.equal(rootURL);
       expect(data['app.favicon_url']).to.equal(faveURL);
diff --git a/frontend/cypress/integration/subscribers.js b/frontend/cypress/e2e/subscribers.cy.js
similarity index 56%
rename from frontend/cypress/integration/subscribers.js
rename to frontend/cypress/e2e/subscribers.cy.js
index 2dc8a6af0..b51eafe45 100644
--- a/frontend/cypress/integration/subscribers.js
+++ b/frontend/cypress/e2e/subscribers.cy.js
@@ -1,15 +1,15 @@
+const apiUrl = Cypress.env('apiUrl');
+
 describe('Subscribers', () => {
   it('Opens subscribers page', () => {
     cy.resetDB();
-    cy.loginAndVisit('/subscribers');
+    cy.loginAndVisit('/admin/subscribers');
   });
 
-
   it('Counts subscribers', () => {
-    cy.get('tbody td[data-label=Status]').its('length').should('eq', 2);
+    cy.get('tbody td[data-label=E-mail]').its('length').should('eq', 2);
   });
 
-
   it('Searches subscribers', () => {
     const cases = [
       { value: 'john{enter}', count: 1, contains: 'john@example.com' },
@@ -19,13 +19,33 @@ describe('Subscribers', () => {
 
     cases.forEach((c) => {
       cy.get('[data-cy=search]').clear().type(c.value);
-      cy.get('tbody td[data-label=Status]').its('length').should('eq', c.count);
+      cy.get('tbody td[data-label=E-mail]').its('length').should('eq', c.count);
       if (c.contains) {
         cy.get('tbody td[data-label=E-mail]').contains(c.contains);
       }
     });
   });
 
+  it('Exports subscribers', () => {
+    const cases = [
+      {
+        listIDs: [], ids: [], query: '', length: 3,
+      },
+      {
+        listIDs: [], ids: [], query: "name ILIKE '%anon%'", length: 2,
+      },
+      {
+        listIDs: [], ids: [], query: "name like 'nope'", length: 1,
+      },
+    ];
+
+    // listIDs[] and ids[] are unused for now as Cypress doesn't support encoding of arrays in `qs`.
+    cases.forEach((c) => {
+      cy.request({ url: `${apiUrl}/api/subscribers/export`, qs: { query: c.query, list_id: c.listIDs, id: c.ids } }).then((resp) => {
+        cy.expect(resp.body.trim().split('\n')).to.have.lengthOf(c.length);
+      });
+    });
+  });
 
   it('Advanced searches subscribers', () => {
     cy.get('[data-cy=btn-advanced-search]').click();
@@ -39,18 +59,18 @@ describe('Subscribers', () => {
     cases.forEach((c) => {
       cy.get('[data-cy=query]').clear().type(c.value);
       cy.get('[data-cy=btn-query]').click();
-      cy.get('tbody td[data-label=Status]').its('length').should('eq', c.count);
+      cy.get('tbody td[data-label=E-mail]').its('length').should('eq', c.count);
     });
 
     cy.get('[data-cy=btn-query-reset]').click();
-    cy.get('tbody td[data-label=Status]').its('length').should('eq', 2);
+    cy.wait(1000);
+    cy.get('tbody td[data-label=E-mail]').its('length').should('eq', 2);
   });
 
-
   it('Does bulk subscriber list add and remove', () => {
     const cases = [
       // radio: action to perform, rows: table rows to select and perform on: [expected statuses of those rows after thea action]
-      { radio: 'check-list-add', lists: [0, 1], rows: { 0: ['unconfirmed', 'unconfirmed'] } },
+      { radio: 'check-list-add', lists: [0, 1], rows: { 0: ['confirmed', 'confirmed'] } },
       { radio: 'check-list-unsubscribe', lists: [0, 1], rows: { 0: ['unsubscribed', 'unsubscribed'], 1: ['unsubscribed'] } },
       { radio: 'check-list-remove', lists: [0, 1], rows: { 1: [] } },
       { radio: 'check-list-add', lists: [0, 1], rows: { 0: ['unsubscribed', 'unsubscribed'], 1: ['unconfirmed', 'unconfirmed'] } },
@@ -58,7 +78,6 @@ describe('Subscribers', () => {
       { radio: 'check-list-add', lists: [0], rows: { 0: ['unconfirmed', 'unsubscribed'] } },
     ];
 
-
     cases.forEach((c, n) => {
       // Select one of the 2 subscribers in the table.
       Object.keys(c.rows).forEach((r) => {
@@ -77,6 +96,11 @@ describe('Subscribers', () => {
       // Select the radio option in the modal.
       cy.get(`[data-cy=${c.radio}] .check`).click();
 
+      // For the first test, check the optin preconfirm box.
+      if (n === 0) {
+        cy.get('[data-cy=preconfirm]').click();
+      }
+
       // Save.
       cy.get('.modal button.is-primary').click();
 
@@ -95,10 +119,9 @@ describe('Subscribers', () => {
 
   it('Resets subscribers page', () => {
     cy.resetDB();
-    cy.loginAndVisit('/subscribers');
+    cy.loginAndVisit('/admin/subscribers');
   });
 
-
   it('Edits subscribers', () => {
     const status = ['enabled', 'blocklisted'];
     const json = '{"string": "hello", "ints": [1,2,3], "null": null, "sub": {"bool": true}}';
@@ -109,7 +132,7 @@ describe('Subscribers', () => {
 
     // Open the edit popup and edit the default lists.
     cy.get('[data-cy=btn-edit]').each(($el, n) => {
-      const email = `email-${n}@email.com`;
+      const email = `email-${n}@EMAIL.com`;
       const name = `name-${n}`;
 
       // Open the edit modal.
@@ -118,11 +141,14 @@ describe('Subscribers', () => {
       // Get the ID from the header and proceed to fill the form.
       let id = 0;
       cy.get('[data-cy=id]').then(($el) => {
-        id = $el.text();
+        id = parseInt($el.text());
 
         cy.get('input[name=email]').clear().type(email);
         cy.get('input[name=name]').clear().type(name);
-        cy.get('select[name=status]').select(status[n]);
+
+        if (status[n] === 'blocklisted') {
+          cy.get('select[name=status]').select(status[n]);
+        }
         cy.get('.list-selector input').click();
         cy.get('.list-selector .autocomplete a').first().click();
         cy.get('textarea[name=attribs]').clear().type(json, { parseSpecialCharSequences: false, delay: 0 });
@@ -133,12 +159,17 @@ describe('Subscribers', () => {
     });
 
     // Confirm the edits on the table.
-    cy.wait(250);
+    cy.wait(500);
+    cy.log(rows);
     cy.get('tbody tr').each(($el) => {
-      cy.wrap($el).find('td[data-id]').invoke('attr', 'data-id').then((id) => {
-        cy.wrap($el).find('td[data-label=E-mail]').contains(rows[id].email);
+      cy.wrap($el).find('td[data-id]').invoke('attr', 'data-id').then((idStr) => {
+        const id = parseInt(idStr);
+        cy.wrap($el).find('td[data-label=E-mail]').contains(rows[id].email.toLowerCase());
         cy.wrap($el).find('td[data-label=Name]').contains(rows[id].name);
-        cy.wrap($el).find('td[data-label=Status]').contains(rows[id].status, { matchCase: false });
+
+        if (rows[id].status === 'blocklisted') {
+          cy.wrap($el).find('[data-cy=blocklisted]');
+        }
 
         // Both lists on the enabled sub should be 'unconfirmed' and the blocklisted one, 'unsubscribed.'
         cy.wrap($el).find(`.tags .${rows[id].status === 'enabled' ? 'unconfirmed' : 'unsubscribed'}`)
@@ -161,17 +192,15 @@ describe('Subscribers', () => {
     cy.get('table tr.is-empty');
   });
 
-
   it('Creates new subscribers', () => {
     const statuses = ['enabled', 'blocklisted'];
     const lists = [[1], [2], [1, 2]];
     const json = '{"string": "hello", "ints": [1,2,3], "null": null, "sub": {"bool": true}}';
 
-
     // Cycle through each status and each list ID combination and create subscribers.
     const n = 0;
     for (let n = 0; n < 6; n++) {
-      const email = `email-${n}@email.com`;
+      const email = `email-${n}@EMAIL.com`;
       const name = `name-${n}`;
       const status = statuses[(n + 1) % statuses.length];
       const list = lists[(n + 1) % lists.length];
@@ -192,9 +221,12 @@ describe('Subscribers', () => {
       // which is always the first row in the table.
       cy.wait(250);
       const tr = cy.get('tbody tr:nth-child(1)').then(($el) => {
-        cy.wrap($el).find('td[data-label=E-mail]').contains(email);
+        cy.wrap($el).find('td[data-label=E-mail]').contains(email.toLowerCase());
         cy.wrap($el).find('td[data-label=Name]').contains(name);
-        cy.wrap($el).find('td[data-label=Status]').contains(status, { matchCase: false });
+
+        if (status === 'blocklisted') {
+          cy.wrap($el).find('[data-cy=blocklisted]');
+        }
         cy.wrap($el).find(`.tags .${status === 'enabled' ? 'unconfirmed' : 'unsubscribed'}`)
           .its('length').should('eq', list.length);
         cy.wrap($el).find('td[data-label=Lists]').then((l) => {
@@ -207,13 +239,131 @@ describe('Subscribers', () => {
   it('Sorts subscribers', () => {
     const asc = [3, 4, 5, 6, 7, 8];
     const desc = [8, 7, 6, 5, 4, 3];
-    const cases = ['cy-status', 'cy-email', 'cy-name', 'cy-created_at', 'cy-updated_at'];
+    const cases = ['cy-email', 'cy-name', 'cy-created_at', 'cy-updated_at'];
 
     cases.forEach((c) => {
       cy.sortTable(`thead th.${c}`, asc);
-      cy.wait(100);
+      cy.wait(250);
       cy.sortTable(`thead th.${c}`, desc);
-      cy.wait(100);
+      cy.wait(250);
+    });
+  });
+});
+
+describe('Domain blocklist', () => {
+  it('Opens settings page', () => {
+    cy.resetDB();
+  });
+
+  it('Add domains to blocklist', () => {
+    cy.loginAndVisit('/admin/settings');
+    cy.get('.b-tabs nav a').eq(2).click();
+    cy.get('textarea[name="privacy.domain_blocklist"]').clear().type('ban.net\n\nBaN.OrG\n\nban.com\n\n');
+    cy.get('[data-cy=btn-save]').click();
+  });
+
+  it('Try subscribing via public page', () => {
+    cy.visit(`${apiUrl}/subscription/form`);
+    cy.get('input[name=email]').clear().type('test@noban.net');
+    cy.get('button[type=submit]').click();
+    cy.get('h2').contains('Subscribe');
+
+    cy.visit(`${apiUrl}/subscription/form`);
+    cy.get('input[name=email]').clear().type('test@ban.net');
+    cy.get('button[type=submit]').click();
+    cy.get('h2').contains('Error');
+  });
+
+  // Post to the admin API.
+  it('Try via admin API', () => {
+    cy.wait(1000);
+
+    // Add non-banned domain.
+    cy.request({
+      method: 'POST',
+      url: `${apiUrl}/api/subscribers`,
+      failOnStatusCode: true,
+      body: {
+        email: 'test1@noban.net', name: 'test', lists: [1], status: 'enabled',
+      },
+    }).should((response) => {
+      expect(response.status).to.equal(200);
+    });
+
+    // Add banned domain.
+    cy.request({
+      method: 'POST',
+      url: `${apiUrl}/api/subscribers`,
+      failOnStatusCode: false,
+      body: {
+        email: 'test1@ban.com', name: 'test', lists: [1], status: 'enabled',
+      },
+    }).should((response) => {
+      expect(response.status).to.equal(400);
+    });
+
+    // Modify an existinb subscriber to a banned domain.
+    cy.request({
+      method: 'PUT',
+      url: `${apiUrl}/api/subscribers/1`,
+      failOnStatusCode: false,
+      body: {
+        email: 'test3@ban.org', name: 'test', lists: [1], status: 'enabled',
+      },
+    }).should((response) => {
+      expect(response.status).to.equal(400);
+    });
+  });
+
+  it('Try via import', () => {
+    cy.loginAndVisit('/admin/subscribers/import');
+    cy.get('.list-selector input').click();
+    cy.get('.list-selector .autocomplete a').first().click();
+
+    cy.fixture('subs-domain-blocklist.csv').then((data) => {
+      cy.get('input[type="file"]').attachFile({
+        fileContent: data.toString(),
+        fileName: 'subs.csv',
+        mimeType: 'text/csv',
+      });
+    });
+
+    cy.get('button.is-primary').click();
+    cy.get('section.wrap .has-text-success');
+    // cy.get('button.is-primary').click();
+    cy.get('.log-view').should('contain', 'ban1-import@BAN.net').and('contain', 'ban2-import@ban.ORG');
+    cy.wait(100);
+  });
+
+  it('Clear blocklist and try', () => {
+    cy.loginAndVisit('/admin/settings');
+    cy.get('.b-tabs nav a').eq(2).click();
+    cy.get('textarea[name="privacy.domain_blocklist"]').clear();
+    cy.get('[data-cy=btn-save]').click();
+    cy.wait(3000);
+
+    // Add banned domain.
+    cy.request({
+      method: 'POST',
+      url: `${apiUrl}/api/subscribers`,
+      failOnStatusCode: true,
+      body: {
+        email: 'test4@BAN.com', name: 'test', lists: [1], status: 'enabled',
+      },
+    }).should((response) => {
+      expect(response.status).to.equal(200);
+    });
+
+    // Modify an existinb subscriber to a banned domain.
+    cy.request({
+      method: 'PUT',
+      url: `${apiUrl}/api/subscribers/1`,
+      failOnStatusCode: true,
+      body: {
+        email: 'test4@BAN.org', name: 'test', lists: [1], status: 'enabled',
+      },
+    }).should((response) => {
+      expect(response.status).to.equal(200);
     });
   });
 });
diff --git a/frontend/cypress/e2e/templates.cy.js b/frontend/cypress/e2e/templates.cy.js
new file mode 100644
index 000000000..374f52c16
--- /dev/null
+++ b/frontend/cypress/e2e/templates.cy.js
@@ -0,0 +1,102 @@
+describe('Templates', () => {
+  it('Opens templates page', () => {
+    cy.resetDB();
+    cy.loginAndVisit('/admin/campaigns/templates');
+  });
+
+  it('Counts default templates', () => {
+    cy.get('tbody td[data-label=Name]').should('have.length', 3);
+  });
+
+  it('Clones campaign template', () => {
+    cy.get('[data-cy=btn-clone]').first().click();
+    cy.get('.modal input').clear().type('cloned campaign').click();
+    cy.get('.modal button.is-primary').click();
+    cy.wait(250);
+
+    // Verify the newly created row.
+    cy.get('tbody td[data-label="Name"]').contains('td', 'cloned campaign');
+  });
+
+  it('Clones tx template', () => {
+    cy.get('tbody td[data-label="Name"]').contains('td', 'Sample transactional template').then((el) => {
+      cy.wrap(el).parent().find('[data-cy=btn-clone]').click();
+      cy.get('.modal input').clear().type('cloned tx').click();
+      cy.get('.modal button.is-primary').click();
+      cy.wait(250);
+    });
+
+    // Verify the newly created row.
+    cy.get('tbody td[data-label="Name"]').contains('td', 'cloned tx');
+  });
+
+  it('Edits template', () => {
+    cy.get('tbody td.actions [data-cy=btn-edit]').first().click();
+    cy.wait(250);
+    cy.get('input[name=name]').clear().type('edited');
+    cy.get('code-flask').shadow().find('.codeflask textarea').invoke('val', 'test {{ template "content" . }}')
+      .trigger('input');
+
+    cy.get('.modal-card-foot button.is-primary').click();
+    cy.wait(250);
+    cy.get('tbody td[data-label="Name"] a').contains('edited');
+  });
+
+  it('Previews campaign templates', () => {
+    // Edited one sould have a bare body.
+    cy.get('tbody [data-cy=btn-preview').eq(0).click();
+    cy.wait(500);
+    cy.get('.modal-card-body iframe').iframe(() => {
+      cy.get('span').first().contains('test');
+      cy.get('p').first().contains('Hi there');
+    });
+    cy.get('.modal-card-foot button').click();
+
+    // Cloned one should have the full template.
+    cy.get('tbody [data-cy=btn-preview').eq(3).click();
+    cy.wait(500);
+    cy.get('.modal-card-body iframe').iframe(() => {
+      cy.get('.wrap p').first().contains('Hi there');
+      cy.get('.footer a').first().contains('Unsubscribe');
+    });
+    cy.get('.modal-card-foot button').click();
+  });
+
+  it('Previews tx templates', () => {
+    cy.get('tbody td[data-label="Name"]').contains('td', 'cloned tx').then((el) => {
+      cy.wrap(el).parent().find('[data-cy=btn-preview]').click();
+      cy.wait(500);
+      cy.get('.modal-card-body iframe').iframe(() => {
+        cy.get('strong').first().contains('Order number');
+      });
+      cy.get('.modal-card-foot button').click();
+    });
+  });
+
+  it('Sets default', () => {
+    cy.get('tbody td[data-label="Name"]').contains('td', 'cloned campaign').then((el) => {
+      cy.wrap(el).parent().find('[data-cy=btn-set-default]').click();
+      cy.get('.modal button.is-primary').click();
+    });
+
+    // The original default shouldn't have default and the new one should have.
+    cy.get('tbody').contains('td', 'edited').parent().find('[data-cy=btn-delete]')
+      .should('exist');
+    cy.get('tbody').contains('td', 'cloned campaign').parent().find('[data-cy=btn-delete]')
+      .should('not.exist');
+  });
+
+  it('Deletes template', () => {
+    cy.wait(250);
+
+    ['Default archive template', 'Sample transactional template'].forEach((t) => {
+      cy.get('tbody td[data-label="Name"]').contains('td', t).then((el) => {
+        cy.wrap(el).parent().find('[data-cy=btn-delete]').click();
+        cy.get('.modal button.is-primary').click();
+      });
+      cy.wait(250);
+    });
+
+    cy.get('tbody td.actions').should('have.length', 3);
+  });
+});
diff --git a/frontend/cypress/e2e/users.cy.js b/frontend/cypress/e2e/users.cy.js
new file mode 100644
index 000000000..74c769c2c
--- /dev/null
+++ b/frontend/cypress/e2e/users.cy.js
@@ -0,0 +1,177 @@
+const apiUrl = Cypress.env('apiUrl');
+
+describe('First time user setup', () => {
+  it('Sets up the superadmin user', () => {
+    cy.resetDBBlank();
+    cy.visit('/admin/login');
+
+    cy.get('input[name=email]').type('super@domain');
+    cy.get('input[name=username]').type('super');
+    cy.get('input[name=password]').type('super123');
+    cy.get('input[name=password2]').type('super123');
+    cy.get('button[type=submit]').click();
+    cy.wait(500);
+    cy.visit('/admin/users');
+
+    cy.get('[data-cy=btn-edit]').first().click();
+    cy.get('select[name=user_role]').should('have.value', '1');
+  });
+});
+
+describe('User roles', () => {
+  it('Opens user roles page', () => {
+    cy.resetDB();
+    cy.loginAndVisit('/admin/users/roles/users');
+  });
+
+  it('Adds new roles', () => {
+    // first - no global list perms.
+    cy.get('[data-cy=btn-new]').click();
+    cy.get('input[name=name]').type('first');
+    cy.get('[data-cy=btn-save]').click();
+    cy.wait(500);
+
+    // second - all perms.
+    cy.get('[data-cy=btn-new]').click();
+    cy.get('input[name=name]').type('second');
+    cy.get('input[type=checkbox]').each((e) => {
+      cy.get(e).check({ force: true });
+    });
+    cy.get('[data-cy=btn-save]').click();
+    cy.wait(200);
+  });
+
+  it('Edits role', () => {
+    cy.get('[data-cy=btn-edit]').first().click();
+    cy.get('input[value="users:get"]').check({ force: true });
+    cy.get('[data-cy=btn-save]').click();
+  });
+
+  it('Deletes role', () => {
+    cy.get('[data-cy=btn-clone]').last().click();
+    cy.get('.modal-card-foot button.is-primary').click();
+    cy.wait(500);
+    cy.get('[data-cy=btn-delete]').last().click();
+    cy.get('.modal button.is-primary').click();
+
+    cy.get('tbody tr').should('have.length', 3);
+  });
+});
+
+describe('List roles', () => {
+  it('Opens roles page', () => {
+    cy.loginAndVisit('/admin/users/roles/lists');
+  });
+
+  it('Adds new roles', () => {
+    cy.get('[data-cy=btn-new]').click();
+    cy.get('input[name=name]').type('first');
+    cy.get('.box button.is-primary').click();
+    cy.get('[data-cy=btn-save]').click();
+    cy.wait(500);
+
+    cy.get('[data-cy=btn-new]').click();
+    cy.get('input[name=name]').type('second');
+    cy.get('.box button.is-primary').click();
+    cy.get('.box button.is-primary').click();
+    cy.get('[data-cy=btn-save]').click();
+    cy.wait(500);
+  });
+
+  it('Edits role', () => {
+    cy.get('[data-cy=btn-edit]').eq(1).click();
+
+    // Uncheck "manage" permission on the second item.
+    cy.get('input[type=checkbox]').eq(3).uncheck({ force: true });
+    cy.get('[data-cy=btn-save]').click();
+  });
+
+  it('Deletes role', () => {
+    cy.get('[data-cy=btn-clone]').last().click();
+    cy.get('.modal-card-foot button.is-primary').click();
+    cy.wait(500);
+    cy.get('[data-cy=btn-delete]').last().click();
+    cy.get('.modal button.is-primary').click();
+
+    cy.get('tbody tr').should('have.length', 2);
+  });
+});
+
+describe('Users ', () => {
+  it('Opens users page', () => {
+    cy.loginAndVisit('/admin/users');
+  });
+
+  it('Adds new users', () => {
+    ['first', 'second', 'third'].forEach((name) => {
+      cy.get('[data-cy=btn-new]').click();
+      cy.get('input[name=username]').type(name);
+      cy.get('input[name=name]').type(name);
+      cy.get('input[name=email]').type(`${name}@domain`);
+      cy.get('input[name=password_login]').check({ force: true });
+      cy.get('input[name=password]').type(`${name}000000`);
+      cy.get('input[name=password2]').type(`${name}000000`);
+
+      const role = name !== 'third' ? name : 'first';
+      cy.get('select[name=user_role]').select(role);
+      cy.get('select[name=list_role]').select(role);
+      cy.get('.modal button.is-primary').click();
+      cy.wait(500);
+    });
+  });
+
+  it('Edits user', () => {
+    cy.get('[data-cy=btn-edit]').last().click();
+    cy.get('input[name=password_login]').uncheck({ force: true });
+    cy.get('select[name=user_role]').select('second');
+    cy.get('select[name=list_role]').select('second');
+    cy.get('.modal button.is-primary').click();
+    cy.wait(500);
+
+    // Fetch the campaigns API and verfiy the values that couldn't be verified on the table UI.
+    cy.request(`${apiUrl}/api/users/4`).should((response) => {
+      const { data } = response.body;
+
+      expect(data.password_login).to.equal(false);
+      expect(data.user_role.name).to.equal('second');
+      expect(data.list_role.name).to.equal('second');
+    });
+  });
+
+  it('Deletes a user', () => {
+    cy.get('[data-cy=btn-delete]').last().click();
+    cy.get('.modal-card-foot button.is-primary').click();
+    cy.wait(500);
+    cy.get('tbody tr').should('have.length', 3);
+  });
+});
+
+describe('Login ', () => {
+  it('Logs in as first', () => {
+    cy.visit('/admin/login?next=/admin/lists');
+    cy.get('input[name=username]').invoke('val', 'first');
+    cy.get('input[name=password]').invoke('val', 'first000000');
+    cy.get('button').click();
+
+    // first=only default list.
+    cy.get('tbody tr').should('have.length', 1);
+    cy.get('tbody td[data-label=Name]').contains('Default list');
+    cy.get('[data-cy=btn-new]').should('not.exist');
+    cy.get('[data-cy=btn-edit]').should('exist');
+    cy.get('[data-cy=btn-delete]').should('exist');
+  });
+
+  it('Logs in as second', () => {
+    cy.visit('/admin/login?next=/admin/lists');
+    cy.get('input[name=username]').invoke('val', 'second');
+    cy.get('input[name=password]').invoke('val', 'second000000');
+    cy.get('button').click();
+
+    // first=only default list.
+    cy.get('tbody tr').should('have.length', 2);
+    cy.get('tbody tr:nth-child(1) [data-cy=btn-edit]').should('exist');
+    cy.get('tbody tr:nth-child(1) [data-cy=btn-delete]').should('exist');
+    cy.get('tbody tr:nth-child(2) [data-cy=btn-edit]').should('not.exist');
+    cy.get('tbody tr:nth-child(2) [data-cy=btn-delete]').should('not.exist');
+  });
+});
diff --git a/frontend/cypress/fixtures/subs-domain-blocklist.csv b/frontend/cypress/fixtures/subs-domain-blocklist.csv
new file mode 100644
index 000000000..9ee31d8ef
--- /dev/null
+++ b/frontend/cypress/fixtures/subs-domain-blocklist.csv
@@ -0,0 +1,5 @@
+email,name,attributes
+noban1-import@mail.com,First0 Last0,"{""age"": 29, ""city"": ""Bangalore"", ""clientId"": ""DAXX79""}"
+ban1-import@BAN.net,First1 Last1,"{""age"": 43, ""city"": ""Bangalore"", ""clientId"": ""DAXX71""}"
+noban2-import1@mail.com,First2 Last2,"{""age"": 47, ""city"": ""Bangalore"", ""clientId"": ""DAXX70""}"
+ban2-import@ban.ORG,First1 Last1,"{""age"": 43, ""city"": ""Bangalore"", ""clientId"": ""DAXX71""}"
diff --git a/frontend/cypress/integration/bounces.js b/frontend/cypress/integration/bounces.js
deleted file mode 100644
index b1db2c738..000000000
--- a/frontend/cypress/integration/bounces.js
+++ /dev/null
@@ -1,75 +0,0 @@
-describe('Bounces', () => {
-  let subs = [];
-
-  it('Enable bounces', () => {
-    cy.resetDB();
-
-    cy.loginAndVisit('/settings');
-    cy.get('.b-tabs nav a').eq(5).click();
-    cy.get('[data-cy=btn-enable-bounce] .switch').click();
-    cy.get('[data-cy=btn-enable-bounce-webhook] .switch').click();
-    cy.get('[data-cy=btn-bounce-count] .plus').click();
-
-    cy.get('[data-cy=btn-save]').click();
-    cy.wait(1000);
-  });
-
-
-  it('Post bounces', () => {
-    // Get campaign.
-    let camp = {};
-    cy.request('/api/campaigns').then((resp) => {
-      camp = resp.body.data.results[0];
-    })
-    cy.then(() => {
-      console.log("campaign is ", camp.uuid);
-    })
-
-
-    // Get subscribers.
-    cy.request('/api/subscribers').then((resp) => {
-      subs = resp.body.data.results;
-      console.log(subs)
-    });
-
-    cy.then(() => {
-      console.log(`got ${subs.length} subscribers`);
-
-      // Post bounces. Blocklist the 1st sub.
-      cy.request('POST', '/webhooks/bounce', { source: "api", type: "hard", email: subs[0].email });
-      cy.request('POST', '/webhooks/bounce', { source: "api", type: "hard", campaign_uuid: camp.uuid, email: subs[0].email });
-      cy.request('POST', '/webhooks/bounce', { source: "api", type: "hard", campaign_uuid: camp.uuid, subscriber_uuid: subs[0].uuid });
-
-      for (let i = 0; i < 2; i++) {
-        cy.request('POST', '/webhooks/bounce', { source: "api", type: "soft", campaign_uuid: camp.uuid, subscriber_uuid: subs[1].uuid });
-      }
-    });
-
-    cy.wait(250);
-  });
-
-  it('Opens bounces page', () => {
-    cy.loginAndVisit('/subscribers/bounces');
-    cy.wait(250);
-    cy.get('tbody tr').its('length').should('eq', 5);
-  });
-
-  it('Delete bounce', () => {
-    cy.get('tbody tr:last-child [data-cy="btn-delete"]').click();
-    cy.get('.modal button.is-primary').click();
-    cy.wait(250);
-    cy.get('tbody tr').its('length').should('eq', 4);
-  });
-
-  it('Check subscriber statuses', () => {
-    cy.loginAndVisit(`/subscribers/${subs[0].id}`);
-    cy.wait(250);
-    cy.get('.modal-card-head .tag').should('have.class', 'blocklisted');
-    cy.get('.modal-card-foot button[type="button"]').click();
-
-    cy.loginAndVisit(`/subscribers/${subs[1].id}`);
-    cy.wait(250);
-    cy.get('.modal-card-head .tag').should('have.class', 'enabled');
-  });
-
-});
diff --git a/frontend/cypress/integration/campaigns.js b/frontend/cypress/integration/campaigns.js
deleted file mode 100644
index a80567e8d..000000000
--- a/frontend/cypress/integration/campaigns.js
+++ /dev/null
@@ -1,211 +0,0 @@
-describe('Subscribers', () => {
-  it('Opens campaigns page', () => {
-    cy.resetDB();
-    cy.loginAndVisit('/campaigns');
-  });
-
-
-  it('Counts campaigns', () => {
-    cy.get('tbody td[data-label=Status]').should('have.length', 1);
-  });
-
-  it('Edits campaign', () => {
-    cy.get('td[data-label=Status] a').click();
-
-    // Fill fields.
-    cy.get('input[name=name]').clear().type('new-name');
-    cy.get('input[name=subject]').clear().type('new-subject');
-    cy.get('input[name=from_email]').clear().type('new ');
-
-    // Change the list.
-    cy.get('.list-selector a.delete').click();
-    cy.get('.list-selector input').click();
-    cy.get('.list-selector .autocomplete a').eq(1).click();
-
-    // Clear and redo tags.
-    cy.get('input[name=tags]').type('{backspace}new-tag{enter}');
-
-    // Enable schedule.
-    cy.get('[data-cy=btn-send-later] .check').click();
-    cy.get('.datepicker input').click();
-    cy.get('.datepicker-header .control:nth-child(2) select').select((new Date().getFullYear() + 1).toString());
-    cy.get('.datepicker-body a.is-selectable:first').click();
-    cy.get('body').click(1, 1);
-
-    // Switch to content tab.
-    cy.get('.b-tabs nav a').eq(1).click();
-
-    // Switch format to plain text.
-    cy.get('label[data-cy=check-plain]').click();
-    cy.get('.modal button.is-primary').click();
-
-    // Enter body value.
-    cy.get('textarea[name=content]').clear().type('new-content');
-    cy.get('button[data-cy=btn-save]').click();
-
-    // Schedule.
-    cy.get('button[data-cy=btn-schedule]').click();
-    cy.get('.modal button.is-primary').click();
-
-    cy.wait(250);
-
-    // Verify the changes.
-    cy.request('/api/campaigns/1').should((response) => {
-      const { data } = response.body;
-      expect(data.status).to.equal('scheduled');
-      expect(data.name).to.equal('new-name');
-      expect(data.subject).to.equal('new-subject');
-      expect(data.content_type).to.equal('plain');
-      expect(data.altbody).to.equal(null);
-      expect(data.send_at).to.not.equal(null);
-      expect(data.body).to.equal('new-content');
-
-      expect(data.lists.length).to.equal(1);
-      expect(data.lists[0].id).to.equal(2);
-      expect(data.tags.length).to.equal(1);
-      expect(data.tags[0]).to.equal('new-tag');
-    });
-
-    cy.get('tbody td[data-label=Status] .tag.scheduled');
-  });
-
-  it('Clones campaign', () => {
-    for (let n = 0; n < 3; n++) {
-      // Clone the campaign.
-      cy.get('[data-cy=btn-clone]').first().click();
-      cy.get('.modal input').clear().type(`clone${n}`).click();
-      cy.get('.modal button.is-primary').click();
-      cy.wait(250);
-      cy.clickMenu('all-campaigns');
-      cy.wait(100);
-
-      // Verify the newly created row.
-      cy.get('tbody td[data-label="Name"]').first().contains(`clone${n}`);
-    }
-  });
-
-
-  it('Searches campaigns', () => {
-    cy.get('input[name=query]').clear().type('clone2{enter}');
-    cy.get('tbody tr').its('length').should('eq', 1);
-    cy.get('tbody td[data-label="Name"]').first().contains('clone2');
-    cy.get('input[name=query]').clear().type('{enter}');
-  });
-
-
-  it('Deletes campaign', () => {
-    // Delete all visible lists.
-    cy.get('tbody tr').each(() => {
-      cy.get('tbody a[data-cy=btn-delete]').first().click();
-      cy.get('.modal button.is-primary').click();
-    });
-
-    // Confirm deletion.
-    cy.get('table tr.is-empty');
-  });
-
-
-  it('Adds new campaigns', () => {
-    const lists = [[1], [1, 2]];
-    const cTypes = ['richtext', 'html', 'plain'];
-
-    let n = 0;
-    cTypes.forEach((c) => {
-      lists.forEach((l) => {
-      // Click the 'new button'
-        cy.get('[data-cy=btn-new]').click();
-        cy.wait(100);
-
-        // Fill fields.
-        cy.get('input[name=name]').clear().type(`name${n}`);
-        cy.get('input[name=subject]').clear().type(`subject${n}`);
-
-        l.forEach(() => {
-          cy.get('.list-selector input').click();
-          cy.get('.list-selector .autocomplete a').first().click();
-        });
-
-        // Add tags.
-        for (let i = 0; i < 3; i++) {
-          cy.get('input[name=tags]').type(`tag${i}{enter}`);
-        }
-
-        // Hit 'Continue'.
-        cy.get('button[data-cy=btn-continue]').click();
-        cy.wait(250);
-
-        // Insert content.
-        cy.get('.ql-editor').type(`hello${n} \{\{ .Subscriber.Name \}\}`, { parseSpecialCharSequences: false });
-        cy.get('.ql-editor').type('{enter}');
-        cy.get('.ql-editor').type('\{\{ .Subscriber.Attribs.city \}\}', { parseSpecialCharSequences: false });
-
-        // Select content type.
-        cy.get(`label[data-cy=check-${c}]`).click();
-
-        // If it's not richtext, there's a "you'll lose formatting" prompt.
-        if (c !== 'richtext') {
-          cy.get('.modal button.is-primary').click();
-        }
-
-        // Save.
-        cy.get('button[data-cy=btn-save]').click();
-
-        cy.clickMenu('all-campaigns');
-        cy.wait(250);
-
-        // Verify the newly created campaign in the table.
-        cy.get('tbody td[data-label="Name"]').first().contains(`name${n}`);
-        cy.get('tbody td[data-label="Name"]').first().contains(`subject${n}`);
-        cy.get('tbody td[data-label="Lists"]').first().then(($el) => {
-          cy.wrap($el).find('li').should('have.length', l.length);
-        });
-
-        n++;
-      });
-    });
-
-    // Fetch the campaigns API and verfiy the values that couldn't be verified on the table UI.
-    cy.request('/api/campaigns?order=asc&order_by=created_at').should((response) => {
-      const { data } = response.body;
-      expect(data.total).to.equal(lists.length * cTypes.length);
-
-      let n = 0;
-      cTypes.forEach((c) => {
-        lists.forEach((l) => {
-          expect(data.results[n].content_type).to.equal(c);
-          expect(data.results[n].lists.map((ls) => ls.id)).to.deep.equal(l);
-          n++;
-        });
-      });
-    });
-  });
-
-  it('Starts and cancels campaigns', () => {
-    for (let n = 1; n <= 2; n++) {
-      cy.get(`tbody tr:nth-child(${n}) [data-cy=btn-start]`).click();
-      cy.get('.modal button.is-primary').click();
-      cy.wait(250);
-      cy.get(`tbody tr:nth-child(${n}) td[data-label=Status] .tag.running`);
-
-      if (n > 1) {
-        cy.get(`tbody tr:nth-child(${n}) [data-cy=btn-cancel]`).click();
-        cy.get('.modal button.is-primary').click();
-        cy.wait(250);
-        cy.get(`tbody tr:nth-child(${n}) td[data-label=Status] .tag.cancelled`);
-      }
-    }
-  });
-
-  it('Sorts campaigns', () => {
-    const asc = [5, 6, 7, 8, 9, 10];
-    const desc = [10, 9, 8, 7, 6, 5];
-    const cases = ['cy-name', 'cy-timestamp'];
-
-    cases.forEach((c) => {
-      cy.sortTable(`thead th.${c}`, asc);
-      cy.wait(250);
-      cy.sortTable(`thead th.${c}`, desc);
-      cy.wait(250);
-    });
-  });
-});
diff --git a/frontend/cypress/integration/forms.js b/frontend/cypress/integration/forms.js
deleted file mode 100644
index 55240a007..000000000
--- a/frontend/cypress/integration/forms.js
+++ /dev/null
@@ -1,68 +0,0 @@
-describe('Forms', () => {
-  it('Opens forms page', () => {
-    cy.resetDB();
-    cy.loginAndVisit('/lists/forms');
-  });
-
-  it('Checks form URL', () => {
-    cy.get('a[data-cy=url]').contains('http://localhost:9000');
-  });
-
-  it('Checks public lists', () => {
-    cy.get('ul[data-cy=lists] li')
-      .should('contain', 'Opt-in list')
-      .its('length')
-      .should('eq', 1);
-
-    cy.get('[data-cy=form] pre').should('not.exist');
-  });
-
-  it('Selects public list', () => {
-    // Click the list checkbox.
-    cy.get('ul[data-cy=lists] .checkbox').click();
-
-    // Make sure the 
 form HTML has appeared.
-    cy.get('[data-cy=form] pre').then(($pre) => {
-      // Check that the ID of the list in the checkbox appears in the HTML.
-      cy.get('ul[data-cy=lists] input').then(($inp) => {
-        cy.wrap($pre).contains($inp.val());
-      });
-    });
-
-    // Click the list checkbox.
-    cy.get('ul[data-cy=lists] .checkbox').click();
-    cy.get('[data-cy=form] pre').should('not.exist');
-  });
-
-  it('Subscribes from public form page', () => {
-    // Create a public test list.
-    cy.request('POST', '/api/lists', { name: 'test-list', type: 'public', optin: 'single' });
-
-    // Open the public page and subscribe to alternating lists multiple times.
-    // There should be no errors and two new subscribers should be subscribed to two lists.
-    for (let i = 0; i < 2; i++) {
-      for (let j = 0; j < 2; j++) {
-        cy.loginAndVisit('/subscription/form');
-        cy.get('input[name=email]').clear().type(`test${i}@test.com`);
-        cy.get('input[name=name]').clear().type(`test${i}`);
-        cy.get('input[type=checkbox]').eq(j).click();
-        cy.get('button').click();
-        cy.wait(250);
-        cy.get('.wrap').contains(/has been sent|successfully/);
-      }
-    }
-
-    // Verify form subscriptions.
-    cy.request('/api/subscribers').should((response) => {
-      const { data } = response.body;
-
-      // Two new + two dummy subscribers that are there by default.
-      expect(data.total).to.equal(4);
-
-      // The two new subscribers should each have two list subscriptions.
-      for (let i = 0; i < 2; i++) {
-        expect(data.results.find((s) => s.email === `test${i}@test.com`).lists.length).to.equal(2);
-      }
-    });
-  });
-});
diff --git a/frontend/cypress/integration/templates.js b/frontend/cypress/integration/templates.js
deleted file mode 100644
index 7e64e0208..000000000
--- a/frontend/cypress/integration/templates.js
+++ /dev/null
@@ -1,78 +0,0 @@
-describe('Templates', () => {
-  it('Opens templates page', () => {
-    cy.resetDB();
-    cy.loginAndVisit('/campaigns/templates');
-  });
-
-
-  it('Counts default templates', () => {
-    cy.get('tbody td[data-label=Name]').should('have.length', 1);
-  });
-
-  it('Clones template', () => {
-    // Clone the campaign.
-    cy.get('[data-cy=btn-clone]').first().click();
-    cy.get('.modal input').clear().type('cloned').click();
-    cy.get('.modal button.is-primary').click();
-    cy.wait(250);
-
-    // Verify the newly created row.
-    cy.get('tbody td[data-label="Name"]').eq(1).contains('cloned');
-  });
-
-  it('Edits template', () => {
-    cy.get('tbody td.actions [data-cy=btn-edit]').first().click();
-    cy.wait(250);
-    cy.get('input[name=name]').clear().type('edited');
-    cy.get('textarea[name=body]').clear().type('test {{ template "content" . }}',
-      { parseSpecialCharSequences: false, delay: 0 });
-    cy.get('.modal-card-foot button.is-primary').click();
-    cy.wait(250);
-    cy.get('tbody td[data-label="Name"] a').contains('edited');
-  });
-
-
-  it('Previews templates', () => {
-    // Edited one sould have a bare body.
-    cy.get('tbody [data-cy=btn-preview').eq(0).click();
-    cy.wait(500);
-    cy.get('.modal-card-body iframe').iframe(() => {
-      cy.get('span').first().contains('test');
-      cy.get('p').first().contains('Hi there');
-    });
-    cy.get('.modal-card-foot button').click();
-
-    // Cloned one should have the full template.
-    cy.get('tbody [data-cy=btn-preview').eq(1).click();
-    cy.wait(500);
-    cy.get('.modal-card-body iframe').iframe(() => {
-      cy.get('.wrap p').first().contains('Hi there');
-      cy.get('.footer a').first().contains('Unsubscribe');
-    });
-    cy.get('.modal-card-foot button').click();
-  });
-
-  it('Sets default', () => {
-    cy.get('tbody td.actions').eq(1).find('[data-cy=btn-set-default]').click();
-    cy.get('.modal button.is-primary').click();
-
-    // The original default shouldn't have default and the new one should have.
-    cy.get('tbody td.actions').eq(0).then((el) => {
-      cy.wrap(el).find('[data-cy=btn-delete]').should('exist');
-      cy.wrap(el).find('[data-cy=btn-set-default]').should('exist');
-    });
-    cy.get('tbody td.actions').eq(1).then((el) => {
-      cy.wrap(el).find('[data-cy=btn-delete]').should('not.exist');
-      cy.wrap(el).find('[data-cy=btn-set-default]').should('not.exist');
-    });
-  });
-
-
-  it('Deletes template', () => {
-    cy.wait(250);
-    cy.get('tbody td.actions [data-cy=btn-delete]').first().click();
-    cy.get('.modal button.is-primary').click();
-    cy.wait(250);
-    cy.get('tbody td.actions').should('have.length', 1);
-  });
-});
diff --git a/frontend/cypress/support/commands.js b/frontend/cypress/support/commands.js
index e8b3fbfe5..d3b2bca6e 100644
--- a/frontend/cypress/support/commands.js
+++ b/frontend/cypress/support/commands.js
@@ -6,7 +6,11 @@ Cypress.Commands.add('resetDB', () => {
   // in the background. If the DB is reset without restartin listmonk,
   // the live Postgres connections in the app throw errors because the
   // schema changes midway.
-  cy.exec(Cypress.env('server_init_command'));
+  cy.exec(Cypress.env('serverInitCmd'));
+});
+
+Cypress.Commands.add('resetDBBlank', () => {
+  cy.exec(Cypress.env('serverInitBlankCmd'));
 });
 
 // Takes a th class selector of a Buefy table, clicks it sorting the table,
@@ -14,18 +18,24 @@ Cypress.Commands.add('resetDB', () => {
 // table against the given IDs, asserting the expected order of sort.
 Cypress.Commands.add('sortTable', (theadSelector, ordIDs) => {
   cy.get(theadSelector).click();
+  cy.wait(250);
   cy.get('tbody td[data-id]').each(($el, index) => {
     expect(ordIDs[index]).to.equal(parseInt($el.attr('data-id')));
   });
 });
 
 Cypress.Commands.add('loginAndVisit', (url) => {
-  cy.visit(url, {
-    auth: {
-      username: Cypress.env('username'),
-      password: Cypress.env('password'),
-    },
-  });
+  cy.visit(`/admin/login?next=${url}`);
+
+  const username = Cypress.env('LISTMONK_ADMIN_USER') || 'admin';
+  const password = Cypress.env('LISTMONK_ADMIN_PASSWORD') || 'listmonk';
+
+  // Fill the username and passowrd and login.
+  cy.get('input[name=username]').invoke('val', username);
+  cy.get('input[name=password]').invoke('val', password);
+
+  // Submit form.
+  cy.get('button').click();
 });
 
 Cypress.Commands.add('clickMenu', (...selectors) => {
@@ -35,8 +45,19 @@ Cypress.Commands.add('clickMenu', (...selectors) => {
 });
 
 // https://www.nicknish.co/blog/cypress-targeting-elements-inside-iframes
-Cypress.Commands.add('iframe', { prevSubject: 'element' }, ($iframe, callback = () => {}) => cy
-    .wrap($iframe)
-    .should((iframe) => expect(iframe.contents().find('body')).to.exist)
-    .then((iframe) => cy.wrap(iframe.contents().find('body')))
-    .within({}, callback));
+Cypress.Commands.add('iframe', { prevSubject: 'element' }, ($iframe, callback = () => { }) => cy
+  .wrap($iframe)
+  .should((iframe) => expect(iframe.contents().find('body')).to.exist)
+  .then((iframe) => cy.wrap(iframe.contents().find('body')))
+  .within({}, callback));
+
+Cypress.on('uncaught:exception', (err, runnable) => {
+  if (err.hasOwnProperty('request')) {
+    const u = err.request.url;
+    if (u.includes('config') || u.includes('settings') || u.includes('events')) {
+      return false;
+    }
+  }
+
+  return true;
+});
diff --git a/frontend/cypress/support/e2e.js b/frontend/cypress/support/e2e.js
new file mode 100644
index 000000000..eae2eeb70
--- /dev/null
+++ b/frontend/cypress/support/e2e.js
@@ -0,0 +1,11 @@
+import './commands';
+
+beforeEach(() => {
+  cy.intercept('GET', '/sockjs-node/**', (req) => {
+    req.destroy();
+  });
+
+  cy.intercept('GET', '/api/health', (req) => {
+    req.reply({});
+  });
+});
diff --git a/frontend/cypress/support/index.js b/frontend/cypress/support/index.js
deleted file mode 100644
index 02d3a1d42..000000000
--- a/frontend/cypress/support/index.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import './commands';
-
-beforeEach(() => {
-  cy.server({
-    ignore: (xhr) => {
-      // Ignore the webpack dev server calls that interfere in the tests
-      // when testing with `yarn serve`.
-      if (xhr.url.indexOf('sockjs-node/') > -1) {
-        return true;
-      }
-
-      // Return the default cypress whitelist filer.
-      return xhr.method === 'GET' && /\.(jsx?|html|css)(\?.*)?$/.test(xhr.url);
-    },
-  });
-});
diff --git a/frontend/fontello/config.json b/frontend/fontello/config.json
old mode 100644
new mode 100755
index 218347a40..9cb2d79c2
--- a/frontend/fontello/config.json
+++ b/frontend/fontello/config.json
@@ -538,6 +538,82 @@
         "email-bounce"
       ]
     },
+    {
+      "uid": "fcb7bfb12b7533c7026762bfc328ca1c",
+      "css": "speedometer",
+      "code": 59430,
+      "src": "custom_icons",
+      "selected": true,
+      "svg": {
+        "path": "M500 666Q447.3 666 411.1 629.9T375 541Q375 507.8 391.6 478.5T437.5 433.6L841.8 199.2 611.3 597.7Q595.7 628.9 565.4 647.5T500 666ZM500 125Q609.4 125 707 179.7L619.1 230.5Q562.5 209 500 209 410.2 209 333 253.9T210.9 375 166 541Q166 609.4 192.4 669.9T263.7 777.3V777.3Q277.3 789.1 277.3 806.6T264.6 835.9 234.4 847.7 205.1 835.9V835.9Q148.4 779.3 116.2 703.1T84 543 115.2 381.8 205.1 246.1 340.8 156.3 500 125ZM916 541Q916 627 883.8 703.1T794.9 835.9V835.9Q783.2 847.7 765.6 847.7T736.3 835.9 724.6 806.6 736.3 777.3V777.3Q781.3 730.5 807.6 669.9T834 541Q834 478.5 810.5 419.9L861.3 334Q916 433.6 916 541Z",
+        "width": 1000
+      },
+      "search": [
+        "speedometer"
+      ]
+    },
+    {
+      "uid": "86efd4d8903ab613b84953efcef01406",
+      "css": "logout-variant",
+      "code": 984573,
+      "src": "custom_icons",
+      "selected": true,
+      "svg": {
+        "path": "M585.9 650.4L695.3 541H291V459H695.3L585.9 349.6 646.5 291 853.5 500 646.5 709ZM791 125Q826.2 125 850.6 149.4T875 209V402.3L791 320.3V209H209V791H791V679.7L875 597.7V791Q875 826.2 850.6 850.6T791 875H209Q173.8 875 149.4 850.6T125 791V209Q125 173.8 149.4 149.4T209 125H791Z",
+        "width": 1000
+      },
+      "search": [
+        "logout-variant"
+      ]
+    },
+    {
+      "uid": "10098901a143c53df6eeaeb317ae3da6",
+      "css": "wrench-outline",
+      "code": 986080,
+      "src": "custom_icons",
+      "selected": true,
+      "svg": {
+        "path": "M941.4 791L564.5 412.1Q593.8 337.9 578.1 258.8T503.9 121.1Q431.6 50.8 332 43T152.3 93.8L312.5 253.9 253.9 312.5 93.8 154.3Q35.2 234.4 42 334T121.1 503.9Q177.7 562.5 255.9 578.1T408.2 566.4L787.1 945.3Q798.8 959 816.4 959T845.7 945.3L941.4 849.6Q955.1 837.9 955.1 821.3T941.4 791ZM816.4 857.4L423.8 462.9Q384.8 492.2 340.8 498T253.9 491.2 180.7 447.3 136.7 378.9 125 302.7L253.9 431.6 429.7 253.9 300.8 125Q384.8 121.1 445.3 179.7 478.5 212.9 491.2 256.8T496.1 344.7 460.9 427.7L853.5 820.3Z",
+        "width": 1000
+      },
+      "search": [
+        "wrench-outline"
+      ]
+    },
+    {
+      "uid": "7fced23a2d76846589ebc2b409bf16e8",
+      "css": "code",
+      "code": 983401,
+      "src": "custom_icons",
+      "selected": true,
+      "svg": {
+        "path": "M334 125Q298.8 125 274.4 149.4T250 209V375Q250 410.2 225.6 434.6T166 459H125V541H166Q201.2 541 225.6 565.4T250 625V791Q250 826.2 274.4 850.6T334 875H416V791H334V584Q334 548.8 309.6 524.4T250 500Q285.2 500 309.6 475.6T334 416V209H416V125H334ZM666 125Q701.2 125 725.6 149.4T750 209V375Q750 410.2 774.4 434.6T834 459H875V541H834Q798.8 541 774.4 565.4T750 625V791Q750 826.2 725.6 850.6T666 875H584V791H666V584Q666 548.8 690.4 524.4T750 500Q714.8 500 690.4 475.6T666 416V209H584V125H666Z",
+        "width": 1000
+      },
+      "search": [
+        "code"
+      ]
+    },
+    {
+      "uid": "8f28d948aa6379b1a69d2a090e7531d4",
+      "css": "warning-empty",
+      "code": 59431,
+      "src": "typicons"
+    },
+    {
+      "uid": "77025195d19e048302e8943e2da4cc75",
+      "css": "account-outline",
+      "code": 983059,
+      "src": "custom_icons",
+      "selected": true,
+      "svg": {
+        "path": "M500 166Q568.4 166 617.2 214.8T666 333 617.2 451.2 500 500 382.8 451.2 334 333 382.8 214.8 500 166ZM500 250Q464.8 250 440.4 274.4T416 333 440.4 391.6 500 416 559.6 391.6 584 333 559.6 274.4 500 250ZM500 541Q562.5 541 636.7 560.5 720.7 582 771.5 615.2 834 656.3 834 709V834H166V709Q166 656.3 228.5 615.2 279.3 582 363.3 560.5 437.5 541 500 541ZM500 621.1Q441.4 621.1 378.9 636.7 324.2 652.3 285.2 673.8T246.1 709V753.9H753.9V709Q753.9 695.3 714.8 673.8T621.1 636.7Q558.6 621.1 500 621.1Z",
+        "width": 1000
+      },
+      "search": [
+        "account-outline"
+      ]
+    },
     {
       "uid": "f4ad3f6d071a0bfb3a8452b514ed0892",
       "css": "vector-square",
@@ -776,20 +852,6 @@
         "account-off"
       ]
     },
-    {
-      "uid": "77025195d19e048302e8943e2da4cc75",
-      "css": "account-outline",
-      "code": 983059,
-      "src": "custom_icons",
-      "selected": false,
-      "svg": {
-        "path": "M500 166Q568.4 166 617.2 214.8T666 333 617.2 451.2 500 500 382.8 451.2 334 333 382.8 214.8 500 166ZM500 250Q464.8 250 440.4 274.4T416 333 440.4 391.6 500 416 559.6 391.6 584 333 559.6 274.4 500 250ZM500 541Q562.5 541 636.7 560.5 720.7 582 771.5 615.2 834 656.3 834 709V834H166V709Q166 656.3 228.5 615.2 279.3 582 363.3 560.5 437.5 541 500 541ZM500 621.1Q441.4 621.1 378.9 636.7 324.2 652.3 285.2 673.8T246.1 709V753.9H753.9V709Q753.9 695.3 714.8 673.8T621.1 636.7Q558.6 621.1 500 621.1Z",
-        "width": 1000
-      },
-      "search": [
-        "account-outline"
-      ]
-    },
     {
       "uid": "571120b7ff63feb71df85710d019302c",
       "css": "account-plus",
@@ -5494,20 +5556,6 @@
         "code-array"
       ]
     },
-    {
-      "uid": "7fced23a2d76846589ebc2b409bf16e8",
-      "css": "code-braces",
-      "code": 983401,
-      "src": "custom_icons",
-      "selected": false,
-      "svg": {
-        "path": "M334 125Q298.8 125 274.4 149.4T250 209V375Q250 410.2 225.6 434.6T166 459H125V541H166Q201.2 541 225.6 565.4T250 625V791Q250 826.2 274.4 850.6T334 875H416V791H334V584Q334 548.8 309.6 524.4T250 500Q285.2 500 309.6 475.6T334 416V209H416V125H334ZM666 125Q701.2 125 725.6 149.4T750 209V375Q750 410.2 774.4 434.6T834 459H875V541H834Q798.8 541 774.4 565.4T750 625V791Q750 826.2 725.6 850.6T666 875H584V791H666V584Q666 548.8 690.4 524.4T750 500Q714.8 500 690.4 475.6T666 416V209H584V125H666Z",
-        "width": 1000
-      },
-      "search": [
-        "code-braces"
-      ]
-    },
     {
       "uid": "a14000e64052e529dac940c9be1dc118",
       "css": "code-brackets",
@@ -17478,20 +17526,6 @@
         "speaker-off"
       ]
     },
-    {
-      "uid": "fcb7bfb12b7533c7026762bfc328ca1c",
-      "css": "speedometer",
-      "code": 984261,
-      "src": "custom_icons",
-      "selected": false,
-      "svg": {
-        "path": "M500 666Q447.3 666 411.1 629.9T375 541Q375 507.8 391.6 478.5T437.5 433.6L841.8 199.2 611.3 597.7Q595.7 628.9 565.4 647.5T500 666ZM500 125Q609.4 125 707 179.7L619.1 230.5Q562.5 209 500 209 410.2 209 333 253.9T210.9 375 166 541Q166 609.4 192.4 669.9T263.7 777.3V777.3Q277.3 789.1 277.3 806.6T264.6 835.9 234.4 847.7 205.1 835.9V835.9Q148.4 779.3 116.2 703.1T84 543 115.2 381.8 205.1 246.1 340.8 156.3 500 125ZM916 541Q916 627 883.8 703.1T794.9 835.9V835.9Q783.2 847.7 765.6 847.7T736.3 835.9 724.6 806.6 736.3 777.3V777.3Q781.3 730.5 807.6 669.9T834 541Q834 478.5 810.5 419.9L861.3 334Q916 433.6 916 541Z",
-        "width": 1000
-      },
-      "search": [
-        "speedometer"
-      ]
-    },
     {
       "uid": "d26bb53c36a567c6d3f5a87d8ce6accf",
       "css": "spellcheck",
@@ -21790,20 +21824,6 @@
         "lock-plus"
       ]
     },
-    {
-      "uid": "86efd4d8903ab613b84953efcef01406",
-      "css": "logout-variant",
-      "code": 984573,
-      "src": "custom_icons",
-      "selected": false,
-      "svg": {
-        "path": "M585.9 650.4L695.3 541H291V459H695.3L585.9 349.6 646.5 291 853.5 500 646.5 709ZM791 125Q826.2 125 850.6 149.4T875 209V402.3L791 320.3V209H209V791H791V679.7L875 597.7V791Q875 826.2 850.6 850.6T791 875H209Q173.8 875 149.4 850.6T125 791V209Q125 173.8 149.4 149.4T209 125H791Z",
-        "width": 1000
-      },
-      "search": [
-        "logout-variant"
-      ]
-    },
     {
       "uid": "90e8ca4d57b7f017c8a63b1dc2917046",
       "css": "music-note-bluetooth",
@@ -42748,20 +42768,6 @@
         "wrap-disabled"
       ]
     },
-    {
-      "uid": "10098901a143c53df6eeaeb317ae3da6",
-      "css": "wrench-outline",
-      "code": 986080,
-      "src": "custom_icons",
-      "selected": false,
-      "svg": {
-        "path": "M941.4 791L564.5 412.1Q593.8 337.9 578.1 258.8T503.9 121.1Q431.6 50.8 332 43T152.3 93.8L312.5 253.9 253.9 312.5 93.8 154.3Q35.2 234.4 42 334T121.1 503.9Q177.7 562.5 255.9 578.1T408.2 566.4L787.1 945.3Q798.8 959 816.4 959T845.7 945.3L941.4 849.6Q955.1 837.9 955.1 821.3T941.4 791ZM816.4 857.4L423.8 462.9Q384.8 492.2 340.8 498T253.9 491.2 180.7 447.3 136.7 378.9 125 302.7L253.9 431.6 429.7 253.9 300.8 125Q384.8 121.1 445.3 179.7 478.5 212.9 491.2 256.8T496.1 344.7 460.9 427.7L853.5 820.3Z",
-        "width": 1000
-      },
-      "search": [
-        "wrench-outline"
-      ]
-    },
     {
       "uid": "d670b0f395ba3a61b975a6387f8a2471",
       "css": "access-point-network-off",
@@ -74835,6 +74841,20 @@
       "search": [
         "set-split"
       ]
+    },
+    {
+      "uid": "b39a043bdb10d9d11ccecca6f17a07fe",
+      "css": "logout-variant",
+      "code": 64737,
+      "src": "custom_icons",
+      "selected": false,
+      "svg": {
+        "path": "M586.7 649.6L694.6 541.7H291.7V458.3H694.6L586.7 350.4 645.8 291.7 854.2 500 645.8 708.3 586.7 649.6M791.7 125C837.5 125 875 162.5 875 208.3V402.9L791.7 319.6V208.3H208.3V791.7H791.7V680.4L875 597.1V791.7C875 837.5 837.5 875 791.7 875H208.3C162.1 875 125 837.5 125 791.7V208.3C125 162.1 162.1 125 208.3 125H791.7Z",
+        "width": 1000
+      },
+      "search": [
+        "logout-variant"
+      ]
     }
   ]
 }
\ No newline at end of file
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 000000000..07d91a7aa
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,21 @@
+
+
+  
+    
+    
+    
+    
+    
+    
+    listmonk
+  
+  
+    
+
+    
+ + + + diff --git a/frontend/jsconfig.json b/frontend/jsconfig.json new file mode 100644 index 000000000..5a1f2d222 --- /dev/null +++ b/frontend/jsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "paths": { + "@/*": ["./src/*"] + } + }, + "exclude": ["node_modules", "dist"] +} diff --git a/frontend/package.json b/frontend/package.json index a5e58f6a6..308584efb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -3,46 +3,48 @@ "version": "0.1.0", "private": true, "scripts": { - "serve": "vue-cli-service serve", - "build": "vue-cli-service build", - "build-report": "vue-cli-service build --report", - "lint": "vue-cli-service lint" + "dev": "vite", + "build": "vite build", + "serve": "vite preview", + "lint": "eslint --ext .js,.vue --ignore-path .gitignore src", + "prebuild": "eslint --ext .js,.vue --ignore-path .gitignore src" }, "dependencies": { - "axios": "^0.21.1", - "buefy": "^0.9.7", - "c3": "^0.7.20", + "@tinymce/tinymce-vue": "^3", + "axios": "^1.7.4", + "buefy": "^0.9.25", + "bulma": "^0.9.4", + "chart.js": "^4.4.1", "codeflask": "^1.4.1", - "core-js": "^3.12.1", - "dayjs": "^1.10.4", - "humps": "^2.0.1", + "dayjs": "^1.11.10", + "js-beautify": "^1.15.1", + "prismjs": "^1.29.0", "qs": "^6.10.1", - "quill": "^1.3.7", - "quill-delta": "^4.2.2", "textversionjs": "^1.1.3", - "turndown": "^7.0.0", - "vue": "^2.6.12", - "vue-c3": "^1.2.11", - "vue-i18n": "^8.22.2", - "vue-quill-editor": "^3.0.6", + "tinymce": "^5.10.9", + "turndown": "^7.1.2", + "vue": "^2.7.14", + "vue-chartjs": "^5.3.0", + "vue-i18n": "^8.28.2", "vue-router": "^3.2.0", "vuex": "^3.6.2" }, "devDependencies": { - "@vue/cli-plugin-babel": "~4.5.13", - "@vue/cli-plugin-eslint": "~4.5.13", - "@vue/cli-plugin-router": "~4.5.13", - "@vue/cli-plugin-vuex": "~4.5.13", - "@vue/cli-service": "~4.5.13", - "@vue/eslint-config-airbnb": "^5.3.0", - "babel-eslint": "^10.1.0", - "cypress": "^6.4.0", + "@vitejs/plugin-vue2": "^2.3.1", + "@vue/eslint-config-airbnb": "^7.0.1", + "cypress": "13.15.0", "cypress-file-upload": "^5.0.2", - "eslint": "^7.27.0", + "eslint": "^8.56.0", + "eslint-define-config": "^2.0.0", "eslint-plugin-import": "^2.23.3", - "eslint-plugin-vue": "^7.9.0", + "eslint-plugin-vue": "^9.19.2", "sass": "^1.34.0", - "sass-loader": "^10.2.0", + "vite": "^5.4.12", + "vue-eslint-parser": "^9.3.2", "vue-template-compiler": "^2.6.12" - } + }, + "resolutions": { + "jackspeak": "2.1.1" + }, + "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } diff --git a/frontend/public/favicon.png b/frontend/public/favicon.png deleted file mode 100644 index 801000105..000000000 Binary files a/frontend/public/favicon.png and /dev/null differ diff --git a/frontend/public/index.html b/frontend/public/index.html deleted file mode 100644 index f0801d8df..000000000 --- a/frontend/public/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - <%= htmlWebpackPlugin.options.title %> - - - - -
- - diff --git a/frontend/public/static/favicon.png b/frontend/public/static/favicon.png new file mode 100644 index 000000000..0ca8f02b9 Binary files /dev/null and b/frontend/public/static/favicon.png differ diff --git a/frontend/public/static/tinymce/lang/cs.js b/frontend/public/static/tinymce/lang/cs.js new file mode 100644 index 000000000..1bc93dd76 --- /dev/null +++ b/frontend/public/static/tinymce/lang/cs.js @@ -0,0 +1,462 @@ +tinymce.addI18n('cs',{ +"Redo": "Opakovat", +"Undo": "Zp\u011bt", +"Cut": "Vyjmout", +"Copy": "Kop\u00edrovat", +"Paste": "Vlo\u017eit", +"Select all": "Vybrat v\u0161e", +"New document": "Nov\u00fd dokument", +"Ok": "OK", +"Cancel": "Storno", +"Visual aids": "Vizu\u00e1ln\u00ed pom\u016fcky", +"Bold": "Tu\u010dn\u00e9", +"Italic": "Kurz\u00edva", +"Underline": "Podtr\u017een\u00ed", +"Strikethrough": "P\u0159e\u0161krtnut\u00e9", +"Superscript": "Horn\u00ed index", +"Subscript": "Doln\u00ed index", +"Clear formatting": "Vymazat form\u00e1tov\u00e1n\u00ed", +"Align left": "Zarovnat doleva", +"Align center": "Zarovnat na st\u0159ed", +"Align right": "Zarovnat doprava", +"Justify": "Zarovnat do bloku", +"Bullet list": "Odr\u00e1\u017eky", +"Numbered list": "\u010c\u00edslov\u00e1n\u00ed", +"Decrease indent": "Zmen\u0161it odsazen\u00ed", +"Increase indent": "Zv\u011bt\u0161it odsazen\u00ed", +"Close": "Zav\u0159\u00edt", +"Formats": "Form\u00e1ty", +"Your browser doesn't support direct access to the clipboard. Please use the Ctrl+X\/C\/V keyboard shortcuts instead.": "V\u00e1\u0161 prohl\u00ed\u017ee\u010d nepodporuje p\u0159\u00edm\u00fd p\u0159\u00edstup do schr\u00e1nky. Pou\u017eijte pros\u00edm kl\u00e1vesov\u00e9 zkratky Ctrl+X\/C\/V.", +"Headers": "Nadpisy", +"Header 1": "Nadpis 1", +"Header 2": "Nadpis 2", +"Header 3": "Nadpis 3", +"Header 4": "Nadpis 4", +"Header 5": "Nadpis 5", +"Header 6": "Nadpis 6", +"Headings": "Nadpisy", +"Heading 1": "Nadpis 1", +"Heading 2": "Nadpis 2", +"Heading 3": "Nadpis 3", +"Heading 4": "Nadpis 4", +"Heading 5": "Nadpis 5", +"Heading 6": "Nadpis 6", +"Preformatted": "P\u0159edform\u00e1tovan\u00fd text", +"Div": "Div", +"Pre": "Pre", +"Code": "K\u00f3d", +"Paragraph": "Odstavec", +"Blockquote": "Blokov\u00e1 citace", +"Inline": "\u0158\u00e1dkov\u00e9 zobrazen\u00ed (inline)", +"Blocks": "Bloky", +"Paste is now in plain text mode. Contents will now be pasted as plain text until you toggle this option off.": "Je zapnuto vkl\u00e1d\u00e1n\u00ed \u010dist\u00e9ho textu. Dokud nebude tato volba vypnuta, bude ve\u0161ker\u00fd obsah vlo\u017een jako \u010dist\u00fd text.", +"Fonts": "P\u00edsma", +"Font Sizes": "Velikost p\u00edsma", +"Class": "T\u0159\u00edda", +"Browse for an image": "Vyhledat obr\u00e1zek", +"OR": "OR", +"Drop an image here": "P\u0159esu\u0148te obr\u00e1zek sem", +"Upload": "Nahr\u00e1t", +"Block": "Do bloku", +"Align": "Zarovnat", +"Default": "V\u00fdchoz\u00ed", +"Circle": "Kole\u010dko", +"Disc": "Punt\u00edk", +"Square": "\u010ctvere\u010dek", +"Lower Alpha": "Norm\u00e1ln\u00ed \u010d\u00edslov\u00e1n\u00ed", +"Lower Greek": "Mal\u00e9 p\u00edsmenkov\u00e1n\u00ed", +"Lower Roman": "Mal\u00e9 \u0159\u00edmsk\u00e9 \u010d\u00edslice", +"Upper Alpha": "velk\u00e9 p\u00edsmenkov\u00e1n\u00ed", +"Upper Roman": "\u0158\u00edmsk\u00e9 \u010d\u00edslice", +"Anchor...": "Kotva...", +"Name": "N\u00e1zev", +"Id": "Id", +"Id should start with a letter, followed only by letters, numbers, dashes, dots, colons or underscores.": "Id by m\u011blo za\u010d\u00ednat p\u00edsmenem a d\u00e1le obsahovat pouze p\u00edsmena, \u010d\u00edsla, poml\u010dky, te\u010dky, dvojte\u010dky, nebo podtr\u017e\u00edtka.", +"You have unsaved changes are you sure you want to navigate away?": "M\u00e1te neulo\u017een\u00e9 zm\u011bny. Opravdu chcete opustit str\u00e1nku?", +"Restore last draft": "Obnovit posledn\u00ed koncept", +"Special character...": "Speci\u00e1ln\u00ed znak\u2026", +"Source code": "Zdrojov\u00fd k\u00f3d", +"Insert\/Edit code sample": "Vlo\u017eit \/ Upravit uk\u00e1zkov\u00fd k\u00f3d", +"Language": "Jazyk", +"Code sample...": "Uk\u00e1zkov\u00fd k\u00f3d...", +"Color Picker": "V\u00fdb\u011br barvy", +"R": "R", +"G": "G", +"B": "B", +"Left to right": "Zleva doprava", +"Right to left": "Zprava doleva", +"Emoticons": "Emotikony", +"Emoticons...": "Emotikony...", +"Metadata and Document Properties": "Metadata a vlastnosti dokumentu", +"Title": "Titulek", +"Keywords": "Kl\u00ed\u010dov\u00e1 slova", +"Description": "Popis", +"Robots": "Roboti", +"Author": "Autor", +"Encoding": "K\u00f3dov\u00e1n\u00ed", +"Fullscreen": "Na celou obrazovku", +"Action": "Akce", +"Shortcut": "Kl\u00e1vesov\u00e1 zkratka", +"Help": "N\u00e1pov\u011bda", +"Address": "Blok s po\u0161tovn\u00ed adresou", +"Focus to menubar": "P\u0159ej\u00edt do menu", +"Focus to toolbar": "P\u0159ej\u00edt na panel n\u00e1stroj\u016f", +"Focus to element path": "P\u0159ej\u00edt na element path", +"Focus to contextual toolbar": "P\u0159ej\u00edt na kontextov\u00fd panel n\u00e1stroj\u016f", +"Insert link (if link plugin activated)": "Vlo\u017eit odkaz (pokud je aktivn\u00ed link plugin)", +"Save (if save plugin activated)": "Ulo\u017eit (pokud je aktivni save plugin)", +"Find (if searchreplace plugin activated)": "Hledat (pokud je aktivn\u00ed plugin searchreplace)", +"Plugins installed ({0}):": "Instalovan\u00e9 pluginy ({0}):", +"Premium plugins:": "Pr\u00e9miov\u00e9 pluginy:", +"Learn more...": "Zjistit v\u00edce...", +"You are using {0}": "Pou\u017e\u00edv\u00e1te {0}", +"Plugins": "Pluginy", +"Handy Shortcuts": "U\u017eite\u010dn\u00e9 kl\u00e1vesov\u00e9 zkratky", +"Horizontal line": "Vodorovn\u00e1 \u010d\u00e1ra", +"Insert\/edit image": "Vlo\u017eit \/ upravit obr\u00e1zek", +"Alternative description": "Alternativn\u00ed text", +"Accessibility": "Bez alternativn\u00edho textu", +"Image is decorative": "(dekorativn\u00ed obr\u00e1zek bez alternativn\u00edho textu)", +"Source": "URL", +"Dimensions": "Rozm\u011bry", +"Constrain proportions": "Zachovat proporce", +"General": "Obecn\u00e9", +"Advanced": "Pokro\u010dil\u00e9", +"Style": "Styl", +"Vertical space": "Vertik\u00e1ln\u00ed mezera", +"Horizontal space": "Horizont\u00e1ln\u00ed mezera", +"Border": "R\u00e1me\u010dek", +"Insert image": "Vlo\u017eit obr\u00e1zek", +"Image...": "Obr\u00e1zek\u2026", +"Image list": "Seznam obr\u00e1zk\u016f", +"Rotate counterclockwise": "Oto\u010dit doleva", +"Rotate clockwise": "Oto\u010dit doprava", +"Flip vertically": "P\u0159evr\u00e1tit svisle", +"Flip horizontally": "P\u0159evr\u00e1tit vodorovn\u011b", +"Edit image": "Upravit obr\u00e1zek", +"Image options": "Vlastnosti obr\u00e1zku", +"Zoom in": "P\u0159ibl\u00ed\u017eit", +"Zoom out": "Odd\u00e1lit", +"Crop": "O\u0159\u00edznout", +"Resize": "Zm\u011bnit velikost", +"Orientation": "Transformovat", +"Brightness": "Jas", +"Sharpen": "Ostrost", +"Contrast": "Kontrast", +"Color levels": "\u00darovn\u011b barev", +"Gamma": "Gama", +"Invert": "Invertovat", +"Apply": "Pou\u017e\u00edt", +"Back": "Zp\u011bt", +"Insert date\/time": "Vlo\u017eit datum \/ \u010das", +"Date\/time": "Datum\/\u010das", +"Insert\/edit link": "Vlo\u017eit \/ upravit odkaz", +"Text to display": "Text k zobrazen\u00ed", +"Url": "URL", +"Open link in...": "Otev\u0159\u00edt odkaz v...", +"Current window": "Aktu\u00e1ln\u00ed okno", +"None": "\u017d\u00e1dn\u00e9", +"New window": "Nov\u00e9 okno", +"Open link": "C\u00edlov\u00e9 okno URL", +"Remove link": "Odstranit odkaz", +"Anchors": "Kotvy", +"Link...": "Odkaz...", +"Paste or type a link": "Vlo\u017eit nebo napsat odkaz", +"The URL you entered seems to be an email address. Do you want to add the required mailto: prefix?": "Zadan\u00e9 URL vypad\u00e1 jako e-mailov\u00e1 adresa. Chcete doplnit povinn\u00fd prefix mailto:?", +"The URL you entered seems to be an external link. Do you want to add the required http:\/\/ prefix?": "Zadan\u00e9 URL vypad\u00e1 jako odkaz na jin\u00fd web. Chcete doplnit povinn\u00fd prefix http:\/\/?", +"The URL you entered seems to be an external link. Do you want to add the required https:\/\/ prefix?": "Zadan\u00e9 URL vypad\u00e1 jako odkaz na jin\u00fd web. Chcete doplnit povinn\u00fd prefix https:\/\/?", +"Link list": "Seznam odkaz\u016f", +"Insert video": "Vlo\u017eit video", +"Insert\/edit video": "Vlo\u017eit \/ upravit video", +"Insert\/edit media": "Vlo\u017eit \/ upravit m\u00e9dia", +"Alternative source": "Alternativn\u00ed zdroj", +"Alternative source URL": "Alternativn\u00ed zdrojov\u00e1 URL", +"Media poster (Image URL)": "Medi\u00e1ln\u00ed plak\u00e1t (URL obr\u00e1zku)", +"Paste your embed code below:": "Vlo\u017ete k\u00f3d pro vlo\u017een\u00ed n\u00ed\u017ee:", +"Embed": "Vlo\u017eit", +"Media...": "M\u00e9dium...", +"Nonbreaking space": "Pevn\u00e1 mezera", +"Page break": "Konec str\u00e1nky", +"Paste as text": "Vlo\u017eit jako \u010dist\u00fd text", +"Preview": "N\u00e1hled", +"Print...": "Tisk...", +"Save": "Ulo\u017eit", +"Find": "Naj\u00edt", +"Replace with": "Nahradit za", +"Replace": "Nahradit", +"Replace all": "Nahradit v\u0161e", +"Previous": "P\u0159edchoz\u00ed", +"Next": "Dal\u0161\u00ed", +"Find and Replace": "Naj\u00edt a nahradit", +"Find and replace...": "Naj\u00edt a nahradit...", +"Could not find the specified string.": "Zadan\u00fd \u0159et\u011bzec nebyl nalezen.", +"Match case": "Rozli\u0161ovat mal\u00e1 a velk\u00e1 p\u00edsmena", +"Find whole words only": "Hledat pouze cel\u00e1 slova", +"Find in selection": "Ozna\u010den\u00fd text", +"Spellcheck": "Kontrola pravopisu", +"Spellcheck Language": "Jazyk kontroly pravopisu", +"No misspellings found.": "Nebyly nalezeny \u017e\u00e1dn\u00e9 p\u0159eklepy.", +"Ignore": "Ignorovat", +"Ignore all": "Ignorovat v\u0161e", +"Finish": "Ukon\u010dit", +"Add to Dictionary": "P\u0159idat do slovn\u00edku", +"Insert table": "Vlo\u017eit tabulku", +"Table properties": "Vlastnosti tabulky", +"Delete table": "Smazat tabulku", +"Cell": "Bu\u0148ka", +"Row": "\u0158\u00e1dek", +"Column": "Sloupec", +"Cell properties": "Vlastnosti bu\u0148ky", +"Merge cells": "Slou\u010dit bu\u0148ky", +"Split cell": "Rozd\u011blit bu\u0148ky", +"Insert row before": "Vlo\u017eit \u0159\u00e1dek nad", +"Insert row after": "Vlo\u017eit \u0159\u00e1dek pod", +"Delete row": "Smazat \u0159\u00e1dek", +"Row properties": "Vlastnosti \u0159\u00e1dku", +"Cut row": "Vyjmout \u0159\u00e1dek", +"Copy row": "Kop\u00edrovat \u0159\u00e1dek", +"Paste row before": "Vlo\u017eit \u0159\u00e1dek nad", +"Paste row after": "Vlo\u017eit \u0159\u00e1dek pod", +"Insert column before": "Vlo\u017eit sloupec vlevo", +"Insert column after": "Vlo\u017eit sloupec vpravo", +"Delete column": "Smazat sloupec", +"Cols": "Sloupc\u016f", +"Rows": "\u0158\u00e1dek", +"Width": "\u0160\u00ed\u0159ka", +"Height": "V\u00fd\u0161ka", +"Cell spacing": "Vn\u011bj\u0161\u00ed okraj bun\u011bk", +"Cell padding": "Vnit\u0159n\u00ed okraj bun\u011bk", +"Caption": "Nadpis", +"Show caption": "Zobrazit titulku", +"Left": "Vlevo", +"Center": "Na st\u0159ed", +"Right": "Vpravo", +"Cell type": "Typ bu\u0148ky", +"Scope": "Rozsah", +"Alignment": "Zarovn\u00e1n\u00ed", +"H Align": "Horizont\u00e1ln\u00ed zarovn\u00e1n\u00ed", +"V Align": "Vertik\u00e1ln\u00ed zarovn\u00e1n\u00ed", +"Top": "Nahoru", +"Middle": "Uprost\u0159ed", +"Bottom": "Dol\u016f", +"Header cell": "Hlavi\u010dkov\u00e1 bu\u0148ka", +"Row group": "Skupina \u0159\u00e1dk\u016f", +"Column group": "Skupina sloupc\u016f", +"Row type": "Typ \u0159\u00e1dku", +"Header": "Hlavi\u010dka", +"Body": "T\u011blo", +"Footer": "Pati\u010dka", +"Border color": "Barva r\u00e1me\u010dku", +"Insert template...": "Vlo\u017eit \u0161ablonu...", +"Templates": "\u0160ablony", +"Template": "\u0160ablona", +"Text color": "Barva p\u00edsma", +"Background color": "Barva pozad\u00ed", +"Custom...": "Vlastn\u00ed...", +"Custom color": "Vlastn\u00ed barva", +"No color": "Bez barvy", +"Remove color": "Odebrat barvu", +"Table of Contents": "Obsah", +"Show blocks": "Uk\u00e1zat bloky", +"Show invisible characters": "Zobrazit speci\u00e1ln\u00ed znaky", +"Word count": "Po\u010det slov", +"Count": "Po\u010det", +"Document": "Dokument", +"Selection": "V\u00fdb\u011br", +"Words": "Slova", +"Words: {0}": "Po\u010det slov: {0}", +"{0} words": "Po\u010det slov: {0}", +"File": "Soubor", +"Edit": "\u00dapravy", +"Insert": "Vlo\u017eit", +"View": "Zobrazit", +"Format": "Form\u00e1t", +"Table": "Tabulka", +"Tools": "N\u00e1stroje", +"Powered by {0}": "Vytvo\u0159il {0}", +"Rich Text Area. Press ALT-F9 for menu. Press ALT-F10 for toolbar. Press ALT-0 for help": "Editor. Stiskn\u011bte ALT-F9 pro menu, ALT-F10 pro n\u00e1strojovou li\u0161tu a ALT-0 pro n\u00e1pov\u011bdu.", +"Image title": "N\u00e1zev obr\u00e1zku", +"Border width": "\u0160\u00ed\u0159ka ohrani\u010den\u00ed", +"Border style": "Styl ohrani\u010den\u00ed", +"Error": "Chyba", +"Warn": "Varov\u00e1n\u00ed", +"Valid": "Platn\u00fd", +"To open the popup, press Shift+Enter": "Vyskakovac\u00ed okno otev\u0159ete stisknut\u00edm Shift+Enter", +"Rich Text Area. Press ALT-0 for help.": "Oblast Rich Text, stiskn\u011bte ALT-0 pro n\u00e1pov\u011bdu.", +"System Font": "Syst\u00e9mov\u00e9 p\u00edsmo", +"Failed to upload image: {0}": "Nahr\u00e1n\u00ed obr\u00e1zku selhalo:", +"Failed to load plugin: {0} from url {1}": "Na\u010dten\u00ed z\u00e1suvn\u00e9ho modulu selhalo: {0} z URL {1}", +"Failed to load plugin url: {0}": "Na\u010dten\u00ed z\u00e1suvn\u00e9ho modulu URL selhalo: {0}", +"Failed to initialize plugin: {0}": "Inicializace z\u00e1suvn\u00e9ho modulu selhala:", +"example": "p\u0159\u00edklad", +"Search": "Hledat", +"All": "V\u0161e", +"Currency": "M\u011bna", +"Text": "Text", +"Quotations": "Citace", +"Mathematical": "Matematick\u00e9 symboly", +"Extended Latin": "Roz\u0161\u00ed\u0159en\u00e9 Latin", +"Symbols": "Symboly", +"Arrows": "\u0160ipky", +"User Defined": "Definovan\u00e9 u\u017eivatelem", +"dollar sign": "znak dolaru", +"currency sign": "znak m\u011bny", +"euro-currency sign": "znak m\u011bny euro", +"colon sign": "znak dvojte\u010dky", +"cruzeiro sign": "znak cruzeiro", +"french franc sign": "znak francouzsk\u00e9ho franku", +"lira sign": "znak liry", +"mill sign": "znak mill", +"naira sign": "znak nairy", +"peseta sign": "znak pesety", +"rupee sign": "znak rupie", +"won sign": "znak wonu", +"new sheqel sign": "znak nov\u00e9ho \u0161ekelu", +"dong sign": "znak dongu", +"kip sign": "znak kipu", +"tugrik sign": "znak tugriku", +"drachma sign": "znak drachmy", +"german penny symbol": "znak n\u011bmeck\u00e9ho feniku", +"peso sign": "znak pesa", +"guarani sign": "znak guaran\u00ed", +"austral sign": "znak austral", +"hryvnia sign": "znak h\u0159ivny", +"cedi sign": "znak cedi", +"livre tournois sign": "znak tournois libry", +"spesmilo sign": "znak spesmilo", +"tenge sign": "znak tenge", +"indian rupee sign": "znak indick\u00e9 rupie", +"turkish lira sign": "znak tureck\u00e9 liry", +"nordic mark sign": "znak norsk\u00e9 marky", +"manat sign": "znak manatu", +"ruble sign": "znak rublu", +"yen character": "znak jenu", +"yuan character": "znak juanu", +"yuan character, in hong kong and taiwan": "znak juanu v hongkongu a tchaj-wanu", +"yen\/yuan character variant one": "znak jenu\/juanu, varianta 1", +"Loading emoticons...": "Na\u010d\u00edt\u00e1n\u00ed emotikon\u016f...", +"Could not load emoticons": "Nelze na\u010d\u00edst emotikony", +"People": "Lid\u00e9", +"Animals and Nature": "Zv\u00ed\u0159ata a p\u0159\u00edroda", +"Food and Drink": "J\u00eddlo a pit\u00ed", +"Activity": "Aktivita", +"Travel and Places": "Cestov\u00e1n\u00ed a m\u00edsta", +"Objects": "Objekty", +"Flags": "Vlajky", +"Characters": "Znaky", +"Characters (no spaces)": "Znaky (bez mezer)", +"{0} characters": "{0} znak\u016f", +"Error: Form submit field collision.": "Chyba: Kolize odes\u00edlac\u00edho formul\u00e1\u0159ov\u00e9ho pole.", +"Error: No form element found.": "Chyba: Nebyl nalezen \u017e\u00e1dn\u00fd prvek formul\u00e1\u0159e.", +"Update": "Aktualizovat", +"Color swatch": "Vzorek barvy", +"Turquoise": "Tyrkysov\u00e1", +"Green": "Zelen\u00e1", +"Blue": "Modr\u00e1", +"Purple": "Fialov\u00e1", +"Navy Blue": "N\u00e1mo\u0159nick\u00e1 mod\u0159", +"Dark Turquoise": "Tmav\u011b tyrkysov\u00e1", +"Dark Green": "Tmav\u011b zelen\u00e1", +"Medium Blue": "St\u0159edn\u011b modr\u00e1", +"Medium Purple": "St\u0159edn\u011b fialov\u00e1", +"Midnight Blue": "P\u016flno\u010dn\u00ed modr\u00e1", +"Yellow": "\u017dlut\u00e1", +"Orange": "Oran\u017eov\u00e1", +"Red": "\u010cerven\u00e1", +"Light Gray": "Sv\u011btle \u0161ed\u00e1", +"Gray": "\u0160ed\u00e1", +"Dark Yellow": "Tmav\u011b \u017elut\u00e1", +"Dark Orange": "Tmav\u011b oran\u017eov\u00e1", +"Dark Red": "Tmav\u011b \u010derven\u00e1", +"Medium Gray": "St\u0159edn\u011b \u0161ed\u00e1", +"Dark Gray": "Tmav\u011b \u0161ed\u00e1", +"Light Green": "Sv\u011btle zelen\u00e1", +"Light Yellow": "Sv\u011btle \u017elut\u00e1", +"Light Red": "Sv\u011btle \u010derven\u00e1", +"Light Purple": "Sv\u011btle fialov\u00e1", +"Light Blue": "Sv\u011btle modr\u00e1", +"Dark Purple": "Tmav\u011b fialov\u00e1", +"Dark Blue": "Tmav\u011b modr\u00e1", +"Black": "\u010cern\u00e1", +"White": "B\u00edl\u00e1", +"Switch to or from fullscreen mode": "P\u0159ep\u00edn\u00e1n\u00ed mezi re\u017eimem cel\u00e9 obrazovky", +"Open help dialog": "Otev\u0159\u00edt okno n\u00e1pov\u011bdy", +"history": "historie", +"styles": "styly", +"formatting": "form\u00e1tov\u00e1n\u00ed", +"alignment": "zarovn\u00e1n\u00ed", +"indentation": "odsazen\u00ed", +"Font": "P\u00edsmo", +"Size": "Velikost", +"More...": "Dal\u0161\u00ed\u2026", +"Select...": "Vybrat\u2026", +"Preferences": "P\u0159edvolby", +"Yes": "Ano", +"No": "Ne", +"Keyboard Navigation": "Navigace pomoc\u00ed kl\u00e1vesnice", +"Version": "Verze", +"Code view": "Zobrazit k\u00f3d", +"Open popup menu for split buttons": "Otev\u0159ete vyskakovac\u00ed nab\u00eddku pro rozd\u011blen\u00e1 tla\u010d\u00edtka", +"List Properties": "Vlastnosti seznamu", +"List properties...": "Vlastnosti seznamu...", +"Start list at number": "Po\u010d\u00e1te\u010dn\u00ed \u010d\u00edslo seznamu", +"Line height": "V\u00fd\u0161ka \u0159\u00e1dku", +"comments": "koment\u00e1\u0159e", +"Format Painter": "Kop\u00edrovat form\u00e1t", +"Insert\/edit iframe": "Vlo\u017eit\/upravit prvek iframe", +"Capitalization": "Velk\u00e1 p\u00edsmena", +"lowercase": "mal\u00e1 p\u00edsmena", +"UPPERCASE": "VELK\u00c1 P\u00cdSMENA", +"Title Case": "V\u0161echna Prvn\u00ed Velk\u00e1", +"permanent pen": "permanentn\u00ed pero", +"Permanent Pen Properties": "Vlastnosti permanentn\u00edho pera", +"Permanent pen properties...": "Vlastnosti permanentn\u00edho pera\u2026", +"case change": "Zm\u011bna velikosti p\u00edsmen", +"page embed": "Vlo\u017eit str\u00e1nku", +"Advanced sort...": "Roz\u0161\u00ed\u0159en\u00e9 \u0159azen\u00ed...", +"Advanced Sort": "Roz\u0161\u00ed\u0159en\u00e9 \u0159azen\u00ed", +"Sort table by column ascending": "Se\u0159adit tabulku podle sloupce vzestupn\u011b", +"Sort table by column descending": "Se\u0159adit tabulku podle sloupce sestupn\u011b", +"Sort": "\u0158adit", +"Order": "\u0158azen\u00ed", +"Sort by": "\u0158adit dle", +"Ascending": "Vzestupn\u011b", +"Descending": "Sestupn\u011b", +"Column {0}": "Sloupec {0}", +"Row {0}": "\u0158\u00e1dek {0}", +"Spellcheck...": "Kontrola pravopisu", +"Misspelled word": "\u0160patn\u011b napsan\u00e9 slovo", +"Suggestions": "N\u00e1vrhy", +"Change": "Zm\u011bnit", +"Finding word suggestions": "Hled\u00e1n\u00ed n\u00e1vrh\u016f slov", +"Success": "\u00dasp\u011b\u0161n\u00e9", +"Repair": "Opraveno", +"Issue {0} of {1}": "Probl\u00e9m {0} z {1}", +"Images must be marked as decorative or have an alternative text description": "Obr\u00e1zky mus\u00ed b\u00fdt ozna\u010deny jako dekorativn\u00ed nebo mus\u00ed m\u00edt alternativn\u00ed textov\u00fd popis.", +"Images must have an alternative text description. Decorative images are not allowed.": "Obr\u00e1zky mus\u00ed m\u00edt alternativn\u00ed textov\u00fd popis. Dekorativn\u00ed obr\u00e1zky nejsou povoleny.", +"Or provide alternative text:": "Nebo zadejte alternativn\u00ed text:", +"Make image decorative:": "Nastavit obr\u00e1zek jako dekorativn\u00ed:", +"ID attribute must be unique": "ID atributu mus\u00ed b\u00fdt jedine\u010dn\u00e9", +"Make ID unique": "Nastavit ID jako jedine\u010dn\u00e9", +"Keep this ID and remove all others": "Ponechat toto ID a odstranit v\u0161echny ostatn\u00ed", +"Remove this ID": "Odebrat toto ID", +"Remove all IDs": "Odebrat v\u0161echna ID", +"Checklist": "Kontroln\u00ed seznam", +"Anchor": "Kotva", +"Special character": "Speci\u00e1ln\u00ed znak", +"Code sample": "Uk\u00e1zkov\u00fd k\u00f3d", +"Color": "Barva", +"Document properties": "Vlastnosti dokumentu", +"Image description": "Popis obr\u00e1zku", +"Image": "Obr\u00e1zek", +"Insert link": "Vlo\u017eit odkaz", +"Target": "C\u00edl", +"Link": "Odkaz", +"Poster": "N\u00e1hled", +"Media": "M\u00e9dia", +"Print": "Tisk", +"Prev": "P\u0159edchoz\u00ed", +"Find and replace": "Naj\u00edt a nahradit", +"Whole words": "Pouze cel\u00e1 slova", +"Insert template": "Vlo\u017eit \u0161ablonu" +}); \ No newline at end of file diff --git a/frontend/public/static/tinymce/lang/de.js b/frontend/public/static/tinymce/lang/de.js new file mode 100644 index 000000000..def1e0bb1 --- /dev/null +++ b/frontend/public/static/tinymce/lang/de.js @@ -0,0 +1,462 @@ +tinymce.addI18n('de',{ +"Redo": "Wiederholen", +"Undo": "R\u00fcckg\u00e4ngig machen", +"Cut": "Ausschneiden", +"Copy": "Kopieren", +"Paste": "Einf\u00fcgen", +"Select all": "Alles ausw\u00e4hlen", +"New document": "Neues Dokument", +"Ok": "Ok", +"Cancel": "Abbrechen", +"Visual aids": "Visuelle Hilfen", +"Bold": "Fett", +"Italic": "Kursiv", +"Underline": "Unterstrichen", +"Strikethrough": "Durchgestrichen", +"Superscript": "Hochgestellt", +"Subscript": "Tiefgestellt", +"Clear formatting": "Formatierung entfernen", +"Align left": "Linksb\u00fcndig ausrichten", +"Align center": "Zentrieren", +"Align right": "Rechtsb\u00fcndig ausrichten", +"Justify": "Blocksatz", +"Bullet list": "Aufz\u00e4hlung", +"Numbered list": "Nummerierte Liste", +"Decrease indent": "Einzug verkleinern", +"Increase indent": "Einzug vergr\u00f6\u00dfern", +"Close": "Schlie\u00dfen", +"Formats": "Formate", +"Your browser doesn't support direct access to the clipboard. Please use the Ctrl+X\/C\/V keyboard shortcuts instead.": "Ihr Browser unterst\u00fctzt leider keinen direkten Zugriff auf die Zwischenablage. Bitte benutzen Sie die Tastenkombinationen Strg+X\/C\/V.", +"Headers": "\u00dcberschriften", +"Header 1": "\u00dcberschrift 1", +"Header 2": "\u00dcberschrift 2", +"Header 3": "\u00dcberschrift 3", +"Header 4": "\u00dcberschrift 4", +"Header 5": "\u00dcberschrift 5", +"Header 6": "\u00dcberschrift 6", +"Headings": "\u00dcberschriften", +"Heading 1": "Kopfzeile 1", +"Heading 2": "Kopfzeile 2", +"Heading 3": "Kopfzeile 3", +"Heading 4": "Kopfzeile 4", +"Heading 5": "Kopfzeile 5", +"Heading 6": "Kopfzeile 6", +"Preformatted": "Vorformatiert", +"Div": "Div", +"Pre": "Pre", +"Code": "Code", +"Paragraph": "Absatz", +"Blockquote": "Blockquote", +"Inline": "Zeichenformate", +"Blocks": "Bl\u00f6cke", +"Paste is now in plain text mode. Contents will now be pasted as plain text until you toggle this option off.": "Einf\u00fcgen ist nun im einfachen Textmodus. Inhalte werden ab jetzt als unformatierter Text eingef\u00fcgt, bis Sie diese Einstellung wieder ausschalten!", +"Fonts": "Schriftarten", +"Font Sizes": "Schriftgr\u00f6\u00dfe", +"Class": "Klasse", +"Browse for an image": "Bild...", +"OR": "ODER", +"Drop an image here": "Bild hier ablegen", +"Upload": "Hochladen", +"Block": "Blocksatz", +"Align": "Ausrichten", +"Default": "Standard", +"Circle": "Kreis", +"Disc": "Punkt", +"Square": "Quadrat", +"Lower Alpha": "Kleinbuchstaben", +"Lower Greek": "Griechische Kleinbuchstaben", +"Lower Roman": "R\u00f6mische Zahlen (Kleinbuchstaben)", +"Upper Alpha": "Gro\u00dfbuchstaben", +"Upper Roman": "R\u00f6mische Zahlen (Gro\u00dfbuchstaben)", +"Anchor...": "Textmarke", +"Name": "Name", +"Id": "Kennung", +"Id should start with a letter, followed only by letters, numbers, dashes, dots, colons or underscores.": "Die Kennung sollte mit einem Buchstaben anfangen. Nachfolgend nur Buchstaben, Zahlen, Striche (Minus), Punkte, Kommas und Unterstriche.", +"You have unsaved changes are you sure you want to navigate away?": "Die \u00c4nderungen wurden noch nicht gespeichert, sind Sie sicher, dass Sie diese Seite verlassen wollen?", +"Restore last draft": "Letzten Entwurf wiederherstellen", +"Special character...": "Sonderzeichen...", +"Source code": "Quelltext", +"Insert\/Edit code sample": "Codebeispiel einf\u00fcgen\/bearbeiten", +"Language": "Sprache", +"Code sample...": "Codebeispiel...", +"Color Picker": "Farbwahl", +"R": "R", +"G": "G", +"B": "B", +"Left to right": "Von links nach rechts", +"Right to left": "Von rechts nach links", +"Emoticons": "Emoticons", +"Emoticons...": "Emoticons...", +"Metadata and Document Properties": "Dokument-Eigenschaften und -Metadaten", +"Title": "Titel", +"Keywords": "Sch\u00fcsselw\u00f6rter", +"Description": "Beschreibung", +"Robots": "Robots", +"Author": "Verfasser", +"Encoding": "Zeichenkodierung", +"Fullscreen": "Vollbild", +"Action": "Aktion", +"Shortcut": "Shortcut", +"Help": "Hilfe", +"Address": "Adresse", +"Focus to menubar": "Fokus auf Men\u00fcleiste", +"Focus to toolbar": "Fokus auf Werkzeugleiste", +"Focus to element path": "Fokus auf Elementpfad", +"Focus to contextual toolbar": "Fokus auf kontextbezogene Werkzeugleiste", +"Insert link (if link plugin activated)": "Link einf\u00fcgen (wenn Link-Plugin aktiviert ist)", +"Save (if save plugin activated)": "Speichern (wenn Save-Plugin aktiviert ist)", +"Find (if searchreplace plugin activated)": "Suchen einf\u00fcgen (wenn Suchen\/Ersetzen-Plugin aktiviert ist)", +"Plugins installed ({0}):": "installierte Plugins ({0}):", +"Premium plugins:": "Premium Plugins:", +"Learn more...": "Erfahren Sie mehr dazu...", +"You are using {0}": "Sie verwenden {0}", +"Plugins": "Plugins", +"Handy Shortcuts": "Praktische Tastenkombinationen", +"Horizontal line": "Horizontale Linie", +"Insert\/edit image": "Bild einf\u00fcgen\/bearbeiten", +"Alternative description": "Alternative Beschreibung", +"Accessibility": "Barrierefreiheit", +"Image is decorative": "Bild ist dekorativ", +"Source": "Quelle", +"Dimensions": "Abmessungen", +"Constrain proportions": "Seitenverh\u00e4ltnis beibehalten", +"General": "Allgemein", +"Advanced": "Erweitert", +"Style": "Stil", +"Vertical space": "Vertikaler Abstand", +"Horizontal space": "Horizontaler Abstand", +"Border": "Rahmen", +"Insert image": "Bild einf\u00fcgen", +"Image...": "Bild...", +"Image list": "Bildliste", +"Rotate counterclockwise": "Gegen den Uhrzeigersinn drehen", +"Rotate clockwise": "Im Uhrzeigersinn drehen", +"Flip vertically": "Vertikal spiegeln", +"Flip horizontally": "Horizontal spiegeln", +"Edit image": "Bild bearbeiten", +"Image options": "Bildeigenschaften", +"Zoom in": "Ansicht vergr\u00f6\u00dfern", +"Zoom out": "Ansicht verkleinern", +"Crop": "Bescheiden", +"Resize": "Skalieren", +"Orientation": "Ausrichtung", +"Brightness": "Helligkeit", +"Sharpen": "Sch\u00e4rfen", +"Contrast": "Kontrast", +"Color levels": "Farbwerte", +"Gamma": "Gamma", +"Invert": "Invertieren", +"Apply": "Anwenden", +"Back": "Zur\u00fcck", +"Insert date\/time": "Datum\/Uhrzeit einf\u00fcgen ", +"Date\/time": "Datum\/Uhrzeit", +"Insert\/edit link": "Link einf\u00fcgen\/bearbeiten", +"Text to display": "Anzuzeigender Text", +"Url": "URL", +"Open link in...": "Link \u00f6ffnen in...", +"Current window": "Aktuelles Fenster", +"None": "Keine", +"New window": "Neues Fenster", +"Open link": "Link \u00f6ffnen", +"Remove link": "Link entfernen", +"Anchors": "Textmarken", +"Link...": "Link...", +"Paste or type a link": "Link einf\u00fcgen oder eintippen", +"The URL you entered seems to be an email address. Do you want to add the required mailto: prefix?": "Diese Adresse scheint eine E-Mail-Adresse zu sein. M\u00f6chten Sie das dazu ben\u00f6tigte \"mailto:\" voranstellen?", +"The URL you entered seems to be an external link. Do you want to add the required http:\/\/ prefix?": "Diese Adresse scheint ein externer Link zu sein. M\u00f6chten Sie das dazu ben\u00f6tigte \"http:\/\/\" voranstellen?", +"The URL you entered seems to be an external link. Do you want to add the required https:\/\/ prefix?": "Die eingegebene URL scheint ein externer Link zu sein. Soll das fehlende https:\/\/ davor erg\u00e4nzt werden?", +"Link list": "Linkliste", +"Insert video": "Video einf\u00fcgen", +"Insert\/edit video": "Video einf\u00fcgen\/bearbeiten", +"Insert\/edit media": "Medien einf\u00fcgen\/bearbeiten", +"Alternative source": "Alternative Quelle", +"Alternative source URL": "URL der alternativen Quelle", +"Media poster (Image URL)": "Medienposter (Bild-URL)", +"Paste your embed code below:": "F\u00fcgen Sie Ihren Einbettungscode hier ein:", +"Embed": "Einbetten", +"Media...": "Medien...", +"Nonbreaking space": "Gesch\u00fctztes Leerzeichen", +"Page break": "Seitenumbruch", +"Paste as text": "Als Text einf\u00fcgen", +"Preview": "Vorschau", +"Print...": "Drucken...", +"Save": "Speichern", +"Find": "Suchen", +"Replace with": "Ersetzen durch", +"Replace": "Ersetzen", +"Replace all": "Alles ersetzen", +"Previous": "Vorherige", +"Next": "Weiter", +"Find and Replace": "Suchen und Ersetzen", +"Find and replace...": "Suchen und ersetzen...", +"Could not find the specified string.": "Die Zeichenfolge wurde nicht gefunden.", +"Match case": "Gro\u00df-\/Kleinschreibung beachten", +"Find whole words only": "Nur ganze W\u00f6rter suchen", +"Find in selection": "In Auswahl suchen", +"Spellcheck": "Rechtschreibpr\u00fcfung", +"Spellcheck Language": "Sprache f\u00fcr die Rechtschreibpr\u00fcfung", +"No misspellings found.": "Keine Rechtschreibfehler gefunden", +"Ignore": "Ignorieren", +"Ignore all": "Alles Ignorieren", +"Finish": "Ende", +"Add to Dictionary": "Zum W\u00f6rterbuch hinzuf\u00fcgen", +"Insert table": "Tabelle einf\u00fcgen", +"Table properties": "Tabelleneigenschaften", +"Delete table": "Tabelle l\u00f6schen", +"Cell": "Zelle", +"Row": "Zeile", +"Column": "Spalte", +"Cell properties": "Zelleneigenschaften", +"Merge cells": "Zellen verbinden", +"Split cell": "Zelle aufteilen", +"Insert row before": "Neue Zeile davor einf\u00fcgen ", +"Insert row after": "Neue Zeile danach einf\u00fcgen", +"Delete row": "Zeile l\u00f6schen", +"Row properties": "Zeileneigenschaften", +"Cut row": "Zeile ausschneiden", +"Copy row": "Zeile kopieren", +"Paste row before": "Zeile davor einf\u00fcgen", +"Paste row after": "Zeile danach einf\u00fcgen", +"Insert column before": "Neue Spalte davor einf\u00fcgen", +"Insert column after": "Neue Spalte danach einf\u00fcgen", +"Delete column": "Spalte l\u00f6schen", +"Cols": "Spalten", +"Rows": "Zeilen", +"Width": "Breite", +"Height": "H\u00f6he", +"Cell spacing": "Zellenabstand", +"Cell padding": "Zelleninnenabstand", +"Caption": "Beschriftung", +"Show caption": "Beschriftung anzeigen", +"Left": "Linksb\u00fcndig", +"Center": "Zentriert", +"Right": "Rechtsb\u00fcndig", +"Cell type": "Zellentyp", +"Scope": "G\u00fcltigkeitsbereich", +"Alignment": "Ausrichtung", +"H Align": "Horizontale Ausrichtung", +"V Align": "Vertikale Ausrichtung", +"Top": "Oben", +"Middle": "Mitte", +"Bottom": "Unten", +"Header cell": "Kopfzelle", +"Row group": "Zeilengruppe", +"Column group": "Spaltengruppe", +"Row type": "Zeilentyp", +"Header": "Kopfzeile", +"Body": "Inhalt", +"Footer": "Fu\u00dfzeile", +"Border color": "Rahmenfarbe", +"Insert template...": "Vorlage einf\u00fcgen...", +"Templates": "Vorlagen", +"Template": "Vorlage", +"Text color": "Textfarbe", +"Background color": "Hintergrundfarbe", +"Custom...": "Benutzerdefiniert...", +"Custom color": "Benutzerdefinierte Farbe", +"No color": "Keine Farbe", +"Remove color": "Farbauswahl aufheben", +"Table of Contents": "Inhaltsverzeichnis", +"Show blocks": "Bl\u00f6cke anzeigen", +"Show invisible characters": "Unsichtbare Zeichen anzeigen", +"Word count": "Anzahl der W\u00f6rter", +"Count": "Anzahl", +"Document": "Dokument", +"Selection": "Auswahl", +"Words": "W\u00f6rter", +"Words: {0}": "W\u00f6rter: {0}", +"{0} words": "{0} W\u00f6rter", +"File": "Datei", +"Edit": "Bearbeiten", +"Insert": "Einf\u00fcgen", +"View": "Ansicht", +"Format": "Format", +"Table": "Tabelle", +"Tools": "Werkzeuge", +"Powered by {0}": "Betrieben von {0}", +"Rich Text Area. Press ALT-F9 for menu. Press ALT-F10 for toolbar. Press ALT-0 for help": "Rich-Text- Area. Dr\u00fccken Sie ALT-F9 f\u00fcr das Men\u00fc. Dr\u00fccken Sie ALT-F10 f\u00fcr Symbolleiste. Dr\u00fccken Sie ALT-0 f\u00fcr Hilfe", +"Image title": "Bildtitel", +"Border width": "Rahmenbreite", +"Border style": "Rahmenstil", +"Error": "Fehler", +"Warn": "Warnung", +"Valid": "G\u00fcltig", +"To open the popup, press Shift+Enter": "Dr\u00fccken Sie Umschalt+Eingabe, um das Popup-Fenster zu \u00f6ffnen.", +"Rich Text Area. Press ALT-0 for help.": "Rich-Text-Bereich. Dr\u00fccken Sie Alt+0 f\u00fcr Hilfe.", +"System Font": "Betriebssystemschriftart", +"Failed to upload image: {0}": "Bild konnte nicht hochgeladen werden: {0}", +"Failed to load plugin: {0} from url {1}": "Plugin konnte nicht geladen werden: {0} von URL {1}", +"Failed to load plugin url: {0}": "Plugin-URL konnte nicht geladen werden: {0}", +"Failed to initialize plugin: {0}": "Plugin konnte nicht initialisiert werden: {0}", +"example": "Beispiel", +"Search": "Suchen", +"All": "Alles", +"Currency": "W\u00e4hrung", +"Text": "Text", +"Quotations": "Anf\u00fchrungszeichen", +"Mathematical": "Mathematisch", +"Extended Latin": "Erweitertes Latein", +"Symbols": "Symbole", +"Arrows": "Pfeile", +"User Defined": "Benutzerdefiniert", +"dollar sign": "Dollarzeichen", +"currency sign": "W\u00e4hrungssymbol", +"euro-currency sign": "Eurozeichen", +"colon sign": "Doppelpunkt", +"cruzeiro sign": "Cruzeirozeichen", +"french franc sign": "Franczeichen", +"lira sign": "Lirezeichen", +"mill sign": "Millzeichen", +"naira sign": "Nairazeichen", +"peseta sign": "Pesetazeichen", +"rupee sign": "Rupiezeichen", +"won sign": "Wonzeichen", +"new sheqel sign": "Schekelzeichen", +"dong sign": "Dongzeichen", +"kip sign": "Kipzeichen", +"tugrik sign": "Tugrikzeichen", +"drachma sign": "Drachmezeichen", +"german penny symbol": "Pfennigzeichen", +"peso sign": "Pesozeichen", +"guarani sign": "Guaranizeichen", +"austral sign": "Australzeichen", +"hryvnia sign": "Hrywnjazeichen", +"cedi sign": "Cedizeichen", +"livre tournois sign": "Livrezeichen", +"spesmilo sign": "Spesmilozeichen", +"tenge sign": "Tengezeichen", +"indian rupee sign": "Indisches Rupiezeichen", +"turkish lira sign": "T\u00fcrkisches Lirazeichen", +"nordic mark sign": "Zeichen nordische Mark", +"manat sign": "Manatzeichen", +"ruble sign": "Rubelzeichen", +"yen character": "Yenzeichen", +"yuan character": "Yuanzeichen", +"yuan character, in hong kong and taiwan": "Yuanzeichen in Hongkong und Taiwan", +"yen\/yuan character variant one": "Yen-\/Yuanzeichen Variante 1", +"Loading emoticons...": "Emoticons werden geladen...", +"Could not load emoticons": "Emoticons konnten nicht geladen werden", +"People": "Menschen", +"Animals and Nature": "Tiere und Natur", +"Food and Drink": "Essen und Trinken", +"Activity": "Aktivit\u00e4t", +"Travel and Places": "Reisen und Orte", +"Objects": "Objekte", +"Flags": "Flaggen", +"Characters": "Zeichen", +"Characters (no spaces)": "Zeichen (ohne Leerzeichen)", +"{0} characters": "{0}\u00a0Zeichen", +"Error: Form submit field collision.": "Fehler: Kollision der Formularbest\u00e4tigungsfelder.", +"Error: No form element found.": "Fehler: Kein Formularelement gefunden.", +"Update": "Aktualisieren", +"Color swatch": "Farbpalette", +"Turquoise": "T\u00fcrkis", +"Green": "Gr\u00fcn", +"Blue": "Blau", +"Purple": "Violett", +"Navy Blue": "Marineblau", +"Dark Turquoise": "Dunkelt\u00fcrkis", +"Dark Green": "Dunkelgr\u00fcn", +"Medium Blue": "Mittleres Blau", +"Medium Purple": "Mittelviolett", +"Midnight Blue": "Mitternachtsblau", +"Yellow": "Gelb", +"Orange": "Orange", +"Red": "Rot", +"Light Gray": "Hellgrau", +"Gray": "Grau", +"Dark Yellow": "Dunkelgelb", +"Dark Orange": "Dunkelorange", +"Dark Red": "Dunkelrot", +"Medium Gray": "Mittelgrau", +"Dark Gray": "Dunkelgrau", +"Light Green": "Hellgr\u00fcn", +"Light Yellow": "Hellgelb", +"Light Red": "Hellrot", +"Light Purple": "Helllila", +"Light Blue": "Hellblau", +"Dark Purple": "Dunkellila", +"Dark Blue": "Dunkelblau", +"Black": "Schwarz", +"White": "Wei\u00df", +"Switch to or from fullscreen mode": "Vollbildmodus umschalten", +"Open help dialog": "Hilfe-Dialog \u00f6ffnen", +"history": "Historie", +"styles": "Stile", +"formatting": "Formatierung", +"alignment": "Ausrichtung", +"indentation": "Einr\u00fcckungen", +"Font": "Schriftart", +"Size": "Schriftgr\u00f6\u00dfe", +"More...": "Mehr...", +"Select...": "Auswahl...", +"Preferences": "Einstellungen", +"Yes": "Ja", +"No": "Nein", +"Keyboard Navigation": "Tastaturnavigation", +"Version": "Version", +"Code view": "Code Ansicht", +"Open popup menu for split buttons": "\u00d6ffne Popup Menge um Buttons zu trennen", +"List Properties": "Liste Eigenschaften", +"List properties...": "Liste Eigenschaften", +"Start list at number": "Beginne Liste mit Nummer", +"Line height": "Liniendicke", +"comments": "Anmerkungen", +"Format Painter": "Format-Painter", +"Insert\/edit iframe": "iframe einf\u00fcgen\/bearbeiten", +"Capitalization": "Gro\u00dfschreibung", +"lowercase": "Kleinbuchstaben", +"UPPERCASE": "Gro\u00dfbuchstaben", +"Title Case": "Gro\u00df-\/Kleinschreibung des Titels", +"permanent pen": "Textmarker", +"Permanent Pen Properties": "Eigenschaften von Permanent Pen", +"Permanent pen properties...": "Eigenschaften von Permanent Pen...", +"case change": "Gro\u00df-\/Kleinschreibung", +"page embed": "Seite einbetten", +"Advanced sort...": "Erweiterte Sortierung...", +"Advanced Sort": "Erweiterte Sortierung", +"Sort table by column ascending": "Tabelle aufsteigend nach Spalten sortieren", +"Sort table by column descending": "Tabelle absteigend nach Spalten sortieren", +"Sort": "Sortieren", +"Order": "Reihenfolge", +"Sort by": "Sortieren nach", +"Ascending": "Aufsteigend", +"Descending": "Absteigend", +"Column {0}": "Spalte {0}", +"Row {0}": "Reihe {0}", +"Spellcheck...": "Rechtschreibpr\u00fcfung...", +"Misspelled word": "Rechtschreibfehler", +"Suggestions": "Vorschl\u00e4ge", +"Change": "Ver\u00e4ndere", +"Finding word suggestions": "Finde Wort Vorschl\u00e4ge", +"Success": "Erfolg", +"Repair": "Reparieren", +"Issue {0} of {1}": "Fehler {0} von {1}", +"Images must be marked as decorative or have an alternative text description": "Bilder m\u00fcssen entweder als dekorativ markiert werden oder eine alternative Beschreibung bekommen", +"Images must have an alternative text description. Decorative images are not allowed.": "Bilder ben\u00f6tigen alternativen Text. Dekorative Bilder nicht erlaubt!", +"Or provide alternative text:": "Oder definiere alternativen Text:", +"Make image decorative:": "Markiere Bild als dekorativ:", +"ID attribute must be unique": "ID muss einzigartig sein", +"Make ID unique": "Mache diese ID einzigartig", +"Keep this ID and remove all others": "Behalte diese ID und entferne alle anderen", +"Remove this ID": "Entferne diese ID", +"Remove all IDs": "Entferne alle IDs", +"Checklist": "Checkliste", +"Anchor": "Textmarke", +"Special character": "Sonderzeichen", +"Code sample": "Codebeispiel", +"Color": "Farbe", +"Document properties": "Dokumenteigenschaften", +"Image description": "Bildbeschreibung", +"Image": "Bild", +"Insert link": "Link einf\u00fcgen", +"Target": "Ziel", +"Link": "Link", +"Poster": "Poster", +"Media": "Medium", +"Print": "Drucken", +"Prev": "Zur\u00fcck", +"Find and replace": "Suchen und ersetzen", +"Whole words": "Nur ganze W\u00f6rter", +"Insert template": "Vorlage einf\u00fcgen " +}); \ No newline at end of file diff --git a/frontend/public/static/tinymce/lang/es_419.js b/frontend/public/static/tinymce/lang/es_419.js new file mode 100644 index 000000000..53d79d98f --- /dev/null +++ b/frontend/public/static/tinymce/lang/es_419.js @@ -0,0 +1,462 @@ +tinymce.addI18n('es_419',{ +"Redo": "Rehacer", +"Undo": "Deshacer", +"Cut": "Cortar", +"Copy": "Copiar", +"Paste": "Pegar", +"Select all": "Seleccionar todo", +"New document": "Nuevo documento", +"Ok": "Ok", +"Cancel": "Cancelar", +"Visual aids": "Ayudas visuales", +"Bold": "Negrita", +"Italic": "Cursiva", +"Underline": "Subrayado", +"Strikethrough": "Tachado", +"Superscript": "Super\u00edndice", +"Subscript": "Sub\u00edndice", +"Clear formatting": "Limpiar formato", +"Align left": "Alinear a la izquierda", +"Align center": "Centrar", +"Align right": "Alinear a la derecha", +"Justify": "Justificar", +"Bullet list": "Lista de vi\u00f1etas", +"Numbered list": "Lista numerada", +"Decrease indent": "Disminuir sangr\u00eda", +"Increase indent": "Aumentar sangr\u00eda", +"Close": "Cerrar", +"Formats": "Formatos", +"Your browser doesn't support direct access to the clipboard. Please use the Ctrl+X\/C\/V keyboard shortcuts instead.": "Tu navegador no soporta acceso directo al portapapeles. Favor usar los comandos de teclado Ctrl+X\/C\/V", +"Headers": "Encabezados", +"Header 1": "Encabezado 1", +"Header 2": "Encabezado 2", +"Header 3": "Encabezado 3", +"Header 4": "Encabezado 4", +"Header 5": "Encabezado 5", +"Header 6": "Encabezado 6", +"Headings": "T\u00edtulos", +"Heading 1": "T\u00edtulo 1", +"Heading 2": "T\u00edtulo 2", +"Heading 3": "T\u00edtulo 3", +"Heading 4": "T\u00edtulo 4", +"Heading 5": "T\u00edtulo 5", +"Heading 6": "T\u00edtulo 6", +"Preformatted": "Preformateado", +"Div": "Div", +"Pre": "Pre", +"Code": "C\u00f3digo", +"Paragraph": "P\u00e1rrafo", +"Blockquote": "Cita", +"Inline": "En l\u00ednea", +"Blocks": "Bloques", +"Paste is now in plain text mode. Contents will now be pasted as plain text until you toggle this option off.": "Paste is now in plain text mode. Contents will now be pasted as plain text until you toggle this option off.", +"Fonts": "Fonts", +"Font Sizes": "Tama\u00f1os de Fuente", +"Class": "Class", +"Browse for an image": "Examinar imagen", +"OR": "O", +"Drop an image here": "Arrastrar imagen aqu\u00ed", +"Upload": "Subir", +"Block": "Bloque", +"Align": "Alinear", +"Default": "Default", +"Circle": "Circle", +"Disc": "Disc", +"Square": "Square", +"Lower Alpha": "Lower Alpha", +"Lower Greek": "Lower Greek", +"Lower Roman": "Lower Roman", +"Upper Alpha": "Upper Alpha", +"Upper Roman": "Upper Roman", +"Anchor...": "Anchor...", +"Name": "Name", +"Id": "Id", +"Id should start with a letter, followed only by letters, numbers, dashes, dots, colons or underscores.": "Id should start with a letter, followed only by letters, numbers, dashes, dots, colons or underscores.", +"You have unsaved changes are you sure you want to navigate away?": "You have unsaved changes are you sure you want to navigate away?", +"Restore last draft": "Restore last draft", +"Special character...": "Special character...", +"Source code": "Source code", +"Insert\/Edit code sample": "Insert\/Edit code sample", +"Language": "Language", +"Code sample...": "Code sample...", +"Color Picker": "Color Picker", +"R": "R", +"G": "G", +"B": "B", +"Left to right": "Left to right", +"Right to left": "Right to left", +"Emoticons": "Emoticons", +"Emoticons...": "Emoticons...", +"Metadata and Document Properties": "Metadata and Document Properties", +"Title": "Title", +"Keywords": "Keywords", +"Description": "Description", +"Robots": "Robots", +"Author": "Author", +"Encoding": "Encoding", +"Fullscreen": "Fullscreen", +"Action": "Action", +"Shortcut": "Shortcut", +"Help": "Help", +"Address": "Address", +"Focus to menubar": "Focus to menubar", +"Focus to toolbar": "Focus to toolbar", +"Focus to element path": "Focus to element path", +"Focus to contextual toolbar": "Focus to contextual toolbar", +"Insert link (if link plugin activated)": "Insert link (if link plugin activated)", +"Save (if save plugin activated)": "Save (if save plugin activated)", +"Find (if searchreplace plugin activated)": "Find (if searchreplace plugin activated)", +"Plugins installed ({0}):": "Plugins installed ({0}):", +"Premium plugins:": "Premium plugins:", +"Learn more...": "Learn more...", +"You are using {0}": "You are using {0}", +"Plugins": "Plugins", +"Handy Shortcuts": "Handy Shortcuts", +"Horizontal line": "Horizontal line", +"Insert\/edit image": "Insert\/edit image", +"Alternative description": "Descripci\u00f3n alternativa", +"Accessibility": "Accesibilidad", +"Image is decorative": "La imagen es decorativa", +"Source": "Source", +"Dimensions": "Dimensions", +"Constrain proportions": "Constrain proportions", +"General": "General", +"Advanced": "Advanced", +"Style": "Style", +"Vertical space": "Vertical space", +"Horizontal space": "Horizontal space", +"Border": "Border", +"Insert image": "Insert image", +"Image...": "Image...", +"Image list": "Image list", +"Rotate counterclockwise": "Rotate counterclockwise", +"Rotate clockwise": "Rotate clockwise", +"Flip vertically": "Flip vertically", +"Flip horizontally": "Flip horizontally", +"Edit image": "Edit image", +"Image options": "Image options", +"Zoom in": "Zoom in", +"Zoom out": "Zoom out", +"Crop": "Crop", +"Resize": "Resize", +"Orientation": "Orientation", +"Brightness": "Brightness", +"Sharpen": "Sharpen", +"Contrast": "Contrast", +"Color levels": "Color levels", +"Gamma": "Gamma", +"Invert": "Invert", +"Apply": "Apply", +"Back": "Back", +"Insert date\/time": "Insert date\/time", +"Date\/time": "Date\/time", +"Insert\/edit link": "Insert\/edit link", +"Text to display": "Text to display", +"Url": "Url", +"Open link in...": "Open link in...", +"Current window": "Current window", +"None": "None", +"New window": "New window", +"Open link": "Enlace abierto", +"Remove link": "Remove link", +"Anchors": "Anchors", +"Link...": "Link...", +"Paste or type a link": "Paste or type a link", +"The URL you entered seems to be an email address. Do you want to add the required mailto: prefix?": "The URL you entered seems to be an email address. Do you want to add the required mailto: prefix?", +"The URL you entered seems to be an external link. Do you want to add the required http:\/\/ prefix?": "The URL you entered seems to be an external link. Do you want to add the required http:\/\/ prefix?", +"The URL you entered seems to be an external link. Do you want to add the required https:\/\/ prefix?": "La URL que ingres\u00f3 parece ser un enlace externo. \u00bfDesea agregar el prefijo https:\/\/ requerido?", +"Link list": "Link list", +"Insert video": "Insert video", +"Insert\/edit video": "Insert\/edit video", +"Insert\/edit media": "Insert\/edit media", +"Alternative source": "Alternative source", +"Alternative source URL": "Alternative source URL", +"Media poster (Image URL)": "Media poster (Image URL)", +"Paste your embed code below:": "Paste your embed code below:", +"Embed": "Embed", +"Media...": "Media...", +"Nonbreaking space": "Nonbreaking space", +"Page break": "Page break", +"Paste as text": "Paste as text", +"Preview": "Preview", +"Print...": "Print...", +"Save": "Save", +"Find": "Find", +"Replace with": "Replace with", +"Replace": "Replace", +"Replace all": "Replace all", +"Previous": "Previous", +"Next": "Next", +"Find and Replace": "Encontrar y Reemplazar", +"Find and replace...": "Find and replace...", +"Could not find the specified string.": "Could not find the specified string.", +"Match case": "Match case", +"Find whole words only": "Find whole words only", +"Find in selection": "Encontrar en la selecci\u00f3n", +"Spellcheck": "Spellcheck", +"Spellcheck Language": "Spellcheck Language", +"No misspellings found.": "No se encontraron errores ortogr\u00e1ficos.", +"Ignore": "Ignore", +"Ignore all": "Ignore all", +"Finish": "Finish", +"Add to Dictionary": "Add to Dictionary", +"Insert table": "Insert table", +"Table properties": "Table properties", +"Delete table": "Delete table", +"Cell": "Cell", +"Row": "Row", +"Column": "Column", +"Cell properties": "Cell properties", +"Merge cells": "Merge cells", +"Split cell": "Split cell", +"Insert row before": "Insert row before", +"Insert row after": "Insert row after", +"Delete row": "Delete row", +"Row properties": "Row properties", +"Cut row": "Cut row", +"Copy row": "Copy row", +"Paste row before": "Paste row before", +"Paste row after": "Paste row after", +"Insert column before": "Insert column before", +"Insert column after": "Insert column after", +"Delete column": "Delete column", +"Cols": "Cols", +"Rows": "Rows", +"Width": "Width", +"Height": "Height", +"Cell spacing": "Cell spacing", +"Cell padding": "Cell padding", +"Caption": "Caption", +"Show caption": "Show caption", +"Left": "Left", +"Center": "Center", +"Right": "Right", +"Cell type": "Cell type", +"Scope": "Scope", +"Alignment": "Alignment", +"H Align": "H Align", +"V Align": "V Align", +"Top": "Top", +"Middle": "Middle", +"Bottom": "Bottom", +"Header cell": "Header cell", +"Row group": "Row group", +"Column group": "Column group", +"Row type": "Row type", +"Header": "Header", +"Body": "Body", +"Footer": "Footer", +"Border color": "Border color", +"Insert template...": "Insert template...", +"Templates": "Templates", +"Template": "Template", +"Text color": "Text color", +"Background color": "Background color", +"Custom...": "Custom...", +"Custom color": "Custom color", +"No color": "No color", +"Remove color": "Remove color", +"Table of Contents": "Table of Contents", +"Show blocks": "Show blocks", +"Show invisible characters": "Show invisible characters", +"Word count": "Word count", +"Count": "Count", +"Document": "Document", +"Selection": "Selection", +"Words": "Words", +"Words: {0}": "Words: {0}", +"{0} words": "{0} words", +"File": "File", +"Edit": "Edit", +"Insert": "Insert", +"View": "View", +"Format": "Format", +"Table": "Table", +"Tools": "Tools", +"Powered by {0}": "Powered by {0}", +"Rich Text Area. Press ALT-F9 for menu. Press ALT-F10 for toolbar. Press ALT-0 for help": "Rich Text Area. Press ALT-F9 for menu. Press ALT-F10 for toolbar. Press ALT-0 for help", +"Image title": "Image title", +"Border width": "Border width", +"Border style": "Border style", +"Error": "Error", +"Warn": "Warn", +"Valid": "Valid", +"To open the popup, press Shift+Enter": "To open the popup, press Shift+Enter", +"Rich Text Area. Press ALT-0 for help.": "Rich Text Area. Press ALT-0 for help.", +"System Font": "System Font", +"Failed to upload image: {0}": "Failed to upload image: {0}", +"Failed to load plugin: {0} from url {1}": "Failed to load plugin: {0} from url {1}", +"Failed to load plugin url: {0}": "Failed to load plugin url: {0}", +"Failed to initialize plugin: {0}": "Failed to initialize plugin: {0}", +"example": "example", +"Search": "Search", +"All": "All", +"Currency": "Currency", +"Text": "Text", +"Quotations": "Quotations", +"Mathematical": "Mathematical", +"Extended Latin": "Extended Latin", +"Symbols": "Symbols", +"Arrows": "Arrows", +"User Defined": "User Defined", +"dollar sign": "dollar sign", +"currency sign": "currency sign", +"euro-currency sign": "euro-currency sign", +"colon sign": "colon sign", +"cruzeiro sign": "cruzeiro sign", +"french franc sign": "french franc sign", +"lira sign": "lira sign", +"mill sign": "mill sign", +"naira sign": "naira sign", +"peseta sign": "peseta sign", +"rupee sign": "rupee sign", +"won sign": "won sign", +"new sheqel sign": "new sheqel sign", +"dong sign": "dong sign", +"kip sign": "kip sign", +"tugrik sign": "tugrik sign", +"drachma sign": "drachma sign", +"german penny symbol": "german penny symbol", +"peso sign": "peso sign", +"guarani sign": "guarani sign", +"austral sign": "austral sign", +"hryvnia sign": "hryvnia sign", +"cedi sign": "cedi sign", +"livre tournois sign": "livre tournois sign", +"spesmilo sign": "spesmilo sign", +"tenge sign": "tenge sign", +"indian rupee sign": "indian rupee sign", +"turkish lira sign": "turkish lira sign", +"nordic mark sign": "nordic mark sign", +"manat sign": "manat sign", +"ruble sign": "ruble sign", +"yen character": "yen character", +"yuan character": "yuan character", +"yuan character, in hong kong and taiwan": "yuan character, in hong kong and taiwan", +"yen\/yuan character variant one": "yen\/yuan character variant one", +"Loading emoticons...": "Loading emoticons...", +"Could not load emoticons": "Could not load emoticons", +"People": "People", +"Animals and Nature": "Animals and Nature", +"Food and Drink": "Food and Drink", +"Activity": "Activity", +"Travel and Places": "Travel and Places", +"Objects": "Objects", +"Flags": "Flags", +"Characters": "Characters", +"Characters (no spaces)": "Characters (no spaces)", +"{0} characters": "{0} characters", +"Error: Form submit field collision.": "Error: Form submit field collision.", +"Error: No form element found.": "Error: No form element found.", +"Update": "Update", +"Color swatch": "Color swatch", +"Turquoise": "Turquoise", +"Green": "Green", +"Blue": "Blue", +"Purple": "Purple", +"Navy Blue": "Navy Blue", +"Dark Turquoise": "Dark Turquoise", +"Dark Green": "Dark Green", +"Medium Blue": "Medium Blue", +"Medium Purple": "Medium Purple", +"Midnight Blue": "Midnight Blue", +"Yellow": "Yellow", +"Orange": "Orange", +"Red": "Red", +"Light Gray": "Light Gray", +"Gray": "Gray", +"Dark Yellow": "Dark Yellow", +"Dark Orange": "Dark Orange", +"Dark Red": "Dark Red", +"Medium Gray": "Medium Gray", +"Dark Gray": "Dark Gray", +"Light Green": "Light Green", +"Light Yellow": "Light Yellow", +"Light Red": "Light Red", +"Light Purple": "Light Purple", +"Light Blue": "Light Blue", +"Dark Purple": "Dark Purple", +"Dark Blue": "Dark Blue", +"Black": "Black", +"White": "White", +"Switch to or from fullscreen mode": "Switch to or from fullscreen mode", +"Open help dialog": "Open help dialog", +"history": "history", +"styles": "styles", +"formatting": "formatting", +"alignment": "alignment", +"indentation": "indentation", +"Font": "Font", +"Size": "Size", +"More...": "More...", +"Select...": "Select...", +"Preferences": "Preferences", +"Yes": "Yes", +"No": "No", +"Keyboard Navigation": "Keyboard Navigation", +"Version": "Version", +"Code view": "Vista de c\u00f3digo", +"Open popup menu for split buttons": "Abrir men\u00fa emergente para botones divididos", +"List Properties": "Propiedades de Lista", +"List properties...": "Propiedades de lista...", +"Start list at number": "Iniciar lista en el n\u00famero", +"Line height": "Altura de la l\u00ednea", +"comments": "comments", +"Format Painter": "Format Painter", +"Insert\/edit iframe": "Insert\/edit iframe", +"Capitalization": "Capitalization", +"lowercase": "lowercase", +"UPPERCASE": "UPPERCASE", +"Title Case": "Title Case", +"permanent pen": "permanent pen", +"Permanent Pen Properties": "Permanent Pen Properties", +"Permanent pen properties...": "Permanent pen properties...", +"case change": "Cambiar May\u00fasculas y Min\u00fasculas", +"page embed": "p\u00e1gina incrustada", +"Advanced sort...": "Orden avanzado...", +"Advanced Sort": "Orden Avanzado", +"Sort table by column ascending": "Ordenar tabla por columna ascendente", +"Sort table by column descending": "Ordenar tabla por columna descendente", +"Sort": "Ordenar", +"Order": "Orden", +"Sort by": "Ordenar por", +"Ascending": "Ascendente", +"Descending": "Descendiente", +"Column {0}": "Columna {0}", +"Row {0}": "Fila {0}", +"Spellcheck...": "Corrector...", +"Misspelled word": "Palabra mal escrita", +"Suggestions": "Sugerencias", +"Change": "Cambiar", +"Finding word suggestions": "Encontrar sugerencias de palabras", +"Success": "\u00c9xito", +"Repair": "Reparar", +"Issue {0} of {1}": "Problema {0} de {1}", +"Images must be marked as decorative or have an alternative text description": "Las im\u00e1genes deben estar marcadas como decorativas o tener una descripci\u00f3n de texto alternativa", +"Images must have an alternative text description. Decorative images are not allowed.": "Las im\u00e1genes deben tener una descripci\u00f3n de texto alternativa. No se permiten im\u00e1genes decorativas.", +"Or provide alternative text:": "O proporcione texto alternativo:", +"Make image decorative:": "Hacer la imagen decorativa:", +"ID attribute must be unique": "El atributo de ID debe ser \u00fanico", +"Make ID unique": "Hacer que ID sea \u00fanica", +"Keep this ID and remove all others": "Conserve esta ID y elimine todas las dem\u00e1s", +"Remove this ID": "Eliminar esta ID", +"Remove all IDs": "Eliminar todos los ID", +"Checklist": "Lista de Verificaci\u00f3n", +"Anchor": "Anchor", +"Special character": "Special character", +"Code sample": "Code sample", +"Color": "Color", +"Document properties": "Document properties", +"Image description": "Image description", +"Image": "Image", +"Insert link": "Insert link", +"Target": "Target", +"Link": "Link", +"Poster": "Poster", +"Media": "Media", +"Print": "Print", +"Prev": "Prev", +"Find and replace": "Find and replace", +"Whole words": "Whole words", +"Insert template": "Insert template" +}); \ No newline at end of file diff --git a/frontend/public/static/tinymce/lang/fr_FR.js b/frontend/public/static/tinymce/lang/fr_FR.js new file mode 100644 index 000000000..3126171f9 --- /dev/null +++ b/frontend/public/static/tinymce/lang/fr_FR.js @@ -0,0 +1,462 @@ +tinymce.addI18n('fr_FR',{ +"Redo": "R\u00e9tablir", +"Undo": "Annuler", +"Cut": "Couper", +"Copy": "Copier", +"Paste": "Coller", +"Select all": "S\u00e9lectionner tout", +"New document": "Nouveau document", +"Ok": "OK", +"Cancel": "Annuler", +"Visual aids": "Aides visuelles", +"Bold": "Gras", +"Italic": "Italique", +"Underline": "Soulign\u00e9", +"Strikethrough": "Barr\u00e9", +"Superscript": "Exposant", +"Subscript": "Indice", +"Clear formatting": "Effacer la mise en forme", +"Align left": "Aligner \u00e0 gauche", +"Align center": "Centrer", +"Align right": "Aligner \u00e0 droite", +"Justify": "Justifier", +"Bullet list": "Liste \u00e0 puces", +"Numbered list": "Liste num\u00e9rot\u00e9e", +"Decrease indent": "R\u00e9duire le retrait", +"Increase indent": "Augmenter le retrait", +"Close": "Fermer", +"Formats": "Formats", +"Your browser doesn't support direct access to the clipboard. Please use the Ctrl+X\/C\/V keyboard shortcuts instead.": "Votre navigateur ne supporte pas l\u2019acc\u00e8s direct au presse-papiers. Merci d'utiliser les raccourcis clavier Ctrl+X\/C\/V.", +"Headers": "En-t\u00eates", +"Header 1": "En-t\u00eate 1", +"Header 2": "En-t\u00eate 2", +"Header 3": "En-t\u00eate 3", +"Header 4": "En-t\u00eate 4", +"Header 5": "En-t\u00eate 5", +"Header 6": "En-t\u00eate 6", +"Headings": "Titres", +"Heading 1": "Titre\u00a01", +"Heading 2": "Titre\u00a02", +"Heading 3": "Titre\u00a03", +"Heading 4": "Titre\u00a04", +"Heading 5": "Titre\u00a05", +"Heading 6": "Titre\u00a06", +"Preformatted": "Pr\u00e9format\u00e9", +"Div": "Div", +"Pre": "Pre", +"Code": "Code", +"Paragraph": "Paragraphe", +"Blockquote": "Blockquote", +"Inline": "En ligne", +"Blocks": "Blocs", +"Paste is now in plain text mode. Contents will now be pasted as plain text until you toggle this option off.": "Le presse-papiers est maintenant en mode \"texte plein\". Les contenus seront coll\u00e9s sans retenir les formatages jusqu'\u00e0 ce que vous d\u00e9sactiviez cette option.", +"Fonts": "Polices", +"Font Sizes": "Tailles de police", +"Class": "Classe", +"Browse for an image": "Rechercher une image", +"OR": "OU", +"Drop an image here": "D\u00e9poser une image ici", +"Upload": "T\u00e9l\u00e9charger", +"Block": "Bloc", +"Align": "Aligner", +"Default": "Par d\u00e9faut", +"Circle": "Cercle", +"Disc": "Disque", +"Square": "Carr\u00e9", +"Lower Alpha": "Alpha minuscule", +"Lower Greek": "Grec minuscule", +"Lower Roman": "Romain minuscule", +"Upper Alpha": "Alpha majuscule", +"Upper Roman": "Romain majuscule", +"Anchor...": "Ancre...", +"Name": "Nom", +"Id": "Id", +"Id should start with a letter, followed only by letters, numbers, dashes, dots, colons or underscores.": "L'Id doit commencer par une lettre suivi par des lettres, nombres, tirets, points, deux-points ou underscores", +"You have unsaved changes are you sure you want to navigate away?": "Vous avez des modifications non enregistr\u00e9es, \u00eates-vous s\u00fbr de quitter la page?", +"Restore last draft": "Restaurer le dernier brouillon", +"Special character...": "Caract\u00e8re sp\u00e9cial...", +"Source code": "Code source", +"Insert\/Edit code sample": "Ins\u00e9rer \/ modifier une exemple de code", +"Language": "Langue", +"Code sample...": "Exemple de code...", +"Color Picker": "S\u00e9lecteur de couleurs", +"R": "R", +"G": "V", +"B": "B", +"Left to right": "Gauche \u00e0 droite", +"Right to left": "Droite \u00e0 gauche", +"Emoticons": "Emotic\u00f4nes", +"Emoticons...": "\u00c9motic\u00f4nes...", +"Metadata and Document Properties": "M\u00e9tadonn\u00e9es et propri\u00e9t\u00e9s du document", +"Title": "Titre", +"Keywords": "Mots-cl\u00e9s", +"Description": "Description", +"Robots": "Robots", +"Author": "Auteur", +"Encoding": "Encodage", +"Fullscreen": "Plein \u00e9cran", +"Action": "Action", +"Shortcut": "Raccourci", +"Help": "Aide", +"Address": "Adresse", +"Focus to menubar": "Cibler la barre de menu", +"Focus to toolbar": "Cibler la barre d'outils", +"Focus to element path": "Cibler le chemin vers l'\u00e9l\u00e9ment", +"Focus to contextual toolbar": "Cibler la barre d'outils contextuelle", +"Insert link (if link plugin activated)": "Ins\u00e9rer un lien (si le module link est activ\u00e9)", +"Save (if save plugin activated)": "Enregistrer (si le module save est activ\u00e9)", +"Find (if searchreplace plugin activated)": "Rechercher (si le module searchreplace est activ\u00e9)", +"Plugins installed ({0}):": "Modules install\u00e9s ({0}) : ", +"Premium plugins:": "Modules premium :", +"Learn more...": "En savoir plus...", +"You are using {0}": "Vous utilisez {0}", +"Plugins": "Plugins", +"Handy Shortcuts": "Raccourcis utiles", +"Horizontal line": "Ligne horizontale", +"Insert\/edit image": "Ins\u00e9rer\/modifier une image", +"Alternative description": "Description alternative", +"Accessibility": "Accessibilit\u00e9", +"Image is decorative": "L'image est d\u00e9corative", +"Source": "Source", +"Dimensions": "Dimensions", +"Constrain proportions": "Conserver les proportions", +"General": "G\u00e9n\u00e9ral", +"Advanced": "Avanc\u00e9", +"Style": "Style", +"Vertical space": "Espacement vertical", +"Horizontal space": "Espacement horizontal", +"Border": "Bordure", +"Insert image": "Ins\u00e9rer une image", +"Image...": "Image...", +"Image list": "Liste d'images", +"Rotate counterclockwise": "Rotation anti-horaire", +"Rotate clockwise": "Rotation horaire", +"Flip vertically": "Retournement vertical", +"Flip horizontally": "Retournement horizontal", +"Edit image": "Modifier l'image", +"Image options": "Options de l'image", +"Zoom in": "Zoomer", +"Zoom out": "D\u00e9zoomer", +"Crop": "Rogner", +"Resize": "Redimensionner", +"Orientation": "Orientation", +"Brightness": "Luminosit\u00e9", +"Sharpen": "Affiner", +"Contrast": "Contraste", +"Color levels": "Niveaux de couleur", +"Gamma": "Gamma", +"Invert": "Inverser", +"Apply": "Appliquer", +"Back": "Retour", +"Insert date\/time": "Ins\u00e9rer date\/heure", +"Date\/time": "Date\/heure", +"Insert\/edit link": "Ins\u00e9rer\/modifier un lien", +"Text to display": "Texte \u00e0 afficher", +"Url": "Url", +"Open link in...": "Ouvrir le lien dans...", +"Current window": "Fen\u00eatre active", +"None": "n\/a", +"New window": "Nouvelle fen\u00eatre", +"Open link": "Ouvrir le lien", +"Remove link": "Enlever le lien", +"Anchors": "Ancres", +"Link...": "Lien...", +"Paste or type a link": "Coller ou taper un lien", +"The URL you entered seems to be an email address. Do you want to add the required mailto: prefix?": "L'URL que vous avez entr\u00e9e semble \u00eatre une adresse e-mail. Voulez-vous ajouter le pr\u00e9fixe mailto: n\u00e9cessaire?", +"The URL you entered seems to be an external link. Do you want to add the required http:\/\/ prefix?": "L'URL que vous avez entr\u00e9e semble \u00eatre un lien externe. Voulez-vous ajouter le pr\u00e9fixe http:\/\/ n\u00e9cessaire?", +"The URL you entered seems to be an external link. Do you want to add the required https:\/\/ prefix?": "L'URL que vous avez saisie semble \u00eatre un lien externe. Voulez-vous ajouter le pr\u00e9fixe https:\/\/ requis\u00a0?", +"Link list": "Liste de liens", +"Insert video": "Ins\u00e9rer une vid\u00e9o", +"Insert\/edit video": "Ins\u00e9rer\/modifier une vid\u00e9o", +"Insert\/edit media": "Ins\u00e9rer\/modifier un m\u00e9dia", +"Alternative source": "Source alternative", +"Alternative source URL": "URL de la source alternative", +"Media poster (Image URL)": "Affiche de m\u00e9dia (URL de l'image)", +"Paste your embed code below:": "Collez votre code d'int\u00e9gration ci-dessous :", +"Embed": "Int\u00e9grer", +"Media...": "M\u00e9dia...", +"Nonbreaking space": "Espace ins\u00e9cable", +"Page break": "Saut de page", +"Paste as text": "Coller comme texte", +"Preview": "Pr\u00e9visualiser", +"Print...": "Imprimer...", +"Save": "Enregistrer", +"Find": "Chercher", +"Replace with": "Remplacer par", +"Replace": "Remplacer", +"Replace all": "Tout remplacer", +"Previous": "Pr\u00e9c\u00e9dente", +"Next": "Suiv", +"Find and Replace": "Trouver et remplacer", +"Find and replace...": "Trouver et remplacer...", +"Could not find the specified string.": "Impossible de trouver la cha\u00eene sp\u00e9cifi\u00e9e.", +"Match case": "Respecter la casse", +"Find whole words only": "Mot entier", +"Find in selection": "Trouver dans la s\u00e9lection", +"Spellcheck": "V\u00e9rification orthographique", +"Spellcheck Language": "Langue du correcteur orthographique", +"No misspellings found.": "Aucune faute d'orthographe trouv\u00e9e.", +"Ignore": "Ignorer", +"Ignore all": "Tout ignorer", +"Finish": "Finie", +"Add to Dictionary": "Ajouter au dictionnaire", +"Insert table": "Ins\u00e9rer un tableau", +"Table properties": "Propri\u00e9t\u00e9s du tableau", +"Delete table": "Supprimer le tableau", +"Cell": "Cellule", +"Row": "Ligne", +"Column": "Colonne", +"Cell properties": "Propri\u00e9t\u00e9s de la cellule", +"Merge cells": "Fusionner les cellules", +"Split cell": "Diviser la cellule", +"Insert row before": "Ins\u00e9rer une ligne avant", +"Insert row after": "Ins\u00e9rer une ligne apr\u00e8s", +"Delete row": "Effacer la ligne", +"Row properties": "Propri\u00e9t\u00e9s de la ligne", +"Cut row": "Couper la ligne", +"Copy row": "Copier la ligne", +"Paste row before": "Coller la ligne avant", +"Paste row after": "Coller la ligne apr\u00e8s", +"Insert column before": "Ins\u00e9rer une colonne avant", +"Insert column after": "Ins\u00e9rer une colonne apr\u00e8s", +"Delete column": "Effacer la colonne", +"Cols": "Colonnes", +"Rows": "Lignes", +"Width": "Largeur", +"Height": "Hauteur", +"Cell spacing": "Espacement inter-cellulles", +"Cell padding": "Espacement interne cellule", +"Caption": "Titre", +"Show caption": "Afficher le sous-titrage", +"Left": "Gauche", +"Center": "Centr\u00e9", +"Right": "Droite", +"Cell type": "Type de cellule", +"Scope": "Etendue", +"Alignment": "Alignement", +"H Align": "Alignement H", +"V Align": "Alignement V", +"Top": "Haut", +"Middle": "Milieu", +"Bottom": "Bas", +"Header cell": "Cellule d'en-t\u00eate", +"Row group": "Groupe de lignes", +"Column group": "Groupe de colonnes", +"Row type": "Type de ligne", +"Header": "En-t\u00eate", +"Body": "Corps", +"Footer": "Pied", +"Border color": "Couleur de la bordure", +"Insert template...": "Ins\u00e9rer un mod\u00e8le...", +"Templates": "Th\u00e8mes", +"Template": "Mod\u00e8le", +"Text color": "Couleur du texte", +"Background color": "Couleur d'arri\u00e8re-plan", +"Custom...": "Personnalis\u00e9...", +"Custom color": "Couleur personnalis\u00e9e", +"No color": "Aucune couleur", +"Remove color": "Supprimer la couleur", +"Table of Contents": "Table des mati\u00e8res", +"Show blocks": "Afficher les blocs", +"Show invisible characters": "Afficher les caract\u00e8res invisibles", +"Word count": "Nombre de mots", +"Count": "Total", +"Document": "Document", +"Selection": "S\u00e9lection", +"Words": "Mots", +"Words: {0}": "Mots : {0}", +"{0} words": "{0} mots", +"File": "Fichier", +"Edit": "Editer", +"Insert": "Ins\u00e9rer", +"View": "Voir", +"Format": "Format", +"Table": "Tableau", +"Tools": "Outils", +"Powered by {0}": "Propuls\u00e9 par {0}", +"Rich Text Area. Press ALT-F9 for menu. Press ALT-F10 for toolbar. Press ALT-0 for help": "Zone Texte Riche. Appuyer sur ALT-F9 pour le menu. Appuyer sur ALT-F10 pour la barre d'outils. Appuyer sur ALT-0 pour de l'aide.", +"Image title": "Titre d'image", +"Border width": "\u00c9paisseur de la bordure", +"Border style": "Style de la bordure", +"Error": "Erreur", +"Warn": "Avertir", +"Valid": "Valide", +"To open the popup, press Shift+Enter": "Pour ouvrir la popup, appuyez sur Maj+Entr\u00e9e", +"Rich Text Area. Press ALT-0 for help.": "Zone de texte riche. Appuyez sur ALT-0 pour l'aide.", +"System Font": "Police syst\u00e8me", +"Failed to upload image: {0}": "\u00c9chec d'envoi de l'image\u00a0: {0}", +"Failed to load plugin: {0} from url {1}": "\u00c9chec de chargement du plug-in\u00a0: {0} \u00e0 partir de l\u2019URL {1}", +"Failed to load plugin url: {0}": "\u00c9chec de chargement de l'URL du plug-in\u00a0: {0}", +"Failed to initialize plugin: {0}": "\u00c9chec d'initialisation du plug-in\u00a0: {0}", +"example": "exemple", +"Search": "Rechercher", +"All": "Tout", +"Currency": "Devise", +"Text": "Texte", +"Quotations": "Citations", +"Mathematical": "Op\u00e9rateurs math\u00e9matiques", +"Extended Latin": "Latin \u00e9tendu", +"Symbols": "Symboles", +"Arrows": "Fl\u00e8ches", +"User Defined": "D\u00e9fini par l'utilisateur", +"dollar sign": "Symbole dollar", +"currency sign": "Symbole devise", +"euro-currency sign": "Symbole euro", +"colon sign": "Symbole col\u00f3n", +"cruzeiro sign": "Symbole cruzeiro", +"french franc sign": "Symbole franc fran\u00e7ais", +"lira sign": "Symbole lire", +"mill sign": "Symbole milli\u00e8me", +"naira sign": "Symbole naira", +"peseta sign": "Symbole peseta", +"rupee sign": "Symbole roupie", +"won sign": "Symbole won", +"new sheqel sign": "Symbole nouveau ch\u00e9kel", +"dong sign": "Symbole dong", +"kip sign": "Symbole kip", +"tugrik sign": "Symbole tougrik", +"drachma sign": "Symbole drachme", +"german penny symbol": "Symbole pfennig", +"peso sign": "Symbole peso", +"guarani sign": "Symbole guarani", +"austral sign": "Symbole austral", +"hryvnia sign": "Symbole hryvnia", +"cedi sign": "Symbole cedi", +"livre tournois sign": "Symbole livre tournois", +"spesmilo sign": "Symbole spesmilo", +"tenge sign": "Symbole tenge", +"indian rupee sign": "Symbole roupie indienne", +"turkish lira sign": "Symbole lire turque", +"nordic mark sign": "Symbole du mark nordique", +"manat sign": "Symbole manat", +"ruble sign": "Symbole rouble", +"yen character": "Sinogramme Yen", +"yuan character": "Sinogramme Yuan", +"yuan character, in hong kong and taiwan": "Sinogramme Yuan, Hong Kong et Taiwan", +"yen\/yuan character variant one": "Sinogramme Yen\/Yuan, premi\u00e8re variante", +"Loading emoticons...": "Chargement des \u00e9motic\u00f4nes en cours...", +"Could not load emoticons": "\u00c9chec de chargement des \u00e9motic\u00f4nes", +"People": "Personnes", +"Animals and Nature": "Animaux & nature", +"Food and Drink": "Nourriture & boissons", +"Activity": "Activit\u00e9", +"Travel and Places": "Voyages & lieux", +"Objects": "Objets", +"Flags": "Drapeaux", +"Characters": "Caract\u00e8res", +"Characters (no spaces)": "Caract\u00e8res (espaces non compris)", +"{0} characters": "{0}\u00a0caract\u00e8res", +"Error: Form submit field collision.": "Erreur\u00a0: conflit de champs lors de la soumission du formulaire.", +"Error: No form element found.": "Erreur : aucun \u00e9l\u00e9ment de formulaire trouv\u00e9.", +"Update": "Mettre \u00e0 jour", +"Color swatch": "\u00c9chantillon de couleurs", +"Turquoise": "Turquoise", +"Green": "Vert", +"Blue": "Bleu", +"Purple": "Violet", +"Navy Blue": "Bleu marine", +"Dark Turquoise": "Turquoise fonc\u00e9", +"Dark Green": "Vert fonc\u00e9", +"Medium Blue": "Bleu moyen", +"Medium Purple": "Violet moyen", +"Midnight Blue": "Bleu de minuit", +"Yellow": "Jaune", +"Orange": "Orange", +"Red": "Rouge", +"Light Gray": "Gris clair", +"Gray": "Gris", +"Dark Yellow": "Jaune fonc\u00e9", +"Dark Orange": "Orange fonc\u00e9", +"Dark Red": "Rouge fonc\u00e9", +"Medium Gray": "Gris moyen", +"Dark Gray": "Gris fonc\u00e9", +"Light Green": "Vert clair", +"Light Yellow": "Jaune clair", +"Light Red": "Rouge clair", +"Light Purple": "Violet clair", +"Light Blue": "Bleu clair", +"Dark Purple": "Violet fonc\u00e9", +"Dark Blue": "Bleu fonc\u00e9", +"Black": "Noir", +"White": "Blanc", +"Switch to or from fullscreen mode": "Passer en ou quitter le mode plein \u00e9cran", +"Open help dialog": "Ouvrir la bo\u00eete de dialogue d'aide", +"history": "historique", +"styles": "styles", +"formatting": "mise en forme", +"alignment": "alignement", +"indentation": "retrait", +"Font": "Police", +"Size": "Taille", +"More...": "Plus...", +"Select...": "S\u00e9lectionner...", +"Preferences": "Pr\u00e9f\u00e9rences", +"Yes": "Oui", +"No": "Non", +"Keyboard Navigation": "Navigation au clavier", +"Version": "Version", +"Code view": "Affichage du code", +"Open popup menu for split buttons": "Ouvrir le menu contextuel pour les boutons partag\u00e9s", +"List Properties": "Propri\u00e9t\u00e9s de la liste", +"List properties...": "Lister les propri\u00e9t\u00e9s...", +"Start list at number": "Liste de d\u00e9part au num\u00e9ro", +"Line height": "Hauteur de la ligne", +"comments": "commentaires", +"Format Painter": "Reproduire la mise en forme", +"Insert\/edit iframe": "Ins\u00e9rer\/modifier iframe", +"Capitalization": "Mise en majuscules", +"lowercase": "minuscule", +"UPPERCASE": "MAJUSCULE", +"Title Case": "Casse du titre", +"permanent pen": "feutre ind\u00e9l\u00e9bile", +"Permanent Pen Properties": "Propri\u00e9t\u00e9s du feutre ind\u00e9l\u00e9bile", +"Permanent pen properties...": "Propri\u00e9t\u00e9s du feutre ind\u00e9l\u00e9bile...", +"case change": "changement de cas", +"page embed": "int\u00e9gration de page", +"Advanced sort...": "Tri avanc\u00e9...", +"Advanced Sort": "Tri avanc\u00e9", +"Sort table by column ascending": "Trier le tableau par colonne ascendante", +"Sort table by column descending": "Trier le tableau par colonne en ordre d\u00e9croissant", +"Sort": "Sorte", +"Order": "Ordre", +"Sort by": "Trier par", +"Ascending": "Ascendant", +"Descending": "Descendant", +"Column {0}": "Colonne {0}", +"Row {0}": "Ligne {0}", +"Spellcheck...": "V\u00e9rification orthographique...", +"Misspelled word": "Mot mal orthographi\u00e9", +"Suggestions": "Suggestions", +"Change": "Changement", +"Finding word suggestions": "Trouver des suggestions de mots", +"Success": "Succ\u00e8s", +"Repair": "R\u00e9paration", +"Issue {0} of {1}": " {0} Erreur sur {1}", +"Images must be marked as decorative or have an alternative text description": "Les images doivent \u00eatre marqu\u00e9es comme d\u00e9coratives ou avoir une description textuelle alternative", +"Images must have an alternative text description. Decorative images are not allowed.": "Les images doivent avoir une description textuelle alternative. Les images d\u00e9coratives ne sont pas autoris\u00e9es.", +"Or provide alternative text:": "Ou fournissez un texte alternatif\u00a0:", +"Make image decorative:": "Rendre l'image d\u00e9corative\u00a0:", +"ID attribute must be unique": "L'attribut ID doit \u00eatre unique", +"Make ID unique": "Rendre l'identifiant unique", +"Keep this ID and remove all others": "Conservez cet identifiant et supprimez tous les autres", +"Remove this ID": "Supprimer cet identifiant", +"Remove all IDs": "Supprimer tous les identifiants", +"Checklist": "Liste de contr\u00f4le", +"Anchor": "Ancre", +"Special character": "Caract\u00e8res sp\u00e9ciaux", +"Code sample": "Extrait de code", +"Color": "Couleur", +"Document properties": "Propri\u00e9t\u00e9 du document", +"Image description": "Description de l'image", +"Image": "Image", +"Insert link": "Ins\u00e9rer un lien", +"Target": "Cible", +"Link": "Lien", +"Poster": "Publier", +"Media": "M\u00e9dia", +"Print": "Imprimer", +"Prev": "Pr\u00e9c ", +"Find and replace": "Trouver et remplacer", +"Whole words": "Mots entiers", +"Insert template": "Ajouter un th\u00e8me" +}); \ No newline at end of file diff --git a/frontend/public/static/tinymce/lang/it_IT.js b/frontend/public/static/tinymce/lang/it_IT.js new file mode 100644 index 000000000..73b332267 --- /dev/null +++ b/frontend/public/static/tinymce/lang/it_IT.js @@ -0,0 +1,370 @@ +tinymce.addI18n('it_IT',{ +"Redo": "Ripristina", +"Undo": "Annulla", +"Cut": "Taglia", +"Copy": "Copia", +"Paste": "Incolla", +"Select all": "Seleziona tutto", +"New document": "Nuovo documento", +"Ok": "OK", +"Cancel": "Annulla", +"Visual aids": "Aiuti visivi", +"Bold": "Grassetto", +"Italic": "Corsivo", +"Underline": "Sottolineato", +"Strikethrough": "Barrato", +"Superscript": "Apice", +"Subscript": "Pedice", +"Clear formatting": "Cancella la formattazione", +"Align left": "Allinea a sinistra", +"Align center": "Allinea al centro", +"Align right": "Allinea a destra", +"Justify": "Giustifica", +"Bullet list": "Elenco puntato", +"Numbered list": "Elenco numerato", +"Decrease indent": "Riduci rientro", +"Increase indent": "Aumenta rientro", +"Close": "Chiudi", +"Formats": "Formati", +"Your browser doesn't support direct access to the clipboard. Please use the Ctrl+X\/C\/V keyboard shortcuts instead.": "Il browser non supporta l'accesso diretto alla cartella degli appunti. Usare i tasti di scelta rapida Ctrl+X\/C\/V.", +"Headers": "Intestazioni", +"Header 1": "Intestazione 1", +"Header 2": "Intestazione 2", +"Header 3": "Intestazione 3", +"Header 4": "Intestazione 4", +"Header 5": "Intestazione 5", +"Header 6": "Intestazione 6", +"Headings": "Titoli", +"Heading 1": "Titolo 1", +"Heading 2": "Titolo 2", +"Heading 3": "Titolo 3", +"Heading 4": "Titolo 4", +"Heading 5": "Titolo 5", +"Heading 6": "Titolo 6", +"Preformatted": "Preformattato", +"Div": "Div", +"Pre": "Pre", +"Code": "Codice", +"Paragraph": "Paragrafo", +"Blockquote": "Blockquote", +"Inline": "In linea", +"Blocks": "Blocchi", +"Paste is now in plain text mode. Contents will now be pasted as plain text until you toggle this option off.": "Incolla \u00e8 in modalit\u00e0 testo normale. I contenuti saranno incollati come testo normale se non viene disattivata questa opzione.", +"Fonts": "Caratteri", +"Font Sizes": "Dimensioni caratteri", +"Class": "Classe", +"Browse for an image": "Cerca un'immagine", +"OR": "OPPURE", +"Drop an image here": "Rilasciare un'immagine qui", +"Upload": "Carica", +"Block": "Blocco", +"Align": "Allinea", +"Default": "Predefinito", +"Circle": "Circolo", +"Disc": "Disco", +"Square": "Quadrato", +"Lower Alpha": "Alfabetico minuscolo", +"Lower Greek": "Greco minuscolo", +"Lower Roman": "Romano minuscolo", +"Upper Alpha": "Alfabetico maiuscolo", +"Upper Roman": "Romano maiuscolo", +"Anchor...": "Ancoraggio...", +"Name": "Nome", +"Id": "ID", +"Id should start with a letter, followed only by letters, numbers, dashes, dots, colons or underscores.": "L'ID dovrebbe cominciare con una lettera, seguita unicamente da lettere, numeri, linee, punti, due punti o caratteri di sottolineatura.", +"You have unsaved changes are you sure you want to navigate away?": "Ci sono modifiche non salvate, si \u00e8 sicuro di volere uscire?", +"Restore last draft": "Ripristina l'ultima bozza", +"Special characters...": "Caratteri speciali...", +"Source code": "Codice sorgente", +"Insert\/Edit code sample": "Inserisci\/modifica esempio di codice", +"Language": "Lingua", +"Code sample...": "Esempio di codice...", +"Color Picker": "Selezione colori", +"R": "R", +"G": "G", +"B": "B", +"Left to right": "Da sinistra a destra", +"Right to left": "Da destra a sinistra", +"Emoticons...": "Emoticon...", +"Metadata and Document Properties": "Metadata e propriet\u00e0 del documento", +"Title": "Titolo", +"Keywords": "Parole chiave", +"Description": "Descrizione", +"Robots": "Robot", +"Author": "Autore", +"Encoding": "Codifica", +"Fullscreen": "A tutto schermo", +"Action": "Azione", +"Shortcut": "Collegamento", +"Help": "Guida", +"Address": "Indirizzo", +"Focus to menubar": "Imposta stato attivo per la barra dei menu", +"Focus to toolbar": "Imposta stato attivo per la barra degli strumenti", +"Focus to element path": "Imposta stato attivo per il percorso dell'elemento", +"Focus to contextual toolbar": "Imposta stato attivo per la barra degli strumenti contestuale", +"Insert link (if link plugin activated)": "Inserisci un collegamento (se \u00e8 attivato l'apposito plugin)", +"Save (if save plugin activated)": "Salva (se \u00e8 attivato l'apposito plugin)", +"Find (if searchreplace plugin activated)": "Trova (se \u00e8 attivato l'apposito plugin)", +"Plugins installed ({0}):": "Plugin installati ({0}):", +"Premium plugins:": "Plugin Premium:", +"Learn more...": "Maggiori informazioni...", +"You are using {0}": "Si sta utilizzando {0}", +"Plugins": "Plugin", +"Handy Shortcuts": "Scorciatoie utili", +"Horizontal line": "Linea orizzontale", +"Insert\/edit image": "Inserisci\/modifica immagine", +"Image description": "Descrizione immagine", +"Source": "Fonte", +"Dimensions": "Dimensioni", +"Constrain proportions": "Mantieni proporzioni", +"General": "Generali", +"Advanced": "Avanzate", +"Style": "Stile", +"Vertical space": "Spazio verticale", +"Horizontal space": "Spazio orizzontale", +"Border": "Bordo", +"Insert image": "Inserisci immagine", +"Image...": "Immagine...", +"Image list": "Elenco immagini", +"Rotate counterclockwise": "Ruota in senso antiorario", +"Rotate clockwise": "Ruota in senso orario", +"Flip vertically": "Rifletti verticalmente", +"Flip horizontally": "Rifletti orizzontalmente", +"Edit image": "Modifica immagine", +"Image options": "Opzioni immagine", +"Zoom in": "Ingrandisci", +"Zoom out": "Zoom indietro", +"Crop": "Ritaglia", +"Resize": "Ridimensiona", +"Orientation": "Orientamento", +"Brightness": "Luminosit\u00e0", +"Sharpen": "Nitidezza", +"Contrast": "Contrasto", +"Color levels": "Livelli di colore", +"Gamma": "Gamma", +"Invert": "Inverti", +"Apply": "Applica", +"Back": "Indietro", +"Insert date\/time": "Inserisci data\/ora", +"Date\/time": "Data\/ora", +"Insert\/Edit Link": "Inserisci\/modifica collegamento", +"Insert\/edit link": "Inserisci\/modifica collegamento", +"Text to display": "Testo da visualizzare", +"Url": "URL", +"Open link in...": "Apri collegamento in...", +"Current window": "Finestra corrente", +"None": "Nessuno", +"New window": "Nuova finestra", +"Remove link": "Rimuovi collegamento", +"Anchors": "Ancoraggi", +"Link...": "Collegamento...", +"Paste or type a link": "Incolla o digita un collegamento", +"The URL you entered seems to be an email address. Do you want to add the required mailto: prefix?": "L'URL inserito sembra essere un indirizzo email. Si vuole aggiungere il necessario prefisso mailto:?", +"The URL you entered seems to be an external link. Do you want to add the required http:\/\/ prefix?": "L'URL inserito sembra essere un collegamento esterno. Si vuole aggiungere il necessario prefisso http:\/\/?", +"Link list": "Elenco collegamenti", +"Insert video": "Inserisci video", +"Insert\/edit video": "Inserisci\/modifica video", +"Insert\/edit media": "Inserisci\/modifica oggetti multimediali", +"Alternative source": "Sorgente alternativa", +"Alternative source URL": "URL sorgente alternativa", +"Media poster (Image URL)": "Poster dell'oggetto multimediale (URL dell'immagine)", +"Paste your embed code below:": "Incolla il codice d'incorporamento di seguito:", +"Embed": "Incorpora", +"Media...": "Oggetto multimediale...", +"Nonbreaking space": "Spazio indivisibile", +"Page break": "Interruzione di pagina", +"Paste as text": "Incolla senza formattazioni", +"Preview": "Anteprima", +"Print...": "Stampa...", +"Save": "Salva", +"Find": "Trova", +"Replace with": "Sostituisci con", +"Replace": "Sostituisci", +"Replace all": "Sostituisci tutto", +"Previous": "Indietro", +"Next": "Avanti", +"Find and replace...": "Trova e sostituisci...", +"Could not find the specified string.": "Impossibile trovare la stringa specificata.", +"Match case": "Maiuscole\/minuscole", +"Find whole words only": "Trova solo parole intere", +"Spell check": "Controllo ortografia", +"Ignore": "Ignora", +"Ignore all": "Ignora tutto", +"Finish": "Fine", +"Add to Dictionary": "Aggiungi al dizionario", +"Insert table": "Inserisci tabella", +"Table properties": "Propriet\u00e0 della tabella", +"Delete table": "Elimina tabella", +"Cell": "Cella", +"Row": "Riga", +"Column": "Colonna", +"Cell properties": "Propriet\u00e0 cella", +"Merge cells": "Unisci le celle", +"Split cell": "Dividi la cella", +"Insert row before": "Inserisci riga prima", +"Insert row after": "Inserisci riga dopo", +"Delete row": "Elimina riga", +"Row properties": "Propriet\u00e0 della riga", +"Cut row": "Taglia riga", +"Copy row": "Copia riga", +"Paste row before": "Incolla riga prima", +"Paste row after": "Incolla riga dopo", +"Insert column before": "Inserisci colonna prima", +"Insert column after": "Inserisci colonna dopo", +"Delete column": "Elimina colonna", +"Cols": "Colonne", +"Rows": "Righe", +"Width": "Larghezza", +"Height": "Altezza", +"Cell spacing": "Spaziatura tra celle", +"Cell padding": "Spaziatura interna celle", +"Show caption": "Mostra didascalia", +"Left": "Sinistra", +"Center": "Centro", +"Right": "Destra", +"Cell type": "Tipo di cella", +"Scope": "Ambito", +"Alignment": "Allineamento", +"H Align": "Allineamento H", +"V Align": "Allineamento V", +"Top": "In alto", +"Middle": "Centrato", +"Bottom": "In basso", +"Header cell": "Cella d'intestazione", +"Row group": "Gruppo di righe", +"Column group": "Gruppo di colonne", +"Row type": "Tipo di riga", +"Header": "Intestazione", +"Body": "Corpo", +"Footer": "Pi\u00e8 di pagina", +"Border color": "Colore del bordo", +"Insert template...": "Inserisci modello...", +"Templates": "Modelli", +"Template": "Modello", +"Text color": "Colore testo", +"Background color": "Colore dello sfondo", +"Custom...": "Personalizzato...", +"Custom color": "Colore personalizzato", +"No color": "Nessun colore", +"Remove color": "Rimuovi colore", +"Table of Contents": "Sommario", +"Show blocks": "Mostra blocchi", +"Show invisible characters": "Mostra caratteri invisibili", +"Word count": "Conteggio parole", +"Words: {0}": "Parole: {0}", +"{0} words": "{0} parole", +"File": "File", +"Edit": "Modifica", +"Insert": "Inserisci", +"View": "Visualizza", +"Format": "Formato", +"Table": "Tabella", +"Tools": "Strumenti", +"Powered by {0}": "Con tecnologia {0}", +"Rich Text Area. Press ALT-F9 for menu. Press ALT-F10 for toolbar. Press ALT-0 for help": "Area di testo RTF. Premere ALT-F9 per il menu. Premere ALT-F10 per la barra degli strumenti. Premere ALT-0 per la guida.", +"Image title": "Titolo immagine", +"Border width": "Larghezza del bordo", +"Border style": "Stile del bordo", +"Error": "Errore", +"Warn": "Avviso", +"Valid": "Valido", +"To open the popup, press Shift+Enter": "Per aprire il popup, premere Shift+Invio", +"Rich Text Area. Press ALT-0 for help.": "Area di testo RTF. Premere ALT-0 per la guida.", +"System Font": "Carattere di sistema", +"Failed to upload image: {0}": "Caricamento immagine fallito: {0}", +"Failed to load plugin: {0} from url {1}": "Caricamento plugin fallito: {0} dall'URL {1}", +"Failed to load plugin url: {0}": "Caricamento URL plugin fallito: {0}", +"Failed to initialize plugin: {0}": "Inizializzazione plugin fallita: {0}", +"example": "esempio", +"Search": "Cerca", +"All": "Tutto", +"Currency": "Valuta", +"Text": "Testo", +"Quotations": "Citazioni", +"Mathematical": "Caratteri matematici", +"Extended Latin": "Latino esteso", +"Symbols": "Simboli", +"Arrows": "Frecce", +"User Defined": "Definito dall'utente", +"dollar sign": "simbolo del dollaro", +"currency sign": "simbolo di valuta", +"euro-currency sign": "simbolo dell'euro", +"colon sign": "simbolo del col\u00f3n", +"cruzeiro sign": "simbolo del cruzeiro", +"french franc sign": "simbolo del franco francese", +"lira sign": "simbolo della lira", +"mill sign": "simbolo del mill", +"naira sign": "simbolo della naira", +"peseta sign": "simbolo della peseta", +"rupee sign": "simbolo della rup\u00eca", +"won sign": "simbolo del won", +"new sheqel sign": "simbolo del nuovo shekel", +"dong sign": "simbolo del dong", +"kip sign": "simbolo del kip", +"tugrik sign": "simbolo del tugrik", +"drachma sign": "simbolo della dracma", +"german penny symbol": "simbolo del pfennig tedesco", +"peso sign": "simbolo del peso", +"guarani sign": "simbolo del guaran\u00ec", +"austral sign": "simbolo dell'austral", +"hryvnia sign": "simbolo della hryvnia", +"cedi sign": "simbolo del cedi", +"livre tournois sign": "simbolo della lira di Tours", +"spesmilo sign": "simbolo dello spesmilo", +"tenge sign": "simbolo del tenge", +"indian rupee sign": "simbolo della rup\u00eca indiana", +"turkish lira sign": "simbolo della lira turca", +"nordic mark sign": "simbolo del marco nordico", +"manat sign": "simbolo del manat", +"ruble sign": "simbolo del rublo", +"yen character": "simbolo dello yen", +"yuan character": "simbolo dello yuan", +"yuan character, in hong kong and taiwan": "simbolo dello yuan, Hong Kong e Taiwan", +"yen\/yuan character variant one": "simbolo yen\/yuan variante uno", +"Loading emoticons...": "Caricamento emoticon in corso", +"Could not load emoticons": "Impossibile caricare emoticon", +"People": "Persone", +"Animals and Nature": "Animali e natura", +"Food and Drink": "Cibi e bevande", +"Activity": "Attivit\u00e0", +"Travel and Places": "Viaggi e luoghi", +"Objects": "Oggetti", +"Flags": "Bandiere", +"Characters": "Caratteri", +"Characters (no spaces)": "Caratteri (senza spazi)", +"Error: Form submit field collision.": "Errore: Conflitto di campi nel modulo inviato.", +"Error: No form element found.": "Errore: Nessun elemento di modulo trovato.", +"Update": "Aggiorna", +"Color swatch": "Campione di colore", +"Turquoise": "Turchese", +"Green": "Verde", +"Blue": "Blu", +"Purple": "Viola", +"Navy Blue": "Blu scuro", +"Dark Turquoise": "Turchese scuro", +"Dark Green": "Verde scuro", +"Medium Blue": "Blu medio", +"Medium Purple": "Viola medio", +"Midnight Blue": "Blu notte", +"Yellow": "Giallo", +"Orange": "Arancio", +"Red": "Rosso", +"Light Gray": "Grigio chiaro", +"Gray": "Grigio", +"Dark Yellow": "Giallo scuro", +"Dark Orange": "Arancio scuro", +"Dark Red": "Rosso scuro", +"Medium Gray": "Grigio medio", +"Dark Gray": "Grigio scuro", +"Black": "Nero", +"White": "Bianco", +"Switch to or from fullscreen mode": "Attiva\/disattiva la modalit\u00e0 schermo intero", +"Open help dialog": "Apri la finestra di aiuto", +"history": "cronologia", +"styles": "stili", +"formatting": "formattazione", +"alignment": "allineamento", +"indentation": "indentazione", +"permanent pen": "penna indelebile", +"comments": "commenti" +}); \ No newline at end of file diff --git a/frontend/public/static/tinymce/lang/pl.js b/frontend/public/static/tinymce/lang/pl.js new file mode 100644 index 000000000..63ae27f6f --- /dev/null +++ b/frontend/public/static/tinymce/lang/pl.js @@ -0,0 +1,419 @@ +tinymce.addI18n('pl',{ +"Redo": "Powt\u00f3rz", +"Undo": "Cofnij", +"Cut": "Wytnij", +"Copy": "Kopiuj", +"Paste": "Wklej", +"Select all": "Zaznacz wszystko", +"New document": "Nowy dokument", +"Ok": "Ok", +"Cancel": "Anuluj", +"Visual aids": "Pomoce wizualne", +"Bold": "Pogrubienie", +"Italic": "Kursywa", +"Underline": "Podkre\u015blenie", +"Strikethrough": "Przekre\u015blenie", +"Superscript": "Indeks g\u00f3rny", +"Subscript": "Indeks dolny", +"Clear formatting": "Wyczy\u015b\u0107 formatowanie", +"Align left": "Wyr\u00f3wnaj do lewej", +"Align center": "Wyr\u00f3wnaj do \u015brodka", +"Align right": "Wyr\u00f3wnaj do prawej", +"Justify": "Wyjustuj", +"Bullet list": "Lista wypunktowana", +"Numbered list": "Lista numerowana", +"Decrease indent": "Zmniejsz wci\u0119cie", +"Increase indent": "Zwi\u0119ksz wci\u0119cie", +"Close": "Zamknij", +"Formats": "Formaty", +"Your browser doesn't support direct access to the clipboard. Please use the Ctrl+X\/C\/V keyboard shortcuts instead.": "Twoja przegl\u0105darka nie obs\u0142uguje bezpo\u015bredniego dost\u0119pu do schowka. U\u017cyj zamiast tego kombinacji klawiszy Ctrl+X\/C\/V.", +"Headers": "Nag\u0142\u00f3wki", +"Header 1": "Nag\u0142\u00f3wek 1", +"Header 2": "Nag\u0142\u00f3wek 2", +"Header 3": "Nag\u0142\u00f3wek 3", +"Header 4": "Nag\u0142\u00f3wek 4", +"Header 5": "Nag\u0142\u00f3wek 5", +"Header 6": "Nag\u0142\u00f3wek 6", +"Headings": "Nag\u0142\u00f3wki", +"Heading 1": "Nag\u0142\u00f3wek 1", +"Heading 2": "Nag\u0142\u00f3wek 2", +"Heading 3": "Nag\u0142\u00f3wek 3", +"Heading 4": "Nag\u0142\u00f3wek 4", +"Heading 5": "Nag\u0142\u00f3wek 5", +"Heading 6": "Nag\u0142\u00f3wek 6", +"Preformatted": "Wst\u0119pne formatowanie", +"Div": "Div", +"Pre": "Pre", +"Code": "Kod", +"Paragraph": "Akapit", +"Blockquote": "Blok cytatu", +"Inline": "W tek\u015bcie", +"Blocks": "Bloki", +"Paste is now in plain text mode. Contents will now be pasted as plain text until you toggle this option off.": "Wklejanie jest w trybie tekstowym. Zawarto\u015b\u0107 zostanie wklejona jako zwyk\u0142y tekst dop\u00f3ki nie wy\u0142\u0105czysz tej opcji.", +"Fonts": "Fonty", +"Font Sizes": "Rozmiar fontu", +"Class": "Klasa", +"Browse for an image": "Przegl\u0105daj za zdj\u0119ciem", +"OR": "LUB", +"Drop an image here": "Upu\u015b\u0107 obraz tutaj", +"Upload": "Prze\u015blij", +"Block": "Zablokuj", +"Align": "Wyr\u00f3wnaj", +"Default": "Domy\u015blne", +"Circle": "K\u00f3\u0142ko", +"Disc": "Dysk", +"Square": "Kwadrat", +"Lower Alpha": "Ma\u0142e litery", +"Lower Greek": "Ma\u0142e greckie", +"Lower Roman": "Ma\u0142e rzymskie", +"Upper Alpha": "Wielkie litery", +"Upper Roman": "Wielkie rzymskie", +"Anchor...": "Kotwica...", +"Name": "Nazwa", +"Id": "Identyfikator", +"Id should start with a letter, followed only by letters, numbers, dashes, dots, colons or underscores.": "Identyfikator powinien zaczyna\u0107 si\u0119 liter\u0105, dozwolone s\u0105 tylko litery, numery, uko\u015bniki, kropki, dwukropki i podkre\u015blniki - tzw. pod\u0142ogi", +"You have unsaved changes are you sure you want to navigate away?": "Masz niezapisane zmiany. Czy na pewno chcesz opu\u015bci\u0107 stron\u0119?", +"Restore last draft": "Przywr\u00f3\u0107 ostatni szkic", +"Special character...": "Znak specjalny...", +"Source code": "Kod \u017ar\u00f3d\u0142owy", +"Insert\/Edit code sample": "Dodaj\/Edytuj przyk\u0142adowy kod", +"Language": "J\u0119zyk", +"Code sample...": "Przyk\u0142ad kodu...", +"Color Picker": "Selektor kolor\u00f3w", +"R": "R", +"G": "G", +"B": "B", +"Left to right": "Od lewej do prawej", +"Right to left": "Od prawej do lewej", +"Emoticons...": "Emotikony...", +"Metadata and Document Properties": "Metadane i w\u0142a\u015bciwo\u015bci dokumentu", +"Title": "Tytu\u0142", +"Keywords": "S\u0142owa kluczowe", +"Description": "Opis", +"Robots": "Roboty", +"Author": "Autor", +"Encoding": "Kodowanie", +"Fullscreen": "Pe\u0142ny ekran", +"Action": "Akcja", +"Shortcut": "Skr\u00f3t", +"Help": "Pomoc", +"Address": "Adres", +"Focus to menubar": "Skup si\u0119 na pasku menu", +"Focus to toolbar": "Skupi\u0107 si\u0119 na pasku", +"Focus to element path": "Skup si\u0119 na \u015bcie\u017cce elementu", +"Focus to contextual toolbar": "Skupi\u0107 si\u0119 na pasku narz\u0119dzi kontekstowych", +"Insert link (if link plugin activated)": "Wstaw \u0142\u0105cze (je\u015bli w\u0142\u0105czysz wtyczk\u0119 link\u00f3w)", +"Save (if save plugin activated)": "Zapisz (je\u015bli aktywowana jest wtyczka do zapisu)", +"Find (if searchreplace plugin activated)": "Znajd\u017a (je\u015bli w\u0142\u0105czysz wtyczk\u0119 do wyszukiwania)", +"Plugins installed ({0}):": "Zainstalowane wtyczki ({0}):", +"Premium plugins:": "Wtyczki Premium:", +"Learn more...": "Dowiedz si\u0119 wi\u0119cej...", +"You are using {0}": "U\u017cywasz {0}", +"Plugins": "Pluginy", +"Handy Shortcuts": "Przydatne skr\u00f3ty", +"Horizontal line": "Pozioma linia", +"Insert\/edit image": "Wstaw\/edytuj obrazek", +"Image description": "Opis obrazka", +"Source": "\u0179r\u00f3d\u0142o", +"Dimensions": "Wymiary", +"Constrain proportions": "Zachowaj proporcje", +"General": "Og\u00f3lne", +"Advanced": "Zaawansowane", +"Style": "Styl", +"Vertical space": "Odst\u0119p pionowy", +"Horizontal space": "Odst\u0119p poziomy", +"Border": "Ramka", +"Insert image": "Wstaw obrazek", +"Image...": "Obraz...", +"Image list": "Lista obrazk\u00f3w", +"Rotate counterclockwise": "Obr\u00f3\u0107 w lewo", +"Rotate clockwise": "Obr\u00f3\u0107 w prawo", +"Flip vertically": "Przerzu\u0107 w pionie", +"Flip horizontally": "Przerzu\u0107 w poziomie", +"Edit image": "Edytuj obrazek", +"Image options": "Opcje obrazu", +"Zoom in": "Powi\u0119ksz", +"Zoom out": "Pomniejsz", +"Crop": "Przytnij", +"Resize": "Zmiana rozmiaru", +"Orientation": "Orientacja", +"Brightness": "Jasno\u015b\u0107", +"Sharpen": "Wyostrz", +"Contrast": "Kontrast", +"Color levels": "Poziom koloru", +"Gamma": "Gamma", +"Invert": "Odwr\u00f3\u0107", +"Apply": "Zaakceptuj", +"Back": "Cofnij", +"Insert date\/time": "Wstaw dat\u0119\/czas", +"Date\/time": "Data\/Czas", +"Insert\/Edit Link": "Wstaw\/Edytuj \u0142\u0105cze", +"Insert\/edit link": "Wstaw\/edytuj \u0142\u0105cze", +"Text to display": "Tekst do wy\u015bwietlenia", +"Url": "URL", +"Open link in...": "Otw\u00f3rz \u0142\u0105cze w...", +"Current window": "Bie\u017c\u0105ce okno", +"None": "\u017baden", +"New window": "Nowe okno", +"Remove link": "Usu\u0144 \u0142\u0105cze", +"Anchors": "Kotwice", +"Link...": "\u0141\u0105cze...", +"Paste or type a link": "Wklej lub wpisz adres \u0142\u0105cza", +"The URL you entered seems to be an email address. Do you want to add the required mailto: prefix?": "URL, kt\u00f3ry wprowadzi\u0142e\u015b wygl\u0105da na adres e-mail. Czy chcesz doda\u0107 mailto: jako prefiks?", +"The URL you entered seems to be an external link. Do you want to add the required http:\/\/ prefix?": "URL, kt\u00f3ry wprowadzi\u0142e\u015b wygl\u0105da na link zewn\u0119trzny. Czy chcesz doda\u0107 http:\/\/ jako prefiks?", +"Link list": "Lista link\u00f3w", +"Insert video": "Wstaw wideo", +"Insert\/edit video": "Wstaw\/edytuj wideo", +"Insert\/edit media": "Wstaw\/Edytuj media", +"Alternative source": "Alternatywne \u017ar\u00f3d\u0142o", +"Alternative source URL": "Alternatywny URL \u017ar\u00f3d\u0142a", +"Media poster (Image URL)": "Plakat (URL obrazu)", +"Paste your embed code below:": "Wklej tutaj kod do osadzenia:", +"Embed": "Osad\u017a", +"Media...": "Multimedia...", +"Nonbreaking space": "Nie\u0142amliwa spacja", +"Page break": "Podzia\u0142 strony", +"Paste as text": "Wklej jako zwyk\u0142y tekst", +"Preview": "Podgl\u0105d", +"Print...": "Drukuj...", +"Save": "Zapisz", +"Find": "Znajd\u017a", +"Replace with": "Zamie\u0144 na", +"Replace": "Zamie\u0144", +"Replace all": "Zamie\u0144 wszystko", +"Previous": "Poprzedni", +"Next": "Nast.", +"Find and replace...": "Znajd\u017a i zamie\u0144...", +"Could not find the specified string.": "Nie znaleziono szukanego tekstu.", +"Match case": "Dopasuj wielko\u015b\u0107 liter", +"Find whole words only": "Znajd\u017a tylko ca\u0142e wyrazy", +"Spell check": "Sprawd\u017a pisowni\u0119", +"Ignore": "Ignoruj", +"Ignore all": "Ignoruj wszystko", +"Finish": "Zako\u0144cz", +"Add to Dictionary": "Dodaj do s\u0142ownika", +"Insert table": "Wstaw tabel\u0119", +"Table properties": "W\u0142a\u015bciwo\u015bci tabeli", +"Delete table": "Usu\u0144 tabel\u0119", +"Cell": "Kom\u00f3rka", +"Row": "Wiersz", +"Column": "Kolumna", +"Cell properties": "W\u0142a\u015bciwo\u015bci kom\u00f3rki", +"Merge cells": "\u0141\u0105cz kom\u00f3rki", +"Split cell": "Podziel kom\u00f3rk\u0119", +"Insert row before": "Wstaw wiersz przed", +"Insert row after": "Wstaw wiersz po", +"Delete row": "Usu\u0144 wiersz", +"Row properties": "W\u0142a\u015bciwo\u015bci wiersza", +"Cut row": "Wytnij wiersz", +"Copy row": "Kopiuj wiersz", +"Paste row before": "Wklej wiersz przed", +"Paste row after": "Wklej wiersz po", +"Insert column before": "Wstaw kolumn\u0119 przed", +"Insert column after": "Wstaw kolumn\u0119 po", +"Delete column": "Usu\u0144 kolumn\u0119", +"Cols": "Kol.", +"Rows": "Wiersz.", +"Width": "Szeroko\u015b\u0107", +"Height": "Wysoko\u015b\u0107", +"Cell spacing": "Odst\u0119py kom\u00f3rek", +"Cell padding": "Dope\u0142nienie kom\u00f3rki", +"Show caption": "Poka\u017c podpis", +"Left": "Lewo", +"Center": "\u015arodek", +"Right": "Prawo", +"Cell type": "Typ kom\u00f3rki", +"Scope": "Kontekst", +"Alignment": "Wyr\u00f3wnanie", +"H Align": "Wyr\u00f3wnanie w pionie", +"V Align": "Wyr\u00f3wnanie w poziomie", +"Top": "G\u00f3ra", +"Middle": "\u015arodek", +"Bottom": "D\u00f3\u0142", +"Header cell": "Kom\u00f3rka nag\u0142\u00f3wka", +"Row group": "Grupa wierszy", +"Column group": "Grupa kolumn", +"Row type": "Typ wiersza", +"Header": "Nag\u0142\u00f3wek", +"Body": "Tre\u015b\u0107", +"Footer": "Stopka", +"Border color": "Kolor ramki", +"Insert template...": "Wstaw szablon...", +"Templates": "Szablony", +"Template": "Szablon", +"Text color": "Kolor tekstu", +"Background color": "Kolor t\u0142a", +"Custom...": "Niestandardowy...", +"Custom color": "Kolor niestandardowy", +"No color": "Bez koloru", +"Remove color": "Usu\u0144 kolor", +"Table of Contents": "Spis tre\u015bci", +"Show blocks": "Poka\u017c bloki", +"Show invisible characters": "Poka\u017c niewidoczne znaki", +"Word count": "Liczba s\u0142\u00f3w", +"Count": "Liczba", +"Document": "Dokument", +"Selection": "Zaznaczenie", +"Words": "S\u0142owa", +"Words: {0}": "S\u0142\u00f3w: {0}", +"{0} words": "{0} s\u0142\u00f3w", +"File": "Plik", +"Edit": "Edycja", +"Insert": "Wstaw", +"View": "Widok", +"Format": "Format", +"Table": "Tabela", +"Tools": "Narz\u0119dzia", +"Powered by {0}": "Powered by {0}", +"Rich Text Area. Press ALT-F9 for menu. Press ALT-F10 for toolbar. Press ALT-0 for help": "Obszar Edycji. ALT-F9 - menu. ALT-F10 - pasek narz\u0119dzi. ALT-0 - pomoc", +"Image title": "Tytu\u0142 obrazu", +"Border width": "Grubo\u015b\u0107 ramki", +"Border style": "Styl ramki", +"Error": "B\u0142\u0105d", +"Warn": "Ostrze\u017cenie", +"Valid": "Prawid\u0142owe", +"To open the popup, press Shift+Enter": "Aby otworzy\u0107 okienko, naci\u015bnij Shift+Enter", +"Rich Text Area. Press ALT-0 for help.": "Obszar tekstu sformatowanego. Naci\u015bnij ALT-0, aby uzyska\u0107 pomoc.", +"System Font": "Font systemowy", +"Failed to upload image: {0}": "Nie uda\u0142o si\u0119 przes\u0142a\u0107 obrazu: {0}", +"Failed to load plugin: {0} from url {1}": "Nie uda\u0142o si\u0119 za\u0142adowa\u0107 dodatku: {0} spod adresu url {1}", +"Failed to load plugin url: {0}": "Nie uda\u0142o si\u0119 za\u0142adowa\u0107 adresu url dodatku: {0}", +"Failed to initialize plugin: {0}": "Nie mo\u017cna zainicjowa\u0107 dodatku: {0}", +"example": "przyk\u0142ad", +"Search": "Wyszukaj", +"All": "Wszystkie", +"Currency": "Waluta", +"Text": "Tekst", +"Quotations": "Cudzys\u0142owy", +"Mathematical": "Matematyczne", +"Extended Latin": "Rozszerzony \u0142aci\u0144ski", +"Symbols": "Symbole", +"Arrows": "Strza\u0142ki", +"User Defined": "W\u0142asny", +"dollar sign": "znak dolara", +"currency sign": "znak waluty", +"euro-currency sign": "znak euro", +"colon sign": "znak colon", +"cruzeiro sign": "znak cruzeiro", +"french franc sign": "znak franka francuskiego", +"lira sign": "znak liry", +"mill sign": "znak mill", +"naira sign": "znak nairy", +"peseta sign": "znak pesety", +"rupee sign": "znak rupii", +"won sign": "znak wona", +"new sheqel sign": "znak nowego szekla", +"dong sign": "znak donga", +"kip sign": "znak kipa", +"tugrik sign": "znak tugrika", +"drachma sign": "znak drachmy", +"german penny symbol": "znak feniga", +"peso sign": "znak peso", +"guarani sign": "znak guarani", +"austral sign": "znak australa", +"hryvnia sign": "znak hrywny", +"cedi sign": "znak cedi", +"livre tournois sign": "znak livre tournois", +"spesmilo sign": "znak spesmilo", +"tenge sign": "znak tenge", +"indian rupee sign": "znak rupii indyjskiej", +"turkish lira sign": "znak liry tureckiej", +"nordic mark sign": "znak nordic mark", +"manat sign": "znak manata", +"ruble sign": "znak rubla", +"yen character": "znak jena", +"yuan character": "znak juana", +"yuan character, in hong kong and taiwan": "znak juana w Hongkongu i na Tajwanie", +"yen\/yuan character variant one": "jen\/juan, wariant pierwszy", +"Loading emoticons...": "\u0141adowanie emotikon\u00f3w...", +"Could not load emoticons": "Nie mo\u017cna za\u0142adowa\u0107 emotikon\u00f3w", +"People": "Ludzie", +"Animals and Nature": "Zwierz\u0119ta i natura", +"Food and Drink": "Jedzenie i picie", +"Activity": "Aktywno\u015b\u0107", +"Travel and Places": "Podr\u00f3\u017ce i miejsca", +"Objects": "Obiekty", +"Flags": "Flagi", +"Characters": "Znaki", +"Characters (no spaces)": "Znaki (bez spacji)", +"{0} characters": "{0} znak\u00f3w", +"Error: Form submit field collision.": "B\u0142\u0105d: kolizja pola przesy\u0142ania formularza.", +"Error: No form element found.": "B\u0142\u0105d: nie znaleziono elementu formularza.", +"Update": "Aktualizuj", +"Color swatch": "Pr\u00f3bka koloru", +"Turquoise": "Turkusowy", +"Green": "Zielony", +"Blue": "Niebieski", +"Purple": "Purpurowy", +"Navy Blue": "Ciemnoniebieski", +"Dark Turquoise": "Ciemnoturkusowy", +"Dark Green": "Ciemnozielony", +"Medium Blue": "\u015arednioniebieski", +"Medium Purple": "\u015aredniopurpurowy", +"Midnight Blue": "Nocny b\u0142\u0119kit", +"Yellow": "\u017b\u00f3\u0142ty", +"Orange": "Pomara\u0144czowy", +"Red": "Czerwony", +"Light Gray": "Jasnoszary", +"Gray": "Szary", +"Dark Yellow": "Ciemno\u017c\u00f3\u0142ty", +"Dark Orange": "Ciemnopomara\u0144czowy", +"Dark Red": "Ciemnoczerwony", +"Medium Gray": "\u015arednioszary", +"Dark Gray": "Ciemnoszary", +"Light Green": "Jasnozielony", +"Light Yellow": "Jasno\u017c\u00f3\u0142ty", +"Light Red": "Jasnoczerwony", +"Light Purple": "Jasnopurpurowy", +"Light Blue": "Jasnoniebieski", +"Dark Purple": "Ciemnopurpurowy", +"Dark Blue": "Ciemnoniebieski", +"Black": "Czarny", +"White": "Bia\u0142y", +"Switch to or from fullscreen mode": "W\u0142\u0105cz lub wy\u0142\u0105cz tryb pe\u0142noekranowy", +"Open help dialog": "Otw\u00f3rz okno dialogowe pomocy", +"history": "historia", +"styles": "style", +"formatting": "formatowanie", +"alignment": "wyr\u00f3wnanie", +"indentation": "wci\u0119cie", +"permanent pen": "marker", +"comments": "komentarze", +"Format Painter": "Malarz format\u00f3w", +"Insert\/edit iframe": "Wstaw\/edytuj iframe", +"Capitalization": "Jak w zdaniu", +"lowercase": "ma\u0142e litery", +"UPPERCASE": "WIELKIE LITERY", +"Title Case": "Jak Nazwy W\u0142asne", +"Permanent Pen Properties": "W\u0142a\u015bciwo\u015bci markera", +"Permanent pen properties...": "W\u0142a\u015bciwo\u015bci markera...", +"Font": "Font", +"Size": "Rozmiar", +"More...": "Wi\u0119cej...", +"Spellcheck Language": "J\u0119zyk sprawdzania pisowni", +"Select...": "Wybierz...", +"Preferences": "Ustawienia", +"Yes": "Tak", +"No": "Nie", +"Keyboard Navigation": "Nawigacja za pomoc\u0105 klawiatury", +"Version": "Wersja", +"Anchor": "Kotwica", +"Special character": "Znak specjalny", +"Code sample": "Przyk\u0142ad kodu \u017ar\u00f3d\u0142owego", +"Color": "Kolor", +"Emoticons": "Ikony emocji", +"Document properties": "W\u0142a\u015bciwo\u015bci dokumentu", +"Image": "Obraz", +"Insert link": "Wstaw \u0142\u0105cze", +"Target": "Cel", +"Link": "Adres \u0142\u0105cza", +"Poster": "Plakat", +"Media": "Media", +"Print": "Drukuj", +"Prev": "Poprz.", +"Find and replace": "Znajd\u017a i zamie\u0144", +"Whole words": "Ca\u0142e s\u0142owa", +"Spellcheck": "Sprawdzanie pisowni", +"Caption": "Tytu\u0142", +"Insert template": "Wstaw szablon" +}); \ No newline at end of file diff --git a/frontend/public/static/tinymce/lang/pt_BR.js b/frontend/public/static/tinymce/lang/pt_BR.js new file mode 100644 index 000000000..49ca0ce4a --- /dev/null +++ b/frontend/public/static/tinymce/lang/pt_BR.js @@ -0,0 +1,462 @@ +tinymce.addI18n('pt_BR',{ +"Redo": "Refazer", +"Undo": "Desfazer", +"Cut": "Cortar", +"Copy": "Copiar", +"Paste": "Colar", +"Select all": "Selecionar tudo", +"New document": "Novo documento", +"Ok": "Ok", +"Cancel": "Cancelar", +"Visual aids": "Ajuda visual", +"Bold": "Negrito", +"Italic": "It\u00e1lico", +"Underline": "Sublinhado", +"Strikethrough": "Tachado", +"Superscript": "Sobrescrito", +"Subscript": "Subscrito", +"Clear formatting": "Limpar formata\u00e7\u00e3o", +"Align left": "Alinhar \u00e0 esquerda", +"Align center": "Centralizar", +"Align right": "Alinhar \u00e0 direita", +"Justify": "Justificar", +"Bullet list": "Lista n\u00e3o ordenada", +"Numbered list": "Lista ordenada", +"Decrease indent": "Diminuir recuo", +"Increase indent": "Aumentar recuo", +"Close": "Fechar", +"Formats": "Formatos", +"Your browser doesn't support direct access to the clipboard. Please use the Ctrl+X\/C\/V keyboard shortcuts instead.": "Seu navegador n\u00e3o suporta acesso direto \u00e0 \u00e1rea de transfer\u00eancia. Por favor use os atalhos Ctrl+X - C - V do teclado", +"Headers": "Cabe\u00e7alhos", +"Header 1": "Cabe\u00e7alho 1", +"Header 2": "Cabe\u00e7alho 2", +"Header 3": "Cabe\u00e7alho 3", +"Header 4": "Cabe\u00e7alho 4", +"Header 5": "Cabe\u00e7alho 5", +"Header 6": "Cabe\u00e7alho 6", +"Headings": "T\u00edtulos", +"Heading 1": "T\u00edtulo 1", +"Heading 2": "T\u00edtulo 2", +"Heading 3": "T\u00edtulo 3", +"Heading 4": "T\u00edtulo 4", +"Heading 5": "T\u00edtulo 5", +"Heading 6": "T\u00edtulo 6", +"Preformatted": "Pr\u00e9-formatado", +"Div": "Div", +"Pre": "Pre", +"Code": "C\u00f3digo", +"Paragraph": "Par\u00e1grafo", +"Blockquote": "Aspas", +"Inline": "Em linha", +"Blocks": "Blocos", +"Paste is now in plain text mode. Contents will now be pasted as plain text until you toggle this option off.": "O comando colar est\u00e1 agora em modo texto plano. O conte\u00fado ser\u00e1 colado como texto plano at\u00e9 voc\u00ea desligar esta op\u00e7\u00e3o.", +"Fonts": "Fontes", +"Font Sizes": "Tamanhos da fonte", +"Class": "Classe", +"Browse for an image": "Procure uma imagem", +"OR": "OU", +"Drop an image here": "Solte uma imagem aqui", +"Upload": "Carregar", +"Block": "Bloco", +"Align": "Alinhamento", +"Default": "Padr\u00e3o", +"Circle": "C\u00edrculo", +"Disc": "Disco", +"Square": "Quadrado", +"Lower Alpha": "a. b. c. ...", +"Lower Greek": "\u03b1. \u03b2. \u03b3. ...", +"Lower Roman": "i. ii. iii. ...", +"Upper Alpha": "A. B. C. ...", +"Upper Roman": "I. II. III. ...", +"Anchor...": "\u00c2ncora...", +"Name": "Nome", +"Id": "Id", +"Id should start with a letter, followed only by letters, numbers, dashes, dots, colons or underscores.": "Id deve come\u00e7ar com uma letra, seguido apenas por letras, n\u00fameros, tra\u00e7os, pontos, dois pontos ou sublinhados.", +"You have unsaved changes are you sure you want to navigate away?": "Voc\u00ea tem mudan\u00e7as n\u00e3o salvas. Voc\u00ea tem certeza que deseja sair?", +"Restore last draft": "Restaurar \u00faltimo rascunho", +"Special character...": "Caractere especial...", +"Source code": "C\u00f3digo fonte", +"Insert\/Edit code sample": "Inserir\/Editar c\u00f3digo de exemplo", +"Language": "Idioma", +"Code sample...": "Exemplo de c\u00f3digo...", +"Color Picker": "Seletor de Cores", +"R": "R", +"G": "G", +"B": "B", +"Left to right": "Da esquerda para a direita", +"Right to left": "Da direita para a esquerda", +"Emoticons": "Emoticons", +"Emoticons...": "Emojis...", +"Metadata and Document Properties": "Metadados e Propriedades do Documento", +"Title": "T\u00edtulo", +"Keywords": "Palavras-chave", +"Description": "Descri\u00e7\u00e3o", +"Robots": "Rob\u00f4s", +"Author": "Autor", +"Encoding": "Codifica\u00e7\u00e3o", +"Fullscreen": "Tela cheia", +"Action": "A\u00e7\u00e3o", +"Shortcut": "Atalho", +"Help": "Ajuda", +"Address": "Endere\u00e7o", +"Focus to menubar": "Foco no menu", +"Focus to toolbar": "Foco na barra de ferramentas", +"Focus to element path": "Foco no caminho do elemento", +"Focus to contextual toolbar": "Foco na barra de ferramentas contextual", +"Insert link (if link plugin activated)": "Inserir link (se o plugin de link estiver ativado)", +"Save (if save plugin activated)": "Salvar (se o plugin de salvar estiver ativado)", +"Find (if searchreplace plugin activated)": "Procurar (se o plugin de procurar e substituir estiver ativado)", +"Plugins installed ({0}):": "Plugins instalados ({0}):", +"Premium plugins:": "Plugins premium:", +"Learn more...": "Saiba mais...", +"You are using {0}": "Voc\u00ea est\u00e1 usando {0}", +"Plugins": "Plugins", +"Handy Shortcuts": "Atalhos \u00fateis", +"Horizontal line": "Linha horizontal", +"Insert\/edit image": "Inserir\/editar imagem", +"Alternative description": "Descri\u00e7\u00e3o alternativa", +"Accessibility": "Acessibilidade", +"Image is decorative": "A imagem \u00e9 decorativa", +"Source": "Endere\u00e7o da imagem", +"Dimensions": "Dimens\u00f5es", +"Constrain proportions": "Manter propor\u00e7\u00f5es", +"General": "Geral", +"Advanced": "Avan\u00e7ado", +"Style": "Estilo", +"Vertical space": "Espa\u00e7amento vertical", +"Horizontal space": "Espa\u00e7amento horizontal", +"Border": "Borda", +"Insert image": "Inserir imagem", +"Image...": "Imagem...", +"Image list": "Lista de Imagens", +"Rotate counterclockwise": "Girar em sentido hor\u00e1rio", +"Rotate clockwise": "Girar em sentido anti-hor\u00e1rio", +"Flip vertically": "Virar verticalmente", +"Flip horizontally": "Virar horizontalmente", +"Edit image": "Editar imagem", +"Image options": "Op\u00e7\u00f5es de Imagem", +"Zoom in": "Aumentar zoom", +"Zoom out": "Diminuir zoom", +"Crop": "Cortar", +"Resize": "Redimensionar", +"Orientation": "Orienta\u00e7\u00e3o", +"Brightness": "Brilho", +"Sharpen": "Aumentar nitidez", +"Contrast": "Contraste", +"Color levels": "N\u00edveis de cor", +"Gamma": "Gama", +"Invert": "Inverter", +"Apply": "Aplicar", +"Back": "Voltar", +"Insert date\/time": "Inserir data\/hora", +"Date\/time": "data\/hora", +"Insert\/edit link": "Inserir\/editar link", +"Text to display": "Texto para mostrar", +"Url": "Url", +"Open link in...": "Abrir link em...", +"Current window": "Janela atual", +"None": "Nenhum", +"New window": "Nova janela", +"Open link": "Abrir link", +"Remove link": "Remover link", +"Anchors": "\u00c2ncoras", +"Link...": "Link...", +"Paste or type a link": "Cole ou digite um Link", +"The URL you entered seems to be an email address. Do you want to add the required mailto: prefix?": "The URL you entered seems to be an email address. Do you want to add the required mailto: prefix?", +"The URL you entered seems to be an external link. Do you want to add the required http:\/\/ prefix?": "A URL que voc\u00ea informou parece ser um link externo. Deseja incluir o prefixo http:\/\/?", +"The URL you entered seems to be an external link. Do you want to add the required https:\/\/ prefix?": "A URL informada parece ser um link externo. Voc\u00ea quer adicionar o prefixo necess\u00e1rio https:\/\/ ?", +"Link list": "Lista de Links", +"Insert video": "Inserir v\u00eddeo", +"Insert\/edit video": "Inserir\/editar v\u00eddeo", +"Insert\/edit media": "Inserir\/editar imagem", +"Alternative source": "Fonte alternativa", +"Alternative source URL": "Endere\u00e7o URL alternativo", +"Media poster (Image URL)": "Post de m\u00eddia (URL da Imagem)", +"Paste your embed code below:": "Insira o c\u00f3digo de incorpora\u00e7\u00e3o abaixo:", +"Embed": "Incorporar", +"Media...": "M\u00eddia...", +"Nonbreaking space": "Espa\u00e7o n\u00e3o separ\u00e1vel", +"Page break": "Quebra de p\u00e1gina", +"Paste as text": "Colar como texto", +"Preview": "Pr\u00e9-visualizar", +"Print...": "Imprimir...", +"Save": "Salvar", +"Find": "Localizar", +"Replace with": "Substituir por", +"Replace": "Substituir", +"Replace all": "Substituir tudo", +"Previous": "Anterior", +"Next": "Pr\u00f3ximo", +"Find and Replace": "Localizar e substituir", +"Find and replace...": "Encontrar e substituir...", +"Could not find the specified string.": "N\u00e3o foi poss\u00edvel encontrar o termo especificado", +"Match case": "Diferenciar mai\u00fasculas e min\u00fasculas", +"Find whole words only": "Encontrar somente palavras inteiras", +"Find in selection": "Localizar na sele\u00e7\u00e3o", +"Spellcheck": "Corretor ortogr\u00e1fico", +"Spellcheck Language": "Idioma de verifica\u00e7\u00e3o ortogr\u00e1fica", +"No misspellings found.": "Sem erros ortogr\u00e1ficos", +"Ignore": "Ignorar", +"Ignore all": "Ignorar tudo", +"Finish": "Finalizar", +"Add to Dictionary": "Adicionar ao Dicion\u00e1rio", +"Insert table": "Inserir tabela", +"Table properties": "Propriedades da tabela", +"Delete table": "Excluir tabela", +"Cell": "C\u00e9lula", +"Row": "Linha", +"Column": "Coluna", +"Cell properties": "Propriedades da c\u00e9lula", +"Merge cells": "Agrupar c\u00e9lulas", +"Split cell": "Dividir c\u00e9lula", +"Insert row before": "Inserir linha antes", +"Insert row after": "Inserir linha depois", +"Delete row": "Excluir linha", +"Row properties": "Propriedades da linha", +"Cut row": "Recortar linha", +"Copy row": "Copiar linha", +"Paste row before": "Colar linha antes", +"Paste row after": "Colar linha depois", +"Insert column before": "Inserir coluna antes", +"Insert column after": "Inserir coluna depois", +"Delete column": "Excluir coluna", +"Cols": "Colunas", +"Rows": "Linhas", +"Width": "Largura", +"Height": "Altura", +"Cell spacing": "Espa\u00e7amento da c\u00e9lula", +"Cell padding": "Espa\u00e7amento interno da c\u00e9lula", +"Caption": "Legenda", +"Show caption": "Mostrar descri\u00e7\u00e3o", +"Left": "Esquerdo", +"Center": "Centro", +"Right": "Direita", +"Cell type": "Tipo de c\u00e9lula", +"Scope": "Escopo", +"Alignment": "Alinhamento", +"H Align": "Alinhamento H", +"V Align": "Alinhamento V", +"Top": "Superior", +"Middle": "Meio", +"Bottom": "Inferior", +"Header cell": "C\u00e9lula cabe\u00e7alho", +"Row group": "Agrupar linha", +"Column group": "Agrupar coluna", +"Row type": "Tipo de linha", +"Header": "Cabe\u00e7alho", +"Body": "Corpo", +"Footer": "Rodap\u00e9", +"Border color": "Cor da borda", +"Insert template...": "Inserir modelo...", +"Templates": "Modelos", +"Template": "Modelo", +"Text color": "Cor do texto", +"Background color": "Cor do fundo", +"Custom...": "Personalizado...", +"Custom color": "Cor personalizada", +"No color": "Nenhuma cor", +"Remove color": "Remover cor", +"Table of Contents": "\u00edndice de Conte\u00fado", +"Show blocks": "Mostrar blocos", +"Show invisible characters": "Exibir caracteres invis\u00edveis", +"Word count": "Contador de palavras", +"Count": "Contar", +"Document": "Documento", +"Selection": "Sele\u00e7\u00e3o", +"Words": "Palavras", +"Words: {0}": "Palavras: {0}", +"{0} words": "{0} palavras", +"File": "Arquivo", +"Edit": "Editar", +"Insert": "Inserir", +"View": "Visualizar", +"Format": "Formatar", +"Table": "Tabela", +"Tools": "Ferramentas", +"Powered by {0}": "Distribu\u00eddo por {0}", +"Rich Text Area. Press ALT-F9 for menu. Press ALT-F10 for toolbar. Press ALT-0 for help": "\u00c1rea de texto formatado. Pressione ALT-F9 para exibir o menu, ALT-F10 para exibir a barra de ferramentas ou ALT-0 para exibir a ajuda", +"Image title": "T\u00edtulo da imagem", +"Border width": "Espessura da borda", +"Border style": "Estilo da borda", +"Error": "Erro", +"Warn": "Aviso", +"Valid": "V\u00e1lido", +"To open the popup, press Shift+Enter": "Para abrir a popup, aperte Shit+Enter", +"Rich Text Area. Press ALT-0 for help.": "\u00c1rea Rich Text. Aperte ALT-0 para ajuda.", +"System Font": "Fonte do sistema", +"Failed to upload image: {0}": "Falha no upload da imagem: {0}", +"Failed to load plugin: {0} from url {1}": "Falha ao carregar plugin: {0} da url {1}", +"Failed to load plugin url: {0}": "Falha ao carregar url do plugin: {0}", +"Failed to initialize plugin: {0}": "Falha ao inicializar plugin: {0}", +"example": "exemplo", +"Search": "Pesquisar", +"All": "Tudo", +"Currency": "Moeda", +"Text": "Texto", +"Quotations": "Cita\u00e7\u00f5es", +"Mathematical": "Matem\u00e1tico", +"Extended Latin": "Latino estendido", +"Symbols": "S\u00edmbolos", +"Arrows": "Setas", +"User Defined": "Definido pelo Usu\u00e1rio", +"dollar sign": "s\u00edmbolo de d\u00f3lar", +"currency sign": "s\u00edmbolo de moeda", +"euro-currency sign": "s\u00edmbolo de euro", +"colon sign": "s\u00edmbolo de dois pontos", +"cruzeiro sign": "s\u00edmbolo de cruzeiro", +"french franc sign": "s\u00edmbolo de franco franc\u00eas", +"lira sign": "s\u00edmbolo de lira", +"mill sign": "s\u00edmbolo do mill", +"naira sign": "s\u00edmbolo da naira", +"peseta sign": "s\u00edmbolo da peseta", +"rupee sign": "s\u00edmbolo da r\u00fapia", +"won sign": "s\u00edmbolo do won", +"new sheqel sign": "s\u00edmbolo do novo sheqel", +"dong sign": "s\u00edmbolo do dong", +"kip sign": "s\u00edmbolo do kip", +"tugrik sign": "s\u00edmbolo do tugrik", +"drachma sign": "s\u00edmbolo do drachma", +"german penny symbol": "s\u00edmbolo de centavo alem\u00e3o", +"peso sign": "s\u00edmbolo do peso", +"guarani sign": "s\u00edmbolo do guarani", +"austral sign": "s\u00edmbolo do austral", +"hryvnia sign": "s\u00edmbolo do hryvnia", +"cedi sign": "s\u00edmbolo do cedi", +"livre tournois sign": "s\u00edmbolo do livre tournois", +"spesmilo sign": "s\u00edmbolo do spesmilo", +"tenge sign": "s\u00edmbolo do tenge", +"indian rupee sign": "s\u00edmbolo de r\u00fapia indiana", +"turkish lira sign": "s\u00edmbolo de lira turca", +"nordic mark sign": "s\u00edmbolo do marco n\u00f3rdico", +"manat sign": "s\u00edmbolo do manat", +"ruble sign": "s\u00edmbolo do rublo", +"yen character": "caractere do yen", +"yuan character": "caractere do yuan", +"yuan character, in hong kong and taiwan": "caractere do yuan, em Hong Kong e Taiwan", +"yen\/yuan character variant one": "varia\u00e7\u00e3o do caractere de yen\/yuan", +"Loading emoticons...": "Carregando emojis...", +"Could not load emoticons": "N\u00e3o foi poss\u00edvel carregar emojis", +"People": "Pessoas", +"Animals and Nature": "Animais e Natureza", +"Food and Drink": "Comida e Bebida", +"Activity": "Atividade", +"Travel and Places": "Viagem e Lugares", +"Objects": "Objetos", +"Flags": "Bandeiras", +"Characters": "Caracteres", +"Characters (no spaces)": "Caracteres (sem espa\u00e7os)", +"{0} characters": "{0} caracteres", +"Error: Form submit field collision.": "Erro: colis\u00e3o de bot\u00e3o de envio do formul\u00e1rio.", +"Error: No form element found.": "Erro: elemento de formul\u00e1rio n\u00e3o encontrado.", +"Update": "Atualizar", +"Color swatch": "Amostra de cor", +"Turquoise": "Turquesa", +"Green": "Verde", +"Blue": "Azul", +"Purple": "Roxo", +"Navy Blue": "Azul marinho", +"Dark Turquoise": "Turquesa escuro", +"Dark Green": "Verde escuro", +"Medium Blue": "Azul m\u00e9dio", +"Medium Purple": "Roxo m\u00e9dio", +"Midnight Blue": "Azul meia-noite", +"Yellow": "Amarelo", +"Orange": "Laranja", +"Red": "Vermelho", +"Light Gray": "Cinza claro", +"Gray": "Cinza", +"Dark Yellow": "Amarelo escuro", +"Dark Orange": "Laranja escuro", +"Dark Red": "Vermelho escuro", +"Medium Gray": "Cinza m\u00e9dio", +"Dark Gray": "Cinza escuro", +"Light Green": "Verde claro", +"Light Yellow": "Amarelo claro", +"Light Red": "Vermelho claro", +"Light Purple": "Roxo claro", +"Light Blue": "Azul claro", +"Dark Purple": "Roxo escuro", +"Dark Blue": "Azul escuro", +"Black": "Preto", +"White": "Branco", +"Switch to or from fullscreen mode": "Abrir ou fechar modo de tela cheia", +"Open help dialog": "Abrir janela de ajuda", +"history": "hist\u00f3rico", +"styles": "estilos", +"formatting": "formata\u00e7\u00e3o", +"alignment": "alinhamento", +"indentation": "indenta\u00e7\u00e3o", +"Font": "Fonte", +"Size": "Tamanho", +"More...": "Mais...", +"Select...": "Selecionar...", +"Preferences": "Prefer\u00eancias", +"Yes": "Sim", +"No": "N\u00e3o", +"Keyboard Navigation": "Navega\u00e7\u00e3o por Teclado", +"Version": "Vers\u00e3o", +"Code view": "Ver c\u00f3digo", +"Open popup menu for split buttons": "Abrir menu popup para bot\u00f5es com divis\u00e3o", +"List Properties": "Listar Propriedades", +"List properties...": "Listar propriedades...", +"Start list at number": "Iniciar a lista no n\u00famero", +"Line height": "Altura da linha", +"comments": "coment\u00e1rios", +"Format Painter": "Pincel de Formata\u00e7\u00e3o", +"Insert\/edit iframe": "Inserir\/editar iframe", +"Capitalization": "Capitaliza\u00e7\u00e3o", +"lowercase": "min\u00fasculos", +"UPPERCASE": "MAI\u00daSCULAS", +"Title Case": "T\u00edtulo do caso", +"permanent pen": "caneta permanente", +"Permanent Pen Properties": "Propriedades da caneta permanente", +"Permanent pen properties...": "Propriedades de caneta permanentes...", +"case change": "mudar caixa", +"page embed": "embutir p\u00e1gina", +"Advanced sort...": "Ordena\u00e7\u00e3o avan\u00e7ada...", +"Advanced Sort": "Ordena\u00e7\u00e3o Avan\u00e7ada...", +"Sort table by column ascending": "Ordenar tabela por coluna ascendente", +"Sort table by column descending": "Ordenar tabela por coluna descendente", +"Sort": "Ordenar", +"Order": "Ordem", +"Sort by": "Ordenar por", +"Ascending": "Ascendente", +"Descending": "Descendente", +"Column {0}": "Coluna {0}", +"Row {0}": "Linha {0}", +"Spellcheck...": "Verifica\u00e7\u00e3o ortogr\u00e1fica", +"Misspelled word": "Palavra com erro ortogr\u00e1fico", +"Suggestions": "Sugest\u00f5es", +"Change": "Mudar", +"Finding word suggestions": "Encontrando sugest\u00f5es de palavras", +"Success": "Sucesso", +"Repair": "Reparo", +"Issue {0} of {1}": "Problema {0} de {1}", +"Images must be marked as decorative or have an alternative text description": "Imagens precisam ser marcadas como decorativas ou terem uma descri\u00e7\u00e3o alternativa de texto", +"Images must have an alternative text description. Decorative images are not allowed.": "Imagens precisam ter uma descri\u00e7\u00e3o alternativa de texto. Imagens decorativas n\u00e3o s\u00e3o permitidas.", +"Or provide alternative text:": "Ou informe um texto alternativo:", +"Make image decorative:": "Fa\u00e7a imagem decorativa:", +"ID attribute must be unique": "O atributo ID precisa ser \u00fanico", +"Make ID unique": "Fa\u00e7a um ID \u00fanico", +"Keep this ID and remove all others": "Mantenha esse ID e remova todos os outros", +"Remove this ID": "Remova esse ID", +"Remove all IDs": "Remova todos os IDs", +"Checklist": "Lista de checagem", +"Anchor": "\u00c2ncora", +"Special character": "Caracteres especiais", +"Code sample": "Exemplo de c\u00f3digo", +"Color": "Cor", +"Document properties": "Propriedades do documento", +"Image description": "Inserir descri\u00e7\u00e3o", +"Image": "Imagem", +"Insert link": "Inserir link", +"Target": "Alvo", +"Link": "Link", +"Poster": "Autor", +"Media": "imagem", +"Print": "Imprimir", +"Prev": "Anterior", +"Find and replace": "Localizar e substituir", +"Whole words": "Palavras inteiras", +"Insert template": "Inserir modelo" +}); \ No newline at end of file diff --git a/frontend/public/static/tinymce/lang/pt_PT.js b/frontend/public/static/tinymce/lang/pt_PT.js new file mode 100644 index 000000000..729d70a08 --- /dev/null +++ b/frontend/public/static/tinymce/lang/pt_PT.js @@ -0,0 +1,462 @@ +tinymce.addI18n('pt_PT',{ +"Redo": "Refazer", +"Undo": "Anular", +"Cut": "Cortar", +"Copy": "Copiar", +"Paste": "Colar", +"Select all": "Selecionar tudo", +"New document": "Novo documento", +"Ok": "Ok", +"Cancel": "Cancelar", +"Visual aids": "Ajuda visual", +"Bold": "Negrito", +"Italic": "It\u00e1lico", +"Underline": "Sublinhado", +"Strikethrough": "Rasurado", +"Superscript": "Superior \u00e0 linha", +"Subscript": "Inferior \u00e0 linha", +"Clear formatting": "Limpar formata\u00e7\u00e3o", +"Align left": "Alinhar \u00e0 esquerda", +"Align center": "Alinhar ao centro", +"Align right": "Alinhar \u00e0 direita", +"Justify": "Justificar", +"Bullet list": "Lista com marcas", +"Numbered list": "Lista numerada", +"Decrease indent": "Diminuir avan\u00e7o", +"Increase indent": "Aumentar avan\u00e7o", +"Close": "Fechar", +"Formats": "Formatos", +"Your browser doesn't support direct access to the clipboard. Please use the Ctrl+X\/C\/V keyboard shortcuts instead.": "O seu navegador n\u00e3o suporta acesso direto \u00e0 \u00e1rea de transfer\u00eancia. Por favor, use os atalhos Ctrl+X\/C\/V do seu teclado.", +"Headers": "Cabe\u00e7alhos", +"Header 1": "Cabe\u00e7alho 1", +"Header 2": "Cabe\u00e7alho 2", +"Header 3": "Cabe\u00e7alho 3", +"Header 4": "Cabe\u00e7alho 4", +"Header 5": "Cabe\u00e7alho 5", +"Header 6": "Cabe\u00e7alho 6", +"Headings": "T\u00edtulos", +"Heading 1": "T\u00edtulo 1", +"Heading 2": "T\u00edtulo 2", +"Heading 3": "T\u00edtulo 3", +"Heading 4": "T\u00edtulo 4", +"Heading 5": "T\u00edtulo 5", +"Heading 6": "T\u00edtulo 6", +"Preformatted": "Pr\u00e9-formatado", +"Div": "Div", +"Pre": "Pre", +"Code": "C\u00f3digo", +"Paragraph": "Par\u00e1grafo", +"Blockquote": "Blockquote", +"Inline": "Inline", +"Blocks": "Blocos", +"Paste is now in plain text mode. Contents will now be pasted as plain text until you toggle this option off.": "O comando colar est\u00e1 em modo de texto simples. O conte\u00fado ser\u00e1 colado como texto simples at\u00e9 desativar esta op\u00e7\u00e3o.", +"Fonts": "Tipos de letra", +"Font Sizes": "Tamanhos dos tipos de letra", +"Class": "Classe", +"Browse for an image": "Procurar uma imagem", +"OR": "OU", +"Drop an image here": "Largar aqui uma imagem", +"Upload": "Carregar", +"Block": "Bloco", +"Align": "Alinhar", +"Default": "Padr\u00e3o", +"Circle": "C\u00edrculo", +"Disc": "Disco", +"Square": "Quadrado", +"Lower Alpha": "a. b. c. ...", +"Lower Greek": "\\u03b1. \\u03b2. \\u03b3. ...", +"Lower Roman": "i. ii. iii. ...", +"Upper Alpha": "A. B. C. ...", +"Upper Roman": "I. II. III. ...", +"Anchor...": "\u00c2ncora...", +"Name": "Nome", +"Id": "ID", +"Id should start with a letter, followed only by letters, numbers, dashes, dots, colons or underscores.": "O ID deve come\u00e7ar com uma letra, seguido apenas por letras, n\u00fameros, pontos, dois pontos, tra\u00e7os ou sobtra\u00e7os.", +"You have unsaved changes are you sure you want to navigate away?": "Existem altera\u00e7\u00f5es que ainda n\u00e3o foram guardadas. Tem a certeza que pretende sair?", +"Restore last draft": "Restaurar o \u00faltimo rascunho", +"Special character...": "Car\u00e1ter especial...", +"Source code": "C\u00f3digo fonte", +"Insert\/Edit code sample": "Inserir\/editar amostra de c\u00f3digo", +"Language": "Idioma", +"Code sample...": "Amostra de c\u00f3digo...", +"Color Picker": "Seletor de cores", +"R": "R", +"G": "G", +"B": "B", +"Left to right": "Da esquerda para a direita", +"Right to left": "Da direita para a esquerda", +"Emoticons": "Emo\u00e7\u00f5es", +"Emoticons...": "\u00cdcones expressivos...", +"Metadata and Document Properties": "Metadados e propriedades do documento", +"Title": "T\u00edtulo", +"Keywords": "Palavras-chave", +"Description": "Descri\u00e7\u00e3o", +"Robots": "Rob\u00f4s", +"Author": "Autor", +"Encoding": "Codifica\u00e7\u00e3o", +"Fullscreen": "Ecr\u00e3 completo", +"Action": "A\u00e7\u00e3o", +"Shortcut": "Atalho", +"Help": "Ajuda", +"Address": "Endere\u00e7o", +"Focus to menubar": "Foco na barra de menu", +"Focus to toolbar": "Foco na barra de ferramentas", +"Focus to element path": "Foco no caminho do elemento", +"Focus to contextual toolbar": "Foco na barra de contexto", +"Insert link (if link plugin activated)": "Inserir hiperliga\u00e7\u00e3o (se o plugin de liga\u00e7\u00f5es estiver ativado)", +"Save (if save plugin activated)": "Guardar (se o plugin de guardar estiver ativado)", +"Find (if searchreplace plugin activated)": "Pesquisar (se o plugin pesquisar e substituir estiver ativado)", +"Plugins installed ({0}):": "Plugins instalados ({0}):", +"Premium plugins:": "Plugins comerciais:", +"Learn more...": "Saiba mais...", +"You are using {0}": "Est\u00e1 a usar {0}", +"Plugins": "Plugins", +"Handy Shortcuts": "Atalhos \u00fateis", +"Horizontal line": "Linha horizontal", +"Insert\/edit image": "Inserir\/editar imagem", +"Alternative description": "Descri\u00e7\u00e3o alternativa", +"Accessibility": "Acessibilidade", +"Image is decorative": "Imagem \u00e9 decorativa", +"Source": "Localiza\u00e7\u00e3o", +"Dimensions": "Dimens\u00f5es", +"Constrain proportions": "Manter propor\u00e7\u00f5es", +"General": "Geral", +"Advanced": "Avan\u00e7ado", +"Style": "Estilo", +"Vertical space": "Espa\u00e7amento vertical", +"Horizontal space": "Espa\u00e7amento horizontal", +"Border": "Contorno", +"Insert image": "Inserir imagem", +"Image...": "Imagem...", +"Image list": "Lista de imagens", +"Rotate counterclockwise": "Rota\u00e7\u00e3o anti-hor\u00e1ria", +"Rotate clockwise": "Rota\u00e7\u00e3o hor\u00e1ria", +"Flip vertically": "Inverter verticalmente", +"Flip horizontally": "Inverter horizontalmente", +"Edit image": "Editar imagem", +"Image options": "Op\u00e7\u00f5es de imagem", +"Zoom in": "Mais zoom", +"Zoom out": "Menos zoom", +"Crop": "Recortar", +"Resize": "Redimensionar", +"Orientation": "Orienta\u00e7\u00e3o", +"Brightness": "Brilho", +"Sharpen": "Mais nitidez", +"Contrast": "Contraste", +"Color levels": "N\u00edveis de cor", +"Gamma": "Gama", +"Invert": "Inverter", +"Apply": "Aplicar", +"Back": "Voltar", +"Insert date\/time": "Inserir data\/hora", +"Date\/time": "Data\/hora", +"Insert\/edit link": "Inserir\/editar liga\u00e7\u00e3o", +"Text to display": "Texto a exibir", +"Url": "URL", +"Open link in...": "Abrir liga\u00e7\u00e3o em...", +"Current window": "Janela atual", +"None": "Nenhum", +"New window": "Nova janela", +"Open link": "Abrir liga\u00e7\u00e3o", +"Remove link": "Remover liga\u00e7\u00e3o", +"Anchors": "\u00c2ncora", +"Link...": "Liga\u00e7\u00e3o...", +"Paste or type a link": "Copiar ou escrever uma hiperliga\u00e7\u00e3o", +"The URL you entered seems to be an email address. Do you want to add the required mailto: prefix?": "O URL que indicou parece ser um endere\u00e7o de email. Quer adicionar o prefixo mailto: tal como necess\u00e1rio?", +"The URL you entered seems to be an external link. Do you want to add the required http:\/\/ prefix?": "O URL que indicou parece ser um endere\u00e7o web. Quer adicionar o prefixo http:\/\/ tal como necess\u00e1rio?", +"The URL you entered seems to be an external link. Do you want to add the required https:\/\/ prefix?": "O URL que introduziu parece ser uma liga\u00e7\u00e3o externa. Deseja adicionar-lhe o prefixo https:\/\/ ?", +"Link list": "Lista de liga\u00e7\u00f5es", +"Insert video": "Inserir v\u00eddeo", +"Insert\/edit video": "Inserir\/editar v\u00eddeo", +"Insert\/edit media": "Inserir\/editar media", +"Alternative source": "Localiza\u00e7\u00e3o alternativa", +"Alternative source URL": "URL da origem alternativa", +"Media poster (Image URL)": "Publicador de media (URL da imagem)", +"Paste your embed code below:": "Colar c\u00f3digo para embeber:", +"Embed": "Embeber", +"Media...": "Media...", +"Nonbreaking space": "Espa\u00e7o n\u00e3o quebr\u00e1vel", +"Page break": "Quebra de p\u00e1gina", +"Paste as text": "Colar como texto", +"Preview": "Pr\u00e9-visualizar", +"Print...": "Imprimir...", +"Save": "Guardar", +"Find": "Pesquisar", +"Replace with": "Substituir por", +"Replace": "Substituir", +"Replace all": "Substituir tudo", +"Previous": "Anterior", +"Next": "Pr\u00f3ximo", +"Find and Replace": "Pesquisar e substituir", +"Find and replace...": "Localizar e substituir...", +"Could not find the specified string.": "N\u00e3o foi poss\u00edvel localizar o termo especificado.", +"Match case": "Diferenciar mai\u00fasculas e min\u00fasculas", +"Find whole words only": "Localizar apenas palavras inteiras", +"Find in selection": "Pesquisar na selec\u00e7\u00e3o", +"Spellcheck": "Corretor ortogr\u00e1fico", +"Spellcheck Language": "Idioma de verifica\u00e7\u00e3o lingu\u00edstica", +"No misspellings found.": "N\u00e3o foram encontrados erros ortogr\u00e1ficos.", +"Ignore": "Ignorar", +"Ignore all": "Ignorar tudo", +"Finish": "Concluir", +"Add to Dictionary": "Adicionar ao dicion\u00e1rio", +"Insert table": "Inserir tabela", +"Table properties": "Propriedades da tabela", +"Delete table": "Eliminar tabela", +"Cell": "C\u00e9lula", +"Row": "Linha", +"Column": "Coluna", +"Cell properties": "Propriedades da c\u00e9lula", +"Merge cells": "Unir c\u00e9lulas", +"Split cell": "Dividir c\u00e9lula", +"Insert row before": "Inserir linha antes", +"Insert row after": "Inserir linha depois", +"Delete row": "Eliminar linha", +"Row properties": "Propriedades da linha", +"Cut row": "Cortar linha", +"Copy row": "Copiar linha", +"Paste row before": "Colar linha antes", +"Paste row after": "Colar linha depois", +"Insert column before": "Inserir coluna antes", +"Insert column after": "Inserir coluna depois", +"Delete column": "Eliminar coluna", +"Cols": "Colunas", +"Rows": "Linhas", +"Width": "Largura", +"Height": "Altura", +"Cell spacing": "Espa\u00e7amento entre c\u00e9lulas", +"Cell padding": "Espa\u00e7amento interno da c\u00e9lula", +"Caption": "Legenda", +"Show caption": "Mostrar legenda", +"Left": "Esquerda", +"Center": "Centro", +"Right": "Direita", +"Cell type": "Tipo de c\u00e9lula", +"Scope": "Escopo", +"Alignment": "Alinhamento", +"H Align": "Alinhamento H", +"V Align": "Alinhamento V", +"Top": "Superior", +"Middle": "Meio", +"Bottom": "Inferior", +"Header cell": "C\u00e9lula de cabe\u00e7alho", +"Row group": "Agrupar linha", +"Column group": "Agrupar coluna", +"Row type": "Tipo de linha", +"Header": "Cabe\u00e7alho", +"Body": "Corpo", +"Footer": "Rodap\u00e9", +"Border color": "Cor de contorno", +"Insert template...": "Inserir modelo...", +"Templates": "Modelos", +"Template": "Tema", +"Text color": "Cor do texto", +"Background color": "Cor de fundo", +"Custom...": "Personalizada...", +"Custom color": "Cor personalizada", +"No color": "Sem cor", +"Remove color": "Remover cor", +"Table of Contents": "\u00cdndice", +"Show blocks": "Mostrar blocos", +"Show invisible characters": "Mostrar caracteres invis\u00edveis", +"Word count": "Contagem de palavras", +"Count": "Contagem", +"Document": "Documento", +"Selection": "Sele\u00e7\u00e3o", +"Words": "Palavras", +"Words: {0}": "Palavras: {0}", +"{0} words": "{0} palavras", +"File": "Ficheiro", +"Edit": "Editar", +"Insert": "Inserir", +"View": "Ver", +"Format": "Formatar", +"Table": "Tabela", +"Tools": "Ferramentas", +"Powered by {0}": "Criado em {0}", +"Rich Text Area. Press ALT-F9 for menu. Press ALT-F10 for toolbar. Press ALT-0 for help": "Caixa de texto formatado. Pressione ALT-F9 para exibir o menu. Pressione ALT-F10 para exibir a barra de ferramentas. Pressione ALT-0 para exibir a ajuda", +"Image title": "T\u00edtulo da imagem", +"Border width": "Largura do limite", +"Border style": "Estilo do limite", +"Error": "Erro", +"Warn": "Aviso", +"Valid": "V\u00e1lido", +"To open the popup, press Shift+Enter": "Para abrir o pop-up, prima Shift+Enter", +"Rich Text Area. Press ALT-0 for help.": "\u00c1rea de texto formatado. Prima ALT-0 para exibir a ajuda.", +"System Font": "Tipo de letra do sistema", +"Failed to upload image: {0}": "Falha ao carregar imagem: {0}", +"Failed to load plugin: {0} from url {1}": "Falha ao carregar plugin: {0} do URL {1}", +"Failed to load plugin url: {0}": "Falha ao carregar o URL do plugin: {0}", +"Failed to initialize plugin: {0}": "Falha ao inicializar plugin: {0}", +"example": "exemplo", +"Search": "Pesquisar", +"All": "Tudo", +"Currency": "Moeda", +"Text": "Texto", +"Quotations": "Aspas", +"Mathematical": "Matem\u00e1tico", +"Extended Latin": "Carateres latinos estendidos", +"Symbols": "S\u00edmbolos", +"Arrows": "Setas", +"User Defined": "Definido pelo utilizador", +"dollar sign": "cifr\u00e3o", +"currency sign": "sinal monet\u00e1rio", +"euro-currency sign": "sinal monet\u00e1rio do euro", +"colon sign": "sinal de dois pontos", +"cruzeiro sign": "sinal de cruzeiro", +"french franc sign": "sinal de franco franc\u00eas", +"lira sign": "sinal de lira", +"mill sign": "sinal de por mil", +"naira sign": "sinal de naira", +"peseta sign": "sinal de peseta", +"rupee sign": "sinal de r\u00fapia", +"won sign": "sinal de won", +"new sheqel sign": "sinal de novo sheqel", +"dong sign": "sinal de dong", +"kip sign": "sinal kip", +"tugrik sign": "sinal tugrik", +"drachma sign": "sinal drachma", +"german penny symbol": "sinal de penny alem\u00e3o", +"peso sign": "sinal de peso", +"guarani sign": "sinal de guarani", +"austral sign": "sinal de austral", +"hryvnia sign": "sinal hryvnia", +"cedi sign": "sinal de cedi", +"livre tournois sign": "sinal de libra de tours", +"spesmilo sign": "sinal de spesmilo", +"tenge sign": "sinal de tengue", +"indian rupee sign": "sinal de rupia indiana", +"turkish lira sign": "sinal de lira turca", +"nordic mark sign": "sinal de marca n\u00f3rdica", +"manat sign": "sinal manat", +"ruble sign": "sinal de rublo", +"yen character": "sinal de iene", +"yuan character": "sinal de iuane", +"yuan character, in hong kong and taiwan": "sinal de iuane, em Hong Kong e Taiwan", +"yen\/yuan character variant one": "variante um de sinal de iene\/iuane", +"Loading emoticons...": "A carregar \u00edcones expressivos...", +"Could not load emoticons": "N\u00e3o foi poss\u00edvel carregar \u00edcones expressivos", +"People": "Pessoas", +"Animals and Nature": "Animais e natureza", +"Food and Drink": "Comida e bebida", +"Activity": "Atividade", +"Travel and Places": "Viagens e lugares", +"Objects": "Objetos", +"Flags": "Bandeiras", +"Characters": "Carateres", +"Characters (no spaces)": "Carateres (sem espa\u00e7os)", +"{0} characters": "{0} carateres", +"Error: Form submit field collision.": "Erro: conflito no campo de submiss\u00e3o de formul\u00e1rio.", +"Error: No form element found.": "Erro: nenhum elemento de formul\u00e1rio encontrado.", +"Update": "Atualizar", +"Color swatch": "Cole\u00e7\u00e3o de cores", +"Turquoise": "Turquesa", +"Green": "Verde", +"Blue": "Azul", +"Purple": "P\u00farpura", +"Navy Blue": "Azul-atl\u00e2ntico", +"Dark Turquoise": "Turquesa escuro", +"Dark Green": "Verde escuro", +"Medium Blue": "Azul interm\u00e9dio", +"Medium Purple": "P\u00farpura interm\u00e9dio", +"Midnight Blue": "Azul muito escuro", +"Yellow": "Amarelo", +"Orange": "Laranja", +"Red": "Vermelho", +"Light Gray": "Cinzento claro", +"Gray": "Cinzento", +"Dark Yellow": "Amarelo escuro", +"Dark Orange": "Laranja escuro", +"Dark Red": "Vermelho escuro", +"Medium Gray": "Cinzento m\u00e9dio", +"Dark Gray": "Cinzento escuro", +"Light Green": "Verde claro", +"Light Yellow": "Amarelo claro", +"Light Red": "Vermelho claro", +"Light Purple": "P\u00farpura claro", +"Light Blue": "Azul claro", +"Dark Purple": "P\u00farpura escuro", +"Dark Blue": "Azul escuro", +"Black": "Preto", +"White": "Branco", +"Switch to or from fullscreen mode": "Entrar ou sair do modo de ecr\u00e3 inteiro", +"Open help dialog": "Abrir caixa de di\u00e1logo Ajuda", +"history": "hist\u00f3rico", +"styles": "estilos", +"formatting": "formata\u00e7\u00e3o", +"alignment": "alinhamento", +"indentation": "avan\u00e7o", +"Font": "Tipo de letra", +"Size": "Tamanho", +"More...": "Mais...", +"Select...": "Selecionar...", +"Preferences": "Prefer\u00eancias", +"Yes": "Sim", +"No": "N\u00e3o", +"Keyboard Navigation": "Navega\u00e7\u00e3o com teclado", +"Version": "Vers\u00e3o", +"Code view": "Vista do c\u00f3digo-fonte", +"Open popup menu for split buttons": "Abrir o menu popup para bot\u00f5es divididos", +"List Properties": "Propriedades da lista", +"List properties...": "Propriedades da lista\u2026", +"Start list at number": "Come\u00e7ar a lista pelo n\u00famero", +"Line height": "Altura da linha", +"comments": "coment\u00e1rios", +"Format Painter": "Pincel de formata\u00e7\u00e3o", +"Insert\/edit iframe": "Inserir\/editar iframe", +"Capitalization": "Capitaliza\u00e7\u00e3o", +"lowercase": "min\u00fasculas", +"UPPERCASE": "MAI\u00daSCULAS", +"Title Case": "Iniciais mai\u00fasculas", +"permanent pen": "caneta permanente", +"Permanent Pen Properties": "Propriedades da Caneta Permanente", +"Permanent pen properties...": "Propriedades da caneta permanente...", +"case change": "mudan\u00e7a de capitaliza\u00e7\u00e3o", +"page embed": "incorporar p\u00e1gina", +"Advanced sort...": "Ordena\u00e7\u00e3o avan\u00e7ada\u2026", +"Advanced Sort": "Ordena\u00e7\u00e3o avan\u00e7ada", +"Sort table by column ascending": "Ordenar tabela por coluna ascendente", +"Sort table by column descending": "Ordenar tabela por coluna descendente", +"Sort": "Ordenar", +"Order": "Ordem", +"Sort by": "Ordenar por", +"Ascending": "Ascendente", +"Descending": "Descendente", +"Column {0}": "Coluna {0}", +"Row {0}": "Linha {0}", +"Spellcheck...": "Verifica\u00e7\u00e3o ortogr\u00e1fica...", +"Misspelled word": "Palavra mal escrita", +"Suggestions": "Sugest\u00f5es", +"Change": "Alterar", +"Finding word suggestions": "Encontrar sugest\u00f5es de palavras", +"Success": "Sucesso", +"Repair": "Reparar", +"Issue {0} of {1}": "Problema {0} de {1}", +"Images must be marked as decorative or have an alternative text description": "As imagens devem ser marcadas como decorativas ou ter uma descri\u00e7\u00e3o textual alternativa", +"Images must have an alternative text description. Decorative images are not allowed.": "As imagens devem ter uma descri\u00e7\u00e3o textual alternativa. N\u00e3o s\u00e3o permitidas imagens meramente decorativas.", +"Or provide alternative text:": "Ou forne\u00e7a um texto alternativo:", +"Make image decorative:": "Marque a imagem como decorativa:", +"ID attribute must be unique": "O atributo ID tem de ser \u00fanico", +"Make ID unique": "Tornar o ID \u00fanico", +"Keep this ID and remove all others": "Mantenha este ID e remova todos os outros", +"Remove this ID": "Remover este ID", +"Remove all IDs": "Remover todos os IDs", +"Checklist": "Lista de verifica\u00e7\u00e3o", +"Anchor": "\u00c2ncora", +"Special character": "Car\u00e1cter especial", +"Code sample": "Amostra de c\u00f3digo", +"Color": "Cor", +"Document properties": "Propriedades do documento", +"Image description": "Descri\u00e7\u00e3o da imagem", +"Image": "Imagem", +"Insert link": "Inserir liga\u00e7\u00e3o", +"Target": "Alvo", +"Link": "Liga\u00e7\u00e3o", +"Poster": "Autor", +"Media": "Media", +"Print": "Imprimir", +"Prev": "Anterior", +"Find and replace": "Pesquisar e substituir", +"Whole words": "Palavras completas", +"Insert template": "Inserir modelo" +}); \ No newline at end of file diff --git a/frontend/public/static/tinymce/lang/ro.js b/frontend/public/static/tinymce/lang/ro.js new file mode 100644 index 000000000..dc812b4ae --- /dev/null +++ b/frontend/public/static/tinymce/lang/ro.js @@ -0,0 +1,461 @@ +tinymce.addI18n('ro',{ +"Redo": "Refacere", +"Undo": "Anulare", +"Cut": "Decupare", +"Copy": "Copiere", +"Paste": "Lipire", +"Select all": "Selecteaz\u0103 tot", +"New document": "Document nou", +"Ok": "Ok", +"Cancel": "Revocare", +"Visual aids": "Ajutoare vizuale", +"Bold": "Aldin", +"Italic": "Cursiv", +"Underline": "Subliniere", +"Strikethrough": "T\u0103iere", +"Superscript": "Exponent", +"Subscript": "Indice", +"Clear formatting": "\u00cendep\u0103rtare formatare", +"Align left": "Aliniere st\u00e2nga", +"Align center": "Aliniere centru", +"Align right": "Aliniere dreapta", +"Justify": "Aliniere st\u00e2nga-dreapta", +"Bullet list": "List\u0103 marcatori", +"Numbered list": "List\u0103 numerotat\u0103", +"Decrease indent": "Mic\u0219orare indent", +"Increase indent": "M\u0103rire indent", +"Close": "\u00cenchidere", +"Formats": "Formate", +"Your browser doesn't support direct access to the clipboard. Please use the Ctrl+X\/C\/V keyboard shortcuts instead.": "Browser-ul dumneavoastr\u0103 nu are acces direct la clipboard. V\u0103 rug\u0103m s\u0103 folosi\u021bi \u00een schimb scurt\u0103turile de tastatur\u0103 Ctrl+X\/C\/V.", +"Headers": "Antete", +"Header 1": "Antet 1", +"Header 2": "Antet 2", +"Header 3": "Antet 3", +"Header 4": "Antet 4", +"Header 5": "Antet 5", +"Header 6": "Antet 6", +"Headings": "Rubrici", +"Heading 1": "Titlu 1", +"Heading 2": "Titlu 2", +"Heading 3": "Titlu 3", +"Heading 4": "Titlu 4", +"Heading 5": "Titlu 5", +"Heading 6": "Titlu 6", +"Preformatted": "Preformatat", +"Div": "Div", +"Pre": "Pre", +"Code": "Cod", +"Paragraph": "Paragraf", +"Blockquote": "Blockquote", +"Inline": "\u00cen linie", +"Blocks": "Blocuri", +"Paste is now in plain text mode. Contents will now be pasted as plain text until you toggle this option off.": "Functia \"lipe\u015fte\" este acum \u00een modul text simplu. Continutul va fi acum inserat ca text simplu p\u00e2n\u0103 c\u00e2nd aceast\u0103 op\u021biune va fi dezactivat.", +"Fonts": "Fonturi", +"Font Sizes": "Dimensiuni font", +"Class": "Clas\u0103", +"Browse for an image": "C\u0103uta\u021bi o imagine", +"OR": "OR", +"Drop an image here": "Glisa\u021bi o imagine aici", +"Upload": "\u00cenc\u0103rcare", +"Block": "Sec\u021biune", +"Align": "Aliniere", +"Default": "Implicit", +"Circle": "Cerc", +"Disc": "Disc", +"Square": "P\u0103trat", +"Lower Alpha": "Minuscule Alfanumerice", +"Lower Greek": "Minuscule Grecesti", +"Lower Roman": "Minuscule Romane", +"Upper Alpha": "Majuscule Alfanumerice", +"Upper Roman": "Majuscule Romane", +"Anchor...": "Ancor\u0103\u2026", +"Name": "Nume", +"Id": "Id", +"Id should start with a letter, followed only by letters, numbers, dashes, dots, colons or underscores.": "Id-ul trebuie s\u0103 inceap\u0103 cu o liter\u0103, urmat\u0103 exclusiv de litere, numere, cratime, puncte, punct \u0219i virgul\u0103 sau underscore-uri.", +"You have unsaved changes are you sure you want to navigate away?": "Ave\u021bi modific\u0103ri nesalvate! Sunte\u0163i sigur c\u0103 dori\u0163i s\u0103 ie\u015fiti?", +"Restore last draft": "Restaurare la ultima salvare", +"Special character...": "Caracter special\u2026", +"Source code": "Codul surs\u0103", +"Insert\/Edit code sample": "Inserare\/Editare mostr\u0103 cod", +"Language": "Limba", +"Code sample...": "Mostr\u0103 cod\u2026", +"Color Picker": "Selector culori", +"R": "R", +"G": "G", +"B": "B", +"Left to right": "St\u00e2nga la dreapta", +"Right to left": "Dreapta la st\u00e2nga", +"Emoticons": "Emoticoane", +"Emoticons...": "Emoticoane\u2026", +"Metadata and Document Properties": "Meta date \u0219i Propriet\u0103\u021bi Document", +"Title": "Titlu", +"Keywords": "Cuvinte cheie", +"Description": "Descriere", +"Robots": "Robo\u021bi", +"Author": "Autor", +"Encoding": "Codare", +"Fullscreen": "Pe tot ecranul", +"Action": "Ac\u0163iune", +"Shortcut": "Comand\u0103 rapid\u0103", +"Help": "Ajutor", +"Address": "Adres\u0103", +"Focus to menubar": "Centrare pe bara de meniuri", +"Focus to toolbar": "Centrare pe bara de unelte", +"Focus to element path": "Centrare pe calea elementului", +"Focus to contextual toolbar": "Centrare pe bara de unelte contextual\u0103", +"Insert link (if link plugin activated)": "Inserare link (dac\u0103 modulul de link-uri este activat)", +"Save (if save plugin activated)": "Salvare (dac\u0103 modulul de salvare este activat)", +"Find (if searchreplace plugin activated)": "C\u0103utare (dac\u0103 modulul de c\u0103utare \u0219i \u00eenlocuire este activat)", +"Plugins installed ({0}):": "Module instalate ({0}):", +"Premium plugins:": "Module premium:", +"Learn more...": "Afla\u021bi mai multe\u2026", +"You are using {0}": "Folosi\u021bi {0}", +"Plugins": "Inserturi", +"Handy Shortcuts": "Comenzi rapide accesibile", +"Horizontal line": "Linie orizontal\u0103", +"Insert\/edit image": "Inserare\/editarea imaginilor", +"Alternative description": "Descriere alternativ\u0103", +"Accessibility": "Accesibilitate", +"Image is decorative": "Imaginea este decorativ\u0103", +"Source": "Surs\u0103", +"Dimensions": "Dimensiuni", +"Constrain proportions": "Constr\u00e2nge propor\u021biile", +"General": "General", +"Advanced": "Avansat", +"Style": "Stil", +"Vertical space": "Spa\u021biul vertical", +"Horizontal space": "Spa\u021biul orizontal", +"Border": "Bordur\u0103", +"Insert image": "Inserare imagine", +"Image...": "Imagine\u2026", +"Image list": "List\u0103 de imagini", +"Rotate counterclockwise": "Rotire \u00een sensul antiorar", +"Rotate clockwise": "Rotire \u00een sensul orar", +"Flip vertically": "R\u0103sturn\u0103 vertical", +"Flip horizontally": "R\u0103sturn\u0103 orizontal", +"Edit image": "Editare imagine", +"Image options": "Op\u021biuni imagine", +"Zoom in": "M\u0103rire", +"Zoom out": "Mic\u015forare", +"Crop": "Decupare", +"Resize": "Redimensionare", +"Orientation": "Orientare", +"Brightness": "Str\u0103lucire", +"Sharpen": "Accentuare", +"Contrast": "Contrast", +"Color levels": "Niveluri de culoare", +"Gamma": "Gamma", +"Invert": "Invers\u0103", +"Apply": "Salveaz\u0103", +"Back": "\u00cenapoi", +"Insert date\/time": "Insereaz\u0103 data\/ora", +"Date\/time": "Data\/ora", +"Insert\/edit link": "Inserare\/editare link", +"Text to display": "Text de afi\u0219at", +"Url": "Url", +"Open link in...": "Deschide link \u00een\u2026", +"Current window": "Fereastra curent\u0103", +"None": "Nici unul", +"New window": "Fereastr\u0103 nou\u0103", +"Open link": "Deschide leg\u0103tur\u0103", +"Remove link": "\u0218terge link-ul", +"Anchors": "Ancor\u0103", +"Link...": "Link\u2026", +"Paste or type a link": "Introduce\u021bi un link", +"The URL you entered seems to be an email address. Do you want to add the required mailto: prefix?": "URL-ul introdus pare s\u0103 fie o adres\u0103 de e-mail. Dori\u021bi s\u0103 ad\u0103uga\u021bi prefixul mailto: ?", +"The URL you entered seems to be an external link. Do you want to add the required http:\/\/ prefix?": "URL-ul introdus pare s\u0103 fie o adres\u0103 web. Dori\u021bi s\u0103 ad\u0103uga\u021bi prefixul http:\/\/ ?", +"The URL you entered seems to be an external link. Do you want to add the required https:\/\/ prefix?": "Adresa URL pe care a\u021bi introdus-o pare a fi un leg\u0103tur\u0103 extern\u0103. Dori\u021bi s\u0103 ad\u0103uga\u021bi prefixul https:\/\/ necesar?", +"Link list": "List\u0103 linkuri", +"Insert video": "Inserare video", +"Insert\/edit video": "Inserare\/editare video", +"Insert\/edit media": "Inserare\/editare media", +"Alternative source": "Surs\u0103 alternativ\u0103", +"Alternative source URL": "URL surs\u0103 alternativ\u0103", +"Media poster (Image URL)": "Poster media (URL imagine)", +"Paste your embed code below:": "Insera\u021bi codul:", +"Embed": "Embed", +"Media...": "Media\u2026", +"Nonbreaking space": "Spa\u021biu neseparator", +"Page break": "\u00centrerupere de pagin\u0103", +"Paste as text": "Lipe\u015fte ca text", +"Preview": "Previzualizare", +"Print...": "Tip\u0103rire\u2026", +"Save": "Salveaz\u0103", +"Find": "Caut\u0103", +"Replace with": "\u00cenlocuie\u015fte cu", +"Replace": "\u00cenlocuie\u015fte", +"Replace all": "\u00cenlocuie\u015fte toate", +"Previous": "Anterior", +"Next": "Precedent", +"Find and Replace": "G\u0103si\u021bi \u0219i \u00eenlocui\u021bi", +"Find and replace...": "C\u0103utare \u0219i \u00eenlocuire\u2026", +"Could not find the specified string.": "Nu am putut g\u0103si \u0219irul specificat.", +"Match case": "Distinge majuscule\/minuscule", +"Find whole words only": "G\u0103se\u0219te doar cuvintele \u00eentregi", +"Find in selection": "G\u0103si\u021bi \u00een selec\u021bie", +"Spellcheck": "Verificarea ortografic\u0103", +"Spellcheck Language": "Verificare ortografic\u0103 a limbii", +"No misspellings found.": "Nu s-au g\u0103sit gre\u0219eli de ortografie.", +"Ignore": "Ignor\u0103", +"Ignore all": "Ignor\u0103 toate", +"Finish": "Finalizeaz\u0103", +"Add to Dictionary": "Adaug\u0103 \u00een Dic\u021bionar", +"Insert table": "Insereaz\u0103 tabel\u0103", +"Table properties": "Propriet\u0103\u021bi tabel\u0103", +"Delete table": "\u0218terge tabel\u0103", +"Cell": "Celul\u0103", +"Row": "Linie", +"Column": "Coloan\u0103", +"Cell properties": "Propriet\u0103\u021bi celul\u0103", +"Merge cells": "\u00cembinarea celulelor", +"Split cell": "\u00cemp\u0103r\u021birea celulelor", +"Insert row before": "Insereaz\u0103 \u00eenainte de linie", +"Insert row after": "Insereaz\u0103 dup\u0103 linie", +"Delete row": "\u0218terge linia", +"Row properties": "Propriet\u0103\u021bi linie", +"Cut row": "Taie linie", +"Copy row": "Copiaz\u0103 linie", +"Paste row before": "Lipe\u015fte \u00eenainte de linie", +"Paste row after": "Lipe\u015fte linie dup\u0103", +"Insert column before": "Insereaza \u00eenainte de coloan\u0103", +"Insert column after": "Insereaza dup\u0103 coloan\u0103", +"Delete column": "\u0218terge coloana", +"Cols": "Coloane", +"Rows": "Linii", +"Width": "L\u0103\u0163ime", +"Height": "\u00cen\u0103l\u0163ime", +"Cell spacing": "Spa\u021biere celule", +"Cell padding": "Spa\u021biere", +"Caption": "Titlu", +"Show caption": "Afi\u0219are captur\u0103", +"Left": "St\u00e2nga", +"Center": "Centru", +"Right": "Dreapta", +"Cell type": "Tip celul\u0103", +"Scope": "Domeniu", +"Alignment": "Aliniament", +"H Align": "Aliniere H", +"V Align": "Aliniere V", +"Top": "Sus", +"Middle": "Mijloc", +"Bottom": "Jos", +"Header cell": "Antet celul\u0103", +"Row group": "Grup de linii", +"Column group": "Grup de coloane", +"Row type": "Tip de linie", +"Header": "Antet", +"Body": "Corp", +"Footer": "Subsol", +"Border color": "Culoare bordur\u0103", +"Insert template...": "Inserare \u0219ablon\u2026", +"Templates": "\u015eabloane", +"Template": "\u0218ablon", +"Text color": "Culoare text", +"Background color": "Culoare fundal", +"Custom...": "Personalizat...", +"Custom color": "Culoare personalizat\u0103", +"No color": "F\u0103r\u0103 culoare", +"Remove color": "Eliminare culoare", +"Table of Contents": "Cuprins", +"Show blocks": "Afi\u0219are blocuri", +"Show invisible characters": "Afi\u0219are caractere invizibile", +"Word count": "Num\u0103r\u0103toare cuvinte", +"Count": "Num\u0103r\u0103toare", +"Document": "Document", +"Selection": "Selec\u021bie", +"Words": "Cuvinte", +"Words: {0}": "Cuvinte: {0}", +"{0} words": "{0} cuvinte", +"File": "Fil\u0103", +"Edit": "Editeaz\u0103", +"Insert": "Insereaz\u0103", +"View": "Vezi", +"Format": "Formateaz\u0103", +"Table": "Tabel\u0103", +"Tools": "Unelte", +"Powered by {0}": "Sus\u021binut de {0}", +"Rich Text Area. Press ALT-F9 for menu. Press ALT-F10 for toolbar. Press ALT-0 for help": "Zon\u0103 cu Rich Text. Apas\u0103 ALT-F9 pentru meniu. Apas\u0103 ALT-F10 pentru bara de unelte. Apas\u0103 ALT-0 pentru ajutor", +"Image title": "Titlu imagine", +"Border width": "Grosime chenar", +"Border style": "Stil chenar", +"Error": "Eroare", +"Warn": "Aten\u021bionare", +"Valid": "Valid", +"To open the popup, press Shift+Enter": "Pentru a deschide fereastra popup, ap\u0103sa\u021bi Shift+Enter", +"Rich Text Area. Press ALT-0 for help.": "Zon\u0103 Text Formatat. Ap\u0103sa\u021bi ALT-0 pentru ajutor.", +"System Font": "Font Sistem", +"Failed to upload image: {0}": "Nu s-a putut \u00eenc\u0103rca imaginea: {0}", +"Failed to load plugin: {0} from url {1}": "Nu s-a putut \u00eenc\u0103rca modulul: {0} de la URL-ul {1}", +"Failed to load plugin url: {0}": "Nu s-a putut \u00eenc\u0103rca URL-ul modulului: {0}", +"Failed to initialize plugin: {0}": "Nu s-a putut ini\u021bializa modulul: {0}", +"example": "exemplu", +"Search": "C\u0103utare", +"All": "Tot", +"Currency": "Moned\u0103", +"Text": "Text", +"Quotations": "Ghilimele", +"Mathematical": "Simboluri matematice", +"Extended Latin": "Simboluri alfabet latin extins", +"Symbols": "Simboluri", +"Arrows": "S\u0103ge\u021bi", +"User Defined": "Definite de utilizator", +"dollar sign": "simbol dolar", +"currency sign": "simbol moned\u0103", +"euro-currency sign": "simbol euro", +"colon sign": "dou\u0103 puncte", +"cruzeiro sign": "simbol cruzeiro", +"french franc sign": "simbol franc francez", +"lira sign": "simbol lir\u0103", +"mill sign": "simbol mill", +"naira sign": "simbol naira", +"peseta sign": "simbol peset\u0103", +"rupee sign": "simbol rupie", +"won sign": "simbol won", +"new sheqel sign": "simbol shekel nou", +"dong sign": "simbol dong", +"kip sign": "simbol kip", +"tugrik sign": "simbol tugrik", +"drachma sign": "simbol drahm\u0103", +"german penny symbol": "simbol peni german", +"peso sign": "simbol peso", +"guarani sign": "simbol guarani", +"austral sign": "simbol austral", +"hryvnia sign": "simbol grivn\u0103", +"cedi sign": "simbol cedi", +"livre tournois sign": "simbol livr\u0103 tournois", +"spesmilo sign": "simbol spesmilo", +"tenge sign": "simbol tenge", +"indian rupee sign": "simbol rupie indian\u0103", +"turkish lira sign": "simbol lir\u0103 turceasc\u0103", +"nordic mark sign": "simbol marc\u0103 nordic\u0103", +"manat sign": "simbol manat", +"ruble sign": "simbol rubl\u0103", +"yen character": "simbol yen", +"yuan character": "simbol yuan", +"yuan character, in hong kong and taiwan": "simbol yuan \u00een Hong Kong \u0219i Taiwan", +"yen\/yuan character variant one": "simbol yen\/yuan prima variant\u0103", +"Loading emoticons...": "Se \u00eencarc\u0103 emoticoanele\u2026", +"Could not load emoticons": "Nu s-au putut \u00eenc\u0103rca emoticoanele", +"People": "Persoane", +"Animals and Nature": "Animale \u0219i natur\u0103", +"Food and Drink": "M\u00e2ncare \u0219i b\u0103uturi", +"Activity": "Activit\u0103\u021bi", +"Travel and Places": "C\u0103l\u0103torii \u0219i loca\u021bii", +"Objects": "Obiecte", +"Flags": "Steaguri", +"Characters": "Caractere", +"Characters (no spaces)": "Caractere (f\u0103r\u0103 spa\u021bii)", +"{0} characters": "{0} caractere", +"Error: Form submit field collision.": "Eroare: Coliziune c\u00e2mpuri la trimiterea formularului.", +"Error: No form element found.": "Eroare: Niciun element de formular g\u0103sit.", +"Update": "Actualizare", +"Color swatch": "Mostr\u0103 de culori", +"Turquoise": "Turcoaz", +"Green": "Verde", +"Blue": "Albastru", +"Purple": "Mov", +"Navy Blue": "Albastru marin", +"Dark Turquoise": "Turcoaz \u00eenchis", +"Dark Green": "Verde \u00eenchis", +"Medium Blue": "Albastru mediu", +"Medium Purple": "Mov mediu", +"Midnight Blue": "Albastru \u00eenchis", +"Yellow": "Galben", +"Orange": "Portocaliu", +"Red": "Ro\u0219u", +"Light Gray": "Gri deschis", +"Gray": "Gri", +"Dark Yellow": "Galben \u00eenchis", +"Dark Orange": "Portocaliu \u00eenchis", +"Dark Red": "Ro\u0219u \u00eenchis", +"Medium Gray": "Gri mediu", +"Dark Gray": "Gri \u00eenchis", +"Light Green": "Verde deschis", +"Light Yellow": "Galben deschis", +"Light Red": "Ro\u015fu deschis", +"Light Purple": "Violet deschis", +"Light Blue": "Albastru deschis", +"Dark Purple": "Violet \u00eenchis", +"Dark Blue": "Negru \u00eenchis", +"Black": "Negru", +"White": "Alb", +"Switch to or from fullscreen mode": "Comutare pe sau de la modul ecran complet", +"Open help dialog": "Deschide dialogul de ajutor", +"history": "istoric", +"styles": "stiluri", +"formatting": "formatare", +"alignment": "aliniere", +"indentation": "indentare", +"Font": "Font", +"Size": "Dimensiuni", +"More...": "Mai multe...", +"Select...": "Selectare...", +"Preferences": "Preferin\u021be", +"Yes": "Da", +"No": "Nu", +"Keyboard Navigation": "Navigare de la tastatur\u0103", +"Version": "Versiune", +"Code view": "Vizualizare cod", +"Open popup menu for split buttons": "Deschide\u021bi meniul pop-up pentru butoanele divizate", +"List Properties": "Propriet\u0103\u021bi list\u0103", +"List properties...": "Propriet\u0103\u021bi list\u0103...", +"Start list at number": "\u00cencepe\u021bi lista la num\u0103rul", +"Line height": "\u00cen\u0103l\u021bimea liniei", +"comments": "comentarii", +"Format Painter": "Descriptor de formate", +"Insert\/edit iframe": "Inserare\/editare icadru", +"Capitalization": "Scriere cu majuscule", +"lowercase": "litere mici", +"UPPERCASE": "MAJUSCULE", +"Title Case": "Ini\u021bial\u0103 majuscul\u0103", +"permanent pen": "stilou permanent", +"Permanent Pen Properties": "Propriet\u0103\u021bile stiloului permanent", +"Permanent pen properties...": "Propriet\u0103\u021bile stiloului permanent...", +"case change": "schimbarea cazului", +"page embed": "\u00eencorporare pagin\u0103", +"Advanced sort...": "Sortare avansat\u0103...", +"Advanced Sort": "Sortare avansat\u0103", +"Sort table by column ascending": "Sorta\u021bi tabelul dup\u0103 coloan\u0103 cresc\u0103toare", +"Sort table by column descending": "Sorta\u021bi tabelul dup\u0103 coloan\u0103 descresc\u0103toare", +"Sort": "Sortare", +"Order": "Ordonare", +"Sort by": "Soreaz\u0103 dup\u0103", +"Ascending": "Cresc\u0103tor", +"Descending": "Descresc\u0103tor", +"Column {0}": "Coloan\u0103 {0}", +"Row {0}": "R\u00e2nd {0}", +"Spellcheck...": "Verificare a ortografiei...", +"Misspelled word": "Cuv\u00e2nt scris gre\u0219it", +"Suggestions": "Sugestii", +"Change": "Schimbare", +"Finding word suggestions": "G\u0103se\u0219te sugestii de cuvinte", +"Success": "Succes", +"Repair": "Repar\u0103", +"Issue {0} of {1}": "Num\u0103rul {0} din {1}", +"Images must be marked as decorative or have an alternative text description": "Imaginile trebuie s\u0103 fie marcate ca decorative sau s\u0103 aib\u0103 o descriere alternativ\u0103 a textului", +"Images must have an alternative text description. Decorative images are not allowed.": "Imaginile trebuie s\u0103 aib\u0103 o descriere alternativ\u0103 a textului. Imaginile decorative nu sunt permise.", +"Or provide alternative text:": "Sau furniza\u021bi un text alternativ:", +"Make image decorative:": "Face\u021bi imaginea decorativ\u0103:", +"ID attribute must be unique": "Atributul ID trebuie s\u0103 fie unic", +"Make ID unique": "Face\u021bi ID-ul unic", +"Keep this ID and remove all others": "P\u0103stra\u021bi acest ID \u0219i elimina\u021bi pe toate celelalte", +"Remove this ID": "Elimina\u021bi acest ID", +"Remove all IDs": "Elimina\u021bi toate ID-urile", +"Checklist": "Lista de verificare", +"Anchor": "Ancor\u0103", +"Special character": "Caractere speciale", +"Color": "Culoare", +"Document properties": "Propriet\u0103\u021bi document", +"Image description": "Descrierea imaginii", +"Image": "Imagine", +"Insert link": "Inserare link", +"Link": "Link", +"Target": "\u021aint\u0103", +"Media": "Media", +"Poster": "Poster", +"Print": "Tip\u0103re\u0219te", +"Whole words": "Doar cuv\u00eentul \u00eentreg", +"Find and replace": "Caut\u0103 \u015fi \u00eenlocuie\u015fte", +"Prev": "Anterior", +"Insert template": "Insereaz\u0103 \u0219ablon" +}); \ No newline at end of file diff --git a/frontend/public/static/tinymce/lang/tr.js b/frontend/public/static/tinymce/lang/tr.js new file mode 100644 index 000000000..200a15f1e --- /dev/null +++ b/frontend/public/static/tinymce/lang/tr.js @@ -0,0 +1,419 @@ +tinymce.addI18n('tr',{ +"Redo": "Yinele", +"Undo": "Geri al", +"Cut": "Kes", +"Copy": "Kopyala", +"Paste": "Yap\u0131\u015ft\u0131r", +"Select all": "T\u00fcm\u00fcn\u00fc se\u00e7", +"New document": "Yeni dok\u00fcman", +"Ok": "Tamam", +"Cancel": "\u0130ptal", +"Visual aids": "G\u00f6rsel ara\u00e7lar", +"Bold": "Kal\u0131n", +"Italic": "\u0130talik", +"Underline": "Alt\u0131 \u00e7izili", +"Strikethrough": "\u00dcst\u00fc \u00e7izgili", +"Superscript": "\u00dcst simge", +"Subscript": "Alt simge", +"Clear formatting": "Bi\u00e7imi temizle", +"Align left": "Sola hizala", +"Align center": "Ortala", +"Align right": "Sa\u011fa hizala", +"Justify": "\u0130ki yana yasla", +"Bullet list": "S\u0131ras\u0131z liste", +"Numbered list": "S\u0131ral\u0131 liste", +"Decrease indent": "Girintiyi azalt", +"Increase indent": "Girintiyi art\u0131r", +"Close": "Kapat", +"Formats": "Bi\u00e7imler", +"Your browser doesn't support direct access to the clipboard. Please use the Ctrl+X\/C\/V keyboard shortcuts instead.": "Taray\u0131c\u0131n\u0131z panoya direk eri\u015fimi desteklemiyor. L\u00fctfen Ctrl+X\/C\/V klavye k\u0131sayollar\u0131n\u0131 kullan\u0131n.", +"Headers": "Ba\u015fl\u0131klar", +"Header 1": "Ba\u015fl\u0131k 1", +"Header 2": "Ba\u015fl\u0131k 2", +"Header 3": "Ba\u015fl\u0131k 3", +"Header 4": "Ba\u015fl\u0131k 4", +"Header 5": "Ba\u015fl\u0131k 5", +"Header 6": "Ba\u015fl\u0131k 6", +"Headings": "Ba\u015fl\u0131klar", +"Heading 1": "Ba\u015fl\u0131k 1", +"Heading 2": "Ba\u015fl\u0131k 2", +"Heading 3": "Ba\u015fl\u0131k 3", +"Heading 4": "Ba\u015fl\u0131k 4", +"Heading 5": "Ba\u015fl\u0131k 5", +"Heading 6": "Ba\u015fl\u0131k 6", +"Preformatted": "\u00d6nceden bi\u00e7imlendirilmi\u015f", +"Div": "Div", +"Pre": "Pre", +"Code": "Kod", +"Paragraph": "Paragraf", +"Blockquote": "Blockquote", +"Inline": "Sat\u0131r i\u00e7i", +"Blocks": "Bloklar", +"Paste is now in plain text mode. Contents will now be pasted as plain text until you toggle this option off.": "D\u00fcz metin modunda yap\u0131\u015ft\u0131r. Bu se\u00e7ene\u011fi kapatana kadar i\u00e7erikler d\u00fcz metin olarak yap\u0131\u015ft\u0131r\u0131l\u0131r.", +"Fonts": "Yaz\u0131 Tipleri", +"Font Sizes": "Yaz\u0131tipi B\u00fcy\u00fckl\u00fc\u011f\u00fc", +"Class": "S\u0131n\u0131f", +"Browse for an image": "Bir resim aray\u0131n", +"OR": "VEYA", +"Drop an image here": "Buraya bir resim koyun", +"Upload": "Y\u00fckle", +"Block": "Blok", +"Align": "Hizala", +"Default": "Varsay\u0131lan", +"Circle": "Daire", +"Disc": "Disk", +"Square": "Kare", +"Lower Alpha": "K\u00fc\u00e7\u00fck Harf", +"Lower Greek": "K\u00fc\u00e7\u00fck Yunan Harfleri", +"Lower Roman": "K\u00fc\u00e7\u00fck Roman Harfleri ", +"Upper Alpha": "B\u00fcy\u00fck Harf", +"Upper Roman": "B\u00fcy\u00fck Roman Harfleri ", +"Anchor...": "\u00c7apa...", +"Name": "\u0130sim", +"Id": "Kimlik", +"Id should start with a letter, followed only by letters, numbers, dashes, dots, colons or underscores.": "Id bir harf ile ba\u015flamal\u0131d\u0131r ve harf, rakam, \u00e7izgi, nokta, iki nokta \u00fcst\u00fcste veya alt \u00e7izgi kullan\u0131labilir.", +"You have unsaved changes are you sure you want to navigate away?": "Kaydedilmemi\u015f de\u011fi\u015fiklikler var, sayfadan ayr\u0131lmak istedi\u011finize emin misiniz?", +"Restore last draft": "Son tasla\u011f\u0131 geri y\u00fckle", +"Special character...": "\u00d6zel karakter...", +"Source code": "Kaynak kodu", +"Insert\/Edit code sample": "\u00d6rnek kod ekle\/d\u00fczenle", +"Language": "Dil", +"Code sample...": "Kod \u00f6rne\u011fi...", +"Color Picker": "Renk Se\u00e7ici", +"R": "R", +"G": "G", +"B": "B", +"Left to right": "Soldan sa\u011fa", +"Right to left": "Sa\u011fdan sola", +"Emoticons...": "\u0130fadeler...", +"Metadata and Document Properties": "\u00d6nbilgi ve Belge \u00d6zellikleri", +"Title": "Ba\u015fl\u0131k", +"Keywords": "Anahtar kelimeler", +"Description": "A\u00e7\u0131klama", +"Robots": "Robotlar", +"Author": "Yazar", +"Encoding": "Kodlama", +"Fullscreen": "Tam ekran", +"Action": "Eylem", +"Shortcut": "K\u0131sayol", +"Help": "Yard\u0131m", +"Address": "Adres", +"Focus to menubar": "Men\u00fcye odaklan", +"Focus to toolbar": "Ara\u00e7 tak\u0131m\u0131na odaklan", +"Focus to element path": "\u00d6\u011fe yoluna odaklan", +"Focus to contextual toolbar": "Ba\u011flamsal ara\u00e7 tak\u0131m\u0131na odaklan", +"Insert link (if link plugin activated)": "Ba\u011flant\u0131 ekle (Ba\u011flant\u0131 eklentisi aktif ise)", +"Save (if save plugin activated)": "Kaydet (Kay\u0131t eklentisi aktif ise)", +"Find (if searchreplace plugin activated)": "Bul (Bul\/De\u011fi\u015ftir eklentisi aktif ise)", +"Plugins installed ({0}):": "Eklentiler y\u00fcklendi ({0}):", +"Premium plugins:": "Premium eklentiler:", +"Learn more...": "Detayl\u0131 bilgi...", +"You are using {0}": "\u015eu an {0} kullan\u0131yorsunuz", +"Plugins": "Plugins", +"Handy Shortcuts": "Handy Shortcuts", +"Horizontal line": "Yatay \u00e7izgi", +"Insert\/edit image": "Resim ekle\/d\u00fczenle", +"Image description": "Resim a\u00e7\u0131klamas\u0131", +"Source": "Kaynak", +"Dimensions": "Boyutlar", +"Constrain proportions": "Oranlar\u0131 koru", +"General": "Genel", +"Advanced": "Geli\u015fmi\u015f", +"Style": "Stil", +"Vertical space": "Dikey bo\u015fluk", +"Horizontal space": "Yatay bo\u015fluk", +"Border": "Kenarl\u0131k", +"Insert image": "Resim ekle", +"Image...": "Resim...", +"Image list": "G\u00f6rsel listesi", +"Rotate counterclockwise": "Saatin tersi y\u00f6n\u00fcnde d\u00f6nd\u00fcr", +"Rotate clockwise": "Saat y\u00f6n\u00fcnde d\u00f6nd\u00fcr", +"Flip vertically": "Dikine \u00e7evir", +"Flip horizontally": "Enine \u00e7evir", +"Edit image": "Resmi d\u00fczenle", +"Image options": "Resim ayarlar\u0131", +"Zoom in": "Yak\u0131nla\u015ft\u0131r", +"Zoom out": "Uzakla\u015ft\u0131r", +"Crop": "K\u0131rp", +"Resize": "Yeniden Boyutland\u0131r", +"Orientation": "Oryantasyon", +"Brightness": "Parlakl\u0131k", +"Sharpen": "Keskinle\u015ftir", +"Contrast": "Kontrast", +"Color levels": "Renk d\u00fczeyleri", +"Gamma": "Gama", +"Invert": "Ters \u00c7evir", +"Apply": "Uygula", +"Back": "Geri", +"Insert date\/time": "Tarih\/saat ekle", +"Date\/time": "Tarih\/saat", +"Insert\/Edit Link": "Ba\u011flant\u0131 Ekle\/D\u00fczenle", +"Insert\/edit link": "Ba\u011flant\u0131 ekle\/d\u00fczenle", +"Text to display": "Yaz\u0131y\u0131 g\u00f6r\u00fcnt\u00fcle", +"Url": "Url", +"Open link in...": "Ba\u011flant\u0131y\u0131 a\u00e7...", +"Current window": "Mevcut pencere", +"None": "Hi\u00e7biri", +"New window": "Yeni pencere", +"Remove link": "Ba\u011flant\u0131y\u0131 kald\u0131r", +"Anchors": "\u00c7apalar", +"Link...": "Ba\u011flant\u0131...", +"Paste or type a link": "Bir ba\u011flant\u0131 yaz\u0131n yada yap\u0131\u015ft\u0131r\u0131n", +"The URL you entered seems to be an email address. Do you want to add the required mailto: prefix?": "Girdi\u011finiz URL bir e-posta adresi gibi g\u00f6r\u00fcn\u00fcyor. Gerekli olan mailto: \u00f6nekini eklemek ister misiniz?", +"The URL you entered seems to be an external link. Do you want to add the required http:\/\/ prefix?": "Girdi\u011finiz URL bir d\u0131\u015f ba\u011flant\u0131 gibi g\u00f6r\u00fcn\u00fcyor. Gerekli olan http:\/\/ \u00f6nekini eklemek ister misiniz?", +"Link list": "Ba\u011flant\u0131 listesi", +"Insert video": "Video ekle", +"Insert\/edit video": "Video ekle\/d\u00fczenle", +"Insert\/edit media": "Medya ekle\/d\u00fczenle", +"Alternative source": "Alternatif kaynak", +"Alternative source URL": "Alternatif kaynak URL", +"Media poster (Image URL)": "Medya posteri (Resim URL)", +"Paste your embed code below:": "Video g\u00f6mme kodunu a\u015fa\u011f\u0131ya yap\u0131\u015ft\u0131r\u0131n\u0131z:", +"Embed": "G\u00f6mme", +"Media...": "Medya...", +"Nonbreaking space": "B\u00f6l\u00fcnemez bo\u015fluk", +"Page break": "Sayfa sonu", +"Paste as text": "Metin olarak yap\u0131\u015ft\u0131r", +"Preview": "\u00d6nizleme", +"Print...": "Yazd\u0131r...", +"Save": "Kaydet", +"Find": "Bul", +"Replace with": "Bununla de\u011fi\u015ftir", +"Replace": "De\u011fi\u015ftir", +"Replace all": "T\u00fcm\u00fcn\u00fc de\u011fi\u015ftir", +"Previous": "Geri", +"Next": "Sonraki", +"Find and replace...": "Bul ve de\u011fi\u015ftir...", +"Could not find the specified string.": "Herhangi bir sonu\u00e7 bulunamad\u0131.", +"Match case": "B\u00fcy\u00fck\/k\u00fc\u00e7\u00fck harf duyarl\u0131", +"Find whole words only": "Sadece t\u00fcm kelimeyi ara", +"Spell check": "Yaz\u0131m denetimi", +"Ignore": "Yoksay", +"Ignore all": "T\u00fcm\u00fcn\u00fc yoksay", +"Finish": "Bitir", +"Add to Dictionary": "S\u00f6zl\u00fc\u011fe Ekle", +"Insert table": "Tablo ekle", +"Table properties": "Tablo \u00f6zellikleri", +"Delete table": "Tablo sil", +"Cell": "H\u00fccre", +"Row": "Sat\u0131r", +"Column": "S\u00fctun", +"Cell properties": "H\u00fccre \u00f6zellikleri", +"Merge cells": "H\u00fccreleri birle\u015ftir", +"Split cell": "H\u00fccre b\u00f6l", +"Insert row before": "\u00dcste sat\u0131r ekle", +"Insert row after": "Alta sat\u0131r ekle ", +"Delete row": "Sat\u0131r sil", +"Row properties": "Sat\u0131r \u00f6zellikleri", +"Cut row": "Sat\u0131r\u0131 kes", +"Copy row": "Sat\u0131r\u0131 kopyala", +"Paste row before": "\u00dcste sat\u0131r yap\u0131\u015ft\u0131r", +"Paste row after": "Alta sat\u0131r yap\u0131\u015ft\u0131r", +"Insert column before": "Sola s\u00fctun ekle", +"Insert column after": "Sa\u011fa s\u00fctun ekle", +"Delete column": "S\u00fctun sil", +"Cols": "S\u00fctunlar", +"Rows": "Sat\u0131rlar", +"Width": "Geni\u015flik", +"Height": "Y\u00fckseklik", +"Cell spacing": "H\u00fccre aral\u0131\u011f\u0131", +"Cell padding": "H\u00fccre dolgusu", +"Show caption": "Ba\u015fl\u0131\u011f\u0131 g\u00f6ster", +"Left": "Sol", +"Center": "Orta", +"Right": "Sa\u011f", +"Cell type": "H\u00fccre tipi", +"Scope": "Kapsam", +"Alignment": "Hizalama", +"H Align": "Yatay Hizalama", +"V Align": "Dikey Hizalama", +"Top": "\u00dcst", +"Middle": "Orta", +"Bottom": "Alt", +"Header cell": "Ba\u015fl\u0131k h\u00fccresi", +"Row group": "Sat\u0131r grubu", +"Column group": "S\u00fctun grubu", +"Row type": "Sat\u0131r tipi", +"Header": "Ba\u015fl\u0131k", +"Body": "G\u00f6vde", +"Footer": "Alt", +"Border color": "Kenarl\u0131k rengi", +"Insert template...": "\u015eablon ekle...", +"Templates": "\u015eablonlar", +"Template": "Taslak", +"Text color": "Yaz\u0131 rengi", +"Background color": "Arka plan rengi", +"Custom...": "\u00d6zel...", +"Custom color": "\u00d6zel renk", +"No color": "Renk yok", +"Remove color": "Rengi kald\u0131r", +"Table of Contents": "\u0130\u00e7erik tablosu", +"Show blocks": "Bloklar\u0131 g\u00f6ster", +"Show invisible characters": "G\u00f6r\u00fcnmez karakterleri g\u00f6ster", +"Word count": "Kelime say\u0131s\u0131", +"Count": "Say\u0131m", +"Document": "Belge", +"Selection": "Se\u00e7im", +"Words": "S\u00f6zc\u00fck", +"Words: {0}": "Kelime: {0}", +"{0} words": "{0} words", +"File": "Dosya", +"Edit": "D\u00fczenle", +"Insert": "Ekle", +"View": "G\u00f6r\u00fcn\u00fcm", +"Format": "Bi\u00e7im", +"Table": "Tablo", +"Tools": "Ara\u00e7lar", +"Powered by {0}": "Powered by {0}", +"Rich Text Area. Press ALT-F9 for menu. Press ALT-F10 for toolbar. Press ALT-0 for help": "Zengin Metin Alan\u0131. Men\u00fc i\u00e7in ALT-F9 tu\u015funa bas\u0131n\u0131z. Ara\u00e7 \u00e7ubu\u011fu i\u00e7in ALT-F10 tu\u015funa bas\u0131n\u0131z. Yard\u0131m i\u00e7in ALT-0 tu\u015funa bas\u0131n\u0131z.", +"Image title": "Resim ba\u015fl\u0131\u011f\u0131", +"Border width": "Kenar geni\u015fli\u011fi", +"Border style": "Kenar sitili", +"Error": "Hata", +"Warn": "Uyar\u0131", +"Valid": "Ge\u00e7erli", +"To open the popup, press Shift+Enter": "Popup'\u0131 a\u00e7mak i\u00e7in Shift+Enter'a bas\u0131n", +"Rich Text Area. Press ALT-0 for help.": "Zengin Metin Alan\u0131. Yard\u0131m i\u00e7in Alt-0'a bas\u0131n.", +"System Font": "Sistem Yaz\u0131 Tipi", +"Failed to upload image: {0}": "Resim y\u00fcklenemedi: {0}", +"Failed to load plugin: {0} from url {1}": "Eklenti y\u00fcklenemedi: {1} url\u2019sinden {0}", +"Failed to load plugin url: {0}": "Url eklentisi y\u00fcklenemedi: {0}", +"Failed to initialize plugin: {0}": "Eklenti ba\u015flat\u0131lamad\u0131: {0}", +"example": "\u00f6rnek", +"Search": "Ara", +"All": "T\u00fcm\u00fc", +"Currency": "Para birimi", +"Text": "Metin", +"Quotations": "Al\u0131nt\u0131", +"Mathematical": "Matematik", +"Extended Latin": "Uzat\u0131lm\u0131\u015f Latin", +"Symbols": "Semboller", +"Arrows": "Oklar", +"User Defined": "Kullan\u0131c\u0131 Tan\u0131ml\u0131", +"dollar sign": "dolar i\u015fareti", +"currency sign": "para birimi i\u015fareti", +"euro-currency sign": "euro para birimi i\u015fareti", +"colon sign": "colon i\u015fareti", +"cruzeiro sign": "cruzeiro i\u015fareti", +"french franc sign": "frans\u0131z frang\u0131 i\u015fareti", +"lira sign": "lira i\u015fareti", +"mill sign": "mill i\u015fareti", +"naira sign": "naira i\u015fareti", +"peseta sign": "peseta i\u015fareti", +"rupee sign": "rupi i\u015fareti", +"won sign": "won i\u015fareti", +"new sheqel sign": "yeni \u015fekel i\u015fareti", +"dong sign": "dong i\u015fareti", +"kip sign": "kip i\u015fareti", +"tugrik sign": "tugrik i\u015fareti", +"drachma sign": "drahma i\u015fareti", +"german penny symbol": "alman kuru\u015f sembol\u00fc", +"peso sign": "peso i\u015fareti", +"guarani sign": "guarani i\u015fareti", +"austral sign": "austral i\u015fareti", +"hryvnia sign": "hrivniya i\u015fareti", +"cedi sign": "cedi i\u015fareti", +"livre tournois sign": "livre tournois i\u015fareti", +"spesmilo sign": "spesmilo i\u015fareti", +"tenge sign": "tenge i\u015fareti", +"indian rupee sign": "hindistan rupisi i\u015fareti", +"turkish lira sign": "t\u00fcrk liras\u0131 i\u015fareti", +"nordic mark sign": "nordic i\u015fareti", +"manat sign": "manat i\u015fareti", +"ruble sign": "ruble i\u015fareti", +"yen character": "yen karakteri", +"yuan character": "yuan karakteri", +"yuan character, in hong kong and taiwan": "yuan karakteri, hong kong ve tayvan'da kullan\u0131lan", +"yen\/yuan character variant one": "yen\/yuan karakter de\u011fi\u015fkeni", +"Loading emoticons...": "\u0130fadeler y\u00fckleniyor...", +"Could not load emoticons": "\u0130fadeler y\u00fcklenemedi", +"People": "\u0130nsan", +"Animals and Nature": "Hayvanlar ve Do\u011fa", +"Food and Drink": "Yiyecek ve \u0130\u00e7ecek", +"Activity": "Etkinlik", +"Travel and Places": "Gezi ve Yerler", +"Objects": "Nesneler", +"Flags": "Bayraklar", +"Characters": "Karakter", +"Characters (no spaces)": "Karakter (bo\u015fluksuz)", +"{0} characters": "{0} karakter", +"Error: Form submit field collision.": "Hata: Form g\u00f6nderme alan\u0131 \u00e7at\u0131\u015fmas\u0131.", +"Error: No form element found.": "Hata: Form eleman\u0131 bulunamad\u0131.", +"Update": "G\u00fcncelle\u015ftir", +"Color swatch": "Renk \u00f6rne\u011fi", +"Turquoise": "Turkuaz", +"Green": "Ye\u015fil", +"Blue": "Mavi", +"Purple": "Mor", +"Navy Blue": "Lacivert", +"Dark Turquoise": "Koyu Turkuaz", +"Dark Green": "Koyu Ye\u015fil", +"Medium Blue": "Donuk Mavi", +"Medium Purple": "Orta Mor", +"Midnight Blue": "Gece Yar\u0131s\u0131 Mavisi", +"Yellow": "Sar\u0131", +"Orange": "Turuncu", +"Red": "K\u0131rm\u0131z\u0131", +"Light Gray": "A\u00e7\u0131k Gri", +"Gray": "Gri", +"Dark Yellow": "Koyu Sar\u0131", +"Dark Orange": "Koyu Turuncu", +"Dark Red": "Koyu K\u0131rm\u0131z\u0131", +"Medium Gray": "Orta Gri", +"Dark Gray": "Koyu Gri", +"Light Green": "A\u00e7\u0131k Ye\u015fil", +"Light Yellow": "A\u00e7\u0131k Sar\u0131", +"Light Red": "A\u00e7\u0131k K\u0131rm\u0131z\u0131", +"Light Purple": "A\u00e7\u0131k Mor", +"Light Blue": "A\u00e7\u0131k Mavi", +"Dark Purple": "Koyu Mor", +"Dark Blue": "Lacivert", +"Black": "Siyah", +"White": "Beyaz", +"Switch to or from fullscreen mode": "Tam ekran moduna ge\u00e7 veya \u00e7\u0131k", +"Open help dialog": "Yard\u0131m penceresini a\u00e7", +"history": "ge\u00e7mi\u015f", +"styles": "stiller", +"formatting": "bi\u00e7imlendirme", +"alignment": "hizalanma", +"indentation": "girinti", +"permanent pen": "kal\u0131c\u0131 kalem", +"comments": "yorumlar", +"Format Painter": "Bi\u00e7im Boyac\u0131s\u0131", +"Insert\/edit iframe": "\u0130frame ekle\/d\u00fczenle", +"Capitalization": "B\u00fcy\u00fck Harfle Yaz\u0131m", +"lowercase": "k\u00fc\u00e7\u00fck harf", +"UPPERCASE": "B\u00dcY\u00dcK HARF", +"Title Case": "\u0130lk Harfler B\u00fcy\u00fck", +"Permanent Pen Properties": "Kal\u0131c\u0131 Kalem \u00d6zellikleri", +"Permanent pen properties...": "Kal\u0131c\u0131 kalem \u00f6zellikleri...", +"Font": "Yaz\u0131 Tipi", +"Size": "Boyut", +"More...": "Devam\u0131...", +"Spellcheck Language": "Yaz\u0131m Denetimi Dili", +"Select...": "Se\u00e7...", +"Preferences": "Tercihler", +"Yes": "Evet", +"No": "Hay\u0131r", +"Keyboard Navigation": "Klavye Tu\u015flar\u0131", +"Version": "S\u00fcr\u00fcm", +"Anchor": "\u00c7apa", +"Special character": "\u00d6zel karakter", +"Code sample": "Code sample", +"Color": "Renk", +"Emoticons": "\u0130fadeler", +"Document properties": "Dok\u00fcman \u00f6zellikleri", +"Image": "Resim", +"Insert link": "Ba\u011flant\u0131 ekle", +"Target": "Hedef", +"Link": "Ba\u011flant\u0131", +"Poster": "Poster", +"Media": "Medya", +"Print": "Yazd\u0131r", +"Prev": "\u00d6nceki", +"Find and replace": "Bul ve de\u011fi\u015ftir", +"Whole words": "Tam kelimeler", +"Spellcheck": "Yaz\u0131m denetimi", +"Caption": "Ba\u015fl\u0131k", +"Insert template": "\u015eablon ekle" +}); \ No newline at end of file diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 485d26a26..691a7ee06 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,101 +1,50 @@