Skip to main content

Email Channel Setup — Staging (h852.work) [ARCHIVED 2026-06-05]

ARCHIVED 2026-06-05. This pre-#1722 CF-Worker-as-primary setup is no longer accurate. Specialist email is now Gmail API polling + DWD impersonation (PR #1722, commit d8333558). CF Worker retained as fallback during 2-week soak (#1441) only. For current architecture see docs/features/channels.md §System 4. Pointer stub at docs/ops/EMAIL-CHANNEL-SETUP.md. The content below is preserved verbatim for historical reference — do not follow these steps to provision a new environment.

Status (2026-06-02): This Cloudflare Email Routing path is the legacy / staging-fallback inbound mechanism. The primary production path is per-OSA Google Workspace mailbox impersonation, shipped via ADR-0002 Phase 2 on 2026-05-29. See docs/features/channels.md §System 3 (Google Workspace) for the live architecture. Keep this doc as the staging fallback reference — Cloudflare-relay still serves @h852.work for non-impersonated staging tests.

Updated 2026-05-19: Cloudflare email worker is deployed and live for the legacy relay path. Dev (@dev.h852.work) and staging (@h852.work) specialist emails route through this worker for fallback. Email threading fallback is implemented. See changelog entry for v0.3.5.

Architecture

Client → email → alex@h852.work
→ Cloudflare Email Routing (MX record on h852.work)
→ Cloudflare Email Worker (forwards to HTTP)
→ POST https://api-dev-b36f.up.railway.app/channels/email/inbound
→ Whitelist lookup: FROM email → email_whitelists → orgId
→ Conversation created (orgId set), routed to Expert queue
→ Expert approves reply
→ Resend sends reply from alex@h852.work back to client

Prerequisites

1. Resend Domain Verification

h852.work must be verified in Resend before outbound emails work.

  • Login: https://resend.com/domains
  • Add domain: h852.work
  • Add the DNS records Resend provides (DKIM TXT records, SPF, etc.)
  • Status must be "verified" before sending from @h852.work

Note: The staging Resend key (re_DgYrhpBH_...) is restricted to sending only (domain list API requires a different key). Verify the domain in the Resend dashboard.

2. Cloudflare Email Routing + Worker

Cloudflare Email Routing does not forward directly to HTTP endpoints. It requires a Cloudflare Worker to relay emails to our webhook.

Steps:

  1. Login: https://dash.cloudflare.com → Select h852.work
  2. Email → Email Routing → Enable Email Routing
  3. Email → Email Workers → Create Worker
  4. Deploy the minimal worker code below
  5. Add catch-all rule: *@h852.work → Route to Worker

Minimal Cloudflare Worker (deploy in Cloudflare dashboard):

export default {
async email(message, env, ctx) {
// Collect raw email body
const chunks = [];
const reader = message.raw.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
}
const rawBytes = new Uint8Array(chunks.reduce((a, b) => a + b.length, 0));
let offset = 0;
for (const chunk of chunks) { rawBytes.set(chunk, offset); offset += chunk.length; }
const rawText = new TextDecoder().decode(rawBytes);

const body = {
from: message.from,
to: message.to,
subject: message.headers.get('subject') || '',
text: rawText, // raw MIME; controller extracts text field
headers: Array.from(message.headers.entries())
.map(([k, v]) => `${k}: ${v}`)
.join('\r\n'),
message_id: message.headers.get('message-id') || ''
};

await fetch('https://api-dev-b36f.up.railway.app/channels/email/inbound', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});

// Optional: forward a copy to an archive address
// await message.forward('archive@h852.work');
}
};

3. Sender Whitelist

For each org, the client's email address (or domain) must be added to the whitelist. Without this, inbound emails are silently dropped (logged as warning).

API:

# Add exact email address
POST /ops/orgs/:orgId/email-whitelist
{ "emailAddress": "client@example.com" }

# Add entire domain
POST /ops/orgs/:orgId/email-whitelist
{ "domain": "example.com" }

# List entries
GET /ops/orgs/:orgId/email-whitelist

# Remove entry
DELETE /ops/orgs/:orgId/email-whitelist/:id

Requires account_manager or superadmin role. Legacy /am/* paths are server-side redirects to /ops/* — do not use them in new tooling.

Railway Environment Variables (dev)

VariableValueStatus
RESEND_API_KEYre_DgYrhpBH_...✅ Already set
HWORK_DOMAINh852.work✅ Set (replaces SPECIALIST_EMAIL_DOMAIN)

Note: HWORK_DOMAIN replaces the old SPECIALIST_EMAIL_DOMAIN variable. Remove SPECIALIST_EMAIL_DOMAIN from any environment where it still appears.

The transactional From address is constructed at runtime as h.work <onboarding@${HWORK_DOMAIN}> in api/src/email/email.service.ts and channels/channels.controller.ts — it is not read from an env var. The Specialist reply From is the routing_address (the TO address of the inbound email, e.g. eleanora@h852.work).

Specialist Email Addresses

Each Specialist has an email in the format: {firstname}`@`{HWORK_DOMAIN}

AddressOrg (staging)
eleanora@h852.workAcme Financial
oliver@h852.workTechCorp Solutions
adelaide@h852.workMeridian Logistics
suki@h852.workBloom People Co
kai@h852.workNexus Capital
alex@h852.workTest / any specialist

Routing Logic (Fixed in dev branch)

The routing uses the SENDER (FROM) address to determine the org, not the TO address.

Flow:

  1. FROM email → lookup email_whitelists table (exact email match, then domain match)
  2. If found → orgId resolved → conversation created with correct org
  3. If not found → message dropped with warning log (no auto-reply to unknown senders)

Bug that was fixed:

The original code passed orgId: undefined to findOrCreateByChannel, creating conversations with orgId = null (not routed to any org's expert queue).

Fix: Added resolveOrgFromEmailSender(from) in ChannelsController that queries email_whitelists before creating the conversation.

Testing

Without Cloudflare (simulate webhook directly):

# Get SA token
SA_TOKEN=$(curl -s -X POST https://api-dev-b36f.up.railway.app/auth/demo-login \
-H "Content-Type: application/json" \
-d '{"role":"superadmin"}' | python3 -c "import json,sys; print(json.load(sys.stdin).get('token',''))")

# First: add sender to whitelist for the target org
ACME_ID="62bf5635-e299-4b7a-995d-a8feff17881a"
curl -s -X POST "https://api-dev-b36f.up.railway.app/ops/orgs/${ACME_ID}/email-whitelist" \
-H "Authorization: Bearer $SA_TOKEN" \
-H "Content-Type: application/json" \
-d '{"emailAddress": "client@example.com"}'

# Then simulate inbound email
curl -s -X POST "https://api-dev-b36f.up.railway.app/channels/email/inbound" \
-H "Content-Type: application/json" \
-d '{
"from": "client@example.com",
"to": "alex@h852.work",
"subject": "Test question",
"text": "Hi, I have a question.",
"headers": "",
"message_id": "test-001@example.com"
}'

# Check conversation was created with correct orgId
curl -s "https://api-dev-b36f.up.railway.app/conversations?channel=email" \
-H "Authorization: Bearer $SA_TOKEN" | python3 -c "
import json,sys
d = json.load(sys.stdin)
convs = d if isinstance(d,list) else d.get('conversations', d.get('data', []))
for c in convs[:3]:
print(f'id={c.get(\"id\")} orgId={c.get(\"orgId\")} customerId={c.get(\"customerId\")}')
"

Test data (staging):

  • Sender (test): e@humanity.org → whitelisted for Acme Financial
  • Recipient: alex@h852.work
  • Acme Financial org ID: 62bf5635-e299-4b7a-995d-a8feff17881a

Outstanding Manual Steps

These steps require manual action in external dashboards:

StepWhereStatus
Verify h852.work in Resendhttps://resend.com/domains✅ Done (staging key re_DgYrhpBH_... active)
Enable Email Routing in Cloudflarehttps://dash.cloudflare.com✅ Done (v0.3.5)
Deploy Cloudflare Email WorkerCloudflare Workers dashboard✅ Deployed (v0.3.5) — forwards base64 RFC822 to API
Add catch-all routing ruleCloudflare Email Routing → Routes✅ Done — @dev.h852.work → dev API, @h852.work → staging API
Confirm MX records on h852.workDNS tab in Cloudflare✅ Auto-configured
Verify h.work in Resend (production)https://resend.com/domains✅ Done — #146 closed by #1217 (production Resend domain verified 2026-05)

Once these are done, a real email to alex@h852.work will flow end-to-end.

Outbound Reply Flow

When a conversation gets a reply approved:

  1. agentMsg.content is set from conversationsService.sendMessage()
  2. RESEND_API_KEY is read from env
  3. Resend sends from routingAddress (the TO address, e.g. alex@h852.work) to the original sender
  4. In-Reply-To and References headers are set for email threading

Requires: h852.work verified in Resend (DKIM signing). Both h852.work (staging) and h.work (production) are verified — production verification closed #146 via #1217 (2026-05).

Email Threading Fallback (v0.3.5)

Replies without an In-Reply-To header now reuse the most recent open conversation for the same sender+org pair instead of creating a new thread. This handles email clients that strip threading headers.

Deactivated Org Guard (v0.3.5)

Inbound email to a deactivated org returns org_inactive and sends an auto-reply: "This workspace is no longer active" — no new conversation is created.

HMAC Validation

The Cloudflare email worker includes a CLOUDFLARE_EMAIL_WEBHOOK_SECRET header in its POST to the API. The API validates this before processing. The shared secret must be set in both Cloudflare Worker environment variables and Railway API (CLOUDFLARE_EMAIL_WEBHOOK_SECRET).