Compare commits
263 commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
95dabe593b | ||
![]() |
e06d379953 | ||
![]() |
7c991677eb | ||
![]() |
fa506643a4 | ||
![]() |
53eb71a83b | ||
![]() |
2ce2a11c7e | ||
![]() |
52ee79bf86 | ||
![]() |
524be2753b | ||
![]() |
75befe5214 | ||
![]() |
4577868567 | ||
![]() |
c59825f3a5 | ||
![]() |
06b4494200 | ||
![]() |
82c74cd544 | ||
![]() |
4c8c19ebb9 | ||
![]() |
d439ecfd84 | ||
![]() |
ef084956b4 | ||
![]() |
44d3462559 | ||
![]() |
62be5e2181 | ||
![]() |
49ec11ca9c | ||
![]() |
e8ecdf8cde | ||
![]() |
ff135ecee3 | ||
![]() |
be4be729a9 | ||
![]() |
f8a55f84fa | ||
![]() |
9e3af91dc5 | ||
![]() |
f720e88b44 | ||
![]() |
b29f565b68 | ||
![]() |
3641f74c64 | ||
![]() |
ccceaa6e92 | ||
![]() |
c7882bb220 | ||
![]() |
fd05a6d17d | ||
![]() |
06332d52a0 | ||
![]() |
1ebd80c577 | ||
![]() |
491fab38cb | ||
![]() |
764a052ecc | ||
![]() |
c911aeb20e | ||
![]() |
954ed45009 | ||
![]() |
a61a9d8c04 | ||
![]() |
99c71a2a0a | ||
![]() |
82c3c6878b | ||
![]() |
04e571d43a | ||
![]() |
8f2a08b8db | ||
![]() |
11f90b2f62 | ||
![]() |
5af6252b14 | ||
![]() |
717a6362d2 | ||
![]() |
7019f26280 | ||
![]() |
9423c74d96 | ||
![]() |
2b95c88188 | ||
![]() |
e5ac111747 | ||
![]() |
34d86fc387 | ||
![]() |
5664e5cc9f | ||
![]() |
83c88d73d7 | ||
![]() |
16eeb9401e | ||
![]() |
e317f2c5ff | ||
![]() |
25513b8104 | ||
![]() |
eefcbc30a3 | ||
![]() |
52a7298a9d | ||
![]() |
6af904dbd4 | ||
![]() |
07a9632860 | ||
![]() |
4b05ab1920 | ||
![]() |
e2f1313566 | ||
![]() |
fcdea6050e | ||
![]() |
2888dcdd45 | ||
![]() |
a6a2b69820 | ||
![]() |
79ff7293ea | ||
![]() |
32b979eeb6 | ||
![]() |
2f07435816 | ||
![]() |
e258ffda38 | ||
![]() |
f3e9b08026 | ||
![]() |
ea2f93ea06 | ||
![]() |
54979c5d0e | ||
![]() |
14e283768b | ||
![]() |
0562e5de8d | ||
![]() |
f4f51d11c5 | ||
![]() |
b1fa28980e | ||
![]() |
acca61f2ca | ||
![]() |
1164afac5e | ||
![]() |
eafae46409 | ||
![]() |
104c4fc993 | ||
![]() |
a1c507b477 | ||
![]() |
2215511f2c | ||
![]() |
dcb87a39b7 | ||
![]() |
e89b9ffb30 | ||
![]() |
a440b79530 | ||
![]() |
2bfbae74ab | ||
![]() |
3865e95296 | ||
![]() |
93db7c45bc | ||
![]() |
ad80c716f9 | ||
![]() |
b26950c427 | ||
![]() |
a62851915c | ||
![]() |
0be5901df7 | ||
![]() |
534c87509a | ||
![]() |
7dd7166bcf | ||
![]() |
3663a8b10b | ||
![]() |
e1c0bf5030 | ||
![]() |
d69b766a3a | ||
![]() |
329b645a3d | ||
![]() |
1894af121f | ||
![]() |
c581fe2f3a | ||
![]() |
5b404615fc | ||
![]() |
530165f5ee | ||
![]() |
f94c1f34b6 | ||
![]() |
92a4d9911e | ||
![]() |
5e24ef9848 | ||
![]() |
f577522fe1 | ||
![]() |
e0cda4b35c | ||
![]() |
a2da75ce98 | ||
![]() |
0b2da4c664 | ||
![]() |
63bc00d8e3 | ||
![]() |
d359ad27aa | ||
![]() |
3b9a0f782e | ||
![]() |
cb2a579252 | ||
![]() |
cbbbe402be | ||
![]() |
5a2627932d | ||
![]() |
917696a543 | ||
![]() |
9ffc912a2c | ||
![]() |
be16297549 | ||
![]() |
e332622db9 | ||
![]() |
39a627d839 | ||
![]() |
9381e086a1 | ||
![]() |
8c46b758ce | ||
![]() |
101459f2f6 | ||
![]() |
81ac6276bd | ||
![]() |
5fc28a733c | ||
![]() |
13ad9adb8b | ||
![]() |
3baf18ea45 | ||
![]() |
98729f63df | ||
![]() |
476d5bebf2 | ||
![]() |
146e8e7a63 | ||
![]() |
0d4c1d1471 | ||
![]() |
7db3d7da0f | ||
![]() |
71a9138b23 | ||
![]() |
ad53632be4 | ||
![]() |
b433ef68ec | ||
![]() |
a95510260d | ||
![]() |
d1d0922a8c | ||
![]() |
ec7a246afc | ||
![]() |
d4fb3a3399 | ||
![]() |
5d3c10d198 | ||
![]() |
684c15a404 | ||
![]() |
152bd37c22 | ||
![]() |
5aedc3a4f6 | ||
![]() |
bf72154727 | ||
![]() |
4821dd7c66 | ||
![]() |
d87a01fad8 | ||
![]() |
eb1d4a3c2a | ||
![]() |
c668523c57 | ||
![]() |
553a61b4d2 | ||
![]() |
1bb9123333 | ||
![]() |
3646e6d20a | ||
![]() |
55f7eca2e8 | ||
![]() |
4181d8a0c5 | ||
![]() |
6cf82347bf | ||
![]() |
aaf5080a27 | ||
![]() |
35ddf3c899 | ||
![]() |
5020bae77e | ||
![]() |
09fe812dd7 | ||
![]() |
8d1f30c101 | ||
![]() |
d6fd4ab586 | ||
![]() |
da377d83e6 | ||
![]() |
dda7d44601 | ||
![]() |
215aae5366 | ||
![]() |
7be73d59d6 | ||
![]() |
80592f60c6 | ||
![]() |
2c531eb1d6 | ||
![]() |
2de72eac56 | ||
![]() |
e77635cdd2 | ||
![]() |
3513988a07 | ||
![]() |
2ed62cb82e | ||
![]() |
bfc8a0cb3b | ||
![]() |
eb9f6876b0 | ||
![]() |
274d86422c | ||
![]() |
4977b746b9 | ||
![]() |
fd655312ab | ||
![]() |
b339482d89 | ||
![]() |
755344e74b | ||
![]() |
8985e5c24a | ||
![]() |
62d3782d04 | ||
![]() |
72d22d40ef | ||
![]() |
66c81c8191 | ||
![]() |
7832248a08 | ||
![]() |
076b7c7a0a | ||
![]() |
3cfbc646e3 | ||
![]() |
5d4f1ea0ad | ||
![]() |
1f693b80f7 | ||
![]() |
96d30d6725 | ||
![]() |
49f20f33db | ||
![]() |
4dee2e9a1b | ||
![]() |
396f85d273 | ||
![]() |
ba46769fcf | ||
![]() |
8a2d053353 | ||
![]() |
c773dc0abc | ||
![]() |
e71c060b69 | ||
![]() |
12b2bdf70a | ||
![]() |
a555fd3876 | ||
![]() |
448f0e3428 | ||
![]() |
8e3e1b9af8 | ||
![]() |
93260396c6 | ||
![]() |
175982443e | ||
![]() |
18746b7116 | ||
![]() |
3a562749dd | ||
![]() |
3a89bfd54b | ||
![]() |
d6a3635192 | ||
![]() |
8f8e83fbb6 | ||
![]() |
a42f635ae7 | ||
![]() |
8d4a5751d8 | ||
![]() |
e60b38527c | ||
![]() |
4b551ef679 | ||
![]() |
de2e5a11aa | ||
![]() |
395ae987da | ||
![]() |
d30ef227ad | ||
![]() |
73bb6081fc | ||
![]() |
c52a06728c | ||
![]() |
b2853fd67f | ||
![]() |
4f2f419ae2 | ||
![]() |
6fcb4ff978 | ||
![]() |
2761a5e033 | ||
![]() |
1e90feecaf | ||
![]() |
832a426f4c | ||
![]() |
af4b532a00 | ||
![]() |
a8193d80c8 | ||
![]() |
d1307c6a2c | ||
![]() |
818f2c9d8e | ||
![]() |
f958f3d24b | ||
![]() |
438568eeb0 | ||
![]() |
eac1240437 | ||
![]() |
23fb178ec4 | ||
![]() |
ebf63b5bed | ||
![]() |
eaaca05f36 | ||
![]() |
56a9836e86 | ||
![]() |
9add728b08 | ||
![]() |
74322cda36 | ||
![]() |
c2d41e0671 | ||
![]() |
d613bb5a44 | ||
![]() |
6d3ae4cc73 | ||
![]() |
fd70776166 | ||
![]() |
879bff854e | ||
![]() |
d8e3e25f06 | ||
![]() |
ef1f84ee7c | ||
![]() |
95b8df2918 | ||
![]() |
3b0083190e | ||
![]() |
372a144322 | ||
![]() |
281c47198c | ||
![]() |
d2e8a9368c | ||
![]() |
c38100427d | ||
![]() |
c3d04a5490 | ||
![]() |
db2fd9ad70 | ||
![]() |
bea1680360 | ||
![]() |
9c94efb863 | ||
![]() |
5b8c705f03 | ||
![]() |
edb4c9168d | ||
![]() |
f266f93cc8 | ||
![]() |
5a9e9209c8 | ||
![]() |
f9bc049271 | ||
![]() |
6d820f4f6e | ||
![]() |
8eb4f7e7da | ||
![]() |
8ace25849e | ||
![]() |
4ef7a6a1ee | ||
![]() |
76df9c8d76 | ||
![]() |
13068ccce2 | ||
![]() |
a5c14a17d3 | ||
![]() |
6b11020a67 | ||
![]() |
c60412dcb3 | ||
![]() |
aaac82a5c9 |
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
name: Bug report
|
||||
about: Report an issue that you have identified to be a bug
|
||||
name: Confirmed bug
|
||||
about: Report an issue that you have definititely confirmed to be a bug
|
||||
title: ''
|
||||
labels: bug
|
||||
assignees: ''
|
18
.github/ISSUE_TEMPLATE/possible-bug--needs-investigation-.md
vendored
Normal file
|
@ -0,0 +1,18 @@
|
|||
---
|
||||
name: Possible bug. Needs investigation.
|
||||
about: Report an issue that could be a bug but is not confirmed yet and needs investigation.
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Version:**
|
||||
- listmonk: [eg: v1.0.0]
|
||||
- OS: [e.g. Fedora]
|
||||
|
||||
**Description of the bug and steps to reproduce:**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**Screenshots:**
|
||||
If applicable, add screenshots to help explain your problem.
|
61
.github/workflows/github-pages.yml
vendored
Normal file
|
@ -0,0 +1,61 @@
|
|||
name: publish-github-pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- 'docs/**'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
submodules: true # Fetch Hugo themes
|
||||
fetch-depth: 0 # Fetch all history for .GitInfo and .Lastmod
|
||||
|
||||
- uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: 3.x
|
||||
- run: pip install mkdocs-material
|
||||
|
||||
- name: Setup Hugo
|
||||
uses: peaceiris/actions-hugo@v2
|
||||
with:
|
||||
hugo-version: '0.68.3'
|
||||
|
||||
# Build the main site to the docs/publish directory. This will be the root (/) in gh-pages.
|
||||
# The -d (output) path is relative to the -s (source) path
|
||||
- name: Build main site
|
||||
run: hugo -s docs/site -d ../publish --gc --minify
|
||||
|
||||
# Build the mkdocs documentation in the docs/publish/docs dir. This will be at (/docs)
|
||||
# The -d (output) path is relative to the -f (source) path
|
||||
- name: Build docs site
|
||||
run: mkdocs build -f docs/docs/mkdocs.yml -d ../publish/docs
|
||||
|
||||
# Copy the static i18n app to the publish directory. This will be at (/i18n)
|
||||
- name: Copy i18n site
|
||||
run: cp -R docs/i18n docs/publish
|
||||
|
||||
- name: Generate Swagger UI
|
||||
uses: Legion2/swagger-ui-action@v1
|
||||
with:
|
||||
spec-file: ./docs/swagger/collections.yaml
|
||||
output: ./docs/publish/docs/swagger
|
||||
|
||||
- name: Deploy
|
||||
uses: peaceiris/actions-gh-pages@v3
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_branch: gh-pages
|
||||
publish_dir: ./docs/publish
|
||||
cname: listmonk.app
|
||||
user_name: 'github-actions[bot]'
|
||||
user_email: 'github-actions[bot]@users.noreply.github.com'
|
28
.github/workflows/release.yml
vendored
|
@ -5,34 +5,50 @@ on:
|
|||
tags:
|
||||
- "v*" # Will trigger only if tag is pushed matching pattern `v*` (Eg: `v0.1.0`)
|
||||
|
||||
permissions: write-all
|
||||
|
||||
jobs:
|
||||
goreleaser:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v2
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: 1.17
|
||||
go-version: "1.20"
|
||||
|
||||
- name: Login to Docker Registry
|
||||
uses: docker/login-action@v1
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Docker Registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Prepare Dependencies
|
||||
run: |
|
||||
make dist
|
||||
|
||||
- name: Check Docker Version
|
||||
run: |
|
||||
docker version
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v2
|
||||
uses: goreleaser/goreleaser-action@v4
|
||||
with:
|
||||
version: latest
|
||||
args: --parallelism 1 --rm-dist --skip-validate
|
||||
args: release --parallelism 1 --clean --skip-validate
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
|
135
.goreleaser.yml
|
@ -1,6 +1,8 @@
|
|||
env:
|
||||
- GO111MODULE=on
|
||||
- CGO_ENABLED=0
|
||||
- GITHUB_ORG=knadh
|
||||
- DOCKER_ORG=listmonk
|
||||
|
||||
before:
|
||||
hooks:
|
||||
|
@ -10,16 +12,21 @@ builds:
|
|||
- binary: listmonk
|
||||
main: ./cmd
|
||||
goos:
|
||||
- linux
|
||||
- windows
|
||||
- darwin
|
||||
- linux
|
||||
- freebsd
|
||||
- openbsd
|
||||
- netbsd
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
- arm
|
||||
goarm:
|
||||
- 6
|
||||
- 7
|
||||
ldflags:
|
||||
- -s -w -X "main.buildString={{ .Tag }} ({{ .ShortCommit }} {{ .Date }})" -X "main.versionString={{ .Tag }}"
|
||||
- -s -w -X "main.buildString={{ .Tag }} ({{ .ShortCommit }} {{ .Date }}, {{ .Os }}/{{ .Arch }})" -X "main.versionString={{ .Tag }}"
|
||||
|
||||
hooks:
|
||||
# stuff executables with static assets.
|
||||
|
@ -32,15 +39,127 @@ archives:
|
|||
- LICENSE
|
||||
|
||||
dockers:
|
||||
-
|
||||
- use: buildx
|
||||
goos: linux
|
||||
goarch: amd64
|
||||
ids:
|
||||
- listmonk
|
||||
- listmonk
|
||||
image_templates:
|
||||
- "listmonk/listmonk:latest"
|
||||
- "listmonk/listmonk:{{ .Tag }}"
|
||||
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-amd64"
|
||||
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-amd64"
|
||||
- "ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:latest-amd64"
|
||||
- "ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:{{ .Tag }}-amd64"
|
||||
build_flag_templates:
|
||||
- --platform=linux/amd64
|
||||
- --label=org.opencontainers.image.title={{ .ProjectName }}
|
||||
- --label=org.opencontainers.image.description={{ .ProjectName }}
|
||||
- --label=org.opencontainers.image.url=https://github.com/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}
|
||||
- --label=org.opencontainers.image.source=https://github.com/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}
|
||||
- --label=org.opencontainers.image.version={{ .Version }}
|
||||
- --label=org.opencontainers.image.created={{ time "2006-01-02T15:04:05Z07:00" }}
|
||||
- --label=org.opencontainers.image.revision={{ .FullCommit }}
|
||||
- --label=org.opencontainers.image.licenses=AGPL-3.0
|
||||
dockerfile: Dockerfile
|
||||
extra_files:
|
||||
- config.toml.sample
|
||||
- config-demo.toml
|
||||
- config.toml.sample
|
||||
- config-demo.toml
|
||||
- use: buildx
|
||||
goos: linux
|
||||
goarch: arm64
|
||||
ids:
|
||||
- listmonk
|
||||
image_templates:
|
||||
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-arm64v8"
|
||||
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-arm64v8"
|
||||
- "ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:latest-arm64v8"
|
||||
- "ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:{{ .Tag }}-arm64v8"
|
||||
build_flag_templates:
|
||||
- --platform=linux/arm64/v8
|
||||
- --label=org.opencontainers.image.title={{ .ProjectName }}
|
||||
- --label=org.opencontainers.image.description={{ .ProjectName }}
|
||||
- --label=org.opencontainers.image.url=https://github.com/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}
|
||||
- --label=org.opencontainers.image.source=https://github.com/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}
|
||||
- --label=org.opencontainers.image.version={{ .Version }}
|
||||
- --label=org.opencontainers.image.created={{ time "2006-01-02T15:04:05Z07:00" }}
|
||||
- --label=org.opencontainers.image.revision={{ .FullCommit }}
|
||||
- --label=org.opencontainers.image.licenses=AGPL-3.0
|
||||
dockerfile: Dockerfile
|
||||
extra_files:
|
||||
- config.toml.sample
|
||||
- config-demo.toml
|
||||
- use: buildx
|
||||
goos: linux
|
||||
goarch: arm
|
||||
goarm: 6
|
||||
ids:
|
||||
- listmonk
|
||||
image_templates:
|
||||
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-armv6"
|
||||
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-armv6"
|
||||
- "ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:latest-armv6"
|
||||
- "ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:{{ .Tag }}-armv6"
|
||||
build_flag_templates:
|
||||
- --platform=linux/arm/v6
|
||||
- --label=org.opencontainers.image.title={{ .ProjectName }}
|
||||
- --label=org.opencontainers.image.description={{ .ProjectName }}
|
||||
- --label=org.opencontainers.image.url=https://github.com/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}
|
||||
- --label=org.opencontainers.image.source=https://github.com/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}
|
||||
- --label=org.opencontainers.image.version={{ .Version }}
|
||||
- --label=org.opencontainers.image.created={{ time "2006-01-02T15:04:05Z07:00" }}
|
||||
- --label=org.opencontainers.image.revision={{ .FullCommit }}
|
||||
- --label=org.opencontainers.image.licenses=AGPL-3.0
|
||||
dockerfile: Dockerfile
|
||||
extra_files:
|
||||
- config.toml.sample
|
||||
- config-demo.toml
|
||||
- use: buildx
|
||||
goos: linux
|
||||
goarch: arm
|
||||
goarm: 7
|
||||
ids:
|
||||
- listmonk
|
||||
image_templates:
|
||||
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-armv7"
|
||||
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-armv7"
|
||||
- "ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:latest-armv7"
|
||||
- "ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:{{ .Tag }}-armv7"
|
||||
build_flag_templates:
|
||||
- --platform=linux/arm/v7
|
||||
- --label=org.opencontainers.image.title={{ .ProjectName }}
|
||||
- --label=org.opencontainers.image.description={{ .ProjectName }}
|
||||
- --label=org.opencontainers.image.url=https://github.com/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}
|
||||
- --label=org.opencontainers.image.source=https://github.com/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}
|
||||
- --label=org.opencontainers.image.version={{ .Version }}
|
||||
- --label=org.opencontainers.image.created={{ time "2006-01-02T15:04:05Z07:00" }}
|
||||
- --label=org.opencontainers.image.revision={{ .FullCommit }}
|
||||
- --label=org.opencontainers.image.licenses=AGPL-3.0
|
||||
dockerfile: Dockerfile
|
||||
extra_files:
|
||||
- config.toml.sample
|
||||
- config-demo.toml
|
||||
|
||||
docker_manifests:
|
||||
- name_template: "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest"
|
||||
image_templates:
|
||||
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-amd64"
|
||||
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-arm64v8"
|
||||
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-armv6"
|
||||
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:latest-armv7"
|
||||
- name_template: "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}"
|
||||
image_templates:
|
||||
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-amd64"
|
||||
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-arm64v8"
|
||||
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-armv6"
|
||||
- "{{ .Env.DOCKER_ORG }}/{{ .ProjectName }}:{{ .Tag }}-armv7"
|
||||
- name_template: ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:latest
|
||||
image_templates:
|
||||
- ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:latest-amd64
|
||||
- ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:latest-arm64v8
|
||||
- ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:latest-armv6
|
||||
- ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:latest-armv7
|
||||
- name_template: ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:{{ .Tag }}
|
||||
image_templates:
|
||||
- ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:{{ .Tag }}-amd64
|
||||
- ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:{{ .Tag }}-arm64v8
|
||||
- ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:{{ .Tag }}-armv6
|
||||
- ghcr.io/{{ .Env.GITHUB_ORG }}/{{ .ProjectName }}:{{ .Tag }}-armv7
|
||||
|
|
|
@ -13,7 +13,7 @@ Welcome to listmonk! You can contribute to the project in the following ways:
|
|||
3. It is the responsibility of the requester to clearly explain and justify why a change is warranted. It is not the responsibility of the maintainers to coax this information out of a requester. So, please post well researched, well thought out, and detailed feature requests saving everyone time.
|
||||
4. Maintainers may close unclear feature requests that lack enough information. [Suggest a feature here](https://github.com/knadh/listmonk/issues/new?assignees=&labels=enhancement&template=feature-or-change-request.md&title=).
|
||||
|
||||
3. **Improving docs:** You can submit corrections and improvements to the [documentation](https://listmonk.app/docs) website on the [docs repo](https://github.com/knadh/listmonk-site).
|
||||
3. **Improving docs:** You can submit corrections and improvements to the [documentation](https://listmonk.app/docs) website on the [docs repo](https://github.com/knadh/listmonk/tree/master/docs).
|
||||
|
||||
4. **i18n translations:** The project is available in many languages thanks to user contributions. You can create a new language pack or submit corrections to existing ones. There is a UI available for making translations easy. [More info here](https://listmonk.app/docs/i18n/).
|
||||
|
||||
|
|
12
Makefile
|
@ -1,8 +1,8 @@
|
|||
# Try to get the commit hash from 1) git 2) the VERSION file 3) fallback.
|
||||
LAST_COMMIT := $(or $(shell git rev-parse --short HEAD 2> /dev/null),$(shell head -n 1 VERSION | grep -oP -m 1 "^[a-z0-9]+$$"),"UNKNOWN")
|
||||
LAST_COMMIT := $(or $(shell git rev-parse --short HEAD 2> /dev/null),$(shell head -n 1 VERSION | grep -oP -m 1 "^[a-z0-9]+$$"),"")
|
||||
|
||||
# Try to get the semver from 1) git 2) the VERSION file 3) fallback.
|
||||
VERSION := $(or $(shell git describe --tags --abbrev=0 2> /dev/null),$(shell grep -oP "tag: \K(.*)(?=,)" VERSION),"v0.0.0")
|
||||
VERSION := $(or $(shell git describe --tags --abbrev=0 2> /dev/null),$(shell grep -oP 'tag: \Kv\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?' VERSION),"v0.0.0")
|
||||
|
||||
BUILDSTR := ${VERSION} (\#${LAST_COMMIT} $(shell date -u +"%Y-%m-%dT%H:%M:%S%z"))
|
||||
|
||||
|
@ -89,13 +89,13 @@ release:
|
|||
.PHONY: build-dev-docker
|
||||
build-dev-docker: build ## Build docker containers for the entire suite (Front/Core/PG).
|
||||
cd dev; \
|
||||
docker-compose build ; \
|
||||
docker compose build ; \
|
||||
|
||||
# Spin a local docker suite for local development.
|
||||
.PHONY: dev-docker
|
||||
dev-docker: build-dev-docker ## Build and spawns docker containers for the entire suite (Front/Core/PG).
|
||||
cd dev; \
|
||||
docker-compose up
|
||||
docker compose up
|
||||
|
||||
# Run the backend in docker-dev mode. The frontend assets in dev mode are loaded from disk from frontend/dist.
|
||||
.PHONY: run-backend-docker
|
||||
|
@ -106,10 +106,10 @@ run-backend-docker:
|
|||
.PHONY: rm-dev-docker
|
||||
rm-dev-docker: build ## Delete the docker containers including DB volumes.
|
||||
cd dev; \
|
||||
docker-compose down -v ; \
|
||||
docker compose down -v ; \
|
||||
|
||||
# Setup the db for local dev docker suite.
|
||||
.PHONY: init-dev-docker
|
||||
init-dev-docker: build-dev-docker ## Delete the docker containers including DB volumes.
|
||||
cd dev; \
|
||||
docker-compose run --rm backend sh -c "make dist && ./listmonk --install --idempotent --yes --config dev/config.toml"
|
||||
docker compose run --rm backend sh -c "make dist && ./listmonk --install --idempotent --yes --config dev/config.toml"
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
<a href="https://zerodha.tech"><img src="https://zerodha.tech/static/images/github-badge.svg" align="right" /></a>
|
||||
|
||||
[](https://listmonk.app)
|
||||
[](https://listmonk.app)
|
||||
|
||||
listmonk is a standalone, self-hosted, newsletter and mailing list manager. It is fast, feature-rich, and packed into a single binary. It uses a PostgreSQL (⩾ v9.4) database as its data store.
|
||||
listmonk is a standalone, self-hosted, newsletter and mailing list manager. It is fast, feature-rich, and packed into a single binary. It uses a PostgreSQL (⩾ 12) database as its data store.
|
||||
|
||||
[](https://listmonk.app)
|
||||
|
||||
|
|
8
TODO.md
|
@ -1,8 +0,0 @@
|
|||
- [ ] Add a "running campaigns" widget on the dashboard
|
||||
- [ ] Add more analytics and stats
|
||||
- [ ] Add bounce tracking
|
||||
- [ ] Pause campaigns on % errors in addition to an absolute numbers
|
||||
- [ ] Support DB migrations for easy upgrades
|
||||
- [ ] Add materialized views for analytics and stats (and more?)
|
||||
- [ ] Add user management and permissions
|
||||
- [ ] Add tests
|
|
@ -89,7 +89,7 @@ func handleReloadApp(c echo.Context) error {
|
|||
app := c.Get("app").(*App)
|
||||
go func() {
|
||||
<-time.After(time.Millisecond * 500)
|
||||
app.sigChan <- syscall.SIGHUP
|
||||
app.chReload <- syscall.SIGHUP
|
||||
}()
|
||||
return c.JSON(http.StatusOK, okResp{true})
|
||||
}
|
||||
|
|
261
cmd/archive.go
Normal file
|
@ -0,0 +1,261 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/feeds"
|
||||
"github.com/knadh/listmonk/internal/manager"
|
||||
"github.com/knadh/listmonk/models"
|
||||
"github.com/labstack/echo/v4"
|
||||
null "gopkg.in/volatiletech/null.v6"
|
||||
)
|
||||
|
||||
type campArchive struct {
|
||||
UUID string `json:"uuid"`
|
||||
Subject string `json:"subject"`
|
||||
Content string `json:"content"`
|
||||
CreatedAt null.Time `json:"created_at"`
|
||||
SendAt null.Time `json:"send_at"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// handleGetCampaignArchives renders the public campaign archives page.
|
||||
func handleGetCampaignArchives(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
pg = app.paginator.NewFromURL(c.Request().URL.Query())
|
||||
)
|
||||
|
||||
camps, total, err := getCampaignArchives(pg.Offset, pg.Limit, false, app)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var out models.PageResults
|
||||
if len(camps) == 0 {
|
||||
out.Results = []campArchive{}
|
||||
return c.JSON(http.StatusOK, okResp{out})
|
||||
}
|
||||
|
||||
// Meta.
|
||||
out.Results = camps
|
||||
out.Total = total
|
||||
out.Page = pg.Page
|
||||
out.PerPage = pg.PerPage
|
||||
|
||||
return c.JSON(200, okResp{out})
|
||||
}
|
||||
|
||||
// handleGetCampaignArchivesFeed renders the public campaign archives RSS feed.
|
||||
func handleGetCampaignArchivesFeed(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
pg = app.paginator.NewFromURL(c.Request().URL.Query())
|
||||
showFullContent = app.constants.EnablePublicArchiveRSSContent
|
||||
)
|
||||
|
||||
camps, _, err := getCampaignArchives(pg.Offset, pg.Limit, showFullContent, app)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out := make([]*feeds.Item, 0, len(camps))
|
||||
for _, c := range camps {
|
||||
pubDate := c.CreatedAt.Time
|
||||
|
||||
if c.SendAt.Valid {
|
||||
pubDate = c.SendAt.Time
|
||||
}
|
||||
|
||||
out = append(out, &feeds.Item{
|
||||
Title: c.Subject,
|
||||
Link: &feeds.Link{Href: c.URL},
|
||||
Content: c.Content,
|
||||
Created: pubDate,
|
||||
})
|
||||
}
|
||||
|
||||
feed := &feeds.Feed{
|
||||
Title: app.constants.SiteName,
|
||||
Link: &feeds.Link{Href: app.constants.RootURL},
|
||||
Description: app.i18n.T("public.archiveTitle"),
|
||||
Items: out,
|
||||
}
|
||||
|
||||
if err := feed.WriteRss(c.Response().Writer); err != nil {
|
||||
app.log.Printf("error generating archive RSS feed: %v", err)
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("public.errorProcessingRequest"))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleCampaignArchivesPage renders the public campaign archives page.
|
||||
func handleCampaignArchivesPage(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
pg = app.paginator.NewFromURL(c.Request().URL.Query())
|
||||
)
|
||||
|
||||
out, total, err := getCampaignArchives(pg.Offset, pg.Limit, false, app)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pg.SetTotal(total)
|
||||
|
||||
title := app.i18n.T("public.archiveTitle")
|
||||
return c.Render(http.StatusOK, "archive", struct {
|
||||
Title string
|
||||
Description string
|
||||
Campaigns []campArchive
|
||||
TotalPages int
|
||||
Pagination template.HTML
|
||||
}{title, title, out, pg.TotalPages, template.HTML(pg.HTML("?page=%d"))})
|
||||
}
|
||||
|
||||
// handleCampaignArchivePage renders the public campaign archives page.
|
||||
func handleCampaignArchivePage(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
uuid = c.Param("uuid")
|
||||
)
|
||||
|
||||
pubCamp, err := app.core.GetArchivedCampaign(0, uuid)
|
||||
if err != nil || pubCamp.Type != models.CampaignTypeRegular {
|
||||
notFound := false
|
||||
if er, ok := err.(*echo.HTTPError); ok {
|
||||
if er.Code == http.StatusBadRequest {
|
||||
notFound = true
|
||||
}
|
||||
} else if pubCamp.Type != models.CampaignTypeRegular {
|
||||
notFound = true
|
||||
}
|
||||
|
||||
if notFound {
|
||||
return c.Render(http.StatusNotFound, tplMessage,
|
||||
makeMsgTpl(app.i18n.T("public.notFoundTitle"), "", app.i18n.T("public.campaignNotFound")))
|
||||
}
|
||||
|
||||
return c.Render(http.StatusInternalServerError, tplMessage,
|
||||
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorFetchingCampaign")))
|
||||
}
|
||||
|
||||
out, err := compileArchiveCampaigns([]models.Campaign{pubCamp}, app)
|
||||
if err != nil {
|
||||
return c.Render(http.StatusInternalServerError, tplMessage,
|
||||
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorFetchingCampaign")))
|
||||
}
|
||||
|
||||
// Render the message body.
|
||||
camp := out[0].Campaign
|
||||
msg, err := app.manager.NewCampaignMessage(camp, out[0].Subscriber)
|
||||
if err != nil {
|
||||
app.log.Printf("error rendering message: %v", err)
|
||||
return c.Render(http.StatusInternalServerError, tplMessage,
|
||||
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorFetchingCampaign")))
|
||||
}
|
||||
|
||||
return c.HTML(http.StatusOK, string(msg.Body()))
|
||||
}
|
||||
|
||||
// handleCampaignArchivePageLatest renders the latest public campaign.
|
||||
func handleCampaignArchivePageLatest(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
)
|
||||
|
||||
camps, _, err := getCampaignArchives(0, 1, true, app)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(camps) == 0 {
|
||||
return c.Render(http.StatusNotFound, tplMessage,
|
||||
makeMsgTpl(app.i18n.T("public.notFoundTitle"), "", app.i18n.T("public.campaignNotFound")))
|
||||
}
|
||||
|
||||
camp := camps[0]
|
||||
|
||||
return c.HTML(http.StatusOK, camp.Content)
|
||||
}
|
||||
|
||||
func getCampaignArchives(offset, limit int, renderBody bool, app *App) ([]campArchive, int, error) {
|
||||
pubCamps, total, err := app.core.GetArchivedCampaigns(offset, limit)
|
||||
if err != nil {
|
||||
return []campArchive{}, total, echo.NewHTTPError(http.StatusInternalServerError, app.i18n.T("public.errorFetchingCampaign"))
|
||||
}
|
||||
|
||||
msgs, err := compileArchiveCampaigns(pubCamps, app)
|
||||
if err != nil {
|
||||
return []campArchive{}, total, err
|
||||
}
|
||||
|
||||
out := make([]campArchive, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
camp := m.Campaign
|
||||
|
||||
archive := campArchive{
|
||||
UUID: camp.UUID,
|
||||
Subject: camp.Subject,
|
||||
CreatedAt: camp.CreatedAt,
|
||||
SendAt: camp.SendAt,
|
||||
URL: app.constants.ArchiveURL + "/" + camp.UUID,
|
||||
}
|
||||
|
||||
if renderBody {
|
||||
msg, err := app.manager.NewCampaignMessage(camp, m.Subscriber)
|
||||
if err != nil {
|
||||
return []campArchive{}, total, err
|
||||
}
|
||||
archive.Content = string(msg.Body())
|
||||
}
|
||||
|
||||
out = append(out, archive)
|
||||
}
|
||||
|
||||
return out, total, nil
|
||||
}
|
||||
|
||||
func compileArchiveCampaigns(camps []models.Campaign, app *App) ([]manager.CampaignMessage, error) {
|
||||
var (
|
||||
b = bytes.Buffer{}
|
||||
)
|
||||
|
||||
out := make([]manager.CampaignMessage, 0, len(camps))
|
||||
for _, c := range camps {
|
||||
camp := c
|
||||
if err := camp.CompileTemplate(app.manager.TemplateFuncs(&camp)); err != nil {
|
||||
app.log.Printf("error compiling template: %v", err)
|
||||
return nil, echo.NewHTTPError(http.StatusInternalServerError, app.i18n.T("public.errorFetchingCampaign"))
|
||||
}
|
||||
|
||||
// Load the dummy subscriber meta.
|
||||
var sub models.Subscriber
|
||||
if err := json.Unmarshal([]byte(camp.ArchiveMeta), &sub); err != nil {
|
||||
app.log.Printf("error unmarshalling campaign archive meta: %v", err)
|
||||
return nil, echo.NewHTTPError(http.StatusInternalServerError, app.i18n.T("public.errorFetchingCampaign"))
|
||||
}
|
||||
|
||||
m := manager.CampaignMessage{
|
||||
Campaign: &camp,
|
||||
Subscriber: sub,
|
||||
}
|
||||
|
||||
// Render the subject if it's a template.
|
||||
if camp.SubjectTpl != nil {
|
||||
if err := camp.SubjectTpl.ExecuteTemplate(&b, models.ContentTpl, m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
camp.Subject = b.String()
|
||||
b.Reset()
|
||||
|
||||
}
|
||||
|
||||
out = append(out, m)
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
|
@ -2,7 +2,7 @@ package main
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io/ioutil"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
@ -15,7 +15,7 @@ import (
|
|||
func handleGetBounces(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
pg = getPagination(c.QueryParams(), 50)
|
||||
pg = app.paginator.NewFromURL(c.Request().URL.Query())
|
||||
|
||||
id, _ = strconv.Atoi(c.Param("id"))
|
||||
campID, _ = strconv.Atoi(c.QueryParam("campaign_id"))
|
||||
|
@ -121,7 +121,7 @@ func handleBounceWebhook(c echo.Context) error {
|
|||
)
|
||||
|
||||
// Read the request body instead of using c.Bind() to read to save the entire raw request as meta.
|
||||
rawReq, err := ioutil.ReadAll(c.Request().Body)
|
||||
rawReq, err := io.ReadAll(c.Request().Body)
|
||||
if err != nil {
|
||||
app.log.Printf("error reading ses notification body: %v", err)
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.internalError"))
|
||||
|
@ -132,7 +132,7 @@ func handleBounceWebhook(c echo.Context) error {
|
|||
case service == "":
|
||||
var b models.Bounce
|
||||
if err := json.Unmarshal(rawReq, &b); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidData"))
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidData")+":"+err.Error())
|
||||
}
|
||||
|
||||
if bv, err := validateBounceFields(b, app); err != nil {
|
||||
|
@ -191,6 +191,19 @@ func handleBounceWebhook(c echo.Context) error {
|
|||
}
|
||||
bounces = append(bounces, bs...)
|
||||
|
||||
// Postmark.
|
||||
case service == "postmark" && app.constants.BouncePostmarkEnabled:
|
||||
bs, err := app.bounce.Postmark.ProcessBounce(rawReq, c)
|
||||
if err != nil {
|
||||
app.log.Printf("error processing postmark notification: %v", err)
|
||||
if _, ok := err.(*echo.HTTPError); ok {
|
||||
return err
|
||||
}
|
||||
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidData"))
|
||||
}
|
||||
bounces = append(bounces, bs...)
|
||||
|
||||
default:
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("bounces.unknownService"))
|
||||
}
|
||||
|
@ -207,11 +220,11 @@ func handleBounceWebhook(c echo.Context) error {
|
|||
|
||||
func validateBounceFields(b models.Bounce, app *App) (models.Bounce, error) {
|
||||
if b.Email == "" && b.SubscriberUUID == "" {
|
||||
return b, echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidData"))
|
||||
return b, echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "email / subscriber_uuid"))
|
||||
}
|
||||
|
||||
if b.SubscriberUUID != "" && !reUUID.MatchString(b.SubscriberUUID) {
|
||||
return b, echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidUUID"))
|
||||
return b, echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "subscriber_uuid"))
|
||||
}
|
||||
|
||||
if b.Email != "" {
|
||||
|
@ -222,8 +235,8 @@ func validateBounceFields(b models.Bounce, app *App) (models.Bounce, error) {
|
|||
b.Email = em
|
||||
}
|
||||
|
||||
if b.Type != models.BounceTypeHard && b.Type != models.BounceTypeSoft {
|
||||
return b, echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidData"))
|
||||
if b.Type != models.BounceTypeHard && b.Type != models.BounceTypeSoft && b.Type != models.BounceTypeComplaint {
|
||||
return b, echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "type"))
|
||||
}
|
||||
|
||||
return b, nil
|
||||
|
|
|
@ -2,6 +2,7 @@ package main
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
|
@ -31,6 +32,8 @@ type campaignReq struct {
|
|||
// to the outside world.
|
||||
ListIDs []int `json:"lists"`
|
||||
|
||||
MediaIDs []int `json:"media"`
|
||||
|
||||
// This is only relevant to campaign test requests.
|
||||
SubscriberEmails pq.StringArray `json:"subscribers"`
|
||||
}
|
||||
|
@ -51,7 +54,7 @@ var (
|
|||
func handleGetCampaigns(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
pg = getPagination(c.QueryParams(), 20)
|
||||
pg = app.paginator.NewFromURL(c.Request().URL.Query())
|
||||
|
||||
status = c.QueryParams()["status"]
|
||||
query = strings.TrimSpace(c.FormValue("query"))
|
||||
|
@ -215,7 +218,11 @@ func handleCreateCampaign(c echo.Context) error {
|
|||
o = c
|
||||
}
|
||||
|
||||
out, err := app.core.CreateCampaign(o.Campaign, o.ListIDs)
|
||||
if o.ArchiveTemplateID == 0 {
|
||||
o.ArchiveTemplateID = o.TemplateID
|
||||
}
|
||||
|
||||
out, err := app.core.CreateCampaign(o.Campaign, o.ListIDs, o.MediaIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -259,7 +266,7 @@ func handleUpdateCampaign(c echo.Context) error {
|
|||
o = c
|
||||
}
|
||||
|
||||
out, err := app.core.UpdateCampaign(id, o.Campaign, o.ListIDs, o.SendLater)
|
||||
out, err := app.core.UpdateCampaign(id, o.Campaign, o.ListIDs, o.MediaIDs, o.SendLater)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -294,6 +301,31 @@ func handleUpdateCampaignStatus(c echo.Context) error {
|
|||
return c.JSON(http.StatusOK, okResp{out})
|
||||
}
|
||||
|
||||
// handleUpdateCampaignArchive handles campaign status modification.
|
||||
func handleUpdateCampaignArchive(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
id, _ = strconv.Atoi(c.Param("id"))
|
||||
)
|
||||
|
||||
req := struct {
|
||||
Archive bool `json:"archive"`
|
||||
TemplateID int `json:"archive_template_id"`
|
||||
Meta models.JSON `json:"archive_meta"`
|
||||
}{}
|
||||
|
||||
// Get and validate fields.
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := app.core.UpdateCampaignArchive(id, req.Archive, req.TemplateID, req.Meta); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, okResp{true})
|
||||
}
|
||||
|
||||
// handleDeleteCampaign handles campaign deletion.
|
||||
// Only scheduled campaigns that have not started yet can be deleted.
|
||||
func handleDeleteCampaign(c echo.Context) error {
|
||||
|
@ -407,6 +439,11 @@ func handleTestCampaign(c echo.Context) error {
|
|||
camp.ContentType = req.ContentType
|
||||
camp.Headers = req.Headers
|
||||
camp.TemplateID = req.TemplateID
|
||||
for _, id := range req.MediaIDs {
|
||||
if id > 0 {
|
||||
camp.MediaIDs = append(camp.MediaIDs, int64(id))
|
||||
}
|
||||
}
|
||||
|
||||
// Send the test messages.
|
||||
for _, s := range subs {
|
||||
|
@ -501,10 +538,6 @@ func validateCampaignFields(c campaignReq, app *App) (campaignReq, error) {
|
|||
return c, errors.New(app.i18n.T("campaigns.fieldInvalidSubject"))
|
||||
}
|
||||
|
||||
// if !hasLen(c.Body, 1, bodyMaxLen) {
|
||||
// return c,errors.New("invalid length for `body`")
|
||||
// }
|
||||
|
||||
// If there's a "send_at" date, it should be in the future.
|
||||
if c.SendAt.Valid {
|
||||
if c.SendAt.Time.Before(time.Now()) {
|
||||
|
@ -529,6 +562,10 @@ func validateCampaignFields(c campaignReq, app *App) (campaignReq, error) {
|
|||
c.Headers = make([]map[string]string, 0)
|
||||
}
|
||||
|
||||
if len(c.ArchiveMeta) == 0 {
|
||||
c.ArchiveMeta = json.RawMessage("{}")
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
|
|
54
cmd/events.go
Normal file
|
@ -0,0 +1,54 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// handleEventStream serves an endpoint that never closes and pushes a
|
||||
// live event stream (text/event-stream) such as a error messages.
|
||||
func handleEventStream(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
)
|
||||
|
||||
h := c.Response().Header()
|
||||
h.Set(echo.HeaderContentType, "text/event-stream")
|
||||
h.Set(echo.HeaderCacheControl, "no-store")
|
||||
h.Set(echo.HeaderConnection, "keep-alive")
|
||||
|
||||
// Subscribe to the event stream with a random ID.
|
||||
id := fmt.Sprintf("api:%v", time.Now().UnixNano())
|
||||
sub, err := app.events.Subscribe(id)
|
||||
if err != nil {
|
||||
log.Fatalf("error subscribing to events: %v", err)
|
||||
}
|
||||
|
||||
ctx := c.Request().Context()
|
||||
for {
|
||||
select {
|
||||
case e := <-sub:
|
||||
b, err := json.Marshal(e)
|
||||
if err != nil {
|
||||
app.log.Printf("error marshalling event: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf("data: %s\n\n", b)
|
||||
|
||||
c.Response().Write([]byte(fmt.Sprintf("retry: 3000\ndata: %s\n\n", b)))
|
||||
c.Response().Flush()
|
||||
|
||||
case <-ctx.Done():
|
||||
// On HTTP connection close, unsubscribe.
|
||||
app.events.Unsubscribe(id)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
|
@ -3,18 +3,17 @@ package main
|
|||
import (
|
||||
"crypto/subtle"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"regexp"
|
||||
"strconv"
|
||||
|
||||
"github.com/knadh/paginator"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v4/middleware"
|
||||
)
|
||||
|
||||
const (
|
||||
// stdInputMaxLen is the maximum allowed length for a standard input field.
|
||||
stdInputMaxLen = 200
|
||||
stdInputMaxLen = 2000
|
||||
|
||||
sortAsc = "asc"
|
||||
sortDesc = "desc"
|
||||
|
@ -35,6 +34,14 @@ type pagination struct {
|
|||
var (
|
||||
reUUID = regexp.MustCompile("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")
|
||||
reLangCode = regexp.MustCompile("[^a-zA-Z_0-9\\-]")
|
||||
|
||||
paginate = paginator.New(paginator.Opt{
|
||||
DefaultPerPage: 20,
|
||||
MaxPerPage: 50,
|
||||
NumPageNums: 10,
|
||||
PageParam: "page",
|
||||
PerPageParam: "per_page",
|
||||
})
|
||||
)
|
||||
|
||||
// registerHandlers registers HTTP handlers.
|
||||
|
@ -49,11 +56,20 @@ func initHTTPHandlers(e *echo.Echo, app *App) {
|
|||
g = e.Group("", middleware.BasicAuth(basicAuth))
|
||||
}
|
||||
|
||||
e.HTTPErrorHandler = func(err error, c echo.Context) {
|
||||
// Generic, non-echo error. Log it.
|
||||
if _, ok := err.(*echo.HTTPError); !ok {
|
||||
app.log.Println(err.Error())
|
||||
}
|
||||
e.DefaultHTTPErrorHandler(err, c)
|
||||
}
|
||||
|
||||
// Admin JS app views.
|
||||
// /admin/static/* file server is registered in initHTTPServer().
|
||||
e.GET("/", func(c echo.Context) error {
|
||||
return c.Render(http.StatusOK, "home", publicTpl{Title: "listmonk"})
|
||||
})
|
||||
|
||||
g.GET(path.Join(adminRoot, ""), handleAdminPage)
|
||||
g.GET(path.Join(adminRoot, "/custom.css"), serveCustomApperance("admin.custom_css"))
|
||||
g.GET(path.Join(adminRoot, "/custom.js"), serveCustomApperance("admin.custom_js"))
|
||||
|
@ -71,6 +87,7 @@ func initHTTPHandlers(e *echo.Echo, app *App) {
|
|||
g.POST("/api/settings/smtp/test", handleTestSMTPSettings)
|
||||
g.POST("/api/admin/reload", handleReloadApp)
|
||||
g.GET("/api/logs", handleGetLogs)
|
||||
g.GET("/api/about", handleGetAboutInfo)
|
||||
|
||||
g.GET("/api/subscribers/:id", handleGetSubscriber)
|
||||
g.GET("/api/subscribers/:id/export", handleExportSubscriberData)
|
||||
|
@ -123,6 +140,7 @@ func initHTTPHandlers(e *echo.Echo, app *App) {
|
|||
g.POST("/api/campaigns", handleCreateCampaign)
|
||||
g.PUT("/api/campaigns/:id", handleUpdateCampaign)
|
||||
g.PUT("/api/campaigns/:id/status", handleUpdateCampaignStatus)
|
||||
g.PUT("/api/campaigns/:id/archive", handleUpdateCampaignArchive)
|
||||
g.DELETE("/api/campaigns/:id", handleDeleteCampaign)
|
||||
|
||||
g.GET("/api/media", handleGetMedia)
|
||||
|
@ -139,8 +157,14 @@ func initHTTPHandlers(e *echo.Echo, app *App) {
|
|||
g.PUT("/api/templates/:id/default", handleTemplateSetDefault)
|
||||
g.DELETE("/api/templates/:id", handleDeleteTemplate)
|
||||
|
||||
g.DELETE("/api/maintenance/subscribers/:type", handleGCSubscribers)
|
||||
g.DELETE("/api/maintenance/analytics/:type", handleGCCampaignAnalytics)
|
||||
g.DELETE("/api/maintenance/subscriptions/unconfirmed", handleGCSubscriptions)
|
||||
|
||||
g.POST("/api/tx", handleSendTxMessage)
|
||||
|
||||
g.GET("/api/events", handleEventStream)
|
||||
|
||||
if app.constants.BounceWebhooksEnabled {
|
||||
// Private authenticated bounce endpoint.
|
||||
g.POST("/webhooks/bounce", handleBounceWebhook)
|
||||
|
@ -149,13 +173,21 @@ func initHTTPHandlers(e *echo.Echo, app *App) {
|
|||
e.POST("/webhooks/service/:service", handleBounceWebhook)
|
||||
}
|
||||
|
||||
// Public API endpoints.
|
||||
e.GET("/api/public/lists", handleGetPublicLists)
|
||||
e.POST("/api/public/subscription", handlePublicSubscription)
|
||||
|
||||
if app.constants.EnablePublicArchive {
|
||||
e.GET("/api/public/archive", handleGetCampaignArchives)
|
||||
}
|
||||
|
||||
// /public/static/* file server is registered in initHTTPServer().
|
||||
// Public subscriber facing views.
|
||||
e.GET("/subscription/form", handleSubscriptionFormPage)
|
||||
e.POST("/subscription/form", handleSubscriptionForm)
|
||||
e.GET("/subscription/:campUUID/:subUUID", noIndex(validateUUID(subscriberExists(handleSubscriptionPage),
|
||||
"campUUID", "subUUID")))
|
||||
e.POST("/subscription/:campUUID/:subUUID", validateUUID(subscriberExists(handleSubscriptionPage),
|
||||
e.POST("/subscription/:campUUID/:subUUID", validateUUID(subscriberExists(handleSubscriptionPrefs),
|
||||
"campUUID", "subUUID"))
|
||||
e.GET("/subscription/optin/:subUUID", noIndex(validateUUID(subscriberExists(handleOptinPage), "subUUID")))
|
||||
e.POST("/subscription/optin/:subUUID", validateUUID(subscriberExists(handleOptinPage), "subUUID"))
|
||||
|
@ -170,11 +202,30 @@ func initHTTPHandlers(e *echo.Echo, app *App) {
|
|||
e.GET("/campaign/:campUUID/:subUUID/px.png", noIndex(validateUUID(handleRegisterCampaignView,
|
||||
"campUUID", "subUUID")))
|
||||
|
||||
if app.constants.EnablePublicArchive {
|
||||
e.GET("/archive", handleCampaignArchivesPage)
|
||||
e.GET("/archive.xml", handleGetCampaignArchivesFeed)
|
||||
e.GET("/archive/:uuid", handleCampaignArchivePage)
|
||||
e.GET("/archive/latest", handleCampaignArchivePageLatest)
|
||||
}
|
||||
|
||||
e.GET("/public/custom.css", serveCustomApperance("public.custom_css"))
|
||||
e.GET("/public/custom.js", serveCustomApperance("public.custom_js"))
|
||||
|
||||
// Public health API endpoint.
|
||||
e.GET("/health", handleHealthCheck)
|
||||
|
||||
// 404 pages.
|
||||
e.RouteNotFound("/*", func(c echo.Context) error {
|
||||
return c.Render(http.StatusNotFound, tplMessage,
|
||||
makeMsgTpl("404 - "+app.i18n.T("public.notFoundTitle"), "", ""))
|
||||
})
|
||||
e.RouteNotFound("/api/*", func(c echo.Context) error {
|
||||
return echo.NewHTTPError(http.StatusNotFound, "404 unknown endpoint")
|
||||
})
|
||||
e.RouteNotFound("/admin/*", func(c echo.Context) error {
|
||||
return echo.NewHTTPError(http.StatusNotFound, "404 page not found")
|
||||
})
|
||||
}
|
||||
|
||||
// handleAdminPage is the root handler that renders the Javascript admin frontend.
|
||||
|
@ -291,34 +342,3 @@ func noIndex(next echo.HandlerFunc, params ...string) echo.HandlerFunc {
|
|||
return next(c)
|
||||
}
|
||||
}
|
||||
|
||||
// getPagination takes form values and extracts pagination values from it.
|
||||
func getPagination(q url.Values, perPage int) pagination {
|
||||
var (
|
||||
page, _ = strconv.Atoi(q.Get("page"))
|
||||
pp = q.Get("per_page")
|
||||
)
|
||||
|
||||
if pp == "all" {
|
||||
// No limit.
|
||||
perPage = 0
|
||||
} else {
|
||||
ppi, _ := strconv.Atoi(pp)
|
||||
if ppi > 0 {
|
||||
perPage = ppi
|
||||
}
|
||||
}
|
||||
|
||||
if page < 1 {
|
||||
page = 0
|
||||
} else {
|
||||
page--
|
||||
}
|
||||
|
||||
return pagination{
|
||||
Page: page + 1,
|
||||
PerPage: perPage,
|
||||
Offset: page * perPage,
|
||||
Limit: perPage,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,7 +3,7 @@ package main
|
|||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
|
@ -66,7 +66,7 @@ func handleImportSubscribers(c echo.Context) error {
|
|||
}
|
||||
defer src.Close()
|
||||
|
||||
out, err := ioutil.TempFile("", "listmonk")
|
||||
out, err := os.CreateTemp("", "listmonk")
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
app.i18n.Ts("import.errorCopyingFile", "error", err.Error()))
|
||||
|
|
205
cmd/init.go
|
@ -8,28 +8,30 @@ import (
|
|||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/sprig/v3"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/jmoiron/sqlx/types"
|
||||
"github.com/knadh/goyesql/v2"
|
||||
goyesqlx "github.com/knadh/goyesql/v2/sqlx"
|
||||
"github.com/knadh/koanf"
|
||||
"github.com/knadh/koanf/maps"
|
||||
"github.com/knadh/koanf/parsers/toml"
|
||||
"github.com/knadh/koanf/providers/confmap"
|
||||
"github.com/knadh/koanf/providers/file"
|
||||
"github.com/knadh/koanf/providers/posflag"
|
||||
"github.com/knadh/koanf/v2"
|
||||
"github.com/knadh/listmonk/internal/bounce"
|
||||
"github.com/knadh/listmonk/internal/bounce/mailbox"
|
||||
"github.com/knadh/listmonk/internal/captcha"
|
||||
"github.com/knadh/listmonk/internal/i18n"
|
||||
"github.com/knadh/listmonk/internal/manager"
|
||||
"github.com/knadh/listmonk/internal/media"
|
||||
"github.com/knadh/listmonk/internal/media/providers/filesystem"
|
||||
"github.com/knadh/listmonk/internal/media/providers/s3"
|
||||
"github.com/knadh/listmonk/internal/messenger"
|
||||
"github.com/knadh/listmonk/internal/messenger/email"
|
||||
"github.com/knadh/listmonk/internal/messenger/postback"
|
||||
"github.com/knadh/listmonk/internal/subimporter"
|
||||
|
@ -49,23 +51,33 @@ const (
|
|||
|
||||
// constants contains static, constant config values required by the app.
|
||||
type constants struct {
|
||||
RootURL string `koanf:"root_url"`
|
||||
LogoURL string `koanf:"logo_url"`
|
||||
FaviconURL string `koanf:"favicon_url"`
|
||||
FromEmail string `koanf:"from_email"`
|
||||
NotifyEmails []string `koanf:"notify_emails"`
|
||||
EnablePublicSubPage bool `koanf:"enable_public_subscription_page"`
|
||||
SendOptinConfirmation bool `koanf:"send_optin_confirmation"`
|
||||
Lang string `koanf:"lang"`
|
||||
DBBatchSize int `koanf:"batch_size"`
|
||||
Privacy struct {
|
||||
SiteName string `koanf:"site_name"`
|
||||
RootURL string `koanf:"root_url"`
|
||||
LogoURL string `koanf:"logo_url"`
|
||||
FaviconURL string `koanf:"favicon_url"`
|
||||
FromEmail string `koanf:"from_email"`
|
||||
NotifyEmails []string `koanf:"notify_emails"`
|
||||
EnablePublicSubPage bool `koanf:"enable_public_subscription_page"`
|
||||
EnablePublicArchive bool `koanf:"enable_public_archive"`
|
||||
EnablePublicArchiveRSSContent bool `koanf:"enable_public_archive_rss_content"`
|
||||
SendOptinConfirmation bool `koanf:"send_optin_confirmation"`
|
||||
Lang string `koanf:"lang"`
|
||||
DBBatchSize int `koanf:"batch_size"`
|
||||
Privacy struct {
|
||||
IndividualTracking bool `koanf:"individual_tracking"`
|
||||
AllowPreferences bool `koanf:"allow_preferences"`
|
||||
AllowBlocklist bool `koanf:"allow_blocklist"`
|
||||
AllowExport bool `koanf:"allow_export"`
|
||||
AllowWipe bool `koanf:"allow_wipe"`
|
||||
RecordOptinIP bool `koanf:"record_optin_ip"`
|
||||
Exportable map[string]bool `koanf:"-"`
|
||||
DomainBlocklist map[string]bool `koanf:"-"`
|
||||
DomainBlocklist []string `koanf:"-"`
|
||||
} `koanf:"privacy"`
|
||||
Security struct {
|
||||
EnableCaptcha bool `koanf:"enable_captcha"`
|
||||
CaptchaKey string `koanf:"captcha_key"`
|
||||
CaptchaSecret string `koanf:"captcha_secret"`
|
||||
} `koanf:"security"`
|
||||
AdminUsername []byte `koanf:"admin_username"`
|
||||
AdminPassword []byte `koanf:"admin_password"`
|
||||
|
||||
|
@ -76,16 +88,22 @@ type constants struct {
|
|||
PublicJS []byte `koanf:"public.custom_js"`
|
||||
}
|
||||
|
||||
UnsubURL string
|
||||
LinkTrackURL string
|
||||
ViewTrackURL string
|
||||
OptinURL string
|
||||
MessageURL string
|
||||
MediaProvider string
|
||||
UnsubURL string
|
||||
LinkTrackURL string
|
||||
ViewTrackURL string
|
||||
OptinURL string
|
||||
MessageURL string
|
||||
ArchiveURL string
|
||||
|
||||
MediaUpload struct {
|
||||
Provider string
|
||||
Extensions []string
|
||||
}
|
||||
|
||||
BounceWebhooksEnabled bool
|
||||
BounceSESEnabled bool
|
||||
BounceSendgridEnabled bool
|
||||
BouncePostmarkEnabled bool
|
||||
}
|
||||
|
||||
type notifTpls struct {
|
||||
|
@ -244,6 +262,7 @@ func initDB() *sqlx.DB {
|
|||
Password string `koanf:"password"`
|
||||
DBName string `koanf:"database"`
|
||||
SSLMode string `koanf:"ssl_mode"`
|
||||
Params string `koanf:"params"`
|
||||
MaxOpen int `koanf:"max_open"`
|
||||
MaxIdle int `koanf:"max_idle"`
|
||||
MaxLifetime time.Duration `koanf:"max_lifetime"`
|
||||
|
@ -254,7 +273,7 @@ func initDB() *sqlx.DB {
|
|||
|
||||
lo.Printf("connecting to db: %s:%d/%s", c.Host, c.Port, c.DBName)
|
||||
db, err := sqlx.Connect("postgres",
|
||||
fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s", c.Host, c.Port, c.User, c.Password, c.DBName, c.SSLMode))
|
||||
fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s %s", c.Host, c.Port, c.User, c.Password, c.DBName, c.SSLMode, c.Params))
|
||||
if err != nil {
|
||||
lo.Fatalf("error connecting to DB: %v", err)
|
||||
}
|
||||
|
@ -346,6 +365,9 @@ func initConstants() *constants {
|
|||
if err := ko.Unmarshal("privacy", &c.Privacy); err != nil {
|
||||
lo.Fatalf("error loading app.privacy config: %v", err)
|
||||
}
|
||||
if err := ko.Unmarshal("security", &c.Security); err != nil {
|
||||
lo.Fatalf("error loading app.security config: %v", err)
|
||||
}
|
||||
if err := ko.UnmarshalWithConf("appearance", &c.Appearance, koanf.UnmarshalConf{FlatPaths: true}); err != nil {
|
||||
lo.Fatalf("error loading app.appearance config: %v", err)
|
||||
}
|
||||
|
@ -353,8 +375,9 @@ func initConstants() *constants {
|
|||
c.RootURL = strings.TrimRight(c.RootURL, "/")
|
||||
c.Lang = ko.String("app.lang")
|
||||
c.Privacy.Exportable = maps.StringSliceToLookupMap(ko.Strings("privacy.exportable"))
|
||||
c.MediaProvider = ko.String("upload.provider")
|
||||
c.Privacy.DomainBlocklist = maps.StringSliceToLookupMap(ko.Strings("privacy.domain_blocklist"))
|
||||
c.MediaUpload.Provider = ko.String("upload.provider")
|
||||
c.MediaUpload.Extensions = ko.Strings("upload.extensions")
|
||||
c.Privacy.DomainBlocklist = ko.Strings("privacy.domain_blocklist")
|
||||
|
||||
// Static URLS.
|
||||
// url.com/subscription/{campaign_uuid}/{subscriber_uuid}
|
||||
|
@ -369,12 +392,17 @@ func initConstants() *constants {
|
|||
// url.com/link/{campaign_uuid}/{subscriber_uuid}
|
||||
c.MessageURL = fmt.Sprintf("%s/campaign/%%s/%%s", c.RootURL)
|
||||
|
||||
// url.com/archive
|
||||
c.ArchiveURL = c.RootURL + "/archive"
|
||||
|
||||
// url.com/campaign/{campaign_uuid}/{subscriber_uuid}/px.png
|
||||
c.ViewTrackURL = fmt.Sprintf("%s/campaign/%%s/%%s/px.png", c.RootURL)
|
||||
|
||||
c.BounceWebhooksEnabled = ko.Bool("bounce.webhooks_enabled")
|
||||
c.BounceSESEnabled = ko.Bool("bounce.ses_enabled")
|
||||
c.BounceSendgridEnabled = ko.Bool("bounce.sendgrid_enabled")
|
||||
c.BouncePostmarkEnabled = ko.Bool("bounce.postmark.enabled")
|
||||
|
||||
return &c
|
||||
}
|
||||
|
||||
|
@ -423,13 +451,14 @@ func initCampaignManager(q *models.Queries, cs *constants, app *App) *manager.Ma
|
|||
LinkTrackURL: cs.LinkTrackURL,
|
||||
ViewTrackURL: cs.ViewTrackURL,
|
||||
MessageURL: cs.MessageURL,
|
||||
ArchiveURL: cs.ArchiveURL,
|
||||
UnsubHeader: ko.Bool("privacy.unsubscribe_header"),
|
||||
SlidingWindow: ko.Bool("app.message_sliding_window"),
|
||||
SlidingWindowDuration: ko.Duration("app.message_sliding_window_duration"),
|
||||
SlidingWindowRate: ko.Int("app.message_sliding_window_rate"),
|
||||
ScanInterval: time.Second * 5,
|
||||
ScanCampaigns: !ko.Bool("passive"),
|
||||
}, newManagerStore(q), campNotifCB, app.i18n, lo)
|
||||
}, newManagerStore(q, app.core, app.media), campNotifCB, app.i18n, lo)
|
||||
}
|
||||
|
||||
func initTxTemplates(m *manager.Manager, app *App) {
|
||||
|
@ -439,11 +468,12 @@ func initTxTemplates(m *manager.Manager, app *App) {
|
|||
}
|
||||
|
||||
for _, t := range tpls {
|
||||
if err := t.Compile(app.manager.GenericTemplateFuncs()); err != nil {
|
||||
lo.Printf("error compiling transactional template %d: %v", t.ID, err)
|
||||
tpl := t
|
||||
if err := tpl.Compile(app.manager.GenericTemplateFuncs()); err != nil {
|
||||
lo.Printf("error compiling transactional template %d: %v", tpl.ID, err)
|
||||
continue
|
||||
}
|
||||
m.CacheTpl(t.ID, &t)
|
||||
m.CacheTpl(tpl.ID, &tpl)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -463,7 +493,7 @@ func initImporter(q *models.Queries, db *sqlx.DB, app *App) *subimporter.Importe
|
|||
}
|
||||
|
||||
// initSMTPMessenger initializes the SMTP messenger.
|
||||
func initSMTPMessenger(m *manager.Manager) messenger.Messenger {
|
||||
func initSMTPMessenger(m *manager.Manager) manager.Messenger {
|
||||
var (
|
||||
mapKeys = ko.MapKeys("smtp")
|
||||
servers = make([]email.Server, 0, len(mapKeys))
|
||||
|
@ -505,13 +535,13 @@ func initSMTPMessenger(m *manager.Manager) messenger.Messenger {
|
|||
|
||||
// initPostbackMessengers initializes and returns all the enabled
|
||||
// HTTP postback messenger backends.
|
||||
func initPostbackMessengers(m *manager.Manager) []messenger.Messenger {
|
||||
func initPostbackMessengers(m *manager.Manager) []manager.Messenger {
|
||||
items := ko.Slices("messengers")
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var out []messenger.Messenger
|
||||
var out []manager.Messenger
|
||||
for _, item := range items {
|
||||
if !item.Bool("enabled") {
|
||||
continue
|
||||
|
@ -545,6 +575,7 @@ func initMediaStore() media.Store {
|
|||
case "s3":
|
||||
var o s3.Opt
|
||||
ko.Unmarshal("upload.s3", &o)
|
||||
|
||||
up, err := s3.NewS3Store(o)
|
||||
if err != nil {
|
||||
lo.Fatalf("error initializing s3 upload provider %s", err)
|
||||
|
@ -575,20 +606,7 @@ func initMediaStore() media.Store {
|
|||
// initNotifTemplates compiles and returns e-mail notification templates that are
|
||||
// used for sending ad-hoc notifications to admins and subscribers.
|
||||
func initNotifTemplates(path string, fs stuffbin.FileSystem, i *i18n.I18n, cs *constants) *notifTpls {
|
||||
// Register utility functions that the e-mail templates can use.
|
||||
funcs := template.FuncMap{
|
||||
"RootURL": func() string {
|
||||
return cs.RootURL
|
||||
},
|
||||
"LogoURL": func() string {
|
||||
return cs.LogoURL
|
||||
},
|
||||
"L": func() *i18n.I18n {
|
||||
return i
|
||||
},
|
||||
}
|
||||
|
||||
tpls, err := stuffbin.ParseTemplatesGlob(funcs, fs, "/static/email-templates/*.html")
|
||||
tpls, err := stuffbin.ParseTemplatesGlob(initTplFuncs(i, cs), fs, "/static/email-templates/*.html")
|
||||
if err != nil {
|
||||
lo.Fatalf("error parsing e-mail notif templates: %v", err)
|
||||
}
|
||||
|
@ -612,7 +630,7 @@ func initNotifTemplates(path string, fs stuffbin.FileSystem, i *i18n.I18n, cs *c
|
|||
h := make([]byte, ln)
|
||||
copy(h, html[0:ln])
|
||||
|
||||
if !bytes.Contains(bytes.ToLower(h), []byte("<!doctype html>")) {
|
||||
if !bytes.Contains(bytes.ToLower(h), []byte("<!doctype html")) {
|
||||
out.contentType = models.CampaignContentTypePlain
|
||||
lo.Println("system e-mail templates are plaintext")
|
||||
}
|
||||
|
@ -628,7 +646,15 @@ func initBounceManager(app *App) *bounce.Manager {
|
|||
SESEnabled: ko.Bool("bounce.ses_enabled"),
|
||||
SendgridEnabled: ko.Bool("bounce.sendgrid_enabled"),
|
||||
SendgridKey: ko.String("bounce.sendgrid_key"),
|
||||
|
||||
Postmark: struct {
|
||||
Enabled bool
|
||||
Username string
|
||||
Password string
|
||||
}{
|
||||
ko.Bool("bounce.postmark.enabled"),
|
||||
ko.String("bounce.postmark.username"),
|
||||
ko.String("bounce.postmark.password"),
|
||||
},
|
||||
RecordBounceCB: app.core.RecordBounce,
|
||||
}
|
||||
|
||||
|
@ -659,6 +685,42 @@ func initBounceManager(app *App) *bounce.Manager {
|
|||
return b
|
||||
}
|
||||
|
||||
func initAbout(q *models.Queries, db *sqlx.DB) about {
|
||||
var (
|
||||
mem runtime.MemStats
|
||||
)
|
||||
|
||||
// Memory / alloc stats.
|
||||
runtime.ReadMemStats(&mem)
|
||||
|
||||
info := types.JSONText(`{}`)
|
||||
if err := db.QueryRow(q.GetDBInfo).Scan(&info); err != nil {
|
||||
lo.Printf("WARNING: error getting database version: %v", err)
|
||||
}
|
||||
|
||||
hostname, err := os.Hostname()
|
||||
if err != nil {
|
||||
lo.Printf("WARNING: error getting hostname: %v", err)
|
||||
}
|
||||
|
||||
return about{
|
||||
Version: versionString,
|
||||
Build: buildString,
|
||||
GoArch: runtime.GOARCH,
|
||||
GoVersion: runtime.Version(),
|
||||
Database: info,
|
||||
System: aboutSystem{
|
||||
NumCPU: runtime.NumCPU(),
|
||||
},
|
||||
Host: aboutHost{
|
||||
OS: runtime.GOOS,
|
||||
Machine: runtime.GOARCH,
|
||||
Hostname: hostname,
|
||||
},
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// initHTTPServer sets up and runs the app's main HTTP server and blocks forever.
|
||||
func initHTTPServer(app *App) *echo.Echo {
|
||||
// Initialize the HTTP server.
|
||||
|
@ -673,19 +735,19 @@ func initHTTPServer(app *App) *echo.Echo {
|
|||
}
|
||||
})
|
||||
|
||||
// Parse and load user facing templates.
|
||||
tpl, err := stuffbin.ParseTemplatesGlob(template.FuncMap{
|
||||
"L": func() *i18n.I18n {
|
||||
return app.i18n
|
||||
}}, app.fs, "/public/templates/*.html")
|
||||
tpl, err := stuffbin.ParseTemplatesGlob(initTplFuncs(app.i18n, app.constants), app.fs, "/public/templates/*.html")
|
||||
if err != nil {
|
||||
lo.Fatalf("error parsing public templates: %v", err)
|
||||
}
|
||||
srv.Renderer = &tplRenderer{
|
||||
templates: tpl,
|
||||
RootURL: app.constants.RootURL,
|
||||
LogoURL: app.constants.LogoURL,
|
||||
FaviconURL: app.constants.FaviconURL}
|
||||
templates: tpl,
|
||||
SiteName: app.constants.SiteName,
|
||||
RootURL: app.constants.RootURL,
|
||||
LogoURL: app.constants.LogoURL,
|
||||
FaviconURL: app.constants.FaviconURL,
|
||||
EnablePublicSubPage: app.constants.EnablePublicSubPage,
|
||||
EnablePublicArchive: app.constants.EnablePublicArchive,
|
||||
}
|
||||
|
||||
// Initialize the static file server.
|
||||
fSrv := app.fs.FileServer()
|
||||
|
@ -718,6 +780,12 @@ func initHTTPServer(app *App) *echo.Echo {
|
|||
return srv
|
||||
}
|
||||
|
||||
func initCaptcha() *captcha.Captcha {
|
||||
return captcha.New(captcha.Opt{
|
||||
CaptchaSecret: ko.String("security.captcha_secret"),
|
||||
})
|
||||
}
|
||||
|
||||
func awaitReload(sigChan chan os.Signal, closerWait chan bool, closer func()) chan bool {
|
||||
// The blocking signal handler that main() waits on.
|
||||
out := make(chan bool)
|
||||
|
@ -761,3 +829,32 @@ func joinFSPaths(root string, paths []string) []string {
|
|||
|
||||
return out
|
||||
}
|
||||
|
||||
func initTplFuncs(i *i18n.I18n, cs *constants) template.FuncMap {
|
||||
funcs := template.FuncMap{
|
||||
"RootURL": func() string {
|
||||
return cs.RootURL
|
||||
},
|
||||
"LogoURL": func() string {
|
||||
return cs.LogoURL
|
||||
},
|
||||
"Date": func(layout string) string {
|
||||
if layout == "" {
|
||||
layout = time.ANSIC
|
||||
}
|
||||
return time.Now().Format(layout)
|
||||
},
|
||||
"L": func() *i18n.I18n {
|
||||
return i
|
||||
},
|
||||
"Safe": func(safeHTML string) template.HTML {
|
||||
return template.HTML(safeHTML)
|
||||
},
|
||||
}
|
||||
|
||||
for k, v := range sprig.GenericFuncMap() {
|
||||
funcs[k] = v
|
||||
}
|
||||
|
||||
return funcs
|
||||
}
|
||||
|
|
|
@ -3,7 +3,6 @@ package main
|
|||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
@ -74,6 +73,7 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempo
|
|||
models.ListTypePrivate,
|
||||
models.ListOptinSingle,
|
||||
pq.StringArray{"test"},
|
||||
"",
|
||||
); err != nil {
|
||||
lo.Fatalf("error creating list: %v", err)
|
||||
}
|
||||
|
@ -83,6 +83,7 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempo
|
|||
models.ListTypePublic,
|
||||
models.ListOptinDouble,
|
||||
pq.StringArray{"test"},
|
||||
"",
|
||||
); err != nil {
|
||||
lo.Fatalf("error creating list: %v", err)
|
||||
}
|
||||
|
@ -123,6 +124,17 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempo
|
|||
lo.Fatalf("error setting default template: %v", err)
|
||||
}
|
||||
|
||||
// Default campaign archive template.
|
||||
archiveTpl, err := fs.Get("/static/email-templates/default-archive.tpl")
|
||||
if err != nil {
|
||||
lo.Fatalf("error reading default archive template: %v", err)
|
||||
}
|
||||
|
||||
var archiveTplID int
|
||||
if err := q.CreateTemplate.Get(&archiveTplID, "Default archive template", models.TemplateTypeCampaign, "", archiveTpl.ReadBytes()); err != nil {
|
||||
lo.Fatalf("error creating default campaign template: %v", err)
|
||||
}
|
||||
|
||||
// Sample campaign.
|
||||
if _, err := q.CreateCampaign.Exec(uuid.Must(uuid.NewV4()),
|
||||
models.CampaignTypeRegular,
|
||||
|
@ -145,6 +157,10 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempo
|
|||
emailMsgr,
|
||||
campTplID,
|
||||
pq.Int64Array{1},
|
||||
false,
|
||||
archiveTplID,
|
||||
`{"name": "Subscriber"}`,
|
||||
nil,
|
||||
); err != nil {
|
||||
lo.Fatalf("error creating sample campaign: %v", err)
|
||||
}
|
||||
|
@ -207,7 +223,7 @@ func newConfigFile(path string) error {
|
|||
ReplaceAll(b, []byte(fmt.Sprintf(`admin_password = "%s"`, pwd)))
|
||||
}
|
||||
|
||||
return ioutil.WriteFile(path, b, 0644)
|
||||
return os.WriteFile(path, b, 0644)
|
||||
}
|
||||
|
||||
// checkSchema checks if the DB schema is installed.
|
||||
|
|
13
cmd/lists.go
|
@ -13,14 +13,15 @@ import (
|
|||
func handleGetLists(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
out models.PageResults
|
||||
pg = app.paginator.NewFromURL(c.Request().URL.Query())
|
||||
|
||||
pg = getPagination(c.QueryParams(), 20)
|
||||
query = strings.TrimSpace(c.FormValue("query"))
|
||||
orderBy = c.FormValue("order_by")
|
||||
order = c.FormValue("order")
|
||||
minimal, _ = strconv.ParseBool(c.FormValue("minimal"))
|
||||
listID, _ = strconv.Atoi(c.Param("id"))
|
||||
|
||||
out models.PageResults
|
||||
)
|
||||
|
||||
// Fetch one list.
|
||||
|
@ -29,6 +30,14 @@ func handleGetLists(c echo.Context) error {
|
|||
single = true
|
||||
}
|
||||
|
||||
if single {
|
||||
out, err := app.core.GetList(listID, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.JSON(http.StatusOK, okResp{out})
|
||||
}
|
||||
|
||||
// Minimal query simply returns the list of all lists without JOIN subscriber counts. This is fast.
|
||||
if !single && minimal {
|
||||
res, err := app.core.GetLists("")
|
||||
|
|
54
cmd/main.go
|
@ -13,17 +13,19 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/knadh/koanf"
|
||||
"github.com/knadh/koanf/providers/env"
|
||||
"github.com/knadh/koanf/v2"
|
||||
"github.com/knadh/listmonk/internal/bounce"
|
||||
"github.com/knadh/listmonk/internal/buflog"
|
||||
"github.com/knadh/listmonk/internal/captcha"
|
||||
"github.com/knadh/listmonk/internal/core"
|
||||
"github.com/knadh/listmonk/internal/events"
|
||||
"github.com/knadh/listmonk/internal/i18n"
|
||||
"github.com/knadh/listmonk/internal/manager"
|
||||
"github.com/knadh/listmonk/internal/media"
|
||||
"github.com/knadh/listmonk/internal/messenger"
|
||||
"github.com/knadh/listmonk/internal/subimporter"
|
||||
"github.com/knadh/listmonk/models"
|
||||
"github.com/knadh/paginator"
|
||||
"github.com/knadh/stuffbin"
|
||||
)
|
||||
|
||||
|
@ -41,16 +43,20 @@ type App struct {
|
|||
constants *constants
|
||||
manager *manager.Manager
|
||||
importer *subimporter.Importer
|
||||
messengers map[string]messenger.Messenger
|
||||
messengers map[string]manager.Messenger
|
||||
media media.Store
|
||||
i18n *i18n.I18n
|
||||
bounce *bounce.Manager
|
||||
paginator *paginator.Paginator
|
||||
captcha *captcha.Captcha
|
||||
events *events.Events
|
||||
notifTpls *notifTpls
|
||||
about about
|
||||
log *log.Logger
|
||||
bufLog *buflog.BufLog
|
||||
|
||||
// Channel for passing reload signals.
|
||||
sigChan chan os.Signal
|
||||
chReload chan os.Signal
|
||||
|
||||
// Global variable that stores the state indicating that a restart is required
|
||||
// after a settings update.
|
||||
|
@ -63,8 +69,9 @@ type App struct {
|
|||
|
||||
var (
|
||||
// Buffered log writer for storing N lines of log entries for the UI.
|
||||
bufLog = buflog.New(5000)
|
||||
lo = log.New(io.MultiWriter(os.Stdout, bufLog), "",
|
||||
evStream = events.New()
|
||||
bufLog = buflog.New(5000)
|
||||
lo = log.New(io.MultiWriter(os.Stdout, bufLog, evStream.ErrWriter()), "",
|
||||
log.Ldate|log.Ltime|log.Lshortfile)
|
||||
|
||||
ko = koanf.New(".")
|
||||
|
@ -163,25 +170,39 @@ func main() {
|
|||
db: db,
|
||||
constants: initConstants(),
|
||||
media: initMediaStore(),
|
||||
messengers: make(map[string]messenger.Messenger),
|
||||
messengers: make(map[string]manager.Messenger),
|
||||
log: lo,
|
||||
bufLog: bufLog,
|
||||
captcha: initCaptcha(),
|
||||
events: evStream,
|
||||
|
||||
paginator: paginator.New(paginator.Opt{
|
||||
DefaultPerPage: 20,
|
||||
MaxPerPage: 50,
|
||||
NumPageNums: 10,
|
||||
PageParam: "page",
|
||||
PerPageParam: "per_page",
|
||||
AllowAll: true,
|
||||
}),
|
||||
}
|
||||
|
||||
// Load i18n language map.
|
||||
app.i18n = initI18n(app.constants.Lang, fs)
|
||||
|
||||
app.core = core.New(&core.Opt{
|
||||
cOpt := &core.Opt{
|
||||
Constants: core.Constants{
|
||||
SendOptinConfirmation: app.constants.SendOptinConfirmation,
|
||||
MaxBounceCount: ko.MustInt("bounce.count"),
|
||||
BounceAction: ko.MustString("bounce.action"),
|
||||
},
|
||||
Queries: queries,
|
||||
DB: db,
|
||||
I18n: app.i18n,
|
||||
Log: lo,
|
||||
}, &core.Hooks{
|
||||
}
|
||||
|
||||
if err := ko.Unmarshal("bounce.actions", &cOpt.Constants.BounceActions); err != nil {
|
||||
lo.Fatalf("error unmarshalling bounce config: %v", err)
|
||||
}
|
||||
|
||||
app.core = core.New(cOpt, &core.Hooks{
|
||||
SendOptinConfirmation: sendOptinConfirmationHook(app),
|
||||
})
|
||||
|
||||
|
@ -209,6 +230,9 @@ func main() {
|
|||
app.manager.AddMessenger(m)
|
||||
}
|
||||
|
||||
// Load system information.
|
||||
app.about = initAbout(queries, db)
|
||||
|
||||
// Start the campaign workers. The campaign batches (fetch from DB, push out
|
||||
// messages) get processed at the specified interval.
|
||||
go app.manager.Run()
|
||||
|
@ -224,11 +248,11 @@ func main() {
|
|||
// Wait for the reload signal with a callback to gracefully shut down resources.
|
||||
// The `wait` channel is passed to awaitReload to wait for the callback to finish
|
||||
// within N seconds, or do a force reload.
|
||||
app.sigChan = make(chan os.Signal)
|
||||
signal.Notify(app.sigChan, syscall.SIGHUP)
|
||||
app.chReload = make(chan os.Signal)
|
||||
signal.Notify(app.chReload, syscall.SIGHUP)
|
||||
|
||||
closerWait := make(chan bool)
|
||||
<-awaitReload(app.sigChan, closerWait, func() {
|
||||
<-awaitReload(app.chReload, closerWait, func() {
|
||||
// Stop the HTTP server.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||
defer cancel()
|
||||
|
|
92
cmd/maintenance.go
Normal file
|
@ -0,0 +1,92 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// handleGCSubscribers garbage collects (deletes) orphaned or blocklisted subscribers.
|
||||
func handleGCSubscribers(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
typ = c.Param("type")
|
||||
)
|
||||
|
||||
var (
|
||||
n int
|
||||
err error
|
||||
)
|
||||
|
||||
switch typ {
|
||||
case "blocklisted":
|
||||
n, err = app.core.DeleteBlocklistedSubscribers()
|
||||
case "orphan":
|
||||
n, err = app.core.DeleteOrphanSubscribers()
|
||||
default:
|
||||
err = echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidData"))
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, okResp{struct {
|
||||
Count int `json:"count"`
|
||||
}{n}})
|
||||
}
|
||||
|
||||
// handleGCSubscriptions garbage collects (deletes) orphaned or blocklisted subscribers.
|
||||
func handleGCSubscriptions(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
)
|
||||
|
||||
t, err := time.Parse(time.RFC3339, c.FormValue("before_date"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidData"))
|
||||
}
|
||||
|
||||
n, err := app.core.DeleteUnconfirmedSubscriptions(t)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, okResp{struct {
|
||||
Count int `json:"count"`
|
||||
}{n}})
|
||||
}
|
||||
|
||||
// handleGCCampaignAnalytics garbage collects (deletes) campaign analytics.
|
||||
func handleGCCampaignAnalytics(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
typ = c.Param("type")
|
||||
)
|
||||
|
||||
t, err := time.Parse(time.RFC3339, c.FormValue("before_date"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidData"))
|
||||
}
|
||||
|
||||
switch typ {
|
||||
case "all":
|
||||
if err := app.core.DeleteCampaignViews(t); err != nil {
|
||||
return err
|
||||
}
|
||||
err = app.core.DeleteCampaignLinkClicks(t)
|
||||
case "views":
|
||||
err = app.core.DeleteCampaignViews(t)
|
||||
case "clicks":
|
||||
err = app.core.DeleteCampaignLinkClicks(t)
|
||||
default:
|
||||
err = echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidData"))
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, okResp{true})
|
||||
}
|
|
@ -1,27 +1,37 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/knadh/listmonk/internal/core"
|
||||
"github.com/knadh/listmonk/internal/manager"
|
||||
"github.com/knadh/listmonk/internal/media"
|
||||
"github.com/knadh/listmonk/models"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
// runnerDB implements runner.DataSource over the primary
|
||||
// store implements DataSource over the primary
|
||||
// database.
|
||||
type runnerDB struct {
|
||||
type store struct {
|
||||
queries *models.Queries
|
||||
core *core.Core
|
||||
media media.Store
|
||||
h *http.Client
|
||||
}
|
||||
|
||||
func newManagerStore(q *models.Queries) *runnerDB {
|
||||
return &runnerDB{
|
||||
func newManagerStore(q *models.Queries, c *core.Core, m media.Store) *store {
|
||||
return &store{
|
||||
queries: q,
|
||||
core: c,
|
||||
media: m,
|
||||
}
|
||||
}
|
||||
|
||||
// NextCampaigns retrieves active campaigns ready to be processed.
|
||||
func (r *runnerDB) NextCampaigns(excludeIDs []int64) ([]*models.Campaign, error) {
|
||||
func (s *store) NextCampaigns(excludeIDs []int64) ([]*models.Campaign, error) {
|
||||
var out []*models.Campaign
|
||||
err := r.queries.NextCampaigns.Select(&out, pq.Int64Array(excludeIDs))
|
||||
err := s.queries.NextCampaigns.Select(&out, pq.Int64Array(excludeIDs))
|
||||
return out, err
|
||||
}
|
||||
|
||||
|
@ -29,27 +39,46 @@ func (r *runnerDB) NextCampaigns(excludeIDs []int64) ([]*models.Campaign, error)
|
|||
// Since batches are processed sequentially, the retrieval is ordered by ID,
|
||||
// and every batch takes the last ID of the last batch and fetches the next
|
||||
// batch above that.
|
||||
func (r *runnerDB) NextSubscribers(campID, limit int) ([]models.Subscriber, error) {
|
||||
func (s *store) NextSubscribers(campID, limit int) ([]models.Subscriber, error) {
|
||||
var out []models.Subscriber
|
||||
err := r.queries.NextCampaignSubscribers.Select(&out, campID, limit)
|
||||
err := s.queries.NextCampaignSubscribers.Select(&out, campID, limit)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// GetCampaign fetches a campaign from the database.
|
||||
func (r *runnerDB) GetCampaign(campID int) (*models.Campaign, error) {
|
||||
func (s *store) GetCampaign(campID int) (*models.Campaign, error) {
|
||||
var out = &models.Campaign{}
|
||||
err := r.queries.GetCampaign.Get(out, campID, nil)
|
||||
err := s.queries.GetCampaign.Get(out, campID, nil, "default")
|
||||
return out, err
|
||||
}
|
||||
|
||||
// UpdateCampaignStatus updates a campaign's status.
|
||||
func (r *runnerDB) UpdateCampaignStatus(campID int, status string) error {
|
||||
_, err := r.queries.UpdateCampaignStatus.Exec(campID, status)
|
||||
func (s *store) UpdateCampaignStatus(campID int, status string) error {
|
||||
_, err := s.queries.UpdateCampaignStatus.Exec(campID, status)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetAttachment fetches a media attachment blob.
|
||||
func (s *store) GetAttachment(mediaID int) (models.Attachment, error) {
|
||||
m, err := s.core.GetMedia(mediaID, "", s.media)
|
||||
if err != nil {
|
||||
return models.Attachment{}, err
|
||||
}
|
||||
|
||||
b, err := s.media.GetBlob(m.URL)
|
||||
if err != nil {
|
||||
return models.Attachment{}, err
|
||||
}
|
||||
|
||||
return models.Attachment{
|
||||
Name: m.Filename,
|
||||
Content: b,
|
||||
Header: manager.MakeAttachmentHeader(m.Filename, "base64", m.ContentType),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateLink registers a URL with a UUID for tracking clicks and returns the UUID.
|
||||
func (r *runnerDB) CreateLink(url string) (string, error) {
|
||||
func (s *store) CreateLink(url string) (string, error) {
|
||||
// Create a new UUID for the URL. If the URL already exists in the DB
|
||||
// the UUID in the database is returned.
|
||||
uu, err := uuid.NewV4()
|
||||
|
@ -58,7 +87,7 @@ func (r *runnerDB) CreateLink(url string) (string, error) {
|
|||
}
|
||||
|
||||
var out string
|
||||
if err := r.queries.CreateLink.Get(&out, uu, url); err != nil {
|
||||
if err := s.queries.CreateLink.Get(&out, uu, url); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
|
@ -66,13 +95,13 @@ func (r *runnerDB) CreateLink(url string) (string, error) {
|
|||
}
|
||||
|
||||
// RecordBounce records a bounce event and returns the bounce count.
|
||||
func (r *runnerDB) RecordBounce(b models.Bounce) (int64, int, error) {
|
||||
func (s *store) RecordBounce(b models.Bounce) (int64, int, error) {
|
||||
var res = struct {
|
||||
SubscriberID int64 `db:"subscriber_id"`
|
||||
Num int `db:"num"`
|
||||
}{}
|
||||
|
||||
err := r.queries.UpdateCampaignStatus.Select(&res,
|
||||
err := s.queries.UpdateCampaignStatus.Select(&res,
|
||||
b.SubscriberUUID,
|
||||
b.Email,
|
||||
b.CampaignUUID,
|
||||
|
@ -83,12 +112,12 @@ func (r *runnerDB) RecordBounce(b models.Bounce) (int64, int, error) {
|
|||
return res.SubscriberID, res.Num, err
|
||||
}
|
||||
|
||||
func (r *runnerDB) BlocklistSubscriber(id int64) error {
|
||||
_, err := r.queries.BlocklistSubscribers.Exec(pq.Int64Array{id})
|
||||
func (s *store) BlocklistSubscriber(id int64) error {
|
||||
_, err := s.queries.BlocklistSubscribers.Exec(pq.Int64Array{id})
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *runnerDB) DeleteSubscriber(id int64) error {
|
||||
_, err := r.queries.DeleteSubscribers.Exec(pq.Int64Array{id})
|
||||
func (s *store) DeleteSubscriber(id int64) error {
|
||||
_, err := s.queries.DeleteSubscribers.Exec(pq.Int64Array{id})
|
||||
return err
|
||||
}
|
||||
|
|
128
cmd/media.go
|
@ -6,20 +6,21 @@ import (
|
|||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/disintegration/imaging"
|
||||
"github.com/knadh/listmonk/models"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
const (
|
||||
thumbPrefix = "thumb_"
|
||||
thumbnailSize = 90
|
||||
thumbnailSize = 250
|
||||
)
|
||||
|
||||
// validMimes is the list of image types allowed to be uploaded.
|
||||
var (
|
||||
validMimes = []string{"image/jpg", "image/jpeg", "image/png", "image/gif"}
|
||||
validExts = []string{".jpg", ".jpeg", ".png", ".gif"}
|
||||
vectorExts = []string{"svg"}
|
||||
imageExts = []string{"gif", "png", "jpg", "jpeg"}
|
||||
)
|
||||
|
||||
// handleUploadMedia handles media file uploads.
|
||||
|
@ -34,23 +35,6 @@ func handleUploadMedia(c echo.Context) error {
|
|||
app.i18n.Ts("media.invalidFile", "error", err.Error()))
|
||||
}
|
||||
|
||||
// Validate file extension.
|
||||
ext := filepath.Ext(file.Filename)
|
||||
if ok := inArray(ext, validExts); !ok {
|
||||
return echo.NewHTTPError(http.StatusBadRequest,
|
||||
app.i18n.Ts("media.unsupportedFileType", "type", ext))
|
||||
}
|
||||
|
||||
// Validate file's mime.
|
||||
typ := file.Header.Get("Content-type")
|
||||
if ok := inArray(typ, validMimes); !ok {
|
||||
return echo.NewHTTPError(http.StatusBadRequest,
|
||||
app.i18n.Ts("media.unsupportedFileType", "type", typ))
|
||||
}
|
||||
|
||||
// Generate filename
|
||||
fName := makeFilename(file.Filename)
|
||||
|
||||
// Read file contents in memory
|
||||
src, err := file.Open()
|
||||
if err != nil {
|
||||
|
@ -59,44 +43,82 @@ func handleUploadMedia(c echo.Context) error {
|
|||
}
|
||||
defer src.Close()
|
||||
|
||||
var (
|
||||
// Naive check for content type and extension.
|
||||
ext = strings.TrimPrefix(strings.ToLower(filepath.Ext(file.Filename)), ".")
|
||||
contentType = file.Header.Get("Content-Type")
|
||||
)
|
||||
|
||||
// Validate file extension.
|
||||
if !inArray("*", app.constants.MediaUpload.Extensions) {
|
||||
if ok := inArray(ext, app.constants.MediaUpload.Extensions); !ok {
|
||||
return echo.NewHTTPError(http.StatusBadRequest,
|
||||
app.i18n.Ts("media.unsupportedFileType", "type", ext))
|
||||
}
|
||||
}
|
||||
|
||||
// Upload the file.
|
||||
fName, err = app.media.Put(fName, typ, src)
|
||||
fName := makeFilename(file.Filename)
|
||||
fName, err = app.media.Put(fName, contentType, src)
|
||||
if err != nil {
|
||||
app.log.Printf("error uploading file: %v", err)
|
||||
cleanUp = true
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
app.i18n.Ts("media.errorUploading", "error", err.Error()))
|
||||
}
|
||||
|
||||
var (
|
||||
thumbfName = ""
|
||||
width = 0
|
||||
height = 0
|
||||
)
|
||||
defer func() {
|
||||
// If any of the subroutines in this function fail,
|
||||
// the uploaded image should be removed.
|
||||
if cleanUp {
|
||||
app.media.Delete(fName)
|
||||
app.media.Delete(thumbPrefix + fName)
|
||||
|
||||
if thumbfName != "" {
|
||||
app.media.Delete(thumbfName)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Create thumbnail from file.
|
||||
thumbFile, err := createThumbnail(file)
|
||||
if err != nil {
|
||||
cleanUp = true
|
||||
app.log.Printf("error resizing image: %v", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
app.i18n.Ts("media.errorResizing", "error", err.Error()))
|
||||
}
|
||||
// Create thumbnail from file for non-vector formats.
|
||||
isImage := inArray(ext, imageExts)
|
||||
if isImage {
|
||||
thumbFile, w, h, err := processImage(file)
|
||||
if err != nil {
|
||||
cleanUp = true
|
||||
app.log.Printf("error resizing image: %v", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
app.i18n.Ts("media.errorResizing", "error", err.Error()))
|
||||
}
|
||||
width = w
|
||||
height = h
|
||||
|
||||
// Upload thumbnail.
|
||||
thumbfName, err := app.media.Put(thumbPrefix+fName, typ, thumbFile)
|
||||
if err != nil {
|
||||
cleanUp = true
|
||||
app.log.Printf("error saving thumbnail: %v", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
app.i18n.Ts("media.errorSavingThumbnail", "error", err.Error()))
|
||||
// Upload thumbnail.
|
||||
tf, err := app.media.Put(thumbPrefix+fName, contentType, thumbFile)
|
||||
if err != nil {
|
||||
cleanUp = true
|
||||
app.log.Printf("error saving thumbnail: %v", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
app.i18n.Ts("media.errorSavingThumbnail", "error", err.Error()))
|
||||
}
|
||||
thumbfName = tf
|
||||
}
|
||||
if inArray(ext, vectorExts) {
|
||||
thumbfName = fName
|
||||
}
|
||||
|
||||
// Write to the DB.
|
||||
m, err := app.core.InsertMedia(fName, thumbfName, app.constants.MediaProvider, app.media)
|
||||
meta := models.JSON{}
|
||||
if isImage {
|
||||
meta = models.JSON{
|
||||
"width": width,
|
||||
"height": height,
|
||||
}
|
||||
}
|
||||
m, err := app.core.InsertMedia(fName, thumbfName, contentType, meta, app.constants.MediaUpload.Provider, app.media)
|
||||
if err != nil {
|
||||
cleanUp = true
|
||||
return err
|
||||
|
@ -108,6 +130,8 @@ func handleUploadMedia(c echo.Context) error {
|
|||
func handleGetMedia(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
pg = app.paginator.NewFromURL(c.Request().URL.Query())
|
||||
query = c.FormValue("query")
|
||||
id, _ = strconv.Atoi(c.Param("id"))
|
||||
)
|
||||
|
||||
|
@ -120,11 +144,18 @@ func handleGetMedia(c echo.Context) error {
|
|||
return c.JSON(http.StatusOK, okResp{out})
|
||||
}
|
||||
|
||||
out, err := app.core.GetAllMedia(app.constants.MediaProvider, app.media)
|
||||
res, total, err := app.core.QueryMedia(app.constants.MediaUpload.Provider, app.media, query, pg.Offset, pg.Limit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out := models.PageResults{
|
||||
Results: res,
|
||||
Total: total,
|
||||
Page: pg.Page,
|
||||
PerPage: pg.PerPage,
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, okResp{out})
|
||||
}
|
||||
|
||||
|
@ -150,17 +181,18 @@ func handleDeleteMedia(c echo.Context) error {
|
|||
return c.JSON(http.StatusOK, okResp{true})
|
||||
}
|
||||
|
||||
// createThumbnail reads the file object and returns a smaller image
|
||||
func createThumbnail(file *multipart.FileHeader) (*bytes.Reader, error) {
|
||||
// processImage reads the image file and returns thumbnail bytes and
|
||||
// the original image's width, and height.
|
||||
func processImage(file *multipart.FileHeader) (*bytes.Reader, int, int, error) {
|
||||
src, err := file.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, 0, 0, err
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
img, err := imaging.Decode(src)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, 0, 0, err
|
||||
}
|
||||
|
||||
// Encode the image into a byte slice as PNG.
|
||||
|
@ -169,7 +201,9 @@ func createThumbnail(file *multipart.FileHeader) (*bytes.Reader, error) {
|
|||
out bytes.Buffer
|
||||
)
|
||||
if err := imaging.Encode(&out, thumb, imaging.PNG); err != nil {
|
||||
return nil, err
|
||||
return nil, 0, 0, err
|
||||
}
|
||||
return bytes.NewReader(out.Bytes()), nil
|
||||
|
||||
b := img.Bounds().Max
|
||||
return bytes.NewReader(out.Bytes()), b.X, b.Y, nil
|
||||
}
|
||||
|
|
|
@ -3,7 +3,7 @@ package main
|
|||
import (
|
||||
"bytes"
|
||||
|
||||
"github.com/knadh/listmonk/internal/manager"
|
||||
"github.com/knadh/listmonk/models"
|
||||
)
|
||||
|
||||
const (
|
||||
|
@ -32,7 +32,7 @@ func (app *App) sendNotification(toEmails []string, subject, tplName string, dat
|
|||
return err
|
||||
}
|
||||
|
||||
m := manager.Message{}
|
||||
m := models.Message{}
|
||||
m.ContentType = app.notifTpls.contentType
|
||||
m.From = app.constants.FromEmail
|
||||
m.To = toEmails
|
||||
|
|
420
cmd/public.go
|
@ -13,8 +13,7 @@ import (
|
|||
"strings"
|
||||
|
||||
"github.com/knadh/listmonk/internal/i18n"
|
||||
"github.com/knadh/listmonk/internal/messenger"
|
||||
"github.com/knadh/listmonk/internal/subimporter"
|
||||
"github.com/knadh/listmonk/internal/manager"
|
||||
"github.com/knadh/listmonk/models"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/lib/pq"
|
||||
|
@ -26,20 +25,26 @@ const (
|
|||
|
||||
// tplRenderer wraps a template.tplRenderer for echo.
|
||||
type tplRenderer struct {
|
||||
templates *template.Template
|
||||
RootURL string
|
||||
LogoURL string
|
||||
FaviconURL string
|
||||
templates *template.Template
|
||||
SiteName string
|
||||
RootURL string
|
||||
LogoURL string
|
||||
FaviconURL string
|
||||
EnablePublicSubPage bool
|
||||
EnablePublicArchive bool
|
||||
}
|
||||
|
||||
// tplData is the data container that is injected
|
||||
// into public templates for accessing data.
|
||||
type tplData struct {
|
||||
RootURL string
|
||||
LogoURL string
|
||||
FaviconURL string
|
||||
Data interface{}
|
||||
L *i18n.I18n
|
||||
SiteName string
|
||||
RootURL string
|
||||
LogoURL string
|
||||
FaviconURL string
|
||||
EnablePublicSubPage bool
|
||||
EnablePublicArchive bool
|
||||
Data interface{}
|
||||
L *i18n.I18n
|
||||
}
|
||||
|
||||
type publicTpl struct {
|
||||
|
@ -49,10 +54,14 @@ type publicTpl struct {
|
|||
|
||||
type unsubTpl struct {
|
||||
publicTpl
|
||||
SubUUID string
|
||||
AllowBlocklist bool
|
||||
AllowExport bool
|
||||
AllowWipe bool
|
||||
Subscriber models.Subscriber
|
||||
Subscriptions []models.Subscription
|
||||
SubUUID string
|
||||
AllowBlocklist bool
|
||||
AllowExport bool
|
||||
AllowWipe bool
|
||||
AllowPreferences bool
|
||||
ShowManage bool
|
||||
}
|
||||
|
||||
type optinTpl struct {
|
||||
|
@ -70,7 +79,8 @@ type msgTpl struct {
|
|||
|
||||
type subFormTpl struct {
|
||||
publicTpl
|
||||
Lists []models.List
|
||||
Lists []models.List
|
||||
CaptchaKey string
|
||||
}
|
||||
|
||||
var (
|
||||
|
@ -80,14 +90,46 @@ var (
|
|||
// Render executes and renders a template for echo.
|
||||
func (t *tplRenderer) Render(w io.Writer, name string, data interface{}, c echo.Context) error {
|
||||
return t.templates.ExecuteTemplate(w, name, tplData{
|
||||
RootURL: t.RootURL,
|
||||
LogoURL: t.LogoURL,
|
||||
FaviconURL: t.FaviconURL,
|
||||
Data: data,
|
||||
L: c.Get("app").(*App).i18n,
|
||||
SiteName: t.SiteName,
|
||||
RootURL: t.RootURL,
|
||||
LogoURL: t.LogoURL,
|
||||
FaviconURL: t.FaviconURL,
|
||||
EnablePublicSubPage: t.EnablePublicSubPage,
|
||||
EnablePublicArchive: t.EnablePublicArchive,
|
||||
Data: data,
|
||||
L: c.Get("app").(*App).i18n,
|
||||
})
|
||||
}
|
||||
|
||||
// handleGetPublicLists returns the list of public lists with minimal fields
|
||||
// required to submit a subscription.
|
||||
func handleGetPublicLists(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
)
|
||||
|
||||
// Get all public lists.
|
||||
lists, err := app.core.GetLists(models.ListTypePublic)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("public.errorFetchingLists"))
|
||||
}
|
||||
|
||||
type list struct {
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
out := make([]list, 0, len(lists))
|
||||
for _, l := range lists {
|
||||
out = append(out, list{
|
||||
UUID: l.UUID,
|
||||
Name: l.Name,
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
// handleViewCampaignMessage renders the HTML view of a campaign message.
|
||||
// This is the view the {{ MessageURL }} template tag links to in e-mail campaigns.
|
||||
func handleViewCampaignMessage(c echo.Context) error {
|
||||
|
@ -107,7 +149,6 @@ func handleViewCampaignMessage(c echo.Context) error {
|
|||
}
|
||||
}
|
||||
|
||||
app.log.Printf("error fetching campaign: %v", err)
|
||||
return c.Render(http.StatusInternalServerError, tplMessage,
|
||||
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorFetchingCampaign")))
|
||||
}
|
||||
|
@ -147,36 +188,148 @@ func handleViewCampaignMessage(c echo.Context) error {
|
|||
// campaigns link to.
|
||||
func handleSubscriptionPage(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
campUUID = c.Param("campUUID")
|
||||
subUUID = c.Param("subUUID")
|
||||
unsub = c.Request().Method == http.MethodPost
|
||||
blocklist, _ = strconv.ParseBool(c.FormValue("blocklist"))
|
||||
out = unsubTpl{}
|
||||
app = c.Get("app").(*App)
|
||||
subUUID = c.Param("subUUID")
|
||||
showManage, _ = strconv.ParseBool(c.FormValue("manage"))
|
||||
out = unsubTpl{}
|
||||
)
|
||||
out.SubUUID = subUUID
|
||||
out.Title = app.i18n.T("public.unsubscribeTitle")
|
||||
out.AllowBlocklist = app.constants.Privacy.AllowBlocklist
|
||||
out.AllowExport = app.constants.Privacy.AllowExport
|
||||
out.AllowWipe = app.constants.Privacy.AllowWipe
|
||||
out.AllowPreferences = app.constants.Privacy.AllowPreferences
|
||||
|
||||
// Unsubscribe.
|
||||
if unsub {
|
||||
// Is blocklisting allowed?
|
||||
if !app.constants.Privacy.AllowBlocklist {
|
||||
blocklist = false
|
||||
s, err := app.core.GetSubscriber(0, subUUID, "")
|
||||
if err != nil {
|
||||
return c.Render(http.StatusInternalServerError, tplMessage,
|
||||
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorProcessingRequest")))
|
||||
}
|
||||
out.Subscriber = s
|
||||
|
||||
if s.Status == models.SubscriberStatusBlockListed {
|
||||
return c.Render(http.StatusOK, tplMessage,
|
||||
makeMsgTpl(app.i18n.T("public.noSubTitle"), "", app.i18n.Ts("public.blocklisted")))
|
||||
}
|
||||
|
||||
// Only show preference management if it's enabled in settings.
|
||||
if app.constants.Privacy.AllowPreferences {
|
||||
out.ShowManage = showManage
|
||||
}
|
||||
if out.ShowManage {
|
||||
// Get the subscriber's lists.
|
||||
subs, err := app.core.GetSubscriptions(0, subUUID, false)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("public.errorFetchingLists"))
|
||||
}
|
||||
|
||||
out.Subscriptions = make([]models.Subscription, 0, len(subs))
|
||||
for _, s := range subs {
|
||||
if s.Type == models.ListTypePrivate {
|
||||
continue
|
||||
}
|
||||
|
||||
out.Subscriptions = append(out.Subscriptions, s)
|
||||
}
|
||||
}
|
||||
|
||||
return c.Render(http.StatusOK, "subscription", out)
|
||||
}
|
||||
|
||||
// handleSubscriptionPrefs renders the subscription management page and
|
||||
// handles unsubscriptions. This is the view that {{ UnsubscribeURL }} in
|
||||
// campaigns link to.
|
||||
func handleSubscriptionPrefs(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
campUUID = c.Param("campUUID")
|
||||
subUUID = c.Param("subUUID")
|
||||
|
||||
req struct {
|
||||
Name string `form:"name" json:"name"`
|
||||
ListUUIDs []string `form:"l" json:"list_uuids"`
|
||||
Blocklist bool `form:"blocklist" json:"blocklist"`
|
||||
Manage bool `form:"manage" json:"manage"`
|
||||
}
|
||||
)
|
||||
|
||||
// Read the form.
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.Render(http.StatusBadRequest, tplMessage,
|
||||
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.T("globals.messages.invalidData")))
|
||||
}
|
||||
|
||||
// Simple unsubscribe.
|
||||
blocklist := app.constants.Privacy.AllowBlocklist && req.Blocklist
|
||||
if !req.Manage || blocklist {
|
||||
if err := app.core.UnsubscribeByCampaign(subUUID, campUUID, blocklist); err != nil {
|
||||
return c.Render(http.StatusInternalServerError, tplMessage,
|
||||
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorProcessingRequest")))
|
||||
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.T("public.errorProcessingRequest")))
|
||||
}
|
||||
|
||||
return c.Render(http.StatusOK, tplMessage,
|
||||
makeMsgTpl(app.i18n.T("public.unsubbedTitle"), "", app.i18n.T("public.unsubbedInfo")))
|
||||
}
|
||||
|
||||
return c.Render(http.StatusOK, "subscription", out)
|
||||
// Is preference management enabled?
|
||||
if !app.constants.Privacy.AllowPreferences {
|
||||
return c.Render(http.StatusBadRequest, tplMessage,
|
||||
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.T("public.invalidFeature")))
|
||||
}
|
||||
|
||||
// Manage preferences.
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
if req.Name == "" || len(req.Name) > 256 {
|
||||
return c.Render(http.StatusBadRequest, tplMessage,
|
||||
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.T("subscribers.invalidName")))
|
||||
}
|
||||
|
||||
// Get the subscriber from the DB.
|
||||
sub, err := app.core.GetSubscriber(0, subUUID, "")
|
||||
if err != nil {
|
||||
return c.Render(http.StatusInternalServerError, tplMessage,
|
||||
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("globals.messages.pFound",
|
||||
"name", app.i18n.T("globals.terms.subscriber"))))
|
||||
}
|
||||
sub.Name = req.Name
|
||||
|
||||
// Update name.
|
||||
if _, err := app.core.UpdateSubscriber(sub.ID, sub); err != nil {
|
||||
return c.Render(http.StatusInternalServerError, tplMessage,
|
||||
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.T("public.errorProcessingRequest")))
|
||||
}
|
||||
|
||||
// Get the subscriber's lists and whatever is not sent in the request (unchecked),
|
||||
// unsubscribe them.
|
||||
reqUUIDs := make(map[string]struct{})
|
||||
for _, u := range req.ListUUIDs {
|
||||
reqUUIDs[u] = struct{}{}
|
||||
}
|
||||
|
||||
subs, err := app.core.GetSubscriptions(0, subUUID, false)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("public.errorFetchingLists"))
|
||||
}
|
||||
|
||||
unsubUUIDs := make([]string, 0, len(req.ListUUIDs))
|
||||
for _, s := range subs {
|
||||
if s.Type == models.ListTypePrivate {
|
||||
continue
|
||||
}
|
||||
if _, ok := reqUUIDs[s.UUID]; !ok {
|
||||
unsubUUIDs = append(unsubUUIDs, s.UUID)
|
||||
}
|
||||
}
|
||||
|
||||
// Unsubscribe from lists.
|
||||
if err := app.core.UnsubscribeLists([]int{sub.ID}, nil, unsubUUIDs); err != nil {
|
||||
return c.Render(http.StatusInternalServerError, tplMessage,
|
||||
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.T("public.errorProcessingRequest")))
|
||||
|
||||
}
|
||||
|
||||
return c.Render(http.StatusOK, tplMessage,
|
||||
makeMsgTpl(app.i18n.T("globals.messages.done"), "", app.i18n.T("public.prefsSaved")))
|
||||
}
|
||||
|
||||
// handleOptinPage renders the double opt-in confirmation page that subscribers
|
||||
|
@ -220,10 +373,20 @@ func handleOptinPage(c echo.Context) error {
|
|||
return c.Render(http.StatusOK, tplMessage,
|
||||
makeMsgTpl(app.i18n.T("public.noSubTitle"), "", app.i18n.Ts("public.noSubInfo")))
|
||||
}
|
||||
out.Lists = lists
|
||||
|
||||
// Confirm.
|
||||
if confirm {
|
||||
if err := app.core.ConfirmOptionSubscription(subUUID, out.ListUUIDs); err != nil {
|
||||
meta := models.JSON{}
|
||||
if app.constants.Privacy.RecordOptinIP {
|
||||
if h := c.Request().Header.Get("X-Forwarded-For"); h != "" {
|
||||
meta["optin_ip"] = h
|
||||
} else if h := c.Request().RemoteAddr; h != "" {
|
||||
meta["optin_ip"] = strings.Split(h, ":")[0]
|
||||
}
|
||||
}
|
||||
|
||||
if err := app.core.ConfirmOptionSubscription(subUUID, out.ListUUIDs, meta); err != nil {
|
||||
app.log.Printf("error unsubscribing: %v", err)
|
||||
return c.Render(http.StatusInternalServerError, tplMessage,
|
||||
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorProcessingRequest")))
|
||||
|
@ -264,6 +427,10 @@ func handleSubscriptionFormPage(c echo.Context) error {
|
|||
out.Title = app.i18n.T("public.sub")
|
||||
out.Lists = lists
|
||||
|
||||
if app.constants.Security.EnableCaptcha {
|
||||
out.CaptchaKey = app.constants.Security.CaptchaKey
|
||||
}
|
||||
|
||||
return c.Render(http.StatusOK, "subscription-form", out)
|
||||
}
|
||||
|
||||
|
@ -272,79 +439,38 @@ func handleSubscriptionFormPage(c echo.Context) error {
|
|||
func handleSubscriptionForm(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
req struct {
|
||||
subimporter.SubReq
|
||||
SubListUUIDs []string `form:"l"`
|
||||
}
|
||||
)
|
||||
|
||||
// Get and validate fields.
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If there's a nonce value, a bot could've filled the form.
|
||||
if c.FormValue("nonce") != "" {
|
||||
return c.Render(http.StatusOK, tplMessage,
|
||||
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.T("public.invalidFeature")))
|
||||
|
||||
return echo.NewHTTPError(http.StatusBadGateway, app.i18n.T("public.invalidFeature"))
|
||||
}
|
||||
|
||||
if len(req.SubListUUIDs) == 0 {
|
||||
return c.Render(http.StatusBadRequest, tplMessage,
|
||||
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.T("public.noListsSelected")))
|
||||
// Process CAPTCHA.
|
||||
if app.constants.Security.EnableCaptcha {
|
||||
err, ok := app.captcha.Verify(c.FormValue("h-captcha-response"))
|
||||
if err != nil {
|
||||
app.log.Printf("Captcha request failed: %v", err)
|
||||
}
|
||||
|
||||
if !ok {
|
||||
return c.Render(http.StatusBadRequest, tplMessage,
|
||||
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.T("public.invalidCaptcha")))
|
||||
}
|
||||
}
|
||||
|
||||
// If there's no name, use the name bit from the e-mail.
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
if req.Name == "" {
|
||||
req.Name = strings.Split(req.Email, "@")[0]
|
||||
}
|
||||
|
||||
// Validate fields.
|
||||
if len(req.Email) > 1000 {
|
||||
return c.Render(http.StatusBadRequest, tplMessage,
|
||||
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.T("subscribers.invalidEmail")))
|
||||
}
|
||||
|
||||
em, err := app.importer.SanitizeEmail(req.Email)
|
||||
hasOptin, err := processSubForm(c)
|
||||
if err != nil {
|
||||
return c.Render(http.StatusBadRequest, tplMessage,
|
||||
makeMsgTpl(app.i18n.T("public.errorTitle"), "", err.Error()))
|
||||
}
|
||||
req.Email = em
|
||||
e, ok := err.(*echo.HTTPError)
|
||||
if !ok {
|
||||
return e
|
||||
}
|
||||
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
if len(req.Name) == 0 || len(req.Name) > stdInputMaxLen {
|
||||
return c.Render(http.StatusBadRequest, tplMessage,
|
||||
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.T("subscribers.invalidName")))
|
||||
return c.Render(e.Code, tplMessage,
|
||||
makeMsgTpl(app.i18n.T("public.errorTitle"), "", fmt.Sprintf("%s", e.Message)))
|
||||
}
|
||||
|
||||
msg := "public.subConfirmed"
|
||||
|
||||
// Insert the subscriber into the DB.
|
||||
req.Status = models.SubscriberStatusEnabled
|
||||
req.ListUUIDs = pq.StringArray(req.SubListUUIDs)
|
||||
_, hasOptin, err := app.core.InsertSubscriber(req.SubReq.Subscriber, nil, req.ListUUIDs, false)
|
||||
if err != nil {
|
||||
// Subscriber already exists. Update subscriptions.
|
||||
if e, ok := err.(*echo.HTTPError); ok && e.Code == http.StatusConflict {
|
||||
sub, err := app.core.GetSubscriber(0, "", req.Email)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := app.core.UpdateSubscriber(sub.ID, sub, nil, req.ListUUIDs, false); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.Render(http.StatusOK, tplMessage, makeMsgTpl(app.i18n.T("public.subTitle"), "", app.i18n.Ts(msg)))
|
||||
}
|
||||
|
||||
return c.Render(http.StatusInternalServerError, tplMessage,
|
||||
makeMsgTpl(app.i18n.T("public.errorTitle"), "", fmt.Sprintf("%s", err.(*echo.HTTPError).Message)))
|
||||
}
|
||||
|
||||
if hasOptin {
|
||||
msg = "public.subOptinPending"
|
||||
}
|
||||
|
@ -352,6 +478,27 @@ func handleSubscriptionForm(c echo.Context) error {
|
|||
return c.Render(http.StatusOK, tplMessage, makeMsgTpl(app.i18n.T("public.subTitle"), "", app.i18n.Ts(msg)))
|
||||
}
|
||||
|
||||
// handlePublicSubscription handles subscription requests coming from public
|
||||
// API calls.
|
||||
func handlePublicSubscription(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
)
|
||||
|
||||
if !app.constants.EnablePublicSubPage {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("public.invalidFeature"))
|
||||
}
|
||||
|
||||
hasOptin, err := processSubForm(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, okResp{struct {
|
||||
HasOptin bool `json:"has_optin"`
|
||||
}{hasOptin}})
|
||||
}
|
||||
|
||||
// handleLinkRedirect redirects a link UUID to its original underlying link
|
||||
// after recording the link click for a particular subscriber in the particular
|
||||
// campaign. These links are generated by {{ TrackLink }} tags in campaigns.
|
||||
|
@ -439,17 +586,17 @@ func handleSelfExportSubscriberData(c echo.Context) error {
|
|||
|
||||
// Send the data as a JSON attachment to the subscriber.
|
||||
const fname = "data.json"
|
||||
if err := app.messengers[emailMsgr].Push(messenger.Message{
|
||||
if err := app.messengers[emailMsgr].Push(models.Message{
|
||||
ContentType: app.notifTpls.contentType,
|
||||
From: app.constants.FromEmail,
|
||||
To: []string{data.Email},
|
||||
Subject: "Your data",
|
||||
Subject: app.i18n.Ts("email.data.title"),
|
||||
Body: msg.Bytes(),
|
||||
Attachments: []messenger.Attachment{
|
||||
Attachments: []models.Attachment{
|
||||
{
|
||||
Name: fname,
|
||||
Content: b,
|
||||
Header: messenger.MakeAttachmentHeader(fname, "base64"),
|
||||
Header: manager.MakeAttachmentHeader(fname, "base64", "application/json"),
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
|
@ -497,3 +644,76 @@ func drawTransparentImage(h, w int) []byte {
|
|||
_ = png.Encode(out, img)
|
||||
return out.Bytes()
|
||||
}
|
||||
|
||||
// processSubForm processes an incoming form/public API subscription request.
|
||||
// The bool indicates whether there was subscription to an optin list so that
|
||||
// an appropriate message can be shown.
|
||||
func processSubForm(c echo.Context) (bool, error) {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
req struct {
|
||||
Name string `form:"name" json:"name"`
|
||||
Email string `form:"email" json:"email"`
|
||||
FormListUUIDs []string `form:"l" json:"list_uuids"`
|
||||
}
|
||||
)
|
||||
|
||||
// Get and validate fields.
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if len(req.FormListUUIDs) == 0 {
|
||||
return false, echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("public.noListsSelected"))
|
||||
}
|
||||
|
||||
// If there's no name, use the name bit from the e-mail.
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
if req.Name == "" {
|
||||
req.Name = strings.Split(req.Email, "@")[0]
|
||||
}
|
||||
|
||||
// Validate fields.
|
||||
if len(req.Email) > 1000 {
|
||||
return false, echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.invalidEmail"))
|
||||
}
|
||||
|
||||
em, err := app.importer.SanitizeEmail(req.Email)
|
||||
if err != nil {
|
||||
return false, echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
||||
}
|
||||
req.Email = em
|
||||
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
if len(req.Name) == 0 || len(req.Name) > stdInputMaxLen {
|
||||
return false, echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.invalidName"))
|
||||
}
|
||||
|
||||
listUUIDs := pq.StringArray(req.FormListUUIDs)
|
||||
|
||||
// Insert the subscriber into the DB.
|
||||
_, hasOptin, err := app.core.InsertSubscriber(models.Subscriber{
|
||||
Name: req.Name,
|
||||
Email: req.Email,
|
||||
Status: models.SubscriberStatusEnabled,
|
||||
}, nil, listUUIDs, false)
|
||||
if err != nil {
|
||||
// Subscriber already exists. Update subscriptions.
|
||||
if e, ok := err.(*echo.HTTPError); ok && e.Code == http.StatusConflict {
|
||||
sub, err := app.core.GetSubscriber(0, "", req.Email)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if _, err := app.core.UpdateSubscriberWithLists(sub.ID, sub, nil, listUUIDs, false, false); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return false, echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("%s", err.(*echo.HTTPError).Message))
|
||||
}
|
||||
|
||||
return hasOptin, nil
|
||||
}
|
||||
|
|
|
@ -2,23 +2,47 @@ package main
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"io/ioutil"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/knadh/koanf"
|
||||
"github.com/jmoiron/sqlx/types"
|
||||
"github.com/knadh/koanf/parsers/json"
|
||||
"github.com/knadh/koanf/providers/rawbytes"
|
||||
"github.com/knadh/listmonk/internal/messenger"
|
||||
"github.com/knadh/koanf/v2"
|
||||
"github.com/knadh/listmonk/internal/messenger/email"
|
||||
"github.com/knadh/listmonk/models"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
const pwdMask = "•"
|
||||
|
||||
type aboutHost struct {
|
||||
OS string `json:"os"`
|
||||
Machine string `json:"arch"`
|
||||
Hostname string `json:"hostname"`
|
||||
}
|
||||
type aboutSystem struct {
|
||||
NumCPU int `json:"num_cpu"`
|
||||
AllocMB uint64 `json:"memory_alloc_mb"`
|
||||
OSMB uint64 `json:"memory_from_os_mb"`
|
||||
}
|
||||
type about struct {
|
||||
Version string `json:"version"`
|
||||
Build string `json:"build"`
|
||||
GoVersion string `json:"go_version"`
|
||||
GoArch string `json:"go_arch"`
|
||||
Database types.JSONText `json:"database"`
|
||||
System aboutSystem `json:"system"`
|
||||
Host aboutHost `json:"host"`
|
||||
}
|
||||
|
||||
var (
|
||||
reAlphaNum = regexp.MustCompile(`[^a-z0-9\-]`)
|
||||
)
|
||||
|
@ -34,16 +58,18 @@ func handleGetSettings(c echo.Context) error {
|
|||
|
||||
// Empty out passwords.
|
||||
for i := 0; i < len(s.SMTP); i++ {
|
||||
s.SMTP[i].Password = ""
|
||||
s.SMTP[i].Password = strings.Repeat(pwdMask, utf8.RuneCountInString(s.SMTP[i].Password))
|
||||
}
|
||||
for i := 0; i < len(s.BounceBoxes); i++ {
|
||||
s.BounceBoxes[i].Password = ""
|
||||
s.BounceBoxes[i].Password = strings.Repeat(pwdMask, utf8.RuneCountInString(s.BounceBoxes[i].Password))
|
||||
}
|
||||
for i := 0; i < len(s.Messengers); i++ {
|
||||
s.Messengers[i].Password = ""
|
||||
s.Messengers[i].Password = strings.Repeat(pwdMask, utf8.RuneCountInString(s.Messengers[i].Password))
|
||||
}
|
||||
s.UploadS3AwsSecretAccessKey = ""
|
||||
s.SendgridKey = ""
|
||||
s.UploadS3AwsSecretAccessKey = strings.Repeat(pwdMask, utf8.RuneCountInString(s.UploadS3AwsSecretAccessKey))
|
||||
s.SendgridKey = strings.Repeat(pwdMask, utf8.RuneCountInString(s.SendgridKey))
|
||||
s.SecurityCaptchaSecret = strings.Repeat(pwdMask, utf8.RuneCountInString(s.SecurityCaptchaSecret))
|
||||
s.BouncePostmark.Password = strings.Repeat(pwdMask, utf8.RuneCountInString(s.BouncePostmark.Password))
|
||||
|
||||
return c.JSON(http.StatusOK, okResp{s})
|
||||
}
|
||||
|
@ -95,6 +121,8 @@ func handleUpdateSettings(c echo.Context) error {
|
|||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("settings.errorNoSMTP"))
|
||||
}
|
||||
|
||||
set.AppRootURL = strings.TrimRight(set.AppRootURL, "/")
|
||||
|
||||
// Bounce boxes.
|
||||
for i, s := range set.BounceBoxes {
|
||||
// Assign a UUID. The frontend only sends a password when the user explicitly
|
||||
|
@ -158,6 +186,16 @@ func handleUpdateSettings(c echo.Context) error {
|
|||
if set.SendgridKey == "" {
|
||||
set.SendgridKey = cur.SendgridKey
|
||||
}
|
||||
if set.BouncePostmark.Password == "" {
|
||||
set.BouncePostmark.Password = cur.BouncePostmark.Password
|
||||
}
|
||||
if set.SecurityCaptchaSecret == "" {
|
||||
set.SecurityCaptchaSecret = cur.SecurityCaptchaSecret
|
||||
}
|
||||
|
||||
for n, v := range set.UploadExtensions {
|
||||
set.UploadExtensions[n] = strings.ToLower(strings.TrimPrefix(strings.TrimSpace(v), "."))
|
||||
}
|
||||
|
||||
// Domain blocklist.
|
||||
doms := make([]string, 0)
|
||||
|
@ -189,7 +227,7 @@ func handleUpdateSettings(c echo.Context) error {
|
|||
// No running campaigns. Reload the app.
|
||||
go func() {
|
||||
<-time.After(time.Millisecond * 500)
|
||||
app.sigChan <- syscall.SIGHUP
|
||||
app.chReload <- syscall.SIGHUP
|
||||
}()
|
||||
|
||||
return c.JSON(http.StatusOK, okResp{true})
|
||||
|
@ -206,7 +244,7 @@ func handleTestSMTPSettings(c echo.Context) error {
|
|||
app := c.Get("app").(*App)
|
||||
|
||||
// Copy the raw JSON post body.
|
||||
reqBody, err := ioutil.ReadAll(c.Request().Body)
|
||||
reqBody, err := io.ReadAll(c.Request().Body)
|
||||
if err != nil {
|
||||
app.log.Printf("error reading SMTP test: %v", err)
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.internalError"))
|
||||
|
@ -246,7 +284,7 @@ func handleTestSMTPSettings(c echo.Context) error {
|
|||
return err
|
||||
}
|
||||
|
||||
m := messenger.Message{}
|
||||
m := models.Message{}
|
||||
m.ContentType = app.notifTpls.contentType
|
||||
m.From = app.constants.FromEmail
|
||||
m.To = []string{to}
|
||||
|
@ -259,3 +297,18 @@ func handleTestSMTPSettings(c echo.Context) error {
|
|||
|
||||
return c.JSON(http.StatusOK, okResp{app.bufLog.Lines()})
|
||||
}
|
||||
|
||||
func handleGetAboutInfo(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
mem runtime.MemStats
|
||||
)
|
||||
|
||||
runtime.ReadMemStats(&mem)
|
||||
|
||||
out := app.about
|
||||
out.System.AllocMB = mem.Alloc / 1024 / 1024
|
||||
out.System.OSMB = mem.Sys / 1024 / 1024
|
||||
|
||||
return c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
|
|
@ -53,7 +53,7 @@ var (
|
|||
Email: "demo@listmonk.app",
|
||||
Name: "Demo Subscriber",
|
||||
UUID: dummyUUID,
|
||||
Attribs: models.SubscriberAttribs{"city": "Bengaluru"},
|
||||
Attribs: models.JSON{"city": "Bengaluru"},
|
||||
}
|
||||
|
||||
subQuerySortFields = []string{"email", "name", "created_at", "updated_at"}
|
||||
|
@ -84,7 +84,7 @@ func handleGetSubscriber(c echo.Context) error {
|
|||
func handleQuerySubscribers(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
pg = getPagination(c.QueryParams(), 30)
|
||||
pg = app.paginator.NewFromURL(c.Request().URL.Query())
|
||||
|
||||
// The "WHERE ?" bit.
|
||||
query = sanitizeSQLExp(c.FormValue("query"))
|
||||
|
@ -251,7 +251,7 @@ func handleUpdateSubscriber(c echo.Context) error {
|
|||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.invalidName"))
|
||||
}
|
||||
|
||||
out, err := app.core.UpdateSubscriber(id, req.Subscriber, req.Lists, nil, req.PreconfirmSubs)
|
||||
out, err := app.core.UpdateSubscriberWithLists(id, req.Subscriber, req.Lists, nil, req.PreconfirmSubs, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -259,7 +259,7 @@ func handleUpdateSubscriber(c echo.Context) error {
|
|||
return c.JSON(http.StatusOK, okResp{out})
|
||||
}
|
||||
|
||||
// handleGetSubscriberSendOptin sends an optin confirmation e-mail to a subscriber.
|
||||
// handleSubscriberSendOptin sends an optin confirmation e-mail to a subscriber.
|
||||
func handleSubscriberSendOptin(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
|
@ -364,7 +364,7 @@ func handleManageSubscriberLists(c echo.Context) error {
|
|||
case "remove":
|
||||
err = app.core.DeleteSubscriptions(subIDs, req.TargetListIDs)
|
||||
case "unsubscribe":
|
||||
err = app.core.UnsubscribeLists(subIDs, req.TargetListIDs)
|
||||
err = app.core.UnsubscribeLists(subIDs, req.TargetListIDs, nil)
|
||||
default:
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.invalidAction"))
|
||||
}
|
||||
|
|
172
cmd/tx.go
|
@ -1,9 +1,12 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
"strings"
|
||||
|
||||
"github.com/knadh/listmonk/internal/manager"
|
||||
"github.com/knadh/listmonk/models"
|
||||
|
@ -17,7 +20,49 @@ func handleSendTxMessage(c echo.Context) error {
|
|||
m models.TxMessage
|
||||
)
|
||||
|
||||
if err := c.Bind(&m); err != nil {
|
||||
// If it's a multipart form, there may be file attachments.
|
||||
if strings.HasPrefix(c.Request().Header.Get("Content-Type"), "multipart/form-data") {
|
||||
form, err := c.MultipartForm()
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest,
|
||||
app.i18n.Ts("globals.messages.invalidFields", "name", err.Error()))
|
||||
}
|
||||
|
||||
data, ok := form.Value["data"]
|
||||
if !ok || len(data) != 1 {
|
||||
return echo.NewHTTPError(http.StatusBadRequest,
|
||||
app.i18n.Ts("globals.messages.invalidFields", "name", "data"))
|
||||
}
|
||||
|
||||
// Parse the JSON data.
|
||||
if err := json.Unmarshal([]byte(data[0]), &m); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest,
|
||||
app.i18n.Ts("globals.messages.invalidFields", "name", fmt.Sprintf("data: %s", err.Error())))
|
||||
}
|
||||
|
||||
// Attach files.
|
||||
for _, f := range form.File["file"] {
|
||||
file, err := f.Open()
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
app.i18n.Ts("globals.messages.invalidFields", "name", fmt.Sprintf("file: %s", err.Error())))
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
b, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
app.i18n.Ts("globals.messages.invalidFields", "name", fmt.Sprintf("file: %s", err.Error())))
|
||||
}
|
||||
|
||||
m.Attachments = append(m.Attachments, models.Attachment{
|
||||
Name: f.Filename,
|
||||
Header: manager.MakeAttachmentHeader(f.Filename, "base64", f.Header.Get("Content-Type")),
|
||||
Content: b,
|
||||
})
|
||||
}
|
||||
|
||||
} else if err := c.Bind(&m); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
@ -35,58 +80,117 @@ func handleSendTxMessage(c echo.Context) error {
|
|||
app.i18n.Ts("globals.messages.notFound", "name", fmt.Sprintf("template %d", m.TemplateID)))
|
||||
}
|
||||
|
||||
// Get the subscriber.
|
||||
sub, err := app.core.GetSubscriber(m.SubscriberID, "", m.SubscriberEmail)
|
||||
if err != nil {
|
||||
return err
|
||||
var (
|
||||
num = len(m.SubscriberEmails)
|
||||
isEmails = true
|
||||
)
|
||||
if len(m.SubscriberIDs) > 0 {
|
||||
num = len(m.SubscriberIDs)
|
||||
isEmails = false
|
||||
}
|
||||
|
||||
// Render the message.
|
||||
if err := m.Render(sub, tpl); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest,
|
||||
app.i18n.Ts("globals.messages.errorFetching", "name"))
|
||||
}
|
||||
notFound := []string{}
|
||||
for n := 0; n < num; n++ {
|
||||
var (
|
||||
subID int
|
||||
subEmail string
|
||||
)
|
||||
|
||||
// Prepare the final message.
|
||||
msg := manager.Message{}
|
||||
msg.Subscriber = sub
|
||||
msg.To = []string{sub.Email}
|
||||
msg.From = m.FromEmail
|
||||
msg.Subject = m.Subject
|
||||
msg.ContentType = m.ContentType
|
||||
msg.Messenger = m.Messenger
|
||||
msg.Body = m.Body
|
||||
if !isEmails {
|
||||
subID = m.SubscriberIDs[n]
|
||||
} else {
|
||||
subEmail = m.SubscriberEmails[n]
|
||||
}
|
||||
|
||||
// Optional headers.
|
||||
if len(m.Headers) != 0 {
|
||||
msg.Headers = make(textproto.MIMEHeader, len(m.Headers))
|
||||
for _, set := range m.Headers {
|
||||
for hdr, val := range set {
|
||||
msg.Headers.Add(hdr, val)
|
||||
// Get the subscriber.
|
||||
sub, err := app.core.GetSubscriber(subID, "", subEmail)
|
||||
if err != nil {
|
||||
// If the subscriber is not found, log that error and move on without halting on the list.
|
||||
if er, ok := err.(*echo.HTTPError); ok && er.Code == http.StatusBadRequest {
|
||||
notFound = append(notFound, fmt.Sprintf("%v", er.Message))
|
||||
continue
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Render the message.
|
||||
if err := m.Render(sub, tpl); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest,
|
||||
app.i18n.Ts("globals.messages.errorFetching", "name"))
|
||||
}
|
||||
|
||||
// Prepare the final message.
|
||||
msg := models.Message{}
|
||||
msg.Subscriber = sub
|
||||
msg.To = []string{sub.Email}
|
||||
msg.From = m.FromEmail
|
||||
msg.Subject = m.Subject
|
||||
msg.ContentType = m.ContentType
|
||||
msg.Messenger = m.Messenger
|
||||
msg.Body = m.Body
|
||||
for _, a := range m.Attachments {
|
||||
msg.Attachments = append(msg.Attachments, models.Attachment{
|
||||
Name: a.Name,
|
||||
Header: a.Header,
|
||||
Content: a.Content,
|
||||
})
|
||||
}
|
||||
|
||||
// Optional headers.
|
||||
if len(m.Headers) != 0 {
|
||||
msg.Headers = make(textproto.MIMEHeader, len(m.Headers))
|
||||
for _, set := range m.Headers {
|
||||
for hdr, val := range set {
|
||||
msg.Headers.Add(hdr, val)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := app.manager.PushMessage(msg); err != nil {
|
||||
app.log.Printf("error sending message (%s): %v", msg.Subject, err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := app.manager.PushMessage(msg); err != nil {
|
||||
app.log.Printf("error sending message (%s): %v", msg.Subject, err)
|
||||
return err
|
||||
if len(notFound) > 0 {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, strings.Join(notFound, "; "))
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, okResp{true})
|
||||
}
|
||||
|
||||
func validateTxMessage(m models.TxMessage, app *App) (models.TxMessage, error) {
|
||||
if m.SubscriberEmail == "" && m.SubscriberID == 0 {
|
||||
if len(m.SubscriberEmails) > 0 && m.SubscriberEmail != "" {
|
||||
return m, echo.NewHTTPError(http.StatusBadRequest,
|
||||
app.i18n.Ts("globals.messages.missingFields", "name", "subscriber_email or subscriber_id"))
|
||||
app.i18n.Ts("globals.messages.invalidFields", "name", "do not send `subscriber_email`"))
|
||||
}
|
||||
if len(m.SubscriberIDs) > 0 && m.SubscriberID != 0 {
|
||||
return m, echo.NewHTTPError(http.StatusBadRequest,
|
||||
app.i18n.Ts("globals.messages.invalidFields", "name", "do not send `subscriber_id`"))
|
||||
}
|
||||
|
||||
if m.SubscriberEmail != "" {
|
||||
em, err := app.importer.SanitizeEmail(m.SubscriberEmail)
|
||||
if err != nil {
|
||||
return m, echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
||||
m.SubscriberEmails = append(m.SubscriberEmails, m.SubscriberEmail)
|
||||
}
|
||||
|
||||
if m.SubscriberID != 0 {
|
||||
m.SubscriberIDs = append(m.SubscriberIDs, m.SubscriberID)
|
||||
}
|
||||
|
||||
if (len(m.SubscriberEmails) == 0 && len(m.SubscriberIDs) == 0) || (len(m.SubscriberEmails) > 0 && len(m.SubscriberIDs) > 0) {
|
||||
return m, echo.NewHTTPError(http.StatusBadRequest,
|
||||
app.i18n.Ts("globals.messages.invalidFields", "name", "send subscriber_emails OR subscriber_ids"))
|
||||
}
|
||||
|
||||
for n, email := range m.SubscriberEmails {
|
||||
if m.SubscriberEmail != "" {
|
||||
em, err := app.importer.SanitizeEmail(email)
|
||||
if err != nil {
|
||||
return m, echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
||||
}
|
||||
m.SubscriberEmails[n] = em
|
||||
}
|
||||
m.SubscriberEmail = em
|
||||
}
|
||||
|
||||
if m.FromEmail == "" {
|
||||
|
|
|
@ -2,7 +2,7 @@ package main
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io/ioutil"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"time"
|
||||
|
@ -48,7 +48,7 @@ func checkUpdates(curVersion string, interval time.Duration, app *App) {
|
|||
continue
|
||||
}
|
||||
|
||||
b, err := ioutil.ReadAll(resp.Body)
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
app.log.Printf("error reading remote update payload: %v", err)
|
||||
continue
|
||||
|
|
|
@ -5,7 +5,7 @@ import (
|
|||
"strings"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/knadh/koanf"
|
||||
"github.com/knadh/koanf/v2"
|
||||
"github.com/knadh/listmonk/internal/migrations"
|
||||
"github.com/knadh/stuffbin"
|
||||
"github.com/lib/pq"
|
||||
|
@ -33,6 +33,10 @@ var migList = []migFunc{
|
|||
{"v2.0.0", migrations.V2_0_0},
|
||||
{"v2.1.0", migrations.V2_1_0},
|
||||
{"v2.2.0", migrations.V2_2_0},
|
||||
{"v2.3.0", migrations.V2_3_0},
|
||||
{"v2.4.0", migrations.V2_4_0},
|
||||
{"v2.5.0", migrations.V2_5_0},
|
||||
{"v2.6.0", migrations.V2_6_0},
|
||||
}
|
||||
|
||||
// upgrade upgrades the database to the current version by running SQL migration files
|
||||
|
@ -141,7 +145,7 @@ func getLastMigrationVersion() (string, error) {
|
|||
return v, nil
|
||||
}
|
||||
|
||||
// isPqNoTableErr checks if the given error represents a Postgres/pq
|
||||
// isTableNotExistErr checks if the given error represents a Postgres/pq
|
||||
// "table does not exist" error.
|
||||
func isTableNotExistErr(err error) bool {
|
||||
if p, ok := err.(*pq.Error); ok {
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
@ -99,3 +100,7 @@ func strSliceContains(str string, sl []string) bool {
|
|||
|
||||
return false
|
||||
}
|
||||
|
||||
func trimNullBytes(b []byte) string {
|
||||
return string(bytes.Trim(b, "\x00"))
|
||||
}
|
||||
|
|
|
@ -26,3 +26,6 @@ ssl_mode = "disable"
|
|||
max_open = 25
|
||||
max_idle = 25
|
||||
max_lifetime = "300s"
|
||||
|
||||
# Optional space separated Postgres DSN params. eg: "application_name=listmonk gssencmode=disable"
|
||||
params = ""
|
||||
|
|
|
@ -15,7 +15,7 @@ x-app-defaults: &app-defaults
|
|||
- TZ=Etc/UTC
|
||||
|
||||
x-db-defaults: &db-defaults
|
||||
image: postgres:13
|
||||
image: postgres:13-alpine
|
||||
ports:
|
||||
- "9432:5432"
|
||||
networks:
|
||||
|
|
9
docs/README.md
Normal file
|
@ -0,0 +1,9 @@
|
|||
# Static website and docs
|
||||
|
||||
This repository contains the source for the static website https://listmonk.app
|
||||
|
||||
- The website is in `site` and is built with hugo (run `hugo serve` inside `site` to preview).
|
||||
|
||||
- Documentation is in `docs` and is built with mkdocs (inside `docs`, run `mkdocs serve` to preview after running `pip install -r requirements.txt`)
|
||||
|
||||
- `i18n` directory has the static UI for i18n translations: https://listmonk.app/i18n
|
58
docs/docs/content/apis/apis.md
Normal file
|
@ -0,0 +1,58 @@
|
|||
# APIs
|
||||
|
||||
All features that are available on the listmonk dashboard are also available as REST-like HTTP APIs that can be interacted with directly. Request and response bodies are JSON. This allows easy scripting of listmonk and integration with other systems, for instance, synchronisation with external subscriber databases.
|
||||
|
||||
API requests require BasicAuth authentication with the admin credentials.
|
||||
|
||||
> The API section is a work in progress. There may be API calls that are yet to be documented. Please consider contributing to docs.
|
||||
|
||||
## OpenAPI (Swagger) spec
|
||||
|
||||
The auto-generated OpenAPI (Swagger) specification site for the APIs are available at [**listmonk.app/docs/swagger**](https://listmonk.app/docs/swagger/)
|
||||
|
||||
## Response structure
|
||||
|
||||
### Successful request
|
||||
|
||||
```http
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
All responses from the API server are JSON with the content-type application/json unless explicitly stated otherwise. A successful 200 OK response always has a JSON response body with a status key with the value success. The data key contains the full response payload.
|
||||
|
||||
### Failed request
|
||||
|
||||
```http
|
||||
HTTP/1.1 500 Server error
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"message": "Error message"
|
||||
}
|
||||
```
|
||||
|
||||
A failure response is preceded by the corresponding 40x or 50x HTTP header. There may be an optional `data` key with additional payload.
|
||||
|
||||
### Timestamps
|
||||
|
||||
All timestamp fields are in the format `2019-01-01T09:00:00.000000+05:30`. The seconds component is suffixed by the milliseconds, followed by the `+` and the timezone offset.
|
||||
|
||||
### Common HTTP error codes
|
||||
|
||||
| Code | |
|
||||
| ----- | ------------------------------------------------------------------------ |
|
||||
| 400 | Missing or bad request parameters or values |
|
||||
| 403 | Session expired or invalidate. Must relogin |
|
||||
| 404 | Request resource was not found |
|
||||
| 405 | Request method (GET, POST etc.) is not allowed on the requested endpoint |
|
||||
| 410 | The requested resource is gone permanently |
|
||||
| 429 | Too many requests to the API (rate limiting) |
|
||||
| 500 | Something unexpected went wrong |
|
||||
| 502 | The backend OMS is down and the API is unable to communicate with it |
|
||||
| 503 | Service unavailable; the API is down |
|
||||
| 504 | Gateway timeout; the API is unreachable |
|
372
docs/docs/content/apis/campaigns.md
Normal file
|
@ -0,0 +1,372 @@
|
|||
# API / Campaigns
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|:-------|:----------------------------------------------------------------------------|:------------------------------------------|
|
||||
| GET | [/api/campaigns](#get-apicampaigns) | Retrieve all campaigns. |
|
||||
| GET | [/api/campaigns/{campaign_id}](#get-apicampaignscampaign_id) | Retrieve a specific campaign. |
|
||||
| GET | [/api/campaigns/{campaign_id}/preview](#get-apicampaignscampaign_idpreview) | Retrieve preview of a campaign. |
|
||||
| GET | [/api/campaigns/running/stats](#get-apicampaignsrunningstats) | Retrieve stats of specified campaigns. |
|
||||
| POST | [/api/campaigns](#post-apicampaigns) | Create a new campaign. |
|
||||
| POST | [/api/campaigns/{campaign_id}/test](#post-apicampaignscampaign_idtest) | Test campaign with arbitrary subscribers. |
|
||||
| PUT | [/api/campaigns/{campaign_id}](#put-apicampaignscampaign_id) | Update a campaign. |
|
||||
| PUT | [/api/campaigns/{campaign_id}/status](#put-apicampaignscampaign_idstatus) | Change status of a campaign. |
|
||||
| DELETE | [/api/campaigns/{campaign_id}](#delete-apicampaignscampaign_id) | Delete a campaign. |
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### GET /api/campaigns
|
||||
|
||||
Retrieve all campaigns.
|
||||
|
||||
##### Example Request
|
||||
|
||||
```shell
|
||||
curl -u "username:password" -X GET 'http://localhost:9000/api/campaigns?page=1&per_page=100'
|
||||
```
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|:---------|:-------|:---------|:---------------------------------------------------------------------|
|
||||
| order | string | | Sorting order: ASC for ascending, DESC for descending. |
|
||||
| order_by | string | | Result sorting field. Options: name, status, created_at, updated_at. |
|
||||
| query | string | | SQL query expression to filter subscribers. |
|
||||
| page | number | | Page number for paginated results. |
|
||||
| per_page | number | | Results per page. Set as 'all' for all results. |
|
||||
|
||||
##### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"results": [
|
||||
{
|
||||
"id": 1,
|
||||
"created_at": "2020-03-14T17:36:41.29451+01:00",
|
||||
"updated_at": "2020-03-14T17:36:41.29451+01:00",
|
||||
"views": 0,
|
||||
"clicks": 0,
|
||||
"lists": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Default list"
|
||||
}
|
||||
],
|
||||
"started_at": null,
|
||||
"to_send": 0,
|
||||
"sent": 0,
|
||||
"uuid": "57702beb-6fae-4355-a324-c2fd5b59a549",
|
||||
"type": "regular",
|
||||
"name": "Test campaign",
|
||||
"subject": "Welcome to listmonk",
|
||||
"from_email": "No Reply <noreply@yoursite.com>",
|
||||
"body": "<h3>Hi {{ .Subscriber.FirstName }}!</h3>\n\t\t\tThis is a test e-mail campaign. Your second name is {{ .Subscriber.LastName }} and you are from {{ .Subscriber.Attribs.city }}.",
|
||||
"send_at": "2020-03-15T17:36:41.293233+01:00",
|
||||
"status": "draft",
|
||||
"content_type": "richtext",
|
||||
"tags": [
|
||||
"test-campaign"
|
||||
],
|
||||
"template_id": 1,
|
||||
"messenger": "email"
|
||||
}
|
||||
],
|
||||
"query": "",
|
||||
"total": 1,
|
||||
"per_page": 20,
|
||||
"page": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### GET /api/campaigns/{campaign_id}
|
||||
|
||||
Retrieve a specific campaign.
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|:------------|:----------|:---------|:-------------|
|
||||
| campaign_id | number | Yes | Campaign ID. |
|
||||
|
||||
##### Example Request
|
||||
|
||||
```shell
|
||||
curl -u "username:password" -X GET 'http://localhost:9000/api/campaigns/1'
|
||||
```
|
||||
|
||||
##### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": 1,
|
||||
"created_at": "2020-03-14T17:36:41.29451+01:00",
|
||||
"updated_at": "2020-03-14T17:36:41.29451+01:00",
|
||||
"views": 0,
|
||||
"clicks": 0,
|
||||
"lists": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Default list"
|
||||
}
|
||||
],
|
||||
"started_at": null,
|
||||
"to_send": 0,
|
||||
"sent": 0,
|
||||
"uuid": "57702beb-6fae-4355-a324-c2fd5b59a549",
|
||||
"type": "regular",
|
||||
"name": "Test campaign",
|
||||
"subject": "Welcome to listmonk",
|
||||
"from_email": "No Reply <noreply@yoursite.com>",
|
||||
"body": "<h3>Hi {{ .Subscriber.FirstName }}!</h3>\n\t\t\tThis is a test e-mail campaign. Your second name is {{ .Subscriber.LastName }} and you are from {{ .Subscriber.Attribs.city }}.",
|
||||
"send_at": "2020-03-15T17:36:41.293233+01:00",
|
||||
"status": "draft",
|
||||
"content_type": "richtext",
|
||||
"tags": [
|
||||
"test-campaign"
|
||||
],
|
||||
"template_id": 1,
|
||||
"messenger": "email"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### GET /api/campaigns/{campaign_id}/preview
|
||||
|
||||
Preview a specific campaign.
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|:------------|:----------|:---------|:------------------------|
|
||||
| campaign_id | number | Yes | Campaign ID to preview. |
|
||||
|
||||
##### Example Request
|
||||
|
||||
```shell
|
||||
curl -u "username:password" -X GET 'http://localhost:9000/api/campaigns/1/preview'
|
||||
```
|
||||
|
||||
##### Example Response
|
||||
|
||||
```html
|
||||
<h3>Hi John!</h3>
|
||||
This is a test e-mail campaign. Your second name is Doe and you are from Bengaluru.
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### GET /api/campaigns/running/stats
|
||||
|
||||
Retrieve stats of specified campaigns.
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|:------------|:----------|:---------|:-------------------------------|
|
||||
| campaign_id | number | Yes | Campaign IDs to get stats for. |
|
||||
|
||||
##### Example Request
|
||||
|
||||
```shell
|
||||
curl -u "username:password" -X GET 'http://localhost:9000/api/campaigns/running/stats?campaign_id=1'
|
||||
```
|
||||
|
||||
##### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": []
|
||||
}
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### POST /api/campaigns
|
||||
|
||||
Create a new campaign.
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|:-------------|:----------|:---------|:----------------------------------------------------------------------------------------|
|
||||
| name | string | Yes | Campaign name. |
|
||||
| subject | string | Yes | Campaign email subject. |
|
||||
| lists | number\[\] | Yes | List IDs to send campaign to. |
|
||||
| from_email | string | | 'From' email in campaign emails. Defaults to value from settings if not provided. |
|
||||
| type | string | Yes | Campaign type: 'regular' or 'optin'. |
|
||||
| content_type | string | Yes | Content type: 'richtext', 'html', 'markdown', 'plain'. |
|
||||
| body | string | Yes | Content body of campaign. |
|
||||
| altbody | string | | Alternate plain text body for HTML (and richtext) emails. |
|
||||
| send_at | string | | Timestamp to schedule campaign. Format: 'YYYY-MM-DDTHH:MM:SS'. |
|
||||
| messenger | string | | 'email' or a custom messenger defined in settings. Defaults to 'email' if not provided. |
|
||||
| template_id | number | | Template ID to use. Defaults to default template if not provided. |
|
||||
| tags | string\[\] | | Tags to mark campaign. |
|
||||
| headers | JSON | | Key-value pairs to send as SMTP headers. Example: \[{"x-custom-header": "value"}\]. |
|
||||
|
||||
##### Example request
|
||||
|
||||
```shell
|
||||
curl -u "username:password" 'http://localhost:9000/api/campaigns' -X POST -H 'Content-Type: application/json;charset=utf-8' --data-raw '{"name":"Test campaign","subject":"Hello, world","lists":[1],"from_email":"listmonk <noreply@listmonk.yoursite.com>","content_type":"richtext","messenger":"email","type":"regular","tags":["test"],"template_id":1}'
|
||||
```
|
||||
|
||||
##### Example response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": 1,
|
||||
"created_at": "2021-12-27T11:50:23.333485Z",
|
||||
"updated_at": "2021-12-27T11:50:23.333485Z",
|
||||
"views": 0,
|
||||
"clicks": 0,
|
||||
"bounces": 0,
|
||||
"lists": [{
|
||||
"id": 1,
|
||||
"name": "Default list"
|
||||
}],
|
||||
"started_at": null,
|
||||
"to_send": 1,
|
||||
"sent": 0,
|
||||
"uuid": "90c889cc-3728-4064-bbcb-5c1c446633b3",
|
||||
"type": "regular",
|
||||
"name": "Test campaign",
|
||||
"subject": "Hello, world",
|
||||
"from_email": "listmonk \u003cnoreply@listmonk.yoursite.com\u003e",
|
||||
"body": "",
|
||||
"altbody": null,
|
||||
"send_at": null,
|
||||
"status": "draft",
|
||||
"content_type": "richtext",
|
||||
"tags": ["test"],
|
||||
"template_id": 1,
|
||||
"messenger": "email"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### POST /api/campaigns/{campaign_id}/test
|
||||
|
||||
Test campaign with arbitrary subscribers.
|
||||
|
||||
Use the same parameters in [POST /api/campaigns](#post-apicampaigns) in addition to the below parameters.
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|:------------|:---------|:---------|:---------------------------------------------------|
|
||||
| subscribers | string\[\] | Yes | List of subscriber e-mails to send the message to. |
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### PUT /api/campaigns/{campaign_id}
|
||||
|
||||
Update a campaign.
|
||||
|
||||
> Refer to parameters from [POST /api/campaigns](#post-apicampaigns)
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### PUT /api/campaigns/{campaign_id}
|
||||
|
||||
Update a specific campaign.
|
||||
|
||||
> Refer to parameters from [POST /api/campaigns](#post-apicampaigns)
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### PUT /api/campaigns/{campaign_id}/status
|
||||
|
||||
Change status of a campaign.
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|:------------|:----------|:---------|:------------------------------------------------------------------------|
|
||||
| campaign_id | number | Yes | Campaign ID to change status. |
|
||||
| status | string | Yes | New status for campaign: 'scheduled', 'running', 'paused', 'cancelled'. |
|
||||
|
||||
##### Note
|
||||
|
||||
> - Only 'scheduled' campaigns can change status to 'draft'.
|
||||
> - Only 'draft' campaigns can change status to 'scheduled'.
|
||||
> - Only 'paused' and 'draft' campaigns can start ('running' status).
|
||||
> - Only 'running' campaigns can change status to 'cancelled' and 'paused'.
|
||||
|
||||
##### Example Request
|
||||
|
||||
```shell
|
||||
curl -u "username:password" -X PUT 'http://localhost:9000/api/campaigns/1/status' \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data-raw '{"status":"scheduled"}'
|
||||
```
|
||||
|
||||
##### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": 1,
|
||||
"created_at": "2020-03-14T17:36:41.29451+01:00",
|
||||
"updated_at": "2020-04-08T19:35:17.331867+01:00",
|
||||
"views": 0,
|
||||
"clicks": 0,
|
||||
"lists": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Default list"
|
||||
}
|
||||
],
|
||||
"started_at": null,
|
||||
"to_send": 0,
|
||||
"sent": 0,
|
||||
"uuid": "57702beb-6fae-4355-a324-c2fd5b59a549",
|
||||
"type": "regular",
|
||||
"name": "Test campaign",
|
||||
"subject": "Welcome to listmonk",
|
||||
"from_email": "No Reply <noreply@yoursite.com>",
|
||||
"body": "<h3>Hi {{ .Subscriber.FirstName }}!</h3>\n\t\t\tThis is a test e-mail campaign. Your second name is {{ .Subscriber.LastName }} and you are from {{ .Subscriber.Attribs.city }}.",
|
||||
"send_at": "2020-03-15T17:36:41.293233+01:00",
|
||||
"status": "scheduled",
|
||||
"content_type": "richtext",
|
||||
"tags": [
|
||||
"test-campaign"
|
||||
],
|
||||
"template_id": 1,
|
||||
"messenger": "email"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### DELETE /api/campaigns/{campaign_id}
|
||||
|
||||
Delete a campaign.
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|:------------|:----------|:---------|:-----------------------|
|
||||
| campaign_id | number | Yes | Campaign ID to delete. |
|
||||
|
||||
##### Example Request
|
||||
|
||||
```shell
|
||||
curl -u "username:password" -X DELETE 'http://localhost:9000/api/campaigns/34'
|
||||
```
|
||||
|
||||
##### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": true
|
||||
}
|
||||
```
|
102
docs/docs/content/apis/import.md
Normal file
|
@ -0,0 +1,102 @@
|
|||
# API / Import
|
||||
|
||||
Method | Endpoint | Description
|
||||
---------|-------------------------------------------------|------------------------------------------------
|
||||
GET | [/api/import/subscribers](#get-apiimportsubscribers) | Retrieve import statistics.
|
||||
GET | [/api/import/subscribers/logs](#get-apiimportsubscriberslogs) | Retrieve import logs.
|
||||
POST | [/api/import/subscribers](#post-apiimportsubscribers) | Upload a file for bulk subscriber import.
|
||||
DELETE | [/api/import/subscribers](#delete-apiimportsubscribers) | Stop and remove an import.
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### GET /api/import/subscribers
|
||||
|
||||
Retrieve the status of an import.
|
||||
|
||||
##### Example Request
|
||||
|
||||
```shell
|
||||
curl -u "username:username" -X GET 'http://localhost:9000/api/import/subscribers'
|
||||
```
|
||||
|
||||
##### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"name": "",
|
||||
"total": 0,
|
||||
"imported": 0,
|
||||
"status": "none"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### GET /api/import/subscribers/logs
|
||||
|
||||
Retrieve logs related to imports.
|
||||
|
||||
##### Example Request
|
||||
|
||||
```shell
|
||||
curl -u "username:username" -X GET 'http://localhost:9000/api/import/subscribers/logs'
|
||||
```
|
||||
|
||||
##### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": "2020/04/08 21:55:20 processing 'import.csv'\n2020/04/08 21:55:21 imported finished\n"
|
||||
}
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### POST /api/import/subscribers
|
||||
|
||||
Send a CSV (optionally ZIP compressed) file to import subscribers. Use a multipart form POST.
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|:-------|:------------|:---------|:-----------------------------------------|
|
||||
| params | JSON string | Yes | Stringified JSON with import parameters. |
|
||||
| file | File | Yes | File for upload. |
|
||||
|
||||
**`params`** (JSON string)
|
||||
|
||||
```json
|
||||
{
|
||||
"mode": "subscribe", // subscribe or blocklist
|
||||
"delim": ",", // delimiter in the uploaded file
|
||||
"lists":[1], // array of list IDs to import into
|
||||
"overwrite": true // overwrite existing entries or skip them?
|
||||
}
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### DELETE /api/import/subscribers
|
||||
|
||||
Stop and delete an ongoing import.
|
||||
|
||||
##### Example Request
|
||||
|
||||
```shell
|
||||
curl -u "username:username" -X DELETE 'http://localhost:9000/api/import/subscribers'
|
||||
```
|
||||
|
||||
##### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"name": "",
|
||||
"total": 0,
|
||||
"imported": 0,
|
||||
"status": "none"
|
||||
}
|
||||
}
|
||||
```
|
212
docs/docs/content/apis/lists.md
Normal file
|
@ -0,0 +1,212 @@
|
|||
# API / Lists
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|:-------|:------------------------------------------------|:--------------------------|
|
||||
| GET | [/api/lists](#get-apilists) | Retrieve all lists. |
|
||||
| GET | [/api/lists/{list_id}](#get-apilistslist_id) | Retrieve a specific list. |
|
||||
| POST | [/api/lists](#post-apilists) | Create a new list. |
|
||||
| PUT | [/api/lists/{list_id}](#put-apilistslist_id) | Update a list. |
|
||||
| DELETE | [/api/lists/{list_id}](#delete-apilistslist_id) | Delete a list. |
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### GET /api/lists
|
||||
|
||||
Retrieve lists.
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|:---------|:----------|:---------|:-----------------------------------------------------------|
|
||||
| query | string | | string for list name search. |
|
||||
| order_by | string | | Sort field. Options: name, status, created_at, updated_at. |
|
||||
| order | string | | Sorting order. Options: ASC, DESC. |
|
||||
| page | number | | Page number for pagination. |
|
||||
| per_page | number | | Results per page. Set to 'all' to return all results. |
|
||||
|
||||
##### Example Request
|
||||
|
||||
```shell
|
||||
curl -u "username:username" -X GET 'http://localhost:9000/api/lists?page=1&per_page=100'
|
||||
```
|
||||
|
||||
##### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"results": [
|
||||
{
|
||||
"id": 1,
|
||||
"created_at": "2020-02-10T23:07:16.194843+01:00",
|
||||
"updated_at": "2020-03-06T22:32:01.118327+01:00",
|
||||
"uuid": "ce13e971-c2ed-4069-bd0c-240e9a9f56f9",
|
||||
"name": "Default list",
|
||||
"type": "public",
|
||||
"optin": "double",
|
||||
"tags": [
|
||||
"test"
|
||||
],
|
||||
"subscriber_count": 2
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"created_at": "2020-03-04T21:12:09.555013+01:00",
|
||||
"updated_at": "2020-03-06T22:34:46.405031+01:00",
|
||||
"uuid": "f20a2308-dfb5-4420-a56d-ecf0618a102d",
|
||||
"name": "get",
|
||||
"type": "private",
|
||||
"optin": "single",
|
||||
"tags": [],
|
||||
"subscriber_count": 0
|
||||
}
|
||||
],
|
||||
"total": 5,
|
||||
"per_page": 20,
|
||||
"page": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### GET /api/lists/{list_id}
|
||||
|
||||
Retrieve a specific list.
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|:--------|:----------|:---------|:----------------------------|
|
||||
| list_id | number | Yes | ID of the list to retrieve. |
|
||||
|
||||
##### Example Request
|
||||
|
||||
```shell
|
||||
curl -u "username:username" -X GET 'http://localhost:9000/api/lists/5'
|
||||
```
|
||||
|
||||
##### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": 5,
|
||||
"created_at": "2020-03-07T06:31:06.072483+01:00",
|
||||
"updated_at": "2020-03-07T06:31:06.072483+01:00",
|
||||
"uuid": "1bb246ab-7417-4cef-bddc-8fc8fc941d3a",
|
||||
"name": "Test list",
|
||||
"type": "public",
|
||||
"optin": "double",
|
||||
"tags": [],
|
||||
"subscriber_count": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### POST /api/lists
|
||||
|
||||
Create a new list.
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|:------|:----------|:---------|:----------------------------------------|
|
||||
| name | string | Yes | Name of the new list. |
|
||||
| type | string | Yes | Type of list. Options: private, public. |
|
||||
| optin | string | Yes | Opt-in type. Options: single, double. |
|
||||
| tags | string\[\] | | Associated tags for a list. |
|
||||
|
||||
##### Example Request
|
||||
|
||||
```shell
|
||||
curl -u "username:username" -X POST 'http://localhost:9000/api/lists'
|
||||
```
|
||||
|
||||
##### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": 5,
|
||||
"created_at": "2020-03-07T06:31:06.072483+01:00",
|
||||
"updated_at": "2020-03-07T06:31:06.072483+01:00",
|
||||
"uuid": "1bb246ab-7417-4cef-bddc-8fc8fc941d3a",
|
||||
"name": "Test list",
|
||||
"type": "public",
|
||||
"tags": [],
|
||||
"subscriber_count": 0
|
||||
}
|
||||
}
|
||||
null
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### PUT /api/lists/{list_id}
|
||||
|
||||
Update a list.
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|:--------|:----------|:---------|:----------------------------------------|
|
||||
| list_id | number | Yes | ID of the list to update. |
|
||||
| name | string | | New name for the list. |
|
||||
| type | string | | Type of list. Options: private, public. |
|
||||
| optin | string | | Opt-in type. Options: single, double. |
|
||||
| tags | string\[\] | | Associated tags for the list. |
|
||||
|
||||
##### Example Request
|
||||
|
||||
```shell
|
||||
curl -u "username:username" -X PUT 'http://localhost:9000/api/lists/5' \
|
||||
--form 'name=modified test list' \
|
||||
--form 'type=private'
|
||||
```
|
||||
|
||||
##### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": 5,
|
||||
"created_at": "2020-03-07T06:31:06.072483+01:00",
|
||||
"updated_at": "2020-03-07T06:52:15.208075+01:00",
|
||||
"uuid": "1bb246ab-7417-4cef-bddc-8fc8fc941d3a",
|
||||
"name": "modified test list",
|
||||
"type": "private",
|
||||
"optin": "single",
|
||||
"tags": [],
|
||||
"subscriber_count": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### DELETE /api/lists/{list_id}
|
||||
|
||||
Delete a specific subscriber.
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|:--------|:----------|:---------|:--------------------------|
|
||||
| list_id | Number | Yes | ID of the list to delete. |
|
||||
|
||||
##### Example Request
|
||||
|
||||
```shell
|
||||
curl -u 'username:password' -X DELETE 'http://localhost:9000/api/lists/1'
|
||||
```
|
||||
|
||||
##### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": true
|
||||
}
|
||||
```
|
98
docs/docs/content/apis/media.md
Normal file
|
@ -0,0 +1,98 @@
|
|||
# API / Media
|
||||
|
||||
Method | Endpoint | Description
|
||||
-------|------------------------------------------------|------------------------------
|
||||
GET | [/api/media](#get-apimedia) | Get uploaded media file
|
||||
POST | [/api/media](#post-apimedia) | Upload media file
|
||||
DELETE | [/api/media/{media_id}](#delete-apimediamedia_id) | Delete uploaded media file
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### GET /api/media
|
||||
|
||||
Get an uploaded media file.
|
||||
|
||||
##### Example Request
|
||||
|
||||
```shell
|
||||
curl -u "username:username" -X GET 'http://localhost:9000/api/media' \
|
||||
--header 'Content-Type: multipart/form-data; boundary=--------------------------093715978792575906250298'
|
||||
```
|
||||
|
||||
##### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"uuid": "ec7b45ce-1408-4e5c-924e-965326a20287",
|
||||
"filename": "Media file",
|
||||
"created_at": "2020-04-08T22:43:45.080058+01:00",
|
||||
"thumb_url": "/uploads/image_thumb.jpg",
|
||||
"uri": "/uploads/image.jpg"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### POST /api/media
|
||||
|
||||
Upload a media file.
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|-----------|----------|---------------------|
|
||||
| file | File | Yes | Media file to upload|
|
||||
|
||||
##### Example Request
|
||||
|
||||
```shell
|
||||
curl -u "username:username" -X POST 'http://localhost:9000/api/media' \
|
||||
--header 'Content-Type: multipart/form-data; boundary=--------------------------183679989870526937212428' \
|
||||
--form 'file=@/path/to/image.jpg'
|
||||
```
|
||||
|
||||
##### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": 1,
|
||||
"uuid": "ec7b45ce-1408-4e5c-924e-965326a20287",
|
||||
"filename": "Media file",
|
||||
"created_at": "2020-04-08T22:43:45.080058+01:00",
|
||||
"thumb_uri": "/uploads/image_thumb.jpg",
|
||||
"uri": "/uploads/image.jpg"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### DELETE /api/media/{media_id}
|
||||
|
||||
Delete an uploaded media file.
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|----------|-----------|----------|-------------------------|
|
||||
| media_id | number | Yes | ID of media file to delete |
|
||||
|
||||
##### Example Request
|
||||
|
||||
```shell
|
||||
curl -u "username:username" -X DELETE 'http://localhost:9000/api/media/1'
|
||||
```
|
||||
|
||||
##### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": true
|
||||
}
|
||||
```
|
434
docs/docs/content/apis/subscribers.md
Normal file
|
@ -0,0 +1,434 @@
|
|||
# API / Subscribers
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| ------ | --------------------------------------------------------------------------------------- | ---------------------------------------------- |
|
||||
| GET | [/api/subscribers](#get-apisubscribers) | Retrieve all subscribers. |
|
||||
| GET | [/api/subscribers/{subscriber_id}](#get-apisubscriberssubscriber_id) | Retrieve a specific subscriber. |
|
||||
| GET | [/api/subscribers/lists/{list_id}](#get-apisubscriberslistslist_id) | Retrieve subscribers in a specific list. |
|
||||
| POST | [/api/subscribers](#post-apisubscribers) | Create a new subscriber. |
|
||||
| POST | [/api/public/subscription](#post-apipublicsubscription) | Create a public subscription. |
|
||||
| PUT | [/api/subscribers/lists](#put-apisubscriberslists) | Modify subscriber list memberships. |
|
||||
| PUT | [/api/subscribers/{subscriber_id}](#put-apisubscriberssubscriber_id) | Update a specific subscriber. |
|
||||
| PUT | [/api/subscribers/{subscriber_id}/blocklist](#put-apisubscriberssubscriber_idblocklist) | Blocklist a specific subscriber. |
|
||||
| PUT | /api/subscribers/blocklist | Blocklist one or more subscribers. |
|
||||
| PUT | [/api/subscribers/query/blocklist](#put-apisubscribersqueryblocklist) | Blocklist subscribers based on SQL expression. |
|
||||
| DELETE | [/api/subscribers/{subscriber_id}](#delete-apisubscriberssubscriber_id) | Delete a specific subscriber. |
|
||||
| DELETE | [/api/subscribers](#delete-apisubscribers) | Delete one or more subscribers. |
|
||||
| POST | [/api/subscribers/query/delete](#post-apisubscribersquerydelete) | Delete subscribers based on SQL expression. |
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### GET /api/subscribers
|
||||
|
||||
Retrieve all subscribers.
|
||||
|
||||
##### Query parameters
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|:---------|:-------|:---------|:---------------------------------------------------------------------|
|
||||
| query | string | | Subscriber search term by name. |
|
||||
| order_by | string | | Result sorting field. Options: name, status, created_at, updated_at. |
|
||||
| order | string | | Sorting order: ASC for ascending, DESC for descending. |
|
||||
| page | number | | Page number for paginated results. |
|
||||
| per_page | number | | Results per page. Set as 'all' for all results. |
|
||||
|
||||
##### Example Request
|
||||
|
||||
```shell
|
||||
curl -u 'username:password' 'http://localhost:9000/api/subscribers?page=1&per_page=100'
|
||||
```
|
||||
|
||||
```shell
|
||||
curl -u 'username:password' 'http://localhost:9000/api/subscribers?list_id=1&list_id=2&page=1&per_page=100'
|
||||
```
|
||||
|
||||
```shell
|
||||
curl -u 'username:password' -X GET 'http://localhost:9000/api/subscribers' \
|
||||
--url-query 'page=1' \
|
||||
--url-query 'per_page=100' \
|
||||
--url-query "query=subscribers.name LIKE 'Test%' AND subscribers.attribs->>'city' = 'Bengaluru'"
|
||||
```
|
||||
|
||||
##### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"results": [
|
||||
{
|
||||
"id": 1,
|
||||
"created_at": "2020-02-10T23:07:16.199433+01:00",
|
||||
"updated_at": "2020-02-10T23:07:16.199433+01:00",
|
||||
"uuid": "ea06b2e7-4b08-4697-bcfc-2a5c6dde8f1c",
|
||||
"email": "john@example.com",
|
||||
"name": "John Doe",
|
||||
"attribs": {
|
||||
"city": "Bengaluru",
|
||||
"good": true,
|
||||
"type": "known"
|
||||
},
|
||||
"status": "enabled",
|
||||
"lists": [
|
||||
{
|
||||
"subscription_status": "unconfirmed",
|
||||
"id": 1,
|
||||
"uuid": "ce13e971-c2ed-4069-bd0c-240e9a9f56f9",
|
||||
"name": "Default list",
|
||||
"type": "public",
|
||||
"tags": [
|
||||
"test"
|
||||
],
|
||||
"created_at": "2020-02-10T23:07:16.194843+01:00",
|
||||
"updated_at": "2020-02-10T23:07:16.194843+01:00"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"created_at": "2020-02-18T21:10:17.218979+01:00",
|
||||
"updated_at": "2020-02-18T21:10:17.218979+01:00",
|
||||
"uuid": "ccf66172-f87f-4509-b7af-e8716f739860",
|
||||
"email": "quadri@example.com",
|
||||
"name": "quadri",
|
||||
"attribs": {},
|
||||
"status": "enabled",
|
||||
"lists": [
|
||||
{
|
||||
"subscription_status": "unconfirmed",
|
||||
"id": 1,
|
||||
"uuid": "ce13e971-c2ed-4069-bd0c-240e9a9f56f9",
|
||||
"name": "Default list",
|
||||
"type": "public",
|
||||
"tags": [
|
||||
"test"
|
||||
],
|
||||
"created_at": "2020-02-10T23:07:16.194843+01:00",
|
||||
"updated_at": "2020-02-10T23:07:16.194843+01:00"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"created_at": "2020-02-19T19:10:49.36636+01:00",
|
||||
"updated_at": "2020-02-19T19:10:49.36636+01:00",
|
||||
"uuid": "5d940585-3cc8-4add-b9c5-76efba3c6edd",
|
||||
"email": "sugar@example.com",
|
||||
"name": "sugar",
|
||||
"attribs": {},
|
||||
"status": "enabled",
|
||||
"lists": []
|
||||
}
|
||||
],
|
||||
"query": "",
|
||||
"total": 3,
|
||||
"per_page": 20,
|
||||
"page": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### GET /api/subscribers/{subscriber_id}
|
||||
|
||||
Retrieve a specific subscriber.
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|:--------------|:----------|:---------|:-----------------|
|
||||
| subscriber_id | Number | Yes | Subscriber's ID. |
|
||||
|
||||
##### Example Request
|
||||
|
||||
```shell
|
||||
curl -u 'username:password' 'http://localhost:9000/api/subscribers/1'
|
||||
```
|
||||
|
||||
##### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": 1,
|
||||
"created_at": "2020-02-10T23:07:16.199433+01:00",
|
||||
"updated_at": "2020-02-10T23:07:16.199433+01:00",
|
||||
"uuid": "ea06b2e7-4b08-4697-bcfc-2a5c6dde8f1c",
|
||||
"email": "john@example.com",
|
||||
"name": "John Doe",
|
||||
"attribs": {
|
||||
"city": "Bengaluru",
|
||||
"good": true,
|
||||
"type": "known"
|
||||
},
|
||||
"status": "enabled",
|
||||
"lists": [
|
||||
{
|
||||
"subscription_status": "unconfirmed",
|
||||
"id": 1,
|
||||
"uuid": "ce13e971-c2ed-4069-bd0c-240e9a9f56f9",
|
||||
"name": "Default list",
|
||||
"type": "public",
|
||||
"tags": [
|
||||
"test"
|
||||
],
|
||||
"created_at": "2020-02-10T23:07:16.194843+01:00",
|
||||
"updated_at": "2020-02-10T23:07:16.194843+01:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### GET /api/subscribers/lists/{list_id}
|
||||
|
||||
Retrieve subscribers in a specific list.
|
||||
|
||||
> Refer to the response structure in [GET /api/subscribers](#get-apisubscribers).
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### POST /api/subscribers
|
||||
|
||||
Create a new subscriber.
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|:-------------------------|:----------|:---------|:-----------------------------------------------------------------------------------------------------|
|
||||
| email | string | Yes | Subscriber's email address. |
|
||||
| name | string | Yes | Subscriber's name. |
|
||||
| status | string | Yes | Subscriber's status: `enabled`, `disabled`, `blocklisted`. |
|
||||
| lists | number\[\] | | List of list IDs to to subscribe to. |
|
||||
| attribs | JSON | | Attributes of the new subscriber. |
|
||||
| preconfirm_subscriptions | bool | | If true, subscriptions are marked as confirmed and no-optin emails are sent for double opt-in lists. |
|
||||
|
||||
##### Example Request
|
||||
|
||||
```shell
|
||||
curl -u 'username:password' 'http://localhost:9000/api/subscribers' -H 'Content-Type: application/json' \
|
||||
--data '{"email":"subsriber@domain.com","name":"The Subscriber","status":"enabled","lists":[1],"attribs":{"city":"Bengaluru","projects":3,"stack":{"languages":["go","python"]}}}'
|
||||
```
|
||||
|
||||
##### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": 3,
|
||||
"created_at": "2019-07-03T12:17:29.735507+05:30",
|
||||
"updated_at": "2019-07-03T12:17:29.735507+05:30",
|
||||
"uuid": "eb420c55-4cfb-4972-92ba-c93c34ba475d",
|
||||
"email": "subsriber@domain.com",
|
||||
"name": "The Subscriber",
|
||||
"attribs": {
|
||||
"city": "Bengaluru",
|
||||
"projects": 3,
|
||||
"stack": { "languages": ["go", "python"] }
|
||||
},
|
||||
"status": "enabled",
|
||||
"lists": [1]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### POST /api/public/subscription
|
||||
|
||||
Create a public subscription, accepts both form encoded or JSON encoded body.
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|:-----------|:----------|:---------|:----------------------------|
|
||||
| email | string | Yes | Subscriber's email address. |
|
||||
| name | string | | Subscriber's name. |
|
||||
| list_uuids | string\[\] | Yes | List of list UUIDs. |
|
||||
|
||||
##### Example JSON Request
|
||||
|
||||
```shell
|
||||
curl 'http://localhost:9000/api/public/subscription' -H 'Content-Type: application/json' \
|
||||
--data '{"email":"subsriber@domain.com","name":"The Subscriber","list_uuids": ["eb420c55-4cfb-4972-92ba-c93c34ba475d", "0c554cfb-eb42-4972-92ba-c93c34ba475d"]}'
|
||||
```
|
||||
|
||||
##### Example Form Request
|
||||
|
||||
```shell
|
||||
curl -u 'http://localhost:9000/api/public/subscription' \
|
||||
-d 'email=subsriber@domain.com' -d 'name=The Subscriber' -d 'l=eb420c55-4cfb-4972-92ba-c93c34ba475d' -d 'l=0c554cfb-eb42-4972-92ba-c93c34ba475d'
|
||||
```
|
||||
|
||||
Note: For form request, use `l` for multiple lists instead of `lists`.
|
||||
|
||||
##### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": true
|
||||
}
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### PUT /api/subscribers/lists
|
||||
|
||||
Modify subscriber list memberships.
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|:----------------|:----------|:-------------------|:------------------------------------------------------------------|
|
||||
| ids | number\[\] | Yes | Array of user IDs to be modified. |
|
||||
| action | string | Yes | Action to be applied: `add`, `remove`, or `unsubscribe`. |
|
||||
| target_list_ids | number\[\] | Yes | Array of list IDs to be modified. |
|
||||
| status | string | Required for `add` | Subscriber status: `confirmed`, `unconfirmed`, or `unsubscribed`. |
|
||||
|
||||
##### Example Request
|
||||
|
||||
```shell
|
||||
curl -u 'username:password' -X PUT 'http://localhost:9000/api/subscribers/lists' \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data-raw '{"ids": [1, 2, 3], "action": "add", "target_list_ids": [4, 5, 6], "status": "confirmed"}'
|
||||
```
|
||||
|
||||
##### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": true
|
||||
}
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### PUT /api/subscribers/{subscriber_id}
|
||||
|
||||
Update a specific subscriber.
|
||||
|
||||
> Refer to parameters from [POST /api/subscribers](#post-apisubscribers). Note: All parameters must be set, if not, the subscriber will be removed from all previously assigned lists.
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### PUT /api/subscribers/{subscriber_id}/blocklist
|
||||
|
||||
Blocklist a specific subscriber.
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|:--------------|:----------|:---------|:-----------------|
|
||||
| subscriber_id | Number | Yes | Subscriber's ID. |
|
||||
|
||||
##### Example Request
|
||||
|
||||
```shell
|
||||
curl -u 'username:password' -X PUT 'http://localhost:9000/api/subscribers/9/blocklist'
|
||||
```
|
||||
|
||||
##### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": true
|
||||
}
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### PUT /api/subscribers/query/blocklist
|
||||
|
||||
Blocklist subscribers based on SQL expression.
|
||||
|
||||
> Refer to the [querying and segmentation](../querying-and-segmentation.md#querying-and-segmenting-subscribers) section for more information on how to query subscribers with SQL expressions.
|
||||
|
||||
##### Example Request
|
||||
|
||||
```shell
|
||||
curl -u 'username:password' -X PUT 'http://localhost:9000/api/subscribers/query/blocklist' \
|
||||
--data-raw '"query=subscribers.name LIKE '\''John Doe'\'' AND subscribers.attribs->>'\''city'\'' = '\''Bengaluru'\''"'
|
||||
```
|
||||
|
||||
##### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": true
|
||||
}
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### DELETE /api/subscribers/{subscriber_id}
|
||||
|
||||
Delete a specific subscriber.
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|:--------------|:----------|:---------|:-----------------|
|
||||
| subscriber_id | Number | Yes | Subscriber's ID. |
|
||||
|
||||
##### Example Request
|
||||
|
||||
```shell
|
||||
curl -u 'username:password' -X DELETE 'http://localhost:9000/api/subscribers/9'
|
||||
```
|
||||
|
||||
##### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": true
|
||||
}
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### DELETE /api/subscribers
|
||||
|
||||
Delete one or more subscribers.
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|:-----|:----------|:---------|:---------------------------|
|
||||
| id | number\[\] | Yes | Array of subscriber's IDs. |
|
||||
|
||||
##### Example Request
|
||||
|
||||
```shell
|
||||
curl -u 'username:password' -X DELETE 'http://localhost:9000/api/subscribers?id=10&id=11'
|
||||
```
|
||||
|
||||
##### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": true
|
||||
}
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### POST /api/subscribers/query/delete
|
||||
|
||||
Delete subscribers based on SQL expression.
|
||||
|
||||
##### Example Request
|
||||
|
||||
```shell
|
||||
curl -u 'username:password' -X POST 'http://localhost:9000/api/subscribers/query/delete' \
|
||||
--data-raw '"query=subscribers.name LIKE '\''John Doe'\'' AND subscribers.attribs->>'\''city'\'' = '\''Bengaluru'\''"'
|
||||
```
|
||||
|
||||
##### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": true
|
||||
}
|
||||
```
|
225
docs/docs/content/apis/templates.md
Normal file
|
@ -0,0 +1,225 @@
|
|||
# API / Templates
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|:-------|:------------------------------------------------------------------------------|:-------------------------------|
|
||||
| GET | [/api/templates](#get-apitemplates) | Retrieve all templates |
|
||||
| GET | [/api/templates/{template_id}](#get-apitemplates-template_id) | Retrieve a template |
|
||||
| GET | [/api/templates/{template_id}/preview](#get-apitemplates-template_id-preview) | Retrieve template HTML preview |
|
||||
| POST | [/api/templates](#post-apitemplates) | Create a template |
|
||||
| POST | /api/templates/preview | Render and preview a template |
|
||||
| PUT | [/api/templates/{template_id}](#put-apitemplatestemplate_id) | Update a template |
|
||||
| PUT | [/api/templates/{template_id}/default](#put-apitemplates-template_id-default) | Set default template |
|
||||
| DELETE | [/api/templates/{template_id}](#delete-apitemplates-template_id) | Delete a template |
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### GET /api/templates
|
||||
|
||||
Retrieve all templates.
|
||||
|
||||
##### Example Request
|
||||
|
||||
```shell
|
||||
curl -u "username:username" -X GET 'http://localhost:9000/api/templates'
|
||||
```
|
||||
|
||||
##### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"created_at": "2020-03-14T17:36:41.288578+01:00",
|
||||
"updated_at": "2020-03-14T17:36:41.288578+01:00",
|
||||
"name": "Default template",
|
||||
"body": "{{ template \"content\" . }}",
|
||||
"type": "campaign",
|
||||
"is_default": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### GET /api/templates/{template_id}
|
||||
|
||||
Retrieve a specific template.
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|:------------|:----------|:---------|:-------------------------------|
|
||||
| template_id | number | Yes | ID of the template to retrieve |
|
||||
|
||||
##### Example Request
|
||||
|
||||
```shell
|
||||
curl -u "username:username" -X GET 'http://localhost:9000/api/templates/1'
|
||||
```
|
||||
|
||||
##### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": 1,
|
||||
"created_at": "2020-03-14T17:36:41.288578+01:00",
|
||||
"updated_at": "2020-03-14T17:36:41.288578+01:00",
|
||||
"name": "Default template",
|
||||
"body": "{{ template \"content\" . }}",
|
||||
"type": "campaign",
|
||||
"is_default": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### GET /api/templates/{template_id}/preview
|
||||
|
||||
Retrieve the HTML preview of a template.
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|:------------|:----------|:---------|:------------------------------|
|
||||
| template_id | number | Yes | ID of the template to preview |
|
||||
|
||||
##### Example Request
|
||||
|
||||
```shell
|
||||
curl -u "username:username" -X GET 'http://localhost:9000/api/templates/1/preview'
|
||||
```
|
||||
|
||||
##### Example Response
|
||||
|
||||
```html
|
||||
<p>Hi there</p>
|
||||
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis et elit ac elit sollicitudin condimentum non a magna.
|
||||
Sed tempor mauris in facilisis vehicula. Aenean nisl urna, accumsan ac tincidunt vitae, interdum cursus massa.
|
||||
Interdum et malesuada fames ac ante ipsum primis in faucibus. Aliquam varius turpis et turpis lacinia placerat.
|
||||
Aenean id ligula a orci lacinia blandit at eu felis. Phasellus vel lobortis lacus. Suspendisse leo elit, luctus sed
|
||||
erat ut, venenatis fermentum ipsum. Donec bibendum neque quis.</p>
|
||||
|
||||
<h3>Sub heading</h3>
|
||||
<p>Nam luctus dui non placerat mattis. Morbi non accumsan orci, vel interdum urna. Duis faucibus id nunc ut euismod.
|
||||
Curabitur et eros id erat feugiat fringilla in eget neque. Aliquam accumsan cursus eros sed faucibus.</p>
|
||||
|
||||
<p>Here is a link to <a href="https://listmonk.app" target="_blank">listmonk</a>.</p>
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### POST /api/templates
|
||||
|
||||
Create a template.
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|:--------|:----------|:---------|:----------------------------------------------|
|
||||
| name | string | Yes | Name of the template |
|
||||
| type | string | Yes | Type of the template (`campaign` or `tx`) |
|
||||
| subject | string | | Subject line for the template (only for `tx`) |
|
||||
| body | string | Yes | HTML body of the template |
|
||||
|
||||
##### Example Request
|
||||
|
||||
```shell
|
||||
curl -u "username:password" -X POST 'http://localhost:9000/api/templates' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"name": "New template",
|
||||
"type": "campaign",
|
||||
"subject": "Your Weekly Newsletter",
|
||||
"body": "<h1>Header</h1><p>Content goes here</p>"
|
||||
}'
|
||||
```
|
||||
|
||||
##### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"created_at": "2020-03-14T17:36:41.288578+01:00",
|
||||
"updated_at": "2020-03-14T17:36:41.288578+01:00",
|
||||
"name": "Default template",
|
||||
"body": "{{ template \"content\" . }}",
|
||||
"type": "campaign",
|
||||
"is_default": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### PUT /api/templates/{template_id}
|
||||
|
||||
Update a template.
|
||||
|
||||
> Refer to parameters from [POST /api/templates](#post-apitemplates)
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### PUT /api/templates/{template_id}/default
|
||||
|
||||
Set a template as the default.
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|:------------|:----------|:---------|:-------------------------------------|
|
||||
| template_id | number | Yes | ID of the template to set as default |
|
||||
|
||||
##### Example Request
|
||||
|
||||
```shell
|
||||
curl -u "username:username" -X PUT 'http://localhost:9000/api/templates/1/default'
|
||||
```
|
||||
|
||||
##### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": 1,
|
||||
"created_at": "2020-03-14T17:36:41.288578+01:00",
|
||||
"updated_at": "2020-03-14T17:36:41.288578+01:00",
|
||||
"name": "Default template",
|
||||
"body": "{{ template \"content\" . }}",
|
||||
"type": "campaign",
|
||||
"is_default": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### DELETE /api/templates/{template_id}
|
||||
|
||||
Delete a template.
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|:------------|:----------|:---------|:-----------------------------|
|
||||
| template_id | number | Yes | ID of the template to delete |
|
||||
|
||||
##### Example Request
|
||||
|
||||
```shell
|
||||
curl -u "username:username" -X DELETE 'http://localhost:9000/api/templates/35'
|
||||
```
|
||||
|
||||
##### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": true
|
||||
}
|
||||
```
|
65
docs/docs/content/apis/transactional.md
Normal file
|
@ -0,0 +1,65 @@
|
|||
# API / Transactional
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|:-------|:---------|:-------------------------------|
|
||||
| POST | /api/tx | Send transactional messages |
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### POST /api/tx
|
||||
|
||||
Allows sending transactional messages to one or more subscribers via a preconfigured transactional template.
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|:------------------|:----------|:---------|:---------------------------------------------------------------------------|
|
||||
| subscriber_email | string | | Email of the subscriber. Can substitute with `subscriber_id`. |
|
||||
| subscriber_id | number | | Subscriber's ID can substitute with `subscriber_email`. |
|
||||
| subscriber_emails | string\[\] | | Multiple subscriber emails as alternative to `subscriber_email`. |
|
||||
| subscriber_ids | number\[\] | | Multiple subscriber IDs as an alternative to `subscriber_id`. |
|
||||
| template_id | number | Yes | ID of the transactional template to be used for the message. |
|
||||
| from_email | string | | Optional sender email. |
|
||||
| data | JSON | | Optional nested JSON map. Available in the template as `{{ .Tx.Data.* }}`. |
|
||||
| headers | JSON\[\] | | Optional array of email headers. |
|
||||
| messenger | string | | Messenger to send the message. Default is `email`. |
|
||||
| content_type | string | | Email format options include `html`, `markdown`, and `plain`. |
|
||||
|
||||
##### Example
|
||||
|
||||
```shell
|
||||
curl -u "username:password" "http://localhost:9000/api/tx" -X POST \
|
||||
-H 'Content-Type: application/json; charset=utf-8' \
|
||||
--data-binary @- << EOF
|
||||
{
|
||||
"subscriber_email": "user@test.com",
|
||||
"template_id": 2,
|
||||
"data": {"order_id": "1234", "date": "2022-07-30", "items": [1, 2, 3]},
|
||||
"content_type": "html"
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
##### Example response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": true
|
||||
}
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### File Attachments
|
||||
|
||||
To include file attachments in a transactional message, use the `multipart/form-data` Content-Type. Use `data` param for the parameters described above as a JSON object. Include any number of attachments via the `file` param.
|
||||
|
||||
```shell
|
||||
curl -u "username:password" "http://localhost:9000/api/tx" -X POST \
|
||||
-F 'data=\"{
|
||||
\"subscriber_email\": \"user@test.com\",
|
||||
\"template_id\": 4
|
||||
}"' \
|
||||
-F 'file=@"/path/to/attachment.pdf"' \
|
||||
-F 'file=@"/path/to/attachment2.pdf"'
|
||||
```
|
32
docs/docs/content/archives.md
Normal file
|
@ -0,0 +1,32 @@
|
|||
# Archives
|
||||
|
||||
A global public archive is maintained on the public web interface. It can be
|
||||
enabled under Settings -> Settings -> General -> Enable public mailing list
|
||||
archive.
|
||||
|
||||
To make a campaign available in the public archive (provided it has been
|
||||
enabled in the settings as described above), enable the option
|
||||
'Publish to public archive' under Campaigns -> Create new -> Archive.
|
||||
|
||||
When using template variables that depend on subscriber data (such as any
|
||||
template variable referencing `.Subscriber`), such data must be supplied
|
||||
as 'Campaign metadata', which is a JSON object that will be used in place
|
||||
of `.Subscriber` when rendering the archive template and content.
|
||||
|
||||
When individual subscriber tracking is enabled, TrackLink requires that a UUID
|
||||
of an existing user is provided as part of the campaign metadata. Any clicks on
|
||||
a TrackLink from the archived campaign will be counted towards that subscriber.
|
||||
|
||||
As an example:
|
||||
|
||||
```json
|
||||
{
|
||||
"UUID": "5a837423-a186-5623-9a87-82691cbe3631",
|
||||
"email": "example@example.com",
|
||||
"name": "Reader",
|
||||
"attribs": {}
|
||||
}
|
||||
```
|
||||
|
||||

|
||||
|
66
docs/docs/content/bounces.md
Normal file
|
@ -0,0 +1,66 @@
|
|||
# Bounce processing
|
||||
|
||||
Enable bounce processing in Settings -> Bounces. POP3 bounce scanning and APIs only become available once the setting is enabled.
|
||||
|
||||
## POP3 bounce mailbox
|
||||
Configure the bounce mailbox in Settings -> Bounces. Either the "From" e-mail that is set on a campaign (or in settings) should have a POP3 mailbox behind it to receive bounce e-mails, or you should configure a dedicated POP3 mailbox and add that address as the `Return-Path` (envelope sender) header in Settings -> SMTP -> Custom headers box. For example:
|
||||
|
||||
```
|
||||
[
|
||||
{"Return-Path": "your-bounce-inbox@site.com"}
|
||||
]
|
||||
|
||||
```
|
||||
|
||||
Some mail servers may also return the bounce to the `Reply-To` address, which can also be added to the header settings.
|
||||
|
||||
## Webhook API
|
||||
The bounce webhook API can be used to record bounce events with custom scripting. This could be by reading a mailbox, a database, or mail server logs.
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| ------ | ---------------- | ---------------------- |
|
||||
| `POST` | /webhooks/bounce | Record a bounce event. |
|
||||
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
| ----------------| --------- | -----------| ------------------------------------------------------------------------------------ |
|
||||
| subscriber_uuid | string | | The UUID of the subscriber. Either this or `email` is required. |
|
||||
| email | string | | The e-mail of the subscriber. Either this or `subscriber_uuid` is required. |
|
||||
| campaign_uuid | string | | UUID of the campaign for which the bounce happened. |
|
||||
| source | string | Yes | A string indicating the source, eg: `api`, `my_script` etc. |
|
||||
| type | string | Yes | `hard` or `soft` bounce. Currently, this has no effect on how the bounce is treated. |
|
||||
| meta | string | | An optional escaped JSON string with arbitrary metadata about the bounce event. |
|
||||
|
||||
|
||||
```shell
|
||||
curl -u 'username:password' -X POST localhost:9000/webhooks/bounce \
|
||||
-H "Content-Type: application/json" \
|
||||
--data '{"email": "user1@mail.com", "campaign_uuid": "9f86b50d-5711-41c8-ab03-bc91c43d711b", "source": "api", "type": "hard", "meta": "{\"additional\": \"info\"}}'
|
||||
|
||||
```
|
||||
|
||||
## External webhooks
|
||||
listmonk supports receiving bounce webhook events from the following SMTP providers.
|
||||
|
||||
| Endpoint | Description | More info |
|
||||
|:----------------------------------------------------------|:---------------------------------------|:-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `https://listmonk.yoursite.com/webhooks/service/ses` | Amazon (AWS) SES | You can use these [Mautic steps](https://docs.mautic.org/en/channels/emails/bounce-management#amazon-webhook) as a general guide, but use your listmonk's endpoint instead. <ul> <li>When creating the *topic* select "standard" instead of the preselected "FIFO". You can put a name and leave everything else at default.</li> <li>When creating a *subscription* choose HTTPS for "Protocol", and leave *"Enable raw message delivery"* UNCHECKED.</li> <li>On the _"SES -> verified identities"_ page, make sure to check **"[include original headers](https://github.com/knadh/listmonk/issues/720#issuecomment-1046877192)"**.</li> <li>The Mautic screenshot suggests you should turn off _email feedback forwarding_, but that's completely optional depending on whether you want want email notifications.</li></ul> |
|
||||
| `https://listmonk.yoursite.com/webhooks/service/sendgrid` | Sendgrid / Twilio Signed event webhook | [More info](https://docs.sendgrid.com/for-developers/tracking-events/getting-started-event-webhook-security-features) |
|
||||
| `https://listmonk.yoursite.com/webhooks/service/postmark` | Postmark webhook | [More info](https://postmarkapp.com/developer/webhooks/webhooks-overview) |
|
||||
|
||||
|
||||
## Verification
|
||||
|
||||
If you're using Amazon SES you can use Amazon's test emails to make sure everything's working: [https://docs.aws.amazon.com/ses/latest/dg/send-an-email-from-console.html](https://docs.aws.amazon.com/ses/latest/dg/send-an-email-from-console.html)
|
||||
```
|
||||
success@simulator.amazonses.com
|
||||
bounce@simulator.amazonses.com
|
||||
complaint@simulator.amazonses.com
|
||||
suppressionlist@simulator.amazonses.com
|
||||
```
|
||||
They all count as _hard_ bounces.
|
||||
|
||||
|
||||
**Exporting bounces**: [https://github.com/knadh/listmonk/issues/863](https://github.com/knadh/listmonk/issues/863)
|
||||
|
||||
|
72
docs/docs/content/concepts.md
Normal file
|
@ -0,0 +1,72 @@
|
|||
# Concepts
|
||||
|
||||
## Subscriber
|
||||
|
||||
A subscriber is a recipient identified by an e-mail address and name. Subscribers receive e-mails that are sent from listmonk. A subscriber can be added to any number of lists. Subscribers who are not a part of any lists are considered *orphan* records.
|
||||
|
||||
### Attributes
|
||||
|
||||
Attributes are arbitrary properties attached to a subscriber in addition to their e-mail and name. They are represented as a JSON map. It is not necessary for all subscribers to have the same attributes. Subscribers can be [queried and segmented](querying-and-segmentation.md) into lists based on their attributes, and the attributes can be inserted into the e-mails sent to them. For example:
|
||||
|
||||
```json
|
||||
{
|
||||
"city": "Bengaluru",
|
||||
"likes_tea": true,
|
||||
"spoken_languages": ["English", "Malayalam"],
|
||||
"projects": 3,
|
||||
"stack": {
|
||||
"frameworks": ["echo", "go"],
|
||||
"languages": ["go", "python"],
|
||||
"preferred_language": "go"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Subscription statuses
|
||||
|
||||
A subscriber can be added to one or more lists, and each such relationship can have one of these statuses.
|
||||
|
||||
| Status | Description |
|
||||
| ------------- | --------------------------------------------------------------------------------- |
|
||||
| `unconfirmed` | The subscriber was added to the list directly without their explicit confirmation. Nonetheless, the subscriber will receive campaign messages sent to single optin campaigns. |
|
||||
| `confirmed` | The subscriber confirmed their subscription by clicking on 'accept' in the confirmation e-mail. Only confirmed subscribers in opt-in lists will receive campaign messages send to the list. |
|
||||
| `unsubscribed` | The subscriber is unsubscribed from the list and will not receive any campaign messages sent to the list.
|
||||
|
||||
|
||||
### Segmentation
|
||||
|
||||
Segmentation is the process of filtering a large list of subscribers into a smaller group based on arbitrary conditions, primarily based on their attributes. For instance, if an e-mail needs to be sent subscribers who live in a particular city, given their city is described in their attributes, it's possible to quickly filter them out into a new list and e-mail them. [Learn more](querying-and-segmentation.md).
|
||||
|
||||
## List
|
||||
|
||||
A list (or a _mailing list_) is a collection of subscribers grouped under a name, for instance, _clients_. Lists are used to organise subscribers and send e-mails to specific groups. A list can be single optin or double optin. Subscribers added to double optin lists have to explicitly accept the subscription by clicking on the confirmation e-mail they receive. Until then, they do not receive campaign messages.
|
||||
|
||||
## Campaign
|
||||
|
||||
A campaign is an e-mail (or any other kind of messages) that is sent to one or more lists.
|
||||
|
||||
|
||||
## Transactional message
|
||||
|
||||
A transactional message is an arbitrary message sent to a subscriber using the transactional message API. For example a welcome e-mail on signing up to a service; an order confirmation e-mail on purchasing an item; a password reset e-mail when a user initiates an online account recovery process.
|
||||
|
||||
|
||||
## Template
|
||||
|
||||
A template is a re-usable HTML design that can be used across campaigns and when sending arbitrary transactional messages. Most commonly, templates have standard header and footer areas with logos and branding elements, where campaign content is inserted in the middle. listmonk supports [Go template](https://gowebexamples.com/templates/) expressions that lets you create powerful, dynamic HTML templates. [Learn more](templating.md).
|
||||
|
||||
## Messenger
|
||||
|
||||
listmonk supports multiple custom messaging backends in additional to the default SMTP e-mail backend, enabling not just e-mail campaigns, but arbitrary message campaigns such as SMS, FCM notifications etc. A *Messenger* is a web service that accepts a campaign message pushed to it as a JSON request, which the service can in turn broadcast as SMS, FCM etc. [Learn more](messengers.md).
|
||||
|
||||
## Tracking pixel
|
||||
|
||||
The tracking pixel is a tiny, invisible image that is inserted into an e-mail body to track e-mail views. This allows measuring the read rate of e-mails. While this is exceedingly common in e-mail campaigns, it carries privacy implications and should be used in compliance with rules and regulations such as GDPR. It is possible to track reads anonymously without associating an e-mail read to a subscriber.
|
||||
|
||||
## Click tracking
|
||||
|
||||
It is possible to track the clicks on every link that is sent in an e-mail. This allows measuring the clickthrough rates of links in e-mails. While this is exceedingly common in e-mail campaigns, it carries privacy implications and should be used in compliance with rules and regulations such as GDPR. It is possible to track link clicks anonymously without associating an e-mail read to a subscriber.
|
||||
|
||||
## Bounce
|
||||
|
||||
A bounce occurs when an e-mail that is sent to a recipient "bounces" back for one of many reasons including the recipient address being invalid, their mailbox being full, or the recipient's e-mail service provider marking the e-mail as spam. listmonk can automatically process such bounce e-mails that land in a configured POP mailbox, or via APIs of SMTP e-mail providers such as AWS SES and Sengrid. Based on settings, subscribers returning bounced e-mails can either be blocklisted or deleted automatically. [Learn more](bounces.md).
|
112
docs/docs/content/configuration.md
Normal file
|
@ -0,0 +1,112 @@
|
|||
# Configuration
|
||||
|
||||
### TOML Configuration file
|
||||
One or more TOML files can be read by passing `--config config.toml` multiple times. Apart from a few low level configuration variables and the database configuration, all other settings can be managed from the `Settings` dashboard on the admin UI.
|
||||
|
||||
To generate a new sample configuration file, run `--listmonk --new-config`
|
||||
|
||||
### Environment variables
|
||||
Variables in config.toml can also be provided as environment variables prefixed by `LISTMONK_` with periods replaced by `__` (double underscore). Example:
|
||||
|
||||
| **Environment variable** | Example value |
|
||||
| ------------------------------ | -------------- |
|
||||
| `LISTMONK_app__address` | "0.0.0.0:9000" |
|
||||
| `LISTMONK_app__admin_username` | listmonk |
|
||||
| `LISTMONK_app__admin_password` | listmonk |
|
||||
| `LISTMONK_db__host` | db |
|
||||
| `LISTMONK_db__port` | 9432 |
|
||||
| `LISTMONK_db__user` | listmonk |
|
||||
| `LISTMONK_db__password` | listmonk |
|
||||
| `LISTMONK_db__database` | listmonk |
|
||||
| `LISTMONK_db__ssl_mode` | disable |
|
||||
|
||||
|
||||
### Customizing system templates
|
||||
See [system templates](templating.md#system-templates).
|
||||
|
||||
|
||||
### HTTP routes
|
||||
When configuring auth proxies and web application firewalls, use this table.
|
||||
|
||||
#### Private admin endpoints.
|
||||
|
||||
| Methods | Route | Description |
|
||||
| ------- | ------------------ | ----------------------- |
|
||||
| `*` | `/api/*` | Admin APIs |
|
||||
| `GET` | `/admin/*` | Admin UI and HTML pages |
|
||||
| `POST` | `/webhooks/bounce` | Admin bounce webhook |
|
||||
|
||||
|
||||
#### Public endpoints to expose to the internet.
|
||||
|
||||
| Methods | Route | Description |
|
||||
| ----------- | --------------------- | --------------------------------------------- |
|
||||
| `GET, POST` | `/subscription/*` | HTML subscription pages |
|
||||
| `GET, ` | `/link/*` | Tracked link redirection |
|
||||
| `GET` | `/campaign/*` | Pixel tracking image |
|
||||
| `GET` | `/public/*` | Static files for HTML subscription pages |
|
||||
| `POST` | `/webhooks/service/*` | Bounce webhook endpoints for AWS and Sendgrid |
|
||||
|
||||
|
||||
## Media uploads
|
||||
|
||||
#### Using filesystem
|
||||
|
||||
When configuring `docker` volume mounts for using filesystem media uploads, you can follow either of two approaches. [The second option may be necessary if](https://github.com/knadh/listmonk/issues/1169#issuecomment-1674475945) your setup requires you to use `sudo` for docker commands.
|
||||
|
||||
After making any changes you will need to run `sudo docker compose stop ; sudo docker compose up`.
|
||||
|
||||
And under `https://listmonk.mysite.com/admin/settings` you put `/listmonk/uploads`.
|
||||
|
||||
#### Using volumes
|
||||
|
||||
Using `docker volumes`, you can specify the name of volume and destination for the files to be uploaded inside the container.
|
||||
|
||||
|
||||
```yml
|
||||
app:
|
||||
volumes:
|
||||
- type: volume
|
||||
source: listmonk-uploads
|
||||
target: /listmonk/uploads
|
||||
|
||||
volumes:
|
||||
listmonk-uploads:
|
||||
```
|
||||
|
||||
!!! note
|
||||
|
||||
This volume is managed by `docker` itself, and you can see find the host path with `docker volume inspect listmonk_listmonk-uploads`.
|
||||
|
||||
#### Using bind mounts
|
||||
|
||||
```yml
|
||||
app:
|
||||
volumes:
|
||||
- ./path/on/your/host/:/path/inside/container
|
||||
```
|
||||
Eg:
|
||||
```yml
|
||||
app:
|
||||
volumes:
|
||||
- ./data/uploads:/listmonk/uploads
|
||||
```
|
||||
The files will be available inside `/data/uploads` directory on the host machine.
|
||||
|
||||
To use the default `uploads` folder:
|
||||
```yml
|
||||
app:
|
||||
volumes:
|
||||
- ./uploads:/listmonk/uploads
|
||||
```
|
||||
|
||||
|
||||
## Time zone
|
||||
|
||||
To change listmonk's time zone (logs, etc.) edit `docker-compose.yml`:
|
||||
```
|
||||
environment:
|
||||
- TZ=Etc/UTC
|
||||
```
|
||||
with any Timezone listed [here](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones). Then run `sudo docker-compose stop ; sudo docker-compose up` after making changes.
|
||||
|
27
docs/docs/content/developer-setup.md
Normal file
|
@ -0,0 +1,27 @@
|
|||
# Developer setup
|
||||
The app has two distinct components, the Go backend and the VueJS frontend. In the dev environment, both are run independently.
|
||||
|
||||
|
||||
### Pre-requisites
|
||||
- `go`
|
||||
- `nodejs` (if you are working on the frontend) and `yarn`
|
||||
- Postgres database. If there is no local installation, the demo docker DB can be used for development (`docker compose up demo-db`)
|
||||
|
||||
|
||||
### First time setup
|
||||
`git clone https://github.com/knadh/listmonk.git`. The project uses go.mod, so it's best to clone it outside the Go src path.
|
||||
|
||||
1. Copy `config.toml.sample` as `config.toml` and add your config.
|
||||
2. `make dist` to build the listmonk binary. Once the binary is built, run `./listmonk --install` to run the DB setup. For subsequent dev runs, use `make run`.
|
||||
|
||||
> [mailhog](https://github.com/mailhog/MailHog) is an excellent standalone mock SMTP server (with a UI) for testing and dev.
|
||||
|
||||
|
||||
### Running the dev environment
|
||||
1. Run `make run` to start the listmonk dev server on `:9000`.
|
||||
2. Run `make run-frontend` to start the Vue frontend in dev mode using yarn on `:8080`. All `/api/*` calls are proxied to the app running on `:9000`. Refer to the [frontend README](https://github.com/knadh/listmonk/blob/master/frontend/README.md) for an overview on how the frontend is structured.
|
||||
3. Visit `http://localhost:8080`
|
||||
|
||||
|
||||
# Production build
|
||||
Run `make dist` to build the Go binary, build the Javascript frontend, and embed the static assets producing a single self-contained binary, `listmonk`
|
11
docs/docs/content/external-integration.md
Normal file
|
@ -0,0 +1,11 @@
|
|||
# Integrating with external systems
|
||||
|
||||
In many environments, a mailing list manager's subscriber database is not run independently but as a part of an existing customer database or a CRM. There are multiple ways of keeping listmonk in sync with external systems.
|
||||
|
||||
## Using APIs
|
||||
|
||||
The [subscriber APIs](apis/subscribers.md) offers several APIs to manipulate the subscribers database, like addition, updation, and deletion. For bulk synchronisation, a CSV can be generated (and optionally zipped) and posted to the import API.
|
||||
|
||||
## Interacting directly with the DB
|
||||
|
||||
listmonk uses tables with simple schemas to represent subscribers (`subscribers`), lists (`lists`), and subscriptions (`subscriber_lists`). It is easy to add, update, and delete subscriber information directly with the database tables for advanced usecases. See the [table schemas](https://github.com/knadh/listmonk/blob/master/schema.sql) for more information.
|
35
docs/docs/content/i18n.md
Normal file
|
@ -0,0 +1,35 @@
|
|||
# Internationalization (i18n)
|
||||
|
||||
listmonk comes available in multiple languages thanks to language packs contributed by volunteers. A language pack is a JSON file with a map of keys and corresponding translations. The bundled languages can be [viewed here](https://github.com/knadh/listmonk/tree/master/i18n).
|
||||
|
||||
## Additional language packs
|
||||
These additional language packs can be downloaded and passed to listmonk with the `--i18n-dir` flag as described in the next section.
|
||||
|
||||
| Language | Description |
|
||||
|------------------|--------------------------------------|
|
||||
| [Deutsch (formal)](https://raw.githubusercontent.com/SvenPe/listmonk/4bbb2e5ebb2314b754cb2318f4f6683a0f854d43/i18n/de.json) | German language with formal pronouns |
|
||||
|
||||
|
||||
## Customizing languages
|
||||
|
||||
To customize an existing language or to load a new language, put one or more `.json` language files in a directory, and pass the directory path to listmonk with the<br />`--i18n-dir=/path/to/dir` flag.
|
||||
|
||||
|
||||
## Contributing a new language
|
||||
|
||||
### Using the basic editor
|
||||
|
||||
- Visit [https://listmonk.app/i18n](https://listmonk.app/i18n)
|
||||
- Click on `Createa new language`, or to make changes to an existing language, use `Load language`.
|
||||
- Translate the text in the text fields on the UI.
|
||||
- Once done, use the `Download raw JSON` to download the language file.
|
||||
- Send a pull request to add the file to the [i18n directory on the GitHub repo](https://github.com/knadh/listmonk/tree/master/i18n).
|
||||
|
||||
### Using InLang (external service)
|
||||
|
||||
[](https://inlang.com/editor/github.com/knadh/listmonk?ref=badge)
|
||||
|
||||
- Visit [https://inlang.com/editor/github.com/knadh/listmonk](https://inlang.com/editor/github.com/knadh/listmonk)
|
||||
- To make changes and push them, you need to log in to GitHub using OAuth and fork the project from the UI.
|
||||
- Translate the text in the input fields on the UI. You can use the filters to see only the necessary translations.
|
||||
- Once you're done, push the changes from the UI and click on "Open a pull request." This will take you to GitHub, where you can write a PR message.
|
BIN
docs/docs/content/images/2021-09-28_00-18.png
Normal file
After Width: | Height: | Size: 96 KiB |
BIN
docs/docs/content/images/archived-campaign-metadata.png
Normal file
After Width: | Height: | Size: 253 KiB |
BIN
docs/docs/content/images/edit-subscriber.png
Normal file
After Width: | Height: | Size: 35 KiB |
BIN
docs/docs/content/images/favicon.png
Normal file
After Width: | Height: | Size: 1.9 KiB |
1
docs/docs/content/images/logo.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="163.03" height="30.38" viewBox="0 0 43.135 8.038" xmlns:v="https://vecta.io/nano"><circle cx="4.019" cy="4.019" r="3.149" fill="none" stroke="#0055d4" stroke-width="1.74"/><path d="M11.457 7.303q-.566 0-.879-.322-.313-.331-.313-.932V.712L11.5.572v5.442q0 .305.253.305.139 0 .244-.052l.253.879q-.357.157-.792.157zm2.619-4.754v4.615H12.84V2.549zM13.449.172q.331 0 .54.209.218.2.218.514 0 .313-.218.522-.209.2-.54.2-.331 0-.54-.2-.209-.209-.209-.522 0-.313.209-.514.209-.209.54-.209zm3.319 2.238q.975 0 1.672.557l-.47.705q-.583-.366-1.149-.366-.305 0-.47.113-.165.113-.165.305 0 .139.07.235.078.096.279.183.209.087.618.209.731.2 1.088.54.357.331.357.914 0 .462-.27.801-.261.34-.714.522-.453.174-1.01.174-.583 0-1.062-.174-.479-.183-.819-.496l.61-.679q.583.453 1.237.453.348 0 .549-.131.209-.139.209-.374 0-.183-.078-.287-.078-.104-.287-.192-.209-.096-.653-.218-.697-.192-1.036-.54-.331-.357-.331-.879 0-.392.226-.705.226-.313.636-.488.418-.183.967-.183zm5.342 4.536q-.253.174-.575.261-.313.096-.627.096-.714-.009-1.08-.409-.366-.401-.366-1.176V3.42h-.688v-.871h.688v-1.01l1.237-.148v1.158h1.062l-.122.871h-.94v2.273q0 .331.113.479.113.148.348.148.235 0 .522-.157zm5.493-4.536q.549 0 .879.374.34.374.34 1.019v3.361h-1.237V4.012q0-.679-.453-.679-.244 0-.427.157-.183.157-.374.488v3.187h-1.237V4.012q0-.679-.453-.679-.244 0-.427.165-.183.157-.366.479v3.187h-1.237V2.549h1.071l.096.575q.261-.348.583-.531.331-.183.758-.183.392 0 .679.2.287.192.418.549.287-.374.618-.557.34-.192.766-.192zm4.148 0q1.036 0 1.62.653.583.644.583 1.794 0 .731-.27 1.289-.261.549-.766.853-.496.305-1.176.305-1.036 0-1.628-.644-.583-.653-.583-1.803 0-.731.261-1.28.27-.557.766-.862.505-.305 1.193-.305zm0 .923q-.47 0-.705.374-.226.366-.226 1.149 0 .784.226 1.158.235.366.697.366.462 0 .688-.366.235-.374.235-1.158 0-.784-.226-1.149-.226-.374-.688-.374zm5.271-.923q.61 0 .949.374.34.366.34 1.019v3.361h-1.237V4.012q0-.374-.131-.522-.122-.157-.374-.157-.261 0-.479.165-.209.157-.409.479v3.187h-1.237V2.549h1.071l.096.583q.287-.357.627-.54.348-.183.784-.183zM40.2.572v6.592h-1.237V.712zm2.804 1.977l-1.472 2.029 1.602 2.586h-1.402l-1.489-2.525 1.48-2.09z"/></svg>
|
After Width: | Height: | Size: 2.1 KiB |
BIN
docs/docs/content/images/query-subscribers.png
Normal file
After Width: | Height: | Size: 21 KiB |
BIN
docs/docs/content/images/splash.png
Normal file
After Width: | Height: | Size: 91 KiB |
10
docs/docs/content/index.md
Normal file
|
@ -0,0 +1,10 @@
|
|||
# Introduction
|
||||
|
||||
[](https://listmonk.app)
|
||||
|
||||
listmonk is a self-hosted, high performance mailing list and newsletter manager. It comes as a standalone binary and the only dependency is a Postgres database.
|
||||
|
||||
[](https://listmonk.app)
|
||||
|
||||
## Developers
|
||||
listmonk is a free and open source software licensed under AGPLv3. If you are interested in contributing, check out the [GitHub repository](https://github.com/knadh/listmonk) and refer to the [developer setup](developer-setup.md). The backend is written in Go and the frontend is Vue with Buefy for UI.
|
165
docs/docs/content/installation.md
Normal file
|
@ -0,0 +1,165 @@
|
|||
# Installation
|
||||
|
||||
listmonk requires Postgres ⩾ 12.
|
||||
|
||||
See the "[Tutorials](#tutorials)" section at the bottom for detailed guides.
|
||||
|
||||
## Binary
|
||||
- Download the [latest release](https://github.com/knadh/listmonk/releases) and extract the listmonk binary.
|
||||
- `./listmonk --new-config` to generate config.toml. Then, edit the file.
|
||||
- `./listmonk --install` to install the tables in the Postgres DB.
|
||||
- Run `./listmonk` and visit `http://localhost:9000`.
|
||||
|
||||
|
||||
## Docker
|
||||
|
||||
The latest image is available on DockerHub at `listmonk/listmonk:latest`
|
||||
|
||||
!!! note
|
||||
Listmonk's docs and scripts use `docker compose`, which is compatible with the latest version of docker. If you installed docker and docker-compose from your Linux distribution, you probably have an older version and will need to use the `docker-compose` command instead, or you'll need to update docker manually. [More info](https://gist.github.com/MaximilianKohler/e5158fcfe6de80a9069926a67afcae11#docker-update).
|
||||
|
||||
Use the sample [docker-compose.yml](https://github.com/knadh/listmonk/blob/master/docker-compose.yml) to run listmonk and Postgres DB with `docker compose` as follows:
|
||||
|
||||
### Demo
|
||||
|
||||
#### Easy Docker install
|
||||
|
||||
```bash
|
||||
mkdir listmonk-demo && cd listmonk-demo
|
||||
sh -c "$(curl -fsSL https://raw.githubusercontent.com/knadh/listmonk/master/install-demo.sh)"
|
||||
```
|
||||
|
||||
#### Manual Docker install
|
||||
|
||||
```bash
|
||||
wget -O docker-compose.yml https://raw.githubusercontent.com/knadh/listmonk/master/docker-compose.yml
|
||||
docker compose up -d demo-db demo-app
|
||||
```
|
||||
|
||||
!!! warning
|
||||
The demo does not persist Postgres after the containers are removed. **DO NOT** use this demo setup in production.
|
||||
|
||||
### Production
|
||||
|
||||
#### Easy Docker install
|
||||
|
||||
This setup is recommended if you want to _quickly_ setup `listmonk` in production.
|
||||
|
||||
```bash
|
||||
mkdir listmonk && cd listmonk
|
||||
sh -c "$(curl -fsSL https://raw.githubusercontent.com/knadh/listmonk/master/install-prod.sh)"
|
||||
```
|
||||
|
||||
The above shell script performs the following actions:
|
||||
|
||||
- Downloads `docker-compose.yml` and generates a `config.toml`.
|
||||
- Runs a Postgres container and installs the database schema.
|
||||
- Runs the `listmonk` container.
|
||||
|
||||
!!! note
|
||||
It's recommended to examine the contents of the shell script, before running in your environment.
|
||||
|
||||
#### Manual Docker install
|
||||
|
||||
The following workflow is recommended to setup `listmonk` manually using `docker compose`. You are encouraged to customise the contents of `docker-compose.yml` to your needs. The overall setup looks like:
|
||||
|
||||
- `docker compose up db` to run the Postgres DB.
|
||||
- `docker compose run --rm app ./listmonk --install` to setup the DB (or `--upgrade` to upgrade an existing DB).
|
||||
- Copy `config.toml.sample` to your directory and make the following changes:
|
||||
- `app.address` => `0.0.0.0:9000` (Port forwarding on Docker will work only if the app is advertising on all interfaces.)
|
||||
- `db.host` => `listmonk_db` (Container Name of the DB container)
|
||||
- Run `docker compose up app` and visit `http://localhost:9000`.
|
||||
|
||||
##### Mounting a custom config.toml
|
||||
|
||||
To mount a local `config.toml` file, add the following section to `docker-compose.yml`:
|
||||
|
||||
```yml
|
||||
app:
|
||||
<<: *app-defaults
|
||||
depends_on:
|
||||
- db
|
||||
volumes:
|
||||
- ./path/on/your/host/config.toml:/listmonk/config.toml
|
||||
```
|
||||
|
||||
!!! note
|
||||
Some common changes done inside `config.toml` for Docker based setups:
|
||||
|
||||
- Change `app.address` to `0.0.0.0:9000`.
|
||||
- Change `db.host` to `listmonk_db`.
|
||||
|
||||
Here's a sample `config.toml` you can use:
|
||||
|
||||
```toml
|
||||
[app]
|
||||
address = "0.0.0.0:9000"
|
||||
admin_username = "listmonk"
|
||||
admin_password = "listmonk"
|
||||
|
||||
# Database.
|
||||
[db]
|
||||
host = "listmonk_db"
|
||||
port = 5432
|
||||
user = "listmonk"
|
||||
password = "listmonk"
|
||||
database = "listmonk"
|
||||
ssl_mode = "disable"
|
||||
max_open = 25
|
||||
max_idle = 25
|
||||
max_lifetime = "300s"
|
||||
```
|
||||
|
||||
Mount the local `config.toml` inside the container at `listmonk/config.toml`.
|
||||
|
||||
!!! tip
|
||||
- See [configuring with environment variables](configuration.md) for variables like `app.admin_password` and `db.password`
|
||||
- Ensure that both `app` and `db` containers are in running. If the containers are not running, restart them `docker compose restart app db`.
|
||||
- Refer to [this tutorial](https://yasoob.me/posts/setting-up-listmonk-opensource-newsletter-mailing/) for setting up a production instance with Docker + Nginx + LetsEncrypt SSL.
|
||||
|
||||
!!! info
|
||||
The example `docker-compose.yml` file works with Docker Engine 24.0.5+ and Docker Compose version v2.20.2+.
|
||||
|
||||
##### Changing the port
|
||||
|
||||
To change the port for listmonk:
|
||||
|
||||
- Ensure no other container of listmonk app is running. You can check with `docker ps | grep listmonk`.
|
||||
- Change [L11](https://github.com/knadh/listmonk/blob/master/docker-compose.yml#L11) to `custom-port:9000` Eg: `3876:9000`. This will expose the port 3876 on your local network to the container's network interface on port 9000.
|
||||
- For NGINX setup, if you're running NGINX on your local machine, you can proxy_pass to the `<MACHINE_IP>:3876`. You can also run NGINX as a docker container within the listmonk's container (for that you need to add a service `nginx` in the docker-compose.yml). If you do that, then proxy_pass will be set to `http://app:9000`. Docker's network will resolve the DNS for `app` and directly speak to port 9000 (which the app is exposing within its own network).
|
||||
|
||||
|
||||
|
||||
|
||||
## Compiling from source
|
||||
|
||||
To compile the latest unreleased version (`master` branch):
|
||||
|
||||
1. Make sure `go`, `nodejs`, and `yarn` are installed on your system.
|
||||
2. `git clone git@github.com:knadh/listmonk.git`
|
||||
3. `cd listmonk && make dist`. This will generate the `listmonk binary`.
|
||||
|
||||
## Release candidate (RC)
|
||||
|
||||
The `master` branch with bleeding edge changes is periodically built and published as `listmonk/listmonk:rc` on DockerHub. To run the latest pre-release version, replace all instances of `listmonk/listmonk:latest` with `listmonk/listmonk:rc` in the docker-compose.yml file and follow the Docker installation steps above. While it is generally safe to run release candidate versions, they may have issues that only get resolved in a general release.
|
||||
|
||||
## 3rd party hosting
|
||||
|
||||
<a href="https://railway.app/new/template/listmonk"><img src="https://camo.githubusercontent.com/081df3dd8cff37aab35044727b02b94a8e948052487a8c6253e190f5940d776d/68747470733a2f2f7261696c7761792e6170702f627574746f6e2e737667" alt="One-click deploy on Raleway" style="max-height: 32px;" /></a>
|
||||
<br />
|
||||
<a href="https://www.pikapods.com/pods?run=listmonk"><img src="https://www.pikapods.com/static/run-button.svg" alt="Deploy on PikaPod" /></a>
|
||||
|
||||
|
||||
## Tutorials
|
||||
|
||||
* [Informal step-by-step on how to get started with Listmonk using **Railway**](https://github.com/knadh/listmonk/issues/120#issuecomment-1421838533)
|
||||
* [Complete Listmonk setup guide. Step-by-step tutorial for installation and all basic functions. **Amazon EC2 & SES**](https://gist.github.com/MaximilianKohler/e5158fcfe6de80a9069926a67afcae11)
|
||||
* [Step-by-step guide on how to install and set up Listmonk on a server (rameerez, **AWS Lightsail & docker**)](https://github.com/knadh/listmonk/issues/1208)
|
||||
* [**Binary** install on Ubuntu 22.04 as a service](https://mumaritc.hashnode.dev/how-to-install-listmonk-using-binary-on-ubuntu-2204)
|
||||
* [**Binary** install on Ubuntu 18.04 as a service (Apache & Plesk)](https://devgypsy.com/post/2020-08-18-installing-listmonk-newsletter-manager/)
|
||||
* [**Binary and docker** on linux (techviewleo)](https://techviewleo.com/manage-mailing-list-and-newsletter-using-listmonk/)
|
||||
* [**Binary** install on your PC](https://www.youtube.com/watch?v=fAOBqgR9Yfo). Discussions of limitations: [[1](https://github.com/knadh/listmonk/issues/862#issuecomment-1307328228)][[2](https://github.com/knadh/listmonk/issues/248#issuecomment-1320806990)].
|
||||
* [Install Listmonk with **Docker on Rocky Linux 8** (nginx, Let's Encrypt SSL)](https://wiki.crowncloud.net/?How_to_Install_Listmonk_with_Docker_on_Rocky_Linux_8)
|
||||
* [**Docker** with nginx reverse proxy, certbot SSL, and Gmail SMTP](https://www.maketecheasier.com/create-own-newsletter-with-listmonk/)
|
||||
* [Install Listmonk on Self-hosting with **Pre-Configured AMI Package at AWS** by Single Click](https://meetrix.io/articles/how-to-install-llama-2-on-aws-with-pre-configured-ami-package/)
|
||||
* [Tutorial for deploying on **Fly.io**](https://github.com/paulrudy/listmonk-on-fly) -- Currently [not working](https://github.com/knadh/listmonk/issues/984#issuecomment-1694545255)
|
43
docs/docs/content/messengers.md
Normal file
|
@ -0,0 +1,43 @@
|
|||
# Messengers
|
||||
|
||||
listmonk supports multiple custom messaging backends in additional to the default SMTP e-mail backend, enabling not just e-mail campaigns, but arbitrary message campaigns such as SMS, FCM notifications etc.
|
||||
|
||||
A *Messenger* is a web service that accepts a campaign message pushed to it as a JSON request, which the service can in turn broadcast as SMS, FCM etc. Messengers are registered in the *Settings -> Messengers* UI, and can be selected on individual campaigns.
|
||||
|
||||
Messengers support optional BasicAuth authentication. `Plain text` format for campaign content is ideal for messengers such as SMS and FCM.
|
||||
|
||||
When a campaign starts, listmonk POSTs messages in the following format to the selected messenger's endpoint. The endpoint should return a `200 OK` response in case of a successful request.
|
||||
|
||||
The address required to broadcast the message, for instance, a phone number or an FCM ID, is expected to be stored and relayed as [subscriber attributes](concepts.md/#attributes).
|
||||
|
||||
```json
|
||||
{
|
||||
"subject": "Welcome to listmonk",
|
||||
"body": "The message body",
|
||||
"content_type": "plain",
|
||||
"recipients": [{
|
||||
"uuid": "e44b4135-1e1d-40c5-8a30-0f9a886c2884",
|
||||
"email": "anon@example.com",
|
||||
"name": "Anon Doe",
|
||||
"attribs": {
|
||||
"phone": "123123123",
|
||||
"fcm_id": "2e7e4b512e7e4b512e7e4b51",
|
||||
"city": "Bengaluru"
|
||||
},
|
||||
"status": "enabled"
|
||||
}],
|
||||
"campaign": {
|
||||
"uuid": "2e7e4b51-f31b-418a-a120-e41800cb689f",
|
||||
"name": "Test campaign",
|
||||
"tags": ["test-campaign"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Messenger implementations
|
||||
|
||||
Following is a list of HTTP messenger servers that connect to various backends.
|
||||
|
||||
| Name | Backend |
|
||||
|------------------------------------------------------------------------|------------------|
|
||||
| [listmonk-messenger](https://github.com/joeirimpan/listmonk-messenger) | AWS Pinpoint SMS |
|
95
docs/docs/content/querying-and-segmentation.md
Normal file
|
@ -0,0 +1,95 @@
|
|||
# Querying and segmenting subscribers
|
||||
|
||||
listmonk allows the writing of partial Postgres SQL expressions to query, filter, and segment subscribers.
|
||||
|
||||
## Database fields
|
||||
|
||||
These are the fields in the subscriber database that can be queried.
|
||||
|
||||
| Field | Description |
|
||||
| ------------------------ | --------------------------------------------------------------------------------------------------- |
|
||||
| `subscribers.uuid` | The randomly generated unique ID of the subscriber |
|
||||
| `subscribers.email` | E-mail ID of the subscriber |
|
||||
| `subscribers.name` | Name of the subscriber |
|
||||
| `subscribers.status` | Status of the subscriber (enabled, disabled, blocklisted) |
|
||||
| `subscribers.attribs` | Map of arbitrary attributes represented as JSON. Accessed via the `->` and `->>` Postgres operator. |
|
||||
| `subscribers.created_at` | Timestamp when the subscriber was first added |
|
||||
| `subscribers.updated_at` | Timestamp when the subscriber was modified |
|
||||
|
||||
## Sample attributes
|
||||
|
||||
Here's a sample JSON map of attributes assigned to an imaginary subscriber.
|
||||
|
||||
```json
|
||||
{
|
||||
"city": "Bengaluru",
|
||||
"likes_tea": true,
|
||||
"spoken_languages": ["English", "Malayalam"],
|
||||
"projects": 3,
|
||||
"stack": {
|
||||
"frameworks": ["echo", "go"],
|
||||
"languages": ["go", "python"],
|
||||
"preferred_language": "go"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||

|
||||
|
||||
## Sample SQL query expressions
|
||||
|
||||

|
||||
|
||||
#### Find a subscriber by e-mail
|
||||
|
||||
```sql
|
||||
-- Exact match
|
||||
subscribers.email = 'some@domain.com'
|
||||
|
||||
-- Partial match to find e-mails that end in @domain.com.
|
||||
subscribers.email LIKE '%@domain.com'
|
||||
|
||||
```
|
||||
|
||||
#### Find a subscriber by name
|
||||
|
||||
```sql
|
||||
-- Find all subscribers whose name start with John.
|
||||
subscribers.email LIKE 'John%'
|
||||
|
||||
```
|
||||
|
||||
#### Multiple conditions
|
||||
|
||||
```sql
|
||||
-- Find all Johns who have been blocklisted.
|
||||
subscribers.email LIKE 'John%' AND status = 'blocklisted'
|
||||
```
|
||||
|
||||
#### Querying attributes
|
||||
|
||||
```sql
|
||||
-- The ->> operator returns the value as text. Find all subscribers
|
||||
-- who live in Bengaluru and have done more than 3 projects.
|
||||
-- Here 'projects' is cast into an integer so that we can apply the
|
||||
-- numerical operator >
|
||||
subscribers.attribs->>'city' = 'Bengaluru' AND
|
||||
(subscribers.attribs->>'projects')::INT > 3
|
||||
```
|
||||
|
||||
#### Querying nested attributes
|
||||
|
||||
```sql
|
||||
-- Find all blocklisted subscribers who like to drink tea, can code Python
|
||||
-- and prefer coding Go.
|
||||
--
|
||||
-- The -> operator returns the value as a structure. Here, the "languages" field
|
||||
-- The ? operator checks for the existence of a value in a list.
|
||||
subscribers.status = 'blocklisted' AND
|
||||
(subscribers.attribs->>'likes_tea')::BOOLEAN = true AND
|
||||
subscribers.attribs->'stack'->'languages' ? 'python' AND
|
||||
subscribers.attribs->'stack'->>'preferred_language' = 'go'
|
||||
|
||||
```
|
||||
|
||||
To learn how to write SQL expressions to do advancd querying on JSON attributes, refer to the Postgres [JSONB documentation](https://www.postgresql.org/docs/11/functions-json.html).
|
112
docs/docs/content/static/style.css
Normal file
|
@ -0,0 +1,112 @@
|
|||
body[data-md-color-primary="white"] .md-header[data-md-state="shadow"] {
|
||||
background: #fff;
|
||||
box-shadow: none;
|
||||
color: #333;
|
||||
|
||||
box-shadow: 1px 1px 3px #ddd;
|
||||
}
|
||||
|
||||
.md-typeset .md-typeset__table table {
|
||||
border: 1px solid #ddd;
|
||||
box-shadow: 2px 2px 0 #f3f3f3;
|
||||
overflow: inherit;
|
||||
}
|
||||
|
||||
body[data-md-color-primary="white"] .md-search__input {
|
||||
background: #f6f6f6;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
body[data-md-color-primary="white"]
|
||||
.md-sidebar--secondary
|
||||
.md-sidebar__scrollwrap {
|
||||
background: #f6f6f6;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
body[data-md-color-primary="white"] .md-nav__item--active {
|
||||
font-weight: 600;
|
||||
color: inherit;
|
||||
}
|
||||
body[data-md-color-primary="white"] .md-nav__item--active a {
|
||||
color: #0055d4;
|
||||
}
|
||||
body[data-md-color-primary="white"] .md-nav__item a:hover {
|
||||
color: #0055d4;
|
||||
}
|
||||
|
||||
body[data-md-color-primary="white"] thead,
|
||||
body[data-md-color-primary="white"] .md-typeset table:not([class]) th {
|
||||
background: #f6f6f6;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
font-weight: 600;
|
||||
}
|
||||
table td span {
|
||||
font-size: 0.85em;
|
||||
color: #bbb;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.md-typeset h1, .md-typeset h2 {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
body[data-md-color-primary="white"] .md-typeset h1 {
|
||||
margin: 4rem 0 0 0;
|
||||
color: inherit;
|
||||
border-top: 1px solid #ddd;
|
||||
padding-top: 2rem;
|
||||
}
|
||||
body[data-md-color-primary="white"] .md-typeset h2 {
|
||||
border-top: 1px solid #eee;
|
||||
padding-top: 2rem;
|
||||
}
|
||||
|
||||
body[data-md-color-primary="white"] .md-content h1:first-child {
|
||||
margin: 0 0 3rem 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
body[data-md-color-primary="white"] .md-typeset code {
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
li img {
|
||||
background: #fff;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #e6e6e6;
|
||||
box-shadow: 1px 1px 4px #e6e6e6;
|
||||
padding: 5px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
/* This hack places the #anchor-links correctly
|
||||
by accommodating for the fixed-header's height */
|
||||
:target:before {
|
||||
content: "";
|
||||
display: block;
|
||||
height: 120px;
|
||||
margin-top: -120px;
|
||||
}
|
||||
|
||||
.md-typeset a {
|
||||
color: #0055d4;
|
||||
}
|
||||
.md-typeset a:hover {
|
||||
color: #666 !important;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.md-typeset hr {
|
||||
background: #f6f6f6;
|
||||
margin: 60px 0;
|
||||
display: block;
|
||||
}
|
||||
.md-header--shadow {
|
||||
box-shadow: 0 4px 3px #eee;
|
||||
transition: none;
|
||||
}
|
||||
.md-header__topic:first-child {
|
||||
font-weight: normal;
|
||||
}
|
170
docs/docs/content/templating.md
Normal file
|
@ -0,0 +1,170 @@
|
|||
# Templating
|
||||
|
||||
A template is a re-usable HTML design that can be used across campaigns and transactional messages. Most commonly, templates have standard header and footer areas with logos and branding elements, where campaign content is inserted in the middle. listmonk supports Go template expressions that lets you create powerful, dynamic HTML templates.
|
||||
|
||||
listmonk supports [Go template](https://gowebexamples.com/templates/) expressions that lets you create powerful, dynamic HTML templates. It also integrates 100+ useful [Sprig template functions](https://masterminds.github.io/sprig/).
|
||||
|
||||
## Campaign templates
|
||||
Campaign templates are used in an e-mail campaigns. These template are created and managed on the UI under `Campaigns -> Templates`, and are selected when creating new campaigns.
|
||||
|
||||
## Transactional templates
|
||||
Transactional templates are used for sending arbitrary transactional messages using the transactional API. These template are created and managed on the UI under `Campaigns -> Templates`.
|
||||
|
||||
## Template expressions
|
||||
|
||||
There are several template functions and expressions that can be used in campaign and template bodies. They are written in the form `{{ .Subscriber.Email }}`, that is, an expression between double curly braces `{{` and `}}`.
|
||||
|
||||
### Subscriber fields
|
||||
|
||||
| Expression | Description |
|
||||
| ----------------------------- | -------------------------------------------------------------------------------------------- |
|
||||
| `{{ .Subscriber.UUID }}` | The randomly generated unique ID of the subscriber |
|
||||
| `{{ .Subscriber.Email }}` | E-mail ID of the subscriber |
|
||||
| `{{ .Subscriber.Name }}` | Name of the subscriber |
|
||||
| `{{ .Subscriber.FirstName }}` | First name of the subscriber (automatically extracted from the name) |
|
||||
| `{{ .Subscriber.LastName }}` | Last name of the subscriber (automatically extracted from the name) |
|
||||
| `{{ .Subscriber.Status }}` | Status of the subscriber (enabled, disabled, blocklisted) |
|
||||
| `{{ .Subscriber.Attribs }}` | Map of arbitrary attributes. Fields can be accessed with `.`, eg: `.Subscriber.Attribs.city` |
|
||||
| `{{ .Subscriber.CreatedAt }}` | Timestamp when the subscriber was first added |
|
||||
| `{{ .Subscriber.UpdatedAt }}` | Timestamp when the subscriber was modified |
|
||||
|
||||
| Expression | Description |
|
||||
| --------------------- | -------------------------------------------------------- |
|
||||
| `{{ .Campaign.UUID }}` | The randomly generated unique ID of the campaign |
|
||||
| `{{ .Campaign.Name }}` | Internal name of the campaign |
|
||||
| `{{ .Campaign.Subject }}` | E-mail subject of the campaign |
|
||||
| `{{ .Campaign.FromEmail }}` | The e-mail address from which the campaign is being sent |
|
||||
|
||||
### Functions
|
||||
|
||||
| Function | Description |
|
||||
| ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `{{ Date "2006-01-01" }}` | Prints the current datetime for the given format expressed as a [Go date layout](https://yourbasic.org/golang/format-parse-string-time-date-example/) |
|
||||
| `{{ TrackLink "https://link.com" }}` | Takes a URL and generates a tracking URL over it. For use in campaign bodies and templates. |
|
||||
| `https://link.com@TrackLink` | Shorthand for `TrackLink`. Eg: `<a href="https://link.com@TrackLink">Link</a>` |
|
||||
| `{{ TrackView }}` | Inserts a single tracking pixel. Should only be used once, ideally in the template footer. |
|
||||
| `{{ UnsubscribeURL }}` | Unsubscription and Manage preferences URL. Ideal for use in the template footer. |
|
||||
| `{{ MessageURL }}` | URL to view the hosted version of an e-mail message. |
|
||||
| `{{ OptinURL }}` | URL to the double-optin confirmation page. |
|
||||
| `{{ Safe "<!-- comment -->" }}` | Add any HTML code as it is. |
|
||||
|
||||
### Sprig functions
|
||||
listmonk integrates the Sprig library that offers 100+ utility functions for working with strings, numbers, dates etc. that can be used in templating. Refer to the [Sprig documentation](https://masterminds.github.io/sprig/) for the full list of functions.
|
||||
|
||||
|
||||
### Example template
|
||||
|
||||
The expression `{{ template "content" . }}` should appear exactly once in every template denoting the spot where an e-mail's content is inserted. Here's a sample HTML e-mail that has a fixed header and footer that inserts the content in the middle.
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body {
|
||||
background: #eee;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 6px;
|
||||
color: #111;
|
||||
}
|
||||
header {
|
||||
border-bottom: 1px solid #ddd;
|
||||
padding-bottom: 30px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.container {
|
||||
background: #fff;
|
||||
width: 450px;
|
||||
margin: 0 auto;
|
||||
padding: 30px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<section class="container">
|
||||
<header>
|
||||
<!-- This will appear in the header of all e-mails.
|
||||
The subscriber's name will be automatically inserted here. //-->
|
||||
Hi {{ .Subscriber.FirstName }}!
|
||||
</header>
|
||||
|
||||
<!-- This is where the e-mail body will be inserted //-->
|
||||
<div class="content">
|
||||
{{ template "content" . }}
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
Copyright 2019. All rights Reserved.
|
||||
</footer>
|
||||
|
||||
<!-- The tracking pixel will be inserted here //-->
|
||||
{{ TrackView }}
|
||||
</section>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
!!! info
|
||||
For use with plaintext campaigns, create a template with no HTML content and just the placeholder `{{ template "content" . }}`
|
||||
|
||||
### Example campaign body
|
||||
|
||||
Campaign bodies can be composed using the built-in WYSIWYG editor or as raw HTML documents. Assuming that the subscriber has a set of [attributes defined](querying-and-segmentation.md#sample-attributes), this example shows how to render those values in a campaign.
|
||||
|
||||
```
|
||||
Hey, did you notice how the template showed your first name?
|
||||
Your last name is {{.Subscriber.LastName }}.
|
||||
|
||||
You have done {{ .Subscriber.Attribs.projects }} projects.
|
||||
|
||||
|
||||
{{ if eq .Subscriber.Attribs.city "Bengaluru" }}
|
||||
You live in Bangalore!
|
||||
{{ else }}
|
||||
Where do you live?
|
||||
{{ end }}
|
||||
|
||||
|
||||
Here is a link for you to click that will be tracked.
|
||||
<a href="{{ TrackLink "https://google.com" }}">Google</a>
|
||||
|
||||
```
|
||||
|
||||
The above example uses an `if` condition to show one of two messages depending on the value of a subscriber attribute. Many such dynamic expressions are possible with Go templating expressions.
|
||||
|
||||
## System templates
|
||||
System templates are used for rendering public user facing pages such as the subscription management page, and in automatically generated system e-mails such as the opt-in confirmation e-mail. These are bundled into listmonk but can be customized by copying the [static directory](https://github.com/knadh/listmonk/tree/master/static) locally, and passing its path to listmonk with the `./listmonk --static-dir=your/custom/path` flag.
|
||||
|
||||
|
||||
### Public pages
|
||||
|
||||
| /static/public/ | |
|
||||
|------------------------|--------------------------------------------------------------------|
|
||||
| `index.html` | Base template with the header and footer that all pages use. |
|
||||
| `home.html` | Landing page on the root domain with the login button. |
|
||||
| `message.html` | Generic success / failure message page. |
|
||||
| `optin.html` | Opt-in confirmation page. |
|
||||
| `subscription.html` | Subscription management page with options for data export and wipe. |
|
||||
| `subscription-form.html` | List selection and subscription form page. |
|
||||
|
||||
|
||||
To edit the appearance of the public pages using CSS and Javascript, head to Settings > Appearance > Public:
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
### System e-mails
|
||||
|
||||
| /static/email-templates/ | |
|
||||
|----------------------------------|------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `base.html` | Base template with the header and footer that all system generated e-mails use. |
|
||||
| `campaign-status.html` | E-mail notification that is sent to admins on campaign start, completion etc. |
|
||||
| `import-status.html` | E-mail notification that is sent to admins on finish of an import job. |
|
||||
| `subscriber-data.html` | E-mail that is sent to subscribers when they request a full dump of their private data. |
|
||||
| `subscriber-optin.html` | Automatic opt-in confirmation e-mail that is sent to an unconfirmed subscriber when they are added. |
|
||||
| `subscriber-optin-campaign.html` | E-mail content that's inserted into a campaign body when starting an opt-in campaign from the lists page. |
|
||||
| `default.tpl` | Default campaign template that is created in Campaigns -> Templates when listmonk is first installed. This is not used after that. |
|
||||
|
||||
!!! info
|
||||
To turn system e-mail templates to plaintext, remove `<!doctype html>` from base.html and remove all HTML tags from the templates while retaining the Go templating code.
|
60
docs/docs/content/upgrade.md
Normal file
|
@ -0,0 +1,60 @@
|
|||
# Upgrade
|
||||
|
||||
Some versions may require changes to the database. These changes or database "migrations" are applied automatically and safely, but, it is recommended to take a backup of the Postgres database before running the `--upgrade` option, especially if you have made customizations to the database tables.
|
||||
|
||||
## Binary
|
||||
- Download the [latest release](https://github.com/knadh/listmonk/releases) and extract the listmonk binary.
|
||||
- `./listmonk --upgrade` to upgrade an existing DB. Upgrades are idempotent and running them multiple times have no side effects.
|
||||
- Run `./listmonk` and visit `http://localhost:9000`.
|
||||
|
||||
If you installed listmonk as a service, you will need to stop it before overwriting the binary. Something like `sudo systemctl stop listmonk` or `sudo service listmonk stop` should work. Then overwrite the binary with the new version, then run `./listmonk --upgrade, and `start` it back with the same commands.
|
||||
|
||||
If it's not running as a service, `pkill -9 listmonk` will stop the listmonk process.
|
||||
|
||||
## Docker
|
||||
|
||||
- `docker compose pull` to pull the latest version from DockerHub.
|
||||
- `docker compose run --rm app ./listmonk --upgrade` to upgrade an existing DB.
|
||||
- Run `docker compose up app db` and visit `http://localhost:9000`.
|
||||
|
||||
## Railway
|
||||
- Head to your dashboard, and select your Listmonk project.
|
||||
- Select the GitHub deployment service.
|
||||
- In the Deployment tab, head to the latest deployment, click on the three vertical dots to the right, and select "Redeploy".
|
||||
|
||||

|
||||
|
||||
## Downgrade
|
||||
|
||||
To restore a previous version, you have to restore the DB for that particular version. DBs that have been upgraded with a particular version shouldn't be used with older versions. There may be DB changes that a new version brings that are incompatible with previous versions.
|
||||
|
||||
**General steps:**
|
||||
|
||||
1. Stop listmonk.
|
||||
2. Restore your pre-upgrade database.
|
||||
3. If you're using `docker compose`, edit `docker-compose.yml` and change `listmonk:latest` to `listmonk:v2.4.0` _(for example)_.
|
||||
4. Restart.
|
||||
|
||||
**Example with docker:**
|
||||
|
||||
1. Stop listmonk (app):
|
||||
```
|
||||
sudo docker stop listmonk_app
|
||||
```
|
||||
2. Restore your pre-upgrade db (required) _(be careful, this will wipe your existing DB)_:
|
||||
```
|
||||
psql -h 127.0.0.1 -p 9432 -U listmonk
|
||||
drop schema public cascade;
|
||||
create schema public;
|
||||
\q
|
||||
psql -h 127.0.0.1 -p 9432 -U listmonk -W listmonk < listmonk-preupgrade-db.sql
|
||||
```
|
||||
3. Edit the `docker-compose.yml`:
|
||||
```
|
||||
x-app-defaults: &app-defaults
|
||||
restart: unless-stopped
|
||||
image: listmonk/listmonk:v2.4.0
|
||||
```
|
||||
4. Restart:
|
||||
`sudo docker compose up -d app db nginx certbot`
|
||||
|
61
docs/docs/mkdocs.yml
Normal file
|
@ -0,0 +1,61 @@
|
|||
site_name: listmonk / Documentation
|
||||
theme:
|
||||
name: material
|
||||
# custom_dir: "mkdocs-material/material"
|
||||
logo: "images/favicon.png"
|
||||
favicon: "images/favicon.png"
|
||||
language: "en"
|
||||
font:
|
||||
text: 'Inter'
|
||||
weights: 400
|
||||
direction: 'ltr'
|
||||
extra:
|
||||
search:
|
||||
language: 'en'
|
||||
feature:
|
||||
tabs: true
|
||||
features:
|
||||
- content.code.copy
|
||||
|
||||
palette:
|
||||
primary: "white"
|
||||
accent: "red"
|
||||
|
||||
site_dir: _out
|
||||
docs_dir: content
|
||||
|
||||
markdown_extensions:
|
||||
- admonition
|
||||
- pymdownx.highlight
|
||||
- pymdownx.superfences
|
||||
- toc:
|
||||
permalink: true
|
||||
|
||||
extra_css:
|
||||
- "static/style.css"
|
||||
|
||||
copyright: "Copyright © 2019-2023, Kailash Nadh."
|
||||
|
||||
nav:
|
||||
- "Introduction": index.md
|
||||
- "Installation": installation.md
|
||||
- "Upgrade": upgrade.md
|
||||
- "Configuration": configuration.md
|
||||
- "Developer setup": developer-setup.md
|
||||
- "Concepts": concepts.md
|
||||
- "Querying and segmenting subscribers": querying-and-segmentation.md
|
||||
- "Templating": templating.md
|
||||
- "Bounce processing": bounces.md
|
||||
- "Messengers": "messengers.md"
|
||||
- "Archives": "archives.md"
|
||||
- "Internationalization": "i18n.md"
|
||||
- "Integrating with external systems": external-integration.md
|
||||
- "API": apis/apis.md
|
||||
- "API / Subscribers": apis/subscribers.md
|
||||
- "API / Lists": apis/lists.md
|
||||
- "API / Import": apis/import.md
|
||||
- "API / Campaigns": apis/campaigns.md
|
||||
- "API / Media": apis/media.md
|
||||
- "API / Templates": apis/templates.md
|
||||
- "API / Transactional": apis/transactional.md
|
||||
|
106
docs/i18n/index.html
Normal file
|
@ -0,0 +1,106 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>listmonk i18n translation editor</title>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" type="text/css" href="style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" class="container">
|
||||
<header class="header">
|
||||
<h1 class="title">{{ values["_.name"] }}</h1>
|
||||
<div class="controls">
|
||||
<div class="import block">
|
||||
<a href="#" @click.prevent="onToggleRaw">
|
||||
<template v-if="!isRawVisible">Switch to raw JSON</template>
|
||||
<template v-else>Switch to editor</template>
|
||||
</a>
|
||||
<a href="#" @click.prevent="onDownloadJSON">Download raw JSON</a>
|
||||
<a v-else href="#" @click.prevent="onToggleRaw">Switch to editor</a>
|
||||
</div>
|
||||
|
||||
<div class="view block">
|
||||
<label for="view-all" class="all">
|
||||
<input v-model="view" name="view" id="view-all" type="radio" value="all" checked="true" />
|
||||
All ({{ keys.length }})
|
||||
</label>
|
||||
<label for="view-pending" class="pending">
|
||||
<input v-model="view" name="view" id="view-pending" type="radio" value="pending" />
|
||||
Pending ({{ keys.length - completed }})
|
||||
</label>
|
||||
<label for="view-complete" class="complete">
|
||||
<input v-model="view" name="view" id="view-complete" type="radio" value="complete" />
|
||||
Complete ({{ completed }})
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="selector block">
|
||||
Load existing language
|
||||
<select v-model="loadLang" @change="onLoadLanguage">
|
||||
<option value="en">Default (en)</option>
|
||||
<option value="ca"> Català (ca) </option>
|
||||
<option value="cs-cz"> čeština (cs) </option>
|
||||
<option value="cy"> Cymraeg (cy) </option>
|
||||
<option value="de"> Deutsch (de) </option>
|
||||
<option value="es"> Español (es) </option>
|
||||
<option value="fi"> Suomi (fi) </option>
|
||||
<option value="fr"> Français (fr) </option>
|
||||
<option value="hu"> Hungary (hu) </option>
|
||||
<option value="it"> Italiano (it) </option>
|
||||
<option value="jp"> 日本語 (jp) </option>
|
||||
<option value="ml"> മലയാളം (ml) </option>
|
||||
<option value="nl"> Nederlands (nl) </option>
|
||||
<option value="pl"> Polski (pl) </option>
|
||||
<option value="pt"> Portuguese (pt) </option>
|
||||
<option value="pt-BR"> Português Brasileiro (pt-BR) </option>
|
||||
<option value="ro"> Română (ro) </option>
|
||||
<option value="ru"> Русский (ru) </option>
|
||||
<option value="se"> Svenska (se) </option>
|
||||
<option value="sk"> slovenčina (sk) </option>
|
||||
<option value="tr"> Turkish (tr) </option>
|
||||
<option value="vi"> Vietnamese (vi) </option>
|
||||
<option value="zh-CN"> 简体中文 (zh-CN) </option>
|
||||
<option value="zh-TW"> 繁體中文(zh-TW) </option>
|
||||
</select>
|
||||
|
||||
|
||||
<a href="#" @click.prevent="onNewLang">+ Create new language</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p>
|
||||
Changes are stored in the browser's localStorage until the cache is cleared.
|
||||
To edit an existing language, load it and edit the fields.
|
||||
To create a new language, load the default language and edit the fields.
|
||||
Once done, copy the raw JSON and send a PR to the
|
||||
<a href="https://github.com/knadh/listmonk/tree/i18n/i18n" target="_blank">repo</a>.
|
||||
</p>
|
||||
|
||||
<div v-if="!isRawVisible" class="data">
|
||||
<div :class="{'item': true, 'done': isDone(k.key)}" v-for="(k, i) in keys" v-if="isItemVisible(k.key)">
|
||||
<h3 class="head" v-if="k.head">{{ k.head }}</h3>
|
||||
|
||||
<div class="controls">
|
||||
<div class="num">{{ i + 1 }}.</div>
|
||||
<div class="fields">
|
||||
<span class="base">{{ base[k.key] }}</span>
|
||||
<input type="text" v-model="values[k.key]" @blur="saveData" />
|
||||
<label class="key">{{ k.key }}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- data -->
|
||||
|
||||
<div v-else class="raw">
|
||||
<textarea v-model="rawData"></textarea>
|
||||
</div><!-- raw -->
|
||||
</div>
|
||||
<h4 id="loading">Loading ...</h4>
|
||||
|
||||
<script src="vue.min.js"></script>
|
||||
<script src="main.js"></script>
|
||||
</body>
|
||||
</html>
|
186
docs/i18n/main.js
Normal file
|
@ -0,0 +1,186 @@
|
|||
const BASEURL = "https://raw.githubusercontent.com/knadh/listmonk/master/i18n/";
|
||||
const BASELANG = "en";
|
||||
|
||||
var app = new Vue({
|
||||
el: "#app",
|
||||
data: {
|
||||
base: {},
|
||||
keys: [],
|
||||
visibleKeys: {},
|
||||
values: {},
|
||||
view: "all",
|
||||
loadLang: BASELANG,
|
||||
|
||||
isRawVisible: false,
|
||||
rawData: "{}"
|
||||
},
|
||||
|
||||
methods: {
|
||||
init() {
|
||||
document.querySelector("#app").style.display = 'block';
|
||||
document.querySelector("#loading").remove();
|
||||
},
|
||||
|
||||
loadBaseLang(url) {
|
||||
return fetch(url).then(response => response.json()).then(data => {
|
||||
// Retain the base values.
|
||||
Object.assign(this.base, data);
|
||||
|
||||
// Get the sorted keys from the language map.
|
||||
const keys = [];
|
||||
const visibleKeys = {};
|
||||
let head = null;
|
||||
Object.entries(this.base).sort((a, b) => a[0].localeCompare(b[0])).forEach((v) => {
|
||||
const h = v[0].split('.')[0];
|
||||
keys.push({
|
||||
"key": v[0],
|
||||
"head": (head !== h ? h : null) // eg: campaigns on `campaigns.something.else`
|
||||
});
|
||||
|
||||
visibleKeys[v[0]] = true;
|
||||
head = h;
|
||||
});
|
||||
|
||||
this.keys = keys;
|
||||
this.visibleKeys = visibleKeys;
|
||||
this.values = { ...this.base };
|
||||
|
||||
// Is there cached localStorage data?
|
||||
if (localStorage.data) {
|
||||
try {
|
||||
this.populateData(JSON.parse(localStorage.data));
|
||||
} catch (e) {
|
||||
console.log("Bad JSON in localStorage: " + e.toString());
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
populateData(data) {
|
||||
// Filter out all keys from data except for the base ones
|
||||
// in the base language.
|
||||
const vals = this.keys.reduce((a, key) => {
|
||||
a[key.key] = data.hasOwnProperty(key.key) ? data[key.key] : this.base[key.key];
|
||||
return a;
|
||||
}, {});
|
||||
|
||||
this.values = vals;
|
||||
this.saveData();
|
||||
},
|
||||
|
||||
loadLanguage(lang) {
|
||||
return fetch(BASEURL + lang + ".json").then(response => response.json()).then(data => {
|
||||
this.populateData(data);
|
||||
}).catch((e) => {
|
||||
console.log(e);
|
||||
alert("error fetching file: " + e.toString());
|
||||
});
|
||||
},
|
||||
|
||||
saveData() {
|
||||
localStorage.data = JSON.stringify(this.values);
|
||||
},
|
||||
|
||||
// Has a key been translated (changed from the base)?
|
||||
isDone(key) {
|
||||
return this.values[key] && this.base[key] !== this.values[key];
|
||||
},
|
||||
|
||||
isItemVisible(key) {
|
||||
return this.visibleKeys[key];
|
||||
},
|
||||
|
||||
onToggleRaw() {
|
||||
if (!this.isRawVisible) {
|
||||
this.rawData = JSON.stringify(this.values, Object.keys(this.values).sort(), 4);
|
||||
} else {
|
||||
try {
|
||||
this.populateData(JSON.parse(this.rawData));
|
||||
} catch (e) {
|
||||
alert("error parsing JSON: " + e.toString());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
this.isRawVisible = !this.isRawVisible;
|
||||
},
|
||||
|
||||
onLoadLanguage() {
|
||||
if (!confirm("Loading this language will overwrite your local changes. Continue?")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.loadLanguage(this.loadLang);
|
||||
},
|
||||
|
||||
onNewLang() {
|
||||
if (!confirm("Creating a new language will overwrite your local changes. Continue?")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let data = { ...this.base };
|
||||
data["_.code"] = "iso-code-here"
|
||||
data["_.name"] = "New language"
|
||||
this.populateData(data);
|
||||
},
|
||||
|
||||
onDownloadJSON() {
|
||||
// Create a Blob using the content, mimeType, and optional encoding
|
||||
const blob = new Blob([JSON.stringify(this.values, Object.keys(this.values).sort(), 4)], { type: "" });
|
||||
|
||||
// Create an anchor element with a download attribute
|
||||
const link = document.createElement('a');
|
||||
link.download = `${this.values["_.code"]}.json`;
|
||||
link.href = URL.createObjectURL(blob);
|
||||
|
||||
// Append the link to the DOM, click it to start the download, and remove it
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.loadBaseLang(BASEURL + BASELANG + ".json").then(() => this.init());
|
||||
},
|
||||
|
||||
watch: {
|
||||
view(v) {
|
||||
// When the view changes, create a copy of the items to be filtered
|
||||
// by and filter the view based on that. Otherwise, the moment the value
|
||||
// in the input changes, the list re-renders making items disappear.
|
||||
|
||||
const visibleKeys = {};
|
||||
this.keys.forEach((k) => {
|
||||
let visible = true;
|
||||
|
||||
if (v === "pending") {
|
||||
visible = !this.isDone(k.key);
|
||||
} else if (v === "complete") {
|
||||
visible = this.isDone(k.key);
|
||||
}
|
||||
|
||||
if (visible) {
|
||||
visibleKeys[k.key] = true;
|
||||
}
|
||||
});
|
||||
|
||||
this.visibleKeys = visibleKeys;
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
completed() {
|
||||
let n = 0;
|
||||
|
||||
this.keys.forEach(k => {
|
||||
if (this.values[k.key] !== this.base[k.key]) {
|
||||
n++;
|
||||
}
|
||||
});
|
||||
|
||||
return n;
|
||||
}
|
||||
}
|
||||
});
|
114
docs/i18n/style.css
Normal file
|
@ -0,0 +1,114 @@
|
|||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: Inter, "Helvetica Neue", "Segoe UI", sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5 {
|
||||
margin: 0 0 15px 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #0055d4;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
.header {
|
||||
align-items: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.header a {
|
||||
display: inline-block;
|
||||
margin-right: 15px;
|
||||
}
|
||||
.header .controls {
|
||||
display: flex;
|
||||
}
|
||||
.header .controls .pending {
|
||||
color: #ff3300;
|
||||
}
|
||||
.header .controls .complete {
|
||||
color: #05a200;
|
||||
}
|
||||
.header .title {
|
||||
margin: 0 0 15px 0;
|
||||
}
|
||||
.header .block {
|
||||
margin: 0 45px 0 0;
|
||||
}
|
||||
.header .view label {
|
||||
cursor: pointer;
|
||||
margin-right: 10px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.data .key,
|
||||
.data .base {
|
||||
display: block;
|
||||
color: #777;
|
||||
display: block;
|
||||
}
|
||||
.data .item {
|
||||
padding: 15px;
|
||||
clear: both;
|
||||
}
|
||||
.data .item:hover {
|
||||
background: #eee;
|
||||
}
|
||||
.data .item.done .num {
|
||||
color: #05a200;
|
||||
}
|
||||
.data .item.done .num::after {
|
||||
content: '✓';
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.data .controls {
|
||||
display: flex;
|
||||
}
|
||||
.data .fields {
|
||||
flex-grow: 1;
|
||||
}
|
||||
.data .num {
|
||||
margin-right: 15px;
|
||||
min-width: 50px;
|
||||
}
|
||||
.data .key {
|
||||
color: #aaa;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
.data input {
|
||||
width: 100%;
|
||||
border: 1px solid #ddd;
|
||||
padding: 5px;
|
||||
display: block;
|
||||
margin: 3px 0;
|
||||
|
||||
}
|
||||
.data input:focus {
|
||||
border-color: #666;
|
||||
}
|
||||
.data p {
|
||||
margin: 0 0 3px 0;
|
||||
}
|
||||
.data .head {
|
||||
margin: 0 0 15px 0;
|
||||
}
|
||||
|
||||
.raw textarea {
|
||||
border: 1px solid #ddd;
|
||||
padding: 5px;
|
||||
width: 100%;
|
||||
height: 90vh;
|
||||
}
|
6
docs/i18n/vue.min.js
vendored
Normal file
0
docs/site/.hugo_build.lock
Normal file
6
docs/site/config.toml
Normal file
|
@ -0,0 +1,6 @@
|
|||
baseurl = "https://listmonk.app/"
|
||||
languageCode = "en-us"
|
||||
title = "listmonk - Free and open source self-hosted newsletter, mailing list manager, and transactional mails"
|
||||
|
||||
[taxonomies]
|
||||
tag = "tags"
|
1
docs/site/content/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
|
32
docs/site/data/github.json
Normal file
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"version": "v2.5.1",
|
||||
"date": "2023-08-11T13:54:12Z",
|
||||
"url": "https://github.com/knadh/listmonk/releases/tag/v2.5.1",
|
||||
"assets":
|
||||
[
|
||||
{
|
||||
"name": "darwin",
|
||||
"url": "https://github.com/knadh/listmonk/releases/download/v2.5.1/listmonk_2.5.1_darwin_amd64.tar.gz"
|
||||
},
|
||||
{
|
||||
"name": "freebsd",
|
||||
"url": "https://github.com/knadh/listmonk/releases/download/v2.5.1/listmonk_2.5.1_freebsd_amd64.tar.gz"
|
||||
},
|
||||
{
|
||||
"name": "linux",
|
||||
"url": "https://github.com/knadh/listmonk/releases/download/v2.5.1/listmonk_2.5.1_linux_amd64.tar.gz"
|
||||
},
|
||||
{
|
||||
"name": "netbsd",
|
||||
"url": "https://github.com/knadh/listmonk/releases/download/v2.5.1/listmonk_2.5.1_netbsd_amd64.tar.gz"
|
||||
},
|
||||
{
|
||||
"name": "openbsd",
|
||||
"url": "https://github.com/knadh/listmonk/releases/download/v2.5.1/listmonk_2.5.1_openbsd_amd64.tar.gz"
|
||||
},
|
||||
{
|
||||
"name": "windows",
|
||||
"url": "https://github.com/knadh/listmonk/releases/download/v2.5.1/listmonk_2.5.1_windows_amd64.tar.gz"
|
||||
}
|
||||
]
|
||||
}
|
219
docs/site/layouts/index.html
Normal file
|
@ -0,0 +1,219 @@
|
|||
{{ partial "header.html" . }}
|
||||
<div class="splash container center">
|
||||
<img class="s4" src="static/images/s4.png" />
|
||||
<div class="hero">
|
||||
<h1 class="title">Self-hosted newsletter and mailing list manager</h1>
|
||||
<h3 class="sub">
|
||||
Performance and features packed into a single binary.<br />
|
||||
<strong>Free and open source.</strong>
|
||||
</h3>
|
||||
<p class="center demo">
|
||||
<a href="https://demo.listmonk.app" class="button">Live demo</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="confetti">
|
||||
<img class="s1" src="static/images/s1.png" />
|
||||
<img class="s2" src="static/images/s2.png" />
|
||||
<img class="s3" src="static/images/s3.png" />
|
||||
<img class="box" src="{{ .Site.BaseURL }}static/images/splash.png" alt="listmonk screenshot" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section id="download">
|
||||
<div class="container">
|
||||
<h2 class="center">Download</h2>
|
||||
<p class="center">
|
||||
The latest version is <strong>{{ .Page.Site.Data.github.version }}</strong>
|
||||
released on {{ .Page.Site.Data.github.date | dateFormat "02 Jan 2006" }}.
|
||||
See <a href="{{ .Page.Site.Data.github.url }}">release notes.</a>
|
||||
</p><br />
|
||||
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<div class="box">
|
||||
<h3>Binary</h3>
|
||||
<ul class="install-steps">
|
||||
<li class="download-links">Download binary:<br />
|
||||
{{ range.Page.Site.Data.github.assets }}
|
||||
<a href="{{ .url }}">{{ .name | title }}</a>
|
||||
{{ end }}
|
||||
</li>
|
||||
<li>
|
||||
<code>./listmonk --new-config</code> to generate config.toml. Edit the file.
|
||||
</li>
|
||||
<li><code>./listmonk --install</code> to setup the Postgres DB (⩾ v9.4) or <code>--upgrade</code> to upgrade an existing DB.</li>
|
||||
<li>Run <code>./listmonk</code> and visit <code>http://localhost:9000</code></li>
|
||||
</ul>
|
||||
<p><a href="/docs/installation">Installation docs →</a></p>
|
||||
|
||||
<br />
|
||||
<h3>Hosting providers</h3>
|
||||
<a href="https://railway.app/new/template/listmonk"><img src="https://camo.githubusercontent.com/081df3dd8cff37aab35044727b02b94a8e948052487a8c6253e190f5940d776d/68747470733a2f2f7261696c7761792e6170702f627574746f6e2e737667" alt="One-click deploy on Raleway" style="max-height: 32px;" /></a>
|
||||
<br />
|
||||
<a href="https://www.pikapods.com/pods?run=listmonk"><img src="https://www.pikapods.com/static/run-button.svg" alt="Deploy on PikaPod" /></a>
|
||||
<br />
|
||||
<a href="https://dash.elest.io/deploy?soft=Listmonk&id=237"><img height="33" src="https://github.com/elestio-examples/wordpress/raw/main/deploy-on-elestio.png" alt="Deploy on Elestio" /></a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="box">
|
||||
<h3>Docker</h3>
|
||||
<p><a href="https://hub.docker.com/r/listmonk/listmonk/tags?page=1&ordering=last_updated&name=latest"><code>listmonk/listmonk:latest</code></a></p>
|
||||
<p>
|
||||
Use the sample <a href="https://github.com/knadh/listmonk/blob/master/docker-compose.yml">docker-compose.yml</a>
|
||||
to run manually or use the helper script.
|
||||
</p>
|
||||
<h4>Demo</h4>
|
||||
<pre>mkdir listmonk-demo && cd listmonk-demo
|
||||
sh -c "$(curl -fsSL https://raw.githubusercontent.com/knadh/listmonk/master/install-demo.sh)"</pre>
|
||||
<p>
|
||||
(DO NOT use this demo setup in production)
|
||||
</p>
|
||||
|
||||
<h4>Production</h4>
|
||||
<pre>mkdir listmonk && cd listmonk
|
||||
sh -c "$(curl -fsSL https://raw.githubusercontent.com/knadh/listmonk/master/install-prod.sh)"</pre>
|
||||
<p>Visit <code>http://localhost:9000</code></p>
|
||||
|
||||
<p><a href="/docs/installation">Installation docs →</a></p>
|
||||
|
||||
<p class="small">NOTE: Always examine the contents of shell scripts before executing them.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="container">
|
||||
<section class="lists feature">
|
||||
<h2>Mailing lists</h2>
|
||||
<div class="center">
|
||||
<img class="box" src="static/images/lists.png" alt="Screenshot of list management feature" />
|
||||
</div>
|
||||
<p>
|
||||
Manage millions of subscribers across many single and double opt-in lists
|
||||
with custom JSON attributes for each subscriber.
|
||||
Query and segment subscribers with SQL expressions.
|
||||
</p>
|
||||
<p>Use the fast bulk importer (~10k records per second) or use HTTP/JSON APIs or interact with the simple
|
||||
table schema to integrate external CRMs and subscriber databases.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="tx feature">
|
||||
<h2>Transactional mails</h2>
|
||||
<div class="center">
|
||||
<img class="box" src="static/images/tx.png" alt="Screenshot of transactional API" />
|
||||
</div>
|
||||
<p>
|
||||
Simple API to send arbitrary transactional messages to subscribers
|
||||
using pre-defined templates. Send messages as e-mail, SMS, Whatsapp messages or any medium via Messenger interfaces.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="media feature">
|
||||
<h2>Analytics</h2>
|
||||
<div class="center">
|
||||
<img class="box" src="static/images/analytics.png" alt="Screenshot of analytics feature" />
|
||||
</div>
|
||||
<p class="center">
|
||||
Simple analaytics and visualizations. Connect external visualization programs to the database easily with the simple table structure.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="templating feature">
|
||||
<h2>Templating</h2>
|
||||
<div class="center">
|
||||
<img class="box" src="static/images/templating.png" alt="Screenshot of templating feature" />
|
||||
</div>
|
||||
<p>
|
||||
Create powerful, dynamic e-mail templates with the <a href="https://golang.org/pkg/text/template/">Go templating language</a>.
|
||||
Use template expressions, logic, and 100+ functions in subject lines and content.
|
||||
Write HTML e-mails in a WYSIWYG editor, Markdown, raw syntax-highlighted HTML, or just plain text.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="performance feature">
|
||||
<h2>Performance</h2>
|
||||
<div class="center">
|
||||
<figure class="box">
|
||||
<img src="static/images/performance.png" alt="Screenshot of performance metrics" />
|
||||
|
||||
<figcaption>
|
||||
A production listmonk instance sending a campaign of 7+ million e-mails.<br />
|
||||
CPU usage is a fraction of a single core with peak RAM usage of 57 MB.
|
||||
</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
<br />
|
||||
<p>
|
||||
Multi-threaded, high-throughput, multi-SMTP e-mail queues.
|
||||
Throughput and sliding window rate limiting for fine grained control.
|
||||
Single binary application with nominal CPU and memory footprint that runs everywhere.
|
||||
The only dependency is a Postgres (⩾ 12) database.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="media feature">
|
||||
<h2>Media</h2>
|
||||
<div class="center">
|
||||
<img class="box" src="static/images/media.png" alt="Screenshot of media feature" />
|
||||
</div>
|
||||
<p class="center">Use the media manager to upload images for e-mail campaigns
|
||||
on the server's filesystem, Amazon S3, or any S3 compatible (Minio) backend.</p>
|
||||
</section>
|
||||
|
||||
<section class="lists feature">
|
||||
<h2>Extensible</h2>
|
||||
<div class="center">
|
||||
<img class="box" src="static/images/messengers.png" alt="Screenshot of Messenger feature" />
|
||||
</div>
|
||||
<p class="center">
|
||||
More than just e-mail campaigns. Connect HTTP webhooks to send SMS,
|
||||
Whatsapp, FCM notifications, or any type of messages.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="privacy feature">
|
||||
<h2>Privacy</h2>
|
||||
<div class="center">
|
||||
<img class="box" src="static/images/privacy.png" alt="Screenshot of privacy features" />
|
||||
</div>
|
||||
<p class="center">
|
||||
Allow subscribers to permanently blocklist themselves, export all their data,
|
||||
and to wipe all their data in a single click.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<h2 class="center">and a lot more …</h2>
|
||||
|
||||
<div class="center">
|
||||
<br />
|
||||
<a href="#download" class="button">Download</a>
|
||||
</div>
|
||||
|
||||
<section class="banner">
|
||||
<div class="row">
|
||||
<div class="col-2"> </div>
|
||||
<div class="col-8">
|
||||
<div class="confetti">
|
||||
<img class="s2" src="static/images/s3.png" />
|
||||
<div class="box">
|
||||
<h2>Developers</h2>
|
||||
<p>
|
||||
listmonk is free and open source software licensed under AGPLv3.
|
||||
If you are interested in contributing, check out the <a href="https://github.com/knadh/listmonk">GitHub repository</a>
|
||||
and refer to the <a href="/docs/developer-setup">developer setup</a>.
|
||||
The backend is written in Go and the frontend is Vue with Buefy for UI.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-2"> </div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{{ partial "footer.html" }}
|
6
docs/site/layouts/page/single.html
Normal file
|
@ -0,0 +1,6 @@
|
|||
{{ partial "header" . }}
|
||||
<article class="page">
|
||||
<h1>{{ .Title }}</h1>
|
||||
{{ .Content }}
|
||||
</article>
|
||||
{{ partial "footer" }}
|
10
docs/site/layouts/partials/footer.html
Normal file
|
@ -0,0 +1,10 @@
|
|||
|
||||
<div class="container">
|
||||
<footer class="footer">
|
||||
© 2019-{{ now.Format "2006" }} / <a href="https://nadh.in">Kailash Nadh</a>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script async defer src="https://buttons.github.io/buttons.js"></script>
|
||||
</body>
|
||||
</html>
|
42
docs/site/layouts/partials/header.html
Normal file
|
@ -0,0 +1,42 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||
<title>{{ .Title }}</title>
|
||||
<meta name="description" content="{{with .Description }}{{ . }}{{else}}Send e-mail campaigns and transactional e-mails. High performance and features packed into one app.{{end}}" />
|
||||
<meta name="keywords" content="{{ if .Keywords }}{{ range .Keywords }}{{ . }}, {{ end }}{{else if isset .Params "tags" }}{{ range .Params.tags }}{{ . }}, {{ end }}{{end}}">
|
||||
<link rel="canonical" href="{{ .Permalink }}">
|
||||
<link href="https://fonts.googleapis.com/css?family=Inter:400,600" rel="stylesheet">
|
||||
<link href="{{ .Site.BaseURL }}static/base.css" rel="stylesheet" type="text/css" />
|
||||
<link href="{{ .Site.BaseURL }}static/style.css" rel="stylesheet" type="text/css" />
|
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1" />
|
||||
<link rel="shortcut icon" href="{{ .Site.BaseURL }}static/images/favicon.png" type="image/x-icon" />
|
||||
|
||||
<meta property="og:title" content="{{ .Title }}" />
|
||||
{{ if .Params.thumbnail }}
|
||||
<link rel="image_src" href="{{ .Site.BaseURL }}static/images/{{ .Params.thumbnail }}" />
|
||||
<meta property="og:image" content="{{ .Site.BaseURL }}static/images/{{ .Params.thumbnail }}" />
|
||||
{{ else }}
|
||||
<link rel="image_src" href="{{ .Site.BaseURL }}static/images/thumbnail.png" />
|
||||
<meta property="og:image" content="{{ .Site.BaseURL }}static/images/thumbnail.png" />
|
||||
{{ end }}
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="container">
|
||||
<header class="header">
|
||||
<div class="row">
|
||||
<div class="col-2 logo">
|
||||
<a href="{{ .Site.BaseURL }}"><img src="{{ .Site.BaseURL }}static/images/logo.svg" alt="Listmonk logo" /></a>
|
||||
</div>
|
||||
<nav class="col-10">
|
||||
<a class="item" href="/#download">Download</a>
|
||||
<a class="item" href="/docs">Docs</a>
|
||||
<div class="github-btn">
|
||||
<a class="github-button" href="https://github.com/knadh/listmonk" data-size="large" data-show-count="true" aria-label="knadh/listmonk on GitHub">GitHub</a>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
5
docs/site/layouts/shortcodes/centered.html
Normal file
|
@ -0,0 +1,5 @@
|
|||
<section class="row">
|
||||
<div class="col2"> </div>
|
||||
<div class="col8">{{ .Inner }}</div>
|
||||
<div class="clear"> </div>
|
||||
</section>
|
17
docs/site/layouts/shortcodes/github.html
Normal file
|
@ -0,0 +1,17 @@
|
|||
<ul id="github" class="no">
|
||||
{{ range .Page.Site.Data.github }}
|
||||
<li class="row">
|
||||
<div class="col2">
|
||||
<span class="date">{{ dateFormat "Jan 2006" (substr .updated_at 0 10) }}</span>
|
||||
</div>
|
||||
<div class="col3">
|
||||
<a href="{{ .url }}">{{ .name }}</a>
|
||||
</div>
|
||||
<div class="col7 last">
|
||||
<span class="desc">{{ .description }}</span>
|
||||
</div>
|
||||
<div class="clear"> </div>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
<div class="clear"> </div>
|
4
docs/site/layouts/shortcodes/half.html
Normal file
|
@ -0,0 +1,4 @@
|
|||
<div class="row">
|
||||
<div class="col7">{{ .Inner }}</div>
|
||||
<div class="clear"> </div>
|
||||
</div>
|
3
docs/site/layouts/shortcodes/section.html
Normal file
|
@ -0,0 +1,3 @@
|
|||
<section>
|
||||
{{ .Inner }}
|
||||
</section>
|
190
docs/site/static/static/base.css
Normal file
|
@ -0,0 +1,190 @@
|
|||
/**
|
||||
*** SIMPLE GRID
|
||||
*** (C) ZACH COLE 2016
|
||||
**/
|
||||
|
||||
|
||||
/* UNIVERSAL */
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
left: 0;
|
||||
top: 0;
|
||||
font-size: 100%;
|
||||
}
|
||||
|
||||
.right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.center {
|
||||
text-align: center;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.justify {
|
||||
text-align: justify;
|
||||
}
|
||||
|
||||
/* ==== GRID SYSTEM ==== */
|
||||
|
||||
.container {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.row {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.row [class^="col"] {
|
||||
float: left;
|
||||
margin: 0.5rem 2%;
|
||||
min-height: 0.125rem;
|
||||
}
|
||||
|
||||
.col-1,
|
||||
.col-2,
|
||||
.col-3,
|
||||
.col-4,
|
||||
.col-5,
|
||||
.col-6,
|
||||
.col-7,
|
||||
.col-8,
|
||||
.col-9,
|
||||
.col-10,
|
||||
.col-11,
|
||||
.col-12 {
|
||||
width: 96%;
|
||||
}
|
||||
|
||||
.col-1-sm {
|
||||
width: 4.33%;
|
||||
}
|
||||
|
||||
.col-2-sm {
|
||||
width: 12.66%;
|
||||
}
|
||||
|
||||
.col-3-sm {
|
||||
width: 21%;
|
||||
}
|
||||
|
||||
.col-4-sm {
|
||||
width: 29.33%;
|
||||
}
|
||||
|
||||
.col-5-sm {
|
||||
width: 37.66%;
|
||||
}
|
||||
|
||||
.col-6-sm {
|
||||
width: 46%;
|
||||
}
|
||||
|
||||
.col-7-sm {
|
||||
width: 54.33%;
|
||||
}
|
||||
|
||||
.col-8-sm {
|
||||
width: 62.66%;
|
||||
}
|
||||
|
||||
.col-9-sm {
|
||||
width: 71%;
|
||||
}
|
||||
|
||||
.col-10-sm {
|
||||
width: 79.33%;
|
||||
}
|
||||
|
||||
.col-11-sm {
|
||||
width: 87.66%;
|
||||
}
|
||||
|
||||
.col-12-sm {
|
||||
width: 96%;
|
||||
}
|
||||
|
||||
.row::after {
|
||||
content: "";
|
||||
display: table;
|
||||
clear: both;
|
||||
}
|
||||
|
||||
.hidden-sm {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 33.75em) { /* 540px */
|
||||
.container {
|
||||
width: 80%;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 45em) { /* 720px */
|
||||
.col-1 {
|
||||
width: 4.33%;
|
||||
}
|
||||
|
||||
.col-2 {
|
||||
width: 12.66%;
|
||||
}
|
||||
|
||||
.col-3 {
|
||||
width: 21%;
|
||||
}
|
||||
|
||||
.col-4 {
|
||||
width: 29.33%;
|
||||
}
|
||||
|
||||
.col-5 {
|
||||
width: 37.66%;
|
||||
}
|
||||
|
||||
.col-6 {
|
||||
width: 46%;
|
||||
}
|
||||
|
||||
.col-7 {
|
||||
width: 54.33%;
|
||||
}
|
||||
|
||||
.col-8 {
|
||||
width: 62.66%;
|
||||
}
|
||||
|
||||
.col-9 {
|
||||
width: 71%;
|
||||
}
|
||||
|
||||
.col-10 {
|
||||
width: 79.33%;
|
||||
}
|
||||
|
||||
.col-11 {
|
||||
width: 87.66%;
|
||||
}
|
||||
|
||||
.col-12 {
|
||||
width: 96%;
|
||||
}
|
||||
|
||||
.hidden-sm {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 60em) { /* 960px */
|
||||
.container {
|
||||
width: 75%;
|
||||
max-width: 60rem;
|
||||
}
|
||||
}
|
BIN
docs/site/static/static/images/2022-07-31_19-07.png
Normal file
After Width: | Height: | Size: 360 KiB |
BIN
docs/site/static/static/images/2022-07-31_19-08.png
Normal file
After Width: | Height: | Size: 372 KiB |
BIN
docs/site/static/static/images/analytics.png
Normal file
After Width: | Height: | Size: 34 KiB |
BIN
docs/site/static/static/images/favicon.png
Normal file
After Width: | Height: | Size: 1.3 KiB |
233
docs/site/static/static/images/listmonk.src.svg
Normal file
|
@ -0,0 +1,233 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
width="45.041653mm"
|
||||
height="9.8558731mm"
|
||||
viewBox="0 0 45.041653 9.8558733"
|
||||
version="1.1"
|
||||
id="svg8"
|
||||
sodipodi:docname="listmonk.src.svg"
|
||||
inkscape:version="1.0 (9f2f71dc58, 2020-08-02)">
|
||||
<defs
|
||||
id="defs2" />
|
||||
<sodipodi:namedview
|
||||
id="base"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:zoom="1"
|
||||
inkscape:cx="742.82396"
|
||||
inkscape:cy="-93.302628"
|
||||
inkscape:document-units="mm"
|
||||
inkscape:current-layer="layer1"
|
||||
showgrid="false"
|
||||
inkscape:window-width="1863"
|
||||
inkscape:window-height="1025"
|
||||
inkscape:window-x="57"
|
||||
inkscape:window-y="27"
|
||||
inkscape:window-maximized="1"
|
||||
fit-margin-top="0"
|
||||
fit-margin-left="0"
|
||||
fit-margin-right="0"
|
||||
fit-margin-bottom="0"
|
||||
inkscape:document-rotation="0" />
|
||||
<metadata
|
||||
id="metadata5">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(-12.438455,-21.535559)">
|
||||
<path
|
||||
style="fill:#ffcc00;fill-opacity:1;stroke:none;stroke-width:2.11094689;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 16.660914,21.535559 a 4.2220837,4.2220837 0 0 0 -4.222459,4.222437 4.2220837,4.2220837 0 0 0 0.490699,1.968681 c 0.649637,-1.386097 2.059696,-2.343758 3.73176,-2.343758 1.672279,0 3.082188,0.958029 3.731731,2.344413 a 4.2220837,4.2220837 0 0 0 0.490039,-1.969336 4.2220837,4.2220837 0 0 0 -4.22177,-4.222437 z"
|
||||
id="circle920"
|
||||
inkscape:connector-curvature="0"
|
||||
inkscape:export-filename="/home/kailash/Site/listmonk/static/logo.png"
|
||||
inkscape:export-xdpi="96"
|
||||
inkscape:export-ydpi="96" />
|
||||
<flowRoot
|
||||
xml:space="preserve"
|
||||
id="flowRoot935"
|
||||
style="font-style:normal;font-weight:normal;font-size:40px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none"
|
||||
transform="matrix(0.27888442,0,0,0.27888442,92.852428,101.67857)"><flowRegion
|
||||
id="flowRegion937"><rect
|
||||
id="rect939"
|
||||
width="338"
|
||||
height="181"
|
||||
x="-374"
|
||||
y="-425.36423" /></flowRegion><flowPara
|
||||
id="flowPara941" /></flowRoot>
|
||||
<text
|
||||
id="text874-8"
|
||||
y="30.29347"
|
||||
x="23.133614"
|
||||
style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:8.70789px;line-height:1.25;font-family:'IBM Plex Sans';-inkscape-font-specification:'IBM Plex Sans, Medium';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.0459737"
|
||||
xml:space="preserve"
|
||||
inkscape:export-filename="/home/kailash/Site/listmonk/static/logo.png"
|
||||
inkscape:export-xdpi="96"
|
||||
inkscape:export-ydpi="96"><tspan
|
||||
style="font-style:normal;font-variant:normal;font-weight:600;font-stretch:normal;font-size:8.70789px;font-family:Inter;-inkscape-font-specification:'Inter Semi-Bold';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;writing-mode:lr-tb;text-anchor:start;stroke-width:0.0459737"
|
||||
y="30.29347"
|
||||
x="23.133614"
|
||||
id="tspan872-0"
|
||||
sodipodi:role="line">listmonk</tspan></text>
|
||||
<circle
|
||||
r="3.1873188"
|
||||
cy="27.647591"
|
||||
cx="16.66629"
|
||||
id="circle876-1"
|
||||
style="fill:none;fill-opacity:1;stroke:#7f2aff;stroke-width:1.11304522;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
inkscape:export-filename="/home/kailash/Site/listmonk/static/logo.png"
|
||||
inkscape:export-xdpi="96"
|
||||
inkscape:export-ydpi="96" />
|
||||
<path
|
||||
inkscape:connector-curvature="0"
|
||||
id="path878-0"
|
||||
d="m 16.666291,24.813242 a 3.1873187,3.8372081 0 0 0 -3.187196,3.837044 3.1873187,3.8372081 0 0 0 0.07347,0.79818 3.1873187,3.8372081 0 0 1 3.113724,-3.027362 3.1873187,3.8372081 0 0 1 3.113721,3.038883 3.1873187,3.8372081 0 0 0 0.07347,-0.809701 3.1873187,3.8372081 0 0 0 -3.187196,-3.837044 z"
|
||||
style="fill:#7f2aff;fill-opacity:1;stroke:none;stroke-width:1.22125876;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
inkscape:export-filename="/home/kailash/Site/listmonk/static/logo.png"
|
||||
inkscape:export-xdpi="96"
|
||||
inkscape:export-ydpi="96" />
|
||||
<path
|
||||
style="fill:#ffcc00;fill-opacity:1;stroke:none;stroke-width:1.06017;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 139.94612,-53.327122 a 2.1703097,2.0716912 0 0 0 -2.17051,2.071864 2.1703097,2.0716912 0 0 0 0.25224,0.965993 c 0.33394,-0.680131 1.05876,-1.150035 1.91827,-1.150035 0.85961,0 1.58436,0.470085 1.91825,1.150356 a 2.1703097,2.0716912 0 0 0 0.2519,-0.966314 2.1703097,2.0716912 0 0 0 -2.17015,-2.071864 z"
|
||||
id="path1200"
|
||||
inkscape:connector-curvature="0"
|
||||
inkscape:export-filename="/home/kailash/Site/listmonk/static/logo.png"
|
||||
inkscape:export-xdpi="96"
|
||||
inkscape:export-ydpi="96" />
|
||||
<text
|
||||
id="text1204"
|
||||
y="-46.771812"
|
||||
x="116.91617"
|
||||
style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:8.70789px;line-height:1.25;font-family:'IBM Plex Sans';-inkscape-font-specification:'IBM Plex Sans, Medium';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.0459737"
|
||||
xml:space="preserve"
|
||||
inkscape:export-filename="/home/kailash/Site/listmonk/static/logo.png"
|
||||
inkscape:export-xdpi="96"
|
||||
inkscape:export-ydpi="96"><tspan
|
||||
style="font-style:normal;font-variant:normal;font-weight:600;font-stretch:normal;font-size:8.70789px;font-family:Inter;-inkscape-font-specification:'Inter Semi-Bold';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;writing-mode:lr-tb;text-anchor:start;stroke-width:0.0459737"
|
||||
y="-46.771812"
|
||||
x="116.91617"
|
||||
id="tspan1202"
|
||||
sodipodi:role="line">listmonk</tspan></text>
|
||||
<text
|
||||
id="text1214"
|
||||
y="-23.851294"
|
||||
x="127.87717"
|
||||
style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:6.82489px;line-height:1.25;font-family:'IBM Plex Sans';-inkscape-font-specification:'IBM Plex Sans, Medium';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.0360324"
|
||||
xml:space="preserve"
|
||||
inkscape:export-filename="/home/kailash/Site/listmonk/static/logo.png"
|
||||
inkscape:export-xdpi="96"
|
||||
inkscape:export-ydpi="96"><tspan
|
||||
style="font-style:normal;font-variant:normal;font-weight:600;font-stretch:normal;font-size:6.82489px;font-family:Inter;-inkscape-font-specification:'Inter Semi-Bold';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;writing-mode:lr-tb;text-anchor:start;stroke-width:0.0360324"
|
||||
y="-23.851294"
|
||||
x="127.87717"
|
||||
id="tspan1212"
|
||||
sodipodi:role="line">listmonk</tspan></text>
|
||||
<circle
|
||||
style="fill:#ffcc00;fill-opacity:1;stroke:none;stroke-width:2.5729"
|
||||
id="path1216"
|
||||
cx="203.43507"
|
||||
cy="-21.854498"
|
||||
r="3.8091576" />
|
||||
<g
|
||||
id="g1239"
|
||||
transform="matrix(1.2398232,0,0,1.2398232,25.599078,-34.522694)">
|
||||
<rect
|
||||
style="fill:#7f2aff;fill-opacity:1;stroke:none;stroke-width:0.91563"
|
||||
id="rect1218"
|
||||
width="3.7532511"
|
||||
height="0.89233136"
|
||||
x="77.048592"
|
||||
y="4.6184554" />
|
||||
<rect
|
||||
style="fill:#7f2aff;fill-opacity:1;stroke:none;stroke-width:0.91563"
|
||||
id="rect1220"
|
||||
width="3.7532511"
|
||||
height="0.89233136"
|
||||
x="77.048592"
|
||||
y="6.1939058" />
|
||||
<rect
|
||||
style="fill:#7f2aff;fill-opacity:1;stroke:none;stroke-width:0.91563"
|
||||
id="rect1226"
|
||||
width="3.7532511"
|
||||
height="0.89233136"
|
||||
x="77.048592"
|
||||
y="7.7760162" />
|
||||
</g>
|
||||
<ellipse
|
||||
style="fill:#ffcc00;fill-opacity:1;stroke:none;stroke-width:1.5875"
|
||||
id="path1247"
|
||||
cx="139.2197"
|
||||
cy="-74.271935"
|
||||
rx="2.1283948"
|
||||
ry="1.9833959" />
|
||||
<text
|
||||
id="text1245"
|
||||
y="-71.648537"
|
||||
x="115.96989"
|
||||
style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:8.70789px;line-height:1.25;font-family:'IBM Plex Sans';-inkscape-font-specification:'IBM Plex Sans, Medium';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.0459737"
|
||||
xml:space="preserve"
|
||||
inkscape:export-filename="/home/kailash/Site/listmonk/static/logo.png"
|
||||
inkscape:export-xdpi="96"
|
||||
inkscape:export-ydpi="96"><tspan
|
||||
style="font-style:normal;font-variant:normal;font-weight:600;font-stretch:normal;font-size:8.70789px;font-family:Inter;-inkscape-font-specification:'Inter Semi-Bold';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;writing-mode:lr-tb;text-anchor:start;stroke-width:0.0459737"
|
||||
y="-71.648537"
|
||||
x="115.96989"
|
||||
id="tspan1243"
|
||||
sodipodi:role="line">listmonk</tspan></text>
|
||||
<text
|
||||
id="text1042"
|
||||
y="-18.770809"
|
||||
x="210.12352"
|
||||
style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:8.70789px;line-height:1.25;font-family:'IBM Plex Sans';-inkscape-font-specification:'IBM Plex Sans, Medium';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;letter-spacing:-0.0529167px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.0459737"
|
||||
xml:space="preserve"
|
||||
inkscape:export-filename="/home/kailash/Site/listmonk/static/logo.png"
|
||||
inkscape:export-xdpi="96"
|
||||
inkscape:export-ydpi="96"><tspan
|
||||
style="font-style:normal;font-variant:normal;font-weight:600;font-stretch:normal;font-size:8.70789px;font-family:'Fira Sans';-inkscape-font-specification:'Fira Sans Semi-Bold';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;writing-mode:lr-tb;text-anchor:start;stroke-width:0.0459737"
|
||||
y="-18.770809"
|
||||
x="210.12352"
|
||||
id="tspan1040"
|
||||
sodipodi:role="line">listmonk</tspan></text>
|
||||
<circle
|
||||
style="fill:none;fill-opacity:1;stroke:#ffcc00;stroke-width:1.73982;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="circle1737"
|
||||
cx="203.74388"
|
||||
cy="-1.1837244"
|
||||
r="3.1489604" />
|
||||
<text
|
||||
id="text1741"
|
||||
y="2.24283"
|
||||
x="210.38811"
|
||||
style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:8.70789px;line-height:1.25;font-family:'IBM Plex Sans';-inkscape-font-specification:'IBM Plex Sans, Medium';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;letter-spacing:-0.0529167px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.0459737"
|
||||
xml:space="preserve"
|
||||
inkscape:export-filename="/home/kailash/Site/listmonk/static/logo.png"
|
||||
inkscape:export-xdpi="96"
|
||||
inkscape:export-ydpi="96"><tspan
|
||||
style="font-style:normal;font-variant:normal;font-weight:600;font-stretch:normal;font-size:8.70789px;font-family:'Fira Sans';-inkscape-font-specification:'Fira Sans Semi-Bold';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;writing-mode:lr-tb;text-anchor:start;stroke-width:0.0459737"
|
||||
y="2.24283"
|
||||
x="210.38811"
|
||||
id="tspan1739"
|
||||
sodipodi:role="line">listmonk</tspan></text>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 13 KiB |
BIN
docs/site/static/static/images/lists.png
Normal file
After Width: | Height: | Size: 18 KiB |
BIN
docs/site/static/static/images/logo.png
Normal file
After Width: | Height: | Size: 2.9 KiB |
1
docs/site/static/static/images/logo.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="163.03" height="30.38" viewBox="0 0 43.135 8.038" xmlns:v="https://vecta.io/nano"><circle cx="4.019" cy="4.019" r="3.149" fill="none" stroke="#0055d4" stroke-width="1.74"/><path d="M11.457 7.303q-.566 0-.879-.322-.313-.331-.313-.932V.712L11.5.572v5.442q0 .305.253.305.139 0 .244-.052l.253.879q-.357.157-.792.157zm2.619-4.754v4.615H12.84V2.549zM13.449.172q.331 0 .54.209.218.2.218.514 0 .313-.218.522-.209.2-.54.2-.331 0-.54-.2-.209-.209-.209-.522 0-.313.209-.514.209-.209.54-.209zm3.319 2.238q.975 0 1.672.557l-.47.705q-.583-.366-1.149-.366-.305 0-.47.113-.165.113-.165.305 0 .139.07.235.078.096.279.183.209.087.618.209.731.2 1.088.54.357.331.357.914 0 .462-.27.801-.261.34-.714.522-.453.174-1.01.174-.583 0-1.062-.174-.479-.183-.819-.496l.61-.679q.583.453 1.237.453.348 0 .549-.131.209-.139.209-.374 0-.183-.078-.287-.078-.104-.287-.192-.209-.096-.653-.218-.697-.192-1.036-.54-.331-.357-.331-.879 0-.392.226-.705.226-.313.636-.488.418-.183.967-.183zm5.342 4.536q-.253.174-.575.261-.313.096-.627.096-.714-.009-1.08-.409-.366-.401-.366-1.176V3.42h-.688v-.871h.688v-1.01l1.237-.148v1.158h1.062l-.122.871h-.94v2.273q0 .331.113.479.113.148.348.148.235 0 .522-.157zm5.493-4.536q.549 0 .879.374.34.374.34 1.019v3.361h-1.237V4.012q0-.679-.453-.679-.244 0-.427.157-.183.157-.374.488v3.187h-1.237V4.012q0-.679-.453-.679-.244 0-.427.165-.183.157-.366.479v3.187h-1.237V2.549h1.071l.096.575q.261-.348.583-.531.331-.183.758-.183.392 0 .679.2.287.192.418.549.287-.374.618-.557.34-.192.766-.192zm4.148 0q1.036 0 1.62.653.583.644.583 1.794 0 .731-.27 1.289-.261.549-.766.853-.496.305-1.176.305-1.036 0-1.628-.644-.583-.653-.583-1.803 0-.731.261-1.28.27-.557.766-.862.505-.305 1.193-.305zm0 .923q-.47 0-.705.374-.226.366-.226 1.149 0 .784.226 1.158.235.366.697.366.462 0 .688-.366.235-.374.235-1.158 0-.784-.226-1.149-.226-.374-.688-.374zm5.271-.923q.61 0 .949.374.34.366.34 1.019v3.361h-1.237V4.012q0-.374-.131-.522-.122-.157-.374-.157-.261 0-.479.165-.209.157-.409.479v3.187h-1.237V2.549h1.071l.096.583q.287-.357.627-.54.348-.183.784-.183zM40.2.572v6.592h-1.237V.712zm2.804 1.977l-1.472 2.029 1.602 2.586h-1.402l-1.489-2.525 1.48-2.09z"/></svg>
|
After Width: | Height: | Size: 2.1 KiB |
BIN
docs/site/static/static/images/media.png
Normal file
After Width: | Height: | Size: 14 KiB |
BIN
docs/site/static/static/images/messengers.png
Normal file
After Width: | Height: | Size: 17 KiB |
BIN
docs/site/static/static/images/performance.png
Normal file
After Width: | Height: | Size: 17 KiB |
BIN
docs/site/static/static/images/privacy.png
Normal file
After Width: | Height: | Size: 14 KiB |
BIN
docs/site/static/static/images/s1.png
Normal file
After Width: | Height: | Size: 506 B |
BIN
docs/site/static/static/images/s2.png
Normal file
After Width: | Height: | Size: 623 B |
83
docs/site/static/static/images/s2.svg
Normal file
|
@ -0,0 +1,83 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
version="1.1"
|
||||
id="svg1065"
|
||||
width="21.1164"
|
||||
height="17.646732"
|
||||
viewBox="0 0 21.1164 17.646732"
|
||||
sodipodi:docname="s2.svg"
|
||||
inkscape:export-filename="/home/kailash/www/listmonk/site/static/static/images/s2.png"
|
||||
inkscape:export-xdpi="115.86"
|
||||
inkscape:export-ydpi="115.86"
|
||||
inkscape:version="1.0.2 (e86c870879, 2021-01-15)">
|
||||
<metadata
|
||||
id="metadata1071">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title></dc:title>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<defs
|
||||
id="defs1069">
|
||||
<inkscape:path-effect
|
||||
effect="fillet_chamfer"
|
||||
id="path-effect1698"
|
||||
is_visible="true"
|
||||
lpeversion="1"
|
||||
satellites_param="F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1"
|
||||
unit="px"
|
||||
method="auto"
|
||||
mode="IC"
|
||||
radius="10"
|
||||
chamfer_steps="1"
|
||||
flexible="false"
|
||||
use_knot_distance="false"
|
||||
apply_no_radius="false"
|
||||
apply_with_radius="false"
|
||||
only_selected="false"
|
||||
hide_knots="false" />
|
||||
</defs>
|
||||
<sodipodi:namedview
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1"
|
||||
objecttolerance="10"
|
||||
gridtolerance="10"
|
||||
guidetolerance="10"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1007"
|
||||
id="namedview1067"
|
||||
showgrid="false"
|
||||
inkscape:zoom="4"
|
||||
inkscape:cx="28.817195"
|
||||
inkscape:cy="14.597549"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="g1073" />
|
||||
<g
|
||||
inkscape:groupmode="layer"
|
||||
inkscape:label="Image"
|
||||
id="g1073"
|
||||
transform="translate(-4.4667969,-5.166384)">
|
||||
<path
|
||||
style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-variant-east-asian:normal;font-feature-settings:normal;font-variation-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;shape-margin:0;inline-size:0;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;vector-effect:none;fill:#0055d4;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:normal;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate;stop-color:#000000;stop-opacity:1"
|
||||
d="M 15.923828,5.3164062 C 11.592147,4.6111791 7.0486038,6.4161348 4.4667969,10.228516 c 0,0.53125 0.060547,2.755859 4.1230469,3.224609 1.7958472,-2.651806 5.8189972,-3.5264183 8.5820312,-1.822266 2.837636,1.750166 3.699383,5.385019 1.949219,8.222657 -0.4375,1.71875 2.066406,3.18164 4.753906,2.93164 C 27.209467,17.378802 25.509869,10.211423 20.103516,6.8769531 18.787461,6.0652517 17.367722,5.551482 15.923828,5.3164062 Z"
|
||||
id="path1671"
|
||||
sodipodi:nodetypes="sccsccss" />
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 4.1 KiB |
BIN
docs/site/static/static/images/s3.png
Normal file
After Width: | Height: | Size: 221 B |
BIN
docs/site/static/static/images/s4.png
Normal file
After Width: | Height: | Size: 269 B |
BIN
docs/site/static/static/images/smtp.png
Normal file
After Width: | Height: | Size: 19 KiB |
BIN
docs/site/static/static/images/splash.png
Normal file
After Width: | Height: | Size: 32 KiB |