1{
2 config,
3 lib,
4 pkgs,
5 ...
6}:
7let
8 cfg = config.services.sympa;
9 dataDir = "/var/lib/sympa";
10 user = "sympa";
11 group = "sympa";
12 pkg = pkgs.sympa;
13 fqdns = lib.attrNames cfg.domains;
14 usingNginx = cfg.web.enable && cfg.web.server == "nginx";
15 mysqlLocal = cfg.database.createLocally && cfg.database.type == "MySQL";
16 pgsqlLocal = cfg.database.createLocally && cfg.database.type == "PostgreSQL";
17
18 sympaSubServices = [
19 "sympa-archive.service"
20 "sympa-bounce.service"
21 "sympa-bulk.service"
22 "sympa-task.service"
23 ];
24
25 # common for all services including wwsympa
26 commonServiceConfig = {
27 StateDirectory = "sympa";
28 ProtectHome = true;
29 ProtectSystem = "full";
30 ProtectControlGroups = true;
31 };
32
33 # wwsympa has its own service config
34 sympaServiceConfig =
35 srv:
36 {
37 Type = "simple";
38 Restart = "always";
39 ExecStart = "${pkg}/bin/${srv}.pl --foreground";
40 PIDFile = "/run/sympa/${srv}.pid";
41 User = user;
42 Group = group;
43
44 # avoid duplicating log messageges in journal
45 StandardError = "null";
46 }
47 // commonServiceConfig;
48
49 configVal = value: if lib.isBool value then if value then "on" else "off" else toString value;
50 configGenerator =
51 c: lib.concatStrings (lib.flip lib.mapAttrsToList c (key: val: "${key}\t${configVal val}\n"));
52
53 mainConfig = pkgs.writeText "sympa.conf" (configGenerator cfg.settings);
54 robotConfig = fqdn: domain: pkgs.writeText "${fqdn}-robot.conf" (configGenerator domain.settings);
55
56 transport = pkgs.writeText "transport.sympa" (
57 lib.concatStringsSep "\n" (
58 lib.flip map fqdns (domain: ''
59 ${domain} error:User unknown in recipient table
60 sympa@${domain} sympa:sympa@${domain}
61 listmaster@${domain} sympa:listmaster@${domain}
62 bounce@${domain} sympabounce:sympa@${domain}
63 abuse-feedback-report@${domain} sympabounce:sympa@${domain}
64 '')
65 )
66 );
67
68 virtual = pkgs.writeText "virtual.sympa" (
69 lib.concatStringsSep "\n" (
70 lib.flip map fqdns (domain: ''
71 sympa-request@${domain} postmaster@localhost
72 sympa-owner@${domain} postmaster@localhost
73 '')
74 )
75 );
76
77 listAliases = pkgs.writeText "list_aliases.tt2" ''
78 #--- [% list.name %]@[% list.domain %]: list transport map created at [% date %]
79 [% list.name %]@[% list.domain %] sympa:[% list.name %]@[% list.domain %]
80 [% list.name %]-request@[% list.domain %] sympa:[% list.name %]-request@[% list.domain %]
81 [% list.name %]-editor@[% list.domain %] sympa:[% list.name %]-editor@[% list.domain %]
82 #[% list.name %]-subscribe@[% list.domain %] sympa:[% list.name %]-subscribe@[%list.domain %]
83 [% list.name %]-unsubscribe@[% list.domain %] sympa:[% list.name %]-unsubscribe@[% list.domain %]
84 [% list.name %][% return_path_suffix %]@[% list.domain %] sympabounce:[% list.name %]@[% list.domain %]
85 '';
86
87 enabledFiles = lib.filterAttrs (n: v: v.enable) cfg.settingsFile;
88in
89{
90
91 ###### interface
92 options.services.sympa = with lib.types; {
93
94 enable = lib.mkEnableOption "Sympa mailing list manager";
95
96 lang = lib.mkOption {
97 type = str;
98 default = "en_US";
99 example = "cs";
100 description = ''
101 Default Sympa language.
102 See <https://github.com/sympa-community/sympa/tree/sympa-6.2/po/sympa>
103 for available options.
104 '';
105 };
106
107 listMasters = lib.mkOption {
108 type = listOf str;
109 example = [ "postmaster@sympa.example.org" ];
110 description = ''
111 The list of the email addresses of the listmasters
112 (users authorized to perform global server commands).
113 '';
114 };
115
116 mainDomain = lib.mkOption {
117 type = nullOr str;
118 default = null;
119 example = "lists.example.org";
120 description = ''
121 Main domain to be used in {file}`sympa.conf`.
122 If `null`, one of the {option}`services.sympa.domains` is chosen for you.
123 '';
124 };
125
126 domains = lib.mkOption {
127 type = attrsOf (
128 submodule (
129 { name, config, ... }:
130 {
131 options = {
132 webHost = lib.mkOption {
133 type = nullOr str;
134 default = null;
135 example = "archive.example.org";
136 description = ''
137 Domain part of the web interface URL (no web interface for this domain if `null`).
138 DNS record of type A (or AAAA or CNAME) has to exist with this value.
139 '';
140 };
141 webLocation = lib.mkOption {
142 type = str;
143 default = "/";
144 example = "/sympa";
145 description = "URL path part of the web interface.";
146 };
147 settings = lib.mkOption {
148 type = attrsOf (oneOf [
149 str
150 int
151 bool
152 ]);
153 default = { };
154 example = {
155 default_max_list_members = 3;
156 };
157 description = ''
158 The {file}`robot.conf` configuration file as key value set.
159 See <https://sympa-community.github.io/gpldoc/man/sympa.conf.5.html>
160 for list of configuration parameters.
161 '';
162 };
163 };
164
165 config.settings = lib.mkIf (cfg.web.enable && config.webHost != null) {
166 wwsympa_url = lib.mkDefault "https://${config.webHost}${lib.removeSuffix "/" config.webLocation}";
167 };
168 }
169 )
170 );
171
172 description = ''
173 Email domains handled by this instance. There have
174 to be MX records for keys of this attribute set.
175 '';
176 example = lib.literalExpression ''
177 {
178 "lists.example.org" = {
179 webHost = "lists.example.org";
180 webLocation = "/";
181 };
182 "sympa.example.com" = {
183 webHost = "example.com";
184 webLocation = "/sympa";
185 };
186 }
187 '';
188 };
189
190 database = {
191 type = lib.mkOption {
192 type = enum [
193 "SQLite"
194 "PostgreSQL"
195 "MySQL"
196 ];
197 default = "SQLite";
198 example = "MySQL";
199 description = "Database engine to use.";
200 };
201
202 host = lib.mkOption {
203 type = nullOr str;
204 default = null;
205 description = ''
206 Database host address.
207
208 For MySQL, use `localhost` to connect using Unix domain socket.
209
210 For PostgreSQL, use path to directory (e.g. {file}`/run/postgresql`)
211 to connect using Unix domain socket located in this directory.
212
213 Use `null` to fall back on Sympa default, or when using
214 {option}`services.sympa.database.createLocally`.
215 '';
216 };
217
218 port = lib.mkOption {
219 type = nullOr port;
220 default = null;
221 description = "Database port. Use `null` for default port.";
222 };
223
224 name = lib.mkOption {
225 type = str;
226 default = if cfg.database.type == "SQLite" then "${dataDir}/sympa.sqlite" else "sympa";
227 defaultText = lib.literalExpression ''if database.type == "SQLite" then "${dataDir}/sympa.sqlite" else "sympa"'';
228 description = ''
229 Database name. When using SQLite this must be an absolute
230 path to the database file.
231 '';
232 };
233
234 user = lib.mkOption {
235 type = nullOr str;
236 default = user;
237 description = "Database user. The system user name is used as a default.";
238 };
239
240 passwordFile = lib.mkOption {
241 type = nullOr path;
242 default = null;
243 example = "/run/keys/sympa-dbpassword";
244 description = ''
245 A file containing the password for {option}`services.sympa.database.name`.
246 '';
247 };
248
249 createLocally = lib.mkOption {
250 type = bool;
251 default = true;
252 description = "Whether to create a local database automatically.";
253 };
254 };
255
256 web = {
257 enable = lib.mkOption {
258 type = bool;
259 default = true;
260 description = "Whether to enable Sympa web interface.";
261 };
262
263 server = lib.mkOption {
264 type = enum [
265 "nginx"
266 "none"
267 ];
268 default = "nginx";
269 description = ''
270 The webserver used for the Sympa web interface. Set it to `none` if you want to configure it yourself.
271 Further nginx configuration can be done by adapting
272 {option}`services.nginx.virtualHosts.«name»`.
273 '';
274 };
275
276 https = lib.mkOption {
277 type = bool;
278 default = true;
279 description = ''
280 Whether to use HTTPS. When nginx integration is enabled, this option forces SSL and enables ACME.
281 Please note that Sympa web interface always uses https links even when this option is disabled.
282 '';
283 };
284
285 fcgiProcs = lib.mkOption {
286 type = ints.positive;
287 default = 2;
288 description = "Number of FastCGI processes to fork.";
289 };
290 };
291
292 mta = {
293 type = lib.mkOption {
294 type = enum [
295 "postfix"
296 "none"
297 ];
298 default = "postfix";
299 description = ''
300 Mail transfer agent (MTA) integration. Use `none` if you want to configure it yourself.
301
302 The `postfix` integration sets up local Postfix instance that will pass incoming
303 messages from configured domains to Sympa. You still need to configure at least outgoing message
304 handling using e.g. {option}`services.postfix.relayHost`.
305 '';
306 };
307 };
308
309 settings = lib.mkOption {
310 type = attrsOf (oneOf [
311 str
312 int
313 bool
314 ]);
315 default = { };
316 example = lib.literalExpression ''
317 {
318 default_home = "lists";
319 viewlogs_page_size = 50;
320 }
321 '';
322 description = ''
323 The {file}`sympa.conf` configuration file as key value set.
324 See <https://sympa-community.github.io/gpldoc/man/sympa.conf.5.html>
325 for list of configuration parameters.
326 '';
327 };
328
329 settingsFile = lib.mkOption {
330 type = attrsOf (
331 submodule (
332 { name, config, ... }:
333 {
334 options = {
335 enable = lib.mkOption {
336 type = bool;
337 default = true;
338 description = "Whether this file should be generated. This option allows specific files to be disabled.";
339 };
340 text = lib.mkOption {
341 default = null;
342 type = nullOr lines;
343 description = "Text of the file.";
344 };
345 source = lib.mkOption {
346 type = path;
347 description = "Path of the source file.";
348 };
349 };
350
351 config.source = lib.mkIf (config.text != null) (
352 lib.mkDefault (pkgs.writeText "sympa-${baseNameOf name}" config.text)
353 );
354 }
355 )
356 );
357 default = { };
358 example = lib.literalExpression ''
359 {
360 "list_data/lists.example.org/help" = {
361 text = "subject This list provides help to users";
362 };
363 }
364 '';
365 description = "Set of files to be linked in {file}`${dataDir}`.";
366 };
367 };
368
369 ###### implementation
370
371 config = lib.mkIf cfg.enable {
372
373 services.sympa.settings = (
374 lib.mapAttrs (_: v: lib.mkDefault v) {
375 domain = if cfg.mainDomain != null then cfg.mainDomain else lib.head fqdns;
376 listmaster = lib.concatStringsSep "," cfg.listMasters;
377 lang = cfg.lang;
378
379 home = "${dataDir}/list_data";
380 arc_path = "${dataDir}/arc";
381 bounce_path = "${dataDir}/bounce";
382
383 sendmail = "${pkgs.system-sendmail}/bin/sendmail";
384
385 db_type = cfg.database.type;
386 db_name = cfg.database.name;
387 db_user = cfg.database.name;
388 }
389 // (lib.optionalAttrs (cfg.database.host != null) {
390 db_host = cfg.database.host;
391 })
392 // (lib.optionalAttrs mysqlLocal {
393 db_host = "localhost"; # use unix domain socket
394 })
395 // (lib.optionalAttrs pgsqlLocal {
396 db_host = "/run/postgresql"; # use unix domain socket
397 })
398 // (lib.optionalAttrs (cfg.database.port != null) {
399 db_port = cfg.database.port;
400 })
401 // (lib.optionalAttrs (cfg.mta.type == "postfix") {
402 sendmail_aliases = "${dataDir}/sympa_transport";
403 aliases_program = lib.getExe' config.services.postfix.package "postmap";
404 aliases_db_type = "hash";
405 })
406 // (lib.optionalAttrs cfg.web.enable {
407 static_content_path = "${dataDir}/static_content";
408 css_path = "${dataDir}/static_content/css";
409 pictures_path = "${dataDir}/static_content/pictures";
410 mhonarc = "${pkgs.perlPackages.MHonArc}/bin/mhonarc";
411 })
412 );
413
414 services.sympa.settingsFile = {
415 "virtual.sympa" = lib.mkDefault { source = virtual; };
416 "transport.sympa" = lib.mkDefault { source = transport; };
417 "etc/list_aliases.tt2" = lib.mkDefault { source = listAliases; };
418 }
419 // (lib.flip lib.mapAttrs' cfg.domains (
420 fqdn: domain:
421 lib.nameValuePair "etc/${fqdn}/robot.conf" (lib.mkDefault { source = robotConfig fqdn domain; })
422 ));
423
424 environment = {
425 systemPackages = [ pkg ];
426 };
427
428 users.users.${user} = {
429 description = "Sympa mailing list manager user";
430 group = group;
431 home = dataDir;
432 createHome = false;
433 isSystemUser = true;
434 };
435
436 users.groups.${group} = { };
437
438 assertions = [
439 {
440 assertion =
441 cfg.database.createLocally -> cfg.database.user == user && cfg.database.name == cfg.database.user;
442 message = "services.sympa.database.user must be set to ${user} if services.sympa.database.createLocally is set to true";
443 }
444 {
445 assertion = cfg.database.createLocally -> cfg.database.passwordFile == null;
446 message = "a password cannot be specified if services.sympa.database.createLocally is set to true";
447 }
448 ];
449
450 systemd.tmpfiles.rules = [
451 "d ${dataDir} 0711 ${user} ${group} - -"
452 "d ${dataDir}/etc 0700 ${user} ${group} - -"
453 "d ${dataDir}/spool 0700 ${user} ${group} - -"
454 "d ${dataDir}/list_data 0700 ${user} ${group} - -"
455 "d ${dataDir}/arc 0700 ${user} ${group} - -"
456 "d ${dataDir}/bounce 0700 ${user} ${group} - -"
457 "f ${dataDir}/sympa_transport 0600 ${user} ${group} - -"
458
459 # force-copy static_content so it's up to date with package
460 # set permissions for wwsympa which needs write access (...)
461 "R ${dataDir}/static_content - - - - -"
462 "C ${dataDir}/static_content 0711 ${user} ${group} - ${pkg}/var/lib/sympa/static_content"
463 "e ${dataDir}/static_content/* 0711 ${user} ${group} - -"
464
465 "d /run/sympa 0755 ${user} ${group} - -"
466 ]
467 ++ (lib.flip lib.concatMap fqdns (fqdn: [
468 "d ${dataDir}/etc/${fqdn} 0700 ${user} ${group} - -"
469 "d ${dataDir}/list_data/${fqdn} 0700 ${user} ${group} - -"
470 ]))
471 #++ (lib.flip lib.mapAttrsToList enabledFiles (k: v:
472 # "L+ ${dataDir}/${k} - - - - ${v.source}"
473 #))
474 ++ (lib.concatLists (
475 lib.flip lib.mapAttrsToList enabledFiles (
476 k: v: [
477 # sympa doesn't handle symlinks well (e.g. fails to create locks)
478 # force-copy instead
479 "R ${dataDir}/${k} - - - - -"
480 "C ${dataDir}/${k} 0700 ${user} ${group} - ${v.source}"
481 ]
482 )
483 ));
484
485 systemd.services.sympa = {
486 description = "Sympa mailing list manager";
487
488 wantedBy = [ "multi-user.target" ];
489 after = [ "network-online.target" ];
490 wants = sympaSubServices ++ [ "network-online.target" ];
491 before = sympaSubServices;
492 serviceConfig = sympaServiceConfig "sympa_msg";
493
494 preStart = ''
495 umask 0077
496
497 cp -f ${mainConfig} ${dataDir}/etc/sympa.conf
498 ${lib.optionalString (cfg.database.passwordFile != null) ''
499 chmod u+w ${dataDir}/etc/sympa.conf
500 echo -n "db_passwd " >> ${dataDir}/etc/sympa.conf
501 cat ${cfg.database.passwordFile} >> ${dataDir}/etc/sympa.conf
502 ''}
503
504 ${lib.optionalString (cfg.mta.type == "postfix") ''
505 ${lib.getExe' config.services.postfix.package "postmap"} hash:${dataDir}/virtual.sympa
506 ${lib.getExe' config.services.postfix.package "postmap"} hash:${dataDir}/transport.sympa
507 ''}
508 ${pkg}/bin/sympa_newaliases.pl
509 ${pkg}/bin/sympa.pl --health_check
510 '';
511 };
512 systemd.services.sympa-archive = {
513 description = "Sympa mailing list manager (archiving)";
514 bindsTo = [ "sympa.service" ];
515 serviceConfig = sympaServiceConfig "archived";
516 };
517 systemd.services.sympa-bounce = {
518 description = "Sympa mailing list manager (bounce processing)";
519 bindsTo = [ "sympa.service" ];
520 serviceConfig = sympaServiceConfig "bounced";
521 };
522 systemd.services.sympa-bulk = {
523 description = "Sympa mailing list manager (message distribution)";
524 bindsTo = [ "sympa.service" ];
525 serviceConfig = sympaServiceConfig "bulk";
526 };
527 systemd.services.sympa-task = {
528 description = "Sympa mailing list manager (task management)";
529 bindsTo = [ "sympa.service" ];
530 serviceConfig = sympaServiceConfig "task_manager";
531 };
532
533 systemd.services.wwsympa = lib.mkIf usingNginx {
534 wantedBy = [ "multi-user.target" ];
535 after = [ "sympa.service" ];
536 serviceConfig = {
537 Type = "forking";
538 PIDFile = "/run/sympa/wwsympa.pid";
539 Restart = "always";
540 ExecStart = ''
541 ${pkgs.spawn_fcgi}/bin/spawn-fcgi \
542 -u ${user} \
543 -g ${group} \
544 -U nginx \
545 -M 0600 \
546 -F ${toString cfg.web.fcgiProcs} \
547 -P /run/sympa/wwsympa.pid \
548 -s /run/sympa/wwsympa.socket \
549 -- ${pkg}/lib/sympa/cgi/wwsympa.fcgi
550 '';
551
552 }
553 // commonServiceConfig;
554 };
555
556 services.nginx.enable = lib.mkIf usingNginx true;
557 services.nginx.virtualHosts = lib.mkIf usingNginx (
558 let
559 vHosts = lib.unique (lib.remove null (lib.mapAttrsToList (_k: v: v.webHost) cfg.domains));
560 hostLocations =
561 host: map (v: v.webLocation) (lib.filter (v: v.webHost == host) (lib.attrValues cfg.domains));
562 httpsOpts = lib.optionalAttrs cfg.web.https {
563 forceSSL = lib.mkDefault true;
564 enableACME = lib.mkDefault true;
565 };
566 in
567 lib.genAttrs vHosts (
568 host:
569 {
570 locations =
571 lib.genAttrs (hostLocations host) (loc: {
572 extraConfig = ''
573 include ${config.services.nginx.package}/conf/fastcgi_params;
574
575 fastcgi_pass unix:/run/sympa/wwsympa.socket;
576 '';
577 })
578 // {
579 "/static-sympa/".alias = "${dataDir}/static_content/";
580 };
581 }
582 // httpsOpts
583 )
584 );
585
586 services.postfix = lib.mkIf (cfg.mta.type == "postfix") {
587 enable = true;
588 settings = {
589 main = {
590 recipient_delimiter = "+";
591 virtual_alias_maps = [ "hash:${dataDir}/virtual.sympa" ];
592 virtual_mailbox_maps = [
593 "hash:${dataDir}/transport.sympa"
594 "hash:${dataDir}/sympa_transport"
595 "hash:${dataDir}/virtual.sympa"
596 ];
597 virtual_mailbox_domains = [ "hash:${dataDir}/transport.sympa" ];
598 transport_maps = [
599 "hash:${dataDir}/transport.sympa"
600 "hash:${dataDir}/sympa_transport"
601 ];
602 };
603 master = {
604 "sympa" = {
605 type = "unix";
606 privileged = true;
607 chroot = false;
608 command = "pipe";
609 args = [
610 "flags=hqRu"
611 "user=${user}"
612 "argv=${pkg}/libexec/queue"
613 "\${nexthop}"
614 ];
615 };
616 "sympabounce" = {
617 type = "unix";
618 privileged = true;
619 chroot = false;
620 command = "pipe";
621 args = [
622 "flags=hqRu"
623 "user=${user}"
624 "argv=${pkg}/libexec/bouncequeue"
625 "\${nexthop}"
626 ];
627 };
628 };
629 };
630 };
631
632 services.mysql = lib.optionalAttrs mysqlLocal {
633 enable = true;
634 package = lib.mkDefault pkgs.mariadb;
635 ensureDatabases = [ cfg.database.name ];
636 ensureUsers = [
637 {
638 name = cfg.database.user;
639 ensurePermissions = {
640 "${cfg.database.name}.*" = "ALL PRIVILEGES";
641 };
642 }
643 ];
644 };
645
646 services.postgresql = lib.optionalAttrs pgsqlLocal {
647 enable = true;
648 ensureDatabases = [ cfg.database.name ];
649 ensureUsers = [
650 {
651 name = cfg.database.user;
652 ensureDBOwnership = true;
653 }
654 ];
655 };
656
657 };
658
659 meta.maintainers = with lib.maintainers; [
660 sorki
661 ];
662}