├── 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 │ │ │ ├── logo-windows.svg │ │ │ ├── logo.svg │ │ │ └── logo-freebsd.svg │ ├── 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 │ │ ├── apis │ │ │ └── sdks.md │ │ ├── maintenance │ │ │ └── performance.md │ │ ├── developer-setup.md │ │ └── i18n.md │ ├── requirements.txt │ └── 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 │ │ ├── auth │ │ │ ├── oidc.png │ │ │ ├── auth0.com.png │ │ │ ├── github.com.png │ │ │ ├── google.com.png │ │ │ └── microsoftonline.com.png │ │ ├── favicon.png │ │ ├── rss.svg │ │ └── logo.svg │ └── templates │ │ ├── home.html │ │ ├── forgot-password.html │ │ ├── message.html │ │ ├── twofa.html │ │ ├── reset-password.html │ │ ├── optin.html │ │ ├── archive.html │ │ ├── login-setup.html │ │ ├── login.html │ │ └── index.html └── email-templates │ ├── smtp-test.html │ ├── subscriber-data.html │ ├── forgot-password.html │ ├── subscriber-optin-campaign.html │ ├── import-status.html │ ├── subscriber-optin.html │ └── campaign-status.html ├── VERSION ├── frontend ├── .browserslistrc ├── email-builder │ ├── src │ │ ├── vite-env.d.ts │ │ ├── favicon │ │ │ ├── favicon.ico │ │ │ ├── favicon-16x16.png │ │ │ ├── favicon-32x32.png │ │ │ ├── apple-touch-icon.png │ │ │ ├── android-chrome-192x192.png │ │ │ └── android-chrome-512x512.png │ │ ├── documents │ │ │ ├── blocks │ │ │ │ ├── helpers │ │ │ │ │ ├── TStyle.ts │ │ │ │ │ ├── zod.ts │ │ │ │ │ ├── block-wrappers │ │ │ │ │ │ ├── ReaderBlockWrapper.tsx │ │ │ │ │ │ └── EditorBlockWrapper.tsx │ │ │ │ │ ├── EditorChildrenIds │ │ │ │ │ │ ├── AddBlockMenu │ │ │ │ │ │ │ ├── PlaceholderButton.tsx │ │ │ │ │ │ │ ├── BlockButton.tsx │ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ │ ├── BlocksMenu.tsx │ │ │ │ │ │ │ └── DividerButton.tsx │ │ │ │ │ │ └── index.tsx │ │ │ │ │ └── fontFamily.ts │ │ │ │ ├── Container │ │ │ │ │ ├── ContainerPropsSchema.tsx │ │ │ │ │ └── ContainerEditor.tsx │ │ │ │ ├── ColumnsContainer │ │ │ │ │ ├── ColumnsContainerPropsSchema.ts │ │ │ │ │ └── ColumnsContainerEditor.tsx │ │ │ │ └── EmailLayout │ │ │ │ │ └── EmailLayoutPropsSchema.tsx │ │ │ └── editor │ │ │ │ └── EditorBlock.tsx │ │ ├── App │ │ │ ├── TemplatePanel │ │ │ │ ├── JsonPanel.tsx │ │ │ │ ├── HtmlPanel.tsx │ │ │ │ ├── DownloadJson │ │ │ │ │ └── index.tsx │ │ │ │ ├── ImportJson │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── validateJsonStringValue.ts │ │ │ │ ├── helper │ │ │ │ │ ├── HighlightedCodePanel.tsx │ │ │ │ │ └── highlighters.tsx │ │ │ │ ├── ShareButton.tsx │ │ │ │ └── MainTabsGroup.tsx │ │ │ └── InspectorDrawer │ │ │ │ ├── ConfigurationPanel │ │ │ │ └── input-panels │ │ │ │ │ ├── helpers │ │ │ │ │ ├── BaseSidebarPanel.tsx │ │ │ │ │ ├── inputs │ │ │ │ │ │ ├── ColorInput │ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ │ ├── Swatch.tsx │ │ │ │ │ │ │ └── Picker.tsx │ │ │ │ │ │ ├── BooleanInput.tsx │ │ │ │ │ │ ├── FontWeightInput.tsx │ │ │ │ │ │ ├── SliderInput.tsx │ │ │ │ │ │ ├── FontSizeInput.tsx │ │ │ │ │ │ ├── TextDimensionInput.tsx │ │ │ │ │ │ ├── RadioGroupInput.tsx │ │ │ │ │ │ ├── FontFamily.tsx │ │ │ │ │ │ ├── TextInput.tsx │ │ │ │ │ │ ├── TextAlignInput.tsx │ │ │ │ │ │ ├── raw │ │ │ │ │ │ │ └── RawSliderInput.tsx │ │ │ │ │ │ └── ColumnWidthsInput.tsx │ │ │ │ │ └── style-inputs │ │ │ │ │ │ └── MultiStylePropertyPanel.tsx │ │ │ │ │ ├── ContainerSidebarPanel.tsx │ │ │ │ │ ├── SpacerSidebarPanel.tsx │ │ │ │ │ ├── HtmlSidebarPanel.tsx │ │ │ │ │ ├── TextSidebarPanel.tsx │ │ │ │ │ ├── DividerSidebarPanel.tsx │ │ │ │ │ └── HeadingSidebarPanel.tsx │ │ │ │ ├── StylesPanel.tsx │ │ │ │ ├── ToggleInspectorPanelButton.tsx │ │ │ │ └── index.tsx │ │ ├── getConfiguration │ │ │ ├── sample │ │ │ │ └── empty-email-message.ts │ │ │ └── index.tsx │ │ └── main.tsx │ ├── README.md │ ├── tsconfig.json │ ├── vite.config.ts │ ├── LICENSE │ └── package.json ├── .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 │ ├── components │ │ ├── EmptyPlaceholder.vue │ │ ├── CopyText.vue │ │ ├── BarChart.vue │ │ ├── LogView.vue │ │ └── editor.js │ └── constants.js ├── public │ └── static │ │ └── favicon.png ├── babel.config.js ├── cypress │ ├── support │ │ ├── reset.sh │ │ └── e2e.js │ ├── fixtures │ │ └── subs-domain-blocklist.csv │ ├── plugins │ │ └── index.js │ └── e2e │ │ ├── dashboard.cy.js │ │ └── settings.cy.js ├── jsconfig.json ├── .editorconfig ├── .gitignore ├── index.html ├── .eslintrc.js ├── cypress.config.js └── vite.config.js ├── .gitattributes ├── .devcontainer └── devcontainer.json ├── .dockerignore ├── .gitignore ├── .github ├── ISSUE_TEMPLATE │ ├── confirmed-bug.md │ ├── feature-or-change-request.md │ ├── general-question.md │ └── possible-bug--needs-investigation-.md └── workflows │ ├── build-sanity.yml │ ├── issues.yml │ ├── release.yml │ └── github-pages.yml ├── internal ├── migrations │ ├── v4.1.0.go │ ├── 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 │ ├── v5.2.0.go │ └── v2.5.0.go ├── bounce │ └── mailbox │ │ └── opt.go ├── media │ ├── media.go │ └── providers │ │ └── filesystem │ │ └── filesystem.go ├── core │ ├── dashboard.go │ └── settings.go ├── buflog │ └── buflog.go └── utils │ └── utils.go ├── queries ├── links.sql ├── media.sql ├── misc.sql ├── templates.sql └── roles.sql ├── Dockerfile ├── scripts ├── refresh-i18n.sh └── translate-i18n.py ├── config.toml.sample ├── project.inlang.json ├── models ├── bounces.go └── lists.go ├── cmd └── events.go ├── permissions.json └── README.md /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 | e215e1e5b 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 |
-------------------------------------------------------------------------------- /frontend/email-builder/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /.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/knadh/listmonk/HEAD/frontend/src/assets/logo.png -------------------------------------------------------------------------------- /static/public/static/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/knadh/listmonk/HEAD/static/public/static/logo.png -------------------------------------------------------------------------------- /frontend/src/assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/knadh/listmonk/HEAD/frontend/src/assets/favicon.png -------------------------------------------------------------------------------- /frontend/public/static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/knadh/listmonk/HEAD/frontend/public/static/favicon.png -------------------------------------------------------------------------------- /static/public/static/auth/oidc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/knadh/listmonk/HEAD/static/public/static/auth/oidc.png -------------------------------------------------------------------------------- /static/public/static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/knadh/listmonk/HEAD/static/public/static/favicon.png -------------------------------------------------------------------------------- /docs/docs/content/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/knadh/listmonk/HEAD/docs/docs/content/images/favicon.png -------------------------------------------------------------------------------- /docs/docs/content/images/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/knadh/listmonk/HEAD/docs/docs/content/images/splash.png -------------------------------------------------------------------------------- /frontend/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/cli-plugin-babel/preset', 4 | ], 5 | }; 6 | -------------------------------------------------------------------------------- /docs/site/static/static/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/knadh/listmonk/HEAD/docs/site/static/static/images/logo.png -------------------------------------------------------------------------------- /docs/site/static/static/images/s1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/knadh/listmonk/HEAD/docs/site/static/static/images/s1.png -------------------------------------------------------------------------------- /docs/site/static/static/images/s2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/knadh/listmonk/HEAD/docs/site/static/static/images/s2.png -------------------------------------------------------------------------------- /docs/site/static/static/images/s3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/knadh/listmonk/HEAD/docs/site/static/static/images/s3.png -------------------------------------------------------------------------------- /docs/site/static/static/images/s4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/knadh/listmonk/HEAD/docs/site/static/static/images/s4.png -------------------------------------------------------------------------------- /docs/site/static/static/images/smtp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/knadh/listmonk/HEAD/docs/site/static/static/images/smtp.png -------------------------------------------------------------------------------- /docs/site/static/static/images/tx.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/knadh/listmonk/HEAD/docs/site/static/static/images/tx.png -------------------------------------------------------------------------------- /static/public/static/auth/auth0.com.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/knadh/listmonk/HEAD/static/public/static/auth/auth0.com.png -------------------------------------------------------------------------------- /docs/site/static/static/images/lists.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/knadh/listmonk/HEAD/docs/site/static/static/images/lists.png -------------------------------------------------------------------------------- /docs/site/static/static/images/media.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/knadh/listmonk/HEAD/docs/site/static/static/images/media.png -------------------------------------------------------------------------------- /docs/site/static/static/images/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/knadh/listmonk/HEAD/docs/site/static/static/images/splash.png -------------------------------------------------------------------------------- /frontend/src/assets/icons/fontello.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/knadh/listmonk/HEAD/frontend/src/assets/icons/fontello.woff2 -------------------------------------------------------------------------------- /static/public/static/auth/github.com.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/knadh/listmonk/HEAD/static/public/static/auth/github.com.png -------------------------------------------------------------------------------- /static/public/static/auth/google.com.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/knadh/listmonk/HEAD/static/public/static/auth/google.com.png -------------------------------------------------------------------------------- /docs/docs/content/images/edit-subscriber.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/knadh/listmonk/HEAD/docs/docs/content/images/edit-subscriber.png -------------------------------------------------------------------------------- /docs/site/static/static/images/analytics.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/knadh/listmonk/HEAD/docs/site/static/static/images/analytics.png -------------------------------------------------------------------------------- /docs/site/static/static/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/knadh/listmonk/HEAD/docs/site/static/static/images/favicon.png -------------------------------------------------------------------------------- /docs/site/static/static/images/privacy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/knadh/listmonk/HEAD/docs/site/static/static/images/privacy.png -------------------------------------------------------------------------------- /docs/site/static/static/images/thumbnail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/knadh/listmonk/HEAD/docs/site/static/static/images/thumbnail.png -------------------------------------------------------------------------------- /frontend/src/assets/fonts/Inter-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/knadh/listmonk/HEAD/frontend/src/assets/fonts/Inter-Bold.woff2 -------------------------------------------------------------------------------- /frontend/src/views/About.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /docs/docs/content/images/2021-09-28_00-18.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/knadh/listmonk/HEAD/docs/docs/content/images/2021-09-28_00-18.png -------------------------------------------------------------------------------- /docs/docs/content/images/query-subscribers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/knadh/listmonk/HEAD/docs/docs/content/images/query-subscribers.png -------------------------------------------------------------------------------- /docs/docs/requirements.txt: -------------------------------------------------------------------------------- 1 | mkdocs>=1.6.1 2 | mkdocs-material>=9.6.14 3 | mkdocs-material-extensions>=1.3.1 4 | pymdown-extensions>=10.15 5 | -------------------------------------------------------------------------------- /docs/site/layouts/shortcodes/half.html: -------------------------------------------------------------------------------- 1 |
2 |
{{ .Inner }}
3 |
4 |
-------------------------------------------------------------------------------- /docs/site/static/static/images/messengers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/knadh/listmonk/HEAD/docs/site/static/static/images/messengers.png -------------------------------------------------------------------------------- /docs/site/static/static/images/performance.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/knadh/listmonk/HEAD/docs/site/static/static/images/performance.png -------------------------------------------------------------------------------- /docs/site/static/static/images/templating.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/knadh/listmonk/HEAD/docs/site/static/static/images/templating.png -------------------------------------------------------------------------------- /frontend/email-builder/src/favicon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/knadh/listmonk/HEAD/frontend/email-builder/src/favicon/favicon.ico -------------------------------------------------------------------------------- /frontend/src/assets/fonts/Inter-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/knadh/listmonk/HEAD/frontend/src/assets/fonts/Inter-Regular.woff2 -------------------------------------------------------------------------------- /static/public/static/auth/microsoftonline.com.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/knadh/listmonk/HEAD/static/public/static/auth/microsoftonline.com.png -------------------------------------------------------------------------------- /frontend/email-builder/src/favicon/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/knadh/listmonk/HEAD/frontend/email-builder/src/favicon/favicon-16x16.png -------------------------------------------------------------------------------- /frontend/email-builder/src/favicon/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/knadh/listmonk/HEAD/frontend/email-builder/src/favicon/favicon-32x32.png -------------------------------------------------------------------------------- /docs/docs/content/images/archived-campaign-metadata.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/knadh/listmonk/HEAD/docs/docs/content/images/archived-campaign-metadata.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 | -------------------------------------------------------------------------------- /frontend/email-builder/src/favicon/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/knadh/listmonk/HEAD/frontend/email-builder/src/favicon/apple-touch-icon.png -------------------------------------------------------------------------------- /frontend/email-builder/src/favicon/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/knadh/listmonk/HEAD/frontend/email-builder/src/favicon/android-chrome-192x192.png -------------------------------------------------------------------------------- /frontend/email-builder/src/favicon/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/knadh/listmonk/HEAD/frontend/email-builder/src/favicon/android-chrome-512x512.png -------------------------------------------------------------------------------- /frontend/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "paths": { 4 | "@/*": ["./src/*"] 5 | } 6 | }, 7 | "exclude": ["node_modules", "dist"] 8 | } 9 | -------------------------------------------------------------------------------- /docs/site/layouts/page/single.html: -------------------------------------------------------------------------------- 1 | {{ partial "header" . }} 2 |
3 |

{{ .Title }}

4 | {{ .Content }} 5 |
6 | {{ partial "footer" }} -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /dev/app.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.20 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 | CMD [ "sleep infinity" ] 12 | -------------------------------------------------------------------------------- /frontend/cypress/support/e2e.js: -------------------------------------------------------------------------------- 1 | import './commands'; 2 | 3 | beforeEach(() => { 4 | cy.intercept('GET', '/sockjs-node/**', (req) => { 5 | req.destroy(); 6 | }); 7 | 8 | cy.intercept('GET', '/api/health', (req) => { 9 | req.reply({}); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /frontend/src/views/404.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 15 | -------------------------------------------------------------------------------- /docs/site/layouts/partials/footer.html: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "listmonk", 3 | "dockerComposeFile": "../dev/docker-compose.yml", 4 | "service": "backend", 5 | "workspaceFolder": "/app", 6 | "forwardPorts": [9000], 7 | "postStartCommand": "make dist && ./listmonk --install --idempotent --yes --config dev/config.toml" 8 | } 9 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /frontend/email-builder/README.md: -------------------------------------------------------------------------------- 1 | # @usewaypoint/editor-sample 2 | 3 | Use this as a sample to self-host EmailBuilder.js. 4 | 5 | To run this locally, fork the repository and then in this directory run: 6 | 7 | - `npm install` 8 | - `npx vite` 9 | 10 | Once the server is running, open http://localhost:5173/email-builder-js/ in your browser. 11 | -------------------------------------------------------------------------------- /frontend/email-builder/src/documents/blocks/helpers/TStyle.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | 3 | export type TStyle = { 4 | backgroundColor?: any; 5 | borderColor?: any; 6 | borderRadius?: any; 7 | color?: any; 8 | fontFamily?: any; 9 | fontSize?: any; 10 | fontWeight?: any; 11 | padding?: any; 12 | textAlign?: any; 13 | }; 14 | -------------------------------------------------------------------------------- /static/email-templates/forgot-password.html: -------------------------------------------------------------------------------- 1 | {{ define "forgot-password" }} 2 | {{ template "header" . }} 3 | 4 |

{{ L.T "email.forgotPassword.subject" }}

5 | 6 |

7 | {{ L.T "email.forgotPassword.button" }} 8 |

9 |

{{ L.T "email.forgotPassword.info" }}

10 | 11 | {{ template "footer" }} 12 | {{ end }} 13 | -------------------------------------------------------------------------------- /.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 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | frontend/node_modules/ 2 | frontend/.cache/ 3 | frontend/yarn.lock 4 | frontend/build/ 5 | frontend/public/static/email-builder/ 6 | frontend/dist/ 7 | frontend/email-builder/dist/ 8 | email-builder/node_modules/ 9 | email-builder/.cache/ 10 | email-builder/yarn.lock 11 | email-builder/dist/ 12 | static/public/static/altcha.umd.js 13 | .vscode/ 14 | 15 | config.toml 16 | node_modules 17 | listmonk 18 | dist/* 19 | uploads/ 20 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /frontend/email-builder/src/App/TemplatePanel/JsonPanel.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react'; 2 | 3 | import { useDocument } from '../../documents/editor/EditorContext'; 4 | 5 | import HighlightedCodePanel from './helper/HighlightedCodePanel'; 6 | 7 | export default function JsonPanel() { 8 | const document = useDocument(); 9 | const code = useMemo(() => JSON.stringify(document, null, ' '), [document]); 10 | return ; 11 | } 12 | -------------------------------------------------------------------------------- /.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/workflows/build-sanity.yml: -------------------------------------------------------------------------------- 1 | name: Build Sanity Check 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - opened 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout Code 14 | uses: actions/checkout@v4 15 | 16 | - name: Set up Go 17 | uses: actions/setup-go@v5 18 | with: 19 | go-version: "1.24.1" 20 | 21 | - name: Prepare Dependencies and Build 22 | run: make dist 23 | -------------------------------------------------------------------------------- /frontend/email-builder/src/getConfiguration/sample/empty-email-message.ts: -------------------------------------------------------------------------------- 1 | import { TEditorConfiguration } from '../../documents/editor/core'; 2 | 3 | const EMPTY_EMAIL_MESSAGE: TEditorConfiguration = { 4 | root: { 5 | type: 'EmailLayout', 6 | data: { 7 | backdropColor: '#F5F5F5', 8 | canvasColor: '#FFFFFF', 9 | textColor: '#262626', 10 | fontFamily: 'MODERN_SANS', 11 | childrenIds: [], 12 | }, 13 | }, 14 | }; 15 | 16 | export default EMPTY_EMAIL_MESSAGE; 17 | -------------------------------------------------------------------------------- /.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/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: 'question' 6 | assignees: '' 7 | 8 | --- 9 | 10 | Note: Please refrain from posting questions about Docker and docker-compose related matters. Please search and refer to the numerous closed issues on these topics. Docker related questions are outside of the purview of this forum and will be closed. Thank you for your understanding. 11 | -------------------------------------------------------------------------------- /.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 }} -------------------------------------------------------------------------------- /frontend/email-builder/src/App/TemplatePanel/HtmlPanel.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react'; 2 | 3 | import { renderToStaticMarkup } from '@usewaypoint/email-builder'; 4 | 5 | import { useDocument } from '../../documents/editor/EditorContext'; 6 | 7 | import HighlightedCodePanel from './helper/HighlightedCodePanel'; 8 | 9 | export default function HtmlPanel() { 10 | const document = useDocument(); 11 | const code = useMemo(() => renderToStaticMarkup(document, { rootBlockId: 'root' }), [document]); 12 | return ; 13 | } 14 | -------------------------------------------------------------------------------- /frontend/email-builder/src/getConfiguration/index.tsx: -------------------------------------------------------------------------------- 1 | import EMPTY_EMAIL_MESSAGE from './sample/empty-email-message'; 2 | 3 | export default function getConfiguration(template: string) { 4 | if (template.startsWith('#code/')) { 5 | const encodedString = template.replace('#code/', ''); 6 | const configurationString = decodeURIComponent(atob(encodedString)); 7 | try { 8 | return JSON.parse(configurationString); 9 | } catch { 10 | console.error(`Couldn't load configuration from hash.`); 11 | } 12 | } 13 | 14 | return EMPTY_EMAIL_MESSAGE; 15 | } 16 | -------------------------------------------------------------------------------- /frontend/src/components/EmptyPlaceholder.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 22 | -------------------------------------------------------------------------------- /frontend/email-builder/src/documents/blocks/Container/ContainerPropsSchema.tsx: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | import { ContainerPropsSchema as BaseContainerPropsSchema } from '@usewaypoint/block-container'; 4 | 5 | const ContainerPropsSchema = z.object({ 6 | style: BaseContainerPropsSchema.shape.style, 7 | props: z 8 | .object({ 9 | childrenIds: z.array(z.string()).optional().nullable(), 10 | }) 11 | .optional() 12 | .nullable(), 13 | }); 14 | 15 | export default ContainerPropsSchema; 16 | 17 | export type ContainerProps = z.infer; 18 | -------------------------------------------------------------------------------- /internal/migrations/v4.1.0.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/jmoiron/sqlx" 7 | "github.com/knadh/koanf/v2" 8 | "github.com/knadh/stuffbin" 9 | ) 10 | 11 | // V4_1_0 performs the DB migrations. 12 | func V4_1_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error { 13 | // Insert new preference settings. 14 | if _, err := db.Exec(` 15 | INSERT INTO settings (key, value) VALUES('bounce.forwardemail', '{"enabled": false, "key": ""}') ON CONFLICT DO NOTHING; 16 | `); err != nil { 17 | return err 18 | } 19 | 20 | return nil 21 | } 22 | -------------------------------------------------------------------------------- /queries/links.sql: -------------------------------------------------------------------------------- 1 | -- links 2 | -- name: create-link 3 | INSERT INTO links (uuid, url) VALUES($1, $2) ON CONFLICT (url) DO UPDATE SET url=EXCLUDED.url RETURNING uuid; 4 | 5 | -- name: register-link-click 6 | WITH link AS( 7 | SELECT id, url FROM links WHERE uuid = $1 8 | ) 9 | INSERT INTO link_clicks (campaign_id, subscriber_id, link_id) VALUES( 10 | (SELECT id FROM campaigns WHERE uuid = $2), 11 | (SELECT id FROM subscribers WHERE 12 | (CASE WHEN $3::TEXT != '' THEN subscribers.uuid = $3::UUID ELSE FALSE END) 13 | ), 14 | (SELECT id FROM link) 15 | ) RETURNING (SELECT url FROM link); 16 | -------------------------------------------------------------------------------- /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 |
    6 | {{ range $i, $l := .Lists }} 7 | {{ if eq .Type "public" }} 8 |
  • {{ .Name }}
  • 9 | {{ else }} 10 |
  • {{ L.Ts "email.optin.privateList" }}
  • 11 | {{ end }} 12 | {{ end }} 13 |
14 |

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

17 | {{ end }} 18 | -------------------------------------------------------------------------------- /frontend/email-builder/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2015", 4 | "module": "esnext", 5 | "outDir": "dist", 6 | "lib": [], 7 | "moduleResolution": "node", 8 | "jsx": "react", 9 | "strict": true, 10 | "sourceMap": true, 11 | "allowJs": true, 12 | "esModuleInterop": true, 13 | "skipLibCheck": true, 14 | "declarationMap": true, 15 | "declaration": true, 16 | "noUnusedLocals": true, 17 | "noImplicitReturns": true, 18 | "noFallthroughCasesInSwitch": true, 19 | "allowSyntheticDefaultImports": true 20 | }, 21 | "exclude": ["dist"] 22 | } 23 | -------------------------------------------------------------------------------- /docs/site/layouts/shortcodes/github.html: -------------------------------------------------------------------------------- 1 |
    2 | {{ range .Page.Site.Data.github }} 3 |
  • 4 |
    5 | {{ dateFormat "Jan 2006" (substr .updated_at 0 10) }} 6 |
    7 |
    8 | {{ .name }} 9 |
    10 |
    11 | {{ .description }} 12 |
    13 |
    14 |
  • 15 | {{ end }} 16 |
17 |
-------------------------------------------------------------------------------- /frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/BaseSidebarPanel.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Box, Stack, Typography } from '@mui/material'; 4 | 5 | type SidebarPanelProps = { 6 | title: string; 7 | children: React.ReactNode; 8 | }; 9 | export default function BaseSidebarPanel({ title, children }: SidebarPanelProps) { 10 | return ( 11 | 12 | 13 | {title} 14 | 15 | 16 | {children} 17 | 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:latest 2 | 3 | # Install dependencies 4 | RUN apk --no-cache add ca-certificates tzdata shadow su-exec 5 | 6 | # Set the working directory 7 | WORKDIR /listmonk 8 | 9 | # Copy only the necessary files 10 | COPY listmonk . 11 | COPY config.toml.sample config.toml 12 | 13 | # Copy the entrypoint script 14 | COPY docker-entrypoint.sh /usr/local/bin/ 15 | 16 | # Make the entrypoint script executable 17 | RUN chmod +x /usr/local/bin/docker-entrypoint.sh 18 | 19 | # Expose the application port 20 | EXPOSE 9000 21 | 22 | # Set the entrypoint 23 | ENTRYPOINT ["docker-entrypoint.sh"] 24 | 25 | # Define the command to run the application 26 | CMD ["./listmonk"] 27 | -------------------------------------------------------------------------------- /internal/migrations/v2.4.0.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/jmoiron/sqlx" 7 | "github.com/knadh/koanf/v2" 8 | "github.com/knadh/stuffbin" 9 | ) 10 | 11 | // V2_4_0 performs the DB migrations. 12 | func V2_4_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error { 13 | // Insert new preference settings. 14 | if _, err := db.Exec(` 15 | INSERT INTO settings (key, value) VALUES 16 | ('security.enable_captcha', 'false'), 17 | ('security.captcha_key', '""'), 18 | ('security.captcha_secret', '""') 19 | ON CONFLICT DO NOTHING; 20 | `); err != nil { 21 | return err 22 | } 23 | 24 | return nil 25 | } 26 | -------------------------------------------------------------------------------- /internal/migrations/v1.0.0.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/jmoiron/sqlx" 7 | "github.com/knadh/koanf/v2" 8 | "github.com/knadh/stuffbin" 9 | ) 10 | 11 | // V1_0_0 performs the DB migrations for v.1.0.0. 12 | func V1_0_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error { 13 | if _, err := db.Exec(`ALTER TYPE content_type ADD VALUE IF NOT EXISTS 'markdown'`); err != nil { 14 | return err 15 | } 16 | 17 | if _, err := db.Exec(` 18 | INSERT INTO settings (key, value) VALUES 19 | ('app.check_updates', 'true') 20 | ON CONFLICT DO NOTHING; 21 | `); err != nil { 22 | return err 23 | } 24 | 25 | return nil 26 | } 27 | -------------------------------------------------------------------------------- /frontend/email-builder/src/documents/blocks/helpers/zod.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | import { FONT_FAMILY_NAMES } from './fontFamily'; 4 | 5 | export function zColor() { 6 | return z.string().regex(/^#[0-9a-fA-F]{6}$/); 7 | } 8 | 9 | export function zFontFamily() { 10 | return z.enum(FONT_FAMILY_NAMES); 11 | } 12 | 13 | export function zFontWeight() { 14 | return z.enum(['bold', 'normal']); 15 | } 16 | 17 | export function zTextAlign() { 18 | return z.enum(['left', 'center', 'right']); 19 | } 20 | 21 | export function zPadding() { 22 | return z.object({ 23 | top: z.number(), 24 | bottom: z.number(), 25 | right: z.number(), 26 | left: z.number(), 27 | }); 28 | } 29 | -------------------------------------------------------------------------------- /frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/inputs/ColorInput/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import BaseColorInput from './BaseColorInput'; 4 | 5 | type Props = { 6 | label: string; 7 | onChange: (value: string) => void; 8 | defaultValue: string; 9 | }; 10 | export default function ColorInput(props: Props) { 11 | return ; 12 | } 13 | 14 | type NullableProps = { 15 | label: string; 16 | onChange: (value: null | string) => void; 17 | defaultValue: null | string; 18 | }; 19 | export function NullableColorInput(props: NullableProps) { 20 | return ; 21 | } 22 | -------------------------------------------------------------------------------- /queries/media.sql: -------------------------------------------------------------------------------- 1 | -- media 2 | -- name: insert-media 3 | INSERT INTO media (uuid, filename, thumb, content_type, provider, meta, created_at) VALUES($1, $2, $3, $4, $5, $6, NOW()) RETURNING id; 4 | 5 | -- name: query-media 6 | SELECT COUNT(*) OVER () AS total, * FROM media 7 | WHERE ($1 = '' OR filename ILIKE $1) AND provider=$2 ORDER BY created_at DESC OFFSET $3 LIMIT $4; 8 | 9 | -- name: get-media 10 | SELECT * FROM media WHERE 11 | CASE 12 | WHEN $1 > 0 THEN id = $1 13 | WHEN $2 != '' THEN uuid = $2::UUID 14 | WHEN $3 != '' THEN filename = $3 15 | ELSE false 16 | END; 17 | 18 | -- name: delete-media 19 | DELETE FROM media WHERE id=$1 RETURNING filename; 20 | 21 | -------------------------------------------------------------------------------- /frontend/email-builder/src/App/InspectorDrawer/StylesPanel.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { setDocument, useDocument } from '../../documents/editor/EditorContext'; 4 | 5 | import EmailLayoutSidebarPanel from './ConfigurationPanel/input-panels/EmailLayoutSidebarPanel'; 6 | 7 | export default function StylesPanel() { 8 | const block = useDocument().root; 9 | if (!block) { 10 | return

Block not found

; 11 | } 12 | 13 | const { data, type } = block; 14 | if (type !== 'EmailLayout') { 15 | throw new Error('Expected "root" element to be of type EmailLayout'); 16 | } 17 | 18 | return setDocument({ root: { type, data } })} />; 19 | } 20 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /scripts/refresh-i18n.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # "Refresh" all i18n language files by merging and syncing keys with the base file. 4 | BASE_DIR=$(dirname "$0")"/../i18n" # Exclude the trailing slash. 5 | BASE_FILE="en.json" 6 | 7 | # Iterate through all i18n files and sync them with the base file. 8 | for fpath in "$BASE_DIR/"*.json; do 9 | if [ "$(basename -- "$fpath")" = "$BASE_FILE" ]; then 10 | continue # Skip the base file itself 11 | fi 12 | echo "$(basename -- "$fpath")" 13 | jq -s --indent 4 --sort-keys \ 14 | '.[0] as $base | .[1] as $target | 15 | $base | with_entries(.value = ($target[.key] // .value))' \ 16 | "$BASE_DIR/$BASE_FILE" "$fpath" > "$fpath.tmp" && mv "$fpath.tmp" "$fpath" 17 | done 18 | -------------------------------------------------------------------------------- /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 one-way 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.md). The backend is written in Go and the frontend is Vue with Buefy for UI. 11 | -------------------------------------------------------------------------------- /frontend/email-builder/src/App/TemplatePanel/DownloadJson/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react'; 2 | 3 | import { FileDownloadOutlined } from '@mui/icons-material'; 4 | import { IconButton, Tooltip } from '@mui/material'; 5 | 6 | import { useDocument } from '../../../documents/editor/EditorContext'; 7 | 8 | export default function DownloadJson() { 9 | const doc = useDocument(); 10 | const href = useMemo(() => { 11 | return `data:text/plain,${encodeURIComponent(JSON.stringify(doc, null, ' '))}`; 12 | }, [doc]); 13 | return ( 14 | 15 | 16 | 17 | 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /frontend/email-builder/src/App/TemplatePanel/ImportJson/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | 3 | import { FileUploadOutlined } from '@mui/icons-material'; 4 | import { IconButton, Tooltip } from '@mui/material'; 5 | 6 | import ImportJsonDialog from './ImportJsonDialog'; 7 | 8 | export default function ImportJson() { 9 | const [open, setOpen] = useState(false); 10 | 11 | let dialog = null; 12 | if (open) { 13 | dialog = setOpen(false)} />; 14 | } 15 | 16 | return ( 17 | <> 18 | 19 | setOpen(true)}> 20 | 21 | 22 | 23 | {dialog} 24 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/style-inputs/MultiStylePropertyPanel.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { TStyle } from '../../../../../../documents/blocks/helpers/TStyle'; 4 | 5 | import SingleStylePropertyPanel from './SingleStylePropertyPanel'; 6 | 7 | type MultiStylePropertyPanelProps = { 8 | names: (keyof TStyle)[]; 9 | value: TStyle | undefined | null; 10 | onChange: (style: TStyle) => void; 11 | }; 12 | export default function MultiStylePropertyPanel({ names, value, onChange }: MultiStylePropertyPanelProps) { 13 | return ( 14 | <> 15 | {names.map((name) => ( 16 | 17 | ))} 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /static/public/templates/forgot-password.html: -------------------------------------------------------------------------------- 1 | {{ define "admin-forgot-password" }} 2 | {{ template "header" .}} 3 | 4 | 21 | 22 | {{ template "footer" .}} 23 | {{ end }} 24 | -------------------------------------------------------------------------------- /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 | # Database. 9 | [db] 10 | host = "localhost" 11 | port = 5432 12 | user = "listmonk" 13 | password = "listmonk" 14 | 15 | # Ensure that this database has been created in Postgres. 16 | database = "listmonk" 17 | 18 | ssl_mode = "disable" 19 | max_open = 25 20 | max_idle = 25 21 | max_lifetime = "300s" 22 | 23 | # Optional space separated Postgres DSN params. eg: "application_name=listmonk gssencmode=disable" 24 | params = "" 25 | -------------------------------------------------------------------------------- /dev/config.toml: -------------------------------------------------------------------------------- 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 = "0.0.0.0:9000" 7 | 8 | # Database. 9 | [db] 10 | host = "db" 11 | port = 5432 12 | user = "listmonk-dev" 13 | password = "listmonk-dev" 14 | 15 | # Ensure that this database has been created in Postgres. 16 | database = "listmonk-dev" 17 | 18 | ssl_mode = "disable" 19 | max_open = 25 20 | max_idle = 25 21 | max_lifetime = "300s" 22 | 23 | # Optional space separated Postgres DSN params. eg: "application_name=listmonk gssencmode=disable" 24 | params = "" 25 | -------------------------------------------------------------------------------- /.github/workflows/issues.yml: -------------------------------------------------------------------------------- 1 | name: "close-stale-issues-and-prs" 2 | on: 3 | schedule: 4 | - cron: "30 1 * * *" 5 | workflow_dispatch: 6 | 7 | jobs: 8 | stale: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/stale@v9 12 | with: 13 | days-before-stale: 90 14 | stale-issue-label: "stale" 15 | stale-pr-label: "stale" 16 | debug-only: false 17 | exempt-all-assignees: true 18 | operations-per-run: 1000 19 | stale-issue-message: "This issue has been marked 'stale' after 90 days of inactivity. If there is no further activity, it will be closed in 7 days." 20 | stale-pr-message: "This PR has been marked 'stale' after 90 days of inactivity. If there is no further activity, it will be closed in 7 days." 21 | -------------------------------------------------------------------------------- /frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/inputs/BooleanInput.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | 3 | import { FormControlLabel, Switch } from '@mui/material'; 4 | 5 | type Props = { 6 | label: string; 7 | defaultValue: boolean; 8 | onChange: (value: boolean) => void; 9 | }; 10 | 11 | export default function BooleanInput({ label, defaultValue, onChange }: Props) { 12 | const [value, setValue] = useState(defaultValue); 13 | return ( 14 | { 20 | setValue(checked); 21 | onChange(checked); 22 | }} 23 | /> 24 | } 25 | /> 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /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 |
    7 | {{ range $i, $l := .Lists }} 8 | {{ if eq .Type "public" }} 9 |
  • {{ .Name }}
  • 10 | {{ else }} 11 |
  • {{ L.Ts "email.optin.privateList" }}
  • 12 | {{ end }} 13 | {{ end }} 14 |
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/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 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | listmonk 11 | 12 | 13 | 16 | 17 |
18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /docs/site/data/github.json: -------------------------------------------------------------------------------- 1 | {"version":"v5.1.0","date":"2025-09-09T17:58:16Z","url":"https://github.com/knadh/listmonk/releases/tag/v5.1.0","assets":[{"name":"darwin","url":"https://github.com/knadh/listmonk/releases/download/v5.1.0/listmonk_5.1.0_darwin_amd64.tar.gz"},{"name":"freebsd","url":"https://github.com/knadh/listmonk/releases/download/v5.1.0/listmonk_5.1.0_freebsd_amd64.tar.gz"},{"name":"linux","url":"https://github.com/knadh/listmonk/releases/download/v5.1.0/listmonk_5.1.0_linux_amd64.tar.gz"},{"name":"netbsd","url":"https://github.com/knadh/listmonk/releases/download/v5.1.0/listmonk_5.1.0_netbsd_amd64.tar.gz"},{"name":"openbsd","url":"https://github.com/knadh/listmonk/releases/download/v5.1.0/listmonk_5.1.0_openbsd_amd64.tar.gz"},{"name":"windows","url":"https://github.com/knadh/listmonk/releases/download/v5.1.0/listmonk_5.1.0_windows_amd64.tar.gz"}]} 2 | -------------------------------------------------------------------------------- /frontend/email-builder/src/App/TemplatePanel/ImportJson/validateJsonStringValue.ts: -------------------------------------------------------------------------------- 1 | import { EditorConfigurationSchema, TEditorConfiguration } from '../../../documents/editor/core'; 2 | 3 | type TResult = { error: string; data?: undefined } | { data: TEditorConfiguration; error?: undefined }; 4 | 5 | export default function validateTextAreaValue(value: string): TResult { 6 | let jsonObject = undefined; 7 | try { 8 | jsonObject = JSON.parse(value); 9 | } catch { 10 | return { error: 'Invalid json' }; 11 | } 12 | 13 | const parseResult = EditorConfigurationSchema.safeParse(jsonObject); 14 | if (!parseResult.success) { 15 | return { error: 'Invalid JSON schema' }; 16 | } 17 | 18 | if (!parseResult.data.root) { 19 | return { error: 'Missing "root" node' }; 20 | } 21 | 22 | return { data: parseResult.data }; 23 | } 24 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /frontend/email-builder/src/documents/blocks/helpers/block-wrappers/ReaderBlockWrapper.tsx: -------------------------------------------------------------------------------- 1 | import React, { CSSProperties } from 'react'; 2 | 3 | import { TStyle } from '../TStyle'; 4 | 5 | type TReaderBlockWrapperProps = { 6 | style: TStyle; 7 | children: JSX.Element; 8 | }; 9 | 10 | export default function ReaderBlockWrapper({ style, children }: TReaderBlockWrapperProps) { 11 | const { padding, borderColor, ...restStyle } = style; 12 | const cssStyle: CSSProperties = { 13 | ...restStyle, 14 | }; 15 | 16 | if (padding) { 17 | const { top, bottom, left, right } = padding; 18 | cssStyle.padding = `${top}px ${right}px ${bottom}px ${left}px`; 19 | } 20 | 21 | if (borderColor) { 22 | cssStyle.border = `1px solid ${borderColor}`; 23 | } 24 | 25 | return
{children}
; 26 | } 27 | -------------------------------------------------------------------------------- /internal/migrations/v0.8.0.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/jmoiron/sqlx" 7 | "github.com/knadh/koanf/v2" 8 | "github.com/knadh/stuffbin" 9 | ) 10 | 11 | // V0_8_0 performs the DB migrations for v.0.8.0. 12 | func V0_8_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error { 13 | _, err := db.Exec(` 14 | INSERT INTO settings (key, value) VALUES ('privacy.individual_tracking', 'false') 15 | ON CONFLICT DO NOTHING; 16 | INSERT INTO settings (key, value) VALUES ('messengers', '[]') 17 | ON CONFLICT DO NOTHING; 18 | 19 | -- Link clicks shouldn't exist if there's no corresponding link. 20 | -- links_clicks.link_id should have been NOT NULL originally. 21 | DELETE FROM link_clicks WHERE link_id is NULL; 22 | ALTER TABLE link_clicks ALTER COLUMN link_id SET NOT NULL; 23 | `) 24 | return err 25 | } 26 | -------------------------------------------------------------------------------- /queries/misc.sql: -------------------------------------------------------------------------------- 1 | -- name: get-dashboard-charts 2 | SELECT data FROM mat_dashboard_charts; 3 | 4 | -- name: get-dashboard-counts 5 | SELECT data FROM mat_dashboard_counts; 6 | 7 | -- name: get-settings 8 | SELECT JSON_OBJECT_AGG(key, value) AS settings FROM (SELECT * FROM settings ORDER BY key) t; 9 | 10 | -- name: update-settings 11 | UPDATE settings AS s SET value = c.value 12 | -- For each key in the incoming JSON map, update the row with the key and its value. 13 | FROM(SELECT * FROM JSONB_EACH($1)) AS c(key, value) WHERE s.key = c.key; 14 | 15 | -- name: update-settings-by-key 16 | UPDATE settings SET value = $2, updated_at = NOW() WHERE key = $1; 17 | 18 | -- name: get-db-info 19 | SELECT JSON_BUILD_OBJECT('version', (SELECT VERSION()), 20 | 'size_mb', (SELECT ROUND(pg_database_size((SELECT CURRENT_DATABASE()))/(1024^2)))) AS info; 21 | -------------------------------------------------------------------------------- /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/email-builder/src/App/InspectorDrawer/ToggleInspectorPanelButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { AppRegistrationOutlined, LastPageOutlined } from '@mui/icons-material'; 4 | import { IconButton } from '@mui/material'; 5 | 6 | import { toggleInspectorDrawerOpen, useInspectorDrawerOpen } from '../../documents/editor/EditorContext'; 7 | 8 | export default function ToggleInspectorPanelButton() { 9 | const inspectorDrawerOpen = useInspectorDrawerOpen(); 10 | 11 | const handleClick = () => { 12 | toggleInspectorDrawerOpen(); 13 | }; 14 | if (inspectorDrawerOpen) { 15 | return ( 16 | 17 | 18 | 19 | ); 20 | } 21 | return ( 22 | 23 | 24 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /frontend/email-builder/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'path'; 2 | import { defineConfig } from 'vite'; 3 | import react from '@vitejs/plugin-react-swc'; 4 | 5 | export default defineConfig({ 6 | plugins: [react()], 7 | define: { 8 | 'process.env.NODE_ENV': '"production"', 9 | }, 10 | build: { 11 | lib: { 12 | entry: resolve(__dirname, 'src/main.tsx'), 13 | name: 'EmailBuilder', 14 | formats: ['umd'], 15 | fileName: (format) => `email-builder.${format}.js`, 16 | }, 17 | minify: 'terser', 18 | cssCodeSplit: true, 19 | cssMinify: true, 20 | 21 | // Option to externalize deps. 22 | // rollupOptions: { 23 | // external: ['react', 'react-dom'], 24 | // output: { 25 | // globals: { 26 | // react: 'React', 27 | // 'react-dom': 'ReactDOM', 28 | // }, 29 | // }, 30 | // } 31 | }, 32 | }); 33 | -------------------------------------------------------------------------------- /frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/inputs/FontWeightInput.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | 3 | import { ToggleButton } from '@mui/material'; 4 | 5 | import RadioGroupInput from './RadioGroupInput'; 6 | 7 | type Props = { 8 | label: string; 9 | defaultValue: string; 10 | onChange: (value: string) => void; 11 | }; 12 | export default function FontWeightInput({ label, defaultValue, onChange }: Props) { 13 | const [value, setValue] = useState(defaultValue); 14 | return ( 15 | { 19 | setValue(fontWeight); 20 | onChange(fontWeight); 21 | }} 22 | > 23 | Regular 24 | Bold 25 | 26 | ); 27 | } 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 | -------------------------------------------------------------------------------- /internal/migrations/v0.4.0.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/jmoiron/sqlx" 7 | "github.com/knadh/koanf/v2" 8 | "github.com/knadh/stuffbin" 9 | ) 10 | 11 | // V0_4_0 performs the DB migrations for v.0.4.0. 12 | func V0_4_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error { 13 | _, err := db.Exec(` 14 | DO $$ 15 | BEGIN 16 | IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'list_optin') THEN 17 | CREATE TYPE list_optin AS ENUM ('single', 'double'); 18 | END IF; 19 | IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'campaign_type') THEN 20 | CREATE TYPE campaign_type AS ENUM ('regular', 'optin'); 21 | END IF; 22 | END$$; 23 | 24 | ALTER TABLE lists ADD COLUMN IF NOT EXISTS optin list_optin NOT NULL DEFAULT 'single'; 25 | ALTER TABLE campaigns ADD COLUMN IF NOT EXISTS type campaign_type DEFAULT 'regular'; 26 | `) 27 | return err 28 | } 29 | -------------------------------------------------------------------------------- /frontend/email-builder/src/documents/blocks/ColumnsContainer/ColumnsContainerPropsSchema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | import { ColumnsContainerPropsSchema as BaseColumnsContainerPropsSchema } from '@usewaypoint/block-columns-container'; 4 | 5 | const BasePropsShape = BaseColumnsContainerPropsSchema.shape.props.unwrap().unwrap().shape; 6 | 7 | const ColumnsContainerPropsSchema = z.object({ 8 | style: BaseColumnsContainerPropsSchema.shape.style, 9 | props: z 10 | .object({ 11 | ...BasePropsShape, 12 | columns: z.tuple([ 13 | z.object({ childrenIds: z.array(z.string()) }), 14 | z.object({ childrenIds: z.array(z.string()) }), 15 | z.object({ childrenIds: z.array(z.string()) }), 16 | ]), 17 | }) 18 | .optional() 19 | .nullable(), 20 | }); 21 | 22 | export type ColumnsContainerProps = z.infer; 23 | export default ColumnsContainerPropsSchema; 24 | -------------------------------------------------------------------------------- /static/public/templates/twofa.html: -------------------------------------------------------------------------------- 1 | {{ define "admin-twofa" }} 2 | {{ template "header" .}} 3 | 4 | 23 | 24 | {{ template "footer" .}} 25 | {{ end }} 26 | -------------------------------------------------------------------------------- /frontend/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true, 5 | // es2022: true, 6 | }, 7 | plugins: ['vue'], 8 | extends: [ 9 | 'eslint:recommended', 10 | 'plugin:vue/essential', 11 | 'plugin:vue/strongly-recommended', 12 | '@vue/eslint-config-airbnb', 13 | ], 14 | parser: 'vue-eslint-parser', 15 | rules: { 16 | 'class-methods-use-this': 'off', 17 | 'vue/multi-word-component-names': 'off', 18 | 'vue/quote-props': 'off', 19 | 'vue/first-attribute-linebreak': 'off', 20 | 'vue/no-child-content': 'off', 21 | 'vue/max-attributes-per-line': 'off', 22 | 'vue/html-indent': 'off', 23 | 'vue/html-closing-bracket-newline': 'off', 24 | 'vue/singleline-html-element-content-newline': 'off', 25 | 'vue/max-len': ['error', { 26 | code: 200, 27 | template: 200, 28 | comments: 200, 29 | }], 30 | }, 31 | ignorePatterns: ['src/email-builder.js'], 32 | }; 33 | -------------------------------------------------------------------------------- /frontend/email-builder/src/documents/editor/EditorBlock.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext } from 'react'; 2 | 3 | import { EditorBlock as CoreEditorBlock } from './core'; 4 | import { useDocument } from './EditorContext'; 5 | 6 | const EditorBlockContext = createContext(null); 7 | export const useCurrentBlockId = () => useContext(EditorBlockContext)!; 8 | 9 | type EditorBlockProps = { 10 | id: string; 11 | }; 12 | 13 | /** 14 | * 15 | * @param id - Block id 16 | * @returns EditorBlock component that loads data from the EditorDocumentContext 17 | */ 18 | export default function EditorBlock({ id }: EditorBlockProps) { 19 | const document = useDocument(); 20 | const block = document[id]; 21 | if (!block) { 22 | throw new Error('Could not find block'); 23 | } 24 | return ( 25 | 26 | 27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /static/public/templates/reset-password.html: -------------------------------------------------------------------------------- 1 | {{ define "admin-reset-password" }} 2 | {{ template "header" .}} 3 | 4 | 24 | 25 | {{ template "footer" .}} 26 | {{ end }} 27 | -------------------------------------------------------------------------------- /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.md) offers several APIs to manipulate the subscribers database, like addition, updation, and deletion. For bulk synchronisation, a CSV can be generated (and optionally zipped) and posted to the import API. 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 }} -------------------------------------------------------------------------------- /frontend/email-builder/src/documents/blocks/EmailLayout/EmailLayoutPropsSchema.tsx: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | const COLOR_SCHEMA = z 4 | .string() 5 | .regex(/^#[0-9a-fA-F]{6}$/) 6 | .nullable() 7 | .optional(); 8 | 9 | const FONT_FAMILY_SCHEMA = z 10 | .enum([ 11 | 'MODERN_SANS', 12 | 'BOOK_SANS', 13 | 'ORGANIC_SANS', 14 | 'GEOMETRIC_SANS', 15 | 'HEAVY_SANS', 16 | 'ROUNDED_SANS', 17 | 'MODERN_SERIF', 18 | 'BOOK_SERIF', 19 | 'MONOSPACE', 20 | ]) 21 | .nullable() 22 | .optional(); 23 | 24 | const EmailLayoutPropsSchema = z.object({ 25 | backdropColor: COLOR_SCHEMA, 26 | borderColor: COLOR_SCHEMA, 27 | borderRadius: z.number().optional().nullable(), 28 | canvasColor: COLOR_SCHEMA, 29 | textColor: COLOR_SCHEMA, 30 | fontFamily: FONT_FAMILY_SCHEMA, 31 | childrenIds: z.array(z.string()).optional().nullable(), 32 | }); 33 | 34 | export default EmailLayoutPropsSchema; 35 | 36 | export type EmailLayoutProps = z.infer; 37 | -------------------------------------------------------------------------------- /frontend/email-builder/src/documents/blocks/helpers/EditorChildrenIds/AddBlockMenu/PlaceholderButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { AddOutlined } from '@mui/icons-material'; 4 | import { ButtonBase } from '@mui/material'; 5 | 6 | type Props = { 7 | onClick: () => void; 8 | }; 9 | export default function PlaceholderButton({ onClick }: Props) { 10 | return ( 11 | { 13 | ev.stopPropagation(); 14 | onClick(); 15 | }} 16 | sx={{ 17 | display: 'flex', 18 | alignContent: 'center', 19 | justifyContent: 'center', 20 | height: 48, 21 | width: '100%', 22 | bgcolor: 'rgba(0,0,0, 0.05)', 23 | }} 24 | > 25 | 34 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /project.inlang.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema":"https://inlang.com/schema/project-settings", 3 | "sourceLanguageTag": "en", 4 | "languageTags": ["ca", "cs-cz", "cy", "de", "en", "es", "fi", "fr", "hu", "it", "jp", "ml", "nl", "pl", "pt-BR", "pt", "ro", "ru", "se", "sk", "tr", "vi", "zh-CN", "zh-TW"], 5 | "modules": [ 6 | "https://cdn.jsdelivr.net/npm/@inlang/plugin-json@4/dist/index.js", 7 | "https://cdn.jsdelivr.net/npm/@inlang/message-lint-rule-empty-pattern@1/dist/index.js", 8 | "https://cdn.jsdelivr.net/npm/@inlang/message-lint-rule-identical-pattern@1/dist/index.js", 9 | "https://cdn.jsdelivr.net/npm/@inlang/message-lint-rule-without-source@1/dist/index.js", 10 | "https://cdn.jsdelivr.net/npm/@inlang/message-lint-rule-missing-translation@1/dist/index.js" 11 | ], 12 | "plugin.inlang.json": { 13 | "pathPattern": "./i18n/{languageTag}.json", 14 | "variableReferencePattern": [ 15 | "{", 16 | "}" 17 | ] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /frontend/src/components/CopyText.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 36 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /frontend/email-builder/src/documents/blocks/helpers/EditorChildrenIds/AddBlockMenu/BlockButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Box, Button, SxProps, Typography } from '@mui/material'; 4 | 5 | type BlockMenuButtonProps = { 6 | label: string; 7 | icon: React.ReactNode; 8 | onClick: () => void; 9 | }; 10 | 11 | const BUTTON_SX: SxProps = { p: 1.5, display: 'flex', flexDirection: 'column' }; 12 | const ICON_SX: SxProps = { 13 | mb: 0.75, 14 | width: '100%', 15 | bgcolor: 'cadet.200', 16 | display: 'flex', 17 | justifyContent: 'center', 18 | p: 1, 19 | border: '1px solid', 20 | borderColor: 'cadet.300', 21 | }; 22 | 23 | export default function BlockTypeButton({ label, icon, onClick }: BlockMenuButtonProps) { 24 | return ( 25 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /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_ADMIN_USER=admin LISTMONK_ADMIN_PASSWORD=listmonk ./listmonk --install --yes && ./listmonk > /dev/null 2>/dev/null &', 8 | serverInitBlankCmd: 9 | 'pkill -9 listmonk | cd ../ && ./listmonk --install --yes && ./listmonk > /dev/null 2>/dev/null &', 10 | LISTMONK_ADMIN_USER: 'admin', 11 | LISTMONK_ADMIN_PASSWORD: 'listmonk', 12 | }, 13 | viewportWidth: 1400, 14 | viewportHeight: 950, 15 | e2e: { 16 | experimentalRunAllSpecs: true, 17 | testIsolation: false, 18 | experimentalSessionAndOrigin: false, 19 | // We've imported your old cypress plugins here. 20 | // You may want to clean this up later by importing these. 21 | setupNodeEvents(on, config) { 22 | return require('./cypress/plugins/index.js')(on, config); 23 | }, 24 | baseUrl: 'http://localhost:9000', 25 | }, 26 | }); 27 | -------------------------------------------------------------------------------- /frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/inputs/SliderInput.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | 3 | import { InputLabel, Stack } from '@mui/material'; 4 | 5 | import RawSliderInput from './raw/RawSliderInput'; 6 | 7 | type SliderInputProps = { 8 | label: string; 9 | iconLabel: JSX.Element; 10 | 11 | step?: number; 12 | marks?: boolean; 13 | units: string; 14 | min?: number; 15 | max?: number; 16 | 17 | defaultValue: number; 18 | onChange: (v: number) => void; 19 | }; 20 | 21 | export default function SliderInput({ label, defaultValue, onChange, ...props }: SliderInputProps) { 22 | const [value, setValue] = useState(defaultValue); 23 | return ( 24 | 25 | {label} 26 | { 29 | setValue(value); 30 | onChange(value); 31 | }} 32 | {...props} 33 | /> 34 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /frontend/src/views/Logs.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 52 | -------------------------------------------------------------------------------- /frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/inputs/FontSizeInput.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | 3 | import { TextFieldsOutlined } from '@mui/icons-material'; 4 | import { InputLabel, Stack } from '@mui/material'; 5 | 6 | import RawSliderInput from './raw/RawSliderInput'; 7 | 8 | type Props = { 9 | label: string; 10 | defaultValue: number; 11 | onChange: (v: number) => void; 12 | }; 13 | export default function FontSizeInput({ label, defaultValue, onChange }: Props) { 14 | const [value, setValue] = useState(defaultValue); 15 | const handleChange = (value: number) => { 16 | setValue(value); 17 | onChange(value); 18 | }; 19 | return ( 20 | 21 | {label} 22 | } 24 | value={value} 25 | setValue={handleChange} 26 | units="px" 27 | step={1} 28 | min={10} 29 | max={48} 30 | /> 31 | 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /frontend/src/components/BarChart.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 49 | -------------------------------------------------------------------------------- /frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/inputs/TextDimensionInput.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { TextField, Typography } from '@mui/material'; 4 | 5 | type TextDimensionInputProps = { 6 | label: string; 7 | defaultValue: number | null | undefined; 8 | onChange: (v: number | null) => void; 9 | }; 10 | export default function TextDimensionInput({ label, defaultValue, onChange }: TextDimensionInputProps) { 11 | const handleChange: React.ChangeEventHandler = (ev) => { 12 | const value = parseInt(ev.target.value); 13 | onChange(isNaN(value) ? null : value); 14 | }; 15 | return ( 16 | 27 | px 28 | 29 | ), 30 | }} 31 | /> 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/inputs/RadioGroupInput.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | 3 | import { InputLabel, Stack, ToggleButtonGroup } from '@mui/material'; 4 | 5 | type Props = { 6 | label: string | JSX.Element; 7 | children: JSX.Element | JSX.Element[]; 8 | defaultValue: string; 9 | onChange: (v: string) => void; 10 | }; 11 | export default function RadioGroupInput({ label, children, defaultValue, onChange }: Props) { 12 | const [value, setValue] = useState(defaultValue); 13 | return ( 14 | 15 | {label} 16 | { 22 | if (typeof v !== 'string') { 23 | throw new Error('RadioGroupInput can only receive string values'); 24 | } 25 | setValue(v); 26 | onChange(v); 27 | }} 28 | > 29 | {children} 30 | 31 | 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /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 | _ = c.refreshCache(matDashboardCharts, false) 13 | 14 | var out types.JSONText 15 | if err := c.q.GetDashboardCharts.Get(&out); err != nil { 16 | return nil, echo.NewHTTPError(http.StatusInternalServerError, 17 | c.i18n.Ts("globals.messages.errorFetching", "name", "dashboard charts", "error", pqErrMsg(err))) 18 | } 19 | 20 | return out, nil 21 | } 22 | 23 | // GetDashboardCounts returns stats counts to show on the dashboard. 24 | func (c *Core) GetDashboardCounts() (types.JSONText, error) { 25 | _ = c.refreshCache(matDashboardCounts, false) 26 | 27 | var out types.JSONText 28 | if err := c.q.GetDashboardCounts.Get(&out); err != nil { 29 | return nil, echo.NewHTTPError(http.StatusInternalServerError, 30 | c.i18n.Ts("globals.messages.errorFetching", "name", "dashboard stats", "error", pqErrMsg(err))) 31 | } 32 | 33 | return out, nil 34 | } 35 | -------------------------------------------------------------------------------- /frontend/email-builder/src/App/TemplatePanel/helper/HighlightedCodePanel.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | 3 | import { html, json } from './highlighters'; 4 | 5 | type TextEditorPanelProps = { 6 | type: 'json' | 'html' | 'javascript'; 7 | value: string; 8 | }; 9 | export default function HighlightedCodePanel({ type, value }: TextEditorPanelProps) { 10 | const [code, setCode] = useState(null); 11 | 12 | useEffect(() => { 13 | switch (type) { 14 | case 'html': 15 | html(value).then(setCode); 16 | return; 17 | case 'json': 18 | json(value).then(setCode); 19 | return; 20 | } 21 | }, [setCode, value, type]); 22 | 23 | if (code === null) { 24 | return null; 25 | } 26 | 27 | return ( 28 |
 {
32 |         const s = window.getSelection();
33 |         if (s === null) {
34 |           return;
35 |         }
36 |         s.selectAllChildren(ev.currentTarget);
37 |       }}
38 |     />
39 |   );
40 | }
41 | 


--------------------------------------------------------------------------------
/frontend/email-builder/LICENSE:
--------------------------------------------------------------------------------
 1 | MIT License
 2 | 
 3 | Copyright (c) 2024 Waypoint (Metaccountant, Inc.)
 4 | 
 5 | Permission is hereby granted, free of charge, to any person obtaining a copy
 6 | of this software and associated documentation files (the "Software"), to deal
 7 | in the Software without restriction, including without limitation the rights
 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 | 
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 | 
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 | 


--------------------------------------------------------------------------------
/frontend/email-builder/src/App/TemplatePanel/helper/highlighters.tsx:
--------------------------------------------------------------------------------
 1 | import hljs from 'highlight.js';
 2 | import jsonHighlighter from 'highlight.js/lib/languages/json';
 3 | import xmlHighlighter from 'highlight.js/lib/languages/xml';
 4 | import prettierPluginBabel from 'prettier/plugins/babel';
 5 | import prettierPluginEstree from 'prettier/plugins/estree';
 6 | import prettierPluginHtml from 'prettier/plugins/html';
 7 | import { format } from 'prettier/standalone';
 8 | 
 9 | hljs.registerLanguage('json', jsonHighlighter);
10 | hljs.registerLanguage('html', xmlHighlighter);
11 | 
12 | export async function html(value: string): Promise {
13 |   const prettyValue = await format(value, {
14 |     parser: 'html',
15 |     plugins: [prettierPluginHtml],
16 |   });
17 |   return hljs.highlight(prettyValue, { language: 'html' }).value;
18 | }
19 | 
20 | export async function json(value: string): Promise {
21 |   const prettyValue = await format(value, {
22 |     parser: 'json',
23 |     printWidth: 0,
24 |     trailingComma: 'all',
25 |     plugins: [prettierPluginBabel, prettierPluginEstree],
26 |   });
27 |   return hljs.highlight(prettyValue, { language: 'javascript' }).value;
28 | }
29 | 


--------------------------------------------------------------------------------
/frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/inputs/FontFamily.tsx:
--------------------------------------------------------------------------------
 1 | import React, { useState } from 'react';
 2 | 
 3 | import { MenuItem, TextField } from '@mui/material';
 4 | 
 5 | import { FONT_FAMILIES } from '../../../../../../documents/blocks/helpers/fontFamily';
 6 | 
 7 | const OPTIONS = FONT_FAMILIES.map((option) => (
 8 |   
 9 |     {option.label}
10 |   
11 | ));
12 | 
13 | type NullableProps = {
14 |   label: string;
15 |   onChange: (value: null | string) => void;
16 |   defaultValue: null | string;
17 | };
18 | export function NullableFontFamily({ label, onChange, defaultValue }: NullableProps) {
19 |   const [value, setValue] = useState(defaultValue ?? 'inherit');
20 |   return (
21 |      {
27 |         const v = ev.target.value;
28 |         setValue(v);
29 |         onChange(v === null ? null : v);
30 |       }}
31 |     >
32 |       Match email settings
33 |       {OPTIONS}
34 |     
35 |   );
36 | }
37 | 


--------------------------------------------------------------------------------
/frontend/email-builder/src/App/TemplatePanel/ShareButton.tsx:
--------------------------------------------------------------------------------
 1 | import React, { useState } from 'react';
 2 | 
 3 | import { IosShareOutlined } from '@mui/icons-material';
 4 | import { IconButton, Snackbar, Tooltip } from '@mui/material';
 5 | 
 6 | import { useDocument } from '../../documents/editor/EditorContext';
 7 | 
 8 | export default function ShareButton() {
 9 |   const document = useDocument();
10 |   const [message, setMessage] = useState < string | null > (null);
11 | 
12 |   const onClick = async () => {
13 |     const c = encodeURIComponent(JSON.stringify(document));
14 |     location.hash = `#code/${btoa(c)}`;
15 |     setMessage('The URL was updated. Copy it to share your current template.');
16 |   };
17 | 
18 |   const onClose = () => {
19 |     setMessage(null);
20 |   };
21 | 
22 |   return (
23 |     <>
24 |       
25 |         
26 |           
27 |         
28 |       
29 |       
35 |     
36 |   );
37 | }
38 | 


--------------------------------------------------------------------------------
/frontend/email-builder/src/main.tsx:
--------------------------------------------------------------------------------
 1 | import React from 'react';
 2 | import ReactDOM from 'react-dom/client';
 3 | import App, { AppProps, DEFAULT_SOURCE } from './App';
 4 | import { setDocument, resetDocument } from './documents/editor/EditorContext';
 5 | 
 6 | import { CssBaseline, ThemeProvider } from '@mui/material';
 7 | import theme from './theme';
 8 | 
 9 | function isRendered(containerId: string): boolean {
10 |   const container = document.getElementById(containerId);
11 |   if (!container) {
12 |     console.error(`Container with id ${containerId} not found`);
13 |     return false;
14 |   }
15 |   return container.hasChildNodes();
16 | }
17 | 
18 | function render(containerId: string, props: AppProps, force: boolean = false) {
19 |   if (!isRendered(containerId) || force) {
20 |     const container = document.getElementById(containerId);
21 |     if (!container) return;
22 | 
23 |     ReactDOM.createRoot(container).render(
24 |       
25 |         
26 |           
27 |           
28 |         
29 |       
30 |     );
31 |   }
32 | }
33 | 
34 | export { App, setDocument, resetDocument, render, isRendered, DEFAULT_SOURCE };
35 | 


--------------------------------------------------------------------------------
/frontend/vite.config.js:
--------------------------------------------------------------------------------
 1 | import vue from '@vitejs/plugin-vue2';
 2 | import { defineConfig, loadEnv } from 'vite';
 3 | 
 4 | const path = require('path');
 5 | 
 6 | // https://vitejs.dev/config/
 7 | export default defineConfig(({ _, mode }) => {
 8 |   const env = loadEnv(mode, process.cwd(), '');
 9 |   return {
10 |     plugins: [vue()],
11 |     base: '/admin',
12 |     mode,
13 |     resolve: {
14 |       alias: {
15 |         '@': path.resolve(__dirname, './src'),
16 |         bulma: require.resolve('bulma/bulma.sass'),
17 |       },
18 |     },
19 |     build: {
20 |       assetsDir: 'static',
21 |     },
22 |     server: {
23 |       port: env.LISTMONK_FRONTEND_PORT || 8080,
24 |       proxy: {
25 |         '^/$': {
26 |           target: env.LISTMONK_API_URL || 'http://127.0.0.1:9000',
27 |         },
28 |         '^/(api|webhooks|subscription|public|health)': {
29 |           target: env.LISTMONK_API_URL || 'http://127.0.0.1:9000',
30 |         },
31 |         '^/admin/login': {
32 |           target: env.LISTMONK_API_URL || 'http://127.0.0.1:9000',
33 |         },
34 |         '^/(admin\/custom\.(css|js))': {
35 |           target: env.LISTMONK_API_URL || 'http://127.0.0.1:9000',
36 |         },
37 |       },
38 |     },
39 |   };
40 | });
41 | 


--------------------------------------------------------------------------------
/models/bounces.go:
--------------------------------------------------------------------------------
 1 | package models
 2 | 
 3 | import (
 4 | 	"encoding/json"
 5 | 	"time"
 6 | )
 7 | 
 8 | const (
 9 | 	BounceTypeHard      = "hard"
10 | 	BounceTypeSoft      = "soft"
11 | 	BounceTypeComplaint = "complaint"
12 | )
13 | 
14 | // Bounce represents a single bounce event.
15 | type Bounce struct {
16 | 	ID        int             `db:"id" json:"id"`
17 | 	Type      string          `db:"type" json:"type"`
18 | 	Source    string          `db:"source" json:"source"`
19 | 	Meta      json.RawMessage `db:"meta" json:"meta"`
20 | 	CreatedAt time.Time       `db:"created_at" json:"created_at"`
21 | 
22 | 	// One of these should be provided.
23 | 	Email            string `db:"email" json:"email,omitempty"`
24 | 	SubscriberUUID   string `db:"subscriber_uuid" json:"subscriber_uuid,omitempty"`
25 | 	SubscriberID     int    `db:"subscriber_id" json:"subscriber_id,omitempty"`
26 | 	SubscriberStatus string `db:"subscriber_status" json:"subscriber_status"`
27 | 
28 | 	CampaignUUID string           `db:"campaign_uuid" json:"campaign_uuid,omitempty"`
29 | 	Campaign     *json.RawMessage `db:"campaign" json:"campaign"`
30 | 
31 | 	// Pseudofield for getting the total number of bounces
32 | 	// in searches and queries.
33 | 	Total int `db:"total" json:"-"`
34 | }
35 | 


--------------------------------------------------------------------------------
/frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/inputs/TextInput.tsx:
--------------------------------------------------------------------------------
 1 | import React, { useState } from 'react';
 2 | 
 3 | import { InputProps, TextField } from '@mui/material';
 4 | 
 5 | type Props = {
 6 |   label: string;
 7 |   rows?: number;
 8 |   placeholder?: string;
 9 |   helperText?: string | JSX.Element;
10 |   InputProps?: InputProps;
11 |   defaultValue: string;
12 |   className?: string;
13 |   onChange: (v: string) => void;
14 | };
15 | export default function TextInput({ helperText, label, placeholder, rows, InputProps, defaultValue, className, onChange }: Props) {
16 |   const [value, setValue] = useState(defaultValue);
17 |   const isMultiline = typeof rows === 'number' && rows > 1;
18 |   return (
19 |      {
31 |         const v = ev.target.value;
32 |         setValue(v);
33 |         onChange(v);
34 |       }}
35 |     />
36 |   );
37 | }
38 | 


--------------------------------------------------------------------------------
/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 | 	defer bu.Unlock()
33 | 
34 | 	if len(bu.lines) >= bu.maxLines {
35 | 		bu.lines[0] = ""
36 | 		bu.lines = bu.lines[1:len(bu.lines)]
37 | 	}
38 | 	bu.lines = append(bu.lines, strings.TrimSpace(string(b)))
39 | 
40 | 	return len(b), nil
41 | }
42 | 
43 | // Lines returns the log lines.
44 | func (bu *BufLog) Lines() []string {
45 | 	bu.RLock()
46 | 	defer bu.RUnlock()
47 | 
48 | 	out := make([]string, len(bu.lines))
49 | 	copy(out[:], bu.lines[:])
50 | 	return out
51 | }
52 | 


--------------------------------------------------------------------------------
/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 | 


--------------------------------------------------------------------------------
/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 | // EventStream serves an endpoint that never closes and pushes a
13 | // live event stream (text/event-stream) such as a error messages.
14 | func (a *App) EventStream(c echo.Context) error {
15 | 	hdr := c.Response().Header()
16 | 	hdr.Set(echo.HeaderContentType, "text/event-stream")
17 | 	hdr.Set(echo.HeaderCacheControl, "no-store")
18 | 	hdr.Set(echo.HeaderConnection, "keep-alive")
19 | 
20 | 	// Subscribe to the event stream with a random ID.
21 | 	id := fmt.Sprintf("api:%v", time.Now().UnixNano())
22 | 	sub, err := a.events.Subscribe(id)
23 | 	if err != nil {
24 | 		log.Fatalf("error subscribing to events: %v", err)
25 | 	}
26 | 
27 | 	ctx := c.Request().Context()
28 | 	for {
29 | 		select {
30 | 		case e := <-sub:
31 | 			b, err := json.Marshal(e)
32 | 			if err != nil {
33 | 				a.log.Printf("error marshalling event: %v", err)
34 | 				continue
35 | 			}
36 | 
37 | 			c.Response().Write([]byte(fmt.Sprintf("retry: 3000\ndata: %s\n\n", b)))
38 | 			c.Response().Flush()
39 | 
40 | 		case <-ctx.Done():
41 | 			// On HTTP connection close, unsubscribe.
42 | 			a.events.Unsubscribe(id)
43 | 			return nil
44 | 		}
45 | 	}
46 | 
47 | }
48 | 


--------------------------------------------------------------------------------
/frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/ContainerSidebarPanel.tsx:
--------------------------------------------------------------------------------
 1 | import React, { useState } from 'react';
 2 | 
 3 | import ContainerPropsSchema, { ContainerProps } from '../../../../documents/blocks/Container/ContainerPropsSchema';
 4 | 
 5 | import BaseSidebarPanel from './helpers/BaseSidebarPanel';
 6 | import MultiStylePropertyPanel from './helpers/style-inputs/MultiStylePropertyPanel';
 7 | 
 8 | type ContainerSidebarPanelProps = {
 9 |   data: ContainerProps;
10 |   setData: (v: ContainerProps) => void;
11 | };
12 | 
13 | export default function ContainerSidebarPanel({ data, setData }: ContainerSidebarPanelProps) {
14 |   const [, setErrors] = useState(null);
15 |   const updateData = (d: unknown) => {
16 |     const res = ContainerPropsSchema.safeParse(d);
17 |     if (res.success) {
18 |       setData(res.data);
19 |       setErrors(null);
20 |     } else {
21 |       setErrors(res.error);
22 |     }
23 |   };
24 |   return (
25 |     
26 |        updateData({ ...data, style })}
30 |       />
31 |     
32 |   );
33 | }
34 | 


--------------------------------------------------------------------------------
/frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/inputs/TextAlignInput.tsx:
--------------------------------------------------------------------------------
 1 | import React, { useState } from 'react';
 2 | 
 3 | import { FormatAlignCenterOutlined, FormatAlignLeftOutlined, FormatAlignRightOutlined } from '@mui/icons-material';
 4 | import { ToggleButton } from '@mui/material';
 5 | 
 6 | import RadioGroupInput from './RadioGroupInput';
 7 | 
 8 | type Props = {
 9 |   label: string;
10 |   defaultValue: string | null;
11 |   onChange: (value: string | null) => void;
12 | };
13 | export default function TextAlignInput({ label, defaultValue, onChange }: Props) {
14 |   const [value, setValue] = useState(defaultValue ?? 'left');
15 | 
16 |   return (
17 |      {
21 |         setValue(value);
22 |         onChange(value);
23 |       }}
24 |     >
25 |       
26 |         
27 |       
28 |       
29 |         
30 |       
31 |       
32 |         
33 |       
34 |     
35 |   );
36 | }
37 | 


--------------------------------------------------------------------------------
/frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/inputs/ColorInput/Swatch.tsx:
--------------------------------------------------------------------------------
 1 | import React from 'react';
 2 | 
 3 | import { Box, Button, SxProps } from '@mui/material';
 4 | 
 5 | type Props = {
 6 |   paletteColors: string[];
 7 |   value: string;
 8 |   onChange: (value: string) => void;
 9 | };
10 | 
11 | const TILE_BUTTON: SxProps = {
12 |   width: 24,
13 |   height: 24,
14 | };
15 | export default function Swatch({ paletteColors, value, onChange }: Props) {
16 |   const renderButton = (colorValue: string) => {
17 |     return (
18 |       

32 | 33 | 34 | 35 | 36 | {{ template "footer" .}} 37 | {{ end }} -------------------------------------------------------------------------------- /internal/migrations/v0.9.0.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | 7 | "github.com/jmoiron/sqlx" 8 | "github.com/knadh/koanf/v2" 9 | "github.com/knadh/stuffbin" 10 | ) 11 | 12 | // V0_9_0 performs the DB migrations for v.0.9.0. 13 | func V0_9_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error { 14 | if _, err := db.Exec(` 15 | INSERT INTO settings (key, value) VALUES 16 | ('app.lang', '"en"'), 17 | ('app.message_sliding_window', 'false'), 18 | ('app.message_sliding_window_duration', '"1h"'), 19 | ('app.message_sliding_window_rate', '10000'), 20 | ('app.enable_public_subscription_page', 'true') 21 | ON CONFLICT DO NOTHING; 22 | 23 | -- Add alternate (plain text) body field on campaigns. 24 | ALTER TABLE campaigns ADD COLUMN IF NOT EXISTS altbody TEXT NULL DEFAULT NULL; 25 | `); err != nil { 26 | return err 27 | } 28 | 29 | // Until this version, the default template during installation was broken! 30 | // Check if there's a broken default template and if yes, override it with the 31 | // actual one. 32 | tplBody, err := fs.Get("/static/email-templates/default.tpl") 33 | if err != nil { 34 | return fmt.Errorf("error reading default e-mail template: %v", err) 35 | } 36 | 37 | if _, err := db.Exec(`UPDATE templates SET body=$1 WHERE body=$2`, 38 | tplBody.ReadBytes(), `{{ template "content" . }}`); err != nil { 39 | return err 40 | } 41 | return nil 42 | } 43 | -------------------------------------------------------------------------------- /internal/migrations/v2.3.0.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/jmoiron/sqlx" 7 | "github.com/knadh/koanf/v2" 8 | "github.com/knadh/stuffbin" 9 | ) 10 | 11 | // V2_2_0 performs the DB migrations for v.2.3.0. 12 | func V2_3_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error { 13 | if _, err := db.Exec(`ALTER TABLE media ADD COLUMN IF NOT EXISTS "meta" JSONB NOT NULL DEFAULT '{}'`); err != nil { 14 | return err 15 | } 16 | 17 | // Add `description` field to lists. 18 | if _, err := db.Exec(`ALTER TABLE lists ADD COLUMN IF NOT EXISTS "description" TEXT NOT NULL DEFAULT ''`); err != nil { 19 | return err 20 | } 21 | 22 | // Add archive publishing field to campaigns. 23 | if _, err := db.Exec(`ALTER TABLE campaigns 24 | ADD COLUMN IF NOT EXISTS archive BOOLEAN NOT NULL DEFAULT false, 25 | ADD COLUMN IF NOT EXISTS archive_meta JSONB NOT NULL DEFAULT '{}', 26 | ADD COLUMN IF NOT EXISTS archive_template_id INTEGER REFERENCES templates(id) ON DELETE SET DEFAULT DEFAULT 1 27 | `); err != nil { 28 | return err 29 | } 30 | 31 | // Insert new preference settings. 32 | if _, err := db.Exec(` 33 | INSERT INTO settings (key, value) VALUES 34 | ('app.site_name', '"Mailing list"'), 35 | ('app.enable_public_archive', 'true'), 36 | ('privacy.allow_preferences', 'false') 37 | ON CONFLICT DO NOTHING; 38 | `); err != nil { 39 | return err 40 | } 41 | 42 | return nil 43 | } 44 | -------------------------------------------------------------------------------- /frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/SpacerSidebarPanel.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | 3 | import { HeightOutlined } from '@mui/icons-material'; 4 | import { SpacerProps, SpacerPropsDefaults, SpacerPropsSchema } from '@usewaypoint/block-spacer'; 5 | 6 | import BaseSidebarPanel from './helpers/BaseSidebarPanel'; 7 | import SliderInput from './helpers/inputs/SliderInput'; 8 | 9 | type SpacerSidebarPanelProps = { 10 | data: SpacerProps; 11 | setData: (v: SpacerProps) => void; 12 | }; 13 | export default function SpacerSidebarPanel({ data, setData }: SpacerSidebarPanelProps) { 14 | const [, setErrors] = useState(null); 15 | 16 | const updateData = (d: unknown) => { 17 | const res = SpacerPropsSchema.safeParse(d); 18 | if (res.success) { 19 | setData(res.data); 20 | setErrors(null); 21 | } else { 22 | setErrors(res.error); 23 | } 24 | }; 25 | 26 | return ( 27 | 28 | } 31 | units="px" 32 | step={4} 33 | min={4} 34 | max={128} 35 | defaultValue={data.props?.height ?? SpacerPropsDefaults.height} 36 | onChange={(height) => updateData({ ...data, props: { ...data.props, height } })} 37 | /> 38 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /internal/migrations/v2.2.0.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/jmoiron/sqlx" 7 | "github.com/knadh/koanf/v2" 8 | "github.com/knadh/stuffbin" 9 | ) 10 | 11 | // V2_2_0 performs the DB migrations for v.2.2.0. 12 | func V2_2_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error { 13 | if _, err := db.Exec(` 14 | DO $$ 15 | BEGIN 16 | IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'template_type') THEN 17 | CREATE TYPE template_type AS ENUM ('campaign', 'tx'); 18 | END IF; 19 | END$$; 20 | `); err != nil { 21 | return err 22 | } 23 | 24 | if _, err := db.Exec(`ALTER TABLE templates ADD COLUMN IF NOT EXISTS "type" template_type NOT NULL DEFAULT 'campaign'`); err != nil { 25 | return err 26 | } 27 | 28 | if _, err := db.Exec(`ALTER TABLE templates ADD COLUMN IF NOT EXISTS subject TEXT NOT NULL DEFAULT ''`); err != nil { 29 | return err 30 | } 31 | if _, err := db.Exec(`ALTER TABLE templates ALTER COLUMN subject DROP DEFAULT`); err != nil { 32 | return err 33 | } 34 | 35 | // Insert transactional template. 36 | txTpl, err := fs.Get("/static/email-templates/sample-tx.tpl") 37 | if err != nil { 38 | return err 39 | } 40 | if _, err := db.Exec(`INSERT INTO templates (name, type, subject, body) VALUES($1, $2, $3, $4)`, 41 | "Sample transactional template", "tx", "Welcome {{ .Subscriber.Name }}", txTpl.ReadBytes()); err != nil { 42 | return err 43 | } 44 | 45 | return nil 46 | } 47 | -------------------------------------------------------------------------------- /.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 | permissions: write-all 9 | 10 | jobs: 11 | goreleaser: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v3 16 | with: 17 | fetch-depth: 0 18 | 19 | - name: Set up QEMU 20 | uses: docker/setup-qemu-action@v2 21 | 22 | - name: Set up Go 23 | uses: actions/setup-go@v5 24 | with: 25 | go-version: "1.24.1" 26 | 27 | - name: Login to Docker Registry 28 | uses: docker/login-action@v2 29 | with: 30 | username: ${{ secrets.DOCKERHUB_USERNAME }} 31 | password: ${{ secrets.DOCKERHUB_TOKEN }} 32 | 33 | - name: Login to GitHub Docker Registry 34 | uses: docker/login-action@v2 35 | with: 36 | registry: ghcr.io 37 | username: ${{ github.actor }} 38 | password: ${{ secrets.GITHUB_TOKEN }} 39 | 40 | - name: Prepare Dependencies 41 | run: | 42 | make dist 43 | 44 | - name: Check Docker Version 45 | run: | 46 | docker version 47 | 48 | - name: Run GoReleaser 49 | uses: goreleaser/goreleaser-action@v5 50 | with: 51 | version: latest 52 | args: release --parallelism 1 --clean 53 | env: 54 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 55 | -------------------------------------------------------------------------------- /frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/HtmlSidebarPanel.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | 3 | import { HtmlProps, HtmlPropsSchema } from '@usewaypoint/block-html'; 4 | 5 | import BaseSidebarPanel from './helpers/BaseSidebarPanel'; 6 | import TextInput from './helpers/inputs/TextInput'; 7 | import MultiStylePropertyPanel from './helpers/style-inputs/MultiStylePropertyPanel'; 8 | 9 | type HtmlSidebarPanelProps = { 10 | data: HtmlProps; 11 | setData: (v: HtmlProps) => void; 12 | }; 13 | export default function HtmlSidebarPanel({ data, setData }: HtmlSidebarPanelProps) { 14 | const [, setErrors] = useState(null); 15 | 16 | const updateData = (d: unknown) => { 17 | const res = HtmlPropsSchema.safeParse(d); 18 | if (res.success) { 19 | setData(res.data); 20 | setErrors(null); 21 | } else { 22 | setErrors(res.error); 23 | } 24 | }; 25 | 26 | return ( 27 | 28 | updateData({ ...data, props: { ...data.props, contents } })} 33 | /> 34 | updateData({ ...data, style })} 38 | /> 39 | 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /internal/migrations/v2.1.0.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/jmoiron/sqlx" 7 | "github.com/knadh/koanf/v2" 8 | "github.com/knadh/stuffbin" 9 | ) 10 | 11 | // V2_1_0 performs the DB migrations for v.2.1.0. 12 | func V2_1_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error { 13 | // Insert appearance related settings. 14 | if _, err := db.Exec(` 15 | INSERT INTO settings (key, value) VALUES 16 | ('appearance.admin.custom_css', '""'), 17 | ('appearance.admin.custom_js', '""'), 18 | ('appearance.public.custom_css', '""'), 19 | ('appearance.public.custom_js', '""'), 20 | ('upload.s3.public_url', '""') 21 | ON CONFLICT DO NOTHING; 22 | `); err != nil { 23 | return err 24 | } 25 | 26 | // Replace all `tls_enabled: true/false` keys in the `smtp` settings JSON array 27 | // with the new field `tls_type: STARTTLS|TLS|none`. 28 | // The `tls_enabled` key is removed. 29 | if _, err := db.Exec(` 30 | UPDATE settings SET value = s.updated 31 | FROM ( 32 | SELECT JSONB_AGG( 33 | JSONB_SET(v - 'tls_enabled', '{tls_type}', (CASE WHEN v->>'tls_enabled' = 'true' THEN '"STARTTLS"' ELSE '"none"' END)::JSONB) 34 | ) AS updated FROM settings, JSONB_ARRAY_ELEMENTS(value) v WHERE key = 'smtp' 35 | ) s WHERE key = 'smtp' AND value::TEXT LIKE '%tls_enabled%'; 36 | `); err != nil { 37 | return err 38 | } 39 | 40 | if _, err := db.Exec(`ALTER TABLE campaigns ADD COLUMN IF NOT EXISTS headers JSONB NOT NULL DEFAULT '[]';`); err != nil { 41 | return err 42 | } 43 | 44 | return nil 45 | } 46 | -------------------------------------------------------------------------------- /internal/migrations/v5.2.0.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/jmoiron/sqlx" 7 | "github.com/knadh/koanf/v2" 8 | "github.com/knadh/stuffbin" 9 | ) 10 | 11 | func V5_2_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error { 12 | _, err := db.Exec(` 13 | INSERT INTO settings (key, value, updated_at) VALUES ('security.cors_origins', '[]', NOW()) ON CONFLICT (key) DO NOTHING 14 | `) 15 | if err != nil { 16 | return err 17 | } 18 | 19 | // Add 2FA fields to users table. 20 | _, err = db.Exec(` 21 | DO $$ BEGIN 22 | -- Create twofa_type enum if it doesn't exist 23 | IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'twofa_type') THEN 24 | CREATE TYPE twofa_type AS ENUM ('none', 'totp'); 25 | END IF; 26 | END $$; 27 | 28 | ALTER TABLE users ADD COLUMN IF NOT EXISTS twofa_type twofa_type NOT NULL DEFAULT 'none'; 29 | ALTER TABLE users ADD COLUMN IF NOT EXISTS twofa_key TEXT NULL; 30 | `) 31 | if err != nil { 32 | return err 33 | } 34 | 35 | // Add status field to lists table. 36 | _, err = db.Exec(` 37 | DO $$ BEGIN 38 | -- Create list_status enum if it doesn't exist 39 | IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'list_status') THEN 40 | CREATE TYPE list_status AS ENUM ('active', 'archived'); 41 | END IF; 42 | END $$; 43 | 44 | ALTER TABLE lists ADD COLUMN IF NOT EXISTS status list_status NOT NULL DEFAULT 'active'; 45 | CREATE INDEX IF NOT EXISTS idx_lists_status ON lists(status); 46 | `) 47 | if err != nil { 48 | return err 49 | } 50 | 51 | return nil 52 | } 53 | -------------------------------------------------------------------------------- /dev/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | adminer: 5 | image: adminer:4.8.1-standalone 6 | restart: always 7 | ports: 8 | - 8070:8080 9 | networks: 10 | - listmonk-dev 11 | 12 | mailhog: 13 | image: mailhog/mailhog:v1.0.1 14 | ports: 15 | - "1025:1025" # SMTP 16 | - "8025:8025" # UI 17 | networks: 18 | - listmonk-dev 19 | 20 | db: 21 | image: postgres:13 22 | ports: 23 | - "5432:5432" 24 | networks: 25 | - listmonk-dev 26 | environment: 27 | - POSTGRES_PASSWORD=listmonk-dev 28 | - POSTGRES_USER=listmonk-dev 29 | - POSTGRES_DB=listmonk-dev 30 | restart: unless-stopped 31 | volumes: 32 | - type: volume 33 | source: listmonk-dev-db 34 | target: /var/lib/postgresql/data 35 | 36 | front: 37 | build: 38 | context: ../ 39 | dockerfile: dev/app.Dockerfile 40 | command: ["make", "run-frontend"] 41 | ports: 42 | - "8080:8080" 43 | environment: 44 | - LISTMONK_API_URL=http://backend:9000 45 | depends_on: 46 | - db 47 | volumes: 48 | - ../:/app 49 | networks: 50 | - listmonk-dev 51 | 52 | backend: 53 | build: 54 | context: ../ 55 | dockerfile: dev/app.Dockerfile 56 | command: ["make", "run-backend-docker"] 57 | ports: 58 | - "9000:9000" 59 | depends_on: 60 | - db 61 | volumes: 62 | - ../:/app 63 | - $GOPATH/pkg/mod/cache:/go/pkg/mod/cache 64 | networks: 65 | - listmonk-dev 66 | 67 | volumes: 68 | listmonk-dev-db: 69 | 70 | networks: 71 | listmonk-dev: 72 | -------------------------------------------------------------------------------- /docs/site/static/static/images/logo-windows.svg: -------------------------------------------------------------------------------- 1 | 2 | 15 | 17 | 35 | 40 | 41 | -------------------------------------------------------------------------------- /models/lists.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "github.com/lib/pq" 5 | null "gopkg.in/volatiletech/null.v6" 6 | ) 7 | 8 | const ( 9 | ListTypePrivate = "private" 10 | ListTypePublic = "public" 11 | ListOptinSingle = "single" 12 | ListOptinDouble = "double" 13 | ListStatusActive = "active" 14 | ListStatusArchived = "archived" 15 | ) 16 | 17 | // List represents a mailing list. 18 | type List struct { 19 | Base 20 | 21 | UUID string `db:"uuid" json:"uuid"` 22 | Name string `db:"name" json:"name"` 23 | Type string `db:"type" json:"type"` 24 | Optin string `db:"optin" json:"optin"` 25 | Status string `db:"status" json:"status"` 26 | Tags pq.StringArray `db:"tags" json:"tags"` 27 | Description string `db:"description" json:"description"` 28 | SubscriberCount int `db:"subscriber_count" json:"subscriber_count"` 29 | SubscriberCounts StringIntMap `db:"subscriber_statuses" json:"subscriber_statuses"` 30 | SubscriberID int `db:"subscriber_id" json:"-"` 31 | 32 | // This is only relevant when querying the lists of a subscriber. 33 | SubscriptionStatus string `db:"subscription_status" json:"subscription_status,omitempty"` 34 | SubscriptionCreatedAt null.Time `db:"subscription_created_at" json:"subscription_created_at,omitempty"` 35 | SubscriptionUpdatedAt null.Time `db:"subscription_updated_at" json:"subscription_updated_at,omitempty"` 36 | 37 | // Pseudofield for getting the total number of subscribers 38 | // in searches and queries. 39 | Total int `db:"total" json:"-"` 40 | } 41 | -------------------------------------------------------------------------------- /frontend/cypress/e2e/settings.cy.js: -------------------------------------------------------------------------------- 1 | const apiUrl = Cypress.env('apiUrl'); 2 | 3 | describe('Settings', () => { 4 | it('Opens settings page', () => { 5 | cy.resetDB(); 6 | cy.loginAndVisit('/admin/settings'); 7 | }); 8 | 9 | it('Changes some settings', () => { 10 | cy.get('.b-tabs nav a').eq(0).click(); 11 | 12 | const rootURL = 'http://127.0.0.1:9000'; 13 | const faveURL = 'http://127.0.0.1:9000/public/static/logo.png'; 14 | 15 | cy.get('input[name="app.root_url"]').clear().type(rootURL); 16 | cy.get('input[name="app.favicon_url"]').type(faveURL); 17 | cy.get('.b-tabs nav a').eq(1).click(); 18 | cy.get('.tab-item:visible').find('.field').first() 19 | .find('button') 20 | .first() 21 | .click(); 22 | 23 | // Enable / disable SMTP and delete one. 24 | cy.get('.b-tabs nav a').eq(5).click(); 25 | cy.get('.tab-item:visible [data-cy=btn-enable-smtp]').eq(1).click(); 26 | cy.get('.tab-item:visible [data-cy=btn-delete-smtp]').first().click(); 27 | cy.get('.modal button.is-primary').click(); 28 | 29 | cy.get('[data-cy=btn-save]').click(); 30 | cy.wait(500); 31 | 32 | cy.waitForBackend(); 33 | }); 34 | 35 | it('Verify settings change', () => { 36 | // Verify the changes. 37 | cy.request(`${apiUrl}/api/settings`).should((response) => { 38 | const { data } = response.body; 39 | expect(data['app.root_url']).to.equal(rootURL); 40 | expect(data['app.favicon_url']).to.equal(faveURL); 41 | expect(data['app.concurrency']).to.equal(9); 42 | 43 | expect(data.smtp.length).to.equal(1); 44 | expect(data.smtp[0].enabled).to.equal(true); 45 | }); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /internal/utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "crypto/rand" 5 | "net/mail" 6 | "net/url" 7 | "path" 8 | "strings" 9 | ) 10 | 11 | // ValidateEmail validates whether the given string is a correctly formed e-mail address. 12 | func ValidateEmail(email string) bool { 13 | // Since `mail.ParseAddress` parses an email address which can also contain an optional name component, 14 | // here we check if incoming email string is same as the parsed email.Address. So this eliminates 15 | // any valid email address with name and also valid address with empty name like ``. 16 | em, err := mail.ParseAddress(email) 17 | if err != nil || em.Address != email { 18 | return false 19 | } 20 | 21 | return true 22 | } 23 | 24 | // GenerateRandomString generates a cryptographically random, alphanumeric string of length n. 25 | func GenerateRandomString(n int) (string, error) { 26 | const dictionary = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" 27 | var bytes = make([]byte, n) 28 | 29 | if _, err := rand.Read(bytes); err != nil { 30 | return "", err 31 | } 32 | for k, v := range bytes { 33 | bytes[k] = dictionary[v%byte(len(dictionary))] 34 | } 35 | 36 | return string(bytes), nil 37 | } 38 | 39 | // SanitizeURI takes a URL or URI, removes the domain from it, returns only the URI. 40 | // This is used for cleaning "next" redirect URLs/URIs to prevent open redirects. 41 | func SanitizeURI(u string) string { 42 | u = strings.TrimSpace(u) 43 | if u == "" { 44 | return "/" 45 | } 46 | 47 | p, err := url.Parse(u) 48 | if err != nil || strings.Contains(p.Path, "..") { 49 | return "/" 50 | } 51 | 52 | return path.Clean(p.Path) 53 | } 54 | -------------------------------------------------------------------------------- /static/public/templates/login.html: -------------------------------------------------------------------------------- 1 | {{ define "admin-login" }} 2 | {{ template "header" .}} 3 | 4 | 43 | 44 | {{ template "footer" .}} 45 | {{ end }} -------------------------------------------------------------------------------- /frontend/email-builder/src/App/TemplatePanel/MainTabsGroup.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { CodeOutlined, DataObjectOutlined, EditOutlined, PreviewOutlined } from '@mui/icons-material'; 4 | import { Tab, Tabs, Tooltip } from '@mui/material'; 5 | 6 | import { setSelectedMainTab, useSelectedMainTab } from '../../documents/editor/EditorContext'; 7 | 8 | export default function MainTabsGroup() { 9 | const selectedMainTab = useSelectedMainTab(); 10 | const handleChange = (_: unknown, v: unknown) => { 11 | switch (v) { 12 | case 'json': 13 | case 'preview': 14 | case 'editor': 15 | case 'html': 16 | setSelectedMainTab(v); 17 | return; 18 | default: 19 | setSelectedMainTab('editor'); 20 | } 21 | }; 22 | 23 | return ( 24 | 25 | 29 | 30 | 31 | } 32 | /> 33 | 37 | 38 | 39 | } 40 | /> 41 | 45 | 46 | 47 | } 48 | /> 49 | 53 | 54 | 55 | } 56 | /> 57 | 58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /frontend/email-builder/src/documents/blocks/helpers/block-wrappers/EditorBlockWrapper.tsx: -------------------------------------------------------------------------------- 1 | import React, { CSSProperties, useState } from 'react'; 2 | 3 | import { Box } from '@mui/material'; 4 | 5 | import { useCurrentBlockId } from '../../../editor/EditorBlock'; 6 | import { setSelectedBlockId, useSelectedBlockId } from '../../../editor/EditorContext'; 7 | 8 | import TuneMenu from './TuneMenu'; 9 | 10 | type TEditorBlockWrapperProps = { 11 | children: JSX.Element; 12 | }; 13 | 14 | export default function EditorBlockWrapper({ children }: TEditorBlockWrapperProps) { 15 | const selectedBlockId = useSelectedBlockId(); 16 | const [mouseInside, setMouseInside] = useState(false); 17 | const blockId = useCurrentBlockId(); 18 | 19 | let outline: CSSProperties['outline']; 20 | if (selectedBlockId === blockId) { 21 | outline = '2px solid rgba(0,121,204, 1)'; 22 | } else if (mouseInside) { 23 | outline = '2px solid rgba(0,121,204, 0.3)'; 24 | } 25 | 26 | const renderMenu = () => { 27 | if (selectedBlockId !== blockId) { 28 | return null; 29 | } 30 | return ; 31 | }; 32 | 33 | return ( 34 | { 42 | setMouseInside(true); 43 | ev.stopPropagation(); 44 | }} 45 | onMouseLeave={() => { 46 | setMouseInside(false); 47 | }} 48 | onClick={(ev) => { 49 | setSelectedBlockId(blockId); 50 | ev.stopPropagation(); 51 | ev.preventDefault(); 52 | }} 53 | > 54 | {renderMenu()} 55 | {children} 56 | 57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /queries/templates.sql: -------------------------------------------------------------------------------- 1 | -- templates 2 | -- name: get-templates 3 | -- Only if the second param ($2 - noBody) is true, body and body_source is returned. 4 | SELECT id, name, type, subject, 5 | (CASE WHEN $2 = false THEN body ELSE '' END) as body, 6 | (CASE WHEN $2 = false THEN body_source ELSE NULL END) as body_source, 7 | is_default, created_at, updated_at 8 | FROM templates WHERE ($1 = 0 OR id = $1) AND ($3 = '' OR type = $3::template_type) 9 | ORDER BY created_at; 10 | 11 | -- name: create-template 12 | INSERT INTO templates (name, type, subject, body, body_source) VALUES($1, $2, $3, $4, $5) RETURNING id; 13 | 14 | -- name: update-template 15 | UPDATE templates SET 16 | name=(CASE WHEN $2 != '' THEN $2 ELSE name END), 17 | subject=(CASE WHEN $3 != '' THEN $3 ELSE name END), 18 | body=(CASE WHEN $4 != '' THEN $4 ELSE body END), 19 | body_source=(CASE WHEN $5 != '' THEN $5 ELSE body_source END), 20 | updated_at=NOW() 21 | WHERE id = $1; 22 | 23 | -- name: set-default-template 24 | WITH u AS ( 25 | UPDATE templates SET is_default=true WHERE id=$1 AND type='campaign' RETURNING id 26 | ) 27 | UPDATE templates SET is_default=false WHERE id != $1; 28 | 29 | -- name: delete-template 30 | -- Delete a template as long as there's more than one. On deletion, set all campaigns 31 | -- with that template to the default template instead. 32 | WITH tpl AS ( 33 | DELETE FROM templates WHERE id = $1 AND (SELECT COUNT(id) FROM templates) > 1 AND is_default = false RETURNING id 34 | ), 35 | def AS ( 36 | SELECT id FROM templates WHERE is_default = true AND (type='campaign' OR type='campaign_visual') LIMIT 1 37 | ), 38 | up AS ( 39 | UPDATE campaigns SET template_id = (SELECT id FROM def) WHERE (SELECT id FROM tpl) > 0 AND template_id = $1 40 | ) 41 | SELECT id FROM tpl; 42 | 43 | -------------------------------------------------------------------------------- /frontend/src/constants.js: -------------------------------------------------------------------------------- 1 | export const models = Object.freeze({ 2 | serverConfig: 'serverConfig', 3 | lang: 'lang', 4 | dashboard: 'dashboard', 5 | // This loading state is used across all contexts where lists are loaded 6 | // via the instant "minimal" API. 7 | lists: 'lists', 8 | // This is used only on the lists page where lists are loaded with full 9 | // context (subscriber counts), which can be slow and expensive. 10 | listsFull: 'listsFull', 11 | subscribers: 'subscribers', 12 | campaigns: 'campaigns', 13 | templates: 'templates', 14 | media: 'media', 15 | bounces: 'bounces', 16 | users: 'users', 17 | profile: 'profile', 18 | userRoles: 'userRoles', 19 | listRoles: 'listRoles', 20 | settings: 'settings', 21 | logs: 'logs', 22 | maintenance: 'maintenance', 23 | }); 24 | 25 | // Ad-hoc URIs that are used outside of vuex requests. 26 | const rootURL = import.meta.env.VUE_APP_ROOT_URL || '/'; 27 | const baseURL = import.meta.env.BASE_URL.replace(/\/$/, ''); 28 | 29 | export const uris = Object.freeze({ 30 | previewCampaign: '/api/campaigns/:id/preview', 31 | previewCampaignArchive: '/api/campaigns/:id/preview/archive', 32 | previewTemplate: '/api/templates/:id/preview', 33 | previewRawTemplate: '/api/templates/preview', 34 | exportSubscribers: '/api/subscribers/export', 35 | errorEvents: '/api/events?type=error', 36 | base: `${baseURL}/static`, 37 | root: rootURL, 38 | static: `${baseURL}/static`, 39 | }); 40 | 41 | // Keys used in Vuex store. 42 | export const storeKeys = Object.freeze({ 43 | models: 'models', 44 | isLoading: 'isLoading', 45 | }); 46 | 47 | export const timestamp = 'ddd D MMM YYYY, hh:mm A'; 48 | 49 | export const colors = Object.freeze({ 50 | primary: '#0055d4', 51 | }); 52 | 53 | export const regDuration = '[0-9]+(ms|s|m|h|d)'; 54 | -------------------------------------------------------------------------------- /frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/TextSidebarPanel.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | 3 | import { TextProps, TextPropsSchema } from '@usewaypoint/block-text'; 4 | 5 | import BaseSidebarPanel from './helpers/BaseSidebarPanel'; 6 | import BooleanInput from './helpers/inputs/BooleanInput'; 7 | import TextInput from './helpers/inputs/TextInput'; 8 | import MultiStylePropertyPanel from './helpers/style-inputs/MultiStylePropertyPanel'; 9 | 10 | type TextSidebarPanelProps = { 11 | data: TextProps; 12 | setData: (v: TextProps) => void; 13 | }; 14 | export default function TextSidebarPanel({ data, setData }: TextSidebarPanelProps) { 15 | const [, setErrors] = useState(null); 16 | 17 | const updateData = (d: unknown) => { 18 | const res = TextPropsSchema.safeParse(d); 19 | if (res.success) { 20 | setData(res.data); 21 | setErrors(null); 22 | } else { 23 | setErrors(res.error); 24 | } 25 | }; 26 | 27 | return ( 28 | 29 | updateData({ ...data, props: { ...data.props, text } })} 34 | /> 35 | updateData({ ...data, props: { ...data.props, markdown } })} 39 | /> 40 | 41 | updateData({ ...data, style })} 45 | /> 46 | 47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /frontend/email-builder/src/documents/blocks/helpers/EditorChildrenIds/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from 'react'; 2 | 3 | import { TEditorBlock } from '../../../editor/core'; 4 | import EditorBlock from '../../../editor/EditorBlock'; 5 | 6 | import AddBlockButton from './AddBlockMenu'; 7 | 8 | export type EditorChildrenChange = { 9 | blockId: string; 10 | block: TEditorBlock; 11 | childrenIds: string[]; 12 | }; 13 | 14 | function generateId() { 15 | return `block-${Date.now()}`; 16 | } 17 | 18 | export type EditorChildrenIdsProps = { 19 | childrenIds: string[] | null | undefined; 20 | onChange: (val: EditorChildrenChange) => void; 21 | }; 22 | export default function EditorChildrenIds({ childrenIds, onChange }: EditorChildrenIdsProps) { 23 | const appendBlock = (block: TEditorBlock) => { 24 | const blockId = generateId(); 25 | return onChange({ 26 | blockId, 27 | block, 28 | childrenIds: [...(childrenIds || []), blockId], 29 | }); 30 | }; 31 | 32 | const insertBlock = (block: TEditorBlock, index: number) => { 33 | const blockId = generateId(); 34 | const newChildrenIds = [...(childrenIds || [])]; 35 | newChildrenIds.splice(index, 0, blockId); 36 | return onChange({ 37 | blockId, 38 | block, 39 | childrenIds: newChildrenIds, 40 | }); 41 | }; 42 | 43 | if (!childrenIds || childrenIds.length === 0) { 44 | return ; 45 | } 46 | 47 | return ( 48 | <> 49 | {childrenIds.map((childId, i) => ( 50 | 51 | insertBlock(block, i)} /> 52 | 53 | 54 | ))} 55 | 56 | 57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /frontend/src/components/LogView.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 65 | -------------------------------------------------------------------------------- /permissions.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "group": "lists", 4 | "permissions": 5 | [ 6 | "lists:get_all", 7 | "lists:manage_all" 8 | ] 9 | }, 10 | { 11 | "group": "subscribers", 12 | "permissions": 13 | [ 14 | "subscribers:get", 15 | "subscribers:get_all", 16 | "subscribers:manage", 17 | "subscribers:import", 18 | "subscribers:sql_query", 19 | "tx:send" 20 | ] 21 | }, 22 | { 23 | "group": "campaigns", 24 | "permissions": 25 | [ 26 | "campaigns:get", 27 | "campaigns:get_all", 28 | "campaigns:get_analytics", 29 | "campaigns:manage", 30 | "campaigns:manage_all" 31 | ] 32 | }, 33 | { 34 | "group": "bounces", 35 | "permissions": 36 | [ 37 | "bounces:get", 38 | "bounces:manage", 39 | "webhooks:post_bounce" 40 | ] 41 | }, 42 | { 43 | "group": "media", 44 | "permissions": 45 | [ 46 | "media:get", 47 | "media:manage" 48 | ] 49 | }, 50 | { 51 | "group": "templates", 52 | "permissions": 53 | [ 54 | "templates:get", 55 | "templates:manage" 56 | ] 57 | }, 58 | { 59 | "group": "users", 60 | "permissions": 61 | [ 62 | "users:get", 63 | "users:manage", 64 | "roles:get", 65 | "roles:manage" 66 | ] 67 | }, 68 | { 69 | "group": "settings", 70 | "permissions": 71 | [ 72 | "settings:get", 73 | "settings:manage", 74 | "settings:maintain" 75 | ] 76 | } 77 | ] 78 | -------------------------------------------------------------------------------- /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 |
44 | {{ L.T "public.poweredBy" }} listmonk 45 |
46 | 47 | 48 | {{ end }} 49 | -------------------------------------------------------------------------------- /frontend/email-builder/src/documents/blocks/helpers/fontFamily.ts: -------------------------------------------------------------------------------- 1 | export const FONT_FAMILIES = [ 2 | { 3 | key: 'MODERN_SANS', 4 | label: 'Modern sans', 5 | value: '"Helvetica Neue", "Arial Nova", "Nimbus Sans", Arial, sans-serif', 6 | }, 7 | { 8 | key: 'BOOK_SANS', 9 | label: 'Book sans', 10 | value: 'Optima, Candara, "Noto Sans", source-sans-pro, sans-serif', 11 | }, 12 | { 13 | key: 'ORGANIC_SANS', 14 | label: 'Organic sans', 15 | value: 'Seravek, "Gill Sans Nova", Ubuntu, Calibri, "DejaVu Sans", source-sans-pro, sans-serif', 16 | }, 17 | { 18 | key: 'GEOMETRIC_SANS', 19 | label: 'Geometric sans', 20 | value: 'Avenir, "Avenir Next LT Pro", Montserrat, Corbel, "URW Gothic", source-sans-pro, sans-serif', 21 | }, 22 | { 23 | key: 'HEAVY_SANS', 24 | label: 'Heavy sans', 25 | value: 26 | 'Bahnschrift, "DIN Alternate", "Franklin Gothic Medium", "Nimbus Sans Narrow", sans-serif-condensed, sans-serif', 27 | }, 28 | { 29 | key: 'ROUNDED_SANS', 30 | label: 'Rounded sans', 31 | value: 32 | 'ui-rounded, "Hiragino Maru Gothic ProN", Quicksand, Comfortaa, Manjari, "Arial Rounded MT Bold", Calibri, source-sans-pro, sans-serif', 33 | }, 34 | { 35 | key: 'MODERN_SERIF', 36 | label: 'Modern serif', 37 | value: 'Charter, "Bitstream Charter", "Sitka Text", Cambria, serif', 38 | }, 39 | { 40 | key: 'BOOK_SERIF', 41 | label: 'Book serif', 42 | value: '"Iowan Old Style", "Palatino Linotype", "URW Palladio L", P052, serif', 43 | }, 44 | { 45 | key: 'MONOSPACE', 46 | label: 'Monospace', 47 | value: '"Nimbus Mono PS", "Courier New", "Cutive Mono", monospace', 48 | }, 49 | ]; 50 | 51 | export const FONT_FAMILY_NAMES = [ 52 | 'MODERN_SANS', 53 | 'BOOK_SANS', 54 | 'ORGANIC_SANS', 55 | 'GEOMETRIC_SANS', 56 | 'HEAVY_SANS', 57 | 'ROUNDED_SANS', 58 | 'MODERN_SERIF', 59 | 'BOOK_SERIF', 60 | 'MONOSPACE', 61 | ] as const; 62 | -------------------------------------------------------------------------------- /frontend/email-builder/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@listmonk/email-builder", 3 | "version": "1.0.0", 4 | "type": "module", 5 | "scripts": { 6 | "dev": "vite", 7 | "build": "vite build", 8 | "preview": "vite preview" 9 | }, 10 | "dependencies": { 11 | "@emotion/react": "^11.11.3", 12 | "@emotion/styled": "^11.11.0", 13 | "@mui/icons-material": "^5.15.10", 14 | "@mui/material": "^5.15.10", 15 | "@usewaypoint/block-avatar": "^0.0.3", 16 | "@usewaypoint/block-button": "^0.0.3", 17 | "@usewaypoint/block-columns-container": "^0.0.3", 18 | "@usewaypoint/block-container": "^0.0.2", 19 | "@usewaypoint/block-divider": "^0.0.4", 20 | "@usewaypoint/block-heading": "^0.0.3", 21 | "@usewaypoint/block-html": "^0.0.3", 22 | "@usewaypoint/block-image": "^0.0.5", 23 | "@usewaypoint/block-spacer": "^0.0.3", 24 | "@usewaypoint/block-text": "^0.0.6", 25 | "@usewaypoint/document-core": "^0.0.6", 26 | "@usewaypoint/email-builder": "^0.0.8", 27 | "highlight.js": "^11.9.0", 28 | "prettier": "^3.2.5", 29 | "react": "^18.2.0", 30 | "react-colorful": "^5.6.1", 31 | "react-dom": "^18.2.0", 32 | "zod": "^3.22.4", 33 | "zustand": "^4.5.1" 34 | }, 35 | "devDependencies": { 36 | "@types/node": "^22.7.4", 37 | "@types/react": "^18.2.55", 38 | "@types/react-dom": "^18.2.19", 39 | "@typescript-eslint/eslint-plugin": "^6.21.0", 40 | "@typescript-eslint/parser": "^6.21.0", 41 | "@vitejs/plugin-react-swc": "^3.5.0", 42 | "eslint": "^8.56.0", 43 | "eslint-plugin-react-hooks": "^4.6.0", 44 | "eslint-plugin-react-refresh": "^0.4.5", 45 | "eslint-plugin-simple-import-sort": "^12.0.0", 46 | "terser": "^5.34.1", 47 | "typescript": "^5.2.2", 48 | "vite": "^5.4.20" 49 | }, 50 | "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" 51 | } 52 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /scripts/translate-i18n.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | from glob import glob 4 | from openai import OpenAI 5 | 6 | client = OpenAI( 7 | # This is the default and can be omitted 8 | api_key=os.environ.get("OPENAI_API_KEY") 9 | ) 10 | 11 | # Keys to translate. If this is empty, all keys are translated. 12 | KEYS = [] 13 | 14 | DEFAULT_LANG = "en.json" 15 | DIR = os.path.normpath(os.path.join( 16 | os.path.dirname(os.path.abspath(__file__)), "../i18n")) 17 | BASE = json.loads(open(os.path.join(DIR, DEFAULT_LANG), "r").read()) 18 | 19 | 20 | def translate(data, lang): 21 | completion = client.chat.completions.create( 22 | model="gpt-4.1-mini", 23 | messages=[ 24 | {"role": "system", "content": "You are an i18n language pack translator for listmonk, a mailing list manager. Remember that context when translating."}, 25 | {"role": "user", 26 | "content": "Translate the untranslated English strings in the following JSON language map to {}. Retain any technical terms or acronyms.".format(lang)}, 27 | {"role": "user", "content": json.dumps(data)} 28 | # {"role": "user", "content": "Hello world good morning!"} 29 | ] 30 | ) 31 | 32 | return json.loads(str(completion.choices[0].message.content)) 33 | 34 | 35 | # Go through every i18n file. 36 | for f in glob(os.path.join(DIR, "*.json")): 37 | if os.path.basename(f) == DEFAULT_LANG: 38 | continue 39 | 40 | print(os.path.basename(f)) 41 | 42 | data = json.loads(open(f, "r").read()) 43 | 44 | # Diff the entire file or only given keys. 45 | if KEYS: 46 | diff = {k: BASE[k] for k in KEYS} 47 | else: 48 | diff = {k: v for k, v in data.items() if BASE.get(k) == v} 49 | 50 | new = translate(diff, data["_.name"]) 51 | data.update(new) 52 | 53 | with open(f, "w") as o: 54 | o.write(json.dumps(data, sort_keys=True, 55 | indent=4, ensure_ascii=False) + "\n") 56 | -------------------------------------------------------------------------------- /frontend/email-builder/src/documents/blocks/helpers/EditorChildrenIds/AddBlockMenu/DividerButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | 3 | import { AddOutlined } from '@mui/icons-material'; 4 | import { Fade, IconButton } from '@mui/material'; 5 | 6 | type Props = { 7 | buttonElement: HTMLElement | null; 8 | onClick: () => void; 9 | }; 10 | export default function DividerButton({ buttonElement, onClick }: Props) { 11 | const [visible, setVisible] = useState(false); 12 | 13 | useEffect(() => { 14 | function listener({ clientX, clientY }: MouseEvent) { 15 | if (!buttonElement) { 16 | return; 17 | } 18 | const rect = buttonElement.getBoundingClientRect(); 19 | const rectY = rect.y; 20 | const bottomX = rect.x; 21 | const topX = bottomX + rect.width; 22 | 23 | if (Math.abs(clientY - rectY) < 20) { 24 | if (bottomX < clientX && clientX < topX) { 25 | setVisible(true); 26 | return; 27 | } 28 | } 29 | setVisible(false); 30 | } 31 | window.addEventListener('mousemove', listener); 32 | return () => { 33 | window.removeEventListener('mousemove', listener); 34 | }; 35 | }, [buttonElement, setVisible]); 36 | 37 | return ( 38 | 39 | { 55 | ev.stopPropagation(); 56 | onClick(); 57 | }} 58 | > 59 | 60 | 61 | 62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /frontend/src/components/editor.js: -------------------------------------------------------------------------------- 1 | const markdownToVisualBlock = (markdown) => { 2 | const lines = markdown.split('\n'); 3 | const blocks = []; 4 | const idBase = Date.now(); 5 | let textBuf = []; 6 | 7 | const createBlock = (type, props, style = {}) => ({ 8 | id: `block-${idBase + blocks.length}`, 9 | type, 10 | data: { 11 | props, 12 | style: { 13 | padding: { 14 | top: 16, bottom: 16, right: 24, left: 24, 15 | }, 16 | ...style, 17 | }, 18 | }, 19 | }); 20 | 21 | const flushText = () => { 22 | if (textBuf.length > 0) { 23 | blocks.push(createBlock('Text', { markdown: true, text: textBuf.join('\n') })); 24 | 25 | textBuf = []; 26 | } 27 | }; 28 | 29 | lines.forEach((line) => { 30 | // Handle ATX headings (# Heading) 31 | const heading = line.match(/^(#+)\s+(.*)/); 32 | if (heading) { 33 | flushText(); 34 | 35 | blocks.push(createBlock('Heading', { 36 | text: heading[2], 37 | level: `h${Math.min(heading[1].length, 6)}`, 38 | })); 39 | return; 40 | } 41 | 42 | // Handle Setext headings (===== or -----) 43 | const trimmed = line.trim(); 44 | if (/^(=+|-+)$/.test(trimmed) && textBuf.length > 0) { 45 | const lastLine = textBuf.pop(); 46 | if (lastLine.trim()) { 47 | flushText(); 48 | 49 | blocks.push(createBlock('Heading', { 50 | text: lastLine, 51 | level: trimmed[0] === '=' ? 'h1' : 'h2', 52 | })); 53 | 54 | return; 55 | } 56 | 57 | textBuf.push(lastLine, line); 58 | } else { 59 | textBuf.push(line); 60 | } 61 | }); 62 | 63 | flushText(); 64 | 65 | return { 66 | root: { 67 | type: 'EmailLayout', 68 | data: { childrenIds: blocks.map((b) => b.id) }, 69 | }, 70 | ...Object.fromEntries(blocks.map((b) => [b.id, { type: b.type, data: b.data }])), 71 | }; 72 | }; 73 | 74 | export default markdownToVisualBlock; 75 | -------------------------------------------------------------------------------- /docs/docs/content/apis/sdks.md: -------------------------------------------------------------------------------- 1 | # SDKs and client libraries 2 | 3 | A list of 3rd party client libraries and SDKs that have been written for listmonk APIs. 4 | 5 | !!! note 6 | The list is community sourced. They have not been verified and are not officially supported. 7 | 8 | - [WordPress - WooCommerce plugin](https://github.com/post-duif/integration-listmonk-wordpress-plugin) integration for listmonk 9 | - [listmonk ](https://github.com/mikeckennedy/listmonk) — Python API client 10 | - [listmonk-api](https://github.com/Knuckles-Team/listmonk-api) — Python API client 11 | - [frappe_listmonk](https://github.com/anandology/frappe_listmonk) — Frappe framework integration for listmonk 12 | - [auto-newsletter-listmonk](https://github.com/chaddyc/auto-newsletter-listmonk) — Ghost CMS integration 13 | - [listmonk-newsletter](https://github.com/iloveitaly/listmonk-newsletter) - RSS to listmonk integration for email newsletters 14 | - [listmonk-crysctal](https://github.com/russ/listmonk-crystal) — Crystal lang API client 15 | - [terraform-provider-listmonk](https://github.com/Muravlev/terraform-provider-listmonk) — Manage listmonk templates in Terraform 16 | - [listmonk-php-client](https://github.com/arunnabraham/listmonk-php-client) — PHP API client 17 | - [php-listmonk](https://github.com/junisan/php-listmonk) — PHP API client 18 | - [go-listmonk](https://github.com/EzeXchange-API/go-listmonk) — Go API client 19 | - [listmonk-nodejs-api](https://github.com/mihairaulea/listmonk-nodejs-api) — NodeJS API client 20 | - [listmonk-laravel](https://github.com/theafolayan/listmonk-laravel) — Laravel API Client 21 | - [nuxt-listmonk](https://github.com/roncallyt/nuxt-listmonk) — Listmonk module for Nuxt.js 22 | - [listmonk-japi](https://codeberg.org/hlassiege/listmonk-japi) - Listmonk client for Java/kotlin 23 | - [listmonk-mcp](https://github.com/rhnvrm/listmonk-mcp) — MCP (Model Context Protocol) server for Claude integration 24 | - [N8N Nodes](https://github.com/wiesinghilker/n8n-nodes-listmonk) — Adds Listmonk Nodes for N8N Automations 25 | -------------------------------------------------------------------------------- /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 | 52 | // UpdateSettingsByKey updates a single setting by key. 53 | func (c *Core) UpdateSettingsByKey(key string, value json.RawMessage) error { 54 | if _, err := c.q.UpdateSettingsByKey.Exec(key, value); err != nil { 55 | return echo.NewHTTPError(http.StatusInternalServerError, 56 | c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.settings}", "error", pqErrMsg(err))) 57 | } 58 | 59 | return nil 60 | } 61 | -------------------------------------------------------------------------------- /frontend/email-builder/src/documents/blocks/ColumnsContainer/ColumnsContainerEditor.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { ColumnsContainer as BaseColumnsContainer } from '@usewaypoint/block-columns-container'; 4 | 5 | import { useCurrentBlockId } from '../../editor/EditorBlock'; 6 | import { setDocument, setSelectedBlockId } from '../../editor/EditorContext'; 7 | import EditorChildrenIds, { EditorChildrenChange } from '../helpers/EditorChildrenIds'; 8 | 9 | import ColumnsContainerPropsSchema, { ColumnsContainerProps } from './ColumnsContainerPropsSchema'; 10 | 11 | const EMPTY_COLUMNS = [{ childrenIds: [] }, { childrenIds: [] }, { childrenIds: [] }]; 12 | 13 | export default function ColumnsContainerEditor({ style, props }: ColumnsContainerProps) { 14 | const currentBlockId = useCurrentBlockId(); 15 | 16 | const { columns, ...restProps } = props ?? {}; 17 | const columnsValue = columns ?? EMPTY_COLUMNS; 18 | 19 | const updateColumn = (columnIndex: 0 | 1 | 2, { block, blockId, childrenIds }: EditorChildrenChange) => { 20 | const nColumns = [...columnsValue]; 21 | nColumns[columnIndex] = { childrenIds }; 22 | setDocument({ 23 | [blockId]: block, 24 | [currentBlockId]: { 25 | type: 'ColumnsContainer', 26 | data: ColumnsContainerPropsSchema.parse({ 27 | style, 28 | props: { 29 | ...restProps, 30 | columns: nColumns, 31 | }, 32 | }), 33 | }, 34 | }); 35 | setSelectedBlockId(blockId); 36 | }; 37 | 38 | return ( 39 | updateColumn(0, change)} />, 44 | updateColumn(1, change)} />, 45 | updateColumn(2, change)} />, 46 | ]} 47 | /> 48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /frontend/email-builder/src/App/InspectorDrawer/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { 4 | Box, Drawer, Tab, Tabs, 5 | } from '@mui/material'; 6 | 7 | import { setSidebarTab, useInspectorDrawerOpen, useSelectedSidebarTab } from '../../documents/editor/EditorContext'; 8 | 9 | import ConfigurationPanel from './ConfigurationPanel'; 10 | import StylesPanel from './StylesPanel'; 11 | 12 | export const INSPECTOR_DRAWER_WIDTH = 320; 13 | 14 | export default function InspectorDrawer() { 15 | const selectedSidebarTab = useSelectedSidebarTab(); 16 | const inspectorDrawerOpen = useInspectorDrawerOpen(); 17 | 18 | const renderCurrentSidebarPanel = () => { 19 | switch (selectedSidebarTab) { 20 | case 'block-configuration': 21 | return ; 22 | case 'styles': 23 | return ; 24 | } 25 | }; 26 | 27 | return ( 28 | 43 | 47 | 48 | setSidebarTab(v)}> 49 | 50 | 51 | 52 | 53 | 54 | 55 | {renderCurrentSidebarPanel()} 56 | 57 | 58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /docs/docs/content/maintenance/performance.md: -------------------------------------------------------------------------------- 1 | # Performance 2 | 3 | listmonk is built to be highly performant and can handle millions of subscribers with minimal system resources. 4 | 5 | However, as the Postgres database grows—with a large number of subscribers, campaign views, and click records—it can significantly slow down certain aspects of the program, particularly in counting records and aggregating various statistics. For instance, loading admin pages that do these aggregations can take tens of seconds if the database has millions of subscribers. 6 | 7 | - Aggregate counts, statistics, and charts on the landing dashboard. 8 | - Subscriber count beside every list on the Lists page. 9 | - Total subscriber count on the Subscribers page. 10 | 11 | However, at that scale, viewing the exact number of subscribers or statistics every time the admin panel is accessed becomes mostly unnecessary. On installations with millions of subscribers, where the above pages do not load instantly, it is highly recommended to turn on the `Settings -> Performance -> Cache slow database queries` option. 12 | 13 | ## Slow query caching 14 | 15 | When this option is enabled, the subscriber counts on the Lists page, the Subscribers page, and the statistics on the dashboard, etc., are no longer counted in real-time in the database. Instead, they are updated periodically and cached, resulting in a massive performance boost. The periodicity can be configured on the Settings -> Performance page using a standard crontab expression (default: `0 3 * * *`, which means 3 AM daily). Use a tool like [crontab.guru](https://crontab.guru) for easily generating a desired crontab expression. 16 | 17 | ## VACUUM-ing 18 | Running [`VACUUM ANALYZE`](https://www.postgresql.org/docs/current/sql-vacuum.html) on large Postgres databases at regular intervals (for instance, once a week), is recommended. It reclaims disk space and improves Postgres' query performance. Do note that this is a blocking operation and all database queries can come to a stand-still on a large database while the operation is running (generally only a few seconds). 19 | -------------------------------------------------------------------------------- /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 | features: 18 | - navigation.indexes 19 | - navigation.sections 20 | - content.code.copy 21 | 22 | palette: 23 | primary: "white" 24 | accent: "red" 25 | 26 | site_dir: _out 27 | docs_dir: content 28 | 29 | markdown_extensions: 30 | - admonition 31 | - pymdownx.highlight 32 | - pymdownx.superfences 33 | - toc: 34 | permalink: true 35 | 36 | extra_css: 37 | - "static/style.css" 38 | 39 | copyright: "CC BY-SA 4.0" 40 | 41 | nav: 42 | - "Introduction": index.md 43 | - "Getting Started": 44 | - "Installation": installation.md 45 | - "Configuration": configuration.md 46 | - "Upgrade": upgrade.md 47 | - "Using listmonk": 48 | - "Concepts": concepts.md 49 | - "Templating": templating.md 50 | - "Querying and segmenting subscribers": querying-and-segmentation.md 51 | - "Bounce processing": bounces.md 52 | - "Messengers": "messengers.md" 53 | - "Archives": "archives.md" 54 | - "Internationalization": "i18n.md" 55 | - "Integrating with external systems": external-integration.md 56 | - "User roles and permissions": roles-and-permissions.md 57 | - "OIDC SSO": oidc.md 58 | - "API": 59 | - "Introduction": apis/apis.md 60 | - "SDKs and libs": apis/sdks.md 61 | - "Subscribers": apis/subscribers.md 62 | - "Lists": apis/lists.md 63 | - "Import": apis/import.md 64 | - "Campaigns": apis/campaigns.md 65 | - "Media": apis/media.md 66 | - "Templates": apis/templates.md 67 | - "Transactional": apis/transactional.md 68 | - "Bounces": apis/bounces.md 69 | - "Maintenance": 70 | - "Performance": maintenance/performance.md 71 | - "Contributions": 72 | - "Developer setup": developer-setup.md 73 | -------------------------------------------------------------------------------- /frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/inputs/ColorInput/Picker.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { HexColorInput, HexColorPicker } from 'react-colorful'; 3 | 4 | import { Box, Stack, SxProps } from '@mui/material'; 5 | 6 | import Swatch from './Swatch'; 7 | 8 | const DEFAULT_PRESET_COLORS = [ 9 | '#E11D48', 10 | '#DB2777', 11 | '#C026D3', 12 | '#9333EA', 13 | '#7C3AED', 14 | '#4F46E5', 15 | '#2563EB', 16 | '#0284C7', 17 | '#0891B2', 18 | '#0D9488', 19 | '#059669', 20 | '#16A34A', 21 | '#65A30D', 22 | '#CA8A04', 23 | '#D97706', 24 | '#EA580C', 25 | '#DC2626', 26 | '#FFFFFF', 27 | '#FAFAFA', 28 | '#F5F5F5', 29 | '#E5E5E5', 30 | '#D4D4D4', 31 | '#A3A3A3', 32 | '#737373', 33 | '#525252', 34 | '#404040', 35 | '#262626', 36 | '#171717', 37 | '#0A0A0A', 38 | '#000000', 39 | ]; 40 | 41 | const SX: SxProps = { 42 | p: 1, 43 | '.react-colorful__pointer ': { 44 | width: 16, 45 | height: 16, 46 | }, 47 | '.react-colorful__saturation': { 48 | mb: 1, 49 | borderRadius: '4px', 50 | }, 51 | '.react-colorful__last-control': { 52 | borderRadius: '4px', 53 | }, 54 | '.react-colorful__hue-pointer': { 55 | width: '4px', 56 | borderRadius: '4px', 57 | height: 24, 58 | cursor: 'col-resize', 59 | }, 60 | '.react-colorful__saturation-pointer': { 61 | cursor: 'all-scroll', 62 | }, 63 | input: { 64 | padding: 1, 65 | border: '1px solid', 66 | borderColor: 'grey.300', 67 | borderRadius: '4px', 68 | width: '100%', 69 | }, 70 | }; 71 | 72 | type Props = { 73 | value: string; 74 | onChange: (v: string) => void; 75 | }; 76 | export default function Picker({ value, onChange }: Props) { 77 | return ( 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | ); 86 | } 87 | -------------------------------------------------------------------------------- /frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/DividerSidebarPanel.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | 3 | import { HeightOutlined } from '@mui/icons-material'; 4 | import { DividerProps, DividerPropsDefaults, DividerPropsSchema } from '@usewaypoint/block-divider'; 5 | 6 | import BaseSidebarPanel from './helpers/BaseSidebarPanel'; 7 | import ColorInput from './helpers/inputs/ColorInput'; 8 | import SliderInput from './helpers/inputs/SliderInput'; 9 | import MultiStylePropertyPanel from './helpers/style-inputs/MultiStylePropertyPanel'; 10 | 11 | type DividerSidebarPanelProps = { 12 | data: DividerProps; 13 | setData: (v: DividerProps) => void; 14 | }; 15 | export default function DividerSidebarPanel({ data, setData }: DividerSidebarPanelProps) { 16 | const [, setErrors] = useState(null); 17 | const updateData = (d: unknown) => { 18 | const res = DividerPropsSchema.safeParse(d); 19 | if (res.success) { 20 | setData(res.data); 21 | setErrors(null); 22 | } else { 23 | setErrors(res.error); 24 | } 25 | }; 26 | 27 | const lineColor = data.props?.lineColor ?? DividerPropsDefaults.lineColor; 28 | const lineHeight = data.props?.lineHeight ?? DividerPropsDefaults.lineHeight; 29 | 30 | return ( 31 | 32 | updateData({ ...data, props: { ...data.props, lineColor } })} 36 | /> 37 | } 40 | units="px" 41 | step={1} 42 | min={1} 43 | max={24} 44 | defaultValue={lineHeight} 45 | onChange={(lineHeight) => updateData({ ...data, props: { ...data.props, lineHeight } })} 46 | /> 47 | updateData({ ...data, style })} 51 | /> 52 | 53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /frontend/src/assets/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /static/public/static/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.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 | github_token: ${{ secrets.GITHUB_TOKEN }} 51 | spec-file: ./docs/swagger/collections.yaml 52 | output: ./docs/publish/docs/swagger 53 | 54 | - name: Deploy 55 | uses: peaceiris/actions-gh-pages@v3 56 | with: 57 | github_token: ${{ secrets.GITHUB_TOKEN }} 58 | publish_branch: gh-pages 59 | publish_dir: ./docs/publish 60 | cname: listmonk.app 61 | user_name: 'github-actions[bot]' 62 | user_email: 'github-actions[bot]@users.noreply.github.com' 63 | -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/helpers/inputs/ColumnWidthsInput.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | 3 | import { Stack } from '@mui/material'; 4 | 5 | import TextDimensionInput from './TextDimensionInput'; 6 | 7 | export const DEFAULT_2_COLUMNS = [6] as [number]; 8 | export const DEFAULT_3_COLUMNS = [4, 8] as [number, number]; 9 | 10 | type TWidthValue = number | null | undefined; 11 | type FixedWidths = [ 12 | // 13 | number | null | undefined, 14 | number | null | undefined, 15 | number | null | undefined, 16 | ]; 17 | type ColumnsLayoutInputProps = { 18 | defaultValue: FixedWidths | null | undefined; 19 | onChange: (v: FixedWidths | null | undefined) => void; 20 | }; 21 | export default function ColumnWidthsInput({ defaultValue, onChange }: ColumnsLayoutInputProps) { 22 | const [currentValue, setCurrentValue] = useState<[TWidthValue, TWidthValue, TWidthValue]>(() => { 23 | if (defaultValue) { 24 | return defaultValue; 25 | } 26 | return [null, null, null]; 27 | }); 28 | 29 | const setIndexValue = (index: 0 | 1 | 2, value: number | null | undefined) => { 30 | const nValue: FixedWidths = [...currentValue]; 31 | nValue[index] = value; 32 | setCurrentValue(nValue); 33 | onChange(nValue); 34 | }; 35 | 36 | const columnsCountValue = 3; 37 | let column3 = null; 38 | if (columnsCountValue === 3) { 39 | column3 = ( 40 | { 44 | setIndexValue(2, v); 45 | }} 46 | /> 47 | ); 48 | } 49 | return ( 50 | 51 | { 55 | setIndexValue(0, v); 56 | }} 57 | /> 58 | { 62 | setIndexValue(1, v); 63 | }} 64 | /> 65 | {column3} 66 | 67 | ); 68 | } 69 | -------------------------------------------------------------------------------- /frontend/email-builder/src/App/InspectorDrawer/ConfigurationPanel/input-panels/HeadingSidebarPanel.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | 3 | import { ToggleButton } from '@mui/material'; 4 | import { HeadingProps, HeadingPropsDefaults, HeadingPropsSchema } from '@usewaypoint/block-heading'; 5 | 6 | import BaseSidebarPanel from './helpers/BaseSidebarPanel'; 7 | import RadioGroupInput from './helpers/inputs/RadioGroupInput'; 8 | import TextInput from './helpers/inputs/TextInput'; 9 | import MultiStylePropertyPanel from './helpers/style-inputs/MultiStylePropertyPanel'; 10 | 11 | type HeadingSidebarPanelProps = { 12 | data: HeadingProps; 13 | setData: (v: HeadingProps) => void; 14 | }; 15 | export default function HeadingSidebarPanel({ data, setData }: HeadingSidebarPanelProps) { 16 | const [, setErrors] = useState(null); 17 | 18 | const updateData = (d: unknown) => { 19 | const res = HeadingPropsSchema.safeParse(d); 20 | if (res.success) { 21 | setData(res.data); 22 | setErrors(null); 23 | } else { 24 | setErrors(res.error); 25 | } 26 | }; 27 | 28 | return ( 29 | 30 | { 35 | updateData({ ...data, props: { ...data.props, text } }); 36 | }} 37 | /> 38 | { 42 | updateData({ ...data, props: { ...data.props, level } }); 43 | }} 44 | > 45 | H1 46 | H2 47 | H3 48 | 49 | updateData({ ...data, style })} 53 | /> 54 | 55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /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 | You can run your dev environment locally or inside containers. 22 | 23 | After setting up the dev environment, you can visit `http://localhost:8080`. 24 | 25 | 26 | 1. Locally 27 | 28 | - Run `make run` to start the listmonk dev server on `:9000`. 29 | - 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. 30 | 31 | 2. Inside containers (Using Makefile) 32 | 33 | - Run `make init-dev-docker` to setup container for db. 34 | - Run `make dev-docker` to setup docker container suite. 35 | - Run `make rm-dev-docker` to clean up docker container suite. 36 | 37 | 3. Inside containers (Using devcontainer) 38 | 39 | - Open repo in vscode, open command palette, and select "Dev Containers: Rebuild and Reopen in Container". 40 | 41 | It will set up db, and start frontend/backend for you. 42 | 43 | 44 | # Production build 45 | Run `make dist` to build the Go binary, build the Javascript frontend, and embed the static assets producing a single self-contained binary, `listmonk` 46 | -------------------------------------------------------------------------------- /internal/media/providers/filesystem/filesystem.go: -------------------------------------------------------------------------------- 1 | package filesystem 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/knadh/listmonk/internal/media" 10 | ) 11 | 12 | // Opts represents filesystem params 13 | type Opts struct { 14 | UploadPath string `koanf:"upload_path"` 15 | UploadURI string `koanf:"upload_uri"` 16 | RootURL string `koanf:"root_url"` 17 | } 18 | 19 | // Client implements `media.Store` 20 | type Client struct { 21 | opts Opts 22 | } 23 | 24 | // New initialises store for Filesystem provider. 25 | func New(opts Opts) (media.Store, error) { 26 | return &Client{ 27 | opts: opts, 28 | }, nil 29 | } 30 | 31 | // Put accepts the filename, the content type and file object itself and stores the file in disk. 32 | func (c *Client) Put(filename string, cType string, src io.ReadSeeker) (string, error) { 33 | // Get the directory path 34 | dir := getDir(c.opts.UploadPath) 35 | 36 | // Read the file contents. 37 | out, err := os.OpenFile(filepath.Join(dir, filename), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0664) 38 | if err != nil { 39 | return "", err 40 | } 41 | defer out.Close() 42 | 43 | // Copy it to the target location. 44 | if _, err := io.Copy(out, src); err != nil { 45 | return "", err 46 | } 47 | 48 | return filename, nil 49 | } 50 | 51 | // GetURL accepts a filename and retrieves the full path from disk. 52 | func (c *Client) GetURL(name string) string { 53 | return fmt.Sprintf("%s%s/%s", c.opts.RootURL, c.opts.UploadURI, name) 54 | } 55 | 56 | // GetBlob accepts a URL, reads the file, and returns the blob. 57 | func (c *Client) GetBlob(url string) ([]byte, error) { 58 | b, err := os.ReadFile(filepath.Join(getDir(c.opts.UploadPath), filepath.Base(url))) 59 | return b, err 60 | } 61 | 62 | // Delete accepts a filename and removes it from disk. 63 | func (c *Client) Delete(file string) error { 64 | dir := getDir(c.opts.UploadPath) 65 | 66 | err := os.Remove(filepath.Join(dir, file)) 67 | return err 68 | } 69 | 70 | // getDir returns the current working directory path if no directory is specified, 71 | // else returns the directory path specified itself. 72 | func getDir(dir string) string { 73 | if dir == "" { 74 | dir, _ = os.Getwd() 75 | } 76 | 77 | return dir 78 | } 79 | -------------------------------------------------------------------------------- /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 |
43 | -------------------------------------------------------------------------------- /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 database as its data store. 6 | 7 | [![listmonk-dashboard](https://github.com/user-attachments/assets/689b5fbb-dd25-4956-a36f-e3226a65f9c4)](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). 16 | Download and use the sample [docker-compose.yml](https://github.com/knadh/listmonk/blob/master/docker-compose.yml). 17 | 18 | 19 | ```shell 20 | # Download the compose file to the current directory. 21 | curl -LO https://github.com/knadh/listmonk/raw/master/docker-compose.yml 22 | 23 | # Run the services in the background. 24 | docker compose up -d 25 | ``` 26 | Visit `http://localhost:9000` 27 | 28 | See [installation docs](https://listmonk.app/docs/installation) 29 | 30 | __________________ 31 | 32 | ### Binary 33 | - Download the [latest release](https://github.com/knadh/listmonk/releases) and extract the listmonk binary. 34 | - `./listmonk --new-config` to generate config.toml. Edit it. 35 | - `./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). 36 | - Run `./listmonk` and visit `http://localhost:9000` 37 | 38 | See [installation docs](https://listmonk.app/docs/installation) 39 | __________________ 40 | 41 | 42 | ## Developers 43 | listmonk is free and open source software licensed under AGPLv3. If you are interested in contributing, refer to the [developer setup](https://listmonk.app/docs/developer-setup). The backend is written in Go and the frontend is Vue with Buefy for UI. 44 | 45 | 46 | ## License 47 | listmonk is licensed under the AGPL v3 license. 48 | -------------------------------------------------------------------------------- /queries/roles.sql: -------------------------------------------------------------------------------- 1 | -- name: get-user-roles 2 | WITH mainroles AS ( 3 | SELECT ur.* FROM roles ur WHERE type = 'user' AND ur.parent_id IS NULL AND 4 | CASE WHEN $1::INT != 0 THEN ur.id = $1 ELSE TRUE END 5 | ), 6 | listPerms AS ( 7 | SELECT ur.parent_id, JSONB_AGG(JSONB_BUILD_OBJECT('id', ur.list_id, 'name', lists.name, 'permissions', ur.permissions)) AS listPerms 8 | FROM roles ur 9 | LEFT JOIN lists ON(lists.id = ur.list_id) 10 | WHERE ur.parent_id IS NOT NULL GROUP BY ur.parent_id 11 | ) 12 | SELECT p.*, COALESCE(l.listPerms, '[]'::JSONB) AS "list_permissions" FROM mainroles p 13 | LEFT JOIN listPerms l ON p.id = l.parent_id ORDER BY p.created_at; 14 | 15 | -- name: get-list-roles 16 | WITH mainroles AS ( 17 | SELECT ur.* FROM roles ur WHERE type = 'list' AND ur.parent_id IS NULL 18 | ), 19 | listPerms AS ( 20 | SELECT ur.parent_id, JSONB_AGG(JSONB_BUILD_OBJECT('id', ur.list_id, 'name', lists.name, 'permissions', ur.permissions)) AS listPerms 21 | FROM roles ur 22 | LEFT JOIN lists ON(lists.id = ur.list_id) 23 | WHERE ur.parent_id IS NOT NULL GROUP BY ur.parent_id 24 | ) 25 | SELECT p.*, COALESCE(l.listPerms, '[]'::JSONB) AS "list_permissions" FROM mainroles p 26 | LEFT JOIN listPerms l ON p.id = l.parent_id ORDER BY p.created_at; 27 | 28 | 29 | -- name: create-role 30 | INSERT INTO roles (name, type, permissions, created_at, updated_at) VALUES($1, $2, $3, NOW(), NOW()) RETURNING *; 31 | 32 | -- name: upsert-list-permissions 33 | WITH d AS ( 34 | -- Delete lists that aren't included. 35 | DELETE FROM roles WHERE parent_id = $1 AND list_id != ALL($2::INT[]) 36 | ), 37 | p AS ( 38 | -- Get (list_id, perms[]), (list_id, perms[]) 39 | SELECT UNNEST($2) AS list_id, JSONB_ARRAY_ELEMENTS(TO_JSONB($3::TEXT[][])) AS perms 40 | ) 41 | INSERT INTO roles (parent_id, list_id, permissions, type) 42 | SELECT $1, list_id, ARRAY_REMOVE(ARRAY(SELECT JSONB_ARRAY_ELEMENTS_TEXT(perms)), ''), 'list' FROM p 43 | ON CONFLICT (parent_id, list_id) DO UPDATE SET permissions = EXCLUDED.permissions; 44 | 45 | -- name: delete-list-permission 46 | DELETE FROM roles WHERE parent_id=$1 AND list_id=$2; 47 | 48 | -- name: update-role 49 | UPDATE roles SET name=$2, permissions=$3 WHERE id=$1 and parent_id IS NULL RETURNING *; 50 | 51 | -- name: delete-role 52 | DELETE FROM roles WHERE id=$1; 53 | -------------------------------------------------------------------------------- /docs/site/static/static/images/logo-freebsd.svg: -------------------------------------------------------------------------------- 1 | 2 | 15 | 17 | 35 | 39 | 42 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /internal/migrations/v2.5.0.go: -------------------------------------------------------------------------------- 1 | package migrations 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/jmoiron/sqlx" 7 | "github.com/knadh/koanf/v2" 8 | "github.com/knadh/stuffbin" 9 | ) 10 | 11 | // V2_5_0 performs the DB migrations. 12 | func V2_5_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error { 13 | // Insert new preference settings. 14 | if _, err := db.Exec(` 15 | INSERT INTO settings (key, value) VALUES 16 | ('upload.extensions', '["jpg","jpeg","png","gif","svg","*"]'), 17 | ('app.enable_public_archive_rss_content', 'false'), 18 | ('bounce.actions', '{"soft": {"count": 2, "action": "none"}, "hard": {"count": 2, "action": "blocklist"}, "complaint" : {"count": 2, "action": "blocklist"}}'), 19 | ('privacy.record_optin_ip', 'false') 20 | ON CONFLICT DO NOTHING; 21 | `); err != nil { 22 | return err 23 | } 24 | 25 | if _, err := db.Exec(` 26 | DELETE FROM settings WHERE key IN ('bounce.count', 'bounce.action'); 27 | 28 | -- Add the content_type column. 29 | ALTER TABLE media ADD COLUMN IF NOT EXISTS content_type TEXT NOT NULL DEFAULT 'application/octet-stream'; 30 | 31 | -- Add meta column to subscriptions. 32 | ALTER TABLE subscriber_lists ADD COLUMN IF NOT EXISTS meta JSONB NOT NULL DEFAULT '{}'; 33 | 34 | -- Fill the content type column for existing files (which would only be images at this point). 35 | UPDATE media SET content_type = CASE 36 | WHEN LOWER(SUBSTRING(filename FROM '.([^.]+)$')) = 'svg' THEN 'image/svg+xml' 37 | ELSE 'image/' || LOWER(SUBSTRING(filename FROM '.([^.]+)$')) 38 | END; 39 | `); err != nil { 40 | return err 41 | } 42 | 43 | if _, err := db.Exec(` 44 | CREATE TABLE IF NOT EXISTS campaign_media ( 45 | campaign_id INTEGER REFERENCES campaigns(id) ON DELETE CASCADE ON UPDATE CASCADE, 46 | 47 | -- Media items may be deleted, so media_id is nullable 48 | -- and a copy of the original name is maintained here. 49 | media_id INTEGER NULL REFERENCES media(id) ON DELETE SET NULL ON UPDATE CASCADE, 50 | 51 | filename TEXT NOT NULL DEFAULT '' 52 | ); 53 | CREATE UNIQUE INDEX IF NOT EXISTS idx_camp_media_id ON campaign_media (campaign_id, media_id); 54 | CREATE INDEX IF NOT EXISTS idx_camp_media_camp_id ON campaign_media(campaign_id); 55 | `); err != nil { 56 | return err 57 | } 58 | 59 | return nil 60 | } 61 | --------------------------------------------------------------------------------