diff --git a/app/plugins/builtins/mail/serializers.py b/app/plugins/builtins/mail/serializers.py new file mode 100644 index 0000000..ec4e49a --- /dev/null +++ b/app/plugins/builtins/mail/serializers.py @@ -0,0 +1,169 @@ +"""Response serializers for the Mail plugin ORM models. + +Extracted from services.py as part of the God-object split (BUG-018 pilot). +Re-exported by ``app.plugins.builtins.mail.services``. + +account_to_response NEVER includes passwords — security-critical contract. +""" + +from __future__ import annotations + +import json + +from app.plugins.builtins.mail.models import ( + Mail, + MailAccount, + MailAttachment, + MailFolder, + MailLabel, + MailRule, + MailSignature, + MailTemplate, +) +from app.plugins.builtins.mail.text_utils import ( + extract_email_addresses, # noqa: F401 — re-exported for backwards compat +) + + +def attachment_to_response(att: MailAttachment) -> dict: + """Convert a MailAttachment ORM object to a response dict.""" + return { + "id": str(att.id), + "mail_id": str(att.mail_id), + "filename": att.filename, + "mime_type": att.mime_type, + "size_bytes": att.size_bytes, + "size": att.size_bytes, # alias for frontend compatibility + "content_id": att.content_id, + "is_inline": bool(att.content_id), + "dms_file_id": str(att.dms_file_id) if att.dms_file_id else None, + } + + +def account_to_response(account: MailAccount) -> dict: + """Convert MailAccount to response dict, NEVER including password.""" + return { + "id": str(account.id), + "email": account.email_address, + "email_address": account.email_address, + "display_name": account.display_name, + "imap_host": account.imap_host, + "imap_port": account.imap_port, + "imap_ssl": account.imap_ssl, + "smtp_host": account.smtp_host, + "smtp_port": account.smtp_port, + "smtp_tls": account.smtp_tls, + "username": account.username, + "is_shared": account.is_shared, + "is_active": account.is_active, + "sent_folder_imap_name": account.sent_folder_imap_name, + "drafts_folder_imap_name": account.drafts_folder_imap_name, + "spam_folder_imap_name": account.spam_folder_imap_name, + "trash_folder_imap_name": account.trash_folder_imap_name, + "created_at": account.created_at, + "updated_at": account.updated_at, + } + + +def mail_to_response( + mail: Mail, + attachments: list[MailAttachment] | None = None, + labels: list[MailLabel] | None = None, +) -> dict: + """Convert a Mail ORM object to a response dict.""" + resp = { + "id": str(mail.id), + "account_id": str(mail.account_id), + "folder_id": str(mail.folder_id), + "message_id": mail.message_id, + "thread_id": mail.thread_id, + "in_reply_to": mail.in_reply_to, + "subject": mail.subject, + "from_address": mail.from_address, + "from_name": mail.from_address.split("<")[0].strip().strip('"') if "<" in mail.from_address else mail.from_address, + "to_addresses": extract_email_addresses(mail.to_addresses) if mail.to_addresses else [], + "cc_addresses": extract_email_addresses(mail.cc_addresses) if mail.cc_addresses else [], + "bcc_addresses": extract_email_addresses(mail.bcc_addresses) if mail.bcc_addresses else [], + "body_text": mail.body_text, + "body_html": mail.body_html if mail.body_html else None, + "body_html_sanitized": mail.body_html_sanitized, + "sanitized_html": mail.body_html_sanitized, + "date": mail.received_at.isoformat() if mail.received_at else (mail.sent_at.isoformat() if mail.sent_at else None), + "is_seen": mail.is_seen, + "is_flagged": mail.is_flagged, + "flag_type": getattr(mail, 'flag_type', None), + "is_draft": mail.is_draft, + "is_answered": mail.is_answered, + "is_forwarded": mail.is_forwarded, + "has_attachments": mail.has_attachments, + "size_bytes": mail.size_bytes, + "received_at": mail.received_at, + "sent_at": mail.sent_at, + "contact_id": str(mail.contact_id) if mail.contact_id else None, + "attachments": [], + "labels": [], + } + if attachments: + resp["attachments"] = [attachment_to_response(a) for a in attachments] + if labels: + resp["labels"] = [ + {"id": str(lbl.id), "name": lbl.name, "color": lbl.color} for lbl in labels + ] + return resp + + +def folder_to_response(folder: MailFolder) -> dict: + """Convert MailFolder to response dict.""" + return { + "id": str(folder.id), + "account_id": str(folder.account_id), + "name": folder.name, + "imap_name": folder.imap_name, + "parent_id": str(folder.parent_id) if folder.parent_id else None, + "is_standard": folder.is_standard, + "unread_count": folder.unread_count, + "total_count": folder.total_count, + } + + +def rule_to_response(rule: MailRule) -> dict: + """Convert MailRule to response dict.""" + return { + "id": str(rule.id), + "name": rule.name, + "account_id": str(rule.account_id) if rule.account_id else None, + "priority": rule.priority, + "is_active": rule.is_active, + "conditions": json.loads(rule.conditions) if rule.conditions else {}, + "actions": json.loads(rule.actions) if rule.actions else {}, + } + + +def template_to_response(template: MailTemplate) -> dict: + """Convert MailTemplate to response dict.""" + return { + "id": str(template.id), + "name": template.name, + "subject": template.subject, + "body_html": template.body_html, + } + + +def signature_to_response(sig: MailSignature) -> dict: + """Convert MailSignature to response dict.""" + return { + "id": str(sig.id), + "name": sig.name, + "body_html": sig.body_html, + "account_id": str(sig.account_id) if sig.account_id else None, + "is_default": sig.is_default, + } + + +def label_to_response(label: MailLabel) -> dict: + """Convert MailLabel to response dict.""" + return { + "id": str(label.id), + "name": label.name, + "color": label.color, + } diff --git a/app/plugins/builtins/mail/services.py b/app/plugins/builtins/mail/services.py index d13f678..5acdf5b 100644 --- a/app/plugins/builtins/mail/services.py +++ b/app/plugins/builtins/mail/services.py @@ -29,25 +29,37 @@ from app.plugins.builtins.mail.crypto import ( # noqa: E402 encrypt_password, generate_salt, ) +from app.plugins.builtins.mail.models import ( + Mail, + MailAccount, + MailAttachment, + MailFolder, + MailLabelAssignment, + MailRule, + MailSignature, + VacationSentLog, +) from app.plugins.builtins.mail.pgp import ( # noqa: E402,F401 import_pgp_private_key, import_pgp_public_key, pgp_decrypt_message, pgp_encrypt_message, ) -from app.plugins.builtins.mail.models import ( - Mail, - MailAccount, - MailAttachment, - MailFolder, - MailLabel, - MailLabelAssignment, - MailRule, - MailSignature, - MailTemplate, - VacationSentLog, -) from app.plugins.builtins.mail.sanitize import sanitize_html # noqa: E402,F401 +from app.plugins.builtins.mail.serializers import ( # noqa: E402,F401 + account_to_response, + attachment_to_response, + folder_to_response, + label_to_response, + mail_to_response, + rule_to_response, + signature_to_response, + template_to_response, +) +from app.plugins.builtins.mail.text_utils import ( # noqa: E402,F401 + _strip_html, + extract_email_addresses, +) logger = logging.getLogger(__name__) @@ -124,21 +136,6 @@ async def _save_attachment_to_storage( return storage_path -def attachment_to_response(att: MailAttachment) -> dict: - """Convert a MailAttachment ORM object to a response dict.""" - return { - "id": str(att.id), - "mail_id": str(att.mail_id), - "filename": att.filename, - "mime_type": att.mime_type, - "size_bytes": att.size_bytes, - "size": att.size_bytes, # alias for frontend compatibility - "content_id": att.content_id, - "is_inline": bool(att.content_id), - "dms_file_id": str(att.dms_file_id) if att.dms_file_id else None, - } - - # ─── IMAP Quota Parser ─── @@ -276,31 +273,6 @@ async def get_account_password(account: MailAccount) -> str: return decrypt_password(account.encrypted_password, account.password_salt or None) -def account_to_response(account: MailAccount) -> dict: - """Convert MailAccount to response dict, NEVER including password.""" - return { - "id": str(account.id), - "email": account.email_address, - "email_address": account.email_address, - "display_name": account.display_name, - "imap_host": account.imap_host, - "imap_port": account.imap_port, - "imap_ssl": account.imap_ssl, - "smtp_host": account.smtp_host, - "smtp_port": account.smtp_port, - "smtp_tls": account.smtp_tls, - "username": account.username, - "is_shared": account.is_shared, - "is_active": account.is_active, - "sent_folder_imap_name": account.sent_folder_imap_name, - "drafts_folder_imap_name": account.drafts_folder_imap_name, - "spam_folder_imap_name": account.spam_folder_imap_name, - "trash_folder_imap_name": account.trash_folder_imap_name, - "created_at": account.created_at, - "updated_at": account.updated_at, - } - - # ─── IMAP Sync Service (F-MAIL-01) ─── @@ -1867,137 +1839,6 @@ async def log_vacation_sent( # ─── Contact Linking (F-MAIL-10) ─── -def extract_email_addresses(text: str) -> list[str]: - """Extract email addresses from a text string.""" - if not text: - return [] - return re.findall(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", text) - - -# ─── Utility ─── - - -def _strip_html(html: str) -> str: - """Simple HTML to text conversion for plain text fallback.""" - if not html: - return "" - # Remove tags - text = re.sub(r"<[^>]+>", "", html) - # Replace HTML entities - text = ( - text.replace(" ", " ") - .replace("&", "&") - .replace("<", "<") - .replace(">", ">") - .replace(""", '"') - ) - return text.strip() - - -def mail_to_response( - mail: Mail, - attachments: list[MailAttachment] | None = None, - labels: list[MailLabel] | None = None, -) -> dict: - """Convert a Mail ORM object to a response dict.""" - resp = { - "id": str(mail.id), - "account_id": str(mail.account_id), - "folder_id": str(mail.folder_id), - "message_id": mail.message_id, - "thread_id": mail.thread_id, - "in_reply_to": mail.in_reply_to, - "subject": mail.subject, - "from_address": mail.from_address, - "from_name": mail.from_address.split("<")[0].strip().strip('"') if "<" in mail.from_address else mail.from_address, - "to_addresses": extract_email_addresses(mail.to_addresses) if mail.to_addresses else [], - "cc_addresses": extract_email_addresses(mail.cc_addresses) if mail.cc_addresses else [], - "bcc_addresses": extract_email_addresses(mail.bcc_addresses) if mail.bcc_addresses else [], - "body_text": mail.body_text, - "body_html": mail.body_html if mail.body_html else None, - "body_html_sanitized": mail.body_html_sanitized, - "sanitized_html": mail.body_html_sanitized, - "date": mail.received_at.isoformat() if mail.received_at else (mail.sent_at.isoformat() if mail.sent_at else None), - "is_seen": mail.is_seen, - "is_flagged": mail.is_flagged, - "flag_type": getattr(mail, 'flag_type', None), - "is_draft": mail.is_draft, - "is_answered": mail.is_answered, - "is_forwarded": mail.is_forwarded, - "has_attachments": mail.has_attachments, - "size_bytes": mail.size_bytes, - "received_at": mail.received_at, - "sent_at": mail.sent_at, - "contact_id": str(mail.contact_id) if mail.contact_id else None, - "attachments": [], - "labels": [], - } - if attachments: - resp["attachments"] = [attachment_to_response(a) for a in attachments] - if labels: - resp["labels"] = [ - {"id": str(lbl.id), "name": lbl.name, "color": lbl.color} for lbl in labels - ] - return resp - - -def folder_to_response(folder: MailFolder) -> dict: - """Convert MailFolder to response dict.""" - return { - "id": str(folder.id), - "account_id": str(folder.account_id), - "name": folder.name, - "imap_name": folder.imap_name, - "parent_id": str(folder.parent_id) if folder.parent_id else None, - "is_standard": folder.is_standard, - "unread_count": folder.unread_count, - "total_count": folder.total_count, - } - - -def rule_to_response(rule: MailRule) -> dict: - """Convert MailRule to response dict.""" - return { - "id": str(rule.id), - "name": rule.name, - "account_id": str(rule.account_id) if rule.account_id else None, - "priority": rule.priority, - "is_active": rule.is_active, - "conditions": json.loads(rule.conditions) if rule.conditions else {}, - "actions": json.loads(rule.actions) if rule.actions else {}, - } - - -def template_to_response(template: MailTemplate) -> dict: - """Convert MailTemplate to response dict.""" - return { - "id": str(template.id), - "name": template.name, - "subject": template.subject, - "body_html": template.body_html, - } - - -def signature_to_response(sig: MailSignature) -> dict: - """Convert MailSignature to response dict.""" - return { - "id": str(sig.id), - "name": sig.name, - "body_html": sig.body_html, - "account_id": str(sig.account_id) if sig.account_id else None, - "is_default": sig.is_default, - } - - -def label_to_response(label: MailLabel) -> dict: - """Convert MailLabel to response dict.""" - return { - "id": str(label.id), - "name": label.name, - "color": label.color, - } - - # ─── IMAP Flag Sync ─── diff --git a/app/plugins/builtins/mail/text_utils.py b/app/plugins/builtins/mail/text_utils.py new file mode 100644 index 0000000..d28356d --- /dev/null +++ b/app/plugins/builtins/mail/text_utils.py @@ -0,0 +1,35 @@ +"""Text utilities for the Mail plugin. + +Extracted from services.py as part of the God-object split (BUG-018 pilot). +Re-exported by ``app.plugins.builtins.mail.services``. +""" + +from __future__ import annotations + +import re + + +def extract_email_addresses(text: str) -> list[str]: + """Extract email addresses from a text string.""" + if not text: + return [] + return re.findall(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}", text) + + +def _strip_html(html: str) -> str: + """Simple HTML to text conversion for plain text fallback.""" + if not html: + return "" + # Remove tags + text = re.sub(r"<[^>]+>", "", html) + # Replace HTML entities + text = ( + text.replace(" ", " ") + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace(""", '"') + .replace("'", "'") + ) + # Collapse whitespace + return re.sub(r"\s+", " ", text).strip()