1{
2 config,
3 lib,
4 pkgs,
5 ...
6}:
7let
8 cfg = config.services.listmonk;
9 tomlFormat = pkgs.formats.toml { };
10 cfgFile = tomlFormat.generate "listmonk.toml" cfg.settings;
11 # Escaping is done according to https://www.postgresql.org/docs/current/sql-syntax-lexical.html#SQL-SYNTAX-CONSTANTS
12 setDatabaseOption =
13 key: value:
14 "UPDATE settings SET value = '${
15 lib.replaceStrings [ "'" ] [ "''" ] (builtins.toJSON value)
16 }' WHERE key = '${key}';";
17 updateDatabaseConfigSQL = pkgs.writeText "update-database-config.sql" (
18 lib.concatStringsSep "\n" (
19 lib.mapAttrsToList setDatabaseOption (
20 if (cfg.database.settings != null) then cfg.database.settings else { }
21 )
22 )
23 );
24 updateDatabaseConfigScript = pkgs.writeShellScriptBin "update-database-config.sh" ''
25 ${
26 if cfg.database.mutableSettings then
27 ''
28 if [ ! -f /var/lib/listmonk/.db_settings_initialized ]; then
29 ${pkgs.postgresql}/bin/psql -d listmonk -f ${updateDatabaseConfigSQL} ;
30 touch /var/lib/listmonk/.db_settings_initialized
31 fi
32 ''
33 else
34 "${pkgs.postgresql}/bin/psql -d listmonk -f ${updateDatabaseConfigSQL}"
35 }
36 '';
37
38 databaseSettingsOpts = with lib.types; {
39 freeformType = oneOf [
40 (listOf str)
41 (listOf (attrsOf anything))
42 str
43 int
44 bool
45 ];
46
47 options = {
48 "app.notify_emails" = lib.mkOption {
49 type = listOf str;
50 default = [ ];
51 description = "Administrator emails for system notifications";
52 };
53
54 "privacy.exportable" = lib.mkOption {
55 type = listOf str;
56 default = [
57 "profile"
58 "subscriptions"
59 "campaign_views"
60 "link_clicks"
61 ];
62 description = "List of fields which can be exported through an automatic export request";
63 };
64
65 "privacy.domain_blocklist" = lib.mkOption {
66 type = listOf str;
67 default = [ ];
68 description = "E-mail addresses with these domains are disallowed from subscribing.";
69 };
70
71 smtp = lib.mkOption {
72 type = listOf (submodule {
73 freeformType = with lib.types; attrsOf anything;
74
75 options = {
76 enabled = lib.mkEnableOption "this SMTP server for listmonk";
77 host = lib.mkOption {
78 type = lib.types.str;
79 description = "Hostname for the SMTP server";
80 };
81 port = lib.mkOption {
82 type = lib.types.port;
83 description = "Port for the SMTP server";
84 };
85 max_conns = lib.mkOption {
86 type = lib.types.int;
87 description = "Maximum number of simultaneous connections, defaults to 1";
88 default = 1;
89 };
90 tls_type = lib.mkOption {
91 type = lib.types.enum [
92 "none"
93 "STARTTLS"
94 "TLS"
95 ];
96 description = "Type of TLS authentication with the SMTP server";
97 };
98 };
99 });
100
101 description = "List of outgoing SMTP servers";
102 };
103
104 # TODO: refine this type based on the smtp one.
105 "bounce.mailboxes" = lib.mkOption {
106 type = listOf (submodule {
107 freeformType = with lib.types; listOf (attrsOf anything);
108 });
109 default = [ ];
110 description = "List of bounce mailboxes";
111 };
112
113 messengers = lib.mkOption {
114 type = listOf str;
115 default = [ ];
116 description = "List of messengers, see: <https://github.com/knadh/listmonk/blob/master/models/settings.go#L64-L74> for options.";
117 };
118 };
119 };
120in
121{
122 ###### interface
123 options = {
124 services.listmonk = {
125 enable = lib.mkEnableOption "Listmonk, this module assumes a reverse proxy to be set";
126 database = {
127 createLocally = lib.mkOption {
128 type = lib.types.bool;
129 default = false;
130 description = "Create the PostgreSQL database and database user locally.";
131 };
132
133 settings = lib.mkOption {
134 default = null;
135 type = with lib.types; nullOr (submodule databaseSettingsOpts);
136 description = "Dynamic settings in the PostgreSQL database, set by a SQL script, see <https://github.com/knadh/listmonk/blob/master/schema.sql#L177-L230> for details.";
137 };
138 mutableSettings = lib.mkOption {
139 type = lib.types.bool;
140 default = true;
141 description = ''
142 Database settings will be reset to the value set in this module if this is not enabled.
143 Enable this if you want to persist changes you have done in the application.
144 '';
145 };
146 };
147 package = lib.mkPackageOption pkgs "listmonk" { };
148 settings = lib.mkOption {
149 type = lib.types.submodule { freeformType = tomlFormat.type; };
150 description = ''
151 Static settings set in the config.toml, see <https://github.com/knadh/listmonk/blob/master/config.toml.sample> for details.
152 You can set secrets using the secretFile option with environment variables following <https://listmonk.app/docs/configuration/#environment-variables>.
153 '';
154 };
155 secretFile = lib.mkOption {
156 type = lib.types.nullOr lib.types.str;
157 default = null;
158 description = "A file containing secrets as environment variables. See <https://listmonk.app/docs/configuration/#environment-variables> for details on supported values.";
159 };
160 };
161 };
162
163 ###### implementation
164 config = lib.mkIf cfg.enable {
165 # Default parameters from https://github.com/knadh/listmonk/blob/master/config.toml.sample
166 services.listmonk.settings."app".address = lib.mkDefault "localhost:9000";
167 services.listmonk.settings."db" = lib.mkMerge [
168 ({
169 max_open = lib.mkDefault 25;
170 max_idle = lib.mkDefault 25;
171 max_lifetime = lib.mkDefault "300s";
172 })
173 (lib.mkIf cfg.database.createLocally {
174 host = lib.mkDefault "/run/postgresql";
175 port = lib.mkDefault 5432;
176 user = lib.mkDefault "listmonk";
177 database = lib.mkDefault "listmonk";
178 })
179 ];
180
181 services.postgresql = lib.mkIf cfg.database.createLocally {
182 enable = true;
183
184 ensureUsers = [
185 {
186 name = "listmonk";
187 ensureDBOwnership = true;
188 }
189 ];
190
191 ensureDatabases = [ "listmonk" ];
192 };
193
194 systemd.services.listmonk = {
195 description = "Listmonk - newsletter and mailing list manager";
196 after = [ "network.target" ] ++ lib.optional cfg.database.createLocally "postgresql.service";
197 wantedBy = [ "multi-user.target" ];
198 serviceConfig = {
199 Type = "exec";
200 EnvironmentFile = lib.mkIf (cfg.secretFile != null) [ cfg.secretFile ];
201 ExecStartPre = [
202 # StateDirectory cannot be used when DynamicUser = true is set this way.
203 # Indeed, it will try to create all the folders and realize one of them already exist.
204 # Therefore, we have to create it ourselves.
205 ''${pkgs.coreutils}/bin/mkdir -p "''${STATE_DIRECTORY}/listmonk/uploads"''
206 # setup database if not already done
207 "${cfg.package}/bin/listmonk --config ${cfgFile} --idempotent --install --yes"
208 # apply db migrations (setup and migrations can not be done in one step
209 # with "--install --upgrade" listmonk ignores the upgrade)
210 "${cfg.package}/bin/listmonk --config ${cfgFile} --upgrade --yes"
211 "${updateDatabaseConfigScript}/bin/update-database-config.sh"
212 ];
213 ExecStart = "${cfg.package}/bin/listmonk --config ${cfgFile}";
214
215 Restart = "on-failure";
216
217 StateDirectory = [ "listmonk" ];
218
219 User = "listmonk";
220 Group = "listmonk";
221 DynamicUser = true;
222 NoNewPrivileges = true;
223 CapabilityBoundingSet = "";
224 SystemCallArchitectures = "native";
225 SystemCallFilter = [
226 "@system-service"
227 "~@privileged"
228 ];
229 PrivateDevices = true;
230 ProtectControlGroups = true;
231 ProtectKernelTunables = true;
232 ProtectHome = true;
233 RestrictNamespaces = true;
234 RestrictRealtime = true;
235 UMask = "0027";
236 MemoryDenyWriteExecute = true;
237 LockPersonality = true;
238 RestrictAddressFamilies = [
239 "AF_INET"
240 "AF_INET6"
241 "AF_UNIX"
242 ];
243 ProtectKernelModules = true;
244 PrivateUsers = true;
245 };
246 };
247 };
248}