Feature Specification: Resource smtp

Created: 2026-10-07

Status: Implemented

Input: Reconstructed from the code at 093e68b6f (core/resources/smtp/default.nix, core/resources/smtp/maddy-provider.nix, core/resources/smtp/external-relay.nix, core/setups/wiring/smtp-routing.nix, core/setups/wiring/mail-domains.nix, core/setups/wiring/resource-routing.nix, core/setups/hosts-common.nix, core/checks/integrations/smtp/), pull requests #1184 and #1205, and issue #527.

User Scenarios & Testing (mandatory)

User Story 1 - Give an application a mail submission account (Priority: P1)

An application must send mail, for example a password reset. It states a request with a sender address. The policy gives the application one submission account on the relay. The consumer node gets the host, the port, the username and a password file. The resource sets the want of the request on the node. A static client module of the component (smtp.nix) reads contracts.smtp.results.<app>.default, which holds the sender and the contract result, to configure the mailer. The request holds only data.

Why this priority: password resets and notifications do not work without outbound mail.

Independent Test: contracts-smtp-maddy (a NixOS test of the maddy provider). nix-unit-resources-plain-requests checks that each smtp request of a component is plain data. nix-unit-resources-smtp checks the process and the apply of the resource: one want per application, the result on the consumer node, the password file of each application, and the secrets of the relay node.

Acceptance Scenarios:

  1. Given two enabled requests from two applications, When process runs, Then it gives one request per application, keyed by the application name.
  2. Given a request from application <app>, When the deployment is evaluated, Then the provider side gets contracts.smtp.want.<app>.default.request.sender.
  3. Given a routed request, When the consumer side is evaluated, Then contracts.smtp.results.<app>.default holds the sender of the request, host = <domain>, port (default 587) and username = <sender>.
  4. Given the maddy provider, When the relay node is evaluated, Then the password of <app> is at <prefix>/secret/<instanceName>/<app>Password in the KV store.
  5. Given the maddy provider and two applications on one node, When the node is evaluated, Then contracts.smtp.results.<app>.default.passwordFile of each application is the rendered password file of its own account, <tmpDir>/secret/<instanceName>/<app>Password.

User Story 2 - Pick the relay per group (Priority: P1)

A group either runs its own maddy relay or submits to an external relay. The group states this choice in the smtp attribute of its group data (core/setups/groups/*.nix), and smtp-routing.nix reads that attribute. The routing knows no group name, host name or secret path. A group on the external relay provides smtp with no relay node.

Why this priority: the choice decides if the group runs a mail node at all.

Independent Test: nix-unit-setups-smtp-routing.

Acceptance Scenarios:

  1. Given a group with smtp.provider = "external-relay", When the routing is evaluated, Then the provider is external-relay on the stated smtp.smarthost host, port, account and password file.
  2. Given a group on external-relay that does not state host, username or passwordFile, When the routing is evaluated, Then the evaluation stops with an error that names the missing attribute.
  3. Given a group with no smtp.provider, When the routing is evaluated, Then the provider is maddy.
  4. Given a group on maddy with smtp.smarthost, When the routing is evaluated, Then the relay forwards through the stated host, port and account, and the password comes from vars/secret/smarthost-upstream/<passwordSeed>.
  5. Given a group on maddy with no smtp.smarthost, When the routing is evaluated, Then the relay delivers directly to the recipient MX.
  6. Given a group with a smtp.provider other than maddy or external-relay, When the routing is evaluated, Then the evaluation stops with an error.

User Story 3 - Sign outbound mail and accept DMARC reports (Priority: P2)

A receiver checks DKIM and DMARC. The maddy relay signs each message with one key for the organizational domain. When the operator has an email, the relay accepts the DMARC aggregate reports and forwards them to that address.

Why this priority: the smarthost rewrites the envelope sender. Only a DKIM signature aligns with the From: header.

Independent Test: integrations-smtp-dkim and integrations-smtp-reports.

Acceptance Scenarios:

  1. Given dkim.enable = true, When the relay sends a message, Then the message carries a DKIM signature with the selector fediversity.
  2. Given the generated DKIM key is missing, When maddy starts, Then the start fails, so maddy does not sign with a key nobody published.
  3. Given reports.enable = true, When the relay is evaluated, Then the firewall opens port 25 and mail to dmarc@smtp.<domain> goes to reports.forwardTo.
  4. Given reports.enable = false, When the relay is evaluated, Then the firewall opens only port.

Edge Cases

  • One application states two different sender addresses: an assertion fails at eval.
  • Two applications state the same sender: an assertion fails at eval, because the sender is the account username.
  • dkim.enable = true and dkim.domain is empty: an assertion fails at eval.
  • reports.address is not under a local mail domain, or reports.forwardTo is under one: an assertion fails at eval.
  • The upstream maddy configuration changes and a replaceStrings rewrite does not match: an assertion fails at eval.
  • enable = false: apply gives nothing.

Requirements (mandatory)

Functional Requirements

  • FR-001: The policy process MUST give one request per application.
  • FR-002: The policy MUST give no output when enable is false.
  • FR-003: The policy MUST give a mainConfig that sets runDaemon = true on the relay node.
  • FR-004: The policy MUST give the vars instances of the routed provider as mainVarsInstances.
  • FR-005: The policy MUST declare domain in sslDomains.
  • FR-006: The maddy provider MUST give each application its own account and password.
  • FR-007: The maddy provider MUST use starttls and auth plain to the smarthost, and MUST rewrite the envelope sender to the smarthost account.
  • FR-008: Without a smarthost, the maddy provider MUST deliver with min_tls_level encrypted, DANE and MTA-STS.
  • FR-009: The maddy provider MUST read its TLS certificate from contracts.ssl.results.fediversity.<domain>.
  • FR-010: The external-relay provider MUST NOT run a daemon.
  • FR-011: mail-domains.nix MUST be the single source of the sender addresses, the DKIM selector and the report address, for the relay and for the zone records.
  • FR-012: The request MUST contain only data. The consumer side MUST give the request to contracts.smtp of the node, not as an argument to a function of the request.
  • FR-013: smtp-routing.nix MUST take the provider, the smarthost and the account from the smtp attribute of the group data. It MUST NOT name a group, a host or a secret path.

Key Entities

  • smtp request: enable and sender. It holds only data.
  • smtp client module: the smtp.nix of a component. It reads contracts.smtp.results.<app>.default and fediversity.smtp.accounts.<app>.
  • smtp policy: enable, domain, port, instanceName and routing.
  • maddy provider: the options under fediversity.smtp, among them smarthost, dkim, reports, the read-only accounts and generateInstances.
  • external-relay provider: fediversity.smtp-relay.host, port, username and passwordFile.
  • Vars instances: <instanceName> (one password per application), <instanceName>-smarthost (SMTP_SMARTHOST_PASSWORD) and <instanceName>-dkim.

Success Criteria (mandatory)

Measurable Outcomes

  • SC-001: contracts-smtp-maddy, integrations-smtp-dkim and integrations-smtp-reports pass.
  • SC-002: nix-unit-setups-smtp-routing, nix-unit-setups-lldap-mail, nix-unit-resources-plain-requests and nix-unit-resources-smtp pass.
  • SC-003: A password reset mail from lldap or Authelia reaches an external mailbox with a passing DKIM signature.

Assumptions

  • The wiring and the setup flow are as in 005. The request and policy model is as in 001.
  • The relay is the smtp component. Its certificate comes from the ssl resource.
  • The consumers include ldap and oidc. Their senders are noreply@ldap.<domain> and noreply@auth.<domain>.
  • An operator provisions the smarthost accounts on mail.protagio.nl and seeds their passwords by hand.