diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix
index 878b77969af1..eaa7038a28db 100644
--- a/nixos/modules/module-list.nix
+++ b/nixos/modules/module-list.nix
@@ -401,6 +401,7 @@
./services/mail/rspamd.nix
./services/mail/rss2email.nix
./services/mail/roundcube.nix
+ ./services/mail/sympa.nix
./services/mail/nullmailer.nix
./services/misc/airsonic.nix
./services/misc/apache-kafka.nix
diff --git a/nixos/modules/services/mail/sympa.nix b/nixos/modules/services/mail/sympa.nix
new file mode 100644
index 000000000000..c3ae9d4255b0
--- /dev/null
+++ b/nixos/modules/services/mail/sympa.nix
@@ -0,0 +1,596 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+
+let
+ cfg = config.services.sympa;
+ dataDir = "/var/lib/sympa";
+ user = "sympa";
+ group = "sympa";
+ pkg = pkgs.sympa;
+ fqdns = attrNames cfg.domains;
+ usingNginx = cfg.web.enable && cfg.web.server == "nginx";
+ mysqlLocal = cfg.database.createLocally && cfg.database.type == "MySQL";
+ pgsqlLocal = cfg.database.createLocally && cfg.database.type == "PostgreSQL";
+
+ sympaSubServices = [
+ "sympa-archive.service"
+ "sympa-bounce.service"
+ "sympa-bulk.service"
+ "sympa-task.service"
+ ];
+
+ # common for all services including wwsympa
+ commonServiceConfig = {
+ StateDirectory = "sympa";
+ ProtectHome = true;
+ ProtectSystem = "full";
+ ProtectKernelTunables = true;
+ ProtectKernelModules = true;
+ ProtectControlGroups = true;
+ };
+
+ # wwsympa has its own service config
+ sympaServiceConfig = srv: {
+ Type = "simple";
+ Restart = "always";
+ ExecStart = "${pkg}/bin/${srv}.pl --foreground";
+ PIDFile = "/run/sympa/${srv}.pid";
+ User = user;
+ Group = group;
+
+ # avoid duplicating log messageges in journal
+ StandardError = "null";
+ } // commonServiceConfig;
+
+ configVal = value:
+ if isBool value then
+ if value then "on" else "off"
+ else toString value;
+ configGenerator = c: concatStrings (flip mapAttrsToList c (key: val: "${key}\t${configVal val}\n"));
+
+ mainConfig = pkgs.writeText "sympa.conf" (configGenerator cfg.settings);
+ robotConfig = fqdn: domain: pkgs.writeText "${fqdn}-robot.conf" (configGenerator domain.settings);
+
+ transport = pkgs.writeText "transport.sympa" (concatStringsSep "\n" (flip map fqdns (domain: ''
+ ${domain} error:User unknown in recipient table
+ sympa@${domain} sympa:sympa@${domain}
+ listmaster@${domain} sympa:listmaster@${domain}
+ bounce@${domain} sympabounce:sympa@${domain}
+ abuse-feedback-report@${domain} sympabounce:sympa@${domain}
+ '')));
+
+ virtual = pkgs.writeText "virtual.sympa" (concatStringsSep "\n" (flip map fqdns (domain: ''
+ sympa-request@${domain} postmaster@localhost
+ sympa-owner@${domain} postmaster@localhost
+ '')));
+
+ listAliases = pkgs.writeText "list_aliases.tt2" ''
+ #--- [% list.name %]@[% list.domain %]: list transport map created at [% date %]
+ [% list.name %]@[% list.domain %] sympa:[% list.name %]@[% list.domain %]
+ [% list.name %]-request@[% list.domain %] sympa:[% list.name %]-request@[% list.domain %]
+ [% list.name %]-editor@[% list.domain %] sympa:[% list.name %]-editor@[% list.domain %]
+ #[% list.name %]-subscribe@[% list.domain %] sympa:[% list.name %]-subscribe@[%list.domain %]
+ [% list.name %]-unsubscribe@[% list.domain %] sympa:[% list.name %]-unsubscribe@[% list.domain %]
+ [% list.name %][% return_path_suffix %]@[% list.domain %] sympabounce:[% list.name %]@[% list.domain %]
+ '';
+
+ enabledFiles = filterAttrs (n: v: v.enable) cfg.settingsFile;
+in
+{
+
+ ###### interface
+ options.services.sympa = with types; {
+
+ enable = mkEnableOption "Sympa mailing list manager";
+
+ lang = mkOption {
+ type = str;
+ default = "en_US";
+ example = "cs";
+ description = ''
+ Default Sympa language.
+ See
+ for available options.
+ '';
+ };
+
+ listMasters = mkOption {
+ type = listOf str;
+ example = [ "postmaster@sympa.example.org" ];
+ description = ''
+ The list of the email addresses of the listmasters
+ (users authorized to perform global server commands).
+ '';
+ };
+
+ mainDomain = mkOption {
+ type = nullOr str;
+ default = null;
+ example = "lists.example.org";
+ description = ''
+ Main domain to be used in sympa.conf.
+ If null, one of the is chosen for you.
+ '';
+ };
+
+ domains = mkOption {
+ type = attrsOf (submodule ({ name, config, ... }: {
+ options = {
+ webHost = mkOption {
+ type = nullOr str;
+ default = null;
+ example = "archive.example.org";
+ description = ''
+ Domain part of the web interface URL (no web interface for this domain if null).
+ DNS record of type A (or AAAA or CNAME) has to exist with this value.
+ '';
+ };
+ webLocation = mkOption {
+ type = str;
+ default = "/";
+ example = "/sympa";
+ description = "URL path part of the web interface.";
+ };
+ settings = mkOption {
+ type = attrsOf (oneOf [ str int bool ]);
+ default = {};
+ example = {
+ default_max_list_members = 3;
+ };
+ description = ''
+ The robot.conf configuration file as key value set.
+ See
+ for list of configuration parameters.
+ '';
+ };
+ };
+
+ config.settings = mkIf (cfg.web.enable && config.webHost != null) {
+ wwsympa_url = mkDefault "https://${config.webHost}${strings.removeSuffix "/" config.webLocation}";
+ };
+ }));
+
+ description = ''
+ Email domains handled by this instance. There have
+ to be MX records for keys of this attribute set.
+ '';
+ example = literalExample ''
+ {
+ "lists.example.org" = {
+ webHost = "lists.example.org";
+ webLocation = "/";
+ };
+ "sympa.example.com" = {
+ webHost = "example.com";
+ webLocation = "/sympa";
+ };
+ }
+ '';
+ };
+
+ database = {
+ type = mkOption {
+ type = enum [ "SQLite" "PostgreSQL" "MySQL" ];
+ default = "SQLite";
+ example = "MySQL";
+ description = "Database engine to use.";
+ };
+
+ host = mkOption {
+ type = nullOr str;
+ default = null;
+ description = ''
+ Database host address.
+
+ For MySQL, use localhost to connect using Unix domain socket.
+
+ For PostgreSQL, use path to directory (e.g. /run/postgresql)
+ to connect using Unix domain socket located in this directory.
+
+ Use null to fall back on Sympa default, or when using
+ .
+ '';
+ };
+
+ port = mkOption {
+ type = nullOr port;
+ default = null;
+ description = "Database port. Use null for default port.";
+ };
+
+ name = mkOption {
+ type = str;
+ default = if cfg.database.type == "SQLite" then "${dataDir}/sympa.sqlite" else "sympa";
+ defaultText = ''if database.type == "SQLite" then "${dataDir}/sympa.sqlite" else "sympa"'';
+ description = ''
+ Database name. When using SQLite this must be an absolute
+ path to the database file.
+ '';
+ };
+
+ user = mkOption {
+ type = nullOr str;
+ default = user;
+ description = "Database user. The system user name is used as a default.";
+ };
+
+ passwordFile = mkOption {
+ type = nullOr path;
+ default = null;
+ example = "/run/keys/sympa-dbpassword";
+ description = ''
+ A file containing the password for .
+ '';
+ };
+
+ createLocally = mkOption {
+ type = bool;
+ default = true;
+ description = "Whether to create a local database automatically.";
+ };
+ };
+
+ web = {
+ enable = mkOption {
+ type = bool;
+ default = true;
+ description = "Whether to enable Sympa web interface.";
+ };
+
+ server = mkOption {
+ type = enum [ "nginx" "none" ];
+ default = "nginx";
+ description = ''
+ The webserver used for the Sympa web interface. Set it to `none` if you want to configure it yourself.
+ Further nginx configuration can be done by adapting
+ .
+ '';
+ };
+
+ https = mkOption {
+ type = bool;
+ default = true;
+ description = ''
+ Whether to use HTTPS. When nginx integration is enabled, this option forces SSL and enables ACME.
+ Please note that Sympa web interface always uses https links even when this option is disabled.
+ '';
+ };
+
+ fcgiProcs = mkOption {
+ type = ints.positive;
+ default = 2;
+ description = "Number of FastCGI processes to fork.";
+ };
+ };
+
+ mta = {
+ type = mkOption {
+ type = enum [ "postfix" "none" ];
+ default = "postfix";
+ description = ''
+ Mail transfer agent (MTA) integration. Use none if you want to configure it yourself.
+
+ The postfix integration sets up local Postfix instance that will pass incoming
+ messages from configured domains to Sympa. You still need to configure at least outgoing message
+ handling using e.g. .
+ '';
+ };
+ };
+
+ settings = mkOption {
+ type = attrsOf (oneOf [ str int bool ]);
+ default = {};
+ example = literalExample ''
+ {
+ default_home = "lists";
+ viewlogs_page_size = 50;
+ }
+ '';
+ description = ''
+ The sympa.conf configuration file as key value set.
+ See
+ for list of configuration parameters.
+ '';
+ };
+
+ settingsFile = mkOption {
+ type = attrsOf (submodule ({ name, config, ... }: {
+ options = {
+ enable = mkOption {
+ type = bool;
+ default = true;
+ description = "Whether this file should be generated. This option allows specific files to be disabled.";
+ };
+ text = mkOption {
+ default = null;
+ type = nullOr lines;
+ description = "Text of the file.";
+ };
+ source = mkOption {
+ type = path;
+ description = "Path of the source file.";
+ };
+ };
+
+ config.source = mkIf (config.text != null) (mkDefault (pkgs.writeText "sympa-${baseNameOf name}" config.text));
+ }));
+ default = {};
+ example = literalExample ''
+ {
+ "list_data/lists.example.org/help" = {
+ text = "subject This list provides help to users";
+ };
+ }
+ '';
+ description = "Set of files to be linked in ${dataDir}.";
+ };
+ };
+
+ ###### implementation
+
+ config = mkIf cfg.enable {
+
+ services.sympa.settings = (mapAttrs (_: v: mkDefault v) {
+ domain = if cfg.mainDomain != null then cfg.mainDomain else head fqdns;
+ listmaster = concatStringsSep "," cfg.listMasters;
+ lang = cfg.lang;
+
+ home = "${dataDir}/list_data";
+ arc_path = "${dataDir}/arc";
+ bounce_path = "${dataDir}/bounce";
+
+ sendmail = "${pkgs.system-sendmail}/bin/sendmail";
+
+ db_type = cfg.database.type;
+ db_name = cfg.database.name;
+ }
+ // (optionalAttrs (cfg.database.host != null) {
+ db_host = cfg.database.host;
+ })
+ // (optionalAttrs mysqlLocal {
+ db_host = "localhost"; # use unix domain socket
+ })
+ // (optionalAttrs pgsqlLocal {
+ db_host = "/run/postgresql"; # use unix domain socket
+ })
+ // (optionalAttrs (cfg.database.port != null) {
+ db_port = cfg.database.port;
+ })
+ // (optionalAttrs (cfg.database.user != null) {
+ db_user = cfg.database.user;
+ })
+ // (optionalAttrs (cfg.mta.type == "postfix") {
+ sendmail_aliases = "${dataDir}/sympa_transport";
+ aliases_program = "${pkgs.postfix}/bin/postmap";
+ aliases_db_type = "hash";
+ })
+ // (optionalAttrs cfg.web.enable {
+ static_content_path = "${dataDir}/static_content";
+ css_path = "${dataDir}/static_content/css";
+ pictures_path = "${dataDir}/static_content/pictures";
+ mhonarc = "${pkgs.perlPackages.MHonArc}/bin/mhonarc";
+ }));
+
+ services.sympa.settingsFile = {
+ "virtual.sympa" = mkDefault { source = virtual; };
+ "transport.sympa" = mkDefault { source = transport; };
+ "etc/list_aliases.tt2" = mkDefault { source = listAliases; };
+ }
+ // (flip mapAttrs' cfg.domains (fqdn: domain:
+ nameValuePair "etc/${fqdn}/robot.conf" (mkDefault { source = robotConfig fqdn domain; })));
+
+ environment = {
+ systemPackages = [ pkg ];
+ };
+
+ users.users.${user} = {
+ description = "Sympa mailing list manager user";
+ group = group;
+ home = dataDir;
+ createHome = false;
+ isSystemUser = true;
+ };
+
+ users.groups.${group} = {};
+
+ assertions = [
+ { assertion = cfg.database.createLocally -> cfg.database.user == user;
+ message = "services.sympa.database.user must be set to ${user} if services.sympa.database.createLocally is set to true";
+ }
+ { assertion = cfg.database.createLocally -> cfg.database.passwordFile == null;
+ message = "a password cannot be specified if services.sympa.database.createLocally is set to true";
+ }
+ ];
+
+ systemd.tmpfiles.rules = [
+ "d ${dataDir} 0711 ${user} ${group} - -"
+ "d ${dataDir}/etc 0700 ${user} ${group} - -"
+ "d ${dataDir}/spool 0700 ${user} ${group} - -"
+ "d ${dataDir}/list_data 0700 ${user} ${group} - -"
+ "d ${dataDir}/arc 0700 ${user} ${group} - -"
+ "d ${dataDir}/bounce 0700 ${user} ${group} - -"
+ "f ${dataDir}/sympa_transport 0600 ${user} ${group} - -"
+
+ # force-copy static_content so it's up to date with package
+ # set permissions for wwsympa which needs write access (...)
+ "R ${dataDir}/static_content - - - - -"
+ "C ${dataDir}/static_content 0711 ${user} ${group} - ${pkg}/static_content"
+ "e ${dataDir}/static_content/* 0711 ${user} ${group} - -"
+
+ "d /run/sympa 0755 ${user} ${group} - -"
+ ]
+ ++ (flip concatMap fqdns (fqdn: [
+ "d ${dataDir}/etc/${fqdn} 0700 ${user} ${group} - -"
+ "d ${dataDir}/list_data/${fqdn} 0700 ${user} ${group} - -"
+ ]))
+ #++ (flip mapAttrsToList enabledFiles (k: v:
+ # "L+ ${dataDir}/${k} - - - - ${v.source}"
+ #))
+ ++ (concatLists (flip mapAttrsToList enabledFiles (k: v: [
+ # sympa doesn't handle symlinks well (e.g. fails to create locks)
+ # force-copy instead
+ "R ${dataDir}/${k} - - - - -"
+ "C ${dataDir}/${k} 0700 ${user} ${group} - ${v.source}"
+ ])));
+
+ systemd.services.sympa = {
+ description = "Sympa mailing list manager";
+
+ wantedBy = [ "multi-user.target" ];
+ after = [ "network-online.target" ];
+ wants = sympaSubServices;
+ before = sympaSubServices;
+ serviceConfig = sympaServiceConfig "sympa_msg";
+
+ preStart = ''
+ umask 0077
+
+ cp -f ${mainConfig} ${dataDir}/etc/sympa.conf
+ ${optionalString (cfg.database.passwordFile != null) ''
+ chmod u+w ${dataDir}/etc/sympa.conf
+ echo -n "db_passwd " >> ${dataDir}/etc/sympa.conf
+ cat ${cfg.database.passwordFile} >> ${dataDir}/etc/sympa.conf
+ ''}
+
+ ${optionalString (cfg.mta.type == "postfix") ''
+ ${pkgs.postfix}/bin/postmap hash:${dataDir}/virtual.sympa
+ ${pkgs.postfix}/bin/postmap hash:${dataDir}/transport.sympa
+ ''}
+ ${pkg}/bin/sympa_newaliases.pl
+ ${pkg}/bin/sympa.pl --health_check
+ '';
+ };
+ systemd.services.sympa-archive = {
+ description = "Sympa mailing list manager (archiving)";
+ bindsTo = [ "sympa.service" ];
+ serviceConfig = sympaServiceConfig "archived";
+ };
+ systemd.services.sympa-bounce = {
+ description = "Sympa mailing list manager (bounce processing)";
+ bindsTo = [ "sympa.service" ];
+ serviceConfig = sympaServiceConfig "bounced";
+ };
+ systemd.services.sympa-bulk = {
+ description = "Sympa mailing list manager (message distribution)";
+ bindsTo = [ "sympa.service" ];
+ serviceConfig = sympaServiceConfig "bulk";
+ };
+ systemd.services.sympa-task = {
+ description = "Sympa mailing list manager (task management)";
+ bindsTo = [ "sympa.service" ];
+ serviceConfig = sympaServiceConfig "task_manager";
+ };
+
+ systemd.services.wwsympa = mkIf usingNginx {
+ wantedBy = [ "multi-user.target" ];
+ after = [ "sympa.service" ];
+ serviceConfig = {
+ Type = "forking";
+ PIDFile = "/run/sympa/wwsympa.pid";
+ Restart = "always";
+ ExecStart = ''${pkgs.spawn_fcgi}/bin/spawn-fcgi \
+ -u ${user} \
+ -g ${group} \
+ -U nginx \
+ -M 0600 \
+ -F ${toString cfg.web.fcgiProcs} \
+ -P /run/sympa/wwsympa.pid \
+ -s /run/sympa/wwsympa.socket \
+ -- ${pkg}/bin/wwsympa.fcgi
+ '';
+
+ } // commonServiceConfig;
+ };
+
+ services.nginx.enable = mkIf usingNginx true;
+ services.nginx.virtualHosts = mkIf usingNginx (let
+ vHosts = unique (remove null (mapAttrsToList (_k: v: v.webHost) cfg.domains));
+ hostLocations = host: map (v: v.webLocation) (filter (v: v.webHost == host) (attrValues cfg.domains));
+ httpsOpts = optionalAttrs cfg.web.https { forceSSL = mkDefault true; enableACME = mkDefault true; };
+ in
+ genAttrs vHosts (host: {
+ locations = genAttrs (hostLocations host) (loc: {
+ extraConfig = ''
+ include ${config.services.nginx.package}/conf/fastcgi_params;
+
+ fastcgi_pass unix:/run/sympa/wwsympa.socket;
+ fastcgi_split_path_info ^(${loc})(.*)$;
+
+ fastcgi_param PATH_INFO $fastcgi_path_info;
+ fastcgi_param SCRIPT_FILENAME ${pkg}/bin/wwsympa.fcgi;
+ '';
+ }) // {
+ "/static-sympa/".alias = "${dataDir}/static_content/";
+ };
+ } // httpsOpts));
+
+ services.postfix = mkIf (cfg.mta.type == "postfix") {
+ enable = true;
+ recipientDelimiter = "+";
+ config = {
+ virtual_alias_maps = [ "hash:${dataDir}/virtual.sympa" ];
+ virtual_mailbox_maps = [
+ "hash:${dataDir}/transport.sympa"
+ "hash:${dataDir}/sympa_transport"
+ "hash:${dataDir}/virtual.sympa"
+ ];
+ virtual_mailbox_domains = [ "hash:${dataDir}/transport.sympa" ];
+ transport_maps = [
+ "hash:${dataDir}/transport.sympa"
+ "hash:${dataDir}/sympa_transport"
+ ];
+ };
+ masterConfig = {
+ "sympa" = {
+ type = "unix";
+ privileged = true;
+ chroot = false;
+ command = "pipe";
+ args = [
+ "flags=hqRu"
+ "user=${user}"
+ "argv=${pkg}/bin/queue"
+ "\${nexthop}"
+ ];
+ };
+ "sympabounce" = {
+ type = "unix";
+ privileged = true;
+ chroot = false;
+ command = "pipe";
+ args = [
+ "flags=hqRu"
+ "user=${user}"
+ "argv=${pkg}/bin/bouncequeue"
+ "\${nexthop}"
+ ];
+ };
+ };
+ };
+
+ services.mysql = optionalAttrs mysqlLocal {
+ enable = true;
+ package = mkDefault pkgs.mariadb;
+ ensureDatabases = [ cfg.database.name ];
+ ensureUsers = [
+ { name = cfg.database.user;
+ ensurePermissions = { "${cfg.database.name}.*" = "ALL PRIVILEGES"; };
+ }
+ ];
+ };
+
+ services.postgresql = optionalAttrs pgsqlLocal {
+ enable = true;
+ ensureDatabases = [ cfg.database.name ];
+ ensureUsers = [
+ { name = cfg.database.user;
+ ensurePermissions = { "DATABASE ${cfg.database.name}" = "ALL PRIVILEGES"; };
+ }
+ ];
+ };
+
+ };
+
+ meta.maintainers = with maintainers; [ mmilata sorki ];
+}
diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix
index e1c299b8413e..83366c271a21 100644
--- a/nixos/tests/all-tests.nix
+++ b/nixos/tests/all-tests.nix
@@ -269,6 +269,7 @@ in
strongswan-swanctl = handleTest ./strongswan-swanctl.nix {};
sudo = handleTest ./sudo.nix {};
switchTest = handleTest ./switch-test.nix {};
+ sympa = handleTest ./sympa.nix {};
syncthing-init = handleTest ./syncthing-init.nix {};
syncthing-relay = handleTest ./syncthing-relay.nix {};
systemd = handleTest ./systemd.nix {};
diff --git a/nixos/tests/sympa.nix b/nixos/tests/sympa.nix
new file mode 100644
index 000000000000..280691f7cb40
--- /dev/null
+++ b/nixos/tests/sympa.nix
@@ -0,0 +1,36 @@
+import ./make-test-python.nix ({ pkgs, lib, ... }: {
+ name = "sympa";
+ meta.maintainers = with lib.maintainers; [ mmilata ];
+
+ machine =
+ { ... }:
+ {
+ virtualisation.memorySize = 1024;
+
+ services.sympa = {
+ enable = true;
+ domains = {
+ "lists.example.org" = {
+ webHost = "localhost";
+ };
+ };
+ listMasters = [ "joe@example.org" ];
+ web.enable = true;
+ web.https = false;
+ database = {
+ type = "PostgreSQL";
+ createLocally = true;
+ };
+ };
+ };
+
+ testScript = ''
+ start_all()
+
+ machine.wait_for_unit("sympa.service")
+ machine.wait_for_unit("wwsympa.service")
+ assert "Mailing lists service" in machine.succeed(
+ "curl --insecure -L http://localhost/"
+ )
+ '';
+})