mirror of
https://github.com/ChrispyBacon-dev/DockFlare.git
synced 2026-04-28 03:39:32 +00:00
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
1522 lines
54 KiB
Python
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"})
|