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