From 6b891f4788a302f9e847e808b32780066b268864 Mon Sep 17 00:00:00 2001 From: Raito Bezarius Date: Tue, 30 Aug 2022 13:15:41 +0200 Subject: nixos/listmonk: init module --- .../from_md/release-notes/rl-2211.section.xml | 7 + nixos/doc/manual/release-notes/rl-2211.section.md | 2 + nixos/modules/module-list.nix | 1 + nixos/modules/services/mail/listmonk.nix | 222 +++++++++++++++++++++ nixos/tests/all-tests.nix | 1 + nixos/tests/listmonk.nix | 69 +++++++ pkgs/servers/mail/listmonk/default.nix | 3 +- 7 files changed, 304 insertions(+), 1 deletion(-) create mode 100644 nixos/modules/services/mail/listmonk.nix create mode 100644 nixos/tests/listmonk.nix diff --git a/nixos/doc/manual/from_md/release-notes/rl-2211.section.xml b/nixos/doc/manual/from_md/release-notes/rl-2211.section.xml index cd2ad54db20fe..8dc889393549e 100644 --- a/nixos/doc/manual/from_md/release-notes/rl-2211.section.xml +++ b/nixos/doc/manual/from_md/release-notes/rl-2211.section.xml @@ -266,6 +266,13 @@ services.writefreely. + + + Listmonk, a + self-hosted newsletter manager. Enable using + services.listmonk. + +
diff --git a/nixos/doc/manual/release-notes/rl-2211.section.md b/nixos/doc/manual/release-notes/rl-2211.section.md index 119cd12492aaf..eede0e7afc722 100644 --- a/nixos/doc/manual/release-notes/rl-2211.section.md +++ b/nixos/doc/manual/release-notes/rl-2211.section.md @@ -94,6 +94,8 @@ Available as [services.patroni](options.html#opt-services.patroni.enable). - [WriteFreely](https://writefreely.org), a simple blogging platform with ActivityPub support. Available as [services.writefreely](options.html#opt-services.writefreely.enable). +- [Listmonk](https://listmonk.app), a self-hosted newsletter manager. Enable using [services.listmonk](options.html#opt-services.listmonk.enable). + ## Backward Incompatibilities {#sec-release-22.11-incompatibilities} diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index 308bd8cb717b0..77cf1b96f4f8e 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -503,6 +503,7 @@ ./services/mail/dovecot.nix ./services/mail/dspam.nix ./services/mail/exim.nix + ./services/mail/listmonk.nix ./services/mail/maddy.nix ./services/mail/mail.nix ./services/mail/mailcatcher.nix diff --git a/nixos/modules/services/mail/listmonk.nix b/nixos/modules/services/mail/listmonk.nix new file mode 100644 index 0000000000000..7c298606a5478 --- /dev/null +++ b/nixos/modules/services/mail/listmonk.nix @@ -0,0 +1,222 @@ +{ config, lib, pkgs, ... }: + +with lib; +let + cfg = config.services.listmonk; + tomlFormat = pkgs.formats.toml { }; + cfgFile = tomlFormat.generate "listmonk.toml" cfg.settings; + # Escaping is done according to https://www.postgresql.org/docs/current/sql-syntax-lexical.html#SQL-SYNTAX-CONSTANTS + setDatabaseOption = key: value: + "UPDATE settings SET value = '${ + lib.replaceChars [ "'" ] [ "''" ] (builtins.toJSON value) + }' WHERE key = '${key}';"; + updateDatabaseConfigSQL = pkgs.writeText "update-database-config.sql" + (concatStringsSep "\n" (mapAttrsToList setDatabaseOption + (if (cfg.database.settings != null) then + cfg.database.settings + else + { }))); + updateDatabaseConfigScript = + pkgs.writeShellScriptBin "update-database-config.sh" '' + ${if cfg.database.mutableSettings then '' + if [ ! -f /var/lib/listmonk/.db_settings_initialized ]; then + ${pkgs.postgresql}/bin/psql -d listmonk -f ${updateDatabaseConfigSQL} ; + touch /var/lib/listmonk/.db_settings_initialized + fi + '' else + "${pkgs.postgresql}/bin/psql -d listmonk -f ${updateDatabaseConfigSQL}"} + ''; + + databaseSettingsOpts = with types; { + freeformType = + oneOf [ (listOf str) (listOf (attrsOf anything)) str int bool ]; + + options = { + "app.notify_emails" = mkOption { + type = listOf str; + default = [ ]; + description = lib.mdDoc "Administrator emails for system notifications"; + }; + + "privacy.exportable" = mkOption { + type = listOf str; + default = [ "profile" "subscriptions" "campaign_views" "link_clicks" ]; + description = lib.mdDoc + "List of fields which can be exported through an automatic export request"; + }; + + "privacy.domain_blocklist" = mkOption { + type = listOf str; + default = [ ]; + description = lib.mdDoc + "E-mail addresses with these domains are disallowed from subscribing."; + }; + + smtp = mkOption { + type = listOf (submodule { + freeformType = with types; attrsOf (oneOf [ str int bool ]); + + options = { + enabled = mkEnableOption (lib.mdDoc "this SMTP server for listmonk"); + host = mkOption { + type = types.str; + description = lib.mdDoc "Hostname for the SMTP server"; + }; + port = mkOption { + type = types.port; + description = lib.mdDoc "Port for the SMTP server"; + }; + max_conns = mkOption { + type = types.int; + description = lib.mdDoc + "Maximum number of simultaneous connections, defaults to 1"; + default = 1; + }; + tls_type = mkOption { + type = types.enum [ "none" "STARTTLS" "TLS" ]; + description = + lib.mdDoc "Type of TLS authentication with the SMTP server"; + }; + }; + }); + + description = lib.mdDoc "List of outgoing SMTP servers"; + }; + + # TODO: refine this type based on the smtp one. + "bounce.mailboxes" = mkOption { + type = listOf + (submodule { freeformType = with types; oneOf [ str int bool ]; }); + default = [ ]; + description = lib.mdDoc "List of bounce mailboxes"; + }; + + messengers = mkOption { + type = listOf str; + default = [ ]; + description = lib.mdDoc + "List of messengers, see: for options."; + }; + }; + }; +in { + ###### interface + options = { + services.listmonk = { + enable = mkEnableOption + (lib.mdDoc "Listmonk, this module assumes a reverse proxy to be set"); + database = { + createLocally = mkOption { + type = types.bool; + default = false; + description = lib.mdDoc + "Create the PostgreSQL database and database user locally."; + }; + + settings = mkOption { + default = null; + type = with types; nullOr (submodule databaseSettingsOpts); + description = lib.mdDoc + "Dynamic settings in the PostgreSQL database, set by a SQL script, see for details."; + }; + mutableSettings = mkOption { + type = types.bool; + default = true; + description = lib.mdDoc '' + Database settings will be reset to the value set in this module if this is not enabled. + Enable this if you want to persist changes you have done in the application. + ''; + }; + }; + package = mkPackageOption pkgs "listmonk" {}; + settings = mkOption { + type = types.submodule { freeformType = tomlFormat.type; }; + description = lib.mdDoc '' + Static settings set in the config.toml, see for details. + You can set secrets using the secretFile option with environment variables following . + ''; + }; + secretFile = mkOption { + type = types.nullOr types.str; + default = null; + description = lib.mdDoc + "A file containing secrets as environment variables. See for details on supported values."; + }; + }; + }; + + ###### implementation + config = mkIf cfg.enable { + # Default parameters from https://github.com/knadh/listmonk/blob/master/config.toml.sample + services.listmonk.settings."app".address = mkDefault "localhost:9000"; + services.listmonk.settings."db" = mkMerge [ + ({ + max_open = mkDefault 25; + max_idle = mkDefault 25; + max_lifetime = mkDefault "300s"; + }) + (mkIf cfg.database.createLocally { + host = mkDefault "/run/postgresql"; + port = mkDefault 5432; + user = mkDefault "listmonk"; + database = mkDefault "listmonk"; + }) + ]; + + services.postgresql = mkIf cfg.database.createLocally { + enable = true; + + ensureUsers = [{ + name = "listmonk"; + ensurePermissions = { "DATABASE listmonk" = "ALL PRIVILEGES"; }; + }]; + + ensureDatabases = [ "listmonk" ]; + }; + + systemd.services.listmonk = { + description = "Listmonk - newsletter and mailing list manager"; + after = [ "network.target" ] + ++ optional cfg.database.createLocally "postgresql.service"; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "exec"; + EnvironmentFile = mkIf (cfg.secretFile != null) [ cfg.secretFile ]; + ExecStartPre = [ + # StateDirectory cannot be used when DynamicUser = true is set this way. + # Indeed, it will try to create all the folders and realize one of them already exist. + # Therefore, we have to create it ourselves. + ''${pkgs.coreutils}/bin/mkdir -p "''${STATE_DIRECTORY}/listmonk/uploads"'' + "${cfg.package}/bin/listmonk --config ${cfgFile} --idempotent --install --upgrade --yes" + "${updateDatabaseConfigScript}/bin/update-database-config.sh" + ]; + ExecStart = "${cfg.package}/bin/listmonk --config ${cfgFile}"; + + Restart = "on-failure"; + + StateDirectory = [ "listmonk" ]; + + User = "listmonk"; + Group = "listmonk"; + DynamicUser = true; + NoNewPrivileges = true; + CapabilityBoundingSet = ""; + SystemCallArchitecture = "native"; + SystemCallFilter = [ "@system-service" "~@privileged" "@resources" ]; + ProtectDevices = true; + ProtectControlGroups = true; + ProtectKernelTunables = true; + ProtectHome = true; + DeviceAllow = false; + RestrictNamespaces = true; + RestrictRealtime = true; + UMask = "0027"; + MemoryDenyWriteExecute = true; + LockPersonality = true; + RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_UNIX" ]; + ProtectKernelModules = true; + PrivateUsers = true; + }; + }; + }; +} diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index 1cf310cb3321f..ad4313c6ad15d 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -289,6 +289,7 @@ in { lightdm = handleTest ./lightdm.nix {}; lighttpd = handleTest ./lighttpd.nix {}; limesurvey = handleTest ./limesurvey.nix {}; + listmonk = handleTest ./listmonk.nix {}; litestream = handleTest ./litestream.nix {}; locate = handleTest ./locate.nix {}; login = handleTest ./login.nix {}; diff --git a/nixos/tests/listmonk.nix b/nixos/tests/listmonk.nix new file mode 100644 index 0000000000000..91003653c09ed --- /dev/null +++ b/nixos/tests/listmonk.nix @@ -0,0 +1,69 @@ +import ./make-test-python.nix ({ lib, ... }: { + name = "listmonk"; + meta.maintainers = with lib.maintainers; [ raitobezarius ]; + + nodes.machine = { pkgs, ... }: { + services.mailhog.enable = true; + services.listmonk = { + enable = true; + settings = { + admin_username = "listmonk"; + admin_password = "hunter2"; + }; + database = { + createLocally = true; + # https://github.com/knadh/listmonk/blob/174a48f252a146d7e69dab42724e3329dbe25ebe/internal/messenger/email/email.go#L18-L27 + settings.smtp = [ { + enabled = true; + host = "localhost"; + port = 1025; + tls_type = "none"; + }]; + }; + }; + }; + + testScript = '' + import json + + start_all() + + basic_auth = "listmonk:hunter2" + def generate_listmonk_request(type, url, data=None): + if data is None: data = {} + json_data = json.dumps(data) + return f'curl -u "{basic_auth}" -X {type} "http://localhost:9000/api/{url}" -H "Content-Type: application/json; charset=utf-8" --data-raw \'{json_data}\''' + + machine.wait_for_unit("mailhog.service") + machine.wait_for_unit("postgresql.service") + machine.wait_for_unit("listmonk.service") + machine.wait_for_open_port(1025) + machine.wait_for_open_port(8025) + machine.wait_for_open_port(9000) + machine.succeed("[[ -f /var/lib/listmonk/.db_settings_initialized ]]") + + # Test transactional endpoint + # subscriber_id=1 is guaranteed to exist at install-time + # template_id=2 is guaranteed to exist at install-time and is a transactional template (1 is a campaign template). + machine.succeed( + generate_listmonk_request('POST', 'tx', data={'subscriber_id': 1, 'template_id': 2}) + ) + assert 'Welcome John Doe' in machine.succeed( + "curl --fail http://localhost:8025/api/v2/messages" + ) + + # Test campaign endpoint + # Based on https://github.com/knadh/listmonk/blob/174a48f252a146d7e69dab42724e3329dbe25ebe/cmd/campaigns.go#L549 as docs do not exist. + campaign_data = json.loads(machine.succeed( + generate_listmonk_request('POST', 'campaigns/1/test', data={'template_id': 1, 'subscribers': ['john@example.com'], 'name': 'Test', 'subject': 'NixOS is great', 'lists': [1], 'messenger': 'email'}) + )) + + assert campaign_data['data'] # This is a boolean asserting if the test was successful or not: https://github.com/knadh/listmonk/blob/174a48f252a146d7e69dab42724e3329dbe25ebe/cmd/campaigns.go#L626 + + messages = json.loads(machine.succeed( + "curl --fail http://localhost:8025/api/v2/messages" + )) + + assert messages['total'] == 2 + ''; +}) diff --git a/pkgs/servers/mail/listmonk/default.nix b/pkgs/servers/mail/listmonk/default.nix index 487ef068c22f5..97ec1924c2a83 100644 --- a/pkgs/servers/mail/listmonk/default.nix +++ b/pkgs/servers/mail/listmonk/default.nix @@ -1,4 +1,4 @@ -{ lib, buildGoModule, fetchFromGitHub, callPackage, stuffbin }: +{ lib, buildGoModule, fetchFromGitHub, callPackage, stuffbin, nixosTests }: buildGoModule rec { pname = "listmonk"; @@ -43,6 +43,7 @@ buildGoModule rec { passthru = { frontend = callPackage ./frontend.nix { inherit meta; }; + tests = { inherit (nixosTests) listmonk; }; }; meta = with lib; { -- cgit 1.4.1