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 cd2ad54db20f..8dc889393549 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 119cd12492aa..eede0e7afc72 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 308bd8cb717b..77cf1b96f4f8 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 000000000000..7c298606a547
--- /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 1cf310cb3321..ad4313c6ad15 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 000000000000..91003653c09e
--- /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 487ef068c22f..97ec1924c2a8 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; {