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 seedocs/features/channels.md§System 4. Pointer stub atdocs/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.workfor 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:
- Login: https://dash.cloudflare.com → Select
h852.work - Email → Email Routing → Enable Email Routing
- Email → Email Workers → Create Worker
- Deploy the minimal worker code below
- 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)
| Variable | Value | Status |
|---|---|---|
RESEND_API_KEY | re_DgYrhpBH_... | ✅ Already set |
HWORK_DOMAIN | h852.work | ✅ Set (replaces SPECIALIST_EMAIL_DOMAIN) |
Note:
HWORK_DOMAINreplaces the oldSPECIALIST_EMAIL_DOMAINvariable. RemoveSPECIALIST_EMAIL_DOMAINfrom any environment where it still appears.The transactional From address is constructed at runtime as
h.work <onboarding@${HWORK_DOMAIN}>inapi/src/email/email.service.tsandchannels/channels.controller.ts— it is not read from an env var. The Specialist reply From is therouting_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}
| Address | Org (staging) |
|---|---|
eleanora@h852.work | Acme Financial |
oliver@h852.work | TechCorp Solutions |
adelaide@h852.work | Meridian Logistics |
suki@h852.work | Bloom People Co |
kai@h852.work | Nexus Capital |
alex@h852.work | Test / any specialist |
Routing Logic (Fixed in dev branch)
The routing uses the SENDER (FROM) address to determine the org, not the TO address.
Flow:
FROMemail → lookupemail_whiteliststable (exact email match, then domain match)- If found →
orgIdresolved → conversation created with correct org - 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:
| Step | Where | Status |
|---|---|---|
Verify h852.work in Resend | https://resend.com/domains | ✅ Done (staging key re_DgYrhpBH_... active) |
| Enable Email Routing in Cloudflare | https://dash.cloudflare.com | ✅ Done (v0.3.5) |
| Deploy Cloudflare Email Worker | Cloudflare Workers dashboard | ✅ Deployed (v0.3.5) — forwards base64 RFC822 to API |
| Add catch-all routing rule | Cloudflare Email Routing → Routes | ✅ Done — @dev.h852.work → dev API, @h852.work → staging API |
| Confirm MX records on h852.work | DNS 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:
agentMsg.contentis set fromconversationsService.sendMessage()RESEND_API_KEYis read from env- Resend sends from
routingAddress(the TO address, e.g.alex@h852.work) to the original sender In-Reply-ToandReferencesheaders are set for email threading
Requires:
h852.workverified in Resend (DKIM signing). Bothh852.work(staging) andh.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).