Full email platform with CF Email Routing inbound, Resend outbound, R2 attachments, FTS5 search, contacts, labels, filters, autoresponder, scheduled send, templates, aliases, and keyboard shortcuts
Email (v5.0.0)
The Email page at /admin/email is a full email platform built into the admin area. It receives emails via Cloudflare Email Routing, sends via Resend, stores messages in D1 with attachments in R2, and includes: FTS5 search, auto-populated contacts, per-mailbox signatures, custom labels, automated filter rules, autoresponder, scheduled send, blocked senders, email templates, address aliases, keyboard shortcuts, and AI-powered compose/summarize, custom folders, email forwarding, CID inline image resolution, dark reader for HTML email body, rich text compose with formatting toolbar, and draft auto-save.
Architecture
Inbound: CF Email Routing → CF Worker (email-receiver)
→ blocked sender check → D1 + R2 attachments
→ contacts auto-populate → filter rules → autoresponder
→ Gmail forward
Outbound: Admin UI → /api/admin/email → Resend API → D1 (sent folder)
Scheduled: Admin UI → D1 email_scheduled → CF Cron (1 min) → Resend
Attachments: Inbound: Worker → R2 + D1 BLOB (up to 25 MB each)
Outbound: UI → API → R2 + D1 BLOB + Resend
Download: API → R2 first, fallback D1 BLOB
Search: FTS5 virtual table with auto-sync triggers
Polling: Admin UI → poll action (10s) → D1 → browser notifications
All emails (inbound and outbound) are stored in the same D1 emails table, distinguished by the direction column (inbound or outbound). Attachments are stored in both R2 (primary) and D1 BLOB (fallback), with metadata rows in D1. The D1 fallback ensures downloads work from CF Pages where the R2 binding may not be available.
Page Layout — 3-Column Design
The UI uses a professional 3-column split-pane layout with glass-morphism design:
┌─────────────┬──────────────────┬────────────────────────────────┐
│ Sidebar │ Email List │ Reading Pane │
│ (220px) │ (360px) │ (flex) │
│ │ │ │
│ │ Mailbox▼ ⚙ [Search] [↻]│ Subject │
│ │ │ ┌─────────────────────────┐ │
│ │ ● Sender 2h │ │ [A] Sender Name │ │
│ 📥 Inbox 5 │ Subject line │ │ <email@addr> Date │ │
│ 📤 Sent 3 │ Preview text │ │ To: recipient@addr │ │
│ 📦 Archive │ ─────────────── │ │ [Reply] [Unread] [Block] │ │
│ 🗑 Trash │ ● Sender 5h │ │ [Spam] [Archive] [Trash] │ │
│ 📅 Sched. │ Subject line │ └─────────────────────────┘ │
│ 🏷 Labels │ Preview text │ │
│ │ │ Email body content... │
│ ┌────────┐ │ │ │
│ │Stats │ │ │ ┌─ Inline Images ─────────┐ │
│ │grid │ │ │ │ [img1] [img2] [img3] │ │
│ └────────┘ │ │ └──────────────────────────┘ │
│ [✏ Compose]│ │ ┌─ Attachments ────────────┐ │
│ │ │ │ 📎 file.pdf (1.2 MB) │ │
└─────────────┴──────────────────┴──────────────────────────────────┘
Responsive behavior:
- < 900px: Reading pane hidden; selecting an email shows pane with back button
- < 640px: Sidebar collapses to horizontal bar; folders become scrollable icon tabs
Sidebar
- Folder nav — Inbox, Sent, Archive, Trash, Scheduled, Purged. Each shows live count.
List Toolbar
- Mailbox dropdown — filter all views by mailbox address. “All Mailboxes” shows everything.
- Manage button (gear icon) — opens Mailbox Settings modal (signatures, aliases, autoresponder)
- Search — FTS5 full-text search across subject, from, to, body
- Refresh — manual poll for new emails
- Labels section — custom labels with email counts, click to filter
- Stats grid — 2×2 grid showing Inbox, Unread, Sent, Archive counts
- Compose button — opens compose modal
Email List Column
- Search bar — FTS5 full-text search across subject, sender, body, recipient
- Refresh button — manual data refresh
- Email rows — each shows:
- Color-coded avatar circle (hue derived from email address hash)
- Sender name (bold if unread), unread indicator dot (cyan, glowing)
- Attachment paperclip icon, sent badge for outbound
- Delivery status indicator (sent folder: delivered/bounced/complained)
- Label pills (colored)
- Timestamp (relative), subject line, star toggle, preview text
Reading Pane
- Header card — subject, sender card, To/CC line
- Action buttons: Reply, Mark Read/Unread, Archive, Trash, Block Sender, Mark as Spam, Purge/Delete Forever/Restore (context-dependent)
- Email body — plain text (
pre-wrap) or HTML (sandboxed<iframe>) - Inline images — attachment images rendered in gallery div
- Attachments — pill-shaped chips with icon, filename, size, click-to-download
- Dark Reader — HTML emails render in sandboxed iframe with dark mode toggle. When active: dark background (#0a0e1a), cyan links, light text, cyan-tinted borders. Styles injected directly into iframe srcdoc (not CSS filter invert). Preference saved to localStorage, defaults to ON.
- CID Inline Images — HTML emails with embedded CID images (e.g.,
<img src="cid:image001.png">) are resolved by fetching the email’s attachments, matching content_id, and replacing src with blob URLs. - Email Forwarding — Forward button in reading pane (and keyboard shortcut
f). Opens compose with forwarded content including original headers (From, Date, Subject, To) and quoted body.
Compose Modal
- From dropdown — select sending mailbox
- To, CC, BCC — with contact autocomplete (debounced)
- Body — Rich text editor (contenteditable) with formatting toolbar: Bold, Italic, Underline, Strikethrough, Headings (H/H2), Bullet/Numbered Lists, Blockquote, Horizontal Rule, Link/Unlink, Clear Formatting, Font Size (Small/Normal/Large/Huge), Text Color (7 colors). Auto-saves draft to localStorage every 3 seconds.
- Attachments — file input with drag-and-drop, 25 MB per file
- Insert Template — template picker dropdown
- Send — delivers via Resend API, saves to D1 sent folder
- Send Later — datetime picker for scheduled send
- AI Draft — streams AI-composed body via brain-chat SSE
- Draft Auto-Save — Compose content automatically saves to localStorage every 3 seconds. Closing the modal saves immediately. Reopening “New Email” offers to restore unsaved drafts (within 24 hours). Successful send clears the draft. “Draft saved” indicator shown in footer.
CSS Architecture
Uses <style is:global> because Astro scoped styles don’t apply to innerHTML-generated content. All selectors prefixed with .email-app to prevent leakage. Glass-morphism design with dark translucent backgrounds, subtle borders, backdrop blur, cyan/purple accents.
Multi-Mailbox System
Each @Arcturus-Prime.com address is a separate mailbox. Default set (seed via “Seed Default Mailboxes”):
| Address | Display Name |
|---|---|
[email protected] | Arcturus-Prime Admin (default) |
[email protected] | Arcturus-Prime |
[email protected] | Arcturus-Prime Support |
[email protected] | daniel |
Address-Based Filtering
When a mailbox is selected, all queries filter by address:
- Inbound:
to_addressmatches the mailbox address - Outbound:
from_addressmatches the mailbox address - Fallback:
mailbox_idexplicitly set to the mailbox
Works for legacy emails without mailbox_id. “All Mailboxes” shows everything unfiltered.
Mailbox Settings
- Add — create a new mailbox (address + display name)
- Seed Default Mailboxes — creates the 4 defaults
- Set Default — star icon sets default from-address
- Enable/Disable — toggle visibility
- Signature — per-mailbox signature text, auto-appended to compose
- Aliases — route additional addresses to this mailbox
- Autoresponder — out-of-office with subject, body, date range
Important: Creating a mailbox in admin only creates a D1 record. For inbound delivery, the address must also be configured in Cloudflare Email Routing to route to the worker.
Contacts (Auto-Populated)
- Worker upserts sender into
email_contactson every inbound email - API upserts recipients on every outbound send
- Contacts panel accessible from sidebar
- Autocomplete in compose To/CC/BCC fields (debounced search)
- Toggle favorites, favorites shown first
- Tracks: email, name, last_contacted, contact_count, is_favorite
Full-Text Search (FTS5)
The emails_fts virtual table is indexed on subject, body_text, from_address, to_address. Auto-synced via triggers on INSERT/UPDATE/DELETE. Search API uses FTS5 MATCH with relevance ranking. Falls back to LIKE queries if FTS table doesn’t exist.
Custom Labels
- Create labels with name and custom color
- Assign/remove labels from emails (reading pane or context menu)
- Sidebar shows label list with email counts
- Click a label to filter the email list
- Label pills displayed on email list items and reading pane
Custom Folders
User-created folders stored in email_custom_folders D1 table:
- Create folder with name, color (8 preset colors), and optional icon
- Move emails to custom folders (uses existing
foldercolumn with folder UUID) - Sidebar shows custom folders with email counts
- Right-click to rename/delete (delete moves emails back to inbox)
- Migration 0019 adds the table
Filter Rules
Automated email sorting evaluated by the worker on every inbound email:
- Conditions:
[{field, operator, value}]- Fields: from, to, subject, body
- Operators: contains, equals, matches (regex)
- Actions:
[{type, params}]- Types: move (to folder), label, star, mark_read, delete
- Priority ordering, first matching rule wins
- Toggle active/inactive per rule
Autoresponder
Per-mailbox out-of-office replies:
- Subject and body text
- Optional date range (start/end)
- Tracks
responded_toset — won’t re-send to same address - Sends via Resend from the mailbox’s address
- Worker checks after storing each inbound email
Scheduled Send
- “Send Later” button in compose opens datetime picker
- Stored in
email_scheduledwith status: pending/sent/cancelled/failed - CF Cron Trigger on worker runs every 1 minute, processes due emails via Resend
- Scheduled folder in sidebar shows pending count
- Cancel button on pending scheduled emails
Blocked Senders / Spam
- Block by exact address (
[email protected]) or wildcard domain (*@domain.com) - “Mark as Spam” = move to trash + auto-block sender
- Worker checks inbound against blocked list before processing — silently rejects
- Blocked senders management panel
Delivery Status (Resend Webhooks)
- Webhook endpoint receives Resend events: delivered, bounced, complained, delayed
- Updates
delivery_statuscolumn on matching email (byresend_id) - Colored status indicators in sent folder
Templates
- CRUD for reusable email templates (name, subject, body)
- “Insert Template” button in compose opens template picker
- Template body replaces compose body, subject auto-fills
Aliases
- Map alias addresses to target mailboxes
- Worker resolves aliases on inbound: if no direct mailbox match, checks
email_aliases - Manage aliases in mailbox settings
Keyboard Shortcuts
| Key | Action |
|---|---|
j / k | Next / previous email in list |
Enter | Open selected email |
Escape | Close reading pane / modal |
c | Open compose |
r | Reply to current email |
e | Archive current email |
# | Trash current email |
s | Toggle star |
u | Toggle read/unread |
/ | Focus search |
? | Show shortcut help overlay |
g i | Go to inbox |
g s | Go to sent |
g a | Go to archive |
Disabled when focus is in input/textarea. Two-key combos use gPending state machine.
Inline Attachment Preview
- Images render in a gallery div below the email body
- Fetches attachment content via API, creates blob URLs
Real-Time Polling
Polls every 10 seconds via the poll API action:
POST { action: 'poll', since: lastPollTs }- If new emails found: refresh list + stats, show in-app notification
- Browser
Notificationif tab not focused and permission granted - Poll timer cleaned up on
astro:before-swap
Read/Unread Toggle
- Unread: cyan glowing dot, bold sender/subject
- “Mark Unread”/“Mark Read” button in reading pane
- Opening an email auto-marks as read
Three-Phase Deletion
- Trash (
folder = 'trash',deleted_atset): Recoverable via “Move to Inbox” - Purge (
purged_atset): Visible only in “Purged” folder, recoverable via “Restore” - Permanent Delete (hard
DELETE): Only from purge area, requires confirmation
“Empty Trash” purges all trashed emails at once.
Email Threading
Uses RFC 822 headers:
- Outbound replies set
In-Reply-Towith originalMessage-ID - Worker looks up
In-Reply-Toto find parent’sthread_id - All emails in a thread share the same
thread_id
Security
- HTML emails: Sandboxed
<iframe>, scripts andon*attributes stripped - Input validation: CRLF injection check, subject 998 chars, body 500KB
- Auth: All API routes require
validateAdmin() - Attachment limits: 25 MB per file (R2 storage)
- Blocked senders: Worker rejects before processing
API Actions
All actions go through POST /api/admin/email with an action field.
Core
| Action | Purpose | Key Params |
|---|---|---|
list | List emails by folder (paginated, mailbox-filtered) | folder, limit, offset, mailbox_id |
read | Get single email, marks as read | id |
thread | Get all emails in a thread | thread_id |
send | Send via Resend + save to D1 | to, subject, body, mailbox_id, in_reply_to, attachments |
move | Move between folders | id, folder |
star | Toggle starred | id |
search | FTS5 search | query, folder, mailbox_id |
stats | Folder counts + unread | mailbox_id |
poll | New emails since timestamp | since, mailbox_id |
Read/Unread & Deletion
| Action | Purpose |
|---|---|
mark-read | Mark email as read |
mark-unread | Mark email as unread |
delete | Purge from trash |
purge-all-trash | Purge all trashed emails |
purged | List purged emails |
permanent-delete | Hard delete from purge |
restore | Restore from purge to inbox |
Attachments
| Action | Purpose |
|---|---|
attachments | List metadata for an email |
download-attachment | Download (R2 first, D1 fallback) |
delete-attachment | Delete from R2 + D1 |
Mailboxes
| Action | Purpose |
|---|---|
mailboxes | List mailboxes |
create-mailbox | Create new mailbox |
update-mailbox | Update settings |
seed-mailboxes | Create defaults |
update-signature | Set mailbox signature |
Contacts
| Action | Purpose |
|---|---|
contacts | List/search contacts |
contact-delete | Remove contact |
contact-favorite | Toggle favorite |
Labels
| Action | Purpose |
|---|---|
labels | CRUD labels |
label-email | Add/remove label from email |
list-by-label | Emails filtered by label |
Filter Rules & Autoresponder
| Action | Purpose |
|---|---|
filter-rules | CRUD filter rules |
autoresponder | Get/set autoresponder per mailbox |
Scheduled Send
| Action | Purpose |
|---|---|
schedule-send | Create scheduled email |
list-scheduled | List pending |
cancel-scheduled | Cancel scheduled email |
Spam/Blocking
| Action | Purpose |
|---|---|
blocked-senders | List blocked |
block-sender | Block email/domain |
unblock-sender | Remove block |
mark-spam | Trash + auto-block |
webhook-resend | Delivery status webhook |
Templates
| Action | Purpose |
|---|---|
templates | List templates |
create-template | Create |
update-template | Update |
delete-template | Delete |
Aliases
| Action | Purpose |
|---|---|
aliases | List aliases |
create-alias | Create |
delete-alias | Delete |
AI
| Action | Purpose |
|---|---|
compose | AI draft (SSE stream) |
summarize | AI thread summary (SSE) |
GET /api/admin/email returns service status and folder stats.
CF Email Worker
Lives at workers/email-receiver/, deploys separately.
Inbound handler:
- Check blocked senders → reject if blocked
- Parse MIME with postal-mime
- Extract headers, resolve threading
- Match
toaddress: direct mailbox → alias fallback - Store in D1, attachments in R2
- Auto-populate contacts
- Evaluate filter rules (first match wins)
- Check autoresponder → send via Resend if active
- Forward to Gmail
Cron handler (every 1 min):
- Query due scheduled emails
- Resolve from-address from mailbox
- Send via Resend
- Save to emails table as outbound
- Update scheduled status
Deploy:
cd workers/email-receiver
npm install
npx wrangler deploy
Client Library
src/lib/email-client.ts — ~45 typed browser-side functions for all API actions. Exports types: Email, EmailMailbox, EmailAttachment, EmailContact, EmailLabel, EmailFilterRule, ScheduledEmail, BlockedSender, EmailTemplate, EmailAlias.
D1 Schema
Core Tables
emails— id, message_id, in_reply_to, thread_id, direction, from_address, from_name, to_address, cc, subject, body_text, body_html, folder, is_read, is_starred, resend_id, delivery_status, raw_size, mailbox_id, deleted_at, purged_at, created_at, updated_atemail_mailboxes— id, address, display_name, signature, is_default, is_active, created_at, updated_atemail_attachments— id, email_id, filename, content_type, size, content (BLOB, legacy), content_id, r2_key, created_at
Feature Tables
email_contacts— id, email, name, last_contacted, contact_count, is_favorite, created_atemail_labels— id, name, color, created_atemail_label_map— email_id, label_id (junction)email_filter_rules— id, name, priority, conditions (JSON), actions (JSON), is_active, created_atemail_autoresponders— id, mailbox_id, is_active, subject, body, start_date, end_date, exclude_contacts, responded_to (JSON), created_atemail_scheduled— id, mailbox_id, to_address, cc, bcc, subject, body, reply_to_id, scheduled_at, status, created_atemail_blocked_senders— id, email_pattern, reason, created_atemail_templates— id, name, subject, body, created_at, updated_atemail_aliases— id, alias_address, target_mailbox_id, is_active, created_at
FTS5
emails_fts— virtual table on (subject, body_text, from_address, to_address) with auto-sync triggers
Migrations
| Migration | Purpose |
|---|---|
0002 | emails table |
0004 | email_mailboxes, mailbox_id column |
0005 | deleted_at, purged_at columns |
0006 | email_attachments (D1 BLOB) |
0007 | content_id column |
0008 | r2_key column (R2 migration) |
0009 | email_contacts table |
0010 | signature column on mailboxes |
0011 | emails_fts FTS5 + triggers |
0012 | email_labels + email_label_map |
0013 | email_filter_rules |
0014 | email_autoresponders |
0015 | email_scheduled + email_blocked_senders + delivery_status |
0016 | email_templates + email_aliases |
0017 | email_attachments: nullable content column (fixes R2-backed inserts) |
0018 | owner_email column on email_mailboxes (user portal support) |
0019 | email_custom_folders table |
Apply all: npx wrangler d1 migrations apply Arcturus-Prime-admin --remote
File Map
| File | Purpose |
|---|---|
src/pages/admin/email.astro | Full page: HTML, global CSS, inline JS client |
src/pages/api/admin/email.ts | API endpoint: ~45 actions |
src/lib/admin-db.ts | Database layer: ~55 methods |
src/lib/email-client.ts | Browser client: ~45 typed functions |
src/config/modules/email.ts | Module manifest |
workers/email-receiver/src/index.ts | CF Worker: inbound + cron |
workers/email-receiver/wrangler.toml | Worker config: D1 + R2 + cron |
wrangler.toml | Pages config: D1 + R2 bindings |
src/pages/user/email.astro | User email portal UI |
src/pages/api/user/email.ts | User email API (~30 actions) |
migrations/0002-0019 | 18 D1 migration files |
Setup Steps
- Apply D1 migrations:
npx wrangler d1 migrations apply Arcturus-Prime-admin --remote - Create R2 bucket:
npx wrangler r2 bucket create Arcturus-Prime-email-attachments - Deploy email worker:
cd workers/email-receiver && npx wrangler deploy - CF Dashboard → Email Routing: Enable for
Arcturus-Prime.com, add routes to worker - Verify DNS: CF auto-adds MX records; confirm Resend SPF/DKIM
- Seed mailboxes:
/admin/email→ Manage → “Seed Default Mailboxes” - Set up Resend webhook: Point to
/api/admin/emailwithaction=webhook-resend - Add addresses: For each new address, create mailbox in admin AND add CF Email Routing rule
Required Environment Variables
| Variable | Where | Purpose |
|---|---|---|
RESEND_API_KEY | CF Pages + Worker env | Resend API for sending |
RESEND_FROM_EMAIL | CF Pages env (optional) | Default from fallback |
ADMIN_DB | wrangler.toml D1 binding | D1 database |
EMAIL_ATTACHMENTS | wrangler.toml R2 binding | R2 attachment bucket |
FORWARD_TO | Worker env (optional) | Gmail forwarding address |
Module Registration
Module ID: email
Version: 5.0.0
Nav Item: Email (group: ai)
Icon: fa-envelope
Pages: /admin/email
API Routes: /api/admin/email
Required Env: RESEND_API_KEY
Site-Wide Mail Notification
An unread mail badge in the site header (next to user avatar) on every page when authenticated. Implementation in src/components/Header.astro:
- Polls email stats every 15 seconds (auth-gated via
/api/auth/me) - Admin polls
/api/admin/email, non-admin polls/api/user/email - Unread > 0: icon glows cyan with
mail-glowanimation (pulsing drop-shadow) - New mail detected (count increased):
mail-shakeanimation (rotation + scale), badge pulses - No unread: icon reverts to default gray
- All animations use
<style is:global>keyframes + inlinecssText(bypasses Astro scoped style renaming) - Red gradient badge shows unread count, hidden when empty
User Email Portal
/user/email provides authenticated non-admin users with their own email interface. Users must have a mailbox with owner_email matching their login email.
Feature parity with admin (subset):
- Labels (create, assign, filter)
- Templates (create, insert in compose)
- Contacts (autocomplete, favorites)
- Blocked senders / mark spam
- Custom folders (create, move, manage)
- Thread view
- Rich text compose with full formatting toolbar
- Dark reader toggle for HTML emails
- CID inline image resolution
- Email forwarding
- Draft auto-save
- Empty trash (but NOT permanent delete — admin-only security boundary)
Files: src/pages/user/email.astro, src/pages/api/user/email.ts
API: ~30 POST actions (ownership-verified subset of admin actions)
Known Issues & Gotchas
- CSS must be
is:global: Astro scoped styles don’t apply toinnerHTML-generated content. All selectors prefixed with.email-app. - Iframe console warnings:
Blocked script execution in 'about:srcdoc'is expected — sandboxed iframe blocks scripts. - New mailbox routing: Creating a mailbox in admin only creates a D1 record. Inbound delivery requires a matching CF Email Routing rule.
- Gmail forwarding: Worker always forwards to a single Gmail address (
FORWARD_TOor default), not per-mailbox. - Resend webhook setup: Delivery tracking requires configuring the webhook URL in the Resend dashboard.
- Cron trigger: Worker must be deployed with cron trigger for scheduled sends to process.
- Worker RESEND_API_KEY: Worker needs its own
RESEND_API_KEYenv var for autoresponder and scheduled sends. - Migration 0017 required for attachments: Original migration 0006 had
content BLOB NOT NULL. R2-backed inserts (0008+) passcontent=NULL, which violated the constraint and silently failed. Migration 0017 fixes this by makingcontentnullable.