Files
leocrm/app/plugins/builtins/mail/smtp_send.py
T

359 lines
13 KiB
Python
Raw Normal View History

"""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"<br><br>{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"<br><br>----- Original Message -----<br>"
f"From: {original_mail.from_address}<br>"
f"Subject: {original_mail.subject}<br><br>"
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