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