Task — engineering-spec@1

"EMAIL outbound 1:1 send — DKIM-signed via TASK-EMAIL-004 + AM confirm-before-send + queue + bounce handling"

doneTASK-EMAIL-009
module email · class product · priority p0 · created 2026-05-17 · shipped 2026-05-23
depends on TASK-EMAIL-004 · blocks TASK-EMAIL-010, TASK-EMAIL-003, TASK-INV-010

§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.

  1. MUST define closed send_status enum: ('drafting','queued','sent','bounced_hard','bounced_soft','complaint','suppressed') per DEC-1481. Cardinality 7.
  1. MUST expose compose POST /v1/email/outbound/compose body { to, cc, bcc, subject, body_html, body_text, in_reply_to? }. Handler:
  1. MUST expose send POST /v1/email/outbound/send body { message_id, confirm_token }. Handler:
  1. MUST handle bounce events from Stalwart per DEC-1481:
  1. MUST handle complaint (Feedback Loop from Gmail/Outlook) per DEC-1481 → status='complaint' + add to suppression + emit email.send_complaint sev-1.
  1. MUST maintain per-tenant suppression list per DEC-1483 with reasons (hard_bounce | complaint | manual). Manual unsuppress endpoint for engagement_admin.
  1. MUST rate-limit 100 sends/hour/Member per DEC-1485 via Redis sliding-window. Excess → 429.
  1. MUST emit 5 memory audit kinds per DEC-1484.
  1. MUST thread trace_id end-to-end.
  1. MUST NOT send without confirm_token (DEC-1482).
  1. 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

  1. send_status cardinality 7.
  2. Compose returns confirm_token valid 5min.
  3. Send without confirm → 400.
  4. Send with valid confirm → queued.
  5. DKIM signed before queue — verified via TASK-EMAIL-004.
  6. Hard bounce adds to suppression + status=bounced_hard.
  7. Soft bounce retried — Stalwart 3-day retry; status=bounced_soft.
  8. Complaint adds to suppression + sev-1 audit.
  9. Suppressed recipient blocked — compose to suppressed → 412.
  10. Rate limit 100/hour — 101st → 429.
  11. Manual unsuppress — engagement_admin can re-enable + audit.
  12. 5 memory audit kinds emitted.
  13. Trace_id end-to-end.
  14. PII scrub — subject + body sha256 in chain; recipient hash; raw KMS.
  15. Cross-tenant RLS denied.
  16. In-reply-to preserved — reply maintains thread.
  17. Bounce notification to sender — UI surfaces bounce.
  18. Confirm token expires 5min — past TTL → 412.
  19. Sender required to be Member of tenant.
  20. 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

FailureDetectionOutcomeRecovery
Stalwart unavailableSMTP errorStatus remains queued; retryStalwart recovery
Confirm token expiredTTL check412Re-compose
Hard bounceStalwart eventSuppression + statusInherent
Soft bounce 3-day exceededwatchdogFinal status=bounced_hardInherent
Complaint via FBLfeedback consumerSuppression + sev-1Inherent
Rate limitcounter429Member waits
Cross-tenant via Member contextRLS403Inherent
DKIM sign failper TASK-EMAIL-004Status=queued but Stalwart rejectsInvestigate KMS
Recipient address invalidRFC 5321 check400Inherent
Body > 25 MiBsize check413Inherent
Compromised Member accountrate-limit triggersAuto-flaggedSec team review
Reply-to thread brokenin_reply_to invalidAllowed; client may not threadInherent
FBL not configured for ISPMicrosoft/Yahoo registrationComplaints not detected for thoseManual unsubscribe handling
Suppression list grows unboundedtier review at 1M entriesIndexed lookup remains O(log n)Inherent

§11 — Implementation notes


End of TASK-EMAIL-009 spec.