"EMAIL outbound 1:1 send — DKIM-signed via TASK-EMAIL-004 + AM confirm-before-send + queue + bounce handling"
§1 — Description (BCP-14 normative)
The EMAIL service MUST ship outbound 1:1 send at services/email/src/outbound/ with confirm-token gate, TASK-EMAIL-004 DKIM signing, bounce + complaint handling, per-tenant suppression, rate limit, and 5 memory audit kinds.
- MUST define closed
send_statusenum:('drafting','queued','sent','bounced_hard','bounced_soft','complaint','suppressed')per DEC-1481. Cardinality 7.
- MUST expose compose
POST /v1/email/outbound/composebody{ to, cc, bcc, subject, body_html, body_text, in_reply_to? }. Handler:
- Validates recipients not in suppression list per DEC-1483; suppressed → 412 + suppressed reason.
- Generates
confirm_token(UUIDv7) with 5-min TTL; cached in Redis. - Returns 201 +
{ message_id, confirm_token, expires_at }.
- MUST expose send
POST /v1/email/outbound/sendbody{ message_id, confirm_token }. Handler:
- Validates token + message ownership.
- Rate-limit check per DEC-1485.
- Invokes TASK-EMAIL-004 DKIM signer.
- Hands to Stalwart SMTP queue.
- Persists status='queued'.
- Emits
email.send_queuedsev-2.
- MUST handle bounce events from Stalwart per DEC-1481:
- Hard bounce (5xx permanent) → status='bounced_hard' + add recipient to suppression.
- Soft bounce (4xx temporary) → status='bounced_soft' + Stalwart retries up to 3 days.
- Emits respective audit kinds.
- MUST handle complaint (Feedback Loop from Gmail/Outlook) per DEC-1481 → status='complaint' + add to suppression + emit
email.send_complaintsev-1.
- MUST maintain per-tenant suppression list per DEC-1483 with reasons (hard_bounce | complaint | manual). Manual unsuppress endpoint for engagement_admin.
- MUST rate-limit 100 sends/hour/Member per DEC-1485 via Redis sliding-window. Excess → 429.
- MUST emit 5 memory audit kinds per DEC-1484.
- MUST thread trace_id end-to-end.
- MUST NOT send without confirm_token (DEC-1482).
- MUST NOT send to suppressed (DEC-1483).
§2 — Why this design (rationale)
Why confirm token (DEC-1482)? Two-step gate prevents accidental sends. UI shows summary before commit.
Why suppression list (DEC-1483)? Repeated sends to hard-bounced addresses = spam-reputation damage. Persistent suppression protects sender reputation.
Why 100/hour/Member (DEC-1485)? Legitimate Member sends ~10-30/day. 100/hour catches compromised accounts before significant damage.
§3 — API contract
-- 0004_outbound_messages.sql
CREATE TYPE send_status AS ENUM ('drafting','queued','sent','bounced_hard','bounced_soft','complaint','suppressed');
CREATE TABLE outbound_messages (
message_id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
sender_subject_id UUID NOT NULL,
to_addrs TEXT[] NOT NULL,
cc_addrs TEXT[],
bcc_addrs TEXT[],
subject_sha256 CHAR(64) NOT NULL,
body_sha256 CHAR(64) NOT NULL,
in_reply_to TEXT,
status send_status NOT NULL DEFAULT 'drafting',
queued_at TIMESTAMPTZ,
sent_at TIMESTAMPTZ,
bounce_reason TEXT,
complaint_reason TEXT,
smtp_message_id TEXT,
trace_id CHAR(32)
);
CREATE INDEX idx_outbound_sender ON outbound_messages(sender_subject_id, queued_at DESC);
ALTER TABLE outbound_messages ENABLE ROW LEVEL SECURITY;
CREATE POLICY outbound_messages_rls ON outbound_messages
USING (tenant_id = current_setting('auth.tenant_id')::uuid)
WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid);
REVOKE DELETE ON outbound_messages FROM cyberos_app;
GRANT UPDATE (status, queued_at, sent_at, bounce_reason, complaint_reason, smtp_message_id)
ON outbound_messages TO cyberos_app;
-- 0005_suppression_list.sql
CREATE TABLE email_suppression (
id BIGSERIAL PRIMARY KEY,
tenant_id UUID NOT NULL,
recipient_addr_hash16 TEXT NOT NULL,
recipient_addr_kms_blob BYTEA NOT NULL,
reason TEXT NOT NULL CHECK (reason IN ('hard_bounce','complaint','manual')),
suppressed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
suppressed_by_subject_id UUID,
unsuppressed_at TIMESTAMPTZ,
UNIQUE (tenant_id, recipient_addr_hash16)
);
ALTER TABLE email_suppression ENABLE ROW LEVEL SECURITY;
CREATE POLICY email_suppression_rls ON email_suppression
USING (tenant_id = current_setting('auth.tenant_id')::uuid)
WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid);
REVOKE UPDATE, DELETE ON email_suppression FROM cyberos_app;
GRANT UPDATE (unsuppressed_at) ON email_suppression TO cyberos_app;
Endpoints:
POST /v1/email/outbound/compose
POST /v1/email/outbound/send
POST /v1/admin/email/suppression/unsuppress (engagement_admin)
GET /v1/email/outbound?status=... (sender or admin)
§4 — Acceptance criteria
- send_status cardinality 7.
- Compose returns confirm_token valid 5min.
- Send without confirm → 400.
- Send with valid confirm → queued.
- DKIM signed before queue — verified via TASK-EMAIL-004.
- Hard bounce adds to suppression + status=bounced_hard.
- Soft bounce retried — Stalwart 3-day retry; status=bounced_soft.
- Complaint adds to suppression + sev-1 audit.
- Suppressed recipient blocked — compose to suppressed → 412.
- Rate limit 100/hour — 101st → 429.
- Manual unsuppress — engagement_admin can re-enable + audit.
- 5 memory audit kinds emitted.
- Trace_id end-to-end.
- PII scrub — subject + body sha256 in chain; recipient hash; raw KMS.
- Cross-tenant RLS denied.
- In-reply-to preserved — reply maintains thread.
- Bounce notification to sender — UI surfaces bounce.
- Confirm token expires 5min — past TTL → 412.
- Sender required to be Member of tenant.
- Audit on each transition.
§5 — Verification
#[tokio::test]
async fn send_requires_confirm_token() {
let ctx = TestContext::with_member().await;
let compose = ctx.compose("to@example.com", "test", "body").await;
let msg_id: Uuid = compose.json::<serde_json::Value>().await.unwrap()["message_id"].as_str().unwrap().parse().unwrap();
let r = ctx.send_without_token(msg_id).await;
assert_eq!(r.status(), 400);
}
#[tokio::test]
async fn hard_bounce_adds_to_suppression() {
let ctx = TestContext::with_member().await;
let msg_id = ctx.compose_and_send("bouncing@example.com").await;
ctx.simulate_hard_bounce(msg_id).await;
let suppressed: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM email_suppression WHERE tenant_id=$1 AND recipient_addr_hash16=$2)"
).bind(ctx.tenant_id).bind(hash16("bouncing@example.com")).fetch_one(&ctx.pool).await.unwrap();
assert!(suppressed);
}
#[tokio::test]
async fn suppressed_recipient_blocked() {
let ctx = TestContext::with_member().await;
ctx.add_suppression(ctx.tenant_id, "blocked@example.com", "manual").await;
let r = ctx.compose("blocked@example.com", "test", "body").await;
assert_eq!(r.status(), 412);
}
#[tokio::test]
async fn rate_limit_100_per_hour() {
let ctx = TestContext::with_member().await;
for _ in 0..100 { ctx.compose_and_send_minimal().await; }
let r = ctx.compose("more@example.com", "test", "body").await;
assert_eq!(r.status(), 429);
}
// 5.5..5.10
§7 — Dependencies
Upstream: TASK-EMAIL-004. Cross-module: TASK-AUTH-101 (engagement_admin), TASK-AI-003, TASK-MEMORY-111. Downstream: TASK-EMAIL-010, TASK-EMAIL-011.
§10 — Failure modes
| Failure | Detection | Outcome | Recovery |
|---|---|---|---|
| Stalwart unavailable | SMTP error | Status remains queued; retry | Stalwart recovery |
| Confirm token expired | TTL check | 412 | Re-compose |
| Hard bounce | Stalwart event | Suppression + status | Inherent |
| Soft bounce 3-day exceeded | watchdog | Final status=bounced_hard | Inherent |
| Complaint via FBL | feedback consumer | Suppression + sev-1 | Inherent |
| Rate limit | counter | 429 | Member waits |
| Cross-tenant via Member context | RLS | 403 | Inherent |
| DKIM sign fail | per TASK-EMAIL-004 | Status=queued but Stalwart rejects | Investigate KMS |
| Recipient address invalid | RFC 5321 check | 400 | Inherent |
| Body > 25 MiB | size check | 413 | Inherent |
| Compromised Member account | rate-limit triggers | Auto-flagged | Sec team review |
| Reply-to thread broken | in_reply_to invalid | Allowed; client may not thread | Inherent |
| FBL not configured for ISP | Microsoft/Yahoo registration | Complaints not detected for those | Manual unsubscribe handling |
| Suppression list grows unbounded | tier review at 1M entries | Indexed lookup remains O(log n) | Inherent |
§11 — Implementation notes
- §11.1 Confirm token in Redis with 5-min TTL.
- §11.2 Bounce parsing via
mail-parserRust crate. - §11.3 Suppression check at compose time (early reject).
- §11.4 Rate limit Redis sliding-window per (sender_id, hour).
- §11.5 In-reply-to header preserved through Stalwart for threading.
End of TASK-EMAIL-009 spec.