mirror of
https://github.com/ChrispyBacon-dev/DockFlare.git
synced 2026-04-28 03:39:32 +00:00
477 lines
17 KiB
Python
477 lines
17 KiB
Python
from flask import Blueprint, request, jsonify, send_file
|
|
from app.core.database import get_db
|
|
from app.api.middleware import jwt_required, admin_required
|
|
from app.core.rate_limiter import limiter
|
|
import json
|
|
import uuid
|
|
import os
|
|
import requests as http_requests
|
|
from datetime import datetime, timezone
|
|
from app.config import ATTACHMENTS_PATH, OUTBOUND_WORKER_URL, OUTBOUND_AUTH_SECRET
|
|
|
|
api_bp = Blueprint('api', __name__)
|
|
|
|
@api_bp.route('/health', methods=['GET'])
|
|
def health():
|
|
db = get_db()
|
|
db.close()
|
|
return jsonify({"status": "ok", "version": "1.0.0", "db_size_bytes": 0})
|
|
|
|
@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]
|
|
db.close()
|
|
return jsonify({
|
|
"total_messages": total_messages,
|
|
"unread_count": unread_count,
|
|
"total_sent": total_sent,
|
|
"storage_bytes": 0,
|
|
"mailbox_count": mailbox_count,
|
|
"messages_today": 0,
|
|
"sent_today": 0
|
|
})
|
|
|
|
@api_bp.route('/mailboxes', methods=['GET'])
|
|
@admin_required
|
|
def get_mailboxes():
|
|
db = get_db()
|
|
cur = db.cursor()
|
|
cur.execute("SELECT * FROM mailboxes")
|
|
res = [dict(row) for row in cur.fetchall()]
|
|
db.close()
|
|
return jsonify(res)
|
|
|
|
@api_bp.route('/mailboxes', methods=['POST'])
|
|
@admin_required
|
|
def create_mailbox():
|
|
data = request.json
|
|
db = get_db()
|
|
now = datetime.now(timezone.utc).isoformat()
|
|
try:
|
|
db.execute(
|
|
"INSERT INTO mailboxes (address, display_name, domain, created_at, is_active) VALUES (?, ?, ?, ?, ?)",
|
|
(data['address'], data.get('display_name', ''), data['domain'], now, 1)
|
|
)
|
|
for folder in ['Inbox', 'Sent', 'Drafts', 'Trash', 'Spam']:
|
|
db.execute(
|
|
"INSERT INTO folders (mailbox_address, name, system_folder, created_at) VALUES (?, ?, 1, ?)",
|
|
(data['address'], folder, now)
|
|
)
|
|
db.commit()
|
|
except Exception as e:
|
|
db.rollback()
|
|
return jsonify({"error": str(e)}), 400
|
|
finally:
|
|
db.close()
|
|
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()
|
|
db.close()
|
|
if row:
|
|
return jsonify(dict(row))
|
|
return jsonify({"error": "not found"}), 404
|
|
|
|
@api_bp.route('/mailboxes/<address>', methods=['DELETE'])
|
|
@admin_required
|
|
def delete_mailbox(address):
|
|
db = get_db()
|
|
db.execute("DELETE FROM mailboxes WHERE address=?", (address,))
|
|
db.commit()
|
|
db.close()
|
|
return jsonify({"status": "deleted"})
|
|
|
|
@api_bp.route('/mailboxes/<address>', methods=['PATCH'])
|
|
@admin_required
|
|
def patch_mailbox(address):
|
|
data = request.json
|
|
if 'display_name' in data:
|
|
db = get_db()
|
|
db.execute("UPDATE mailboxes SET display_name=? WHERE address=?", (data['display_name'], address))
|
|
db.commit()
|
|
db.close()
|
|
return jsonify({"status": "updated"})
|
|
|
|
def check_mailbox_access(address):
|
|
if request.user.get('role') == 'admin':
|
|
return True
|
|
return address in request.user.get('mailboxes', [])
|
|
|
|
@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 = int(request.args.get('page', 1))
|
|
per_page = int(request.args.get('per_page', 50))
|
|
offset = (page - 1) * per_page
|
|
|
|
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:
|
|
db.close()
|
|
return jsonify({"error": "folder not found"}), 404
|
|
|
|
folder_id = folder_row['id']
|
|
cur = db.execute("SELECT * FROM messages WHERE folder_id=? ORDER BY received_at DESC LIMIT ? OFFSET ?", (folder_id, per_page, offset))
|
|
msgs = [dict(row) for row in cur.fetchall()]
|
|
db.close()
|
|
return jsonify(msgs)
|
|
|
|
@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:
|
|
db.close()
|
|
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()]
|
|
db.close()
|
|
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 FROM messages WHERE id=? AND mailbox_address=?", (msg_id, address))
|
|
msg = cur.fetchone()
|
|
if not msg:
|
|
db.close()
|
|
return jsonify({"error": "not found"}), 404
|
|
|
|
cur = db.execute("SELECT id FROM folders WHERE mailbox_address=? AND name=?", (address, 'Trash'))
|
|
trash_id = cur.fetchone()['id']
|
|
|
|
if msg['folder_id'] == trash_id:
|
|
db.execute("DELETE FROM messages WHERE id=?", (msg_id,))
|
|
else:
|
|
db.execute("UPDATE messages SET folder_id=? WHERE id=?", (trash_id, msg_id))
|
|
|
|
db.commit()
|
|
db.close()
|
|
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
|
|
db = get_db()
|
|
if 'is_read' in data:
|
|
db.execute("UPDATE messages SET is_read=? WHERE id=? AND mailbox_address=?", (data['is_read'], msg_id, address))
|
|
if 'is_starred' in data:
|
|
db.execute("UPDATE messages SET is_starred=? WHERE id=? AND mailbox_address=?", (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()
|
|
db.close()
|
|
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
|
|
msg_ids = data.get('message_ids', [])
|
|
folder_id = data.get('folder_id')
|
|
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()
|
|
db.close()
|
|
return jsonify({"status": "moved"})
|
|
|
|
@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
|
|
msg_ids = data.get('message_ids', [])
|
|
is_read = data.get('is_read')
|
|
db = get_db()
|
|
for mid in msg_ids:
|
|
db.execute("UPDATE messages SET is_read=? WHERE id=? AND mailbox_address=?", (is_read, mid, address))
|
|
db.commit()
|
|
db.close()
|
|
return jsonify({"status": "marked"})
|
|
|
|
@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 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]
|
|
db.close()
|
|
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
|
|
name = request.json.get('name')
|
|
now = datetime.now(timezone.utc).isoformat()
|
|
db = get_db()
|
|
try:
|
|
db.execute("INSERT INTO folders (mailbox_address, name, system_folder, created_at) VALUES (?, ?, 0, ?)", (address, name, now))
|
|
db.commit()
|
|
except Exception as e:
|
|
db.rollback()
|
|
db.close()
|
|
return jsonify({"error": str(e)}), 400
|
|
db.close()
|
|
return jsonify({"status": "created"}), 201
|
|
|
|
@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 row and row['system_folder'] == 1:
|
|
db.close()
|
|
return jsonify({"error": "cannot delete system folder"}), 400
|
|
db.execute("DELETE FROM folders WHERE id=? AND mailbox_address=?", (fid, address))
|
|
db.commit()
|
|
db.close()
|
|
return jsonify({"status": "deleted"})
|
|
|
|
@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', '')
|
|
folder = request.args.get('folder')
|
|
page = int(request.args.get('page', 1))
|
|
per_page = int(request.args.get('per_page', 50))
|
|
offset = (page - 1) * per_page
|
|
|
|
db = get_db()
|
|
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, q]
|
|
|
|
if folder:
|
|
cur = db.execute("SELECT id FROM folders WHERE mailbox_address=? AND name=?", (address, folder))
|
|
row = cur.fetchone()
|
|
if row:
|
|
query += " AND m.folder_id = ?"
|
|
params.append(row['id'])
|
|
|
|
query += " ORDER BY m.received_at DESC LIMIT ? OFFSET ?"
|
|
params.extend([per_page, offset])
|
|
|
|
cur = db.execute(query, params)
|
|
msgs = [dict(row) for row in cur.fetchall()]
|
|
db.close()
|
|
return jsonify(msgs)
|
|
|
|
def _dispatch_send(address, data):
|
|
allowed, reason = limiter.check_rate(address)
|
|
if not allowed:
|
|
return jsonify({"error": reason}), 429
|
|
|
|
now = datetime.now(timezone.utc).isoformat()
|
|
msg_id = f"<{uuid.uuid4()}@{address.split('@')[1]}>"
|
|
|
|
worker_payload = {
|
|
"from": address,
|
|
"to": data.get('to', []),
|
|
"cc": data.get('cc'),
|
|
"bcc": data.get('bcc'),
|
|
"subject": data.get('subject', ''),
|
|
"text": data.get('text') or data.get('text_body', ''),
|
|
"html": data.get('html') or data.get('html_body', ''),
|
|
"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
|
|
}
|
|
|
|
status = 'sent'
|
|
error_msg = None
|
|
worker_resp = None
|
|
|
|
if OUTBOUND_WORKER_URL:
|
|
try:
|
|
resp = http_requests.post(
|
|
OUTBOUND_WORKER_URL,
|
|
json=worker_payload,
|
|
headers={"Authorization": f"Bearer {OUTBOUND_AUTH_SECRET}"},
|
|
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 = get_db()
|
|
db.execute(
|
|
"INSERT INTO send_log (message_id, from_address, to_addresses, subject, sent_at, status, error_message, worker_response) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
(msg_id, address, json.dumps(data.get('to', [])), data.get('subject', ''), now, status, error_msg, worker_resp)
|
|
)
|
|
|
|
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:
|
|
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'], address,
|
|
json.dumps(data.get('to', [])), json.dumps(data.get('cc', [])),
|
|
data.get('subject', ''), data.get('text') or data.get('text_body', ''), data.get('html') or data.get('html_body', ''),
|
|
now, data.get('in_reply_to') or data.get('inReplyTo', ''), data.get('references', ''), now
|
|
))
|
|
|
|
db.commit()
|
|
db.close()
|
|
|
|
if status == 'failed':
|
|
return jsonify({"error": error_msg or "Send failed"}), 502
|
|
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
|
|
return _dispatch_send(address, request.json)
|
|
|
|
@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
|
|
db = get_db()
|
|
cur = db.execute("SELECT id FROM folders WHERE mailbox_address=? AND name='Drafts'", (address,))
|
|
folder_id = cur.fetchone()['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()
|
|
draft_id = cur.lastrowid
|
|
db.close()
|
|
return jsonify({"status": "created", "id": draft_id})
|
|
|
|
@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
|
|
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()
|
|
db.close()
|
|
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()
|
|
db.close()
|
|
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)
|
|
if not isinstance(result, tuple):
|
|
db = get_db()
|
|
db.execute("DELETE FROM messages WHERE id=? AND mailbox_address=? AND is_draft=1", (did, address))
|
|
db.commit()
|
|
db.close()
|
|
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:
|
|
db.close()
|
|
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']):
|
|
db.close()
|
|
return jsonify({"error": "forbidden"}), 403
|
|
|
|
db.close()
|
|
path = att['storage_path']
|
|
if not os.path.abspath(path).startswith(os.path.abspath(ATTACHMENTS_PATH)):
|
|
return jsonify({"error": "invalid path"}), 400
|
|
|
|
return send_file(path, as_attachment=True, download_name=att['filename'], mimetype=att['content_type'])
|