# Proposal: Email Channel (Inbound + Outbound)

Draft. 2026-05-16. Iter 3 of audit loop.

---

## Why

Pitchbar today is widget-only. Buyers asking for Intercom-Fin-style omnichannel
where the SAME conversation thread spans widget + email + (eventually) WhatsApp /
SMS. Without email, visitors who close the tab can't be re-engaged unless they
left a lead AND the operator follows up out-of-band — losing context.

Competitive reality:
- Intercom Fin, Drift, Tidio, Crisp: email arrives in the same inbox as chat
- Pitchbar's `Conversation` model is widget-scoped; `Lead` model is the only
  bridge to out-of-band contact

Buyer ask (recurring across Lucian, Jatin, ttsoft): "Operator gets the lead by
email, but the visitor never sees a reply unless they come back to the site."

## Scope (v1)

**In:**
- Inbound visitor email → creates / reattaches a `Conversation` (email source)
- AI auto-reply on first inbound, using the agent's KB
- Operator can reply from `/app/inbox/{lead}` thread; reply is emailed back to visitor
- Visitor's reply threads into the same conversation (Reply-To addressing)
- Per-workspace email alias: `{workspace_slug}+conv-{conversation_id}@inbound.{site_host}`
- Unified Inbox UI shows email + widget messages in one transcript

**Out (v2+):**
- Multiple email aliases per workspace
- Email templates / sequences
- DKIM/SPF setup wizard (point at SES / Postmark / Mailgun docs for now)

## Stack

- **Inbound**: Cloudflare Email Routing → Worker → webhook to Pitchbar `/api/v1/email/inbound`. Same Cloudflare account already used for Workers AI / Vectorize / Browser Rendering. Zero extra vendor.
- **Outbound**: existing `Mail` facade. Default mailer (SMTP / SES / Postmark) buyers already set in `/settings/system → Mail`.

Why Cloudflare Email Routing:
- Free tier covers most CodeCanyon buyers (no per-msg fee)
- Workers AI account already provisioned
- Same `CLOUDFLARE_ACCOUNT_ID` + token (with `Email Routing: Write` scope added)
- No DNS scramble (CF guides users through MX record setup)

## Data model

New table `email_threads`:
- `id` (uuid)
- `conversation_id` (fk → conversations)
- `visitor_email` (string, indexed)
- `subject` (nullable string)
- `last_inbound_at` / `last_outbound_at` (timestamps)

New `Message.role` value: existing `user` / `assistant` / `human-agent` already
covers it. Add `Message.channel` enum: `widget` | `email` | `wp` (default `widget`).

New `Conversation.source` enum: `widget` | `email` | `api`. Existing convos
default to `widget`.

## Routes

```
POST /api/v1/email/inbound       — Cloudflare Worker webhook
GET  /admin/emails               — super_admin: failed-delivery ledger
POST /app/inbox/{lead}/email-reply — operator-side outbound (mirrors /reply)
```

Inbound webhook auth: shared secret (env `EMAIL_INBOUND_SECRET`); Worker signs.

## Hot path

Inbound email does NOT touch the SSE hot path. Auto-reply runs in a queued
`HandleInboundEmailJob`:
1. Parse `to` address — extract conversation_id from `+conv-{uuid}` part
2. If conversation_id missing → new conversation, new visitor (synthesized
   from email)
3. Persist visitor message (`channel = email`)
4. Run normal RAG pipeline (same `Retriever`, `PromptBuilder`, `OpenAiClient`)
5. Mail::to($visitorEmail)->send($reply) with conversation context

Latency: not constrained — email is async by nature. Single retry on transient
mailer errors; 1-hour backoff. Failed deliveries surface in a new
`/admin/emails` ledger UI.

## Multi-tenancy

`email_threads` uses `BelongsToAgent` via the conversation FK chain. Inbound
parsing must validate the `to` address against an actual workspace alias
before persisting — otherwise the endpoint is a write-amplifier for spam.

Alias format: `{workspace_slug}+conv-{conversation_id}@inbound.{site_host}`.
`workspace_slug` looks up the workspace; if conversation_id doesn't belong to
that workspace's agents, reject.

## Plan-gating

Email channel is plan-gated. Existing `Plan` table gets a new boolean
`email_channel_enabled` (free = false, paid = true). Reuses the same
`PlanLimits` service shipped 2026-05-15 for resource caps.

## Effort estimate

| Phase | Days | What ships |
|---|---|---|
| Migrations + models | 0.5 | email_threads table, channel/source enums |
| Inbound endpoint + job | 1.5 | webhook auth, parse, AI reply, threading |
| Outbound from inbox | 1 | operator UI button + Mail dispatch |
| Cloudflare Worker template | 0.5 | one-click deploy via existing CronWorker pattern |
| Tests | 1 | inbound parsing, reply threading, alias auth, plan gate |
| Docs | 0.5 | new page; settings → DNS setup |
| **Total** | **5 days** | |

## Open questions

1. Spam: rely on Cloudflare's pre-filter or add Pitchbar-side rate limit? Probably both — CF filters at edge, Pitchbar rate-limits per `from` address at 10 msg/hour.
2. Attachments: defer to v2. Reject inbound attachments with a polite bounce.
3. Reply-To address: should use a hash-signed alias (HMAC over conversation_id) so attackers can't enumerate. Reuse `CtaContextSigner` pattern.
4. White-label: operator-side `Reply` email "From" should be the workspace's branded sender (`support@{their-domain}.com`), not pitchbar.app. Requires DKIM-aligned domain config in Settings → Mail. Already half-built; verify.

## Why this is the right next bet

| Signal | Source |
|---|---|
| 3 of 5 buyer-feedback sessions today complained about losing visitors after tab close | Lucian, Jatin, ttsoft transcripts |
| Email is universally expected; widget-only feels incomplete | competitor parity |
| Cloudflare-native stack means $0 incremental infra for free-tier buyers | CodeCanyon margin |
| Reuses existing `Conversation` + `Message` + `RAG pipeline` — minimal new surface area | engineering pragmatism |
| Plan-gateable → upgrades from free to paid | revenue lever |

## Next step

Get approval to scope into board cards #75-#80 (one per phase). Likely 2026-05-20 ship target.
