Compare commits
810 commits
v0.9.0-bet
...
master
Author | SHA1 | Date | |
---|---|---|---|
![]() |
95dabe593b | ||
![]() |
e06d379953 | ||
![]() |
7c991677eb | ||
![]() |
fa506643a4 | ||
![]() |
53eb71a83b | ||
![]() |
2ce2a11c7e | ||
![]() |
52ee79bf86 | ||
![]() |
524be2753b | ||
![]() |
75befe5214 | ||
![]() |
4577868567 | ||
![]() |
c59825f3a5 | ||
![]() |
06b4494200 | ||
![]() |
82c74cd544 | ||
![]() |
4c8c19ebb9 | ||
![]() |
d439ecfd84 | ||
![]() |
ef084956b4 | ||
![]() |
44d3462559 | ||
![]() |
62be5e2181 | ||
![]() |
49ec11ca9c | ||
![]() |
e8ecdf8cde | ||
![]() |
ff135ecee3 | ||
![]() |
be4be729a9 | ||
![]() |
f8a55f84fa | ||
![]() |
9e3af91dc5 | ||
![]() |
f720e88b44 | ||
![]() |
b29f565b68 | ||
![]() |
3641f74c64 | ||
![]() |
ccceaa6e92 | ||
![]() |
c7882bb220 | ||
![]() |
fd05a6d17d | ||
![]() |
06332d52a0 | ||
![]() |
1ebd80c577 | ||
![]() |
491fab38cb | ||
![]() |
764a052ecc | ||
![]() |
c911aeb20e | ||
![]() |
954ed45009 | ||
![]() |
a61a9d8c04 | ||
![]() |
99c71a2a0a | ||
![]() |
82c3c6878b | ||
![]() |
04e571d43a | ||
![]() |
8f2a08b8db | ||
![]() |
11f90b2f62 | ||
![]() |
5af6252b14 | ||
![]() |
717a6362d2 | ||
![]() |
7019f26280 | ||
![]() |
9423c74d96 | ||
![]() |
2b95c88188 | ||
![]() |
e5ac111747 | ||
![]() |
34d86fc387 | ||
![]() |
5664e5cc9f | ||
![]() |
83c88d73d7 | ||
![]() |
16eeb9401e | ||
![]() |
e317f2c5ff | ||
![]() |
25513b8104 | ||
![]() |
eefcbc30a3 | ||
![]() |
52a7298a9d | ||
![]() |
6af904dbd4 | ||
![]() |
07a9632860 | ||
![]() |
4b05ab1920 | ||
![]() |
e2f1313566 | ||
![]() |
fcdea6050e | ||
![]() |
2888dcdd45 | ||
![]() |
a6a2b69820 | ||
![]() |
79ff7293ea | ||
![]() |
32b979eeb6 | ||
![]() |
2f07435816 | ||
![]() |
e258ffda38 | ||
![]() |
f3e9b08026 | ||
![]() |
ea2f93ea06 | ||
![]() |
54979c5d0e | ||
![]() |
14e283768b | ||
![]() |
0562e5de8d | ||
![]() |
f4f51d11c5 | ||
![]() |
b1fa28980e | ||
![]() |
acca61f2ca | ||
![]() |
1164afac5e | ||
![]() |
eafae46409 | ||
![]() |
104c4fc993 | ||
![]() |
a1c507b477 | ||
![]() |
2215511f2c | ||
![]() |
dcb87a39b7 | ||
![]() |
e89b9ffb30 | ||
![]() |
a440b79530 | ||
![]() |
2bfbae74ab | ||
![]() |
3865e95296 | ||
![]() |
93db7c45bc | ||
![]() |
ad80c716f9 | ||
![]() |
b26950c427 | ||
![]() |
a62851915c | ||
![]() |
0be5901df7 | ||
![]() |
534c87509a | ||
![]() |
7dd7166bcf | ||
![]() |
3663a8b10b | ||
![]() |
e1c0bf5030 | ||
![]() |
d69b766a3a | ||
![]() |
329b645a3d | ||
![]() |
1894af121f | ||
![]() |
c581fe2f3a | ||
![]() |
5b404615fc | ||
![]() |
530165f5ee | ||
![]() |
f94c1f34b6 | ||
![]() |
92a4d9911e | ||
![]() |
5e24ef9848 | ||
![]() |
f577522fe1 | ||
![]() |
e0cda4b35c | ||
![]() |
a2da75ce98 | ||
![]() |
0b2da4c664 | ||
![]() |
63bc00d8e3 | ||
![]() |
d359ad27aa | ||
![]() |
3b9a0f782e | ||
![]() |
cb2a579252 | ||
![]() |
cbbbe402be | ||
![]() |
5a2627932d | ||
![]() |
917696a543 | ||
![]() |
9ffc912a2c | ||
![]() |
be16297549 | ||
![]() |
e332622db9 | ||
![]() |
39a627d839 | ||
![]() |
9381e086a1 | ||
![]() |
8c46b758ce | ||
![]() |
101459f2f6 | ||
![]() |
81ac6276bd | ||
![]() |
5fc28a733c | ||
![]() |
13ad9adb8b | ||
![]() |
3baf18ea45 | ||
![]() |
98729f63df | ||
![]() |
476d5bebf2 | ||
![]() |
146e8e7a63 | ||
![]() |
0d4c1d1471 | ||
![]() |
7db3d7da0f | ||
![]() |
71a9138b23 | ||
![]() |
ad53632be4 | ||
![]() |
b433ef68ec | ||
![]() |
a95510260d | ||
![]() |
d1d0922a8c | ||
![]() |
ec7a246afc | ||
![]() |
d4fb3a3399 | ||
![]() |
5d3c10d198 | ||
![]() |
684c15a404 | ||
![]() |
152bd37c22 | ||
![]() |
5aedc3a4f6 | ||
![]() |
bf72154727 | ||
![]() |
4821dd7c66 | ||
![]() |
d87a01fad8 | ||
![]() |
eb1d4a3c2a | ||
![]() |
c668523c57 | ||
![]() |
553a61b4d2 | ||
![]() |
1bb9123333 | ||
![]() |
3646e6d20a | ||
![]() |
55f7eca2e8 | ||
![]() |
4181d8a0c5 | ||
![]() |
6cf82347bf | ||
![]() |
aaf5080a27 | ||
![]() |
35ddf3c899 | ||
![]() |
5020bae77e | ||
![]() |
09fe812dd7 | ||
![]() |
8d1f30c101 | ||
![]() |
d6fd4ab586 | ||
![]() |
da377d83e6 | ||
![]() |
dda7d44601 | ||
![]() |
215aae5366 | ||
![]() |
7be73d59d6 | ||
![]() |
80592f60c6 | ||
![]() |
2c531eb1d6 | ||
![]() |
2de72eac56 | ||
![]() |
e77635cdd2 | ||
![]() |
3513988a07 | ||
![]() |
2ed62cb82e | ||
![]() |
bfc8a0cb3b | ||
![]() |
eb9f6876b0 | ||
![]() |
274d86422c | ||
![]() |
4977b746b9 | ||
![]() |
fd655312ab | ||
![]() |
b339482d89 | ||
![]() |
755344e74b | ||
![]() |
8985e5c24a | ||
![]() |
62d3782d04 | ||
![]() |
72d22d40ef | ||
![]() |
66c81c8191 | ||
![]() |
7832248a08 | ||
![]() |
076b7c7a0a | ||
![]() |
3cfbc646e3 | ||
![]() |
5d4f1ea0ad | ||
![]() |
1f693b80f7 | ||
![]() |
96d30d6725 | ||
![]() |
49f20f33db | ||
![]() |
4dee2e9a1b | ||
![]() |
396f85d273 | ||
![]() |
ba46769fcf | ||
![]() |
8a2d053353 | ||
![]() |
c773dc0abc | ||
![]() |
e71c060b69 | ||
![]() |
12b2bdf70a | ||
![]() |
a555fd3876 | ||
![]() |
448f0e3428 | ||
![]() |
8e3e1b9af8 | ||
![]() |
93260396c6 | ||
![]() |
175982443e | ||
![]() |
18746b7116 | ||
![]() |
3a562749dd | ||
![]() |
3a89bfd54b | ||
![]() |
d6a3635192 | ||
![]() |
8f8e83fbb6 | ||
![]() |
a42f635ae7 | ||
![]() |
8d4a5751d8 | ||
![]() |
e60b38527c | ||
![]() |
4b551ef679 | ||
![]() |
de2e5a11aa | ||
![]() |
395ae987da | ||
![]() |
d30ef227ad | ||
![]() |
73bb6081fc | ||
![]() |
c52a06728c | ||
![]() |
b2853fd67f | ||
![]() |
4f2f419ae2 | ||
![]() |
6fcb4ff978 | ||
![]() |
2761a5e033 | ||
![]() |
1e90feecaf | ||
![]() |
832a426f4c | ||
![]() |
af4b532a00 | ||
![]() |
a8193d80c8 | ||
![]() |
d1307c6a2c | ||
![]() |
818f2c9d8e | ||
![]() |
f958f3d24b | ||
![]() |
438568eeb0 | ||
![]() |
eac1240437 | ||
![]() |
23fb178ec4 | ||
![]() |
ebf63b5bed | ||
![]() |
eaaca05f36 | ||
![]() |
56a9836e86 | ||
![]() |
9add728b08 | ||
![]() |
74322cda36 | ||
![]() |
c2d41e0671 | ||
![]() |
d613bb5a44 | ||
![]() |
6d3ae4cc73 | ||
![]() |
fd70776166 | ||
![]() |
879bff854e | ||
![]() |
d8e3e25f06 | ||
![]() |
ef1f84ee7c | ||
![]() |
95b8df2918 | ||
![]() |
3b0083190e | ||
![]() |
372a144322 | ||
![]() |
281c47198c | ||
![]() |
d2e8a9368c | ||
![]() |
c38100427d | ||
![]() |
c3d04a5490 | ||
![]() |
db2fd9ad70 | ||
![]() |
bea1680360 | ||
![]() |
9c94efb863 | ||
![]() |
5b8c705f03 | ||
![]() |
edb4c9168d | ||
![]() |
f266f93cc8 | ||
![]() |
5a9e9209c8 | ||
![]() |
f9bc049271 | ||
![]() |
6d820f4f6e | ||
![]() |
8eb4f7e7da | ||
![]() |
8ace25849e | ||
![]() |
4ef7a6a1ee | ||
![]() |
76df9c8d76 | ||
![]() |
13068ccce2 | ||
![]() |
a5c14a17d3 | ||
![]() |
6b11020a67 | ||
![]() |
c60412dcb3 | ||
![]() |
aaac82a5c9 | ||
![]() |
bbbf28c5ce | ||
![]() |
cd09c5a59d | ||
![]() |
57dbb9e5db | ||
![]() |
b497f52ae7 | ||
![]() |
bfc27def57 | ||
![]() |
3550d5453d | ||
![]() |
d19a55b08a | ||
![]() |
2ef7e262f5 | ||
![]() |
71dd48b0a0 | ||
![]() |
6aa63fe76b | ||
![]() |
3163f919e6 | ||
![]() |
54349ceefa | ||
![]() |
fb3c429116 | ||
![]() |
08c7de148b | ||
![]() |
650e23ed0b | ||
![]() |
f607c0b989 | ||
![]() |
300fb7f5e6 | ||
![]() |
a5ce226324 | ||
![]() |
b5b69861ee | ||
![]() |
c742c91c85 | ||
![]() |
f68af83dbe | ||
![]() |
96197b01ee | ||
![]() |
641616efb7 | ||
![]() |
0cd41ed9c4 | ||
![]() |
b44d0a653a | ||
![]() |
df31426566 | ||
![]() |
77bc8a7745 | ||
![]() |
c84837f8cb | ||
![]() |
9107edf867 | ||
![]() |
278d5bf74e | ||
![]() |
ee448170ef | ||
![]() |
e99c8ed86b | ||
![]() |
4de5d53fe4 | ||
![]() |
5a5caca256 | ||
![]() |
2dcac57cba | ||
![]() |
f26f7c60af | ||
![]() |
bc07a459ea | ||
![]() |
d3774d606a | ||
![]() |
0574a1b820 | ||
![]() |
3f5a50fc0d | ||
![]() |
68da86aadc | ||
![]() |
4a6e041ca8 | ||
![]() |
463e92d1e1 | ||
![]() |
83a0e1057e | ||
![]() |
13603b7141 | ||
![]() |
6dfe4a0c08 | ||
![]() |
dc7b44a7cc | ||
![]() |
c7c331ee84 | ||
![]() |
c7eb491d3e | ||
![]() |
a2d01b20da | ||
![]() |
175770d8b8 | ||
![]() |
a1df02b41c | ||
![]() |
66499acbf7 | ||
![]() |
0834ab7756 | ||
![]() |
ffffdcf028 | ||
![]() |
97f8c35009 | ||
![]() |
d133cf2028 | ||
![]() |
b2fc35a3df | ||
![]() |
92d49fdcc7 | ||
![]() |
e0b01a89ef | ||
![]() |
59c9441b3b | ||
![]() |
fe5466dfda | ||
![]() |
a3fd4616e3 | ||
![]() |
b4f0c7ef31 | ||
![]() |
fee2ef3cc9 | ||
![]() |
959541f8ee | ||
![]() |
9aef4f2741 | ||
![]() |
19c1e51c60 | ||
![]() |
b94da621d7 | ||
![]() |
d39816e5eb | ||
![]() |
5fd4d7b44b | ||
![]() |
d2ef23d3fa | ||
![]() |
e303850584 | ||
![]() |
aa19771307 | ||
![]() |
b5cd9498b1 | ||
![]() |
12b845ef97 | ||
![]() |
89eca5f14b | ||
![]() |
75190d9854 | ||
![]() |
a94f238952 | ||
![]() |
d5b912aed3 | ||
![]() |
6252a166af | ||
![]() |
f489573298 | ||
![]() |
20cbeb7f82 | ||
![]() |
8794c92a83 | ||
![]() |
c898ec2b21 | ||
![]() |
1e8f8aba25 | ||
![]() |
06e4b77e29 | ||
![]() |
eb7c07b308 | ||
![]() |
7ea523db37 | ||
![]() |
8c9fccb8bf | ||
![]() |
73e4c1cf28 | ||
![]() |
2b787025a4 | ||
![]() |
26483334c5 | ||
![]() |
a7145511fd | ||
![]() |
61e1260144 | ||
![]() |
cd48262f77 | ||
![]() |
4e6410ec17 | ||
![]() |
a7af9e3448 | ||
![]() |
2b0bb77a86 | ||
![]() |
ef643a14a3 | ||
![]() |
8db8ecfccd | ||
![]() |
bfce146895 | ||
![]() |
a7ac8ce527 | ||
![]() |
3eca66c81b | ||
![]() |
9a0f7623b5 | ||
![]() |
d6318f9090 | ||
![]() |
f9854bc54b | ||
![]() |
8f45abec27 | ||
![]() |
d02efee094 | ||
![]() |
6ebfb6f00f | ||
![]() |
3b0c8b3b15 | ||
![]() |
b4c716302f | ||
![]() |
8d6e475479 | ||
![]() |
c4f1bed517 | ||
![]() |
e87c80eee1 | ||
![]() |
174a48f252 | ||
![]() |
04c4552a9c | ||
![]() |
28a8b9600d | ||
![]() |
ac8c568d39 | ||
![]() |
76a86fa34a | ||
![]() |
45878db35f | ||
![]() |
6fe36db477 | ||
![]() |
09f97c40d7 | ||
![]() |
028377c7a4 | ||
![]() |
5dd5cb1cc3 | ||
![]() |
4835a956ec | ||
![]() |
3495af7712 | ||
![]() |
caa27f30cf | ||
![]() |
0a6f28ae28 | ||
![]() |
17e723a050 | ||
![]() |
6e45b0be90 | ||
![]() |
632373795e | ||
![]() |
048fbc2e56 | ||
![]() |
9ed0ae747b | ||
![]() |
516743673a | ||
![]() |
fcb413f71a | ||
![]() |
afdaf46824 | ||
![]() |
6c903239dd | ||
![]() |
d442de0444 | ||
![]() |
ea6acddff9 | ||
![]() |
dc4e3a6780 | ||
![]() |
e623088884 | ||
![]() |
0ecfb89f19 | ||
![]() |
481d6ef0a2 | ||
![]() |
93366f4e9e | ||
![]() |
0f6a0376da | ||
![]() |
1b163d1895 | ||
![]() |
02eaa661aa | ||
![]() |
8fb459dc48 | ||
![]() |
48ef3dcb14 | ||
![]() |
251c1ea64f | ||
![]() |
da30d4688e | ||
![]() |
182795ec10 | ||
![]() |
1b017c06e0 | ||
![]() |
2614b072f2 | ||
![]() |
1c37732b34 | ||
![]() |
0d88bd89d0 | ||
![]() |
d0b32b95c1 | ||
![]() |
d2cf6e0f14 | ||
![]() |
9551f548ad | ||
![]() |
636db204fc | ||
![]() |
f46ab23207 | ||
![]() |
3b1614b0dc | ||
![]() |
6a5ed43275 | ||
![]() |
5c2005d5d2 | ||
![]() |
c7c04c561f | ||
![]() |
5a6b338766 | ||
![]() |
4ecd044788 | ||
![]() |
64d2c5aeb9 | ||
![]() |
7955a4fa27 | ||
![]() |
4ddd3e803f | ||
![]() |
c6d5d862e2 | ||
![]() |
4c09cc1fc3 | ||
![]() |
3f026090ca | ||
![]() |
28efe27cbe | ||
![]() |
04ea18c87d | ||
![]() |
740373d9d8 | ||
![]() |
be1d048e7b | ||
![]() |
c95427e299 | ||
![]() |
a2458cf668 | ||
![]() |
a314eb5885 | ||
![]() |
e62dc24576 | ||
![]() |
2f56057fc3 | ||
![]() |
b0787f7331 | ||
![]() |
4c48c3240b | ||
![]() |
e200ab0dab | ||
![]() |
f266f55981 | ||
![]() |
e1d3dd4a65 | ||
![]() |
d8ed40422e | ||
![]() |
583dab4bc6 | ||
![]() |
9e9ea0ef15 | ||
![]() |
d42c676503 | ||
![]() |
73e6668d20 | ||
![]() |
dd061f56d4 | ||
![]() |
e46a5cdf78 | ||
![]() |
c003aec9c9 | ||
![]() |
d523d0a114 | ||
![]() |
e4d8286535 | ||
![]() |
b48a15cfa3 | ||
![]() |
e982e6bb25 | ||
![]() |
c1c2b67503 | ||
![]() |
fabe06e339 | ||
![]() |
920645f90e | ||
![]() |
13edf426a6 | ||
![]() |
c9189a12d1 | ||
![]() |
ca128df49a | ||
![]() |
e9709e54ee | ||
![]() |
02c1408f5c | ||
![]() |
4cb5eb782f | ||
![]() |
e9dded774a | ||
![]() |
e977b901e1 | ||
![]() |
e6c1f1e4bd | ||
![]() |
1c8ab5c721 | ||
![]() |
3386de40c7 | ||
![]() |
d32c11a595 | ||
![]() |
8a70595aff | ||
![]() |
575d007575 | ||
![]() |
903330b972 | ||
![]() |
a7fa97a214 | ||
![]() |
c8c135e31f | ||
![]() |
35ac1ccdf5 | ||
![]() |
46f13bf9cd | ||
![]() |
2388a05003 | ||
![]() |
7b9ba2efbc | ||
![]() |
125d51f7bf | ||
![]() |
a2c885b8bc | ||
![]() |
ebf6af2f81 | ||
![]() |
19e0ea5f6c | ||
![]() |
0bd13fe541 | ||
![]() |
d00a1a1e84 | ||
![]() |
738c8e95ee | ||
![]() |
34915f176b | ||
![]() |
58bd242b5b | ||
![]() |
bfefb0ff27 | ||
![]() |
88d0c77f3b | ||
![]() |
2819ca86cb | ||
![]() |
1ece738613 | ||
![]() |
5bfbe15c24 | ||
![]() |
644f98fe60 | ||
![]() |
ef4de09a8a | ||
![]() |
1054c019ce | ||
![]() |
11010393d8 | ||
![]() |
9dd8592fdd | ||
![]() |
f39ee4e783 | ||
![]() |
b290d271c0 | ||
![]() |
1c8ac0f866 | ||
![]() |
76cd4d382a | ||
![]() |
ed8d68bd54 | ||
![]() |
151b86acc0 | ||
![]() |
fb3da6bf3d | ||
![]() |
b163b1305b | ||
![]() |
bc9252f410 | ||
![]() |
0f896c1b6e | ||
![]() |
ca51c48474 | ||
![]() |
f91b27dc8f | ||
![]() |
6cd7d669c1 | ||
![]() |
3064844c6e | ||
![]() |
a01759756e | ||
![]() |
469f392235 | ||
![]() |
d6703f2da7 | ||
![]() |
823f11ef63 | ||
![]() |
b46ab6d3a9 | ||
![]() |
d91d6e5ce3 | ||
![]() |
6053b0948b | ||
![]() |
7aa850824c | ||
![]() |
4ec4a1bb5a | ||
![]() |
7015c047d1 | ||
![]() |
443ba184c3 | ||
![]() |
ecc35164b3 | ||
![]() |
0d8c0366d3 | ||
![]() |
ac69f6c16e | ||
![]() |
d0f1a2700b | ||
![]() |
b45baaa421 | ||
![]() |
30dbe88560 | ||
![]() |
05585b701b | ||
![]() |
bf2703bc60 | ||
![]() |
93c7c8727c | ||
![]() |
cd639e89c4 | ||
![]() |
60badb2198 | ||
![]() |
f0b033b889 | ||
![]() |
1f31218639 | ||
![]() |
0db6f0c866 | ||
![]() |
30f9f030cd | ||
![]() |
98ed4fb384 | ||
![]() |
9d2bc9c41d | ||
![]() |
a1a9f3ac6a | ||
![]() |
3ffd88f0df | ||
![]() |
4056187fec | ||
![]() |
f6cd24d6c9 | ||
![]() |
d86438bde9 | ||
![]() |
d3f543cb15 | ||
![]() |
e0bf1f1b77 | ||
![]() |
492efe1ffa | ||
![]() |
4dbac141f2 | ||
![]() |
7aee36eab1 | ||
![]() |
9f3eb7e4a4 | ||
![]() |
9f8e9c018b | ||
![]() |
e71115db26 | ||
![]() |
51da1a16a0 | ||
![]() |
c2a3f7d9d7 | ||
![]() |
68512d2dcd | ||
![]() |
0dc9e78710 | ||
![]() |
b6f68b8786 | ||
![]() |
ffcb9879c8 | ||
![]() |
a0addc7edc | ||
![]() |
1e4f97425f | ||
![]() |
c140578c65 | ||
![]() |
4afe4a7cea | ||
![]() |
71fc73fa33 | ||
![]() |
c09d2fcd5d | ||
![]() |
a97d81a8dc | ||
![]() |
6904b1f3d0 | ||
![]() |
bb340b8785 | ||
![]() |
855d440d5b | ||
![]() |
0ebf4949de | ||
![]() |
13f16486fb | ||
![]() |
7d4bac687e | ||
![]() |
956e990fe6 | ||
![]() |
4b13f0c74f | ||
![]() |
9f9425c408 | ||
![]() |
4b127f1eda | ||
![]() |
623030a0c5 | ||
![]() |
6a316979f8 | ||
![]() |
2ed54b8609 | ||
![]() |
71fd71d18c | ||
![]() |
070472c12d | ||
![]() |
d19728c533 | ||
![]() |
6f2aa1a318 | ||
![]() |
1df827c58a | ||
![]() |
4e5e466b03 | ||
![]() |
f86a64787d | ||
![]() |
56629ccb1c | ||
![]() |
f1fbcd473e | ||
![]() |
8733b205a0 | ||
![]() |
6eb589444a | ||
![]() |
1bb630cf83 | ||
![]() |
54f1b55006 | ||
![]() |
61e88681ed | ||
![]() |
fd8f5a96c9 | ||
![]() |
9302dfbd56 | ||
![]() |
3d0031b207 | ||
![]() |
3135bfc12a | ||
![]() |
d205f1c19e | ||
![]() |
f0299a87eb | ||
![]() |
1f4f4263a3 | ||
![]() |
68369a8f13 | ||
![]() |
85c88062e6 | ||
![]() |
647bea3a45 | ||
![]() |
6cf0b46711 | ||
![]() |
2edd3ec800 | ||
![]() |
7691fbd90f | ||
![]() |
abedb266d4 | ||
![]() |
00275df910 | ||
![]() |
3847c67087 | ||
![]() |
edac5a1910 | ||
![]() |
81d183b808 | ||
![]() |
27e1e83d0b | ||
![]() |
158ea9fad2 | ||
![]() |
b6d60d9c95 | ||
![]() |
ab0b5dd804 | ||
![]() |
f149c63b5b | ||
![]() |
d6d1883587 | ||
![]() |
923b882f05 | ||
![]() |
1be8c7d387 | ||
![]() |
d41b697bfb | ||
![]() |
cce5cff539 | ||
![]() |
185d5111d7 | ||
![]() |
59c897645d | ||
![]() |
1ae98699e7 | ||
![]() |
c7a962bfd0 | ||
![]() |
e23b4fdd34 | ||
![]() |
ccee852e33 | ||
![]() |
26c099a50a | ||
![]() |
d27e16e9ca | ||
![]() |
b19013daf4 | ||
![]() |
137e9dd0e0 | ||
![]() |
7f5e975a30 | ||
![]() |
9e64dcb49d | ||
![]() |
4405550cab | ||
![]() |
e6566189ed | ||
![]() |
fb48477aa7 | ||
![]() |
af11a176f1 | ||
![]() |
6a87f388ee | ||
![]() |
b7a25e5a32 | ||
![]() |
4d8e73b654 | ||
![]() |
9e61bfc5df | ||
![]() |
a7f7016b4e | ||
![]() |
82735bba69 | ||
![]() |
c8826d060e | ||
![]() |
c10c03178b | ||
![]() |
67c0ca0be3 | ||
![]() |
3be5227c22 | ||
![]() |
078ca39606 | ||
![]() |
5e2c24b662 | ||
![]() |
ea9895e732 | ||
![]() |
893fab2975 | ||
![]() |
f101dded3a | ||
![]() |
c818ad965c | ||
![]() |
442d7f7393 | ||
![]() |
89bfe74f50 | ||
![]() |
07478a588c | ||
![]() |
5988ea36cb | ||
![]() |
d6551e174c | ||
![]() |
fc84082c87 | ||
![]() |
674536c1f5 | ||
![]() |
a22d7facbd | ||
![]() |
8d40422e0f | ||
![]() |
b3612927c8 | ||
![]() |
a3b285fa62 | ||
![]() |
63520d2370 | ||
![]() |
3abac31161 | ||
![]() |
3ecac7671a | ||
![]() |
868fae6ac2 | ||
![]() |
7ca08f0a36 | ||
![]() |
c37a7690d6 | ||
![]() |
a914b5d194 | ||
![]() |
8859911c73 | ||
![]() |
948dbc9e85 | ||
![]() |
6ddb03c452 | ||
![]() |
3d26366620 | ||
![]() |
bbffbbc5f3 | ||
![]() |
baca95e4eb | ||
![]() |
50dc9fca16 | ||
![]() |
59bcc8eb13 | ||
![]() |
cb07774fa5 | ||
![]() |
e3d3420901 | ||
![]() |
30132c5757 | ||
![]() |
44adcd44de | ||
![]() |
dba47bca28 | ||
![]() |
69f84c99d0 | ||
![]() |
e54c33e8e8 | ||
![]() |
b7932e4b57 | ||
![]() |
57962918e7 | ||
![]() |
f5221ab1ee | ||
![]() |
dea4d185ae | ||
![]() |
c593be51c1 | ||
![]() |
25f5f9b060 | ||
![]() |
931e467b25 | ||
![]() |
3cc7ecc2b5 | ||
![]() |
30074ecd36 | ||
![]() |
d6bdcd4f54 | ||
![]() |
edd7e70adc | ||
![]() |
0146d6ff07 | ||
![]() |
fc3e517027 | ||
![]() |
ea92e8b12e | ||
![]() |
9f2e708798 | ||
![]() |
0e5cd6043f | ||
![]() |
89481edd11 | ||
![]() |
95a81d17ce | ||
![]() |
d695bb34cc | ||
![]() |
aa5eff915d | ||
![]() |
9fe78d6247 | ||
![]() |
ed57ecca99 | ||
![]() |
cf0c8f3855 | ||
![]() |
194e530d3b | ||
![]() |
20939e8121 | ||
![]() |
6bbde095a8 | ||
![]() |
cd1aa810dd | ||
![]() |
6a21776124 | ||
![]() |
0b0cd5a791 | ||
![]() |
a06f1aed77 | ||
![]() |
02b92b5916 | ||
![]() |
65d25fc3f9 | ||
![]() |
49c747d7d0 | ||
![]() |
f08254d2bf | ||
![]() |
09c56da8c6 | ||
![]() |
26a023813e | ||
![]() |
6c40e05d2d | ||
![]() |
708ec66d9b | ||
![]() |
68b80d0eb6 | ||
![]() |
6ada0aabda | ||
![]() |
a401b1cb48 | ||
![]() |
c7505389d4 | ||
![]() |
60220c7424 | ||
![]() |
f6339c7b5c | ||
![]() |
5868db0124 | ||
![]() |
1c8d2725c6 | ||
![]() |
37824136c0 | ||
![]() |
fe61e898a3 | ||
![]() |
97d297e18c | ||
![]() |
9a4f1a0781 | ||
![]() |
f346f0f9ea | ||
![]() |
33450f86bb | ||
![]() |
c479a90c42 | ||
![]() |
cf5cd95c83 | ||
![]() |
2bbe38f4f5 | ||
![]() |
4ddaba889f | ||
![]() |
ad0a0e0841 | ||
![]() |
c6a4d43efe | ||
![]() |
f9a2eb87f0 | ||
![]() |
777a89877a | ||
![]() |
708d0e0b00 | ||
![]() |
07d8be5465 | ||
![]() |
ca19c5998b | ||
![]() |
12f9ad46b5 | ||
![]() |
620271bec4 | ||
![]() |
bf6d4718e4 | ||
![]() |
1e59d53135 | ||
![]() |
4581e47c80 | ||
![]() |
40aaa2694d | ||
![]() |
c358281193 | ||
![]() |
8a9b3efbb0 | ||
![]() |
a266027f6c | ||
![]() |
b060c751ce | ||
![]() |
f8f074cb95 | ||
![]() |
178ee281b1 | ||
![]() |
bc8b4d08e7 | ||
![]() |
97f8c017ae | ||
![]() |
96f63d010c | ||
![]() |
4485460f53 | ||
![]() |
570a81f966 | ||
![]() |
039feef938 | ||
![]() |
e7e36a080f | ||
![]() |
35b1d01621 | ||
![]() |
ca403d5583 | ||
![]() |
6d61c52126 | ||
![]() |
6dbcfee080 | ||
![]() |
d519a29c7c | ||
![]() |
51d218a484 | ||
![]() |
531d7680e7 | ||
![]() |
faf45d45e1 | ||
![]() |
df34e57e65 | ||
![]() |
c6b85651af | ||
![]() |
207f516673 | ||
![]() |
4d681f053e | ||
![]() |
2579d7c2b3 | ||
![]() |
1ac0e65dd8 | ||
![]() |
e8ad7a9adc | ||
![]() |
f8e555dac5 | ||
![]() |
93a710c9ae | ||
![]() |
8a6ed2ac2e | ||
![]() |
860953e331 | ||
![]() |
267dd52ec3 | ||
![]() |
f268dc69ab | ||
![]() |
d66227256a | ||
![]() |
31ce55a3a1 | ||
![]() |
8779c49660 | ||
![]() |
5777738636 | ||
![]() |
c2d7e101cd | ||
![]() |
82f033b1da | ||
![]() |
77a6110c25 | ||
![]() |
2b8b10c691 | ||
![]() |
da7975f82b | ||
![]() |
b4fea57543 | ||
![]() |
99ff64bd82 | ||
![]() |
97b78aa695 | ||
![]() |
50549f3bfe |
2
.gitattributes
vendored
|
@ -1 +1,3 @@
|
|||
frontend/* linguist-vendored
|
||||
VERSION export-subst
|
||||
* text=auto eol=lf
|
||||
|
|
18
.github/ISSUE_TEMPLATE/confirmed-bug.md
vendored
Normal 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.
|
14
.github/ISSUE_TEMPLATE/feature-or-change-request.md
vendored
Normal 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.
|
10
.github/ISSUE_TEMPLATE/general-question.md
vendored
Normal 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: ''
|
||||
|
||||
---
|
||||
|
||||
|
18
.github/ISSUE_TEMPLATE/possible-bug--needs-investigation-.md
vendored
Normal file
|
@ -0,0 +1,18 @@
|
|||
---
|
||||
name: Possible bug. Needs investigation.
|
||||
about: Report an issue that could be a bug but is not confirmed yet and needs investigation.
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Version:**
|
||||
- listmonk: [eg: v1.0.0]
|
||||
- OS: [e.g. Fedora]
|
||||
|
||||
**Description of the bug and steps to reproduce:**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**Screenshots:**
|
||||
If applicable, add screenshots to help explain your problem.
|
61
.github/workflows/github-pages.yml
vendored
Normal file
|
@ -0,0 +1,61 @@
|
|||
name: publish-github-pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- 'docs/**'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
submodules: true # Fetch Hugo themes
|
||||
fetch-depth: 0 # Fetch all history for .GitInfo and .Lastmod
|
||||
|
||||
- uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: 3.x
|
||||
- run: pip install mkdocs-material
|
||||
|
||||
- name: Setup Hugo
|
||||
uses: peaceiris/actions-hugo@v2
|
||||
with:
|
||||
hugo-version: '0.68.3'
|
||||
|
||||
# Build the main site to the docs/publish directory. This will be the root (/) in gh-pages.
|
||||
# The -d (output) path is relative to the -s (source) path
|
||||
- name: Build main site
|
||||
run: hugo -s docs/site -d ../publish --gc --minify
|
||||
|
||||
# Build the mkdocs documentation in the docs/publish/docs dir. This will be at (/docs)
|
||||
# The -d (output) path is relative to the -f (source) path
|
||||
- name: Build docs site
|
||||
run: mkdocs build -f docs/docs/mkdocs.yml -d ../publish/docs
|
||||
|
||||
# Copy the static i18n app to the publish directory. This will be at (/i18n)
|
||||
- name: Copy i18n site
|
||||
run: cp -R docs/i18n docs/publish
|
||||
|
||||
- name: Generate Swagger UI
|
||||
uses: Legion2/swagger-ui-action@v1
|
||||
with:
|
||||
spec-file: ./docs/swagger/collections.yaml
|
||||
output: ./docs/publish/docs/swagger
|
||||
|
||||
- name: Deploy
|
||||
uses: peaceiris/actions-gh-pages@v3
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_branch: gh-pages
|
||||
publish_dir: ./docs/publish
|
||||
cname: listmonk.app
|
||||
user_name: 'github-actions[bot]'
|
||||
user_email: 'github-actions[bot]@users.noreply.github.com'
|
54
.github/workflows/release.yml
vendored
Normal 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 }}
|
137
.goreleaser.yml
|
@ -1,6 +1,8 @@
|
|||
env:
|
||||
- GO111MODULE=on
|
||||
- CGO_ENABLED=0
|
||||
- GITHUB_ORG=knadh
|
||||
- DOCKER_ORG=listmonk
|
||||
|
||||
before:
|
||||
hooks:
|
||||
|
@ -10,16 +12,21 @@ builds:
|
|||
- binary: listmonk
|
||||
main: ./cmd
|
||||
goos:
|
||||
- linux
|
||||
- windows
|
||||
- darwin
|
||||
- linux
|
||||
- freebsd
|
||||
- openbsd
|
||||
- netbsd
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
- arm
|
||||
goarm:
|
||||
- 6
|
||||
- 7
|
||||
ldflags:
|
||||
- -s -w -X "main.buildString={{ .Tag }} ({{ .ShortCommit }} {{ .Date }})" -X "main.versionString={{ .Tag }}"
|
||||
- -s -w -X "main.buildString={{ .Tag }} ({{ .ShortCommit }} {{ .Date }}, {{ .Os }}/{{ .Arch }})" -X "main.versionString={{ .Tag }}"
|
||||
|
||||
hooks:
|
||||
# stuff executables with static assets.
|
||||
|
@ -32,15 +39,127 @@ archives:
|
|||
- LICENSE
|
||||
|
||||
dockers:
|
||||
-
|
||||
- use: buildx
|
||||
goos: linux
|
||||
goarch: amd64
|
||||
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
|
@ -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!
|
|
@ -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
|
||||
|
|
94
Makefile
|
@ -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"
|
||||
|
|
41
README.md
|
@ -1,33 +1,39 @@
|
|||
<a href="https://zerodha.tech"><img src="https://zerodha.tech/static/images/github-badge.svg" align="right" /></a>
|
||||
|
||||

|
||||
[](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.
|
||||
|
||||
[](https://listmonk.app)
|
||||
Visit [listmonk.app](https://listmonk.app)
|
||||
[](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.
|
||||
|
||||
[](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.
|
||||
|
|
8
TODO.md
|
@ -1,8 +0,0 @@
|
|||
- [ ] Add a "running campaigns" widget on the dashboard
|
||||
- [ ] Add more analytics and stats
|
||||
- [ ] Add bounce tracking
|
||||
- [ ] Pause campaigns on % errors in addition to an absolute numbers
|
||||
- [ ] Support DB migrations for easy upgrades
|
||||
- [ ] Add materialized views for analytics and stats (and more?)
|
||||
- [ ] Add user management and permissions
|
||||
- [ ] Add tests
|
2
VERSION
Normal file
|
@ -0,0 +1,2 @@
|
|||
$Format:%h$
|
||||
$Format:%D$
|
21
cmd/admin.go
|
@ -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
|
@ -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
|
@ -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
|
||||
}
|
571
cmd/campaigns.go
|
@ -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
|
@ -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
|
||||
}
|
258
cmd/handlers.go
|
@ -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
|
||||
}
|
||||
|
|
25
cmd/i18n.go
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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()})
|
||||
|
|
566
cmd/init.go
|
@ -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 := ¬ifTpls{
|
||||
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
|
||||
}
|
||||
|
|
118
cmd/install.go
|
@ -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><a href="https:/‌/listmonk.app@TrackLink"></a></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.
|
||||
|
|
167
cmd/lists.go
|
@ -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})
|
||||
|
|
111
cmd/main.go
|
@ -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
|
@ -0,0 +1,92 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// handleGCSubscribers garbage collects (deletes) orphaned or blocklisted subscribers.
|
||||
func handleGCSubscribers(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
typ = c.Param("type")
|
||||
)
|
||||
|
||||
var (
|
||||
n int
|
||||
err error
|
||||
)
|
||||
|
||||
switch typ {
|
||||
case "blocklisted":
|
||||
n, err = app.core.DeleteBlocklistedSubscribers()
|
||||
case "orphan":
|
||||
n, err = app.core.DeleteOrphanSubscribers()
|
||||
default:
|
||||
err = echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidData"))
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, okResp{struct {
|
||||
Count int `json:"count"`
|
||||
}{n}})
|
||||
}
|
||||
|
||||
// handleGCSubscriptions garbage collects (deletes) orphaned or blocklisted subscribers.
|
||||
func handleGCSubscriptions(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
)
|
||||
|
||||
t, err := time.Parse(time.RFC3339, c.FormValue("before_date"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidData"))
|
||||
}
|
||||
|
||||
n, err := app.core.DeleteUnconfirmedSubscriptions(t)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, okResp{struct {
|
||||
Count int `json:"count"`
|
||||
}{n}})
|
||||
}
|
||||
|
||||
// handleGCCampaignAnalytics garbage collects (deletes) campaign analytics.
|
||||
func handleGCCampaignAnalytics(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
typ = c.Param("type")
|
||||
)
|
||||
|
||||
t, err := time.Parse(time.RFC3339, c.FormValue("before_date"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidData"))
|
||||
}
|
||||
|
||||
switch typ {
|
||||
case "all":
|
||||
if err := app.core.DeleteCampaignViews(t); err != nil {
|
||||
return err
|
||||
}
|
||||
err = app.core.DeleteCampaignLinkClicks(t)
|
||||
case "views":
|
||||
err = app.core.DeleteCampaignViews(t)
|
||||
case "clicks":
|
||||
err = app.core.DeleteCampaignLinkClicks(t)
|
||||
default:
|
||||
err = echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidData"))
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, okResp{true})
|
||||
}
|
|
@ -1,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
|
@ -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
|
||||
}
|
179
cmd/media.go
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
541
cmd/public.go
|
@ -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
|
||||
}
|
||||
|
|
260
cmd/settings.go
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
231
cmd/templates.go
|
@ -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
|
@ -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
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
76
cmd/utils.go
|
@ -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"))
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
@ -0,0 +1 @@
|
|||
!config.toml
|
62
dev/README.md
Normal 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
|
@ -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
|
@ -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
|
@ -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:
|
|
@ -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
|
@ -0,0 +1,9 @@
|
|||
# Static website and docs
|
||||
|
||||
This repository contains the source for the static website https://listmonk.app
|
||||
|
||||
- The website is in `site` and is built with hugo (run `hugo serve` inside `site` to preview).
|
||||
|
||||
- Documentation is in `docs` and is built with mkdocs (inside `docs`, run `mkdocs serve` to preview after running `pip install -r requirements.txt`)
|
||||
|
||||
- `i18n` directory has the static UI for i18n translations: https://listmonk.app/i18n
|
58
docs/docs/content/apis/apis.md
Normal file
|
@ -0,0 +1,58 @@
|
|||
# APIs
|
||||
|
||||
All features that are available on the listmonk dashboard are also available as REST-like HTTP APIs that can be interacted with directly. Request and response bodies are JSON. This allows easy scripting of listmonk and integration with other systems, for instance, synchronisation with external subscriber databases.
|
||||
|
||||
API requests require BasicAuth authentication with the admin credentials.
|
||||
|
||||
> The API section is a work in progress. There may be API calls that are yet to be documented. Please consider contributing to docs.
|
||||
|
||||
## OpenAPI (Swagger) spec
|
||||
|
||||
The auto-generated OpenAPI (Swagger) specification site for the APIs are available at [**listmonk.app/docs/swagger**](https://listmonk.app/docs/swagger/)
|
||||
|
||||
## Response structure
|
||||
|
||||
### Successful request
|
||||
|
||||
```http
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
All responses from the API server are JSON with the content-type application/json unless explicitly stated otherwise. A successful 200 OK response always has a JSON response body with a status key with the value success. The data key contains the full response payload.
|
||||
|
||||
### Failed request
|
||||
|
||||
```http
|
||||
HTTP/1.1 500 Server error
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"message": "Error message"
|
||||
}
|
||||
```
|
||||
|
||||
A failure response is preceded by the corresponding 40x or 50x HTTP header. There may be an optional `data` key with additional payload.
|
||||
|
||||
### Timestamps
|
||||
|
||||
All timestamp fields are in the format `2019-01-01T09:00:00.000000+05:30`. The seconds component is suffixed by the milliseconds, followed by the `+` and the timezone offset.
|
||||
|
||||
### Common HTTP error codes
|
||||
|
||||
| Code | |
|
||||
| ----- | ------------------------------------------------------------------------ |
|
||||
| 400 | Missing or bad request parameters or values |
|
||||
| 403 | Session expired or invalidate. Must relogin |
|
||||
| 404 | Request resource was not found |
|
||||
| 405 | Request method (GET, POST etc.) is not allowed on the requested endpoint |
|
||||
| 410 | The requested resource is gone permanently |
|
||||
| 429 | Too many requests to the API (rate limiting) |
|
||||
| 500 | Something unexpected went wrong |
|
||||
| 502 | The backend OMS is down and the API is unable to communicate with it |
|
||||
| 503 | Service unavailable; the API is down |
|
||||
| 504 | Gateway timeout; the API is unreachable |
|
372
docs/docs/content/apis/campaigns.md
Normal file
|
@ -0,0 +1,372 @@
|
|||
# API / Campaigns
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|:-------|:----------------------------------------------------------------------------|:------------------------------------------|
|
||||
| GET | [/api/campaigns](#get-apicampaigns) | Retrieve all campaigns. |
|
||||
| GET | [/api/campaigns/{campaign_id}](#get-apicampaignscampaign_id) | Retrieve a specific campaign. |
|
||||
| GET | [/api/campaigns/{campaign_id}/preview](#get-apicampaignscampaign_idpreview) | Retrieve preview of a campaign. |
|
||||
| GET | [/api/campaigns/running/stats](#get-apicampaignsrunningstats) | Retrieve stats of specified campaigns. |
|
||||
| POST | [/api/campaigns](#post-apicampaigns) | Create a new campaign. |
|
||||
| POST | [/api/campaigns/{campaign_id}/test](#post-apicampaignscampaign_idtest) | Test campaign with arbitrary subscribers. |
|
||||
| PUT | [/api/campaigns/{campaign_id}](#put-apicampaignscampaign_id) | Update a campaign. |
|
||||
| PUT | [/api/campaigns/{campaign_id}/status](#put-apicampaignscampaign_idstatus) | Change status of a campaign. |
|
||||
| DELETE | [/api/campaigns/{campaign_id}](#delete-apicampaignscampaign_id) | Delete a campaign. |
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### GET /api/campaigns
|
||||
|
||||
Retrieve all campaigns.
|
||||
|
||||
##### Example Request
|
||||
|
||||
```shell
|
||||
curl -u "username:password" -X GET 'http://localhost:9000/api/campaigns?page=1&per_page=100'
|
||||
```
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|:---------|:-------|:---------|:---------------------------------------------------------------------|
|
||||
| order | string | | Sorting order: ASC for ascending, DESC for descending. |
|
||||
| order_by | string | | Result sorting field. Options: name, status, created_at, updated_at. |
|
||||
| query | string | | SQL query expression to filter subscribers. |
|
||||
| page | number | | Page number for paginated results. |
|
||||
| per_page | number | | Results per page. Set as 'all' for all results. |
|
||||
|
||||
##### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"results": [
|
||||
{
|
||||
"id": 1,
|
||||
"created_at": "2020-03-14T17:36:41.29451+01:00",
|
||||
"updated_at": "2020-03-14T17:36:41.29451+01:00",
|
||||
"views": 0,
|
||||
"clicks": 0,
|
||||
"lists": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Default list"
|
||||
}
|
||||
],
|
||||
"started_at": null,
|
||||
"to_send": 0,
|
||||
"sent": 0,
|
||||
"uuid": "57702beb-6fae-4355-a324-c2fd5b59a549",
|
||||
"type": "regular",
|
||||
"name": "Test campaign",
|
||||
"subject": "Welcome to listmonk",
|
||||
"from_email": "No Reply <noreply@yoursite.com>",
|
||||
"body": "<h3>Hi {{ .Subscriber.FirstName }}!</h3>\n\t\t\tThis is a test e-mail campaign. Your second name is {{ .Subscriber.LastName }} and you are from {{ .Subscriber.Attribs.city }}.",
|
||||
"send_at": "2020-03-15T17:36:41.293233+01:00",
|
||||
"status": "draft",
|
||||
"content_type": "richtext",
|
||||
"tags": [
|
||||
"test-campaign"
|
||||
],
|
||||
"template_id": 1,
|
||||
"messenger": "email"
|
||||
}
|
||||
],
|
||||
"query": "",
|
||||
"total": 1,
|
||||
"per_page": 20,
|
||||
"page": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### GET /api/campaigns/{campaign_id}
|
||||
|
||||
Retrieve a specific campaign.
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|:------------|:----------|:---------|:-------------|
|
||||
| campaign_id | number | Yes | Campaign ID. |
|
||||
|
||||
##### Example Request
|
||||
|
||||
```shell
|
||||
curl -u "username:password" -X GET 'http://localhost:9000/api/campaigns/1'
|
||||
```
|
||||
|
||||
##### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": 1,
|
||||
"created_at": "2020-03-14T17:36:41.29451+01:00",
|
||||
"updated_at": "2020-03-14T17:36:41.29451+01:00",
|
||||
"views": 0,
|
||||
"clicks": 0,
|
||||
"lists": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Default list"
|
||||
}
|
||||
],
|
||||
"started_at": null,
|
||||
"to_send": 0,
|
||||
"sent": 0,
|
||||
"uuid": "57702beb-6fae-4355-a324-c2fd5b59a549",
|
||||
"type": "regular",
|
||||
"name": "Test campaign",
|
||||
"subject": "Welcome to listmonk",
|
||||
"from_email": "No Reply <noreply@yoursite.com>",
|
||||
"body": "<h3>Hi {{ .Subscriber.FirstName }}!</h3>\n\t\t\tThis is a test e-mail campaign. Your second name is {{ .Subscriber.LastName }} and you are from {{ .Subscriber.Attribs.city }}.",
|
||||
"send_at": "2020-03-15T17:36:41.293233+01:00",
|
||||
"status": "draft",
|
||||
"content_type": "richtext",
|
||||
"tags": [
|
||||
"test-campaign"
|
||||
],
|
||||
"template_id": 1,
|
||||
"messenger": "email"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### GET /api/campaigns/{campaign_id}/preview
|
||||
|
||||
Preview a specific campaign.
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|:------------|:----------|:---------|:------------------------|
|
||||
| campaign_id | number | Yes | Campaign ID to preview. |
|
||||
|
||||
##### Example Request
|
||||
|
||||
```shell
|
||||
curl -u "username:password" -X GET 'http://localhost:9000/api/campaigns/1/preview'
|
||||
```
|
||||
|
||||
##### Example Response
|
||||
|
||||
```html
|
||||
<h3>Hi John!</h3>
|
||||
This is a test e-mail campaign. Your second name is Doe and you are from Bengaluru.
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### GET /api/campaigns/running/stats
|
||||
|
||||
Retrieve stats of specified campaigns.
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|:------------|:----------|:---------|:-------------------------------|
|
||||
| campaign_id | number | Yes | Campaign IDs to get stats for. |
|
||||
|
||||
##### Example Request
|
||||
|
||||
```shell
|
||||
curl -u "username:password" -X GET 'http://localhost:9000/api/campaigns/running/stats?campaign_id=1'
|
||||
```
|
||||
|
||||
##### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": []
|
||||
}
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### POST /api/campaigns
|
||||
|
||||
Create a new campaign.
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|:-------------|:----------|:---------|:----------------------------------------------------------------------------------------|
|
||||
| name | string | Yes | Campaign name. |
|
||||
| subject | string | Yes | Campaign email subject. |
|
||||
| lists | number\[\] | Yes | List IDs to send campaign to. |
|
||||
| from_email | string | | 'From' email in campaign emails. Defaults to value from settings if not provided. |
|
||||
| type | string | Yes | Campaign type: 'regular' or 'optin'. |
|
||||
| content_type | string | Yes | Content type: 'richtext', 'html', 'markdown', 'plain'. |
|
||||
| body | string | Yes | Content body of campaign. |
|
||||
| altbody | string | | Alternate plain text body for HTML (and richtext) emails. |
|
||||
| send_at | string | | Timestamp to schedule campaign. Format: 'YYYY-MM-DDTHH:MM:SS'. |
|
||||
| messenger | string | | 'email' or a custom messenger defined in settings. Defaults to 'email' if not provided. |
|
||||
| template_id | number | | Template ID to use. Defaults to default template if not provided. |
|
||||
| tags | string\[\] | | Tags to mark campaign. |
|
||||
| headers | JSON | | Key-value pairs to send as SMTP headers. Example: \[{"x-custom-header": "value"}\]. |
|
||||
|
||||
##### Example request
|
||||
|
||||
```shell
|
||||
curl -u "username:password" 'http://localhost:9000/api/campaigns' -X POST -H 'Content-Type: application/json;charset=utf-8' --data-raw '{"name":"Test campaign","subject":"Hello, world","lists":[1],"from_email":"listmonk <noreply@listmonk.yoursite.com>","content_type":"richtext","messenger":"email","type":"regular","tags":["test"],"template_id":1}'
|
||||
```
|
||||
|
||||
##### Example response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": 1,
|
||||
"created_at": "2021-12-27T11:50:23.333485Z",
|
||||
"updated_at": "2021-12-27T11:50:23.333485Z",
|
||||
"views": 0,
|
||||
"clicks": 0,
|
||||
"bounces": 0,
|
||||
"lists": [{
|
||||
"id": 1,
|
||||
"name": "Default list"
|
||||
}],
|
||||
"started_at": null,
|
||||
"to_send": 1,
|
||||
"sent": 0,
|
||||
"uuid": "90c889cc-3728-4064-bbcb-5c1c446633b3",
|
||||
"type": "regular",
|
||||
"name": "Test campaign",
|
||||
"subject": "Hello, world",
|
||||
"from_email": "listmonk \u003cnoreply@listmonk.yoursite.com\u003e",
|
||||
"body": "",
|
||||
"altbody": null,
|
||||
"send_at": null,
|
||||
"status": "draft",
|
||||
"content_type": "richtext",
|
||||
"tags": ["test"],
|
||||
"template_id": 1,
|
||||
"messenger": "email"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### POST /api/campaigns/{campaign_id}/test
|
||||
|
||||
Test campaign with arbitrary subscribers.
|
||||
|
||||
Use the same parameters in [POST /api/campaigns](#post-apicampaigns) in addition to the below parameters.
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|:------------|:---------|:---------|:---------------------------------------------------|
|
||||
| subscribers | string\[\] | Yes | List of subscriber e-mails to send the message to. |
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### PUT /api/campaigns/{campaign_id}
|
||||
|
||||
Update a campaign.
|
||||
|
||||
> Refer to parameters from [POST /api/campaigns](#post-apicampaigns)
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### PUT /api/campaigns/{campaign_id}
|
||||
|
||||
Update a specific campaign.
|
||||
|
||||
> Refer to parameters from [POST /api/campaigns](#post-apicampaigns)
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### PUT /api/campaigns/{campaign_id}/status
|
||||
|
||||
Change status of a campaign.
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|:------------|:----------|:---------|:------------------------------------------------------------------------|
|
||||
| campaign_id | number | Yes | Campaign ID to change status. |
|
||||
| status | string | Yes | New status for campaign: 'scheduled', 'running', 'paused', 'cancelled'. |
|
||||
|
||||
##### Note
|
||||
|
||||
> - Only 'scheduled' campaigns can change status to 'draft'.
|
||||
> - Only 'draft' campaigns can change status to 'scheduled'.
|
||||
> - Only 'paused' and 'draft' campaigns can start ('running' status).
|
||||
> - Only 'running' campaigns can change status to 'cancelled' and 'paused'.
|
||||
|
||||
##### Example Request
|
||||
|
||||
```shell
|
||||
curl -u "username:password" -X PUT 'http://localhost:9000/api/campaigns/1/status' \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data-raw '{"status":"scheduled"}'
|
||||
```
|
||||
|
||||
##### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": 1,
|
||||
"created_at": "2020-03-14T17:36:41.29451+01:00",
|
||||
"updated_at": "2020-04-08T19:35:17.331867+01:00",
|
||||
"views": 0,
|
||||
"clicks": 0,
|
||||
"lists": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Default list"
|
||||
}
|
||||
],
|
||||
"started_at": null,
|
||||
"to_send": 0,
|
||||
"sent": 0,
|
||||
"uuid": "57702beb-6fae-4355-a324-c2fd5b59a549",
|
||||
"type": "regular",
|
||||
"name": "Test campaign",
|
||||
"subject": "Welcome to listmonk",
|
||||
"from_email": "No Reply <noreply@yoursite.com>",
|
||||
"body": "<h3>Hi {{ .Subscriber.FirstName }}!</h3>\n\t\t\tThis is a test e-mail campaign. Your second name is {{ .Subscriber.LastName }} and you are from {{ .Subscriber.Attribs.city }}.",
|
||||
"send_at": "2020-03-15T17:36:41.293233+01:00",
|
||||
"status": "scheduled",
|
||||
"content_type": "richtext",
|
||||
"tags": [
|
||||
"test-campaign"
|
||||
],
|
||||
"template_id": 1,
|
||||
"messenger": "email"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### DELETE /api/campaigns/{campaign_id}
|
||||
|
||||
Delete a campaign.
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|:------------|:----------|:---------|:-----------------------|
|
||||
| campaign_id | number | Yes | Campaign ID to delete. |
|
||||
|
||||
##### Example Request
|
||||
|
||||
```shell
|
||||
curl -u "username:password" -X DELETE 'http://localhost:9000/api/campaigns/34'
|
||||
```
|
||||
|
||||
##### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": true
|
||||
}
|
||||
```
|
102
docs/docs/content/apis/import.md
Normal file
|
@ -0,0 +1,102 @@
|
|||
# API / Import
|
||||
|
||||
Method | Endpoint | Description
|
||||
---------|-------------------------------------------------|------------------------------------------------
|
||||
GET | [/api/import/subscribers](#get-apiimportsubscribers) | Retrieve import statistics.
|
||||
GET | [/api/import/subscribers/logs](#get-apiimportsubscriberslogs) | Retrieve import logs.
|
||||
POST | [/api/import/subscribers](#post-apiimportsubscribers) | Upload a file for bulk subscriber import.
|
||||
DELETE | [/api/import/subscribers](#delete-apiimportsubscribers) | Stop and remove an import.
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### GET /api/import/subscribers
|
||||
|
||||
Retrieve the status of an import.
|
||||
|
||||
##### Example Request
|
||||
|
||||
```shell
|
||||
curl -u "username:username" -X GET 'http://localhost:9000/api/import/subscribers'
|
||||
```
|
||||
|
||||
##### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"name": "",
|
||||
"total": 0,
|
||||
"imported": 0,
|
||||
"status": "none"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### GET /api/import/subscribers/logs
|
||||
|
||||
Retrieve logs related to imports.
|
||||
|
||||
##### Example Request
|
||||
|
||||
```shell
|
||||
curl -u "username:username" -X GET 'http://localhost:9000/api/import/subscribers/logs'
|
||||
```
|
||||
|
||||
##### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": "2020/04/08 21:55:20 processing 'import.csv'\n2020/04/08 21:55:21 imported finished\n"
|
||||
}
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### POST /api/import/subscribers
|
||||
|
||||
Send a CSV (optionally ZIP compressed) file to import subscribers. Use a multipart form POST.
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|:-------|:------------|:---------|:-----------------------------------------|
|
||||
| params | JSON string | Yes | Stringified JSON with import parameters. |
|
||||
| file | File | Yes | File for upload. |
|
||||
|
||||
**`params`** (JSON string)
|
||||
|
||||
```json
|
||||
{
|
||||
"mode": "subscribe", // subscribe or blocklist
|
||||
"delim": ",", // delimiter in the uploaded file
|
||||
"lists":[1], // array of list IDs to import into
|
||||
"overwrite": true // overwrite existing entries or skip them?
|
||||
}
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### DELETE /api/import/subscribers
|
||||
|
||||
Stop and delete an ongoing import.
|
||||
|
||||
##### Example Request
|
||||
|
||||
```shell
|
||||
curl -u "username:username" -X DELETE 'http://localhost:9000/api/import/subscribers'
|
||||
```
|
||||
|
||||
##### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"name": "",
|
||||
"total": 0,
|
||||
"imported": 0,
|
||||
"status": "none"
|
||||
}
|
||||
}
|
||||
```
|
212
docs/docs/content/apis/lists.md
Normal file
|
@ -0,0 +1,212 @@
|
|||
# API / Lists
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|:-------|:------------------------------------------------|:--------------------------|
|
||||
| GET | [/api/lists](#get-apilists) | Retrieve all lists. |
|
||||
| GET | [/api/lists/{list_id}](#get-apilistslist_id) | Retrieve a specific list. |
|
||||
| POST | [/api/lists](#post-apilists) | Create a new list. |
|
||||
| PUT | [/api/lists/{list_id}](#put-apilistslist_id) | Update a list. |
|
||||
| DELETE | [/api/lists/{list_id}](#delete-apilistslist_id) | Delete a list. |
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### GET /api/lists
|
||||
|
||||
Retrieve lists.
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|:---------|:----------|:---------|:-----------------------------------------------------------|
|
||||
| query | string | | string for list name search. |
|
||||
| order_by | string | | Sort field. Options: name, status, created_at, updated_at. |
|
||||
| order | string | | Sorting order. Options: ASC, DESC. |
|
||||
| page | number | | Page number for pagination. |
|
||||
| per_page | number | | Results per page. Set to 'all' to return all results. |
|
||||
|
||||
##### Example Request
|
||||
|
||||
```shell
|
||||
curl -u "username:username" -X GET 'http://localhost:9000/api/lists?page=1&per_page=100'
|
||||
```
|
||||
|
||||
##### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"results": [
|
||||
{
|
||||
"id": 1,
|
||||
"created_at": "2020-02-10T23:07:16.194843+01:00",
|
||||
"updated_at": "2020-03-06T22:32:01.118327+01:00",
|
||||
"uuid": "ce13e971-c2ed-4069-bd0c-240e9a9f56f9",
|
||||
"name": "Default list",
|
||||
"type": "public",
|
||||
"optin": "double",
|
||||
"tags": [
|
||||
"test"
|
||||
],
|
||||
"subscriber_count": 2
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"created_at": "2020-03-04T21:12:09.555013+01:00",
|
||||
"updated_at": "2020-03-06T22:34:46.405031+01:00",
|
||||
"uuid": "f20a2308-dfb5-4420-a56d-ecf0618a102d",
|
||||
"name": "get",
|
||||
"type": "private",
|
||||
"optin": "single",
|
||||
"tags": [],
|
||||
"subscriber_count": 0
|
||||
}
|
||||
],
|
||||
"total": 5,
|
||||
"per_page": 20,
|
||||
"page": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### GET /api/lists/{list_id}
|
||||
|
||||
Retrieve a specific list.
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|:--------|:----------|:---------|:----------------------------|
|
||||
| list_id | number | Yes | ID of the list to retrieve. |
|
||||
|
||||
##### Example Request
|
||||
|
||||
```shell
|
||||
curl -u "username:username" -X GET 'http://localhost:9000/api/lists/5'
|
||||
```
|
||||
|
||||
##### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": 5,
|
||||
"created_at": "2020-03-07T06:31:06.072483+01:00",
|
||||
"updated_at": "2020-03-07T06:31:06.072483+01:00",
|
||||
"uuid": "1bb246ab-7417-4cef-bddc-8fc8fc941d3a",
|
||||
"name": "Test list",
|
||||
"type": "public",
|
||||
"optin": "double",
|
||||
"tags": [],
|
||||
"subscriber_count": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### POST /api/lists
|
||||
|
||||
Create a new list.
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|:------|:----------|:---------|:----------------------------------------|
|
||||
| name | string | Yes | Name of the new list. |
|
||||
| type | string | Yes | Type of list. Options: private, public. |
|
||||
| optin | string | Yes | Opt-in type. Options: single, double. |
|
||||
| tags | string\[\] | | Associated tags for a list. |
|
||||
|
||||
##### Example Request
|
||||
|
||||
```shell
|
||||
curl -u "username:username" -X POST 'http://localhost:9000/api/lists'
|
||||
```
|
||||
|
||||
##### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": 5,
|
||||
"created_at": "2020-03-07T06:31:06.072483+01:00",
|
||||
"updated_at": "2020-03-07T06:31:06.072483+01:00",
|
||||
"uuid": "1bb246ab-7417-4cef-bddc-8fc8fc941d3a",
|
||||
"name": "Test list",
|
||||
"type": "public",
|
||||
"tags": [],
|
||||
"subscriber_count": 0
|
||||
}
|
||||
}
|
||||
null
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### PUT /api/lists/{list_id}
|
||||
|
||||
Update a list.
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|:--------|:----------|:---------|:----------------------------------------|
|
||||
| list_id | number | Yes | ID of the list to update. |
|
||||
| name | string | | New name for the list. |
|
||||
| type | string | | Type of list. Options: private, public. |
|
||||
| optin | string | | Opt-in type. Options: single, double. |
|
||||
| tags | string\[\] | | Associated tags for the list. |
|
||||
|
||||
##### Example Request
|
||||
|
||||
```shell
|
||||
curl -u "username:username" -X PUT 'http://localhost:9000/api/lists/5' \
|
||||
--form 'name=modified test list' \
|
||||
--form 'type=private'
|
||||
```
|
||||
|
||||
##### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": 5,
|
||||
"created_at": "2020-03-07T06:31:06.072483+01:00",
|
||||
"updated_at": "2020-03-07T06:52:15.208075+01:00",
|
||||
"uuid": "1bb246ab-7417-4cef-bddc-8fc8fc941d3a",
|
||||
"name": "modified test list",
|
||||
"type": "private",
|
||||
"optin": "single",
|
||||
"tags": [],
|
||||
"subscriber_count": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### DELETE /api/lists/{list_id}
|
||||
|
||||
Delete a specific subscriber.
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|:--------|:----------|:---------|:--------------------------|
|
||||
| list_id | Number | Yes | ID of the list to delete. |
|
||||
|
||||
##### Example Request
|
||||
|
||||
```shell
|
||||
curl -u 'username:password' -X DELETE 'http://localhost:9000/api/lists/1'
|
||||
```
|
||||
|
||||
##### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": true
|
||||
}
|
||||
```
|
98
docs/docs/content/apis/media.md
Normal file
|
@ -0,0 +1,98 @@
|
|||
# API / Media
|
||||
|
||||
Method | Endpoint | Description
|
||||
-------|------------------------------------------------|------------------------------
|
||||
GET | [/api/media](#get-apimedia) | Get uploaded media file
|
||||
POST | [/api/media](#post-apimedia) | Upload media file
|
||||
DELETE | [/api/media/{media_id}](#delete-apimediamedia_id) | Delete uploaded media file
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### GET /api/media
|
||||
|
||||
Get an uploaded media file.
|
||||
|
||||
##### Example Request
|
||||
|
||||
```shell
|
||||
curl -u "username:username" -X GET 'http://localhost:9000/api/media' \
|
||||
--header 'Content-Type: multipart/form-data; boundary=--------------------------093715978792575906250298'
|
||||
```
|
||||
|
||||
##### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"uuid": "ec7b45ce-1408-4e5c-924e-965326a20287",
|
||||
"filename": "Media file",
|
||||
"created_at": "2020-04-08T22:43:45.080058+01:00",
|
||||
"thumb_url": "/uploads/image_thumb.jpg",
|
||||
"uri": "/uploads/image.jpg"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### POST /api/media
|
||||
|
||||
Upload a media file.
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|-----------|----------|---------------------|
|
||||
| file | File | Yes | Media file to upload|
|
||||
|
||||
##### Example Request
|
||||
|
||||
```shell
|
||||
curl -u "username:username" -X POST 'http://localhost:9000/api/media' \
|
||||
--header 'Content-Type: multipart/form-data; boundary=--------------------------183679989870526937212428' \
|
||||
--form 'file=@/path/to/image.jpg'
|
||||
```
|
||||
|
||||
##### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": 1,
|
||||
"uuid": "ec7b45ce-1408-4e5c-924e-965326a20287",
|
||||
"filename": "Media file",
|
||||
"created_at": "2020-04-08T22:43:45.080058+01:00",
|
||||
"thumb_uri": "/uploads/image_thumb.jpg",
|
||||
"uri": "/uploads/image.jpg"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### DELETE /api/media/{media_id}
|
||||
|
||||
Delete an uploaded media file.
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|----------|-----------|----------|-------------------------|
|
||||
| media_id | number | Yes | ID of media file to delete |
|
||||
|
||||
##### Example Request
|
||||
|
||||
```shell
|
||||
curl -u "username:username" -X DELETE 'http://localhost:9000/api/media/1'
|
||||
```
|
||||
|
||||
##### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": true
|
||||
}
|
||||
```
|
434
docs/docs/content/apis/subscribers.md
Normal file
|
@ -0,0 +1,434 @@
|
|||
# API / Subscribers
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| ------ | --------------------------------------------------------------------------------------- | ---------------------------------------------- |
|
||||
| GET | [/api/subscribers](#get-apisubscribers) | Retrieve all subscribers. |
|
||||
| GET | [/api/subscribers/{subscriber_id}](#get-apisubscriberssubscriber_id) | Retrieve a specific subscriber. |
|
||||
| GET | [/api/subscribers/lists/{list_id}](#get-apisubscriberslistslist_id) | Retrieve subscribers in a specific list. |
|
||||
| POST | [/api/subscribers](#post-apisubscribers) | Create a new subscriber. |
|
||||
| POST | [/api/public/subscription](#post-apipublicsubscription) | Create a public subscription. |
|
||||
| PUT | [/api/subscribers/lists](#put-apisubscriberslists) | Modify subscriber list memberships. |
|
||||
| PUT | [/api/subscribers/{subscriber_id}](#put-apisubscriberssubscriber_id) | Update a specific subscriber. |
|
||||
| PUT | [/api/subscribers/{subscriber_id}/blocklist](#put-apisubscriberssubscriber_idblocklist) | Blocklist a specific subscriber. |
|
||||
| PUT | /api/subscribers/blocklist | Blocklist one or more subscribers. |
|
||||
| PUT | [/api/subscribers/query/blocklist](#put-apisubscribersqueryblocklist) | Blocklist subscribers based on SQL expression. |
|
||||
| DELETE | [/api/subscribers/{subscriber_id}](#delete-apisubscriberssubscriber_id) | Delete a specific subscriber. |
|
||||
| DELETE | [/api/subscribers](#delete-apisubscribers) | Delete one or more subscribers. |
|
||||
| POST | [/api/subscribers/query/delete](#post-apisubscribersquerydelete) | Delete subscribers based on SQL expression. |
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### GET /api/subscribers
|
||||
|
||||
Retrieve all subscribers.
|
||||
|
||||
##### Query parameters
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|:---------|:-------|:---------|:---------------------------------------------------------------------|
|
||||
| query | string | | Subscriber search term by name. |
|
||||
| order_by | string | | Result sorting field. Options: name, status, created_at, updated_at. |
|
||||
| order | string | | Sorting order: ASC for ascending, DESC for descending. |
|
||||
| page | number | | Page number for paginated results. |
|
||||
| per_page | number | | Results per page. Set as 'all' for all results. |
|
||||
|
||||
##### Example Request
|
||||
|
||||
```shell
|
||||
curl -u 'username:password' 'http://localhost:9000/api/subscribers?page=1&per_page=100'
|
||||
```
|
||||
|
||||
```shell
|
||||
curl -u 'username:password' 'http://localhost:9000/api/subscribers?list_id=1&list_id=2&page=1&per_page=100'
|
||||
```
|
||||
|
||||
```shell
|
||||
curl -u 'username:password' -X GET 'http://localhost:9000/api/subscribers' \
|
||||
--url-query 'page=1' \
|
||||
--url-query 'per_page=100' \
|
||||
--url-query "query=subscribers.name LIKE 'Test%' AND subscribers.attribs->>'city' = 'Bengaluru'"
|
||||
```
|
||||
|
||||
##### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"results": [
|
||||
{
|
||||
"id": 1,
|
||||
"created_at": "2020-02-10T23:07:16.199433+01:00",
|
||||
"updated_at": "2020-02-10T23:07:16.199433+01:00",
|
||||
"uuid": "ea06b2e7-4b08-4697-bcfc-2a5c6dde8f1c",
|
||||
"email": "john@example.com",
|
||||
"name": "John Doe",
|
||||
"attribs": {
|
||||
"city": "Bengaluru",
|
||||
"good": true,
|
||||
"type": "known"
|
||||
},
|
||||
"status": "enabled",
|
||||
"lists": [
|
||||
{
|
||||
"subscription_status": "unconfirmed",
|
||||
"id": 1,
|
||||
"uuid": "ce13e971-c2ed-4069-bd0c-240e9a9f56f9",
|
||||
"name": "Default list",
|
||||
"type": "public",
|
||||
"tags": [
|
||||
"test"
|
||||
],
|
||||
"created_at": "2020-02-10T23:07:16.194843+01:00",
|
||||
"updated_at": "2020-02-10T23:07:16.194843+01:00"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"created_at": "2020-02-18T21:10:17.218979+01:00",
|
||||
"updated_at": "2020-02-18T21:10:17.218979+01:00",
|
||||
"uuid": "ccf66172-f87f-4509-b7af-e8716f739860",
|
||||
"email": "quadri@example.com",
|
||||
"name": "quadri",
|
||||
"attribs": {},
|
||||
"status": "enabled",
|
||||
"lists": [
|
||||
{
|
||||
"subscription_status": "unconfirmed",
|
||||
"id": 1,
|
||||
"uuid": "ce13e971-c2ed-4069-bd0c-240e9a9f56f9",
|
||||
"name": "Default list",
|
||||
"type": "public",
|
||||
"tags": [
|
||||
"test"
|
||||
],
|
||||
"created_at": "2020-02-10T23:07:16.194843+01:00",
|
||||
"updated_at": "2020-02-10T23:07:16.194843+01:00"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"created_at": "2020-02-19T19:10:49.36636+01:00",
|
||||
"updated_at": "2020-02-19T19:10:49.36636+01:00",
|
||||
"uuid": "5d940585-3cc8-4add-b9c5-76efba3c6edd",
|
||||
"email": "sugar@example.com",
|
||||
"name": "sugar",
|
||||
"attribs": {},
|
||||
"status": "enabled",
|
||||
"lists": []
|
||||
}
|
||||
],
|
||||
"query": "",
|
||||
"total": 3,
|
||||
"per_page": 20,
|
||||
"page": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### GET /api/subscribers/{subscriber_id}
|
||||
|
||||
Retrieve a specific subscriber.
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|:--------------|:----------|:---------|:-----------------|
|
||||
| subscriber_id | Number | Yes | Subscriber's ID. |
|
||||
|
||||
##### Example Request
|
||||
|
||||
```shell
|
||||
curl -u 'username:password' 'http://localhost:9000/api/subscribers/1'
|
||||
```
|
||||
|
||||
##### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": 1,
|
||||
"created_at": "2020-02-10T23:07:16.199433+01:00",
|
||||
"updated_at": "2020-02-10T23:07:16.199433+01:00",
|
||||
"uuid": "ea06b2e7-4b08-4697-bcfc-2a5c6dde8f1c",
|
||||
"email": "john@example.com",
|
||||
"name": "John Doe",
|
||||
"attribs": {
|
||||
"city": "Bengaluru",
|
||||
"good": true,
|
||||
"type": "known"
|
||||
},
|
||||
"status": "enabled",
|
||||
"lists": [
|
||||
{
|
||||
"subscription_status": "unconfirmed",
|
||||
"id": 1,
|
||||
"uuid": "ce13e971-c2ed-4069-bd0c-240e9a9f56f9",
|
||||
"name": "Default list",
|
||||
"type": "public",
|
||||
"tags": [
|
||||
"test"
|
||||
],
|
||||
"created_at": "2020-02-10T23:07:16.194843+01:00",
|
||||
"updated_at": "2020-02-10T23:07:16.194843+01:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### GET /api/subscribers/lists/{list_id}
|
||||
|
||||
Retrieve subscribers in a specific list.
|
||||
|
||||
> Refer to the response structure in [GET /api/subscribers](#get-apisubscribers).
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### POST /api/subscribers
|
||||
|
||||
Create a new subscriber.
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|:-------------------------|:----------|:---------|:-----------------------------------------------------------------------------------------------------|
|
||||
| email | string | Yes | Subscriber's email address. |
|
||||
| name | string | Yes | Subscriber's name. |
|
||||
| status | string | Yes | Subscriber's status: `enabled`, `disabled`, `blocklisted`. |
|
||||
| lists | number\[\] | | List of list IDs to to subscribe to. |
|
||||
| attribs | JSON | | Attributes of the new subscriber. |
|
||||
| preconfirm_subscriptions | bool | | If true, subscriptions are marked as confirmed and no-optin emails are sent for double opt-in lists. |
|
||||
|
||||
##### Example Request
|
||||
|
||||
```shell
|
||||
curl -u 'username:password' 'http://localhost:9000/api/subscribers' -H 'Content-Type: application/json' \
|
||||
--data '{"email":"subsriber@domain.com","name":"The Subscriber","status":"enabled","lists":[1],"attribs":{"city":"Bengaluru","projects":3,"stack":{"languages":["go","python"]}}}'
|
||||
```
|
||||
|
||||
##### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": 3,
|
||||
"created_at": "2019-07-03T12:17:29.735507+05:30",
|
||||
"updated_at": "2019-07-03T12:17:29.735507+05:30",
|
||||
"uuid": "eb420c55-4cfb-4972-92ba-c93c34ba475d",
|
||||
"email": "subsriber@domain.com",
|
||||
"name": "The Subscriber",
|
||||
"attribs": {
|
||||
"city": "Bengaluru",
|
||||
"projects": 3,
|
||||
"stack": { "languages": ["go", "python"] }
|
||||
},
|
||||
"status": "enabled",
|
||||
"lists": [1]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### POST /api/public/subscription
|
||||
|
||||
Create a public subscription, accepts both form encoded or JSON encoded body.
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|:-----------|:----------|:---------|:----------------------------|
|
||||
| email | string | Yes | Subscriber's email address. |
|
||||
| name | string | | Subscriber's name. |
|
||||
| list_uuids | string\[\] | Yes | List of list UUIDs. |
|
||||
|
||||
##### Example JSON Request
|
||||
|
||||
```shell
|
||||
curl 'http://localhost:9000/api/public/subscription' -H 'Content-Type: application/json' \
|
||||
--data '{"email":"subsriber@domain.com","name":"The Subscriber","list_uuids": ["eb420c55-4cfb-4972-92ba-c93c34ba475d", "0c554cfb-eb42-4972-92ba-c93c34ba475d"]}'
|
||||
```
|
||||
|
||||
##### Example Form Request
|
||||
|
||||
```shell
|
||||
curl -u 'http://localhost:9000/api/public/subscription' \
|
||||
-d 'email=subsriber@domain.com' -d 'name=The Subscriber' -d 'l=eb420c55-4cfb-4972-92ba-c93c34ba475d' -d 'l=0c554cfb-eb42-4972-92ba-c93c34ba475d'
|
||||
```
|
||||
|
||||
Note: For form request, use `l` for multiple lists instead of `lists`.
|
||||
|
||||
##### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": true
|
||||
}
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### PUT /api/subscribers/lists
|
||||
|
||||
Modify subscriber list memberships.
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|:----------------|:----------|:-------------------|:------------------------------------------------------------------|
|
||||
| ids | number\[\] | Yes | Array of user IDs to be modified. |
|
||||
| action | string | Yes | Action to be applied: `add`, `remove`, or `unsubscribe`. |
|
||||
| target_list_ids | number\[\] | Yes | Array of list IDs to be modified. |
|
||||
| status | string | Required for `add` | Subscriber status: `confirmed`, `unconfirmed`, or `unsubscribed`. |
|
||||
|
||||
##### Example Request
|
||||
|
||||
```shell
|
||||
curl -u 'username:password' -X PUT 'http://localhost:9000/api/subscribers/lists' \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data-raw '{"ids": [1, 2, 3], "action": "add", "target_list_ids": [4, 5, 6], "status": "confirmed"}'
|
||||
```
|
||||
|
||||
##### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": true
|
||||
}
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### PUT /api/subscribers/{subscriber_id}
|
||||
|
||||
Update a specific subscriber.
|
||||
|
||||
> Refer to parameters from [POST /api/subscribers](#post-apisubscribers). Note: All parameters must be set, if not, the subscriber will be removed from all previously assigned lists.
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### PUT /api/subscribers/{subscriber_id}/blocklist
|
||||
|
||||
Blocklist a specific subscriber.
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|:--------------|:----------|:---------|:-----------------|
|
||||
| subscriber_id | Number | Yes | Subscriber's ID. |
|
||||
|
||||
##### Example Request
|
||||
|
||||
```shell
|
||||
curl -u 'username:password' -X PUT 'http://localhost:9000/api/subscribers/9/blocklist'
|
||||
```
|
||||
|
||||
##### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": true
|
||||
}
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### PUT /api/subscribers/query/blocklist
|
||||
|
||||
Blocklist subscribers based on SQL expression.
|
||||
|
||||
> Refer to the [querying and segmentation](../querying-and-segmentation.md#querying-and-segmenting-subscribers) section for more information on how to query subscribers with SQL expressions.
|
||||
|
||||
##### Example Request
|
||||
|
||||
```shell
|
||||
curl -u 'username:password' -X PUT 'http://localhost:9000/api/subscribers/query/blocklist' \
|
||||
--data-raw '"query=subscribers.name LIKE '\''John Doe'\'' AND subscribers.attribs->>'\''city'\'' = '\''Bengaluru'\''"'
|
||||
```
|
||||
|
||||
##### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": true
|
||||
}
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### DELETE /api/subscribers/{subscriber_id}
|
||||
|
||||
Delete a specific subscriber.
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|:--------------|:----------|:---------|:-----------------|
|
||||
| subscriber_id | Number | Yes | Subscriber's ID. |
|
||||
|
||||
##### Example Request
|
||||
|
||||
```shell
|
||||
curl -u 'username:password' -X DELETE 'http://localhost:9000/api/subscribers/9'
|
||||
```
|
||||
|
||||
##### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": true
|
||||
}
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### DELETE /api/subscribers
|
||||
|
||||
Delete one or more subscribers.
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|:-----|:----------|:---------|:---------------------------|
|
||||
| id | number\[\] | Yes | Array of subscriber's IDs. |
|
||||
|
||||
##### Example Request
|
||||
|
||||
```shell
|
||||
curl -u 'username:password' -X DELETE 'http://localhost:9000/api/subscribers?id=10&id=11'
|
||||
```
|
||||
|
||||
##### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": true
|
||||
}
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### POST /api/subscribers/query/delete
|
||||
|
||||
Delete subscribers based on SQL expression.
|
||||
|
||||
##### Example Request
|
||||
|
||||
```shell
|
||||
curl -u 'username:password' -X POST 'http://localhost:9000/api/subscribers/query/delete' \
|
||||
--data-raw '"query=subscribers.name LIKE '\''John Doe'\'' AND subscribers.attribs->>'\''city'\'' = '\''Bengaluru'\''"'
|
||||
```
|
||||
|
||||
##### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": true
|
||||
}
|
||||
```
|
225
docs/docs/content/apis/templates.md
Normal file
|
@ -0,0 +1,225 @@
|
|||
# API / Templates
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|:-------|:------------------------------------------------------------------------------|:-------------------------------|
|
||||
| GET | [/api/templates](#get-apitemplates) | Retrieve all templates |
|
||||
| GET | [/api/templates/{template_id}](#get-apitemplates-template_id) | Retrieve a template |
|
||||
| GET | [/api/templates/{template_id}/preview](#get-apitemplates-template_id-preview) | Retrieve template HTML preview |
|
||||
| POST | [/api/templates](#post-apitemplates) | Create a template |
|
||||
| POST | /api/templates/preview | Render and preview a template |
|
||||
| PUT | [/api/templates/{template_id}](#put-apitemplatestemplate_id) | Update a template |
|
||||
| PUT | [/api/templates/{template_id}/default](#put-apitemplates-template_id-default) | Set default template |
|
||||
| DELETE | [/api/templates/{template_id}](#delete-apitemplates-template_id) | Delete a template |
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### GET /api/templates
|
||||
|
||||
Retrieve all templates.
|
||||
|
||||
##### Example Request
|
||||
|
||||
```shell
|
||||
curl -u "username:username" -X GET 'http://localhost:9000/api/templates'
|
||||
```
|
||||
|
||||
##### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"created_at": "2020-03-14T17:36:41.288578+01:00",
|
||||
"updated_at": "2020-03-14T17:36:41.288578+01:00",
|
||||
"name": "Default template",
|
||||
"body": "{{ template \"content\" . }}",
|
||||
"type": "campaign",
|
||||
"is_default": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### GET /api/templates/{template_id}
|
||||
|
||||
Retrieve a specific template.
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|:------------|:----------|:---------|:-------------------------------|
|
||||
| template_id | number | Yes | ID of the template to retrieve |
|
||||
|
||||
##### Example Request
|
||||
|
||||
```shell
|
||||
curl -u "username:username" -X GET 'http://localhost:9000/api/templates/1'
|
||||
```
|
||||
|
||||
##### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": 1,
|
||||
"created_at": "2020-03-14T17:36:41.288578+01:00",
|
||||
"updated_at": "2020-03-14T17:36:41.288578+01:00",
|
||||
"name": "Default template",
|
||||
"body": "{{ template \"content\" . }}",
|
||||
"type": "campaign",
|
||||
"is_default": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### GET /api/templates/{template_id}/preview
|
||||
|
||||
Retrieve the HTML preview of a template.
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|:------------|:----------|:---------|:------------------------------|
|
||||
| template_id | number | Yes | ID of the template to preview |
|
||||
|
||||
##### Example Request
|
||||
|
||||
```shell
|
||||
curl -u "username:username" -X GET 'http://localhost:9000/api/templates/1/preview'
|
||||
```
|
||||
|
||||
##### Example Response
|
||||
|
||||
```html
|
||||
<p>Hi there</p>
|
||||
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis et elit ac elit sollicitudin condimentum non a magna.
|
||||
Sed tempor mauris in facilisis vehicula. Aenean nisl urna, accumsan ac tincidunt vitae, interdum cursus massa.
|
||||
Interdum et malesuada fames ac ante ipsum primis in faucibus. Aliquam varius turpis et turpis lacinia placerat.
|
||||
Aenean id ligula a orci lacinia blandit at eu felis. Phasellus vel lobortis lacus. Suspendisse leo elit, luctus sed
|
||||
erat ut, venenatis fermentum ipsum. Donec bibendum neque quis.</p>
|
||||
|
||||
<h3>Sub heading</h3>
|
||||
<p>Nam luctus dui non placerat mattis. Morbi non accumsan orci, vel interdum urna. Duis faucibus id nunc ut euismod.
|
||||
Curabitur et eros id erat feugiat fringilla in eget neque. Aliquam accumsan cursus eros sed faucibus.</p>
|
||||
|
||||
<p>Here is a link to <a href="https://listmonk.app" target="_blank">listmonk</a>.</p>
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### POST /api/templates
|
||||
|
||||
Create a template.
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|:--------|:----------|:---------|:----------------------------------------------|
|
||||
| name | string | Yes | Name of the template |
|
||||
| type | string | Yes | Type of the template (`campaign` or `tx`) |
|
||||
| subject | string | | Subject line for the template (only for `tx`) |
|
||||
| body | string | Yes | HTML body of the template |
|
||||
|
||||
##### Example Request
|
||||
|
||||
```shell
|
||||
curl -u "username:password" -X POST 'http://localhost:9000/api/templates' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"name": "New template",
|
||||
"type": "campaign",
|
||||
"subject": "Your Weekly Newsletter",
|
||||
"body": "<h1>Header</h1><p>Content goes here</p>"
|
||||
}'
|
||||
```
|
||||
|
||||
##### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"created_at": "2020-03-14T17:36:41.288578+01:00",
|
||||
"updated_at": "2020-03-14T17:36:41.288578+01:00",
|
||||
"name": "Default template",
|
||||
"body": "{{ template \"content\" . }}",
|
||||
"type": "campaign",
|
||||
"is_default": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### PUT /api/templates/{template_id}
|
||||
|
||||
Update a template.
|
||||
|
||||
> Refer to parameters from [POST /api/templates](#post-apitemplates)
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### PUT /api/templates/{template_id}/default
|
||||
|
||||
Set a template as the default.
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|:------------|:----------|:---------|:-------------------------------------|
|
||||
| template_id | number | Yes | ID of the template to set as default |
|
||||
|
||||
##### Example Request
|
||||
|
||||
```shell
|
||||
curl -u "username:username" -X PUT 'http://localhost:9000/api/templates/1/default'
|
||||
```
|
||||
|
||||
##### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"id": 1,
|
||||
"created_at": "2020-03-14T17:36:41.288578+01:00",
|
||||
"updated_at": "2020-03-14T17:36:41.288578+01:00",
|
||||
"name": "Default template",
|
||||
"body": "{{ template \"content\" . }}",
|
||||
"type": "campaign",
|
||||
"is_default": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### DELETE /api/templates/{template_id}
|
||||
|
||||
Delete a template.
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|:------------|:----------|:---------|:-----------------------------|
|
||||
| template_id | number | Yes | ID of the template to delete |
|
||||
|
||||
##### Example Request
|
||||
|
||||
```shell
|
||||
curl -u "username:username" -X DELETE 'http://localhost:9000/api/templates/35'
|
||||
```
|
||||
|
||||
##### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": true
|
||||
}
|
||||
```
|
65
docs/docs/content/apis/transactional.md
Normal file
|
@ -0,0 +1,65 @@
|
|||
# API / Transactional
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|:-------|:---------|:-------------------------------|
|
||||
| POST | /api/tx | Send transactional messages |
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### POST /api/tx
|
||||
|
||||
Allows sending transactional messages to one or more subscribers via a preconfigured transactional template.
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|:------------------|:----------|:---------|:---------------------------------------------------------------------------|
|
||||
| subscriber_email | string | | Email of the subscriber. Can substitute with `subscriber_id`. |
|
||||
| subscriber_id | number | | Subscriber's ID can substitute with `subscriber_email`. |
|
||||
| subscriber_emails | string\[\] | | Multiple subscriber emails as alternative to `subscriber_email`. |
|
||||
| subscriber_ids | number\[\] | | Multiple subscriber IDs as an alternative to `subscriber_id`. |
|
||||
| template_id | number | Yes | ID of the transactional template to be used for the message. |
|
||||
| from_email | string | | Optional sender email. |
|
||||
| data | JSON | | Optional nested JSON map. Available in the template as `{{ .Tx.Data.* }}`. |
|
||||
| headers | JSON\[\] | | Optional array of email headers. |
|
||||
| messenger | string | | Messenger to send the message. Default is `email`. |
|
||||
| content_type | string | | Email format options include `html`, `markdown`, and `plain`. |
|
||||
|
||||
##### Example
|
||||
|
||||
```shell
|
||||
curl -u "username:password" "http://localhost:9000/api/tx" -X POST \
|
||||
-H 'Content-Type: application/json; charset=utf-8' \
|
||||
--data-binary @- << EOF
|
||||
{
|
||||
"subscriber_email": "user@test.com",
|
||||
"template_id": 2,
|
||||
"data": {"order_id": "1234", "date": "2022-07-30", "items": [1, 2, 3]},
|
||||
"content_type": "html"
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
##### Example response
|
||||
|
||||
```json
|
||||
{
|
||||
"data": true
|
||||
}
|
||||
```
|
||||
|
||||
______________________________________________________________________
|
||||
|
||||
#### File Attachments
|
||||
|
||||
To include file attachments in a transactional message, use the `multipart/form-data` Content-Type. Use `data` param for the parameters described above as a JSON object. Include any number of attachments via the `file` param.
|
||||
|
||||
```shell
|
||||
curl -u "username:password" "http://localhost:9000/api/tx" -X POST \
|
||||
-F 'data=\"{
|
||||
\"subscriber_email\": \"user@test.com\",
|
||||
\"template_id\": 4
|
||||
}"' \
|
||||
-F 'file=@"/path/to/attachment.pdf"' \
|
||||
-F 'file=@"/path/to/attachment2.pdf"'
|
||||
```
|
32
docs/docs/content/archives.md
Normal file
|
@ -0,0 +1,32 @@
|
|||
# Archives
|
||||
|
||||
A global public archive is maintained on the public web interface. It can be
|
||||
enabled under Settings -> Settings -> General -> Enable public mailing list
|
||||
archive.
|
||||
|
||||
To make a campaign available in the public archive (provided it has been
|
||||
enabled in the settings as described above), enable the option
|
||||
'Publish to public archive' under Campaigns -> Create new -> Archive.
|
||||
|
||||
When using template variables that depend on subscriber data (such as any
|
||||
template variable referencing `.Subscriber`), such data must be supplied
|
||||
as 'Campaign metadata', which is a JSON object that will be used in place
|
||||
of `.Subscriber` when rendering the archive template and content.
|
||||
|
||||
When individual subscriber tracking is enabled, TrackLink requires that a UUID
|
||||
of an existing user is provided as part of the campaign metadata. Any clicks on
|
||||
a TrackLink from the archived campaign will be counted towards that subscriber.
|
||||
|
||||
As an example:
|
||||
|
||||
```json
|
||||
{
|
||||
"UUID": "5a837423-a186-5623-9a87-82691cbe3631",
|
||||
"email": "example@example.com",
|
||||
"name": "Reader",
|
||||
"attribs": {}
|
||||
}
|
||||
```
|
||||
|
||||

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

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

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

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

|
||||
|
||||
## Downgrade
|
||||
|
||||
To restore a previous version, you have to restore the DB for that particular version. DBs that have been upgraded with a particular version shouldn't be used with older versions. There may be DB changes that a new version brings that are incompatible with previous versions.
|
||||
|
||||
**General steps:**
|
||||
|
||||
1. Stop listmonk.
|
||||
2. Restore your pre-upgrade database.
|
||||
3. If you're using `docker compose`, edit `docker-compose.yml` and change `listmonk:latest` to `listmonk:v2.4.0` _(for example)_.
|
||||
4. Restart.
|
||||
|
||||
**Example with docker:**
|
||||
|
||||
1. Stop listmonk (app):
|
||||
```
|
||||
sudo docker stop listmonk_app
|
||||
```
|
||||
2. Restore your pre-upgrade db (required) _(be careful, this will wipe your existing DB)_:
|
||||
```
|
||||
psql -h 127.0.0.1 -p 9432 -U listmonk
|
||||
drop schema public cascade;
|
||||
create schema public;
|
||||
\q
|
||||
psql -h 127.0.0.1 -p 9432 -U listmonk -W listmonk < listmonk-preupgrade-db.sql
|
||||
```
|
||||
3. Edit the `docker-compose.yml`:
|
||||
```
|
||||
x-app-defaults: &app-defaults
|
||||
restart: unless-stopped
|
||||
image: listmonk/listmonk:v2.4.0
|
||||
```
|
||||
4. Restart:
|
||||
`sudo docker compose up -d app db nginx certbot`
|
||||
|
61
docs/docs/mkdocs.yml
Normal file
|
@ -0,0 +1,61 @@
|
|||
site_name: listmonk / Documentation
|
||||
theme:
|
||||
name: material
|
||||
# custom_dir: "mkdocs-material/material"
|
||||
logo: "images/favicon.png"
|
||||
favicon: "images/favicon.png"
|
||||
language: "en"
|
||||
font:
|
||||
text: 'Inter'
|
||||
weights: 400
|
||||
direction: 'ltr'
|
||||
extra:
|
||||
search:
|
||||
language: 'en'
|
||||
feature:
|
||||
tabs: true
|
||||
features:
|
||||
- content.code.copy
|
||||
|
||||
palette:
|
||||
primary: "white"
|
||||
accent: "red"
|
||||
|
||||
site_dir: _out
|
||||
docs_dir: content
|
||||
|
||||
markdown_extensions:
|
||||
- admonition
|
||||
- pymdownx.highlight
|
||||
- pymdownx.superfences
|
||||
- toc:
|
||||
permalink: true
|
||||
|
||||
extra_css:
|
||||
- "static/style.css"
|
||||
|
||||
copyright: "Copyright © 2019-2023, Kailash Nadh."
|
||||
|
||||
nav:
|
||||
- "Introduction": index.md
|
||||
- "Installation": installation.md
|
||||
- "Upgrade": upgrade.md
|
||||
- "Configuration": configuration.md
|
||||
- "Developer setup": developer-setup.md
|
||||
- "Concepts": concepts.md
|
||||
- "Querying and segmenting subscribers": querying-and-segmentation.md
|
||||
- "Templating": templating.md
|
||||
- "Bounce processing": bounces.md
|
||||
- "Messengers": "messengers.md"
|
||||
- "Archives": "archives.md"
|
||||
- "Internationalization": "i18n.md"
|
||||
- "Integrating with external systems": external-integration.md
|
||||
- "API": apis/apis.md
|
||||
- "API / Subscribers": apis/subscribers.md
|
||||
- "API / Lists": apis/lists.md
|
||||
- "API / Import": apis/import.md
|
||||
- "API / Campaigns": apis/campaigns.md
|
||||
- "API / Media": apis/media.md
|
||||
- "API / Templates": apis/templates.md
|
||||
- "API / Transactional": apis/transactional.md
|
||||
|
106
docs/i18n/index.html
Normal file
|
@ -0,0 +1,106 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>listmonk i18n translation editor</title>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" type="text/css" href="style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" class="container">
|
||||
<header class="header">
|
||||
<h1 class="title">{{ values["_.name"] }}</h1>
|
||||
<div class="controls">
|
||||
<div class="import block">
|
||||
<a href="#" @click.prevent="onToggleRaw">
|
||||
<template v-if="!isRawVisible">Switch to raw JSON</template>
|
||||
<template v-else>Switch to editor</template>
|
||||
</a>
|
||||
<a href="#" @click.prevent="onDownloadJSON">Download raw JSON</a>
|
||||
<a v-else href="#" @click.prevent="onToggleRaw">Switch to editor</a>
|
||||
</div>
|
||||
|
||||
<div class="view block">
|
||||
<label for="view-all" class="all">
|
||||
<input v-model="view" name="view" id="view-all" type="radio" value="all" checked="true" />
|
||||
All ({{ keys.length }})
|
||||
</label>
|
||||
<label for="view-pending" class="pending">
|
||||
<input v-model="view" name="view" id="view-pending" type="radio" value="pending" />
|
||||
Pending ({{ keys.length - completed }})
|
||||
</label>
|
||||
<label for="view-complete" class="complete">
|
||||
<input v-model="view" name="view" id="view-complete" type="radio" value="complete" />
|
||||
Complete ({{ completed }})
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="selector block">
|
||||
Load existing language
|
||||
<select v-model="loadLang" @change="onLoadLanguage">
|
||||
<option value="en">Default (en)</option>
|
||||
<option value="ca"> Català (ca) </option>
|
||||
<option value="cs-cz"> čeština (cs) </option>
|
||||
<option value="cy"> Cymraeg (cy) </option>
|
||||
<option value="de"> Deutsch (de) </option>
|
||||
<option value="es"> Español (es) </option>
|
||||
<option value="fi"> Suomi (fi) </option>
|
||||
<option value="fr"> Français (fr) </option>
|
||||
<option value="hu"> Hungary (hu) </option>
|
||||
<option value="it"> Italiano (it) </option>
|
||||
<option value="jp"> 日本語 (jp) </option>
|
||||
<option value="ml"> മലയാളം (ml) </option>
|
||||
<option value="nl"> Nederlands (nl) </option>
|
||||
<option value="pl"> Polski (pl) </option>
|
||||
<option value="pt"> Portuguese (pt) </option>
|
||||
<option value="pt-BR"> Português Brasileiro (pt-BR) </option>
|
||||
<option value="ro"> Română (ro) </option>
|
||||
<option value="ru"> Русский (ru) </option>
|
||||
<option value="se"> Svenska (se) </option>
|
||||
<option value="sk"> slovenčina (sk) </option>
|
||||
<option value="tr"> Turkish (tr) </option>
|
||||
<option value="vi"> Vietnamese (vi) </option>
|
||||
<option value="zh-CN"> 简体中文 (zh-CN) </option>
|
||||
<option value="zh-TW"> 繁體中文(zh-TW) </option>
|
||||
</select>
|
||||
|
||||
|
||||
<a href="#" @click.prevent="onNewLang">+ Create new language</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p>
|
||||
Changes are stored in the browser's localStorage until the cache is cleared.
|
||||
To edit an existing language, load it and edit the fields.
|
||||
To create a new language, load the default language and edit the fields.
|
||||
Once done, copy the raw JSON and send a PR to the
|
||||
<a href="https://github.com/knadh/listmonk/tree/i18n/i18n" target="_blank">repo</a>.
|
||||
</p>
|
||||
|
||||
<div v-if="!isRawVisible" class="data">
|
||||
<div :class="{'item': true, 'done': isDone(k.key)}" v-for="(k, i) in keys" v-if="isItemVisible(k.key)">
|
||||
<h3 class="head" v-if="k.head">{{ k.head }}</h3>
|
||||
|
||||
<div class="controls">
|
||||
<div class="num">{{ i + 1 }}.</div>
|
||||
<div class="fields">
|
||||
<span class="base">{{ base[k.key] }}</span>
|
||||
<input type="text" v-model="values[k.key]" @blur="saveData" />
|
||||
<label class="key">{{ k.key }}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- data -->
|
||||
|
||||
<div v-else class="raw">
|
||||
<textarea v-model="rawData"></textarea>
|
||||
</div><!-- raw -->
|
||||
</div>
|
||||
<h4 id="loading">Loading ...</h4>
|
||||
|
||||
<script src="vue.min.js"></script>
|
||||
<script src="main.js"></script>
|
||||
</body>
|
||||
</html>
|
186
docs/i18n/main.js
Normal file
|
@ -0,0 +1,186 @@
|
|||
const BASEURL = "https://raw.githubusercontent.com/knadh/listmonk/master/i18n/";
|
||||
const BASELANG = "en";
|
||||
|
||||
var app = new Vue({
|
||||
el: "#app",
|
||||
data: {
|
||||
base: {},
|
||||
keys: [],
|
||||
visibleKeys: {},
|
||||
values: {},
|
||||
view: "all",
|
||||
loadLang: BASELANG,
|
||||
|
||||
isRawVisible: false,
|
||||
rawData: "{}"
|
||||
},
|
||||
|
||||
methods: {
|
||||
init() {
|
||||
document.querySelector("#app").style.display = 'block';
|
||||
document.querySelector("#loading").remove();
|
||||
},
|
||||
|
||||
loadBaseLang(url) {
|
||||
return fetch(url).then(response => response.json()).then(data => {
|
||||
// Retain the base values.
|
||||
Object.assign(this.base, data);
|
||||
|
||||
// Get the sorted keys from the language map.
|
||||
const keys = [];
|
||||
const visibleKeys = {};
|
||||
let head = null;
|
||||
Object.entries(this.base).sort((a, b) => a[0].localeCompare(b[0])).forEach((v) => {
|
||||
const h = v[0].split('.')[0];
|
||||
keys.push({
|
||||
"key": v[0],
|
||||
"head": (head !== h ? h : null) // eg: campaigns on `campaigns.something.else`
|
||||
});
|
||||
|
||||
visibleKeys[v[0]] = true;
|
||||
head = h;
|
||||
});
|
||||
|
||||
this.keys = keys;
|
||||
this.visibleKeys = visibleKeys;
|
||||
this.values = { ...this.base };
|
||||
|
||||
// Is there cached localStorage data?
|
||||
if (localStorage.data) {
|
||||
try {
|
||||
this.populateData(JSON.parse(localStorage.data));
|
||||
} catch (e) {
|
||||
console.log("Bad JSON in localStorage: " + e.toString());
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
populateData(data) {
|
||||
// Filter out all keys from data except for the base ones
|
||||
// in the base language.
|
||||
const vals = this.keys.reduce((a, key) => {
|
||||
a[key.key] = data.hasOwnProperty(key.key) ? data[key.key] : this.base[key.key];
|
||||
return a;
|
||||
}, {});
|
||||
|
||||
this.values = vals;
|
||||
this.saveData();
|
||||
},
|
||||
|
||||
loadLanguage(lang) {
|
||||
return fetch(BASEURL + lang + ".json").then(response => response.json()).then(data => {
|
||||
this.populateData(data);
|
||||
}).catch((e) => {
|
||||
console.log(e);
|
||||
alert("error fetching file: " + e.toString());
|
||||
});
|
||||
},
|
||||
|
||||
saveData() {
|
||||
localStorage.data = JSON.stringify(this.values);
|
||||
},
|
||||
|
||||
// Has a key been translated (changed from the base)?
|
||||
isDone(key) {
|
||||
return this.values[key] && this.base[key] !== this.values[key];
|
||||
},
|
||||
|
||||
isItemVisible(key) {
|
||||
return this.visibleKeys[key];
|
||||
},
|
||||
|
||||
onToggleRaw() {
|
||||
if (!this.isRawVisible) {
|
||||
this.rawData = JSON.stringify(this.values, Object.keys(this.values).sort(), 4);
|
||||
} else {
|
||||
try {
|
||||
this.populateData(JSON.parse(this.rawData));
|
||||
} catch (e) {
|
||||
alert("error parsing JSON: " + e.toString());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
this.isRawVisible = !this.isRawVisible;
|
||||
},
|
||||
|
||||
onLoadLanguage() {
|
||||
if (!confirm("Loading this language will overwrite your local changes. Continue?")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.loadLanguage(this.loadLang);
|
||||
},
|
||||
|
||||
onNewLang() {
|
||||
if (!confirm("Creating a new language will overwrite your local changes. Continue?")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let data = { ...this.base };
|
||||
data["_.code"] = "iso-code-here"
|
||||
data["_.name"] = "New language"
|
||||
this.populateData(data);
|
||||
},
|
||||
|
||||
onDownloadJSON() {
|
||||
// Create a Blob using the content, mimeType, and optional encoding
|
||||
const blob = new Blob([JSON.stringify(this.values, Object.keys(this.values).sort(), 4)], { type: "" });
|
||||
|
||||
// Create an anchor element with a download attribute
|
||||
const link = document.createElement('a');
|
||||
link.download = `${this.values["_.code"]}.json`;
|
||||
link.href = URL.createObjectURL(blob);
|
||||
|
||||
// Append the link to the DOM, click it to start the download, and remove it
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.loadBaseLang(BASEURL + BASELANG + ".json").then(() => this.init());
|
||||
},
|
||||
|
||||
watch: {
|
||||
view(v) {
|
||||
// When the view changes, create a copy of the items to be filtered
|
||||
// by and filter the view based on that. Otherwise, the moment the value
|
||||
// in the input changes, the list re-renders making items disappear.
|
||||
|
||||
const visibleKeys = {};
|
||||
this.keys.forEach((k) => {
|
||||
let visible = true;
|
||||
|
||||
if (v === "pending") {
|
||||
visible = !this.isDone(k.key);
|
||||
} else if (v === "complete") {
|
||||
visible = this.isDone(k.key);
|
||||
}
|
||||
|
||||
if (visible) {
|
||||
visibleKeys[k.key] = true;
|
||||
}
|
||||
});
|
||||
|
||||
this.visibleKeys = visibleKeys;
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
completed() {
|
||||
let n = 0;
|
||||
|
||||
this.keys.forEach(k => {
|
||||
if (this.values[k.key] !== this.base[k.key]) {
|
||||
n++;
|
||||
}
|
||||
});
|
||||
|
||||
return n;
|
||||
}
|
||||
}
|
||||
});
|
114
docs/i18n/style.css
Normal file
|
@ -0,0 +1,114 @@
|
|||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: Inter, "Helvetica Neue", "Segoe UI", sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5 {
|
||||
margin: 0 0 15px 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #0055d4;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
.header {
|
||||
align-items: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.header a {
|
||||
display: inline-block;
|
||||
margin-right: 15px;
|
||||
}
|
||||
.header .controls {
|
||||
display: flex;
|
||||
}
|
||||
.header .controls .pending {
|
||||
color: #ff3300;
|
||||
}
|
||||
.header .controls .complete {
|
||||
color: #05a200;
|
||||
}
|
||||
.header .title {
|
||||
margin: 0 0 15px 0;
|
||||
}
|
||||
.header .block {
|
||||
margin: 0 45px 0 0;
|
||||
}
|
||||
.header .view label {
|
||||
cursor: pointer;
|
||||
margin-right: 10px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.data .key,
|
||||
.data .base {
|
||||
display: block;
|
||||
color: #777;
|
||||
display: block;
|
||||
}
|
||||
.data .item {
|
||||
padding: 15px;
|
||||
clear: both;
|
||||
}
|
||||
.data .item:hover {
|
||||
background: #eee;
|
||||
}
|
||||
.data .item.done .num {
|
||||
color: #05a200;
|
||||
}
|
||||
.data .item.done .num::after {
|
||||
content: '✓';
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.data .controls {
|
||||
display: flex;
|
||||
}
|
||||
.data .fields {
|
||||
flex-grow: 1;
|
||||
}
|
||||
.data .num {
|
||||
margin-right: 15px;
|
||||
min-width: 50px;
|
||||
}
|
||||
.data .key {
|
||||
color: #aaa;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
.data input {
|
||||
width: 100%;
|
||||
border: 1px solid #ddd;
|
||||
padding: 5px;
|
||||
display: block;
|
||||
margin: 3px 0;
|
||||
|
||||
}
|
||||
.data input:focus {
|
||||
border-color: #666;
|
||||
}
|
||||
.data p {
|
||||
margin: 0 0 3px 0;
|
||||
}
|
||||
.data .head {
|
||||
margin: 0 0 15px 0;
|
||||
}
|
||||
|
||||
.raw textarea {
|
||||
border: 1px solid #ddd;
|
||||
padding: 5px;
|
||||
width: 100%;
|
||||
height: 90vh;
|
||||
}
|
6
docs/i18n/vue.min.js
vendored
Normal file
0
docs/site/.hugo_build.lock
Normal file
6
docs/site/config.toml
Normal file
|
@ -0,0 +1,6 @@
|
|||
baseurl = "https://listmonk.app/"
|
||||
languageCode = "en-us"
|
||||
title = "listmonk - Free and open source self-hosted newsletter, mailing list manager, and transactional mails"
|
||||
|
||||
[taxonomies]
|
||||
tag = "tags"
|
1
docs/site/content/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
|
32
docs/site/data/github.json
Normal file
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"version": "v2.5.1",
|
||||
"date": "2023-08-11T13:54:12Z",
|
||||
"url": "https://github.com/knadh/listmonk/releases/tag/v2.5.1",
|
||||
"assets":
|
||||
[
|
||||
{
|
||||
"name": "darwin",
|
||||
"url": "https://github.com/knadh/listmonk/releases/download/v2.5.1/listmonk_2.5.1_darwin_amd64.tar.gz"
|
||||
},
|
||||
{
|
||||
"name": "freebsd",
|
||||
"url": "https://github.com/knadh/listmonk/releases/download/v2.5.1/listmonk_2.5.1_freebsd_amd64.tar.gz"
|
||||
},
|
||||
{
|
||||
"name": "linux",
|
||||
"url": "https://github.com/knadh/listmonk/releases/download/v2.5.1/listmonk_2.5.1_linux_amd64.tar.gz"
|
||||
},
|
||||
{
|
||||
"name": "netbsd",
|
||||
"url": "https://github.com/knadh/listmonk/releases/download/v2.5.1/listmonk_2.5.1_netbsd_amd64.tar.gz"
|
||||
},
|
||||
{
|
||||
"name": "openbsd",
|
||||
"url": "https://github.com/knadh/listmonk/releases/download/v2.5.1/listmonk_2.5.1_openbsd_amd64.tar.gz"
|
||||
},
|
||||
{
|
||||
"name": "windows",
|
||||
"url": "https://github.com/knadh/listmonk/releases/download/v2.5.1/listmonk_2.5.1_windows_amd64.tar.gz"
|
||||
}
|
||||
]
|
||||
}
|
219
docs/site/layouts/index.html
Normal file
|
@ -0,0 +1,219 @@
|
|||
{{ partial "header.html" . }}
|
||||
<div class="splash container center">
|
||||
<img class="s4" src="static/images/s4.png" />
|
||||
<div class="hero">
|
||||
<h1 class="title">Self-hosted newsletter and mailing list manager</h1>
|
||||
<h3 class="sub">
|
||||
Performance and features packed into a single binary.<br />
|
||||
<strong>Free and open source.</strong>
|
||||
</h3>
|
||||
<p class="center demo">
|
||||
<a href="https://demo.listmonk.app" class="button">Live demo</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="confetti">
|
||||
<img class="s1" src="static/images/s1.png" />
|
||||
<img class="s2" src="static/images/s2.png" />
|
||||
<img class="s3" src="static/images/s3.png" />
|
||||
<img class="box" src="{{ .Site.BaseURL }}static/images/splash.png" alt="listmonk screenshot" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section id="download">
|
||||
<div class="container">
|
||||
<h2 class="center">Download</h2>
|
||||
<p class="center">
|
||||
The latest version is <strong>{{ .Page.Site.Data.github.version }}</strong>
|
||||
released on {{ .Page.Site.Data.github.date | dateFormat "02 Jan 2006" }}.
|
||||
See <a href="{{ .Page.Site.Data.github.url }}">release notes.</a>
|
||||
</p><br />
|
||||
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<div class="box">
|
||||
<h3>Binary</h3>
|
||||
<ul class="install-steps">
|
||||
<li class="download-links">Download binary:<br />
|
||||
{{ range.Page.Site.Data.github.assets }}
|
||||
<a href="{{ .url }}">{{ .name | title }}</a>
|
||||
{{ end }}
|
||||
</li>
|
||||
<li>
|
||||
<code>./listmonk --new-config</code> to generate config.toml. Edit the file.
|
||||
</li>
|
||||
<li><code>./listmonk --install</code> to setup the Postgres DB (⩾ v9.4) or <code>--upgrade</code> to upgrade an existing DB.</li>
|
||||
<li>Run <code>./listmonk</code> and visit <code>http://localhost:9000</code></li>
|
||||
</ul>
|
||||
<p><a href="/docs/installation">Installation docs →</a></p>
|
||||
|
||||
<br />
|
||||
<h3>Hosting providers</h3>
|
||||
<a href="https://railway.app/new/template/listmonk"><img src="https://camo.githubusercontent.com/081df3dd8cff37aab35044727b02b94a8e948052487a8c6253e190f5940d776d/68747470733a2f2f7261696c7761792e6170702f627574746f6e2e737667" alt="One-click deploy on Raleway" style="max-height: 32px;" /></a>
|
||||
<br />
|
||||
<a href="https://www.pikapods.com/pods?run=listmonk"><img src="https://www.pikapods.com/static/run-button.svg" alt="Deploy on PikaPod" /></a>
|
||||
<br />
|
||||
<a href="https://dash.elest.io/deploy?soft=Listmonk&id=237"><img height="33" src="https://github.com/elestio-examples/wordpress/raw/main/deploy-on-elestio.png" alt="Deploy on Elestio" /></a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="box">
|
||||
<h3>Docker</h3>
|
||||
<p><a href="https://hub.docker.com/r/listmonk/listmonk/tags?page=1&ordering=last_updated&name=latest"><code>listmonk/listmonk:latest</code></a></p>
|
||||
<p>
|
||||
Use the sample <a href="https://github.com/knadh/listmonk/blob/master/docker-compose.yml">docker-compose.yml</a>
|
||||
to run manually or use the helper script.
|
||||
</p>
|
||||
<h4>Demo</h4>
|
||||
<pre>mkdir listmonk-demo && cd listmonk-demo
|
||||
sh -c "$(curl -fsSL https://raw.githubusercontent.com/knadh/listmonk/master/install-demo.sh)"</pre>
|
||||
<p>
|
||||
(DO NOT use this demo setup in production)
|
||||
</p>
|
||||
|
||||
<h4>Production</h4>
|
||||
<pre>mkdir listmonk && cd listmonk
|
||||
sh -c "$(curl -fsSL https://raw.githubusercontent.com/knadh/listmonk/master/install-prod.sh)"</pre>
|
||||
<p>Visit <code>http://localhost:9000</code></p>
|
||||
|
||||
<p><a href="/docs/installation">Installation docs →</a></p>
|
||||
|
||||
<p class="small">NOTE: Always examine the contents of shell scripts before executing them.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="container">
|
||||
<section class="lists feature">
|
||||
<h2>Mailing lists</h2>
|
||||
<div class="center">
|
||||
<img class="box" src="static/images/lists.png" alt="Screenshot of list management feature" />
|
||||
</div>
|
||||
<p>
|
||||
Manage millions of subscribers across many single and double opt-in lists
|
||||
with custom JSON attributes for each subscriber.
|
||||
Query and segment subscribers with SQL expressions.
|
||||
</p>
|
||||
<p>Use the fast bulk importer (~10k records per second) or use HTTP/JSON APIs or interact with the simple
|
||||
table schema to integrate external CRMs and subscriber databases.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="tx feature">
|
||||
<h2>Transactional mails</h2>
|
||||
<div class="center">
|
||||
<img class="box" src="static/images/tx.png" alt="Screenshot of transactional API" />
|
||||
</div>
|
||||
<p>
|
||||
Simple API to send arbitrary transactional messages to subscribers
|
||||
using pre-defined templates. Send messages as e-mail, SMS, Whatsapp messages or any medium via Messenger interfaces.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="media feature">
|
||||
<h2>Analytics</h2>
|
||||
<div class="center">
|
||||
<img class="box" src="static/images/analytics.png" alt="Screenshot of analytics feature" />
|
||||
</div>
|
||||
<p class="center">
|
||||
Simple analaytics and visualizations. Connect external visualization programs to the database easily with the simple table structure.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="templating feature">
|
||||
<h2>Templating</h2>
|
||||
<div class="center">
|
||||
<img class="box" src="static/images/templating.png" alt="Screenshot of templating feature" />
|
||||
</div>
|
||||
<p>
|
||||
Create powerful, dynamic e-mail templates with the <a href="https://golang.org/pkg/text/template/">Go templating language</a>.
|
||||
Use template expressions, logic, and 100+ functions in subject lines and content.
|
||||
Write HTML e-mails in a WYSIWYG editor, Markdown, raw syntax-highlighted HTML, or just plain text.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="performance feature">
|
||||
<h2>Performance</h2>
|
||||
<div class="center">
|
||||
<figure class="box">
|
||||
<img src="static/images/performance.png" alt="Screenshot of performance metrics" />
|
||||
|
||||
<figcaption>
|
||||
A production listmonk instance sending a campaign of 7+ million e-mails.<br />
|
||||
CPU usage is a fraction of a single core with peak RAM usage of 57 MB.
|
||||
</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
<br />
|
||||
<p>
|
||||
Multi-threaded, high-throughput, multi-SMTP e-mail queues.
|
||||
Throughput and sliding window rate limiting for fine grained control.
|
||||
Single binary application with nominal CPU and memory footprint that runs everywhere.
|
||||
The only dependency is a Postgres (⩾ 12) database.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="media feature">
|
||||
<h2>Media</h2>
|
||||
<div class="center">
|
||||
<img class="box" src="static/images/media.png" alt="Screenshot of media feature" />
|
||||
</div>
|
||||
<p class="center">Use the media manager to upload images for e-mail campaigns
|
||||
on the server's filesystem, Amazon S3, or any S3 compatible (Minio) backend.</p>
|
||||
</section>
|
||||
|
||||
<section class="lists feature">
|
||||
<h2>Extensible</h2>
|
||||
<div class="center">
|
||||
<img class="box" src="static/images/messengers.png" alt="Screenshot of Messenger feature" />
|
||||
</div>
|
||||
<p class="center">
|
||||
More than just e-mail campaigns. Connect HTTP webhooks to send SMS,
|
||||
Whatsapp, FCM notifications, or any type of messages.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="privacy feature">
|
||||
<h2>Privacy</h2>
|
||||
<div class="center">
|
||||
<img class="box" src="static/images/privacy.png" alt="Screenshot of privacy features" />
|
||||
</div>
|
||||
<p class="center">
|
||||
Allow subscribers to permanently blocklist themselves, export all their data,
|
||||
and to wipe all their data in a single click.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<h2 class="center">and a lot more …</h2>
|
||||
|
||||
<div class="center">
|
||||
<br />
|
||||
<a href="#download" class="button">Download</a>
|
||||
</div>
|
||||
|
||||
<section class="banner">
|
||||
<div class="row">
|
||||
<div class="col-2"> </div>
|
||||
<div class="col-8">
|
||||
<div class="confetti">
|
||||
<img class="s2" src="static/images/s3.png" />
|
||||
<div class="box">
|
||||
<h2>Developers</h2>
|
||||
<p>
|
||||
listmonk is free and open source software licensed under AGPLv3.
|
||||
If you are interested in contributing, check out the <a href="https://github.com/knadh/listmonk">GitHub repository</a>
|
||||
and refer to the <a href="/docs/developer-setup">developer setup</a>.
|
||||
The backend is written in Go and the frontend is Vue with Buefy for UI.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-2"> </div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{{ partial "footer.html" }}
|
6
docs/site/layouts/page/single.html
Normal file
|
@ -0,0 +1,6 @@
|
|||
{{ partial "header" . }}
|
||||
<article class="page">
|
||||
<h1>{{ .Title }}</h1>
|
||||
{{ .Content }}
|
||||
</article>
|
||||
{{ partial "footer" }}
|
10
docs/site/layouts/partials/footer.html
Normal file
|
@ -0,0 +1,10 @@
|
|||
|
||||
<div class="container">
|
||||
<footer class="footer">
|
||||
© 2019-{{ now.Format "2006" }} / <a href="https://nadh.in">Kailash Nadh</a>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script async defer src="https://buttons.github.io/buttons.js"></script>
|
||||
</body>
|
||||
</html>
|
42
docs/site/layouts/partials/header.html
Normal file
|
@ -0,0 +1,42 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||
<title>{{ .Title }}</title>
|
||||
<meta name="description" content="{{with .Description }}{{ . }}{{else}}Send e-mail campaigns and transactional e-mails. High performance and features packed into one app.{{end}}" />
|
||||
<meta name="keywords" content="{{ if .Keywords }}{{ range .Keywords }}{{ . }}, {{ end }}{{else if isset .Params "tags" }}{{ range .Params.tags }}{{ . }}, {{ end }}{{end}}">
|
||||
<link rel="canonical" href="{{ .Permalink }}">
|
||||
<link href="https://fonts.googleapis.com/css?family=Inter:400,600" rel="stylesheet">
|
||||
<link href="{{ .Site.BaseURL }}static/base.css" rel="stylesheet" type="text/css" />
|
||||
<link href="{{ .Site.BaseURL }}static/style.css" rel="stylesheet" type="text/css" />
|
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1" />
|
||||
<link rel="shortcut icon" href="{{ .Site.BaseURL }}static/images/favicon.png" type="image/x-icon" />
|
||||
|
||||
<meta property="og:title" content="{{ .Title }}" />
|
||||
{{ if .Params.thumbnail }}
|
||||
<link rel="image_src" href="{{ .Site.BaseURL }}static/images/{{ .Params.thumbnail }}" />
|
||||
<meta property="og:image" content="{{ .Site.BaseURL }}static/images/{{ .Params.thumbnail }}" />
|
||||
{{ else }}
|
||||
<link rel="image_src" href="{{ .Site.BaseURL }}static/images/thumbnail.png" />
|
||||
<meta property="og:image" content="{{ .Site.BaseURL }}static/images/thumbnail.png" />
|
||||
{{ end }}
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="container">
|
||||
<header class="header">
|
||||
<div class="row">
|
||||
<div class="col-2 logo">
|
||||
<a href="{{ .Site.BaseURL }}"><img src="{{ .Site.BaseURL }}static/images/logo.svg" alt="Listmonk logo" /></a>
|
||||
</div>
|
||||
<nav class="col-10">
|
||||
<a class="item" href="/#download">Download</a>
|
||||
<a class="item" href="/docs">Docs</a>
|
||||
<div class="github-btn">
|
||||
<a class="github-button" href="https://github.com/knadh/listmonk" data-size="large" data-show-count="true" aria-label="knadh/listmonk on GitHub">GitHub</a>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
5
docs/site/layouts/shortcodes/centered.html
Normal file
|
@ -0,0 +1,5 @@
|
|||
<section class="row">
|
||||
<div class="col2"> </div>
|
||||
<div class="col8">{{ .Inner }}</div>
|
||||
<div class="clear"> </div>
|
||||
</section>
|
17
docs/site/layouts/shortcodes/github.html
Normal file
|
@ -0,0 +1,17 @@
|
|||
<ul id="github" class="no">
|
||||
{{ range .Page.Site.Data.github }}
|
||||
<li class="row">
|
||||
<div class="col2">
|
||||
<span class="date">{{ dateFormat "Jan 2006" (substr .updated_at 0 10) }}</span>
|
||||
</div>
|
||||
<div class="col3">
|
||||
<a href="{{ .url }}">{{ .name }}</a>
|
||||
</div>
|
||||
<div class="col7 last">
|
||||
<span class="desc">{{ .description }}</span>
|
||||
</div>
|
||||
<div class="clear"> </div>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
<div class="clear"> </div>
|
4
docs/site/layouts/shortcodes/half.html
Normal file
|
@ -0,0 +1,4 @@
|
|||
<div class="row">
|
||||
<div class="col7">{{ .Inner }}</div>
|
||||
<div class="clear"> </div>
|
||||
</div>
|
3
docs/site/layouts/shortcodes/section.html
Normal file
|
@ -0,0 +1,3 @@
|
|||
<section>
|
||||
{{ .Inner }}
|
||||
</section>
|
190
docs/site/static/static/base.css
Normal file
|
@ -0,0 +1,190 @@
|
|||
/**
|
||||
*** SIMPLE GRID
|
||||
*** (C) ZACH COLE 2016
|
||||
**/
|
||||
|
||||
|
||||
/* UNIVERSAL */
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
left: 0;
|
||||
top: 0;
|
||||
font-size: 100%;
|
||||
}
|
||||
|
||||
.right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.center {
|
||||
text-align: center;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.justify {
|
||||
text-align: justify;
|
||||
}
|
||||
|
||||
/* ==== GRID SYSTEM ==== */
|
||||
|
||||
.container {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.row {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.row [class^="col"] {
|
||||
float: left;
|
||||
margin: 0.5rem 2%;
|
||||
min-height: 0.125rem;
|
||||
}
|
||||
|
||||
.col-1,
|
||||
.col-2,
|
||||
.col-3,
|
||||
.col-4,
|
||||
.col-5,
|
||||
.col-6,
|
||||
.col-7,
|
||||
.col-8,
|
||||
.col-9,
|
||||
.col-10,
|
||||
.col-11,
|
||||
.col-12 {
|
||||
width: 96%;
|
||||
}
|
||||
|
||||
.col-1-sm {
|
||||
width: 4.33%;
|
||||
}
|
||||
|
||||
.col-2-sm {
|
||||
width: 12.66%;
|
||||
}
|
||||
|
||||
.col-3-sm {
|
||||
width: 21%;
|
||||
}
|
||||
|
||||
.col-4-sm {
|
||||
width: 29.33%;
|
||||
}
|
||||
|
||||
.col-5-sm {
|
||||
width: 37.66%;
|
||||
}
|
||||
|
||||
.col-6-sm {
|
||||
width: 46%;
|
||||
}
|
||||
|
||||
.col-7-sm {
|
||||
width: 54.33%;
|
||||
}
|
||||
|
||||
.col-8-sm {
|
||||
width: 62.66%;
|
||||
}
|
||||
|
||||
.col-9-sm {
|
||||
width: 71%;
|
||||
}
|
||||
|
||||
.col-10-sm {
|
||||
width: 79.33%;
|
||||
}
|
||||
|
||||
.col-11-sm {
|
||||
width: 87.66%;
|
||||
}
|
||||
|
||||
.col-12-sm {
|
||||
width: 96%;
|
||||
}
|
||||
|
||||
.row::after {
|
||||
content: "";
|
||||
display: table;
|
||||
clear: both;
|
||||
}
|
||||
|
||||
.hidden-sm {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 33.75em) { /* 540px */
|
||||
.container {
|
||||
width: 80%;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 45em) { /* 720px */
|
||||
.col-1 {
|
||||
width: 4.33%;
|
||||
}
|
||||
|
||||
.col-2 {
|
||||
width: 12.66%;
|
||||
}
|
||||
|
||||
.col-3 {
|
||||
width: 21%;
|
||||
}
|
||||
|
||||
.col-4 {
|
||||
width: 29.33%;
|
||||
}
|
||||
|
||||
.col-5 {
|
||||
width: 37.66%;
|
||||
}
|
||||
|
||||
.col-6 {
|
||||
width: 46%;
|
||||
}
|
||||
|
||||
.col-7 {
|
||||
width: 54.33%;
|
||||
}
|
||||
|
||||
.col-8 {
|
||||
width: 62.66%;
|
||||
}
|
||||
|
||||
.col-9 {
|
||||
width: 71%;
|
||||
}
|
||||
|
||||
.col-10 {
|
||||
width: 79.33%;
|
||||
}
|
||||
|
||||
.col-11 {
|
||||
width: 87.66%;
|
||||
}
|
||||
|
||||
.col-12 {
|
||||
width: 96%;
|
||||
}
|
||||
|
||||
.hidden-sm {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 60em) { /* 960px */
|
||||
.container {
|
||||
width: 75%;
|
||||
max-width: 60rem;
|
||||
}
|
||||
}
|
BIN
docs/site/static/static/images/2022-07-31_19-07.png
Normal file
After Width: | Height: | Size: 360 KiB |
BIN
docs/site/static/static/images/2022-07-31_19-08.png
Normal file
After Width: | Height: | Size: 372 KiB |
BIN
docs/site/static/static/images/analytics.png
Normal file
After Width: | Height: | Size: 34 KiB |
BIN
docs/site/static/static/images/favicon.png
Normal file
After Width: | Height: | Size: 1.3 KiB |
233
docs/site/static/static/images/listmonk.src.svg
Normal file
|
@ -0,0 +1,233 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
width="45.041653mm"
|
||||
height="9.8558731mm"
|
||||
viewBox="0 0 45.041653 9.8558733"
|
||||
version="1.1"
|
||||
id="svg8"
|
||||
sodipodi:docname="listmonk.src.svg"
|
||||
inkscape:version="1.0 (9f2f71dc58, 2020-08-02)">
|
||||
<defs
|
||||
id="defs2" />
|
||||
<sodipodi:namedview
|
||||
id="base"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:zoom="1"
|
||||
inkscape:cx="742.82396"
|
||||
inkscape:cy="-93.302628"
|
||||
inkscape:document-units="mm"
|
||||
inkscape:current-layer="layer1"
|
||||
showgrid="false"
|
||||
inkscape:window-width="1863"
|
||||
inkscape:window-height="1025"
|
||||
inkscape:window-x="57"
|
||||
inkscape:window-y="27"
|
||||
inkscape:window-maximized="1"
|
||||
fit-margin-top="0"
|
||||
fit-margin-left="0"
|
||||
fit-margin-right="0"
|
||||
fit-margin-bottom="0"
|
||||
inkscape:document-rotation="0" />
|
||||
<metadata
|
||||
id="metadata5">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(-12.438455,-21.535559)">
|
||||
<path
|
||||
style="fill:#ffcc00;fill-opacity:1;stroke:none;stroke-width:2.11094689;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 16.660914,21.535559 a 4.2220837,4.2220837 0 0 0 -4.222459,4.222437 4.2220837,4.2220837 0 0 0 0.490699,1.968681 c 0.649637,-1.386097 2.059696,-2.343758 3.73176,-2.343758 1.672279,0 3.082188,0.958029 3.731731,2.344413 a 4.2220837,4.2220837 0 0 0 0.490039,-1.969336 4.2220837,4.2220837 0 0 0 -4.22177,-4.222437 z"
|
||||
id="circle920"
|
||||
inkscape:connector-curvature="0"
|
||||
inkscape:export-filename="/home/kailash/Site/listmonk/static/logo.png"
|
||||
inkscape:export-xdpi="96"
|
||||
inkscape:export-ydpi="96" />
|
||||
<flowRoot
|
||||
xml:space="preserve"
|
||||
id="flowRoot935"
|
||||
style="font-style:normal;font-weight:normal;font-size:40px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none"
|
||||
transform="matrix(0.27888442,0,0,0.27888442,92.852428,101.67857)"><flowRegion
|
||||
id="flowRegion937"><rect
|
||||
id="rect939"
|
||||
width="338"
|
||||
height="181"
|
||||
x="-374"
|
||||
y="-425.36423" /></flowRegion><flowPara
|
||||
id="flowPara941" /></flowRoot>
|
||||
<text
|
||||
id="text874-8"
|
||||
y="30.29347"
|
||||
x="23.133614"
|
||||
style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:8.70789px;line-height:1.25;font-family:'IBM Plex Sans';-inkscape-font-specification:'IBM Plex Sans, Medium';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.0459737"
|
||||
xml:space="preserve"
|
||||
inkscape:export-filename="/home/kailash/Site/listmonk/static/logo.png"
|
||||
inkscape:export-xdpi="96"
|
||||
inkscape:export-ydpi="96"><tspan
|
||||
style="font-style:normal;font-variant:normal;font-weight:600;font-stretch:normal;font-size:8.70789px;font-family:Inter;-inkscape-font-specification:'Inter Semi-Bold';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;writing-mode:lr-tb;text-anchor:start;stroke-width:0.0459737"
|
||||
y="30.29347"
|
||||
x="23.133614"
|
||||
id="tspan872-0"
|
||||
sodipodi:role="line">listmonk</tspan></text>
|
||||
<circle
|
||||
r="3.1873188"
|
||||
cy="27.647591"
|
||||
cx="16.66629"
|
||||
id="circle876-1"
|
||||
style="fill:none;fill-opacity:1;stroke:#7f2aff;stroke-width:1.11304522;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
inkscape:export-filename="/home/kailash/Site/listmonk/static/logo.png"
|
||||
inkscape:export-xdpi="96"
|
||||
inkscape:export-ydpi="96" />
|
||||
<path
|
||||
inkscape:connector-curvature="0"
|
||||
id="path878-0"
|
||||
d="m 16.666291,24.813242 a 3.1873187,3.8372081 0 0 0 -3.187196,3.837044 3.1873187,3.8372081 0 0 0 0.07347,0.79818 3.1873187,3.8372081 0 0 1 3.113724,-3.027362 3.1873187,3.8372081 0 0 1 3.113721,3.038883 3.1873187,3.8372081 0 0 0 0.07347,-0.809701 3.1873187,3.8372081 0 0 0 -3.187196,-3.837044 z"
|
||||
style="fill:#7f2aff;fill-opacity:1;stroke:none;stroke-width:1.22125876;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
inkscape:export-filename="/home/kailash/Site/listmonk/static/logo.png"
|
||||
inkscape:export-xdpi="96"
|
||||
inkscape:export-ydpi="96" />
|
||||
<path
|
||||
style="fill:#ffcc00;fill-opacity:1;stroke:none;stroke-width:1.06017;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 139.94612,-53.327122 a 2.1703097,2.0716912 0 0 0 -2.17051,2.071864 2.1703097,2.0716912 0 0 0 0.25224,0.965993 c 0.33394,-0.680131 1.05876,-1.150035 1.91827,-1.150035 0.85961,0 1.58436,0.470085 1.91825,1.150356 a 2.1703097,2.0716912 0 0 0 0.2519,-0.966314 2.1703097,2.0716912 0 0 0 -2.17015,-2.071864 z"
|
||||
id="path1200"
|
||||
inkscape:connector-curvature="0"
|
||||
inkscape:export-filename="/home/kailash/Site/listmonk/static/logo.png"
|
||||
inkscape:export-xdpi="96"
|
||||
inkscape:export-ydpi="96" />
|
||||
<text
|
||||
id="text1204"
|
||||
y="-46.771812"
|
||||
x="116.91617"
|
||||
style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:8.70789px;line-height:1.25;font-family:'IBM Plex Sans';-inkscape-font-specification:'IBM Plex Sans, Medium';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.0459737"
|
||||
xml:space="preserve"
|
||||
inkscape:export-filename="/home/kailash/Site/listmonk/static/logo.png"
|
||||
inkscape:export-xdpi="96"
|
||||
inkscape:export-ydpi="96"><tspan
|
||||
style="font-style:normal;font-variant:normal;font-weight:600;font-stretch:normal;font-size:8.70789px;font-family:Inter;-inkscape-font-specification:'Inter Semi-Bold';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;writing-mode:lr-tb;text-anchor:start;stroke-width:0.0459737"
|
||||
y="-46.771812"
|
||||
x="116.91617"
|
||||
id="tspan1202"
|
||||
sodipodi:role="line">listmonk</tspan></text>
|
||||
<text
|
||||
id="text1214"
|
||||
y="-23.851294"
|
||||
x="127.87717"
|
||||
style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:6.82489px;line-height:1.25;font-family:'IBM Plex Sans';-inkscape-font-specification:'IBM Plex Sans, Medium';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.0360324"
|
||||
xml:space="preserve"
|
||||
inkscape:export-filename="/home/kailash/Site/listmonk/static/logo.png"
|
||||
inkscape:export-xdpi="96"
|
||||
inkscape:export-ydpi="96"><tspan
|
||||
style="font-style:normal;font-variant:normal;font-weight:600;font-stretch:normal;font-size:6.82489px;font-family:Inter;-inkscape-font-specification:'Inter Semi-Bold';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;writing-mode:lr-tb;text-anchor:start;stroke-width:0.0360324"
|
||||
y="-23.851294"
|
||||
x="127.87717"
|
||||
id="tspan1212"
|
||||
sodipodi:role="line">listmonk</tspan></text>
|
||||
<circle
|
||||
style="fill:#ffcc00;fill-opacity:1;stroke:none;stroke-width:2.5729"
|
||||
id="path1216"
|
||||
cx="203.43507"
|
||||
cy="-21.854498"
|
||||
r="3.8091576" />
|
||||
<g
|
||||
id="g1239"
|
||||
transform="matrix(1.2398232,0,0,1.2398232,25.599078,-34.522694)">
|
||||
<rect
|
||||
style="fill:#7f2aff;fill-opacity:1;stroke:none;stroke-width:0.91563"
|
||||
id="rect1218"
|
||||
width="3.7532511"
|
||||
height="0.89233136"
|
||||
x="77.048592"
|
||||
y="4.6184554" />
|
||||
<rect
|
||||
style="fill:#7f2aff;fill-opacity:1;stroke:none;stroke-width:0.91563"
|
||||
id="rect1220"
|
||||
width="3.7532511"
|
||||
height="0.89233136"
|
||||
x="77.048592"
|
||||
y="6.1939058" />
|
||||
<rect
|
||||
style="fill:#7f2aff;fill-opacity:1;stroke:none;stroke-width:0.91563"
|
||||
id="rect1226"
|
||||
width="3.7532511"
|
||||
height="0.89233136"
|
||||
x="77.048592"
|
||||
y="7.7760162" />
|
||||
</g>
|
||||
<ellipse
|
||||
style="fill:#ffcc00;fill-opacity:1;stroke:none;stroke-width:1.5875"
|
||||
id="path1247"
|
||||
cx="139.2197"
|
||||
cy="-74.271935"
|
||||
rx="2.1283948"
|
||||
ry="1.9833959" />
|
||||
<text
|
||||
id="text1245"
|
||||
y="-71.648537"
|
||||
x="115.96989"
|
||||
style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:8.70789px;line-height:1.25;font-family:'IBM Plex Sans';-inkscape-font-specification:'IBM Plex Sans, Medium';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.0459737"
|
||||
xml:space="preserve"
|
||||
inkscape:export-filename="/home/kailash/Site/listmonk/static/logo.png"
|
||||
inkscape:export-xdpi="96"
|
||||
inkscape:export-ydpi="96"><tspan
|
||||
style="font-style:normal;font-variant:normal;font-weight:600;font-stretch:normal;font-size:8.70789px;font-family:Inter;-inkscape-font-specification:'Inter Semi-Bold';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;writing-mode:lr-tb;text-anchor:start;stroke-width:0.0459737"
|
||||
y="-71.648537"
|
||||
x="115.96989"
|
||||
id="tspan1243"
|
||||
sodipodi:role="line">listmonk</tspan></text>
|
||||
<text
|
||||
id="text1042"
|
||||
y="-18.770809"
|
||||
x="210.12352"
|
||||
style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:8.70789px;line-height:1.25;font-family:'IBM Plex Sans';-inkscape-font-specification:'IBM Plex Sans, Medium';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;letter-spacing:-0.0529167px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.0459737"
|
||||
xml:space="preserve"
|
||||
inkscape:export-filename="/home/kailash/Site/listmonk/static/logo.png"
|
||||
inkscape:export-xdpi="96"
|
||||
inkscape:export-ydpi="96"><tspan
|
||||
style="font-style:normal;font-variant:normal;font-weight:600;font-stretch:normal;font-size:8.70789px;font-family:'Fira Sans';-inkscape-font-specification:'Fira Sans Semi-Bold';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;writing-mode:lr-tb;text-anchor:start;stroke-width:0.0459737"
|
||||
y="-18.770809"
|
||||
x="210.12352"
|
||||
id="tspan1040"
|
||||
sodipodi:role="line">listmonk</tspan></text>
|
||||
<circle
|
||||
style="fill:none;fill-opacity:1;stroke:#ffcc00;stroke-width:1.73982;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="circle1737"
|
||||
cx="203.74388"
|
||||
cy="-1.1837244"
|
||||
r="3.1489604" />
|
||||
<text
|
||||
id="text1741"
|
||||
y="2.24283"
|
||||
x="210.38811"
|
||||
style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:8.70789px;line-height:1.25;font-family:'IBM Plex Sans';-inkscape-font-specification:'IBM Plex Sans, Medium';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;letter-spacing:-0.0529167px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.0459737"
|
||||
xml:space="preserve"
|
||||
inkscape:export-filename="/home/kailash/Site/listmonk/static/logo.png"
|
||||
inkscape:export-xdpi="96"
|
||||
inkscape:export-ydpi="96"><tspan
|
||||
style="font-style:normal;font-variant:normal;font-weight:600;font-stretch:normal;font-size:8.70789px;font-family:'Fira Sans';-inkscape-font-specification:'Fira Sans Semi-Bold';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-feature-settings:normal;text-align:start;writing-mode:lr-tb;text-anchor:start;stroke-width:0.0459737"
|
||||
y="2.24283"
|
||||
x="210.38811"
|
||||
id="tspan1739"
|
||||
sodipodi:role="line">listmonk</tspan></text>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 13 KiB |