feat: inbound email handler for finance@chitty.cc#108
Conversation
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>
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ❌ Deployment failed View logs |
chittyfinance | 2c7cac0 | Apr 24 2026, 01:46 PM |
|
@coderabbitai review Please evaluate:
|
|
Caution Review failedThe pull request is closed. ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (1)
📝 WalkthroughWalkthroughA new Changes
Sequence DiagramsequenceDiagram
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
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Poem
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
Code Review — PR #108: Inbound Email HandlerOverviewSmall, focused PR (41 lines) that wires up Cloudflare's Bugs / CorrectnessSequential 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, 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
Redundant regex in key sanitization messageId.replace(/[<>]/g, '').replace(/[^a-zA-Z0-9@._-]/g, '_')The second replace already handles messageId.replace(/[^a-zA-Z0-9@._-]/g, '_')SecurityNo 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;
}
Subject logged raw
Error HandlingThere'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 try {
await Promise.all([...]);
} catch (err) {
console.error('[email:inbound] storage failed', err);
message.setReject('Storage error — please retry');
return;
}Minor
Test Plan GapThe 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
Fixing the error handling and |
There was a problem hiding this comment.
💡 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".
| receivedAt: new Date().toISOString(), | ||
| sizeBytes: rawBytes.byteLength, | ||
| }); | ||
| await kv.put(`email:inbound:${ts}`, indexEntry, { expirationTtl: 86400 * 90 }); // 90 days |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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
.emlpayloads 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.
| receivedAt: new Date().toISOString(), | ||
| sizeBytes: rawBytes.byteLength, | ||
| }); | ||
| await kv.put(`email:inbound:${ts}`, indexEntry, { expirationTtl: 86400 * 90 }); // 90 days |
There was a problem hiding this comment.
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()).
| 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 |
| const rawBytes = await new Response(message.raw).arrayBuffer(); | ||
| await env.FINANCE_R2.put(key, rawBytes, { |
There was a problem hiding this comment.
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.
| messageId, | ||
| receivedAt: new Date().toISOString(), | ||
| sizeBytes: String(size), |
There was a problem hiding this comment.
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.
Summary
email()handler to Worker for inbound email processinginbound-email/<ts>_<msgid>.eml) with from/to/subject metadatafinance@chitty.cc→chittyfinanceWorkerTest plan
npx tsc --noEmitpasses🤖 Generated with Claude Code
Summary by CodeRabbit
New Features