Compare commits

..

810 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
Kailash Nadh
bbbf28c5ce Create default tx template on upgrade. 2022-07-30 22:37:05 +05:30
Kailash Nadh
cd09c5a59d Remove MailerSend (no config available) from SMTP settings UI. 2022-07-30 21:05:58 +05:30
Kailash Nadh
57dbb9e5db Add explicit warning on empty password to SMTP test UI. 2022-07-30 20:42:17 +05:30
Kailash Nadh
b497f52ae7 Merge branch 'fix-analytics' 2022-07-30 20:12:11 +05:30
Kailash Nadh
bfc27def57 Fix regression of public subscriber page behaviour. 2022-07-30 20:11:59 +05:30
Kailash Nadh
3550d5453d Fix incorrect analytics count. Closes #712. 2022-07-30 19:01:20 +05:30
dependabot[bot]
d19a55b08a
Bump terser from 4.7.0 to 4.8.1 in /frontend (#885)
Bumps [terser](https://github.com/terser/terser) from 4.7.0 to 4.8.1.
- [Release notes](https://github.com/terser/terser/releases)
- [Changelog](https://github.com/terser/terser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/terser/terser/commits)

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

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-07-21 10:40:21 +05:30
Petteri Pucilowski
2ef7e262f5
update FI translation (still partial) (#878) 2022-07-16 19:51:46 +05:30
Nathanaël Houn
71dd48b0a0
I18n fr translations update (#876) 2022-07-15 14:54:57 +05:30
Lumir Srch
6aa63fe76b
Update cs-cz.json (#879) 2022-07-15 12:37:53 +05:30
Rafael Slonik
3163f919e6
fix(i18n): update i18n/pt-BR for 2.2.0 release (#875) 2022-07-15 10:12:19 +05:30
MickGe
54349ceefa
Update fr.json (#877) 2022-07-15 10:11:02 +05:30
Kailash Nadh
fb3c429116 Fix Chinese i18n language code. 2022-07-14 19:34:38 +05:30
an0nfunc
08c7de148b
updated german translation (#874) 2022-07-14 18:08:54 +05:30
Chris
650e23ed0b
Update de.json (#873) 2022-07-14 18:05:07 +05:30
marcofucito
f607c0b989
Italian translations (#872)
Made several translations into Italian
2022-07-14 17:22:39 +05:30
David Regla
300fb7f5e6
Update Spanish (es.json) translations (#871) 2022-07-14 17:22:03 +05:30
Kailash Nadh
a5ce226324
Merge pull request #870 from TychoWerner/tychowerner_nlTranslations
Translated new strings
2022-07-14 13:06:28 +05:30
Tycho Werner
b5b69861ee
Translated new strings 2022-07-14 09:24:30 +02:00
Kailash Nadh
c742c91c85
Merge pull request #869 from t3hmrman/fix/i18n-jp-for-release-2.2.0
fix(i18n): update i18n/jp for 2.2.0 release
2022-07-14 11:50:05 +05:30
vados
f68af83dbe
fix(i18n): two leftover replacements 2022-07-14 15:17:57 +09:00
vados
96197b01ee
fix(i18n): JP language name tag and templated vars 2022-07-14 15:15:57 +09:00
vados
641616efb7
fix(i18n): update i18n/jp for 2.2.0 release 2022-07-14 15:02:42 +09:00
Kailash Nadh
0cd41ed9c4 Add comment explicitly stating that DB has to be created externally. Closes #830. 2022-07-14 10:52:41 +05:30
Kailash Nadh
b44d0a653a Refresh newly added i18n langauge strings. 2022-07-13 22:24:54 +05:30
Kailash Nadh
df31426566 Add button to insert HTML snippets into WYSIWYG editor. 2022-07-13 22:24:27 +05:30
Kailash Nadh
77bc8a7745 Send full media object in upload API response. Closes #770. 2022-07-13 19:48:09 +05:30
Kailash Nadh
c84837f8cb Fix '&amp' encoding in tracked URLs before saving in the DB. Closes #844. 2022-07-11 23:18:08 +05:30
Kailash Nadh
9107edf867 Add SMTP config shortcuts for popular providers in the settings UI. 2022-07-11 21:17:23 +05:30
Kailash Nadh
278d5bf74e Merge branch 'test-smtp' 2022-07-11 19:46:03 +05:30
Kailash Nadh
ee448170ef Add support for testing SMTP connections in the settings UI. 2022-07-11 19:44:44 +05:30
Kailash Nadh
e99c8ed86b Disable template type updation after creation to prevent breaking of campaign relations. 2022-07-09 10:36:12 +05:30
Kailash Nadh
4de5d53fe4 Refactor upgrade schema to remove column default. 2022-07-09 10:36:12 +05:30
Kailash Nadh
5a5caca256 Refactor campaign/template preview functions and component. 2022-07-09 10:36:12 +05:30
Kailash Nadh
2dcac57cba Fix tx template delete query. 2022-07-09 10:36:12 +05:30
Kailash Nadh
f26f7c60af Refactor template tests. 2022-07-09 10:36:12 +05:30
Kailash Nadh
bc07a459ea Fix clone tx template on the templates UI. 2022-07-09 10:36:12 +05:30
Kailash Nadh
d3774d606a Make tx DB upgrade schema consistent with install schema. 2022-07-09 10:36:12 +05:30
Kailash Nadh
0574a1b820 Fix template compilation check on CRUD. 2022-07-09 10:36:12 +05:30
Kailash Nadh
3f5a50fc0d Fix header processing in tx send. 2022-07-09 10:36:12 +05:30
Kailash Nadh
68da86aadc Fix redundant echo/http error wrapping. 2022-07-09 10:36:12 +05:30
Kailash Nadh
4a6e041ca8 Don't break boot on tx template compilation errors. 2022-07-09 10:36:12 +05:30
Kailash Nadh
463e92d1e1 Add transactional (tx) messaging capability.
This commit adds a new API `POST /api/tx` that sends an ad-hoc message
to a subscriber based on a pre-defined transactional template. This is
a large commit that adds the following:

- New campaign / tx template types on the UI. tx templates have an
  additional subject field.
- New fields `type` and `subject` to the templates table.
- Refactor template CRUD operations and models.
- Refactor template func assignment in manager.
- Add pre-compiled template caching to manager runtime.
- Pre-compile all tx templates into memory on program boot to avoid
  expensive template compilation on ad-hoc tx messages.
2022-07-09 10:36:12 +05:30
Kailash Nadh
83a0e1057e Add 'test' button to SMTP UI to test connections. 2022-07-09 10:33:50 +05:30
Kailash Nadh
13603b7141
Merge pull request #860 from p1slave/patch-1
Create zh-CN.json
2022-07-04 13:23:06 +05:30
p1slave
6dfe4a0c08
Create zh-CN.json 2022-07-02 14:36:11 -05:00
Kailash Nadh
dc7b44a7cc
Merge pull request #858 from pucilpet/fi-translation
Added Finnish translation (partial, public fields)
2022-06-29 12:50:51 +05:30
Petteri Pucilowski
c7c331ee84
Added Finnish translation (partial, public fields) 2022-06-28 15:56:09 +03:00
Kailash Nadh
c7eb491d3e
Merge pull request #847 from knadh/dependabot/npm_and_yarn/frontend/shell-quote-1.7.3
Bump shell-quote from 1.7.2 to 1.7.3 in /frontend
2022-06-27 16:24:08 +05:30
Kailash Nadh
a2d01b20da
Merge pull request #854 from joeirimpan/postback
feat(postback): Add attachment, from email to postback body
2022-06-27 16:19:47 +05:30
Joe Paul
175770d8b8 fix: Use list append instead of indexing 2022-06-27 16:14:46 +05:30
Joe Paul
a1df02b41c feat(postback): Add attachment, from email to postback body 2022-06-27 15:47:38 +05:30
dependabot[bot]
66499acbf7
Bump shell-quote from 1.7.2 to 1.7.3 in /frontend
Bumps [shell-quote](https://github.com/substack/node-shell-quote) from 1.7.2 to 1.7.3.
- [Release notes](https://github.com/substack/node-shell-quote/releases)
- [Changelog](https://github.com/substack/node-shell-quote/blob/master/CHANGELOG.md)
- [Commits](https://github.com/substack/node-shell-quote/compare/v1.7.2...1.7.3)

---
updated-dependencies:
- dependency-name: shell-quote
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-06-23 04:10:01 +00:00
Kailash Nadh
0834ab7756
Merge pull request #824 from knadh/dependabot/npm_and_yarn/frontend/eventsource-1.1.1
Bump eventsource from 1.0.7 to 1.1.1 in /frontend
2022-06-16 20:12:58 +05:30
dependabot[bot]
ffffdcf028
Bump eventsource from 1.0.7 to 1.1.1 in /frontend
Bumps [eventsource](https://github.com/EventSource/eventsource) from 1.0.7 to 1.1.1.
- [Release notes](https://github.com/EventSource/eventsource/releases)
- [Changelog](https://github.com/EventSource/eventsource/blob/master/HISTORY.md)
- [Commits](https://github.com/EventSource/eventsource/compare/v1.0.7...v1.1.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
2022-06-01 18:33:35 +00:00
Kailash Nadh
97f8c35009
Merge pull request #811 from rslonik/master
Fix pt-BR subscription form header
2022-05-12 10:06:18 +05:30
Kailash Nadh
d133cf2028
Merge pull request #808 from sjoerdvanderhoorn/patch-1
Update nl.json
2022-05-12 10:05:30 +05:30
Rafael Slonik
b2fc35a3df
Fix pt-BR subscription form header 2022-05-11 17:20:38 -03:00
Sjoerd van der Hoorn
92d49fdcc7
Update nl.json 2022-05-11 19:10:58 +02:00
Kailash Nadh
e0b01a89ef Fix UI elements not getting locked on finished campaigns. Closes #771 2022-05-11 21:53:34 +05:30
Kailash Nadh
59c9441b3b Fix subscriber create query to not ignore duplicate e-mail error.
Trying to insert a pre-existing e-mail on POST /api/subscribers
now return a 409 Conflict error.

Closes #718
2022-05-11 21:40:31 +05:30
Kailash Nadh
fe5466dfda Remove test files committed accidentaly. 2022-05-10 11:00:45 +05:30
Kailash Nadh
a3fd4616e3 Merge big refactor with the 'core' branch. 2022-05-08 19:23:57 +05:30
Kailash Nadh
b4f0c7ef31 Fix ambiguous route name in frontend route definitions. 2022-05-08 19:23:43 +05:30
Kailash Nadh
fee2ef3cc9 Upgrade axios lib.
The dependabot axios upgrade introduced an unknown bug that broke
axios calls. Upgrading to the latest version seems to fix that.
2022-05-08 19:21:32 +05:30
Kailash Nadh
959541f8ee Rename unsub query to match the core method name. 2022-05-08 14:45:45 +05:30
Kailash Nadh
9aef4f2741 Enable browser spell check in the campaign editor UI. Closes #786. 2022-05-08 14:41:54 +05:30
Kailash Nadh
19c1e51c60 Fix unsub status not showing for non-optin lists on the subscribers UI. 2022-05-08 14:39:08 +05:30
Kailash Nadh
b94da621d7 Fix broken public link redirect. 2022-05-05 18:05:13 +05:30
Kailash Nadh
d39816e5eb
Merge pull request #795 from knadh/dependabot/npm_and_yarn/frontend/axios-0.21.2
Bump axios from 0.21.1 to 0.21.2 in /frontend
2022-05-03 10:52:19 +05:30
Kailash Nadh
5fd4d7b44b Refactor paginated bounce query function to return DB total. 2022-05-03 10:50:33 +05:30
Kailash Nadh
d2ef23d3fa Refactor paginated campaign query function to return DB total. 2022-05-03 10:50:33 +05:30
Kailash Nadh
e303850584 Refactor paginated list query function to return DB total. 2022-05-03 10:50:33 +05:30
Kailash Nadh
aa19771307 Refactor bounces package to remove db/queries dependency.
Instead of passing a DB/SQL statement references, instead pass a
callback that inserts a bounce into the DB via the `core` package.
2022-05-03 10:50:33 +05:30
Kailash Nadh
b5cd9498b1 Refactore all CRUD functions to a new core package.
This is a long pending refactor. All the DB, query, CRUD, and related
logic scattered across HTTP handlers are now moved into a central
`core` package with clean, abstracted methods, decoupling HTTP
handlers from executing direct DB queries and other business logic.

eg: `core.CreateList()`, `core.GetLists()` etc.

- Remove obsolete subscriber methods.
- Move optin hook queries to core.
- Move campaign methods to `core`.
- Move all campaign methods to `core`.
- Move public page functions to `core`.
- Move all template functions to `core`.
- Move media and settings function to `core`.
- Move handler middleware functions to `core`.
- Move all bounce functions to `core`.
- Move all dashboard functions to `core`.
- Fix GetLists() not honouring type
- Fix unwrapped JSON responses.
- Clean up obsolete pre-core util function.
- Replace SQL array null check with cardinality check.
- Fix missing validations in `core` queries.
- Remove superfluous deps on internal `subimporter`.
- Add dashboard functions to `core`.
- Fix broken domain ban check.
- Fix broken subscriber check middleware.
- Remove redundant error handling.
- Remove obsolete functions.
- Remove obsolete structs.
- Remove obsolete queries and DB functions.
- Document the `core` package.
2022-05-03 10:50:29 +05:30
Kailash Nadh
12b845ef97 Fix incorrect HTTP resp code on public page. Fixes #772. 2022-05-03 10:49:25 +05:30
Kailash Nadh
89eca5f14b Changed email subject template from HTML to text. Fixes #785. 2022-05-03 10:41:46 +05:30
Kailash Nadh
75190d9854 Fix broken line in the JP language pack. 2022-05-01 14:49:30 +05:30
Kailash Nadh
a94f238952 Sanitize HTML in Buefy dialogs. 2022-05-01 12:14:01 +05:30
Kailash Nadh
d5b912aed3
Merge pull request #798 from t3hmrman/feat/add-jp-translation
feat(i18n): add japanese translation
2022-05-01 11:39:18 +05:30
vados
6252a166af
feat(i18n): add japanese translation
Signed-off-by: vados <vados@vadosware.io>
2022-05-01 14:16:36 +09:00
dependabot[bot]
f489573298
Bump axios from 0.21.1 to 0.21.2 in /frontend
Bumps [axios](https://github.com/axios/axios) from 0.21.1 to 0.21.2.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/master/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v0.21.1...v0.21.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
2022-04-30 05:50:26 +00:00
Kailash Nadh
20cbeb7f82
Merge pull request #794 from knadh/dependabot/npm_and_yarn/frontend/async-2.6.4
Bump async from 2.6.3 to 2.6.4 in /frontend
2022-04-30 11:19:59 +05:30
dependabot[bot]
8794c92a83
Bump async from 2.6.3 to 2.6.4 in /frontend
Bumps [async](https://github.com/caolan/async) from 2.6.3 to 2.6.4.
- [Release notes](https://github.com/caolan/async/releases)
- [Changelog](https://github.com/caolan/async/blob/v2.6.4/CHANGELOG.md)
- [Commits](https://github.com/caolan/async/compare/v2.6.3...v2.6.4)

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

Signed-off-by: dependabot[bot] <support@github.com>
2022-04-29 17:20:14 +00:00
Kailash Nadh
c898ec2b21
Merge pull request #790 from nathanaelhoun/patch-1
Updated French translations
2022-04-29 12:19:24 +05:30
Nathanaël Houn
1e8f8aba25
Updated some French translations 2022-04-28 18:57:24 +02:00
Kailash Nadh
06e4b77e29
Merge pull request #788 from etcware/patch-1
Update it.json
2022-04-27 08:34:28 +05:30
Alex
eb7c07b308
Update it.json
I fixed some translations.
2022-04-26 14:58:24 +02:00
Kailash Nadh
7ea523db37
Merge pull request #727 from yatish27/fix_typos_in_comments
Fix typos
2022-04-03 11:39:07 +05:30
Kailash Nadh
8c9fccb8bf
Merge pull request #762 from knadh/dependabot/npm_and_yarn/frontend/minimist-1.2.6
Bump minimist from 1.2.5 to 1.2.6 in /frontend
2022-04-03 11:38:17 +05:30
Kailash Nadh
73e4c1cf28 Fix POP mail parsing in multipart bounce e-mails.
This was originally authored by @stevesavanna in #707. This commit
contains changes and refactors that could not be pushed to the original PR.

Changes from #707

- Don't ignore bounce mails missing campaign / subscriber UUIDs. The
  original behaviour falls back to looking up subscribers by e-mail.
- Refactor repetetive header.get + regexp conditions per header into
  a simpler lookup map.
- Trim e-mail header values of `\r`.

Closes #707, #763

Co-authored-by: stevesavanna <steven@savannacorp.com>
2022-04-03 11:37:11 +05:30
dependabot[bot]
2b787025a4
Bump minimist from 1.2.5 to 1.2.6 in /frontend
Bumps [minimist](https://github.com/substack/minimist) from 1.2.5 to 1.2.6.
- [Release notes](https://github.com/substack/minimist/releases)
- [Commits](https://github.com/substack/minimist/compare/1.2.5...1.2.6)

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

Signed-off-by: dependabot[bot] <support@github.com>
2022-03-27 01:48:03 +00:00
Yatish Mehta
26483334c5 Fix typos 2022-03-20 11:17:29 -07:00
Kailash Nadh
a7145511fd
Merge pull request #726 from knadh/dependabot/npm_and_yarn/frontend/prismjs-1.27.0
Bump prismjs from 1.25.0 to 1.27.0 in /frontend
2022-03-20 11:08:16 +05:30
Kailash Nadh
61e1260144
Merge pull request #728 from knadh/dependabot/npm_and_yarn/frontend/url-parse-1.5.10
Bump url-parse from 1.5.7 to 1.5.10 in /frontend
2022-03-20 11:08:01 +05:30
Kailash Nadh
cd48262f77 Merge branch 'master' of github.com:knadh/listmonk 2022-03-20 11:06:06 +05:30
Kailash Nadh
4e6410ec17 Fix list_ids not being considered in bulk list change on the UI. Fixes #737. 2022-03-20 11:02:43 +05:30
Kailash Nadh
a7af9e3448
Merge pull request #751 from TychoWerner/patch-1
Update nl.json
2022-03-20 10:31:31 +05:30
Tycho Werner
2b0bb77a86
Update nl.json
Translates new strings and corrects some others.
2022-03-19 09:50:27 +01:00
Kailash Nadh
ef643a14a3 Add ability to export select subscriber ids.
- Add `id=[]` query param to `/api/subscribers/export` API.
- Add UI export prompt.
- Add Cypress tests.

Closes #739
2022-03-19 13:44:23 +05:30
Kailash Nadh
8db8ecfccd Upgrade Cypress. 2022-03-19 13:44:06 +05:30
Kailash Nadh
bfce146895 Hide confirmed/unconfirmed from single opt-in lists on the UI. Ref #741 2022-03-19 11:14:02 +05:30
Kailash Nadh
a7ac8ce527
Merge pull request #749 from an0nfunc/feat-tinymce-anchor
Activate anchor plugin (TinyMCE)
2022-03-17 16:47:34 +05:30
Giovanni Harting
3eca66c81b activated anchor plugin for TinyMCE editor 2022-03-17 11:44:45 +01:00
Kailash Nadh
9a0f7623b5
Merge pull request #743 from jonathandhn/master
Update fr.json
2022-03-14 09:33:42 +05:30
Jonathan Dahan
d6318f9090
Update fr.json
Offers a French version enriched with some missing terms.
2022-03-13 22:18:14 +01:00
Kailash Nadh
f9854bc54b Remove redundant status from single opt-in list subscriptions on the UI. Closes #741. 2022-03-10 19:30:28 +05:30
Kailash Nadh
8f45abec27 Remove Heroku buttons (as it has blocked listmonk without explanation). 2022-03-07 18:25:20 +05:30
Kailash Nadh
d02efee094
Merge pull request #735 from an0nfunc/patch-german-translation
German translation mostly for analytics
2022-03-03 10:38:24 +05:30
an0nfunc
6ebfb6f00f
German translation mostly for Analytics 2022-03-02 21:41:27 +01:00
Kailash Nadh
3b0c8b3b15 Fix updated settings/config init routine on settings UI. 2022-03-02 21:31:00 +05:30
Kailash Nadh
b4c716302f Don't show duration on scheduled campaigns that are finished. Closes #701. 2022-03-02 21:22:44 +05:30
Kailash Nadh
8d6e475479 Merge branch 'fix-i18n' 2022-03-02 21:00:07 +05:30
Kailash Nadh
c4f1bed517 Add missing i18n strings to dayjs. Closes #717. 2022-03-02 20:58:12 +05:30
Kailash Nadh
e87c80eee1 Refactor app init routines to load config/i18n before main app mount.
Move config/language loading outside the main Vue() instance mount
as the app dependencies require i18n before they are mounted.

This commit moves config/language loading outside the app routines
to plain API calls, which on success, initialize and mount the main
view App instance.
2022-03-01 22:23:54 +05:30
Kailash Nadh
174a48f252
Merge pull request #730 from ohyesgocool/fixtypos
Fixed Typos
2022-03-01 20:39:07 +05:30
Gokul Menon
04c4552a9c Fixed typos 2022-02-28 14:19:50 +01:00
dependabot[bot]
28a8b9600d
Bump url-parse from 1.5.7 to 1.5.10 in /frontend
Bumps [url-parse](https://github.com/unshiftio/url-parse) from 1.5.7 to 1.5.10.
- [Release notes](https://github.com/unshiftio/url-parse/releases)
- [Commits](https://github.com/unshiftio/url-parse/compare/1.5.7...1.5.10)

---
updated-dependencies:
- dependency-name: url-parse
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-28 04:50:31 +00:00
dependabot[bot]
ac8c568d39
Bump prismjs from 1.25.0 to 1.27.0 in /frontend
Bumps [prismjs](https://github.com/PrismJS/prism) from 1.25.0 to 1.27.0.
- [Release notes](https://github.com/PrismJS/prism/releases)
- [Changelog](https://github.com/PrismJS/prism/blob/master/CHANGELOG.md)
- [Commits](https://github.com/PrismJS/prism/compare/v1.25.0...v1.27.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-26 01:59:33 +00:00
Kailash Nadh
76a86fa34a Add i18n translation to document titles in the router. 2022-02-24 08:56:06 +05:30
Kailash Nadh
45878db35f Change list UI status counts to i18n plural. Ref: #717 2022-02-23 20:53:39 +05:30
Kailash Nadh
6fe36db477 Fix incorrect i18n tooltip in dashboard graph. 2022-02-23 20:50:35 +05:30
Kailash Nadh
09f97c40d7
Merge pull request #721 from knadh/dependabot/npm_and_yarn/frontend/url-parse-1.5.7
Bump url-parse from 1.5.3 to 1.5.7 in /frontend
2022-02-23 20:36:53 +05:30
Kailash Nadh
028377c7a4
Merge pull request #722 from rhnvrm/bump-simples3
feat: bump simples3 for digitalocean support
2022-02-22 13:14:48 +05:30
Rohan
5dd5cb1cc3 feat: bump simples3 for digitalocean support 2022-02-22 11:30:56 +05:30
dependabot[bot]
4835a956ec
Bump url-parse from 1.5.3 to 1.5.7 in /frontend
Bumps [url-parse](https://github.com/unshiftio/url-parse) from 1.5.3 to 1.5.7.
- [Release notes](https://github.com/unshiftio/url-parse/releases)
- [Commits](https://github.com/unshiftio/url-parse/compare/1.5.3...1.5.7)

---
updated-dependencies:
- dependency-name: url-parse
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-19 04:38:16 +00:00
Kailash Nadh
3495af7712
Merge pull request #714 from knadh/dependabot/npm_and_yarn/frontend/ajv-6.12.6
Bump ajv from 6.12.2 to 6.12.6 in /frontend
2022-02-16 20:04:25 +05:30
Kailash Nadh
caa27f30cf
Merge pull request #699 from yatish27/fix_typos
Fix typos
2022-02-16 20:04:03 +05:30
dependabot[bot]
0a6f28ae28
Bump ajv from 6.12.2 to 6.12.6 in /frontend
Bumps [ajv](https://github.com/ajv-validator/ajv) from 6.12.2 to 6.12.6.
- [Release notes](https://github.com/ajv-validator/ajv/releases)
- [Commits](https://github.com/ajv-validator/ajv/compare/v6.12.2...v6.12.6)

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

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-16 14:33:53 +00:00
Kailash Nadh
17e723a050
Merge pull request #700 from knadh/dependabot/npm_and_yarn/frontend/follow-redirects-1.14.8
Bump follow-redirects from 1.14.7 to 1.14.8 in /frontend
2022-02-16 20:03:15 +05:30
Kailash Nadh
6e45b0be90
Merge pull request #710 from m3nu/issue/705/empty-logo-url
Use empty logo_url as default
2022-02-16 19:53:06 +05:30
Kailash Nadh
632373795e
Merge pull request #713 from marcinkunert/patch-4
Updated polish translations
2022-02-16 19:49:40 +05:30
Marcin Kunert
048fbc2e56
Updated polish translations
- Added missing translations
- Fixed typos and wrong context issues
2022-02-16 11:42:20 +01:00
Manu
9ed0ae747b Use empty logo_url as default 2022-02-16 12:09:38 +04:00
Kailash Nadh
516743673a
Merge pull request #709 from mannm123/patch-2
Create vi.json
2022-02-16 11:54:43 +05:30
mannm123
fcb413f71a
Create vi.json 2022-02-16 09:53:36 +07:00
dependabot[bot]
afdaf46824
Bump follow-redirects from 1.14.7 to 1.14.8 in /frontend
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.14.7 to 1.14.8.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.14.7...v1.14.8)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-13 17:28:55 +00:00
Yatish Mehta
6c903239dd Fix typos 2022-02-13 08:54:39 -08:00
Kailash Nadh
d442de0444
Merge pull request #696 from candideu/master
Added correct link to repo for docs contribution
2022-02-13 12:48:20 +05:30
Kailash Nadh
ea6acddff9
Merge pull request #698 from yatish27/patch-2
Fix typo in manager.go
2022-02-13 12:47:04 +05:30
Yatish Mehta
dc4e3a6780
Fix typo in manager.go 2022-02-12 23:03:41 -08:00
Candide U
e623088884
Added correct link to repo for docs contribution 2022-02-12 22:29:32 -05:00
Kailash Nadh
0ecfb89f19 Remember appearance sub tab in settings UI. 2022-02-12 17:13:45 +05:30
Kailash Nadh
481d6ef0a2 Move bundled fonts to a better location. 2022-02-12 13:24:18 +05:30
Kailash Nadh
93366f4e9e Remember last chosen tab on the settings UI.
This commit adds a UI setting that was accidentally lost from an
earlier PR.

It introduces `$utils.setPref()|getPref()` to save arbitrary key/value
preferences in a JSON blob under the app's namespace in localStorage.
2022-02-06 16:12:08 +05:30
Kailash Nadh
0f6a0376da Add accurate realtime message rate counter.
The `rate` field `/api/campaigns/running/stats` returned was computed
based on the total time spent from the start of the campaign to the
current time. This meant that for large campaigns, if there were
pauses or slowdowns in between, the rate would be skewed heavily
making it useless to figure out the current send rate.

This commit introduces a realtime running rate counter in the campaign
manager that returns accurate (running) send rates for the last minute.

The `rate` field in the API now shows the live running rate and a
new `net_rate` field shows the rate from the beginning of the campaign.
2022-02-06 11:38:02 +05:30
Kailash Nadh
1b163d1895 Fix next-subscribers batch query for a ~210x speedup.
It was observed that the next-campaign-subscribers query on an instance
with ~9 million subscribers had slowed down significantly. Fetching
a batch of 5k subscribers was taking around ~25 seconds.

After multiple hours of debugging and trial and errors, it turned out
that Postgres was doing very poor query planning on JOINs with CTEs
because of the dynamic cardinality of some CTEs (even with just 1 row).
Afer rewriting the query and adding a hack to overcome the CTE
cardinality issue, the same query now takes a few milliseconds,
a speed up of several orders of magnitude.
2022-02-05 22:41:43 +05:30
Kailash Nadh
02eaa661aa Fix lists test to accommodate new UI yes/no campaign prompts. 2022-02-05 18:51:22 +05:30
Kailash Nadh
8fb459dc48 Fix custom DB type scan failing when nil. 2022-02-05 18:48:41 +05:30
Kailash Nadh
48ef3dcb14 Support status in bulk subscriber list update API. Closes #604. 2022-02-05 00:03:28 +05:30
Kailash Nadh
251c1ea64f Fix campaign start throwing error when disabling schedule on the UI.
Closes #690
2022-02-04 22:43:42 +05:30
Kailash Nadh
da30d4688e Add subscriber status counts to the lists UI.
- Change `query-lists` query to aggregate the subscriber count by
  status (confirmed, unsubscribed etc.) and expose them under a new
  `subscriber_statuses: {}` field in the `GET /lists` API.
- Display the statuses and counts in the lists table on the UI.

Closes #616
2022-02-03 00:03:31 +05:30
Kailash Nadh
182795ec10 Refactor table stats field set styles. 2022-02-03 00:00:57 +05:30
Kailash Nadh
1b017c06e0 Merge branch 'master' of github.com:knadh/listmonk 2022-02-01 23:42:36 +05:30
Kailash Nadh
2614b072f2 Refactor campaign analytics to show unique / non-unique data.
The analytics page showed non-unique counts for views and clicks which
was misleading and source of confusion: #522, #561, #571, #676, #680
This commit changes this behaviour to pull unique views and clicks when
individual subscriber tracking is turned on in settings, and non-unique
counts when it is turned off (as `subscriber_id` in `campaign_views`
and `link_clicks` will be NULL, rendering unique queries dysfunctional).

This commit changes the stats SQL queries to use string interpolation
to either to SELECT `*` or `DISTINCT subscriber_id` on app boot based
on the setting in the DB. This involves significant changes to how
queries are read and prepared on init.

- Refactor `initQueries()` to `readQueries()` and `prepareQueries()`.
- Read queries first before preparing.
- Load settings from the DB using the read settings query.
- Prepare queries next. Use the privacy setting from the DB to apply
  string interpolation to the analytics queries to pull
  unique/non-unique before preparing the queries.

On the UI:
- Show a note on the analytics page about unique/non-unique counts.
- Hide the % donut charts on the analytics page in non-unique mode.

Closes #676, closes #680
2022-02-01 23:40:03 +05:30
Kailash Nadh
1c37732b34
Merge pull request #683 from sanketsaurav/master
Fix spelling for "compatible"
2022-01-31 16:26:05 +05:30
Sanket Saurav
0d88bd89d0
Fix spelling for "compatible" 2022-01-31 15:41:34 +05:30
Kailash Nadh
d0b32b95c1 Allow unsubscribed users to re-subscribe. Closes #588 2022-01-30 23:08:39 +05:30
Kailash Nadh
d2cf6e0f14 Fix TrackLink template code to accept Go template variables. Closes #667. 2022-01-30 22:41:45 +05:30
Kailash Nadh
9551f548ad Merge branch 'master' of github.com:knadh/listmonk 2022-01-30 21:43:36 +05:30
Kailash Nadh
636db204fc Fix editor HTML beautification in incorrectly adding breaks to links.
Closes #655
2022-01-30 21:43:23 +05:30
Kailash Nadh
f46ab23207
Merge pull request #679 from marcinkunert/patch-3
Added end of line config for git
2022-01-29 16:28:20 +05:30
Marcin Kunert
3b1614b0dc
Added end of line config for git
Eslint complains on Windows about linebreaks not being LF, because they are the default CRLF on Windows. This change allows git to clone it with the expected EOL
2022-01-26 09:50:06 +01:00
Kailash Nadh
6a5ed43275
Change Heroku button repo. 2022-01-21 09:25:23 +05:30
Kailash Nadh
5c2005d5d2
Merge pull request #668 from avanier/upkeep/spiffy-up-docker-dev-stack
Spiffy up containerized dev stack
2022-01-20 12:42:22 +05:30
Alexis Vanier
c7c04c561f Provide a default configuration file for containerized development 2022-01-19 09:30:23 -05:00
Alexis Vanier
5a6b338766 Use --idempotent and --yes flags when bootstrapping the dev db 2022-01-19 09:29:47 -05:00
Alexis Vanier
4ecd044788 Spiffy up the continerized dev README
- Fold lines at 80 chars
- Add more instructions to help bootstrap the stack
2022-01-19 09:29:04 -05:00
Kailash Nadh
64d2c5aeb9 Add support for custom public S3 URLs. Closes #505. 2022-01-15 21:20:32 +05:30
Kailash Nadh
7955a4fa27 Fix media upload S3 IAM init blocking outside non-AWS environments.
- Update `simples3` to a version that supports IAM timeout.
- On IAM error, fall back to key/secret mode (although with empty creds)
  so that the app still starts.
2022-01-15 20:45:17 +05:30
Kailash Nadh
4ddd3e803f Add 'View in browser' link to the default email template. Closes #540. 2022-01-15 17:25:45 +05:30
Kailash Nadh
c6d5d862e2 Warn of unsaved changes on the campaign editor on navigation. Closes #551. 2022-01-15 17:20:22 +05:30
Kailash Nadh
4c09cc1fc3 Auto-focus TinyMCE editor area on load. 2022-01-15 16:46:38 +05:30
Kailash Nadh
3f026090ca Add unsubscribe link to opt-in confirmation e-mail. Closes #573. 2022-01-15 16:41:48 +05:30
Kailash Nadh
28efe27cbe Merge branch 'master' of github.com:knadh/listmonk 2022-01-15 15:55:52 +05:30
Kailash Nadh
04ea18c87d Refactor opt-in confirmation behaviour in subscriber update API.
- Updating a subscriber no longer triggers an opt-in confirmation mail
  as `POST /api/subscribers/:id/optin` allows that.
- A "Send opt-in confirmation" option is added to the subscriber
  update UI.

Closes #656.
2022-01-15 15:50:13 +05:30
Kailash Nadh
740373d9d8
Merge pull request #664 from nikochiko/spaces-in-filenames
Fix #652: Replace whitespace with dash in names of uploaded files
2022-01-15 15:12:15 +05:30
Kaustubh Maske Patil
be1d048e7b Replace whitespace with dash in names of uploaded files 2022-01-15 13:23:58 +05:30
Kailash Nadh
c95427e299 Merge branch 'master' of github.com:knadh/listmonk 2022-01-15 12:59:35 +05:30
Kailash Nadh
a2458cf668
Merge pull request #663 from knadh/dependabot/npm_and_yarn/frontend/follow-redirects-1.14.7
Bump follow-redirects from 1.13.1 to 1.14.7 in /frontend
2022-01-15 11:38:12 +05:30
dependabot[bot]
a314eb5885
Bump follow-redirects from 1.13.1 to 1.14.7 in /frontend
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.13.1 to 1.14.7.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.13.1...v1.14.7)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-01-14 14:12:19 +00:00
Kailash Nadh
e62dc24576 Re-order SMTP auth protocols in the settings UI by popularity. 2022-01-06 23:14:36 +05:30
Kailash Nadh
2f56057fc3 Close burger 'menu' when clicking items in mobile view. 2022-01-05 20:20:55 +05:30
Kailash Nadh
b0787f7331
Merge pull request #649 from joeirimpan/fix/msgr-persist
fix(frontend): Persist messenger selection
2022-01-05 11:52:36 +05:30
Joe Paul
4c48c3240b fix(frontend): Persist messenger selection 2022-01-05 11:41:29 +05:30
Kailash Nadh
e200ab0dab Add support for additional POP3 mail charsets. Closes #644. 2022-01-04 22:46:42 +05:30
Kailash Nadh
f266f55981 Tidy go.mod 2022-01-04 22:34:51 +05:30
Kailash Nadh
e1d3dd4a65 Merge branch 'master' of github.com:knadh/listmonk 2022-01-04 22:33:58 +05:30
Kailash Nadh
d8ed40422e Make tls_enabled key migratin idempotent. 2022-01-04 22:30:43 +05:30
Kailash Nadh
583dab4bc6 Add support for per-campaign custom headers.
- Add new `headers[]` column to the campain table.
- Add new headers box to the campaign UI that takes a JSON array of
  custom headers like the headers on the SMTP settings UI.
- Headers are added to e-mails and messenger postback webhooks.
- Add cypress tests.

Closes #514.
2022-01-04 22:27:40 +05:30
Kailash Nadh
9e9ea0ef15 Refactor automatic camel casing of API response fields.
- Remove `humps` lib dependency with a new util function.
- Replace hacky way of excluding certain fields in responses with
  a proper key filtering mechanism.
2022-01-04 22:17:05 +05:30
Kailash Nadh
d42c676503
Merge pull request #646 from ldidry/add-autoheadingid-option-to-markdown-parser
Add AutoHeadingID option to Markdown parser
2022-01-04 19:02:34 +05:30
Luc Didry
73e6668d20
Add AutoHeadingID option to Markdown parser 2022-01-04 09:36:14 +01:00
Kailash Nadh
dd061f56d4 Add support for direct SSL/TLS (non-STARTTLS) SMTP connections.
- Add support for TLS in `smtppool` (v0.4.0) and upgrade the lib.
- Change `tls_enabled: bool` in the settings table to string
  `tls_type: STARTTLS|TLS|none` and on the settings UI.
- Add DB migrations and schema changes to apply the field change.

Closes #504.
2022-01-03 19:28:36 +05:30
Kailash Nadh
e46a5cdf78
Merge pull request #640 from rhnvrm/feat-s3-put
feat: switch from s3 POST to s3 put
2021-12-29 17:24:21 +05:30
Rohan
c003aec9c9 feat: switch from s3 POST to s3 put
This commit switches simples3 from issuing a POST to a PUT.

Ref #617
2021-12-29 15:13:23 +05:30
Kailash Nadh
d523d0a114
Merge pull request #639 from mr-karan/tz
feat: Add timezone config in app container
2021-12-29 11:53:48 +05:30
Karan Sharma
e4d8286535 feat: Add timezone config in app container
Adds `tzdata` in the `Dockerfile` of the app so that
user can pass a `TZ` env variable to the container to configure
their timezone information.
2021-12-29 11:42:04 +05:30
Kailash Nadh
b48a15cfa3 Fix incorrect 'nice date' formatting. Closes #635. 2021-12-27 18:55:13 +05:30
Kailash Nadh
e982e6bb25 Don't warn on format change when campaign content is empty. Closes #634. 2021-12-27 18:41:25 +05:30
Kailash Nadh
c1c2b67503 Add a link to more language packs to the language settings UI. 2021-12-18 21:50:17 +05:30
Kailash Nadh
fabe06e339 Add support for custom CSS/JS in settings for admin and public pages.
This feature was originally authored by @sweetppro in PR #438.
However, since the PR ended up in an unclean state with
multiple master merges (instead of rebase) from the upstream, there are
several commits that are out of order and can can no longer be be
squashed for a clean feature merge.

This commit aggregates the changes from the original PR and applies the
following fixes on top of it.

- Add custom admin JS box to appearance UI.
- Refactor i18n language strings.
- Add handlers and migrations for the new `appearance.admin.custom_js`
  field.
- Fix migration version to `v2.1.0`
- Load custom appearance CSS/JS bytes into global constants during boot
  instead of making a DB call on every request.
- Fix and canonicalize URIs from `/api/custom*` to `/public/*.css`
  and `/admin/*.css`. Add proxy paths to yarn proxy config.
- Remove redundant HTTP handlers for different custom appearance files
  and refactor into a single handler `serveCustomApperance()`
- Fix content-type and UTF8 encoding headers for different file types.
- Fix incorrect registration of public facing custom CSS/JS handlers
  in the authenticated admin URI group.
- Fix merge conflicts in `Settings.vue`.
- Minor HTML and style fixes.
- Remove the `AppearanceEditor` component and use the existing
  `HTMLEditor` component instead.
- Add `language` prop to the `HTMLEditor` component.

Co-authored-by: SweetPPro <sweetppro@users.noreply.github.com>
2021-12-18 15:38:42 +05:30
Kailash Nadh
920645f90e Fix typo in Makefile. 2021-12-18 13:14:14 +05:30
Kailash Nadh
13edf426a6
Merge pull request #625 from mr-karan/master
fix(install-prod.sh): Make `tr` work with macOS
2021-12-15 16:20:33 +05:30
Karan Sharma
c9189a12d1
fix(install-prod.sh): Make tr work with macOS
Fixes https://github.com/knadh/listmonk/issues/624

Source https://unix.stackexchange.com/questions/230673/how-to-generate-a-random-string#comment393964_230684
2021-12-15 14:44:00 +05:30
Kailash Nadh
ca128df49a Add support for searching lists + search UI. Closes #618. 2021-12-09 21:34:38 +05:30
Kailash Nadh
e9709e54ee Upgrade labstack/echo webserver to major version v4.
- echo is now on v4 with major changes including a few breaking changes
- bind() behaviour is now strict. JSON / form etc. unmarshalling of
  request data need appropriate `json`, `form` tags. Missing tags for
  the public subscription page is added in this commit.
- This also closes #602.
2021-12-09 20:51:07 +05:30
Kailash Nadh
02c1408f5c Fix broken Cypress UI tests. 2021-12-09 20:47:52 +05:30
Kailash Nadh
4cb5eb782f Fix settings form input validation.
- Fix settings UI form submit button.
- Validate upload URI. Closes #621.
2021-12-09 19:30:15 +05:30
Kailash Nadh
e9dded774a
Merge pull request #608 from mr-karan/dev_docker
feat: Add dev docker setup
2021-12-05 12:37:30 +05:30
Karan Sharma
e977b901e1 feat: Add dev docker setup 2021-12-01 23:17:14 +05:30
Kailash Nadh
e6c1f1e4bd
Merge pull request #605 from Jjagg/i18n-nl
Add Dutch (nl) translation
2021-11-30 09:44:55 +05:30
Jesse Gielen
1c8ab5c721
Add dutch (nl) translation 2021-11-29 17:53:02 +01:00
Kailash Nadh
3386de40c7 Fix GET /subscribers calls not accepting multiple list_ids.
Closes #585
2021-11-29 20:38:57 +05:30
Kailash Nadh
d32c11a595
Merge pull request #603 from NicoHood/patch-2
Fix #601 german translation
2021-11-29 13:12:13 +05:30
NicoHood
8a70595aff
Fix #601 german translation 2021-11-29 08:40:23 +01:00
natekfl
575d007575
Fix alert email urls (#595) 2021-11-19 21:05:46 +05:30
bohemtucsok
903330b972
Create hu.json (#591) 2021-11-16 19:10:28 +05:30
Kailash Nadh
a7fa97a214 Add scanning of full bounce email body for bounce headers. Closes #492. 2021-11-10 20:47:55 +05:30
Kailash Nadh
c8c135e31f Fix broken test mail due to missing tpl param. 2021-11-10 20:47:55 +05:30
SweetPPro
35ac1ccdf5
Embed Inter font files and remove Google font links. Closes #547.
Only Woff2 fonts have been added, from here (utliising all charsets):
https://google-webfonts-helper.herokuapp.com/fonts/inter?subsets=cyrillic,cyrillic-ext,greek,greek-ext,latin,latin-ext,vietnamese
2021-11-10 20:43:42 +05:30
SweetPPro
46f13bf9cd
Fix broken logout link in desktop nav view (#580) 2021-11-10 20:31:34 +05:30
MickGe
2388a05003
Update fr.json (#581) 2021-11-10 20:20:48 +05:30
SweetPPro
7b9ba2efbc
improved mobile navbar/sidebar (#574)
* improved mobile navbar/sidebar

Sidebar is hidden and all menu items moved to hamburger menu on mobile devices

* improvements to menu rendering

-removed redundant code
-fixed an issue with emitting data to App.vue

* Update Navigation.vue

fixed linting errors

* Add minor refactors to the mobile menu PR.

- Fix indentation and line lengths.
- Simplify prop definitions in the Navigation component.
- Remove redundant computed methods and use prop variables directly in
  the Navigation compontent.
- Simplify menu rendering logic by:
  removing isSidebar, showLogout and using simpler v-if / else
  in the parent instead of the Navigation component.

* Update App.vue

removed orphaned isSideBar Boolean

Co-authored-by: Kailash Nadh <kailash@nadh.in>
2021-11-10 00:26:34 +05:30
Kailash Nadh
125d51f7bf
Merge pull request #576 from MickGe/patch-1
Clear placeholder on focus
2021-11-09 22:47:38 +05:30
Kailash Nadh
a2c885b8bc Add a note on Postgres min version. 2021-11-09 22:43:44 +05:30
MickGe
ebf6af2f81
Clear placeholder on focus
Because it could be disturbing for people who are not confident with forms not being able to delete words before writing.
2021-11-09 00:20:30 +01:00
Kailash Nadh
19e0ea5f6c Fix scheduling params being ignored on the create campaign UI. Fixes #516. 2021-11-07 09:17:54 +05:30
Kailash Nadh
0bd13fe541 Fix response content type in plaintext campaign previews. Closes #568 2021-11-05 09:21:39 +05:30
Kailash Nadh
d00a1a1e84 Merge branch 'master' of github.com:knadh/listmonk 2021-11-05 09:15:49 +05:30
Kailash Nadh
738c8e95ee
Merge pull request #569 from ChrisTG742/patch-2
German translation needed for #526
2021-11-05 08:53:21 +05:30
Kailash Nadh
34915f176b
Merge pull request #570 from jorge-vitrubio/patch-1
Updated es.json
2021-11-05 08:52:43 +05:30
Jorge - vitrubio
58bd242b5b
Updated es.json
Updates in non translated strings and corrected some typos. Modified some translations.
2021-11-05 00:40:15 +01:00
ChrisTG742
bfefb0ff27
German translation needed for #526
Adds missing german translation for #526
2021-11-04 09:40:54 +01:00
Kailash Nadh
88d0c77f3b
Merge pull request #567 from knadh/dependabot/npm_and_yarn/frontend/tinymce-5.10.0
Bump tinymce from 5.9.2 to 5.10.0 in /frontend
2021-11-03 09:23:16 +05:30
dependabot[bot]
2819ca86cb
Bump tinymce from 5.9.2 to 5.10.0 in /frontend
Bumps [tinymce](https://github.com/tinymce/tinymce/tree/HEAD/modules/tinymce) from 5.9.2 to 5.10.0.
- [Release notes](https://github.com/tinymce/tinymce/releases)
- [Changelog](https://github.com/tinymce/tinymce/blob/develop/modules/tinymce/CHANGELOG.md)
- [Commits](https://github.com/tinymce/tinymce/commits/5.10.0/modules/tinymce)

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

Signed-off-by: dependabot[bot] <support@github.com>
2021-11-02 15:53:03 +00:00
Kailash Nadh
1ece738613 Fix incorrect container width on public page responsive view. 2021-10-31 11:57:00 +05:30
Kailash Nadh
5bfbe15c24 Fix campaign template preview not working without saving. Closes #553. 2021-10-31 11:49:43 +05:30
Kailash Nadh
644f98fe60 Fix typo 2021-10-30 16:50:41 +05:30
Kailash Nadh
ef4de09a8a Add contribution and participation guidelines and code of conduct. 2021-10-30 16:12:53 +05:30
Kailash Nadh
1054c019ce Hide 'Back' button when it is superfluous on public pages. 2021-10-30 12:46:10 +05:30
Kailash Nadh
11010393d8 Add "passive" mode with --passive flag.
Every listmonk instance scans the DB periodically to look for
running campaigns to process. This made running multiple instances of
listmonk impractical as they would all pick up the same running
campaign and process them, resulting in duplicate e-mails.

This commit adds a `--passive` flag to the binary that runs listmonk
in a "passive" mode where campaign processing is disabled. This allows
multiple instances of listmonk to be run to handle different kinds of
requests if there is a requirement (scale/traffic?). It is important
to note that there should only be one non-passive instance running at
any given time. If distributed campaign processing is ever considered,
this will change.
2021-10-29 14:40:22 +05:30
Kailash Nadh
9dd8592fdd Prevent images from being squished in the default e-mail template. Closes #548. 2021-10-28 22:55:12 +05:30
Kailash Nadh
f39ee4e783 Fix TinyMce campaign editor toolbar to the top on scroll. Closes #549. 2021-10-28 22:21:44 +05:30
Kailash Nadh
b290d271c0 Add support for plaintext system e-mail templates.
If `<!doctype html>` is not found in static/email-templates/base.html,
all system e-mail templates are assumed to be plaintext and go out
as content-type: plaintext e-mails. With this, all HTML tags can
be stripped out of the system e-mail templates (while maintaining
Go template tags and logic) to have plaintext system e-mail templates.

Closes #546
2021-10-28 20:09:06 +05:30
Kailash Nadh
1c8ac0f866 Add 'preconfirm subscription' option to subscriber UI. Closes #526. 2021-10-27 20:46:37 +05:30
Kailash Nadh
76cd4d382a Merge branch 'master' of github.com:knadh/listmonk 2021-10-27 20:10:42 +05:30
Kailash Nadh
ed8d68bd54 Add anti-bot nonce field to generated forms. Closes #541. 2021-10-27 20:10:29 +05:30
Kailash Nadh
151b86acc0
Merge pull request #538 from NicoHood/patch-1
Add german back button translation
2021-10-25 12:02:59 +05:30
NicoHood
fb3da6bf3d
Add german back button translation 2021-10-24 18:27:15 +02:00
Kailash Nadh
b163b1305b Add a "Back" button the public subscription/message page.
On a successful form submission, error message etc., check if there's
browser history and render a button that takes the user back to
a meaningful origin from the dead-end message page.

Closes #527.
2021-10-23 11:39:39 +05:30
Kailash Nadh
bc9252f410 Autogenerate subscriber name from e-mail on the UI if it's empty. Closes #525. 2021-10-20 21:54:22 +05:30
Kailash Nadh
0f896c1b6e Fix email field in generated form HTML. Closes #529. 2021-10-20 21:38:50 +05:30
Kailash Nadh
ca51c48474 Fix duplicate class attr in optin e-mail. Closes #524. 2021-10-19 20:10:40 +05:30
Kailash Nadh
f91b27dc8f
Merge pull request #518 from mr-karan/script_fix
fix: Add a check for existing docker db volume
2021-10-18 10:18:30 +05:30
Karan Sharma
6cd7d669c1 fix: Add a check for existing docker db volume
Fixes https://github.com/knadh/listmonk/issues/517
2021-10-18 10:08:02 +05:30
Kailash Nadh
3064844c6e Merge branch 'master' of github.com:knadh/listmonk 2021-10-16 12:44:17 +05:30
Kailash Nadh
a01759756e Fix strings on the UI missed in i18n translation. Closes #506. 2021-10-16 12:43:03 +05:30
Kailash Nadh
469f392235
Merge pull request #507 from marcinkunert/patch-2
Updated polish translations
2021-10-11 17:01:26 +05:30
Marcin Kunert
d6703f2da7
Updated polish translations 2021-10-11 10:24:02 +02:00
Kailash Nadh
823f11ef63 Remove redundant GitHub issue template. 2021-10-10 17:46:05 +05:30
Kailash Nadh
b46ab6d3a9 Fallback to default S3 URL on empty media upload URL in settings. 2021-10-04 22:20:24 +05:30
Kailash Nadh
d91d6e5ce3 Merge branch 'go-deps' 2021-10-03 13:30:39 +05:30
Kailash Nadh
6053b0948b Merge branch 'master' of github.com:knadh/listmonk 2021-10-02 17:12:44 +05:30
Kailash Nadh
7aa850824c Add explicit public-read ACL to public S3 uploads. Closes #496. 2021-10-02 17:12:31 +05:30
Kailash Nadh
4ec4a1bb5a
Merge pull request #494 from marcofucito/master
Fixed some Italian translations
2021-09-30 09:29:23 +05:30
marco.fucito
7015c047d1 Italian translation
Fixed some translations.
Dashboards, logs, newsletters and privacy are also commonly used in Italian.
2021-09-29 22:37:44 +02:00
Kailash Nadh
443ba184c3
Merge pull request #491 from citrus-it/makedep
pack-bin is missing dependency on build-frontend
2021-09-29 19:15:23 +05:30
Andy Fiddaman
ecc35164b3 pack-bin is missing dependency on build-frontend
When building on a system with enough cores, there is a race condition where
make runs pack-bin before build-frontend is complete.

Running: /usr/bin/gmake -j 60 dist
go install github.com/knadh/stuffbin/...
CGO_ENABLED=0 go build -o listmonk -ldflags="-s -w -X 'main.buildString=v2.0.0 (#05585b7 2021-09-29T08:59:00+0000)' -X 'main.versionString=v2.0.0'" cmd/*.go
cd frontend && /data/omnios-build/omniosorg/r151038/_extra/listmonk-2.0.0/listmonk-2.0.0/listmonk/_deps/node_modules/yarn/bin/yarn install
yarn install v1.22.11
[1/4] Resolving packages...
[2/4] Fetching packages...
/data/omnios-build/omniosorg/r151038/_extra/listmonk-2.0.0/listmonk-2.0.0/listmonk/_deps/bin/stuffbin -a stuff -in listmonk -out listmonk config.toml.sample schema.sql queries.sql static/public:/public static/email-templates frontend/dist:/admin i18n:/i18n
stuffing failed: stat frontend/dist: no such file or directory
gmake: *** [Makefile:76: pack-bin] Error 1
gmake: *** Waiting for unfinished jobs....
2021-09-29 09:17:21 +00:00
Kailash Nadh
0d8c0366d3
Merge pull request #490 from citrus-it/touch
Use POSIX standard -c flag for "touch"
2021-09-29 14:35:15 +05:30
Andy Fiddaman
ac69f6c16e Use POSIX standard -c flag for "touch"
non-GNU systems like FreeBSD and illumos do not understand the long
"--no-create" flag to touch. POSIX defines that conforming implementations
must understand "-c" for this, so use the flag that is widely understood
(including by GNU touch).

    https://pubs.opengroup.org/onlinepubs/9699919799/utilities/touch.html
2021-09-29 08:51:09 +00:00
Kailash Nadh
d0f1a2700b Update Go deps. 2021-09-29 00:07:26 +05:30
Kailash Nadh
b45baaa421
Merge pull request #485 from tachyons/patch-1
Fix typo
2021-09-28 18:55:05 +05:30
Aboobacker MK
30dbe88560
Fix typo 2021-09-28 18:52:34 +05:30
Kailash Nadh
05585b701b Fix build step in GitHub actions. 2021-09-27 23:26:56 +05:30
Kailash Nadh
bf2703bc60 Fix status tag flashing on campaign edit UI load. 2021-09-27 23:20:36 +05:30
Kailash Nadh
93c7c8727c Replace TinyMCE source editor with Flask HTML editor. 2021-09-27 23:11:19 +05:30
Kailash Nadh
cd639e89c4 Add link to bounces docs in settings UI. 2021-09-27 21:20:17 +05:30
Kailash Nadh
60badb2198 Update README to reflect v2.0.0 changes. 2021-09-27 21:18:01 +05:30
Kailash Nadh
f0b033b889 Add missing home template. 2021-09-27 21:17:23 +05:30
Kailash Nadh
1f31218639 Add a 404 page to the admin UI. 2021-09-27 20:53:30 +05:30
Kailash Nadh
0db6f0c866 Bump Postgres version to 13 in example Docker setup. 2021-09-27 20:38:24 +05:30
Kailash Nadh
30f9f030cd Replace TinyMCE UI pt font sizes with px. 2021-09-27 17:36:02 +05:30
Kailash Nadh
98ed4fb384 Add a landing login page and a logout option.
BasicAuth without an explicit landing page or a logout option has
sometimes been confusing to users. This commit adds a static
landing page on / with a login link and a logout option in the admin
that "logs out" BasicAuth session by posting invalid credentials to
the server to obtain a 401.
2021-09-26 23:42:57 +05:30
Kailash Nadh
9d2bc9c41d Add HTML syntax highlighted editing to the template editor.
- Refactor codeflask HTML editor into a standalone html-editor
  component.
- Replace the plaintext box in the template editor with html-editor.
- Replace codeflask in the campaign editor with the new html-editor.
- Refactor templates Cypress tests to test the new editor.
- Refactor campaigns Cypress tests to test the new editor and also
  test switching between different editors and content formats.
2021-09-26 21:56:53 +05:30
Kailash Nadh
a1a9f3ac6a Fix incorrect i18n variable in notification e-mail. 2021-09-26 20:13:04 +05:30
Kailash Nadh
3ffd88f0df Remove obsolete bounce routines from manager package. 2021-09-26 18:57:25 +05:30
Kailash Nadh
4056187fec Add sane defaults to POST creation APIs. 2021-09-26 16:43:10 +05:30
Kailash Nadh
f6cd24d6c9 Fix TinyMCE modal styles and overlapping issues. 2021-09-26 16:29:27 +05:30
Kailash Nadh
d86438bde9 Introduce @TrackLink shorthand for generating tracking links.
The default `{{ TrackLink "https://listmonk.app" }}` template function
is clumsy to write and does breaks WYSIWYG editors and HTML syntax
highlighting because of the quotes. The new syntax doesn't break HTML
and is easier to write.

Eg: `<a href="https://listmonk.app@TrackLink">Link</a>`

- Introduce @TrackLink shorthand.
- Add first-class support for tracking links in the WYSIWYG (TinyMCE)
  editor by introducing an on/off checkbox on the link dialog.
- Improve default dummy campaign content to highlight this.
2021-09-26 16:03:05 +05:30
Kailash Nadh
d3f543cb15 Fix issues with Buefy responsive styles.
- Fix button and input sizing and alignments.
- Make settings tabs responsive.
- Fix toast and modal overlay issues.
- Fix Buefy table top-left/right controls.
- Fix 'New' buttons across pages.
- Fix search and bulk-select controls on subscribers page.
2021-09-26 13:12:12 +05:30
Kailash Nadh
e0bf1f1b77 Fix broken Cypress tests.
- DOM / UI / JS spaghetti state management is just ...
2021-09-25 18:05:15 +05:30
Kailash Nadh
492efe1ffa Bump Go compiler to v1.17 in GitHub actions. 2021-09-25 16:03:18 +05:30
Kailash Nadh
4dbac141f2 Add Romanian i18n language pack contributed by @gabrielpioaru.
- Also add TinyMCE Romaninan language pack.

Closes #482.
2021-09-25 15:53:24 +05:30
Kailash Nadh
7aee36eab1 Add support for blocklisting e-mail domains.
E-mails in the domain blocklist are disallowed on the admin UI, public
subscription forms, API, and in the bulk importer.

- Add blocklist setting that takes a list of multi-line domains on the
  Settings -> Privacy UI.
- Refactor e-mail validation in subimporter to add blocklist checking
  centrally.
- Add Cypress testr testing domain blocklist behaviour on admin
  and non-admin views.

Closes #336.
2021-09-25 15:39:09 +05:30
Kailash Nadh
9f3eb7e4a4 Fix Cypress tests to accommodate new admin UI URI. 2021-09-25 12:44:09 +05:30
Kailash Nadh
9f8e9c018b Fix subscriber form UI to have a default status value. 2021-09-25 10:45:04 +05:30
Kailash Nadh
e71115db26 Add option to toggle sending opt-in confirmation. Closes #363. 2021-09-25 10:38:13 +05:30
Kailash Nadh
51da1a16a0 Add check to skip admin notifications with no e-mails. Closes #300. 2021-09-24 19:28:32 +05:30
Kailash Nadh
c2a3f7d9d7
Merge pull request #472 from henk23/feature/replace-quill-with-tinymce
Replace Quill editor with TinyMCE
2021-09-23 20:08:07 +05:30
Kailash Nadh
68512d2dcd Add i18n support to TinyMCE.
- Load bundled TinyMCE i18n language file based on a
  listmonk -> TinyMCE map.
- Refactor editor initialisation to accommodate this change.
- Introduce `constants.js -> uris.static` to make the static URI
  available to TinyMCE for loading language files.
2021-09-23 20:04:48 +05:30
Kailash Nadh
0dc9e78710 Refactor HTML formatting and indentation in richtext -> HTML on UI. 2021-09-23 19:27:53 +05:30
Kailash Nadh
b6f68b8786 Tweak editor page and box styles. 2021-09-23 19:27:53 +05:30
Kailash Nadh
ffcb9879c8 Fix incorrect init and change events on TinyMCE.
- Moved the init event to init_instance_callback() from
@init event which doesn't fire.
- Add watcher for form.body to fire onEditorChange event. This
  fixes TinyMCE editor changes not getting saved.
2021-09-23 19:27:53 +05:30
Kailash Nadh
a0addc7edc Clean up syntax, toolbar, and editor styles. 2021-09-23 19:27:53 +05:30
Heiko Salmon
1e4f97425f Make media selection work and add more plugins and tools 2021-09-23 19:27:52 +05:30
Heiko Salmon
c140578c65 Put TinyMce init options into variable, add some TODOs 2021-09-23 19:27:52 +05:30
Heiko Salmon
4afe4a7cea Re-add changes from master, that got lost by accident 2021-09-23 19:27:52 +05:30
Heiko Salmon
71fc73fa33 Fix long line issue in dist build 2021-09-23 19:27:52 +05:30
Heiko Salmon
c09d2fcd5d Replace Quill editor with TinyMCE 2021-09-23 19:27:52 +05:30
Kailash Nadh
a97d81a8dc Merge branch 'refactor-frontend-path' 2021-09-23 19:26:45 +05:30
Kailash Nadh
6904b1f3d0 Remove redundant clause from the Makefile. 2021-09-23 19:26:38 +05:30
Kailash Nadh
bb340b8785 Refactor frontend build and name space all admin URIs behind /admin/.
- Namespace all admin UI URLs behind `/admin/*`.
  This breaks the current admin UI URLs.
- Make Vue output build assets to `frontend/dist/*` instead of
  `frontend/dist/frontend`.
- Namespace Vue static assets to `/admin/static/*`.

This commit reduces the cofusing and convoluted Vue+WebPack build URI
and static path schemes. In addition, it removes ambiguity in URLs
where non-UI URLs like `/public`, `/api`, `/webhooks` etc. were in the
same name space as UI URLs like `/campaigns`, `/lists` etc. Now all UI
URLs are behind `/admin/`, also simplifying security rules for proxies.
2021-09-23 19:21:35 +05:30
Kailash Nadh
855d440d5b
Merge pull request #477 from aiac/patch-1
CD to directory before install script
2021-09-22 10:34:25 +05:30
aiac
0ebf4949de
CD to directory before install script
Change directory to newly created before running install script
2021-09-21 20:33:37 +02:00
Kailash Nadh
13f16486fb
Merge pull request #476 from knadh/dependabot/npm_and_yarn/frontend/prismjs-1.25.0
Bump prismjs from 1.24.0 to 1.25.0 in /frontend
2021-09-21 22:11:08 +05:30
dependabot[bot]
7d4bac687e
Bump prismjs from 1.24.0 to 1.25.0 in /frontend
Bumps [prismjs](https://github.com/PrismJS/prism) from 1.24.0 to 1.25.0.
- [Release notes](https://github.com/PrismJS/prism/releases)
- [Changelog](https://github.com/PrismJS/prism/blob/master/CHANGELOG.md)
- [Commits](https://github.com/PrismJS/prism/compare/v1.24.0...v1.25.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2021-09-20 21:58:49 +00:00
Kailash Nadh
956e990fe6 Improve codeflask HTML syntax highlighting colours. 2021-09-19 17:11:48 +05:30
Kailash Nadh
4b13f0c74f Add public URIs to yarn dev proxy. 2021-09-19 17:10:40 +05:30
Kailash Nadh
9f9425c408 Refresh i18n files. 2021-09-19 15:05:15 +05:30
Kailash Nadh
4b127f1eda Merge branch 'campaign-analytics' 2021-09-19 15:02:32 +05:30
Kailash Nadh
623030a0c5 Replace go get with go install stuffbin (Go 1.17 deprecation). 2021-09-19 15:02:29 +05:30
Kailash Nadh
6a316979f8 Fix inconsistent non-ii18n tag and label displays. 2021-09-19 14:51:42 +05:30
Kailash Nadh
2ed54b8609 Fix Buefy UI modal breaking body and sidebar scroll. 2021-09-19 14:26:37 +05:30
Kailash Nadh
71fd71d18c Refactor individual subscriber edit view.
- Add route /lists/:id URI to load subscriber edit modal.
- Make list name open the edit popup to be consistent with all other
  table views.
- Refactor get-lists query to make single list look up faster.
2021-09-19 13:27:14 +05:30
Kailash Nadh
070472c12d Add missing speedometer Fontello icon to the campaigns UI. 2021-09-19 13:03:13 +05:30
Kailash Nadh
d19728c533 Make container size uniform on multiple views on the UI. 2021-09-19 12:56:46 +05:30
Kailash Nadh
6f2aa1a318 Fix and refactor list selector UI component.
- Refactor font-size tag colours and dropdown padding.
- Fixed oninput list filter that wasn't working.
2021-09-19 12:49:22 +05:30
Kailash Nadh
1df827c58a Fix automatic field camel casing for subscriber attribs 2021-09-19 12:39:00 +05:30
Kailash Nadh
4e5e466b03 Add a ?minimal mode to GET /lists API.
Passing `?minimal=true` to the /lists API returns all lists without
additional metadata (subscriber count) which is orders of magnitude
faster than counting subscribers per list in large DBs.

The frontend intitialization always calls the GET /lists API on load
to keep it available in multiple contexts like the new campaign page.
However, this "boot up" call does not need additional metdata. This
initialization GET /lists call now calls /lists?minimal=true.
2021-09-18 20:15:24 +05:30
Kailash Nadh
f86a64787d Add Intl formatting to large numbers on the UI. 2021-09-18 19:24:25 +05:30
Kailash Nadh
56629ccb1c Fix lists pagination breaking on the UI. 2021-09-18 19:14:58 +05:30
Kailash Nadh
f1fbcd473e Fix automatic camel casing of subscriber attribs on the UI. 2021-09-18 17:26:09 +05:30
Kailash Nadh
8733b205a0 Refactor SQL schema and queries for performance improvements.
- Add indexes.
- Refactor dashboard charts and view/click count queries.
  (~10x speed bump on a setup of 7mn subscribers and 80mn views)
- Refactor get subscriber queries.
  (~10x speed bump on 7mn subscribers)
- Make subscriber UI issue an equality query for email seach strings.
2021-09-18 17:25:08 +05:30
Kailash Nadh
6eb589444a Fix 'Analytics' menu item not getting highlighted. 2021-09-17 22:20:56 +05:30
Kailash Nadh
1bb630cf83 Fix Buefy taginput padding 2021-09-17 22:19:18 +05:30
Kailash Nadh
54f1b55006 Merge branch 'analytics-migration' into campaign-analytics 2021-09-17 21:43:59 +05:30
Kailash Nadh
61e88681ed Add campaign analytics APIs and UI 2021-09-17 21:41:25 +05:30
Kailash Nadh
fd8f5a96c9 Add missing bounce_type to v2 migration. 2021-09-17 20:18:53 +05:30
Kailash Nadh
9302dfbd56 Add missing id (pkey) to analytics tables for faster queries 2021-09-17 20:11:45 +05:30
Kailash Nadh
3d0031b207 Add campaign analytics APIs and UI 2021-09-17 18:45:35 +05:30
Kailash Nadh
3135bfc12a Upgrade and refactor global theme.
- Change public and admin frontend primary colours.
- Change images.
- Refactor and fix styling on public pages.
- Remove CSS grid lib from public pages.
- Update Buefy and fix broken component styles (modal, toast).
2021-09-16 17:46:39 +05:30
Kailash Nadh
d205f1c19e
Merge pull request #464 from tusharsadhwani/patch-1
Clarify default option in prompt
2021-09-13 10:56:30 +05:30
Tushar Sadhwani
f0299a87eb
Clarify default option in prompt
Pressing enter without specifying a letter in the continue prompt cancells installation. So, the script should show as such.
2021-09-12 23:47:06 +05:30
Kailash Nadh
1f4f4263a3 Fix incorrect [list_id] param in bulk subscriber deletion UI 2021-09-07 17:15:48 +05:30
Kailash Nadh
68369a8f13 Update issue templates 2021-09-02 17:42:31 +05:30
Kailash Nadh
85c88062e6
Merge pull request #450 from ChrisTG742/patch-1
german translation fixes
2021-09-01 14:40:42 +05:30
ChrisTG742
647bea3a45
translation fixes
fixed a few upper case and other minor grammatical glitches
2021-09-01 10:44:43 +02:00
Karan Sharma
6cf0b46711
Merge pull request #445 from tusharsadhwani/fix-whitespace
Fix whitespace inconsistency in install scripts
2021-08-30 10:58:15 +05:30
Tushar Sadhwani
2edd3ec800 Fix whitespace inconsistency in install scripts 2021-08-28 19:13:51 +05:30
Kailash Nadh
7691fbd90f Refactor the large settings UI view to multiple files. 2021-08-28 16:44:26 +05:30
Kailash Nadh
abedb266d4 Add Czech i18 translation contributed by @srchlm
Closes #444
2021-08-28 16:32:28 +05:30
Kailash Nadh
00275df910 Fix i18n language code validation to include - 2021-08-28 16:31:35 +05:30
Kailash Nadh
3847c67087 Add --idempotent to make --install idempotent 2021-08-22 20:09:39 +05:30
Kailash Nadh
edac5a1910 Add bounce tests (Cypress) 2021-08-22 15:48:36 +05:30
Kailash Nadh
81d183b808 Fix incorrect date in bounce insert 2021-08-22 15:46:37 +05:30
Kailash Nadh
27e1e83d0b Validate type in bounce webhook API. 2021-08-22 15:43:35 +05:30
Kailash Nadh
158ea9fad2 Fix bounce action only triggering on n+1st bounce. 2021-08-22 15:42:54 +05:30
Kailash Nadh
b6d60d9c95 Merge branch 'master' of github.com:knadh/listmonk 2021-08-22 12:57:30 +05:30
Kailash Nadh
ab0b5dd804 Remove obsolete 'embed' import 2021-08-22 12:57:09 +05:30
Kailash Nadh
f149c63b5b
Replace the Heroku button with a functional one. 2021-08-21 18:01:31 +05:30
Kailash Nadh
d6d1883587 Add custom S3 backend support (eg: Minio) to media uploads
- Introduce a new S3 backend URL on the settings UI
- Add DB migration to populate S3 URL for existing S3 settings
- Refactor and fix URL formatting

Closes #139
2021-08-15 16:09:00 +05:30
Kailash Nadh
923b882f05 Add migration to remove obsolete subscribers.campaigns field 2021-08-14 17:23:05 +05:30
Kailash Nadh
1be8c7d387 Merge branch 'bounce' 2021-08-14 17:13:59 +05:30
Kailash Nadh
d41b697bfb Fix race in settings UI and settings API fetch 2021-08-14 17:13:22 +05:30
Kailash Nadh
cce5cff539 Fix bounce upgrade schema 2021-08-14 17:00:11 +05:30
Kailash Nadh
185d5111d7
Merge pull request #434 from knadh/dependabot/npm_and_yarn/frontend/url-parse-1.5.3
Bump url-parse from 1.5.1 to 1.5.3 in /frontend
2021-08-14 16:00:33 +05:30
Kailash Nadh
59c897645d Normalize i18n files with new bounce keys.
- Rename some 'settings.smtp' keys to `settings.mailserver` so that
  they can be reused across SMTP and bounce settings UIs.
2021-08-14 15:56:43 +05:30
Kailash Nadh
1ae98699e7 Add support for bounce processing.
- Blocklist or unsubscribe subscribers based on a bounce threshold
- Add /bounces UI for viewing bounces and in the subscriber view
- Add settings UI for managing bounce settings
- Add support for scanning POP3 bounce mailboxes
- Add a generic webhook for posting custom bounces at /webhooks/bounce
- Add SES bounce webhook support at /webhooks/services/ses
- Add Sendgrid bounce webhook support at /webhooks/services/sendgrid
2021-08-14 15:35:29 +05:30
dependabot[bot]
c7a962bfd0
Bump url-parse from 1.5.1 to 1.5.3 in /frontend
Bumps [url-parse](https://github.com/unshiftio/url-parse) from 1.5.1 to 1.5.3.
- [Release notes](https://github.com/unshiftio/url-parse/releases)
- [Commits](https://github.com/unshiftio/url-parse/compare/1.5.1...1.5.3)

---
updated-dependencies:
- dependency-name: url-parse
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-08-14 09:50:02 +00:00
Kailash Nadh
e23b4fdd34
Merge pull request #431 from knadh/dependabot/npm_and_yarn/frontend/path-parse-1.0.7
Bump path-parse from 1.0.6 to 1.0.7 in /frontend
2021-08-14 15:19:25 +05:30
Kailash Nadh
ccee852e33 Remove incorrect RootURL link from e-mail template. Closes #432 2021-08-14 13:51:41 +05:30
Kailash Nadh
26c099a50a Merge branch 'version-file' 2021-08-14 13:42:15 +05:30
Kailash Nadh
d27e16e9ca Add a VERSION file for git-archive export
- Use git to get tag and commit hash or fall back to extracting
  the values from the VERSION file if it is (from git archive)
2021-08-14 13:41:19 +05:30
Kailash Nadh
b19013daf4 Merge branch 'master' of github.com:knadh/listmonk 2021-08-14 12:42:51 +05:30
David Regla
137e9dd0e0 Refine Spanish (es) i18n translations 2021-08-14 12:42:28 +05:30
Kailash Nadh
7f5e975a30
Merge pull request #433 from dreglad/i18n-es-refine
Refine Spanish (es) i18n translations
2021-08-14 12:34:40 +05:30
David Regla
9e64dcb49d Refine Spanish (es) i18n translations 2021-08-13 21:56:00 -05:00
dependabot[bot]
4405550cab
Bump path-parse from 1.0.6 to 1.0.7 in /frontend
Bumps [path-parse](https://github.com/jbgutierrez/path-parse) from 1.0.6 to 1.0.7.
- [Release notes](https://github.com/jbgutierrez/path-parse/releases)
- [Commits](https://github.com/jbgutierrez/path-parse/commits/v1.0.7)

---
updated-dependencies:
- dependency-name: path-parse
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-08-12 16:57:51 +00:00
Kailash Nadh
e6566189ed Add preconfirm_subscriptions to subscriber update. Closes #426. 2021-08-02 19:23:46 +05:30
Kailash Nadh
fb48477aa7 Fix SQL expressions breaking subscriber export. Closes #408 2021-07-25 22:42:54 +05:30
Kailash Nadh
af11a176f1 Refactor make run to always compile and use the correct frontend path 2021-07-25 15:47:37 +05:30
Kailash Nadh
6a87f388ee Merge branch 'static-paths' 2021-07-25 14:27:37 +05:30
Kailash Nadh
b7a25e5a32
Merge pull request #423 from mr-karan/install_fix
fix colorized output in terminal
2021-07-22 17:05:35 +05:30
Karan Sharma
4d8e73b654 fix colorized output in terminal
Closes https://github.com/knadh/listmonk/issues/422
2021-07-22 16:35:47 +05:30
Kailash Nadh
9e61bfc5df
Merge pull request #419 from justinbeaty/topic-root-url-fix
Fix RootURL in campaign-status.html
2021-07-14 14:23:23 +05:30
Justin Beaty
a7f7016b4e Fix RootURL in campaign-status.html 2021-07-13 17:18:40 -07:00
Kailash Nadh
82735bba69 Refactor behaviour of loading static files from disk vs. embedding.
Ref: https://github.com/knadh/listmonk/issues/409

- Introduce `main.appDir` and `main.fronendDir` Go compile-time flags
  to hardcode custom paths for loading frontend assets
  (frontend/dist/frontend in the repo after build) and app assets
  (queries.sql, schema.sql, config.toml.sample) in environments where
  embedding files in the binary is not feasible.
  These default to CWD unless explicitly set during compilation.

- Fix the Vue favicon path oddity by copying the icon into the built
  frontend dir in the `make-frontend` step.
2021-07-11 10:46:45 +05:30
Kailash Nadh
c8826d060e
Merge pull request #417 from kmohrf/master
add systemd service unit
2021-07-08 22:07:20 +05:30
Konrad Mohrfeldt
c10c03178b add systemd service unit
This systemd service unit may serve as an example for package
maintainers to write their own units or can be used as-is to run
listmonk on a operating system that uses systemd as its init system.

This is a template unit so that multiple listmonk instances can be
started and controlled through the @-syntax. Instances are started by
calling `systemctl start listmonk@myinstance.service` which goes on to
read `/etc/default/listmonk` and `/etc/default/listmonk-myinstance` as
environment files, uses `/etc/listmonk/myinstance.toml` as the
configuration file and creates a state directory in
`/var/lib/private/listmonk-myinstance`.
2021-07-07 14:15:16 +02:00
Kailash Nadh
67c0ca0be3 Merge branch 'master' of github.com:knadh/listmonk 2021-06-29 22:24:07 +05:30
Kailash Nadh
3be5227c22 Account for all *.go files in the repo in the Makefile build target 2021-06-29 22:23:52 +05:30
Kailash Nadh
078ca39606
Merge pull request #405 from knadh/dependabot/npm_and_yarn/frontend/color-string-1.5.5
Bump color-string from 1.5.3 to 1.5.5 in /frontend
2021-06-29 20:13:09 +05:30
Kailash Nadh
5e2c24b662 Make --new-config accept path from --config. Closes #410. 2021-06-29 20:10:59 +05:30
Kailash Nadh
ea9895e732
Merge pull request #406 from knadh/dependabot/npm_and_yarn/frontend/prismjs-1.24.0
Bump prismjs from 1.23.0 to 1.24.0 in /frontend
2021-06-29 09:35:15 +05:30
dependabot[bot]
893fab2975
Bump prismjs from 1.23.0 to 1.24.0 in /frontend
Bumps [prismjs](https://github.com/PrismJS/prism) from 1.23.0 to 1.24.0.
- [Release notes](https://github.com/PrismJS/prism/releases)
- [Changelog](https://github.com/PrismJS/prism/blob/master/CHANGELOG.md)
- [Commits](https://github.com/PrismJS/prism/compare/v1.23.0...v1.24.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2021-06-28 20:19:23 +00:00
dependabot[bot]
f101dded3a
Bump color-string from 1.5.3 to 1.5.5 in /frontend
Bumps [color-string](https://github.com/Qix-/color-string) from 1.5.3 to 1.5.5.
- [Release notes](https://github.com/Qix-/color-string/releases)
- [Changelog](https://github.com/Qix-/color-string/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Qix-/color-string/commits/1.5.5)

---
updated-dependencies:
- dependency-name: color-string
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-06-27 14:16:31 +00:00
Kailash Nadh
c818ad965c
Merge pull request #399 from knadh/dependabot/npm_and_yarn/frontend/browserslist-4.16.6
Bump browserslist from 4.12.0 to 4.16.6 in /frontend
2021-06-27 19:45:54 +05:30
Kailash Nadh
442d7f7393
Merge pull request #403 from kmohrf/master
streamline make configuration
2021-06-27 12:09:57 +05:30
Konrad Mohrfeldt
89bfe74f50 use make’s dependency handling to speed-up rebuilds
make allows us to run build targets based on dependencies and will only
execute targets if the dependencies have changed. This drastically
speeds up rebuilds if some targets have already been executed and
are still up to date.
2021-06-26 21:10:08 +02:00
Konrad Mohrfeldt
07478a588c allow yarn bin to be overridden
Users might want to override the yarn command to add options
or use a different bin on systems like debian where yarn is
named yarnpkg.
2021-06-26 19:20:40 +02:00
Kailash Nadh
5988ea36cb Sanitize media upload filenames. Closes #397. 2021-06-19 17:11:27 +05:30
dependabot[bot]
d6551e174c
Bump browserslist from 4.12.0 to 4.16.6 in /frontend
Bumps [browserslist](https://github.com/browserslist/browserslist) from 4.12.0 to 4.16.6.
- [Release notes](https://github.com/browserslist/browserslist/releases)
- [Changelog](https://github.com/browserslist/browserslist/blob/main/CHANGELOG.md)
- [Commits](https://github.com/browserslist/browserslist/compare/4.12.0...4.16.6)

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

Signed-off-by: dependabot[bot] <support@github.com>
2021-06-17 08:20:32 +00:00
Kailash Nadh
fc84082c87
Merge pull request #398 from knadh/dependabot/npm_and_yarn/frontend/postcss-7.0.36
Bump postcss from 7.0.32 to 7.0.36 in /frontend
2021-06-17 13:49:55 +05:30
dependabot[bot]
674536c1f5
Bump postcss from 7.0.32 to 7.0.36 in /frontend
Bumps [postcss](https://github.com/postcss/postcss) from 7.0.32 to 7.0.36.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/7.0.32...7.0.36)

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

Signed-off-by: dependabot[bot] <support@github.com>
2021-06-17 06:39:48 +00:00
Kailash Nadh
a22d7facbd
Merge pull request #394 from mr-karan/install
feat: Add easy install script
2021-06-15 18:44:16 +05:30
Karan Sharma
8d40422e0f feat: Add easy install script
- Add a shell script to orchestrate a production setup with
`docker-compose`. The script fetches config and `docker-compose.yml`
from the `master` branch, generates a secure password, performs DB
migrations and starts the container services.

- Add a health check for Postgres container service in `docker-compose.yml`.

- Add cusotm `container_name` for services inside `docker-compose`.
This is helpful to check the status of containers in the install shell script.
2021-06-15 18:38:45 +05:30
Kailash Nadh
b3612927c8 Display app version the settings UI 2021-06-09 20:11:45 +05:30
Kailash Nadh
a3b285fa62 Fix Buefy number input width 2021-06-09 20:02:24 +05:30
Kailash Nadh
63520d2370
Merge pull request #388 from dunklesToast/chore/update-german-translations
chore(translations): improve german translations
2021-06-09 10:52:01 +05:30
Tom Sacher
3abac31161 chore(translations): improve german translations 2021-06-08 21:50:47 +02:00
Kailash Nadh
3ecac7671a Fix Vue linting issue 2021-06-07 18:44:58 +05:30
Kailash Nadh
868fae6ac2 Refactor subsbscription status option on the import page.
- Refactor subimporter New*() funcs to take opt structs.
- Refactor and simplify Vue code.
- Remove redundant i18n entries and use existing ones.
- Remove redundant subimporter constants and use existing ones.

- Consider 'overwrite' option for subscription status as well.
- Write Cypress integration tests for the new feature.
2021-06-06 17:33:23 +05:30
Russ Smith
7ca08f0a36 Adding a subscription status option to the import.
Ref #168
2021-06-06 17:33:23 +05:30
Kailash Nadh
c37a7690d6 Add robots noindex header to public user specific subscription pages 2021-06-05 12:45:10 +05:30
Kailash Nadh
a914b5d194 Merge branch 'master' of github.com:knadh/listmonk 2021-06-05 12:02:02 +05:30
Kailash Nadh
8859911c73 Remove hardcoded limit for per_page in pagination 2021-06-05 12:01:33 +05:30
Kailash Nadh
948dbc9e85
Merge pull request #385 from knadh/dependabot/npm_and_yarn/frontend/ws-6.2.2
Bump ws from 6.2.1 to 6.2.2 in /frontend
2021-06-05 11:42:35 +05:30
dependabot[bot]
6ddb03c452
Bump ws from 6.2.1 to 6.2.2 in /frontend
Bumps [ws](https://github.com/websockets/ws) from 6.2.1 to 6.2.2.
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/commits)

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

Signed-off-by: dependabot[bot] <support@github.com>
2021-06-04 14:19:26 +00:00
Kailash Nadh
3d26366620 Fix pagination query.
- Fix '?per_page=all' not working inconditional LIMIT queries.
- Fetch all lists on the UI for list dropdowns everywhere.
2021-06-04 19:47:55 +05:30
Kailash Nadh
bbffbbc5f3 Fix listID not being passed in bulk sub deletion. Closes #384 2021-06-04 19:32:42 +05:30
Kailash Nadh
baca95e4eb
Merge pull request #381 from mr-karan/health
fix: expose healthcheck API as public endpoint
2021-06-03 14:49:08 +05:30
Karan Sharma
50dc9fca16 feat: add a public healthcheck endpoint
- Adds `/health` as a public facing healthcheck endpoint.
- `/api/health` is meant for internal healthchecks. This endpoint in
  future can serve sensitive information about Listmonk *or* can be
deprecated if there's not enough usecase.

Closes https://github.com/knadh/listmonk/issues/380
2021-06-03 11:19:03 +05:30
Kailash Nadh
59bcc8eb13
Merge pull request #376 from knadh/dependabot/npm_and_yarn/frontend/dns-packet-1.3.4
Bump dns-packet from 1.3.1 to 1.3.4 in /frontend
2021-05-29 17:06:46 +05:30
dependabot[bot]
cb07774fa5
Bump dns-packet from 1.3.1 to 1.3.4 in /frontend
Bumps [dns-packet](https://github.com/mafintosh/dns-packet) from 1.3.1 to 1.3.4.
- [Release notes](https://github.com/mafintosh/dns-packet/releases)
- [Changelog](https://github.com/mafintosh/dns-packet/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mafintosh/dns-packet/compare/v1.3.1...v1.3.4)

Signed-off-by: dependabot[bot] <support@github.com>
2021-05-28 21:34:02 +00:00
Kailash Nadh
e3d3420901
Merge pull request #374 from jonathanmmm/patch-1
Update de.json
2021-05-28 11:41:21 +05:30
jonathanmmm
30132c5757
Update de.json
I have tried changing them as best as I could.
I found that in english there is about AWS written something about access key and secret key but
AWS tells on their page about  `Access keys (access key ID and secret access key) `
https://docs.aws.amazon.com/general/latest/gr/aws-sec-cred-types.html
Should the access key mean ID and the second secret access key?
2021-05-27 22:51:00 +02:00
Kailash Nadh
44adcd44de Stop checking for updates on boot.
This commit disables the automatic upe check thappens immediately
on boot, giving users an opportunity disable it from the settings UI
before any remote requests are initiated. Tupdate checks happen
every 24 houfter boot.

Ref: #326
2021-05-23 21:08:32 +05:30
Kailash Nadh
dba47bca28 Add file extsnsion check to media uploads.
While file content (MIME) check already existed, the lack of file
extension check allowed arbitrary extensions to be uploaded and
then accessed via the static file server. For instance, a .html file
with JPG content intersperesed with Javascript.

This commit adds a file extension check on top of the MIME type check.
2021-05-23 20:17:42 +05:30
Kailash Nadh
69f84c99d0 Refactor log line view to prevent HTML render log lines.
This commit processes log lis and renders them as different fields
removing the use of <pre> and also `v-html` which renders HTML strings
from log lines.
2021-05-23 19:13:47 +05:30
Kailash Nadh
e54c33e8e8
Merge pull request #371 from knadh/upgrade-frontend
Upgrade all frontend JS deps
2021-05-23 18:00:05 +05:30
Kailash Nadh
b7932e4b57 Upgrade all JS deps to latest 2021-05-23 17:57:30 +05:30
Kailash Nadh
57962918e7 Remove unused JS deps 2021-05-23 17:44:08 +05:30
Kailash Nadh
f5221ab1ee Upgrade JS sass libs.
See issue #369

Upgrade sass libs to work with Node 16.
2021-05-23 17:27:32 +05:30
Kailash Nadh
dea4d185ae Upgrade Vue and Buefy UI lib.
- Vue 2.6 introduces "v-slot" which Buefy 0.9.7 uses.
- Refactor all `<b-table>` and `<b-column>` instances to work with the
  new `v-slot` snytax.
- Refactor `<b-column>` <td> and class attributes to work wit hthe new
  syntax.
- Fix Buefy scss setup to work with the update.
- Fix sidebar responsive view to work with the update.
2021-05-23 17:27:21 +05:30
Kailash Nadh
c593be51c1 Upgrade Vue + eslint to the latest version 2021-05-23 14:20:21 +05:30
Kailash Nadh
25f5f9b060 Merge branch 'master' of github.com:knadh/listmonk 2021-05-21 23:35:37 +05:30
Kailash Nadh
931e467b25 Fixes campaign test messages not including unsub headers.
Campaign messages are handled by `manager` whereas test messages
were being pushed directly into a messenger skipping some campaign
related routines such as the addition of list unsub headers.

This commit exposes a new function `manager.PushCampaignMessage()`
that accepts arbitrary campaign messages that then pass through
the standard campaign message workers, thus getting the missing unsub
headers. This closes #360.

In addition, this removes the superfluous `CampaignMessage.Render()`
function which had to be mandatorily called always and makes it
implicit in `manager.NewCampaignMessage()`.
2021-05-21 23:35:08 +05:30
Kailash Nadh
3cc7ecc2b5
Merge pull request #366 from senolcolak/master
Turkish translations added
2021-05-21 15:24:02 +05:30
senol
30074ecd36 Turkish translations added 2021-05-21 02:14:27 +02:00
Kailash Nadh
d6bdcd4f54
Merge pull request #365 from jorge-vitrubio/patch-1
minor translation changes
2021-05-20 17:16:07 +05:30
Kailash Nadh
edd7e70adc
Merge pull request #364 from jorge-vitrubio/patch-3
minor translation and typo
2021-05-20 16:06:32 +05:30
Jorge - vitrubio
0146d6ff07
minor typo 2021-05-20 12:26:45 +02:00
Jorge - vitrubio
fc3e517027
minor translation changes 2021-05-20 12:11:37 +02:00
Kailash Nadh
ea92e8b12e Merge branch 'master' of github.com:knadh/listmonk 2021-05-18 16:17:10 +05:30
Kailash Nadh
9f2e708798 Wrap lines in <pre> without overflowing the viewport.
Closes #359.
2021-05-18 16:16:28 +05:30
Kailash Nadh
0e5cd6043f
Delete feature---change-request.md 2021-05-17 20:05:39 +05:30
Kailash Nadh
89481edd11 Update issue templates 2021-05-17 20:04:49 +05:30
Kailash Nadh
95a81d17ce Add option on UI to toggle update checks.
Closes #326
2021-05-16 16:54:55 +05:30
Kailash Nadh
d695bb34cc Prioritise --static-dir on init when no assets are embedded.
When no static assets are found on init, i.e., when a binary without
stuffbin assets are loaded, the app looks for all necessary static
files in the working dir, including the `./static/*` path which renders
the `--static-dir` flag irrelevant.

This patch gives `--static-dir`, if set, precedence over `./static/*`
when loading assets from the working dir when a binary is not stuffed
with static files.

Closes #340.
2021-05-16 15:53:30 +05:30
Kailash Nadh
aa5eff915d Fix incorre check on template deletion. 2021-05-16 15:26:38 +05:30
Kailash Nadh
9fe78d6247 Make conditional icons consistent on templates UI 2021-05-16 15:26:32 +05:30
Kailash Nadh
ed57ecca99 Sanitize HTML strings passed to buefy.toast().
The buefy toast component does not sanitize HTML leaving it open
to XSS. This patch centralised all toast calls in the app to a util
function which sanitizes HTML strings before passing to toast().

Closes #357.
2021-05-16 13:27:58 +05:30
Kailash Nadh
cf0c8f3855
Merge pull request #355 from seba81/master
Adding spanish language
2021-05-16 11:28:48 +05:30
seba81
194e530d3b Adding spanish language 2021-05-14 13:14:02 -03:00
Kailash Nadh
20939e8121
Merge pull request #354 from knadh/dependabot/npm_and_yarn/frontend/url-parse-1.5.1
Bump url-parse from 1.4.7 to 1.5.1 in /frontend
2021-05-09 16:47:04 +05:30
Kailash Nadh
6bbde095a8
Merge pull request #353 from knadh/dependabot/npm_and_yarn/frontend/hosted-git-info-2.8.9
Bump hosted-git-info from 2.8.8 to 2.8.9 in /frontend
2021-05-09 16:46:52 +05:30
Kailash Nadh
cd1aa810dd
Merge pull request #352 from knadh/dependabot/npm_and_yarn/frontend/lodash-4.17.21
Bump lodash from 4.17.19 to 4.17.21 in /frontend
2021-05-09 16:46:39 +05:30
Kailash Nadh
6a21776124 Fix password fields not updating settings UI.
Closes #332.
2021-05-09 16:04:01 +05:30
dependabot[bot]
0b0cd5a791
Bump url-parse from 1.4.7 to 1.5.1 in /frontend
Bumps [url-parse](https://github.com/unshiftio/url-parse) from 1.4.7 to 1.5.1.
- [Release notes](https://github.com/unshiftio/url-parse/releases)
- [Commits](https://github.com/unshiftio/url-parse/compare/1.4.7...1.5.1)

Signed-off-by: dependabot[bot] <support@github.com>
2021-05-09 10:11:06 +00:00
dependabot[bot]
a06f1aed77
Bump hosted-git-info from 2.8.8 to 2.8.9 in /frontend
Bumps [hosted-git-info](https://github.com/npm/hosted-git-info) from 2.8.8 to 2.8.9.
- [Release notes](https://github.com/npm/hosted-git-info/releases)
- [Changelog](https://github.com/npm/hosted-git-info/blob/v2.8.9/CHANGELOG.md)
- [Commits](https://github.com/npm/hosted-git-info/compare/v2.8.8...v2.8.9)

Signed-off-by: dependabot[bot] <support@github.com>
2021-05-09 10:09:59 +00:00
dependabot[bot]
02b92b5916
Bump lodash from 4.17.19 to 4.17.21 in /frontend
Bumps [lodash](https://github.com/lodash/lodash) from 4.17.19 to 4.17.21.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.19...4.17.21)

Signed-off-by: dependabot[bot] <support@github.com>
2021-05-09 10:09:50 +00:00
Kailash Nadh
65d25fc3f9 Improve campaign content format conversion.
Previously, converting between formats simply copied over raw content.
This update does actual conversion between different formats. While
lossy, this seems to a good enough approximation for even reasonbly
rich HTML content. Closes #348.

- richtext, html => plain
  Strips HTML and converts content to plain text.

- richtext, html => markdown
  Uses turndown (JS) lib to convert HTML to Markdown.

- plain => richtext, html
  Converts line breaks in plain text to HTML breaks.

- richtext => html
  "Beautifies" the HTML generated by the WYSIWYG editor unlike the
  earlier behaviour of dumping one long line of HTML.

- markdown => richtext, html
  Makes an API call to the backend to use the Goldmark lib to convert
  Markdown to HTML.
2021-05-09 15:36:31 +05:30
Kailash Nadh
49c747d7d0 Allow HTML and additional syntax in the Markdown parser. 2021-05-08 16:55:48 +05:30
Kailash Nadh
f08254d2bf
Merge pull request #350 from alerque/config-handling
Cleanup sample config
2021-05-08 13:22:30 +05:30
Caleb Maclennan
09c56da8c6
Document tidbits about listening addresses for non-sysadmin types 2021-05-07 14:44:17 +03:00
Caleb Maclennan
26a023813e
Bind to ‘localhost’ instead of ‘0.0.0.0’ by default
This is a small safety precaution to make sure the out of the box
configuration is not world routeable. Bringing this up on a public
interface with a connected database could be a security concern. Any
sysadmin worth their salt is going to test offline or by binding to
localhost only first anyway, but this gets them started on the right
foot and makes sure people don't make mistakes.

Also with the high likelihood that a proxy is going to be used for HTTPS
termination anyway, the decision to move to a public IP should be more
deliberate.
2021-05-07 12:35:22 +03:00
Caleb Maclennan
6c40e05d2d
Use ‘localhost’ as default name for database server
Using localhost instead of some random string is much more likely to
actually work out of the box. Also it's a lot easier for a sysamdmin to
'scan' for things that need changing.
2021-05-07 12:33:22 +03:00
Caleb Maclennan
708ec66d9b
Don't indent TOML keys deeper than their sections 2021-05-06 18:28:04 +03:00
Kailash Nadh
68b80d0eb6
Merge pull request #334 from inpos/master
Fix typo
2021-04-24 12:36:00 +05:30
Роман
6ada0aabda
Fix typo 2021-04-23 16:42:55 +03:00
Kailash Nadh
a401b1cb48
Merge pull request #331 from inpos/master
better translation option
2021-04-22 10:43:09 +05:30
Роман
c7505389d4
better translation option 2021-04-21 20:13:03 +03:00
Kailash Nadh
60220c7424
Merge pull request #330 from inpos/master
Typo in ru.json
2021-04-21 20:14:03 +05:30
Роман
f6339c7b5c
Update ru.json 2021-04-21 17:31:36 +03:00
Kailash Nadh
5868db0124 Sort i18n language list on the settings UI 2021-04-21 19:04:04 +05:30
Kailash Nadh
1c8d2725c6 Add Russian translation by @inpos. Closes #329. 2021-04-21 18:48:50 +05:30
Kailash Nadh
37824136c0 Refactor campaign preview to use dummy campaign and subscriber.
Use a dummy subscriber instead of fetching a random one from the
DB. In addition, replace the preview campaign UUID with a dummy
one to prevent clicks and views being registered against the
campaign when previewing.
2021-04-21 15:32:05 +05:30
Kailash Nadh
fe61e898a3 Add hidden nonce (honeypot) field to filter bot autofills on subs page 2021-04-21 14:01:32 +05:30
Kailash Nadh
97d297e18c Normalize i18n files 2021-04-21 13:53:31 +05:30
Kailash Nadh
9a4f1a0781
Merge pull request #327 from brunowego/patch-1
chore(dockerfile): not are using multi-stage build
2021-04-20 09:20:39 +05:30
Bruno Wego
f346f0f9ea
chore(dockerfile): not are using multi-stage build 2021-04-19 18:14:32 -03:00
Kailash Nadh
33450f86bb Merge branch 'master' of github.com:knadh/listmonk 2021-04-17 15:55:14 +05:30
Kailash Nadh
c479a90c42 Add support for loading external i18n language files.
The new `--i18n-dir` directory allows the loading of an external
directory of i18n JSON files, milar to have `--static-dir`
works. New languages can be added and existing language files
can be customized this way.

This commit changes file loading behaviour so that invalid or
non-existent don't halt the execution of the app completely but
merely throw a warning and continue with the default (en) lang.
2021-04-17 14:26:56 +05:30
Kailash Nadh
cf5cd95c83
Merge pull request #324 from knadh/dependabot/npm_and_yarn/frontend/ssri-6.0.2
Bump ssri from 6.0.1 to 6.0.2 in /frontend
2021-04-17 13:46:52 +05:30
dependabot[bot]
2bbe38f4f5
Bump ssri from 6.0.1 to 6.0.2 in /frontend
Bumps [ssri](https://github.com/npm/ssri) from 6.0.1 to 6.0.2.
- [Release notes](https://github.com/npm/ssri/releases)
- [Changelog](https://github.com/npm/ssri/blob/v6.0.2/CHANGELOG.md)
- [Commits](https://github.com/npm/ssri/compare/v6.0.1...v6.0.2)

Signed-off-by: dependabot[bot] <support@github.com>
2021-04-17 08:10:32 +00:00
Kailash Nadh
4ddaba889f Merge branch 'master' of github.com:knadh/listmonk 2021-04-17 13:36:53 +05:30
Kailash Nadh
ad0a0e0841 Add preconfirm_subscriptions=true/falsenew subs API.
Sending th optional flag as `trunue` in the POST /api/subscrirs
body will skip sending opt-iconfirmation e-mails to subscribers
and mark list subscriptions in the request a`confirmed`.
2021-04-17 13:34:37 +05:30
Kailash Nadh
c6a4d43efe
Merge pull request #321 from marcinkunert/patch-1
Polish translations
2021-04-16 13:26:29 +05:30
Marcin Kunert
f9a2eb87f0
Finished Polish (pl) translations 2021-04-16 09:53:09 +02:00
Marcin Kunert
777a89877a
Polish translations
Work in progress
2021-04-15 23:15:40 +02:00
Kailash Nadh
708d0e0b00 Fix re-submission of public form e-mails not registering 2021-04-15 21:53:36 +05:30
Kailash Nadh
07d8be5465
Merge pull request #317 from FelixDz/patch-1
French translations
2021-04-15 20:27:59 +05:30
FelixDz
ca19c5998b
Merge branch 'master' into patch-1 2021-04-14 20:19:00 +02:00
FelixDz
12f9ad46b5
Create fr.json
Added / corrected french translations.
2021-04-14 18:05:43 +02:00
Kailash Nadh
620271bec4 Normalize and merge missing keys into all i18n files 2021-04-14 13:52:56 +05:30
Kailash Nadh
bf6d4718e4 Add script to merge and normalize i18n files 2021-04-14 13:52:13 +05:30
Kailash Nadh
1e59d53135 Add markdown support to campaign content. 2021-04-14 12:26:09 +05:30
Kailash Nadh
4581e47c80
Merge pull request #313 from tamalsaha/sprigv3
Use github.com/Masterminds/sprig/v3
2021-04-11 15:37:36 +05:30
Tamal Saha
40aaa2694d Use github.com/Masterminds/sprig/v3
Signed-off-by: Tamal Saha <tamal@appscode.com>
2021-04-11 03:00:48 -07:00
Kailash Nadh
c358281193 Merge branch 'master' of github.com:knadh/listmonk 2021-04-11 15:15:23 +05:30
Tamal Saha
8a9b3efbb0 Fix indentation of docker-compose file
Signed-off-by: Tamal Saha <tamal@appscode.com>
2021-04-11 15:07:20 +05:30
Tamal Saha
a266027f6c Build static Go binary
Signed-off-by: Tamal Saha <tamal@appscode.com>
2021-04-11 15:07:19 +05:30
Tamal Saha
b060c751ce Bundle sprig template functions
Signed-off-by: Tamal Saha <tamal@appscode.com>
2021-04-11 15:07:10 +05:30
Kailash Nadh
f8f074cb95
Merge pull request #312 from tamalsaha/fmt3
Fix indentation of docker-compose file
2021-04-11 14:40:26 +05:30
Kailash Nadh
178ee281b1
Merge pull request #311 from tamalsaha/fmt
Build static Go binary
2021-04-11 14:38:46 +05:30
Kailash Nadh
bc8b4d08e7
Merge pull request #309 from tamalsaha/sprig
Bundle sprig template functions
2021-04-11 13:24:38 +05:30
Tamal Saha
97f8c017ae Fix indentation of docker-compose file
Signed-off-by: Tamal Saha <tamal@appscode.com>
2021-04-11 00:29:57 -07:00
Tamal Saha
96f63d010c Build static Go binary
Signed-off-by: Tamal Saha <tamal@appscode.com>
2021-04-11 00:27:18 -07:00
Tamal Saha
4485460f53 Bundle sprig template functions
Signed-off-by: Tamal Saha <tamal@appscode.com>
2021-04-10 05:09:54 -07:00
Kailash Nadh
570a81f966 WIP: Add tests 2021-04-10 12:26:33 +05:30
Kailash Nadh
039feef938
Merge pull request #307 from knadh/dependabot/npm_and_yarn/frontend/y18n-4.0.1
Bump y18n from 4.0.0 to 4.0.1 in /frontend
2021-04-10 12:14:13 +05:30
Kailash Nadh
e7e36a080f
Merge pull request #306 from kousikmitra/fix/campaign-field-names
Fix: campaign field names
2021-04-02 12:12:42 +05:30
Kousik Mitra
35b1d01621 Fix template box label name 2021-04-01 19:43:00 +05:30
dependabot[bot]
ca403d5583
Bump y18n from 4.0.0 to 4.0.1 in /frontend
Bumps [y18n](https://github.com/yargs/y18n) from 4.0.0 to 4.0.1.
- [Release notes](https://github.com/yargs/y18n/releases)
- [Changelog](https://github.com/yargs/y18n/blob/master/CHANGELOG.md)
- [Commits](https://github.com/yargs/y18n/commits)

Signed-off-by: dependabot[bot] <support@github.com>
2021-04-01 03:55:39 +00:00
Kousik Mitra
6d61c52126 Fix placeholder attribute typo 2021-03-30 20:11:26 +05:30
Kailash Nadh
6dbcfee080
Merge pull request #298 from mr-karan/master
fix: rename Github Token secret variable
2021-03-19 18:10:21 +05:30
Kailash Nadh
d519a29c7c
Merge pull request #297 from spezifisch/german-translation-updates
Add German translations for missing keys in public.*, fix some typos
2021-03-19 18:09:43 +05:30
Karan Sharma
51d218a484 fix: rename Github Token secret variable
Since `GITHUB_TOKEN` is automatically injected in build
pipelines, we don't need to generate and add our custom token here.
2021-03-19 15:57:00 +05:30
Pascal Below
531d7680e7 add german translations for missing keys in public.*, fix typos 2021-03-19 09:43:43 +01:00
Kailash Nadh
faf45d45e1
Merge pull request #296 from mr-karan/master
Add GitHub Actions
2021-03-19 12:30:53 +05:30
Karan Sharma
df34e57e65 fix: remove deprecated syntax in goreleaser
`binaries` in `docker` is now deprecated
(https://goreleaser.com/deprecations#dockerbinaries).
2021-03-19 12:21:35 +05:30
Karan Sharma
c6b85651af chore: release via github actions
Add a `release.yml` Github Actions workflow config which automates
the build and release process of Listmonk.
2021-03-19 12:21:35 +05:30
Kailash Nadh
207f516673
Merge pull request #294 from joicemjoseph/patch/ml-word-corrections
fix: typo corrections to malayalam localization
2021-03-14 14:38:02 +05:30
Joice
4d681f053e fix: typo corrections to malayalam localization 2021-03-14 13:26:42 +05:30
Kailash Nadh
2579d7c2b3
Merge pull request #291 from knadh/dependabot/npm_and_yarn/frontend/elliptic-6.5.4
Bump elliptic from 6.5.3 to 6.5.4 in /frontend
2021-03-11 11:51:45 +05:30
dependabot[bot]
1ac0e65dd8
Bump elliptic from 6.5.3 to 6.5.4 in /frontend
Bumps [elliptic](https://github.com/indutny/elliptic) from 6.5.3 to 6.5.4.
- [Release notes](https://github.com/indutny/elliptic/releases)
- [Commits](https://github.com/indutny/elliptic/compare/v6.5.3...v6.5.4)

Signed-off-by: dependabot[bot] <support@github.com>
2021-03-11 00:27:23 +00:00
Kailash Nadh
e8ad7a9adc Fix subscriber attribs update API.
Change the behaviour where not passing attribs to the update API
overwrites the attribs with empty values. This commit changes the
behaviour so that in the absence of the attribs field in the
subscriber API, the existing value in the DB is retained.
2021-03-10 21:20:26 +05:30
Kailash Nadh
f8e555dac5 Fix incorrect ID handling in update handlers 2021-03-09 17:54:07 +05:30
Kailash Nadh
93a710c9ae
Merge pull request #284 from RustyDust/master
Fix wrong list links in subscribers overview
2021-03-07 13:22:20 +05:30
Kailash Nadh
8a6ed2ac2e Fix incorrect week day name logic in translation 2021-03-07 12:36:10 +05:30
Stefan Rubner
860953e331 Fix wrong list links in subscribers overview 2021-03-05 16:45:22 +01:00
Kailash Nadh
267dd52ec3
Merge pull request #281 from knadh/dependabot/npm_and_yarn/frontend/prismjs-1.23.0
Bump prismjs from 1.20.0 to 1.23.0 in /frontend
2021-03-02 09:25:02 +05:30
dependabot[bot]
f268dc69ab
Bump prismjs from 1.20.0 to 1.23.0 in /frontend
Bumps [prismjs](https://github.com/PrismJS/prism) from 1.20.0 to 1.23.0.
- [Release notes](https://github.com/PrismJS/prism/releases)
- [Changelog](https://github.com/PrismJS/prism/blob/master/CHANGELOG.md)
- [Commits](https://github.com/PrismJS/prism/compare/v1.20.0...v1.23.0)

Signed-off-by: dependabot[bot] <support@github.com>
2021-03-01 21:19:19 +00:00
Kailash Nadh
d66227256a
Merge pull request #276 from herzkerl/patch-1
Including an EXPOSE instruction in the Dockerfile
2021-02-20 13:30:49 +05:30
Ben
31ce55a3a1
Including an EXPOSE instruction in the Dockerfile 2021-02-19 18:34:09 +01:00
Kailash Nadh
8779c49660
Merge pull request #274 from enniosousa/master
Add Brazilian Portuguese i18n
2021-02-18 12:34:09 +05:30
Kailash Nadh
5777738636
Merge pull request #273 from TomBoss/master
corr. template
2021-02-18 12:33:40 +05:30
Ennio Sousa
c2d7e101cd
Create pt-BR.json 2021-02-17 17:15:29 -03:00
TomBoss
82f033b1da
corr. template 2021-02-17 15:41:59 +01:00
Kailash Nadh
77a6110c25
Merge pull request #272 from TomBoss/master
Add Italian i18n
2021-02-16 11:20:26 +05:30
TomBoss
2b8b10c691
Add Italian i18n 2021-02-15 20:12:23 +01:00
TomBoss
da7975f82b
corr. 2021-02-15 19:10:08 +01:00
TomBoss
b4fea57543
Merge pull request #1 from knadh/master
update
2021-02-15 19:06:05 +01:00
Kailash Nadh
99ff64bd82
Merge pull request #271 from TomBoss/master
Adding Safe templating function for keeping HTML comments
2021-02-15 18:43:49 +05:30
Kailash Nadh
97b78aa695 Fix incorrect 'get subscriber' calls 2021-02-15 18:27:14 +05:30
TomBoss
50549f3bfe
Adding Safe templating function for keeping HTML comment
Closes #270
According to https://stackoverflow.com/questions/34348072/go-html-comments-are-not-rendered
2021-02-15 13:33:47 +01:00
304 changed files with 48632 additions and 12561 deletions

2
.gitattributes vendored
View file

@ -1 +1,3 @@
frontend/* linguist-vendored
VERSION export-subst
* text=auto eol=lf

18
.github/ISSUE_TEMPLATE/confirmed-bug.md vendored Normal file
View file

@ -0,0 +1,18 @@
---
name: Confirmed bug
about: Report an issue that you have definititely confirmed to be a bug
title: ''
labels: bug
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.

View file

@ -0,0 +1,14 @@
---
name: Feature or change request
about: Suggest new features or changes to existing features
title: ''
labels: enhancement
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.

View file

@ -0,0 +1,10 @@
---
name: General question
about: You have a question about something or want to start a general discussion
title: ''
labels: ''
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'

54
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,54 @@
name: goreleaser
on:
push:
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@v3
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: "1.20"
- name: Login to Docker Registry
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@v4
with:
version: latest
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
binaries:
- listmonk
ids:
- 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

49
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,49 @@
# 1. Contributing
Welcome to listmonk! You can contribute to the project in the following ways:
1. **Bug reports:** One liner reports are difficult to understand and review.
1. Follow the bug reporting issue template and provide clear, concise descriptions and steps to reproduce the bug.
2. Ensure that you have searched the existing issues to avoid duplicates.
3. Maintainers may close unclear issues that lack enough information to reproduce a bug. [Report a bug here](https://github.com/knadh/listmonk/issues/new?assignees=&labels=bug&template=bug_report.md).
2. **Feature suggestions:** If you feel there is a nice enhancement or feature that can benefit many users, please open a feature request issue.
1. Ensure that you have searched the existing issues to avoid duplicates.
2. What makes sense for the project, what suits its scope and goals, and its future direction are at the discretion of the maintainers who put in the time, effort, and energy in building and maintaining the project for free. Please be respectful of this and keep discussions friendly and fruitful.
3. It is the responsibility of the requester to clearly explain and justify why a change is warranted. It is not the responsibility of the maintainers to coax this information out of a requester. So, please post well researched, well thought out, and detailed feature requests saving everyone time.
4. Maintainers may close unclear feature requests that lack enough information. [Suggest a feature here](https://github.com/knadh/listmonk/issues/new?assignees=&labels=enhancement&template=feature-or-change-request.md&title=).
3. **Improving docs:** You can submit corrections and improvements to the [documentation](https://listmonk.app/docs) website on the [docs repo](https://github.com/knadh/listmonk/tree/master/docs).
4. **i18n translations:** The project is available in many languages thanks to user contributions. You can create a new language pack or submit corrections to existing ones. There is a UI available for making translations easy. [More info here](https://listmonk.app/docs/i18n/).
# 2. Pull requests
This is a tricky one for many reasons. A PR, be it a new feature or a small enhancement, has to make sense to the project's overall scope, goals, and technical aspects. The quality, style, and conventions of the code have to conform to that of the project's. Performance, usability, stability and other kinds of impacts of a PR should be well understood.
This makes reviewing PRs a difficult and time consuming task. The bigger a PR, the more difficult it is to understand. Reviewing a PR in detail, engaging in back and forth discussions to improve it, and deciding that it is meaningful and safe to merge can often require more time and effort than what has gone into creating a PR. Thus, ultimately, whether a PR gets accepted or not, for whatever reason, is at the discretion of the maintainers. Please be respectful of the fact that maintainers have a much deeper understanding of the overall project. So, nitpicking on micro aspects may not be meaningful.
To keep the process smooth:
1. **Send a proposal first:** Open an issue describing what you aim to accomplish, how it makes sense to the project, and how you plan on implementing it (with useful technical details), before committing time and effort to writing code. This saves everyone time.
2. **Send small PRs:** Whenever possible, send small PRs with well defined scopes. The smaller the PR, the easier it is to review and test. Bundling multiple features into a single PR is highly discouraged.
3. **PRs will be squashed in the end:** A PR may change considerably with multiple commits before it is approved. Once a PR is approved, if there are multiple commits, they will be squashed into a single commit during merging.
# 3. Be respectful
Remember, most FOSS projects are fruits of love and labour of maintainers who share them with the world for free with no expectations of any returns. Free as in freedom, and free as in beer too. Really, *some people just want to watch the world turn*.
So:
1. Please be respectful and refrain from using aggressive or snarky language. It wastes time, cognitive bandwidth, and goodwill.
2. Please refrain from demanding. How badly you want a feature has no bearing on whether it warrants a maintainer's time or attention. It is entirely up to the maintainers, if, how, and when they want to implement something.
3. Please do not nitpick and generate unnecessary discussions that waste time.
4. Please make sure you have searched the docs and issues before asking support questions.
5. **Please remember, FOSS project maintainers owe you nothing** (unless you have an explicit agreement with them, of course) including their time in responding to your messages or providing free customer support. If you want to be heard, please be respectful and establish goodwill.
6. If these are unacceptable to you a) you don't have to use the project b) you can always fork the project and change it to your liking while adhering to the terms of the license. That is the beauty of FOSS, afterall.
Thank you!

View file

@ -1,7 +1,8 @@
FROM alpine:latest AS deploy
RUN apk --no-cache add ca-certificates
FROM alpine:latest
RUN apk --no-cache add ca-certificates tzdata
WORKDIR /listmonk
COPY listmonk .
COPY config.toml.sample config.toml
COPY config-demo.toml .
CMD ["./listmonk"]
EXPOSE 9000

View file

@ -1,41 +1,63 @@
LAST_COMMIT := $(shell git rev-parse --short HEAD)
VERSION := $(shell git describe --tags --abbrev=0)
# 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]+$$"),"")
# 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: \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"))
YARN ?= yarn
GOPATH ?= $(HOME)/go
STUFFBIN ?= $(GOPATH)/bin/stuffbin
FRONTEND_YARN_MODULES = frontend/node_modules
FRONTEND_DIST = frontend/dist
FRONTEND_DEPS = \
$(FRONTEND_YARN_MODULES) \
frontend/package.json \
frontend/vue.config.js \
frontend/babel.config.js \
$(shell find frontend/fontello frontend/public frontend/src -type f)
BIN := listmonk
STATIC := config.toml.sample \
schema.sql queries.sql \
static/public:/public \
static/email-templates \
frontend/dist/favicon.png:/frontend/favicon.png \
frontend/dist/frontend:/frontend \
frontend/dist:/admin \
i18n:/i18n
# Install dependencies for building.
.PHONY: deps
deps:
go get -u github.com/knadh/stuffbin/...
cd frontend && yarn install
.PHONY: build
build: $(BIN)
$(STUFFBIN):
go install github.com/knadh/stuffbin/...
$(FRONTEND_YARN_MODULES): frontend/package.json frontend/yarn.lock
cd frontend && $(YARN) install
touch -c $(FRONTEND_YARN_MODULES)
# Build the backend to ./listmonk.
.PHONY: build
build:
go build -o ${BIN} -ldflags="-s -w -X 'main.buildString=${BUILDSTR}' -X 'main.versionString=${VERSION}'" cmd/*.go
$(BIN): $(shell find . -type f -name "*.go")
CGO_ENABLED=0 go build -o ${BIN} -ldflags="-s -w -X 'main.buildString=${BUILDSTR}' -X 'main.versionString=${VERSION}'" cmd/*.go
# Run the backend.
# Run the backend in dev mode. The frontend assets in dev mode are loaded from disk from frontend/dist.
.PHONY: run
run: build
./${BIN}
run:
CGO_ENABLED=0 go run -ldflags="-s -w -X 'main.buildString=${BUILDSTR}' -X 'main.versionString=${VERSION}' -X 'main.frontendDir=frontend/dist'" cmd/*.go
# Build the JS frontend into frontend/dist.
$(FRONTEND_DIST): $(FRONTEND_DEPS)
export VUE_APP_VERSION="${VERSION}" && cd frontend && $(YARN) build
touch -c $(FRONTEND_DIST)
.PHONY: build-frontend
build-frontend:
export VUE_APP_VERSION="${VERSION}" && cd frontend && yarn build
build-frontend: $(FRONTEND_DIST)
# Run the JS frontend server in dev mode.
.PHONY: run-frontend
run-frontend:
export VUE_APP_VERSION="${VERSION}" && cd frontend && yarn serve
export VUE_APP_VERSION="${VERSION}" && cd frontend && $(YARN) serve
# Run Go tests.
.PHONY: test
@ -45,14 +67,13 @@ test:
# Bundle all static assets including the JS frontend into the ./listmonk binary
# using stuffbin (installed with make deps).
.PHONY: dist
dist: build build-frontend
stuffbin -a stuff -in ${BIN} -out ${BIN} ${STATIC}
dist: $(STUFFBIN) build build-frontend pack-bin
# pack-releases runns stuffbin packing on the given binary. This is used
# in the .goreleaser post-build hook.
.PHONY: pack-bin
pack-bin:
stuffbin -a stuff -in ${BIN} -out ${BIN} ${STATIC}
pack-bin: build-frontend $(BIN) $(STUFFBIN)
$(STUFFBIN) -a stuff -in ${BIN} -out ${BIN} ${STATIC}
# Use goreleaser to do a dry run producing local builds.
.PHONY: release-dry
@ -63,3 +84,32 @@ release-dry:
.PHONY: release
release:
goreleaser --parallelism 1 --rm-dist --skip-validate
# Build local docker images for development.
.PHONY: build-dev-docker
build-dev-docker: build ## Build docker containers for the entire suite (Front/Core/PG).
cd dev; \
docker compose build ; \
# Spin a local docker suite for local development.
.PHONY: dev-docker
dev-docker: build-dev-docker ## Build and spawns docker containers for the entire suite (Front/Core/PG).
cd dev; \
docker compose up
# Run the backend in docker-dev mode. The frontend assets in dev mode are loaded from disk from frontend/dist.
.PHONY: run-backend-docker
run-backend-docker:
CGO_ENABLED=0 go run -ldflags="-s -w -X 'main.buildString=${BUILDSTR}' -X 'main.versionString=${VERSION}' -X 'main.frontendDir=frontend/dist'" cmd/*.go --config=dev/config.toml
# Tear down the complete local development docker suite.
.PHONY: rm-dev-docker
rm-dev-docker: build ## Delete the docker containers including DB volumes.
cd dev; \
docker compose down -v ; \
# Setup the db for local dev docker suite.
.PHONY: init-dev-docker
init-dev-docker: build-dev-docker ## Delete the docker containers including DB volumes.
cd dev; \
docker compose run --rm backend sh -c "make dist && ./listmonk --install --idempotent --yes --config dev/config.toml"

View file

@ -1,33 +1,39 @@
<a href="https://zerodha.tech"><img src="https://zerodha.tech/static/images/github-badge.svg" align="right" /></a>
![listmonk](https://user-images.githubusercontent.com/547147/89733021-43fbf700-da70-11ea-82e4-e98cb5010257.png)
[![listmonk-logo](https://user-images.githubusercontent.com/547147/231084896-835dba66-2dfe-497c-ba0f-787564c0819e.png)](https://listmonk.app)
listmonk is a standalone, self-hosted, newsletter and mailing list manager. It is fast, feature-rich, and packed into a single binary. It uses a PostgreSQL database as its data store.
listmonk is a standalone, self-hosted, newsletter and mailing list manager. It is fast, feature-rich, and packed into a single binary. It uses a PostgreSQL (⩾ 12) database as its data store.
[![listmonk-dashboard](https://user-images.githubusercontent.com/547147/89733057-87566580-da70-11ea-8160-855f6f046a55.png)](https://listmonk.app)
Visit [listmonk.app](https://listmonk.app)
[![listmonk-dashboard](https://user-images.githubusercontent.com/547147/134939475-e0391111-f762-44cb-b056-6cb0857755e3.png)](https://listmonk.app)
Visit [listmonk.app](https://listmonk.app) for more info. Check out the [**live demo**](https://demo.listmonk.app).
## Installation
### Docker
The latest image is available on DockerHub at `listmonk/listmonk:latest`. Use the sample [docker-compose.yml](https://github.com/knadh/listmonk/blob/master/docker-compose.yml) to run listmonk and Postgres DB with docker-compose as follows:
The latest image is available on DockerHub at [`listmonk/listmonk:latest`](https://hub.docker.com/r/listmonk/listmonk/tags?page=1&ordering=last_updated&name=latest). Use the sample [docker-compose.yml](https://github.com/knadh/listmonk/blob/master/docker-compose.yml) to run manually or use the helper script.
#### Demo
```bash
mkdir listmonk-demo
sh -c "$(curl -sSL https://raw.githubusercontent.com/knadh/listmonk/master/install-demo.sh)"
mkdir listmonk-demo && cd listmonk-demo
sh -c "$(curl -fsSL https://raw.githubusercontent.com/knadh/listmonk/master/install-demo.sh)"
```
The demo does not persist Postgres after the containers are removed. DO NOT use this demo setup in production.
DO NOT use this demo setup in production.
#### Production
- `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)
- Run `docker-compose up app` and visit `http://localhost:9000`.
More information on [docs](https://listmonk.app/docs).
```bash
mkdir listmonk && cd listmonk
sh -c "$(curl -fsSL https://raw.githubusercontent.com/knadh/listmonk/master/install-prod.sh)"
```
Visit `http://localhost:9000`.
**NOTE**: Always examine the contents of shell scripts before executing them.
See [installation docs](https://listmonk.app/docs/installation).
__________________
@ -37,18 +43,9 @@ __________________
- `./listmonk --install` to setup the Postgres DB (or `--upgrade` to upgrade an existing DB. Upgrades are idempotent and running them multiple times have no side effects).
- Run `./listmonk` and visit `http://localhost:9000`.
See [installation docs](https://listmonk.app/docs/installation).
__________________
### Heroku
Using the [Nginx buildpack](https://github.com/heroku/heroku-buildpack-nginx) can be used to deploy listmonk on Heroku and use Nginx as a proxy to setup basicauth.
This one-click [Heroku deploy button](https://github.com/bumi/listmonk-heroku) provides an automated default deployment.
[![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy?template=https://github.com/bumi/listmonk-heroku)
Please note that [configuration options](https://listmonk.app/docs/configuration) must be set using [environment configuration variables](https://devcenter.heroku.com/articles/config-vars).
## Developers
listmonk is a free and open source software licensed under AGPLv3. If you are interested in contributing, refer to the [developer setup](https://listmonk.app/docs/developer-setup). The backend is written in Go and the frontend is Vue with Buefy for UI.

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

2
VERSION Normal file
View file

@ -0,0 +1,2 @@
$Format:%h$
$Format:%D$

View file

@ -7,8 +7,7 @@ import (
"syscall"
"time"
"github.com/jmoiron/sqlx/types"
"github.com/labstack/echo"
"github.com/labstack/echo/v4"
)
type serverConfig struct {
@ -17,6 +16,7 @@ type serverConfig struct {
Lang string `json:"lang"`
Update *AppUpdate `json:"update"`
NeedsRestart bool `json:"needs_restart"`
Version string `json:"version"`
}
// handleGetServerConfig returns general server config.
@ -51,6 +51,7 @@ func handleGetServerConfig(c echo.Context) error {
out.NeedsRestart = app.needsRestart
out.Update = app.update
app.Unlock()
out.Version = versionString
return c.JSON(http.StatusOK, okResp{out})
}
@ -59,12 +60,11 @@ func handleGetServerConfig(c echo.Context) error {
func handleGetDashboardCharts(c echo.Context) error {
var (
app = c.Get("app").(*App)
out types.JSONText
)
if err := app.queries.GetDashboardCharts.Get(&out); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorFetching", "name", "dashboard charts", "error", pqErrMsg(err)))
out, err := app.core.GetDashboardCharts()
if err != nil {
return err
}
return c.JSON(http.StatusOK, okResp{out})
@ -74,12 +74,11 @@ func handleGetDashboardCharts(c echo.Context) error {
func handleGetDashboardCounts(c echo.Context) error {
var (
app = c.Get("app").(*App)
out types.JSONText
)
if err := app.queries.GetDashboardCounts.Get(&out); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorFetching", "name", "dashboard stats", "error", pqErrMsg(err)))
out, err := app.core.GetDashboardCounts()
if err != nil {
return err
}
return c.JSON(http.StatusOK, okResp{out})
@ -90,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
}

243
cmd/bounce.go Normal file
View file

@ -0,0 +1,243 @@
package main
import (
"encoding/json"
"io"
"net/http"
"strconv"
"time"
"github.com/knadh/listmonk/models"
"github.com/labstack/echo/v4"
)
// handleGetBounces handles retrieval of bounce records.
func handleGetBounces(c echo.Context) error {
var (
app = c.Get("app").(*App)
pg = app.paginator.NewFromURL(c.Request().URL.Query())
id, _ = strconv.Atoi(c.Param("id"))
campID, _ = strconv.Atoi(c.QueryParam("campaign_id"))
source = c.FormValue("source")
orderBy = c.FormValue("order_by")
order = c.FormValue("order")
)
// Fetch one bounce.
if id > 0 {
out, err := app.core.GetBounce(id)
if err != nil {
return err
}
return c.JSON(http.StatusOK, okResp{out})
}
res, total, err := app.core.QueryBounces(campID, 0, source, orderBy, order, pg.Offset, pg.Limit)
if err != nil {
return err
}
// No results.
var out models.PageResults
if len(res) == 0 {
out.Results = []models.Bounce{}
return c.JSON(http.StatusOK, okResp{out})
}
// Meta.
out.Results = res
out.Total = total
out.Page = pg.Page
out.PerPage = pg.PerPage
return c.JSON(http.StatusOK, okResp{out})
}
// handleGetSubscriberBounces retrieves a subscriber's bounce records.
func handleGetSubscriberBounces(c echo.Context) error {
var (
app = c.Get("app").(*App)
subID, _ = strconv.Atoi(c.Param("id"))
)
if subID < 1 {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}
out, _, err := app.core.QueryBounces(0, subID, "", "", "", 0, 1000)
if err != nil {
return err
}
return c.JSON(http.StatusOK, okResp{out})
}
// handleDeleteBounces handles bounce deletion, either a single one (ID in the URI), or a list.
func handleDeleteBounces(c echo.Context) error {
var (
app = c.Get("app").(*App)
pID = c.Param("id")
all, _ = strconv.ParseBool(c.QueryParam("all"))
IDs = []int{}
)
// Is it an /:id call?
if pID != "" {
id, _ := strconv.Atoi(pID)
if id < 1 {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}
IDs = append(IDs, id)
} else if !all {
// Multiple IDs.
i, err := parseStringIDs(c.Request().URL.Query()["id"])
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("globals.messages.invalidID", "error", err.Error()))
}
if len(i) == 0 {
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("globals.messages.invalidID"))
}
IDs = i
}
if err := app.core.DeleteBounces(IDs); err != nil {
return err
}
return c.JSON(http.StatusOK, okResp{true})
}
// handleBounceWebhook renders the HTML preview of a template.
func handleBounceWebhook(c echo.Context) error {
var (
app = c.Get("app").(*App)
service = c.Param("service")
bounces []models.Bounce
)
// Read the request body instead of using c.Bind() to read to save the entire raw request as meta.
rawReq, err := io.ReadAll(c.Request().Body)
if err != nil {
app.log.Printf("error reading ses notification body: %v", err)
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.internalError"))
}
switch true {
// Native internal webhook.
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")+":"+err.Error())
}
if bv, err := validateBounceFields(b, app); err != nil {
return err
} else {
b = bv
}
if len(b.Meta) == 0 {
b.Meta = json.RawMessage("{}")
}
if b.CreatedAt.Year() == 0 {
b.CreatedAt = time.Now()
}
bounces = append(bounces, b)
// Amazon SES.
case service == "ses" && app.constants.BounceSESEnabled:
switch c.Request().Header.Get("X-Amz-Sns-Message-Type") {
// SNS webhook registration confirmation. Only after these are processed will the endpoint
// start getting bounce notifications.
case "SubscriptionConfirmation", "UnsubscribeConfirmation":
if err := app.bounce.SES.ProcessSubscription(rawReq); err != nil {
app.log.Printf("error processing SNS (SES) subscription: %v", err)
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidData"))
}
break
// Bounce notification.
case "Notification":
b, err := app.bounce.SES.ProcessBounce(rawReq)
if err != nil {
app.log.Printf("error processing SES notification: %v", err)
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidData"))
}
bounces = append(bounces, b)
default:
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidData"))
}
// SendGrid.
case service == "sendgrid" && app.constants.BounceSendgridEnabled:
var (
sig = c.Request().Header.Get("X-Twilio-Email-Event-Webhook-Signature")
ts = c.Request().Header.Get("X-Twilio-Email-Event-Webhook-Timestamp")
)
// Sendgrid sends multiple bounces.
bs, err := app.bounce.Sendgrid.ProcessBounce(sig, ts, rawReq)
if err != nil {
app.log.Printf("error processing sendgrid notification: %v", err)
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidData"))
}
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"))
}
// Record bounces if any.
for _, b := range bounces {
if err := app.bounce.Record(b); err != nil {
app.log.Printf("error recording bounce: %v", err)
}
}
return c.JSON(http.StatusOK, okResp{true})
}
func validateBounceFields(b models.Bounce, app *App) (models.Bounce, error) {
if b.Email == "" && b.SubscriberUUID == "" {
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.Ts("globals.messages.invalidFields", "name", "subscriber_uuid"))
}
if b.Email != "" {
em, err := app.importer.SanitizeEmail(b.Email)
if err != nil {
return b, echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
b.Email = em
}
if b.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,7 +2,7 @@ package main
import (
"bytes"
"database/sql"
"encoding/json"
"errors"
"fmt"
"html/template"
@ -13,69 +13,49 @@ import (
"strings"
"time"
"github.com/gofrs/uuid"
"github.com/jaytaylor/html2text"
"github.com/knadh/listmonk/internal/messenger"
"github.com/knadh/listmonk/internal/subimporter"
"github.com/knadh/listmonk/models"
"github.com/labstack/echo"
"github.com/labstack/echo/v4"
"github.com/lib/pq"
null "gopkg.in/volatiletech/null.v6"
)
// campaignReq is a wrapper over the Campaign model.
// campaignReq is a wrapper over the Campaign model for receiving
// campaign creation and update data from APIs.
type campaignReq struct {
models.Campaign
// Indicates if the "send_at" date should be written or set to null.
SendLater bool `db:"-" json:"send_later"`
SendLater bool `json:"send_later"`
// This overrides Campaign.Lists to receive and
// write a list of int IDs during creation and updation.
// Campaign.Lists is JSONText for sending lists children
// to the outside world.
ListIDs pq.Int64Array `db:"-" json:"lists"`
ListIDs []int `json:"lists"`
MediaIDs []int `json:"media"`
// This is only relevant to campaign test requests.
SubscriberEmails pq.StringArray `json:"subscribers"`
Type string `json:"type"`
}
type campaignStats struct {
ID int `db:"id" json:"id"`
Status string `db:"status" json:"status"`
ToSend int `db:"to_send" json:"to_send"`
Sent int `db:"sent" json:"sent"`
Started null.Time `db:"started_at" json:"started_at"`
UpdatedAt null.Time `db:"updated_at" json:"updated_at"`
Rate float64 `json:"rate"`
}
type campsWrap struct {
Results models.Campaigns `json:"results"`
Query string `json:"query"`
Total int `json:"total"`
PerPage int `json:"per_page"`
Page int `json:"page"`
// campaignContentReq wraps params coming from API requests for converting
// campaign content formats.
type campaignContentReq struct {
models.Campaign
From string `json:"from"`
To string `json:"to"`
}
var (
regexFromAddress = regexp.MustCompile(`(.+?)\s<(.+?)@(.+?)>`)
regexFullTextQuery = regexp.MustCompile(`\s+`)
campaignQuerySortFields = []string{"name", "status", "created_at", "updated_at"}
regexFromAddress = regexp.MustCompile(`(.+?)\s<(.+?)@(.+?)>`)
)
// handleGetCampaigns handles retrieval of campaigns.
func handleGetCampaigns(c echo.Context) error {
var (
app = c.Get("app").(*App)
pg = getPagination(c.QueryParams(), 20, 50)
out campsWrap
pg = app.paginator.NewFromURL(c.Request().URL.Query())
id, _ = strconv.Atoi(c.Param("id"))
status = c.QueryParams()["status"]
query = strings.TrimSpace(c.FormValue("query"))
orderBy = c.FormValue("order_by")
@ -83,145 +63,122 @@ func handleGetCampaigns(c echo.Context) error {
noBody, _ = strconv.ParseBool(c.QueryParam("no_body"))
)
// Fetch one list.
single := false
if id > 0 {
single = true
}
if query != "" {
query = `%` +
string(regexFullTextQuery.ReplaceAll([]byte(query), []byte("&"))) + `%`
res, total, err := app.core.QueryCampaigns(query, status, orderBy, order, pg.Offset, pg.Limit)
if err != nil {
return err
}
// Sort params.
if !strSliceContains(orderBy, campaignQuerySortFields) {
orderBy = "created_at"
}
if order != sortAsc && order != sortDesc {
order = sortDesc
if noBody {
for i := 0; i < len(res); i++ {
res[i].Body = ""
}
}
stmt := fmt.Sprintf(app.queries.QueryCampaigns, orderBy, order)
// Unsafe to ignore scanning fields not present in models.Campaigns.
if err := db.Select(&out.Results, stmt, id, pq.StringArray(status), query, pg.Offset, pg.Limit); err != nil {
app.log.Printf("error fetching campaigns: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorFetching",
"name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
}
if single && len(out.Results) == 0 {
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("campaigns.notFound", "name", "{globals.terms.campaign}"))
}
if len(out.Results) == 0 {
var out models.PageResults
if len(res) == 0 {
out.Results = []models.Campaign{}
return c.JSON(http.StatusOK, okResp{out})
}
for i := 0; i < len(out.Results); i++ {
// Replace null tags.
if out.Results[i].Tags == nil {
out.Results[i].Tags = make(pq.StringArray, 0)
}
if noBody {
out.Results[i].Body = ""
}
}
// Lazy load stats.
if err := out.Results.LoadStats(app.queries.GetCampaignStats); err != nil {
app.log.Printf("error fetching campaign stats: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorFetching",
"name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
}
if single {
return c.JSON(http.StatusOK, okResp{out.Results[0]})
}
// Meta.
out.Total = out.Results[0].Total
out.Query = query
out.Results = res
out.Total = total
out.Page = pg.Page
out.PerPage = pg.PerPage
return c.JSON(http.StatusOK, okResp{out})
}
// handleGetCampaign handles retrieval of campaigns.
func handleGetCampaign(c echo.Context) error {
var (
app = c.Get("app").(*App)
id, _ = strconv.Atoi(c.Param("id"))
noBody, _ = strconv.ParseBool(c.QueryParam("no_body"))
)
out, err := app.core.GetCampaign(id, "")
if err != nil {
return err
}
if noBody {
out.Body = ""
}
return c.JSON(http.StatusOK, okResp{out})
}
// handlePreviewCampaign renders the HTML preview of a campaign body.
func handlePreviewCampaign(c echo.Context) error {
var (
app = c.Get("app").(*App)
id, _ = strconv.Atoi(c.Param("id"))
body = c.FormValue("body")
camp = &models.Campaign{}
app = c.Get("app").(*App)
id, _ = strconv.Atoi(c.Param("id"))
tplID, _ = strconv.Atoi(c.FormValue("template_id"))
)
if id < 1 {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}
err := app.queries.GetCampaignForPreview.Get(camp, id)
camp, err := app.core.GetCampaignForPreview(id, tplID)
if err != nil {
if err == sql.ErrNoRows {
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.campaign}"))
}
app.log.Printf("error fetching campaign: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorFetching",
"name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
return err
}
var sub models.Subscriber
// Get a random subscriber from the campaign.
if err := app.queries.GetOneCampaignSubscriber.Get(&sub, camp.ID); err != nil {
if err == sql.ErrNoRows {
// There's no subscriber. Mock one.
sub = dummySubscriber
} else {
app.log.Printf("error fetching subscriber: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorFetching",
"name", "{globals.terms.subscriber}", "error", pqErrMsg(err)))
}
// There's a body in the request to preview instead of the body in the DB.
if c.Request().Method == http.MethodPost {
camp.ContentType = c.FormValue("content_type")
camp.Body = c.FormValue("body")
}
// Compile the template.
if body != "" {
camp.Body = body
}
if err := camp.CompileTemplate(app.manager.TemplateFuncs(camp)); err != nil {
// Use a dummy campaign ID to prevent views and clicks from {{ TrackView }}
// and {{ TrackLink }} being registered on preview.
camp.UUID = dummySubscriber.UUID
if err := camp.CompileTemplate(app.manager.TemplateFuncs(&camp)); err != nil {
app.log.Printf("error compiling template: %v", err)
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("templates.errorCompiling", "error", err.Error()))
}
// Render the message body.
m := app.manager.NewCampaignMessage(camp, sub)
if err := m.Render(); err != nil {
msg, err := app.manager.NewCampaignMessage(&camp, dummySubscriber)
if err != nil {
app.log.Printf("error rendering message: %v", err)
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("templates.errorRendering", "error", err.Error()))
}
return c.HTML(http.StatusOK, string(m.Body()))
if camp.ContentType == models.CampaignContentTypePlain {
return c.String(http.StatusOK, string(msg.Body()))
}
return c.HTML(http.StatusOK, string(msg.Body()))
}
// handleCampainBodyToText converts an HTML campaign body to plaintext.
func handleCampainBodyToText(c echo.Context) error {
out, err := html2text.FromString(c.FormValue("body"),
html2text.Options{PrettyTables: false})
if err != nil {
// handleCampaignContent handles campaign content (body) format conversions.
func handleCampaignContent(c echo.Context) error {
var (
app = c.Get("app").(*App)
id, _ = strconv.Atoi(c.Param("id"))
)
if id < 1 {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}
var camp campaignContentReq
if err := c.Bind(&camp); err != nil {
return err
}
return c.HTML(http.StatusOK, string(out))
out, err := camp.ConvertContent(camp.From, camp.To)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
return c.JSON(http.StatusOK, okResp{out})
}
// handleCreateCampaign handles campaign creation.
@ -243,6 +200,15 @@ func handleCreateCampaign(c echo.Context) error {
return err
}
o = op
} else if o.Type == "" {
o.Type = models.CampaignTypeRegular
}
if o.ContentType == "" {
o.ContentType = models.CampaignContentTypeRichtext
}
if o.Messenger == "" {
o.Messenger = "email"
}
// Validate.
@ -252,44 +218,16 @@ func handleCreateCampaign(c echo.Context) error {
o = c
}
uu, err := uuid.NewV4()
if o.ArchiveTemplateID == 0 {
o.ArchiveTemplateID = o.TemplateID
}
out, err := app.core.CreateCampaign(o.Campaign, o.ListIDs, o.MediaIDs)
if err != nil {
app.log.Printf("error generating UUID: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorUUID", "error", err.Error()))
return err
}
// Insert and read ID.
var newID int
if err := app.queries.CreateCampaign.Get(&newID,
uu,
o.Type,
o.Name,
o.Subject,
o.FromEmail,
o.Body,
o.AltBody,
o.ContentType,
o.SendAt,
pq.StringArray(normalizeTags(o.Tags)),
o.Messenger,
o.TemplateID,
o.ListIDs,
); err != nil {
if err == sql.ErrNoRows {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("campaigns.noSubs"))
}
app.log.Printf("error creating campaign: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorCreating",
"name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
}
// Hand over to the GET handler to return the last insertion.
return handleGetCampaigns(copyEchoCtx(c, map[string]string{
"id": fmt.Sprintf("%d", newID),
}))
return c.JSON(http.StatusOK, okResp{out})
}
// handleUpdateCampaign handles campaign modification.
@ -305,17 +243,9 @@ func handleUpdateCampaign(c echo.Context) error {
}
var cm models.Campaign
if err := app.queries.GetCampaign.Get(&cm, id, nil); err != nil {
if err == sql.ErrNoRows {
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.campaign}"))
}
app.log.Printf("error fetching campaign: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorFetching",
"name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
cm, err := app.core.GetCampaign(id, "")
if err != nil {
return err
}
if isCampaignalMutable(cm.Status) {
@ -323,7 +253,7 @@ func handleUpdateCampaign(c echo.Context) error {
}
// Read the incoming params into the existing campaign fields from the DB.
// This allows updating of values that have been sent where as fields
// This allows updating of values that have been sent whereas fields
// that are not in the request retain the old values.
o := campaignReq{Campaign: cm}
if err := c.Bind(&o); err != nil {
@ -336,27 +266,12 @@ func handleUpdateCampaign(c echo.Context) error {
o = c
}
_, err := app.queries.UpdateCampaign.Exec(cm.ID,
o.Name,
o.Subject,
o.FromEmail,
o.Body,
o.AltBody,
o.ContentType,
o.SendAt,
o.SendLater,
pq.StringArray(normalizeTags(o.Tags)),
o.Messenger,
o.TemplateID,
o.ListIDs)
out, err := app.core.UpdateCampaign(id, o.Campaign, o.ListIDs, o.MediaIDs, o.SendLater)
if err != nil {
app.log.Printf("error updating campaign: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorUpdating",
"name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
return err
}
return handleGetCampaigns(c)
return c.JSON(http.StatusOK, okResp{out})
}
// handleUpdateCampaignStatus handles campaign status modification.
@ -370,73 +285,45 @@ func handleUpdateCampaignStatus(c echo.Context) error {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}
var cm models.Campaign
if err := app.queries.GetCampaign.Get(&cm, id, nil); err != nil {
if err == sql.ErrNoRows {
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("globals.message.notFound", "name", "{globals.terms.campaign}"))
}
app.log.Printf("error fetching campaign: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorFetching",
"name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
var o struct {
Status string `json:"status"`
}
// Incoming params.
var o campaignReq
if err := c.Bind(&o); err != nil {
return err
}
errMsg := ""
switch o.Status {
case models.CampaignStatusDraft:
if cm.Status != models.CampaignStatusScheduled {
errMsg = app.i18n.T("campaigns.onlyScheduledAsDraft")
}
case models.CampaignStatusScheduled:
if cm.Status != models.CampaignStatusDraft {
errMsg = app.i18n.T("campaigns.onlyDraftAsScheduled")
}
if !cm.SendAt.Valid {
errMsg = app.i18n.T("campaigns.needsSendAt")
}
case models.CampaignStatusRunning:
if cm.Status != models.CampaignStatusPaused && cm.Status != models.CampaignStatusDraft {
errMsg = app.i18n.T("campaigns.onlyPausedDraft")
}
case models.CampaignStatusPaused:
if cm.Status != models.CampaignStatusRunning {
errMsg = app.i18n.T("campaigns.onlyActivePause")
}
case models.CampaignStatusCancelled:
if cm.Status != models.CampaignStatusRunning && cm.Status != models.CampaignStatusPaused {
errMsg = app.i18n.T("campaigns.onlyActiveCancel")
}
}
if len(errMsg) > 0 {
return echo.NewHTTPError(http.StatusBadRequest, errMsg)
}
res, err := app.queries.UpdateCampaignStatus.Exec(cm.ID, o.Status)
out, err := app.core.UpdateCampaignStatus(id, o.Status)
if err != nil {
app.log.Printf("error updating campaign status: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorUpdating",
"name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("globals.messages.notFound",
"name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
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
}
return handleGetCampaigns(c)
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.
@ -451,26 +338,8 @@ func handleDeleteCampaign(c echo.Context) error {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}
var cm models.Campaign
if err := app.queries.GetCampaign.Get(&cm, id, nil); err != nil {
if err == sql.ErrNoRows {
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("globals.messages.notFound",
"name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
}
app.log.Printf("error fetching campaign: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorFetching",
"name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
}
if _, err := app.queries.DeleteCampaign.Exec(cm.ID); err != nil {
app.log.Printf("error deleting campaign: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorDeleting",
"name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
if err := app.core.DeleteCampaign(id); err != nil {
return err
}
return c.JSON(http.StatusOK, okResp{true})
@ -480,36 +349,35 @@ func handleDeleteCampaign(c echo.Context) error {
func handleGetRunningCampaignStats(c echo.Context) error {
var (
app = c.Get("app").(*App)
out []campaignStats
)
if err := app.queries.GetCampaignStatus.Select(&out, models.CampaignStatusRunning); err != nil {
if err == sql.ErrNoRows {
return c.JSON(http.StatusOK, okResp{[]struct{}{}})
}
out, err := app.core.GetRunningCampaignStats()
if err != nil {
return err
}
app.log.Printf("error fetching campaign stats: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorFetching",
"name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
} else if len(out) == 0 {
if len(out) == 0 {
return c.JSON(http.StatusOK, okResp{[]struct{}{}})
}
// Compute rate.
for i, c := range out {
if c.Started.Valid && c.UpdatedAt.Valid {
diff := c.UpdatedAt.Time.Sub(c.Started.Time).Minutes()
if diff > 0 {
var (
sent = float64(c.Sent)
rate = sent / diff
)
if rate > sent || rate > float64(c.ToSend) {
rate = sent
}
out[i].Rate = rate
diff := int(c.UpdatedAt.Time.Sub(c.Started.Time).Minutes())
if diff < 1 {
diff = 1
}
rate := c.Sent / diff
if rate > c.Sent || rate > c.ToSend {
rate = c.Sent
}
// Rate since the starting of the campaign.
out[i].NetRate = rate
// Realtime running rate over the last minute.
out[i].Rate = app.manager.GetCampaignStats(c.ID).SendRate
}
}
@ -522,6 +390,7 @@ func handleTestCampaign(c echo.Context) error {
var (
app = c.Get("app").(*App)
campID, _ = strconv.Atoi(c.Param("id"))
tplID, _ = strconv.Atoi(c.FormValue("template_id"))
req campaignReq
)
@ -548,29 +417,16 @@ func handleTestCampaign(c echo.Context) error {
for i := 0; i < len(req.SubscriberEmails); i++ {
req.SubscriberEmails[i] = strings.ToLower(strings.TrimSpace(req.SubscriberEmails[i]))
}
var subs models.Subscribers
if err := app.queries.GetSubscribersByEmails.Select(&subs, req.SubscriberEmails); err != nil {
app.log.Printf("error fetching subscribers: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorFetching",
"name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
} else if len(subs) == 0 {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("campaigns.noKnownSubsToTest"))
subs, err := app.core.GetSubscribersByEmail(req.SubscriberEmails)
if err != nil {
return err
}
// The campaign.
var camp models.Campaign
if err := app.queries.GetCampaignForPreview.Get(&camp, campID); err != nil {
if err == sql.ErrNoRows {
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("globals.messages.notFound",
"name", "{globals.terms.campaign}"))
}
app.log.Printf("error fetching campaign: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorFetching",
"name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
camp, err := app.core.GetCampaignForPreview(campID, tplID)
if err != nil {
return err
}
// Override certain values from the DB with incoming values.
@ -581,7 +437,13 @@ func handleTestCampaign(c echo.Context) error {
camp.AltBody = req.AltBody
camp.Messenger = req.Messenger
camp.ContentType = req.ContentType
camp.Headers = req.Headers
camp.TemplateID = req.TemplateID
for _, id := range req.MediaIDs {
if id > 0 {
camp.MediaIDs = append(camp.MediaIDs, int64(id))
}
}
// Send the test messages.
for _, s := range subs {
@ -596,7 +458,51 @@ func handleTestCampaign(c echo.Context) error {
return c.JSON(http.StatusOK, okResp{true})
}
// sendTestMessage takes a campaign and a subsriber and sends out a sample campaign message.
// handleGetCampaignViewAnalytics retrieves view counts for a campaign.
func handleGetCampaignViewAnalytics(c echo.Context) error {
var (
app = c.Get("app").(*App)
typ = c.Param("type")
from = c.QueryParams().Get("from")
to = c.QueryParams().Get("to")
)
ids, err := parseStringIDs(c.Request().URL.Query()["id"])
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("globals.messages.errorInvalidIDs", "error", err.Error()))
}
if len(ids) == 0 {
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("globals.messages.missingFields", "name", "`id`"))
}
if !strHasLen(from, 10, 30) || !strHasLen(to, 10, 30) {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("analytics.invalidDates"))
}
// Campaign link stats.
if typ == "links" {
out, err := app.core.GetCampaignAnalyticsLinks(ids, typ, from, to)
if err != nil {
return err
}
return c.JSON(http.StatusOK, okResp{out})
}
// View, click, bounce stats.
out, err := app.core.GetCampaignAnalyticsCounts(ids, typ, from, to)
if err != nil {
return err
}
return c.JSON(http.StatusOK, okResp{out})
}
// sendTestMessage takes a campaign and a subscriber and sends out a sample campaign message.
func sendTestMessage(sub models.Subscriber, camp *models.Campaign, app *App) error {
if err := camp.CompileTemplate(app.manager.TemplateFuncs(camp)); err != nil {
app.log.Printf("error compiling template: %v", err)
@ -604,24 +510,15 @@ func sendTestMessage(sub models.Subscriber, camp *models.Campaign, app *App) err
app.i18n.Ts("templates.errorCompiling", "error", err.Error()))
}
// Render the message body.
m := app.manager.NewCampaignMessage(camp, sub)
if err := m.Render(); err != nil {
// Create a sample campaign message.
msg, err := app.manager.NewCampaignMessage(camp, sub)
if err != nil {
app.log.Printf("error rendering message: %v", err)
return echo.NewHTTPError(http.StatusNotFound,
app.i18n.Ts("templates.errorRendering", "error", err.Error()))
}
return app.messengers[camp.Messenger].Push(messenger.Message{
From: camp.FromEmail,
To: []string{sub.Email},
Subject: m.Subject(),
ContentType: camp.ContentType,
Body: m.Body(),
AltBody: m.AltBody(),
Subscriber: sub,
Campaign: camp,
})
return app.manager.PushCampaignMessage(msg)
}
// validateCampaignFields validates incoming campaign field values.
@ -629,7 +526,7 @@ func validateCampaignFields(c campaignReq, app *App) (campaignReq, error) {
if c.FromEmail == "" {
c.FromEmail = app.constants.FromEmail
} else if !regexFromAddress.Match([]byte(c.FromEmail)) {
if !subimporter.IsEmail(c.FromEmail) {
if _, err := app.importer.SanitizeEmail(c.FromEmail); err != nil {
return c, errors.New(app.i18n.T("campaigns.fieldInvalidFromEmail"))
}
}
@ -641,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()) {
@ -665,6 +558,14 @@ func validateCampaignFields(c campaignReq, app *App) (campaignReq, error) {
return c, errors.New(app.i18n.Ts("campaigns.fieldInvalidBody", "error", err.Error()))
}
if len(c.Headers) == 0 {
c.Headers = make([]map[string]string, 0)
}
if len(c.ArchiveMeta) == 0 {
c.ArchiveMeta = json.RawMessage("{}")
}
return c, nil
}
@ -683,13 +584,9 @@ func makeOptinCampaignMessage(o campaignReq, app *App) (campaignReq, error) {
}
// Fetch double opt-in lists from the given list IDs.
var lists []models.List
err := app.queries.GetListsByOptin.Select(&lists, models.ListOptinDouble, pq.Int64Array(o.ListIDs), nil)
lists, err := app.core.GetListsByOptin(o.ListIDs, models.ListOptinDouble)
if err != nil {
app.log.Printf("error fetching lists for opt-in: %s", pqErrMsg(err))
return o, echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorFetching",
"name", "{globals.terms.list}", "error", pqErrMsg(err)))
return o, err
}
// No opt-in lists.
@ -707,7 +604,7 @@ func makeOptinCampaignMessage(o campaignReq, app *App) (campaignReq, error) {
// Prepare sample opt-in message for the campaign.
var b bytes.Buffer
if err := app.notifTpls.ExecuteTemplate(&b, "optin-campaign", struct {
if err := app.notifTpls.tpls.ExecuteTemplate(&b, "optin-campaign", struct {
Lists []models.List
OptinURLAttr template.HTMLAttr
}{lists, optinURLAttr}); err != nil {

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,17 +3,17 @@ package main
import (
"crypto/subtle"
"net/http"
"net/url"
"path"
"regexp"
"strconv"
"github.com/labstack/echo"
"github.com/labstack/echo/middleware"
"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"
@ -33,14 +33,49 @@ 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]")
reLangCode = regexp.MustCompile("[^a-zA-Z_0-9\\-]")
paginate = paginator.New(paginator.Opt{
DefaultPerPage: 20,
MaxPerPage: 50,
NumPageNums: 10,
PageParam: "page",
PerPageParam: "per_page",
})
)
// registerHandlers registers HTTP handlers.
func registerHTTPHandlers(e *echo.Echo) {
func initHTTPHandlers(e *echo.Echo, app *App) {
// Group of private handlers with BasicAuth.
g := e.Group("", middleware.BasicAuth(basicAuth))
g.GET("/", handleIndexPage)
var g *echo.Group
if len(app.constants.AdminUsername) == 0 ||
len(app.constants.AdminPassword) == 0 {
g = e.Group("")
} else {
g = e.Group("", middleware.BasicAuth(basicAuth))
}
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"))
g.GET(path.Join(adminRoot, "/*"), handleAdminPage)
// API endpoints.
g.GET("/api/health", handleHealthCheck)
g.GET("/api/config", handleGetServerConfig)
g.GET("/api/lang/:lang", handleGetI18nLang)
@ -49,11 +84,15 @@ func registerHTTPHandlers(e *echo.Echo) {
g.GET("/api/settings", handleGetSettings)
g.PUT("/api/settings", handleUpdateSettings)
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)
g.GET("/api/subscribers/:id/bounces", handleGetSubscriberBounces)
g.DELETE("/api/subscribers/:id/bounces", handleDeleteSubscriberBounces)
g.POST("/api/subscribers", handleCreateSubscriber)
g.PUT("/api/subscribers/:id", handleUpdateSubscriber)
g.POST("/api/subscribers/:id/optin", handleSubscriberSendOptin)
@ -64,6 +103,11 @@ func registerHTTPHandlers(e *echo.Echo) {
g.DELETE("/api/subscribers/:id", handleDeleteSubscribers)
g.DELETE("/api/subscribers", handleDeleteSubscribers)
g.GET("/api/bounces", handleGetBounces)
g.GET("/api/bounces/:id", handleGetBounces)
g.DELETE("/api/bounces", handleDeleteBounces)
g.DELETE("/api/bounces/:id", handleDeleteBounces)
// Subscriber operations based on arbitrary SQL queries.
// These aren't very REST-like.
g.POST("/api/subscribers/query/delete", handleDeleteSubscribersByQuery)
@ -86,17 +130,21 @@ func registerHTTPHandlers(e *echo.Echo) {
g.GET("/api/campaigns", handleGetCampaigns)
g.GET("/api/campaigns/running/stats", handleGetRunningCampaignStats)
g.GET("/api/campaigns/:id", handleGetCampaigns)
g.GET("/api/campaigns/:id", handleGetCampaign)
g.GET("/api/campaigns/analytics/:type", handleGetCampaignViewAnalytics)
g.GET("/api/campaigns/:id/preview", handlePreviewCampaign)
g.POST("/api/campaigns/:id/preview", handlePreviewCampaign)
g.POST("/api/campaigns/:id/content", handleCampaignContent)
g.POST("/api/campaigns/:id/text", handlePreviewCampaign)
g.POST("/api/campaigns/:id/test", handleTestCampaign)
g.POST("/api/campaigns", handleCreateCampaign)
g.PUT("/api/campaigns/:id", handleUpdateCampaign)
g.PUT("/api/campaigns/:id/status", handleUpdateCampaignStatus)
g.PUT("/api/campaigns/:id/archive", handleUpdateCampaignArchive)
g.DELETE("/api/campaigns/:id", handleDeleteCampaign)
g.GET("/api/media", handleGetMedia)
g.GET("/api/media/:id", handleGetMedia)
g.POST("/api/media", handleUploadMedia)
g.DELETE("/api/media/:id", handleDeleteMedia)
@ -109,52 +157,87 @@ func registerHTTPHandlers(e *echo.Echo) {
g.PUT("/api/templates/:id/default", handleTemplateSetDefault)
g.DELETE("/api/templates/:id", handleDeleteTemplate)
// Static admin views.
g.GET("/lists", handleIndexPage)
g.GET("/lists/forms", handleIndexPage)
g.GET("/subscribers", handleIndexPage)
g.GET("/subscribers/lists/:listID", handleIndexPage)
g.GET("/subscribers/import", handleIndexPage)
g.GET("/campaigns", handleIndexPage)
g.GET("/campaigns/new", handleIndexPage)
g.GET("/campaigns/media", handleIndexPage)
g.GET("/campaigns/templates", handleIndexPage)
g.GET("/campaigns/:campignID", handleIndexPage)
g.GET("/settings", handleIndexPage)
g.GET("/settings/logs", handleIndexPage)
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)
// Public bounce endpoints for webservices like SES.
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", validateUUID(subscriberExists(handleSubscriptionPage),
e.GET("/subscription/:campUUID/:subUUID", noIndex(validateUUID(subscriberExists(handleSubscriptionPage),
"campUUID", "subUUID")))
e.POST("/subscription/:campUUID/:subUUID", validateUUID(subscriberExists(handleSubscriptionPrefs),
"campUUID", "subUUID"))
e.POST("/subscription/:campUUID/:subUUID", validateUUID(subscriberExists(handleSubscriptionPage),
"campUUID", "subUUID"))
e.GET("/subscription/optin/:subUUID", validateUUID(subscriberExists(handleOptinPage), "subUUID"))
e.GET("/subscription/optin/:subUUID", noIndex(validateUUID(subscriberExists(handleOptinPage), "subUUID")))
e.POST("/subscription/optin/:subUUID", validateUUID(subscriberExists(handleOptinPage), "subUUID"))
e.POST("/subscription/export/:subUUID", validateUUID(subscriberExists(handleSelfExportSubscriberData),
"subUUID"))
e.POST("/subscription/wipe/:subUUID", validateUUID(subscriberExists(handleWipeSubscriberData),
"subUUID"))
e.GET("/link/:linkUUID/:campUUID/:subUUID", validateUUID(handleLinkRedirect,
"linkUUID", "campUUID", "subUUID"))
e.GET("/campaign/:campUUID/:subUUID", validateUUID(handleViewCampaignMessage,
"campUUID", "subUUID"))
e.GET("/campaign/:campUUID/:subUUID/px.png", validateUUID(handleRegisterCampaignView,
"campUUID", "subUUID"))
e.GET("/link/:linkUUID/:campUUID/:subUUID", noIndex(validateUUID(handleLinkRedirect,
"linkUUID", "campUUID", "subUUID")))
e.GET("/campaign/:campUUID/:subUUID", noIndex(validateUUID(handleViewCampaignMessage,
"campUUID", "subUUID")))
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")
})
}
// handleIndex is the root handler that renders the Javascript frontend.
func handleIndexPage(c echo.Context) error {
// handleAdminPage is the root handler that renders the Javascript admin frontend.
func handleAdminPage(c echo.Context) error {
app := c.Get("app").(*App)
b, err := app.fs.Read("/frontend/index.html")
b, err := app.fs.Read(path.Join(adminRoot, "/index.html"))
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
c.Response().Header().Set("Content-Type", "text/html")
return c.String(http.StatusOK, string(b))
return c.HTMLBlob(http.StatusOK, b)
}
// handleHealthCheck is a healthcheck endpoint that returns a 200 response.
@ -162,6 +245,39 @@ func handleHealthCheck(c echo.Context) error {
return c.JSON(http.StatusOK, okResp{true})
}
// serveCustomApperance serves the given custom CSS/JS appearance blob
// meant for customizing public and admin pages from the admin settings UI.
func serveCustomApperance(name string) echo.HandlerFunc {
return func(c echo.Context) error {
var (
app = c.Get("app").(*App)
out []byte
hdr string
)
switch name {
case "admin.custom_css":
out = app.constants.Appearance.AdminCSS
hdr = "text/css; charset=utf-8"
case "admin.custom_js":
out = app.constants.Appearance.AdminJS
hdr = "application/javascript; charset=utf-8"
case "public.custom_css":
out = app.constants.Appearance.PublicCSS
hdr = "text/css; charset=utf-8"
case "public.custom_js":
out = app.constants.Appearance.PublicJS
hdr = "application/javascript; charset=utf-8"
}
return c.Blob(http.StatusOK, hdr, out)
}
}
// basicAuth middleware does an HTTP BasicAuth authentication for admin handlers.
func basicAuth(username, password string, c echo.Context) (bool, error) {
app := c.Get("app").(*App)
@ -204,67 +320,25 @@ func subscriberExists(next echo.HandlerFunc, params ...string) echo.HandlerFunc
subUUID = c.Param("subUUID")
)
var exists bool
if err := app.queries.SubscriberExists.Get(&exists, 0, subUUID); err != nil {
if _, err := app.core.GetSubscriber(0, subUUID, ""); err != nil {
if er, ok := err.(*echo.HTTPError); ok && er.Code == http.StatusBadRequest {
return c.Render(http.StatusNotFound, tplMessage,
makeMsgTpl(app.i18n.T("public.notFoundTitle"), "", er.Message.(string)))
}
app.log.Printf("error checking subscriber existence: %v", err)
return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl(app.i18n.T("public.errorTitle"), "",
app.i18n.T("public.errorProcessingRequest")))
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.T("public.errorProcessingRequest")))
}
if !exists {
return c.Render(http.StatusNotFound, tplMessage,
makeMsgTpl(app.i18n.T("public.notFoundTitle"), "",
app.i18n.T("public.subNotFound")))
}
return next(c)
}
}
// getPagination takes form values and extracts pagination values from it.
func getPagination(q url.Values, perPage, maxPerPage int) pagination {
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 && ppi <= maxPerPage {
perPage = ppi
}
}
if page < 1 {
page = 0
} else {
page--
}
return pagination{
Page: page + 1,
PerPage: perPage,
Offset: page * perPage,
Limit: perPage,
// noIndex adds the HTTP header requesting robots to not crawl the page.
func noIndex(next echo.HandlerFunc, params ...string) echo.HandlerFunc {
return func(c echo.Context) error {
c.Response().Header().Set("X-Robots-Tag", "noindex")
return next(c)
}
}
// copyEchoCtx returns a copy of the the current echo.Context in a request
// with the given params set for the active handler to proxy the request
// to another handler without mutating its context.
func copyEchoCtx(c echo.Context, params map[string]string) echo.Context {
var (
keys = make([]string, 0, len(params))
vals = make([]string, 0, len(params))
)
for k, v := range params {
keys = append(keys, k)
vals = append(vals, v)
}
b := c.Echo().NewContext(c.Request(), c.Response())
b.Set("app", c.Get("app").(*App))
b.SetParamNames(keys...)
b.SetParamValues(vals...)
return b
}

View file

@ -4,10 +4,11 @@ import (
"encoding/json"
"fmt"
"net/http"
"sort"
"github.com/knadh/listmonk/internal/i18n"
"github.com/knadh/stuffbin"
"github.com/labstack/echo"
"github.com/labstack/echo/v4"
)
type i18nLang struct {
@ -29,8 +30,8 @@ func handleGetI18nLang(c echo.Context) error {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid language code.")
}
i, err := getI18nLang(lang, app.fs)
if err != nil {
i, ok, err := getI18nLang(lang, app.fs)
if err != nil && !ok {
return echo.NewHTTPError(http.StatusBadRequest, "Unknown language.")
}
@ -62,32 +63,38 @@ func getI18nLangList(lang string, app *App) ([]i18nLang, error) {
})
}
sort.SliceStable(out, func(i, j int) bool {
return out[i].Code < out[j].Code
})
return out, nil
}
func getI18nLang(lang string, fs stuffbin.FileSystem) (*i18n.I18n, error) {
// The bool indicates whether the specified language could be loaded. If it couldn't
// be, the app shouldn't halt but throw a warning.
func getI18nLang(lang string, fs stuffbin.FileSystem) (*i18n.I18n, bool, error) {
const def = "en"
b, err := fs.Read(fmt.Sprintf("/i18n/%s.json", def))
if err != nil {
return nil, fmt.Errorf("error reading default i18n language file: %s: %v", def, err)
return nil, false, fmt.Errorf("error reading default i18n language file: %s: %v", def, err)
}
// Initialize with the default language.
i, err := i18n.New(b)
if err != nil {
return nil, fmt.Errorf("error unmarshalling i18n language: %v", err)
return nil, false, fmt.Errorf("error unmarshalling i18n language: %s: %v", lang, err)
}
// Load the selected language on top of it.
b, err = fs.Read(fmt.Sprintf("/i18n/%s.json", lang))
if err != nil {
return nil, fmt.Errorf("error reading i18n language file: %v", err)
return i, true, fmt.Errorf("error reading i18n language file: %s: %v", lang, err)
}
if err := i.Load(b); err != nil {
return nil, fmt.Errorf("error loading i18n language file: %v", err)
return i, true, fmt.Errorf("error loading i18n language file: %s: %v", lang, err)
}
return i, nil
return i, true, nil
}

View file

@ -3,22 +3,15 @@ package main
import (
"encoding/json"
"io"
"io/ioutil"
"os"
"net/http"
"strings"
"github.com/knadh/listmonk/internal/subimporter"
"github.com/labstack/echo"
"github.com/knadh/listmonk/models"
"github.com/labstack/echo/v4"
)
// reqImport represents file upload import params.
type reqImport struct {
Mode string `json:"mode"`
Overwrite bool `json:"overwrite"`
Delim string `json:"delim"`
ListIDs []int `json:"lists"`
}
// handleImportSubscribers handles the uploading and bulk importing of
// a ZIP file of one or more CSV files.
func handleImportSubscribers(c echo.Context) error {
@ -29,18 +22,35 @@ func handleImportSubscribers(c echo.Context) error {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("import.alreadyRunning"))
}
// Unmarsal the JSON params.
var r reqImport
if err := json.Unmarshal([]byte(c.FormValue("params")), &r); err != nil {
// Unmarshal the JSON params.
var opt subimporter.SessionOpt
if err := json.Unmarshal([]byte(c.FormValue("params")), &opt); err != nil {
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("import.invalidParams", "error", err.Error()))
}
if r.Mode != subimporter.ModeSubscribe && r.Mode != subimporter.ModeBlocklist {
// Validate mode.
if opt.Mode != subimporter.ModeSubscribe && opt.Mode != subimporter.ModeBlocklist {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("import.invalidMode"))
}
if len(r.Delim) != 1 {
// If no status is specified, pick a default one.
if opt.SubStatus == "" {
switch opt.Mode {
case subimporter.ModeSubscribe:
opt.SubStatus = models.SubscriptionStatusUnconfirmed
case subimporter.ModeBlocklist:
opt.SubStatus = models.SubscriptionStatusUnsubscribed
}
}
if opt.SubStatus != models.SubscriptionStatusUnconfirmed &&
opt.SubStatus != models.SubscriptionStatusConfirmed &&
opt.SubStatus != models.SubscriptionStatusUnsubscribed {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("import.invalidSubStatus"))
}
if len(opt.Delim) != 1 {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("import.invalidDelim"))
}
@ -56,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()))
@ -69,7 +79,8 @@ func handleImportSubscribers(c echo.Context) error {
}
// Start the importer session.
impSess, err := app.importer.NewSession(file.Filename, r.Mode, r.Overwrite, r.ListIDs)
opt.Filename = file.Filename
impSess, err := app.importer.NewSession(opt)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("import.errorStarting", "error", err.Error()))
@ -77,20 +88,20 @@ func handleImportSubscribers(c echo.Context) error {
go impSess.Start()
if strings.HasSuffix(strings.ToLower(file.Filename), ".csv") {
go impSess.LoadCSV(out.Name(), rune(r.Delim[0]))
go impSess.LoadCSV(out.Name(), rune(opt.Delim[0]))
} else {
// Only 1 CSV from the ZIP is considered. If multiple files have
// to be processed, counting the net number of lines (to track progress),
// keeping the global import state (failed / successful) etc. across
// multiple files becomes complex. Instead, it's just easier for the
// end user to concat multiple CSVs (if there are multiple in the first)
// place and uploada as one in the first place.
// place and upload as one in the first place.
dir, files, err := impSess.ExtractZIP(out.Name(), 1)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("import.errorProcessingZIP", "error", err.Error()))
}
go impSess.LoadCSV(dir+"/"+files[0], rune(r.Delim[0]))
go impSess.LoadCSV(dir+"/"+files[0], rune(opt.Delim[0]))
}
return c.JSON(http.StatusOK, okResp{app.importer.GetStats()})

View file

@ -1,69 +1,114 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"html/template"
"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"
"github.com/knadh/listmonk/models"
"github.com/knadh/stuffbin"
"github.com/labstack/echo"
"github.com/labstack/echo/v4"
"github.com/lib/pq"
flag "github.com/spf13/pflag"
)
const (
queryFilePath = "queries.sql"
// Root URI of the admin frontend.
adminRoot = "/admin"
)
// constants contains static, constant config values required by the app.
type constants struct {
RootURL string `koanf:"root_url"`
LogoURL string `koanf:"logo_url"`
FaviconURL string `koanf:"favicon_url"`
FromEmail string `koanf:"from_email"`
NotifyEmails []string `koanf:"notify_emails"`
EnablePublicSubPage bool `koanf:"enable_public_subscription_page"`
Lang string `koanf:"lang"`
DBBatchSize int `koanf:"batch_size"`
Privacy struct {
SiteName string `koanf:"site_name"`
RootURL string `koanf:"root_url"`
LogoURL string `koanf:"logo_url"`
FaviconURL string `koanf:"favicon_url"`
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 []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"`
UnsubURL string
LinkTrackURL string
ViewTrackURL string
OptinURL string
MessageURL string
MediaProvider string
Appearance struct {
AdminCSS []byte `koanf:"admin.custom_css"`
AdminJS []byte `koanf:"admin.custom_js"`
PublicCSS []byte `koanf:"public.custom_css"`
PublicJS []byte `koanf:"public.custom_js"`
}
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 {
tpls *template.Template
contentType string
}
func initFlags() {
@ -77,12 +122,15 @@ func initFlags() {
// Register the commandline flags.
f.StringSlice("config", []string{"config.toml"},
"path to one or more config files (will be merged in order)")
f.Bool("install", false, "run first time installation")
f.Bool("install", false, "setup database (first time)")
f.Bool("idempotent", false, "make --install run only if the database isn't already setup")
f.Bool("upgrade", false, "upgrade database to the current version")
f.Bool("version", false, "current version of the build")
f.Bool("version", false, "show current version of the build")
f.Bool("new-config", false, "generate sample config file")
f.String("static-dir", "", "(optional) path to directory with static files")
f.Bool("yes", false, "assume 'yes' to prompts, eg: during --install")
f.String("i18n-dir", "", "(optional) path to directory with i18n language files")
f.Bool("yes", false, "assume 'yes' to prompts during --install/upgrade")
f.Bool("passive", false, "run in passive mode where campaigns are not processed")
if err := f.Parse(os.Args[1:]); err != nil {
lo.Fatalf("error loading flags: %v", err)
}
@ -100,89 +148,145 @@ func initConfigFiles(files []string, ko *koanf.Koanf) {
if os.IsNotExist(err) {
lo.Fatal("config file not found. If there isn't one yet, run --new-config to generate one.")
}
lo.Fatalf("error loadng config from file: %v.", err)
lo.Fatalf("error loading config from file: %v.", err)
}
}
}
// initFileSystem initializes the stuffbin FileSystem to provide
// access to bunded static assets to the app.
func initFS(staticDir string) stuffbin.FileSystem {
// access to bundled static assets to the app.
func initFS(appDir, frontendDir, staticDir, i18nDir string) stuffbin.FileSystem {
var (
// stuffbin real_path:virtual_alias paths to map local assets on disk
// when there an embedded filestystem is not found.
// These paths are joined with appDir.
appFiles = []string{
"./config.toml.sample:config.toml.sample",
"./queries.sql:queries.sql",
"./schema.sql:schema.sql",
}
frontendFiles = []string{
// Admin frontend's static assets accessible at /admin/* during runtime.
// These paths are sourced from frontendDir.
"./:/admin",
}
staticFiles = []string{
// These paths are joined with staticDir.
"./email-templates:static/email-templates",
"./public:/public",
}
i18nFiles = []string{
// These paths are joined with i18nDir.
"./:/i18n",
}
)
// Get the executable's path.
path, err := os.Executable()
if err != nil {
lo.Fatalf("error getting executable path: %v", err)
}
// Load the static files stuffed in the binary.
// Load embedded files in the executable.
hasEmbed := true
fs, err := stuffbin.UnStuff(path)
if err != nil {
hasEmbed = false
// Running in local mode. Load local assets into
// the in-memory stuffbin.FileSystem.
lo.Printf("unable to initialize embedded filesystem: %v", err)
lo.Printf("using local filesystem for static assets")
files := []string{
"config.toml.sample",
"queries.sql",
"schema.sql",
"static/email-templates",
lo.Printf("unable to initialize embedded filesystem (%v). Using local filesystem", err)
// Alias /static/public to /public for the HTTP fileserver.
"static/public:/public",
// The frontend app's static assets are aliased to /frontend
// so that they are accessible at /frontend/js/* etc.
// Alias all files inside dist/ and dist/frontend to frontend/*.
"frontend/dist/favicon.png:/frontend/favicon.png",
"frontend/dist/frontend:/frontend",
"i18n:/i18n",
}
fs, err = stuffbin.NewLocalFS("/", files...)
fs, err = stuffbin.NewLocalFS("/")
if err != nil {
lo.Fatalf("failed to initialize local file for assets: %v", err)
}
}
// Optional static directory to override files.
if staticDir != "" {
lo.Printf("loading static files from: %v", staticDir)
fStatic, err := stuffbin.NewLocalFS("/", []string{
filepath.Join(staticDir, "/email-templates") + ":/static/email-templates",
// Alias /static/public to /public for the HTTP fileserver.
filepath.Join(staticDir, "/public") + ":/public",
}...)
if err != nil {
lo.Fatalf("failed reading static directory: %s: %v", staticDir, err)
}
if err := fs.Merge(fStatic); err != nil {
lo.Fatalf("error merging static directory: %s: %v", staticDir, err)
}
// If the embed failed, load app and frontend files from the compile-time paths.
files := []string{}
if !hasEmbed {
files = append(files, joinFSPaths(appDir, appFiles)...)
files = append(files, joinFSPaths(frontendDir, frontendFiles)...)
}
// Irrespective of the embeds, if there are user specified static or i18n paths,
// load files from there and override default files (embedded or picked up from CWD).
if !hasEmbed || i18nDir != "" {
if i18nDir == "" {
// Default dir in cwd.
i18nDir = "i18n"
}
lo.Printf("loading i18n files from: %v", i18nDir)
files = append(files, joinFSPaths(i18nDir, i18nFiles)...)
}
if !hasEmbed || staticDir != "" {
if staticDir == "" {
// Default dir in cwd.
staticDir = "static"
}
lo.Printf("loading static files from: %v", staticDir)
files = append(files, joinFSPaths(staticDir, staticFiles)...)
}
// No additional files to load.
if len(files) == 0 {
return fs
}
// Load files from disk and overlay into the FS.
fStatic, err := stuffbin.NewLocalFS("/", files...)
if err != nil {
lo.Fatalf("failed reading static files from disk: '%s': %v", staticDir, err)
}
if err := fs.Merge(fStatic); err != nil {
lo.Fatalf("error merging static files: '%s': %v", staticDir, err)
}
return fs
}
// initDB initializes the main DB connection pool and parse and loads the app's
// SQL queries into a prepared query map.
func initDB() *sqlx.DB {
var dbCfg dbConf
if err := ko.Unmarshal("db", &dbCfg); err != nil {
var c struct {
Host string `koanf:"host"`
Port int `koanf:"port"`
User string `koanf:"user"`
Password string `koanf:"password"`
DBName string `koanf:"database"`
SSLMode string `koanf:"ssl_mode"`
Params string `koanf:"params"`
MaxOpen int `koanf:"max_open"`
MaxIdle int `koanf:"max_idle"`
MaxLifetime time.Duration `koanf:"max_lifetime"`
}
if err := ko.Unmarshal("db", &c); err != nil {
lo.Fatalf("error loading db config: %v", err)
}
lo.Printf("connecting to db: %s:%d/%s", dbCfg.Host, dbCfg.Port, dbCfg.DBName)
db, err := connectDB(dbCfg)
lo.Printf("connecting to db: %s:%d/%s", c.Host, c.Port, c.DBName)
db, err := sqlx.Connect("postgres",
fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s %s", c.Host, c.Port, c.User, c.Password, c.DBName, c.SSLMode, c.Params))
if err != nil {
lo.Fatalf("error connecting to DB: %v", err)
}
db.SetMaxOpenConns(c.MaxOpen)
db.SetMaxIdleConns(c.MaxIdle)
db.SetConnMaxLifetime(c.MaxLifetime)
return db
}
// initQueries loads named SQL queries from the queries file and optionally
// prepares them.
func initQueries(sqlFile string, db *sqlx.DB, fs stuffbin.FileSystem, prepareQueries bool) (goyesql.Queries, *Queries) {
// readQueries reads named SQL queries from the SQL queries file into a query map.
func readQueries(sqlFile string, db *sqlx.DB, fs stuffbin.FileSystem) goyesql.Queries {
// Load SQL queries.
qB, err := fs.Read(sqlFile)
if err != nil {
@ -193,24 +297,52 @@ func initQueries(sqlFile string, db *sqlx.DB, fs stuffbin.FileSystem, prepareQue
lo.Fatalf("error parsing SQL queries: %v", err)
}
if !prepareQueries {
return qMap, nil
return qMap
}
// prepareQueries queries prepares a query map and returns a *Queries
func prepareQueries(qMap goyesql.Queries, db *sqlx.DB, ko *koanf.Koanf) *models.Queries {
var (
countQuery = "get-campaign-analytics-counts"
linkSel = "*"
)
if ko.Bool("privacy.individual_tracking") {
countQuery = "get-campaign-analytics-unique-counts"
linkSel = "DISTINCT subscriber_id"
}
// Prepare queries.
var q Queries
// These don't exist in the SQL file but are in the queries struct to be prepared.
qMap["get-campaign-view-counts"] = &goyesql.Query{
Query: fmt.Sprintf(qMap[countQuery].Query, "campaign_views"),
Tags: map[string]string{"name": "get-campaign-view-counts"},
}
qMap["get-campaign-click-counts"] = &goyesql.Query{
Query: fmt.Sprintf(qMap[countQuery].Query, "link_clicks"),
Tags: map[string]string{"name": "get-campaign-click-counts"},
}
qMap["get-campaign-link-counts"].Query = fmt.Sprintf(qMap["get-campaign-link-counts"].Query, linkSel)
// Scan and prepare all queries.
var q models.Queries
if err := goyesqlx.ScanToStruct(&q, qMap, db.Unsafe()); err != nil {
lo.Fatalf("error preparing SQL queries: %v", err)
}
return qMap, &q
return &q
}
// initSettings loads settings from the DB.
func initSettings(q *Queries) {
// initSettings loads settings from the DB into the given Koanf map.
func initSettings(query string, db *sqlx.DB, ko *koanf.Koanf) {
var s types.JSONText
if err := q.GetSettings.Get(&s); err != nil {
lo.Fatalf("error reading settings from DB: %s", pqErrMsg(err))
if err := db.Get(&s, query); err != nil {
msg := err.Error()
if err, ok := err.(*pq.Error); ok {
if err.Detail != "" {
msg = fmt.Sprintf("%s. %s", err, err.Detail)
}
}
lo.Fatalf("error reading settings from DB: %s", msg)
}
// Setting keys are dot separated, eg: app.favicon_url. Unflatten them into
@ -231,13 +363,21 @@ func initConstants() *constants {
lo.Fatalf("error loading app config: %v", err)
}
if err := ko.Unmarshal("privacy", &c.Privacy); err != nil {
lo.Fatalf("error loading app config: %v", err)
lo.Fatalf("error loading app.privacy config: %v", err)
}
if err := ko.Unmarshal("security", &c.Security); err != nil {
lo.Fatalf("error loading app.security config: %v", err)
}
if err := ko.UnmarshalWithConf("appearance", &c.Appearance, koanf.UnmarshalConf{FlatPaths: true}); err != nil {
lo.Fatalf("error loading app.appearance config: %v", err)
}
c.RootURL = strings.TrimRight(c.RootURL, "/")
c.Lang = ko.String("app.lang")
c.Privacy.Exportable = maps.StringSliceToLookupMap(ko.Strings("privacy.exportable"))
c.MediaProvider = ko.String("upload.provider")
c.MediaUpload.Provider = ko.String("upload.provider")
c.MediaUpload.Extensions = ko.Strings("upload.extensions")
c.Privacy.DomainBlocklist = ko.Strings("privacy.domain_blocklist")
// Static URLS.
// url.com/subscription/{campaign_uuid}/{subscriber_uuid}
@ -252,8 +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
}
@ -262,15 +411,19 @@ func initConstants() *constants {
// and then the selected language is loaded on top of it so that if there are
// missing translations in it, the default English translations show up.
func initI18n(lang string, fs stuffbin.FileSystem) *i18n.I18n {
i, err := getI18nLang(lang, fs)
i, ok, err := getI18nLang(lang, fs)
if err != nil {
lo.Fatal(err)
if ok {
lo.Println(err)
} else {
lo.Fatal(err)
}
}
return i
}
// initCampaignManager initializes the campaign manager.
func initCampaignManager(q *Queries, cs *constants, app *App) *manager.Manager {
func initCampaignManager(q *models.Queries, cs *constants, app *App) *manager.Manager {
campNotifCB := func(subject string, data interface{}) error {
return app.sendNotification(cs.NotifyEmails, subject, notifTplCampaign, data)
}
@ -282,6 +435,10 @@ func initCampaignManager(q *Queries, cs *constants, app *App) *manager.Manager {
lo.Fatal("app.message_rate should be at least 1")
}
if ko.Bool("passive") {
lo.Println("running in passive mode. won't process campaigns.")
}
return manager.New(manager.Config{
BatchSize: ko.Int("app.batch_size"),
Concurrency: ko.Int("app.concurrency"),
@ -294,18 +451,37 @@ func initCampaignManager(q *Queries, cs *constants, app *App) *manager.Manager {
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"),
}, newManagerDB(q), campNotifCB, app.i18n, lo)
ScanInterval: time.Second * 5,
ScanCampaigns: !ko.Bool("passive"),
}, newManagerStore(q, app.core, app.media), campNotifCB, app.i18n, lo)
}
func initTxTemplates(m *manager.Manager, app *App) {
tpls, err := app.core.GetTemplates(models.TemplateTypeTx, false)
if err != nil {
lo.Fatalf("error loading transactional templates: %v", err)
}
for _, t := range tpls {
tpl := t
if err := tpl.Compile(app.manager.GenericTemplateFuncs()); err != nil {
lo.Printf("error compiling transactional template %d: %v", tpl.ID, err)
continue
}
m.CacheTpl(tpl.ID, &tpl)
}
}
// initImporter initializes the bulk subscriber importer.
func initImporter(q *Queries, db *sqlx.DB, app *App) *subimporter.Importer {
func initImporter(q *models.Queries, db *sqlx.DB, app *App) *subimporter.Importer {
return subimporter.New(
subimporter.Options{
DomainBlocklist: app.constants.Privacy.DomainBlocklist,
UpsertStmt: q.UpsertSubscriber.Stmt,
BlocklistStmt: q.UpsertBlocklistSubscriber.Stmt,
UpdateListDateStmt: q.UpdateListsDate.Stmt,
@ -313,11 +489,11 @@ func initImporter(q *Queries, db *sqlx.DB, app *App) *subimporter.Importer {
app.sendNotification(app.constants.NotifyEmails, subject, notifTplImport, data)
return nil
},
}, db.DB)
}, db.DB, app.i18n)
}
// 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))
@ -328,7 +504,7 @@ func initSMTPMessenger(m *manager.Manager) messenger.Messenger {
lo.Fatalf("no SMTP servers found in config")
}
// Load the config for multipme SMTP servers.
// Load the config for multiple SMTP servers.
for _, item := range items {
if !item.Bool("enabled") {
continue
@ -359,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
@ -397,8 +573,9 @@ func initPostbackMessengers(m *manager.Manager) []messenger.Messenger {
func initMediaStore() media.Store {
switch provider := ko.String("upload.provider"); provider {
case "s3":
var o s3.Opts
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)
@ -413,7 +590,7 @@ func initMediaStore() media.Store {
o.RootURL = ko.String("app.root_url")
o.UploadPath = filepath.Clean(o.UploadPath)
o.UploadURI = filepath.Clean(o.UploadURI)
up, err := filesystem.NewDiskStore(o)
up, err := filesystem.New(o)
if err != nil {
lo.Fatalf("error initializing filesystem upload provider %s", err)
}
@ -428,25 +605,120 @@ func initMediaStore() media.Store {
// initNotifTemplates compiles and returns e-mail notification templates that are
// used for sending ad-hoc notifications to admins and subscribers.
func initNotifTemplates(path string, fs stuffbin.FileSystem, i *i18n.I18n, cs *constants) *template.Template {
// Register utility functions that the e-mail templates can use.
funcs := template.FuncMap{
"RootURL": func() string {
return cs.RootURL
},
"LogoURL": func() string {
return cs.LogoURL
},
"L": func() *i18n.I18n {
return i
},
}
tpl, err := stuffbin.ParseTemplatesGlob(funcs, fs, "/static/email-templates/*.html")
func initNotifTemplates(path string, fs stuffbin.FileSystem, i *i18n.I18n, cs *constants) *notifTpls {
tpls, err := stuffbin.ParseTemplatesGlob(initTplFuncs(i, cs), fs, "/static/email-templates/*.html")
if err != nil {
lo.Fatalf("error parsing e-mail notif templates: %v", err)
}
return tpl
html, err := fs.Read("/static/email-templates/base.html")
if err != nil {
lo.Fatalf("error reading static/email-templates/base.html: %v", err)
}
out := &notifTpls{
tpls: tpls,
contentType: models.CampaignContentTypeHTML,
}
// Determine whether the notification templates are HTML or plaintext.
// Copy the first few (arbitrary) bytes of the template and check if has the <!doctype html> tag.
ln := 256
if len(html) < ln {
ln = len(html)
}
h := make([]byte, ln)
copy(h, html[0:ln])
if !bytes.Contains(bytes.ToLower(h), []byte("<!doctype html")) {
out.contentType = models.CampaignContentTypePlain
lo.Println("system e-mail templates are plaintext")
}
return out
}
// initBounceManager initializes the bounce manager that scans mailboxes and listens to webhooks
// for incoming bounce events.
func initBounceManager(app *App) *bounce.Manager {
opt := bounce.Opt{
WebhooksEnabled: ko.Bool("bounce.webhooks_enabled"),
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,
}
// For now, only one mailbox is supported.
for _, b := range ko.Slices("bounce.mailboxes") {
if !b.Bool("enabled") {
continue
}
var boxOpt mailbox.Opt
if err := b.UnmarshalWithConf("", &boxOpt, koanf.UnmarshalConf{Tag: "json"}); err != nil {
lo.Fatalf("error reading bounce mailbox config: %v", err)
}
opt.MailboxType = b.String("type")
opt.MailboxEnabled = true
opt.Mailbox = boxOpt
break
}
b, err := bounce.New(opt, &bounce.Queries{
RecordQuery: app.queries.RecordBounce,
}, app.log)
if err != nil {
lo.Fatalf("error initializing bounce manager: %v", err)
}
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.
@ -463,31 +735,36 @@ 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()
srv.GET("/public/*", echo.WrapHandler(fSrv))
srv.GET("/frontend/*", echo.WrapHandler(fSrv))
if ko.String("upload.provider") == "filesystem" {
srv.Static(ko.String("upload.filesystem.upload_uri"),
ko.String("upload.filesystem.upload_path"))
// Public (subscriber) facing static files.
srv.GET("/public/static/*", echo.WrapHandler(fSrv))
// Admin (frontend) facing static files.
srv.GET("/admin/static/*", echo.WrapHandler(fSrv))
// Public (subscriber) facing media upload files.
if ko.String("upload.provider") == "filesystem" && ko.String("upload.filesystem.upload_uri") != "" {
srv.Static(ko.String("upload.filesystem.upload_uri"), ko.String("upload.filesystem.upload_path"))
}
// Register all HTTP handlers.
registerHTTPHandlers(srv)
initHTTPHandlers(srv, app)
// Start the server.
go func() {
@ -503,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)
@ -534,3 +817,44 @@ func awaitReload(sigChan chan os.Signal, closerWait chan bool, closer func()) ch
return out
}
func joinFSPaths(root string, paths []string) []string {
out := make([]string, 0, len(paths))
for _, p := range paths {
// real_path:stuffbin_alias
f := strings.Split(p, ":")
out = append(out, path.Join(root, f[0])+":"+f[1])
}
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

@ -1,16 +1,14 @@
package main
import (
"errors"
"encoding/json"
"fmt"
"io/ioutil"
"os"
"regexp"
"strings"
"github.com/gofrs/uuid"
"github.com/jmoiron/sqlx"
goyesqlx "github.com/knadh/goyesql/v2/sqlx"
"github.com/knadh/listmonk/models"
"github.com/knadh/stuffbin"
"github.com/lib/pq"
@ -18,18 +16,22 @@ import (
// install runs the first time setup of creating and
// migrating the database and creating the super user.
func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt bool) {
qMap, _ := initQueries(queryFilePath, db, fs, false)
func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempotent bool) {
qMap := readQueries(queryFilePath, db, fs)
fmt.Println("")
fmt.Println("** first time installation **")
fmt.Printf("** IMPORTANT: This will wipe existing listmonk tables and types in the DB '%s' **",
ko.String("db.database"))
if !idempotent {
fmt.Println("** first time installation **")
fmt.Printf("** IMPORTANT: This will wipe existing listmonk tables and types in the DB '%s' **",
ko.String("db.database"))
} else {
fmt.Println("** first time (idempotent) installation **")
}
fmt.Println("")
if prompt {
var ok string
fmt.Print("continue (y/n)? ")
fmt.Print("continue (y/N)? ")
if _, err := fmt.Scanf("%s", &ok); err != nil {
lo.Fatalf("error reading value from terminal: %v", err)
}
@ -39,17 +41,26 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt bool) {
}
}
// If idempotence is on, check if the DB is already setup.
if idempotent {
if _, err := db.Exec("SELECT count(*) FROM settings"); err != nil {
// If "settings" doesn't exist, assume it's a fresh install.
if pqErr, ok := err.(*pq.Error); ok && pqErr.Code != "42P01" {
lo.Fatalf("error checking existing DB schema: %v", err)
}
} else {
lo.Println("skipping install as database appears to be already setup")
os.Exit(0)
}
}
// Migrate the tables.
err := installSchema(lastVer, db, fs)
if err != nil {
lo.Fatalf("Error migrating DB schema: %v", err)
if err := installSchema(lastVer, db, fs); err != nil {
lo.Fatalf("error migrating DB schema: %v", err)
}
// Load the queries.
var q Queries
if err := goyesqlx.ScanToStruct(&q, qMap, db.Unsafe()); err != nil {
lo.Fatalf("error loading SQL queries: %v", err)
}
q := prepareQueries(qMap, db, ko)
// Sample list.
var (
@ -62,8 +73,9 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt bool) {
models.ListTypePrivate,
models.ListOptinSingle,
pq.StringArray{"test"},
"",
); err != nil {
lo.Fatalf("Error creating list: %v", err)
lo.Fatalf("error creating list: %v", err)
}
if err := q.CreateList.Get(&optinList, uuid.Must(uuid.NewV4()),
@ -71,8 +83,9 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt bool) {
models.ListTypePublic,
models.ListOptinDouble,
pq.StringArray{"test"},
"",
); err != nil {
lo.Fatalf("Error creating list: %v", err)
lo.Fatalf("error creating list: %v", err)
}
// Sample subscriber.
@ -82,6 +95,7 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt bool) {
"John Doe",
`{"type": "known", "good": true, "city": "Bengaluru"}`,
pq.Int64Array{int64(defList)},
models.SubscriptionStatusUnconfirmed,
true); err != nil {
lo.Fatalf("Error creating subscriber: %v", err)
}
@ -91,27 +105,36 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt bool) {
"Anon Doe",
`{"type": "unknown", "good": true, "city": "Bengaluru"}`,
pq.Int64Array{int64(optinList)},
models.SubscriptionStatusUnconfirmed,
true); err != nil {
lo.Fatalf("Error creating subscriber: %v", err)
lo.Fatalf("error creating subscriber: %v", err)
}
// Default template.
tplBody, err := fs.Get("/static/email-templates/default.tpl")
// Default campaign template.
campTpl, err := fs.Get("/static/email-templates/default.tpl")
if err != nil {
lo.Fatalf("error reading default e-mail template: %v", err)
}
var tplID int
if err := q.CreateTemplate.Get(&tplID,
"Default template",
string(tplBody.ReadBytes()),
); err != nil {
lo.Fatalf("error creating default template: %v", err)
var campTplID int
if err := q.CreateTemplate.Get(&campTplID, "Default campaign template", models.TemplateTypeCampaign, "", campTpl.ReadBytes()); err != nil {
lo.Fatalf("error creating default campaign template: %v", err)
}
if _, err := q.SetDefaultTemplate.Exec(tplID); err != nil {
if _, err := q.SetDefaultTemplate.Exec(campTplID); err != nil {
lo.Fatalf("error setting default template: %v", err)
}
// Default campaign archive template.
archiveTpl, err := fs.Get("/static/email-templates/default-archive.tpl")
if err != nil {
lo.Fatalf("error reading default archive template: %v", err)
}
var archiveTplID int
if err := q.CreateTemplate.Get(&archiveTplID, "Default archive template", models.TemplateTypeCampaign, "", archiveTpl.ReadBytes()); err != nil {
lo.Fatalf("error creating default campaign template: %v", err)
}
// Sample campaign.
if _, err := q.CreateCampaign.Exec(uuid.Must(uuid.NewV4()),
models.CampaignTypeRegular,
@ -119,20 +142,41 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt bool) {
"Welcome to listmonk",
"No Reply <noreply@yoursite.com>",
`<h3>Hi {{ .Subscriber.FirstName }}!</h3>
This is a test e-mail campaign. Your second name is {{ .Subscriber.LastName }} and you are from {{ .Subscriber.Attribs.city }}.`,
<p>This is a test e-mail campaign. Your second name is {{ .Subscriber.LastName }} and you are from {{ .Subscriber.Attribs.city }}.</p>
<p>Here is a <a href="https://listmonk.app@TrackLink">tracked link</a>.</p>
<p>Use the link icon in the editor toolbar or when writing raw HTML or Markdown,
simply suffix @TrackLink to the end of a URL to turn it into a tracking link. Example:</p>
<pre>&lt;a href=&quot;https:/&zwnj;/listmonk.app&#064;TrackLink&quot;&gt;&lt;/a&gt;</pre>
<p>For help, refer to the <a href="https://listmonk.app/docs">documentation</a>.</p>
`,
nil,
"richtext",
nil,
json.RawMessage("[]"),
pq.StringArray{"test-campaign"},
emailMsgr,
1,
campTplID,
pq.Int64Array{1},
false,
archiveTplID,
`{"name": "Subscriber"}`,
nil,
); err != nil {
lo.Fatalf("error creating sample campaign: %v", err)
}
lo.Printf("Setup complete")
lo.Printf(`Run the program and access the dashboard at %s`, ko.MustString("app.address"))
// Sample tx template.
txTpl, err := fs.Get("/static/email-templates/sample-tx.tpl")
if err != nil {
lo.Fatalf("error reading default e-mail template: %v", err)
}
if _, err := q.CreateTemplate.Exec("Sample transactional template", models.TemplateTypeTx, "Welcome {{ .Subscriber.Name }}", txTpl.ReadBytes()); err != nil {
lo.Fatalf("error creating sample transactional template: %v", err)
}
lo.Printf("setup complete")
lo.Printf(`run the program and access the dashboard at %s`, ko.MustString("app.address"))
}
// installSchema executes the SQL schema and creates the necessary tables and types.
@ -159,14 +203,14 @@ func recordMigrationVersion(ver string, db *sqlx.DB) error {
return err
}
func newConfigFile() error {
if _, err := os.Stat("config.toml"); !os.IsNotExist(err) {
return errors.New("config.toml exists. Remove it to generate a new one")
func newConfigFile(path string) error {
if _, err := os.Stat(path); !os.IsNotExist(err) {
return fmt.Errorf("%s exists. Remove it to generate a new one.", path)
}
// Initialize the static file system into which all
// required static assets (.sql, .js files etc.) are loaded.
fs := initFS("")
fs := initFS(appDir, "", "", "")
b, err := fs.Read("config.toml.sample")
if err != nil {
return fmt.Errorf("error reading sample config (is binary stuffed?): %v", err)
@ -179,7 +223,7 @@ func newConfigFile() error {
ReplaceAll(b, []byte(fmt.Sprintf(`admin_password = "%s"`, pwd)))
}
return ioutil.WriteFile("config.toml", b, 0644)
return os.WriteFile(path, b, 0644)
}
// checkSchema checks if the DB schema is installed.

View file

@ -1,84 +1,83 @@
package main
import (
"fmt"
"net/http"
"strconv"
"strings"
"github.com/gofrs/uuid"
"github.com/knadh/listmonk/models"
"github.com/lib/pq"
"github.com/labstack/echo"
"github.com/labstack/echo/v4"
)
type listsWrap struct {
Results []models.List `json:"results"`
Total int `json:"total"`
PerPage int `json:"per_page"`
Page int `json:"page"`
}
var (
listQuerySortFields = []string{"name", "type", "subscriber_count", "created_at", "updated_at"}
)
// handleGetLists handles retrieval of lists.
// handleGetLists retrieves lists with additional metadata like subscriber counts. This may be slow.
func handleGetLists(c echo.Context) error {
var (
app = c.Get("app").(*App)
out listsWrap
pg = app.paginator.NewFromURL(c.Request().URL.Query())
pg = getPagination(c.QueryParams(), 20, 50)
orderBy = c.FormValue("order_by")
order = c.FormValue("order")
listID, _ = strconv.Atoi(c.Param("id"))
single = false
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.
single := false
if listID > 0 {
single = true
}
// Sort params.
if !strSliceContains(orderBy, listQuerySortFields) {
orderBy = "created_at"
}
if order != sortAsc && order != sortDesc {
order = sortAsc
if single {
out, err := app.core.GetList(listID, "")
if err != nil {
return err
}
return c.JSON(http.StatusOK, okResp{out})
}
if err := db.Select(&out.Results, fmt.Sprintf(app.queries.QueryLists, orderBy, order), listID, pg.Offset, pg.Limit); err != nil {
app.log.Printf("error fetching lists: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorFetching",
"name", "{globals.terms.lists}", "error", pqErrMsg(err)))
// Minimal query simply returns the list of all lists without JOIN subscriber counts. This is fast.
if !single && minimal {
res, err := app.core.GetLists("")
if err != nil {
return err
}
if len(res) == 0 {
return c.JSON(http.StatusOK, okResp{[]struct{}{}})
}
// Meta.
out.Results = res
out.Total = len(res)
out.Page = 1
out.PerPage = out.Total
return c.JSON(http.StatusOK, okResp{out})
}
if single && len(out.Results) == 0 {
// Full list query.
res, total, err := app.core.QueryLists(query, orderBy, order, pg.Offset, pg.Limit)
if err != nil {
return err
}
if single && len(res) == 0 {
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.list}"))
}
if len(out.Results) == 0 {
return c.JSON(http.StatusOK, okResp{[]struct{}{}})
}
// Replace null tags.
for i, v := range out.Results {
if v.Tags == nil {
out.Results[i].Tags = make(pq.StringArray, 0)
}
}
if single {
return c.JSON(http.StatusOK, okResp{out.Results[0]})
return c.JSON(http.StatusOK, okResp{res[0]})
}
// Meta.
out.Total = out.Results[0].Total
out.Query = query
out.Results = res
out.Total = total
out.Page = pg.Page
out.PerPage = pg.PerPage
return c.JSON(http.StatusOK, okResp{out})
}
@ -86,44 +85,24 @@ func handleGetLists(c echo.Context) error {
func handleCreateList(c echo.Context) error {
var (
app = c.Get("app").(*App)
o = models.List{}
l = models.List{}
)
if err := c.Bind(&o); err != nil {
if err := c.Bind(&l); err != nil {
return err
}
// Validate.
if !strHasLen(o.Name, 1, stdInputMaxLen) {
if !strHasLen(l.Name, 1, stdInputMaxLen) {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("lists.invalidName"))
}
uu, err := uuid.NewV4()
out, err := app.core.CreateList(l)
if err != nil {
app.log.Printf("error generating UUID: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorUUID", "error", err.Error()))
return err
}
// Insert and read ID.
var newID int
o.UUID = uu.String()
if err := app.queries.CreateList.Get(&newID,
o.UUID,
o.Name,
o.Type,
o.Optin,
pq.StringArray(normalizeTags(o.Tags))); err != nil {
app.log.Printf("error creating list: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorCreating",
"name", "{globals.terms.list}", "error", pqErrMsg(err)))
}
// Hand over to the GET handler to return the last insertion.
return handleGetLists(copyEchoCtx(c, map[string]string{
"id": fmt.Sprintf("%d", newID),
}))
return c.JSON(http.StatusOK, okResp{out})
}
// handleUpdateList handles list modification.
@ -138,35 +117,30 @@ func handleUpdateList(c echo.Context) error {
}
// Incoming params.
var o models.List
if err := c.Bind(&o); err != nil {
var l models.List
if err := c.Bind(&l); err != nil {
return err
}
res, err := app.queries.UpdateList.Exec(id,
o.Name, o.Type, o.Optin, pq.StringArray(normalizeTags(o.Tags)))
// Validate.
if !strHasLen(l.Name, 1, stdInputMaxLen) {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("lists.invalidName"))
}
out, err := app.core.UpdateList(id, l)
if err != nil {
app.log.Printf("error updating list: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorUpdating",
"name", "{globals.terms.list}", "error", pqErrMsg(err)))
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.list}"))
}
return handleGetLists(c)
return c.JSON(http.StatusOK, okResp{out})
}
// handleDeleteLists handles deletion deletion,
// either a single one (ID in the URI), or a list.
// handleDeleteLists handles list deletion, either a single one (ID in the URI), or a list.
func handleDeleteLists(c echo.Context) error {
var (
app = c.Get("app").(*App)
id, _ = strconv.ParseInt(c.Param("id"), 10, 64)
ids pq.Int64Array
ids []int
)
if id < 1 && len(ids) == 0 {
@ -174,14 +148,11 @@ func handleDeleteLists(c echo.Context) error {
}
if id > 0 {
ids = append(ids, id)
ids = append(ids, int(id))
}
if _, err := app.queries.DeleteLists.Exec(ids); err != nil {
app.log.Printf("error deleting lists: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorDeleting",
"name", "{globals.terms.list}", "error", pqErrMsg(err)))
if err := app.core.DeleteLists(ids); err != nil {
return err
}
return c.JSON(http.StatusOK, okResp{true})

View file

@ -3,7 +3,6 @@ package main
import (
"context"
"fmt"
"html/template"
"io"
"log"
"os"
@ -14,14 +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"
)
@ -32,21 +36,27 @@ const (
// App contains the "global" components that are
// passed around, especially through HTTP handlers.
type App struct {
core *core.Core
fs stuffbin.FileSystem
db *sqlx.DB
queries *Queries
queries *models.Queries
constants *constants
manager *manager.Manager
importer *subimporter.Importer
messengers map[string]messenger.Messenger
messengers map[string]manager.Messenger
media media.Store
i18n *i18n.I18n
notifTpls *template.Template
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.
@ -59,17 +69,25 @@ 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(".")
fs stuffbin.FileSystem
db *sqlx.DB
queries *Queries
queries *models.Queries
// Compile-time variables.
buildString string
versionString string
// If these are set in build ldflags and static assets (*.sql, config.toml.sample. ./frontend)
// are not embedded (in make dist), these paths are looked up. The default values before, when not
// overridden by build flags, are relative to the CWD at runtime.
appDir string = "."
frontendDir string = "frontend"
)
func init() {
@ -85,11 +103,12 @@ func init() {
// Generate new config.
if ko.Bool("new-config") {
if err := newConfigFile(); err != nil {
path := ko.Strings("config")[0]
if err := newConfigFile(path); err != nil {
lo.Println(err)
os.Exit(1)
}
lo.Println("generated config.toml. Edit and run --install")
lo.Printf("generated %s. Edit and run --install", path)
os.Exit(0)
}
@ -106,13 +125,13 @@ func init() {
// Connect to the database, load the filesystem to read SQL queries.
db = initDB()
fs = initFS(ko.String("static-dir"))
fs = initFS(appDir, frontendDir, ko.String("static-dir"), ko.String("i18n-dir"))
// Installer mode? This runs before the SQL queries are loaded and prepared
// as the installer needs to work on an empty DB.
if ko.Bool("install") {
// Save the version of the last listed migration.
install(migList[len(migList)-1].version, db, fs, !ko.Bool("yes"))
install(migList[len(migList)-1].version, db, fs, !ko.Bool("yes"), ko.Bool("idempotent"))
os.Exit(0)
}
@ -131,11 +150,16 @@ func init() {
// Before the queries are prepared, see if there are pending upgrades.
checkUpgrade(db)
// Load the SQL queries from the filesystem.
_, queries := initQueries(queryFilePath, db, fs, true)
// Read the SQL queries from the queries file.
qMap := readQueries(queryFilePath, db, fs)
// Load settings from DB.
initSettings(queries)
if q, ok := qMap["get-settings"]; ok {
initSettings(q.Query, db, ko)
}
// Prepare queries.
queries = prepareQueries(qMap, db, ko)
}
func main() {
@ -146,18 +170,52 @@ 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)
cOpt := &core.Opt{
Constants: core.Constants{
SendOptinConfirmation: app.constants.SendOptinConfirmation,
},
Queries: queries,
DB: db,
I18n: app.i18n,
Log: lo,
}
_, app.queries = initQueries(queryFilePath, db, fs, true)
if err := ko.Unmarshal("bounce.actions", &cOpt.Constants.BounceActions); err != nil {
lo.Fatalf("error unmarshalling bounce config: %v", err)
}
app.core = core.New(cOpt, &core.Hooks{
SendOptinConfirmation: sendOptinConfirmationHook(app),
})
app.queries = queries
app.manager = initCampaignManager(app.queries, app.constants, app)
app.importer = initImporter(app.queries, db, app)
app.notifTpls = initNotifTemplates("/email-templates/*.html", fs, app.i18n, app.constants)
initTxTemplates(app.manager, app)
if ko.Bool("bounce.enabled") {
app.bounce = initBounceManager(app)
go app.bounce.Run()
}
// Initialize the default SMTP (`email`) messenger.
app.messengers[emailMsgr] = initSMTPMessenger(app.manager)
@ -172,24 +230,29 @@ 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(time.Second * 5)
go app.manager.Run()
// Start the app server.
srv := initHTTPServer(app)
// Star the update checker.
go checkUpdates(versionString, time.Hour*24, app)
if ko.Bool("app.check_updates") {
go checkUpdates(versionString, time.Hour*24, app)
}
// 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,66 +0,0 @@
package main
import (
"github.com/gofrs/uuid"
"github.com/knadh/listmonk/models"
"github.com/lib/pq"
)
// runnerDB implements runner.DataSource over the primary
// database.
type runnerDB struct {
queries *Queries
}
func newManagerDB(q *Queries) *runnerDB {
return &runnerDB{
queries: q,
}
}
// NextCampaigns retrieves active campaigns ready to be processed.
func (r *runnerDB) NextCampaigns(excludeIDs []int64) ([]*models.Campaign, error) {
var out []*models.Campaign
err := r.queries.NextCampaigns.Select(&out, pq.Int64Array(excludeIDs))
return out, err
}
// NextSubscribers retrieves a subset of subscribers of a given campaign.
// 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) {
var out []models.Subscriber
err := r.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) {
var out = &models.Campaign{}
err := r.queries.GetCampaign.Get(out, campID, nil)
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)
return err
}
// CreateLink registers a URL with a UUID for tracking clicks and returns the UUID.
func (r *runnerDB) 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()
if err != nil {
return "", err
}
var out string
if err := r.queries.CreateLink.Get(&out, uu, url); err != nil {
return "", err
}
return out, nil
}

123
cmd/manager_store.go Normal file
View file

@ -0,0 +1,123 @@
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"
)
// store implements DataSource over the primary
// database.
type store struct {
queries *models.Queries
core *core.Core
media media.Store
h *http.Client
}
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 (s *store) NextCampaigns(excludeIDs []int64) ([]*models.Campaign, error) {
var out []*models.Campaign
err := s.queries.NextCampaigns.Select(&out, pq.Int64Array(excludeIDs))
return out, err
}
// NextSubscribers retrieves a subset of subscribers of a given campaign.
// 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 (s *store) NextSubscribers(campID, limit int) ([]models.Subscriber, error) {
var out []models.Subscriber
err := s.queries.NextCampaignSubscribers.Select(&out, campID, limit)
return out, err
}
// GetCampaign fetches a campaign from the database.
func (s *store) GetCampaign(campID int) (*models.Campaign, error) {
var out = &models.Campaign{}
err := s.queries.GetCampaign.Get(out, campID, nil, "default")
return out, err
}
// UpdateCampaignStatus updates a campaign's 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 (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()
if err != nil {
return "", err
}
var out string
if err := s.queries.CreateLink.Get(&out, uu, url); err != nil {
return "", err
}
return out, nil
}
// RecordBounce records a bounce event and returns the bounce count.
func (s *store) RecordBounce(b models.Bounce) (int64, int, error) {
var res = struct {
SubscriberID int64 `db:"subscriber_id"`
Num int `db:"num"`
}{}
err := s.queries.UpdateCampaignStatus.Select(&res,
b.SubscriberUUID,
b.Email,
b.CampaignUUID,
b.Type,
b.Source,
b.Meta)
return res.SubscriberID, res.Num, err
}
func (s *store) BlocklistSubscriber(id int64) error {
_, err := s.queries.BlocklistSubscribers.Exec(pq.Int64Array{id})
return err
}
func (s *store) DeleteSubscriber(id int64) error {
_, err := s.queries.DeleteSubscribers.Exec(pq.Int64Array{id})
return err
}

View file

@ -4,26 +4,24 @@ import (
"bytes"
"mime/multipart"
"net/http"
"path/filepath"
"strconv"
"strings"
"github.com/disintegration/imaging"
"github.com/gofrs/uuid"
"github.com/knadh/listmonk/internal/media"
"github.com/labstack/echo"
"github.com/knadh/listmonk/models"
"github.com/labstack/echo/v4"
)
const (
thumbPrefix = "thumb_"
thumbnailSize = 90
thumbnailSize = 250
)
// imageMimes is the list of image types allowed to be uploaded.
var imageMimes = []string{
"image/jpg",
"image/jpeg",
"image/png",
"image/svg",
"image/gif"}
var (
vectorExts = []string{"svg"}
imageExts = []string{"gif", "png", "jpg", "jpeg"}
)
// handleUploadMedia handles media file uploads.
func handleUploadMedia(c echo.Context) error {
@ -37,16 +35,6 @@ func handleUploadMedia(c echo.Context) error {
app.i18n.Ts("media.invalidFile", "error", err.Error()))
}
// Validate MIME type with the list of allowed types.
var typ = file.Header.Get("Content-type")
if ok := validateMIME(typ, imageMimes); !ok {
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("media.unsupportedFileType", "type", typ))
}
// Generate filename
fName := generateFileName(file.Filename)
// Read file contents in memory
src, err := file.Open()
if err != nil {
@ -55,76 +43,117 @@ 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
}
uu, err := uuid.NewV4()
if err != nil {
app.log.Printf("error generating UUID: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorUUID", "error", err.Error()))
if inArray(ext, vectorExts) {
thumbfName = fName
}
// Write to the DB.
if _, err := app.queries.InsertMedia.Exec(uu, fName, thumbfName, app.constants.MediaProvider); err != nil {
cleanUp = true
app.log.Printf("error inserting uploaded file to db: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorCreating",
"name", "{globals.terms.media}", "error", pqErrMsg(err)))
meta := models.JSON{}
if isImage {
meta = models.JSON{
"width": width,
"height": height,
}
}
return c.JSON(http.StatusOK, okResp{true})
m, err := app.core.InsertMedia(fName, thumbfName, contentType, meta, app.constants.MediaUpload.Provider, app.media)
if err != nil {
cleanUp = true
return err
}
return c.JSON(http.StatusOK, okResp{m})
}
// handleGetMedia handles retrieval of uploaded media.
func handleGetMedia(c echo.Context) error {
var (
app = c.Get("app").(*App)
out = []media.Media{}
app = c.Get("app").(*App)
pg = app.paginator.NewFromURL(c.Request().URL.Query())
query = c.FormValue("query")
id, _ = strconv.Atoi(c.Param("id"))
)
if err := app.queries.GetMedia.Select(&out, app.constants.MediaProvider); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorFetching",
"name", "{globals.terms.media}", "error", pqErrMsg(err)))
// Fetch one list.
if id > 0 {
out, err := app.core.GetMedia(id, "", app.media)
if err != nil {
return err
}
return c.JSON(http.StatusOK, okResp{out})
}
for i := 0; i < len(out); i++ {
out[i].URL = app.media.Get(out[i].Filename)
out[i].ThumbURL = app.media.Get(out[i].Thumb)
res, total, err := app.core.QueryMedia(app.constants.MediaUpload.Provider, app.media, query, pg.Offset, pg.Limit)
if err != nil {
return err
}
out := models.PageResults{
Results: res,
Total: total,
Page: pg.Page,
PerPage: pg.PerPage,
}
return c.JSON(http.StatusOK, okResp{out})
@ -141,29 +170,29 @@ func handleDeleteMedia(c echo.Context) error {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}
var m media.Media
if err := app.queries.DeleteMedia.Get(&m, id); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorDeleting",
"name", "{globals.terms.media}", "error", pqErrMsg(err)))
fname, err := app.core.DeleteMedia(id)
if err != nil {
return err
}
app.media.Delete(m.Filename)
app.media.Delete(thumbPrefix + m.Filename)
app.media.Delete(fname)
app.media.Delete(thumbPrefix + fname)
return c.JSON(http.StatusOK, okResp{true})
}
// createThumbnail reads the file object and returns a smaller image
func createThumbnail(file *multipart.FileHeader) (*bytes.Reader, error) {
// processImage reads the image file and returns thumbnail bytes and
// the original image's width, and height.
func processImage(file *multipart.FileHeader) (*bytes.Reader, int, int, error) {
src, err := file.Open()
if err != nil {
return nil, err
return nil, 0, 0, err
}
defer src.Close()
img, err := imaging.Decode(src)
if err != nil {
return nil, err
return nil, 0, 0, err
}
// Encode the image into a byte slice as PNG.
@ -172,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 (
@ -22,13 +22,18 @@ type notifData struct {
// sendNotification sends out an e-mail notification to admins.
func (app *App) sendNotification(toEmails []string, subject, tplName string, data interface{}) error {
if len(toEmails) == 0 {
return nil
}
var b bytes.Buffer
if err := app.notifTpls.ExecuteTemplate(&b, tplName, data); err != nil {
if err := app.notifTpls.tpls.ExecuteTemplate(&b, tplName, data); err != nil {
app.log.Printf("error compiling notification template '%s': %v", tplName, err)
return err
}
m := manager.Message{}
m := models.Message{}
m.ContentType = app.notifTpls.contentType
m.From = app.constants.FromEmail
m.To = toEmails
m.Subject = subject

View file

@ -13,10 +13,9 @@ import (
"strings"
"github.com/knadh/listmonk/internal/i18n"
"github.com/knadh/listmonk/internal/messenger"
"github.com/knadh/listmonk/internal/subimporter"
"github.com/knadh/listmonk/internal/manager"
"github.com/knadh/listmonk/models"
"github.com/labstack/echo"
"github.com/labstack/echo/v4"
"github.com/lib/pq"
)
@ -26,20 +25,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,12 +79,8 @@ type msgTpl struct {
type subFormTpl struct {
publicTpl
Lists []models.List
}
type subForm struct {
subimporter.SubReq
SubListUUIDs []string `form:"l"`
Lists []models.List
CaptchaKey string
}
var (
@ -85,14 +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 {
@ -103,53 +140,47 @@ func handleViewCampaignMessage(c echo.Context) error {
)
// Get the campaign.
var camp models.Campaign
if err := app.queries.GetCampaign.Get(&camp, 0, campUUID); err != nil {
if err == sql.ErrNoRows {
return c.Render(http.StatusNotFound, tplMessage,
makeMsgTpl(app.i18n.T("public.notFoundTitle"), "",
app.i18n.T("public.campaignNotFound")))
camp, err := app.core.GetCampaign(0, campUUID)
if err != nil {
if er, ok := err.(*echo.HTTPError); ok {
if er.Code == http.StatusBadRequest {
return c.Render(http.StatusNotFound, tplMessage,
makeMsgTpl(app.i18n.T("public.notFoundTitle"), "", app.i18n.T("public.campaignNotFound")))
}
}
app.log.Printf("error fetching campaign: %v", err)
return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl(app.i18n.T("public.errorTitle"), "",
app.i18n.Ts("public.errorFetchingCampaign")))
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorFetchingCampaign")))
}
// Get the subscriber.
var sub models.Subscriber
if err := app.queries.GetSubscriber.Get(&sub, 0, subUUID); err != nil {
sub, err := app.core.GetSubscriber(0, subUUID, "")
if err != nil {
if err == sql.ErrNoRows {
return c.Render(http.StatusNotFound, tplMessage,
makeMsgTpl(app.i18n.T("public.notFoundTitle"), "",
app.i18n.T("public.errorFetchingEmail")))
makeMsgTpl(app.i18n.T("public.notFoundTitle"), "", app.i18n.T("public.errorFetchingEmail")))
}
app.log.Printf("error fetching campaign subscriber: %v", err)
return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl(app.i18n.T("public.errorTitle"), "",
app.i18n.Ts("public.errorFetchingCampaign")))
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorFetchingCampaign")))
}
// Compile the template.
if err := camp.CompileTemplate(app.manager.TemplateFuncs(&camp)); err != nil {
app.log.Printf("error compiling template: %v", err)
return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl(app.i18n.T("public.errorTitle"), "",
app.i18n.Ts("public.errorFetchingCampaign")))
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorFetchingCampaign")))
}
// Render the message body.
m := app.manager.NewCampaignMessage(&camp, sub)
if err := m.Render(); err != nil {
msg, err := app.manager.NewCampaignMessage(&camp, sub)
if err != nil {
app.log.Printf("error rendering message: %v", err)
return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl(app.i18n.T("public.errorTitle"), "",
app.i18n.Ts("public.errorFetchingCampaign")))
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorFetchingCampaign")))
}
return c.HTML(http.StatusOK, string(m.Body()))
return c.HTML(http.StatusOK, string(msg.Body()))
}
// handleSubscriptionPage renders the subscription management page and
@ -157,41 +188,150 @@ 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
}
if _, err := app.queries.Unsubscribe.Exec(campUUID, subUUID, blocklist); 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")))
}
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.unsubbedTitle"), "",
app.i18n.T("public.unsubbedInfo")))
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.T("public.errorProcessingRequest")))
}
return c.Render(http.StatusOK, tplMessage,
makeMsgTpl(app.i18n.T("public.unsubbedTitle"), "", app.i18n.T("public.unsubbedInfo")))
}
// 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
// see when they click on the "Confirm subscription" button in double-optin
// notifications.
@ -216,41 +356,44 @@ func handleOptinPage(c echo.Context) error {
for _, l := range out.ListUUIDs {
if !reUUID.MatchString(l) {
return c.Render(http.StatusBadRequest, tplMessage,
makeMsgTpl(app.i18n.T("public.errorTitle"), "",
app.i18n.T("globals.messages.invalidUUID")))
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.T("globals.messages.invalidUUID")))
}
}
}
// Get the list of subscription lists where the subscriber hasn't confirmed.
if err := app.queries.GetSubscriberLists.Select(&out.Lists, 0, subUUID,
nil, pq.StringArray(out.ListUUIDs), models.SubscriptionStatusUnconfirmed, nil); err != nil {
app.log.Printf("error fetching lists for opt-in: %s", pqErrMsg(err))
lists, err := app.core.GetSubscriberLists(0, subUUID, nil, out.ListUUIDs, models.SubscriptionStatusUnconfirmed, "")
if err != nil {
return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl(app.i18n.T("public.errorTitle"), "",
app.i18n.Ts("public.errorFetchingLists")))
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorFetchingLists")))
}
// There are no lists to confirm.
if len(out.Lists) == 0 {
return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl(app.i18n.T("public.noSubTitle"), "",
app.i18n.Ts("public.noSubInfo")))
if len(lists) == 0 {
return c.Render(http.StatusOK, tplMessage,
makeMsgTpl(app.i18n.T("public.noSubTitle"), "", app.i18n.Ts("public.noSubInfo")))
}
out.Lists = lists
// Confirm.
if confirm {
if _, err := app.queries.ConfirmSubscriptionOptin.Exec(subUUID, pq.StringArray(out.ListUUIDs)); err != nil {
meta := models.JSON{}
if app.constants.Privacy.RecordOptinIP {
if h := c.Request().Header.Get("X-Forwarded-For"); h != "" {
meta["optin_ip"] = h
} else if h := c.Request().RemoteAddr; h != "" {
meta["optin_ip"] = strings.Split(h, ":")[0]
}
}
if err := app.core.ConfirmOptionSubscription(subUUID, out.ListUUIDs, meta); err != nil {
app.log.Printf("error unsubscribing: %v", err)
return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl(app.i18n.T("public.errorTitle"), "",
app.i18n.Ts("public.errorProcessingRequest")))
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorProcessingRequest")))
}
return c.Render(http.StatusOK, tplMessage,
makeMsgTpl(app.i18n.T("public.subConfirmedTitle"), "",
app.i18n.Ts("public.subConfirmed")))
makeMsgTpl(app.i18n.T("public.subConfirmedTitle"), "", app.i18n.Ts("public.subConfirmed")))
}
return c.Render(http.StatusOK, "optin", out)
@ -265,28 +408,29 @@ func handleSubscriptionFormPage(c echo.Context) error {
if !app.constants.EnablePublicSubPage {
return c.Render(http.StatusNotFound, tplMessage,
makeMsgTpl(app.i18n.T("public.errorTitle"), "",
app.i18n.Ts("public.invalidFeature")))
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.invalidFeature")))
}
// Get all public lists.
var lists []models.List
if err := app.queries.GetLists.Select(&lists, models.ListTypePublic); err != nil {
app.log.Printf("error fetching public lists for form: %s", pqErrMsg(err))
lists, err := app.core.GetLists(models.ListTypePublic)
if err != nil {
return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl(app.i18n.T("public.errorTitle"), "",
app.i18n.Ts("public.errorFetchingLists")))
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorFetchingLists")))
}
if len(lists) == 0 {
return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl(app.i18n.T("public.errorTitle"), "",
app.i18n.Ts("public.noListsAvailable")))
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.noListsAvailable")))
}
out := subFormTpl{}
out.Title = app.i18n.T("public.sub")
out.Lists = lists
if app.constants.Security.EnableCaptcha {
out.CaptchaKey = app.constants.Security.CaptchaKey
}
return c.Render(http.StatusOK, "subscription-form", out)
}
@ -295,39 +439,35 @@ func handleSubscriptionFormPage(c echo.Context) error {
func handleSubscriptionForm(c echo.Context) error {
var (
app = c.Get("app").(*App)
req subForm
)
// Get and validate fields.
if err := c.Bind(&req); err != nil {
return err
// If there's a nonce value, a bot could've filled the form.
if c.FormValue("nonce") != "" {
return 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.Email = strings.ToLower(req.Email)
if req.Name == "" {
req.Name = strings.Split(req.Email, "@")[0]
}
// Validate fields.
if err := subimporter.ValidateFields(req.SubReq); err != nil {
return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl(app.i18n.T("public.errorTitle"), "", err.Error()))
}
// Insert the subscriber into the DB.
req.Status = models.SubscriberStatusEnabled
req.ListUUIDs = pq.StringArray(req.SubListUUIDs)
_, _, hasOptin, err := insertSubscriber(req.SubReq, app)
hasOptin, err := processSubForm(c)
if err != nil {
return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl(app.i18n.T("public.errorTitle"), "", fmt.Sprintf("%s", err.(*echo.HTTPError).Message)))
e, ok := err.(*echo.HTTPError)
if !ok {
return e
}
return c.Render(e.Code, tplMessage,
makeMsgTpl(app.i18n.T("public.errorTitle"), "", fmt.Sprintf("%s", e.Message)))
}
msg := "public.subConfirmed"
@ -338,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.
@ -354,18 +515,10 @@ func handleLinkRedirect(c echo.Context) error {
subUUID = ""
}
var url string
if err := app.queries.RegisterLinkClick.Get(&url, linkUUID, campUUID, subUUID); err != nil {
if pqErr, ok := err.(*pq.Error); ok && pqErr.Column == "link_id" {
return c.Render(http.StatusNotFound, tplMessage,
makeMsgTpl(app.i18n.T("public.errorTitle"), "",
app.i18n.Ts("public.invalidLink")))
}
app.log.Printf("error fetching redirect link: %s", err)
return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl(app.i18n.T("public.errorTitle"), "",
app.i18n.Ts("public.errorProcessingRequest")))
url, err := app.core.RegisterCampaignLinkClick(linkUUID, campUUID, subUUID)
if err != nil {
e := err.(*echo.HTTPError)
return c.Render(e.Code, tplMessage, makeMsgTpl(app.i18n.T("public.errorTitle"), "", e.Error()))
}
return c.Redirect(http.StatusTemporaryRedirect, url)
@ -389,7 +542,7 @@ func handleRegisterCampaignView(c echo.Context) error {
// Exclude dummy hits from template previews.
if campUUID != dummyUUID && subUUID != dummyUUID {
if _, err := app.queries.RegisterCampaignView.Exec(campUUID, subUUID); err != nil {
if err := app.core.RegisterCampaignView(campUUID, subUUID); err != nil {
app.log.Printf("error registering campaign view: %s", err)
}
}
@ -410,8 +563,7 @@ func handleSelfExportSubscriberData(c echo.Context) error {
// Is export allowed?
if !app.constants.Privacy.AllowExport {
return c.Render(http.StatusBadRequest, tplMessage,
makeMsgTpl(app.i18n.T("public.errorTitle"), "",
app.i18n.Ts("public.invalidFeature")))
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.invalidFeature")))
}
// Get the subscriber's data. A single query that gets the profile,
@ -421,43 +573,40 @@ func handleSelfExportSubscriberData(c echo.Context) error {
if err != nil {
app.log.Printf("error exporting subscriber data: %s", err)
return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl(app.i18n.T("public.errorTitle"), "",
app.i18n.Ts("public.errorProcessingRequest")))
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorProcessingRequest")))
}
// Prepare the attachment e-mail.
var msg bytes.Buffer
if err := app.notifTpls.ExecuteTemplate(&msg, notifSubscriberData, data); err != nil {
if err := app.notifTpls.tpls.ExecuteTemplate(&msg, notifSubscriberData, data); err != nil {
app.log.Printf("error compiling notification template '%s': %v", notifSubscriberData, err)
return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl(app.i18n.T("public.errorTitle"), "",
app.i18n.Ts("public.errorProcessingRequest")))
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorProcessingRequest")))
}
// Send the data as a JSON attachment to the subscriber.
const fname = "data.json"
if err := app.messengers[emailMsgr].Push(messenger.Message{
From: app.constants.FromEmail,
To: []string{data.Email},
Subject: "Your data",
Body: msg.Bytes(),
Attachments: []messenger.Attachment{
if err := app.messengers[emailMsgr].Push(models.Message{
ContentType: app.notifTpls.contentType,
From: app.constants.FromEmail,
To: []string{data.Email},
Subject: app.i18n.Ts("email.data.title"),
Body: msg.Bytes(),
Attachments: []models.Attachment{
{
Name: fname,
Content: b,
Header: messenger.MakeAttachmentHeader(fname, "base64"),
Header: manager.MakeAttachmentHeader(fname, "base64", "application/json"),
},
},
}); err != nil {
app.log.Printf("error e-mailing subscriber profile: %s", err)
return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl(app.i18n.T("public.errorTitle"), "",
app.i18n.Ts("public.errorProcessingRequest")))
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorProcessingRequest")))
}
return c.Render(http.StatusOK, tplMessage,
makeMsgTpl(app.i18n.T("public.dataSentTitle"), "",
app.i18n.T("public.dataSent")))
makeMsgTpl(app.i18n.T("public.dataSentTitle"), "", app.i18n.T("public.dataSent")))
}
// handleWipeSubscriberData allows a subscriber to delete their data. The
@ -472,20 +621,17 @@ func handleWipeSubscriberData(c echo.Context) error {
// Is wiping allowed?
if !app.constants.Privacy.AllowWipe {
return c.Render(http.StatusBadRequest, tplMessage,
makeMsgTpl(app.i18n.T("public.errorTitle"), "",
app.i18n.Ts("public.invalidFeature")))
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.invalidFeature")))
}
if _, err := app.queries.DeleteSubscribers.Exec(nil, pq.StringArray{subUUID}); err != nil {
if err := app.core.DeleteSubscribers(nil, []string{subUUID}); err != nil {
app.log.Printf("error wiping subscriber data: %s", err)
return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl(app.i18n.T("public.errorTitle"), "",
app.i18n.Ts("public.errorProcessingRequest")))
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorProcessingRequest")))
}
return c.Render(http.StatusOK, tplMessage,
makeMsgTpl(app.i18n.T("public.dataRemovedTitle"), "",
app.i18n.T("public.dataRemoved")))
makeMsgTpl(app.i18n.T("public.dataRemovedTitle"), "", app.i18n.T("public.dataRemoved")))
}
// drawTransparentImage draws a transparent PNG of given dimensions
@ -498,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

@ -1,84 +1,46 @@
package main
import (
"encoding/json"
"bytes"
"io"
"net/http"
"regexp"
"runtime"
"strings"
"syscall"
"time"
"unicode/utf8"
"github.com/gofrs/uuid"
"github.com/jmoiron/sqlx/types"
"github.com/labstack/echo"
"github.com/knadh/koanf/parsers/json"
"github.com/knadh/koanf/providers/rawbytes"
"github.com/knadh/koanf/v2"
"github.com/knadh/listmonk/internal/messenger/email"
"github.com/knadh/listmonk/models"
"github.com/labstack/echo/v4"
)
type settings struct {
AppRootURL string `json:"app.root_url"`
AppLogoURL string `json:"app.logo_url"`
AppFaviconURL string `json:"app.favicon_url"`
AppFromEmail string `json:"app.from_email"`
AppNotifyEmails []string `json:"app.notify_emails"`
EnablePublicSubPage bool `json:"app.enable_public_subscription_page"`
AppLang string `json:"app.lang"`
const pwdMask = "•"
AppBatchSize int `json:"app.batch_size"`
AppConcurrency int `json:"app.concurrency"`
AppMaxSendErrors int `json:"app.max_send_errors"`
AppMessageRate int `json:"app.message_rate"`
AppMessageSlidingWindow bool `json:"app.message_sliding_window"`
AppMessageSlidingWindowDuration string `json:"app.message_sliding_window_duration"`
AppMessageSlidingWindowRate int `json:"app.message_sliding_window_rate"`
PrivacyIndividualTracking bool `json:"privacy.individual_tracking"`
PrivacyUnsubHeader bool `json:"privacy.unsubscribe_header"`
PrivacyAllowBlocklist bool `json:"privacy.allow_blocklist"`
PrivacyAllowExport bool `json:"privacy.allow_export"`
PrivacyAllowWipe bool `json:"privacy.allow_wipe"`
PrivacyExportable []string `json:"privacy.exportable"`
UploadProvider string `json:"upload.provider"`
UploadFilesystemUploadPath string `json:"upload.filesystem.upload_path"`
UploadFilesystemUploadURI string `json:"upload.filesystem.upload_uri"`
UploadS3AwsAccessKeyID string `json:"upload.s3.aws_access_key_id"`
UploadS3AwsDefaultRegion string `json:"upload.s3.aws_default_region"`
UploadS3AwsSecretAccessKey string `json:"upload.s3.aws_secret_access_key,omitempty"`
UploadS3Bucket string `json:"upload.s3.bucket"`
UploadS3BucketDomain string `json:"upload.s3.bucket_domain"`
UploadS3BucketPath string `json:"upload.s3.bucket_path"`
UploadS3BucketType string `json:"upload.s3.bucket_type"`
UploadS3Expiry string `json:"upload.s3.expiry"`
SMTP []struct {
UUID string `json:"uuid"`
Enabled bool `json:"enabled"`
Host string `json:"host"`
HelloHostname string `json:"hello_hostname"`
Port int `json:"port"`
AuthProtocol string `json:"auth_protocol"`
Username string `json:"username"`
Password string `json:"password,omitempty"`
EmailHeaders []map[string]string `json:"email_headers"`
MaxConns int `json:"max_conns"`
MaxMsgRetries int `json:"max_msg_retries"`
IdleTimeout string `json:"idle_timeout"`
WaitTimeout string `json:"wait_timeout"`
TLSEnabled bool `json:"tls_enabled"`
TLSSkipVerify bool `json:"tls_skip_verify"`
} `json:"smtp"`
Messengers []struct {
UUID string `json:"uuid"`
Enabled bool `json:"enabled"`
Name string `json:"name"`
RootURL string `json:"root_url"`
Username string `json:"username"`
Password string `json:"password,omitempty"`
MaxConns int `json:"max_conns"`
Timeout string `json:"timeout"`
MaxMsgRetries int `json:"max_msg_retries"`
} `json:"messengers"`
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 (
@ -89,19 +51,25 @@ var (
func handleGetSettings(c echo.Context) error {
app := c.Get("app").(*App)
s, err := getSettings(app)
s, err := app.core.GetSettings()
if err != nil {
return err
}
// Empty out passwords.
for i := 0; i < len(s.SMTP); i++ {
s.SMTP[i].Password = ""
s.SMTP[i].Password = strings.Repeat(pwdMask, utf8.RuneCountInString(s.SMTP[i].Password))
}
for i := 0; i < len(s.BounceBoxes); i++ {
s.BounceBoxes[i].Password = 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.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})
}
@ -110,7 +78,7 @@ func handleGetSettings(c echo.Context) error {
func handleUpdateSettings(c echo.Context) error {
var (
app = c.Get("app").(*App)
set settings
set models.Settings
)
// Unmarshal and marshal the fields once to sanitize the settings blob.
@ -119,7 +87,7 @@ func handleUpdateSettings(c echo.Context) error {
}
// Get the existing settings.
cur, err := getSettings(app)
cur, err := app.core.GetSettings()
if err != nil {
return err
}
@ -131,7 +99,7 @@ func handleUpdateSettings(c echo.Context) error {
has = true
}
// Assign a UUID. The frontend only sends a password when the user explictly
// Assign a UUID. The frontend only sends a password when the user explicitly
// changes the password. In other cases, the existing password in the DB
// is copied while updating the settings and the UUID is used to match
// the incoming array of SMTP blocks with the array in the DB.
@ -153,6 +121,33 @@ 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
// changes the password. In other cases, the existing password in the DB
// is copied while updating the settings and the UUID is used to match
// the incoming array of blocks with the array in the DB.
if s.UUID == "" {
set.BounceBoxes[i].UUID = uuid.Must(uuid.NewV4()).String()
}
if d, _ := time.ParseDuration(s.ScanInterval); d.Minutes() < 1 {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("settings.bounces.invalidScanInterval"))
}
// If there's no password coming in from the frontend, copy the existing
// password by matching the UUID.
if s.Password == "" {
for _, c := range cur.BounceBoxes {
if s.UUID == c.UUID {
set.BounceBoxes[i].Password = c.Password
}
}
}
}
// Validate and sanitize postback Messenger names. Duplicates are disallowed
// and "email" is a reserved name.
names := map[string]bool{emailMsgr: true}
@ -188,19 +183,33 @@ func handleUpdateSettings(c echo.Context) error {
if set.UploadS3AwsSecretAccessKey == "" {
set.UploadS3AwsSecretAccessKey = cur.UploadS3AwsSecretAccessKey
}
// Marshal settings.
b, err := json.Marshal(set)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("settings.errorEncoding", "error", err.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)
for _, d := range set.DomainBlocklist {
d = strings.TrimSpace(strings.ToLower(d))
if d != "" {
doms = append(doms, d)
}
}
set.DomainBlocklist = doms
// Update the settings in the DB.
if _, err := app.queries.UpdateSettings.Exec(b); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorUpdating",
"name", "{globals.terms.settings}", "error", pqErrMsg(err)))
if err := app.core.UpdateSettings(set); err != nil {
return err
}
// If there are any active campaigns, don't do an auto reload and
@ -218,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})
@ -230,23 +239,76 @@ func handleGetLogs(c echo.Context) error {
return c.JSON(http.StatusOK, okResp{app.bufLog.Lines()})
}
func getSettings(app *App) (settings, error) {
// handleTestSMTPSettings returns the log entries stored in the log buffer.
func handleTestSMTPSettings(c echo.Context) error {
app := c.Get("app").(*App)
// Copy the raw JSON post body.
reqBody, err := io.ReadAll(c.Request().Body)
if err != nil {
app.log.Printf("error reading SMTP test: %v", err)
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.internalError"))
}
// Load the JSON into koanf to parse SMTP settings properly including timestrings.
ko := koanf.New(".")
if err := ko.Load(rawbytes.Provider(reqBody), json.Parser()); err != nil {
app.log.Printf("error unmarshalling SMTP test request: %v", err)
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.internalError"))
}
req := email.Server{}
if err := ko.UnmarshalWithConf("", &req, koanf.UnmarshalConf{Tag: "json"}); err != nil {
app.log.Printf("error scanning SMTP test request: %v", err)
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.internalError"))
}
to := ko.String("email")
if to == "" {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.missingFields", "name", "email"))
}
// Initialize a new SMTP pool.
req.MaxConns = 1
req.IdleTimeout = time.Second * 2
req.PoolWaitTimeout = time.Second * 2
msgr, err := email.New(req)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("globals.messages.errorCreating", "name", "SMTP", "error", err.Error()))
}
var b bytes.Buffer
if err := app.notifTpls.tpls.ExecuteTemplate(&b, "smtp-test", nil); err != nil {
app.log.Printf("error compiling notification template '%s': %v", "smtp-test", err)
return err
}
m := models.Message{}
m.ContentType = app.notifTpls.contentType
m.From = app.constants.FromEmail
m.To = []string{to}
m.Subject = app.i18n.T("settings.smtp.testConnection")
m.Body = b.Bytes()
if err := msgr.Push(m); err != nil {
app.log.Printf("error sending SMTP test (%s): %v", m.Subject, err)
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
return c.JSON(http.StatusOK, okResp{app.bufLog.Lines()})
}
func handleGetAboutInfo(c echo.Context) error {
var (
b types.JSONText
out settings
app = c.Get("app").(*App)
mem runtime.MemStats
)
if err := app.queries.GetSettings.Get(&b); err != nil {
return out, echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorFetching",
"name", "{globals.terms.settings}", "error", pqErrMsg(err)))
}
runtime.ReadMemStats(&mem)
// Unmarshall the settings and filter out sensitive fields.
if err := json.Unmarshal([]byte(b), &out); err != nil {
return out, echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("settings.errorEncoding", "error", err.Error()))
}
out := app.about
out.System.AllocMB = mem.Alloc / 1024 / 1024
out.System.OSMB = mem.Sys / 1024 / 1024
return out, nil
return c.JSON(http.StatusOK, out)
}

View file

@ -1,8 +1,6 @@
package main
import (
"context"
"database/sql"
"encoding/csv"
"encoding/json"
"errors"
@ -12,11 +10,8 @@ import (
"strconv"
"strings"
"github.com/gofrs/uuid"
"github.com/knadh/listmonk/internal/subimporter"
"github.com/knadh/listmonk/models"
"github.com/labstack/echo"
"github.com/lib/pq"
"github.com/labstack/echo/v4"
)
const (
@ -26,20 +21,12 @@ const (
// subQueryReq is a "catch all" struct for reading various
// subscriber related requests.
type subQueryReq struct {
Query string `json:"query"`
ListIDs pq.Int64Array `json:"list_ids"`
TargetListIDs pq.Int64Array `json:"target_list_ids"`
SubscriberIDs pq.Int64Array `json:"ids"`
Action string `json:"action"`
}
type subsWrap struct {
Results models.Subscribers `json:"results"`
Query string `json:"query"`
Total int `json:"total"`
PerPage int `json:"per_page"`
Page int `json:"page"`
Query string `json:"query"`
ListIDs []int `json:"list_ids"`
TargetListIDs []int `json:"target_list_ids"`
SubscriberIDs []int `json:"ids"`
Action string `json:"action"`
Status string `json:"status"`
}
// subProfileData represents a subscriber's collated data in JSON
@ -54,17 +41,19 @@ type subProfileData struct {
// subOptin contains the data that's passed to the double opt-in e-mail template.
type subOptin struct {
*models.Subscriber
models.Subscriber
OptinURL string
UnsubURL string
Lists []models.List
}
var (
dummySubscriber = models.Subscriber{
Email: "dummy@listmonk.app",
Name: "Dummy Subscriber",
UUID: dummyUUID,
Email: "demo@listmonk.app",
Name: "Demo Subscriber",
UUID: dummyUUID,
Attribs: models.JSON{"city": "Bengaluru"},
}
subQuerySortFields = []string{"email", "name", "created_at", "updated_at"}
@ -83,85 +72,41 @@ func handleGetSubscriber(c echo.Context) error {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}
sub, err := getSubscriber(id, "", "", app)
out, err := app.core.GetSubscriber(id, "", "")
if err != nil {
return err
}
return c.JSON(http.StatusOK, okResp{sub})
return c.JSON(http.StatusOK, okResp{out})
}
// handleQuerySubscribers handles querying subscribers based on an arbitrary SQL expression.
func handleQuerySubscribers(c echo.Context) error {
var (
app = c.Get("app").(*App)
pg = getPagination(c.QueryParams(), 30, 100)
// Limit the subscribers to a particular list?
listID, _ = strconv.Atoi(c.FormValue("list_id"))
pg = app.paginator.NewFromURL(c.Request().URL.Query())
// The "WHERE ?" bit.
query = sanitizeSQLExp(c.FormValue("query"))
orderBy = c.FormValue("order_by")
order = c.FormValue("order")
out subsWrap
out models.PageResults
)
listIDs := pq.Int64Array{}
if listID < 0 {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.errorID"))
} else if listID > 0 {
listIDs = append(listIDs, int64(listID))
}
// There's an arbitrary query condition.
cond := ""
if query != "" {
cond = " AND " + query
}
// Sort params.
if !strSliceContains(orderBy, subQuerySortFields) {
orderBy = "updated_at"
}
if order != sortAsc && order != sortDesc {
order = sortAsc
}
stmt := fmt.Sprintf(app.queries.QuerySubscribers, cond, orderBy, order)
// Create a readonly transaction to prevent mutations.
tx, err := app.db.BeginTxx(context.Background(), &sql.TxOptions{ReadOnly: true})
// Limit the subscribers to specific lists?
listIDs, err := getQueryInts("list_id", c.QueryParams())
if err != nil {
app.log.Printf("error preparing subscriber query: %v", err)
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("subscribers.errorPreparingQuery", "error", pqErrMsg(err)))
}
defer tx.Rollback()
// Run the query. stmt is the raw SQL query.
if err := tx.Select(&out.Results, stmt, listIDs, pg.Offset, pg.Limit); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorFetching",
"name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}
// Lazy load lists for each subscriber.
if err := out.Results.LoadLists(app.queries.GetSubscriberListsLazy); err != nil {
app.log.Printf("error fetching subscriber lists: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorFetching",
"name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
res, total, err := app.core.QuerySubscribers(query, listIDs, order, orderBy, pg.Offset, pg.Limit)
if err != nil {
return err
}
out.Query = query
if len(out.Results) == 0 {
out.Results = make(models.Subscribers, 0)
return c.JSON(http.StatusOK, okResp{out})
}
// Meta.
out.Total = out.Results[0].Total
out.Results = res
out.Total = total
out.Page = pg.Page
out.PerPage = pg.PerPage
@ -173,55 +118,29 @@ func handleExportSubscribers(c echo.Context) error {
var (
app = c.Get("app").(*App)
// Limit the subscribers to a particular list?
listID, _ = strconv.Atoi(c.FormValue("list_id"))
// The "WHERE ?" bit.
query = sanitizeSQLExp(c.FormValue("query"))
)
listIDs := pq.Int64Array{}
if listID < 0 {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.errorID"))
} else if listID > 0 {
listIDs = append(listIDs, int64(listID))
}
// There's an arbitrary query condition.
cond := ""
if query != "" {
cond = " AND " + query
}
stmt := fmt.Sprintf(app.queries.QuerySubscribersForExport, cond)
// Verify that the arbitrary SQL search expression is read only.
if cond != "" {
tx, err := app.db.Unsafe().BeginTxx(context.Background(), &sql.TxOptions{ReadOnly: true})
if err != nil {
app.log.Printf("error preparing subscriber query: %v", err)
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("subscribers.errorPreparingQuery", "error", pqErrMsg(err)))
}
defer tx.Rollback()
if _, err := tx.Query(stmt, nil, 0, 1); err != nil {
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("subscribers.errorPreparingQuery", "error", pqErrMsg(err)))
}
}
// Prepare the actual query statement.
tx, err := db.Preparex(stmt)
// Limit the subscribers to specific lists?
listIDs, err := getQueryInts("list_id", c.QueryParams())
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("subscribers.errorPreparingQuery", "error", pqErrMsg(err)))
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}
// Run the query until all rows are exhausted.
var (
id = 0
// Export only specific subscriber IDs?
subIDs, err := getQueryInts("id", c.QueryParams())
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}
// Get the batched export iterator.
exp, err := app.core.ExportSubscribers(query, subIDs, listIDs, app.constants.DBBatchSize)
if err != nil {
return err
}
var (
h = c.Response().Header()
wr = csv.NewWriter(c.Response())
)
@ -234,15 +153,14 @@ func handleExportSubscribers(c echo.Context) error {
wr.Write([]string{"uuid", "email", "name", "attributes", "status", "created_at", "updated_at"})
loop:
// Iterate in batches until there are no more subscribers to export.
for {
var out []models.SubscriberExport
if err := tx.Select(&out, listIDs, id, app.constants.DBBatchSize); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorFetching",
"name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
out, err := exp()
if err != nil {
return err
}
if len(out) == 0 {
break loop
if out == nil || len(out) == 0 {
break
}
for _, r := range out {
@ -252,9 +170,9 @@ loop:
break loop
}
}
wr.Flush()
id = out[len(out)-1].ID
// Flush CSV to stream after each batch.
wr.Flush()
}
return nil
@ -264,26 +182,40 @@ loop:
func handleCreateSubscriber(c echo.Context) error {
var (
app = c.Get("app").(*App)
req subimporter.SubReq
req struct {
models.Subscriber
Lists []int `json:"lists"`
ListUUIDs []string `json:"list_uuids"`
PreconfirmSubs bool `json:"preconfirm_subscriptions"`
}
)
// Get and validate fields.
if err := c.Bind(&req); err != nil {
return err
}
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
if err := subimporter.ValidateFields(req); err != nil {
// Validate fields.
if len(req.Email) > 1000 {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.invalidEmail"))
}
em, err := app.importer.SanitizeEmail(req.Email)
if err != nil {
return 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 echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.invalidName"))
}
// Insert the subscriber into the DB.
sub, isNew, _, err := insertSubscriber(req, app)
sub, _, err := app.core.InsertSubscriber(req.Subscriber, req.Lists, req.ListUUIDs, req.PreconfirmSubs)
if err != nil {
return err
}
if !isNew {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.emailExists"))
}
return c.JSON(http.StatusOK, okResp{sub})
}
@ -292,9 +224,14 @@ func handleCreateSubscriber(c echo.Context) error {
func handleUpdateSubscriber(c echo.Context) error {
var (
app = c.Get("app").(*App)
id, _ = strconv.ParseInt(c.Param("id"), 10, 64)
req subimporter.SubReq
id, _ = strconv.Atoi(c.Param("id"))
req struct {
models.Subscriber
Lists []int `json:"lists"`
PreconfirmSubs bool `json:"preconfirm_subscriptions"`
}
)
// Get and validate fields.
if err := c.Bind(&req); err != nil {
return err
@ -303,42 +240,30 @@ func handleUpdateSubscriber(c echo.Context) error {
if id < 1 {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}
if req.Email != "" && !subimporter.IsEmail(req.Email) {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.invalidEmail"))
if em, err := app.importer.SanitizeEmail(req.Email); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
} else {
req.Email = em
}
if req.Name != "" && !strHasLen(req.Name, 1, stdInputMaxLen) {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.invalidName"))
}
_, err := app.queries.UpdateSubscriber.Exec(req.ID,
strings.ToLower(strings.TrimSpace(req.Email)),
strings.TrimSpace(req.Name),
req.Status,
req.Attribs,
req.Lists)
if err != nil {
app.log.Printf("error updating subscriber: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorUpdating",
"name", "{globals.terms.subscriber}", "error", pqErrMsg(err)))
}
// Send a confirmation e-mail (if there are any double opt-in lists).
sub, err := getSubscriber(int(id), "", "", app)
out, err := app.core.UpdateSubscriberWithLists(id, req.Subscriber, req.Lists, nil, req.PreconfirmSubs, true)
if err != nil {
return err
}
_, _ = sendOptinConfirmation(sub, []int64(req.Lists), app)
return c.JSON(http.StatusOK, okResp{sub})
return c.JSON(http.StatusOK, okResp{out})
}
// handleGetSubscriberSendOptin sends an optin confirmation e-mail to a subscriber.
// handleSubscriberSendOptin sends an optin confirmation e-mail to a subscriber.
func handleSubscriberSendOptin(c echo.Context) error {
var (
app = c.Get("app").(*App)
id, _ = strconv.Atoi(c.Param("id"))
out models.Subscribers
)
if id < 1 {
@ -346,21 +271,13 @@ func handleSubscriberSendOptin(c echo.Context) error {
}
// Fetch the subscriber.
err := app.queries.GetSubscriber.Select(&out, id, nil)
out, err := app.core.GetSubscriber(id, "", "")
if err != nil {
app.log.Printf("error fetching subscriber: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorFetching",
"name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
}
if len(out) == 0 {
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.subscriber}"))
return err
}
if _, err := sendOptinConfirmation(out[0], nil, app); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.T("subscribers.errorSendingOptin"))
if _, err := sendOptinConfirmationHook(app)(out, nil); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, app.i18n.T("subscribers.errorSendingOptin"))
}
return c.JSON(http.StatusOK, okResp{true})
@ -370,36 +287,36 @@ func handleSubscriberSendOptin(c echo.Context) error {
// It takes either an ID in the URI, or a list of IDs in the request body.
func handleBlocklistSubscribers(c echo.Context) error {
var (
app = c.Get("app").(*App)
pID = c.Param("id")
IDs pq.Int64Array
app = c.Get("app").(*App)
pID = c.Param("id")
subIDs []int
)
// Is it a /:id call?
if pID != "" {
id, _ := strconv.ParseInt(pID, 10, 64)
id, _ := strconv.Atoi(pID)
if id < 1 {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}
IDs = append(IDs, id)
subIDs = append(subIDs, id)
} else {
// Multiple IDs.
var req subQueryReq
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("subscribers.errorInvalidIDs", "error", err.Error()))
app.i18n.Ts("globals.messages.errorInvalidIDs", "error", err.Error()))
}
if len(req.SubscriberIDs) == 0 {
return echo.NewHTTPError(http.StatusBadRequest,
"No IDs given.")
}
IDs = req.SubscriberIDs
subIDs = req.SubscriberIDs
}
if _, err := app.queries.BlocklistSubscribers.Exec(IDs); err != nil {
app.log.Printf("error blocklisting subscribers: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("subscribers.errorBlocklisting", "error", err.Error()))
if err := app.core.BlocklistSubscribers(subIDs); err != nil {
return err
}
return c.JSON(http.StatusOK, okResp{true})
@ -410,30 +327,30 @@ func handleBlocklistSubscribers(c echo.Context) error {
// It takes either an ID in the URI, or a list of IDs in the request body.
func handleManageSubscriberLists(c echo.Context) error {
var (
app = c.Get("app").(*App)
pID = c.Param("id")
IDs pq.Int64Array
app = c.Get("app").(*App)
pID = c.Param("id")
subIDs []int
)
// Is it a /:id call?
// Is it an /:id call?
if pID != "" {
id, _ := strconv.ParseInt(pID, 10, 64)
id, _ := strconv.Atoi(pID)
if id < 1 {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}
IDs = append(IDs, id)
subIDs = append(subIDs, id)
}
var req subQueryReq
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("subscribers.errorInvalidIDs", "error", err.Error()))
app.i18n.Ts("globals.messages.errorInvalidIDs", "error", err.Error()))
}
if len(req.SubscriberIDs) == 0 {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.errorNoIDs"))
}
if len(IDs) == 0 {
IDs = req.SubscriberIDs
if len(subIDs) == 0 {
subIDs = req.SubscriberIDs
}
if len(req.TargetListIDs) == 0 {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.errorNoListsGiven"))
@ -443,20 +360,17 @@ func handleManageSubscriberLists(c echo.Context) error {
var err error
switch req.Action {
case "add":
_, err = app.queries.AddSubscribersToLists.Exec(IDs, req.TargetListIDs)
err = app.core.AddSubscriptions(subIDs, req.TargetListIDs, req.Status)
case "remove":
_, err = app.queries.DeleteSubscriptions.Exec(IDs, req.TargetListIDs)
err = app.core.DeleteSubscriptions(subIDs, req.TargetListIDs)
case "unsubscribe":
_, err = app.queries.UnsubscribeSubscribersFromLists.Exec(IDs, req.TargetListIDs)
err = app.core.UnsubscribeLists(subIDs, req.TargetListIDs, nil)
default:
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.invalidAction"))
}
if err != nil {
app.log.Printf("error updating subscriptions: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorUpdating",
"name", "{globals.terms.subscribers}", "error", err.Error()))
return err
}
return c.JSON(http.StatusOK, okResp{true})
@ -466,37 +380,34 @@ func handleManageSubscriberLists(c echo.Context) error {
// It takes either an ID in the URI, or a list of IDs in the request body.
func handleDeleteSubscribers(c echo.Context) error {
var (
app = c.Get("app").(*App)
pID = c.Param("id")
IDs pq.Int64Array
app = c.Get("app").(*App)
pID = c.Param("id")
subIDs []int
)
// Is it an /:id call?
if pID != "" {
id, _ := strconv.ParseInt(pID, 10, 64)
id, _ := strconv.Atoi(pID)
if id < 1 {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}
IDs = append(IDs, id)
subIDs = append(subIDs, id)
} else {
// Multiple IDs.
i, err := parseStringIDs(c.Request().URL.Query()["id"])
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("subscribers.errorInvalidIDs", "error", err.Error()))
app.i18n.Ts("globals.messages.errorInvalidIDs", "error", err.Error()))
}
if len(i) == 0 {
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("subscribers.errorNoIDs", "error", err.Error()))
}
IDs = i
subIDs = i
}
if _, err := app.queries.DeleteSubscribers.Exec(IDs, nil); err != nil {
app.log.Printf("error deleting subscribers: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorDeleting",
"name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
if err := app.core.DeleteSubscribers(subIDs, nil); err != nil {
return err
}
return c.JSON(http.StatusOK, okResp{true})
@ -514,14 +425,8 @@ func handleDeleteSubscribersByQuery(c echo.Context) error {
return err
}
err := app.queries.execSubscriberQueryTpl(sanitizeSQLExp(req.Query),
app.queries.DeleteSubscribersByQuery,
req.ListIDs, app.db)
if err != nil {
app.log.Printf("error deleting subscribers: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorDeleting",
"name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
if err := app.core.DeleteSubscribersByQuery(req.Query, req.ListIDs); err != nil {
return err
}
return c.JSON(http.StatusOK, okResp{true})
@ -539,19 +444,14 @@ func handleBlocklistSubscribersByQuery(c echo.Context) error {
return err
}
err := app.queries.execSubscriberQueryTpl(sanitizeSQLExp(req.Query),
app.queries.BlocklistSubscribersByQuery,
req.ListIDs, app.db)
if err != nil {
app.log.Printf("error blocklisting subscribers: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("subscribers.errorBlocklisting", "error", pqErrMsg(err)))
if err := app.core.BlocklistSubscribersByQuery(req.Query, req.ListIDs); err != nil {
return err
}
return c.JSON(http.StatusOK, okResp{true})
}
// handleManageSubscriberListsByQuery bulk adds/removes/unsubscribers subscribers
// handleManageSubscriberListsByQuery bulk adds/removes/unsubscribes subscribers
// from one or more lists based on an arbitrary SQL expression.
func handleManageSubscriberListsByQuery(c echo.Context) error {
var (
@ -568,25 +468,39 @@ func handleManageSubscriberListsByQuery(c echo.Context) error {
}
// Action.
var stmt string
var err error
switch req.Action {
case "add":
stmt = app.queries.AddSubscribersToListsByQuery
err = app.core.AddSubscriptionsByQuery(req.Query, req.ListIDs, req.TargetListIDs)
case "remove":
stmt = app.queries.DeleteSubscriptionsByQuery
err = app.core.DeleteSubscriptionsByQuery(req.Query, req.ListIDs, req.TargetListIDs)
case "unsubscribe":
stmt = app.queries.UnsubscribeSubscribersFromListsByQuery
err = app.core.UnsubscribeListsByQuery(req.Query, req.ListIDs, req.TargetListIDs)
default:
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.invalidAction"))
}
err := app.queries.execSubscriberQueryTpl(sanitizeSQLExp(req.Query),
stmt, req.ListIDs, app.db, req.TargetListIDs)
if err != nil {
app.log.Printf("error updating subscriptions: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorUpdating",
"name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
return err
}
return c.JSON(http.StatusOK, okResp{true})
}
// handleDeleteSubscriberBounces deletes all the bounces on a subscriber.
func handleDeleteSubscriberBounces(c echo.Context) error {
var (
app = c.Get("app").(*App)
pID = c.Param("id")
)
id, _ := strconv.Atoi(pID)
if id < 1 {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}
if err := app.core.DeleteSubscriberBounces(id, ""); err != nil {
return err
}
return c.JSON(http.StatusOK, okResp{true})
@ -601,7 +515,8 @@ func handleExportSubscriberData(c echo.Context) error {
app = c.Get("app").(*App)
pID = c.Param("id")
)
id, _ := strconv.ParseInt(pID, 10, 64)
id, _ := strconv.Atoi(pID)
if id < 1 {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}
@ -622,90 +537,13 @@ func handleExportSubscriberData(c echo.Context) error {
return c.Blob(http.StatusOK, "application/json", b)
}
// insertSubscriber inserts a subscriber and returns the ID. The first bool indicates if
// it was a new subscriber, and the second bool indicates if the subscriber was sent an optin confirmation.
func insertSubscriber(req subimporter.SubReq, app *App) (models.Subscriber, bool, bool, error) {
uu, err := uuid.NewV4()
if err != nil {
return req.Subscriber, false, false, err
}
req.UUID = uu.String()
isNew := true
if err = app.queries.InsertSubscriber.Get(&req.ID,
req.UUID,
req.Email,
strings.TrimSpace(req.Name),
req.Status,
req.Attribs,
req.Lists,
req.ListUUIDs); err != nil {
if pqErr, ok := err.(*pq.Error); ok && pqErr.Constraint == "subscribers_email_key" {
isNew = false
} else {
// return req.Subscriber, errSubscriberExists
app.log.Printf("error inserting subscriber: %v", err)
return req.Subscriber, false, false, echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorCreating",
"name", "{globals.terms.subscriber}", "error", pqErrMsg(err)))
}
}
// Fetch the subscriber's full data. If the subscriber already existed and wasn't
// created, the id will be empty. Fetch the details by e-mail then.
sub, err := getSubscriber(req.ID, "", strings.ToLower(req.Email), app)
if err != nil {
return sub, false, false, err
}
// Send a confirmation e-mail (if there are any double opt-in lists).
num, _ := sendOptinConfirmation(sub, []int64(req.Lists), app)
return sub, isNew, num > 0, nil
}
// getSubscriber gets a single subscriber by ID, uuid, or e-mail in that order.
// Only one of these params should have a value.
func getSubscriber(id int, uuid, email string, app *App) (models.Subscriber, error) {
var out models.Subscribers
if err := app.queries.GetSubscriber.Select(&out, id, uuid, email); err != nil {
app.log.Printf("error fetching subscriber: %v", err)
return models.Subscriber{}, echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorFetching",
"name", "{globals.terms.subscriber}", "error", pqErrMsg(err)))
}
if len(out) == 0 {
return models.Subscriber{}, echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.subscriber}"))
}
if err := out.LoadLists(app.queries.GetSubscriberListsLazy); err != nil {
app.log.Printf("error loading subscriber lists: %v", err)
return models.Subscriber{}, echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorFetching",
"name", "{globals.terms.lists}", "error", pqErrMsg(err)))
}
return out[0], nil
}
// exportSubscriberData collates the data of a subscriber including profile,
// subscriptions, campaign_views, link_clicks (if they're enabled in the config)
// and returns a formatted, indented JSON payload. Either takes a numeric id
// and an empty subUUID or takes 0 and a string subUUID.
func exportSubscriberData(id int64, subUUID string, exportables map[string]bool, app *App) (subProfileData, []byte, error) {
// Get the subscriber's data. A single query that gets the profile,
// list subscriptions, campaign views, and link clicks. Names of
// private lists are replaced with "Private list".
var (
data subProfileData
uu interface{}
)
// UUID should be a valid value or a nil.
if subUUID != "" {
uu = subUUID
}
if err := app.queries.ExportSubscriberData.Get(&data, id, uu); err != nil {
app.log.Printf("error fetching subscriber export data: %v", err)
func exportSubscriberData(id int, subUUID string, exportables map[string]bool, app *App) (models.SubscriberExportProfile, []byte, error) {
data, err := app.core.GetSubscriberProfileForExport(id, subUUID)
if err != nil {
return data, nil, err
}
@ -729,47 +567,10 @@ func exportSubscriberData(id int64, subUUID string, exportables map[string]bool,
app.log.Printf("error marshalling subscriber export data: %v", err)
return data, nil, err
}
return data, b, nil
}
// sendOptinConfirmation sends a double opt-in confirmation e-mail to a subscriber
// if at least one of the given listIDs is set to optin=double. It returns the number of
// opt-in lists that were found.
func sendOptinConfirmation(sub models.Subscriber, listIDs []int64, app *App) (int, error) {
var lists []models.List
// Fetch double opt-in lists from the given list IDs.
// Get the list of subscription lists where the subscriber hasn't confirmed.
if err := app.queries.GetSubscriberLists.Select(&lists, sub.ID, nil,
pq.Int64Array(listIDs), nil, models.SubscriptionStatusUnconfirmed, models.ListOptinDouble); err != nil {
app.log.Printf("error fetching lists for opt-in: %s", pqErrMsg(err))
return 0, err
}
// None.
if len(lists) == 0 {
return 0, nil
}
var (
out = subOptin{Subscriber: &sub, Lists: lists}
qListIDs = url.Values{}
)
// Construct the opt-in URL with list IDs.
for _, l := range out.Lists {
qListIDs.Add("l", l.UUID)
}
out.OptinURL = fmt.Sprintf(app.constants.OptinURL, sub.UUID, qListIDs.Encode())
// Send the e-mail.
if err := app.sendNotification([]string{sub.Email},
app.i18n.T("subscribers.optinSubject"), notifSubscriberOptin, out); err != nil {
app.log.Printf("error sending opt-in e-mail: %s", err)
return 0, err
}
return len(lists), nil
}
// sanitizeSQLExp does basic sanitisation on arbitrary
// SQL query expressions coming from the frontend.
func sanitizeSQLExp(q string) string {
@ -784,3 +585,59 @@ func sanitizeSQLExp(q string) string {
}
return q
}
func getQueryInts(param string, qp url.Values) ([]int, error) {
var out []int
if vals, ok := qp[param]; ok {
for _, v := range vals {
if v == "" {
continue
}
listID, err := strconv.Atoi(v)
if err != nil {
return nil, err
}
out = append(out, listID)
}
}
return out, nil
}
// sendOptinConfirmationHook returns an enclosed callback that sends optin confirmation e-mails.
// This is plugged into the 'core' package to send optin confirmations when a new subscriber is
// created via `core.CreateSubscriber()`.
func sendOptinConfirmationHook(app *App) func(sub models.Subscriber, listIDs []int) (int, error) {
return func(sub models.Subscriber, listIDs []int) (int, error) {
lists, err := app.core.GetSubscriberLists(sub.ID, "", listIDs, nil, models.SubscriptionStatusUnconfirmed, models.ListOptinDouble)
if err != nil {
return 0, err
}
// None.
if len(lists) == 0 {
return 0, nil
}
var (
out = subOptin{Subscriber: sub, Lists: lists}
qListIDs = url.Values{}
)
// Construct the opt-in URL with list IDs.
for _, l := range out.Lists {
qListIDs.Add("l", l.UUID)
}
out.OptinURL = fmt.Sprintf(app.constants.OptinURL, sub.UUID, qListIDs.Encode())
out.UnsubURL = fmt.Sprintf(app.constants.UnsubURL, dummyUUID, sub.UUID)
// Send the e-mail.
if err := app.sendNotification([]string{sub.Email}, app.i18n.T("subscribers.optinSubject"), notifSubscriberOptin, out); err != nil {
app.log.Printf("error sending opt-in e-mail: %s", err)
return 0, err
}
return len(lists), nil
}
}

View file

@ -1,15 +1,15 @@
package main
import (
"database/sql"
"errors"
"fmt"
"html/template"
"net/http"
"regexp"
"strconv"
"strings"
"github.com/knadh/listmonk/models"
"github.com/labstack/echo"
"github.com/labstack/echo/v4"
)
const (
@ -35,33 +35,24 @@ var (
func handleGetTemplates(c echo.Context) error {
var (
app = c.Get("app").(*App)
out []models.Template
id, _ = strconv.Atoi(c.Param("id"))
single = false
noBody, _ = strconv.ParseBool(c.QueryParam("no_body"))
)
// Fetch one list.
if id > 0 {
single = true
out, err := app.core.GetTemplate(id, noBody)
if err != nil {
return err
}
return c.JSON(http.StatusOK, okResp{out})
}
err := app.queries.GetTemplates.Select(&out, id, noBody)
out, err := app.core.GetTemplates("", noBody)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorFetching",
"name", "{globals.terms.templates}", "error", pqErrMsg(err)))
}
if single && len(out) == 0 {
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.template}"))
}
if len(out) == 0 {
return c.JSON(http.StatusOK, okResp{[]struct{}{}})
} else if single {
return c.JSON(http.StatusOK, okResp{out[0]})
return err
}
return c.JSON(http.StatusOK, okResp{out})
@ -72,58 +63,80 @@ func handlePreviewTemplate(c echo.Context) error {
var (
app = c.Get("app").(*App)
id, _ = strconv.Atoi(c.Param("id"))
body = c.FormValue("body")
tpls []models.Template
)
if body != "" {
if !regexpTplTag.MatchString(body) {
tpl := models.Template{
Type: c.FormValue("template_type"),
Body: c.FormValue("body"),
}
// Body is posted.
if tpl.Body != "" {
if tpl.Type == "" {
tpl.Type = models.TemplateTypeCampaign
}
if tpl.Type == models.TemplateTypeCampaign && !regexpTplTag.MatchString(tpl.Body) {
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("templates.placeholderHelp", "placeholder", tplTag))
}
} else {
// There is no body. Fetch the template.
if id < 1 {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}
err := app.queries.GetTemplates.Select(&tpls, id, false)
t, err := app.core.GetTemplate(id, false)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorFetching",
"name", "{globals.terms.templates}", "error", pqErrMsg(err)))
return err
}
if len(tpls) == 0 {
tpl = t
}
// Compile the campaign template.
var out []byte
if tpl.Type == models.TemplateTypeCampaign {
camp := models.Campaign{
UUID: dummyUUID,
Name: app.i18n.T("templates.dummyName"),
Subject: app.i18n.T("templates.dummySubject"),
FromEmail: "dummy-campaign@listmonk.app",
TemplateBody: tpl.Body,
Body: dummyTpl,
}
if err := camp.CompileTemplate(app.manager.TemplateFuncs(&camp)); err != nil {
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.template}"))
app.i18n.Ts("templates.errorCompiling", "error", err.Error()))
}
body = tpls[0].Body
// Render the message body.
msg, err := app.manager.NewCampaignMessage(&camp, dummySubscriber)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("templates.errorRendering", "error", err.Error()))
}
out = msg.Body()
} else {
// Compile transactional template.
if err := tpl.Compile(app.manager.GenericTemplateFuncs()); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
m := models.TxMessage{
Subject: tpl.Subject,
}
// Render the message.
if err := m.Render(dummySubscriber, &tpl); err != nil {
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("globals.messages.errorFetching", "name"))
}
out = m.Body
}
// Compile the template.
camp := models.Campaign{
UUID: dummyUUID,
Name: app.i18n.T("templates.dummyName"),
Subject: app.i18n.T("templates.dummySubject"),
FromEmail: "dummy-campaign@listmonk.app",
TemplateBody: body,
Body: dummyTpl,
}
if err := camp.CompileTemplate(app.manager.TemplateFuncs(&camp)); err != nil {
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("templates.errorCompiling", "error", err.Error()))
}
// Render the message body.
m := app.manager.NewCampaignMessage(&camp, dummySubscriber)
if err := m.Render(); err != nil {
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("templates.errorRendering", "error", err.Error()))
}
return c.HTML(http.StatusOK, string(m.Body()))
return c.HTML(http.StatusOK, string(out))
}
// handleCreateTemplate handles template creation.
@ -138,23 +151,38 @@ func handleCreateTemplate(c echo.Context) error {
}
if err := validateTemplate(o, app); err != nil {
return err
}
var f template.FuncMap
// Subject is only relevant for fixed tx templates. For campaigns,
// the subject changes per campaign and is on models.Campaign.
if o.Type == models.TemplateTypeCampaign {
o.Subject = ""
f = app.manager.TemplateFuncs(nil)
} else {
f = app.manager.GenericTemplateFuncs()
}
// Compile the template and validate.
if err := o.Compile(f); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
// Insert and read ID.
var newID int
if err := app.queries.CreateTemplate.Get(&newID,
o.Name,
o.Body); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorCreating",
"name", "{globals.terms.template}", "error", pqErrMsg(err)))
// Create the template the in the DB.
out, err := app.core.CreateTemplate(o.Name, o.Type, o.Subject, []byte(o.Body))
if err != nil {
return err
}
// Hand over to the GET handler to return the last insertion.
return handleGetTemplates(copyEchoCtx(c, map[string]string{
"id": fmt.Sprintf("%d", newID),
}))
// If it's a transactional template, cache it in the manager
// to be used for arbitrary incoming tx message pushes.
if o.Type == models.TemplateTypeTx {
app.manager.CacheTpl(out.ID, &o)
}
return c.JSON(http.StatusOK, okResp{out})
}
// handleUpdateTemplate handles template modification.
@ -174,23 +202,37 @@ func handleUpdateTemplate(c echo.Context) error {
}
if err := validateTemplate(o, app); err != nil {
return err
}
var f template.FuncMap
// Subject is only relevant for fixed tx templates. For campaigns,
// the subject changes per campaign and is on models.Campaign.
if o.Type == models.TemplateTypeCampaign {
o.Subject = ""
f = app.manager.TemplateFuncs(nil)
} else {
f = app.manager.GenericTemplateFuncs()
}
// Compile the template and validate.
if err := o.Compile(f); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
// TODO: PASSWORD HASHING.
res, err := app.queries.UpdateTemplate.Exec(o.ID, o.Name, o.Body)
out, err := app.core.UpdateTemplate(id, o.Name, o.Subject, []byte(o.Body))
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorUpdating",
"name", "{globals.terms.template}", "error", pqErrMsg(err)))
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.template}"))
// If it's a transactional template, cache it.
if o.Type == models.TemplateTypeTx {
app.manager.CacheTpl(out.ID, &o)
}
return handleGetTemplates(c)
return c.JSON(http.StatusOK, okResp{out})
}
// handleTemplateSetDefault handles template modification.
@ -204,11 +246,8 @@ func handleTemplateSetDefault(c echo.Context) error {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
}
_, err := app.queries.SetDefaultTemplate.Exec(id)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorUpdating",
"name", "{globals.terms.template}", "error", pqErrMsg(err)))
if err := app.core.SetDefaultTemplate(id); err != nil {
return err
}
return handleGetTemplates(c)
@ -223,41 +262,33 @@ func handleDeleteTemplate(c echo.Context) error {
if id < 1 {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
} else if id == 1 {
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.T("templates.cantDeleteDefault"))
}
var delID int
err := app.queries.DeleteTemplate.Get(&delID, id)
if err != nil {
if err == sql.ErrNoRows {
return c.JSON(http.StatusOK, okResp{true})
}
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.errorCreating",
"name", "{globals.terms.template}", "error", pqErrMsg(err)))
if err := app.core.DeleteTemplate(id); err != nil {
return err
}
if delID == 0 {
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.T("templates.cantDeleteDefault"))
}
// Delete cached template.
app.manager.DeleteTpl(id)
return c.JSON(http.StatusOK, okResp{true})
}
// validateTemplate validates template fields.
// compileTemplate validates template fields.
func validateTemplate(o models.Template, app *App) error {
if !strHasLen(o.Name, 1, stdInputMaxLen) {
return errors.New(app.i18n.T("campaigns.fieldInvalidName"))
}
if !regexpTplTag.MatchString(o.Body) {
if o.Type == models.TemplateTypeCampaign && !regexpTplTag.MatchString(o.Body) {
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("templates.placeholderHelp", "placeholder", tplTag))
}
if o.Type == models.TemplateTypeTx && strings.TrimSpace(o.Subject) == "" {
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("globals.messages.missingFields", "name", "subject"))
}
return nil
}

207
cmd/tx.go Normal file
View file

@ -0,0 +1,207 @@
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/textproto"
"strings"
"github.com/knadh/listmonk/internal/manager"
"github.com/knadh/listmonk/models"
"github.com/labstack/echo/v4"
)
// handleSendTxMessage handles the sending of a transactional message.
func handleSendTxMessage(c echo.Context) error {
var (
app = c.Get("app").(*App)
m models.TxMessage
)
// If it's a multipart form, there may be file attachments.
if strings.HasPrefix(c.Request().Header.Get("Content-Type"), "multipart/form-data") {
form, err := c.MultipartForm()
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("globals.messages.invalidFields", "name", err.Error()))
}
data, ok := form.Value["data"]
if !ok || len(data) != 1 {
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("globals.messages.invalidFields", "name", "data"))
}
// Parse the JSON data.
if err := json.Unmarshal([]byte(data[0]), &m); err != nil {
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("globals.messages.invalidFields", "name", fmt.Sprintf("data: %s", err.Error())))
}
// Attach files.
for _, f := range form.File["file"] {
file, err := f.Open()
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.invalidFields", "name", fmt.Sprintf("file: %s", err.Error())))
}
defer file.Close()
b, err := io.ReadAll(file)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("globals.messages.invalidFields", "name", fmt.Sprintf("file: %s", err.Error())))
}
m.Attachments = append(m.Attachments, models.Attachment{
Name: f.Filename,
Header: manager.MakeAttachmentHeader(f.Filename, "base64", f.Header.Get("Content-Type")),
Content: b,
})
}
} else if err := c.Bind(&m); err != nil {
return err
}
// Validate input.
if r, err := validateTxMessage(m, app); err != nil {
return err
} else {
m = r
}
// Get the cached tx template.
tpl, err := app.manager.GetTpl(m.TemplateID)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("globals.messages.notFound", "name", fmt.Sprintf("template %d", m.TemplateID)))
}
var (
num = len(m.SubscriberEmails)
isEmails = true
)
if len(m.SubscriberIDs) > 0 {
num = len(m.SubscriberIDs)
isEmails = false
}
notFound := []string{}
for n := 0; n < num; n++ {
var (
subID int
subEmail string
)
if !isEmails {
subID = m.SubscriberIDs[n]
} else {
subEmail = m.SubscriberEmails[n]
}
// Get the subscriber.
sub, err := app.core.GetSubscriber(subID, "", subEmail)
if err != nil {
// If the subscriber is not found, log that error and move on without halting on the list.
if er, ok := err.(*echo.HTTPError); ok && er.Code == http.StatusBadRequest {
notFound = append(notFound, fmt.Sprintf("%v", er.Message))
continue
}
return err
}
// Render the message.
if err := m.Render(sub, tpl); err != nil {
return echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("globals.messages.errorFetching", "name"))
}
// Prepare the final message.
msg := models.Message{}
msg.Subscriber = sub
msg.To = []string{sub.Email}
msg.From = m.FromEmail
msg.Subject = m.Subject
msg.ContentType = m.ContentType
msg.Messenger = m.Messenger
msg.Body = m.Body
for _, a := range m.Attachments {
msg.Attachments = append(msg.Attachments, models.Attachment{
Name: a.Name,
Header: a.Header,
Content: a.Content,
})
}
// Optional headers.
if len(m.Headers) != 0 {
msg.Headers = make(textproto.MIMEHeader, len(m.Headers))
for _, set := range m.Headers {
for hdr, val := range set {
msg.Headers.Add(hdr, val)
}
}
}
if err := app.manager.PushMessage(msg); err != nil {
app.log.Printf("error sending message (%s): %v", msg.Subject, err)
return err
}
}
if len(notFound) > 0 {
return echo.NewHTTPError(http.StatusBadRequest, strings.Join(notFound, "; "))
}
return c.JSON(http.StatusOK, okResp{true})
}
func validateTxMessage(m models.TxMessage, app *App) (models.TxMessage, error) {
if len(m.SubscriberEmails) > 0 && m.SubscriberEmail != "" {
return m, echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("globals.messages.invalidFields", "name", "do not send `subscriber_email`"))
}
if len(m.SubscriberIDs) > 0 && m.SubscriberID != 0 {
return m, echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("globals.messages.invalidFields", "name", "do not send `subscriber_id`"))
}
if m.SubscriberEmail != "" {
m.SubscriberEmails = append(m.SubscriberEmails, m.SubscriberEmail)
}
if m.SubscriberID != 0 {
m.SubscriberIDs = append(m.SubscriberIDs, m.SubscriberID)
}
if (len(m.SubscriberEmails) == 0 && len(m.SubscriberIDs) == 0) || (len(m.SubscriberEmails) > 0 && len(m.SubscriberIDs) > 0) {
return m, echo.NewHTTPError(http.StatusBadRequest,
app.i18n.Ts("globals.messages.invalidFields", "name", "send subscriber_emails OR subscriber_ids"))
}
for n, email := range m.SubscriberEmails {
if m.SubscriberEmail != "" {
em, err := app.importer.SanitizeEmail(email)
if err != nil {
return m, echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
m.SubscriberEmails[n] = em
}
}
if m.FromEmail == "" {
m.FromEmail = app.constants.FromEmail
}
if m.Messenger == "" {
m.Messenger = emailMsgr
} else if !app.manager.HasMessenger(m.Messenger) {
return m, echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("campaigns.fieldInvalidMessenger", "name", m.Messenger))
}
return m, nil
}

View file

@ -2,7 +2,7 @@ package main
import (
"encoding/json"
"io/ioutil"
"io"
"net/http"
"regexp"
"time"
@ -36,7 +36,7 @@ func checkUpdates(curVersion string, interval time.Duration, app *App) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for ; true; <-ticker.C {
for range ticker.C {
resp, err := http.Get(updateCheckURL)
if err != nil {
app.log.Printf("error checking for remote update: %v", err)
@ -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"
@ -29,6 +29,14 @@ var migList = []migFunc{
{"v0.7.0", migrations.V0_7_0},
{"v0.8.0", migrations.V0_8_0},
{"v0.9.0", migrations.V0_9_0},
{"v1.0.0", migrations.V1_0_0},
{"v2.0.0", migrations.V2_0_0},
{"v2.1.0", migrations.V2_1_0},
{"v2.2.0", migrations.V2_2_0},
{"v2.3.0", migrations.V2_3_0},
{"v2.4.0", migrations.V2_4_0},
{"v2.5.0", migrations.V2_5_0},
{"v2.6.0", migrations.V2_6_0},
}
// upgrade upgrades the database to the current version by running SQL migration files
@ -109,7 +117,7 @@ func getPendingMigrations(db *sqlx.DB) (string, []migFunc, error) {
}
// Iterate through the migration versions and get everything above the last
// last upgraded semver.
// upgraded semver.
var toRun []migFunc
for i, m := range migList {
if semver.Compare(m.version, lastVer) > 0 {
@ -137,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

@ -4,77 +4,39 @@ import (
"bytes"
"crypto/rand"
"fmt"
"path/filepath"
"regexp"
"strconv"
"strings"
"github.com/lib/pq"
)
var (
tagRegexpSpaces = regexp.MustCompile(`[\s]+`)
regexpSpaces = regexp.MustCompile(`[\s]+`)
)
// validateMIME is a helper function to validate uploaded file's MIME type
// against the slice of MIME types is given.
func validateMIME(typ string, mimes []string) (ok bool) {
if len(mimes) > 0 {
var (
ok = false
)
for _, m := range mimes {
if typ == m {
ok = true
break
}
}
if !ok {
return false
// inArray checks if a string is present in a list of strings.
func inArray(val string, vals []string) (ok bool) {
for _, v := range vals {
if v == val {
return true
}
}
return true
return false
}
// generateFileName appends the incoming file's name with a small random hash.
func generateFileName(fName string) string {
// makeFilename sanitizes a filename (user supplied upload filenames).
func makeFilename(fName string) string {
name := strings.TrimSpace(fName)
if name == "" {
name, _ = generateRandomString(10)
}
return name
}
// Given an error, pqErrMsg will try to return pq error details
// if it's a pq error.
func pqErrMsg(err error) string {
if err, ok := err.(*pq.Error); ok {
if err.Detail != "" {
return fmt.Sprintf("%s. %s", err, err.Detail)
}
}
return err.Error()
}
// normalizeTags takes a list of string tags and normalizes them by
// lowercasing and removing all special characters except for dashes.
func normalizeTags(tags []string) []string {
var (
out []string
dash = []byte("-")
)
for _, t := range tags {
rep := tagRegexpSpaces.ReplaceAll(bytes.TrimSpace([]byte(t)), dash)
if len(rep) > 0 {
out = append(out, string(rep))
}
}
return out
// replace whitespace with "-"
name = regexpSpaces.ReplaceAllString(name, "-")
return filepath.Base(name)
}
// makeMsgTpl takes a page title, heading, and message and returns
// a msgTpl that can be rendered as a HTML view. This is used for
// a msgTpl that can be rendered as an HTML view. This is used for
// rendering arbitrary HTML views with error and success messages.
func makeMsgTpl(pageTitle, heading, msg string) msgTpl {
if heading == "" {
@ -90,10 +52,10 @@ func makeMsgTpl(pageTitle, heading, msg string) msgTpl {
// parseStringIDs takes a slice of numeric string IDs and
// parses each number into an int64 and returns a slice of the
// resultant values.
func parseStringIDs(s []string) ([]int64, error) {
vals := make([]int64, 0, len(s))
func parseStringIDs(s []string) ([]int, error) {
vals := make([]int, 0, len(s))
for _, v := range s {
i, err := strconv.ParseInt(v, 10, 64)
i, err := strconv.Atoi(v)
if err != nil {
return nil, err
}
@ -138,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

@ -1,15 +1,15 @@
[app]
# Interface and port where the app will run its webserver.
address = "0.0.0.0:9000"
# Interface and port where the app will run its webserver.
address = "0.0.0.0:9000"
# Database.
[db]
host = "demo-db"
port = 5432
user = "listmonk"
password = "listmonk"
database = "listmonk"
ssl_mode = "disable"
max_open = 25
max_idle = 25
max_lifetime = "300s"
host = "demo-db"
port = 5432
user = "listmonk"
password = "listmonk"
database = "listmonk"
ssl_mode = "disable"
max_open = 25
max_idle = 25
max_lifetime = "300s"

View file

@ -1,22 +1,31 @@
[app]
# Interface and port where the app will run its webserver.
address = "0.0.0.0:9000"
# Interface and port where the app will run its webserver. The default value
# of localhost will only listen to connections from the current machine. To
# listen on all interfaces use '0.0.0.0'. To listen on the default web address
# port, use port 80 (this will require running with elevated permissions).
address = "localhost:9000"
# BasicAuth authentication for the admin dashboard. This will eventually
# be replaced with a better multi-user, role-based authentication system.
# IMPORTANT: Leave both values empty to disable authentication on admin
# only where an external authentication is already setup.
admin_username = "listmonk"
admin_password = "listmonk"
# BasicAuth authentication for the admin dashboard. This will eventually
# be replaced with a better multi-user, role-based authentication system.
# IMPORTANT: Leave both values empty to disable authentication on admin
# only where an external authentication is already setup.
admin_username = "listmonk"
admin_password = "listmonk"
# Database.
[db]
host = "db"
port = 5432
user = "listmonk"
password = "listmonk"
database = "listmonk"
ssl_mode = "disable"
max_open = 25
max_idle = 25
max_lifetime = "300s"
host = "localhost"
port = 5432
user = "listmonk"
password = "listmonk"
# Ensure that this database has been created in Postgres.
database = "listmonk"
ssl_mode = "disable"
max_open = 25
max_idle = 25
max_lifetime = "300s"
# Optional space separated Postgres DSN params. eg: "application_name=listmonk gssencmode=disable"
params = ""

1
dev/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
!config.toml

62
dev/README.md Normal file
View file

@ -0,0 +1,62 @@
# Docker suite for development
**NOTE**: This exists only for local development. If you're interested in using
Docker for a production setup, visit the
[docs](https://listmonk.app/docs/installation/#docker) instead.
### Objective
The purpose of this Docker suite for local development is to isolate all the dev
dependencies in a Docker environment. The containers have a host volume mounted
inside for the entire app directory. This helps us to not do a full
`docker build` for every single local change, only restarting the Docker
environment is enough.
## Setting up a dev suite
To spin up a local suite of:
- PostgreSQL
- Mailhog
- Node.js frontend app
- Golang backend app
### Verify your config file
The config file provided at `dev/config.toml` will be used when running the
containerized development stack. Make sure the values set within are suitable
for the feature you're trying to develop.
### Setup DB
Running this will build the appropriate images and initialize the database.
```bash
make init-dev-docker
```
### Start frontend and backend apps
Running this start your local development stack.
```bash
make dev-docker
```
Visit `http://localhost:8080` on your browser.
### Tear down
This will tear down all the data, including DB.
```bash
make rm-dev-docker
```
### See local changes in action
- Backend: Anytime you do a change to the Go app, it needs to be compiled. Just
run `make dev-docker` again and that should automatically handle it for you.
- Frontend: Anytime you change the frontend code, you don't need to do anything.
Since `yarn` is watching for all the changes and we have mounted the code
inside the docker container, `yarn` server automatically restarts.

11
dev/app.Dockerfile Normal file
View file

@ -0,0 +1,11 @@
FROM golang:1.17 AS go
FROM node:16 AS node
COPY --from=go /usr/local/go /usr/local/go
ENV GOPATH /go
ENV CGO_ENABLED=0
ENV PATH $GOPATH/bin:/usr/local/go/bin:$PATH
WORKDIR /app
ENTRYPOINT [ "" ]

28
dev/config.toml Normal file
View file

@ -0,0 +1,28 @@
# IMPORTANT: This configuration is meant for development only
### DO NOT USE IN PRODUCTION ###
[app]
# Interface and port where the app will run its webserver. The default value
# of localhost will only listen to connections from the current machine. To
# listen on all interfaces use '0.0.0.0'. To listen on the default web address
# port, use port 80 (this will require running with elevated permissions).
address = "0.0.0.0:9000"
# BasicAuth authentication for the admin dashboard. This will eventually
# be replaced with a better multi-user, role-based authentication system.
# IMPORTANT: Leave both values empty to disable authentication on admin
# only where an external authentication is already setup.
admin_username = "listmonk"
admin_password = "listmonk"
# Database.
[db]
host = "db"
port = 5432
user = "listmonk-dev"
password = "listmonk-dev"
database = "listmonk-dev"
ssl_mode = "disable"
max_open = 25
max_idle = 25
max_lifetime = "300s"

61
dev/docker-compose.yml Normal file
View file

@ -0,0 +1,61 @@
version: "3"
services:
mailhog:
image: mailhog/mailhog:v1.0.1
ports:
- "1025:1025" # SMTP
- "8025:8025" # UI
db:
image: postgres:13
ports:
- "5432:5432"
networks:
- listmonk-dev
environment:
- POSTGRES_PASSWORD=listmonk-dev
- POSTGRES_USER=listmonk-dev
- POSTGRES_DB=listmonk-dev
restart: unless-stopped
volumes:
- type: volume
source: listmonk-dev-db
target: /var/lib/postgresql/data
front:
build:
context: ../
dockerfile: dev/app.Dockerfile
command: ["make", "run-frontend"]
ports:
- "8080:8080"
environment:
- LISTMONK_API_URL=http://backend:9000
depends_on:
- db
volumes:
- ../:/app
networks:
- listmonk-dev
backend:
build:
context: ../
dockerfile: dev/app.Dockerfile
command: ["make", "run-backend-docker"]
ports:
- "9000:9000"
depends_on:
- db
volumes:
- ../:/app
- $GOPATH/pkg/mod/cache:/go/pkg/mod/cache
networks:
- listmonk-dev
volumes:
listmonk-dev-db:
networks:
listmonk-dev:

View file

@ -11,22 +11,30 @@ x-app-defaults: &app-defaults
- "9000:9000"
networks:
- listmonk
environment:
- TZ=Etc/UTC
x-db-defaults: &db-defaults
image: postgres:11
ports:
- "9432:5432"
networks:
- listmonk
environment:
- POSTGRES_PASSWORD=listmonk
- POSTGRES_USER=listmonk
- POSTGRES_DB=listmonk
restart: unless-stopped
image: postgres:13-alpine
ports:
- "9432:5432"
networks:
- listmonk
environment:
- POSTGRES_PASSWORD=listmonk
- POSTGRES_USER=listmonk
- POSTGRES_DB=listmonk
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U listmonk"]
interval: 10s
timeout: 5s
retries: 6
services:
db:
<<: *db-defaults
container_name: listmonk_db
volumes:
- type: volume
source: listmonk-data
@ -34,16 +42,21 @@ services:
app:
<<: *app-defaults
container_name: listmonk_app
depends_on:
- db
volumes:
- ./config.toml:/listmonk/config.toml
demo-db:
container_name: listmonk_demo_db
<<: *db-defaults
demo-app:
<<: *app-defaults
container_name: listmonk_demo_app
command: [sh, -c, "yes | ./listmonk --install --config config-demo.toml && ./listmonk --config config-demo.toml"]
depends_on:
depends_on:
- demo-db
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

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