Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions cmd/elemta/commands/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,9 @@ func startServer() {
// Map delivery config
smtpConfig.Delivery = cfg.Delivery

// Map DKIM outbound signing config
smtpConfig.DKIM = cfg.DKIM

// Map resources config (for Valkey integration)
smtpConfig.Resources = cfg.Resources

Expand Down
34 changes: 33 additions & 1 deletion config/elemta.toml.example
Original file line number Diff line number Diff line change
Expand Up @@ -95,5 +95,37 @@ listen_addr = "0.0.0.0:8080"
metrics_path = "/metrics"
health_path = "/health"

# Use remote SMTP delivery for outbound mail. DKIM signing is intentionally not
# applied when mode is "lmtp" because that path is local delivery to Dovecot.
[delivery]
mode = "smtp"

# Outbound DKIM signing configuration.
# Signs mail on the remote SMTP delivery path (not local LMTP delivery) to
# improve deliverability to Gmail/Yahoo/Outlook. Publish the matching public
# key at <selector>._domainkey.<domain> in DNS.
#
# Private keys must NOT be readable or writable by group or other:
# chmod 600 /etc/elemta/dkim/example.com.key
[dkim]
enabled = true
# Canonicalization defaults to relaxed/relaxed (recommended). Override with
# "simple" only if you have a specific reason.
header_canonicalization = "relaxed"
body_canonicalization = "relaxed"

# One block per signing domain. The signing domain is chosen by matching the
# envelope-from (or, when empty, the From header) domain against these entries.
[[dkim.domains]]
domain = "example.com"
selector = "mail"
private_key_path = "/etc/elemta/dkim/example.com.key"
# Optional per-domain override of the signed header set. When omitted, a
# sensible default is used (From, To, Cc, Subject, Date, Message-ID,
# MIME-Version, Content-Type, Reply-To, In-Reply-To, References). Custom lists
# must contain valid RFC 5322 field names and include From; Elemta rejects the
# configuration at startup otherwise.
# headers_to_sign = ["From", "To", "Subject", "Date", "Message-ID"]

[server]
# session_timeout = "5m" # Session idle timeout (default: 5m)
# session_timeout = "5m" # Session idle timeout (default: 5m)
9 changes: 9 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,14 @@ file = "/var/log/elemta/elemta.log"
- `[tls].key_file`
- optional `[tls.letsencrypt]`

### DKIM (outbound signing)

- `[dkim].enabled`
- `[dkim].header_canonicalization` / `[dkim].body_canonicalization` (default `relaxed`)
- `[[dkim.domains]]` blocks with `domain`, `selector`, `private_key_path`, optional `headers_to_sign`

See [DKIM Signing](dkim-signing.md) for key generation, DNS setup, and details.

---

## Compatibility notes
Expand All @@ -160,6 +168,7 @@ file = "/var/log/elemta/elemta.log"
## Related docs

- [Installation](installation.md)
- [DKIM Signing](dkim-signing.md)
- [SMTP Server](smtp_server.md)
- [Queue Management](queue_management.md)
- [Queue Backend Runbook](queue-backend-runbook.md)
128 changes: 128 additions & 0 deletions docs/dkim-signing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# Outbound DKIM Signing

Elemta can DKIM-sign outbound mail so that receiving providers
(Gmail, Yahoo, Outlook, etc.) can verify the message originated from a domain
you control. Signing measurably improves deliverability and is effectively a
prerequisite for bulk sending to the large mailbox providers.

This document covers the outbound **signing** path. Inbound DKIM/DMARC/ARC
**verification** is handled separately by the plugin system.

## How it works

- Signing happens on the **remote SMTP delivery path** (delivery to a remote
MX), inside `SMTPDeliveryHandler`. Local LMTP delivery (e.g. to Dovecot) is
intentionally **not** signed, because a locally delivered copy never crosses
an administrative boundary where DKIM matters.
- A message is signed **once**, before recipients are grouped by domain, so the
exact same signed bytes are delivered to every recipient MX.
- Existing `DKIM-Signature` headers are preserved, but they never suppress
Elemta's configured signature. Each queue attempt reloads the original stored
content and adds one fresh Elemta signature, so retries do not accumulate
signatures from earlier attempts.
- The signing domain is selected by matching the **envelope-from** domain
against the configured domains. When the envelope-from is empty (for example,
a bounce/DSN with a null return path), the **From header** domain is used as a
fallback. If no key is configured for the resolved domain, the message is
delivered **unsigned** and a debug line is logged.
- If signing fails for any reason, the message is delivered **unsigned** (with a
warning log) rather than being failed outright.

The signer reuses [`github.com/emersion/go-msgauth/dkim`](https://pkg.go.dev/github.com/emersion/go-msgauth/dkim),
the same library referenced by the verification code, so a message signed by
elemta verifies with the identical implementation.

## Generating keys

RSA (2048-bit is the widely interoperable choice):

```sh
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \
-out /etc/elemta/dkim/example.com.key
chmod 600 /etc/elemta/dkim/example.com.key
```

Ed25519 (smaller signatures; publish an additional RSA selector for receivers
that do not yet support Ed25519):

```sh
openssl genpkey -algorithm ed25519 -out /etc/elemta/dkim/example.com.ed25519.key
chmod 600 /etc/elemta/dkim/example.com.ed25519.key
```

Both PKCS#1 and PKCS#8 encoded keys are accepted.

### Key file permissions

Elemta refuses to start if a private key is readable or writable by group or
other (any of the `0077` permission bits set), matching the repo's file-security
posture. Keep keys at mode `0600` (or `0400`), owned by the elemta user.

## Publishing the public key in DNS

Extract the public key and publish it as a TXT record at
`<selector>._domainkey.<domain>`:

```sh
openssl rsa -in /etc/elemta/dkim/example.com.key -pubout 2>/dev/null \
| grep -v '^-----' | tr -d '\n'
```

Then create a DNS TXT record, e.g. for selector `mail` on `example.com`:

```
mail._domainkey.example.com. IN TXT "v=DKIM1; k=rsa; p=<base64-public-key>"
```

For Ed25519 use `k=ed25519` and the base64 of the raw 32-byte public key.

## Configuration

Select remote SMTP delivery and add a top-level `[dkim]` section. DKIM signing
does not run in local `lmtp` mode.

```toml
[delivery]
mode = "smtp"

[dkim]
enabled = true
header_canonicalization = "relaxed" # default: relaxed
body_canonicalization = "relaxed" # default: relaxed

[[dkim.domains]]
domain = "example.com"
selector = "mail"
private_key_path = "/etc/elemta/dkim/example.com.key"

[[dkim.domains]]
domain = "example.net"
selector = "s2026"
private_key_path = "/etc/elemta/dkim/example.net.key"
# Optional per-domain override of the signed headers.
headers_to_sign = ["From", "To", "Subject", "Date", "Message-ID"]
```

### Fields

| Field | Scope | Description |
|-------|-------|-------------|
| `enabled` | `[dkim]` | Master switch. When `false` (or the section is absent), no outbound signing occurs. |
| `header_canonicalization` | `[dkim]` | `relaxed` (default) or `simple`. |
| `body_canonicalization` | `[dkim]` | `relaxed` (default) or `simple`. |
| `domain` | `[[dkim.domains]]` | The signing domain (the `d=` tag). |
| `selector` | `[[dkim.domains]]` | The DKIM selector (the `s=` tag). |
| `private_key_path` | `[[dkim.domains]]` | Path to the PEM RSA or Ed25519 private key. |
| `headers_to_sign` | `[[dkim.domains]]` | Optional. Overrides the default signed header set for this domain. Entries must be valid RFC 5322 field names and must include `From` (case-insensitive), or startup fails. |

Default signed headers (when `headers_to_sign` is omitted): `From`, `To`, `Cc`,
`Subject`, `Date`, `Message-ID`, `MIME-Version`, `Content-Type`, `Reply-To`,
`In-Reply-To`, `References`.

## Verifying it works

Send a message to a Gmail account and check **Show original**; the DKIM result
should read `PASS`. Command-line tooling such as `opendkim-testkey`,
`dig TXT <selector>._domainkey.<domain>`, or an auto-responder like
`check-auth@verifier.port25.com` can confirm both the DNS record and live
signature validity.
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ go 1.25.12
require (
github.com/BurntSushi/toml v1.6.0
github.com/bradfitz/gomemcache v0.0.0-20260422231931-4d751bb6e37c
github.com/emersion/go-msgauth v0.7.0
github.com/go-ldap/ldap/v3 v3.4.14
github.com/go-sql-driver/mysql v1.10.0
github.com/google/uuid v1.6.0
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/emersion/go-msgauth v0.7.0 h1:vj2hMn6KhFtW41kshIBTXvp6KgYSqpA/ZN9Pv4g1INc=
github.com/emersion/go-msgauth v0.7.0/go.mod h1:mmS9I6HkSovrNgq0HNXTeu8l3sRAAuQ9RMvbM4KU7Ck=
github.com/go-asn1-ber/asn1-ber v1.5.8 h1:H9AZkK22UOmfX8J84ubyaZxKJZ3FMHVwn8swoMML7iQ=
github.com/go-asn1-ber/asn1-ber v1.5.8/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-ldap/ldap/v3 v3.4.14 h1:D6PYdEgsaVzsXyr6w/yDC06Ria4uUhWm+Rb+er8lfAs=
Expand Down
4 changes: 4 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"strings"
"time"

"github.com/busybox42/elemta/internal/dkim"
"github.com/busybox42/elemta/internal/runtimepaths"
"github.com/busybox42/elemta/internal/smtp"

Expand Down Expand Up @@ -217,6 +218,9 @@ type Config struct {
// Delivery configuration
Delivery *smtp.DeliveryConfig `toml:"delivery"`

// DKIM outbound signing configuration
DKIM *dkim.Config `toml:"dkim"`

// Metrics configuration
Metrics *smtp.MetricsConfig `toml:"metrics"`

Expand Down
77 changes: 77 additions & 0 deletions internal/config/dkim_config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package config

import (
"testing"

toml "github.com/pelletier/go-toml/v2"
)

// TestDKIMConfigParsing verifies the [dkim] TOML section unmarshals into the
// Config struct, including per-domain selectors, key paths and header overrides.
func TestDKIMConfigParsing(t *testing.T) {
const cfgTOML = `
hostname = "mail.example.com"

[dkim]
enabled = true
header_canonicalization = "relaxed"
body_canonicalization = "relaxed"

[[dkim.domains]]
domain = "example.com"
selector = "mail"
private_key_path = "/etc/elemta/dkim/example.com.key"

[[dkim.domains]]
domain = "example.net"
selector = "s2026"
private_key_path = "/etc/elemta/dkim/example.net.key"
headers_to_sign = ["From", "Subject", "Date"]
`

var cfg Config
if err := toml.Unmarshal([]byte(cfgTOML), &cfg); err != nil {
t.Fatalf("unmarshal: %v", err)
}

if cfg.DKIM == nil {
t.Fatal("DKIM section not parsed")
}
if !cfg.DKIM.Enabled {
t.Fatal("expected DKIM enabled")
}
if cfg.DKIM.HeaderCanonicalization != "relaxed" || cfg.DKIM.BodyCanonicalization != "relaxed" {
t.Fatalf("canonicalization = %q/%q", cfg.DKIM.HeaderCanonicalization, cfg.DKIM.BodyCanonicalization)
}
if len(cfg.DKIM.Domains) != 2 {
t.Fatalf("expected 2 domains, got %d", len(cfg.DKIM.Domains))
}

d0 := cfg.DKIM.Domains[0]
if d0.Domain != "example.com" || d0.Selector != "mail" || d0.PrivateKeyPath != "/etc/elemta/dkim/example.com.key" {
t.Fatalf("domain[0] parsed incorrectly: %+v", d0)
}
if len(d0.HeadersToSign) != 0 {
t.Fatalf("domain[0] should have no header override, got %v", d0.HeadersToSign)
}

d1 := cfg.DKIM.Domains[1]
if d1.Domain != "example.net" || d1.Selector != "s2026" {
t.Fatalf("domain[1] parsed incorrectly: %+v", d1)
}
if want := []string{"From", "Subject", "Date"}; len(d1.HeadersToSign) != len(want) {
t.Fatalf("domain[1] header override = %v, want %v", d1.HeadersToSign, want)
}
}

// TestDKIMConfigAbsentIsNil confirms that omitting the section leaves DKIM nil
// (signing disabled) rather than producing a partially populated struct.
func TestDKIMConfigAbsentIsNil(t *testing.T) {
var cfg Config
if err := toml.Unmarshal([]byte(`hostname = "mail.example.com"`), &cfg); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if cfg.DKIM != nil {
t.Fatalf("expected nil DKIM when section absent, got %+v", cfg.DKIM)
}
}
Loading
Loading