Skip to main content

Email Channel Setup — Staging (h852.work)

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 /am/orgs/:orgId/email-whitelist
{ "emailAddress": "client@example.com" }

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

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

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

Requires account_manager or superadmin role.

Railway Environment Variables (dev)

VariableValueStatus
RESEND_API_KEYre_DgYrhpBH_...✅ Already set
HWORK_DOMAINh852.work✅ Set (replaces SPECIALIST_EMAIL_DOMAIN)
EMAIL_FROM_ADDRESSHuman.work <onboarding@h852.work>✅ Set 2026-05-03

Note: HWORK_DOMAIN replaces the old SPECIALIST_EMAIL_DOMAIN variable. Remove SPECIALIST_EMAIL_DOMAIN from any environment where it still appears. EMAIL_FROM_ADDRESS is used as fallback for transactional email. The actual reply from-address is the routing_address (the TO address of the inbound email, e.g. alex@h852.work).

Specialist Email Addresses

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

AddressOrg (staging)
bob@h852.workAcme Financial
nova@h852.workTechCorp Solutions
atlas@h852.workMeridian Logistics
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/am/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⏳ Manual — add DKIM/SPF DNS records
Enable Email Routing in Cloudflarehttps://dash.cloudflare.com⏳ Manual
Deploy Cloudflare Email WorkerCloudflare Workers dashboard⏳ Manual — use code above
Add catch-all routing ruleCloudflare Email Routing → Routes⏳ Manual
Confirm MX records on h852.workDNS tab in Cloudflare⏳ Auto when Email Routing enabled

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). Without verification, Resend will reject the send request.