DockFlare/mail-manager/app/api/routes.py
ChrispyBacon-dev 0fad7c592f
Some checks failed
Docker Image - Webmail / build_self_hosted (push) Waiting to run
Docker Image - Webmail / build_github_hosted_fallback (push) Blocked by required conditions
Docker Image - DockFlare / build_self_hosted (push) Has been cancelled
Docker Image - Mail Manager / build_self_hosted (push) Has been cancelled
Docker Image - DockFlare / build_github_hosted_fallback (push) Has been cancelled
Docker Image - Mail Manager / build_github_hosted_fallback (push) Has been cancelled
v3.1.1 - RC - more testing needed - Details in Changelog.md
2026-04-24 23:32:33 +02:00

1522 lines
54 KiB
Python

import base64
import json
import logging
import os
import re
import shutil
import threading
import uuid
from datetime import datetime, timezone, timedelta
import requests as http_requests
from flask import Blueprint, request, jsonify, send_file
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__)
_EMAIL_RE = re.compile(r'^[^@\s]+@[^@\s]+\.[^@\s]+$')
_MAX_BODY_LEN = 1_000_000
def _post_quota_kv_action(address, action):
master_url = os.environ.get('DOCKFLARE_MASTER_URL', '').rstrip('/')
if not master_url:
return
try:
http_requests.post(
f"{master_url}/email/internal/quota-kv-sync",
json={'domain': address.split('@')[1], 'address': address, 'action': action},
headers={'X-Bootstrap-Token': os.environ.get('INTERNAL_BOOTSTRAP_SECRET', '')},
timeout=3,
)
except Exception:
pass
def _paginated(items, total, page, per_page):
return jsonify({
"items": items,
"total": total,
"page": page,
"per_page": per_page,
"pages": max(1, -(-total // per_page)),
})
def _check_mailbox_access(address):
if request.user.get('role') == 'admin':
return True
return address in request.user.get('mailboxes', [])
@api_bp.route('/stats', methods=['GET'])
@admin_required
def stats():
db = get_db()
cur = db.cursor()
cur.execute("SELECT COUNT(*) FROM messages")
total_messages = cur.fetchone()[0]
cur.execute("SELECT COUNT(*) FROM messages WHERE is_read=0")
unread_count = cur.fetchone()[0]
cur.execute("SELECT COUNT(*) FROM send_log")
total_sent = cur.fetchone()[0]
cur.execute("SELECT COUNT(*) FROM mailboxes")
mailbox_count = cur.fetchone()[0]
cur.execute("SELECT COALESCE(SUM(size_bytes), 0) FROM messages")
total_storage_bytes = cur.fetchone()[0]
data_dir_bytes = 0
for dirpath, _, filenames in os.walk(config.MAIL_DATA_PATH):
for f in filenames:
try:
data_dir_bytes += os.path.getsize(os.path.join(dirpath, f))
except OSError:
pass
disk = shutil.disk_usage(config.MAIL_DATA_PATH)
return jsonify({
"total_messages": total_messages,
"unread_count": unread_count,
"total_sent": total_sent,
"mailbox_count": mailbox_count,
"total_storage_bytes": total_storage_bytes,
"disk_used_bytes": data_dir_bytes,
"disk_free_bytes": disk.free,
})
@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(mailbox_address, endpoint) DO UPDATE SET
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():
db = get_db()
mailboxes = [dict(r) for r in db.execute("SELECT * FROM mailboxes").fetchall()]
received = {r['mailbox_address']: r['cnt'] for r in db.execute("""
SELECT m.mailbox_address, COUNT(*) as cnt FROM messages m
JOIN folders f ON f.id = m.folder_id
WHERE m.is_draft=0 AND f.name != 'Sent'
GROUP BY m.mailbox_address
""").fetchall()}
storage = {r['mailbox_address']: r['bytes'] for r in db.execute("""
SELECT mailbox_address, COALESCE(SUM(size_bytes), 0) as bytes
FROM messages WHERE is_system=0 GROUP BY mailbox_address
""").fetchall()}
sent = {r['from_address']: r['cnt'] for r in db.execute("""
SELECT from_address, COUNT(*) as cnt FROM send_log
WHERE status='sent' GROUP BY from_address
""").fetchall()}
for mb in mailboxes:
addr = mb['address']
mb['received_count'] = received.get(addr, 0)
mb['sent_count'] = sent.get(addr, 0)
mb['storage_bytes'] = storage.get(addr, 0)
if mb['quota_bytes'] and mb['quota_bytes'] > 0:
if storage.get(addr, 0) <= mb['quota_bytes'] and mb['quota_exceeded_count'] > 0:
db.execute("UPDATE mailboxes SET quota_exceeded_count=0 WHERE address=?", (addr,))
mb['quota_exceeded_count'] = 0
db.commit()
return jsonify(mailboxes)
@api_bp.route('/mailboxes', methods=['POST'])
@admin_required
def create_mailbox():
data = request.json or {}
address = data.get('address', '')
domain = data.get('domain', '')
if not address or not domain:
return jsonify({"error": "address and domain are required"}), 400
if not _EMAIL_RE.match(address):
return jsonify({"error": "invalid email address format"}), 400
db = get_db()
now = datetime.now(timezone.utc).isoformat()
quota_bytes = data.get('quota_bytes', 10737418240)
try:
db.execute(
"INSERT INTO mailboxes (address, display_name, domain, created_at, is_active, quota_bytes) VALUES (?, ?, ?, ?, 1, ?) "
"ON CONFLICT(address) DO UPDATE SET is_active=1, display_name=excluded.display_name, quota_bytes=excluded.quota_bytes",
(address, data.get('display_name', ''), domain, now, quota_bytes),
)
folder_count = db.execute(
"SELECT COUNT(*) FROM folders WHERE mailbox_address=?", (address,)
).fetchone()[0]
if folder_count == 0:
for folder in ['Inbox', 'Sent', 'Drafts', 'Trash', 'Spam']:
db.execute(
"INSERT INTO folders (mailbox_address, name, system_folder, created_at) VALUES (?, ?, 1, ?)",
(address, folder, now),
)
db.commit()
except Exception as e:
db.rollback()
return jsonify({"error": str(e)}), 400
return jsonify({"status": "created"}), 201
@api_bp.route('/mailboxes/<address>', methods=['GET'])
@admin_required
def get_mailbox(address):
db = get_db()
cur = db.execute("SELECT * FROM mailboxes WHERE address=?", (address,))
row = cur.fetchone()
if not row:
return jsonify({"error": "not found"}), 404
return jsonify(dict(row))
@api_bp.route('/mailboxes/<address>', methods=['PATCH'])
@admin_required
def update_mailbox(address):
data = request.json or {}
db = get_db()
if not db.execute("SELECT 1 FROM mailboxes WHERE address=?", (address,)).fetchone():
return jsonify({"error": "not found"}), 404
if 'quota_bytes' in data:
db.execute(
"UPDATE mailboxes SET quota_bytes=?, quota_exceeded_count=0, last_quota_warning_at=NULL WHERE address=?",
(data['quota_bytes'], address)
)
if 'display_name' in data:
db.execute("UPDATE mailboxes SET display_name=? WHERE address=?", (data['display_name'], address))
db.commit()
return jsonify({"status": "updated"})
@api_bp.route('/mailboxes/<address>', methods=['DELETE'])
@admin_required
def delete_mailbox(address):
db = get_db()
cur = db.execute(
"SELECT id FROM messages WHERE mailbox_address=? AND has_attachments=1", (address,)
)
for row in cur.fetchall():
shutil.rmtree(os.path.join(config.ATTACHMENTS_PATH, str(row['id'])), ignore_errors=True)
db.execute("DELETE FROM mailboxes WHERE address=?", (address,))
db.commit()
def _vacuum():
import sqlite3 as _sqlite3
conn = _sqlite3.connect(config.DB_PATH)
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
conn.execute("VACUUM")
conn.close()
threading.Thread(target=_vacuum, daemon=True).start()
return jsonify({"status": "deleted"})
@api_bp.route('/mailboxes/<address>/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/<address>/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/<address>/messages', methods=['GET'])
@jwt_required
def get_messages(address):
if not _check_mailbox_access(address):
return jsonify({"error": "forbidden"}), 403
folder = request.args.get('folder', 'Inbox')
page = max(1, int(request.args.get('page', 1)))
per_page = min(100, max(1, int(request.args.get('per_page', 50))))
offset = (page - 1) * per_page
_SORT_COLS = {'received_at', 'sent_at', 'subject', 'from_address'}
sort_col = request.args.get('sort', 'default')
order = 'ASC' if request.args.get('order', 'desc').lower() == 'asc' else 'DESC'
if sort_col not in _SORT_COLS:
sort_expr = "COALESCE(sent_at, received_at, created_at)"
else:
sort_expr = sort_col
db = get_db()
cur = db.execute(
"SELECT id FROM folders WHERE mailbox_address=? AND name=?",
(address, folder),
)
folder_row = cur.fetchone()
if not folder_row:
return jsonify({"error": "folder not found"}), 404
folder_id = folder_row['id']
cur = db.execute(
"SELECT COUNT(*) FROM messages WHERE folder_id=?", (folder_id,)
)
total = cur.fetchone()[0]
cur = db.execute(
f"SELECT * FROM messages WHERE folder_id=? ORDER BY {sort_expr} {order} LIMIT ? OFFSET ?",
(folder_id, per_page, offset),
)
msgs = [dict(row) for row in cur.fetchall()]
return _paginated(msgs, total, page, per_page)
@api_bp.route('/mailboxes/<address>/messages/<int:msg_id>', methods=['GET'])
@jwt_required
def get_message(address, msg_id):
if not _check_mailbox_access(address):
return jsonify({"error": "forbidden"}), 403
db = get_db()
cur = db.execute(
"SELECT * FROM messages WHERE id=? AND mailbox_address=?",
(msg_id, address),
)
msg = cur.fetchone()
if not msg:
return jsonify({"error": "not found"}), 404
res = dict(msg)
cur = db.execute("SELECT * FROM attachments WHERE message_id=?", (msg_id,))
res['attachments'] = [dict(row) for row in cur.fetchall()]
return jsonify(res)
@api_bp.route('/mailboxes/<address>/messages/<int:msg_id>', methods=['DELETE'])
@jwt_required
def delete_message(address, msg_id):
if not _check_mailbox_access(address):
return jsonify({"error": "forbidden"}), 403
db = get_db()
cur = db.execute(
"SELECT folder_id, has_attachments FROM messages WHERE id=? AND mailbox_address=?",
(msg_id, address),
)
msg = cur.fetchone()
if not msg:
return jsonify({"error": "not found"}), 404
cur = db.execute(
"SELECT id FROM folders WHERE mailbox_address=? AND name=?",
(address, 'Trash'),
)
trash_row = cur.fetchone()
if not trash_row:
return jsonify({"error": "trash folder missing"}), 500
trash_id = trash_row['id']
if msg['folder_id'] == trash_id:
if msg['has_attachments']:
shutil.rmtree(os.path.join(config.ATTACHMENTS_PATH, str(msg_id)), ignore_errors=True)
db.execute("DELETE FROM messages WHERE id=?", (msg_id,))
db.commit()
quota_row = db.execute("SELECT quota_bytes FROM mailboxes WHERE address=?", (address,)).fetchone()
if quota_row and quota_row['quota_bytes'] and quota_row['quota_bytes'] > 0:
new_size = int(db.execute(
"SELECT COALESCE(SUM(size_bytes),0) FROM messages WHERE mailbox_address=? AND is_system=0",
(address,)
).fetchone()[0])
if new_size < quota_row['quota_bytes']:
_post_quota_kv_action(address, 'unblock')
else:
db.execute(
"UPDATE messages SET folder_id=? WHERE id=?", (trash_id, msg_id)
)
db.commit()
return jsonify({"status": "deleted"})
@api_bp.route('/mailboxes/<address>/messages/<int:msg_id>', methods=['PATCH'])
@jwt_required
def patch_message(address, msg_id):
if not _check_mailbox_access(address):
return jsonify({"error": "forbidden"}), 403
data = request.json or {}
db = get_db()
if 'is_read' in data:
db.execute(
"UPDATE messages SET is_read=? WHERE id=? AND mailbox_address=?",
(int(bool(data['is_read'])), msg_id, address),
)
if 'is_starred' in data:
db.execute(
"UPDATE messages SET is_starred=? WHERE id=? AND mailbox_address=?",
(int(bool(data['is_starred'])), msg_id, address),
)
if 'folder_id' in data:
db.execute(
"UPDATE messages SET folder_id=? WHERE id=? AND mailbox_address=?",
(data['folder_id'], msg_id, address),
)
db.commit()
return jsonify({"status": "updated"})
@api_bp.route('/mailboxes/<address>/messages/move', methods=['POST'])
@jwt_required
def bulk_move(address):
if not _check_mailbox_access(address):
return jsonify({"error": "forbidden"}), 403
data = request.json or {}
msg_ids = data.get('message_ids', [])
folder_id = data.get('folder_id')
if not msg_ids or folder_id is None:
return jsonify({"error": "message_ids and folder_id are required"}), 400
db = get_db()
for mid in msg_ids:
db.execute(
"UPDATE messages SET folder_id=? WHERE id=? AND mailbox_address=?",
(folder_id, mid, address),
)
db.commit()
return jsonify({"status": "moved", "count": len(msg_ids)})
@api_bp.route('/mailboxes/<address>/messages/mark', methods=['POST'])
@jwt_required
def bulk_mark(address):
if not _check_mailbox_access(address):
return jsonify({"error": "forbidden"}), 403
data = request.json or {}
msg_ids = data.get('message_ids', [])
is_read = data.get('is_read')
if not msg_ids or is_read is None:
return jsonify({"error": "message_ids and is_read are required"}), 400
db = get_db()
for mid in msg_ids:
db.execute(
"UPDATE messages SET is_read=? WHERE id=? AND mailbox_address=?",
(int(bool(is_read)), mid, address),
)
db.commit()
return jsonify({"status": "marked", "count": len(msg_ids)})
@api_bp.route('/mailboxes/<address>/folders', methods=['GET'])
@jwt_required
def get_folders(address):
if not _check_mailbox_access(address):
return jsonify({"error": "forbidden"}), 403
db = get_db()
cur = db.execute(
"SELECT id, name, system_folder, color FROM folders WHERE mailbox_address=?",
(address,),
)
folders = [dict(row) for row in cur.fetchall()]
for f in folders:
cur = db.execute(
"SELECT COUNT(*) FROM messages WHERE folder_id=? AND is_read=0",
(f['id'],),
)
f['unread_count'] = cur.fetchone()[0]
cur = db.execute(
"SELECT COUNT(*) FROM messages WHERE folder_id=?",
(f['id'],),
)
f['total_count'] = cur.fetchone()[0]
return jsonify(folders)
@api_bp.route('/mailboxes/<address>/folders', methods=['POST'])
@jwt_required
def create_folder(address):
if not _check_mailbox_access(address):
return jsonify({"error": "forbidden"}), 403
data = request.json or {}
name = (data.get('name') or '').strip()
if not name:
return jsonify({"error": "name is required"}), 400
color = (data.get('color') or '').strip() or None
now = datetime.now(timezone.utc).isoformat()
db = get_db()
try:
db.execute(
"INSERT INTO folders (mailbox_address, name, system_folder, color, created_at) VALUES (?, ?, 0, ?, ?)",
(address, name, color, now),
)
db.commit()
except Exception as e:
db.rollback()
return jsonify({"error": str(e)}), 400
return jsonify({"status": "created"}), 201
@api_bp.route('/mailboxes/<address>/folders/<int:fid>', methods=['PATCH'])
@jwt_required
def patch_folder(address, fid):
if not _check_mailbox_access(address):
return jsonify({"error": "forbidden"}), 403
db = get_db()
cur = db.execute(
"SELECT system_folder FROM folders WHERE id=? AND mailbox_address=?",
(fid, address),
)
row = cur.fetchone()
if not row:
return jsonify({"error": "not found"}), 404
if row['system_folder'] == 1:
return jsonify({"error": "cannot modify system folder"}), 400
data = request.json or {}
fields, values = [], []
if 'name' in data:
name = (data['name'] or '').strip()
if not name:
return jsonify({"error": "name cannot be empty"}), 400
fields.append("name=?")
values.append(name)
if 'color' in data:
color = (data['color'] or '').strip() or None
fields.append("color=?")
values.append(color)
if not fields:
return jsonify({"error": "nothing to update"}), 400
values.extend([fid, address])
try:
db.execute(f"UPDATE folders SET {', '.join(fields)} WHERE id=? AND mailbox_address=?", values)
db.commit()
except Exception as e:
db.rollback()
return jsonify({"error": str(e)}), 400
return jsonify({"status": "updated"})
@api_bp.route('/mailboxes/<address>/folders/<int:fid>', methods=['DELETE'])
@jwt_required
def delete_folder(address, fid):
if not _check_mailbox_access(address):
return jsonify({"error": "forbidden"}), 403
db = get_db()
cur = db.execute(
"SELECT system_folder FROM folders WHERE id=? AND mailbox_address=?",
(fid, address),
)
row = cur.fetchone()
if not row:
return jsonify({"error": "not found"}), 404
if row['system_folder'] == 1:
return jsonify({"error": "cannot delete system folder"}), 400
db.execute(
"DELETE FROM folders WHERE id=? AND mailbox_address=?", (fid, address)
)
db.commit()
return jsonify({"status": "deleted"})
@api_bp.route('/mailboxes/<address>/folders/<int:fid>/empty', methods=['DELETE'])
@jwt_required
def empty_folder(address, fid):
if not _check_mailbox_access(address):
return jsonify({"error": "forbidden"}), 403
db = get_db()
cur = db.execute(
"SELECT name FROM folders WHERE id=? AND mailbox_address=?",
(fid, address),
)
row = cur.fetchone()
if not row:
return jsonify({"error": "not found"}), 404
if row['name'] != 'Trash':
return jsonify({"error": "can only empty Trash folder"}), 400
msg_rows = db.execute(
"SELECT id FROM messages WHERE folder_id=? AND mailbox_address=? AND has_attachments=1",
(fid, address),
).fetchall()
for msg in msg_rows:
shutil.rmtree(os.path.join(config.ATTACHMENTS_PATH, str(msg['id'])), ignore_errors=True)
db.execute("DELETE FROM messages WHERE folder_id=? AND mailbox_address=?", (fid, address))
db.commit()
quota_row = db.execute("SELECT quota_bytes FROM mailboxes WHERE address=?", (address,)).fetchone()
if quota_row and quota_row['quota_bytes'] and quota_row['quota_bytes'] > 0:
new_size = int(db.execute(
"SELECT COALESCE(SUM(size_bytes),0) FROM messages WHERE mailbox_address=? AND is_system=0",
(address,)
).fetchone()[0])
if new_size < quota_row['quota_bytes']:
_post_quota_kv_action(address, 'unblock')
return jsonify({"status": "emptied"})
@api_bp.route('/mailboxes/<address>/search', methods=['GET'])
@jwt_required
def search_messages(address):
if not _check_mailbox_access(address):
return jsonify({"error": "forbidden"}), 403
q = request.args.get('q', '').strip()
if not q:
return jsonify({"error": "q parameter is required"}), 400
folder = request.args.get('folder')
page = max(1, int(request.args.get('page', 1)))
per_page = min(100, max(1, int(request.args.get('per_page', 50))))
offset = (page - 1) * per_page
safe_q = re.sub(r'[^\w\s@.\-]', '', q).strip()
if not safe_q:
return jsonify({"error": "invalid search query"}), 400
db = get_db()
count_query = """
SELECT COUNT(*) FROM messages m
JOIN messages_fts fts ON m.id = fts.rowid
WHERE m.mailbox_address = ? AND messages_fts MATCH ?
"""
select_query = """
SELECT m.* FROM messages m
JOIN messages_fts fts ON m.id = fts.rowid
WHERE m.mailbox_address = ? AND messages_fts MATCH ?
"""
params = [address, safe_q]
if folder:
cur = db.execute(
"SELECT id FROM folders WHERE mailbox_address=? AND name=?",
(address, folder),
)
row = cur.fetchone()
if row:
count_query += " AND m.folder_id = ?"
select_query += " AND m.folder_id = ?"
params.append(row['id'])
try:
cur = db.execute(count_query, params)
total = cur.fetchone()[0]
select_query += " ORDER BY m.received_at DESC LIMIT ? OFFSET ?"
cur = db.execute(select_query, params + [per_page, offset])
msgs = [dict(row) for row in cur.fetchall()]
except Exception:
log.exception("FTS search failed for query: %s", safe_q)
return jsonify({"error": "search query syntax error"}), 400
return _paginated(msgs, total, page, per_page)
def _parse_email_address(raw):
m = re.search(r'<([^>]+)>', raw)
return m.group(1).strip() if m else raw.strip()
def _local_deliver(db, recipient_addr, from_address, to_field, data, subject, text, html, msg_id, now):
cur = db.execute(
"SELECT id FROM folders WHERE mailbox_address=? AND name='Inbox'",
(recipient_addr,),
)
inbox = cur.fetchone()
if not inbox:
return
local_msg_id = f"<local-{uuid.uuid4()}@{msg_id.split('@')[-1].rstrip('>')}>"
db.execute("""
INSERT INTO messages (
message_id, mailbox_address, folder_id, from_address,
to_addresses, cc_addresses, subject, text_body, html_body,
received_at, is_read, is_starred, is_draft, in_reply_to,
reference_ids, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, 0, ?, ?, ?)
""", (
local_msg_id, recipient_addr, inbox['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', ''),
data.get('references', ''), now,
))
log.info("Local delivery: msg_id=%s to=%s", local_msg_id, recipient_addr)
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
to_field = data.get('to', [])
if isinstance(to_field, str):
to_field = [to_field]
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', '')
if len(text) > _MAX_BODY_LEN or len(html) > _MAX_BODY_LEN:
return jsonify({"error": "message body too large"}), 413
attachments = data.get('attachments') or []
_MAX_ATTACH_BYTES = 10 * 1024 * 1024
total_attach = sum(a.get('size_bytes', 0) for a in attachments)
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]}>"
db = get_db()
local_recipients = []
external_recipients = []
for recipient in to_field:
addr = _parse_email_address(recipient)
cur = db.execute("SELECT address FROM mailboxes WHERE address=? AND is_active=1", (addr,))
if cur.fetchone():
local_recipients.append(addr)
else:
external_recipients.append(recipient)
status = 'sent'
error_msg = None
worker_resp = None
sender_domain = address.split('@')[-1] if '@' in address else ''
outbound_url = config.OUTBOUND_WORKER_URL
outbound_auth = config.OUTBOUND_AUTH_SECRET
if sender_domain:
db_cfg = db.execute(
"SELECT outbound_worker_url, outbound_auth_secret FROM domain_configs WHERE domain_name=?",
(sender_domain,)
).fetchone()
if db_cfg and db_cfg['outbound_worker_url']:
outbound_url = db_cfg['outbound_worker_url']
outbound_auth = db_cfg['outbound_auth_secret']
if external_recipients:
worker_payload = {
"from": from_address,
"to": external_recipients,
"cc": data.get('cc'),
"bcc": data.get('bcc'),
"subject": subject,
"text": text,
"html": html,
"replyTo": data.get('reply_to') or data.get('replyTo'),
"inReplyTo": data.get('in_reply_to') or data.get('inReplyTo'),
"references": data.get('references'),
"messageId": msg_id,
"attachments": attachments,
}
if outbound_url:
try:
resp = http_requests.post(
outbound_url,
json=worker_payload,
headers={"Authorization": f"Bearer {outbound_auth}"},
timeout=30,
)
worker_resp = resp.text
if not resp.ok:
status = 'failed'
error_msg = resp.text
except Exception as e:
status = 'failed'
error_msg = str(e)
else:
status = 'failed'
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, via_alias) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(msg_id, from_address, json.dumps(to_field), subject, now, status, error_msg, worker_resp, via_alias),
)
if status == 'sent':
limiter.record_send(address)
cur = db.execute(
"SELECT id FROM folders WHERE mailbox_address=? AND name='Sent'",
(address,),
)
sent_folder = cur.fetchone()
if sent_folder:
cur2 = db.execute("""
INSERT INTO messages (
message_id, mailbox_address, folder_id, from_address,
to_addresses, cc_addresses, subject, text_body, html_body,
sent_at, is_read, is_starred, is_draft, in_reply_to,
reference_ids, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 0, 0, ?, ?, ?)
""", (
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', ''),
data.get('references', ''), now,
))
sent_msg_id = cur2.lastrowid
for att in attachments:
db.execute(
"INSERT INTO attachments (message_id, filename, content_type, size_bytes, storage_path, is_inline, created_at) VALUES (?, ?, ?, ?, ?, 0, ?)",
(sent_msg_id, att['filename'], att['content_type'], att.get('size_bytes', 0), None, now),
)
for recipient_addr in local_recipients:
_local_deliver(db, recipient_addr, address, to_field, data, subject, text, html, msg_id, now)
db.commit()
if status == 'failed':
log.warning("Send failed: from=%s error=%s", address, error_msg)
return jsonify({"error": error_msg or "Send failed"}), 502
log.info("Send success: from=%s to=%s msg_id=%s", address, to_field, msg_id)
return jsonify({"status": "sent", "message_id": msg_id})
@api_bp.route('/mailboxes/<address>/send', methods=['POST'])
@jwt_required
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 = {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')
attachments = []
for f in files:
raw = f.read()
attachments.append({
'filename': f.filename,
'content_type': f.content_type or 'application/octet-stream',
'data_b64': base64.b64encode(raw).decode('ascii'),
'size_bytes': len(raw),
})
data['attachments'] = attachments
else:
data = request.json or {}
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/<address>/drafts', methods=['POST'])
@jwt_required
def create_draft(address):
if not _check_mailbox_access(address):
return jsonify({"error": "forbidden"}), 403
data = request.json or {}
db = get_db()
cur = db.execute(
"SELECT id FROM folders WHERE mailbox_address=? AND name='Drafts'",
(address,),
)
row = cur.fetchone()
if not row:
return jsonify({"error": "drafts folder missing"}), 500
folder_id = row['id']
now = datetime.now(timezone.utc).isoformat()
cur = db.execute("""
INSERT INTO messages (
message_id, mailbox_address, folder_id, to_addresses, subject,
text_body, html_body, is_draft, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?)
""", (
str(uuid.uuid4()), address, folder_id,
json.dumps(data.get('to', [])), data.get('subject', ''),
data.get('text_body', ''), data.get('html_body', ''), now,
))
db.commit()
return jsonify({"status": "created", "id": cur.lastrowid}), 201
@api_bp.route('/mailboxes/<address>/drafts/<int:did>', methods=['PUT'])
@jwt_required
def update_draft(address, did):
if not _check_mailbox_access(address):
return jsonify({"error": "forbidden"}), 403
data = request.json or {}
db = get_db()
db.execute("""
UPDATE messages SET to_addresses=?, subject=?, text_body=?, html_body=?
WHERE id=? AND mailbox_address=? AND is_draft=1
""", (
json.dumps(data.get('to', [])), data.get('subject', ''),
data.get('text_body', ''), data.get('html_body', ''),
did, address,
))
db.commit()
return jsonify({"status": "updated"})
@api_bp.route('/mailboxes/<address>/drafts/<int:did>/send', methods=['POST'])
@jwt_required
def send_draft(address, did):
if not _check_mailbox_access(address):
return jsonify({"error": "forbidden"}), 403
db = get_db()
cur = db.execute(
"SELECT * FROM messages WHERE id=? AND mailbox_address=? AND is_draft=1",
(did, address),
)
draft = cur.fetchone()
if not draft:
return jsonify({"error": "draft not found"}), 404
draft = dict(draft)
send_data = {
"to": json.loads(draft.get('to_addresses') or '[]'),
"subject": draft.get('subject', ''),
"text_body": draft.get('text_body', ''),
"html_body": draft.get('html_body', ''),
"in_reply_to": draft.get('in_reply_to', ''),
"references": draft.get('reference_ids', ''),
}
result = _dispatch_send(address, send_data)
response = result[0] if isinstance(result, tuple) else result
status_code = result[1] if isinstance(result, tuple) else response.status_code
if status_code < 400:
db.execute(
"DELETE FROM messages WHERE id=? AND mailbox_address=? AND is_draft=1",
(did, address),
)
db.commit()
return result
@api_bp.route('/attachments/<int:aid>/download', methods=['GET'])
@jwt_required
def download_attachment(aid):
db = get_db()
cur = db.execute("SELECT * FROM attachments WHERE id=?", (aid,))
att = cur.fetchone()
if not att:
return jsonify({"error": "not found"}), 404
cur = db.execute(
"SELECT mailbox_address FROM messages WHERE id=?", (att['message_id'],)
)
msg = cur.fetchone()
if not msg or not _check_mailbox_access(msg['mailbox_address']):
return jsonify({"error": "forbidden"}), 403
att_path = os.path.abspath(att['storage_path'])
safe_root = os.path.abspath(config.ATTACHMENTS_PATH) + os.sep
if not att_path.startswith(safe_root):
return jsonify({"error": "invalid path"}), 400
if not os.path.isfile(att_path):
return jsonify({"error": "file not found"}), 404
return send_file(
att_path,
as_attachment=True,
download_name=att['filename'],
mimetype=att['content_type'],
)
@api_bp.route('/logs/send-log', methods=['GET'])
@admin_required
def get_send_log():
db = get_db()
mailbox = request.args.get('mailbox')
status = request.args.get('status')
date_from = request.args.get('date_from')
date_to = request.args.get('date_to')
try:
page = max(1, int(request.args.get('page', 1)))
limit = min(200, max(1, int(request.args.get('limit', 50))))
except (ValueError, TypeError):
page, limit = 1, 50
offset = (page - 1) * limit
conditions = []
params = []
if mailbox:
conditions.append("from_address = ?")
params.append(mailbox)
if status:
conditions.append("status = ?")
params.append(status)
if date_from:
conditions.append("sent_at >= ?")
params.append(date_from)
if date_to:
conditions.append("sent_at <= ?")
params.append(date_to + 'T23:59:59')
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
total = db.execute(f"SELECT COUNT(*) FROM send_log {where}", params).fetchone()[0]
rows = db.execute(
f"SELECT id, message_id, from_address, to_addresses, subject, sent_at, status, error_message FROM send_log {where} ORDER BY sent_at DESC LIMIT ? OFFSET ?",
params + [limit, offset],
).fetchall()
logs = []
for row in rows:
entry = dict(row)
try:
entry['to_addresses'] = json.loads(entry['to_addresses'] or '[]')
except Exception:
entry['to_addresses'] = [entry['to_addresses']] if entry['to_addresses'] else []
logs.append(entry)
return jsonify({"logs": logs, "total": total, "page": page, "limit": limit})
@api_bp.route('/logs/bounce-log', methods=['GET'])
@admin_required
def get_bounce_log():
db = get_db()
bounce_type = request.args.get('bounce_type')
date_from = request.args.get('date_from')
date_to = request.args.get('date_to')
try:
page = max(1, int(request.args.get('page', 1)))
limit = min(200, max(1, int(request.args.get('limit', 50))))
except (ValueError, TypeError):
page, limit = 1, 50
offset = (page - 1) * limit
conditions = []
params = []
if bounce_type:
conditions.append("bounce_type = ?")
params.append(bounce_type)
if date_from:
conditions.append("received_at >= ?")
params.append(date_from)
if date_to:
conditions.append("received_at <= ?")
params.append(date_to + 'T23:59:59')
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
total = db.execute(f"SELECT COUNT(*) FROM bounce_log {where}", params).fetchone()[0]
rows = db.execute(
f"SELECT * FROM bounce_log {where} ORDER BY received_at DESC LIMIT ? OFFSET ?",
params + [limit, offset],
).fetchall()
return jsonify({"logs": [dict(r) for r in rows], "total": total, "page": page, "limit": limit})
@api_bp.route('/logs/stats', methods=['GET'])
@admin_required
def get_log_stats():
db = get_db()
total_sent = db.execute("SELECT COUNT(*) FROM send_log WHERE status='sent'").fetchone()[0]
total_failed = db.execute("SELECT COUNT(*) FROM send_log WHERE status='failed'").fetchone()[0]
total_bounced = db.execute("SELECT COUNT(*) FROM bounce_log").fetchone()[0]
bounce_rate = round(total_bounced / total_sent * 100, 2) if total_sent > 0 else 0.0
top_reasons = [
dict(r) for r in db.execute(
"SELECT reason, COUNT(*) as count FROM bounce_log WHERE reason != '' GROUP BY reason ORDER BY count DESC LIMIT 5"
).fetchall()
]
return jsonify({
"total_sent": total_sent,
"total_failed": total_failed,
"total_bounced": total_bounced,
"bounce_rate": bounce_rate,
"top_bounce_reasons": top_reasons,
})
@api_bp.route('/catch-all/<domain>', methods=['GET'])
@admin_required
def get_catch_all(domain):
db = get_db()
row = db.execute("SELECT catch_all_mailbox FROM domain_configs WHERE domain_name=?", (domain,)).fetchone()
if not row:
return jsonify({"error": "domain not found"}), 404
return jsonify({"catch_all_mailbox": row['catch_all_mailbox']})
@api_bp.route('/catch-all/<domain>', methods=['POST'])
@admin_required
def set_catch_all(domain):
data = request.json or {}
mailbox = (data.get('mailbox_address') or '').strip() or None
db = get_db()
if not db.execute("SELECT 1 FROM domain_configs WHERE domain_name=?", (domain,)).fetchone():
return jsonify({"error": "domain not found"}), 404
db.execute("UPDATE domain_configs SET catch_all_mailbox=? WHERE domain_name=?", (mailbox, domain))
db.commit()
return jsonify({"status": "updated", "catch_all_mailbox": mailbox})
@api_bp.route('/catch-all/<domain>', methods=['DELETE'])
@admin_required
def clear_catch_all(domain):
db = get_db()
db.execute("UPDATE domain_configs SET catch_all_mailbox=NULL WHERE domain_name=?", (domain,))
db.commit()
return jsonify({"status": "updated"})
@api_bp.route('/mailboxes/<address>/auto-responder', methods=['GET'])
@jwt_required
def get_auto_responder(address):
if not _check_mailbox_access(address):
return jsonify({"error": "forbidden"}), 403
db = get_db()
row = db.execute("SELECT * FROM auto_responders WHERE mailbox_address=?", (address,)).fetchone()
return jsonify({"auto_responder": dict(row) if row else None})
@api_bp.route('/mailboxes/<address>/auto-responder', methods=['POST'])
@jwt_required
def set_auto_responder(address):
if not _check_mailbox_access(address):
return jsonify({"error": "forbidden"}), 403
data = request.json or {}
subject = (data.get('subject') or 'Auto Reply').strip()
message_body = (data.get('message_body') or '').strip()
if not message_body:
return jsonify({"error": "message_body is required"}), 400
start_date = data.get('start_date') or None
end_date = data.get('end_date') or None
is_active = 1 if data.get('is_active', True) else 0
reply_interval_hours = max(1, int(data.get('reply_interval_hours', 24) or 24))
db = get_db()
now = datetime.now(timezone.utc).isoformat()
existing = db.execute("SELECT id FROM auto_responders WHERE mailbox_address=?", (address,)).fetchone()
if existing:
db.execute(
"UPDATE auto_responders SET subject=?, message_body=?, start_date=?, end_date=?, is_active=?, reply_interval_hours=?, updated_at=? WHERE mailbox_address=?",
(subject, message_body, start_date, end_date, is_active, reply_interval_hours, now, address),
)
else:
db.execute(
"INSERT INTO auto_responders (mailbox_address, subject, message_body, start_date, end_date, is_active, reply_interval_hours, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(address, subject, message_body, start_date, end_date, is_active, reply_interval_hours, now, now),
)
db.commit()
row = db.execute("SELECT * FROM auto_responders WHERE mailbox_address=?", (address,)).fetchone()
return jsonify({"auto_responder": dict(row)})
@api_bp.route('/mailboxes/<address>/auto-responder', methods=['DELETE'])
@jwt_required
def delete_auto_responder(address):
if not _check_mailbox_access(address):
return jsonify({"error": "forbidden"}), 403
db = get_db()
db.execute("DELETE FROM auto_responders WHERE mailbox_address=?", (address,))
db.commit()
return jsonify({"status": "deleted"})
@api_bp.route('/auto-responders', methods=['GET'])
@admin_required
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/<path:address>', 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/<path:address>', 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/<path:address>', 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"})