Skip to content

Commit 935620b

Browse files
hotfixes around inbound CF worker
1 parent e29179f commit 935620b

4 files changed

Lines changed: 81 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,17 @@ All notable changes to this project will be documented in this file.
3838
### Fixed
3939
- **Webmail - Dark Mode:** Placeholder text in the reply composer and input backgrounds in the settings panel were broken in dark mode due to a Vue scoped CSS compilation issue. Fixed throughout.
4040

41+
### Hotfixes (post-release)
42+
- **Inbound - R2 Cron Delivery:** Emails buffered in R2 were not delivered when the EML `To:` header differed from the SMTP envelope recipient (e.g. GitHub mailing list addresses). Mail manager now falls back through payload `resolved_mailbox`, EML delivery headers (`X-GitHub-Recipient-Address`, `Delivered-To`), and the `Received: for <addr>` header.
43+
- **Inbound - Worker `list()` Metadata:** Cloudflare R2 `list()` does not return `customMetadata` unless explicitly requested. Added `include: ['customMetadata']` to the cron sweep so envelope metadata is available on retry.
44+
- **Inbound - R2 Delete Error Handling:** A transient R2 delete failure in the success path caused a false HTTP 500 response despite the message being delivered. Now try/except with a warning log; cron cleans up any orphaned object.
45+
- **Inbound - Empty Message-ID:** Emails with no `Message-ID` header produced an empty string, causing a UNIQUE constraint collision. A UUID fallback is now generated.
46+
- **Inbound - Attachment Filename Safety:** Filenames with path traversal components (`..`) are now stripped via `os.path.basename`. Duplicate filenames within the same message get a numeric suffix instead of overwriting each other on disk.
47+
- **Worker - KV Availability Guard:** Alias KV lookups in the `email()` handler now check `typeof env.QUOTA_KV !== 'undefined'` before use, matching the pattern already applied to quota checks. A missing KV binding previously caused all alias mail to be silently rejected.
48+
- **Worker - Subject Metadata Truncation:** Subject stored in R2 `customMetadata` is now capped at 500 characters to stay within Cloudflare's 2 KB per-object metadata limit.
49+
- **Worker - R2 Object TTL:** The cron sweep now expires and deletes `temp_cache/` objects older than 7 days to prevent unbounded accumulation during persistent misconfigurations.
50+
- **Inbound - Multi-Domain Header Requirement:** When multiple domains are configured, an inbound webhook without the `X-DockFlare-Domain` header now returns 400 instead of silently using a non-deterministic domain config.
51+
4152
## [v3.1.0] - 2026-04-16
4253

4354
> **Cloudflare Context:** Cloudflare's Email Service entered public beta today — the same `send_email` Workers binding that powers DockFlare Mail's outbound relay is now generally available. Read the announcement: [Email for Agents](https://blog.cloudflare.com/email-for-agents/)

dockflare/app/core/worker_templates/inbound_worker.js

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,11 @@ export default {
4545
const allowedRecipients = JSON.parse(env.ALLOWED_RECIPIENTS || '[]');
4646
if (!allowedRecipients.includes(message.to)) {
4747
let aliasRecord = null;
48-
try {
49-
aliasRecord = await env.QUOTA_KV.get('alias::' + message.to, 'json');
50-
} catch (_) {}
48+
if (typeof env.QUOTA_KV !== 'undefined') {
49+
try {
50+
aliasRecord = await env.QUOTA_KV.get('alias::' + message.to, 'json');
51+
} catch (_) {}
52+
}
5153

5254
if (!aliasRecord) {
5355
message.setReject("Recipient not allowed");
@@ -81,7 +83,7 @@ export default {
8183
to: message.to,
8284
resolved_mailbox: resolvedMailbox || message.to,
8385
via_alias: resolvedMailbox ? "1" : "0",
84-
subject: message.headers.get("subject") || "",
86+
subject: (message.headers.get("subject") || "").slice(0, 500),
8587
receivedAt: receivedAt
8688
}
8789
});
@@ -121,6 +123,8 @@ export default {
121123
async scheduled(event, env, ctx) {
122124
console.log("Cron: scanning R2 temp_cache for buffered emails...");
123125

126+
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
127+
const now = Date.now();
124128
let cursor;
125129
let processed = 0;
126130
let failed = 0;
@@ -129,11 +133,19 @@ export default {
129133
const list = await env.EMAIL_BUCKET.list({
130134
prefix: "temp_cache/",
131135
limit: 100,
132-
cursor: cursor
136+
cursor: cursor,
137+
include: ['customMetadata']
133138
});
134139

135140
for (const object of list.objects) {
136141
const r2Key = object.key;
142+
143+
if (object.uploaded && (now - object.uploaded.getTime()) > MAX_AGE_MS) {
144+
console.warn(`Cron: expiring ${r2Key} (age > 7d)`);
145+
try { await env.EMAIL_BUCKET.delete(r2Key); } catch (_) {}
146+
failed++;
147+
continue;
148+
}
137149
const meta = object.customMetadata || {};
138150
const messageId = r2Key.replace("temp_cache/", "").replace(".eml", "");
139151

mail-manager/app/api/webhook.py

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -184,10 +184,14 @@ def inbound():
184184
return jsonify({"error": "unknown domain"}), 401
185185
secret = domain_cfg['webhook_secret']
186186
else:
187-
cur = get_db().execute("SELECT webhook_secret FROM domain_configs LIMIT 1")
188-
row = cur.fetchone()
189-
secret = row['webhook_secret'] if row else config.WEBHOOK_SECRET
190-
domain_cfg = None
187+
db = get_db()
188+
count = db.execute("SELECT COUNT(*) FROM domain_configs").fetchone()[0]
189+
if count > 1:
190+
log.warning("Inbound webhook: X-DockFlare-Domain header missing with %d domains configured", count)
191+
return jsonify({"error": "domain header required"}), 400
192+
row = db.execute("SELECT * FROM domain_configs LIMIT 1").fetchone()
193+
domain_cfg = row if row else None
194+
secret = domain_cfg['webhook_secret'] if domain_cfg else config.WEBHOOK_SECRET
191195

192196
if not _verify_signature(request, secret):
193197
return jsonify({"error": "invalid signature"}), 401
@@ -218,6 +222,11 @@ def inbound():
218222
to_address = addr
219223
break
220224

225+
if not to_address:
226+
raw_resolved = data.get('resolved_mailbox') or data.get('to', '')
227+
if raw_resolved and db.execute("SELECT 1 FROM mailboxes WHERE address=?", (raw_resolved,)).fetchone():
228+
to_address = raw_resolved
229+
221230
if not to_address:
222231
via_alias = data.get('via_alias', False)
223232
raw_resolved = data.get('resolved_mailbox', '')
@@ -252,6 +261,12 @@ def inbound():
252261
)
253262
break
254263

264+
if not to_address:
265+
for addr in parsed.get('delivered_to_addresses', []):
266+
if db.execute("SELECT 1 FROM mailboxes WHERE address=?", (addr,)).fetchone():
267+
to_address = addr
268+
break
269+
255270
if not to_address and domain_cfg and domain_cfg['catch_all_mailbox']:
256271
catch_all = domain_cfg['catch_all_mailbox']
257272
if db.execute("SELECT 1 FROM mailboxes WHERE address=?", (catch_all,)).fetchone():
@@ -298,10 +313,21 @@ def inbound():
298313
))
299314
msg_id = cur.lastrowid
300315

316+
used_filenames = set()
301317
for att in parsed['attachments']:
302318
att_dir = os.path.join(config.ATTACHMENTS_PATH, str(msg_id))
303319
os.makedirs(att_dir, exist_ok=True)
304-
safe_filename = att['filename'].replace('/', '_').replace('\\', '_')
320+
raw_name = os.path.basename(att['filename'] or '').replace('/', '_').replace('\\', '_').strip('. ')
321+
safe_filename = raw_name or 'unnamed_attachment'
322+
if safe_filename in used_filenames:
323+
stem, sep, ext = safe_filename.rpartition('.')
324+
base = stem if sep else safe_filename
325+
tail = (sep + ext) if sep else ''
326+
counter = 2
327+
while f"{base}_{counter}{tail}" in used_filenames:
328+
counter += 1
329+
safe_filename = f"{base}_{counter}{tail}"
330+
used_filenames.add(safe_filename)
305331
att_path = os.path.join(att_dir, safe_filename)
306332
with open(att_path, 'wb') as f:
307333
f.write(att['data'])
@@ -432,7 +458,10 @@ def inbound():
432458
})
433459
_check_and_send_auto_reply(db, to_address, parsed, domain_cfg)
434460

435-
delete_from_r2(r2_key, domain_cfg)
461+
try:
462+
delete_from_r2(r2_key, domain_cfg)
463+
except Exception:
464+
log.warning("Inbound: R2 delete failed for %s — will clean on next cron", r2_key)
436465

437466
log.info("Inbound delivered: message=%s to=%s db_id=%s",
438467
msg_uuid, to_address, msg_id)

mail-manager/app/core/mime_parser.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import email
22
import email.policy
33
import email.utils
4+
import re
5+
import uuid
46
from datetime import datetime, timezone
57
import nh3
68

@@ -21,12 +23,13 @@
2123
def parse_eml(eml_bytes):
2224
msg = email.message_from_bytes(eml_bytes, policy=email.policy.default)
2325
parsed = {
24-
'message_id': msg.get('Message-ID', '').strip('<>'),
26+
'message_id': msg.get('Message-ID', '').strip('<>') or str(uuid.uuid4()),
2527
'from_address': '',
2628
'from_name': '',
2729
'to_addresses': [],
2830
'cc_addresses': [],
2931
'bcc_addresses': [],
32+
'delivered_to_addresses': [],
3033
'subject': msg.get('Subject', ''),
3134
'date': msg.get('Date'),
3235
'in_reply_to': msg.get('In-Reply-To', '').strip('<>'),
@@ -54,6 +57,20 @@ def parse_eml(eml_bytes):
5457
addr for _, addr in pairs if addr
5558
]
5659

60+
for delivery_header in ['Delivered-To', 'X-Original-To', 'X-Forwarded-To', 'X-GitHub-Recipient-Address', 'destinations']:
61+
for val in msg.get_all(delivery_header) or []:
62+
_, addr = email.utils.parseaddr(str(val))
63+
if addr and addr not in parsed['delivered_to_addresses']:
64+
parsed['delivered_to_addresses'].append(addr)
65+
66+
for received in msg.get_all('Received') or []:
67+
m = re.search(r'\bfor\s+<([^>]+)>', str(received))
68+
if m:
69+
addr = m.group(1).strip()
70+
if addr and addr not in parsed['delivered_to_addresses']:
71+
parsed['delivered_to_addresses'].append(addr)
72+
break
73+
5774
try:
5875
if parsed['date']:
5976
dt = email.utils.parsedate_to_datetime(parsed['date'])

0 commit comments

Comments
 (0)