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
11 changes: 11 additions & 0 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,17 @@ username = "user2"
password = "password2"

# Optional DKIM configuration
# Sign every outgoing message using this domain and key.
# For strict From-domain selection, replace this table with one or more
# [[server.dkim]] entries (see docs/src/pages/dkim.md). Example:
# [[server.dkim]]
# domain = "example.com"
# selector = "default"
# private_key = "/etc/hedwig/dkim/example.com.key"
# [[server.dkim]]
# domain = "another.com"
# selector = "default"
# private_key = "/etc/hedwig/dkim/another.com.key"
[server.dkim]
domain = "example.com"
selector = "default"
Expand Down
161 changes: 161 additions & 0 deletions dev/verify-multi-domain.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
#!/usr/bin/env python3
"""Verify multi-domain DKIM against the Docker DNS/SMTP harness, for both queues.

Start dev DNS and leave 172.30.0.4 free (see verify-config-reload.py).
Run with an image-compatible binary:
uv run --with dkimpy --with pynacl dev/verify-multi-domain.py /path/to/hedwig
Keys and queues are temporary; DKIM verification uses local public keys, no DNS.
"""
import argparse
import base64
import os
import pathlib
import runpy
import smtplib
import subprocess
import tempfile
import time
import uuid

import dkim

helpers = runpy.run_path(str(pathlib.Path(__file__).with_name("verify-config-reload.py")))
run, wait_for = helpers["run"], helpers["wait_for"]


def verify(binary, backend):
suffix = uuid.uuid4().hex[:8]
server, sink = f"hedwig-domains-{suffix}", f"hedwig-domains-sink-{suffix}"
with tempfile.TemporaryDirectory(prefix="hedwig-domains-") as temporary:
scratch = pathlib.Path(temporary)
run("openssl", "genrsa", "-out", str(scratch / "rsa.key"), "2048")
run("openssl", "genpkey", "-algorithm", "ED25519", "-out", str(scratch / "ed.key"))
rsa_public = subprocess.check_output(["openssl", "pkey", "-in", str(scratch / "rsa.key"), "-pubout", "-outform", "DER"])
ed_public = subprocess.check_output(["openssl", "pkey", "-in", str(scratch / "ed.key"), "-pubout", "-outform", "DER"])[-32:]
keys = {
b"rsa._domainkey.first.test": b"v=DKIM1; k=rsa; p=" + base64.b64encode(rsa_public),
b"ed._domainkey.second.test": b"v=DKIM1; k=ed25519; p=" + base64.b64encode(ed_public),
b"rotated._domainkey.first.test": b"v=DKIM1; k=ed25519; p=" + base64.b64encode(ed_public),
}
def entry(domain, selector, key, kind="rsa", table="[[server.dkim]]"):
return f'{table}\ndomain = "{domain}"\nselector = "{selector}"\nprivate_key = "/scratch/{key}"\nkey_type = "{kind}"\n'
first = entry("first.test", "rsa", "rsa.key")
second = entry("second.test", "ed", "ed.key", "ed25519")
def config(entries):
return f'''[log]
level = "info"
format = "json"
[server]
workers = 1
max_retries = 1
outbound_local = true
[[server.listeners]]
addr = "0.0.0.0:2525"
{entries}
[storage]
storage_type = "{backend}"
base_path = "/scratch/spool"
'''
config_path = scratch / "config.toml"
config_path.write_text(config(first + second))
sink_code = helpers["SINK"].replace(' p = pathlib.Path("/scratch/messages")', ' if pathlib.Path("/scratch/hold").exists():\n pathlib.Path("/scratch/held").touch()\n while pathlib.Path("/scratch/hold").exists():\n await asyncio.sleep(0.05)\n p = pathlib.Path("/scratch/messages")')
(scratch / "sink.py").write_text(sink_code)
try:
run("docker", "run", "-d", "--name", sink, "--network", "smtp_test", "--ip", "172.30.0.4",
"-v", f"{scratch}:/scratch", "python:3.12-slim", "python", "/scratch/sink.py")
wait_for(lambda: "sink ready" in run("docker", "logs", sink), "SMTP sink")
run("docker", "run", "-d", "--name", server, "--network", "smtp_test", "--dns", "172.30.0.2",
"-p", "127.0.0.1::2525", "-v", f"{scratch}:/scratch", "-v", f"{binary}:/hedwig:ro",
"dev-smtp:latest", "/hedwig", "--config", "/scratch/config.toml")
port = int(run("docker", "port", server, "2525/tcp").strip().rsplit(":", 1)[1])
def connect():
client = smtplib.SMTP("127.0.0.1", port, timeout=5)
client.ehlo()
return client
client = wait_for(connect, "Hedwig startup")
def send(header, domain, selector):
count = len(list((scratch / "messages").glob("*")))
# The envelope intentionally differs from the visible From domain.
client.sendmail("bounce@envelope.test", ["recipient@throttle.test"],
f"{header}\r\nTo: recipient@throttle.test\r\nSubject: domains\r\n\r\nhello\r\n")
raw = wait_for(lambda: (scratch / "messages" / str(count)).read_bytes(), "signed delivery")
assert f"d={domain}".encode() in raw and f"s={selector}".encode() in raw, raw
assert dkim.verify(raw, dnsfunc=lambda name, **kw: keys.get(name.rstrip(b"."))), raw
send("From: Alice <alice@FIRST.TEST>", "first.test", "rsa")
send("From: Bob\r\n <bob@second.test>", "second.test", "ed")
for header in ["From: a@unknown.test", "From: a@sub.first.test", "Subject: missing From",
"From: a@first.test\r\nFrom: b@second.test", "From: a@first.test, b@first.test",
"From: invalid", "From: <a@first.test", "From: a@first.test>", "From: group: a@first.test;"]:
try:
client.sendmail("bounce@envelope.test", ["recipient@throttle.test"], f"{header}\r\n\r\nhello\r\n")
except smtplib.SMTPDataError as error:
assert error.smtp_code == 550 and error.smtp_error.startswith(b"5.7.1"), error
else:
raise AssertionError(f"accepted {header}")
# A rejected DATA transaction leaves the session usable.
send("From: a@first.test", "first.test", "rsa")
def reload(entries, failure=False):
before = len(run("docker", "logs", server))
config_path.write_text(config(entries))
run("docker", "kill", "--signal", "HUP", server)
expected = "configuration reload failed" if failure else "configuration reload completed"
wait_for(lambda: expected in run("docker", "logs", server)[before:].lower(), "reload")
for invalid in [first + entry("FIRST.TEST", "rsa", "rsa.key"),
first + entry("third.test", "rsa", "missing.key")]:
reload(invalid, failure=True)
send("From: a@second.test", "second.test", "ed")
reload(entry("first.test", "rotated", "ed.key", "ed25519") + second)
send("From: a@first.test", "first.test", "rotated")
reload(first)
try:
client.sendmail("bounce@envelope.test", ["recipient@throttle.test"], "From: a@second.test\r\n\r\nhello\r\n")
except smtplib.SMTPDataError as error:
assert error.smtp_code == 550
else:
raise AssertionError("removed domain accepted")
# Legacy table signs unrelated From domains exactly as before.
reload(entry("first.test", "rsa", "rsa.key", table="[server.dkim]"))
send("From: a@unknown.test", "first.test", "rsa")
# Hold the only worker on an earlier message while a second-domain
# message is queued, then remove that domain before it is attempted.
reload(first + second)
(scratch / "hold").touch()
client.sendmail("bounce@envelope.test", ["recipient@throttle.test"], "From: a@first.test\r\n\r\nblocker\r\n")
wait_for(lambda: (scratch / "held").exists(), "worker blocked at sink")
client.sendmail("bounce@envelope.test", ["recipient@throttle.test"], "From: a@second.test\r\n\r\npending\r\n")
reload(first)
before = len(run("docker", "logs", server))
(scratch / "hold").unlink()
wait_for(lambda: "Sending domain second.test is not configured" in run("docker", "logs", server)[before:], "queued domain removal defers and exhausts retries")
logs = run("docker", "logs", server)[before:]
assert '"status":"deferred"' in logs, logs
assert '"status":"bounced"' not in logs, logs
# With one allowed attempt the deferred message must eventually
# move to the bounce archive, on both queue implementations.
deadline = time.monotonic() + 110
while time.monotonic() < deadline:
if any(b"pending" in path.read_bytes() for path in (scratch / "spool" / "bounced").rglob("*") if path.is_file()):
break
time.sleep(0.5)
else:
raise AssertionError("removed-domain message did not bounce after retry exhaustion")
client.quit()
print(f"PASS {backend}: RSA/Ed25519 signatures verified, From routing, rejection, rotation, atomic reload, legacy compatibility, queued domain removal defers and exhausts retries")
except Exception:
subprocess.run(["docker", "logs", server], check=False)
raise
finally:
for name in (server, sink):
subprocess.run(["docker", "rm", "-f", name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
run("docker", "run", "--rm", "-v", f"{scratch}:/scratch", "dev-smtp:latest",
"chown", "-R", f"{os.getuid()}:{os.getgid()}", "/scratch")


if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("binary", type=pathlib.Path)
args = parser.parse_args()
binary = args.binary.resolve()
assert binary.is_file(), binary
for backend in ("log", "fs"):
verify(binary, backend)
78 changes: 76 additions & 2 deletions docs/src/pages/dkim.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ description: Configure DKIM signing and generate keys.

DKIM (DomainKeys Identified Mail) allows receiving mail servers to verify that emails were sent by an authorized sender.

## Configuration
## Sign all messages with one domain

```toml
[server.dkim]
Expand All @@ -17,8 +17,82 @@ selector = "default"
private_key = "/path/to/dkim/private.key"
```

With `[server.dkim]`, Hedwig signs every outgoing message using the configured
domain and key, regardless of the sender’s `From:` address. In this example,
all messages are signed as `yourdomain.com`.

If you omit DKIM configuration, Hedwig does not add a signature. Any signatures
already present on the message are kept.

## Multiple sending domains

Replace the single table with an array of tables to select a signer using the
message's **`From:` header**, independently of the SMTP `MAIL FROM` address:

```toml
[[server.dkim]]
domain = "example.com"
selector = "default"
private_key = "/etc/hedwig/dkim/example.com.key"

[[server.dkim]]
domain = "another.com"
selector = "default"
private_key = "/etc/hedwig/dkim/another.com.key"
key_type = "ed25519"
```

`alice@example.com` uses the first key; `bob@another.com` uses the second.
Publish the corresponding DKIM TXT record for each domain. Listeners, the SMTP
hostname, queue, workers, and outbound connections remain shared.

- Matching is case-insensitive and exact. Configure subdomains separately;
wildcard and parent-domain fallback are not supported.
- Use ASCII DNS names (punycode/A-label form for internationalized domains).
- Exactly one `From:` header with one mailbox is required. Missing, malformed,
multiple-mailbox, and unconfigured senders receive `550 5.7.1` after DATA,
before the message is queued. The connection remains usable.
- Even a one-entry array enables these checks. Do not combine `[server.dkim]`
and `[[server.dkim]]` in the same configuration.
- Empty lists, duplicate domains, invalid domains, and unreadable or invalid
keys prevent startup or reject a reload. Failed reloads retain all previous
signers. These identities do not restrict individual SMTP users to domains.

SIGHUP reloads all signers atomically. Queued messages use the current key when
attempted, so rotation applies to pending mail too. When a domain is removed
from the array, its queued messages are deferred under the normal bounded retry
policy, eventually bouncing if the domain is not restored. Disabling DKIM
entirely stops Hedwig from adding signatures, including to pending mail.
Already-running signing operations may finish using the previous snapshot.

When switching from a single signer or unsigned relaying to domain-based
signing, queued messages also undergo the stricter `From:` validation. Messages
with malformed or ambiguous `From:` headers bounce on their next attempt;
messages with a valid but unconfigured domain retry. Drain the queue before
switching if you need pending messages delivered under the previous rules.

For a multi-domain configuration, select the entry when generating its key:

```bash
hedwig --config config.toml dkim-generate --domain example.com
hedwig --config config.toml dkim-generate --domain another.com
```

The entry supplies its selector, private-key path, and key type; flags can
override them. `--domain` is required in this mode, even with one entry. An
unconfigured domain requires explicit `--selector` and `--private-key` flags.
Key generation writes the configured private-key file; publish its DNS record
before enabling a new identity or rotating the active signer.

## Generating keys

When overriding a selector or key path, the configured key type remains the
default. Changing it with `--key-type` requires an explicit `--private-key` path.
Update the configured key type, key path, and DNS record before enabling the new
key. With a single signing domain,
overriding `--domain` with a different domain also requires `--private-key` so
the command does not overwrite the configured domain’s key by default.

```bash
./target/release/hedwig dkim-generate
```
Expand All @@ -37,7 +111,7 @@ Available flags:
- `--domain`: Domain for DKIM signature
- `--selector`: DKIM selector
- `--private-key`: Path to save the private key
- `--key-type`: Key type (rsa or ed25519, default: rsa)
- `--key-type`: Key type (rsa or ed25519, default: configured key type, or rsa if none)

Add the DNS TXT record output by the command:

Expand Down
60 changes: 60 additions & 0 deletions smtp-server/src/callbacks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -609,6 +609,12 @@ impl SmtpCallbacks for Callbacks {

// Handles the DATA command.
async fn on_data(&self, email: Email) -> Result<(), SmtpError> {
self.runtime
.load()
.signer_for(&email.body)
.map_err(|error| SmtpError::DataRejected {
message: format!("5.7.1 {error}"),
})?;
self.process_email(email).await?;
Ok(())
}
Expand Down Expand Up @@ -1656,6 +1662,60 @@ mod tests {
}
}

#[tokio::test]
async fn rejected_from_never_reaches_storage_or_worker_channel() {
use crate::config::{CfgDKIM, CfgDkim, DkimKeyType};
use futures::StreamExt;
let mut cfg = create_test_config();
cfg.server.dkim = Some(CfgDkim::Domains(vec![CfgDKIM {
domain: "example.com".into(),
selector: "default".into(),
private_key: format!(
"{}/../dev/certs/dkim-private.pem",
env!("CARGO_MANIFEST_DIR")
),
key_type: DkimKeyType::Rsa,
}]));
let directory = tempfile::tempdir().unwrap();
let storage = Arc::new(
crate::storage::fs_storage::FileSystemStorage::new(directory.path().to_str().unwrap())
.await
.unwrap(),
);
let (sender_channel, receiver) = async_channel::unbounded();
let callbacks = Callbacks {
runtime: Arc::new(RuntimeConfig::new(cfg).unwrap()),
storage: storage.clone(),
sender_channel,
log_queue: None,
};
for headers in [
"From: a@unknown.com",
"From: a@example.com\r\nFrom: b@example.com",
] {
let result = callbacks
.on_data(Email {
from: "bounce@example.com".into(),
to: vec!["r@example.com".into()],
body: format!("{headers}\r\n\r\nbody").into(),
})
.await;
assert!(matches!(result, Err(SmtpError::DataRejected { .. })));
assert!(receiver.is_empty());
assert!(storage.list(Status::Queued).next().await.is_none());
}
callbacks
.on_data(Email {
from: "bounce@other.com".into(),
to: vec!["r@example.com".into()],
body: "From: a@example.com\r\n\r\nbody".into(),
})
.await
.unwrap();
assert_eq!(receiver.len(), 1);
assert!(storage.list(Status::Queued).next().await.unwrap().is_ok());
}

#[tokio::test]
async fn test_on_data() {
// Test on_data method (lines 339-341)
Expand Down
Loading
Loading