├── docs ├── site │ ├── .hugo_build.lock │ ├── content │ │ └── .gitignore │ ├── layouts │ │ ├── shortcodes │ │ │ ├── section.html │ │ │ ├── half.html │ │ │ ├── centered.html │ │ │ └── github.html │ │ ├── page │ │ │ └── single.html │ │ └── partials │ │ │ ├── footer.html │ │ │ └── header.html │ ├── static │ │ └── static │ │ │ ├── images │ │ │ ├── logo.png │ │ │ ├── s1.png │ │ │ ├── s2.png │ │ │ ├── s3.png │ │ │ ├── s4.png │ │ │ ├── smtp.png │ │ │ ├── tx.png │ │ │ ├── lists.png │ │ │ ├── media.png │ │ │ ├── splash.png │ │ │ ├── analytics.png │ │ │ ├── favicon.png │ │ │ ├── privacy.png │ │ │ ├── thumbnail.png │ │ │ ├── messengers.png │ │ │ ├── performance.png │ │ │ ├── templating.png │ │ │ ├── 2022-07-31_19-07.png │ │ │ ├── 2022-07-31_19-08.png │ │ │ └── logo.svg │ │ │ └── base.css │ ├── config.toml │ └── data │ │ └── github.json ├── docs │ ├── content │ │ ├── images │ │ │ ├── favicon.png │ │ │ ├── splash.png │ │ │ ├── edit-subscriber.png │ │ │ ├── 2021-09-28_00-18.png │ │ │ ├── query-subscribers.png │ │ │ ├── archived-campaign-metadata.png │ │ │ └── logo.svg │ │ ├── index.md │ │ ├── external-integration.md │ │ ├── archives.md │ │ ├── upgrade.md │ │ ├── developer-setup.md │ │ ├── messengers.md │ │ ├── apis │ │ │ ├── transactional.md │ │ │ ├── apis.md │ │ │ └── import.md │ │ ├── i18n.md │ │ ├── static │ │ │ └── style.css │ │ ├── configuration.md │ │ └── querying-and-segmentation.md │ └── mkdocs.yml ├── README.md └── i18n │ └── style.css ├── dev ├── .gitignore ├── app.Dockerfile ├── config.toml ├── docker-compose.yml └── README.md ├── static ├── public │ ├── static │ │ ├── script.js │ │ ├── logo.png │ │ ├── favicon.png │ │ ├── rss.svg │ │ └── logo.svg │ └── templates │ │ ├── home.html │ │ ├── message.html │ │ ├── optin.html │ │ ├── archive.html │ │ ├── index.html │ │ └── subscription-form.html └── email-templates │ ├── smtp-test.html │ ├── subscriber-data.html │ ├── subscriber-optin-campaign.html │ ├── import-status.html │ ├── subscriber-optin.html │ ├── campaign-status.html │ ├── base.html │ ├── default-archive.tpl │ └── default.tpl ├── VERSION ├── frontend ├── .browserslistrc ├── .env.sample ├── src │ ├── assets │ │ ├── logo.png │ │ ├── favicon.png │ │ ├── icons │ │ │ └── fontello.woff2 │ │ ├── fonts │ │ │ ├── Inter-Bold.woff2 │ │ │ └── Inter-Regular.woff2 │ │ └── logo.svg │ ├── views │ │ ├── About.vue │ │ ├── 404.vue │ │ ├── Logs.vue │ │ ├── settings │ │ │ ├── security.vue │ │ │ ├── privacy.vue │ │ │ ├── appearance.vue │ │ │ └── performance.vue │ │ └── SubscriberBulkList.vue │ ├── components │ │ ├── EmptyPlaceholder.vue │ │ ├── LogView.vue │ │ ├── HTMLEditor.vue │ │ ├── CampaignPreview.vue │ │ └── ListSelector.vue │ ├── constants.js │ ├── main.js │ └── store │ │ └── index.js ├── babel.config.js ├── public │ ├── static │ │ └── favicon.png │ └── index.html ├── cypress │ ├── support │ │ ├── reset.sh │ │ ├── e2e.js │ │ └── commands.js │ ├── fixtures │ │ └── subs-domain-blocklist.csv │ ├── plugins │ │ └── index.js │ └── e2e │ │ ├── dashboard.cy.js │ │ ├── settings.cy.js │ │ ├── archive.cy.js │ │ ├── bounces.cy.js │ │ └── import.cy.js ├── .editorconfig ├── .gitignore ├── .eslintrc.js ├── cypress.config.js ├── vue.config.js ├── package.json └── README.md ├── .gitattributes ├── .gitignore ├── Dockerfile ├── .github ├── ISSUE_TEMPLATE │ ├── general-question.md │ ├── confirmed-bug.md │ ├── feature-or-change-request.md │ └── possible-bug--needs-investigation-.md └── workflows │ ├── release.yml │ └── github-pages.yml ├── config-demo.toml ├── .dockerignore ├── scripts └── refresh-i18n.sh ├── inlang.config.js ├── internal ├── migrations │ ├── v2.4.0.go │ ├── v1.0.0.go │ ├── v0.8.0.go │ ├── v0.4.0.go │ ├── v0.9.0.go │ ├── v2.3.0.go │ ├── v2.2.0.go │ ├── v2.1.0.go │ ├── v2.5.0.go │ └── v2.0.0.go ├── bounce │ ├── mailbox │ │ └── opt.go │ ├── webhooks │ │ └── sendgrid.go │ └── bounce.go ├── core │ ├── dashboard.go │ ├── settings.go │ ├── media.go │ ├── templates.go │ └── bounces.go ├── media │ └── media.go ├── buflog │ └── buflog.go ├── captcha │ └── captcha.go └── events │ └── events.go ├── install-demo.sh ├── config.toml.sample ├── cmd ├── notifications.go ├── events.go ├── updates.go ├── maintenance.go ├── admin.go ├── i18n.go └── utils.go ├── docker-compose.yml ├── .goreleaser.yml ├── go.mod ├── README.md └── listmonk@.service /docs/site/.hugo_build.lock: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /dev/.gitignore: -------------------------------------------------------------------------------- 1 | !config.toml 2 | -------------------------------------------------------------------------------- /docs/site/content/.gitignore: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /static/public/static/script.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | e1c0bf503 2 | HEAD -> master 3 | -------------------------------------------------------------------------------- /frontend/.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | not dead 4 | -------------------------------------------------------------------------------- /docs/site/layouts/shortcodes/section.html: -------------------------------------------------------------------------------- 1 |
2 | {{ .Inner }} 3 |
-------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | frontend/* linguist-vendored 2 | VERSION export-subst 3 | * text=auto eol=lf 4 | -------------------------------------------------------------------------------- /frontend/.env.sample: -------------------------------------------------------------------------------- 1 | LISTMONK_FRONTEND_PORT=8080 2 | LISTMONK_API_URL="http://127.0.0.1:9000" 3 | -------------------------------------------------------------------------------- /frontend/src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polydice/listmonk/master/frontend/src/assets/logo.png -------------------------------------------------------------------------------- /static/public/static/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polydice/listmonk/master/static/public/static/logo.png -------------------------------------------------------------------------------- /frontend/src/assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polydice/listmonk/master/frontend/src/assets/favicon.png -------------------------------------------------------------------------------- /frontend/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/cli-plugin-babel/preset', 4 | ], 5 | }; 6 | -------------------------------------------------------------------------------- /frontend/public/static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polydice/listmonk/master/frontend/public/static/favicon.png -------------------------------------------------------------------------------- /static/public/static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polydice/listmonk/master/static/public/static/favicon.png -------------------------------------------------------------------------------- /docs/docs/content/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polydice/listmonk/master/docs/docs/content/images/favicon.png -------------------------------------------------------------------------------- /docs/docs/content/images/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polydice/listmonk/master/docs/docs/content/images/splash.png -------------------------------------------------------------------------------- /docs/site/static/static/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polydice/listmonk/master/docs/site/static/static/images/logo.png -------------------------------------------------------------------------------- /docs/site/static/static/images/s1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polydice/listmonk/master/docs/site/static/static/images/s1.png -------------------------------------------------------------------------------- /docs/site/static/static/images/s2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polydice/listmonk/master/docs/site/static/static/images/s2.png -------------------------------------------------------------------------------- /docs/site/static/static/images/s3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polydice/listmonk/master/docs/site/static/static/images/s3.png -------------------------------------------------------------------------------- /docs/site/static/static/images/s4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polydice/listmonk/master/docs/site/static/static/images/s4.png -------------------------------------------------------------------------------- /docs/site/static/static/images/smtp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polydice/listmonk/master/docs/site/static/static/images/smtp.png -------------------------------------------------------------------------------- /docs/site/static/static/images/tx.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polydice/listmonk/master/docs/site/static/static/images/tx.png -------------------------------------------------------------------------------- /docs/site/static/static/images/lists.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polydice/listmonk/master/docs/site/static/static/images/lists.png -------------------------------------------------------------------------------- /docs/site/static/static/images/media.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polydice/listmonk/master/docs/site/static/static/images/media.png -------------------------------------------------------------------------------- /docs/site/static/static/images/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polydice/listmonk/master/docs/site/static/static/images/splash.png -------------------------------------------------------------------------------- /frontend/src/assets/icons/fontello.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polydice/listmonk/master/frontend/src/assets/icons/fontello.woff2 -------------------------------------------------------------------------------- /frontend/src/views/About.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /docs/docs/content/images/edit-subscriber.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polydice/listmonk/master/docs/docs/content/images/edit-subscriber.png -------------------------------------------------------------------------------- /docs/site/layouts/shortcodes/half.html: -------------------------------------------------------------------------------- 1 |
2 |
{{ .Inner }}
3 |
4 |
-------------------------------------------------------------------------------- /docs/site/static/static/images/analytics.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polydice/listmonk/master/docs/site/static/static/images/analytics.png -------------------------------------------------------------------------------- /docs/site/static/static/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polydice/listmonk/master/docs/site/static/static/images/favicon.png -------------------------------------------------------------------------------- /docs/site/static/static/images/privacy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polydice/listmonk/master/docs/site/static/static/images/privacy.png -------------------------------------------------------------------------------- /docs/site/static/static/images/thumbnail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polydice/listmonk/master/docs/site/static/static/images/thumbnail.png -------------------------------------------------------------------------------- /frontend/src/assets/fonts/Inter-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polydice/listmonk/master/frontend/src/assets/fonts/Inter-Bold.woff2 -------------------------------------------------------------------------------- /docs/docs/content/images/2021-09-28_00-18.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polydice/listmonk/master/docs/docs/content/images/2021-09-28_00-18.png -------------------------------------------------------------------------------- /docs/docs/content/images/query-subscribers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polydice/listmonk/master/docs/docs/content/images/query-subscribers.png -------------------------------------------------------------------------------- /docs/site/static/static/images/messengers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polydice/listmonk/master/docs/site/static/static/images/messengers.png -------------------------------------------------------------------------------- /docs/site/static/static/images/performance.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polydice/listmonk/master/docs/site/static/static/images/performance.png -------------------------------------------------------------------------------- /docs/site/static/static/images/templating.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polydice/listmonk/master/docs/site/static/static/images/templating.png -------------------------------------------------------------------------------- /frontend/src/assets/fonts/Inter-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polydice/listmonk/master/frontend/src/assets/fonts/Inter-Regular.woff2 -------------------------------------------------------------------------------- /docs/site/static/static/images/2022-07-31_19-07.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polydice/listmonk/master/docs/site/static/static/images/2022-07-31_19-07.png -------------------------------------------------------------------------------- /docs/site/static/static/images/2022-07-31_19-08.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polydice/listmonk/master/docs/site/static/static/images/2022-07-31_19-08.png -------------------------------------------------------------------------------- /frontend/cypress/support/reset.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | pkill -9 listmonk 4 | cd ../ 5 | ./listmonk --install --yes 6 | ./listmonk > /dev/null 2>/dev/null & 7 | -------------------------------------------------------------------------------- /docs/docs/content/images/archived-campaign-metadata.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/polydice/listmonk/master/docs/docs/content/images/archived-campaign-metadata.png -------------------------------------------------------------------------------- /docs/site/layouts/page/single.html: -------------------------------------------------------------------------------- 1 | {{ partial "header" . }} 2 |
3 |

{{ .Title }}

4 | {{ .Content }} 5 |
6 | {{ partial "footer" }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | frontend/node_modules/ 2 | frontend/.cache/ 3 | frontend/yarn.lock 4 | frontend/build/ 5 | .vscode/ 6 | 7 | config.toml 8 | node_modules 9 | listmonk 10 | dist/* -------------------------------------------------------------------------------- /docs/site/layouts/shortcodes/centered.html: -------------------------------------------------------------------------------- 1 |
2 |
 
3 |
{{ .Inner }}
4 |
5 |
-------------------------------------------------------------------------------- /static/email-templates/smtp-test.html: -------------------------------------------------------------------------------- 1 | {{ define "smtp-test" }} 2 | {{ template "header" . }} 3 |

{{ L.Ts "settings.smtp.testConnection" }}

4 | {{ template "footer" }} 5 | {{ end }} 6 | -------------------------------------------------------------------------------- /frontend/.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{js,jsx,ts,tsx,vue}] 2 | indent_style = space 3 | indent_size = 2 4 | end_of_line = lf 5 | trim_trailing_whitespace = true 6 | insert_final_newline = true 7 | max_line_length = 100 8 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:latest 2 | RUN apk --no-cache add ca-certificates tzdata 3 | WORKDIR /listmonk 4 | COPY listmonk . 5 | COPY config.toml.sample config.toml 6 | COPY config-demo.toml . 7 | CMD ["./listmonk"] 8 | EXPOSE 9000 9 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/general-question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: General question 3 | about: You have a question about something or want to start a general discussion 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /docs/site/config.toml: -------------------------------------------------------------------------------- 1 | baseurl = "https://listmonk.app/" 2 | languageCode = "en-us" 3 | title = "listmonk - Free and open source self-hosted newsletter, mailing list manager, and transactional mails" 4 | 5 | [taxonomies] 6 | tag = "tags" 7 | -------------------------------------------------------------------------------- /static/email-templates/subscriber-data.html: -------------------------------------------------------------------------------- 1 | {{ define "subscriber-data" }} 2 | {{ template "header" . }} 3 |

{{ L.Ts "email.data.title" }}

4 |

5 | {{ L.Ts "email.data.info" }} 6 |

7 | {{ template "footer" }} 8 | {{ end }} 9 | -------------------------------------------------------------------------------- /frontend/src/views/404.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 13 | -------------------------------------------------------------------------------- /dev/app.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.17 AS go 2 | 3 | FROM node:16 AS node 4 | 5 | COPY --from=go /usr/local/go /usr/local/go 6 | ENV GOPATH /go 7 | ENV CGO_ENABLED=0 8 | ENV PATH $GOPATH/bin:/usr/local/go/bin:$PATH 9 | 10 | WORKDIR /app 11 | ENTRYPOINT [ "" ] 12 | -------------------------------------------------------------------------------- /docs/site/layouts/partials/footer.html: -------------------------------------------------------------------------------- 1 | 2 |
3 | 6 |
7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /config-demo.toml: -------------------------------------------------------------------------------- 1 | [app] 2 | # Interface and port where the app will run its webserver. 3 | address = "0.0.0.0:9000" 4 | 5 | # Database. 6 | [db] 7 | host = "demo-db" 8 | port = 5432 9 | user = "listmonk" 10 | password = "listmonk" 11 | database = "listmonk" 12 | ssl_mode = "disable" 13 | max_open = 25 14 | max_idle = 25 15 | max_lifetime = "300s" 16 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | # local env files 6 | .env.local 7 | .env.*.local 8 | 9 | # Log files 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | pnpm-debug.log* 14 | 15 | # Editor directories and files 16 | .idea 17 | .vscode 18 | *.suo 19 | *.ntvs* 20 | *.njsproj 21 | *.sln 22 | *.sw? 23 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | **/.classpath 2 | **/.dockerignore 3 | **/.env 4 | **/.git 5 | **/.gitignore 6 | **/.project 7 | **/.settings 8 | **/.toolstarget 9 | **/.vs 10 | **/.vscode 11 | **/*.*proj.user 12 | **/*.dbmdl 13 | **/*.jfm 14 | **/azds.yaml 15 | **/bin 16 | **/charts 17 | **/docker-compose* 18 | **/Dockerfile* 19 | **/node_modules 20 | **/npm-debug.log 21 | **/obj 22 | **/secrets.dev.yaml 23 | **/values.dev.yaml 24 | LICENSE 25 | README.md -------------------------------------------------------------------------------- /frontend/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true, 5 | }, 6 | extends: [ 7 | 'plugin:vue/essential', 8 | '@vue/airbnb', 9 | ], 10 | parserOptions: { 11 | parser: 'babel-eslint', 12 | }, 13 | rules: { 14 | 'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 15 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /static/public/static/rss.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Static website and docs 2 | 3 | This repository contains the source for the static website https://listmonk.app 4 | 5 | - The website is in `site` and is built with hugo (run `hugo serve` inside `site` to preview). 6 | 7 | - Documentation is in `docs` and is built with mkdocs (inside `docs`, run `mkdocs serve` to preview after running `pip install -r requirements.txt`) 8 | 9 | - `i18n` directory has the static UI for i18n translations: https://listmonk.app/i18n 10 | -------------------------------------------------------------------------------- /frontend/cypress/fixtures/subs-domain-blocklist.csv: -------------------------------------------------------------------------------- 1 | email,name,attributes 2 | noban1-import@mail.com,First0 Last0,"{""age"": 29, ""city"": ""Bangalore"", ""clientId"": ""DAXX79""}" 3 | ban1-import@BAN.net,First1 Last1,"{""age"": 43, ""city"": ""Bangalore"", ""clientId"": ""DAXX71""}" 4 | noban2-import1@mail.com,First2 Last2,"{""age"": 47, ""city"": ""Bangalore"", ""clientId"": ""DAXX70""}" 5 | ban2-import@ban.ORG,First1 Last1,"{""age"": 43, ""city"": ""Bangalore"", ""clientId"": ""DAXX71""}" 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/confirmed-bug.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Confirmed bug 3 | about: Report an issue that you have definititely confirmed to be a bug 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Version:** 11 | - listmonk: [eg: v1.0.0] 12 | - OS: [e.g. Fedora] 13 | 14 | **Description of the bug and steps to reproduce:** 15 | A clear and concise description of what the bug is. 16 | 17 | **Screenshots:** 18 | If applicable, add screenshots to help explain your problem. 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-or-change-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature or change request 3 | about: Suggest new features or changes to existing features 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/possible-bug--needs-investigation-.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Possible bug. Needs investigation. 3 | about: Report an issue that could be a bug but is not confirmed yet and needs investigation. 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Version:** 11 | - listmonk: [eg: v1.0.0] 12 | - OS: [e.g. Fedora] 13 | 14 | **Description of the bug and steps to reproduce:** 15 | A clear and concise description of what the bug is. 16 | 17 | **Screenshots:** 18 | If applicable, add screenshots to help explain your problem. 19 | -------------------------------------------------------------------------------- /static/public/templates/home.html: -------------------------------------------------------------------------------- 1 | {{ define "home" }} 2 | {{ template "header" .}} 3 | 4 |
5 | {{ L.T "users.login" }} 6 | 7 |
8 | {{ if .EnablePublicSubPage }} 9 | {{ L.T "public.sub" }} 10 | {{ end }} 11 | {{ if .EnablePublicArchive }} 12 | {{ L.T "public.archiveTitle" }} 13 | {{ end }} 14 |
15 |
16 | 17 | {{ template "footer" .}} 18 | {{ end }} -------------------------------------------------------------------------------- /scripts/refresh-i18n.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # "Refresh" all i18n language files by merging missing keys in lang files 4 | # from a base language file. In addition, sort all files by keys. 5 | 6 | BASE_DIR=$(dirname "$0")"/../i18n" # Exclude the trailing slash. 7 | BASE_FILE="en.json" 8 | 9 | # Iterate through all i18n files and merge them into the base file, 10 | # filling in missing keys. 11 | for fpath in "$BASE_DIR/"*.json; do 12 | echo $(basename -- $fpath) 13 | echo "$( jq -s '.[0] * .[1]' -S --indent 4 "$BASE_DIR/$BASE_FILE" $fpath )" > $fpath 14 | done 15 | -------------------------------------------------------------------------------- /frontend/src/components/EmptyPlaceholder.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 22 | -------------------------------------------------------------------------------- /inlang.config.js: -------------------------------------------------------------------------------- 1 | export async function defineConfig(env) { 2 | const { default: pluginJson } = await env.$import( 3 | 'https://cdn.jsdelivr.net/gh/samuelstroschein/inlang-plugin-json@2.3.1/dist/index.js' 4 | ); 5 | 6 | const { default: standardLintRules } = await env.$import( 7 | 'https://cdn.jsdelivr.net/gh/inlang/standard-lint-rules@2/dist/index.js' 8 | ); 9 | 10 | return { 11 | referenceLanguage: 'en', 12 | plugins: [pluginJson({ 13 | pathPattern: './i18n/{language}.json', 14 | variableReferencePattern: ["{", "}"] 15 | }), standardLintRules()] 16 | }; 17 | } 18 | -------------------------------------------------------------------------------- /static/email-templates/subscriber-optin-campaign.html: -------------------------------------------------------------------------------- 1 | {{ define "optin-campaign" }} 2 | 3 |

{{ L.Ts "email.optin.confirmSubWelcome" }} {{ "{{" }}.Subscriber.FirstName {{ "}}" }}

4 |

{{ L.Ts "email.optin.confirmSubInfo" }}

5 | 14 |

15 | {{ L.Ts "email.optin.confirmSub" }} 16 |

17 | {{ end }} 18 | -------------------------------------------------------------------------------- /internal/migrations/v2.4.0.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "github.com/jmoiron/sqlx" 5 | "github.com/knadh/koanf/v2" 6 | "github.com/knadh/stuffbin" 7 | ) 8 | 9 | // V2_4_0 performs the DB migrations. 10 | func V2_4_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error { 11 | // Insert new preference settings. 12 | if _, err := db.Exec(` 13 | INSERT INTO settings (key, value) VALUES 14 | ('security.enable_captcha', 'false'), 15 | ('security.captcha_key', '""'), 16 | ('security.captcha_secret', '""') 17 | ON CONFLICT DO NOTHING; 18 | `); err != nil { 19 | return err 20 | } 21 | 22 | return nil 23 | } 24 | -------------------------------------------------------------------------------- /docs/site/layouts/shortcodes/github.html: -------------------------------------------------------------------------------- 1 | 17 |
-------------------------------------------------------------------------------- /internal/migrations/v1.0.0.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "github.com/jmoiron/sqlx" 5 | "github.com/knadh/koanf/v2" 6 | "github.com/knadh/stuffbin" 7 | ) 8 | 9 | // V1_0_0 performs the DB migrations for v.1.0.0. 10 | func V1_0_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error { 11 | if _, err := db.Exec(`ALTER TYPE content_type ADD VALUE IF NOT EXISTS 'markdown'`); err != nil { 12 | return err 13 | } 14 | 15 | if _, err := db.Exec(` 16 | INSERT INTO settings (key, value) VALUES 17 | ('app.check_updates', 'true') 18 | ON CONFLICT DO NOTHING; 19 | `); err != nil { 20 | return err 21 | } 22 | 23 | return nil 24 | } 25 | -------------------------------------------------------------------------------- /frontend/cypress/support/e2e.js: -------------------------------------------------------------------------------- 1 | import './commands'; 2 | 3 | beforeEach(() => { 4 | cy.server({ 5 | ignore: (xhr) => { 6 | // Ignore the webpack dev server calls that interfere in the tests 7 | // when testing with `yarn serve`. 8 | if (xhr.url.indexOf('sockjs-node/') > -1) { 9 | return true; 10 | } 11 | 12 | if (xhr.url.indexOf('api/health') > -1) { 13 | return true; 14 | } 15 | 16 | // Return the default cypress whitelist filer. 17 | return xhr.method === 'GET' && /\.(jsx?|html|css)(\?.*)?$/.test(xhr.url); 18 | }, 19 | }); 20 | 21 | cy.intercept('GET', '/api/health', {}); 22 | }); 23 | -------------------------------------------------------------------------------- /frontend/cypress.config.js: -------------------------------------------------------------------------------- 1 | const { defineConfig } = require('cypress') 2 | 3 | module.exports = defineConfig({ 4 | env: { 5 | apiUrl: 'http://localhost:9000', 6 | serverInitCmd: 7 | 'pkill -9 listmonk | cd ../ && ./listmonk --install --yes && ./listmonk > /dev/null 2>/dev/null &', 8 | username: 'listmonk', 9 | password: 'listmonk', 10 | }, 11 | viewportWidth: 1300, 12 | viewportHeight: 950, 13 | e2e: { 14 | // We've imported your old cypress plugins here. 15 | // You may want to clean this up later by importing these. 16 | setupNodeEvents(on, config) { 17 | return require('./cypress/plugins/index.js')(on, config) 18 | }, 19 | baseUrl: 'http://localhost:9000/admin', 20 | }, 21 | }) 22 | -------------------------------------------------------------------------------- /docs/docs/content/index.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | [![listmonk](images/logo.svg)](https://listmonk.app) 4 | 5 | listmonk is a self-hosted, high performance mailing list and newsletter manager. It comes as a standalone binary and the only dependency is a Postgres database. 6 | 7 | [![listmonk screenshot](https://user-images.githubusercontent.com/547147/134939475-e0391111-f762-44cb-b056-6cb0857755e3.png)](https://listmonk.app) 8 | 9 | ## Developers 10 | 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). The backend is written in Go and the frontend is Vue with Buefy for UI. 11 | -------------------------------------------------------------------------------- /static/email-templates/import-status.html: -------------------------------------------------------------------------------- 1 | {{ define "import-status" }} 2 | {{ template "header" . }} 3 |

{{ L.Ts "email.status.importTitle" }}

4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
{{ L.Ts "email.status.importFile" }}{{ .Name }}
{{ L.Ts "email.status.status" }}{{ .Status }}
{{ L.Ts "email.status.importRecords" }}{{ .Imported }} / {{ .Total }}
18 | {{ template "footer" }} 19 | {{ end }} 20 | -------------------------------------------------------------------------------- /static/email-templates/subscriber-optin.html: -------------------------------------------------------------------------------- 1 | {{ define "subscriber-optin" }} 2 | {{ template "header" . }} 3 |

{{ L.Ts "email.optin.confirmSubTitle" }}

4 |

{{ L.Ts "email.optin.confirmSubWelcome" }} {{ .Subscriber.FirstName }}

5 |

{{ L.Ts "email.optin.confirmSubInfo" }}

6 | 15 |

{{ L.Ts "email.optin.confirmSubHelp" }}

16 |

17 | {{ L.Ts "email.optin.confirmSub" }} 18 |

19 | {{ L.T "email.unsub" }} 20 | 21 | {{ template "footer" }} 22 | {{ end }} 23 | -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | <%= htmlWebpackPlugin.options.title %> 11 | 12 | 13 | 16 | 17 |
18 | 19 | 20 | -------------------------------------------------------------------------------- /frontend/cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | /// 2 | // *********************************************************** 3 | // This example plugins/index.js can be used to load plugins 4 | // 5 | // You can change the location of this file or turn off loading 6 | // the plugins file with the 'pluginsFile' configuration option. 7 | // 8 | // You can read more here: 9 | // https://on.cypress.io/plugins-guide 10 | // *********************************************************** 11 | 12 | // This function is called when a project is opened or re-opened (e.g. due to 13 | // the project's config changing) 14 | 15 | /** 16 | * @type {Cypress.PluginConfig} 17 | */ 18 | module.exports = (on, config) => { 19 | // `on` is used to hook into various events Cypress emits 20 | // `config` is the resolved Cypress config 21 | } 22 | -------------------------------------------------------------------------------- /internal/migrations/v0.8.0.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "github.com/jmoiron/sqlx" 5 | "github.com/knadh/koanf/v2" 6 | "github.com/knadh/stuffbin" 7 | ) 8 | 9 | // V0_8_0 performs the DB migrations for v.0.8.0. 10 | func V0_8_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error { 11 | _, err := db.Exec(` 12 | INSERT INTO settings (key, value) VALUES ('privacy.individual_tracking', 'false') 13 | ON CONFLICT DO NOTHING; 14 | INSERT INTO settings (key, value) VALUES ('messengers', '[]') 15 | ON CONFLICT DO NOTHING; 16 | 17 | -- Link clicks shouldn't exist if there's no corresponding link. 18 | -- links_clicks.link_id should have been NOT NULL originally. 19 | DELETE FROM link_clicks WHERE link_id is NULL; 20 | ALTER TABLE link_clicks ALTER COLUMN link_id SET NOT NULL; 21 | `) 22 | return err 23 | } 24 | -------------------------------------------------------------------------------- /docs/site/data/github.json: -------------------------------------------------------------------------------- 1 | {"version":"v2.4.0","date":"2023-03-20T13:54:12Z","url":"https://github.com/knadh/listmonk/releases/tag/v2.4.0","assets":[{"name":"darwin","url":"https://github.com/knadh/listmonk/releases/download/v2.4.0/listmonk_2.4.0_darwin_amd64.tar.gz"},{"name":"freebsd","url":"https://github.com/knadh/listmonk/releases/download/v2.4.0/listmonk_2.4.0_freebsd_amd64.tar.gz"},{"name":"linux","url":"https://github.com/knadh/listmonk/releases/download/v2.4.0/listmonk_2.4.0_linux_amd64.tar.gz"},{"name":"netbsd","url":"https://github.com/knadh/listmonk/releases/download/v2.4.0/listmonk_2.4.0_netbsd_amd64.tar.gz"},{"name":"openbsd","url":"https://github.com/knadh/listmonk/releases/download/v2.4.0/listmonk_2.4.0_openbsd_amd64.tar.gz"},{"name":"windows","url":"https://github.com/knadh/listmonk/releases/download/v2.4.0/listmonk_2.4.0_windows_amd64.tar.gz"}]} 2 | -------------------------------------------------------------------------------- /internal/bounce/mailbox/opt.go: -------------------------------------------------------------------------------- 1 | package mailbox 2 | 3 | import "time" 4 | 5 | // Opt represents an e-mail POP/IMAP mailbox configuration. 6 | type Opt struct { 7 | // Host is the server's hostname. 8 | Host string `json:"host"` 9 | 10 | // Port is the server port. 11 | Port int `json:"port"` 12 | 13 | AuthProtocol string `json:"auth_protocol"` 14 | 15 | // Username is the mail server login username. 16 | Username string `json:"username"` 17 | 18 | // Password is the mail server login password. 19 | Password string `json:"password"` 20 | 21 | // Folder is the name of the IMAP folder to scan for e-mails. 22 | Folder string `json:"folder"` 23 | 24 | // Optional TLS settings. 25 | TLSEnabled bool `json:"tls_enabled"` 26 | TLSSkipVerify bool `json:"tls_skip_verify"` 27 | 28 | ScanInterval time.Duration `json:"scan_interval"` 29 | } 30 | -------------------------------------------------------------------------------- /internal/migrations/v0.4.0.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "github.com/jmoiron/sqlx" 5 | "github.com/knadh/koanf/v2" 6 | "github.com/knadh/stuffbin" 7 | ) 8 | 9 | // V0_4_0 performs the DB migrations for v.0.4.0. 10 | func V0_4_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error { 11 | _, err := db.Exec(` 12 | DO $$ 13 | BEGIN 14 | IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'list_optin') THEN 15 | CREATE TYPE list_optin AS ENUM ('single', 'double'); 16 | END IF; 17 | IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'campaign_type') THEN 18 | CREATE TYPE campaign_type AS ENUM ('regular', 'optin'); 19 | END IF; 20 | END$$; 21 | 22 | ALTER TABLE lists ADD COLUMN IF NOT EXISTS optin list_optin NOT NULL DEFAULT 'single'; 23 | ALTER TABLE campaigns ADD COLUMN IF NOT EXISTS type campaign_type DEFAULT 'regular'; 24 | `) 25 | return err 26 | } 27 | -------------------------------------------------------------------------------- /static/public/templates/message.html: -------------------------------------------------------------------------------- 1 | {{ define "message" }} 2 | {{ template "header" .}} 3 | 4 |

{{ .Data.Title }}

5 |
6 | {{ .Data.Message }} 7 |
8 | 9 |

10 | {{ L.T "globals.buttons.back" }} 11 |

12 | 13 | 26 | {{ template "footer" .}} 27 | {{ end }} 28 | -------------------------------------------------------------------------------- /frontend/cypress/e2e/dashboard.cy.js: -------------------------------------------------------------------------------- 1 | describe('Dashboard', () => { 2 | it('Opens dashboard', () => { 3 | cy.resetDB(); 4 | cy.loginAndVisit('/'); 5 | 6 | // List counts. 7 | cy.get('[data-cy=lists] .title').contains('2'); 8 | cy.get('[data-cy=lists]') 9 | .and('contain', '1 Public') 10 | .and('contain', '1 Private') 11 | .and('contain', '1 Single opt-in') 12 | .and('contain', '1 Double opt-in'); 13 | 14 | // Campaign counts. 15 | cy.get('[data-cy=campaigns] .title').contains('1'); 16 | cy.get('[data-cy=campaigns-draft]').contains('1'); 17 | 18 | // Subscriber counts. 19 | cy.get('[data-cy=subscribers] .title').contains('2'); 20 | cy.get('[data-cy=subscribers]') 21 | .should('contain', '0 Blocklisted') 22 | .and('contain', '0 Orphans'); 23 | 24 | // Message count. 25 | cy.get('[data-cy=messages] .title').contains('0'); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /install-demo.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | set -eu 3 | 4 | # Listmonk demo setup using `docker-compose`. 5 | # See https://listmonk.app/docs/installation/ for detailed installation steps. 6 | 7 | check_dependencies() { 8 | if ! command -v curl > /dev/null; then 9 | echo "curl is not installed." 10 | exit 1 11 | fi 12 | 13 | if ! command -v docker > /dev/null; then 14 | echo "docker is not installed." 15 | exit 1 16 | fi 17 | 18 | if ! command -v docker-compose > /dev/null; then 19 | echo "docker-compose is not installed." 20 | exit 1 21 | fi 22 | } 23 | 24 | setup_containers() { 25 | curl -o docker-compose.yml https://raw.githubusercontent.com/knadh/listmonk/master/docker-compose.yml 26 | docker-compose up -d demo-db demo-app 27 | } 28 | 29 | show_output(){ 30 | echo -e "\nListmonk is now up and running. Visit http://localhost:9000 in your browser.\n" 31 | } 32 | 33 | 34 | check_dependencies 35 | setup_containers 36 | show_output 37 | -------------------------------------------------------------------------------- /docs/docs/content/external-integration.md: -------------------------------------------------------------------------------- 1 | # Integrating with external systems 2 | 3 | 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. 4 | 5 | ## Using APIs 6 | 7 | The [subscriber APIs](../apis/subscribers) 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. 8 | 9 | ## Interacting directly with the DB 10 | 11 | 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. 12 | -------------------------------------------------------------------------------- /static/public/templates/optin.html: -------------------------------------------------------------------------------- 1 | {{ define "optin" }} 2 | {{ template "header" .}} 3 |
4 |

{{ L.T "public.confirmSubTitle" }}

5 |

6 | {{ L.T "public.confirmSubInfo" }} 7 |

8 | 9 |
10 |
    11 | {{ range $i, $l := .Data.Lists }} 12 | 13 | {{ if eq $l.Type "public" }} 14 |
  • {{ $l.Name }}
  • 15 | {{ else }} 16 |
  • {{ L.Ts "public.subPrivateList" }}
  • 17 | {{ end }} 18 | {{ end }} 19 |
20 |

21 | 22 | 25 |

26 |
27 |
28 | 29 | {{ template "footer" .}} 30 | {{ end }} -------------------------------------------------------------------------------- /static/email-templates/campaign-status.html: -------------------------------------------------------------------------------- 1 | {{ define "campaign-status" }} 2 | {{ template "header" . }} 3 |

{{ L.Ts "email.status.campaignUpdateTitle" }}

4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | {{ if ne (index . "Reason") "" }} 18 | 19 | 20 | 21 | 22 | {{ end }} 23 |
{{ L.Ts "globals.terms.campaign" }}{{ index . "Name" }}
{{ L.Ts "email.status.status" }}{{ index . "Status" }}
{{ L.Ts "email.status.campaignSent" }}{{ index . "Sent" }} / {{ index . "ToSend" }}
{{ L.Ts "email.status.campaignReason" }}{{ index . "Reason" }}
24 | {{ template "footer" }} 25 | {{ end }} 26 | -------------------------------------------------------------------------------- /internal/core/dashboard.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/jmoiron/sqlx/types" 7 | "github.com/labstack/echo/v4" 8 | ) 9 | 10 | // GetDashboardCharts returns chart data points to render on the dashboard. 11 | func (c *Core) GetDashboardCharts() (types.JSONText, error) { 12 | var out types.JSONText 13 | if err := c.q.GetDashboardCharts.Get(&out); err != nil { 14 | return nil, echo.NewHTTPError(http.StatusInternalServerError, 15 | c.i18n.Ts("globals.messages.errorFetching", "name", "dashboard charts", "error", pqErrMsg(err))) 16 | } 17 | 18 | return out, nil 19 | } 20 | 21 | // GetDashboardCounts returns stats counts to show on the dashboard. 22 | func (c *Core) GetDashboardCounts() (types.JSONText, error) { 23 | var out types.JSONText 24 | if err := c.q.GetDashboardCounts.Get(&out); err != nil { 25 | return nil, echo.NewHTTPError(http.StatusInternalServerError, 26 | c.i18n.Ts("globals.messages.errorFetching", "name", "dashboard stats", "error", pqErrMsg(err))) 27 | } 28 | 29 | return out, nil 30 | } 31 | -------------------------------------------------------------------------------- /internal/media/media.go: -------------------------------------------------------------------------------- 1 | package media 2 | 3 | import ( 4 | "io" 5 | 6 | "github.com/knadh/listmonk/models" 7 | "gopkg.in/volatiletech/null.v6" 8 | ) 9 | 10 | // Media represents an uploaded object. 11 | type Media struct { 12 | ID int `db:"id" json:"id"` 13 | UUID string `db:"uuid" json:"uuid"` 14 | Filename string `db:"filename" json:"filename"` 15 | ContentType string `db:"content_type" json:"content_type"` 16 | Thumb string `db:"thumb" json:"-"` 17 | CreatedAt null.Time `db:"created_at" json:"created_at"` 18 | ThumbURL null.String `json:"thumb_url"` 19 | Provider string `json:"provider"` 20 | Meta models.JSON `db:"meta" json:"meta"` 21 | URL string `json:"url"` 22 | 23 | Total int `db:"total" json:"-"` 24 | } 25 | 26 | // Store represents functions to store and retrieve media (files). 27 | type Store interface { 28 | Put(string, string, io.ReadSeeker) (string, error) 29 | Delete(string) error 30 | GetURL(string) string 31 | GetBlob(string) ([]byte, error) 32 | } 33 | -------------------------------------------------------------------------------- /dev/config.toml: -------------------------------------------------------------------------------- 1 | # IMPORTANT: This configuration is meant for development only 2 | ### DO NOT USE IN PRODUCTION ### 3 | 4 | [app] 5 | # Interface and port where the app will run its webserver. The default value 6 | # of localhost will only listen to connections from the current machine. To 7 | # listen on all interfaces use '0.0.0.0'. To listen on the default web address 8 | # port, use port 80 (this will require running with elevated permissions). 9 | address = "0.0.0.0:9000" 10 | 11 | # BasicAuth authentication for the admin dashboard. This will eventually 12 | # be replaced with a better multi-user, role-based authentication system. 13 | # IMPORTANT: Leave both values empty to disable authentication on admin 14 | # only where an external authentication is already setup. 15 | admin_username = "listmonk" 16 | admin_password = "listmonk" 17 | 18 | # Database. 19 | [db] 20 | host = "db" 21 | port = 5432 22 | user = "listmonk-dev" 23 | password = "listmonk-dev" 24 | database = "listmonk-dev" 25 | ssl_mode = "disable" 26 | max_open = 25 27 | max_idle = 25 28 | max_lifetime = "300s" 29 | -------------------------------------------------------------------------------- /frontend/src/views/Logs.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 50 | -------------------------------------------------------------------------------- /config.toml.sample: -------------------------------------------------------------------------------- 1 | [app] 2 | # Interface and port where the app will run its webserver. The default value 3 | # of localhost will only listen to connections from the current machine. To 4 | # listen on all interfaces use '0.0.0.0'. To listen on the default web address 5 | # port, use port 80 (this will require running with elevated permissions). 6 | address = "localhost:9000" 7 | 8 | # BasicAuth authentication for the admin dashboard. This will eventually 9 | # be replaced with a better multi-user, role-based authentication system. 10 | # IMPORTANT: Leave both values empty to disable authentication on admin 11 | # only where an external authentication is already setup. 12 | admin_username = "listmonk" 13 | admin_password = "listmonk" 14 | 15 | # Database. 16 | [db] 17 | host = "localhost" 18 | port = 5432 19 | user = "listmonk" 20 | password = "listmonk" 21 | 22 | # Ensure that this database has been created in Postgres. 23 | database = "listmonk" 24 | 25 | ssl_mode = "disable" 26 | max_open = 25 27 | max_idle = 25 28 | max_lifetime = "300s" 29 | 30 | # Optional space separated Postgres DSN params. eg: "application_name=listmonk gssencmode=disable" 31 | params = "" 32 | -------------------------------------------------------------------------------- /internal/buflog/buflog.go: -------------------------------------------------------------------------------- 1 | package buflog 2 | 3 | import ( 4 | "bytes" 5 | "strings" 6 | "sync" 7 | ) 8 | 9 | // BufLog implements a simple log buffer that can be supplied to a std 10 | // log instance. It stores logs up to N lines. 11 | type BufLog struct { 12 | maxLines int 13 | buf *bytes.Buffer 14 | lines []string 15 | 16 | sync.RWMutex 17 | } 18 | 19 | // New returns a new log buffer that stores up to maxLines lines. 20 | func New(maxLines int) *BufLog { 21 | return &BufLog{ 22 | maxLines: maxLines, 23 | buf: &bytes.Buffer{}, 24 | lines: make([]string, 0, maxLines), 25 | } 26 | } 27 | 28 | // Write writes a log item to the buffer maintaining maxLines capacity 29 | // using LIFO. 30 | func (bu *BufLog) Write(b []byte) (n int, err error) { 31 | bu.Lock() 32 | if len(bu.lines) >= bu.maxLines { 33 | bu.lines[0] = "" 34 | bu.lines = bu.lines[1:len(bu.lines)] 35 | } 36 | 37 | bu.lines = append(bu.lines, strings.TrimSpace(string(b))) 38 | bu.Unlock() 39 | return len(b), nil 40 | } 41 | 42 | // Lines returns the log lines. 43 | func (bu *BufLog) Lines() []string { 44 | bu.RLock() 45 | defer bu.RUnlock() 46 | 47 | out := make([]string, len(bu.lines)) 48 | copy(out[:], bu.lines[:]) 49 | return out 50 | } 51 | -------------------------------------------------------------------------------- /docs/docs/content/archives.md: -------------------------------------------------------------------------------- 1 | # Archives 2 | 3 | A global public archive is maintained on the public web interface. It can be 4 | enabled under Settings -> Settings -> General -> Enable public mailing list 5 | archive. 6 | 7 | To make a campaign available in the public archive (provided it has been 8 | enabled in the settings as described above), enable the option 9 | 'Publish to public archive' under Campaigns -> Create new -> Archive. 10 | 11 | When using template variables that depend on subscriber data (such as any 12 | template variable referencing `.Subscriber`), such data must be supplied 13 | as 'Campaign metadata', which is a JSON object that will be used in place 14 | of `.Subscriber` when rendering the archive template and content. 15 | 16 | When individual subscriber tracking is enabled, TrackLink requires that a UUID 17 | of an existing user is provided as part of the campaign metadata. Any clicks on 18 | a TrackLink from the archived campaign will be counted towards that subscriber. 19 | 20 | As an example: 21 | 22 | ```json 23 | { 24 | "UUID": "5a837423-a186-5623-9a87-82691cbe3631", 25 | "email": "example@example.com", 26 | "name": "Reader", 27 | "attribs": {} 28 | } 29 | ``` 30 | 31 | ![Archive campaign](images/archived-campaign-metadata.png) 32 | 33 | -------------------------------------------------------------------------------- /docs/docs/content/upgrade.md: -------------------------------------------------------------------------------- 1 | # Upgrade 2 | 3 | Some versions may require changes to the database. These changes or database "migrations" are applied automatically and safely, but, it is recommended to take a backup of the Postgres database before running the `--upgrade` option, especially if you have made customizations to the database tables. 4 | 5 | ## Binary 6 | - Download the [latest release](https://github.com/knadh/listmonk/releases) and extract the listmonk binary. 7 | - `./listmonk --upgrade` to upgrade an existing DB. Upgrades are idempotent and running them multiple times have no side effects. 8 | - Run `./listmonk` and visit `http://localhost:9000`. 9 | 10 | ## Docker 11 | 12 | - `docker-compose pull` to pull the latest version from DockerHub. 13 | - `docker-compose run --rm app ./listmonk --upgrade` to upgrade an existing DB. 14 | - Run `docker-compose up app db` and visit `http://localhost:9000`. 15 | 16 | ## Railway 17 | - Head to your dashboard, and select your Listmonk project. 18 | - Select the GitHub deployment service. 19 | - In the Deployment tab, head to the latest deployment, click on the three vertical dots to the right, and select "Redeploy". 20 | - ![Railway Redeploy option](https://user-images.githubusercontent.com/55474996/226517149-6dc512d5-f862-46f7-a57d-5e55b781ff53.png) 21 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" # Will trigger only if tag is pushed matching pattern `v*` (Eg: `v0.1.0`) 7 | 8 | jobs: 9 | goreleaser: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v2 14 | with: 15 | fetch-depth: 0 16 | 17 | - name: Set up Go 18 | uses: actions/setup-go@v2 19 | with: 20 | go-version: 1.19 21 | 22 | - name: Login to Docker Registry 23 | uses: docker/login-action@v1 24 | with: 25 | username: ${{ secrets.DOCKERHUB_USERNAME }} 26 | password: ${{ secrets.DOCKERHUB_TOKEN }} 27 | 28 | - name: Login to GitHub Docker Registry 29 | uses: docker/login-action@v2 30 | with: 31 | registry: ghcr.io 32 | username: ${{ github.actor }} 33 | password: ${{ secrets.GITHUB_TOKEN }} 34 | 35 | - name: Prepare Dependencies 36 | run: | 37 | make dist 38 | 39 | - name: Run GoReleaser 40 | uses: goreleaser/goreleaser-action@v2 41 | with: 42 | version: latest 43 | args: --parallelism 1 --rm-dist --skip-validate 44 | env: 45 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 46 | -------------------------------------------------------------------------------- /frontend/src/constants.js: -------------------------------------------------------------------------------- 1 | export const models = Object.freeze({ 2 | serverConfig: 'serverConfig', 3 | lang: 'lang', 4 | dashboard: 'dashboard', 5 | lists: 'lists', 6 | subscribers: 'subscribers', 7 | campaigns: 'campaigns', 8 | templates: 'templates', 9 | media: 'media', 10 | bounces: 'bounces', 11 | settings: 'settings', 12 | logs: 'logs', 13 | maintenance: 'maintenance', 14 | }); 15 | 16 | // Ad-hoc URIs that are used outside of vuex requests. 17 | const rootURL = process.env.VUE_APP_ROOT_URL || '/'; 18 | const baseURL = process.env.BASE_URL.replace(/\/$/, ''); 19 | 20 | export const uris = Object.freeze({ 21 | previewCampaign: '/api/campaigns/:id/preview', 22 | previewTemplate: '/api/templates/:id/preview', 23 | previewRawTemplate: '/api/templates/preview', 24 | exportSubscribers: '/api/subscribers/export', 25 | errorEvents: '/api/events?type=error', 26 | base: `${baseURL}/static`, 27 | root: rootURL, 28 | static: `${baseURL}/static`, 29 | }); 30 | 31 | // Keys used in Vuex store. 32 | export const storeKeys = Object.freeze({ 33 | models: 'models', 34 | isLoading: 'isLoading', 35 | }); 36 | 37 | export const timestamp = 'ddd D MMM YYYY, hh:mm A'; 38 | 39 | export const colors = Object.freeze({ 40 | primary: '#0055d4', 41 | }); 42 | 43 | export const regDuration = '[0-9]+(ms|s|m|h|d)'; 44 | -------------------------------------------------------------------------------- /cmd/notifications.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | 6 | "github.com/knadh/listmonk/models" 7 | ) 8 | 9 | const ( 10 | notifTplImport = "import-status" 11 | notifTplCampaign = "campaign-status" 12 | notifSubscriberOptin = "subscriber-optin" 13 | notifSubscriberData = "subscriber-data" 14 | ) 15 | 16 | // notifData represents params commonly used across different notification 17 | // templates. 18 | type notifData struct { 19 | RootURL string 20 | LogoURL string 21 | } 22 | 23 | // sendNotification sends out an e-mail notification to admins. 24 | func (app *App) sendNotification(toEmails []string, subject, tplName string, data interface{}) error { 25 | if len(toEmails) == 0 { 26 | return nil 27 | } 28 | 29 | var b bytes.Buffer 30 | if err := app.notifTpls.tpls.ExecuteTemplate(&b, tplName, data); err != nil { 31 | app.log.Printf("error compiling notification template '%s': %v", tplName, err) 32 | return err 33 | } 34 | 35 | m := models.Message{} 36 | m.ContentType = app.notifTpls.contentType 37 | m.From = app.constants.FromEmail 38 | m.To = toEmails 39 | m.Subject = subject 40 | m.Body = b.Bytes() 41 | m.Messenger = emailMsgr 42 | if err := app.manager.PushMessage(m); err != nil { 43 | app.log.Printf("error sending admin notification (%s): %v", subject, err) 44 | return err 45 | } 46 | return nil 47 | } 48 | -------------------------------------------------------------------------------- /frontend/vue.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | publicPath: '/admin', 3 | outputDir: 'dist', 4 | 5 | // This is to make all static file requests generated by Vue to go to 6 | // /frontend/*. However, this also ends up creating a `dist/frontend` 7 | // directory and moves all the static files in it. The physical directory 8 | // and the URI for assets are tightly coupled. This is handled in the Go app 9 | // by using stuffbin aliases. 10 | assetsDir: 'static', 11 | 12 | // Move the index.html file from dist/index.html to dist/frontend/index.html 13 | // indexPath: './frontend/index.html', 14 | 15 | productionSourceMap: false, 16 | filenameHashing: true, 17 | 18 | css: { 19 | loaderOptions: { 20 | sass: { 21 | implementation: require('sass'), // This line must in sass option 22 | }, 23 | }, 24 | }, 25 | 26 | devServer: { 27 | port: process.env.LISTMONK_FRONTEND_PORT || 8080, 28 | proxy: { 29 | '^/$': { 30 | target: process.env.LISTMONK_API_URL || 'http://127.0.0.1:9000' 31 | }, 32 | '^/(api|webhooks|subscription|public|health)': { 33 | target: process.env.LISTMONK_API_URL || 'http://127.0.0.1:9000' 34 | }, 35 | '^/(admin\/custom\.(css|js))': { 36 | target: process.env.LISTMONK_API_URL || 'http://127.0.0.1:9000' 37 | } 38 | } 39 | } 40 | }; 41 | -------------------------------------------------------------------------------- /cmd/events.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | "time" 8 | 9 | "github.com/labstack/echo/v4" 10 | ) 11 | 12 | // handleEventStream serves an endpoint that never closes and pushes a 13 | // live event stream (text/event-stream) such as a error messages. 14 | func handleEventStream(c echo.Context) error { 15 | var ( 16 | app = c.Get("app").(*App) 17 | ) 18 | 19 | h := c.Response().Header() 20 | h.Set(echo.HeaderContentType, "text/event-stream") 21 | h.Set(echo.HeaderCacheControl, "no-store") 22 | h.Set(echo.HeaderConnection, "keep-alive") 23 | 24 | // Subscribe to the event stream with a random ID. 25 | id := fmt.Sprintf("api:%v", time.Now().UnixNano()) 26 | sub, err := app.events.Subscribe(id) 27 | if err != nil { 28 | log.Fatalf("error subscribing to events: %v", err) 29 | } 30 | 31 | ctx := c.Request().Context() 32 | for { 33 | select { 34 | case e := <-sub: 35 | b, err := json.Marshal(e) 36 | if err != nil { 37 | app.log.Printf("error marshalling event: %v", err) 38 | continue 39 | } 40 | 41 | fmt.Printf("data: %s\n\n", b) 42 | 43 | c.Response().Write([]byte(fmt.Sprintf("retry: 3000\ndata: %s\n\n", b))) 44 | c.Response().Flush() 45 | 46 | case <-ctx.Done(): 47 | // On HTTP connection close, unsubscribe. 48 | app.events.Unsubscribe(id) 49 | return nil 50 | } 51 | } 52 | 53 | return nil 54 | } 55 | -------------------------------------------------------------------------------- /dev/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | mailhog: 5 | image: mailhog/mailhog:v1.0.1 6 | ports: 7 | - "1025:1025" # SMTP 8 | - "8025:8025" # UI 9 | 10 | db: 11 | image: postgres:13 12 | ports: 13 | - "5432:5432" 14 | networks: 15 | - listmonk-dev 16 | environment: 17 | - POSTGRES_PASSWORD=listmonk-dev 18 | - POSTGRES_USER=listmonk-dev 19 | - POSTGRES_DB=listmonk-dev 20 | restart: unless-stopped 21 | volumes: 22 | - type: volume 23 | source: listmonk-dev-db 24 | target: /var/lib/postgresql/data 25 | 26 | front: 27 | build: 28 | context: ../ 29 | dockerfile: dev/app.Dockerfile 30 | command: ["make", "run-frontend"] 31 | ports: 32 | - "8080:8080" 33 | environment: 34 | - LISTMONK_API_URL=http://backend:9000 35 | depends_on: 36 | - db 37 | volumes: 38 | - ../:/app 39 | networks: 40 | - listmonk-dev 41 | 42 | backend: 43 | build: 44 | context: ../ 45 | dockerfile: dev/app.Dockerfile 46 | command: ["make", "run-backend-docker"] 47 | ports: 48 | - "9000:9000" 49 | depends_on: 50 | - db 51 | volumes: 52 | - ../:/app 53 | - $GOPATH/pkg/mod/cache:/go/pkg/mod/cache 54 | networks: 55 | - listmonk-dev 56 | 57 | volumes: 58 | listmonk-dev-db: 59 | 60 | networks: 61 | listmonk-dev: 62 | -------------------------------------------------------------------------------- /internal/migrations/v0.9.0.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/jmoiron/sqlx" 7 | "github.com/knadh/koanf/v2" 8 | "github.com/knadh/stuffbin" 9 | ) 10 | 11 | // V0_9_0 performs the DB migrations for v.0.9.0. 12 | func V0_9_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error { 13 | if _, err := db.Exec(` 14 | INSERT INTO settings (key, value) VALUES 15 | ('app.lang', '"en"'), 16 | ('app.message_sliding_window', 'false'), 17 | ('app.message_sliding_window_duration', '"1h"'), 18 | ('app.message_sliding_window_rate', '10000'), 19 | ('app.enable_public_subscription_page', 'true') 20 | ON CONFLICT DO NOTHING; 21 | 22 | -- Add alternate (plain text) body field on campaigns. 23 | ALTER TABLE campaigns ADD COLUMN IF NOT EXISTS altbody TEXT NULL DEFAULT NULL; 24 | `); err != nil { 25 | return err 26 | } 27 | 28 | // Until this version, the default template during installation was broken! 29 | // Check if there's a broken default template and if yes, override it with the 30 | // actual one. 31 | tplBody, err := fs.Get("/static/email-templates/default.tpl") 32 | if err != nil { 33 | return fmt.Errorf("error reading default e-mail template: %v", err) 34 | } 35 | 36 | if _, err := db.Exec(`UPDATE templates SET body=$1 WHERE body=$2`, 37 | tplBody.ReadBytes(), `{{ template "content" . }}`); err != nil { 38 | return err 39 | } 40 | return nil 41 | } 42 | -------------------------------------------------------------------------------- /static/public/templates/archive.html: -------------------------------------------------------------------------------- 1 | {{ define "archive" }} 2 | {{ template "header" .}} 3 |
4 |

{{ L.T "public.archiveTitle" }}

5 | 6 | 20 | 21 | {{ if not .Data.Campaigns }} 22 | {{ L.T "public.archiveEmpty" }} 23 | {{ end }} 24 | 25 | {{ if .EnablePublicSubPage }} 26 | 33 | {{ end }} 34 | 35 | {{ if gt .Data.TotalPages 1 }} 36 | 37 | {{ end }} 38 |
39 | 40 | {{ template "footer" .}} 41 | {{ end }} 42 | -------------------------------------------------------------------------------- /internal/migrations/v2.3.0.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "github.com/jmoiron/sqlx" 5 | "github.com/knadh/koanf/v2" 6 | "github.com/knadh/stuffbin" 7 | ) 8 | 9 | // V2_2_0 performs the DB migrations for v.2.2.0. 10 | func V2_3_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error { 11 | if _, err := db.Exec(`ALTER TABLE media ADD COLUMN IF NOT EXISTS "meta" JSONB NOT NULL DEFAULT '{}'`); err != nil { 12 | return err 13 | } 14 | 15 | // Add `description` field to lists. 16 | if _, err := db.Exec(`ALTER TABLE lists ADD COLUMN IF NOT EXISTS "description" TEXT NOT NULL DEFAULT ''`); err != nil { 17 | return err 18 | } 19 | 20 | // Add archive publishing field to campaigns. 21 | if _, err := db.Exec(`ALTER TABLE campaigns 22 | ADD COLUMN IF NOT EXISTS archive BOOLEAN NOT NULL DEFAULT false, 23 | ADD COLUMN IF NOT EXISTS archive_meta JSONB NOT NULL DEFAULT '{}', 24 | ADD COLUMN IF NOT EXISTS archive_template_id INTEGER REFERENCES templates(id) ON DELETE SET DEFAULT DEFAULT 1 25 | `); err != nil { 26 | return err 27 | } 28 | 29 | // Insert new preference settings. 30 | if _, err := db.Exec(` 31 | INSERT INTO settings (key, value) VALUES 32 | ('app.site_name', '"Mailing list"'), 33 | ('app.enable_public_archive', 'true'), 34 | ('privacy.allow_preferences', 'false') 35 | ON CONFLICT DO NOTHING; 36 | `); err != nil { 37 | return err 38 | } 39 | 40 | return nil 41 | } 42 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "listmonk", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "serve": "vue-cli-service serve", 7 | "build": "vue-cli-service build", 8 | "build-report": "vue-cli-service build --report", 9 | "lint": "vue-cli-service lint" 10 | }, 11 | "dependencies": { 12 | "@tinymce/tinymce-vue": "^3", 13 | "axios": "^0.27.2", 14 | "buefy": "^0.9.10", 15 | "c3": "^0.7.20", 16 | "codeflask": "^1.4.1", 17 | "core-js": "^3.12.1", 18 | "dayjs": "^1.10.4", 19 | "indent.js": "^0.3.5", 20 | "qs": "^6.10.1", 21 | "textversionjs": "^1.1.3", 22 | "tinymce": "^5.10.7", 23 | "turndown": "^7.0.0", 24 | "vue": "^2.6.12", 25 | "vue-i18n": "^8.22.2", 26 | "vue-router": "^3.2.0", 27 | "vuex": "^3.6.2" 28 | }, 29 | "devDependencies": { 30 | "@vue/cli-plugin-babel": "~5.0.8", 31 | "@vue/cli-plugin-eslint": "~5.0.8", 32 | "@vue/cli-plugin-router": "~5.0.8", 33 | "@vue/cli-plugin-vuex": "~5.0.8", 34 | "@vue/cli-service": "~5.0.8", 35 | "@vue/eslint-config-airbnb": "^5.3.0", 36 | "babel-eslint": "^10.1.0", 37 | "cypress": "10.10.0", 38 | "cypress-file-upload": "^5.0.2", 39 | "eslint": "^7.27.0", 40 | "eslint-plugin-import": "^2.23.3", 41 | "eslint-plugin-vue": "^7.9.0", 42 | "sass": "^1.34.0", 43 | "sass-loader": "^10.2.0", 44 | "vue-template-compiler": "^2.6.12" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /internal/migrations/v2.2.0.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "github.com/jmoiron/sqlx" 5 | "github.com/knadh/koanf/v2" 6 | "github.com/knadh/stuffbin" 7 | ) 8 | 9 | // V2_2_0 performs the DB migrations for v.2.2.0. 10 | func V2_2_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error { 11 | if _, err := db.Exec(` 12 | DO $$ 13 | BEGIN 14 | IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'template_type') THEN 15 | CREATE TYPE template_type AS ENUM ('campaign', 'tx'); 16 | END IF; 17 | END$$; 18 | `); err != nil { 19 | return err 20 | } 21 | 22 | if _, err := db.Exec(`ALTER TABLE templates ADD COLUMN IF NOT EXISTS "type" template_type NOT NULL DEFAULT 'campaign'`); err != nil { 23 | return err 24 | } 25 | 26 | if _, err := db.Exec(`ALTER TABLE templates ADD COLUMN IF NOT EXISTS subject TEXT NOT NULL DEFAULT ''`); err != nil { 27 | return err 28 | } 29 | if _, err := db.Exec(`ALTER TABLE templates ALTER COLUMN subject DROP DEFAULT`); err != nil { 30 | return err 31 | } 32 | 33 | // Insert transactional template. 34 | txTpl, err := fs.Get("/static/email-templates/sample-tx.tpl") 35 | if err != nil { 36 | return err 37 | } 38 | if _, err := db.Exec(`INSERT INTO templates (name, type, subject, body) VALUES($1, $2, $3, $4)`, 39 | "Sample transactional template", "tx", "Welcome {{ .Subscriber.Name }}", txTpl.ReadBytes()); err != nil { 40 | return err 41 | } 42 | 43 | return nil 44 | } 45 | -------------------------------------------------------------------------------- /frontend/src/views/settings/security.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 43 | -------------------------------------------------------------------------------- /frontend/cypress/e2e/settings.cy.js: -------------------------------------------------------------------------------- 1 | const apiUrl = Cypress.env('apiUrl'); 2 | 3 | describe('Templates', () => { 4 | it('Opens settings page', () => { 5 | cy.resetDB(); 6 | cy.loginAndVisit('/settings'); 7 | }); 8 | 9 | it('Changes some settings', () => { 10 | const rootURL = 'http://127.0.0.1:9000'; 11 | const faveURL = 'http://127.0.0.1:9000/public/static/logo.png'; 12 | 13 | cy.get('input[name="app.root_url"]').clear().type(rootURL); 14 | cy.get('input[name="app.favicon_url"]').type(faveURL); 15 | cy.get('.b-tabs nav a').eq(1).click(); 16 | cy.get('.tab-item:visible').find('.field').first() 17 | .find('button') 18 | .first() 19 | .click(); 20 | 21 | // Enable / disable SMTP and delete one. 22 | cy.get('.b-tabs nav a').eq(5).click(); 23 | cy.get('.tab-item:visible [data-cy=btn-enable-smtp]').eq(1).click(); 24 | cy.get('.tab-item:visible [data-cy=btn-delete-smtp]').first().click(); 25 | cy.get('.modal button.is-primary').click(); 26 | 27 | cy.get('[data-cy=btn-save]').click(); 28 | 29 | cy.wait(1000); 30 | 31 | // Verify the changes. 32 | cy.request(`${apiUrl}/api/settings`).should((response) => { 33 | const { data } = response.body; 34 | expect(data['app.root_url']).to.equal(rootURL); 35 | expect(data['app.favicon_url']).to.equal(faveURL); 36 | expect(data['app.concurrency']).to.equal(9); 37 | 38 | expect(data.smtp.length).to.equal(1); 39 | expect(data.smtp[0].enabled).to.equal(true); 40 | }); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /frontend/src/components/LogView.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 54 | -------------------------------------------------------------------------------- /internal/migrations/v2.1.0.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "github.com/jmoiron/sqlx" 5 | "github.com/knadh/koanf/v2" 6 | "github.com/knadh/stuffbin" 7 | ) 8 | 9 | // V2_1_0 performs the DB migrations for v.2.1.0. 10 | func V2_1_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error { 11 | // Insert appearance related settings. 12 | if _, err := db.Exec(` 13 | INSERT INTO settings (key, value) VALUES 14 | ('appearance.admin.custom_css', '""'), 15 | ('appearance.admin.custom_js', '""'), 16 | ('appearance.public.custom_css', '""'), 17 | ('appearance.public.custom_js', '""'), 18 | ('upload.s3.public_url', '""') 19 | ON CONFLICT DO NOTHING; 20 | `); err != nil { 21 | return err 22 | } 23 | 24 | // Replace all `tls_enabled: true/false` keys in the `smtp` settings JSON array 25 | // with the new field `tls_type: STARTTLS|TLS|none`. 26 | // The `tls_enabled` key is removed. 27 | if _, err := db.Exec(` 28 | UPDATE settings SET value = s.updated 29 | FROM ( 30 | SELECT JSONB_AGG( 31 | JSONB_SET(v - 'tls_enabled', '{tls_type}', (CASE WHEN v->>'tls_enabled' = 'true' THEN '"STARTTLS"' ELSE '"none"' END)::JSONB) 32 | ) AS updated FROM settings, JSONB_ARRAY_ELEMENTS(value) v WHERE key = 'smtp' 33 | ) s WHERE key = 'smtp' AND value::TEXT LIKE '%tls_enabled%'; 34 | `); err != nil { 35 | return err 36 | } 37 | 38 | if _, err := db.Exec(`ALTER TABLE campaigns ADD COLUMN IF NOT EXISTS headers JSONB NOT NULL DEFAULT '[]';`); err != nil { 39 | return err 40 | } 41 | 42 | return nil 43 | } 44 | -------------------------------------------------------------------------------- /docs/docs/content/developer-setup.md: -------------------------------------------------------------------------------- 1 | # Developer setup 2 | The app has two distinct components, the Go backend and the VueJS frontend. In the dev environment, both are run independently. 3 | 4 | 5 | ### Pre-requisites 6 | - `go` 7 | - `nodejs` (if you are working on the frontend) and `yarn` 8 | - Postgres database. If there is no local installation, the demo docker DB can be used for development (`docker-compose up demo-db`) 9 | 10 | 11 | ### First time setup 12 | `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. 13 | 14 | 1. Copy `config.toml.sample` as `config.toml` and add your config. 15 | 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`. 16 | 17 | > [mailhog](https://github.com/mailhog/MailHog) is an excellent standalone mock SMTP server (with a UI) for testing and dev. 18 | 19 | 20 | ### Running the dev environment 21 | 1. Run `make run` to start the listmonk dev server on `:9000`. 22 | 2. 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. 23 | 3. Visit `http://localhost:8080` 24 | 25 | 26 | # Production build 27 | Run `make dist` to build the Go binary, build the Javascript frontend, and embed the static assets producing a single self-contained binary, `listmonk` 28 | -------------------------------------------------------------------------------- /internal/core/settings.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | "github.com/jmoiron/sqlx/types" 8 | "github.com/knadh/listmonk/models" 9 | "github.com/labstack/echo/v4" 10 | ) 11 | 12 | // GetSettings returns settings from the DB. 13 | func (c *Core) GetSettings() (models.Settings, error) { 14 | var ( 15 | b types.JSONText 16 | out models.Settings 17 | ) 18 | 19 | if err := c.q.GetSettings.Get(&b); err != nil { 20 | return out, echo.NewHTTPError(http.StatusInternalServerError, 21 | c.i18n.Ts("globals.messages.errorFetching", 22 | "name", "{globals.terms.settings}", "error", pqErrMsg(err))) 23 | } 24 | 25 | // Unmarshal the settings and filter out sensitive fields. 26 | if err := json.Unmarshal([]byte(b), &out); err != nil { 27 | return out, echo.NewHTTPError(http.StatusInternalServerError, 28 | c.i18n.Ts("settings.errorEncoding", "error", err.Error())) 29 | } 30 | 31 | return out, nil 32 | } 33 | 34 | // UpdateSettings updates settings. 35 | func (c *Core) UpdateSettings(s models.Settings) error { 36 | // Marshal settings. 37 | b, err := json.Marshal(s) 38 | if err != nil { 39 | return echo.NewHTTPError(http.StatusInternalServerError, 40 | c.i18n.Ts("settings.errorEncoding", "error", err.Error())) 41 | } 42 | 43 | // Update the settings in the DB. 44 | if _, err := c.q.UpdateSettings.Exec(b); err != nil { 45 | return echo.NewHTTPError(http.StatusInternalServerError, 46 | c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.settings}", "error", pqErrMsg(err))) 47 | } 48 | 49 | return nil 50 | } 51 | -------------------------------------------------------------------------------- /docs/docs/mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: listmonk / Documentation 2 | theme: 3 | name: material 4 | # custom_dir: "mkdocs-material/material" 5 | logo: "images/favicon.png" 6 | favicon: "images/favicon.png" 7 | language: "en" 8 | font: 9 | text: 'Inter' 10 | weights: 400 11 | direction: 'ltr' 12 | extra: 13 | search: 14 | language: 'en' 15 | feature: 16 | tabs: true 17 | 18 | palette: 19 | primary: "white" 20 | accent: "red" 21 | 22 | site_dir: _out 23 | docs_dir: content 24 | 25 | markdown_extensions: 26 | - admonition 27 | - pymdownx.highlight 28 | - pymdownx.superfences 29 | - toc: 30 | permalink: true 31 | 32 | extra_css: 33 | - "static/style.css" 34 | 35 | copyright: "Copyright © 2019-2023, Kailash Nadh." 36 | 37 | nav: 38 | - "Introduction": index.md 39 | - "Installation": installation.md 40 | - "Upgrade": upgrade.md 41 | - "Configuration": configuration.md 42 | - "Developer setup": developer-setup.md 43 | - "Concepts": concepts.md 44 | - "Querying and segmenting subscribers": querying-and-segmentation.md 45 | - "Templating": templating.md 46 | - "Bounce processing": bounces.md 47 | - "Messengers": "messengers.md" 48 | - "Archives": "archives.md" 49 | - "Internationalization": "i18n.md" 50 | - "Integrating with external systems": external-integration.md 51 | - "API": apis/apis.md 52 | - "API / Subscribers": apis/subscribers.md 53 | - "API / Lists": apis/lists.md 54 | - "API / Import": apis/import.md 55 | - "API / Campaigns": apis/campaigns.md 56 | - "API / Media": apis/media.md 57 | - "API / Templates": apis/templates.md 58 | - "API / Transactional": apis/transactional.md 59 | 60 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # NOTE: This docker-compose.yml is meant to be just an example guideline 2 | # on how you can achieve the same. It is not intented to run out of the box 3 | # and you must edit the below configurations to suit your needs. 4 | 5 | version: "3.7" 6 | 7 | x-app-defaults: &app-defaults 8 | restart: unless-stopped 9 | image: listmonk/listmonk:latest 10 | ports: 11 | - "9000:9000" 12 | networks: 13 | - listmonk 14 | environment: 15 | - TZ=Etc/UTC 16 | 17 | x-db-defaults: &db-defaults 18 | image: postgres:13 19 | ports: 20 | - "9432:5432" 21 | networks: 22 | - listmonk 23 | environment: 24 | - POSTGRES_PASSWORD=listmonk 25 | - POSTGRES_USER=listmonk 26 | - POSTGRES_DB=listmonk 27 | restart: unless-stopped 28 | healthcheck: 29 | test: ["CMD-SHELL", "pg_isready -U listmonk"] 30 | interval: 10s 31 | timeout: 5s 32 | retries: 6 33 | 34 | services: 35 | db: 36 | <<: *db-defaults 37 | container_name: listmonk_db 38 | volumes: 39 | - type: volume 40 | source: listmonk-data 41 | target: /var/lib/postgresql/data 42 | 43 | app: 44 | <<: *app-defaults 45 | container_name: listmonk_app 46 | depends_on: 47 | - db 48 | volumes: 49 | - ./config.toml:/listmonk/config.toml 50 | 51 | demo-db: 52 | container_name: listmonk_demo_db 53 | <<: *db-defaults 54 | 55 | demo-app: 56 | <<: *app-defaults 57 | container_name: listmonk_demo_app 58 | command: [sh, -c, "yes | ./listmonk --install --config config-demo.toml && ./listmonk --config config-demo.toml"] 59 | depends_on: 60 | - demo-db 61 | 62 | networks: 63 | listmonk: 64 | 65 | volumes: 66 | listmonk-data: 67 | -------------------------------------------------------------------------------- /frontend/cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | import 'cypress-file-upload'; 2 | 3 | Cypress.Commands.add('resetDB', () => { 4 | // Although cypress clearly states that a webserver should not be run 5 | // from within it, listmonk is killed, the DB reset, and run again 6 | // in the background. If the DB is reset without restartin listmonk, 7 | // the live Postgres connections in the app throw errors because the 8 | // schema changes midway. 9 | cy.exec(Cypress.env('serverInitCmd')); 10 | }); 11 | 12 | // Takes a th class selector of a Buefy table, clicks it sorting the table, 13 | // then compares the values of [td.data-id] attri of all the rows in the 14 | // table against the given IDs, asserting the expected order of sort. 15 | Cypress.Commands.add('sortTable', (theadSelector, ordIDs) => { 16 | cy.get(theadSelector).click(); 17 | cy.wait(100); 18 | cy.get('tbody td[data-id]').each(($el, index) => { 19 | expect(ordIDs[index]).to.equal(parseInt($el.attr('data-id'))); 20 | }); 21 | }); 22 | 23 | Cypress.Commands.add('loginAndVisit', (url) => { 24 | cy.visit(url, { 25 | auth: { 26 | username: Cypress.env('username'), 27 | password: Cypress.env('password'), 28 | }, 29 | }); 30 | }); 31 | 32 | Cypress.Commands.add('clickMenu', (...selectors) => { 33 | selectors.forEach((s) => { 34 | cy.get(`.menu a[data-cy="${s}"]`).click(); 35 | }); 36 | }); 37 | 38 | // https://www.nicknish.co/blog/cypress-targeting-elements-inside-iframes 39 | Cypress.Commands.add('iframe', { prevSubject: 'element' }, ($iframe, callback = () => { }) => cy 40 | .wrap($iframe) 41 | .should((iframe) => expect(iframe.contents().find('body')).to.exist) 42 | .then((iframe) => cy.wrap(iframe.contents().find('body'))) 43 | .within({}, callback)); 44 | -------------------------------------------------------------------------------- /static/public/templates/index.html: -------------------------------------------------------------------------------- 1 | {{ define "header" }} 2 | 3 | 4 | 5 | 6 | {{ .Data.Title }} - {{ .SiteName }} 7 | 8 | 9 | 10 | {{ if .EnablePublicArchive }} 11 | 13 | {{ end }} 14 | 15 | 16 | 17 | 18 | 19 | {{ if ne .FaviconURL "" }} 20 | 21 | {{ else }} 22 | 23 | {{ end }} 24 | 25 | 26 |
27 |
28 | 37 |
38 | {{ end }} 39 | 40 | {{ define "footer" }} 41 |
42 | 43 | 46 | 47 | 48 | {{ end }} 49 | -------------------------------------------------------------------------------- /internal/captcha/captcha.go: -------------------------------------------------------------------------------- 1 | package captcha 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | "net/http" 8 | "net/url" 9 | "strings" 10 | "time" 11 | ) 12 | 13 | const ( 14 | rootURL = "https://hcaptcha.com/siteverify" 15 | ) 16 | 17 | type captchaResp struct { 18 | Success bool `json:"success"` 19 | ErrorCodes []string `json:"error_codes"` 20 | } 21 | 22 | // Captcha is a simple Captcha client. 23 | // It currently implements hcaptcha.com 24 | type Captcha struct { 25 | o Opt 26 | client *http.Client 27 | } 28 | 29 | type Opt struct { 30 | CaptchaSecret string `json:"captcha_secret"` 31 | } 32 | 33 | // New returns a new instance of the HTTP CAPTCHA client. 34 | func New(o Opt) *Captcha { 35 | timeout := time.Second * 5 36 | 37 | return &Captcha{ 38 | o: o, 39 | client: &http.Client{ 40 | Timeout: timeout, 41 | Transport: &http.Transport{ 42 | MaxIdleConnsPerHost: 10, 43 | MaxConnsPerHost: 100, 44 | ResponseHeaderTimeout: timeout, 45 | IdleConnTimeout: timeout, 46 | }, 47 | }} 48 | } 49 | 50 | // Verify verifies a CAPTCHA request. 51 | func (c *Captcha) Verify(token string) (error, bool) { 52 | resp, err := c.client.PostForm(rootURL, url.Values{ 53 | "secret": {c.o.CaptchaSecret}, 54 | "response": {token}, 55 | }) 56 | if err != nil { 57 | return err, false 58 | } 59 | 60 | defer resp.Body.Close() 61 | body, err := ioutil.ReadAll(resp.Body) 62 | if err != nil { 63 | return err, false 64 | } 65 | 66 | var r captchaResp 67 | if json.Unmarshal(body, &r); err != nil { 68 | return err, true 69 | } 70 | 71 | if r.Success != true { 72 | return fmt.Errorf("captcha failed: %s", strings.Join(r.ErrorCodes, ",")), false 73 | } 74 | 75 | return nil, true 76 | } 77 | -------------------------------------------------------------------------------- /frontend/cypress/e2e/archive.cy.js: -------------------------------------------------------------------------------- 1 | const apiUrl = Cypress.env('apiUrl'); 2 | 3 | describe('Archive', () => { 4 | it('Opens campaigns page', () => { 5 | cy.resetDB(); 6 | cy.loginAndVisit('/campaigns'); 7 | cy.wait(500); 8 | }); 9 | 10 | it('Clones campaign', () => { 11 | cy.loginAndVisit('/campaigns'); 12 | cy.get('[data-cy=btn-clone]').first().click(); 13 | cy.get('.modal input').clear().type('clone').click(); 14 | cy.get('.modal button.is-primary').click(); 15 | cy.wait(250); 16 | cy.clickMenu('all-campaigns'); 17 | }); 18 | 19 | it('Starts un-archived campaign', () => { 20 | cy.get('td[data-label=Status] a').eq(0).click(); 21 | cy.get('[data-cy=btn-start]').click(); 22 | cy.get('.modal button.is-primary').click(); 23 | cy.wait(1000); 24 | }); 25 | 26 | it('Enables archive on one campaign', () => { 27 | cy.loginAndVisit('/campaigns'); 28 | cy.wait(250); 29 | cy.get('td[data-label=Status] a').eq(1).click(); 30 | 31 | // Switch to archive tab and enable archive. 32 | cy.get('.b-tabs nav a').eq(2).click(); 33 | cy.wait(500); 34 | cy.get('[data-cy=btn-archive] .check').click(); 35 | cy.get('[data-cy=archive-meta]').clear() 36 | .type('{"email": "archive@domain.com", "name": "Archive", "attribs": { "city": "Bengaluru"}}', { 'parseSpecialCharSequences': false }); 37 | 38 | // Start the campaign. 39 | cy.get('[data-cy=btn-save]').click(); 40 | cy.wait(500); 41 | cy.get('[data-cy=btn-start]').click(); 42 | cy.get('.modal button.is-primary').click(); 43 | cy.wait(1000); 44 | }); 45 | 46 | it('Opens campaign archive page', () => { 47 | cy.loginAndVisit(`${apiUrl}/archive`); 48 | cy.get('li a').click(); 49 | cy.get('h3').contains('Hi Archive!'); 50 | cy.get('p').eq(0).contains('Bengaluru'); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | env: 2 | - GO111MODULE=on 3 | - CGO_ENABLED=0 4 | 5 | before: 6 | hooks: 7 | - make build-frontend 8 | 9 | builds: 10 | - binary: listmonk 11 | main: ./cmd 12 | goos: 13 | - windows 14 | - darwin 15 | - linux 16 | - freebsd 17 | - openbsd 18 | - netbsd 19 | goarch: 20 | - amd64 21 | - arm64 22 | - arm 23 | goarm: 24 | - 6 25 | - 7 26 | ldflags: 27 | - -s -w -X "main.buildString={{ .Tag }} ({{ .ShortCommit }} {{ .Date }})" -X "main.versionString={{ .Tag }}" 28 | 29 | hooks: 30 | # stuff executables with static assets. 31 | post: make pack-bin BIN={{ .Path }} 32 | 33 | archives: 34 | - format: tar.gz 35 | files: 36 | - README.md 37 | - LICENSE 38 | 39 | dockers: 40 | - goos: linux 41 | goarch: amd64 42 | ids: 43 | - listmonk 44 | image_templates: 45 | - "listmonk/listmonk:latest" 46 | - "listmonk/listmonk:{{ .Tag }}" 47 | - "ghcr.io/knadh/{{ .ProjectName }}:latest" 48 | - "ghcr.io/knadh/{{ .ProjectName }}:{{ .Tag }}" 49 | build_flag_templates: 50 | - --platform=linux/amd64 51 | - --label=org.opencontainers.image.title={{ .ProjectName }} 52 | - --label=org.opencontainers.image.description={{ .ProjectName }} 53 | - --label=org.opencontainers.image.url=https://github.com/knadh/{{ .ProjectName }} 54 | - --label=org.opencontainers.image.source=https://github.com/knadh/{{ .ProjectName }} 55 | - --label=org.opencontainers.image.version={{ .Version }} 56 | - --label=org.opencontainers.image.created={{ time "2006-01-02T15:04:05Z07:00" }} 57 | - --label=org.opencontainers.image.revision={{ .FullCommit }} 58 | - --label=org.opencontainers.image.licenses=AGPL-3.0 59 | dockerfile: Dockerfile 60 | extra_files: 61 | - config.toml.sample 62 | - config-demo.toml 63 | -------------------------------------------------------------------------------- /frontend/src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Buefy from 'buefy'; 3 | import VueI18n from 'vue-i18n'; 4 | 5 | import App from './App.vue'; 6 | import router from './router'; 7 | import store from './store'; 8 | import * as api from './api'; 9 | import Utils from './utils'; 10 | 11 | // Internationalisation. 12 | Vue.use(VueI18n); 13 | const i18n = new VueI18n(); 14 | 15 | Vue.use(Buefy, {}); 16 | Vue.config.productionTip = false; 17 | 18 | // Setup the router. 19 | router.beforeEach((to, from, next) => { 20 | if (to.matched.length === 0) { 21 | next('/404'); 22 | } else { 23 | next(); 24 | } 25 | }); 26 | 27 | router.afterEach((to) => { 28 | Vue.nextTick(() => { 29 | const t = to.meta.title && i18n.te(to.meta.title) ? `${i18n.tc(to.meta.title, 0)} /` : ''; 30 | document.title = `${t} listmonk`; 31 | }); 32 | }); 33 | 34 | function initConfig(app) { 35 | // Load server side config and language before mounting the app. 36 | api.getServerConfig().then((data) => { 37 | api.getLang(data.lang).then((lang) => { 38 | i18n.locale = data.lang; 39 | i18n.setLocaleMessage(i18n.locale, lang); 40 | 41 | Vue.prototype.$utils = new Utils(i18n); 42 | Vue.prototype.$api = api; 43 | 44 | // Set the page title after i18n has loaded. 45 | const to = router.history.current; 46 | const t = to.meta.title ? `${i18n.tc(to.meta.title, 0)} /` : ''; 47 | document.title = `${t} listmonk`; 48 | 49 | if (app) { 50 | app.$mount('#app'); 51 | } 52 | }); 53 | }); 54 | 55 | api.getSettings(); 56 | } 57 | 58 | const v = new Vue({ 59 | router, 60 | store, 61 | i18n, 62 | render: (h) => h(App), 63 | 64 | data: { 65 | isLoaded: false, 66 | }, 67 | 68 | methods: { 69 | loadConfig() { 70 | initConfig(); 71 | }, 72 | }, 73 | 74 | mounted() { 75 | v.isLoaded = true; 76 | }, 77 | }); 78 | 79 | initConfig(v); 80 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/knadh/listmonk 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/Masterminds/sprig/v3 v3.2.3 7 | github.com/disintegration/imaging v1.6.2 8 | github.com/emersion/go-message v0.16.0 9 | github.com/gofrs/uuid v4.0.0+incompatible 10 | github.com/google/uuid v1.3.0 // indirect 11 | github.com/gorilla/feeds v1.1.1 12 | github.com/huandu/xstrings v1.4.0 // indirect 13 | github.com/imdario/mergo v0.3.14 // indirect 14 | github.com/jmoiron/sqlx v1.3.5 15 | github.com/knadh/go-pop3 v0.3.0 16 | github.com/knadh/goyesql/v2 v2.2.0 17 | github.com/knadh/koanf/maps v0.1.1 18 | github.com/knadh/koanf/parsers/json v0.1.0 19 | github.com/knadh/koanf/parsers/toml v0.1.0 20 | github.com/knadh/koanf/providers/confmap v0.1.0 21 | github.com/knadh/koanf/providers/env v0.1.0 22 | github.com/knadh/koanf/providers/file v0.1.0 23 | github.com/knadh/koanf/providers/posflag v0.1.0 24 | github.com/knadh/koanf/providers/rawbytes v0.1.0 25 | github.com/knadh/koanf/v2 v2.0.1 26 | github.com/knadh/paginator v1.0.1 27 | github.com/knadh/smtppool v1.0.2 28 | github.com/knadh/stuffbin v1.1.0 29 | github.com/kr/pretty v0.3.1 // indirect 30 | github.com/labstack/echo/v4 v4.10.2 31 | github.com/lib/pq v1.10.7 32 | github.com/mailru/easyjson v0.7.7 33 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect 34 | github.com/paulbellamy/ratecounter v0.2.0 35 | github.com/rhnvrm/simples3 v0.8.3 36 | github.com/shopspring/decimal v1.3.1 // indirect 37 | github.com/spf13/cast v1.5.0 // indirect 38 | github.com/spf13/pflag v1.0.5 39 | github.com/yuin/goldmark v1.5.4 40 | golang.org/x/crypto v0.7.0 // indirect 41 | golang.org/x/image v0.6.0 // indirect 42 | golang.org/x/mod v0.9.0 43 | golang.org/x/sys v0.7.0 // indirect 44 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect 45 | gopkg.in/volatiletech/null.v6 v6.0.0-20170828023728-0bef4e07ae1b 46 | ) 47 | 48 | replace github.com/imdario/mergo => github.com/imdario/mergo v0.3.8 49 | -------------------------------------------------------------------------------- /dev/README.md: -------------------------------------------------------------------------------- 1 | # Docker suite for development 2 | 3 | **NOTE**: This exists only for local development. If you're interested in using 4 | Docker for a production setup, visit the 5 | [docs](https://listmonk.app/docs/installation/#docker) instead. 6 | 7 | ### Objective 8 | 9 | The purpose of this Docker suite for local development is to isolate all the dev 10 | dependencies in a Docker environment. The containers have a host volume mounted 11 | inside for the entire app directory. This helps us to not do a full 12 | `docker build` for every single local change, only restarting the Docker 13 | environment is enough. 14 | 15 | ## Setting up a dev suite 16 | 17 | To spin up a local suite of: 18 | 19 | - PostgreSQL 20 | - Mailhog 21 | - Node.js frontend app 22 | - Golang backend app 23 | 24 | ### Verify your config file 25 | 26 | The config file provided at `dev/config.toml` will be used when running the 27 | containerized development stack. Make sure the values set within are suitable 28 | for the feature you're trying to develop. 29 | 30 | ### Setup DB 31 | 32 | Running this will build the appropriate images and initialize the database. 33 | 34 | ```bash 35 | make init-dev-docker 36 | ``` 37 | 38 | ### Start frontend and backend apps 39 | 40 | Running this start your local development stack. 41 | 42 | ```bash 43 | make dev-docker 44 | ``` 45 | 46 | Visit `http://localhost:8080` on your browser. 47 | 48 | ### Tear down 49 | 50 | This will tear down all the data, including DB. 51 | 52 | ```bash 53 | make rm-dev-docker 54 | ``` 55 | 56 | ### See local changes in action 57 | 58 | - Backend: Anytime you do a change to the Go app, it needs to be compiled. Just 59 | run `make dev-docker` again and that should automatically handle it for you. 60 | - Frontend: Anytime you change the frontend code, you don't need to do anything. 61 | Since `yarn` is watching for all the changes and we have mounted the code 62 | inside the docker container, `yarn` server automatically restarts. 63 | -------------------------------------------------------------------------------- /docs/docs/content/messengers.md: -------------------------------------------------------------------------------- 1 | # Messengers 2 | 3 | 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. 4 | 5 | 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. 6 | 7 | Messengers support optional BasicAuth authentication. `Plain text` format for campaign content is ideal for messengers such as SMS and FCM. 8 | 9 | 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. 10 | 11 | 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/#attributes). 12 | 13 | ```json 14 | { 15 | "subject": "Welcome to listmonk", 16 | "body": "The message body", 17 | "content_type": "plain", 18 | "recipients": [{ 19 | "uuid": "e44b4135-1e1d-40c5-8a30-0f9a886c2884", 20 | "email": "anon@example.com", 21 | "name": "Anon Doe", 22 | "attribs": { 23 | "phone": "123123123", 24 | "fcm_id": "2e7e4b512e7e4b512e7e4b51", 25 | "city": "Bengaluru" 26 | }, 27 | "status": "enabled" 28 | }], 29 | "campaign": { 30 | "uuid": "2e7e4b51-f31b-418a-a120-e41800cb689f", 31 | "name": "Test campaign", 32 | "tags": ["test-campaign"] 33 | } 34 | } 35 | ``` 36 | 37 | ## Messenger implementations 38 | 39 | Following is a list of HTTP messenger servers that connect to various backends. 40 | 41 | | Name | Backend | 42 | |------------------------------------------------------------------------|------------------| 43 | | [listmonk-messenger](https://github.com/joeirimpan/listmonk-messenger) | AWS Pinpoint SMS | 44 | -------------------------------------------------------------------------------- /.github/workflows/github-pages.yml: -------------------------------------------------------------------------------- 1 | name: publish-github-pages 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths: 8 | - 'docs/**' 9 | workflow_dispatch: 10 | 11 | permissions: 12 | contents: write 13 | 14 | jobs: 15 | deploy: 16 | runs-on: ubuntu-22.04 17 | steps: 18 | - uses: actions/checkout@v2 19 | with: 20 | submodules: true # Fetch Hugo themes 21 | fetch-depth: 0 # Fetch all history for .GitInfo and .Lastmod 22 | 23 | - uses: actions/setup-python@v2 24 | with: 25 | python-version: 3.x 26 | - run: pip install mkdocs-material 27 | 28 | - name: Setup Hugo 29 | uses: peaceiris/actions-hugo@v2 30 | with: 31 | hugo-version: '0.68.3' 32 | 33 | # Build the main site to the docs/publish directory. This will be the root (/) in gh-pages. 34 | # The -d (output) path is relative to the -s (source) path 35 | - name: Build main site 36 | run: hugo -s docs/site -d ../publish --gc --minify 37 | 38 | # Build the mkdocs documentation in the docs/publish/docs dir. This will be at (/docs) 39 | # The -d (output) path is relative to the -f (source) path 40 | - name: Build docs site 41 | run: mkdocs build -f docs/docs/mkdocs.yml -d ../publish/docs 42 | 43 | # Copy the static i18n app to the publish directory. This will be at (/i18n) 44 | - name: Copy i18n site 45 | run: cp -R docs/i18n docs/publish 46 | 47 | - name: Generate Swagger UI 48 | uses: Legion2/swagger-ui-action@v1 49 | with: 50 | spec-file: ./docs/swagger/collections.yaml 51 | output: ./docs/publish/docs/swagger 52 | 53 | - name: Deploy 54 | uses: peaceiris/actions-gh-pages@v3 55 | with: 56 | github_token: ${{ secrets.GITHUB_TOKEN }} 57 | publish_branch: gh-pages 58 | publish_dir: ./docs/publish 59 | cname: listmonk.app 60 | user_name: 'github-actions[bot]' 61 | user_email: 'github-actions[bot]@users.noreply.github.com' 62 | -------------------------------------------------------------------------------- /frontend/src/assets/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /internal/migrations/v2.5.0.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "github.com/jmoiron/sqlx" 5 | "github.com/knadh/koanf/v2" 6 | "github.com/knadh/stuffbin" 7 | ) 8 | 9 | // V2_5_0 performs the DB migrations. 10 | func V2_5_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error { 11 | // Insert new preference settings. 12 | if _, err := db.Exec(` 13 | INSERT INTO settings (key, value) VALUES 14 | ('upload.extensions', '["jpg","jpeg","png","gif","svg","*"]'), 15 | ('app.enable_public_archive_rss_content', 'false'), 16 | ('bounce.actions', '{"soft": {"count": 2, "action": "none"}, "hard": {"count": 2, "action": "blocklist"}, "complaint" : {"count": 2, "action": "blocklist"}}') 17 | ON CONFLICT DO NOTHING; 18 | `); err != nil { 19 | return err 20 | } 21 | 22 | if _, err := db.Exec(` 23 | DELETE FROM settings WHERE key IN ('bounce.count', 'bounce.action'); 24 | 25 | -- Add the content_type column. 26 | ALTER TABLE media ADD COLUMN IF NOT EXISTS content_type TEXT NOT NULL DEFAULT 'application/octet-stream'; 27 | 28 | -- Fill the content type column for existing files (which would only be images at this point). 29 | UPDATE media SET content_type = CASE 30 | WHEN LOWER(SUBSTRING(filename FROM '.([^.]+)$')) = 'svg' THEN 'image/svg+xml' 31 | ELSE 'image/' || LOWER(SUBSTRING(filename FROM '.([^.]+)$')) 32 | END; 33 | 34 | `); err != nil { 35 | return err 36 | } 37 | 38 | if _, err := db.Exec(` 39 | CREATE TABLE IF NOT EXISTS campaign_media ( 40 | campaign_id INTEGER REFERENCES campaigns(id) ON DELETE CASCADE ON UPDATE CASCADE, 41 | 42 | -- Media items may be deleted, so media_id is nullable 43 | -- and a copy of the original name is maintained here. 44 | media_id INTEGER NULL REFERENCES media(id) ON DELETE SET NULL ON UPDATE CASCADE, 45 | 46 | filename TEXT NOT NULL DEFAULT '' 47 | ); 48 | CREATE UNIQUE INDEX IF NOT EXISTS idx_camp_media_id ON campaign_media (campaign_id, media_id); 49 | CREATE INDEX IF NOT EXISTS idx_camp_media_camp_id ON campaign_media(campaign_id); 50 | `); err != nil { 51 | return err 52 | } 53 | 54 | return nil 55 | } 56 | -------------------------------------------------------------------------------- /static/public/static/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/docs/content/images/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/site/static/static/images/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/i18n/style.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | body { 6 | font-family: Inter, "Helvetica Neue", "Segoe UI", sans-serif; 7 | font-size: 16px; 8 | line-height: 24px; 9 | } 10 | 11 | h1, h2, h3, h4, h5 { 12 | margin: 0 0 15px 0; 13 | } 14 | 15 | a { 16 | color: #0055d4; 17 | } 18 | 19 | .container { 20 | padding: 30px; 21 | } 22 | 23 | .header { 24 | align-items: center; 25 | margin-bottom: 30px; 26 | } 27 | .header a { 28 | display: inline-block; 29 | margin-right: 15px; 30 | } 31 | .header .controls { 32 | display: flex; 33 | } 34 | .header .controls .pending { 35 | color: #ff3300; 36 | } 37 | .header .controls .complete { 38 | color: #05a200; 39 | } 40 | .header .title { 41 | margin: 0 0 15px 0; 42 | } 43 | .header .block { 44 | margin: 0 45px 0 0; 45 | } 46 | .header .view label { 47 | cursor: pointer; 48 | margin-right: 10px; 49 | display: inline-block; 50 | } 51 | 52 | #app { 53 | display: none; 54 | } 55 | 56 | .data .key, 57 | .data .base { 58 | display: block; 59 | color: #777; 60 | display: block; 61 | } 62 | .data .item { 63 | padding: 15px; 64 | clear: both; 65 | } 66 | .data .item:hover { 67 | background: #eee; 68 | } 69 | .data .item.done .num { 70 | color: #05a200; 71 | } 72 | .data .item.done .num::after { 73 | content: '✓'; 74 | font-weight: bold; 75 | } 76 | 77 | .data .controls { 78 | display: flex; 79 | } 80 | .data .fields { 81 | flex-grow: 1; 82 | } 83 | .data .num { 84 | margin-right: 15px; 85 | min-width: 50px; 86 | } 87 | .data .key { 88 | color: #aaa; 89 | font-size: 0.875em; 90 | } 91 | .data input { 92 | width: 100%; 93 | border: 1px solid #ddd; 94 | padding: 5px; 95 | display: block; 96 | margin: 3px 0; 97 | 98 | } 99 | .data input:focus { 100 | border-color: #666; 101 | } 102 | .data p { 103 | margin: 0 0 3px 0; 104 | } 105 | .data .head { 106 | margin: 0 0 15px 0; 107 | } 108 | 109 | .raw textarea { 110 | border: 1px solid #ddd; 111 | padding: 5px; 112 | width: 100%; 113 | height: 90vh; 114 | } -------------------------------------------------------------------------------- /docs/docs/content/apis/transactional.md: -------------------------------------------------------------------------------- 1 | # API / Transactional 2 | 3 | | Method | Endpoint | Description | 4 | |:-------|:---------|:------------| 5 | | `POST` | /api/tx | | 6 | 7 | 8 | ## POST /api/tx 9 | Send a transactional message to a subscriber using a predefined transactional template. 10 | 11 | 12 | ##### Parameters 13 | | Name | Data Type | Optional | Description | 14 | |:-------------------|:----------|:---------|:----------------------------------------------------------------------------------| 15 | | `subscriber_email` | String | Optional | E-mail of the subscriber. Either this or `subscriber_id` should be passed. | 16 | | `subscriber_id` | Number | Optional | ID of the subscriber. Either this or `subscriber_email` should be passed. | 17 | | `template_id` | Number | Required | ID of the transactional template to use in the message. | 18 | | `from_email` | String | Optional | Optional `from` email. eg: `Company ` | 19 | | `data` | Map | Optional | Optional data in `{}` nested map. Available in the template as `{{ .Tx.Data.* }}` | 20 | | `headers` | []Map | Optional | Optional array of mail headers. `[{"key": "value"}, {"key": "value"}]` | 21 | | `messenger` | String | Optional | Messenger to use to send the message. Default value is `email`. | 22 | | `content_type` | String | Optional | `html`, `markdown`, `plain` | 23 | 24 | 25 | ##### Request 26 | ```shell 27 | curl -u "username:password" "http://localhost:9000/api/tx" -X POST \ 28 | -H 'Content-Type: application/json; charset=utf-8' \ 29 | --data-binary @- << EOF 30 | { 31 | "subscriber_email": "user@test.com", 32 | "template_id": 2, 33 | "data": {"order_id": "1234", "date": "2022-07-30", "items": [1, 2, 3]}, 34 | "content_type": "html" 35 | } 36 | EOF 37 | ``` 38 | 39 | ##### Response 40 | ``` json 41 | { 42 | "data": true 43 | } 44 | ``` 45 | 46 | 47 | -------------------------------------------------------------------------------- /frontend/src/store/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Vuex from 'vuex'; 3 | import { models } from '../constants'; 4 | 5 | Vue.use(Vuex); 6 | 7 | export default new Vuex.Store({ 8 | state: { 9 | // Data from API responses for different models, eg: lists, campaigns. 10 | // The API responses are stored in this map as-is. This is invoked by 11 | // API requests in `http`. This initialises lists: {}, campaigns: {} 12 | // etc. on state. 13 | ...Object.keys(models).reduce((obj, cur) => ({ ...obj, [cur]: [] }), {}), 14 | 15 | // Map of loading status (true, false) indicators for different model keys 16 | // like lists, campaigns etc. loading: {lists: true, campaigns: true ...}. 17 | // The Axios API global request interceptor marks a model as loading=true 18 | // and the response interceptor marks it as false. The model keys are being 19 | // pre-initialised here to fix "reactivity" issues on first loads. 20 | loading: Object.keys(models).reduce((obj, cur) => ({ ...obj, [cur]: false }), {}), 21 | }, 22 | 23 | mutations: { 24 | // Set data from API responses. `model` is 'lists', 'campaigns' etc. 25 | setModelResponse(state, { model, data }) { 26 | state[model] = data; 27 | }, 28 | 29 | // Set the loading status for a model globally. When a request starts, 30 | // status is set to true which is used by the UI to show loaders and block 31 | // forms. When a response is received, the status is set to false. This is 32 | // invoked by API requests in `http`. 33 | setLoading(state, { model, status }) { 34 | state.loading[model] = status; 35 | }, 36 | }, 37 | 38 | getters: { 39 | [models.lists]: (state) => state[models.lists], 40 | [models.subscribers]: (state) => state[models.subscribers], 41 | [models.campaigns]: (state) => state[models.campaigns], 42 | [models.media]: (state) => state[models.media], 43 | [models.templates]: (state) => state[models.templates], 44 | [models.settings]: (state) => state[models.settings], 45 | [models.serverConfig]: (state) => state[models.serverConfig], 46 | [models.logs]: (state) => state[models.logs], 47 | }, 48 | 49 | modules: { 50 | }, 51 | }); 52 | -------------------------------------------------------------------------------- /frontend/src/views/settings/privacy.vue: -------------------------------------------------------------------------------- 1 | 47 | 48 | 65 | -------------------------------------------------------------------------------- /frontend/src/components/HTMLEditor.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 78 | -------------------------------------------------------------------------------- /static/public/templates/subscription-form.html: -------------------------------------------------------------------------------- 1 | {{ define "subscription-form" }} 2 | {{ template "header" . }} 3 |
4 |

{{ L.T "public.subTitle" }}

5 | 6 |
7 |
8 |

9 | 10 | 11 | 12 | 13 |

14 |

15 | 16 | 17 |

18 | 19 |
    20 |

    {{ L.T "globals.terms.lists" }}

    21 | {{ range $i, $l := .Data.Lists }} 22 |
  • 23 | 24 | 25 | {{ if ne $l.Description "" }} 26 |

    {{ $l.Description }}

    27 | {{ end }} 28 |
  • 29 | {{ end }} 30 |
31 | 32 | {{ if .Data.CaptchaKey }} 33 |
34 |
35 | 36 |
37 | {{ end }} 38 |

39 | 40 | 41 | {{ if .EnablePublicArchive }} 42 |

43 | {{ L.T "public.archiveTitle" }} 44 |

45 | {{ end }} 46 |

47 |
48 |
49 |
50 | 51 | {{ template "footer" .}} 52 | {{ end }} 53 | -------------------------------------------------------------------------------- /docs/docs/content/i18n.md: -------------------------------------------------------------------------------- 1 | # Internationalization (i18n) 2 | 3 | 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). 4 | 5 | ## Additional language packs 6 | These additional language packs can be downloaded and passed to listmonk with the `--i18n-dir` flag as described in the next section. 7 | 8 | | Language | Description | 9 | |------------------|--------------------------------------| 10 | | [Deutsch (formal)](https://raw.githubusercontent.com/SvenPe/listmonk/4bbb2e5ebb2314b754cb2318f4f6683a0f854d43/i18n/de.json) | German language with formal pronouns | 11 | 12 | 13 | ## Customizing languages 14 | 15 | 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. 16 | 17 | 18 | ## Contributing a new language 19 | 20 | ### Using the basic editor 21 | 22 | - Visit [https://listmonk.app/i18n](https://listmonk.app/i18n) 23 | - Click on `Createa new language`, or to make changes to an existing language, use `Load language`. 24 | - Translate the text in the text fields on the UI. 25 | - Once done, use the `Download raw JSON` to download the language file. 26 | - Send a pull request to add the file to the [i18n directory on the GitHub repo](https://github.com/knadh/listmonk/tree/master/i18n). 27 | 28 | ### Using InLang (external service) 29 | 30 | [![translation badge](https://inlang.com/badge?url=github.com/knadh/listmonk)](https://inlang.com/editor/github.com/knadh/listmonk?ref=badge) 31 | 32 | - Visit [https://inlang.com/editor/github.com/knadh/listmonk](https://inlang.com/editor/github.com/knadh/listmonk) 33 | - To make changes and push them, you need to log in to GitHub using OAuth and fork the project from the UI. 34 | - Translate the text in the input fields on the UI. You can use the filters to see only the necessary translations. 35 | - 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. 36 | -------------------------------------------------------------------------------- /docs/site/layouts/partials/header.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{ .Title }} 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | {{ if .Params.thumbnail }} 18 | 19 | 20 | {{ else }} 21 | 22 | 23 | {{ end }} 24 | 25 | 26 | 27 |
28 |
29 |
30 | 33 | 40 |
41 |
42 |
-------------------------------------------------------------------------------- /cmd/updates.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "io/ioutil" 6 | "net/http" 7 | "regexp" 8 | "time" 9 | 10 | "golang.org/x/mod/semver" 11 | ) 12 | 13 | const updateCheckURL = "https://api.github.com/repos/knadh/listmonk/releases/latest" 14 | 15 | type remoteUpdateResp struct { 16 | Version string `json:"tag_name"` 17 | URL string `json:"html_url"` 18 | } 19 | 20 | // AppUpdate contains information of a new update available to the app that 21 | // is sent to the frontend. 22 | type AppUpdate struct { 23 | Version string `json:"version"` 24 | URL string `json:"url"` 25 | } 26 | 27 | var reSemver = regexp.MustCompile(`-(.*)`) 28 | 29 | // checkUpdates is a blocking function that checks for updates to the app 30 | // at the given intervals. On detecting a new update (new semver), it 31 | // sets the global update status that renders a prompt on the UI. 32 | func checkUpdates(curVersion string, interval time.Duration, app *App) { 33 | // Strip -* suffix. 34 | curVersion = reSemver.ReplaceAllString(curVersion, "") 35 | time.Sleep(time.Second * 1) 36 | ticker := time.NewTicker(interval) 37 | defer ticker.Stop() 38 | 39 | for range ticker.C { 40 | resp, err := http.Get(updateCheckURL) 41 | if err != nil { 42 | app.log.Printf("error checking for remote update: %v", err) 43 | continue 44 | } 45 | 46 | if resp.StatusCode != 200 { 47 | app.log.Printf("non 200 response on remote update check: %d", resp.StatusCode) 48 | continue 49 | } 50 | 51 | b, err := ioutil.ReadAll(resp.Body) 52 | if err != nil { 53 | app.log.Printf("error reading remote update payload: %v", err) 54 | continue 55 | } 56 | resp.Body.Close() 57 | 58 | var up remoteUpdateResp 59 | if err := json.Unmarshal(b, &up); err != nil { 60 | app.log.Printf("error unmarshalling remote update payload: %v", err) 61 | continue 62 | } 63 | 64 | // There is an update. Set it on the global app state. 65 | if semver.IsValid(up.Version) { 66 | v := reSemver.ReplaceAllString(up.Version, "") 67 | if semver.Compare(v, curVersion) > 0 { 68 | app.Lock() 69 | app.update = &AppUpdate{ 70 | Version: up.Version, 71 | URL: up.URL, 72 | } 73 | app.Unlock() 74 | 75 | app.log.Printf("new update %s found", up.Version) 76 | } 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /frontend/src/views/settings/appearance.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | 78 | -------------------------------------------------------------------------------- /cmd/maintenance.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | 7 | "github.com/labstack/echo/v4" 8 | ) 9 | 10 | // handleGCSubscribers garbage collects (deletes) orphaned or blocklisted subscribers. 11 | func handleGCSubscribers(c echo.Context) error { 12 | var ( 13 | app = c.Get("app").(*App) 14 | typ = c.Param("type") 15 | ) 16 | 17 | var ( 18 | n int 19 | err error 20 | ) 21 | 22 | switch typ { 23 | case "blocklisted": 24 | n, err = app.core.DeleteBlocklistedSubscribers() 25 | case "orphan": 26 | n, err = app.core.DeleteOrphanSubscribers() 27 | default: 28 | err = echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidData")) 29 | } 30 | 31 | if err != nil { 32 | return err 33 | } 34 | 35 | return c.JSON(http.StatusOK, okResp{struct { 36 | Count int `json:"count"` 37 | }{n}}) 38 | } 39 | 40 | // handleGCSubscriptions garbage collects (deletes) orphaned or blocklisted subscribers. 41 | func handleGCSubscriptions(c echo.Context) error { 42 | var ( 43 | app = c.Get("app").(*App) 44 | ) 45 | 46 | t, err := time.Parse(time.RFC3339, c.FormValue("before_date")) 47 | if err != nil { 48 | return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidData")) 49 | } 50 | 51 | n, err := app.core.DeleteUnconfirmedSubscriptions(t) 52 | if err != nil { 53 | return err 54 | } 55 | 56 | return c.JSON(http.StatusOK, okResp{struct { 57 | Count int `json:"count"` 58 | }{n}}) 59 | } 60 | 61 | // handleGCCampaignAnalytics garbage collects (deletes) campaign analytics. 62 | func handleGCCampaignAnalytics(c echo.Context) error { 63 | var ( 64 | app = c.Get("app").(*App) 65 | typ = c.Param("type") 66 | ) 67 | 68 | t, err := time.Parse(time.RFC3339, c.FormValue("before_date")) 69 | if err != nil { 70 | return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidData")) 71 | } 72 | 73 | switch typ { 74 | case "all": 75 | if err := app.core.DeleteCampaignViews(t); err != nil { 76 | return err 77 | } 78 | err = app.core.DeleteCampaignLinkClicks(t) 79 | case "views": 80 | err = app.core.DeleteCampaignViews(t) 81 | case "clicks": 82 | err = app.core.DeleteCampaignLinkClicks(t) 83 | default: 84 | err = echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidData")) 85 | } 86 | 87 | if err != nil { 88 | return err 89 | } 90 | 91 | return c.JSON(http.StatusOK, okResp{true}) 92 | } 93 | -------------------------------------------------------------------------------- /frontend/cypress/e2e/bounces.cy.js: -------------------------------------------------------------------------------- 1 | const apiUrl = Cypress.env('apiUrl'); 2 | 3 | describe('Bounces', () => { 4 | let subs = []; 5 | 6 | it('Enable bounces', () => { 7 | cy.resetDB(); 8 | 9 | cy.loginAndVisit('/settings'); 10 | cy.get('.b-tabs nav a').eq(6).click(); 11 | cy.get('[data-cy=btn-enable-bounce] .switch').click(); 12 | cy.get('[data-cy=btn-enable-bounce-webhook] .switch').click(); 13 | 14 | cy.get('[data-cy=btn-save]').click(); 15 | cy.wait(2000); 16 | }); 17 | 18 | it('Post bounces', () => { 19 | // Get campaign. 20 | let camp = {}; 21 | cy.request(`${apiUrl}/api/campaigns`).then((resp) => { 22 | camp = resp.body.data.results[0]; 23 | }).then(() => { 24 | console.log('campaign is ', camp.uuid); 25 | }); 26 | 27 | // Get subscribers. 28 | let subs = []; 29 | cy.request(`${apiUrl}/api/subscribers`).then((resp) => { 30 | subs = resp.body.data.results; 31 | }).then(() => { 32 | // Register soft bounces do nothing. 33 | let sub = {}; 34 | cy.request('POST', `${apiUrl}/webhooks/bounce`, { source: 'api', type: 'soft', email: subs[0].email }); 35 | cy.request('POST', `${apiUrl}/webhooks/bounce`, { source: 'api', type: 'soft', email: subs[0].email }); 36 | cy.request(`${apiUrl}/api/subscribers/${subs[0].id}`).then((resp) => { 37 | sub = resp.body.data; 38 | }).then(() => { 39 | cy.expect(sub.status).to.equal('enabled'); 40 | }); 41 | 42 | // Hard bounces blocklist. 43 | cy.request('POST', `${apiUrl}/webhooks/bounce`, { source: 'api', type: 'hard', email: subs[0].email }); 44 | cy.request('POST', `${apiUrl}/webhooks/bounce`, { source: 'api', type: 'hard', email: subs[0].email }); 45 | cy.request(`${apiUrl}/api/subscribers/${subs[0].id}`).then((resp) => { 46 | sub = resp.body.data; 47 | }).then(() => { 48 | cy.expect(sub.status).to.equal('blocklisted'); 49 | }); 50 | 51 | // Complaint bounces delete. 52 | cy.request('POST', `${apiUrl}/webhooks/bounce`, { source: 'api', type: 'complaint', email: subs[1].email }); 53 | cy.request('POST', `${apiUrl}/webhooks/bounce`, { source: 'api', type: 'complaint', email: subs[1].email }); 54 | cy.request({ url: `${apiUrl}/api/subscribers/${subs[1].id}`, failOnStatusCode: false }).then((resp) => { 55 | expect(resp.status).to.eq(400); 56 | }); 57 | 58 | cy.loginAndVisit('/subscribers/bounces'); 59 | }); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /internal/events/events.go: -------------------------------------------------------------------------------- 1 | // Package events implements a simple event broadcasting mechanism 2 | // for usage in broadcasting error messages, postbacks etc. various 3 | // channels. 4 | package events 5 | 6 | import ( 7 | "bytes" 8 | "fmt" 9 | "io" 10 | "sync" 11 | ) 12 | 13 | const ( 14 | TypeError = "error" 15 | ) 16 | 17 | // Event represents a single event in the system. 18 | type Event struct { 19 | ID string `json:"id"` 20 | Type string `json:"type"` 21 | Message string `json:"message"` 22 | Data interface{} `json:"data"` 23 | Channels []string `json:"-"` 24 | } 25 | 26 | type Events struct { 27 | subs map[string]chan Event 28 | sync.RWMutex 29 | } 30 | 31 | // New returns a new instance of Events. 32 | func New() *Events { 33 | return &Events{ 34 | subs: make(map[string]chan Event), 35 | } 36 | } 37 | 38 | // Subscribe returns a channel to which the given event `types` are streamed. 39 | // id is the unique identifier for the caller. A caller can only register 40 | // for subscription once. 41 | func (ev *Events) Subscribe(id string) (chan Event, error) { 42 | ev.Lock() 43 | defer ev.Unlock() 44 | 45 | if ch, ok := ev.subs[id]; ok { 46 | return ch, nil 47 | } 48 | 49 | ch := make(chan Event, 100) 50 | ev.subs[id] = ch 51 | 52 | return ch, nil 53 | } 54 | 55 | // Unsubscribe unsubscribes a subscriber (obviously). 56 | func (ev *Events) Unsubscribe(id string) { 57 | ev.Lock() 58 | defer ev.Unlock() 59 | delete(ev.subs, id) 60 | } 61 | 62 | // Publish publishes an event to all subscribers. 63 | func (ev *Events) Publish(e Event) error { 64 | ev.Lock() 65 | defer ev.Unlock() 66 | 67 | for _, ch := range ev.subs { 68 | select { 69 | case ch <- e: 70 | default: 71 | return fmt.Errorf("event queue full for type: %s", e.Type) 72 | } 73 | } 74 | 75 | return nil 76 | } 77 | 78 | // This implements an io.Writer specifically for receiving error messages 79 | // mirrored (io.MultiWriter) from error log writing. 80 | type wri struct { 81 | ev *Events 82 | } 83 | 84 | func (w *wri) Write(b []byte) (n int, err error) { 85 | // Only broadcast error messages. 86 | if !bytes.Contains(b, []byte("error")) { 87 | return 0, nil 88 | } 89 | 90 | w.ev.Publish(Event{ 91 | Type: TypeError, 92 | Message: string(b), 93 | }) 94 | 95 | return len(b), nil 96 | } 97 | 98 | func (ev *Events) ErrWriter() io.Writer { 99 | return &wri{ev: ev} 100 | } 101 | -------------------------------------------------------------------------------- /cmd/admin.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "sort" 7 | "syscall" 8 | "time" 9 | 10 | "github.com/labstack/echo/v4" 11 | ) 12 | 13 | type serverConfig struct { 14 | Messengers []string `json:"messengers"` 15 | Langs []i18nLang `json:"langs"` 16 | Lang string `json:"lang"` 17 | Update *AppUpdate `json:"update"` 18 | NeedsRestart bool `json:"needs_restart"` 19 | Version string `json:"version"` 20 | } 21 | 22 | // handleGetServerConfig returns general server config. 23 | func handleGetServerConfig(c echo.Context) error { 24 | var ( 25 | app = c.Get("app").(*App) 26 | out = serverConfig{} 27 | ) 28 | 29 | // Language list. 30 | langList, err := getI18nLangList(app.constants.Lang, app) 31 | if err != nil { 32 | return echo.NewHTTPError(http.StatusInternalServerError, 33 | fmt.Sprintf("Error loading language list: %v", err)) 34 | } 35 | out.Langs = langList 36 | out.Lang = app.constants.Lang 37 | 38 | // Sort messenger names with `email` always as the first item. 39 | var names []string 40 | for name := range app.messengers { 41 | if name == emailMsgr { 42 | continue 43 | } 44 | names = append(names, name) 45 | } 46 | sort.Strings(names) 47 | out.Messengers = append(out.Messengers, emailMsgr) 48 | out.Messengers = append(out.Messengers, names...) 49 | 50 | app.Lock() 51 | out.NeedsRestart = app.needsRestart 52 | out.Update = app.update 53 | app.Unlock() 54 | out.Version = versionString 55 | 56 | return c.JSON(http.StatusOK, okResp{out}) 57 | } 58 | 59 | // handleGetDashboardCharts returns chart data points to render ont he dashboard. 60 | func handleGetDashboardCharts(c echo.Context) error { 61 | var ( 62 | app = c.Get("app").(*App) 63 | ) 64 | 65 | out, err := app.core.GetDashboardCharts() 66 | if err != nil { 67 | return err 68 | } 69 | 70 | return c.JSON(http.StatusOK, okResp{out}) 71 | } 72 | 73 | // handleGetDashboardCounts returns stats counts to show on the dashboard. 74 | func handleGetDashboardCounts(c echo.Context) error { 75 | var ( 76 | app = c.Get("app").(*App) 77 | ) 78 | 79 | out, err := app.core.GetDashboardCounts() 80 | if err != nil { 81 | return err 82 | } 83 | 84 | return c.JSON(http.StatusOK, okResp{out}) 85 | } 86 | 87 | // handleReloadApp restarts the app. 88 | func handleReloadApp(c echo.Context) error { 89 | app := c.Get("app").(*App) 90 | go func() { 91 | <-time.After(time.Millisecond * 500) 92 | app.chReload <- syscall.SIGHUP 93 | }() 94 | return c.JSON(http.StatusOK, okResp{true}) 95 | } 96 | -------------------------------------------------------------------------------- /docs/docs/content/static/style.css: -------------------------------------------------------------------------------- 1 | body[data-md-color-primary="white"] .md-header[data-md-state="shadow"] { 2 | background: #fff; 3 | box-shadow: none; 4 | color: #333; 5 | 6 | box-shadow: 1px 1px 3px #ddd; 7 | } 8 | 9 | .md-typeset .md-typeset__table table { 10 | border: 1px solid #ddd; 11 | box-shadow: 2px 2px 0 #f3f3f3; 12 | overflow: inherit; 13 | } 14 | 15 | body[data-md-color-primary="white"] .md-search__input { 16 | background: #f6f6f6; 17 | color: #333; 18 | } 19 | 20 | body[data-md-color-primary="white"] 21 | .md-sidebar--secondary 22 | .md-sidebar__scrollwrap { 23 | background: #f6f6f6; 24 | padding: 10px 0; 25 | } 26 | 27 | body[data-md-color-primary="white"] .md-nav__item--active { 28 | font-weight: 600; 29 | color: inherit; 30 | } 31 | body[data-md-color-primary="white"] .md-nav__item--active a { 32 | color: #0055d4; 33 | } 34 | body[data-md-color-primary="white"] .md-nav__item a:hover { 35 | color: #0055d4; 36 | } 37 | 38 | body[data-md-color-primary="white"] thead, 39 | body[data-md-color-primary="white"] .md-typeset table:not([class]) th { 40 | background: #f6f6f6; 41 | border: 0; 42 | color: inherit; 43 | font-weight: 600; 44 | } 45 | table td span { 46 | font-size: 0.85em; 47 | color: #bbb; 48 | display: block; 49 | } 50 | 51 | .md-typeset h1, .md-typeset h2 { 52 | font-weight: 500; 53 | } 54 | 55 | body[data-md-color-primary="white"] .md-typeset h1 { 56 | margin: 4rem 0 0 0; 57 | color: inherit; 58 | border-top: 1px solid #ddd; 59 | padding-top: 2rem; 60 | } 61 | body[data-md-color-primary="white"] .md-typeset h2 { 62 | border-top: 1px solid #eee; 63 | padding-top: 2rem; 64 | } 65 | 66 | body[data-md-color-primary="white"] .md-content h1:first-child { 67 | margin: 0 0 3rem 0; 68 | padding: 0; 69 | border: 0; 70 | } 71 | 72 | body[data-md-color-primary="white"] .md-typeset code { 73 | word-break: normal; 74 | } 75 | 76 | li img { 77 | background: #fff; 78 | border-radius: 6px; 79 | border: 1px solid #e6e6e6; 80 | box-shadow: 1px 1px 4px #e6e6e6; 81 | padding: 5px; 82 | margin-top: 10px; 83 | } 84 | 85 | /* This hack places the #anchor-links correctly 86 | by accommodating for the fixed-header's height */ 87 | :target:before { 88 | content: ""; 89 | display: block; 90 | height: 120px; 91 | margin-top: -120px; 92 | } 93 | 94 | .md-typeset a { 95 | color: #0055d4; 96 | } 97 | .md-typeset a:hover { 98 | color: #666 !important; 99 | text-decoration: underline; 100 | } 101 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | [![listmonk-logo](https://user-images.githubusercontent.com/547147/231084896-835dba66-2dfe-497c-ba0f-787564c0819e.png)](https://listmonk.app) 4 | 5 | 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 (⩾ v9.4) database as its data store. 6 | 7 | [![listmonk-dashboard](https://user-images.githubusercontent.com/547147/134939475-e0391111-f762-44cb-b056-6cb0857755e3.png)](https://listmonk.app) 8 | 9 | Visit [listmonk.app](https://listmonk.app) for more info. Check out the [**live demo**](https://demo.listmonk.app). 10 | 11 | ## Installation 12 | 13 | ### Docker 14 | 15 | 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). Use the sample [docker-compose.yml](https://github.com/knadh/listmonk/blob/master/docker-compose.yml) to run manually or use the helper script. 16 | 17 | #### Demo 18 | 19 | ```bash 20 | mkdir listmonk-demo && cd listmonk-demo 21 | sh -c "$(curl -fsSL https://raw.githubusercontent.com/knadh/listmonk/master/install-demo.sh)" 22 | ``` 23 | 24 | DO NOT use this demo setup in production. 25 | 26 | #### Production 27 | 28 | ```bash 29 | mkdir listmonk && cd listmonk 30 | sh -c "$(curl -fsSL https://raw.githubusercontent.com/knadh/listmonk/master/install-prod.sh)" 31 | ``` 32 | Visit `http://localhost:9000`. 33 | 34 | **NOTE**: Always examine the contents of shell scripts before executing them. 35 | 36 | See [installation docs](https://listmonk.app/docs/installation). 37 | 38 | __________________ 39 | 40 | ### Binary 41 | - Download the [latest release](https://github.com/knadh/listmonk/releases) and extract the listmonk binary. 42 | - `./listmonk --new-config` to generate config.toml. Then, edit the file. 43 | - `./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). 44 | - Run `./listmonk` and visit `http://localhost:9000`. 45 | 46 | See [installation docs](https://listmonk.app/docs/installation). 47 | __________________ 48 | 49 | 50 | ## Developers 51 | 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. 52 | 53 | 54 | ## License 55 | listmonk is licensed under the AGPL v3 license. 56 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # listmonk frontend (Vue + Buefy) 2 | 3 | It's best if the `listmonk/frontend` directory is opened in an IDE as a separate project where the frontend directory is the root of the project. 4 | 5 | For developer setup instructions, refer to the main project's README. 6 | 7 | ## Globals 8 | In `main.js`, Buefy and vue-i18n are attached globally. In addition: 9 | 10 | - `$api` (collection of API calls from `api/index.js`) 11 | - `$utils` (util functions from `util.js`). They are accessible within Vue as `this.$api` and `this.$utils`. 12 | 13 | Some constants are defined in `constants.js`. 14 | 15 | 16 | ## APIs and states 17 | The project uses a global `vuex` state to centrally store the responses to pretty much all APIs (eg: fetch lists, campaigns etc.) except for a few exceptions. These are called `models` and have been defined in `constants.js`. The definitions are in `store/index.js`. 18 | 19 | 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. 20 | 21 | *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`. 22 | 23 | 24 | ## Icon pack 25 | Buefy by default uses [Material Design Icons](https://materialdesignicons.com) (MDI) with icon classes prefixed by `mdi-`. 26 | 27 | listmonk uses only a handful of icons from the massive MDI set packed as web font, using [Fontello](https://fontello.com). To add more icons to the set using fontello: 28 | 29 | - Go to Fontello and drag and drop `frontend/fontello/config.json` (This is the full MDI set converted from TTF to SVG icons to work with Fontello). 30 | - Use the UI to search for icons and add them to the selection (add icons from under the `Custom` section) 31 | - Download the Fontello pack and from the ZIP: 32 | - Copy and overwrite `config.json` to `frontend/fontello` 33 | - Copy `fontello.woff2` to `frontend/src/assets/icons`. 34 | - Open `css/fontello.css` and copy the individual icon definitions and overwrite the ones in `frontend/src/assets/icons/fontello.css` 35 | -------------------------------------------------------------------------------- /docs/docs/content/apis/apis.md: -------------------------------------------------------------------------------- 1 | # APIs 2 | 3 | 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. 4 | 5 | API requests require BasicAuth authentication with the admin credentials. 6 | 7 | > The API section is a work in progress. There may be API calls that are yet to be documented. Please consider contributing to docs. 8 | 9 | ## OpenAPI (Swagger) spec 10 | 11 | The auto-generated OpenAPI (Swagger) specification site for the APIs are available at [**listmonk.app/docs/swagger**](https://listmonk.app/docs/swagger/) 12 | 13 | ## Response structure 14 | 15 | ### Successful request 16 | 17 | ```http 18 | HTTP/1.1 200 OK 19 | Content-Type: application/json 20 | 21 | { 22 | "data": {} 23 | } 24 | ``` 25 | 26 | 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. 27 | 28 | ### Failed request 29 | 30 | ```http 31 | HTTP/1.1 500 Server error 32 | Content-Type: application/json 33 | 34 | { 35 | "message": "Error message" 36 | } 37 | ``` 38 | 39 | A failure response is preceded by the corresponding 40x or 50x HTTP header. There may be an optional `data` key with additional payload. 40 | 41 | ### Timestamps 42 | 43 | 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. 44 | 45 | ### Common HTTP error codes 46 | 47 | | code |   | 48 | | ----- | ------------------------------------------------------------------------ | 49 | | `400` | Missing or bad request parameters or values | 50 | | `403` | Session expired or invalidate. Must relogin | 51 | | `404` | Request resource was not found | 52 | | `405` | Request method (GET, POST etc.) is not allowed on the requested endpoint | 53 | | `410` | The requested resource is gone permanently | 54 | | `429` | Too many requests to the API (rate limiting) | 55 | | `500` | Something unexpected went wrong | 56 | | `502` | The backend OMS is down and the API is unable to communicate with it | 57 | | `503` | Service unavailable; the API is down | 58 | | `504` | Gateway timeout; the API is unreachable | 59 | -------------------------------------------------------------------------------- /docs/docs/content/apis/import.md: -------------------------------------------------------------------------------- 1 | # API / Import 2 | 3 | Method | Endpoint | Description 4 | ---------|--------------------------------------------------------------|---------------------------------------------------------- 5 | `GET` | [api/import/subscribers](#get-apiimportsubscribers) | Gets a import statistics. 6 | `GET` | [api/import/subscribers/logs](#get-apiimportsubscriberslogs) | Get a import statistics . 7 | `POST` | [api/import/subscribers](#post-apiimportsubscribers) | Upload a ZIP file or CSV file to bulk import subscribers. 8 | `DELETE` | [api/import/subscribers](#delete-apiimportsubscribers) | Stops and deletes a import. 9 | 10 | 11 | #### **`GET`** api/import/subscribers 12 | Gets import status. 13 | 14 | ##### Example Request 15 | ```shell 16 | curl -u "username:username" -X GET 'http://localhost:9000/api/import/subscribers' 17 | ``` 18 | 19 | ##### Example Response 20 | ```json 21 | { 22 | "data": { 23 | "name": "", 24 | "total": 0, 25 | "imported": 0, 26 | "status": "none" 27 | } 28 | } 29 | ``` 30 | 31 | #### **`GET`** api/import/subscribers/logs 32 | Gets import logs. 33 | 34 | ##### Example Request 35 | ```shell 36 | curl -u "username:username" -X GET 'http://localhost:9000/api/import/subscribers/logs' 37 | ``` 38 | 39 | ##### Example Response 40 | ```json 41 | { 42 | "data": "2020/04/08 21:55:20 processing 'import.csv'\n2020/04/08 21:55:21 imported finished\n" 43 | } 44 | ``` 45 | 46 | 47 | 48 | #### **`POST`** api/import/subscribers 49 | Post a CSV (optionally zipped) file to do a bulk import. The request should be a multipart form POST. 50 | 51 | 52 | ##### Parameters 53 | 54 | Name | Parameter type | Data type | Required/Optional | Description 55 | ---------|----------------|-----------|-------------------|------------------------------------ 56 | `params` | Request body | String | Required | Stringified JSON with import params 57 | `file` | Request body | File | Required | File to upload 58 | 59 | ***params*** (JSON string) 60 | 61 | ```json 62 | { 63 | "mode": "subscribe", // subscribe or blocklist 64 | "delim": ",", // delimiter in the uploaded file 65 | "lists":[1], // array of list IDs to import into 66 | "overwrite": true // overwrite existing entries or skip them? 67 | } 68 | ``` 69 | 70 | 71 | #### **`DELETE`** api/import/subscribers 72 | Stops and deletes an import. 73 | 74 | ##### Example Request 75 | ```shell 76 | curl -u "username:username" -X DELETE 'http://localhost:9000/api/import/subscribers' 77 | ``` 78 | 79 | ##### Example Response 80 | ```json 81 | { 82 | "data": { 83 | "name": "", 84 | "total": 0, 85 | "imported": 0, 86 | "status": "none" 87 | } 88 | } 89 | ``` -------------------------------------------------------------------------------- /docs/site/static/static/base.css: -------------------------------------------------------------------------------- 1 | /** 2 | *** SIMPLE GRID 3 | *** (C) ZACH COLE 2016 4 | **/ 5 | 6 | 7 | /* UNIVERSAL */ 8 | 9 | html, 10 | body { 11 | height: 100%; 12 | width: 100%; 13 | margin: 0; 14 | padding: 0; 15 | left: 0; 16 | top: 0; 17 | font-size: 100%; 18 | } 19 | 20 | .right { 21 | text-align: right; 22 | } 23 | 24 | .center { 25 | text-align: center; 26 | margin-left: auto; 27 | margin-right: auto; 28 | } 29 | 30 | .justify { 31 | text-align: justify; 32 | } 33 | 34 | /* ==== GRID SYSTEM ==== */ 35 | 36 | .container { 37 | margin-left: auto; 38 | margin-right: auto; 39 | } 40 | 41 | .row { 42 | position: relative; 43 | width: 100%; 44 | } 45 | 46 | .row [class^="col"] { 47 | float: left; 48 | margin: 0.5rem 2%; 49 | min-height: 0.125rem; 50 | } 51 | 52 | .col-1, 53 | .col-2, 54 | .col-3, 55 | .col-4, 56 | .col-5, 57 | .col-6, 58 | .col-7, 59 | .col-8, 60 | .col-9, 61 | .col-10, 62 | .col-11, 63 | .col-12 { 64 | width: 96%; 65 | } 66 | 67 | .col-1-sm { 68 | width: 4.33%; 69 | } 70 | 71 | .col-2-sm { 72 | width: 12.66%; 73 | } 74 | 75 | .col-3-sm { 76 | width: 21%; 77 | } 78 | 79 | .col-4-sm { 80 | width: 29.33%; 81 | } 82 | 83 | .col-5-sm { 84 | width: 37.66%; 85 | } 86 | 87 | .col-6-sm { 88 | width: 46%; 89 | } 90 | 91 | .col-7-sm { 92 | width: 54.33%; 93 | } 94 | 95 | .col-8-sm { 96 | width: 62.66%; 97 | } 98 | 99 | .col-9-sm { 100 | width: 71%; 101 | } 102 | 103 | .col-10-sm { 104 | width: 79.33%; 105 | } 106 | 107 | .col-11-sm { 108 | width: 87.66%; 109 | } 110 | 111 | .col-12-sm { 112 | width: 96%; 113 | } 114 | 115 | .row::after { 116 | content: ""; 117 | display: table; 118 | clear: both; 119 | } 120 | 121 | .hidden-sm { 122 | display: none; 123 | } 124 | 125 | @media only screen and (min-width: 33.75em) { /* 540px */ 126 | .container { 127 | width: 80%; 128 | } 129 | } 130 | 131 | @media only screen and (min-width: 45em) { /* 720px */ 132 | .col-1 { 133 | width: 4.33%; 134 | } 135 | 136 | .col-2 { 137 | width: 12.66%; 138 | } 139 | 140 | .col-3 { 141 | width: 21%; 142 | } 143 | 144 | .col-4 { 145 | width: 29.33%; 146 | } 147 | 148 | .col-5 { 149 | width: 37.66%; 150 | } 151 | 152 | .col-6 { 153 | width: 46%; 154 | } 155 | 156 | .col-7 { 157 | width: 54.33%; 158 | } 159 | 160 | .col-8 { 161 | width: 62.66%; 162 | } 163 | 164 | .col-9 { 165 | width: 71%; 166 | } 167 | 168 | .col-10 { 169 | width: 79.33%; 170 | } 171 | 172 | .col-11 { 173 | width: 87.66%; 174 | } 175 | 176 | .col-12 { 177 | width: 96%; 178 | } 179 | 180 | .hidden-sm { 181 | display: block; 182 | } 183 | } 184 | 185 | @media only screen and (min-width: 60em) { /* 960px */ 186 | .container { 187 | width: 75%; 188 | max-width: 60rem; 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /frontend/src/views/SubscriberBulkList.vue: -------------------------------------------------------------------------------- 1 | 53 | 54 | 95 | -------------------------------------------------------------------------------- /cmd/i18n.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "sort" 8 | 9 | "github.com/knadh/listmonk/internal/i18n" 10 | "github.com/knadh/stuffbin" 11 | "github.com/labstack/echo/v4" 12 | ) 13 | 14 | type i18nLang struct { 15 | Code string `json:"code"` 16 | Name string `json:"name"` 17 | } 18 | 19 | type i18nLangRaw struct { 20 | Code string `json:"_.code"` 21 | Name string `json:"_.name"` 22 | } 23 | 24 | // handleGetI18nLang returns the JSON language pack given the language code. 25 | func handleGetI18nLang(c echo.Context) error { 26 | app := c.Get("app").(*App) 27 | 28 | lang := c.Param("lang") 29 | if len(lang) > 6 || reLangCode.MatchString(lang) { 30 | return echo.NewHTTPError(http.StatusBadRequest, "Invalid language code.") 31 | } 32 | 33 | i, ok, err := getI18nLang(lang, app.fs) 34 | if err != nil && !ok { 35 | return echo.NewHTTPError(http.StatusBadRequest, "Unknown language.") 36 | } 37 | 38 | return c.JSON(http.StatusOK, okResp{json.RawMessage(i.JSON())}) 39 | } 40 | 41 | // getI18nLangList returns the list of available i18n languages. 42 | func getI18nLangList(lang string, app *App) ([]i18nLang, error) { 43 | list, err := app.fs.Glob("/i18n/*.json") 44 | if err != nil { 45 | return nil, err 46 | } 47 | 48 | var out []i18nLang 49 | for _, l := range list { 50 | b, err := app.fs.Get(l) 51 | if err != nil { 52 | return out, fmt.Errorf("error reading lang file: %s: %v", l, err) 53 | } 54 | 55 | var lang i18nLangRaw 56 | if err := json.Unmarshal(b.ReadBytes(), &lang); err != nil { 57 | return out, fmt.Errorf("error parsing lang file: %s: %v", l, err) 58 | } 59 | 60 | out = append(out, i18nLang{ 61 | Code: lang.Code, 62 | Name: lang.Name, 63 | }) 64 | } 65 | 66 | sort.SliceStable(out, func(i, j int) bool { 67 | return out[i].Code < out[j].Code 68 | }) 69 | 70 | return out, nil 71 | } 72 | 73 | // The bool indicates whether the specified language could be loaded. If it couldn't 74 | // be, the app shouldn't halt but throw a warning. 75 | func getI18nLang(lang string, fs stuffbin.FileSystem) (*i18n.I18n, bool, error) { 76 | const def = "en" 77 | 78 | b, err := fs.Read(fmt.Sprintf("/i18n/%s.json", def)) 79 | if err != nil { 80 | return nil, false, fmt.Errorf("error reading default i18n language file: %s: %v", def, err) 81 | } 82 | 83 | // Initialize with the default language. 84 | i, err := i18n.New(b) 85 | if err != nil { 86 | return nil, false, fmt.Errorf("error unmarshalling i18n language: %s: %v", lang, err) 87 | } 88 | 89 | // Load the selected language on top of it. 90 | b, err = fs.Read(fmt.Sprintf("/i18n/%s.json", lang)) 91 | if err != nil { 92 | return i, true, fmt.Errorf("error reading i18n language file: %s: %v", lang, err) 93 | } 94 | 95 | if err := i.Load(b); err != nil { 96 | return i, true, fmt.Errorf("error loading i18n language file: %s: %v", lang, err) 97 | } 98 | 99 | return i, true, nil 100 | } 101 | -------------------------------------------------------------------------------- /static/email-templates/base.html: -------------------------------------------------------------------------------- 1 | {{ define "header" }} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 78 | 79 | 80 |
 
81 |
82 |
83 | {{ if ne LogoURL "" }} 84 | listmonk 85 | {{ end }} 86 |
87 | {{ end }} 88 | 89 | {{ define "footer" }} 90 |
91 | 92 | 95 |
 
96 | 97 | 98 | {{ end }} 99 | -------------------------------------------------------------------------------- /cmd/utils.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "crypto/rand" 6 | "fmt" 7 | "path/filepath" 8 | "regexp" 9 | "strconv" 10 | "strings" 11 | ) 12 | 13 | var ( 14 | regexpSpaces = regexp.MustCompile(`[\s]+`) 15 | ) 16 | 17 | // inArray checks if a string is present in a list of strings. 18 | func inArray(val string, vals []string) (ok bool) { 19 | for _, v := range vals { 20 | if v == val { 21 | return true 22 | } 23 | } 24 | return false 25 | } 26 | 27 | // makeFilename sanitizes a filename (user supplied upload filenames). 28 | func makeFilename(fName string) string { 29 | name := strings.TrimSpace(fName) 30 | if name == "" { 31 | name, _ = generateRandomString(10) 32 | } 33 | // replace whitespace with "-" 34 | name = regexpSpaces.ReplaceAllString(name, "-") 35 | return filepath.Base(name) 36 | } 37 | 38 | // makeMsgTpl takes a page title, heading, and message and returns 39 | // a msgTpl that can be rendered as an HTML view. This is used for 40 | // rendering arbitrary HTML views with error and success messages. 41 | func makeMsgTpl(pageTitle, heading, msg string) msgTpl { 42 | if heading == "" { 43 | heading = pageTitle 44 | } 45 | err := msgTpl{} 46 | err.Title = pageTitle 47 | err.MessageTitle = heading 48 | err.Message = msg 49 | return err 50 | } 51 | 52 | // parseStringIDs takes a slice of numeric string IDs and 53 | // parses each number into an int64 and returns a slice of the 54 | // resultant values. 55 | func parseStringIDs(s []string) ([]int, error) { 56 | vals := make([]int, 0, len(s)) 57 | for _, v := range s { 58 | i, err := strconv.Atoi(v) 59 | if err != nil { 60 | return nil, err 61 | } 62 | 63 | if i < 1 { 64 | return nil, fmt.Errorf("%d is not a valid ID", i) 65 | } 66 | 67 | vals = append(vals, i) 68 | } 69 | 70 | return vals, nil 71 | } 72 | 73 | // generateRandomString generates a cryptographically random, alphanumeric string of length n. 74 | func generateRandomString(n int) (string, error) { 75 | const dictionary = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" 76 | var bytes = make([]byte, n) 77 | 78 | if _, err := rand.Read(bytes); err != nil { 79 | return "", err 80 | } 81 | for k, v := range bytes { 82 | bytes[k] = dictionary[v%byte(len(dictionary))] 83 | } 84 | 85 | return string(bytes), nil 86 | } 87 | 88 | // strHasLen checks if the given string has a length within min-max. 89 | func strHasLen(str string, min, max int) bool { 90 | return len(str) >= min && len(str) <= max 91 | } 92 | 93 | // strSliceContains checks if a string is present in the string slice. 94 | func strSliceContains(str string, sl []string) bool { 95 | for _, s := range sl { 96 | if s == str { 97 | return true 98 | } 99 | } 100 | 101 | return false 102 | } 103 | 104 | func int8ToStr(bs []int8) string { 105 | b := make([]byte, len(bs)) 106 | for i, v := range bs { 107 | b[i] = byte(v) 108 | } 109 | 110 | return string(bytes.Trim(b, "\x00")) 111 | } 112 | -------------------------------------------------------------------------------- /listmonk@.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=listmonk mailing list and newsletter manager (%I) 3 | ConditionPathExists=/etc/listmonk/%i.toml 4 | Wants=network.target 5 | # The PostgreSQL database may not be on the same host but if it 6 | # is listmonk should wait for it to start up. 7 | After=postgresql.service 8 | 9 | [Service] 10 | Type=simple 11 | EnvironmentFile=-/etc/default/listmonk 12 | EnvironmentFile=-/etc/default/listmonk-%i 13 | ExecStartPre=/usr/bin/mkdir -p "${HOME}/uploads" 14 | ExecStartPre=/usr/bin/listmonk --config /etc/listmonk/%i.toml --upgrade --yes 15 | ExecStart=/usr/bin/listmonk --config /etc/listmonk/%i.toml $SYSTEMD_LISTMONK_ARGS 16 | Restart=on-failure 17 | 18 | # Create dynamic users for listmonk service instances 19 | # but create a state directory for uploads in /var/lib/private/%i. 20 | DynamicUser=True 21 | StateDirectory=listmonk-%i 22 | Environment=HOME=%S/listmonk-%i 23 | WorkingDirectory=%S/listmonk-%i 24 | 25 | # Use systemd’s ability to disable security-sensitive features 26 | # that listmonk does not explicitly need. 27 | # NoNewPrivileges should be enabled by DynamicUser=yes but systemd-analyze 28 | # still recommended to explicitly enable it. 29 | NoNewPrivileges=True 30 | # listmonk doesn’t need any capabilities as defined by the linux kernel 31 | # see: https://man7.org/linux/man-pages/man7/capabilities.7.html 32 | CapabilityBoundingSet= 33 | # listmonk only executes native code with no need for any other ABIs. 34 | SystemCallArchitectures=native 35 | # Only enable a reasonable set of system calls. 36 | # see: https://www.freedesktop.org/software/systemd/man/systemd.exec.html#SystemCallFilter= 37 | SystemCallFilter=@system-service 38 | SystemCallFilter=~@privileged 39 | # ProtectSystem=strict, which is implied by DynamicUser=True, already disabled write calls 40 | # to the entire filesystem hierarchy, leaving only /dev/, /proc/, and /sys/ writable. 41 | # listmonk doesn’t need access to those so might as well disable them. 42 | PrivateDevices=True 43 | ProtectControlGroups=True 44 | ProtectKernelTunables=True 45 | # Make /home/, /root/, and /run/user/ inaccessible. 46 | ProtectHome=True 47 | # listmonk doesn’t handle any specific device nodes. 48 | DeviceAllow=False 49 | # listmonk doesn’t make use of linux namespaces. 50 | RestrictNamespaces=True 51 | # listmonk doesn’t need realtime scheduling. 52 | RestrictRealtime=True 53 | # Make sure files created by listmonk are only readable by itself and 54 | # others in the listmonk system group. 55 | UMask=0027 56 | # Disable memory mappings that are both writable and executable. 57 | MemoryDenyWriteExecute=True 58 | # listmonk doesn’t make use of linux personality switching. 59 | LockPersonality=True 60 | # listmonk only needs to support the IPv4 and IPv6 address families. 61 | RestrictAddressFamilies=AF_INET AF_INET6 62 | # listmonk doesn’t need to load any linux kernel modules. 63 | ProtectKernelModules=True 64 | # Create a sandboxed environment where the system users are mapped to a 65 | # service-specific linux kernel namespace. 66 | PrivateUsers=True 67 | 68 | [Install] 69 | WantedBy=multi-user.target 70 | -------------------------------------------------------------------------------- /frontend/src/components/CampaignPreview.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 108 | -------------------------------------------------------------------------------- /frontend/cypress/e2e/import.cy.js: -------------------------------------------------------------------------------- 1 | 2 | describe('Import', () => { 3 | it('Opens import page', () => { 4 | cy.resetDB(); 5 | cy.loginAndVisit('/subscribers/import'); 6 | }); 7 | 8 | it('Imports subscribers', () => { 9 | const cases = [ 10 | { chkMode: 'subscribe', status: 'enabled', chkSubStatus: 'unconfirmed', subStatus: 'unconfirmed', overwrite: true, count: 102 }, 11 | { chkMode: 'subscribe', status: 'enabled', chkSubStatus: 'confirmed', subStatus: 'confirmed', overwrite: true, count: 102 }, 12 | { chkMode: 'subscribe', status: 'enabled', chkSubStatus: 'unconfirmed', subStatus: 'confirmed', overwrite: false, count: 102 }, 13 | { chkMode: 'blocklist', status: 'blocklisted', chkSubStatus: 'unsubscribed', subStatus: 'unsubscribed', overwrite: true, count: 102 }, 14 | ]; 15 | 16 | cases.forEach((c) => { 17 | cy.get(`[data-cy=check-${c.chkMode}] .check`).click(); 18 | cy.get(`[data-cy=check-${c.chkSubStatus}] .check`).click(); 19 | 20 | if (!c.overwrite) { 21 | cy.get(`[data-cy=overwrite]`).click(); 22 | } 23 | 24 | if (c.status === 'enabled') { 25 | cy.get('.list-selector input').click(); 26 | cy.get('.list-selector .autocomplete a').first().click(); 27 | } 28 | 29 | cy.fixture('subs.csv').then((data) => { 30 | cy.get('input[type="file"]').attachFile({ 31 | fileContent: data.toString(), 32 | fileName: 'subs.csv', 33 | mimeType: 'text/csv', 34 | }); 35 | }); 36 | 37 | cy.get('button.is-primary').click(); 38 | cy.get('section.wrap .has-text-success'); 39 | cy.get('button.is-primary').click(); 40 | cy.wait(100); 41 | 42 | // Verify that 100 (+2 default) subs are imported. 43 | cy.loginAndVisit('/subscribers'); 44 | cy.wait(100); 45 | cy.get('[data-cy=count]').then(($el) => { 46 | cy.expect(parseInt($el.text().trim())).to.equal(c.count); 47 | }); 48 | 49 | // Subscriber status. 50 | cy.get('tbody td[data-label=Status]').each(($el) => { 51 | cy.wrap($el).find(`.tag.${c.status}`); 52 | }); 53 | 54 | // Subscription status. 55 | cy.get('tbody td[data-label=E-mail]').each(($el) => { 56 | cy.wrap($el).find(`.tag.${c.subStatus}`); 57 | }); 58 | 59 | cy.loginAndVisit('/subscribers/import'); 60 | cy.wait(100); 61 | }); 62 | }); 63 | 64 | it('Imports subscribers incorrectly', () => { 65 | cy.wait(1000); 66 | cy.resetDB(); 67 | cy.wait(1000); 68 | cy.loginAndVisit('/subscribers/import'); 69 | 70 | cy.get('.list-selector input').click(); 71 | cy.get('.list-selector .autocomplete a').first().click(); 72 | cy.get('input[name=delim]').clear().type('|'); 73 | 74 | cy.fixture('subs.csv').then((data) => { 75 | cy.get('input[type="file"]').attachFile({ 76 | fileContent: data.toString(), 77 | fileName: 'subs.csv', 78 | mimeType: 'text/csv', 79 | }); 80 | }); 81 | 82 | cy.get('button.is-primary').click(); 83 | cy.wait(250); 84 | cy.get('section.wrap .has-text-danger'); 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /static/email-templates/default-archive.tpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{ .Campaign.Subject }} 5 | 6 | 7 | 8 | 87 | 88 | 89 |
 
90 |
91 | {{ template "content" . }} 92 |
93 | 94 | 97 | 98 | 99 | -------------------------------------------------------------------------------- /internal/core/media.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strings" 7 | 8 | "github.com/gofrs/uuid" 9 | "github.com/knadh/listmonk/internal/media" 10 | "github.com/knadh/listmonk/models" 11 | "github.com/labstack/echo/v4" 12 | "gopkg.in/volatiletech/null.v6" 13 | ) 14 | 15 | // QueryMedia returns media entries optionally filtered by a query string. 16 | func (c *Core) QueryMedia(provider string, s media.Store, query string, offset, limit int) ([]media.Media, int, error) { 17 | out := []media.Media{} 18 | 19 | if query != "" { 20 | query = strings.ToLower(query) 21 | } 22 | 23 | if err := c.q.QueryMedia.Select(&out, fmt.Sprintf("%%%s%%", query), provider, offset, limit); err != nil { 24 | return out, 0, echo.NewHTTPError(http.StatusInternalServerError, 25 | c.i18n.Ts("globals.messages.errorFetching", 26 | "name", "{globals.terms.media}", "error", pqErrMsg(err))) 27 | } 28 | 29 | total := 0 30 | if len(out) > 0 { 31 | total = out[0].Total 32 | 33 | for i := 0; i < len(out); i++ { 34 | out[i].URL = s.GetURL(out[i].Filename) 35 | 36 | if out[i].Thumb != "" { 37 | out[i].ThumbURL = null.String{Valid: true, String: s.GetURL(out[i].Thumb)} 38 | } 39 | } 40 | } 41 | 42 | return out, total, nil 43 | } 44 | 45 | // GetMedia returns a media item. 46 | func (c *Core) GetMedia(id int, uuid string, s media.Store) (media.Media, error) { 47 | var uu interface{} 48 | if uuid != "" { 49 | uu = uuid 50 | } 51 | 52 | var out media.Media 53 | if err := c.q.GetMedia.Get(&out, id, uu); err != nil { 54 | return out, echo.NewHTTPError(http.StatusInternalServerError, 55 | c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.media}", "error", pqErrMsg(err))) 56 | } 57 | 58 | out.URL = s.GetURL(out.Filename) 59 | if out.Thumb != "" { 60 | out.ThumbURL = null.String{Valid: true, String: s.GetURL(out.Thumb)} 61 | } 62 | 63 | return out, nil 64 | } 65 | 66 | // InsertMedia inserts a new media file into the DB. 67 | func (c *Core) InsertMedia(fileName, thumbName, contentType string, meta models.JSON, provider string, s media.Store) (media.Media, error) { 68 | uu, err := uuid.NewV4() 69 | if err != nil { 70 | c.log.Printf("error generating UUID: %v", err) 71 | return media.Media{}, echo.NewHTTPError(http.StatusInternalServerError, 72 | c.i18n.Ts("globals.messages.errorUUID", "error", err.Error())) 73 | } 74 | 75 | // Write to the DB. 76 | var newID int 77 | if err := c.q.InsertMedia.Get(&newID, uu, fileName, thumbName, contentType, provider, meta); err != nil { 78 | c.log.Printf("error inserting uploaded file to db: %v", err) 79 | return media.Media{}, echo.NewHTTPError(http.StatusInternalServerError, 80 | c.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.media}", "error", pqErrMsg(err))) 81 | } 82 | 83 | return c.GetMedia(newID, "", s) 84 | } 85 | 86 | // DeleteMedia deletes a given media item and returns the filename of the deleted item. 87 | func (c *Core) DeleteMedia(id int) (string, error) { 88 | var fname string 89 | if err := c.q.DeleteMedia.Get(&fname, id); err != nil { 90 | c.log.Printf("error inserting uploaded file to db: %v", err) 91 | return "", echo.NewHTTPError(http.StatusInternalServerError, 92 | c.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.media}", "error", pqErrMsg(err))) 93 | } 94 | 95 | return fname, nil 96 | } 97 | -------------------------------------------------------------------------------- /internal/bounce/webhooks/sendgrid.go: -------------------------------------------------------------------------------- 1 | package webhooks 2 | 3 | import ( 4 | "crypto/ecdsa" 5 | "crypto/sha256" 6 | "crypto/x509" 7 | "encoding/asn1" 8 | "encoding/base64" 9 | "encoding/json" 10 | "errors" 11 | "fmt" 12 | "math/big" 13 | "strings" 14 | "time" 15 | 16 | "github.com/knadh/listmonk/models" 17 | ) 18 | 19 | type sendgridNotif struct { 20 | Email string `json:"email"` 21 | Timestamp int64 `json:"timestamp"` 22 | Event string `json:"event"` 23 | BounceClassification string `json:"bounce_classification"` 24 | 25 | // SendGrid flattens all X-headers and adds them to the bounce 26 | // event notification. 27 | CampaignUUID string `json:"XListmonkCampaign"` 28 | } 29 | 30 | // Sendgrid handles Sendgrid/SNS webhook notifications including confirming SNS topic subscription 31 | // requests and bounce notifications. 32 | type Sendgrid struct { 33 | pubKey *ecdsa.PublicKey 34 | } 35 | 36 | // NewSendgrid returns a new Sendgrid instance. 37 | func NewSendgrid(key string) (*Sendgrid, error) { 38 | // Get the certificate from the key. 39 | sigB, err := base64.StdEncoding.DecodeString(key) 40 | if err != nil { 41 | return nil, err 42 | } 43 | 44 | pubKey, err := x509.ParsePKIXPublicKey(sigB) 45 | if err != nil { 46 | return nil, err 47 | } 48 | 49 | return &Sendgrid{pubKey: pubKey.(*ecdsa.PublicKey)}, nil 50 | } 51 | 52 | // ProcessBounce processes Sendgrid bounce notifications and returns one or more Bounce objects. 53 | func (s *Sendgrid) ProcessBounce(sig, timestamp string, b []byte) ([]models.Bounce, error) { 54 | if err := s.verifyNotif(sig, timestamp, b); err != nil { 55 | return nil, err 56 | } 57 | 58 | var notifs []sendgridNotif 59 | if err := json.Unmarshal(b, ¬ifs); err != nil { 60 | return nil, fmt.Errorf("error unmarshalling Sendgrid notification: %v", err) 61 | } 62 | 63 | out := make([]models.Bounce, 0, len(notifs)) 64 | for _, n := range notifs { 65 | if n.Event != "bounce" { 66 | continue 67 | } 68 | 69 | typ := models.BounceTypeHard 70 | if n.BounceClassification == "technical" || n.BounceClassification == "content" { 71 | typ = models.BounceTypeSoft 72 | } 73 | 74 | tstamp := time.Unix(n.Timestamp, 0) 75 | bn := models.Bounce{ 76 | CampaignUUID: n.CampaignUUID, 77 | Email: strings.ToLower(n.Email), 78 | Type: typ, 79 | Meta: json.RawMessage(b), 80 | Source: "sendgrid", 81 | CreatedAt: tstamp, 82 | } 83 | out = append(out, bn) 84 | } 85 | 86 | return out, nil 87 | } 88 | 89 | // verifyNotif verifies the signature on a notification payload. 90 | func (s *Sendgrid) verifyNotif(sig, timestamp string, b []byte) error { 91 | sigB, err := base64.StdEncoding.DecodeString(sig) 92 | if err != nil { 93 | return err 94 | } 95 | 96 | ecdsaSig := struct { 97 | R *big.Int 98 | S *big.Int 99 | }{} 100 | 101 | if _, err := asn1.Unmarshal(sigB, &ecdsaSig); err != nil { 102 | return fmt.Errorf("error asn1 unmarshal of signature: %v", err) 103 | } 104 | 105 | h := sha256.New() 106 | h.Write([]byte(timestamp)) 107 | h.Write(b) 108 | hash := h.Sum(nil) 109 | 110 | if !ecdsa.Verify(s.pubKey, hash, ecdsaSig.R, ecdsaSig.S) { 111 | return errors.New("invalid signature") 112 | } 113 | 114 | return nil 115 | } 116 | -------------------------------------------------------------------------------- /docs/docs/content/configuration.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | ### TOML Configuration file 4 | 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. 5 | 6 | To generate a new sample configuration file, run `--listmonk --new-config` 7 | 8 | ### Environment variables 9 | Variables in config.toml can also be provided as environment variables prefixed by `LISTMONK_` with periods replaced by `__` (double underscore). Example: 10 | 11 | | **Environment variable** | Example value | 12 | |--------------------------------|----------------| 13 | | `LISTMONK_app__address` | "0.0.0.0:9000" | 14 | | `LISTMONK_app__admin_username` | listmonk | 15 | | `LISTMONK_app__admin_password` | listmonk | 16 | | `LISTMONK_db__host` | db | 17 | | `LISTMONK_db__port` | 9432 | 18 | | `LISTMONK_db__user` | listmonk | 19 | | `LISTMONK_db__password` | listmonk | 20 | | `LISTMONK_db__database` | listmonk | 21 | | `LISTMONK_db__ssl_mode` | disable | 22 | 23 | 24 | ### Customizing system templates 25 | [Read this](../templating/#system-templates) 26 | 27 | 28 | ### HTTP routes 29 | When configuring auth proxies and web application firewalls, use this table. 30 | 31 | #### Private admin endpoints. 32 | 33 | | Methods | Route | Description | 34 | |---------|--------------------|-------------------------| 35 | | `*` | `/api/*` | Admin APIs | 36 | | `GET` | `/admin/*` | Admin UI and HTML pages | 37 | | `POST` | `/webhooks/bounce` | Admin bounce webhook | 38 | 39 | 40 | #### Public endpoints to expose to the internet. 41 | 42 | | Methods | Route | Description | 43 | |-------------|-----------------------|-----------------------------------------------| 44 | | `GET, POST` | `/subscription/*` | HTML subscription pages | 45 | | `GET, ` | `/link/*` | Tracked link redirection | 46 | | `GET` | `/campaign/*` | Pixel tracking image | 47 | | `GET` | `/public/*` | Static files for HTML subscription pages | 48 | | `POST` | `/webhooks/service/*` | Bounce webhook endpoints for AWS and Sendgrid | 49 | 50 | 51 | ## Media Uploads 52 | 53 | ### Filesystem 54 | 55 | When configuring `docker` volume mounts for using filesystem media uploads, you can follow either of two approaches. 56 | 57 | #### Using volumes 58 | 59 | Using `docker volumes`, you can specify the name of volume and destination for the files to be uploaded inside the container. 60 | 61 | 62 | ```yml 63 | app: 64 | volumes: 65 | - type: volume 66 | source: listmonk-uploads 67 | target: /listmonk/uploads 68 | 69 | volumes: 70 | listmonk-uploads: 71 | ``` 72 | 73 | !!! note 74 | 75 | This volume is managed by `docker` itself, and you can see find the host path with `docker volume inspect listmonk_listmonk-uploads`. 76 | 77 | #### Using bind mounts 78 | 79 | ```yml 80 | volumes: 81 | - /data/uploads:/listmonk/uploads 82 | ``` 83 | 84 | The files will be available inside `/data/uploads` directory on the host machine. 85 | -------------------------------------------------------------------------------- /internal/core/templates.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "database/sql" 5 | "net/http" 6 | 7 | "github.com/knadh/listmonk/models" 8 | "github.com/labstack/echo/v4" 9 | ) 10 | 11 | // GetTemplates retrieves all templates. 12 | func (c *Core) GetTemplates(status string, noBody bool) ([]models.Template, error) { 13 | out := []models.Template{} 14 | if err := c.q.GetTemplates.Select(&out, 0, noBody, status); err != nil { 15 | return nil, echo.NewHTTPError(http.StatusInternalServerError, 16 | c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.templates}", "error", pqErrMsg(err))) 17 | } 18 | 19 | return out, nil 20 | } 21 | 22 | // GetTemplate retrieves a given template. 23 | func (c *Core) GetTemplate(id int, noBody bool) (models.Template, error) { 24 | var out []models.Template 25 | if err := c.q.GetTemplates.Select(&out, id, noBody, ""); err != nil { 26 | return models.Template{}, echo.NewHTTPError(http.StatusInternalServerError, 27 | c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.templates}", "error", pqErrMsg(err))) 28 | } 29 | 30 | if len(out) == 0 { 31 | return models.Template{}, echo.NewHTTPError(http.StatusBadRequest, 32 | c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.template}")) 33 | } 34 | 35 | return out[0], nil 36 | } 37 | 38 | // CreateTemplate creates a new template. 39 | func (c *Core) CreateTemplate(name, typ, subject string, body []byte) (models.Template, error) { 40 | var newID int 41 | if err := c.q.CreateTemplate.Get(&newID, name, typ, subject, body); err != nil { 42 | return models.Template{}, echo.NewHTTPError(http.StatusInternalServerError, 43 | c.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.template}", "error", pqErrMsg(err))) 44 | } 45 | 46 | return c.GetTemplate(newID, false) 47 | } 48 | 49 | // UpdateTemplate updates a given template. 50 | func (c *Core) UpdateTemplate(id int, name, subject string, body []byte) (models.Template, error) { 51 | res, err := c.q.UpdateTemplate.Exec(id, name, subject, body) 52 | if err != nil { 53 | return models.Template{}, echo.NewHTTPError(http.StatusInternalServerError, 54 | c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.template}", "error", pqErrMsg(err))) 55 | } 56 | 57 | if n, _ := res.RowsAffected(); n == 0 { 58 | return models.Template{}, echo.NewHTTPError(http.StatusBadRequest, 59 | c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.template}")) 60 | } 61 | 62 | return c.GetTemplate(id, false) 63 | } 64 | 65 | // SetDefaultTemplate sets a template as default. 66 | func (c *Core) SetDefaultTemplate(id int) error { 67 | if _, err := c.q.SetDefaultTemplate.Exec(id); err != nil { 68 | return echo.NewHTTPError(http.StatusInternalServerError, 69 | c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.template}", "error", pqErrMsg(err))) 70 | } 71 | 72 | return nil 73 | } 74 | 75 | // DeleteTemplate deletes a given template. 76 | func (c *Core) DeleteTemplate(id int) error { 77 | var delID int 78 | if err := c.q.DeleteTemplate.Get(&delID, id); err != nil && err != sql.ErrNoRows { 79 | return echo.NewHTTPError(http.StatusInternalServerError, 80 | c.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.template}", "error", pqErrMsg(err))) 81 | } 82 | if delID == 0 { 83 | return echo.NewHTTPError(http.StatusBadRequest, c.i18n.T("templates.cantDeleteDefault")) 84 | } 85 | 86 | return nil 87 | } 88 | -------------------------------------------------------------------------------- /frontend/src/views/settings/performance.vue: -------------------------------------------------------------------------------- 1 | 72 | 73 | 92 | -------------------------------------------------------------------------------- /internal/core/bounces.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strings" 7 | 8 | "github.com/knadh/listmonk/models" 9 | "github.com/labstack/echo/v4" 10 | "github.com/lib/pq" 11 | ) 12 | 13 | var bounceQuerySortFields = []string{"email", "campaign_name", "source", "created_at"} 14 | 15 | // QueryBounces retrieves paginated bounce entries based on the given params. 16 | // It also returns the total number of bounce records in the DB. 17 | func (c *Core) QueryBounces(campID, subID int, source, orderBy, order string, offset, limit int) ([]models.Bounce, int, error) { 18 | if !strSliceContains(orderBy, bounceQuerySortFields) { 19 | orderBy = "created_at" 20 | } 21 | if order != SortAsc && order != SortDesc { 22 | order = SortDesc 23 | } 24 | 25 | out := []models.Bounce{} 26 | stmt := strings.ReplaceAll(c.q.QueryBounces, "%order%", orderBy+" "+order) 27 | if err := c.db.Select(&out, stmt, 0, campID, subID, source, offset, limit); err != nil { 28 | c.log.Printf("error fetching bounces: %v", err) 29 | return nil, 0, echo.NewHTTPError(http.StatusInternalServerError, 30 | c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.bounce}", "error", pqErrMsg(err))) 31 | } 32 | 33 | total := 0 34 | if len(out) > 0 { 35 | total = out[0].Total 36 | } 37 | 38 | return out, total, nil 39 | } 40 | 41 | // GetBounce retrieves bounce entries based on the given params. 42 | func (c *Core) GetBounce(id int) (models.Bounce, error) { 43 | var out []models.Bounce 44 | stmt := fmt.Sprintf(c.q.QueryBounces, "id", SortAsc) 45 | if err := c.db.Select(&out, stmt, id, 0, 0, "", 0, 1); err != nil { 46 | c.log.Printf("error fetching bounces: %v", err) 47 | return models.Bounce{}, echo.NewHTTPError(http.StatusInternalServerError, 48 | c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.bounce}", "error", pqErrMsg(err))) 49 | } 50 | 51 | if len(out) == 0 { 52 | return models.Bounce{}, echo.NewHTTPError(http.StatusBadRequest, 53 | c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.bounce}")) 54 | 55 | } 56 | 57 | return out[0], nil 58 | } 59 | 60 | // RecordBounce records a new bounce. 61 | func (c *Core) RecordBounce(b models.Bounce) error { 62 | action, ok := c.constants.BounceActions[b.Type] 63 | if !ok { 64 | return echo.NewHTTPError(http.StatusBadRequest, c.i18n.Ts("globals.messages.invalidData")+": "+b.Type) 65 | } 66 | 67 | _, err := c.q.RecordBounce.Exec(b.SubscriberUUID, 68 | b.Email, 69 | b.CampaignUUID, 70 | b.Type, 71 | b.Source, 72 | b.Meta, 73 | b.CreatedAt, 74 | action.Count, 75 | action.Action) 76 | 77 | if err != nil { 78 | // Ignore the error if it complained of no subscriber. 79 | if pqErr, ok := err.(*pq.Error); ok && pqErr.Column == "subscriber_id" { 80 | c.log.Printf("bounced subscriber (%s / %s) not found", b.SubscriberUUID, b.Email) 81 | return nil 82 | } 83 | 84 | c.log.Printf("error recording bounce: %v", err) 85 | } 86 | 87 | return err 88 | } 89 | 90 | // DeleteBounce deletes a list. 91 | func (c *Core) DeleteBounce(id int) error { 92 | return c.DeleteBounces([]int{id}) 93 | } 94 | 95 | // DeleteBounces deletes multiple lists. 96 | func (c *Core) DeleteBounces(ids []int) error { 97 | if _, err := c.q.DeleteBounces.Exec(pq.Array(ids)); err != nil { 98 | c.log.Printf("error deleting lists: %v", err) 99 | return echo.NewHTTPError(http.StatusInternalServerError, 100 | c.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.list}", "error", pqErrMsg(err))) 101 | } 102 | return nil 103 | } 104 | -------------------------------------------------------------------------------- /frontend/src/components/ListSelector.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 124 | -------------------------------------------------------------------------------- /internal/bounce/bounce.go: -------------------------------------------------------------------------------- 1 | package bounce 2 | 3 | import ( 4 | "errors" 5 | "log" 6 | "time" 7 | 8 | "github.com/jmoiron/sqlx" 9 | "github.com/knadh/listmonk/internal/bounce/mailbox" 10 | "github.com/knadh/listmonk/internal/bounce/webhooks" 11 | "github.com/knadh/listmonk/models" 12 | ) 13 | 14 | const ( 15 | // subID is the identifying subscriber ID header to look for in 16 | // bounced e-mails. 17 | subID = "X-Listmonk-Subscriber" 18 | campID = "X-Listmonk-Campaign" 19 | ) 20 | 21 | // Mailbox represents a POP/IMAP mailbox client that can scan messages and pass 22 | // them to a given channel. 23 | type Mailbox interface { 24 | Scan(limit int, ch chan models.Bounce) error 25 | } 26 | 27 | // Opt represents bounce processing options. 28 | type Opt struct { 29 | MailboxEnabled bool `json:"mailbox_enabled"` 30 | MailboxType string `json:"mailbox_type"` 31 | Mailbox mailbox.Opt `json:"mailbox"` 32 | WebhooksEnabled bool `json:"webhooks_enabled"` 33 | SESEnabled bool `json:"ses_enabled"` 34 | SendgridEnabled bool `json:"sendgrid_enabled"` 35 | SendgridKey string `json:"sendgrid_key"` 36 | 37 | RecordBounceCB func(models.Bounce) error 38 | } 39 | 40 | // Manager handles e-mail bounces. 41 | type Manager struct { 42 | queue chan models.Bounce 43 | mailbox Mailbox 44 | SES *webhooks.SES 45 | Sendgrid *webhooks.Sendgrid 46 | queries *Queries 47 | opt Opt 48 | log *log.Logger 49 | } 50 | 51 | // Queries contains the queries. 52 | type Queries struct { 53 | DB *sqlx.DB 54 | RecordQuery *sqlx.Stmt 55 | } 56 | 57 | // New returns a new instance of the bounce manager. 58 | func New(opt Opt, q *Queries, lo *log.Logger) (*Manager, error) { 59 | m := &Manager{ 60 | opt: opt, 61 | queries: q, 62 | queue: make(chan models.Bounce, 1000), 63 | log: lo, 64 | } 65 | 66 | // Is there a mailbox? 67 | if opt.MailboxEnabled { 68 | switch opt.MailboxType { 69 | case "pop": 70 | m.mailbox = mailbox.NewPOP(opt.Mailbox) 71 | default: 72 | return nil, errors.New("unknown bounce mailbox type") 73 | } 74 | } 75 | 76 | if opt.WebhooksEnabled { 77 | if opt.SESEnabled { 78 | m.SES = webhooks.NewSES() 79 | } 80 | if opt.SendgridEnabled { 81 | sg, err := webhooks.NewSendgrid(opt.SendgridKey) 82 | if err != nil { 83 | lo.Printf("error initializing sendgrid webhooks: %v", err) 84 | } else { 85 | m.Sendgrid = sg 86 | } 87 | } 88 | } 89 | 90 | return m, nil 91 | } 92 | 93 | // Run is a blocking function that listens for bounce events from webhooks and or mailboxes 94 | // and executes them on the DB. 95 | func (m *Manager) Run() { 96 | if m.opt.MailboxEnabled { 97 | go m.runMailboxScanner() 98 | } 99 | 100 | for { 101 | select { 102 | case b, ok := <-m.queue: 103 | if !ok { 104 | return 105 | } 106 | 107 | if b.CreatedAt.IsZero() { 108 | b.CreatedAt = time.Now() 109 | } 110 | 111 | if err := m.opt.RecordBounceCB(b); err != nil { 112 | continue 113 | } 114 | } 115 | } 116 | } 117 | 118 | // runMailboxScanner runs a blocking loop that scans the mailbox at given intervals. 119 | func (m *Manager) runMailboxScanner() { 120 | for { 121 | if err := m.mailbox.Scan(1000, m.queue); err != nil { 122 | m.log.Printf("error scanning bounce mailbox: %v", err) 123 | } 124 | 125 | time.Sleep(m.opt.Mailbox.ScanInterval) 126 | } 127 | } 128 | 129 | // Record records a new bounce event given the subscriber's email or UUID. 130 | func (m *Manager) Record(b models.Bounce) error { 131 | select { 132 | case m.queue <- b: 133 | } 134 | return nil 135 | } 136 | -------------------------------------------------------------------------------- /static/email-templates/default.tpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{ .Campaign.Subject }} 5 | 6 | 7 | 8 | 87 | 88 | 89 |
 
90 |
91 | {{ template "content" . }} 92 |
93 | 94 | 102 |
 {{ TrackView }}
103 | 104 | 105 | -------------------------------------------------------------------------------- /internal/migrations/v2.0.0.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "github.com/jmoiron/sqlx" 5 | "github.com/knadh/koanf/v2" 6 | "github.com/knadh/stuffbin" 7 | ) 8 | 9 | // V2_0_0 performs the DB migrations for v.1.0.0. 10 | func V2_0_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error { 11 | if _, err := db.Exec(` 12 | DO $$ 13 | BEGIN 14 | IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'bounce_type') THEN 15 | CREATE TYPE bounce_type AS ENUM ('soft', 'hard', 'complaint'); 16 | END IF; 17 | END$$; 18 | `); err != nil { 19 | return err 20 | } 21 | 22 | if _, err := db.Exec(` 23 | CREATE TABLE IF NOT EXISTS bounces ( 24 | id SERIAL PRIMARY KEY, 25 | subscriber_id INTEGER NOT NULL REFERENCES subscribers(id) ON DELETE CASCADE ON UPDATE CASCADE, 26 | campaign_id INTEGER NULL REFERENCES campaigns(id) ON DELETE SET NULL ON UPDATE CASCADE, 27 | type bounce_type NOT NULL DEFAULT 'hard', 28 | source TEXT NOT NULL DEFAULT '', 29 | meta JSONB NOT NULL DEFAULT '{}', 30 | created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() 31 | ); 32 | CREATE INDEX IF NOT EXISTS idx_bounces_sub_id ON bounces(subscriber_id); 33 | CREATE INDEX IF NOT EXISTS idx_bounces_camp_id ON bounces(campaign_id); 34 | CREATE INDEX IF NOT EXISTS idx_bounces_source ON bounces(source); 35 | `); err != nil { 36 | return err 37 | } 38 | 39 | if _, err := db.Exec(` 40 | INSERT INTO settings (key, value) VALUES 41 | ('app.send_optin_confirmation', 'true'), 42 | ('privacy.domain_blocklist', '[]'), 43 | ('bounce.enabled', 'false'), 44 | ('bounce.webhooks_enabled', 'false'), 45 | ('bounce.count', '2'), 46 | ('bounce.action', '"blocklist"'), 47 | ('bounce.ses_enabled', 'false'), 48 | ('bounce.sendgrid_enabled', 'false'), 49 | ('bounce.sendgrid_key', '""'), 50 | ('bounce.mailboxes', '[{"enabled":false, "type": "pop", "host":"pop.yoursite.com","port":995,"auth_protocol":"userpass","username":"username","password":"password","return_path": "bounce@listmonk.yoursite.com","scan_interval":"15m","tls_enabled":true,"tls_skip_verify":false}]') 51 | ON CONFLICT DO NOTHING;`); err != nil { 52 | return err 53 | } 54 | 55 | if _, err := db.Exec(`ALTER TABLE subscribers DROP COLUMN IF EXISTS campaigns`); err != nil { 56 | return err 57 | } 58 | 59 | if _, err := db.Exec(` 60 | DO $$ 61 | BEGIN 62 | IF NOT EXISTS (SELECT 1 FROM pg_class WHERE relname = 'campaign_views_pkey') THEN 63 | ALTER TABLE campaign_views ADD COLUMN IF NOT EXISTS id BIGSERIAL PRIMARY KEY; 64 | END IF; 65 | IF NOT EXISTS (SELECT 1 FROM pg_class WHERE relname = 'link_clicks_pkey') THEN 66 | ALTER TABLE link_clicks ADD COLUMN IF NOT EXISTS id BIGSERIAL PRIMARY KEY; 67 | END IF; 68 | IF NOT EXISTS (SELECT 1 FROM pg_class WHERE relname = 'campaign_lists_pkey') THEN 69 | ALTER TABLE campaign_lists ADD COLUMN IF NOT EXISTS id BIGSERIAL PRIMARY KEY; 70 | END IF; 71 | END$$; 72 | 73 | CREATE INDEX IF NOT EXISTS idx_views_date ON campaign_views((TIMEZONE('UTC', created_at)::DATE)); 74 | CREATE INDEX IF NOT EXISTS idx_clicks_date ON link_clicks((TIMEZONE('UTC', created_at)::DATE)); 75 | `); err != nil { 76 | return err 77 | } 78 | 79 | // S3 URL i snow a settings field. Prepare S3 URL based on region and bucket. 80 | if _, err := db.Exec(` 81 | WITH region AS ( 82 | SELECT value#>>'{}' AS value FROM settings WHERE key='upload.s3.aws_default_region' 83 | ), s3url AS ( 84 | SELECT FORMAT('https://s3.%s.amazonaws.com', (SELECT value FROM region)) AS value 85 | ) 86 | 87 | INSERT INTO settings (key, value) VALUES ('upload.s3.url', TO_JSON((SELECT * FROM s3url))) ON CONFLICT DO NOTHING;`); err != nil { 88 | return err 89 | } 90 | 91 | return nil 92 | } 93 | -------------------------------------------------------------------------------- /docs/docs/content/querying-and-segmentation.md: -------------------------------------------------------------------------------- 1 | # Querying and segmenting subscribers 2 | 3 | listmonk allows the writing of partial Postgres SQL expressions to query, filter, and segment subscribers. 4 | 5 | ## Database fields 6 | 7 | These are the fields in the subscriber database that can be queried. 8 | 9 | | Field | Description | 10 | | ------------------------ | --------------------------------------------------------------------------------------------------- | 11 | | `subscribers.uuid` | The randomly generated unique ID of the subscriber | 12 | | `subscribers.email` | E-mail ID of the subscriber | 13 | | `subscribers.name` | Name of the subscriber | 14 | | `subscribers.status` | Status of the subscriber (enabled, disabled, blocklisted) | 15 | | `subscribers.attribs` | Map of arbitrary attributes represented as JSON. Accessed via the `->` and `->>` Postgres operator. | 16 | | `subscribers.created_at` | Timestamp when the subscriber was first added | 17 | | `subscribers.updated_at` | Timestamp when the subscriber was modified | 18 | 19 | ## Sample attributes 20 | 21 | Here's a sample JSON map of attributes assigned to an imaginary subscriber. 22 | 23 | ```json 24 | { 25 | "city": "Bengaluru", 26 | "likes_tea": true, 27 | "spoken_languages": ["English", "Malayalam"], 28 | "projects": 3, 29 | "stack": { 30 | "frameworks": ["echo", "go"], 31 | "languages": ["go", "python"], 32 | "preferred_language": "go" 33 | } 34 | } 35 | ``` 36 | 37 | ![listmonk screenshot](images/edit-subscriber.png) 38 | 39 | ## Sample SQL query expressions 40 | 41 | ![listmonk](images/query-subscribers.png) 42 | 43 | #### Find a subscriber by e-mail 44 | 45 | ```sql 46 | -- Exact match 47 | subscribers.email = 'some@domain.com' 48 | 49 | -- Partial match to find e-mails that end in @domain.com. 50 | subscribers.email LIKE '%@domain.com' 51 | 52 | ``` 53 | 54 | #### Find a subscriber by name 55 | 56 | ```sql 57 | -- Find all subscribers whose name start with John. 58 | subscribers.email LIKE 'John%' 59 | 60 | ``` 61 | 62 | #### Multiple conditions 63 | 64 | ```sql 65 | -- Find all Johns who have been blocklisted. 66 | subscribers.email LIKE 'John%' AND status = 'blocklisted' 67 | ``` 68 | 69 | #### Querying attributes 70 | 71 | ```sql 72 | -- The ->> operator returns the value as text. Find all subscribers 73 | -- who live in Bengaluru and have done more than 3 projects. 74 | -- Here 'projects' is cast into an integer so that we can apply the 75 | -- numerical operator > 76 | subscribers.attribs->>'city' = 'Bengaluru' AND 77 | (subscribers.attribs->>'projects')::INT > 3 78 | ``` 79 | 80 | #### Querying nested attributes 81 | 82 | ```sql 83 | -- Find all blocklisted subscribers who like to drink tea, can code Python 84 | -- and prefer coding Go. 85 | -- 86 | -- The -> operator returns the value as a structure. Here, the "languages" field 87 | -- The ? operator checks for the existence of a value in a list. 88 | subscribers.status = 'blocklisted' AND 89 | (subscribers.attribs->>'likes_tea')::BOOLEAN = true AND 90 | subscribers.attribs->'stack'->'languages' ? 'python' AND 91 | subscribers.attribs->'stack'->>'preferred_language' = 'go' 92 | 93 | ``` 94 | 95 | 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). 96 | --------------------------------------------------------------------------------