Compare commits

...

263 commits

Author SHA1 Message Date
MaximilianKohler
95dabe593b
Update upgrade.md +binary info (#1613) 2023-12-02 22:41:27 +05:30
MaximilianKohler
e06d379953
Update installation.md +tutorials (#1611) 2023-12-01 08:52:06 +05:30
MaximilianKohler
7c991677eb
Update installation.md +changing port (#1607) 2023-11-28 12:28:40 +05:30
Isaak Tsalicoglou
fa506643a4
Add i18n Greek translation (#1605) 2023-11-27 12:53:28 +05:30
Kailash Nadh
53eb71a83b Add 404 HTTP handlers to prevent those requests going to BasicAuth endpoints. 2023-11-25 11:53:56 +05:30
relikd
2ce2a11c7e
feat: docker compose use alpine for postgres (#1603) 2023-11-24 14:48:57 +05:30
relikd
52ee79bf86
chore: noreferrer for listmonk url in footer (#1601)
* chore: noreferrer for listmonk url in footer

* chore: noreferrer for email templates
2023-11-23 06:29:31 +05:30
dependabot[bot]
524be2753b
Bump tinymce from 5.10.8 to 5.10.9 in /frontend (#1592)
Bumps [tinymce](https://github.com/tinymce/tinymce/tree/HEAD/modules/tinymce) from 5.10.8 to 5.10.9.
- [Changelog](https://github.com/tinymce/tinymce/blob/5.10.9/modules/tinymce/CHANGELOG.md)
- [Commits](https://github.com/tinymce/tinymce/commits/5.10.9/modules/tinymce)

---
updated-dependencies:
- dependency-name: tinymce
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-11-21 11:07:08 +05:30
Luc Didry
75befe5214
📝🐛 — Fix /api/subscribers/lists doc (#1594) 2023-11-21 11:06:42 +05:30
guangwu
4577868567
chore: remove refs to deprecated io/ioutil (#1593)
Signed-off-by: guoguangwu <guoguangwu@magic-shield.com>
2023-11-16 13:57:00 +05:30
Kailash Nadh
c59825f3a5 Fix broken sorting (lists -> subcount, subscribers -> status) in queries. Closes #1076. 2023-11-12 10:29:32 +05:30
Kailash Nadh
06b4494200 Fix incorrect sanitisation of search queries on list/campaign frontend. 2023-11-12 10:29:32 +05:30
dependabot[bot]
82c74cd544
Bump golang.org/x/image from 0.6.0 to 0.10.0 (#1580)
Bumps [golang.org/x/image](https://github.com/golang/image) from 0.6.0 to 0.10.0.
- [Commits](https://github.com/golang/image/compare/v0.6.0...v0.10.0)

---
updated-dependencies:
- dependency-name: golang.org/x/image
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-11-11 20:33:06 +05:30
MaximilianKohler
4c8c19ebb9
Update configuration.md - update path due to recent install path change (#1581)
Since `&& cd listmonk` was added to the installation, this path is changed.
2023-11-11 20:32:40 +05:30
dependabot[bot]
d439ecfd84
Bump axios from 0.27.2 to 1.6.0 in /frontend (#1587)
Bumps [axios](https://github.com/axios/axios) from 0.27.2 to 1.6.0.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v0.27.2...v1.6.0)

---
updated-dependencies:
- dependency-name: axios
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-11-11 20:31:09 +05:30
Kailash Nadh
ef084956b4 Make the unsub form on opt-in confirmation e-mail open 'manage' by default. Closes #1515. 2023-11-11 20:28:42 +05:30
Kailash Nadh
44d3462559 Fix 'confirmed' subscriptions becoming 'unconfirmed' on public form re-signup. Closes #1441. 2023-11-11 18:46:38 +05:30
Kailash Nadh
62be5e2181 Fix mysteriously broken frontend build by switching eslint parser dep. 2023-11-11 15:56:42 +05:30
Kailash Nadh
49ec11ca9c Merge branch 'refactor-docs' 2023-11-10 22:44:24 +05:30
Kailash Nadh
e8ecdf8cde Fix mkdocs links in docs. 2023-11-10 22:44:08 +05:30
Kailash Nadh
ff135ecee3 Add 'copy code' button to code snippets in docs. 2023-11-10 22:37:05 +05:30
Kailash Nadh
be4be729a9 Refactor and clean up API Markdown docs.
This has been pending for several years. Finally, managed to do with
GPT-4 "auto" cleaning language and other consistency issues.
90% GPT4, 10% manual hand-wringing.

GPT-4 prompt:
```
- Fix grammar, simplify language, and make language and terms consistent.
- Remove superfluous language, avoid unncessary "the"s etc.
- Change endpoint description in tables that list endpoints, change to terse imperative language.
- Parameter / field description in Parameter tables, don't be imperative or use active sentences. Only describe the parameter passively and tersely.
- Remove backticks from all headings and table values.
- Format markdown tables.
- Standardize "data type" columns values into number, string[], string, JSON.
- Remove the "parameter type" column from tables.
- Wrap ID parameters in URIs in braces. Eg: /lists/list_id to /lists/{list_id}
- Add a markdown line above every API path heading that begins with GET, POST, PUT, DELETE.
- Leave the lines starting with `#code-` untouched
- Do not add new sections, headings, or any new content. Only edit existing content as described above.
- Do not remove any sections or any existing content.
```
2023-11-10 22:30:14 +05:30
Kailash Nadh
f8a55f84fa Fix mkdocs navbar shadow 2023-11-10 12:17:11 +05:30
Thomas-LCP
9e3af91dc5
Update fr.json (#1586) 2023-11-10 11:47:14 +05:30
Kailash Nadh
f720e88b44 Refactor <hr> style in mkdocs template. 2023-11-10 09:03:47 +05:30
Kailash Nadh
b29f565b68 Fix broken table in campaign doc. Closes #1585. 2023-11-09 16:48:45 +05:30
Kailash Nadh
3641f74c64 Add missing header field in campaign creation docs. Closes #1561. 2023-11-01 18:49:48 +05:30
dependabot[bot]
ccceaa6e92
Bump @babel/traverse from 7.21.3 to 7.23.2 in /frontend (#1563)
Bumps [@babel/traverse](https://github.com/babel/babel/tree/HEAD/packages/babel-traverse) from 7.21.3 to 7.23.2.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.23.2/packages/babel-traverse)

---
updated-dependencies:
- dependency-name: "@babel/traverse"
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-10-31 11:04:57 +05:30
dependabot[bot]
c7882bb220
Bump tinymce from 5.10.7 to 5.10.8 in /frontend (#1564)
Bumps [tinymce](https://github.com/tinymce/tinymce/tree/HEAD/modules/tinymce) from 5.10.7 to 5.10.8.
- [Changelog](https://github.com/tinymce/tinymce/blob/5.10.8/modules/tinymce/CHANGELOG.md)
- [Commits](https://github.com/tinymce/tinymce/commits/5.10.8/modules/tinymce)

---
updated-dependencies:
- dependency-name: tinymce
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-10-31 11:04:51 +05:30
MaximilianKohler
fd05a6d17d
Update installation.md - add cd listmonk (#1576) 2023-10-31 11:04:38 +05:30
Fussel132
06332d52a0
Update de.json (#1572)
The settings idleTimeout and waitTimeout were exactly the same in the German translation and could not be distinguished
2023-10-30 10:55:28 +05:30
Kailash Nadh
1ebd80c577 Add Hebrew translation. Closes #1517.
Completed @liy1414's pending PR with automated GPT-4 translation.

Co-Authored-By: liy1414 <88595225+liy1414@users.noreply.github.com>
2023-10-15 21:00:12 +05:30
Kailash Nadh
491fab38cb Update i18n language files. 2023-10-15 20:55:46 +05:30
dependabot[bot]
764a052ecc
Bump golang.org/x/net from 0.8.0 to 0.17.0 (#1555)
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.8.0 to 0.17.0.
- [Commits](https://github.com/golang/net/compare/v0.8.0...v0.17.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-10-15 20:42:38 +05:30
Robert R George
c911aeb20e
Fix attachments being omitted from postback (#1557) 2023-10-14 02:06:42 +05:30
Kailash Nadh
954ed45009 Fix incorrect Slovak language code. Closes #1533. 2023-09-25 11:14:29 +05:30
Kailash Nadh
a61a9d8c04 Fix preference management logic to avoid unnecessary DB calls. 2023-09-25 11:14:29 +05:30
Jannes Blobel
99c71a2a0a
fix: update inlang settings (#1529) 2023-09-20 14:19:08 +05:30
Kailash Nadh
82c3c6878b Clean root URL of trailing slashes when updating settings. 2023-09-19 15:23:32 +05:30
Kailash Nadh
04e571d43a Fix file fetch in attachments failing for signed URLs. Closes #1499. 2023-09-19 15:20:27 +05:30
Kailash Nadh
8f2a08b8db Fix invalid suffix 'd' in timestring string in s3 expiry config. 2023-09-19 14:45:51 +05:30
Kailash Nadh
11f90b2f62 Fix typo in i18n S3 expiry description. 2023-09-19 13:51:26 +05:30
Kailash Nadh
5af6252b14 Fix make not picking up semver from git archive builds. Fixes #1380. 2023-09-19 11:42:07 +05:30
Kailash Nadh
717a6362d2 Add Sprig and other tpl functions to static templates as well. Closes #1527. 2023-09-19 11:19:34 +05:30
Heiko
7019f26280
Fix docs for public subscription api (#1522) 2023-09-12 21:32:23 +05:30
David Lorenz
9423c74d96
Docker Multi Arch (esp. ARM) builds: Improving Build File (#1451)
Co-authored-by: David Lorenz <david@wahnsinn.design>
2023-09-07 22:23:17 +05:30
Kailash Nadh
2b95c88188
Add Postmark bounce webhook support (refactor #1385) (#1485)
Co-authored-by: Thomas Siebers <tom@tsiebers.de>
2023-08-31 21:27:34 +05:30
MaximilianKohler
e5ac111747
Update installation.md - fly.io instructions not working (#1487) 2023-08-31 19:43:40 +05:30
MaximilianKohler
34d86fc387
Update installation.md (#1494)
Add note about docker version
2023-08-30 10:51:20 +05:30
Karan Sharma
5664e5cc9f
fix: replace docker-compose with docker compose (#1490)
Fixes https://github.com/knadh/listmonk/issues/1431
2023-08-28 20:13:03 +05:30
Kailash Nadh
83c88d73d7 Escape search query in list search. Closes #1471. 2023-08-27 14:35:42 +05:30
MaximilianKohler
16eeb9401e
added "tutorials" section and other minor edits (#1444) 2023-08-27 13:34:22 +05:30
Kailash Nadh
e317f2c5ff Modify sed flag based on OS. Closes #1474.
Co-authored-by: Holden Moreland <holden@hdmoreland.com>
2023-08-27 13:32:57 +05:30
Ryan Lahfa
25513b8104
go: bump to 1.20 (#1479) 2023-08-27 13:24:47 +05:30
Kailash Nadh
eefcbc30a3 Fix hardcoded DB name in 'about' SQL query. Closes #1477. 2023-08-27 13:20:41 +05:30
MaximilianKohler
52a7298a9d
Update bounces.md (#1468) 2023-08-27 12:54:31 +05:30
Kailash Nadh
6af904dbd4 Lowercase email search on UI to match lowercase email index in DB. Closes #1464. 2023-08-27 12:17:50 +05:30
Kailash Nadh
07a9632860 Update latest version on homepage. 2023-08-21 10:12:36 +05:30
Kailash Nadh
4b05ab1920 Add check for SES 'invalid domain' transient bounces. Closes #1463. 2023-08-20 09:48:21 +05:30
MaximilianKohler
e2f1313566
Update configuration.md (#1465) 2023-08-20 09:41:30 +05:30
MaximilianKohler
fcdea6050e
Update bounces.md (#1466) 2023-08-20 09:41:13 +05:30
MaximilianKohler
2888dcdd45
Update upgrade.md (#1467) 2023-08-20 09:40:44 +05:30
Kailash Nadh
a6a2b69820 Make public subscription API follow the 'enable public' setting. 2023-08-15 21:05:29 +05:30
Kailash Nadh
79ff7293ea Fix broken dummy password warning on SMTP test UI. Closes #1450. 2023-08-15 20:57:58 +05:30
Kailash Nadh
32b979eeb6 Update subscriber docs and add public subscription API. Closes #1449. 2023-08-13 18:48:24 +05:30
MaximilianKohler
2f07435816
Update bounces docs (#1445)
proper bullets
2023-08-13 15:48:39 +05:30
MaximilianKohler
e258ffda38
Update configuration docs (#1446)
media uploads
2023-08-13 15:39:43 +05:30
Kailash Nadh
f3e9b08026 Update release on website. 2023-08-11 00:24:52 +05:30
Kailash Nadh
ea2f93ea06 Fix GH actions write permission. 2023-08-11 00:02:43 +05:30
Karan Sharma
54979c5d0e
ci: add qemu for multi-arch 2023-08-09 14:50:51 +05:30
Karan Sharma
14e283768b
ci: Update release.yml (#1440)
Updates action and Go versions
2023-08-09 14:09:56 +05:30
Kailash Nadh
0562e5de8d Apply minor fixes to the admin maintenance page template. 2023-08-08 00:07:21 +05:30
Kailash Nadh
f4f51d11c5 Add classes to public forms to make them scriptable. 2023-08-07 23:57:29 +05:30
Felix Winterleitner
b1fa28980e
update documentation for api/tx to include file attachments and multiple recipients (#1436) 2023-08-07 16:29:34 +05:30
Kailash Nadh
acca61f2ca Fix race conditions in Cypress tests (sigh). 2023-08-06 21:25:19 +05:30
Kailash Nadh
1164afac5e Fix private lists being unsubscribed from public form and re-factor subscription status view in admin. 2023-08-06 21:09:16 +05:30
Kailash Nadh
eafae46409 Fix incorrect unsubscription behaviour in the public 'Manage' flow. Closes #1407. 2023-08-06 11:38:13 +05:30
Kailash Nadh
104c4fc993 Remove non-crossplatform syscalls from /api/about.
The only way to get precise OS information cross platform is to have
a separate file for each platform with a build tag that returns the
info, which seems excessive for this usecase.
2023-08-06 10:49:20 +05:30
Ikko Eltociear Ashimine
a1c507b477
Fix typo in queries.sql (#1432)
defualt -> default
2023-08-06 10:06:44 +05:30
Kailash Nadh
2215511f2c Increase the arbitrarily low max-input-length limit. Closes #1416. 2023-08-03 23:52:20 +05:30
Kailash Nadh
dcb87a39b7 Removed unused syscall in /about API. Closes #1421, closes #1422.
Co-authored-by: Gabriel Vasile <gabriel.vasile@email.com>
2023-08-03 23:46:46 +05:30
Kailash Nadh
e89b9ffb30 Remove dead media cleanup flag. Closes #1423. 2023-08-03 23:35:21 +05:30
Unmesh Sagar
a440b79530
modified mistakes and typos in translation (#1419) 2023-08-01 17:09:43 +05:30
Kailash Nadh
2bfbae74ab Update Postgres version in doc strings. 2023-07-27 23:45:39 +05:30
Kailash Nadh
3865e95296 Update untranslated i18n language strings. Auto-translated with GPT 3.5. 2023-07-27 23:32:07 +05:30
Kailash Nadh
93db7c45bc Fix incorrect Buefy checkbox colour. 2023-07-26 23:15:20 +05:30
Kailash Nadh
ad80c716f9 Add new privacy option 'Record opt-in IP' to record IP address of optin confirmation.
- Add new 'Subscriptions' table on the subscriber list form that shows subs,
  IP, and other data.
- Add new `meta` JSONB field to `subscriber_lsts` table.

Closes #1329.
2023-07-26 23:00:32 +05:30
Kailash Nadh
b26950c427 Add new subscription list table to the subscriber edit UI modal. 2023-07-22 16:23:57 +05:30
Kailash Nadh
a62851915c Mask passwords on the UI accurately with the actual passwords length.
This PR masks all the password fields in the UI with a pseudo dot character
retaining the rune length of the original password so that the password
fields on the UI appear to be containing the entered value as-is.

The earlier implementation would revert to a fixed length dummy password
confusing certain users and making it look like the password they entered
wasn't being saved.
2023-07-21 23:46:46 +05:30
Kailash Nadh
0be5901df7 Fix lists UI queries overriding full lists in selections elsewhere. Closes #1400. 2023-07-20 23:46:00 +05:30
Kailash Nadh
534c87509a Ignore common on-reload xhr errors in Cypress. 2023-07-20 23:41:53 +05:30
dependabot[bot]
7dd7166bcf
Bump word-wrap from 1.2.3 to 1.2.4 in /frontend (#1403)
Bumps [word-wrap](https://github.com/jonschlinkert/word-wrap) from 1.2.3 to 1.2.4.
- [Release notes](https://github.com/jonschlinkert/word-wrap/releases)
- [Commits](https://github.com/jonschlinkert/word-wrap/compare/1.2.3...1.2.4)

---
updated-dependencies:
- dependency-name: word-wrap
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-07-19 15:03:17 +05:30
Chinmay Pai
3663a8b10b
feat: add multiarch docker build support (#1344)
* feat: add multiarch docker build support

adds docker build support for the following platforms:
- amd64 (default)
- arm64v8
- armv6
- armv7

also adds GOOS and GOARCH information to the buildString

utilizes docker manifest to store image architecture metadata

* chore: update goreleaser action to v4

* chore: update docker login action to v2

* chore: format buildstring
2023-07-18 11:16:25 +05:30
Kailash Nadh
e1c0bf5030 Fix unsubbed subscribers not getting re-sub optin confirmation. Closes #1315. 2023-07-12 23:09:44 +05:30
runningnoodle
d69b766a3a
Enable extra system calls in systemd service (#1309) 2023-07-12 19:42:54 +05:30
Koen Martens
329b645a3d
Add documentation for archive function. Closes #1396. (#1399) 2023-07-12 19:36:15 +05:30
dependabot[bot]
1894af121f
Bump semver from 5.7.1 to 5.7.2 in /frontend (#1398)
Bumps [semver](https://github.com/npm/node-semver) from 5.7.1 to 5.7.2.
- [Release notes](https://github.com/npm/node-semver/releases)
- [Changelog](https://github.com/npm/node-semver/blob/v5.7.2/CHANGELOG.md)
- [Commits](https://github.com/npm/node-semver/compare/v5.7.1...v5.7.2)

---
updated-dependencies:
- dependency-name: semver
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-07-12 10:24:56 +05:30
Kailash Nadh
c581fe2f3a Add GET /api/about that returns useful system info. Closes #1354. 2023-06-24 13:07:13 +05:30
Kailash Nadh
5b404615fc Stop requiring a DELETE call for fresh import after finished imports. Closes #1369. 2023-06-24 11:29:18 +05:30
Yatish Mehta
530165f5ee
Fix typos in comments (#1368)
Co-authored-by: Yatish Mehta <yatish@example.com>
2023-06-20 23:40:34 +05:30
cui fliter
f94c1f34b6
fix function name in comment (#1374)
Signed-off-by: cui fliter <imcusg@gmail.com>
2023-06-20 23:40:13 +05:30
Jorge - vitrubio
92a4d9911e
Update es.json (#1378)
updated translations and grammar.
2023-06-20 23:36:37 +05:30
Jorge - vitrubio
5e24ef9848
Update it.json (#1379)
Updated italian language
2023-06-20 23:35:13 +05:30
Nils Jacobsen
f577522fe1
Update inlang.config.js (#1359) 2023-06-03 18:22:13 +05:30
Kailash Nadh
e0cda4b35c Fix all pagination. Closes #1356. Closes #1357. 2023-06-03 16:19:10 +05:30
Kailash Nadh
a2da75ce98 Merge branch 'events' 2023-05-27 15:45:22 +05:30
Kailash Nadh
0b2da4c664 Add support for streaming async events via HTTP serverside events.
- `GET /api/events?type=error` opens a long-lived HTTP server side
  event connection that streams error messages.
- async (typically SMTP) errors are now streamed to the frontend and
  disaplyed as an error toast on the admin UI.
2023-05-27 15:44:59 +05:30
Rohan Shetty
63bc00d8e3
Fix issues in the Swagger collection (#1322). Closes #1321. 2023-05-24 10:44:41 +05:30
Kailash Nadh
d359ad27aa Refactor media management.
- Change tiled UI to table UI.
- Add support for search and pagination.
- Important: This breaks the `GET /api/media` API to introduce pagination
  fields. Media items are now moved into `{ data: results[] }`.
2023-05-21 15:19:12 +05:30
Kailash Nadh
3b9a0f782e
Add support for file attachments on campaigns (#1341)
- Adds support for arbitrary file uploads with an admin setting to select allowed file extensions.
- Adds support for attaching media (files) to campaigns.
2023-05-18 16:55:59 +05:30
Viorel-Cosmin Miron
cb2a579252
Update ro.json (#1326)
Fixed and updated romanian translation.
2023-05-12 13:01:38 +05:30
Rohan Shetty
cbbbe402be
Fix missing fields in Swagger collection. Closes #1317. 2023-05-10 11:18:40 +05:30
Rohan Shetty
5a2627932d
Fix Swagger collection. Closes #1308. Closes #1313 (PR #1314) 2023-05-08 23:37:11 +05:30
Kailash Nadh
917696a543 Refactor Messenger interface.
Remove `messenger.go` and move the interface definition to `manager`
and the `Message` struct to the `models` package, removing superflous
and redundant message structs used in multiple places.
2023-05-08 22:43:25 +05:30
Kailash Nadh
9ffc912a2c Refactor media provider.
- Rename `Get()` to `GetURL()`.
- Add `GetBlob()` to retrieve the media file blobs in
  filesystem and and s3 media providers.

This enables reading media files inside listmonk paving the way to
campaign file attachments.
2023-05-08 20:28:25 +05:30
Kailash Nadh
be16297549 Fix incorrect assignment of subscriber in message struct. Closes #1307. 2023-05-06 13:41:21 +05:30
Rohan Shetty
e332622db9
Added remaining endpoints to the Swagger Collection (#1283)
* added swagger collection for APIs

* added remaining endpoints to the swagger collection
2023-04-25 22:17:43 +05:30
Kailash Nadh
39a627d839 Upgrade koanf lib to fix mapstructure []byte unmarshal bug. 2023-04-12 22:00:09 +05:30
Kailash Nadh
9381e086a1 Link the Swagger pages on the API doc. 2023-04-11 21:51:30 +05:30
Chinmay Pai
8c46b758ce
build: push docker image to ghcr.io (#1237)
with the new recent changes in dockerhub[1] we should
start pushing images to ghcr, and eventually phase out
the existing dockerhub setup.

[1]: https://blog.alexellis.io/docker-is-deleting-open-source-images/

Signed-off-by: Chinmay D. Pai <chinmay.pai@zerodha.com>
2023-04-11 21:42:41 +05:30
Chinmay Pai
101459f2f6
actions: generate swagger ui for github pages (#1281) 2023-04-11 21:39:40 +05:30
Kailash Nadh
81ac6276bd Add white background to logo in README for dark mode. Closes #1268. 2023-04-11 12:51:24 +05:30
Kailash Nadh
5fc28a733c Add support for variable bounce processing actions.
- Add support for `complaint` to the SES bounce processor.
- Add support for `hard/soft` to Sendgrid bounce processor.
- Add new bounce actions `None` and `Unsubscribe`.
- Add per type (`soft/hard/complaint`) bounce rule configuration to
  admin settings UI.
- Refactor Cypress bounce tests.
2023-04-11 11:33:40 +05:30
Kailash Nadh
13ad9adb8b Upgrade koanf to v2. 2023-04-10 12:45:25 +05:30
Kailash Nadh
3baf18ea45 Add support for wildcards in the email domain blocklist.
Eg: *.example.com, *.mail.example.com etc.

Closes #1275. Closes #1276.
2023-04-09 14:04:02 +05:30
Kailash Nadh
98729f63df Refactor email domain blocklist config field in importer package. 2023-04-09 14:04:02 +05:30
Justin Beaty
476d5bebf2
Add support for publishing full content in public archive RSS feed body (#1262)
- Introduces a new option on the settings UI to optionally publish the full campaign body in
  public archive RSS feeds.

Closes #1033 

Co-authored-by: Kailash Nadh <kailash@nadh.in>
2023-04-08 09:39:10 +05:30
peix187
146e8e7a63
Add missing translation message in maintenance page (#1279) 2023-04-08 08:48:40 +05:30
peix187
0d4c1d1471
Fix portuges missing translations (#1278) 2023-04-08 08:48:03 +05:30
Kailash Nadh
7db3d7da0f Improve i18n editor.
- Add a one-click `download JSON` button.
- Add a `New language` button.

Add secondary editor InLang (remote service) to docs.

Closes #1266, #1267.
2023-04-06 11:57:57 +05:30
Kailash Nadh
71a9138b23 Add missing i18n languages to the editor 2023-04-06 11:29:58 +05:30
Marcin Kunert
ad53632be4
Expanded docs about PUT /api/subscribers/:id (#1269)
I've expanded the docs a bit, related to #995
2023-04-02 20:40:38 +05:30
Kailash Nadh
b433ef68ec Fix broken SES bounce type check. 2023-04-02 20:39:01 +05:30
Justin Beaty
a95510260d
Add functions to notification templates (#1263) 2023-03-26 12:00:35 +05:30
Kailash Nadh
d1d0922a8c Fix gh-pages workflow script to run on push to master. 2023-03-26 11:54:46 +05:30
Kailash Nadh
ec7a246afc Fix link to the docs repo. 2023-03-26 11:17:15 +05:30
Kailash Nadh
d4fb3a3399 Remove obsolete files. 2023-03-26 11:15:55 +05:30
Kailash Nadh
5d3c10d198 Add new README to the docs directory. 2023-03-26 11:13:25 +05:30
Kailash Nadh
684c15a404
Add static Hugo website and mkdocs documentation to docs directory. (#1261)
This commit merges the static website and docs that was on
https://github.com/knadh/listmonk-site repository into the main
listmonk repo.

It also adds a GitHub Actions workflow to public the sites on GitHub
Pages instead of Netlify.
2023-03-26 00:51:20 +05:30
Kailash Nadh
152bd37c22 Fix no opt-in mails when existing subscribers subscriber to new opt-in lists. Closes #1257. 2023-03-25 21:06:59 +05:30
Kailash Nadh
5aedc3a4f6 Make media upload file extension validation case insensitive. Closes #1256. 2023-03-25 11:46:23 +05:30
Rohan Shetty
bf72154727
Add Swagger collection for APIs (#1253) 2023-03-25 11:35:15 +05:30
Alfredo Sola
4821dd7c66
Update es.json (#1258) 2023-03-25 11:31:32 +05:30
Kailash Nadh
d87a01fad8 Include CAPTCHA in HTML form generation. 2023-03-25 11:21:06 +05:30
Navan Chauhan
eb1d4a3c2a
add support for arm binaries (#1249) 2023-03-21 18:01:30 +05:30
Vivek R
c668523c57
upgrade frontend dev deps to support node v17+ (#1247)
Used `vue-cli` to upgrade all the dev deps from node v16 or lower. Here
are the steps followed

- `vue upgrade` to upgrade all deps to latest.
- remove `node_modules` directory and `yarn.lock` file.
- `yarn install` to install dependencies again.
2023-03-20 17:57:51 +05:30
Kailash Nadh
553a61b4d2 Update Go build version in GitHub workflow. 2023-03-19 23:17:08 +05:30
Kailash Nadh
1bb9123333 Fix Cypress tests (settings security tab, new default tpls). 2023-03-19 22:58:38 +05:30
Kailash Nadh
3646e6d20a Upgrade Go package deps. 2023-03-19 22:58:38 +05:30
Kailash Nadh
55f7eca2e8
Add support for file attachments in the transactional (tx) API. (#1243)
The original PR accepts files to the `/tx` endpoints as Base64 encoded
strings in the JSON payload. This isn't ideal as the payload size
increase caused by Base64 for larger files can be significant,
in addition to the added clientside API complexity.

This PR adds supports for multipart form posts to `/tx` where the
JSON data (name: `data`) and multiple files can be posted simultaenously
(one or more `file` fields).

--- PR: #1166
* Attachment model for TxMessage
* Don't reassign values, just pass the manager.Messgage
* Read attachment info from API; create attachment Header
* Refactor tx attachments to use multipart form files. Closes #1166.
---

Co-authored-by: MatiSSL <matiss.lidaka@nic.lv>
2023-03-19 15:50:44 +05:30
Samuel Stroschein
4181d8a0c5
Integrate inlang for easy i18n translations (#1189) 2023-03-19 14:03:30 +05:30
Kailash Nadh
6cf82347bf Add support for SVG files to media. Closes #1217.
- Add support for SVG in media uploader.
- Add provision to exclude vector formats from thumbnail creation.
- Increase default thumb size to 120px from 90px.

Co-authored-by: Ronan <ronan.le_meillat@sctg.eu.org>
2023-03-19 13:58:41 +05:30
Kailash Nadh
aaf5080a27 Fix discrepency in SQL query/export queries. Closes #1241. 2023-03-19 12:53:05 +05:30
Margu
35ddf3c899
fixed weekday order to fix #1182 (#1227) 2023-03-06 19:04:04 +05:30
saginsa
5020bae77e
fix #1210 French day name (#1226) 2023-03-04 15:08:32 +05:30
Andrási István
09fe812dd7
Update hu.json (#1219)
I tried my best to fix the translation according to context, cultural analogies and technical jargon. While some of it was right, there were severe mistranslations, sometimes it crucial areas. I'm Hungarian.

Thanks!
2023-02-28 10:26:51 +05:30
Ronan LE MEILLAT
8d1f30c101
correct eslint "no-multiple-empty-lines" (#1179) 2023-02-27 14:46:57 +05:30
Kailash Nadh
d6fd4ab586 Fix 'delete' -> 'clear' language on bounces UI. Closes #1072. 2023-02-26 13:19:00 +05:30
Kailash Nadh
da377d83e6 Parse campaign UUID in SendGrid webhook. Closes #1177. 2023-02-26 13:06:30 +05:30
Kailash Nadh
dda7d44601 Hide private lists from prefs manage page. Closes #1200. 2023-02-26 12:36:46 +05:30
Kailash Nadh
215aae5366 Fix public preference manage page's list style. 2023-02-26 12:36:06 +05:30
dependabot[bot]
7be73d59d6
Bump golang.org/x/sys from 0.0.0-20211205182925-97ca703d548d to 0.1.0 (#1212)
Bumps [golang.org/x/sys](https://github.com/golang/sys) from 0.0.0-20211205182925-97ca703d548d to 0.1.0.
- [Release notes](https://github.com/golang/sys/releases)
- [Commits](https://github.com/golang/sys/commits/v0.1.0)

---
updated-dependencies:
- dependency-name: golang.org/x/sys
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-02-26 11:28:04 +05:30
Ronan LE MEILLAT
80592f60c6
Translate the subject of the email sent with personnal datas (#1193) 2023-02-20 20:33:44 +05:30
Erik Junsved
2c531eb1d6
(Public strings only) Add Swedish translation (#1194)
* Add swedish translation (public strings only)

* Fix spelling error in public.privacyWipeHelp
2023-02-20 20:03:13 +05:30
Ronan LE MEILLAT
2de72eac56
update french translation (#1190)
* Update hCaptcha french translation

* update french translation
2023-02-16 23:30:56 +05:30
Ronan LE MEILLAT
e77635cdd2
Update hCaptcha french translation (#1178) 2023-02-08 00:04:23 +05:30
kosssi
3513988a07
[i18n] Add translation of the term Powered by (#1168) 2023-02-07 14:43:25 +05:30
saginsa
2ed62cb82e
fix russian translate (#1174) 2023-02-04 20:04:37 +05:30
Marcin Kunert
bfc8a0cb3b
Update pl.json (#1165) 2023-02-01 10:58:09 +05:30
Marcin Kunert
eb9f6876b0
Update pl.json (#1164) 2023-01-31 19:19:18 +05:30
Kailash Nadh
274d86422c Upgrade smtp-pool lib fixing incorrect nested mail commands. 2023-01-30 23:07:53 +05:30
Tycho Werner
4977b746b9
Partial update of NL i18n (#1056)
* Partial update of NL i18n

* Update i18n/nl.json

Co-authored-by: Jesse <jessegielen@hotmail.com>

---------

Co-authored-by: Jesse <jessegielen@hotmail.com>
2023-01-28 14:47:22 +05:30
dependabot[bot]
fd655312ab
Bump decode-uri-component from 0.2.0 to 0.2.2 in /frontend (#1084)
Bumps [decode-uri-component](https://github.com/SamVerschueren/decode-uri-component) from 0.2.0 to 0.2.2.
- [Release notes](https://github.com/SamVerschueren/decode-uri-component/releases)
- [Commits](https://github.com/SamVerschueren/decode-uri-component/compare/v0.2.0...v0.2.2)

---
updated-dependencies:
- dependency-name: decode-uri-component
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-01-28 14:00:18 +05:30
Kailash Nadh
b339482d89 Update Polish language.
Co-authored by tymoteusz@jozwiak.top

Closes #1120
2023-01-28 13:59:07 +05:30
Haseeb Ahmad
755344e74b
Add check for SES bounce notif type (#1123) 2023-01-28 13:55:48 +05:30
Kailash Nadh
8985e5c24a
Add hCaptcha.com support to public subscription form. (#1152)
Bots easily bypass the simple `nonce` hack. This commit adds support
for the hcaptcha.com widget.

- New `Security` tab in the admin settings UI.
- Enable/disable CAPTCHA.
- Render CAPTCHA on the public subscription form.

Closes #1116.
2023-01-23 21:50:10 +05:30
Kailash Nadh
62d3782d04 Use send_at date for scheduled campaigns in RSS feed. Closes #1149. 2023-01-21 12:43:30 +05:30
Rohan Verma
72d22d40ef
fix: check public URL before presigned URL generation (#1148)
Fixes #1141
2023-01-16 15:49:21 +05:30
Manuel Villarroel
66c81c8191
Update es.json (#1139)
The use of the word "Subscribir" is updated by "Suscribir", because it is the form recommended by the RAE:
https://dle.rae.es/suscribir?m=form

The use of the word "Des-subscribir" is updated, since its use is wrong. The RAE recommends the use of "Darse de baja":
https://twitter.com/raeinforma/status/748125040722448384?lang=es
2023-01-09 00:32:40 +05:30
Shrilakshmi Shastry
7832248a08
Fix label/input accessibility on subscription form (#1134) 2023-01-06 15:29:26 +05:30
Filip Hanes
076b7c7a0a
Slovak translation (#1128) 2023-01-02 16:04:45 +05:30
Kailash Nadh
3cfbc646e3 Add support for multiple subscribers in a single transactional message call.
This patch adds new array fields on `POST /tx`: `subscriber_emails[]`, `subscriber_ids[]`.
Either of these array fields can be sent with multiple subscribers.

The individual non-array fields `subscriber_id` and `subscriber_email` are deprecated.

Closes #994.
2022-12-25 17:32:01 +05:30
Kailash Nadh
5d4f1ea0ad Add optional params in DB config to accept arbitrary Postgres params. Closes #1016. 2022-12-25 16:28:19 +05:30
Kailash Nadh
1f693b80f7 Add a default public archive template. 2022-12-25 16:07:12 +05:30
Kailash Nadh
96d30d6725
Update issue templates 2022-12-25 14:10:57 +05:30
Kailash Nadh
49f20f33db Fix broken sorting in list query. Closes #1076.
- Replace "%s %s" sprintf substitution in some raw SQL queries with named
  string replace params. Eg: `%order%`.
2022-12-25 14:04:43 +05:30
Kailash Nadh
4dee2e9a1b Fix per_page=all in API calls in the paginator lib.
Closes #1098.
2022-12-25 00:43:58 +05:30
Jorge - vitrubio
396f85d273
updated spanish translation (#1119) 2022-12-24 21:44:38 +05:30
Jorge - vitrubio
ba46769fcf
updated italian translation (#1118) 2022-12-24 21:36:24 +05:30
Johannes Filter
8a2d053353
Fix misleading German translation (#1115)
The word `erfolgreich` (successfully) implies the user is already confirmed, even though s/he still has to click on the confirmation link.
2022-12-21 20:00:33 +05:30
p_0g_8mm3_
c773dc0abc
Fix maintenance settings title (#1096) 2022-12-11 10:07:47 +05:30
dependabot[bot]
e71c060b69
Bump express from 4.17.1 to 4.18.2 in /frontend (#1091)
Bumps [express](https://github.com/expressjs/express) from 4.17.1 to 4.18.2.
- [Release notes](https://github.com/expressjs/express/releases)
- [Changelog](https://github.com/expressjs/express/blob/master/History.md)
- [Commits](https://github.com/expressjs/express/compare/4.17.1...4.18.2)

---
updated-dependencies:
- dependency-name: express
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-12-09 11:54:30 +05:30
dependabot[bot]
12b2bdf70a
Bump tinymce from 5.10.0 to 5.10.7 in /frontend (#1090)
Bumps [tinymce](https://github.com/tinymce/tinymce/tree/HEAD/modules/tinymce) from 5.10.0 to 5.10.7.
- [Release notes](https://github.com/tinymce/tinymce/releases)
- [Changelog](https://github.com/tinymce/tinymce/blob/5.10.7/modules/tinymce/CHANGELOG.md)
- [Commits](https://github.com/tinymce/tinymce/commits/5.10.7/modules/tinymce)

---
updated-dependencies:
- dependency-name: tinymce
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-12-09 09:58:53 +05:30
Kailash Nadh
a555fd3876 Fix nil tpl when campaign body is empty. Closes #1085. 2022-12-06 19:47:05 +05:30
Kailash Nadh
448f0e3428 Fix missing subscriber count in individual list GET API. 2022-11-27 23:07:40 +05:30
Kailash Nadh
8e3e1b9af8 Change naive ILIKE search to full text (unindexed) on lists and campaigns. Closes #1058. 2022-11-27 23:01:15 +05:30
Kailash Nadh
93260396c6 Silence health check error in Cypress tests. 2022-11-27 22:12:05 +05:30
Volkan Tokmak
175982443e
chore: fixes translations for Turkish language (#1063) 2022-11-27 18:15:32 +05:30
William Griffiths
18746b7116
Add Welsh to i18n (#1060)
* Add Welsh to i18n

* Fix template variable name
2022-11-26 12:13:34 +05:30
Diogo Correia
3a562749dd
Add Portuguese translations for v2.3.0 (#1061) 2022-11-26 12:12:30 +05:30
p1slave
3a89bfd54b
Update zh-CN.json (#1062) 2022-11-26 12:11:39 +05:30
davidesteve
d6a3635192
Update ca.json (#1059)
Update Catalan version for v2.3.0 #1039
2022-11-25 14:42:37 +05:30
Marcin Kunert
8f8e83fbb6
Update Polish translation (#1055) 2022-11-24 12:32:49 +05:30
Роман
a42f635ae7
Update ru.json (#1054) 2022-11-23 22:16:01 +05:30
Kailash Nadh
8d4a5751d8 Fix broken single list fetch API. 2022-11-22 23:11:20 +05:30
MickGe
e60b38527c
Update fr i18n file (#1050) 2022-11-22 13:28:47 +05:30
Rafael Slonik
4b551ef679
pt-BR.json v2.3.0 (#1046) 2022-11-21 17:33:38 +05:30
NicoHood
de2e5a11aa
Update german translation (#1045) 2022-11-21 11:38:51 +05:30
Kailash Nadh
395ae987da Update cs-cz i18n file. Co-authored-by: Lumir Srch <srchlm@its.cz> 2022-11-20 11:25:13 +05:30
Kailash Nadh
d30ef227ad Include archive params when cloning campaigns on the UI. Closes #1026. 2022-11-20 11:14:13 +05:30
Kailash Nadh
73bb6081fc Add helper button on campaign UI to fill default archive meta JSON. 2022-11-20 11:14:13 +05:30
vados
c52a06728c
i18n(jp): update for 2.3.0 release (#1042) 2022-11-19 17:17:16 +05:30
Joice
b2853fd67f
Adds malayalam strings localized (#1041) 2022-11-19 17:16:31 +05:30
Kailash Nadh
4f2f419ae2 Include send_at when cloning campaigns on the UI. Closes #1027. 2022-11-19 17:01:07 +05:30
Kailash Nadh
6fcb4ff978 Add archive page link icon next to campaign archive toggle on UI. Closes #1028. 2022-11-18 23:45:49 +05:30
Kailash Nadh
2761a5e033 Fix modal overflow issue on bulk list popup UI. Closes #1030. 2022-11-18 23:25:49 +05:30
Kailash Nadh
1e90feecaf Show send_at on archive page for scheduled campaigns. Closes #1036. 2022-11-18 23:12:53 +05:30
Kailash Nadh
832a426f4c Fix settings Cypress test. 2022-11-10 23:51:19 +05:30
Kailash Nadh
af4b532a00 Add version to public css for cache busting. 2022-11-10 23:43:59 +05:30
Kailash Nadh
a8193d80c8 Tidy go.mod 2022-11-10 23:31:15 +05:30
Kailash Nadh
d1307c6a2c Add missing RSS icon. 2022-11-10 23:30:53 +05:30
Kailash Nadh
818f2c9d8e Add public archive on/off toggle to settings. 2022-11-10 23:30:53 +05:30
Kailash Nadh
f958f3d24b Add RSS feed to the public mailing list archive. 2022-11-10 23:30:53 +05:30
Kailash Nadh
438568eeb0 Add global site name setting to render name on public pages. 2022-11-10 23:30:53 +05:30
Kailash Nadh
eac1240437 Exclude opt-in campaign from public archive. 2022-11-10 23:30:53 +05:30
Kailash Nadh
23fb178ec4 Add subscription/archive links to public pages. 2022-11-10 23:30:53 +05:30
Kailash Nadh
ebf63b5bed Disable archiving on opt-in campaigns. 2022-11-10 23:30:53 +05:30
Kailash Nadh
eaaca05f36 Fix archive template selection in campaign creation query. 2022-11-10 23:30:53 +05:30
Kailash Nadh
56a9836e86 Integrate paginator library in place of custom pagination function. 2022-11-10 23:30:50 +05:30
Kailash Nadh
9add728b08 WIP: Add support for publishing campaigns to publish archives. 2022-11-10 23:30:11 +05:30
Kailash Nadh
74322cda36 Fix e-mail From/Return-Path envelope sender. Closes #908, closes #1008. 2022-11-10 00:01:24 +05:30
Kailash Nadh
c2d41e0671 Remove redundant test code. 2022-11-09 23:30:13 +05:30
Kailash Nadh
d613bb5a44 Make font size of certain on the settings UI consistent. 2022-11-09 23:29:29 +05:30
Romain
6d3ae4cc73
Add subscription created_at, updated_at when listing subscribers list (#1018) 2022-11-09 21:40:11 +05:30
Kailash Nadh
fd70776166 Fix table cell content alignment in campaign list. Closes #742. 2022-11-01 22:54:40 +05:30
Kailash Nadh
879bff854e Update subscription date on public unsubscribe. Closes #915. 2022-11-01 21:46:07 +05:30
Kailash Nadh
d8e3e25f06 Add preconfirm optin option to bulk list management UI. Closes #935. 2022-11-01 21:39:02 +05:30
Kailash Nadh
ef1f84ee7c Add new description field to lists. Closes #925. 2022-11-01 21:04:35 +05:30
Kailash Nadh
95b8df2918 Add tests for new subscription form. 2022-10-29 15:23:28 +05:30
Kailash Nadh
3b0083190e Add ability for subscribers to manage preferences on the unsub form.
- Ability to change name.
- Ability to unsubscribe from individual lists.
- Toggle option to enable this in Admin Settings -> Privacy.

Closes #455.
2022-10-29 15:23:28 +05:30
Kailash Nadh
372a144322 Display template IDs on the list UI and popup editor. Closes #986. 2022-10-22 17:29:31 +05:30
Kailash Nadh
281c47198c Fix go-for-loop reference bug in template caching. 2022-10-21 17:59:56 +05:30
Kailash Nadh
d2e8a9368c Upgrade Cypress to major version 10. 2022-10-18 21:58:57 +05:30
Kailash Nadh
c38100427d Add arbitrary meta field to media. Closes #938.
- Add new `meta` JSONB field to `media` table.
- Start storing image width and height as meta with media uploads.
2022-10-02 23:04:51 +05:30
Kailash Nadh
c3d04a5490 Refactor models.SubscriberAttribs JSON wrapper to generic name JSON. 2022-10-02 22:53:38 +05:30
dependabot[bot]
db2fd9ad70
Bump github.com/labstack/echo/v4 from 4.6.1 to 4.9.0 (#962)
Bumps [github.com/labstack/echo/v4](https://github.com/labstack/echo) from 4.6.1 to 4.9.0.
- [Release notes](https://github.com/labstack/echo/releases)
- [Changelog](https://github.com/labstack/echo/blob/master/CHANGELOG.md)
- [Commits](https://github.com/labstack/echo/compare/v4.6.1...v4.9.0)

---
updated-dependencies:
- dependency-name: github.com/labstack/echo/v4
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-10-01 14:12:31 +05:30
Kailash Nadh
bea1680360 Fix incorrect day-of-week on the UI. Closes #942. 2022-10-01 14:12:07 +05:30
davidesteve
9c94efb863
Create ca.json (#955) 2022-09-27 09:44:24 +05:30
Kailash Nadh
5b8c705f03 Fix headers not being copied in campaign clone. Closes #948. 2022-09-16 23:19:15 +05:30
Kailash Nadh
edb4c9168d Improve HTML check in notif template loading. Closes #903. 2022-09-10 12:21:53 +05:30
Kailash Nadh
f266f93cc8 Add Safe() template function to notif templates. 2022-09-10 12:20:27 +05:30
Kailash Nadh
5a9e9209c8 Fix missing list names on optin page. Closes #940. 2022-09-10 12:17:01 +05:30
Kailash Nadh
f9bc049271 Merge branch 'maintenance' 2022-09-03 14:59:04 +05:30
Kailash Nadh
6d820f4f6e Add maintenance options.
- Add new maintenance UI with options to garbage collect (delete)
  orphan subscriber and analytics records.
2022-09-03 14:58:25 +05:30
Peter yang
8eb4f7e7da
Create zh-TW.json (#936) 2022-09-02 12:11:22 +05:30
Kailash Nadh
8ace25849e Add /api/public/* endpoints.
- Add `/api/public/lists` that returns the list of public lists, same
  information revealed on the `/subscription/form` page.

- Add `/api/public/subscription` that accepts a JSON POST for a
  subscription signup same as `/subscription/form`.

Closes #910.
2022-08-28 15:12:20 +05:30
Tarlan Isaev
4ef7a6a1ee
Update RU i18n translation (#922) 2022-08-28 12:15:10 +05:30
Kailash Nadh
76df9c8d76 Refactor and simply function name. 2022-08-26 16:49:58 +05:30
Kailash Nadh
13068ccce2 Fix broken bulk subscriber query. Closes #897. 2022-08-26 16:49:58 +05:30
Marcin Kunert
a5c14a17d3
Update Polish translations (#918) 2022-08-24 17:19:42 +05:30
Kailash Nadh
6b11020a67 Fix empty subject on non-tpl tx subject. Closes #898. 2022-08-20 19:57:30 +05:30
mannminh
c60412dcb3
Update vi.json (#901)
change "campaigns.rateMinuteShort":
2022-08-19 13:57:38 +05:30
George
aaac82a5c9
Update zh-CN.json (#904)
Modify the language name of zh-CN package.
2022-08-19 12:23:26 +05:30
247 changed files with 24262 additions and 9962 deletions

View file

@ -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: ''

View 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
View 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'

View file

@ -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 }}

View file

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

View file

@ -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/).

View file

@ -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"

View file

@ -1,8 +1,8 @@
<a href="https://zerodha.tech"><img src="https://zerodha.tech/static/images/github-badge.svg" align="right" /></a>
[![listmonk-logo](https://user-images.githubusercontent.com/547147/134940003-1de03d83-8c7b-459b-8056-baa8d5f3b448.png)](https://listmonk.app)
[![listmonk-logo](https://user-images.githubusercontent.com/547147/231084896-835dba66-2dfe-497c-ba0f-787564c0819e.png)](https://listmonk.app)
listmonk is a standalone, self-hosted, newsletter and mailing list manager. It is fast, feature-rich, and packed into a single binary. It uses a PostgreSQL (⩾ 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.
[![listmonk-dashboard](https://user-images.githubusercontent.com/547147/134939475-e0391111-f762-44cb-b056-6cb0857755e3.png)](https://listmonk.app)

View file

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

View file

@ -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
View 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
}

View file

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

View file

@ -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
View 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
}

View file

@ -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,
}
}

View file

@ -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()))

View file

@ -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
}

View file

@ -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.

View file

@ -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("")

View file

@ -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
View 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})
}

View file

@ -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
}

View file

@ -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
}

View file

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

View file

@ -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
}

View file

@ -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)
}

View file

@ -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
View file

@ -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 == "" {

View file

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

View file

@ -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 {

View file

@ -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"))
}

View file

@ -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 = ""

View file

@ -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
View 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

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

View 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
}
```

View 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"
}
}
```

View 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
}
```

View 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
}
```

View 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
}
```

View 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
}
```

View 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"'
```

View 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": {}
}
```
![Archive campaign](images/archived-campaign-metadata.png)

View 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)

View 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).

View 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.

View 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`

View 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
View 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)
[![translation badge](https://inlang.com/badge?url=github.com/knadh/listmonk)](https://inlang.com/editor/github.com/knadh/listmonk?ref=badge)
- Visit [https://inlang.com/editor/github.com/knadh/listmonk](https://inlang.com/editor/github.com/knadh/listmonk)
- To make changes and push them, you need to log in to GitHub using OAuth and fork the project from the UI.
- Translate the text in the input fields on the UI. You can use the filters to see only the necessary translations.
- Once you're done, push the changes from the UI and click on "Open a pull request." This will take you to GitHub, where you can write a PR message.

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

View file

@ -0,0 +1,10 @@
# Introduction
[![listmonk](images/logo.svg)](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.
[![listmonk screenshot](https://user-images.githubusercontent.com/547147/134939475-e0391111-f762-44cb-b056-6cb0857755e3.png)](https://listmonk.app)
## Developers
listmonk is a free and open source software licensed under AGPLv3. If you are interested in contributing, check out the [GitHub repository](https://github.com/knadh/listmonk) and refer to the [developer setup](developer-setup.md). The backend is written in Go and the frontend is Vue with Buefy for UI.

View 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)

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

View 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"
}
}
```
![listmonk screenshot](images/edit-subscriber.png)
## Sample SQL query expressions
![listmonk](images/query-subscribers.png)
#### Find a subscriber by e-mail
```sql
-- Exact match
subscribers.email = 'some@domain.com'
-- Partial match to find e-mails that end in @domain.com.
subscribers.email LIKE '%@domain.com'
```
#### Find a subscriber by name
```sql
-- Find all subscribers whose name start with John.
subscribers.email LIKE 'John%'
```
#### Multiple conditions
```sql
-- Find all Johns who have been blocklisted.
subscribers.email LIKE 'John%' AND status = 'blocklisted'
```
#### Querying 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).

View 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;
}

View 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:
![image](https://user-images.githubusercontent.com/55474996/153739792-93074af6-d1dd-40aa-8cde-c02ea4bbb67b.png)
### System e-mails
| /static/email-templates/ | |
|----------------------------------|------------------------------------------------------------------------------------------------------------------------------------|
| `base.html` | Base template with the header and footer that all system generated e-mails use. |
| `campaign-status.html` | E-mail notification that is sent to admins on campaign start, completion etc. |
| `import-status.html` | E-mail notification that is sent to admins on finish of an import job. |
| `subscriber-data.html` | E-mail that is sent to subscribers when they request a full dump of their private data. |
| `subscriber-optin.html` | Automatic opt-in confirmation e-mail that is sent to an unconfirmed subscriber when they are added. |
| `subscriber-optin-campaign.html` | E-mail content that's inserted into a campaign body when starting an opt-in campaign from the lists page. |
| `default.tpl` | Default campaign template that is created in Campaigns -> Templates when listmonk is first installed. This is not used after that. |
!!! info
To turn system e-mail templates to plaintext, remove `<!doctype html>` from base.html and remove all HTML tags from the templates while retaining the Go templating code.

View 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".
![Railway Redeploy option](https://user-images.githubusercontent.com/55474996/226517149-6dc512d5-f862-46f7-a57d-5e55b781ff53.png)
## Downgrade
To restore a previous version, you have to restore the DB for that particular version. DBs that have been upgraded with a particular version shouldn't be used with older versions. There may be DB changes that a new version brings that are incompatible with previous versions.
**General steps:**
1. Stop listmonk.
2. Restore your pre-upgrade database.
3. If you're using `docker compose`, edit `docker-compose.yml` and change `listmonk:latest` to `listmonk:v2.4.0` _(for example)_.
4. Restart.
**Example with docker:**
1. Stop listmonk (app):
```
sudo docker stop listmonk_app
```
2. Restore your pre-upgrade db (required) _(be careful, this will wipe your existing DB)_:
```
psql -h 127.0.0.1 -p 9432 -U listmonk
drop schema public cascade;
create schema public;
\q
psql -h 127.0.0.1 -p 9432 -U listmonk -W listmonk < listmonk-preupgrade-db.sql
```
3. Edit the `docker-compose.yml`:
```
x-app-defaults: &app-defaults
restart: unless-stopped
image: listmonk/listmonk:v2.4.0
```
4. Restart:
`sudo docker compose up -d app db nginx certbot`

61
docs/docs/mkdocs.yml Normal file
View 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 &copy; 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
View 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>
&nbsp;&nbsp;&nbsp;
<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
View 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
View 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

File diff suppressed because one or more lines are too long

View file

6
docs/site/config.toml Normal file
View 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
View file

@ -0,0 +1 @@

View 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"
}
]
}

View 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 &rarr;</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 &rarr;</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 &hellip;</h2>
<div class="center">
<br />
<a href="#download" class="button">Download</a>
</div>
<section class="banner">
<div class="row">
<div class="col-2">&nbsp;</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">&nbsp;</div>
</div>
</section>
</div>
{{ partial "footer.html" }}

View file

@ -0,0 +1,6 @@
{{ partial "header" . }}
<article class="page">
<h1>{{ .Title }}</h1>
{{ .Content }}
</article>
{{ partial "footer" }}

View file

@ -0,0 +1,10 @@
<div class="container">
<footer class="footer">
&copy; 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>

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

View file

@ -0,0 +1,5 @@
<section class="row">
<div class="col2">&nbsp;</div>
<div class="col8">{{ .Inner }}</div>
<div class="clear"> </div>
</section>

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

View file

@ -0,0 +1,4 @@
<div class="row">
<div class="col7">{{ .Inner }}</div>
<div class="clear"> </div>
</div>

View file

@ -0,0 +1,3 @@
<section>
{{ .Inner }}
</section>

View 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;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 360 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 506 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 623 B

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Some files were not shown because too many files have changed in this diff Show more