"EMAIL Stalwart Rust mail server deployment — JMAP + IMAP + SMTP + ManageSieve + MTA-STS + DANE + per-tenant residency + S3+KMS body storage + Postgres metadata"
§1 — Description (BCP-14 normative)
The EMAIL service MUST deploy Stalwart as the canonical mail server with Postgres metadata mirror + S3+KMS body storage + per-tenant residency routing. Each requirement:
- MUST deploy Stalwart v0.10.x as a containerised service (per DEC-300). Image:
stalwartlabs/mail-server:0.10plus our config layer atservices/email/docker/stalwart.toml. The container listens on ports 25, 465, 587 (SMTP), 143, 993 (IMAP), 4190 (ManageSieve); JMAP is reverse-proxied at/jmapon the application HTTPS endpoint.
- MUST configure Stalwart's metadata backend as Postgres (per DEC-301):
[storage.data]\ntype = "pg"\nhost = "..."\ndatabase = "stalwart_metadata"\n. The embedded RocksDB store is forbidden at production (per DEC-301); the Postgres backend is the single source of truth that integrates with our cluster's RLS + backup strategy.
- MUST configure Stalwart's blob store as S3 with KMS encryption (per DEC-302), residency-pinned per tenant. Per-tenant routing is via Stalwart's directory-tag mechanism: a Stalwart user's
tenant_iddirects blob writes to the per-tenant bucket. Bucket naming:cyberos-email-<residency>-bodies(e.g.cyberos-email-vn-1-bodies).
- MUST open the following protocol endpoints (per DEC-303):
- SMTP port 25 (relay-in only; receives mail from external MTAs).
- SMTP port 465 (TLS implicit; outbound submission).
- SMTP port 587 (STARTTLS; outbound submission; preferred for clients).
- IMAP port 143 (STARTTLS-required).
- IMAP port 993 (TLS implicit).
- ManageSieve port 4190 (filter rule management).
- JMAP at
/jmapon the application HTTPS endpoint (RFC 8620 + 8621).
- MUST ship per-tenant DKIM key registry at
dkim_keystable (per DEC-304). Schema:(tenant_id, key_id, dkim_selector, key_algorithm, public_key, private_key_encrypted_kms_id, created_at, status active|rotated|revoked). Slice 1 generates RSA-2048 keys; Ed25519 (RFC 8463) deferred to slice 2. Stalwart's signing keystore is synced from this registry at start + on rotation.
- MUST support MTA-STS policy fetch for outbound mail (per DEC-305). Stalwart fetches
https://mta-sts.<peer>.com/.well-known/mta-sts.txtand enforces the policy (mode=enforce→ drop on TLS fail;mode=testing→ log only). DANE TLSA records consulted when the peer publishes them; STARTTLS opportunistic for legacy peers.
- MUST ship the
message_metadataPostgres table mirroring Stalwart's message envelope (per DEC-301). Schema:id UUID PRIMARY KEY,tenant_id UUID NOT NULL,stalwart_message_id BIGINT NOT NULL(Stalwart's internal id; for join),thread_id TEXT NOT NULL(RFC 8621 JMAP threadId),direction message_direction NOT NULL(inbound | outbound | internal),from_address TEXT NOT NULL,to_addresses TEXT[] NOT NULL,cc_addresses TEXT[] NOT NULL DEFAULT '{}',subject TEXT,received_at TIMESTAMPTZ NOT NULL,s3_body_key TEXT NOT NULL,s3_body_kms_key_id TEXT NOT NULL,body_sha256_hex CHAR(64) NOT NULL,byte_size BIGINT NOT NULL,status message_status NOT NULL(received | quarantined | delivered | sent | bounced | dropped),spam_score REAL,dkim_pass BOOLEAN,spf_pass BOOLEAN,dmarc_pass BOOLEAN,bimi_present BOOLEAN,created_at TIMESTAMPTZ NOT NULL DEFAULT now(). RLS-protected.
- MUST ship the
thread_metadatatable for JMAP-native threading:(thread_id TEXT, tenant_id UUID, subject_normalised TEXT, last_message_at TIMESTAMPTZ, message_count INT, participant_addresses TEXT[]). Inbound messages are merged into existing threads by JMAP'sthreadIdalgorithm; thread_metadata is the materialised view for fast list queries.
- MUST declare 2 closed Postgres enums:
message_direction: 3 values (inbound,outbound,internal).message_status: 6 values (received,quarantined,delivered,sent,bounced,dropped).
- MUST enforce RLS with both
USINGandWITH CHECKclauses onmessage_metadata,thread_metadata,bounce_log,dkim_keys. Policy:tenant_id = current_setting('auth.tenant_id')::uuid.
- MUST be append-only on
message_metadataANDbounce_logat the SQL-grant layer (per task-audit skill rule 12).REVOKE UPDATE, DELETE ON message_metadata, bounce_log FROM cyberos_app;. Status changes (received → quarantined → delivered) create new rows linked viaprior_message_idself-FK. Bounce events are pure inserts.
- MUST route messages to per-tenant storage per TASK-AI-016 (per DEC-306). The Stalwart inbound handler:
- Resolves the recipient's tenant via the local-part-to-tenant directory.
- Looks up tenant residency via
residency::resolve(tenant_id). - Writes the body to the residency-pinned S3 bucket with the residency-pinned KMS key.
- Records
s3_body_key+s3_body_kms_key_idinmessage_metadata. Cross-residency leakage (e.g. VN tenant body accidentally written to eu-1 bucket) is fail-closed: the handler asserts residency match before write.
- MUST emit memory audit row
email.message_receivedon every inbound delivery;email.message_senton every outbound;email.message_bouncedon every bounce;email.message_quarantinedon spam classification;email.dkim_key_rotatedon key rotation (per DEC-310). All rows carry{tenant_id, message_id, thread_id, direction, from_hash16, to_hash16, body_sha256_hex, status, ts_ns}.
- MUST PII-scrub sensitive headers + body summary via TASK-MEMORY-111 BEFORE chain commit. The PostgreSQL row retains the raw values (tenant-scoped + RLS-protected); the memory audit chain holds PII-stripped form.
from_hash16/to_hash16= SHA-256[..16] of normalised email address.
- MUST sign outbound messages with the tenant's active DKIM key (per DEC-304). Stalwart's signing keystore is synchronised from
dkim_keysat start + on rotation event. Outbound message lacking DKIM signature → dropped +email.message_droppedmemory row + sev-2 alarm via TASK-OBS-007.
- MUST ship
cyberos-email-cli provision --tenant-id <uuid> --local-part <name> --display-name <text>as the slice-1 user-provisioning entry point. The CLI calls Stalwart's admin API to create the user, generates the per-tenant DKIM key if absent, and emitsemail.user_provisionedmemory row. TASK-EMAIL-002 replaces this with the JWT bridge plugin.
- MUST track bounces in
bounce_log(per DEC-309). Schema:id BIGSERIAL PRIMARY KEY, tenant_id UUID, message_id UUID REFERENCES message_metadata(id), bounce_kind TEXT (hard | soft | transient), bounce_reason TEXT, bounce_code TEXT (SMTP status), remote_peer TEXT, ts TIMESTAMPTZ. Bounce rate > 1% per tenant over rolling 24h → sev-3 alarm.
- MUST quarantine spam per Stalwart's built-in classifier (per DEC-308). Spam score threshold: 5.0 (Stalwart default). Above →
status = quarantined, message routed to per-userTrashfolder with 30-day retention.
- MUST expose internal REST handlers:
GET /v1/email/healthz— returns{stalwart_status, last_message_received_at, last_message_sent_at, postgres_status, s3_status, registered_tenants}.GET /v1/email/messages/{id}/status— returns delivery status, DKIM/SPF/DMARC pass flags, bounce events.GET /v1/email/messages?tenant_id=<>&subject_id=<>&from=<ts>&to=<ts>— list with cursor pagination (used by TASK-EMAIL-003 + TASK-EMAIL-011).
- MUST complete inbound metadata write + memory audit emit in ≤ 200 ms p95 (excluding body upload to S3, which is async).
inbound_perf_testasserts.
- MUST complete
cyberos-email-cli provisionin ≤ 5 seconds (includes DKIM key generation if needed; RSA-2048 generation typically 1-2s).
- MUST emit OTel span
email.{inbound,outbound,bounce,quarantine,dkim_rotate}with attributes:tenant_id,message_id,direction,outcome(success | residency_mismatch | dkim_missing | body_too_large | s3_write_failed | postgres_write_failed).
- MUST emit OTel metrics:
email_message_received_total{tenant_id, outcome}(counter).email_message_sent_total{tenant_id, outcome}(counter).email_message_bounced_total{tenant_id, bounce_kind}(counter).email_message_quarantined_total{tenant_id, spam_score_band}(counter; bands: 5-7, 7-10, 10+).email_dkim_key_rotated_total{tenant_id}(counter).email_stalwart_up{instance}(gauge 0/1; scraped from /healthz).
- MUST support graceful shutdown: SIGTERM triggers (a) refuse new SMTP connections with 421 temporary failure, (b) drain in-flight messages with 30s budget, (c) close IMAP/JMAP sessions cleanly, (d) shut down. Shutdown deadline configurable via env
EMAIL_SHUTDOWN_BUDGET_SECONDS.
- MUST ensure body integrity at storage:
body_sha256_hexrecorded in metadata MUST match the actual SHA-256 of the S3 object. A reconciliation job (out of scope here; task-EMAIL-2xx) cross-checks; this task enforces at write time via S3 ETag comparison.
- MUST support
Bccrecipients without leaking into_addressesorcc_addressesarrays. Per RFC 5322, Bcc is preserved at the SMTP envelope level but stripped from the message body. The Stalwart adapter records Bcc in a separatebcc_addresses TEXT[]column visible only to the sender's view (RLS additional clause).
§2 — Why this design (rationale for humans)
Why Stalwart instead of building a mail server (DEC-300)? Building a production-grade mail server is years of work (RFC compliance + spam filtering + reputation handling + DKIM + MTA-STS + DANE all need to be correct or mail breaks). Stalwart is a credible Rust implementation of the full stack: actively developed, AGPL-licensed (commercial deployment is OK for our use), single binary (operational simplicity). The cost is "we don't own every line"; the benefit is "we ship a working mail server in 12 hours instead of 12 months."
Why Postgres backend instead of RocksDB (DEC-301)? Stalwart's default RocksDB is single-machine and lacks RLS. Pointing at Postgres lets us (a) share the cluster (one operational surface), (b) apply RLS (tenant isolation by construction), (c) use existing backup tooling. The Postgres backend is officially supported by Stalwart; the only cost is slightly higher write latency (5ms vs 0.5ms typical), which is invisible against SMTP's ~100ms baseline.
Why S3 + KMS for bodies (DEC-302, DEC-311)? Bodies are large + PII-heavy + retention-bound. Storing in Postgres would bloat the cluster + create a PII-everywhere problem (every backup carries every body). S3 + KMS isolates bodies from the metadata cluster, encryption at rest is per-tenant (residency + KMS keyspace), retention can be enforced via S3 lifecycle. The cost is a hop on read (50-100ms typical); the benefit is operational + compliance separation.
Why per-tenant residency routing (DEC-306, §1 #12)? Decree 53/2022 (VN) + GDPR (EU) + DORA (EU finance) impose data-residency obligations. A single global mail server violates all three for cross-jurisdictional tenants. The residency router maps tenant → bucket + KMS at body-write time; cross-region writes are fail-closed. The implementation cost is one lookup per message; the compliance benefit is mandatory.
Why per-tenant DKIM keys (DEC-304, §1 #5)? A shared DKIM key for all tenants means any tenant's compromised key affects all. Per-tenant keys isolate compromise + give each tenant a stable signing identity (matters for reputation + BIMI). RSA-2048 is the universal-support floor; Ed25519 (smaller + faster) deferred to slice 2 because not all remote MTAs support it yet.
Why MTA-STS + DANE (DEC-305, §1 #6)? STARTTLS without policy is "best-effort encryption" — a MITM downgrade attack succeeds. MTA-STS publishes a policy ("require TLS for my domain"); DANE pins the TLS certificate via DNSSEC. Together they make MTA-to-MTA TLS verifiable. Stalwart supports both; we just need to enable.
Why append-only message metadata (§1 #11)? Mail is legally significant — operators tracing "did we receive this message?" need the original recorded fact. UPDATE-in-place would let a malicious or buggy handler rewrite history. Status transitions write new rows linked via prior_message_id; the original received row stays. Bounce events are pure inserts.
Why ports 25/465/587 all open (§1 #4)? 25 is the standard inbound-relay port (legacy MTAs use it). 465 is the SMTPS submission port (TLS implicit). 587 is the modern submission port (STARTTLS-required). Modern clients prefer 587; legacy clients use 465. Both submission ports require auth; port 25 is recipient-domain accepted only.
Why JMAP on application HTTPS endpoint, not direct (DEC-303)? JMAP-over-HTTPS uses standard HTTP/2 paths; reverse-proxying through our ingress lets us apply standard rate-limiting + WAF + auth (TASK-EMAIL-002 JWT bridge). The cost is one extra hop; the benefit is unified observability + security.
Why CaMeL quarantine deferred to TASK-EMAIL-005 (disallowed_tools)? CaMeL is a deliberate isolation pattern (quarantine LLM with no tools + no memory parses untrusted body; privileged CUO only sees sanitised extraction). Implementing it correctly is its own work; shipping TASK-EMAIL-001 + TASK-EMAIL-005 in parallel slows both. Split: this task ships the mail server; TASK-EMAIL-005 wires CaMeL between the inbound handler and any CUO-bound consumer.
Why shared-inbox UX deferred to TASK-EMAIL-003? Shared-inbox UX (assignment, internal comments, snooze, tagging) is a TypeScript SPA + backend handlers — substantial. Splitting lets this task focus on the protocol stack + storage + residency; the UX builds on top.
Why JWT bridge deferred to TASK-EMAIL-002 (DEC-307)? Stalwart has its own user store; integrating with our AUTH JWT (TASK-AUTH-004) is a Stalwart plugin. The plugin is a focused work item (~6h); shipping it together would mean blocking on AUTH-004 fully. Slice 1 uses Stalwart's internal store via the CLI; slice 2 cuts over.
Why bounces in append-only log (§1 #17, DEC-309)? Bounce reasons are diagnostic + reputation-relevant. A hard bounce (mailbox doesn't exist) means future sends to that address should be suppressed; a soft bounce (mailbox full) is transient. Tracking both with timestamps lets us build the suppression list + monitor reputation. Append-only because reputation reports are forensic — they need the original sequence.
Why spam quarantine at threshold 5.0 (§1 #18)? Stalwart's classifier uses the SpamAssassin-style additive scoring (each rule adds a small number). 5.0 is the well-established threshold; below = ham; above = spam. Quarantined → Trash with 30-day retention gives users time to recover false positives.
Why fail-closed residency mismatch (§1 #12)? A cross-region body write is a Decree 53/2022 violation per message — a single mistake creates real regulatory exposure. Fail-closed means the handler refuses to commit; the message gets retried (transient SMTP failure 421) until the residency lookup is fixed. The cost is occasional delivery delay; the benefit is "compliance is enforced by code, not by review."
Why no DELETE on metadata or bounce_log (§1 #11)? Both are forensic records. DELETE would let operators "make a mistake go away"; legal/compliance reviews of mail flows need the complete history. Retention policies are S3-side (lifecycle rules); metadata persists until explicit GDPR/PDPL erasure (task-EMAIL-2xx ships handler that anonymises rather than deletes).
Why hashed addresses in memory audit (§1 #14)? Email addresses are PII. memory audit chain is forensically queryable but kept lean; embedding raw addresses would create everywhere-PII. SHA-256[..16] is collision-safe at our scale (~10⁹) + lets forensic queries join.
Why ManageSieve port 4190 (§1 #4)? Sieve is the standard mail-filter language; ManageSieve is the protocol for clients to upload/manage filter rules. Members may set per-mailbox auto-filters (e.g. "label as project-alpha if from:client-x@example.com"); the standard port is 4190 per RFC 5804.
Why 30s shutdown drain (§1 #24)? SMTP receivers can hold a connection for several seconds during DATA phase; 30s gives all in-flight messages a chance to finish. Past 30s the receiver gets 421 (try again later) — the sending MTA retries.
Why Bcc separate column (§1 #26)? RFC 5322 keeps Bcc out of the delivered message body to preserve recipient privacy (other recipients shouldn't see Bcc'd parties). Our to_addresses + cc_addresses arrays mirror the delivered message; bcc_addresses captures the SMTP envelope (visible only to the sender via RLS-extended policy).
Why TASK-EMAIL-001 has zero upstream deps (depends_on: [])? The mail server itself is a leaf service — it doesn't depend on RBAC at slice 1 (per DEC-307 — Stalwart internal user store), it doesn't depend on RLS (Stalwart handles its own auth), it doesn't depend on TASK-AI-016 logically (residency routing is a config lookup; the residency tag table can ship alongside this task with a default vn-1 tag). Slice 2 wires JWT (TASK-EMAIL-002 = TASK-AUTH-004) + slice 3 wires CUO (TASK-EMAIL-005 = TASK-CUO-101). Slice 1 is foundational and independent.
Why slice 1 uses Stalwart internal user store (DEC-307)? Two reasons. (1) Stalwart's directory layer already exists; reusing it is fastest path to working mail. (2) Mail server deployment is risky enough; adding JWT-bridge complexity at the same slice multiplies failure modes. TASK-EMAIL-002 cuts over in slice 2, after slice 1 is proven stable.
Why DKIM keys in their own table not as Stalwart config (§1 #5)? Two reasons. (1) Stalwart's keystore is per-server; if we run multiple Stalwart instances (HA), we need a shared key store. (2) Rotation history is forensically relevant — knowing "this message was signed with key v3 (rotated 2026-04-15)" matters. Postgres table is the source of truth; Stalwart's keystore is a synced cache.
§3 — API contract
3.1 — Stalwart config
# services/email/docker/stalwart.toml
[storage]
data = "pg"
blob = "s3"
fts = "pg"
[storage.data]
type = "pg"
host = "${POSTGRES_HOST}"
port = 5432
database = "stalwart_metadata"
user = "${POSTGRES_USER}"
password = "${POSTGRES_PASSWORD}"
[storage.blob]
type = "s3"
region = "${S3_REGION_DEFAULT}" # adapter override per-tenant via directory tag
bucket = "${S3_BUCKET_DEFAULT}"
kms-key-arn = "${KMS_KEY_DEFAULT}"
sse = "aws:kms"
# SMTP listeners
[server.listener."smtp.relay-in"]
bind = ["[::]:25"]
protocol = "smtp"
tls.implicit = false
[server.listener."smtp.submission-implicit"]
bind = ["[::]:465"]
protocol = "smtp"
tls.implicit = true
[server.listener."smtp.submission-starttls"]
bind = ["[::]:587"]
protocol = "smtp"
tls.implicit = false
# IMAP listeners
[server.listener."imap.starttls"]
bind = ["[::]:143"]
protocol = "imap"
tls.implicit = false
[server.listener."imap.implicit"]
bind = ["[::]:993"]
protocol = "imap"
tls.implicit = true
# ManageSieve
[server.listener."managesieve"]
bind = ["[::]:4190"]
protocol = "managesieve"
# JMAP — reverse-proxied at application layer
[server.listener."jmap"]
bind = ["[::]:8080"]
protocol = "jmap"
# DKIM signing keys (synced from Postgres dkim_keys table at boot)
[signature."default"]
type = "dkim"
domain = "%{tenant.primary_domain}%"
selector = "cyberos"
algorithm = "rsa-sha256"
private-key = "file:///etc/stalwart/keys/%{tenant.id}%/cyberos.pem"
# MTA-STS + DANE
[security.tls.outbound]
mta-sts = "enforce"
dane = "opportunistic"
fallback = "starttls"
3.2 — Migration 0001 — message_metadata + thread_metadata
-- services/email/migrations/0001_messages.sql
BEGIN;
CREATE TYPE message_direction AS ENUM ('inbound', 'outbound', 'internal');
CREATE TYPE message_status AS ENUM ('received', 'quarantined', 'delivered', 'sent', 'bounced', 'dropped');
CREATE TABLE thread_metadata (
thread_id TEXT PRIMARY KEY,
tenant_id UUID NOT NULL,
subject_normalised TEXT,
last_message_at TIMESTAMPTZ NOT NULL,
message_count INT NOT NULL DEFAULT 0,
participant_addresses TEXT[] NOT NULL DEFAULT '{}'
);
CREATE TABLE message_metadata (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
stalwart_message_id BIGINT NOT NULL,
thread_id TEXT NOT NULL REFERENCES thread_metadata(thread_id) ON DELETE RESTRICT,
direction message_direction NOT NULL,
from_address TEXT NOT NULL,
to_addresses TEXT[] NOT NULL,
cc_addresses TEXT[] NOT NULL DEFAULT '{}',
bcc_addresses TEXT[] NOT NULL DEFAULT '{}',
subject TEXT,
received_at TIMESTAMPTZ NOT NULL,
s3_body_key TEXT NOT NULL,
s3_body_kms_key_id TEXT NOT NULL,
body_sha256_hex CHAR(64) NOT NULL CHECK (body_sha256_hex ~ '^[0-9a-f]{64}$'),
byte_size BIGINT NOT NULL CHECK (byte_size BETWEEN 1 AND 26214400), -- 25 MB attachment cap
status message_status NOT NULL,
prior_message_id UUID REFERENCES message_metadata(id),
spam_score REAL,
dkim_pass BOOLEAN,
spf_pass BOOLEAN,
dmarc_pass BOOLEAN,
bimi_present BOOLEAN,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX message_metadata_tenant_received_idx ON message_metadata (tenant_id, received_at DESC);
CREATE INDEX message_metadata_thread_idx ON message_metadata (thread_id, received_at ASC);
CREATE INDEX message_metadata_from_idx ON message_metadata (tenant_id, from_address);
CREATE INDEX message_metadata_status_idx ON message_metadata (tenant_id, status);
CREATE INDEX thread_metadata_tenant_last_idx ON thread_metadata (tenant_id, last_message_at DESC);
ALTER TABLE message_metadata ENABLE ROW LEVEL SECURITY;
ALTER TABLE thread_metadata ENABLE ROW LEVEL SECURITY;
CREATE POLICY message_metadata_tenant_isolation ON message_metadata
USING (tenant_id = current_setting('auth.tenant_id')::uuid)
WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid);
CREATE POLICY thread_metadata_tenant_isolation ON thread_metadata
USING (tenant_id = current_setting('auth.tenant_id')::uuid)
WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid);
-- Append-only enforcement (§1 #11)
REVOKE UPDATE, DELETE ON message_metadata FROM cyberos_app;
COMMIT;
3.3 — Migration 0002 — bounce_log
-- services/email/migrations/0002_bounce_log.sql
BEGIN;
CREATE TABLE bounce_log (
id BIGSERIAL PRIMARY KEY,
tenant_id UUID NOT NULL,
message_id UUID NOT NULL REFERENCES message_metadata(id) ON DELETE RESTRICT,
bounce_kind TEXT NOT NULL CHECK (bounce_kind IN ('hard', 'soft', 'transient')),
bounce_reason TEXT NOT NULL,
bounce_code TEXT, -- SMTP enhanced status
remote_peer TEXT,
ts TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX bounce_log_tenant_ts_idx ON bounce_log (tenant_id, ts DESC);
CREATE INDEX bounce_log_message_idx ON bounce_log (message_id);
ALTER TABLE bounce_log ENABLE ROW LEVEL SECURITY;
CREATE POLICY bounce_log_tenant_isolation ON bounce_log
USING (tenant_id = current_setting('auth.tenant_id')::uuid)
WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid);
REVOKE UPDATE, DELETE ON bounce_log FROM cyberos_app;
COMMIT;
3.4 — Migration 0003 — DKIM keystore
-- services/email/migrations/0003_dkim_keys.sql
BEGIN;
CREATE TABLE dkim_keys (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
dkim_selector TEXT NOT NULL DEFAULT 'cyberos',
key_algorithm TEXT NOT NULL CHECK (key_algorithm IN ('rsa-2048', 'ed25519')),
public_key_pem TEXT NOT NULL,
private_key_kms_encrypted_blob BYTEA NOT NULL,
kms_key_id TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('active', 'rotated', 'revoked')) DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
rotated_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX uniq_active_dkim_key ON dkim_keys (tenant_id, dkim_selector) WHERE status = 'active';
CREATE INDEX dkim_keys_tenant_idx ON dkim_keys (tenant_id, created_at DESC);
ALTER TABLE dkim_keys ENABLE ROW LEVEL SECURITY;
CREATE POLICY dkim_keys_tenant_isolation ON dkim_keys
USING (tenant_id = current_setting('auth.tenant_id')::uuid)
WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid);
REVOKE UPDATE, DELETE ON dkim_keys FROM cyberos_app;
COMMIT;
3.5 — Residency resolver
// services/email/src/residency.rs
use uuid::Uuid;
#[derive(Debug, Clone)]
pub struct EmailStorageBinding {
pub region: String,
pub bucket: String,
pub kms_key_id: String,
}
pub async fn resolve(tenant_id: Uuid, db: &sqlx::PgPool) -> anyhow::Result<EmailStorageBinding> {
let residency: String = sqlx::query_scalar("SELECT residency FROM tenant_residency WHERE tenant_id = $1")
.bind(tenant_id).fetch_one(db).await?;
let (region, bucket_prefix) = match residency.as_str() {
"vn-1" => ("ap-southeast-1", "cyberos-email-vn-1-bodies"),
"sg-1" => ("ap-southeast-1", "cyberos-email-sg-1-bodies"),
"eu-1" => ("eu-west-1", "cyberos-email-eu-1-bodies"),
"us-1" => ("us-east-1", "cyberos-email-us-1-bodies"),
_ => anyhow::bail!("unknown_residency: {residency}"),
};
Ok(EmailStorageBinding {
region: region.into(),
bucket: bucket_prefix.into(),
kms_key_id: format!("alias/{bucket_prefix}"),
})
}
3.6 — Inbound adapter
// services/email/src/stalwart_adapter/inbound.rs
use axum::{Json, extract::State};
use crate::types::*;
use crate::audit::email_events;
use crate::residency;
#[derive(Deserialize)]
pub struct StalwartInboundEvent {
pub stalwart_message_id: i64,
pub thread_id: String,
pub tenant_id: Uuid,
pub from_address: String,
pub to_addresses: Vec<String>,
pub cc_addresses: Vec<String>,
pub bcc_addresses: Vec<String>,
pub subject: Option<String>,
pub received_at: DateTime<Utc>,
pub body_bytes: Vec<u8>,
pub spam_score: f32,
pub dkim_pass: bool,
pub spf_pass: bool,
pub dmarc_pass: bool,
pub bimi_present: bool,
}
pub async fn on_inbound(
State(state): State<AppState>,
Json(evt): Json<StalwartInboundEvent>,
) -> Result<StatusCode, ApiError> {
// Validate body size at adapter boundary
if evt.body_bytes.is_empty() || evt.body_bytes.len() > 26_214_400 {
return Err(ApiError::BodyTooLarge);
}
let body_sha = hex::encode(sha2::Sha256::digest(&evt.body_bytes));
// Residency-pin write to S3
let binding = residency::resolve(evt.tenant_id, &state.db).await?;
let s3_key = format!("{tenant_id}/{stalwart_id}/{sha}",
tenant_id = evt.tenant_id, stalwart_id = evt.stalwart_message_id, sha = body_sha);
state.s3.put_object()
.bucket(&binding.bucket)
.key(&s3_key)
.body(evt.body_bytes.clone().into())
.ssekms_key_id(&binding.kms_key_id)
.server_side_encryption(aws_sdk_s3::types::ServerSideEncryption::AwsKms)
.send().await?;
// Insert thread (upsert) + message metadata
let mut tx = state.db.begin().await?;
sqlx::query(r#"
INSERT INTO thread_metadata (thread_id, tenant_id, subject_normalised, last_message_at, message_count, participant_addresses)
VALUES ($1, $2, $3, $4, 1, $5)
ON CONFLICT (thread_id) DO UPDATE
SET last_message_at = EXCLUDED.last_message_at,
message_count = thread_metadata.message_count + 1,
participant_addresses = thread_metadata.participant_addresses || EXCLUDED.participant_addresses
"#).bind(&evt.thread_id).bind(evt.tenant_id).bind(normalise_subject(&evt.subject))
.bind(evt.received_at).bind(&[evt.from_address.clone()][..])
.execute(&mut *tx).await?;
let status = if evt.spam_score >= 5.0 { MessageStatus::Quarantined } else { MessageStatus::Received };
let msg_id = Uuid::new_v4();
sqlx::query(r#"
INSERT INTO message_metadata (id, tenant_id, stalwart_message_id, thread_id, direction,
from_address, to_addresses, cc_addresses, bcc_addresses, subject, received_at,
s3_body_key, s3_body_kms_key_id, body_sha256_hex, byte_size, status,
spam_score, dkim_pass, spf_pass, dmarc_pass, bimi_present)
VALUES ($1, $2, $3, $4, 'inbound'::message_direction, $5, $6, $7, $8, $9, $10,
$11, $12, $13, $14, $15::message_status, $16, $17, $18, $19, $20)
"#).bind(msg_id).bind(evt.tenant_id).bind(evt.stalwart_message_id).bind(&evt.thread_id)
.bind(&evt.from_address).bind(&evt.to_addresses).bind(&evt.cc_addresses).bind(&evt.bcc_addresses)
.bind(&evt.subject).bind(evt.received_at)
.bind(&s3_key).bind(&binding.kms_key_id).bind(&body_sha).bind(evt.body_bytes.len() as i64)
.bind(status as MessageStatus)
.bind(evt.spam_score).bind(evt.dkim_pass).bind(evt.spf_pass).bind(evt.dmarc_pass).bind(evt.bimi_present)
.execute(&mut *tx).await?;
email_events::emit_message_received(&mut tx, evt.tenant_id, msg_id, &evt, status).await?;
tx.commit().await?;
Ok(StatusCode::CREATED)
}
§4 — Acceptance criteria
- Stalwart container starts —
docker compose upbrings up Stalwart + Postgres + minio; healthcheck passes within 30s. - All protocol ports listen — 25, 465, 587 (SMTP), 143, 993 (IMAP), 4190 (ManageSieve), 8080 (JMAP);
protocol_endpoints_testasserts. - Stalwart uses Postgres backend —
[storage.data].type == "pg"; rocksdb forbidden via env check. - Stalwart uses S3 blob store —
[storage.blob].type == "s3"; per-tenant bucket override applied via residency resolver. - Inbound mail creates message_metadata row — mock Stalwart event → 1 row in
message_metadata+ 1 inthread_metadata(or message_count += 1) + 1 memoryemail.message_receivedrow. - Outbound mail signed with tenant DKIM — mock outbound → message DKIM-Signature header carries tenant's
cyberosselector. - Per-tenant DKIM keys distinct — two tenants get distinct keys;
dkim_per_tenant_testasserts. - DKIM key rotation creates new row —
cyberos-email-cli rotate-dkim --tenant <uuid>→ newdkim_keysrow withstatus=active; prior row →status=rotated; memoryemail.dkim_key_rotatedrow emitted. - Residency pinning: VN tenant → vn-1 bucket — body written to
cyberos-email-vn-1-bodies; metadata recordss3_body_kms_key_id = alias/cyberos-email-vn-1-bodies. - Cross-residency leak prevented — handler refuses to write body to wrong-region bucket; returns SMTP 421 transient failure.
- Append-only metadata —
UPDATE message_metadata SET status='delivered' WHERE id=$1as cyberos_app → permission denied. - Append-only bounce log — same.
- Spam quarantined at score ≥ 5.0 — mock event with
spam_score=5.5→status=quarantined;email.message_quarantinedrow. - DSAR query returns all messages for subject —
GET /v1/email/messages?subject_id=<uuid>returns all messages where subject is sender or in to/cc. - MTA-STS policy enforcement — outbound to peer with
mode=enforceMTA-STS + TLS handshake failure → message dropped +email.message_droppedmemory row. - DANE TLSA pinning — peer with DANE TLSA + matching certificate → delivered; mismatch → dropped.
- Bcc not in to_addresses/cc_addresses — incoming message with Bcc → metadata has Bcc in separate
bcc_addressescolumn. - Body integrity verified at write —
body_sha256_hexmatches actual S3 ETag. - Body > 25 MB rejected — DB CHECK + handler validation → 413.
- Bounce rate alarm — > 1% rolling 24h per tenant → sev-3.
- Graceful shutdown — SIGTERM → SMTP returns 421; in-flight messages drained ≤ 30 s.
- Stalwart up gauge —
email_stalwart_up{instance=...} == 1; scraped from /healthz. - OTel span emitted per inbound —
email.inboundwithoutcome=success. - Counter
email_message_received_total{tenant_id, outcome=success}increments — per inbound. - Counter
email_message_quarantined_total{tenant_id, spam_score_band=5-7}increments — per quarantine. - Inbound perf < 200ms p95 —
inbound_perf_test(1k iters). - provision CLI < 5s — RSA-2048 generation + Stalwart admin call.
§5 — Verification
// services/email/tests/protocol_endpoints_test.rs
#[tokio::test]
async fn all_protocol_ports_listening() {
let ctx = TestEmailStack::up().await;
for (port, label) in [
(25, "smtp-relay-in"), (465, "smtp-implicit"), (587, "smtp-starttls"),
(143, "imap-starttls"), (993, "imap-implicit"),
(4190, "managesieve"), (8080, "jmap"),
] {
let conn = tokio::net::TcpStream::connect(format!("127.0.0.1:{port}")).await;
assert!(conn.is_ok(), "{label} port {port} not listening: {:?}", conn.err());
}
}
// services/email/tests/residency_pin_test.rs
#[tokio::test]
async fn vn_tenant_body_lands_in_vn_bucket() {
let ctx = TestEmailStack::up().await;
let tenant_id = ctx.create_tenant_with_residency("vn-1").await;
let evt = mock_inbound_event(tenant_id);
ctx.simulate_stalwart_inbound(evt.clone()).await.unwrap();
let row = ctx.fetch_message_metadata_for_message(evt.stalwart_message_id).await;
assert!(row.s3_body_key.starts_with(&format!("{tenant_id}/")));
assert_eq!(row.s3_body_kms_key_id, "alias/cyberos-email-vn-1-bodies");
let obj_exists = ctx.s3_head_object("cyberos-email-vn-1-bodies", &row.s3_body_key).await;
assert!(obj_exists);
let obj_in_wrong_bucket = ctx.s3_head_object("cyberos-email-eu-1-bodies", &row.s3_body_key).await;
assert!(!obj_in_wrong_bucket);
}
// services/email/tests/dkim_per_tenant_test.rs
#[tokio::test]
async fn each_tenant_has_distinct_active_key() {
let ctx = TestEmailStack::up().await;
let t1 = ctx.create_tenant_with_residency("vn-1").await;
let t2 = ctx.create_tenant_with_residency("vn-1").await;
ctx.run_cli(&["provision", "--tenant-id", &t1.to_string(), "--local-part", "ops", "--display-name", "Ops"]).await;
ctx.run_cli(&["provision", "--tenant-id", &t2.to_string(), "--local-part", "ops", "--display-name", "Ops"]).await;
let k1: String = ctx.fetch_active_dkim_public(t1).await;
let k2: String = ctx.fetch_active_dkim_public(t2).await;
assert_ne!(k1, k2);
}
// services/email/tests/audit_row_test.rs
#[sqlx::test]
async fn metadata_update_blocked(pool: sqlx::PgPool) {
set_role_app(&pool).await;
let id = seed_message_metadata(&pool).await;
let err = sqlx::query("UPDATE message_metadata SET status = 'delivered'::message_status WHERE id = $1")
.bind(id).execute(&pool).await.unwrap_err();
assert!(format!("{err}").contains("permission denied"));
}
#[sqlx::test]
async fn metadata_delete_blocked(pool: sqlx::PgPool) {
set_role_app(&pool).await;
let id = seed_message_metadata(&pool).await;
let err = sqlx::query("DELETE FROM message_metadata WHERE id = $1").bind(id).execute(&pool).await.unwrap_err();
assert!(format!("{err}").contains("permission denied"));
}
§6 — Implementation skeleton
(API contract above is the skeleton; outbound, bounce handlers, and the DKIM keystore writer follow the same pattern.)
§7 — Dependencies
Upstream: none — this task is a leaf service. Residency lookup consumes TASK-AI-016's policy table; TASK-AI-016 ships independently (its dependency on this task is for the email-side residency tag).
Downstream (6 placeholders):
- TASK-EMAIL-002 —
cyberos-email-authbridgeplugin (Stalwart JMAP auth delegates to AUTH JWT). - TASK-EMAIL-003 — Missive-style shared-inbox UX.
- TASK-EMAIL-004 — DKIM signing + ARC chain forward + BIMI brand indicator.
- TASK-EMAIL-006 — tracked-domain auto-log to CRM activity feed.
- TASK-EMAIL-007 — "Convert to issue" thread → PROJ Issue.
- TASK-EMAIL-011 — DSAR export per subject.
Cross-module:
- TASK-AI-016 — residency policy; consumed by
residency::resolve(). - TASK-AI-003 — memory audit bridge; receives 5
email.*audit row kinds. - TASK-MEMORY-111 — PII scrubbing on
from_address,to_addresses,subjectbefore chain commit.
§8 — Example payloads
8.1 — Stalwart inbound webhook event
{
"stalwart_message_id": 8472,
"thread_id": "<CABcd123ef.tenant.cyberos@mail.cyberos.com>",
"tenant_id": "5e8f1d2a-...",
"from_address": "alice@example.com",
"to_addresses": ["support@cyberskill.world"],
"cc_addresses": [],
"bcc_addresses": [],
"subject": "Question about Q4 invoice",
"received_at": "2026-05-16T10:00:00Z",
"body_bytes": "<base64-encoded MIME message>",
"spam_score": 0.5,
"dkim_pass": true,
"spf_pass": true,
"dmarc_pass": true,
"bimi_present": false
}
8.2 — email.message_received memory row
{
"kind": "email.message_received",
"tenant_id": "5e8f1d2a-...",
"message_id": "01HG7V8B0K8M4Z8Z8M8M8M8M8M",
"thread_id": "<CABcd123ef.tenant.cyberos@mail.cyberos.com>",
"direction": "inbound",
"from_hash16": "a47c8e0c5f8a8e8e",
"to_hash16": "b58d9f1d6e9b9f9f",
"body_sha256_hex": "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9",
"byte_size": 12340,
"status": "received",
"spam_score": 0.5,
"dkim_pass": true,
"spf_pass": true,
"dmarc_pass": true,
"ts_ns": 1747920731000000000
}
8.3 — email.message_quarantined memory row
{
"kind": "email.message_quarantined",
"tenant_id": "5e8f1d2a-...",
"message_id": "01HG7V8B0K8M4Z8Z8M8M8M8M8N",
"thread_id": "<...>",
"from_hash16": "spammyhash01234",
"spam_score": 7.3,
"spam_score_band": "7-10",
"ts_ns": 1747920731000000000
}
8.4 — email.dkim_key_rotated memory row
{
"kind": "email.dkim_key_rotated",
"tenant_id": "5e8f1d2a-...",
"old_key_id": "01HG7V8B0K8M4Z8Z8M8M8M8M8M",
"new_key_id": "01HG7V8B0K8M4Z8Z8M8M8M8M8N",
"dkim_selector": "cyberos",
"key_algorithm": "rsa-2048",
"rotated_by_subject_id_hash16": "9b1deb4d3b7d4bad",
"ts_ns": 1747920731000000000
}
8.5 — cyberos-email-cli provision
$ cyberos-email-cli provision \
--tenant-id 5e8f1d2a-... \
--local-part support \
--display-name "Support Team"
✓ Stalwart user created: support@cyberskill.world
✓ DKIM key generated (RSA-2048, selector=cyberos)
✓ memory audit row email.user_provisioned written
§9 — Open questions
Deferred:
- JMAP JWT bridge — TASK-EMAIL-002.
- Shared-inbox UX (assignment, comments, snooze, tag) — TASK-EMAIL-003.
- DKIM + ARC + BIMI hardening — TASK-EMAIL-004.
- CaMeL quarantine LLM — TASK-EMAIL-005.
- Tracked-domain auto-log to CRM — TASK-EMAIL-006.
- Convert thread to PROJ issue — TASK-EMAIL-007.
- Outbound 1:1 send with AM confirm — TASK-EMAIL-009.
- DSAR export — TASK-EMAIL-011.
- Ed25519 DKIM (RFC 8463) — slice 2.
- Vietnamese-aware PGroonga search — slice 2.
All other questions resolved.
§10 — Failure modes inventory
| Failure | Detection | Outcome | Recovery |
|---|---|---|---|
| Stalwart container fails to start | docker healthcheck | Service unavailable | Investigate logs; common causes: bad config, DB unreachable, S3 perm |
| Postgres backend connection lost | Stalwart loses metadata | SMTP returns 451 (temporary) | DB restored |
| S3 write failure for body | adapter logs + S3 SDK error | SMTP returns 421 | S3 outage recovery |
| KMS key disabled | aws-sdk error on PUT | SMTP returns 421 + sev-1 | Re-enable; rotate per task-AUTH-2xx |
| Residency mismatch attempt | handler check | SMTP returns 421 | Operator fixes residency tag |
| Body > 25MB | DB CHECK + handler | SMTP returns 552 size limit | Caller chunks |
| MTA-STS policy fetch fails | Stalwart fallback to STARTTLS | Outbound proceeds opportunistic | Designed |
| DANE TLSA mismatch | TLS verify fail | Outbound dropped + sev-3 alarm | Investigate peer cert change |
| DKIM signing key missing for tenant | outbound handler check | Outbound dropped + sev-2 alarm | Provision key via CLI |
| DKIM private-key KMS decrypt fail | aws-sdk error | Outbound dropped + sev-2 | KMS investigation |
| Spam score above threshold | Stalwart classifier | Quarantined to Trash; memory row | None — designed |
| Hard bounce | bounce report → bounce_log | Tracked; reputation gauge update | None — designed |
| Sustained > 1% bounce rate | OTel alarm | sev-3 | Investigate sender reputation |
| Thread merge race (two messages same thread_id) | UPSERT idempotent | None | Designed |
| Cross-tenant FK | RLS | 0 rows | Designed |
| RLS bypass attempt | USING predicate | 0 rows | Designed |
| Bcc accidentally exposed in to_addresses | handler test | CI fails | Fix adapter |
| Audit row commit fails | tx rollback | metadata not persisted; SMTP 421 | memory_writer diagnosis |
| body_sha256 mismatch S3 ETag | reconciliation job (task-EMAIL-2xx) | sev-2 alarm | Investigate; possible corruption |
| Stalwart admin API unauthorised | CLI errors out | Service unavailable | Re-credential per docker secret |
| ManageSieve port blocked at firewall | client cannot upload filters | Filters not updated | Operator opens port |
| JMAP HTTP/2 frame too large | proxy returns 431 | Caller chunks | None — designed |
| DKIM key rotation in progress + outbound queued | active key sync atomic | Old key still valid | Designed (no flap) |
| User mailbox quota exceeded | Stalwart 552 | Sender retries / drop | Quota mgmt at task-EMAIL-2xx |
| Stalwart upgrade requires migration | upgrade docs | Operator runs migration | docs |
| Concurrent SMTP listener crash | tokio task supervisor | Restart; ~5s gap | Designed |
| TLS cert expiring | OBS alarm at 14 days | Operator rotates per task-AUTH-2xx | Standard rotation |
Postgres tenant_residency row missing | residency lookup fails | SMTP 421 | Provision tenant |
| OTel exporter down | metrics buffered locally | None visible | OTel restore |
| Bounce_log row mid-tx fails | tx rollback | None | Designed |
| Graceful shutdown drain timeout | force-abort after 30s | in-flight messages drop | Designed |
| Stalwart container OOM | docker kill | Stalwart restarts; ~5s gap | Resource sizing review |
| message_metadata.thread_id orphaned (thread_metadata missing) | FK ON DELETE RESTRICT | UPSERT keeps thread alive | Designed |
§11 — Implementation notes
- Stalwart is the leaf service — adapter layer reads webhook events + writes metadata. Don't reimplement protocol code; rely on Stalwart for RFC compliance.
- Postgres backend mandatory at production — RocksDB is for dev only; cluster + RLS + backups depend on Postgres.
- S3 + KMS bodies separate from Postgres — keeps PII-heavy bodies out of the cluster's backup envelope.
- Per-tenant residency routed at adapter layer — Stalwart sees a single bucket config; the adapter overrides per write via the tenant directory tag.
- Per-tenant DKIM keys — table is source of truth; Stalwart's keystore is a synced cache. Keystore filesystem path:
/etc/stalwart/keys/<tenant-uuid>/cyberos.pem. - Append-only metadata — status transitions create new rows linked via
prior_message_id; bounce events are pure inserts. - SHA-256 body integrity verified at write —
body_sha256_hexcomputed by adapter from raw bytes; compared against S3 ETag (S3 computes ETag as MD5 by default; we compute SHA-256 separately for integrity). stalwart_message_idBIGINT — Stalwart's internal id, useful for joining adapter-side metadata back to Stalwart's own message store.- JMAP
threadId— RFC 8621 algorithm; Stalwart computes; we mirror asthread_id TEXT. - Adapter-side validation duplicates DB CHECK — defence in depth; both layers reject body > 25 MB.
- MTA-STS = "enforce" outbound — sender-side strict mode; receiver-side is the peer's responsibility.
- DANE = "opportunistic" outbound — many peers lack DNSSEC; opportunistic uses DANE when available, falls back to STARTTLS otherwise.
- Spam classifier threshold 5.0 — Stalwart default; tunable via env if a tenant has unusually high false-positive rate.
- Quarantine → Trash with 30-day retention — gives users time to recover; standard mail-UX pattern.
- Bounce_kind enum (hard | soft | transient) — RFC 3463 enhanced status maps: 5.x.x = hard, 4.x.x = soft/transient (depending on remote code).
bcc_addressesseparate column — RFC 5322 mandates Bcc be stripped from delivered body; adapter records envelope-level Bcc in this column for sender's view.spam_score_bandcardinality bounded — Prometheus labels: 5-7, 7-10, 10+ (only spam-classified messages get banded; ham messages have no spam_score_band label).participant_addressesarray on thread_metadata — append-only growing list; useful for "who has been on this thread?" queries.normalise_subject— stripsRe:,Fwd:, leading/trailing whitespace; for thread-merge heuristics (slice 2; this task uses Stalwart's threadId).cyberos-email-cli provisionis slice-1 only — TASK-EMAIL-002 replaces with JWT-driven JIT provisioning.status: droppedcovers outbound drop (DKIM missing, MTA-STS fail, etc.) — distinct frombounced(peer rejected) anddelivered(peer accepted).MessageStatus::Senttransitions toDeliveredon bounce-window-passed — at slice 1, sent stays as sent (we don't implement the bounce window timer); task-EMAIL-2xx ships the transition.- Stalwart healthcheck:
curl http://localhost:8080/admin/healthreturns 200 when ready; ouremail_stalwart_upgauge scrapes. - Docker compose includes minio for local-dev S3; production uses real S3.
Bccprivacy concerns: per-recipient view should NOT show other Bcc'd addresses; this task ships the column; UI (TASK-EMAIL-003) enforces visibility.
End of TASK-EMAIL-001.