diff --git a/app/plugins/builtins/mail/services.py b/app/plugins/builtins/mail/services.py index 24b348f..dcfe4e1 100644 --- a/app/plugins/builtins/mail/services.py +++ b/app/plugins/builtins/mail/services.py @@ -13,7 +13,7 @@ from email.utils import formataddr, formatdate, make_msgid import aiofiles import aioimaplib -import aiosmtplib +import aiosmtplib # noqa: F401 — test_mail.py patches services.aiosmtplib.SMTP from sqlalchemy import and_, or_, select from sqlalchemy.ext.asyncio import AsyncSession @@ -43,7 +43,6 @@ from app.plugins.builtins.mail.models import ( MailFolder, MailLabelAssignment, MailRule, - MailSignature, VacationSentLog, ) from app.plugins.builtins.mail.pgp import ( # noqa: E402,F401 @@ -63,6 +62,11 @@ from app.plugins.builtins.mail.serializers import ( # noqa: E402,F401 signature_to_response, template_to_response, ) +from app.plugins.builtins.mail.smtp_send import ( # noqa: E402,F401 + forward_mail, + reply_to_mail, + send_mail_via_smtp, +) from app.plugins.builtins.mail.text_utils import ( # noqa: E402,F401 _strip_html, extract_email_addresses, @@ -242,327 +246,6 @@ async def update_mail_account(db: AsyncSession, account: MailAccount, data: dict return account -# ─── SMTP Send Service (F-MAIL-02) ─── - - -async def send_mail_via_smtp( - db: AsyncSession, - *, - tenant_id: uuid.UUID, - user_id: uuid.UUID, - account: MailAccount, - to_addrs: list[str], - cc_addrs: list[str] = None, - bcc_addrs: list[str] = None, - subject: str = "", - body_html: str = "", - body_text: str = "", - in_reply_to: str | None = None, - references_header: str | None = None, - signature: MailSignature | None = None, - attachment_paths: list[dict] | None = None, -) -> dict: - """Send an email via SMTP using aiosmtplib. - - Args: - attachment_paths: list of dicts with keys 'path', 'filename', 'mime_type' - pointing to files on disk to attach. - """ - cc_addrs = cc_addrs or [] - bcc_addrs = bcc_addrs or [] - attachment_paths = attachment_paths or [] - logger = logging.getLogger(__name__) - - # Apply signature if provided - if signature and signature.body_html: - body_html = body_html + f"

{signature.body_html}" - if body_text: - body_text = body_text + "\n\n-- \n" + _strip_html(signature.body_html) - - # Build email message - msg = EmailMessage() - msg["From"] = formataddr((account.display_name or "", account.email_address)) - msg["To"] = ", ".join(to_addrs) - if cc_addrs: - msg["Cc"] = ", ".join(cc_addrs) - msg["Subject"] = subject - msg["Date"] = formatdate(localtime=True) - msg_id = make_msgid( - domain=account.email_address.split("@")[-1] if "@" in account.email_address else "localhost" - ) - msg["Message-ID"] = msg_id - if in_reply_to: - msg["In-Reply-To"] = in_reply_to - if references_header: - msg["References"] = references_header - - if body_html: - msg.set_content(body_text or _strip_html(body_html), subtype="plain") - msg.add_alternative(body_html, subtype="html") - else: - msg.set_content(body_text, subtype="plain") - - # Add attachments to the message - for att_info in attachment_paths: - file_path = att_info.get("path", "") - filename = att_info.get("filename", os.path.basename(file_path)) - mime_type = att_info.get("mime_type", "application/octet-stream") - if not file_path or not os.path.exists(file_path): # noqa: ASYNC240 - continue - async with aiofiles.open(file_path, "rb") as f: - content = await f.read() - # Determine maintype/subtype from mime_type - if "/" in mime_type: - maintype, subtype = mime_type.split("/", 1) - else: - maintype, subtype = "application", "octet-stream" - msg.add_attachment( - content, - maintype=maintype, - subtype=subtype, - filename=filename, - ) - - # ── Hook: mail.before_send (Filter) ── - from app.core.hooks import apply_filters - mail_data = { - "subject": subject, - "body_html": body_html, - "body_text": body_text, - "to_addrs": to_addrs, - "cc_addrs": cc_addrs, - "bcc_addrs": bcc_addrs, - "attachment_paths": attachment_paths, - } - mail_data = await apply_filters("mail.before_send", mail_data) - - # Send via SMTP - password = await get_account_password(account) - try: - smtp = aiosmtplib.SMTP( - hostname=account.smtp_host, - port=account.smtp_port, - use_tls=account.smtp_tls, - ) - await smtp.connect() - await smtp.login(account.username, password) - recipients = to_addrs + cc_addrs + bcc_addrs - await smtp.send_message(msg, recipients=recipients) - await smtp.quit() - - # ── Hook: mail.after_send (Action) ── - from app.core.hooks import do_action - await do_action("mail.after_send", mail_data, db=db, account=account, msg_id=msg_id) - await do_action("mail.after_update", mail_data, db=db, tenant_id=str(mail_data.get('tenant_id', '')) if isinstance(mail_data, dict) else None) - - # Store sent mail in Sent folder — use configured mapping if set, - # otherwise flexible lookup to handle different IMAP naming conventions - if account.sent_folder_imap_name: - sent_folder = ( - await db.execute( - select(MailFolder).where( - and_( - MailFolder.account_id == account.id, - MailFolder.imap_name == account.sent_folder_imap_name, - ) - ) - ) - ).scalar_one_or_none() - else: - sent_folder = ( - await db.execute( - select(MailFolder).where( - and_( - MailFolder.account_id == account.id, - MailFolder.imap_name.in_( - ["Sent", "INBOX.Sent", "Sent Items", "Sent Mail"] - ), - ) - ) - ) - ).scalar_one_or_none() - - if not sent_folder: - sent_folder = ( - await db.execute( - select(MailFolder).where( - and_( - MailFolder.account_id == account.id, - MailFolder.is_standard.is_(True), - MailFolder.imap_name.ilike("%sent%"), - ) - ) - ) - ).scalar_one_or_none() - - if sent_folder: - thread_id = _compute_thread_id(msg_id, references_header or "", in_reply_to) - sent_mail = Mail( - tenant_id=tenant_id, - account_id=account.id, - folder_id=sent_folder.id, - message_id=msg_id, - thread_id=thread_id, - in_reply_to=in_reply_to, - references_header=references_header, - subject=subject, - from_address=account.email_address, - to_addresses=", ".join(to_addrs), - cc_addresses=", ".join(cc_addrs), - bcc_addresses=", ".join(bcc_addrs), - body_text=body_text or _strip_html(body_html), - body_html=body_html, - body_html_sanitized=sanitize_html(body_html), - is_seen=True, - is_answered=bool(in_reply_to), - sent_at=datetime.now(UTC), - ) - db.add(sent_mail) - await db.flush() - - # Upload sent mail to IMAP Sent folder via APPEND - try: - imap_client = aioimaplib.IMAP4_SSL(host=account.imap_host, port=account.imap_port) - await imap_client.wait_hello_from_server() - await imap_client.login(account.username, password) - # Build raw email bytes for APPEND - raw_email_bytes = msg.as_bytes() - append_resp = await imap_client.append( - sent_folder.imap_name, - r'(\Seen)', - None, - raw_email_bytes, - ) - if append_resp.result == 'OK': - logger.info("send_mail: uploaded sent mail to IMAP folder %s", sent_folder.imap_name) - # Retrieve the new UID for the appended mail by searching for its Message-ID - try: - await imap_client.select(sent_folder.imap_name) - search_resp = await imap_client.uid_search(f'HEADER Message-ID "{msg_id}"') - uid_raw = search_resp[1][0] if search_resp[1] and search_resp[1][0] else b'' - if isinstance(uid_raw, (bytes, bytearray)) and uid_raw: - new_uids = uid_raw.decode().split() - if new_uids: - sent_mail.imap_uid = new_uids[0] - logger.info("send_mail: set imap_uid=%s for sent mail %s", new_uids[0], sent_mail.id) - except Exception as uid_exc: - logger.warning("send_mail: could not retrieve UID after APPEND: %s", uid_exc) - else: - logger.warning("send_mail: IMAP APPEND failed for sent folder %s: %s", sent_folder.imap_name, append_resp) - await imap_client.logout() - except Exception as imap_exc: - logger.warning("send_mail: IMAP APPEND failed (non-critical): %s", imap_exc) - - # ── Notification: mail sent ── - try: - await create_notification( - db, tenant_id, user_id, - "mail_sent", - "E-Mail gesendet", - subject, - ) - await db.flush() - except Exception: - logger.debug("Ignored exception in mail service", exc_info=True) - - return {"status": "sent", "message_id": msg_id} - except Exception as e: - # ── Notification: send error ── - try: - await create_notification( - db, tenant_id, user_id, - "mail_send_error", - "E-Mail konnte nicht gesendet werden", - str(e), - ) - await db.flush() - except Exception: - logger.debug("Ignored exception in mail service", exc_info=True) - return {"status": "error", "error": str(e)} - - -async def reply_to_mail( - db: AsyncSession, - *, - tenant_id: uuid.UUID, - user_id: uuid.UUID, - original_mail: Mail, - account: MailAccount, - body_html: str, - body_text: str = "", - reply_to_all: bool = False, - signature: MailSignature | None = None, -) -> dict: - """Reply to a mail, setting In-Reply-To and References headers (F-MAIL-02).""" - to_addrs = [original_mail.from_address] - if reply_to_all and original_mail.cc_addresses: - to_addrs.extend([a.strip() for a in original_mail.cc_addresses.split(",") if a.strip()]) - - refs = original_mail.references_header or "" - new_refs = f"{refs} {original_mail.message_id}".strip() - - result = await send_mail_via_smtp( - db, - tenant_id=tenant_id, - user_id=user_id, - account=account, - to_addrs=to_addrs, - subject=f"Re: {original_mail.subject}".replace("Re: Re: ", "Re: "), - body_html=body_html, - body_text=body_text, - in_reply_to=original_mail.message_id, - references_header=new_refs, - signature=signature, - ) - - # Mark original as answered - original_mail.is_answered = True - await db.flush() - return result - - -async def forward_mail( - db: AsyncSession, - *, - tenant_id: uuid.UUID, - user_id: uuid.UUID, - original_mail: Mail, - account: MailAccount, - to_addrs: list[str], - cc_addrs: list[str] = None, - body_html: str = "", - body_text: str = "", - signature: MailSignature | None = None, -) -> dict: - """Forward a mail with original as forwarded content (F-MAIL-02).""" - fwd_subject = f"Fwd: {original_mail.subject}".replace("Fwd: Fwd: ", "Fwd: ") - fwd_body = ( - f"

----- Original Message -----
" - f"From: {original_mail.from_address}
" - f"Subject: {original_mail.subject}

" - f"{original_mail.body_html or original_mail.body_text}" - ) - full_html = body_html + fwd_body - full_text = (body_text or _strip_html(body_html)) + "\n\n----- Original Message -----\n" - - result = await send_mail_via_smtp( - db, - tenant_id=tenant_id, - user_id=user_id, - account=account, - to_addrs=to_addrs, - cc_addrs=cc_addrs or [], - subject=fwd_subject, - body_html=full_html, - body_text=full_text, - signature=signature, - ) - - original_mail.is_forwarded = True - await db.flush() - return result - - # ─── Template Service (F-MAIL-06) ─── diff --git a/app/plugins/builtins/mail/smtp_send.py b/app/plugins/builtins/mail/smtp_send.py new file mode 100644 index 0000000..68d63b9 --- /dev/null +++ b/app/plugins/builtins/mail/smtp_send.py @@ -0,0 +1,358 @@ +"""SMTP send service for the Mail plugin (F-MAIL-02). + +Extracted from services.py as part of the God-object split (BUG-018 pilot). +Re-exported by ``app.plugins.builtins.mail.services``. + +Contains send_mail_via_smtp, reply_to_mail and forward_mail. +""" + +from __future__ import annotations + +import logging +import os +import uuid +from datetime import UTC, datetime +from email.message import EmailMessage +from email.utils import formataddr, formatdate, make_msgid + +import aiofiles +import aioimaplib +import aiosmtplib +from sqlalchemy import and_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.notifications import create_notification +from app.plugins.builtins.mail.imap_sync import ( + _compute_thread_id, + get_account_password, +) +from app.plugins.builtins.mail.models import ( + Mail, + MailAccount, + MailFolder, + MailSignature, +) +from app.plugins.builtins.mail.sanitize import sanitize_html +from app.plugins.builtins.mail.text_utils import _strip_html + +logger = logging.getLogger(__name__) + + +async def send_mail_via_smtp( + db: AsyncSession, + *, + tenant_id: uuid.UUID, + user_id: uuid.UUID, + account: MailAccount, + to_addrs: list[str], + cc_addrs: list[str] = None, + bcc_addrs: list[str] = None, + subject: str = "", + body_html: str = "", + body_text: str = "", + in_reply_to: str | None = None, + references_header: str | None = None, + signature: MailSignature | None = None, + attachment_paths: list[dict] | None = None, +) -> dict: + """Send an email via SMTP using aiosmtplib. + + Args: + attachment_paths: list of dicts with keys 'path', 'filename', 'mime_type' + pointing to files on disk to attach. + """ + cc_addrs = cc_addrs or [] + bcc_addrs = bcc_addrs or [] + attachment_paths = attachment_paths or [] + logger = logging.getLogger(__name__) + + # Apply signature if provided + if signature and signature.body_html: + body_html = body_html + f"

{signature.body_html}" + if body_text: + body_text = body_text + "\n\n-- \n" + _strip_html(signature.body_html) + + # Build email message + msg = EmailMessage() + msg["From"] = formataddr((account.display_name or "", account.email_address)) + msg["To"] = ", ".join(to_addrs) + if cc_addrs: + msg["Cc"] = ", ".join(cc_addrs) + msg["Subject"] = subject + msg["Date"] = formatdate(localtime=True) + msg_id = make_msgid( + domain=account.email_address.split("@")[-1] if "@" in account.email_address else "localhost" + ) + msg["Message-ID"] = msg_id + if in_reply_to: + msg["In-Reply-To"] = in_reply_to + if references_header: + msg["References"] = references_header + + if body_html: + msg.set_content(body_text or _strip_html(body_html), subtype="plain") + msg.add_alternative(body_html, subtype="html") + else: + msg.set_content(body_text, subtype="plain") + + # Add attachments to the message + for att_info in attachment_paths: + file_path = att_info.get("path", "") + filename = att_info.get("filename", os.path.basename(file_path)) + mime_type = att_info.get("mime_type", "application/octet-stream") + if not file_path or not os.path.exists(file_path): # noqa: ASYNC240 + continue + async with aiofiles.open(file_path, "rb") as f: + content = await f.read() + # Determine maintype/subtype from mime_type + if "/" in mime_type: + maintype, subtype = mime_type.split("/", 1) + else: + maintype, subtype = "application", "octet-stream" + msg.add_attachment( + content, + maintype=maintype, + subtype=subtype, + filename=filename, + ) + + # ── Hook: mail.before_send (Filter) ── + from app.core.hooks import apply_filters + mail_data = { + "subject": subject, + "body_html": body_html, + "body_text": body_text, + "to_addrs": to_addrs, + "cc_addrs": cc_addrs, + "bcc_addrs": bcc_addrs, + "attachment_paths": attachment_paths, + } + mail_data = await apply_filters("mail.before_send", mail_data) + + # Send via SMTP + password = await get_account_password(account) + try: + smtp = aiosmtplib.SMTP( + hostname=account.smtp_host, + port=account.smtp_port, + use_tls=account.smtp_tls, + ) + await smtp.connect() + await smtp.login(account.username, password) + recipients = to_addrs + cc_addrs + bcc_addrs + await smtp.send_message(msg, recipients=recipients) + await smtp.quit() + + # ── Hook: mail.after_send (Action) ── + from app.core.hooks import do_action + await do_action("mail.after_send", mail_data, db=db, account=account, msg_id=msg_id) + await do_action("mail.after_update", mail_data, db=db, tenant_id=str(mail_data.get('tenant_id', '')) if isinstance(mail_data, dict) else None) + + # Store sent mail in Sent folder — use configured mapping if set, + # otherwise flexible lookup to handle different IMAP naming conventions + if account.sent_folder_imap_name: + sent_folder = ( + await db.execute( + select(MailFolder).where( + and_( + MailFolder.account_id == account.id, + MailFolder.imap_name == account.sent_folder_imap_name, + ) + ) + ) + ).scalar_one_or_none() + else: + sent_folder = ( + await db.execute( + select(MailFolder).where( + and_( + MailFolder.account_id == account.id, + MailFolder.imap_name.in_( + ["Sent", "INBOX.Sent", "Sent Items", "Sent Mail"] + ), + ) + ) + ) + ).scalar_one_or_none() + + if not sent_folder: + sent_folder = ( + await db.execute( + select(MailFolder).where( + and_( + MailFolder.account_id == account.id, + MailFolder.is_standard.is_(True), + MailFolder.imap_name.ilike("%sent%"), + ) + ) + ) + ).scalar_one_or_none() + + if sent_folder: + thread_id = _compute_thread_id(msg_id, references_header or "", in_reply_to) + sent_mail = Mail( + tenant_id=tenant_id, + account_id=account.id, + folder_id=sent_folder.id, + message_id=msg_id, + thread_id=thread_id, + in_reply_to=in_reply_to, + references_header=references_header, + subject=subject, + from_address=account.email_address, + to_addresses=", ".join(to_addrs), + cc_addresses=", ".join(cc_addrs), + bcc_addresses=", ".join(bcc_addrs), + body_text=body_text or _strip_html(body_html), + body_html=body_html, + body_html_sanitized=sanitize_html(body_html), + is_seen=True, + is_answered=bool(in_reply_to), + sent_at=datetime.now(UTC), + ) + db.add(sent_mail) + await db.flush() + + # Upload sent mail to IMAP Sent folder via APPEND + try: + imap_client = aioimaplib.IMAP4_SSL(host=account.imap_host, port=account.imap_port) + await imap_client.wait_hello_from_server() + await imap_client.login(account.username, password) + # Build raw email bytes for APPEND + raw_email_bytes = msg.as_bytes() + append_resp = await imap_client.append( + sent_folder.imap_name, + r'(\Seen)', + None, + raw_email_bytes, + ) + if append_resp.result == 'OK': + logger.info("send_mail: uploaded sent mail to IMAP folder %s", sent_folder.imap_name) + # Retrieve the new UID for the appended mail by searching for its Message-ID + try: + await imap_client.select(sent_folder.imap_name) + search_resp = await imap_client.uid_search(f'HEADER Message-ID "{msg_id}"') + uid_raw = search_resp[1][0] if search_resp[1] and search_resp[1][0] else b'' + if isinstance(uid_raw, (bytes, bytearray)) and uid_raw: + new_uids = uid_raw.decode().split() + if new_uids: + sent_mail.imap_uid = new_uids[0] + logger.info("send_mail: set imap_uid=%s for sent mail %s", new_uids[0], sent_mail.id) + except Exception as uid_exc: + logger.warning("send_mail: could not retrieve UID after APPEND: %s", uid_exc) + else: + logger.warning("send_mail: IMAP APPEND failed for sent folder %s: %s", sent_folder.imap_name, append_resp) + await imap_client.logout() + except Exception as imap_exc: + logger.warning("send_mail: IMAP APPEND failed (non-critical): %s", imap_exc) + + # ── Notification: mail sent ── + try: + await create_notification( + db, tenant_id, user_id, + "mail_sent", + "E-Mail gesendet", + subject, + ) + await db.flush() + except Exception: + logger.debug("Ignored exception in mail service", exc_info=True) + + return {"status": "sent", "message_id": msg_id} + except Exception as e: + # ── Notification: send error ── + try: + await create_notification( + db, tenant_id, user_id, + "mail_send_error", + "E-Mail konnte nicht gesendet werden", + str(e), + ) + await db.flush() + except Exception: + logger.debug("Ignored exception in mail service", exc_info=True) + return {"status": "error", "error": str(e)} + + +async def reply_to_mail( + db: AsyncSession, + *, + tenant_id: uuid.UUID, + user_id: uuid.UUID, + original_mail: Mail, + account: MailAccount, + body_html: str, + body_text: str = "", + reply_to_all: bool = False, + signature: MailSignature | None = None, +) -> dict: + """Reply to a mail, setting In-Reply-To and References headers (F-MAIL-02).""" + to_addrs = [original_mail.from_address] + if reply_to_all and original_mail.cc_addresses: + to_addrs.extend([a.strip() for a in original_mail.cc_addresses.split(",") if a.strip()]) + + refs = original_mail.references_header or "" + new_refs = f"{refs} {original_mail.message_id}".strip() + + result = await send_mail_via_smtp( + db, + tenant_id=tenant_id, + user_id=user_id, + account=account, + to_addrs=to_addrs, + subject=f"Re: {original_mail.subject}".replace("Re: Re: ", "Re: "), + body_html=body_html, + body_text=body_text, + in_reply_to=original_mail.message_id, + references_header=new_refs, + signature=signature, + ) + + # Mark original as answered + original_mail.is_answered = True + await db.flush() + return result + + +async def forward_mail( + db: AsyncSession, + *, + tenant_id: uuid.UUID, + user_id: uuid.UUID, + original_mail: Mail, + account: MailAccount, + to_addrs: list[str], + cc_addrs: list[str] = None, + body_html: str = "", + body_text: str = "", + signature: MailSignature | None = None, +) -> dict: + """Forward a mail with original as forwarded content (F-MAIL-02).""" + fwd_subject = f"Fwd: {original_mail.subject}".replace("Fwd: Fwd: ", "Fwd: ") + fwd_body = ( + f"

----- Original Message -----
" + f"From: {original_mail.from_address}
" + f"Subject: {original_mail.subject}

" + f"{original_mail.body_html or original_mail.body_text}" + ) + full_html = body_html + fwd_body + full_text = (body_text or _strip_html(body_html)) + "\n\n----- Original Message -----\n" + + result = await send_mail_via_smtp( + db, + tenant_id=tenant_id, + user_id=user_id, + account=account, + to_addrs=to_addrs, + cc_addrs=cc_addrs or [], + subject=fwd_subject, + body_html=full_html, + body_text=full_text, + signature=signature, + ) + + original_mail.is_forwarded = True + await db.flush() + return result + +