Skip to content

feat: inbound email handler for finance@chitty.cc#108

Merged
chitcommit merged 1 commit intomainfrom
feat/email-inbound-handler
Apr 24, 2026
Merged

feat: inbound email handler for finance@chitty.cc#108
chitcommit merged 1 commit intomainfrom
feat/email-inbound-handler

Conversation

@chitcommit
Copy link
Copy Markdown
Contributor

@chitcommit chitcommit commented Apr 24, 2026

Summary

  • Add email() handler to Worker for inbound email processing
  • Stores raw .eml files in R2 (inbound-email/<ts>_<msgid>.eml) with from/to/subject metadata
  • Indexes in KV with 90-day TTL for quick lookup
  • Requires dashboard action: create Email Routing rule for finance@chitty.ccchittyfinance Worker

Test plan

  • npx tsc --noEmit passes
  • Deploy Worker and create Email Routing rule in Cloudflare dashboard
  • Send test email to finance@chitty.cc and verify R2 storage

🤖 Generated with Claude Code

Summary by CodeRabbit

New Features

  • Added inbound email processing with automatic extraction of sender, recipient, subject, and message identifier information, stored with 90-day retention.

Route emails to finance@chitty.cc through Cloudflare Email Routing
to the chittyfinance Worker. Stores raw .eml in R2 at
inbound-email/<timestamp>_<message-id>.eml with metadata (from, to,
subject, size). Indexes in KV for 90-day lookup.

Dashboard action needed: create Email Routing rule for finance@chitty.cc
→ chittyfinance Worker under chitty.cc zone > Email > Email Routing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 24, 2026 13:46
@chitcommit chitcommit enabled auto-merge (squash) April 24, 2026 13:46
@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented Apr 24, 2026

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
❌ Deployment failed
View logs
chittyfinance 2c7cac0 Apr 24 2026, 01:46 PM

@github-actions
Copy link
Copy Markdown
Contributor

@coderabbitai review

Please evaluate:

  • Security implications
  • Credential exposure risk
  • Dependency supply chain concerns
  • Breaking API changes

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 24, 2026

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b2cb119e-b8e0-4365-aa11-8dd214601d0c

📥 Commits

Reviewing files that changed from the base of the PR and between f8f1e52 and 2c7cac0.

📒 Files selected for processing (1)
  • server/worker.ts

📝 Walkthrough

Walkthrough

A new email handler has been added to the worker that processes inbound email messages. Upon receipt, the handler extracts key email properties, logs them, and persists both the raw email content to R2 cloud storage and a JSON index entry to KV with a 90-day expiration policy.

Changes

Cohort / File(s) Summary
Email Handler Implementation
server/worker.ts
Added email exported handler that receives email messages, extracts metadata (from, to, subject, message-id, rawSize), converts raw payload to bytes, stores raw email content in R2 with custom metadata (timestamp and size), and creates a JSON index entry in KV using timestamp-based keys with 90-day TTL.

Sequence Diagram

sequenceDiagram
    actor Email as Email Source
    participant Worker as Email Handler
    participant R2 as R2 Storage
    participant KV as KV Store

    Email->>Worker: Inbound email message
    Worker->>Worker: Extract metadata<br/>(from, to, subject,<br/>message-id, rawSize)
    Worker->>Worker: Log details
    Worker->>Worker: Convert raw payload<br/>to bytes
    Worker->>R2: Store raw email content<br/>(with metadata: timestamp, size)
    R2-->>Worker: Confirmation
    Worker->>KV: Write JSON index entry<br/>(timestamp-based key,<br/>90-day expiration)
    KV-->>Worker: Confirmation
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 A message arrives, through the digital air,
I hop to extract it with utmost care,
To R2 it travels, in bytes it will stay,
KV indexes the path for another day!
Ninety days stored, then it hops away. 📧

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/email-inbound-handler

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@chitcommit chitcommit merged commit 26bfaa7 into main Apr 24, 2026
11 of 15 checks passed
@chitcommit chitcommit deleted the feat/email-inbound-handler branch April 24, 2026 13:47
@claude
Copy link
Copy Markdown

claude Bot commented Apr 24, 2026

Code Review — PR #108: Inbound Email Handler

Overview

Small, focused PR (41 lines) that wires up Cloudflare's email() Worker export to store inbound emails to R2 and index them in KV. The scope is appropriate and the implementation is clean, but there are a few issues worth addressing before merging.


Bugs / Correctness

Sequential async writes could be parallelized — and should share a single timestamp

The R2 write and KV write are independent and can run concurrently. More importantly, new Date().toISOString() is called twice for receivedAt (once in R2 metadata, once in the KV entry), meaning the two records can have different timestamps. Use one shared value and Promise.all:

const receivedAt = new Date().toISOString();
const ts = receivedAt.replace(/[:.]/g, '-');
// ...
await Promise.all([
  env.FINANCE_R2.put(key, rawBytes, { customMetadata: { ..., receivedAt, ... } }),
  kv.put(`email:inbound:${ts}`, JSON.stringify({ ..., receivedAt, ... }), { expirationTtl: 86400 * 90 }),
]);

KV key collision on same-millisecond arrival

email:inbound:${ts} uses ISO timestamp. Two emails arriving in the same millisecond would have the same KV key, silently losing the earlier entry. The R2 key is safe because it includes the message ID, but the KV index isn't. Consider appending the sanitized message ID to the KV key too: email:inbound:${ts}_${sanitizedId}.

Redundant regex in key sanitization

messageId.replace(/[<>]/g, '').replace(/[^a-zA-Z0-9@._-]/g, '_')

The second replace already handles < and >, making the first redundant. One pass is enough:

messageId.replace(/[^a-zA-Z0-9@._-]/g, '_')

Security

No to address validation

If the Email Routing rule ever changes or additional rules are added, this handler would silently store emails intended for other addresses. A guard is cheap insurance:

if (message.to !== 'finance@chitty.cc') {
  message.setReject('Invalid recipient');
  return;
}

from is sender-controlled and unauthenticated

message.from reflects the SMTP envelope sender, which can be spoofed. Don't use it as a trust signal downstream without DKIM/SPF verification. This isn't a blocker for storage, but worth a comment so whoever builds the ingestion pipeline doesn't assume it's authoritative.

Subject logged raw

console.log(\... subject="${subject}" ...`)— if subject contains\nor\r` characters (common in log-injection payloads), it could pollute log output. Sanitize before logging or use structured logging.


Error Handling

There's no try/catch around the R2 or KV writes. If either fails (R2 quota exceeded, transient error), the handler throws, and Cloudflare will likely reject the inbound email with a 500. Consider wrapping in try/catch and using message.setReject() with a meaningful reason, or logging + swallowing the error if silent failure is acceptable:

try {
  await Promise.all([...]);
} catch (err) {
  console.error('[email:inbound] storage failed', err);
  message.setReject('Storage error — please retry');
  return;
}

Minor

  • ctx: ExecutionContext is unused. If it stays unused, _ctx is the conventional naming to signal intentional non-use.
  • rawSize (from message.rawSize) is stored in R2 metadata as sizeBytes, while rawBytes.byteLength (actual decoded size) is stored in the KV entry under the same field name. These can differ. Use one consistently, or rename them to avoid confusion.
  • The FINANCE_R2 binding isn't shown in this diff — confirming it was already declared in system-wrangler.jsonc (from Phase 6) would be worth a note in the PR description.

Test Plan Gap

The two unchecked test plan items are real blockers — this can't be verified without a live deploy + Email Routing rule. Suggest adding at least a unit test for the key sanitization logic and the KV index key format, since those are pure functions that don't need Workers infrastructure.


Summary

Area Status
Functionality ✅ Correct approach for Cloudflare Email Workers
Error handling ⚠️ Missing — handler will reject emails on storage failure
Security ⚠️ Missing to validation; from trust caveat worth documenting
Performance ⚠️ Sequential writes should be parallelized
Tests ⚠️ No automated coverage added
Code quality ✅ Clean and readable

Fixing the error handling and to validation would be the two highest-priority changes before this merges.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2c7cac0777

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread server/worker.ts
receivedAt: new Date().toISOString(),
sizeBytes: rawBytes.byteLength,
});
await kv.put(`email:inbound:${ts}`, indexEntry, { expirationTtl: 86400 * 90 }); // 90 days
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make KV index keys collision-resistant

Using only ts in the KV key means two emails processed within the same millisecond will write to the same key and the later write overwrites the earlier index record. This drops one message from the 90-day lookup index even though both .eml files may exist in R2, which is especially likely under bursty inbound traffic or multi-region concurrent handling. Include a stable per-message discriminator (for example sanitized message-id or a random suffix) in the KV key to avoid silent index loss.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an inbound email handler to the Cloudflare Worker so emails routed to the worker can be persisted for later finance/document ingestion and discoverability.

Changes:

  • Implement email() handler to ingest inbound emails and log basic metadata.
  • Store raw .eml payloads in R2 with custom metadata (from/to/subject/messageId/etc).
  • Write a 90-day TTL index entry to KV for lookup.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread server/worker.ts
receivedAt: new Date().toISOString(),
sizeBytes: rawBytes.byteLength,
});
await kv.put(`email:inbound:${ts}`, indexEntry, { expirationTtl: 86400 * 90 }); // 90 days
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

KV index key uses only the timestamp (email:inbound:${ts}), so two emails processed within the same millisecond will overwrite each other and you’ll lose index entries. Also, the PR description mentions indexing by <ts>_<msgid>, but the KV key currently doesn’t include the message id. Use a collision-resistant key (e.g., include sanitizedId/messageId and/or a random suffix like crypto.randomUUID()).

Suggested change
await kv.put(`email:inbound:${ts}`, indexEntry, { expirationTtl: 86400 * 90 }); // 90 days
const indexKey = `email:inbound:${ts}_${sanitizedId}_${crypto.randomUUID()}`;
await kv.put(indexKey, indexEntry, { expirationTtl: 86400 * 90 }); // 90 days

Copilot uses AI. Check for mistakes.
Comment thread server/worker.ts
Comment on lines +39 to +40
const rawBytes = await new Response(message.raw).arrayBuffer();
await env.FINANCE_R2.put(key, rawBytes, {
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

new Response(message.raw).arrayBuffer() buffers the entire email into memory before writing to R2. For larger messages this can be slow and risk Worker memory limits; prefer streaming message.raw directly to R2Bucket.put (or otherwise avoid holding the full payload in memory) since message.rawSize is already available for sizing.

Copilot uses AI. Check for mistakes.
Comment thread server/worker.ts
Comment on lines +45 to +47
messageId,
receivedAt: new Date().toISOString(),
sizeBytes: String(size),
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

receivedAt is generated multiple times (R2 metadata and KV index) independently of the ts used in the object key. This can lead to inconsistent timestamps for the same email and makes correlation/debugging harder. Consider capturing a single receivedAt once and reusing it for ts/R2 metadata/KV entry.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants