diff --git a/dockflare/app/web/email_routes.py b/dockflare/app/web/email_routes.py index fc6b591..cf62dbd 100644 --- a/dockflare/app/web/email_routes.py +++ b/dockflare/app/web/email_routes.py @@ -1,3 +1,4 @@ +import base64 import hmac import ipaddress import json @@ -9,7 +10,7 @@ import jwt from flask import Blueprint, render_template, request, jsonify, redirect, url_for, current_app from flask_login import login_required, current_user from cryptography.hazmat.primitives import serialization -from cryptography.hazmat.primitives.asymmetric import ed25519 +from cryptography.hazmat.primitives.asymmetric import ec, ed25519 from werkzeug.security import generate_password_hash, check_password_hash from app import config, docker_client, limiter from app.core import email_manager @@ -106,7 +107,21 @@ def setup_email_domain(): ) email_cfg['jwt_signing_key'] = private_bytes.decode('utf-8') email_cfg['jwt_public_key'] = public_bytes.decode('utf-8') - + + if 'vapid_private_key' not in email_cfg: + vapid_key = ec.generate_private_key(ec.SECP256R1()) + email_cfg['vapid_private_key'] = vapid_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption() + ).decode() + email_cfg['vapid_public_key'] = base64.urlsafe_b64encode( + vapid_key.public_key().public_bytes( + encoding=serialization.Encoding.X962, + format=serialization.PublicFormat.UncompressedPoint + ) + ).rstrip(b'=').decode() + webhook_secret = secrets.token_hex(32) outbound_auth_secret = secrets.token_hex(32) inbound_worker_name = f"dockflare-mail-inbound-{zone_name.replace('.', '-')}" @@ -470,6 +485,23 @@ def internal_mail_config(): cfg = config.EMAIL_CONFIG if not cfg or not cfg.get('enabled') or not cfg.get('domains'): return jsonify({'configured': False}) + + if 'vapid_private_key' not in cfg or not cfg.get('vapid_private_key'): + vapid_key = ec.generate_private_key(ec.SECP256R1()) + cfg['vapid_private_key'] = vapid_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption() + ).decode() + cfg['vapid_public_key'] = base64.urlsafe_b64encode( + vapid_key.public_key().public_bytes( + encoding=serialization.Encoding.X962, + format=serialization.PublicFormat.UncompressedPoint + ) + ).rstrip(b'=').decode() + save_email_config(cfg) + logging.info("Generated VAPID keys for existing email config") + domains_out = {} for zone_name, d in cfg['domains'].items(): domains_out[zone_name] = { @@ -491,6 +523,8 @@ def internal_mail_config(): 'jwt_algorithm': config.EMAIL_JWT_ALGORITHM, 'jwt_issuer': config.EMAIL_JWT_ISSUER, 'jwt_audience': config.EMAIL_JWT_AUDIENCE, + 'vapid_private_key': cfg.get('vapid_private_key', ''), + 'vapid_public_key': cfg.get('vapid_public_key', ''), 'domains': domains_out }) diff --git a/mail-manager/app/api/routes.py b/mail-manager/app/api/routes.py index 965a7e9..89c0990 100644 --- a/mail-manager/app/api/routes.py +++ b/mail-manager/app/api/routes.py @@ -58,6 +58,91 @@ def stats(): }) +@api_bp.route('/notifications/vapid-key', methods=['GET']) +@jwt_required +def get_vapid_key(): + public_key = os.environ.get('VAPID_PUBLIC_KEY', '') + if not public_key: + return jsonify({"error": "push notifications not configured"}), 503 + return jsonify({"public_key": public_key}) + + +@api_bp.route('/notifications/subscribe', methods=['POST']) +@jwt_required +def push_subscribe(): + data = request.json or {} + endpoint = data.get('endpoint', '') + keys = data.get('keys') or {} + p256dh = keys.get('p256dh', '') + auth_key = keys.get('auth', '') + mailbox_address = data.get('mailbox_address', '') + + if not endpoint or not p256dh or not auth_key or not mailbox_address: + return jsonify({"error": "endpoint, keys, and mailbox_address are required"}), 400 + + if not _check_mailbox_access(mailbox_address): + return jsonify({"error": "forbidden"}), 403 + + db = get_db() + now = datetime.now(timezone.utc).isoformat() + db.execute(""" + INSERT INTO push_subscriptions (mailbox_address, endpoint, p256dh, auth, created_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(endpoint) DO UPDATE SET + mailbox_address=excluded.mailbox_address, + p256dh=excluded.p256dh, + auth=excluded.auth, + created_at=excluded.created_at + """, (mailbox_address, endpoint, p256dh, auth_key, now)) + db.commit() + return jsonify({"status": "subscribed"}) + + +@api_bp.route('/notifications/subscribe', methods=['DELETE']) +@jwt_required +def push_unsubscribe(): + data = request.json or {} + endpoint = data.get('endpoint', '') + if not endpoint: + return jsonify({"error": "endpoint is required"}), 400 + db = get_db() + db.execute("DELETE FROM push_subscriptions WHERE endpoint=?", (endpoint,)) + db.commit() + return jsonify({"status": "unsubscribed"}) + + +@api_bp.route('/mailboxes/status', methods=['GET']) +@jwt_required +def mailbox_status(): + db = get_db() + user = request.user + + if user.get('role') == 'admin': + cur = db.execute("SELECT address FROM mailboxes WHERE is_active=1") + addresses = [row['address'] for row in cur.fetchall()] + else: + addresses = user.get('mailboxes', []) + + results = [] + for address in addresses: + cur = db.execute(""" + SELECT + COUNT(CASE WHEN m.is_read=0 THEN 1 END) AS unread_count, + MAX(m.received_at) AS latest_received_at + FROM messages m + JOIN folders f ON m.folder_id = f.id + WHERE m.mailbox_address=? AND f.name='Inbox' AND m.is_draft=0 + """, (address,)) + row = cur.fetchone() + results.append({ + 'address': address, + 'unread_count': row['unread_count'] or 0, + 'latest_received_at': row['latest_received_at'], + }) + + return jsonify(results) + + @api_bp.route('/mailboxes', methods=['GET']) @admin_required def get_mailboxes(): @@ -130,6 +215,36 @@ def patch_mailbox(address): return jsonify({"status": "updated"}) +@api_bp.route('/mailboxes/
/preferences', methods=['GET']) +@jwt_required +def get_mailbox_preferences(address): + if not _check_mailbox_access(address): + return jsonify({"error": "forbidden"}), 403 + db = get_db() + cur = db.execute("SELECT notification_preview FROM mailboxes WHERE address=?", (address,)) + row = cur.fetchone() + if not row: + return jsonify({"error": "not found"}), 404 + preview = row['notification_preview'] if row['notification_preview'] is not None else 1 + return jsonify({"notification_preview": bool(preview)}) + + +@api_bp.route('/mailboxes//preferences', methods=['PATCH']) +@jwt_required +def patch_mailbox_preferences(address): + if not _check_mailbox_access(address): + return jsonify({"error": "forbidden"}), 403 + data = request.json or {} + db = get_db() + if 'notification_preview' in data: + db.execute( + "UPDATE mailboxes SET notification_preview=? WHERE address=?", + (int(bool(data['notification_preview'])), address), + ) + db.commit() + return jsonify({"status": "updated"}) + + @api_bp.route('/mailboxes//messages', methods=['GET']) @jwt_required def get_messages(address): diff --git a/mail-manager/app/api/webhook.py b/mail-manager/app/api/webhook.py index 8eb13d7..616384f 100644 --- a/mail-manager/app/api/webhook.py +++ b/mail-manager/app/api/webhook.py @@ -8,6 +8,7 @@ from datetime import datetime, timezone from flask import Blueprint, request, jsonify from app.config import config from app.core.database import get_db +from app.core.push import send_push_notifications from app.core.r2_client import fetch_email_from_r2, delete_from_r2 from app.core.mime_parser import parse_eml @@ -135,6 +136,12 @@ def inbound(): )) db.commit() + send_push_notifications(to_address, { + 'message_id': msg_id, + 'subject': parsed['subject'], + 'from_name': parsed['from_name'] or parsed['from_address'], + 'mailbox': to_address, + }) delete_from_r2(r2_key, domain_cfg) log.info("Inbound delivered: message=%s to=%s db_id=%s", diff --git a/mail-manager/app/core/database.py b/mail-manager/app/core/database.py index e17f2c6..caefdf5 100644 --- a/mail-manager/app/core/database.py +++ b/mail-manager/app/core/database.py @@ -101,6 +101,16 @@ _SCHEMA = """ CREATE INDEX IF NOT EXISTS idx_messages_read ON messages(is_read); 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 ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + mailbox_address TEXT NOT NULL, + endpoint TEXT NOT NULL UNIQUE, + p256dh TEXT NOT NULL, + auth TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (mailbox_address) REFERENCES mailboxes(address) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_push_subscriptions_mailbox ON push_subscriptions(mailbox_address); DROP TRIGGER IF EXISTS messages_ai; CREATE TRIGGER messages_ai AFTER INSERT ON messages BEGIN @@ -144,9 +154,10 @@ def get_standalone_db(): return _connect() -def _migrate(conn): +def _migrate(conn): migrations = [ "ALTER TABLE folders ADD COLUMN color TEXT", + "ALTER TABLE mailboxes ADD COLUMN notification_preview INTEGER DEFAULT 1", ] for sql in migrations: try: diff --git a/mail-manager/app/core/push.py b/mail-manager/app/core/push.py new file mode 100644 index 0000000..15d1787 --- /dev/null +++ b/mail-manager/app/core/push.py @@ -0,0 +1,75 @@ +import json +import logging +import os +import threading + +log = logging.getLogger(__name__) + + +def send_push_notifications(mailbox_address: str, payload: dict): + threading.Thread( + target=_dispatch, + args=(mailbox_address, payload), + daemon=True, + ).start() + + +def _dispatch(mailbox_address: str, payload: dict): + private_key = os.environ.get('VAPID_PRIVATE_KEY', '') + if not private_key: + return + + from pywebpush import webpush, WebPushException + from app.core.database import get_standalone_db + + db = get_standalone_db() + try: + row = db.execute(""" + SELECT m.notification_preview, + (SELECT COUNT(*) FROM messages msg + JOIN folders f ON msg.folder_id = f.id + WHERE msg.mailbox_address = m.address AND f.name='Inbox' + AND msg.is_read=0 AND msg.is_draft=0) AS unread_count + FROM mailboxes m WHERE m.address=? + """, (mailbox_address,)).fetchone() + + unread_count = row['unread_count'] if row else 0 + preview = row['notification_preview'] if row and row['notification_preview'] is not None else 1 + + push_payload = { + 'message_id': payload.get('message_id'), + 'mailbox': payload.get('mailbox'), + 'unread_count': unread_count, + } + if preview: + push_payload['subject'] = payload.get('subject', '') + push_payload['from_name'] = payload.get('from_name', '') + + cur = db.execute( + "SELECT id, endpoint, p256dh, auth FROM push_subscriptions WHERE mailbox_address=?", + (mailbox_address,), + ) + subscriptions = list(cur.fetchall()) + + for sub in subscriptions: + try: + webpush( + subscription_info={ + "endpoint": sub['endpoint'], + "keys": {"p256dh": sub['p256dh'], "auth": sub['auth']}, + }, + data=json.dumps(push_payload), + vapid_private_key=private_key, + vapid_claims={"sub": "mailto:push@dockflare.local"}, + ) + except WebPushException as e: + if e.response is not None and e.response.status_code == 410: + db.execute("DELETE FROM push_subscriptions WHERE id=?", (sub['id'],)) + db.commit() + log.info("Removed stale push subscription for %s", mailbox_address) + else: + log.warning("Push failed for %s: %s", mailbox_address, e) + except Exception: + log.exception("Push dispatch error for %s", mailbox_address) + finally: + db.close() diff --git a/mail-manager/entrypoint.py b/mail-manager/entrypoint.py index eea448e..4e64e57 100644 --- a/mail-manager/entrypoint.py +++ b/mail-manager/entrypoint.py @@ -36,6 +36,8 @@ def bootstrap(): os.environ['JWT_ALGORITHM'] = data.get('jwt_algorithm', 'EdDSA') os.environ['JWT_ISSUER'] = data.get('jwt_issuer', 'dockflare-master') os.environ['JWT_AUDIENCE'] = data.get('jwt_audience', 'dockflare-mail') + os.environ['VAPID_PRIVATE_KEY'] = data.get('vapid_private_key', '') + os.environ['VAPID_PUBLIC_KEY'] = data.get('vapid_public_key', '') domains = data.get('domains', {}) if not domains: log.warning("No domains in bootstrap config") diff --git a/mail-manager/requirements.txt b/mail-manager/requirements.txt index bd09176..98d7394 100644 --- a/mail-manager/requirements.txt +++ b/mail-manager/requirements.txt @@ -7,3 +7,4 @@ cryptography==46.0.6 python-dateutil==2.9.0.post0 nh3==0.2.21 requests==2.33.1 +pywebpush==2.0.0 diff --git a/webmail/docker-entrypoint.sh b/webmail/docker-entrypoint.sh index a335cd0..6de09df 100644 --- a/webmail/docker-entrypoint.sh +++ b/webmail/docker-entrypoint.sh @@ -8,7 +8,7 @@ server { listen 80; server_name _; client_max_body_size 25m; - add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: https: blob:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self';"; + add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: https: blob:; style-src 'self' 'unsafe-inline'; script-src 'self'; worker-src 'self'; connect-src 'self';"; location /api/ { proxy_pass http://dockflare-mail-manager:8025/api/; @@ -24,6 +24,18 @@ server { proxy_set_header CF-Connecting-IP \$http_cf_connecting_ip; } + location = /sw.js { + root /usr/share/nginx/html; + add_header Cache-Control "no-cache"; + add_header Service-Worker-Allowed "/"; + } + + location = /manifest.webmanifest { + root /usr/share/nginx/html; + add_header Cache-Control "no-cache"; + types { application/manifest+json webmanifest; } + } + location = /config.json { root /usr/share/nginx/html; add_header Cache-Control "no-store"; diff --git a/webmail/index.html b/webmail/index.html index 9ae622e..94f0cb2 100644 --- a/webmail/index.html +++ b/webmail/index.html @@ -3,7 +3,8 @@ -DockFlare Mail will reconnect when your network is available.
+Settings are managed in DockFlare Master.
++ Install DockFlare Mail as a desktop app for faster access and desktop notifications. +
++ Get notified when new mail arrives, even when the app is closed. +
++ Notifications are blocked. Enable them in your browser or OS settings. +
+ + + + + + + +Permission granted.
++ Background push is not supported in this browser. +
+ +Additional settings are managed in DockFlare Master.