#!/usr/bin/env bash # 130-email-loopback — the email medium, round trip, on the EMAIL_PROVIDER=fake # stack. An agent has a real mailbox whose address is DERIVED, never stored # (@), and /capabilities — not a 404 — is where a # client learns the medium is on. Mail from an org human is recognized by # membership alone, so it lands with no approval, becomes an ordinary work item # on the ONE unified queue (`{type:'email'}` beside chat's `{type:'chat.message'}`), # and the human-readable drain leads with the medium it came from. The agent # answers in-thread and the fake provider captures exactly what would have gone # on the wire: the derived recipient set, one `Re: ` prefix and never two, an # In-Reply-To pointing at the message being answered, and a Message-ID minted by # core as <{emailId}@{org mail domain}> — the identity that makes the reply # coming back resolve in a single lookup, which the scenario then proves by # injecting that reply and watching it land in the SAME thread. Mailboxes belong # to agents: a human session on /me/email/* is 403, not 404. # # A second, keyless stack (no email env at all) is the other half of the # contract: the medium is entirely dormant — capabilities.email false, every # route 404, and an agent that advertises no address at all. set -euo pipefail SCENARIO_NAME="130-email-loopback" . "$(cd "$(dirname "$0")/.." && pwd)/lib.sh" # --- the clean, fully authenticated verification block the edge would compute. verified() { # jq -cn --arg d "$1" '{spf:"pass",dkim:"pass",dmarc:"pass",domain:$d}' } # --- build one normalized inbound payload (the shape POST /email/inbound takes). # jq --arg keeps the generated org slug from ever being pasted into a string. mail() { # [inReplyTo] jq -cn --arg id "$1" --arg f "$2" --arg to "$3" --arg s "$4" --arg t "$5" \ --argjson v "$6" --arg irt "${7:-}" \ '{rfcMessageId:$id, from:{email:$f,name:"Dana Lee"}, to:[{email:$to}], subject:$s, text:$t, verification:$v} + (if $irt == "" then {} else {inReplyTo:$irt, references:[$irt]} end)' } # =========================================================================== # Stack 1 — EMAIL_PROVIDER=fake: the medium is on and loops back in-process. # =========================================================================== SCENARIO_EXTRA_ENV=(EMAIL_PROVIDER=fake EMAIL_ORG_SUFFIX=.example.com EMAIL_INBOUND_TOKEN=scenario-inbound) scenario_start # 1) /capabilities is the authority on whether a medium exists. caps="$(curl -fsS "$SERVER/api/v1/capabilities")" || fail "GET /capabilities failed" assert_json "$caps" '.email' 'true' 'fake email stack: capabilities.email true' owner="$(signup owner@ex.com password123 Owner)" org="$(first_org_id "$owner")" slug="$(api "$owner" GET "/orgs/$org" | jq -r '.org.slug')" [ -n "$slug" ] && [ "$slug" != null ] || fail "could not read the org slug" f="$(create_agent "$owner" "$org" fable)" fid="$(jq -r '.agent.id' <<<"$f")" fkey="$(jq -r '.key' <<<"$f")" addr="fable@${slug}.example.com" # 2) The address is DERIVED — @ — and is # advertised on the agent record as well as on its own /me/email/address route. assert_json "$f" '.agent.emailAddress' "$addr" 'a minted agent advertises its derived address' mine="$(ac_tok "$fkey" email address --json)" assert_json "$mine" '.address' "$addr" 'GET /me/email/address is @' assert_json "$mine" '.domain' "${slug}.example.com" 'the mail domain is the slug plus the suffix' assert_json "$mine" '.orgId' "$org" 'the address route names the org' assert_json "$mine" '.agentId' "$fid" 'the address route names the agent' assert_contains "$(ac_tok "$fkey" email address)" "$addr" 'the human-readable address prints it' # The owner reaches the same mailbox through the org twin. theirs="$(api "$owner" GET "/orgs/$org/agents/$fid/email/address")" assert_json "$theirs" '.address' "$addr" 'the owner reads the same address via the org route' # 3) Mailboxes belong to AGENTS. A human session on /me/email/* is 403 — the # route exists, the caller is simply the wrong kind of principal ("email # addresses belong to agents"), so this must not degrade into a 404. assert_eq 403 "$(http_status "$owner" GET /me/email/address)" 'human session on /me/email/address → 403' assert_eq 403 "$(http_status "$owner" GET /me/email/threads)" 'human session on /me/email/threads → 403' # 4) An inbound from an ORG HUMAN's account email. Membership IS the grant # (trust-set entry 1), so it delivers with no approval and no quarantine. msgA='' resA="$(admin_api POST /admin/email/inject \ "$(mail "$msgA" owner@ex.com "$addr" 'Q3 rollout' 'ship it on the 14th' "$(verified ex.com)")")" assert_json "$resA" '.status' 'delivered' 'an org human is recognized: delivered' assert_json "$resA" '.reason' 'null' 'a clean delivery carries no reason' assert_json "$resA" '.deliveries | length' '1' 'one anchor agent, one delivery' assert_json "$resA" '.deliveries[0].agentId' "$fid" 'the delivery is anchored to fable' emlA="$(jq -r '.email.id' <<<"$resA")" ethA="$(jq -r '.email.threadId' <<<"$resA")" assert_contains "$emlA" 'eml_' 'the inbound response carries the email id' assert_contains "$ethA" 'eth_' 'the inbound response carries the thread id' # Nothing was parked for a human: recognized mail never touches the queue. assert_json "$(api "$owner" GET "/orgs/$org/email/approvals")" '.items | length' '0' \ 'recognized inbound is not in the approvals queue' # 5) A thread exists, and it is the agent's own. threads="$(ac_tok "$fkey" email threads --json)" assert_json "$threads" '.items | length' '1' 'the delivery created exactly one thread' assert_json "$threads" '.items[0].id' "$ethA" 'the thread is the one the inbound named' assert_json "$threads" '.items[0].subject' 'Q3 rollout' 'the thread keeps the first subject' assert_json "$threads" '.items[0].agentId' "$fid" 'the thread is anchored to fable' # 6) It pops off the ONE unified queue as a typed email work item. p1="$(ac_tok "$fkey" pop --json)" assert_json "$p1" '.item.type' 'email' 'the popped work item is an email' assert_json "$p1" '.item.email.id' "$emlA" 'pop returns the delivered email' assert_json "$p1" '.item.email.direction' 'in' 'the popped email is inbound' assert_json "$p1" '.item.email.from.email' 'owner@ex.com' 'pop names the sender' # The From resolved to a PRINCIPAL, not a stranger: this address is a member's. assert_json "$p1" '.item.email.from.principalId != null' 'true' 'the sender resolved to an org principal' assert_json "$p1" '.item.email.from.contactId' 'null' 'a member is never an external contact' assert_json "$p1" '.item.email.text' 'ship it on the 14th' 'the work item carries the body' assert_json "$p1" '.item.thread.id' "$ethA" 'the email item carries its medium ref: the thread' # The email variant carries email+thread and nothing from the chat medium. assert_json "$p1" '.item | has("message")' 'false' 'an email item carries no chat message ref' assert_json "$p1" '.item | has("room")' 'false' 'an email item carries no room ref' # Popping is the read: a drained mailbox pops nothing. assert_json "$(ac_tok "$fkey" pop --json)" '.item' 'null' 'the popped email was the only work' # 7) The agent answers in the thread. owner@ex.com is an org human, so the # recipient is recognized and the mail is relayed (captured) immediately. admin_api DELETE /admin/email/outbox >/dev/null r1="$(ac_tok "$fkey" email reply 'on it — the 14th works.' --to "$emlA" --json)" assert_json "$r1" '.disposition' 'sent' 'a reply to a recognized recipient is sent, not held' assert_json "$r1" '.direction' 'out' 'the reply is outbound' assert_json "$r1" '.threadId' "$ethA" 'the reply lands in the inbound message’s thread' rid1="$(jq -r '.id' <<<"$r1")" # 8) What the fake provider captured is what would have gone on the wire. box="$(admin_api GET /admin/email/outbox)" assert_json "$box" '.items | length' '1' 'the reply was captured exactly once' cap="$(jq -c '.items[0]' <<<"$box")" # Recipients are DERIVED from the thread's most recent inbound email, minus the # agent's own address — the agent wrote only a body. assert_json "$cap" '.to | join(",")' 'owner@ex.com' 'the capture is addressed to the inbound sender' assert_json "$cap" '.to | length' '1' 'the agent’s own address is dropped from its reply' assert_json "$cap" '.raw.subject' 'Re: Q3 rollout' 'the reply subject is Re: ' assert_json "$cap" '.raw.text' 'on it — the 14th works.' 'the capture carries the body' assert_json "$cap" '.headers.inReplyTo' "$msgA" 'In-Reply-To is the message being answered' assert_json "$cap" '.headers.messageId' "<${rid1}@${slug}.example.com>" \ 'Message-ID is <{emailId}@{agent address domain}>' assert_json "$cap" '.email.id' "$rid1" 'the capture carries the persisted email row' # 9) `Re: ` at most ONCE. A second reply in the same thread — whose subject is # already prefixed — must not compound into "Re: Re: ". admin_api DELETE /admin/email/outbox >/dev/null r2="$(ac_tok "$fkey" email reply 'confirming again.' --to "$emlA" --json)" rid2="$(jq -r '.id' <<<"$r2")" cap2="$(admin_api GET /admin/email/outbox | jq -c '.items[0]')" assert_json "$cap2" '.raw.subject' 'Re: Q3 rollout' 'a reply to a Re: thread keeps ONE Re: prefix' assert_not_contains "$(jq -r '.raw.subject' <<<"$cap2")" 'Re: Re:' 'the Re: prefix never compounds' # In-Reply-To tracks the thread's most recent email in ANY direction — here the # agent's own first reply. assert_json "$cap2" '.headers.inReplyTo' "<${rid1}@${slug}.example.com>" \ 'In-Reply-To is the thread’s most recent email, whatever its direction' assert_json "$cap2" '.headers.messageId' "<${rid2}@${slug}.example.com>" 'the second reply mints its own Message-ID' # 10) The loop closes: the correspondent answers the agent's Message-ID and the # reply resolves into the SAME thread in one lookup — the reason core, not the # gateway, owns threading identity. resB="$(admin_api POST /admin/email/inject \ "$(mail '' owner@ex.com "$addr" 'Re: Q3 rollout' 'great, thanks.' \ "$(verified ex.com)" "<${rid2}@${slug}.example.com>")")" assert_json "$resB" '.status' 'delivered' 'the answer to the agent’s reply delivers' assert_json "$resB" '.email.threadId' "$ethA" 'In-Reply-To resolved it into the original thread' assert_json "$(ac_tok "$fkey" email threads --json)" '.items | length' '1' \ 'the round trip stayed in one thread' # 11) The human-readable drain LEADS with the medium the work came from. human="$(ac_tok "$fkey" pop)" assert_contains "$human" "[email: ${ethA}" 'the popped email leads with [email: ]' assert_contains "$human" 'Q3 rollout' 'the popped email names its subject' assert_contains "$human" 'owner@ex.com' 'the popped email names its sender' assert_contains "$human" 'great, thanks.' 'the popped email prints the body' # =========================================================================== # Stack 2 — keyless (no email env at all): the medium is entirely dormant. # =========================================================================== docker rm -fv "$SPARROW_CID" >/dev/null 2>&1 || true SPARROW_CID="" SCENARIO_EXTRA_ENV=() scenario_start caps2="$(curl -fsS "$SERVER/api/v1/capabilities")" || fail "keyless GET /capabilities failed" assert_json "$caps2" '.email' 'false' 'keyless stack: capabilities.email false' owner2="$(signup owner@ex.com password123 Owner)" org2="$(first_org_id "$owner2")" f2="$(create_agent "$owner2" "$org2" fable)" fid2="$(jq -r '.agent.id' <<<"$f2")" fkey2="$(jq -r '.key' <<<"$f2")" # No provider, no address: nothing is advertised anywhere an agent is described. assert_json "$f2" '.agent.emailAddress' 'null' 'keyless stack: a minted agent advertises no address' assert_json "$(api "$owner2" GET "/orgs/$org2/agents")" \ "[.items[] | select(.id == \"$fid2\")][0].emailAddress" 'null' \ 'keyless stack: the org agent list advertises no address' # Every route in the medium 404s — for the agent, for its owner, and for the edge. assert_eq 404 "$(http_status "$fkey2" GET /me/email/address)" 'keyless /me/email/address → 404' assert_eq 404 "$(http_status "$fkey2" GET /me/email/threads)" 'keyless /me/email/threads → 404' assert_eq 404 "$(http_status "$fkey2" POST /me/email/send '{"to":["x@y.com"],"subject":"s","text":"t"}')" \ 'keyless /me/email/send → 404' assert_eq 404 "$(http_status "$owner2" GET "/orgs/$org2/agents/$fid2/email/address")" \ 'keyless org agent address route → 404' assert_eq 404 "$(http_status "$owner2" GET "/orgs/$org2/agents/$fid2/email/threads")" \ 'keyless org agent threads route → 404' assert_eq 404 "$(http_status "$owner2" GET "/orgs/$org2/email/approvals")" 'keyless approvals queue → 404' assert_eq 404 "$(http_status "$owner2" GET "/orgs/$org2/email/contacts")" 'keyless contacts list → 404' # The inbound seam and the fake provider's admin surface are gone too. assert_eq 404 "$(curl -s -o /dev/null -w '%{http_code}' -X POST "$SERVER/api/v1/email/inbound" \ -H 'content-type: application/json' -d '{}')" 'keyless POST /email/inbound → 404' assert_eq 404 "$(curl -s -o /dev/null -w '%{http_code}' "$SERVER/api/v1/admin/email/outbox" \ -H "x-admin-token: $ADMIN_TOKEN")" 'no fake provider → no admin outbox' # The CLI says the one true sentence rather than pretending a mailbox exists. set +e cliout="$(ac_tok "$fkey2" email address 2>&1)" set -e assert_contains "$cliout" 'email is not enabled on this server' \ 'the CLI reports the medium is off, never a bare 404' pass "derived address, org-human inbound → pop → in-thread reply captured with To/Subject/In-Reply-To/Message-ID; keyless stack fully dormant"