diff --git a/CHANGELOG.md b/CHANGELOG.md index d066a3c..c34026e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,28 @@ All notable changes to this project will be documented in this file. +## [v3.1.1] - 2026-04-24 + +### Added +- **Email Alias System:** A disposable email alias layer for the DockFlare Email Suite. + - **Alias Creation:** Generate aliases with three styles (`word-word-num`, `word-num`, `uuid-short`) or define a custom local-part. Each alias is bound to a mailbox and routed transparently. + - **Inbound Forwarding:** Aliases are enforced at the Cloudflare Worker layer via KV lookups mail to an unknown alias is rejected at the SMTP level before reaching the server. Quota enforcement follows the resolved mailbox. + - **Outbound Reply Support:** When replying to an email received via an alias, the webmail compose view pre-selects the alias as the sender. A From dropdown appears whenever aliases are configured on the active mailbox. + - **Alias Management UI:** Full alias management from the webmail Settings panel create, toggle active/inactive, set an optional expiry date, add a label and description, and delete. Usage count and last-use timestamp are tracked per alias. + - **Alias Expiry:** An hourly background job deactivates expired aliases and removes their KV entries from Cloudflare automatically. + - **Per-Alias Rate Limiting:** Maximum 20 alias creations per hour and 100 aliases per mailbox enforced server-side. +- **Webmail Compose Enhancements:** + - **Multi-Recipient To Field:** The compose dialog now supports multiple recipients in the To field. Addresses are entered as chips — press Enter, Tab, or comma to confirm. Paste a comma-separated list to add multiple at once. + - **Cc and Bcc Fields:** Cc and Bcc fields are hidden by default and revealed via inline buttons. + - **Sent Folder Recipient Display:** Message list items in the Sent folder now show recipient addresses instead of the sender's own address. + - **Emoji Picker:** An emoji selector is available in the compose toolbar. Searchable, categorized, and lazy-loaded with no impact on initial bundle size. + - **Inline Link Popover:** The insert-link toolbar button now opens an inline popover with a URL input and Apply/Cancel actions, replacing the browser-native prompt dialog. +- **Mobile Support:** The webmail is now fully responsive for phones and small screens. + - **Single-Panel Navigation:** On mobile, the three-panel desktop layout switches to a stacked single-panel view — Folders → Message List → Message Detail — with a back button to navigate up the stack. + - **Bottom Navigation Bar:** A persistent bottom bar provides quick access to Folders, Compose, and Mail with a floating action button for compose. + - **Full-Screen Compose:** The compose dialog opens as a full-screen overlay on mobile instead of the desktop floating popup. + - **iOS Safe Area Support:** Bottom navigation respects the iOS home indicator safe area via `viewport-fit=cover`. + ## [v3.1.0] - 2026-04-16 > **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/) diff --git a/dockflare/app/config.py b/dockflare/app/config.py index 4209a32..2372a0d 100644 --- a/dockflare/app/config.py +++ b/dockflare/app/config.py @@ -35,7 +35,7 @@ def _get_int_env(name, default, minimum=None): return default # --- DockFlare Version --- -APP_VERSION = "v3.1.0" +APP_VERSION = "v3.1.1" # --- web: https://dockflare.app --- # --- github: https://github.com/ChrispyBacon-dev/DockFlare --- diff --git a/dockflare/app/core/email_manager.py b/dockflare/app/core/email_manager.py index 5a26e6f..2f5f543 100644 --- a/dockflare/app/core/email_manager.py +++ b/dockflare/app/core/email_manager.py @@ -337,6 +337,28 @@ def create_kv_namespace(title): json_data={'title': title}) return res.get('result', {}).get('id') + +def get_or_create_kv_namespace(title): + try: + ns_id = create_kv_namespace(title) + if ns_id: + return ns_id + except Exception: + pass + page = 1 + while True: + res = cf_api_request('GET', f'/accounts/{config.CF_ACCOUNT_ID}/storage/kv/namespaces', + params={'per_page': 100, 'page': page}) + results = res.get('result') or [] + for ns in results: + if ns.get('title') == title: + return ns.get('id') + info = res.get('result_info', {}) + if page >= info.get('total_pages', 1): + break + page += 1 + return None + def update_kv_entry(namespace_id, key, value_dict): url = f"{config.CF_API_BASE_URL}/accounts/{config.CF_ACCOUNT_ID}/storage/kv/namespaces/{namespace_id}/values/{key}" headers = {"Authorization": f"Bearer {config.CF_API_TOKEN}", "Content-Type": "text/plain"} diff --git a/dockflare/app/core/worker_templates/inbound_worker.js b/dockflare/app/core/worker_templates/inbound_worker.js index 47d6031..418a589 100644 --- a/dockflare/app/core/worker_templates/inbound_worker.js +++ b/dockflare/app/core/worker_templates/inbound_worker.js @@ -30,10 +30,11 @@ async function dispatchWebhook(env, payload) { } export default { - // ── Inbound email handler ──────────────────────────────────────────────────-.--...-- async email(message, env, ctx) { try { + let resolvedMailbox = null; const catchAllEnabled = env.CATCH_ALL_ENABLED === 'true'; + if (catchAllEnabled) { const domain = (env.DOMAIN_NAME || '').toLowerCase(); if (!message.to.toLowerCase().endsWith('@' + domain)) { @@ -43,21 +44,28 @@ export default { } else { const allowedRecipients = JSON.parse(env.ALLOWED_RECIPIENTS || '[]'); if (!allowedRecipients.includes(message.to)) { - message.setReject("Recipient not allowed"); - return; + let aliasRecord = null; + try { + aliasRecord = await env.QUOTA_KV.get('alias::' + message.to, 'json'); + } catch (_) {} + + if (!aliasRecord) { + message.setReject("Recipient not allowed"); + return; + } + resolvedMailbox = aliasRecord.mailbox; } } - // Check quota KV before accepting — reject at SMTP level so sender gets a bounce if (typeof env.QUOTA_KV !== 'undefined') { try { - const state = await env.QUOTA_KV.get(message.to, "json"); + const quotaTarget = resolvedMailbox || message.to; + const state = await env.QUOTA_KV.get(quotaTarget, "json"); if (state?.blocked) { message.setReject("550 5.2.2 Mailbox full"); return; } } catch (kvErr) { - // KV unavailable — fall through, webhook safety net handles enforcement console.warn(`KV quota check failed for ${message.to}: ${kvErr.message}`); } } @@ -66,12 +74,13 @@ export default { const r2Key = `temp_cache/${messageId}.eml`; const receivedAt = new Date().toISOString(); - // Upload to R2 first — email is now safely buffered regardless of what happens next const rawBytes = await new Response(message.raw).arrayBuffer(); await env.EMAIL_BUCKET.put(r2Key, rawBytes, { customMetadata: { from: message.from, to: message.to, + resolved_mailbox: resolvedMailbox || message.to, + via_alias: resolvedMailbox ? "1" : "0", subject: message.headers.get("subject") || "", receivedAt: receivedAt } @@ -81,6 +90,8 @@ export default { message_id: messageId, from: message.from, to: message.to, + resolved_mailbox: resolvedMailbox || message.to, + via_alias: !!resolvedMailbox, subject: message.headers.get("subject") || "", received_at: receivedAt, r2_key: r2Key, @@ -92,31 +103,21 @@ export default { if (webhookResponse.ok) { const body = await webhookResponse.json().catch(() => ({})); if (body.reason === 'over_hard_quota') { - // Mail Manager rejected the email (hard quota exceeded) and already cleaned - // up R2 + the DB entry. Reject at SMTP level so sender gets an NDR bounce. message.setReject("550 5.2.2 Mailbox full"); return; } - // On success the mail-manager deletes the R2 file itself after processing. } else { - // DockFlare returned an error — leave email in R2 for cron retry. - // The email is safely buffered and will be delivered - // automatically when DockFlare is healthy again. console.warn(`Webhook returned ${webhookResponse.status} for ${messageId} — buffered in R2 for retry`); } } catch (webhookErr) { - // DockFlare is unreachable (offline, timeout, network error). - // Email is already in R2. Cron will retry. Do NOT reject. console.warn(`Webhook unreachable for ${messageId} — buffered in R2 for retry: ${webhookErr.message}`); } } catch (err) { - // Only reject if failed to store the email in R2 (truly unrecoverable). - reminder need some tests still message.setReject(`Worker error: ${err.message}`); } }, - // ── Cron trigger: retry buffered emails in R2 ─────────────────────────-..-.-.-.-──── async scheduled(event, env, ctx) { console.log("Cron: scanning R2 temp_cache for buffered emails..."); @@ -140,6 +141,8 @@ export default { message_id: messageId, from: meta.from || "", to: meta.to || "", + resolved_mailbox: meta.resolved_mailbox || meta.to || "", + via_alias: meta.via_alias === "1", subject: meta.subject || "", received_at: meta.receivedAt || new Date().toISOString(), r2_key: r2Key, @@ -151,8 +154,6 @@ export default { if (response.ok) { const body = await response.json().catch(() => ({})); if (body.reason === 'over_hard_quota') { - // Mailbox was full when cron retried — webhook already cleaned R2 + set KV block. - // Count as processed (not a retry-able failure). console.warn(`Cron: buffered email ${messageId} rejected (over_hard_quota) — R2 cleaned by Mail Manager`); processed++; } else { @@ -165,7 +166,6 @@ export default { failed++; } } catch (err) { - // DockFlare still offline — will retry on next cron run console.warn(`Cron: DockFlare still unreachable for ${messageId}: ${err.message}`); failed++; } diff --git a/dockflare/app/core/worker_templates/outbound_worker.js b/dockflare/app/core/worker_templates/outbound_worker.js index db57b6c..21b632d 100644 --- a/dockflare/app/core/worker_templates/outbound_worker.js +++ b/dockflare/app/core/worker_templates/outbound_worker.js @@ -10,10 +10,8 @@ export default { return new Response("Unauthorized", { status: 401 }); } const body = await request.json(); - const toHeader = Array.isArray(body.to) ? body.to.join(", ") : body.to; - const rawTo = Array.isArray(body.to) ? body.to[0] : body.to; - const addrMatch = typeof rawTo === 'string' ? rawTo.match(/<([^>]+)>/) : null; - const toAddress = addrMatch ? addrMatch[1] : (typeof rawTo === 'string' ? rawTo.trim() : rawTo); + const toList = Array.isArray(body.to) ? body.to : [body.to]; + const toHeader = toList.join(", "); const attachments = Array.isArray(body.attachments) ? body.attachments.filter(a => a && a.data_b64) : []; const hasAttachments = attachments.length > 0; @@ -73,9 +71,13 @@ export default { mimeMessage += `--${innerBoundary}--\r\n`; } - const message = new EmailMessage(body.from, toAddress, mimeMessage); try { - await env.SEND_EMAIL.send(message); + for (const recipient of toList) { + const addrMatch = typeof recipient === 'string' ? recipient.match(/<([^>]+)>/) : null; + const toAddress = addrMatch ? addrMatch[1] : (typeof recipient === 'string' ? recipient.trim() : recipient); + const message = new EmailMessage(body.from, toAddress, mimeMessage); + await env.SEND_EMAIL.send(message); + } return new Response(JSON.stringify({ success: true, message_id: body.messageId }), { status: 200, headers: { "Content-Type": "application/json" } diff --git a/dockflare/app/web/email_routes.py b/dockflare/app/web/email_routes.py index 820e7ac..2846168 100644 --- a/dockflare/app/web/email_routes.py +++ b/dockflare/app/web/email_routes.py @@ -399,15 +399,15 @@ def _redeploy_inbound_worker(email_cfg, domain): kv_ns_id = d.get('quota_kv_namespace_id') if not kv_ns_id: - try: - kv_ns_id = email_manager.create_kv_namespace( - f"dockflare-quota-{domain.replace('.', '-')}" - ) + kv_ns_id = email_manager.get_or_create_kv_namespace( + f"dockflare-quota-{domain.replace('.', '-')}" + ) + if kv_ns_id: email_cfg['domains'][domain]['quota_kv_namespace_id'] = kv_ns_id save_email_config(email_cfg) - logging.info(f"Created quota KV namespace for existing domain {domain}: {kv_ns_id}") - except Exception as e: - logging.warning(f"Could not create quota KV namespace for {domain}: {e}") + logging.info(f"Resolved quota KV namespace for {domain}: {kv_ns_id}") + else: + logging.warning(f"Could not resolve quota KV namespace for {domain}") catch_all_address = d.get('catch_all_address', '') catch_all_enabled = 'true' if catch_all_address else 'false' @@ -1190,6 +1190,78 @@ def quota_kv_sync(): return jsonify({'status': 'ok', 'action': action}) +@email_bp.route('/internal/alias-kv-sync', methods=['POST']) +def alias_kv_sync(): + if not _check_internal_request(): + return jsonify({'error': 'forbidden'}), 403 + data = request.get_json(force=True, silent=True) or {} + domain = data.get('domain') + alias_address = data.get('alias_address') + action = data.get('action') + mailbox_address = data.get('mailbox_address') + if not domain or not alias_address or action not in ('put', 'delete'): + return jsonify({'error': 'missing domain, alias_address, or valid action'}), 400 + if action == 'put' and not mailbox_address: + return jsonify({'error': 'mailbox_address required for put'}), 400 + cfg = config.EMAIL_CONFIG + if not cfg or domain not in cfg.get('domains', {}): + return jsonify({'status': 'domain_not_found'}), 200 + kv_ns_id = cfg['domains'][domain].get('quota_kv_namespace_id') + if not kv_ns_id: + return jsonify({'status': 'no_kv'}), 200 + kv_key = f"alias::{alias_address}" + try: + if action == 'put': + email_manager.update_kv_entry(kv_ns_id, kv_key, {"mailbox": mailbox_address}) + else: + email_manager.delete_kv_entry(kv_ns_id, kv_key) + except Exception as e: + logging.warning(f"alias-kv-sync {action} failed for {alias_address}: {e}") + return jsonify({'error': str(e)}), 500 + return jsonify({'status': 'ok', 'action': action}) + + +@email_bp.route('/domain//aliases', methods=['GET']) +@login_required +def domain_aliases(domain): + import requests as req_lib + token = _generate_jwt(current_user.get_id(), role='admin') + if not token: + return jsonify({'error': 'JWT configuration missing'}), 500 + try: + resp = req_lib.get( + f"{config.MAIL_MANAGER_INTERNAL_URL}/api/v1/system/aliases", + headers={'Authorization': f'Bearer {token}'}, + params={'domain': domain}, + timeout=10, + ) + if resp.ok: + return jsonify(resp.json()) + return jsonify({'error': 'mail-manager unreachable'}), 502 + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +@email_bp.route('/alias//delete', methods=['POST']) +@login_required +def admin_delete_alias(address): + import requests as req_lib + token = _generate_jwt(current_user.get_id(), role='admin') + if not token: + return jsonify({'error': 'JWT configuration missing'}), 500 + try: + resp = req_lib.delete( + f"{config.MAIL_MANAGER_INTERNAL_URL}/api/v1/aliases/{address}", + headers={'Authorization': f'Bearer {token}'}, + timeout=10, + ) + if resp.ok: + return jsonify({'status': 'deleted'}) + return jsonify({'error': resp.text[:200]}), resp.status_code + except Exception as e: + return jsonify({'error': str(e)}), 500 + + def _restart_mail_container(): try: container = docker_client.containers.get('dockflare-mail-manager') diff --git a/dockflare/app/web/routes.py b/dockflare/app/web/routes.py index 18d6144..5a1b22f 100644 --- a/dockflare/app/web/routes.py +++ b/dockflare/app/web/routes.py @@ -188,7 +188,7 @@ def gating_logic(): return if not current_user.is_authenticated: - exempt_endpoints = ['static', 'web.ping', 'web.cloudflare_ping_route', 'setup.step_import_env', 'email.internal_mail_config', 'email.mailbox_login', 'email.quota_kv_sync'] + 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'] oauth_endpoints = ['web.login_provider', 'web.auth_callback', 'web.login'] if request.endpoint and not request.endpoint.startswith('auth.') and request.endpoint not in exempt_endpoints and request.endpoint not in oauth_endpoints: try: diff --git a/mail-manager/app/api/routes.py b/mail-manager/app/api/routes.py index 8af8fa0..6bef193 100644 --- a/mail-manager/app/api/routes.py +++ b/mail-manager/app/api/routes.py @@ -6,7 +6,7 @@ import re import shutil import threading import uuid -from datetime import datetime, timezone +from datetime import datetime, timezone, timedelta import requests as http_requests from flask import Blueprint, request, jsonify, send_file @@ -15,6 +15,7 @@ from app.config import config from app.core.database import get_db from app.api.middleware import jwt_required, admin_required from app.core.rate_limiter import limiter +from app.core.alias_words import generate_alias, validate_alias_address log = logging.getLogger(__name__) api_bp = Blueprint('api', __name__) @@ -749,7 +750,7 @@ def _local_deliver(db, recipient_addr, from_address, to_field, data, subject, te log.info("Local delivery: msg_id=%s to=%s", local_msg_id, recipient_addr) -def _dispatch_send(address, data): +def _dispatch_send(address, data, effective_from=None, via_alias=None): allowed, reason = limiter.check_rate(address) if not allowed: return jsonify({"error": reason}), 429 @@ -760,6 +761,16 @@ def _dispatch_send(address, data): if not to_field: return jsonify({"error": "to is required"}), 400 + cc_field = data.get('cc') or [] + if isinstance(cc_field, str): + cc_field = [cc_field] + data['cc'] = cc_field + + bcc_field = data.get('bcc') or [] + if isinstance(bcc_field, str): + bcc_field = [bcc_field] + data['bcc'] = bcc_field + subject = data.get('subject', '') text = data.get('text') or data.get('text_body', '') html = data.get('html') or data.get('html_body', '') @@ -773,6 +784,7 @@ def _dispatch_send(address, data): if total_attach > _MAX_ATTACH_BYTES: return jsonify({"error": "attachments exceed 10 MB limit"}), 413 + from_address = effective_from or address now = datetime.now(timezone.utc).isoformat() msg_id = f"<{uuid.uuid4()}@{address.split('@')[1]}>" @@ -807,7 +819,7 @@ def _dispatch_send(address, data): if external_recipients: worker_payload = { - "from": address, + "from": from_address, "to": external_recipients, "cc": data.get('cc'), "bcc": data.get('bcc'), @@ -840,8 +852,8 @@ def _dispatch_send(address, data): error_msg = 'Outbound worker not configured' db.execute( - "INSERT INTO send_log (message_id, from_address, to_addresses, subject, sent_at, status, error_message, worker_response) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", - (msg_id, address, json.dumps(to_field), subject, now, status, error_msg, worker_resp), + "INSERT INTO send_log (message_id, from_address, to_addresses, subject, sent_at, status, error_message, worker_response, via_alias) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + (msg_id, from_address, json.dumps(to_field), subject, now, status, error_msg, worker_resp, via_alias), ) if status == 'sent': @@ -860,7 +872,7 @@ def _dispatch_send(address, data): reference_ids, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 0, 0, ?, ?, ?) """, ( - msg_id, address, sent_folder['id'], address, + msg_id, address, sent_folder['id'], from_address, json.dumps(to_field), json.dumps(data.get('cc') or []), subject, text, html, now, data.get('in_reply_to') or data.get('inReplyTo', ''), @@ -891,15 +903,12 @@ def send_email(address): if not _check_mailbox_access(address): return jsonify({"error": "forbidden"}), 403 if request.content_type and 'multipart/form-data' in request.content_type: - data = dict(request.form) - # Flatten single-value lists produced by request.form - data = {k: v[0] if isinstance(v, list) and len(v) == 1 else v for k, v in data.items()} + data = {k: request.form.getlist(k) if len(request.form.getlist(k)) > 1 else request.form.get(k) + for k in request.form.keys()} files = request.files.getlist('attachments') - log.debug("send_email multipart: form_keys=%s file_count=%d", list(data.keys()), len(files)) attachments = [] for f in files: raw = f.read() - log.debug("send_email attachment: filename=%s content_type=%s size=%d", f.filename, f.content_type, len(raw)) attachments.append({ 'filename': f.filename, 'content_type': f.content_type or 'application/octet-stream', @@ -909,7 +918,24 @@ def send_email(address): data['attachments'] = attachments else: data = request.json or {} - return _dispatch_send(address, data) + + from_override = (data.get('from_address') or '').strip() + effective_from = None + via_alias = None + + if from_override and from_override != address: + db = get_db() + now_iso = datetime.now(timezone.utc).isoformat() + alias_row = db.execute( + "SELECT address FROM aliases WHERE address=? AND mailbox_address=? AND is_active=1 AND (expires_at IS NULL OR expires_at > ?)", + (from_override, address, now_iso) + ).fetchone() + if not alias_row: + return jsonify({"error": "unauthorized_sender"}), 403 + effective_from = from_override + via_alias = from_override + + return _dispatch_send(address, data, effective_from=effective_from, via_alias=via_alias) @api_bp.route('/mailboxes/
/drafts', methods=['POST']) @@ -1233,3 +1259,264 @@ def list_auto_responders(): db = get_db() rows = db.execute("SELECT mailbox_address, is_active FROM auto_responders").fetchall() return jsonify({"auto_responders": [dict(r) for r in rows]}) + + +_ALIAS_MAX_PER_MAILBOX = int(os.environ.get('ALIAS_MAX_PER_MAILBOX', 100)) +_ALIAS_RATE_LIMIT_PER_HOUR = 20 + + +def _sync_alias_kv(alias_address, mailbox_address, action): + master_url = os.environ.get('DOCKFLARE_MASTER_URL', '').rstrip('/') + if not master_url: + return False + payload = {"domain": alias_address.split('@')[1], "alias_address": alias_address, "action": action} + if action == "put": + payload["mailbox_address"] = mailbox_address + try: + resp = http_requests.post( + f"{master_url}/email/internal/alias-kv-sync", + json=payload, + headers={"X-Bootstrap-Token": os.environ.get("INTERNAL_BOOTSTRAP_SECRET", "")}, + timeout=5, + ) + if not resp.ok: + log.error("alias-kv-sync %s failed for %s: HTTP %s %s", action, alias_address, resp.status_code, resp.text[:200]) + return resp.ok + except Exception as e: + log.error("alias-kv-sync %s failed for %s: %s", action, alias_address, e) + return False + + +def _alias_to_dict(row): + return { + "address": row["address"], + "mailbox_address": row["mailbox_address"], + "domain": row["domain"], + "label": row["label"], + "description": row["description"], + "is_active": bool(row["is_active"]), + "expires_at": row["expires_at"], + "created_at": row["created_at"], + "use_count": row["use_count"], + "last_use_at": row["last_use_at"], + } + + +def _caller_mailboxes(): + user = request.user + if user.get('role') == 'admin': + return None + return set(user.get('mailboxes', [])) + + +def _check_alias_ownership(alias_row): + user = request.user + if user.get('role') == 'admin': + return True + return alias_row['mailbox_address'] in user.get('mailboxes', []) + + +@api_bp.route('/aliases', methods=['GET']) +@jwt_required +def list_aliases(): + db = get_db() + caller_mailboxes = _caller_mailboxes() + domain_filter = request.args.get('domain', '').strip() + active_filter = request.args.get('active', '') + label_filter = request.args.get('label', '').strip() + mailbox_filter = request.args.get('mailbox', '').strip() + + conditions = [] + params = [] + + if caller_mailboxes is not None: + placeholders = ','.join('?' * len(caller_mailboxes)) + conditions.append(f"mailbox_address IN ({placeholders})") + params.extend(list(caller_mailboxes)) + elif mailbox_filter: + conditions.append("mailbox_address = ?") + params.append(mailbox_filter) + + if domain_filter: + conditions.append("domain = ?") + params.append(domain_filter) + if active_filter != '': + conditions.append("is_active = ?") + params.append(1 if active_filter in ('1', 'true') else 0) + if label_filter: + conditions.append("label = ?") + params.append(label_filter) + + where = ("WHERE " + " AND ".join(conditions)) if conditions else "" + rows = db.execute(f"SELECT * FROM aliases {where} ORDER BY created_at DESC", params).fetchall() + aliases = [_alias_to_dict(r) for r in rows] + + now_iso = datetime.now(timezone.utc).isoformat() + total = len(aliases) + active = sum(1 for a in aliases if a['is_active'] and (not a['expires_at'] or a['expires_at'] > now_iso)) + expired = sum(1 for a in aliases if a['expires_at'] and a['expires_at'] <= now_iso) + disabled = sum(1 for a in aliases if not a['is_active']) + + return jsonify({"aliases": aliases, "total": total, "active": active, "expired": expired, "disabled": disabled}) + + +@api_bp.route('/aliases/generate', methods=['POST']) +@jwt_required +def generate_alias_suggestion(): + data = request.json or {} + mailbox_address = (data.get('mailbox_address') or '').strip() + domain = (data.get('domain') or '').strip() + style = data.get('style', 'word-word-num') + + if not mailbox_address or not domain: + return jsonify({"error": "mailbox_address and domain are required"}), 400 + if style not in ('word-word-num', 'word-num', 'uuid-short'): + return jsonify({"error": "invalid style"}), 400 + + caller_mailboxes = _caller_mailboxes() + if caller_mailboxes is not None and mailbox_address not in caller_mailboxes: + return jsonify({"error": "forbidden"}), 403 + + db = get_db() + if not db.execute("SELECT 1 FROM mailboxes WHERE address=? AND domain=?", (mailbox_address, domain)).fetchone(): + return jsonify({"error": "mailbox not found for domain"}), 404 + + suggestion = generate_alias(domain, style=style, db=db) + return jsonify({"suggestion": suggestion, "available": True}) + + +@api_bp.route('/aliases', methods=['POST']) +@jwt_required +def create_alias(): + data = request.json or {} + address = (data.get('address') or '').strip().lower() + mailbox_address = (data.get('mailbox_address') or '').strip() + label = (data.get('label') or '').strip() or None + description = (data.get('description') or '').strip()[:200] or None + expires_at = data.get('expires_at') or None + + if not address or not mailbox_address: + return jsonify({"error": "address and mailbox_address are required"}), 400 + + valid, err = validate_alias_address(address) + if not valid: + return jsonify({"error": err}), 400 + + alias_domain = address.split('@')[1] + + caller_mailboxes = _caller_mailboxes() + if caller_mailboxes is not None and mailbox_address not in caller_mailboxes: + return jsonify({"error": "forbidden"}), 403 + + db = get_db() + + if not db.execute("SELECT 1 FROM mailboxes WHERE address=? AND domain=?", (mailbox_address, alias_domain)).fetchone(): + return jsonify({"error": "mailbox not found or domain mismatch"}), 403 + + if db.execute("SELECT 1 FROM aliases WHERE address=?", (address,)).fetchone(): + return jsonify({"error": "alias already exists"}), 409 + if db.execute("SELECT 1 FROM mailboxes WHERE address=?", (address,)).fetchone(): + return jsonify({"error": "address already used as mailbox"}), 409 + + now_iso = datetime.now(timezone.utc).isoformat() + + if expires_at: + if not isinstance(expires_at, str) or expires_at <= now_iso: + return jsonify({"error": "expires_at must be a future ISO-8601 datetime"}), 400 + + cutoff = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat() + rate_count = db.execute( + "SELECT COUNT(*) FROM aliases WHERE mailbox_address=? AND created_at > ?", + (mailbox_address, cutoff) + ).fetchone()[0] + if rate_count >= _ALIAS_RATE_LIMIT_PER_HOUR: + return jsonify({"error": "rate_limit", "message": "max 20 aliases per hour"}), 429 + + total_count = db.execute( + "SELECT COUNT(*) FROM aliases WHERE mailbox_address=?", (mailbox_address,) + ).fetchone()[0] + if total_count >= _ALIAS_MAX_PER_MAILBOX: + return jsonify({"error": "alias_limit_reached", "message": f"max {_ALIAS_MAX_PER_MAILBOX} aliases per mailbox"}), 429 + + db.execute( + "INSERT INTO aliases (address, mailbox_address, domain, label, description, is_active, expires_at, created_at, use_count) VALUES (?, ?, ?, ?, ?, 1, ?, ?, 0)", + (address, mailbox_address, alias_domain, label, description, expires_at, now_iso) + ) + db.commit() + + threading.Thread(target=_sync_alias_kv, args=(address, mailbox_address, "put"), daemon=True).start() + + row = db.execute("SELECT * FROM aliases WHERE address=?", (address,)).fetchone() + return jsonify(_alias_to_dict(row)), 201 + + +@api_bp.route('/aliases/', methods=['GET']) +@jwt_required +def get_alias(address): + db = get_db() + row = db.execute("SELECT * FROM aliases WHERE address=?", (address,)).fetchone() + if not row: + return jsonify({"error": "not found"}), 404 + if not _check_alias_ownership(row): + return jsonify({"error": "forbidden"}), 403 + return jsonify(_alias_to_dict(row)) + + +@api_bp.route('/aliases/', methods=['PATCH']) +@jwt_required +def update_alias(address): + db = get_db() + row = db.execute("SELECT * FROM aliases WHERE address=?", (address,)).fetchone() + if not row: + return jsonify({"error": "not found"}), 404 + if not _check_alias_ownership(row): + return jsonify({"error": "forbidden"}), 403 + + data = request.json or {} + updates = {} + kv_action = None + + if 'is_active' in data: + new_active = 1 if data['is_active'] else 0 + updates['is_active'] = new_active + kv_action = "put" if new_active else "delete" + + if 'label' in data: + updates['label'] = (data['label'] or '').strip() or None + if 'description' in data: + updates['description'] = (data['description'] or '').strip()[:200] or None + if 'expires_at' in data: + updates['expires_at'] = data['expires_at'] or None + + if not updates: + return jsonify({"error": "no valid fields to update"}), 400 + + set_clause = ', '.join(f"{k}=?" for k in updates) + db.execute(f"UPDATE aliases SET {set_clause} WHERE address=?", list(updates.values()) + [address]) + db.commit() + + if kv_action: + mailbox = row['mailbox_address'] + threading.Thread(target=_sync_alias_kv, args=(address, mailbox, kv_action), daemon=True).start() + + row = db.execute("SELECT * FROM aliases WHERE address=?", (address,)).fetchone() + return jsonify(_alias_to_dict(row)) + + +@api_bp.route('/aliases/', methods=['DELETE']) +@jwt_required +def delete_alias(address): + db = get_db() + row = db.execute("SELECT * FROM aliases WHERE address=?", (address,)).fetchone() + if not row: + return jsonify({"error": "not found"}), 404 + if not _check_alias_ownership(row): + return jsonify({"error": "forbidden"}), 403 + + mailbox = row['mailbox_address'] + db.execute("DELETE FROM aliases WHERE address=?", (address,)) + db.commit() + + threading.Thread(target=_sync_alias_kv, args=(address, mailbox, "delete"), daemon=True).start() + + return jsonify({"status": "deleted"}) diff --git a/mail-manager/app/api/system.py b/mail-manager/app/api/system.py index da4c256..8299226 100644 --- a/mail-manager/app/api/system.py +++ b/mail-manager/app/api/system.py @@ -147,6 +147,32 @@ def wipe_all(): config.IN_MAINTENANCE = False return jsonify({"error": str(e)}), 500 +@system_bp.route('/aliases', methods=['GET']) +@admin_required +def list_aliases_system(): + domain = request.args.get('domain', '').strip() + db_path = config.DB_PATH + try: + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + if domain: + rows = conn.execute( + "SELECT * FROM aliases WHERE domain=? ORDER BY use_count DESC, created_at DESC", + (domain,) + ).fetchall() + else: + rows = conn.execute( + "SELECT * FROM aliases ORDER BY use_count DESC, created_at DESC" + ).fetchall() + conn.close() + aliases = [dict(r) for r in rows] + for a in aliases: + a['is_active'] = bool(a['is_active']) + return jsonify({"domain": domain or None, "aliases": aliases, "total": len(aliases)}) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + def _schedule_restart(): def restart(): import time diff --git a/mail-manager/app/api/webhook.py b/mail-manager/app/api/webhook.py index 13de742..5d2bfc6 100644 --- a/mail-manager/app/api/webhook.py +++ b/mail-manager/app/api/webhook.py @@ -211,26 +211,55 @@ def inbound(): db = get_db() to_address = '' + alias_address = None + for addr in parsed['to_addresses']: - cur = db.execute( - "SELECT address FROM mailboxes WHERE address=?", (addr,) - ) - if cur.fetchone(): + if db.execute("SELECT 1 FROM mailboxes WHERE address=?", (addr,)).fetchone(): to_address = addr break + if not to_address: + via_alias = data.get('via_alias', False) + raw_resolved = data.get('resolved_mailbox', '') + + if via_alias and raw_resolved: + envelope_to = data.get('to', '') + now_utc = datetime.now(timezone.utc).isoformat() + alias_row = db.execute( + "SELECT * FROM aliases WHERE address=? AND is_active=1 AND (expires_at IS NULL OR expires_at > ?)", + (envelope_to, now_utc) + ).fetchone() + if alias_row and alias_row['mailbox_address'] == raw_resolved: + to_address = alias_row['mailbox_address'] + alias_address = envelope_to + db.execute( + "UPDATE aliases SET use_count=use_count+1, last_use_at=? WHERE address=?", + (now_utc, alias_address) + ) + else: + now_utc = datetime.now(timezone.utc).isoformat() + for addr in parsed['to_addresses']: + alias_row = db.execute( + "SELECT * FROM aliases WHERE address=? AND is_active=1 AND (expires_at IS NULL OR expires_at > ?)", + (addr, now_utc) + ).fetchone() + if alias_row: + to_address = alias_row['mailbox_address'] + alias_address = addr + db.execute( + "UPDATE aliases SET use_count=use_count+1, last_use_at=? WHERE address=?", + (now_utc, alias_address) + ) + break + if not to_address and domain_cfg and domain_cfg['catch_all_mailbox']: catch_all = domain_cfg['catch_all_mailbox'] if db.execute("SELECT 1 FROM mailboxes WHERE address=?", (catch_all,)).fetchone(): to_address = catch_all if not to_address: - log.info("Inbound ignored: no matching mailbox for %s", - parsed['to_addresses']) - return jsonify({ - "status": "ignored", - "reason": "unknown recipient", - }), 200 + log.info("Inbound ignored: no matching mailbox for %s", parsed['to_addresses']) + return jsonify({"status": "ignored", "reason": "unknown recipient"}), 200 cur = db.execute( "SELECT id FROM folders WHERE mailbox_address=? AND name='Inbox'", @@ -253,8 +282,8 @@ def inbound(): to_addresses, cc_addresses, bcc_addresses, subject, text_body, html_body, received_at, is_read, is_starred, is_draft, in_reply_to, reference_ids, size_bytes, has_attachments, - headers_json, created_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, 0, ?, ?, ?, ?, ?, ?) + headers_json, created_at, received_via_alias + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, 0, ?, ?, ?, ?, ?, ?, ?) """, ( parsed['message_id'], to_address, folder_id, parsed['from_address'], parsed['from_name'], @@ -265,7 +294,7 @@ def inbound(): parsed['received_at'], parsed['in_reply_to'], parsed['references'], actual_size, 1 if parsed['attachments'] else 0, - json.dumps(parsed['headers_json']), now, + json.dumps(parsed['headers_json']), now, alias_address, )) msg_id = cur.lastrowid @@ -399,6 +428,7 @@ def inbound(): 'subject': parsed['subject'], 'from_name': parsed['from_name'] or parsed['from_address'], 'mailbox': to_address, + 'via_alias': alias_address, }) _check_and_send_auto_reply(db, to_address, parsed, domain_cfg) diff --git a/mail-manager/app/core/alias_expiry.py b/mail-manager/app/core/alias_expiry.py new file mode 100644 index 0000000..6ba3400 --- /dev/null +++ b/mail-manager/app/core/alias_expiry.py @@ -0,0 +1,92 @@ +import logging +import os +from datetime import datetime, timezone + +import requests as http_requests + +log = logging.getLogger(__name__) + + +def _sync_alias_kv_delete(alias_address): + master_url = os.environ.get('DOCKFLARE_MASTER_URL', '').rstrip('/') + if not master_url: + return + try: + http_requests.post( + f"{master_url}/email/internal/alias-kv-sync", + json={ + "domain": alias_address.split('@')[1], + "alias_address": alias_address, + "action": "delete", + }, + headers={"X-Bootstrap-Token": os.environ.get("INTERNAL_BOOTSTRAP_SECRET", "")}, + timeout=5, + ) + except Exception: + log.error("alias-kv-sync delete failed during expiry for %s", alias_address) + + +def _insert_expiry_message(db, mailbox_address, alias_address, now): + inbox = db.execute( + "SELECT id FROM folders WHERE mailbox_address=? AND name='Inbox'", + (mailbox_address,) + ).fetchone() + if not inbox: + return + alias_row = db.execute( + "SELECT use_count FROM aliases WHERE address=?", (alias_address,) + ).fetchone() + if not alias_row or alias_row['use_count'] == 0: + return + db.execute(""" + INSERT OR IGNORE INTO messages ( + message_id, mailbox_address, folder_id, + from_address, from_name, to_addresses, + cc_addresses, bcc_addresses, subject, + text_body, html_body, received_at, + is_read, is_starred, is_draft, + in_reply_to, reference_ids, size_bytes, + has_attachments, headers_json, created_at, is_system + ) VALUES (?, ?, ?, 'noreply@dockflare', 'DockFlare System', ?, + '[]', '[]', + ?, ?, '', ?, 0, 0, 0, + NULL, NULL, 0, 0, '{}', ?, 1) + """, ( + f"alias-expired-{alias_address}-{now}", + mailbox_address, + inbox['id'], + f'["{mailbox_address}"]', + f"Alias expired: {alias_address}", + f"The alias {alias_address} has reached its expiration date and is no longer active.\n\n" + f"Emails sent to this address will no longer be delivered to your mailbox.", + now, + now, + )) + + +def expire_aliases(): + from app.core.database import get_standalone_db + + db = get_standalone_db() + try: + now = datetime.now(timezone.utc).isoformat() + expired = db.execute( + "SELECT address, mailbox_address FROM aliases " + "WHERE is_active=1 AND expires_at IS NOT NULL AND expires_at < ?", + (now,) + ).fetchall() + + if not expired: + return + + for alias in expired: + db.execute("UPDATE aliases SET is_active=0 WHERE address=?", (alias['address'],)) + _sync_alias_kv_delete(alias['address']) + _insert_expiry_message(db, alias['mailbox_address'], alias['address'], now) + log.info("Alias expired: %s -> %s", alias['address'], alias['mailbox_address']) + + db.commit() + except Exception: + log.exception("alias expiry: unhandled error") + finally: + db.close() diff --git a/mail-manager/app/core/alias_words.py b/mail-manager/app/core/alias_words.py new file mode 100644 index 0000000..ba664a1 --- /dev/null +++ b/mail-manager/app/core/alias_words.py @@ -0,0 +1,80 @@ +import re +import random +from uuid import uuid4 + +WORDS = [ + "able", "acorn", "acre", "aged", "agile", "airy", "alba", "alert", "aloe", "alto", + "amber", "ample", "anchor", "arid", "ariel", "arrow", "aspen", "atlas", "azure", "baron", + "basil", "beam", "bear", "beech", "birch", "blade", "bloom", "blue", "bold", "boon", + "brake", "brave", "briar", "bridge", "bright", "brisk", "brook", "brush", "bulk", "buoy", + "calm", "cape", "cedar", "chalk", "chase", "chime", "chip", "chord", "civic", "clamp", + "clan", "claro", "clean", "clear", "cliff", "cloud", "clove", "coast", "comet", "coral", + "crest", "crisp", "crop", "cross", "crust", "curl", "curve", "damp", "dawn", "deep", + "delta", "dense", "depot", "dew", "dome", "door", "dove", "draft", "dune", "dust", + "eagle", "echo", "edge", "elder", "elm", "ember", "epoch", "equal", "ever", "fable", + "fair", "fawn", "fern", "field", "film", "fine", "fire", "firm", "fixed", "flag", + "flame", "flare", "flat", "fleet", "flint", "float", "flood", "floor", "flow", "foam", + "fond", "ford", "forge", "form", "forte", "frame", "frank", "free", "fresh", "frost", + "full", "gale", "game", "gate", "gaze", "gear", "gild", "glade", "glare", "glow", + "gold", "grade", "grain", "grand", "grant", "grape", "grass", "gravel", "gray", "green", + "grove", "guide", "gulf", "hale", "halt", "haven", "hazel", "helm", "hill", "hive", + "hold", "holly", "honor", "hope", "horn", "hull", "hunt", "inlet", "iron", "isle", + "jade", "jasper", "jest", "jewel", "joint", "jump", "keen", "kelp", "kind", "king", + "kite", "lake", "lance", "lark", "laser", "leaf", "lean", "ledge", "level", "light", + "lime", "link", "lion", "loch", "lodge", "loft", "loom", "loop", "lore", "lotus", + "lunar", "mace", "maple", "marsh", "mast", "meadow", "mesa", "mild", "mint", "mist", + "moat", "mode", "moon", "moor", "moss", "mount", "mural", "naval", "noble", "noon", + "north", "nova", "oaken", "ocean", "olive", "onyx", "open", "orbit", "otter", "ozone", + "pact", "pale", "palm", "panel", "patch", "path", "peak", "pearl", "pine", "plain", + "plank", "plant", "plate", "plaza", "plum", "polar", "pond", "pool", "port", "prime", + "prism", "proof", "pure", "quail", "quartz", "quest", "quiet", "rail", "rain", "ramp", + "range", "rapid", "raven", "realm", "reed", "reef", "reign", "ridge", "rift", "rigid", + "ring", "river", "road", "robin", "rock", "rouge", "round", "route", "ruby", "rune", + "rush", "sage", "sail", "salt", "sand", "satin", "scale", "scope", "scout", "seal", + "shelf", "shim", "shore", "sigil", "silk", "silver", "skiff", "slate", "slick", "slope", + "solar", "solid", "sonic", "span", "spark", "speed", "spire", "spray", "sprint", "spur", + "stark", "star", "steam", "steel", "stern", "still", "stone", "storm", "strand", "stream", + "stripe", "strong", "strut", "sunlit", "swift", "talon", "teal", "terra", "test", "tide", + "timber", "titan", "tonal", "torch", "tower", "trace", "trail", "tram", "trend", "trove", + "trunk", "tulip", "tuned", "ultra", "unity", "valid", "valor", "vault", "veil", "venom", + "virid", "vivid", "vocal", "void", "ward", "wave", "weld", "wheat", "white", "wild", + "willow", "wind", "wing", "winter", "wisp", "wren", "yard", "zeal", "zenith", "zinc", +] + +_ADDRESS_RE = re.compile(r'^[a-zA-Z0-9._+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$') + + +def validate_alias_address(address): + if not address or not isinstance(address, str): + return False, "address required" + if not _ADDRESS_RE.match(address): + return False, "invalid address format" + local, _, domain = address.partition('@') + if not local or not domain: + return False, "invalid address format" + if '..' in address: + return False, "invalid address format" + return True, None + + +def generate_alias(domain, style="word-word-num", db=None, max_attempts=20): + for _ in range(max_attempts): + if style == "word-word-num": + candidate = f"{random.choice(WORDS)}.{random.choice(WORDS)}.{random.randint(100, 999)}@{domain}" + elif style == "word-num": + candidate = f"{random.choice(WORDS)}.{random.randint(1000, 9999)}@{domain}" + else: + candidate = f"{uuid4().hex[:10]}@{domain}" + + if db is None or not _address_exists(db, candidate): + return candidate + + return f"{uuid4().hex[:10]}@{domain}" + + +def _address_exists(db, address): + if db.execute("SELECT 1 FROM aliases WHERE address=?", (address,)).fetchone(): + return True + if db.execute("SELECT 1 FROM mailboxes WHERE address=?", (address,)).fetchone(): + return True + return False diff --git a/mail-manager/app/core/database.py b/mail-manager/app/core/database.py index 0b1af6b..f5e40ec 100644 --- a/mail-manager/app/core/database.py +++ b/mail-manager/app/core/database.py @@ -45,6 +45,7 @@ _SCHEMA = """ has_attachments INTEGER, headers_json TEXT, created_at TEXT, + received_via_alias TEXT, FOREIGN KEY(mailbox_address) REFERENCES mailboxes(address) ON DELETE CASCADE, FOREIGN KEY(folder_id) REFERENCES folders(id) ON DELETE CASCADE ); @@ -73,7 +74,8 @@ _SCHEMA = """ sent_at TEXT, status TEXT, error_message TEXT, - worker_response TEXT + worker_response TEXT, + via_alias TEXT ); CREATE TABLE IF NOT EXISTS bounce_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -94,11 +96,28 @@ _SCHEMA = """ outbound_auth_secret TEXT NOT NULL, updated_at TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS aliases ( + address TEXT PRIMARY KEY, + mailbox_address TEXT NOT NULL, + domain TEXT NOT NULL, + label TEXT, + description TEXT, + is_active INTEGER NOT NULL DEFAULT 1, + expires_at TEXT, + created_at TEXT NOT NULL, + use_count INTEGER NOT NULL DEFAULT 0, + last_use_at TEXT, + FOREIGN KEY (mailbox_address) REFERENCES mailboxes(address) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_aliases_mailbox ON aliases(mailbox_address); + CREATE INDEX IF NOT EXISTS idx_aliases_domain ON aliases(domain); + CREATE INDEX IF NOT EXISTS idx_aliases_active ON aliases(is_active, expires_at); CREATE INDEX IF NOT EXISTS idx_domain_configs_name ON domain_configs(domain_name); CREATE INDEX IF NOT EXISTS idx_messages_mailbox ON messages(mailbox_address); CREATE INDEX IF NOT EXISTS idx_messages_folder ON messages(folder_id); CREATE INDEX IF NOT EXISTS idx_messages_received ON messages(received_at DESC); CREATE INDEX IF NOT EXISTS idx_messages_read ON messages(is_read); + CREATE INDEX IF NOT EXISTS idx_messages_alias ON messages(received_via_alias) WHERE received_via_alias IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_attachments_message ON attachments(message_id); CREATE INDEX IF NOT EXISTS idx_send_log_from ON send_log(from_address); CREATE TABLE IF NOT EXISTS push_subscriptions ( @@ -119,7 +138,9 @@ _SCHEMA = """ DROP TRIGGER IF EXISTS messages_ai; CREATE TRIGGER messages_ai AFTER INSERT ON messages BEGIN INSERT INTO messages_fts(rowid, subject, from_address, from_name, to_addresses, text_body) - VALUES (new.id, new.subject, new.from_address, new.from_name, new.to_addresses, new.text_body); + VALUES (new.id, new.subject, new.from_address, new.from_name, + new.to_addresses || ' ' || COALESCE(new.received_via_alias, ''), + new.text_body); END; DROP TRIGGER IF EXISTS messages_ad; CREATE TRIGGER messages_ad AFTER DELETE ON messages BEGIN @@ -129,7 +150,9 @@ _SCHEMA = """ CREATE TRIGGER messages_au AFTER UPDATE ON messages BEGIN DELETE FROM messages_fts WHERE rowid = old.id; INSERT INTO messages_fts(rowid, subject, from_address, from_name, to_addresses, text_body) - VALUES (new.id, new.subject, new.from_address, new.from_name, new.to_addresses, new.text_body); + VALUES (new.id, new.subject, new.from_address, new.from_name, + new.to_addresses || ' ' || COALESCE(new.received_via_alias, ''), + new.text_body); END; """ @@ -210,12 +233,36 @@ def _migrate(conn): "ALTER TABLE mailboxes ADD COLUMN last_quota_warning_at TEXT DEFAULT NULL", "ALTER TABLE domain_configs ADD COLUMN grace_buffer_bytes INTEGER DEFAULT NULL", "ALTER TABLE messages ADD COLUMN is_system INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE messages ADD COLUMN received_via_alias TEXT DEFAULT NULL", + "ALTER TABLE send_log ADD COLUMN via_alias TEXT DEFAULT NULL", + "CREATE INDEX IF NOT EXISTS idx_messages_alias ON messages(received_via_alias) WHERE received_via_alias IS NOT NULL", ]: try: conn.execute(sql) except Exception: pass + try: + conn.executescript(""" + CREATE TABLE IF NOT EXISTS aliases ( + address TEXT PRIMARY KEY, + mailbox_address TEXT NOT NULL, + domain TEXT NOT NULL, + label TEXT, + description TEXT, + is_active INTEGER NOT NULL DEFAULT 1, + expires_at TEXT, + created_at TEXT NOT NULL, + use_count INTEGER NOT NULL DEFAULT 0, + last_use_at TEXT, + FOREIGN KEY (mailbox_address) REFERENCES mailboxes(address) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_aliases_mailbox ON aliases(mailbox_address); + CREATE INDEX IF NOT EXISTS idx_aliases_domain ON aliases(domain); + CREATE INDEX IF NOT EXISTS idx_aliases_active ON aliases(is_active, expires_at); + """) + except Exception: + pass try: conn.executescript(""" @@ -250,8 +297,8 @@ def init_db(): import logging os.makedirs(os.path.dirname(config.DB_PATH), exist_ok=True) conn = _connect() - conn.executescript(_SCHEMA) _migrate(conn) + conn.executescript(_SCHEMA) result = conn.execute("PRAGMA quick_check").fetchone() if result and result[0] != 'ok': logging.getLogger('mail-manager').critical("SQLite integrity check failed: %s", result[0]) diff --git a/mail-manager/app/core/scheduler.py b/mail-manager/app/core/scheduler.py index 9d21b80..da86d4b 100644 --- a/mail-manager/app/core/scheduler.py +++ b/mail-manager/app/core/scheduler.py @@ -5,7 +5,8 @@ import time log = logging.getLogger(__name__) -_INTERVAL = 7 * 24 * 3600 +_CLEANUP_INTERVAL = 7 * 24 * 3600 +_ALIAS_EXPIRY_INTERVAL = 3600 def _run_cleanup(): @@ -47,7 +48,7 @@ def _run_cleanup(): def _scheduler_loop(): while True: - time.sleep(_INTERVAL) + time.sleep(_CLEANUP_INTERVAL) log.info("Scheduler: running push subscription cleanup") try: _run_cleanup() @@ -55,9 +56,23 @@ def _scheduler_loop(): log.exception("Scheduler: unhandled error in cleanup run") +def _alias_expiry_loop(): + time.sleep(300) + while True: + try: + from app.core.alias_expiry import expire_aliases + expire_aliases() + except Exception: + log.exception("Scheduler: unhandled error in alias expiry run") + time.sleep(_ALIAS_EXPIRY_INTERVAL) + + def start_scheduler(): if os.environ.get('DISABLE_SCHEDULER'): return t = threading.Thread(target=_scheduler_loop, daemon=True, name="push-cleanup-scheduler") t.start() log.info("Scheduler: push subscription cleanup scheduled every 7 days") + a = threading.Thread(target=_alias_expiry_loop, daemon=True, name="alias-expiry-scheduler") + a.start() + log.info("Scheduler: alias expiry scheduled every hour") diff --git a/webmail/index.html b/webmail/index.html index 94f0cb2..4d20d92 100644 --- a/webmail/index.html +++ b/webmail/index.html @@ -2,7 +2,7 @@ - + DockFlare Mail diff --git a/webmail/package-lock.json b/webmail/package-lock.json index fd69041..829276c 100644 --- a/webmail/package-lock.json +++ b/webmail/package-lock.json @@ -8,6 +8,7 @@ "name": "dockflare-webmail", "version": "1.0.0", "dependencies": { + "@emoji-mart/data": "^1.2.1", "@tiptap/extension-color": "^2.27.2", "@tiptap/extension-font-family": "^2.27.2", "@tiptap/extension-highlight": "^2.27.2", @@ -25,6 +26,7 @@ "clsx": "^2.1.0", "date-fns": "^3.6.0", "dompurify": "^3.4.0", + "emoji-mart": "^5.6.0", "lucide-vue-next": "^0.400.0", "pinia": "^2.2.0", "radix-vue": "^1.9.0", @@ -1595,6 +1597,12 @@ "node": ">=6.9.0" } }, + "node_modules/@emoji-mart/data": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emoji-mart/data/-/data-1.2.1.tgz", + "integrity": "sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw==", + "license": "MIT" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -4285,6 +4293,12 @@ "dev": true, "license": "ISC" }, + "node_modules/emoji-mart": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/emoji-mart/-/emoji-mart-5.6.0.tgz", + "integrity": "sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==", + "license": "MIT" + }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", diff --git a/webmail/package.json b/webmail/package.json index 0018800..0779d75 100644 --- a/webmail/package.json +++ b/webmail/package.json @@ -8,6 +8,7 @@ "preview": "vite preview" }, "dependencies": { + "@emoji-mart/data": "^1.2.1", "@tiptap/extension-color": "^2.27.2", "@tiptap/extension-font-family": "^2.27.2", "@tiptap/extension-highlight": "^2.27.2", @@ -25,6 +26,7 @@ "clsx": "^2.1.0", "date-fns": "^3.6.0", "dompurify": "^3.4.0", + "emoji-mart": "^5.6.0", "lucide-vue-next": "^0.400.0", "pinia": "^2.2.0", "radix-vue": "^1.9.0", diff --git a/webmail/src/api/auth.js b/webmail/src/api/auth.js index ba0a35e..b21642a 100644 --- a/webmail/src/api/auth.js +++ b/webmail/src/api/auth.js @@ -9,5 +9,17 @@ export const authApi = { }); return response.json(); }, + changePassword: async (currentPassword, newPassword) => { + const token = localStorage.getItem('jwt_token'); + const response = await fetch('/email/auth/change-password', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }), + }); + return response.json(); + }, }; //# sourceMappingURL=auth.js.map \ No newline at end of file diff --git a/webmail/src/api/auth.js.map b/webmail/src/api/auth.js.map index 3f8c656..eba46f3 100644 --- a/webmail/src/api/auth.js.map +++ b/webmail/src/api/auth.js.map @@ -1 +1 @@ -{"version":3,"file":"auth.js","sourceRoot":"","sources":["auth.ts"],"names":[],"mappings":"AAAA,OAAO,SAAS,MAAM,UAAU,CAAA;AAEhC,MAAM,CAAC,MAAM,OAAO,GAAG;IACrB,SAAS,EAAE,GAAG,EAAE,CAAC,SAAS,CAAC,GAAG,CAAC,UAAU,CAAC;IAE1C,iBAAiB,EAAE,KAAK,EAAE,KAAa,EAAE,QAAgB,EAAE,EAAE;QAC3D,MAAM,QAAQ,GAAG,MAAM,KAAK,CAAC,mBAAmB,EAAE;YAChD,MAAM,EAAE,MAAM;YACd,OAAO,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE;YAC/C,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,EAAE,KAAK,EAAE,QAAQ,EAAE,CAAC;SAC1C,CAAC,CAAA;QACF,OAAO,QAAQ,CAAC,IAAI,EAAE,CAAA;IACxB,CAAC;CACF,CAAA"} \ No newline at end of file +{"version":3,"file":"auth.js","sourceRoot":"","sources":["auth.ts"],"names":[],"mappings":"AAAA,OAAO,SAAS,MAAM,UAAU,CAAA;AAEhC,MAAM,CAAC,MAAM,OAAO,GAAG;IACrB,SAAS,EAAE,GAAG,EAAE,CAAC,SAAS,CAAC,GAAG,CAAC,UAAU,CAAC;IAE1C,iBAAiB,EAAE,KAAK,EAAE,KAAa,EAAE,QAAgB,EAAE,EAAE;QAC3D,MAAM,QAAQ,GAAG,MAAM,KAAK,CAAC,mBAAmB,EAAE;YAChD,MAAM,EAAE,MAAM;YACd,OAAO,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE;YAC/C,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,EAAE,KAAK,EAAE,QAAQ,EAAE,CAAC;SAC1C,CAAC,CAAA;QACF,OAAO,QAAQ,CAAC,IAAI,EAAE,CAAA;IACxB,CAAC;IAED,cAAc,EAAE,KAAK,EAAE,eAAuB,EAAE,WAAmB,EAAE,EAAE;QACrE,MAAM,KAAK,GAAG,YAAY,CAAC,OAAO,CAAC,WAAW,CAAC,CAAA;QAC/C,MAAM,QAAQ,GAAG,MAAM,KAAK,CAAC,6BAA6B,EAAE;YAC1D,MAAM,EAAE,MAAM;YACd,OAAO,EAAE;gBACP,cAAc,EAAE,kBAAkB;gBAClC,eAAe,EAAE,UAAU,KAAK,EAAE;aACnC;YACD,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,EAAE,gBAAgB,EAAE,eAAe,EAAE,YAAY,EAAE,WAAW,EAAE,CAAC;SACvF,CAAC,CAAA;QACF,OAAO,QAAQ,CAAC,IAAI,EAAE,CAAA;IACxB,CAAC;CACF,CAAA"} \ No newline at end of file diff --git a/webmail/src/api/client.js b/webmail/src/api/client.js index ba77ffa..fd5d6be 100644 --- a/webmail/src/api/client.js +++ b/webmail/src/api/client.js @@ -12,7 +12,6 @@ apiClient.interceptors.request.use(config => { if (token) { config.headers.Authorization = `Bearer ${token}`; } - // Let the browser set Content-Type (with boundary) for FormData if (config.data instanceof FormData) { delete config.headers['Content-Type']; } @@ -21,6 +20,10 @@ apiClient.interceptors.request.use(config => { apiClient.interceptors.response.use(response => response, error => { if (error.response?.status === 401) { localStorage.removeItem('jwt_token'); + import('../stores/auth').then(({ useAuthStore }) => { + const authStore = useAuthStore(); + authStore.token = ''; + }); router.push('/login'); } return Promise.reject(error); diff --git a/webmail/src/api/client.js.map b/webmail/src/api/client.js.map index 723b46f..689ac8e 100644 --- a/webmail/src/api/client.js.map +++ b/webmail/src/api/client.js.map @@ -1 +1 @@ -{"version":3,"file":"client.js","sourceRoot":"","sources":["client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAA;AACzB,OAAO,MAAM,MAAM,WAAW,CAAA;AAE9B,MAAM,SAAS,GAAG,KAAK,CAAC,MAAM,CAAC;IAC7B,OAAO,EAAE,SAAS;IAClB,OAAO,EAAE,KAAK;IACd,OAAO,EAAE;QACP,cAAc,EAAE,kBAAkB;KACnC;CACF,CAAC,CAAA;AAEF,SAAS,CAAC,YAAY,CAAC,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE;IAC1C,MAAM,KAAK,GAAG,YAAY,CAAC,OAAO,CAAC,WAAW,CAAC,CAAA;IAC/C,IAAI,KAAK,EAAE,CAAC;QACV,MAAM,CAAC,OAAO,CAAC,aAAa,GAAG,UAAU,KAAK,EAAE,CAAA;IAClD,CAAC;IACD,gEAAgE;IAChE,IAAI,MAAM,CAAC,IAAI,YAAY,QAAQ,EAAE,CAAC;QACpC,OAAO,MAAM,CAAC,OAAO,CAAC,cAAc,CAAC,CAAA;IACvC,CAAC;IACD,OAAO,MAAM,CAAA;AACf,CAAC,CAAC,CAAA;AAEF,SAAS,CAAC,YAAY,CAAC,QAAQ,CAAC,GAAG,CACjC,QAAQ,CAAC,EAAE,CAAC,QAAQ,EACpB,KAAK,CAAC,EAAE;IACN,IAAI,KAAK,CAAC,QAAQ,EAAE,MAAM,KAAK,GAAG,EAAE,CAAC;QACnC,YAAY,CAAC,UAAU,CAAC,WAAW,CAAC,CAAA;QACpC,MAAM,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAA;IACvB,CAAC;IACD,OAAO,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,CAAA;AAC9B,CAAC,CACF,CAAA;AAED,eAAe,SAAS,CAAA"} \ No newline at end of file +{"version":3,"file":"client.js","sourceRoot":"","sources":["client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAA;AACzB,OAAO,MAAM,MAAM,WAAW,CAAA;AAE9B,MAAM,SAAS,GAAG,KAAK,CAAC,MAAM,CAAC;IAC7B,OAAO,EAAE,SAAS;IAClB,OAAO,EAAE,KAAK;IACd,OAAO,EAAE;QACP,cAAc,EAAE,kBAAkB;KACnC;CACF,CAAC,CAAA;AAEF,SAAS,CAAC,YAAY,CAAC,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE;IAC1C,MAAM,KAAK,GAAG,YAAY,CAAC,OAAO,CAAC,WAAW,CAAC,CAAA;IAC/C,IAAI,KAAK,EAAE,CAAC;QACV,MAAM,CAAC,OAAO,CAAC,aAAa,GAAG,UAAU,KAAK,EAAE,CAAA;IAClD,CAAC;IACD,IAAI,MAAM,CAAC,IAAI,YAAY,QAAQ,EAAE,CAAC;QACpC,OAAO,MAAM,CAAC,OAAO,CAAC,cAAc,CAAC,CAAA;IACvC,CAAC;IACD,OAAO,MAAM,CAAA;AACf,CAAC,CAAC,CAAA;AAEF,SAAS,CAAC,YAAY,CAAC,QAAQ,CAAC,GAAG,CACjC,QAAQ,CAAC,EAAE,CAAC,QAAQ,EACpB,KAAK,CAAC,EAAE;IACN,IAAI,KAAK,CAAC,QAAQ,EAAE,MAAM,KAAK,GAAG,EAAE,CAAC;QACnC,YAAY,CAAC,UAAU,CAAC,WAAW,CAAC,CAAA;QACpC,MAAM,CAAC,gBAAgB,CAAC,CAAC,IAAI,CAAC,CAAC,EAAE,YAAY,EAAE,EAAE,EAAE;YACjD,MAAM,SAAS,GAAG,YAAY,EAAE,CAAA;YAChC,SAAS,CAAC,KAAK,GAAG,EAAE,CAAA;QACtB,CAAC,CAAC,CAAA;QACF,MAAM,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAA;IACvB,CAAC;IACD,OAAO,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,CAAA;AAC9B,CAAC,CACF,CAAA;AAED,eAAe,SAAS,CAAA"} \ No newline at end of file diff --git a/webmail/src/api/mail.js b/webmail/src/api/mail.js index 9794a6a..e6c27d7 100644 --- a/webmail/src/api/mail.js +++ b/webmail/src/api/mail.js @@ -15,9 +15,20 @@ export const mailApi = { sendMessage: (address, data) => apiClient.post(`/mailboxes/${address}/send`, data), searchMessages: (address, params) => apiClient.get(`/mailboxes/${address}/search`, { params }), getMailboxStatus: () => apiClient.get('/mailboxes/status'), + getMailboxPreferences: (address) => apiClient.get(`/mailboxes/${address}/preferences`), + updateMailboxPreferences: (address, data) => apiClient.patch(`/mailboxes/${address}/preferences`, data), getAttachmentUrl: (id) => `/api/v1/attachments/${id}/download`, downloadAttachment: (id) => apiClient.get(`/attachments/${id}/download`, { responseType: 'blob' }).then(r => r.data), createDraft: (address, data) => apiClient.post(`/mailboxes/${address}/drafts`, data), updateDraft: (address, id, data) => apiClient.put(`/mailboxes/${address}/drafts/${id}`, data), + getAutoResponder: (address) => apiClient.get(`/mailboxes/${address}/auto-responder`), + setAutoResponder: (address, data) => apiClient.post(`/mailboxes/${address}/auto-responder`, data), + deleteAutoResponder: (address) => apiClient.delete(`/mailboxes/${address}/auto-responder`), + getAliases: (mailbox) => apiClient.get('/aliases', { params: { mailbox, active: 1 } }), + getAllAliases: (mailbox) => apiClient.get('/aliases', { params: { mailbox } }), + createAlias: (data) => apiClient.post('/aliases', data), + updateAlias: (address, data) => apiClient.patch(`/aliases/${encodeURIComponent(address)}`, data), + deleteAlias: (address) => apiClient.delete(`/aliases/${encodeURIComponent(address)}`), + generateAlias: (mailbox, domain, style) => apiClient.post('/aliases/generate', { mailbox_address: mailbox, domain, style: style || 'word-word-num' }), }; //# sourceMappingURL=mail.js.map \ No newline at end of file diff --git a/webmail/src/api/mail.js.map b/webmail/src/api/mail.js.map index 4c6fc8f..18e1277 100644 --- a/webmail/src/api/mail.js.map +++ b/webmail/src/api/mail.js.map @@ -1 +1 @@ -{"version":3,"file":"mail.js","sourceRoot":"","sources":["mail.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"mail.js","sourceRoot":"","sources":["mail.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/webmail/src/api/mail.ts b/webmail/src/api/mail.ts index 888565b..53f2638 100644 --- a/webmail/src/api/mail.ts +++ b/webmail/src/api/mail.ts @@ -37,4 +37,16 @@ export const mailApi = { apiClient.post(`/mailboxes/${address}/auto-responder`, data), deleteAutoResponder: (address: string) => apiClient.delete(`/mailboxes/${address}/auto-responder`), + getAliases: (mailbox: string) => + apiClient.get('/aliases', { params: { mailbox, active: 1 } }), + getAllAliases: (mailbox: string) => + apiClient.get('/aliases', { params: { mailbox } }), + createAlias: (data: Record) => + apiClient.post('/aliases', data), + updateAlias: (address: string, data: Record) => + apiClient.patch(`/aliases/${encodeURIComponent(address)}`, data), + deleteAlias: (address: string) => + apiClient.delete(`/aliases/${encodeURIComponent(address)}`), + generateAlias: (mailbox: string, domain: string, style?: string) => + apiClient.post('/aliases/generate', { mailbox_address: mailbox, domain, style: style || 'word-word-num' }), } diff --git a/webmail/src/assets/styles/main.css b/webmail/src/assets/styles/main.css index dbf2aaf..bbffdc6 100644 --- a/webmail/src/assets/styles/main.css +++ b/webmail/src/assets/styles/main.css @@ -2,6 +2,15 @@ @tailwind components; @tailwind utilities; +@layer utilities { + .pb-safe { + padding-bottom: env(safe-area-inset-bottom, 0px); + } + .h-\[100dvh\] { + height: 100dvh; + } +} + @layer base { :root { --background: 0 0% 100%; diff --git a/webmail/src/components/mail/ComposeDialog.vue b/webmail/src/components/mail/ComposeDialog.vue index 100174b..979f32c 100644 --- a/webmail/src/components/mail/ComposeDialog.vue +++ b/webmail/src/components/mail/ComposeDialog.vue @@ -1,9 +1,10 @@