"""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