Skip to content

Commit 11fa752

Browse files
Merge pull request #350 from ChrispyBacon-dev/unstable
v3.1.1 - details changelog
2 parents 9604fba + 65750aa commit 11fa752

99 files changed

Lines changed: 9304 additions & 2654 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,41 @@
33
All notable changes to this project will be documented in this file.
44

55

6+
## [v3.1.1] - 2026-04-24
7+
8+
### Added
9+
- **Webmail - Inline Settings:** Settings replaced the modal with a split-panel layout inside the main mail area. Clicking any folder exits settings. Mobile uses a full-screen overlay with a back button.
10+
- **Webmail - Appearance Setting:** Date and time format selector (US, European, ISO 8601) persisted to localStorage, applied to all timestamps.
11+
- **Webmail - About and Help Sections:** About shows project links ([dockflare.app](https://dockflare.app), GitHub) and a PWA install prompt. Help is a placeholder for future documentation with a GitHub and issue tracker link.
12+
- **Webmail - Bulk Email Actions:** Multi-select mode in the message list with select-all, trash, and folder move controls.
13+
- **Webmail - Animated Logo:** Sidebar shows the animated DockFlare logo when expanded, falls back to `DF` when collapsed.
14+
- **Email Alias System:** A disposable email alias layer for the DockFlare Email Suite.
15+
- **Alias Creation:** Generate aliases with three styles (`word-word-num`, `word-num`, `uuid-short`) or define a custom local-part.
16+
- **Inbound Forwarding:** Aliases enforced at the Cloudflare Worker layer via KV lookups. Unknown aliases are rejected at the SMTP level.
17+
- **Outbound Reply Support:** Replying to an alias-received email pre-selects the alias as sender via a From dropdown.
18+
- **Alias Management UI:** Create, toggle, set expiry, label, describe, and delete aliases from Settings. Usage count tracked per alias.
19+
- **Alias Expiry:** Hourly background job deactivates expired aliases and removes their KV entries from Cloudflare.
20+
- **Per-Alias Rate Limiting:** 20 alias creations per hour and 100 per mailbox enforced server-side.
21+
- **Webmail Compose Enhancements:**
22+
- **Multi-Recipient To Field:** Multiple recipients as chips, confirmed with Enter, Tab, or comma. Supports paste of comma-separated lists.
23+
- **Cc and Bcc Fields:** Hidden by default, revealed via inline buttons.
24+
- **Sent Folder Recipient Display:** Sent messages show recipient addresses instead of the sender.
25+
- **Emoji Picker:** Searchable, categorized, lazy-loaded emoji selector in the compose toolbar.
26+
- **Inline Link Popover:** Link insertion opens an inline popover instead of a browser prompt.
27+
- **Mobile Support:** Fully responsive webmail for phones and small screens.
28+
- **Single-Panel Navigation:** Stacked Folders, Message List, and Message Detail panels with a back button.
29+
- **Bottom Navigation Bar:** Persistent bar with Folders, Compose, and Mail shortcuts.
30+
- **Full-Screen Compose:** Compose opens as a full-screen overlay on mobile.
31+
- **iOS Safe Area Support:** Bottom navigation respects the iOS home indicator via `viewport-fit=cover`.
32+
33+
### Changed
34+
- **Webmail - Sidebar Layout:** Collapse and expand controls moved to the bottom action bar. Sidebar header reserved for the logo.
35+
- **Webmail - Folder Navigation:** Removed the "CUSTOM" section label. Active custom folders use a `FolderOpen` icon.
36+
- **Webmail - Alias Delete:** Native browser confirm dialog replaced with a styled in-app confirmation modal.
37+
38+
### Fixed
39+
- **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.
40+
641
## [v3.1.0] - 2026-04-16
742

843
> **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/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ def _get_int_env(name, default, minimum=None):
3535
return default
3636

3737
# --- DockFlare Version ---
38-
APP_VERSION = "v3.1.0"
38+
APP_VERSION = "v3.1.1"
3939
# --- web: https://dockflare.app ---
4040
# --- github: https://github.com/ChrispyBacon-dev/DockFlare ---
4141

dockflare/app/core/email_manager.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,28 @@ def create_kv_namespace(title):
337337
json_data={'title': title})
338338
return res.get('result', {}).get('id')
339339

340+
341+
def get_or_create_kv_namespace(title):
342+
try:
343+
ns_id = create_kv_namespace(title)
344+
if ns_id:
345+
return ns_id
346+
except Exception:
347+
pass
348+
page = 1
349+
while True:
350+
res = cf_api_request('GET', f'/accounts/{config.CF_ACCOUNT_ID}/storage/kv/namespaces',
351+
params={'per_page': 100, 'page': page})
352+
results = res.get('result') or []
353+
for ns in results:
354+
if ns.get('title') == title:
355+
return ns.get('id')
356+
info = res.get('result_info', {})
357+
if page >= info.get('total_pages', 1):
358+
break
359+
page += 1
360+
return None
361+
340362
def update_kv_entry(namespace_id, key, value_dict):
341363
url = f"{config.CF_API_BASE_URL}/accounts/{config.CF_ACCOUNT_ID}/storage/kv/namespaces/{namespace_id}/values/{key}"
342364
headers = {"Authorization": f"Bearer {config.CF_API_TOKEN}", "Content-Type": "text/plain"}

dockflare/app/core/worker_templates/inbound_worker.js

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,11 @@ async function dispatchWebhook(env, payload) {
3030
}
3131

3232
export default {
33-
// ── Inbound email handler ──────────────────────────────────────────────────-.--...--
3433
async email(message, env, ctx) {
3534
try {
35+
let resolvedMailbox = null;
3636
const catchAllEnabled = env.CATCH_ALL_ENABLED === 'true';
37+
3738
if (catchAllEnabled) {
3839
const domain = (env.DOMAIN_NAME || '').toLowerCase();
3940
if (!message.to.toLowerCase().endsWith('@' + domain)) {
@@ -43,21 +44,28 @@ export default {
4344
} else {
4445
const allowedRecipients = JSON.parse(env.ALLOWED_RECIPIENTS || '[]');
4546
if (!allowedRecipients.includes(message.to)) {
46-
message.setReject("Recipient not allowed");
47-
return;
47+
let aliasRecord = null;
48+
try {
49+
aliasRecord = await env.QUOTA_KV.get('alias::' + message.to, 'json');
50+
} catch (_) {}
51+
52+
if (!aliasRecord) {
53+
message.setReject("Recipient not allowed");
54+
return;
55+
}
56+
resolvedMailbox = aliasRecord.mailbox;
4857
}
4958
}
5059

51-
// Check quota KV before accepting — reject at SMTP level so sender gets a bounce
5260
if (typeof env.QUOTA_KV !== 'undefined') {
5361
try {
54-
const state = await env.QUOTA_KV.get(message.to, "json");
62+
const quotaTarget = resolvedMailbox || message.to;
63+
const state = await env.QUOTA_KV.get(quotaTarget, "json");
5564
if (state?.blocked) {
5665
message.setReject("550 5.2.2 Mailbox full");
5766
return;
5867
}
5968
} catch (kvErr) {
60-
// KV unavailable — fall through, webhook safety net handles enforcement
6169
console.warn(`KV quota check failed for ${message.to}: ${kvErr.message}`);
6270
}
6371
}
@@ -66,12 +74,13 @@ export default {
6674
const r2Key = `temp_cache/${messageId}.eml`;
6775
const receivedAt = new Date().toISOString();
6876

69-
// Upload to R2 first — email is now safely buffered regardless of what happens next
7077
const rawBytes = await new Response(message.raw).arrayBuffer();
7178
await env.EMAIL_BUCKET.put(r2Key, rawBytes, {
7279
customMetadata: {
7380
from: message.from,
7481
to: message.to,
82+
resolved_mailbox: resolvedMailbox || message.to,
83+
via_alias: resolvedMailbox ? "1" : "0",
7584
subject: message.headers.get("subject") || "",
7685
receivedAt: receivedAt
7786
}
@@ -81,6 +90,8 @@ export default {
8190
message_id: messageId,
8291
from: message.from,
8392
to: message.to,
93+
resolved_mailbox: resolvedMailbox || message.to,
94+
via_alias: !!resolvedMailbox,
8495
subject: message.headers.get("subject") || "",
8596
received_at: receivedAt,
8697
r2_key: r2Key,
@@ -92,31 +103,21 @@ export default {
92103
if (webhookResponse.ok) {
93104
const body = await webhookResponse.json().catch(() => ({}));
94105
if (body.reason === 'over_hard_quota') {
95-
// Mail Manager rejected the email (hard quota exceeded) and already cleaned
96-
// up R2 + the DB entry. Reject at SMTP level so sender gets an NDR bounce.
97106
message.setReject("550 5.2.2 Mailbox full");
98107
return;
99108
}
100-
// On success the mail-manager deletes the R2 file itself after processing.
101109
} else {
102-
// DockFlare returned an error — leave email in R2 for cron retry.
103-
// The email is safely buffered and will be delivered
104-
// automatically when DockFlare is healthy again.
105110
console.warn(`Webhook returned ${webhookResponse.status} for ${messageId} — buffered in R2 for retry`);
106111
}
107112
} catch (webhookErr) {
108-
// DockFlare is unreachable (offline, timeout, network error).
109-
// Email is already in R2. Cron will retry. Do NOT reject.
110113
console.warn(`Webhook unreachable for ${messageId} — buffered in R2 for retry: ${webhookErr.message}`);
111114
}
112115

113116
} catch (err) {
114-
// Only reject if failed to store the email in R2 (truly unrecoverable). - reminder need some tests still
115117
message.setReject(`Worker error: ${err.message}`);
116118
}
117119
},
118120

119-
// ── Cron trigger: retry buffered emails in R2 ─────────────────────────-..-.-.-.-────
120121
async scheduled(event, env, ctx) {
121122
console.log("Cron: scanning R2 temp_cache for buffered emails...");
122123

@@ -140,6 +141,8 @@ export default {
140141
message_id: messageId,
141142
from: meta.from || "",
142143
to: meta.to || "",
144+
resolved_mailbox: meta.resolved_mailbox || meta.to || "",
145+
via_alias: meta.via_alias === "1",
143146
subject: meta.subject || "",
144147
received_at: meta.receivedAt || new Date().toISOString(),
145148
r2_key: r2Key,
@@ -151,8 +154,6 @@ export default {
151154
if (response.ok) {
152155
const body = await response.json().catch(() => ({}));
153156
if (body.reason === 'over_hard_quota') {
154-
// Mailbox was full when cron retried — webhook already cleaned R2 + set KV block.
155-
// Count as processed (not a retry-able failure).
156157
console.warn(`Cron: buffered email ${messageId} rejected (over_hard_quota) — R2 cleaned by Mail Manager`);
157158
processed++;
158159
} else {
@@ -165,7 +166,6 @@ export default {
165166
failed++;
166167
}
167168
} catch (err) {
168-
// DockFlare still offline — will retry on next cron run
169169
console.warn(`Cron: DockFlare still unreachable for ${messageId}: ${err.message}`);
170170
failed++;
171171
}

dockflare/app/core/worker_templates/outbound_worker.js

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,8 @@ export default {
1010
return new Response("Unauthorized", { status: 401 });
1111
}
1212
const body = await request.json();
13-
const toHeader = Array.isArray(body.to) ? body.to.join(", ") : body.to;
14-
const rawTo = Array.isArray(body.to) ? body.to[0] : body.to;
15-
const addrMatch = typeof rawTo === 'string' ? rawTo.match(/<([^>]+)>/) : null;
16-
const toAddress = addrMatch ? addrMatch[1] : (typeof rawTo === 'string' ? rawTo.trim() : rawTo);
13+
const toList = Array.isArray(body.to) ? body.to : [body.to];
14+
const toHeader = toList.join(", ");
1715

1816
const attachments = Array.isArray(body.attachments) ? body.attachments.filter(a => a && a.data_b64) : [];
1917
const hasAttachments = attachments.length > 0;
@@ -73,9 +71,13 @@ export default {
7371
mimeMessage += `--${innerBoundary}--\r\n`;
7472
}
7573

76-
const message = new EmailMessage(body.from, toAddress, mimeMessage);
7774
try {
78-
await env.SEND_EMAIL.send(message);
75+
for (const recipient of toList) {
76+
const addrMatch = typeof recipient === 'string' ? recipient.match(/<([^>]+)>/) : null;
77+
const toAddress = addrMatch ? addrMatch[1] : (typeof recipient === 'string' ? recipient.trim() : recipient);
78+
const message = new EmailMessage(body.from, toAddress, mimeMessage);
79+
await env.SEND_EMAIL.send(message);
80+
}
7981
return new Response(JSON.stringify({ success: true, message_id: body.messageId }), {
8082
status: 200,
8183
headers: { "Content-Type": "application/json" }

dockflare/app/web/email_routes.py

Lines changed: 79 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -399,15 +399,15 @@ def _redeploy_inbound_worker(email_cfg, domain):
399399

400400
kv_ns_id = d.get('quota_kv_namespace_id')
401401
if not kv_ns_id:
402-
try:
403-
kv_ns_id = email_manager.create_kv_namespace(
404-
f"dockflare-quota-{domain.replace('.', '-')}"
405-
)
402+
kv_ns_id = email_manager.get_or_create_kv_namespace(
403+
f"dockflare-quota-{domain.replace('.', '-')}"
404+
)
405+
if kv_ns_id:
406406
email_cfg['domains'][domain]['quota_kv_namespace_id'] = kv_ns_id
407407
save_email_config(email_cfg)
408-
logging.info(f"Created quota KV namespace for existing domain {domain}: {kv_ns_id}")
409-
except Exception as e:
410-
logging.warning(f"Could not create quota KV namespace for {domain}: {e}")
408+
logging.info(f"Resolved quota KV namespace for {domain}: {kv_ns_id}")
409+
else:
410+
logging.warning(f"Could not resolve quota KV namespace for {domain}")
411411

412412
catch_all_address = d.get('catch_all_address', '')
413413
catch_all_enabled = 'true' if catch_all_address else 'false'
@@ -1190,6 +1190,78 @@ def quota_kv_sync():
11901190
return jsonify({'status': 'ok', 'action': action})
11911191

11921192

1193+
@email_bp.route('/internal/alias-kv-sync', methods=['POST'])
1194+
def alias_kv_sync():
1195+
if not _check_internal_request():
1196+
return jsonify({'error': 'forbidden'}), 403
1197+
data = request.get_json(force=True, silent=True) or {}
1198+
domain = data.get('domain')
1199+
alias_address = data.get('alias_address')
1200+
action = data.get('action')
1201+
mailbox_address = data.get('mailbox_address')
1202+
if not domain or not alias_address or action not in ('put', 'delete'):
1203+
return jsonify({'error': 'missing domain, alias_address, or valid action'}), 400
1204+
if action == 'put' and not mailbox_address:
1205+
return jsonify({'error': 'mailbox_address required for put'}), 400
1206+
cfg = config.EMAIL_CONFIG
1207+
if not cfg or domain not in cfg.get('domains', {}):
1208+
return jsonify({'status': 'domain_not_found'}), 200
1209+
kv_ns_id = cfg['domains'][domain].get('quota_kv_namespace_id')
1210+
if not kv_ns_id:
1211+
return jsonify({'status': 'no_kv'}), 200
1212+
kv_key = f"alias::{alias_address}"
1213+
try:
1214+
if action == 'put':
1215+
email_manager.update_kv_entry(kv_ns_id, kv_key, {"mailbox": mailbox_address})
1216+
else:
1217+
email_manager.delete_kv_entry(kv_ns_id, kv_key)
1218+
except Exception as e:
1219+
logging.warning(f"alias-kv-sync {action} failed for {alias_address}: {e}")
1220+
return jsonify({'error': str(e)}), 500
1221+
return jsonify({'status': 'ok', 'action': action})
1222+
1223+
1224+
@email_bp.route('/domain/<domain>/aliases', methods=['GET'])
1225+
@login_required
1226+
def domain_aliases(domain):
1227+
import requests as req_lib
1228+
token = _generate_jwt(current_user.get_id(), role='admin')
1229+
if not token:
1230+
return jsonify({'error': 'JWT configuration missing'}), 500
1231+
try:
1232+
resp = req_lib.get(
1233+
f"{config.MAIL_MANAGER_INTERNAL_URL}/api/v1/system/aliases",
1234+
headers={'Authorization': f'Bearer {token}'},
1235+
params={'domain': domain},
1236+
timeout=10,
1237+
)
1238+
if resp.ok:
1239+
return jsonify(resp.json())
1240+
return jsonify({'error': 'mail-manager unreachable'}), 502
1241+
except Exception as e:
1242+
return jsonify({'error': str(e)}), 500
1243+
1244+
1245+
@email_bp.route('/alias/<path:address>/delete', methods=['POST'])
1246+
@login_required
1247+
def admin_delete_alias(address):
1248+
import requests as req_lib
1249+
token = _generate_jwt(current_user.get_id(), role='admin')
1250+
if not token:
1251+
return jsonify({'error': 'JWT configuration missing'}), 500
1252+
try:
1253+
resp = req_lib.delete(
1254+
f"{config.MAIL_MANAGER_INTERNAL_URL}/api/v1/aliases/{address}",
1255+
headers={'Authorization': f'Bearer {token}'},
1256+
timeout=10,
1257+
)
1258+
if resp.ok:
1259+
return jsonify({'status': 'deleted'})
1260+
return jsonify({'error': resp.text[:200]}), resp.status_code
1261+
except Exception as e:
1262+
return jsonify({'error': str(e)}), 500
1263+
1264+
11931265
def _restart_mail_container():
11941266
try:
11951267
container = docker_client.containers.get('dockflare-mail-manager')

dockflare/app/web/routes.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ def gating_logic():
188188
return
189189

190190
if not current_user.is_authenticated:
191-
exempt_endpoints = ['static', 'web.ping', 'web.cloudflare_ping_route', 'setup.step_import_env', 'email.internal_mail_config', 'email.mailbox_login', 'email.quota_kv_sync']
191+
exempt_endpoints = ['static', 'web.ping', 'web.cloudflare_ping_route', 'setup.step_import_env', 'email.internal_mail_config', 'email.mailbox_login', 'email.quota_kv_sync', 'email.alias_kv_sync']
192192
oauth_endpoints = ['web.login_provider', 'web.auth_callback', 'web.login']
193193
if request.endpoint and not request.endpoint.startswith('auth.') and request.endpoint not in exempt_endpoints and request.endpoint not in oauth_endpoints:
194194
try:

0 commit comments

Comments
 (0)