Skip to content

feat(dkim): add outbound DKIM signing#65

Merged
EvalAlan merged 3 commits into
mainfrom
feat/dkim-signing
Jul 24, 2026
Merged

feat(dkim): add outbound DKIM signing#65
EvalAlan merged 3 commits into
mainfrom
feat/dkim-signing

Conversation

@EvalAlan

Copy link
Copy Markdown
Owner

Summary

Adds outbound DKIM signing. Previously the plugin system only verified DKIM/DMARC/ARC on inbound mail; there was no signing path, which hurts deliverability to Gmail/Yahoo/Outlook. This signs outbound mail on the remote SMTP delivery path.

Design decisions

  • Library reuse, no new signing scheme. The signer wraps github.com/emersion/go-msgauth/dkim, the same library referenced by the existing verification code. It supports both signing and verifying with RSA and Ed25519, so the unit tests verify exactly what we sign. No hand-rolled DKIM crypto was added.
  • Integration point: SMTPDeliveryHandler (remote delivery), not LMTP. Signing is applied in DeliverMessageWithMetadata once, before recipients are grouped by domain, so identical signed bytes go to every MX. Local LMTP delivery (to Dovecot) is intentionally not signed — a locally delivered copy never crosses an administrative boundary where DKIM matters. The signer is attached via SetDKIMSigner, wired up in initQueueSystem.
  • No double-signing on retry. Signer.Sign is idempotent: if the content already carries a DKIM-Signature for the selected domain (folded continuation lines handled), it returns the content unchanged. A delivery retry therefore never adds a second signature. This is covered by tests at both the signer and delivery-handler level.
  • Signing-domain selection. The domain is taken from the envelope-from address, falling back to the From header domain when the envelope-from is empty (e.g. bounces/DSNs with a null return path). If no key is configured for the resolved domain, the message is delivered unsigned with a debug log — never failed.
  • Fail-open on signing errors. A signing error logs a warning and delivers the unsigned message rather than failing it outright.
  • Security posture. Private keys are permission-checked on load; any key readable/writable by group or other (0077 bits) is rejected, matching the repo's existing file-security checks. Keys are validated up-front in NewSigner so a misconfiguration fails fast at start-up.
  • Config. New top-level [dkim] section: enabled, header_canonicalization/body_canonicalization (default relaxed/relaxed), and per-domain [[dkim.domains]] blocks with domain, selector, private_key_path, and optional headers_to_sign (defaults to From, To, Cc, Subject, Date, Message-ID, MIME-Version, Content-Type, Reply-To, In-Reply-To, References).

Files changed

  • internal/dkim/signer.go — new package: config types, key loading (PKCS#1/PKCS#8/SEC1, RSA + Ed25519), permission validation, signing, domain resolution helpers.
  • internal/queue/delivery_handler.gomessageSigner seam, SetDKIMSigner, signContent, sign call in DeliverMessageWithMetadata.
  • internal/config/config.go, internal/smtp/config.go[dkim] config field.
  • cmd/elemta/commands/server.go, internal/smtp/server.go — build the signer and attach it to the remote SMTP handler.
  • Tests: internal/dkim/signer_test.go, internal/config/dkim_config_test.go, internal/queue/delivery_dkim_test.go.
  • Docs: docs/dkim-signing.md, section in docs/configuration.md, [dkim] example in config/elemta.toml.example.

Testing

  • go build ./..., go vet ./... — clean.
  • gofmt -l . — empty.
  • go test ./... — full suite passes.

Tests include RSA and Ed25519 sign→verify round-trips through the verification path, config parsing, insecure-key-permission rejection, and retry-does-not-double-sign at both layers.

Comment thread internal/dkim/signer.go Fixed
@EvalAlan
EvalAlan force-pushed the feat/dkim-signing branch from b8c50c4 to 31f054d Compare July 13, 2026 06:03
Sign outbound mail on the remote SMTP delivery path to improve
deliverability to Gmail/Yahoo/Outlook. Until now the plugin system only
verified DKIM/DMARC/ARC on inbound mail; there was no signing path.

- New internal/dkim package wrapping github.com/emersion/go-msgauth/dkim
  (the same library the verification code references) for both RSA and
  Ed25519 keys.
- [dkim] TOML config section: enabled, relaxed/relaxed canonicalization by
  default, and per-domain selector / private_key_path / optional
  headers_to_sign (sensible default header set).
- Private-key permission validation on load: keys readable/writable by
  group or other (0077 bits) are rejected, matching the repo's
  file-security posture.
- Signing wired into SMTPDeliveryHandler, applied once before recipients
  are grouped by domain. Signing is idempotent (skips a message already
  signed for the domain) so delivery retries never double-sign. Local
  LMTP delivery is not signed.
- Signing domain selected from the envelope-from domain, falling back to
  the From header domain (bounces); unconfigured domains are skipped with
  a debug log and delivered unsigned.
- Tests: sign+verify round-trip for RSA and Ed25519 via the verification
  path, config parsing, key-permission rejection, and retry-no-double-sign
  (both at the signer and delivery-handler level).
- Docs: docs/dkim-signing.md plus a [dkim] example in
  config/elemta.toml.example.
Reject invalid signed-header configuration at startup, validate DKIM before queue resources are started, and prevent configured-domain signing failures from downgrading delivery to unsigned mail.
@EvalAlan
EvalAlan force-pushed the feat/dkim-signing branch from 5477a89 to fbd0edf Compare July 24, 2026 02:19
@EvalAlan
EvalAlan merged commit fc584bc into main Jul 24, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants