Compare commits
2 Commits
cbe36e0c0e
...
c34715574a
| Author | SHA1 | Date | |
|---|---|---|---|
| c34715574a | |||
| fce17aac9c |
+1
-1
@@ -119,7 +119,7 @@
|
|||||||
| I-G-1 | BUG-022/070 Audits: npm audit = 0 vulnerabilities bereits sauber; pip-audit fand **9 known CVEs in starlette 0.46.2** (PYSEC-2026-161/248/249/1941/1942/2280/2281) — Dilemma: fastapi 0.115.x pinnt starlette<0.47.0, Fixes brauchen >=1.x | ✅ **0 pip findings**: fastapi 0.141.1 (zieht starlette ohne Obergrenze) + starlette direkt auf 1.3.1 gepinnt in requirements.txt; Regressionssmoke auth+api_audit 19/19 + mail+permissions+outbox+audit_middleware+cross_tenant_v2 84/85 (1 Failure = bekannter Reihenfolge-Vorbestand, isolat grün) | 34c9c85 |
|
| I-G-1 | BUG-022/070 Audits: npm audit = 0 vulnerabilities bereits sauber; pip-audit fand **9 known CVEs in starlette 0.46.2** (PYSEC-2026-161/248/249/1941/1942/2280/2281) — Dilemma: fastapi 0.115.x pinnt starlette<0.47.0, Fixes brauchen >=1.x | ✅ **0 pip findings**: fastapi 0.141.1 (zieht starlette ohne Obergrenze) + starlette direkt auf 1.3.1 gepinnt in requirements.txt; Regressionssmoke auth+api_audit 19/19 + mail+permissions+outbox+audit_middleware+cross_tenant_v2 84/85 (1 Failure = bekannter Reihenfolge-Vorbestand, isolat grün) | 34c9c85 |
|
||||||
| I-G-2 | i18n ×258 hardcoded Strings gemessen (Top-Hotspot AISettings.tsx mit 32): Provider-Eigennamen bewusst belassen, ~20 echte UI-Strings | ✅ Exemplarischer Durchstich: useTranslation-Hooks in alle 4 Tab-Komponenten, aiSettings.*-Namespace in de+en ergänzt; tsc=0; AISettings-Tests 18/18; Rest folgt im selben Muster | e7afbaa |
|
| I-G-2 | i18n ×258 hardcoded Strings gemessen (Top-Hotspot AISettings.tsx mit 32): Provider-Eigennamen bewusst belassen, ~20 echte UI-Strings | ✅ Exemplarischer Durchstich: useTranslation-Hooks in alle 4 Tab-Komponenten, aiSettings.*-Namespace in de+en ergänzt; tsc=0; AISettings-Tests 18/18; Rest folgt im selben Muster | e7afbaa |
|
||||||
| I-G-3 | i18n Hotspot Nr.2: ProactiveAISettings.tsx (15+ deutsche Hardcodes inkl. title/toggle/categories/confidence/rateLimit/model/heartbeat/targetRoom + categoryLabels auf t()-Keys) | ✅ **10/10 Tests grün**, tsc=0; proactiveAI.*-Namespace in de+en; categoryLabels-Record durch t()-basierte categoryKeys ersetzt; modelOptions inline mit t()-Labels | 26b5ae9 |
|
| I-G-3 | i18n Hotspot Nr.2: ProactiveAISettings.tsx (15+ deutsche Hardcodes inkl. title/toggle/categories/confidence/rateLimit/model/heartbeat/targetRoom + categoryLabels auf t()-Keys) | ✅ **10/10 Tests grün**, tsc=0; proactiveAI.*-Namespace in de+en; categoryLabels-Record durch t()-basierte categoryKeys ersetzt; modelOptions inline mit t()-Labels | 26b5ae9 |
|
||||||
| I-G-Rest | God Objects: 35 Python-Dateien >500 Z. — Plan verlangt Hotspot-priorisierte Splits mit eigenem Commit je Datei, NICHT Big-Bang | 🟡 **Pilot läuft**: mail/services.py 3087→~1680 Z. (**−46%**) in 3 Schritten: S1 = crypto+sanitize+pgp (pure functions), S2 = serializers+text_utils (inkl. NEVER-password-Contract), S3 = imap_sync.py (~1070 Z.: folder/account sync, thread-id, German folder names, quota parser) + attachments.py (path-traversal-geschützt) + get_account_password als async (Fix während Extraktion — erste Version war sync und brach send/reply/forward_mail). Re-Export via noqa F401 überall; Beweise: mail+sig_label_routes **51/51 grün** nach jedem Schritt; ruff clean | a1d5e56, be81fe5, 6702d69 |
|
| I-G-Rest | God Objects: 35 Python-Dateien >500 Z. — Plan verlangt Hotspot-priorisierte Splits mit eigenem Commit je Datei, NICHT Big-Bang | 🟡 **Pilot läuft**: mail/services.py 3087→~1365 Z. (**−56%**) in 4 Schritten: S1 = crypto+sanitize+pgp (pure functions), S2 = serializers+text_utils (NEVER-password-Contract), S3 = imap_sync.py (~1070 Z.) + attachments.py (path-traversal-geschützt) + get_account_password async-Fix, S4 = smtp_send.py (~320 Z.: send/reply/forward) — inkl. aiosmtplib-Modulattribut-Fix für Test-Mocks (services.aiosmtplib.SMTP). Re-Export via noqa F401 überall; Beweise: mail+sig_label_routes **51/51 grün** nach jedem Schritt; ruff clean | a1d5e56, be81fe5, 6702d69, fce17aa |
|
||||||
| G2 | Session-Revocation bei Passwortänderung — Befund differenzierter als Plan annahm: Reset-via-Token (confirm_password_reset) revocierte Sessions bereits korrekt (Redis scan_iter session:*), aber Profil-/Admin-Pfad (users.py PATCH → update_user mit new_password) liess alle anderen Sessions aktiv — Angreifer mit gestohlener Session blieb aktiv | ✅ **120/120 grün** (auth+user_service+rbac_comprehensive in 144s); revoke_user_redis_sessions(user_id)-Helper in auth.py extrahiert (never-raises), von beiden Pfaden genutzt; Postgres sessions-Tabelle unberührt (Audit-Trail by Design) | 0baec27 |
|
| G2 | Session-Revocation bei Passwortänderung — Befund differenzierter als Plan annahm: Reset-via-Token (confirm_password_reset) revocierte Sessions bereits korrekt (Redis scan_iter session:*), aber Profil-/Admin-Pfad (users.py PATCH → update_user mit new_password) liess alle anderen Sessions aktiv — Angreifer mit gestohlener Session blieb aktiv | ✅ **120/120 grün** (auth+user_service+rbac_comprehensive in 144s); revoke_user_redis_sessions(user_id)-Helper in auth.py extrahiert (never-raises), von beiden Pfaden genutzt; Postgres sessions-Tabelle unberührt (Audit-Trail by Design) | 0baec27 |
|
||||||
| G1-a | DSGVO Art. 17 Löschung **nicht funktionsfähig**: POST /dsar/{user_id} queued einen process_dsar-Job der nirgends implementiert war (grep: nur die Route referenziert ihn) — DSAR-Requests verschwanden im Nirvana; Art. 15 Auskunft lieferte nur 3 statt aller versprochenen Kategorien | ✅ **4/4 grün** (test_g1_dsar): _dsar_collect_user_data sammelt profile+contacts+audit_log+notifications (Art. 15/20); _dsar_execute_deletion führt Art. 17 aus — contacts soft-delete (Audit-/Aufbewahrungspflichten respektiert), notifications hard-delete, User anonymisiert + deaktiviert mit FK-Integrität für Audit-Zeilen, dsar_erasure-Audit-Eintrag; process_dsar dispatcht access/deletion/rectification (rectification = manuelle Bearbeitung via Systemnachricht) | f4a5937 |
|
| G1-a | DSGVO Art. 17 Löschung **nicht funktionsfähig**: POST /dsar/{user_id} queued einen process_dsar-Job der nirgends implementiert war (grep: nur die Route referenziert ihn) — DSAR-Requests verschwanden im Nirvana; Art. 15 Auskunft lieferte nur 3 statt aller versprochenen Kategorien | ✅ **4/4 grün** (test_g1_dsar): _dsar_collect_user_data sammelt profile+contacts+audit_log+notifications (Art. 15/20); _dsar_execute_deletion führt Art. 17 aus — contacts soft-delete (Audit-/Aufbewahrungspflichten respektiert), notifications hard-delete, User anonymisiert + deaktiviert mit FK-Integrität für Audit-Zeilen, dsar_erasure-Audit-Eintrag; process_dsar dispatcht access/deletion/rectification (rectification = manuelle Bearbeitung via Systemnachricht) | f4a5937 |
|
||||||
| G1-b | dsgvo-export-Endpoint-Docstring versprach Mail-Accounts/Tasks/Calendar/Comm-Messages — geliefert wurden nie welche (Docstring-Fiktion) | ⏳ Export erweitern auf die fehlenden Kategorien als Follow-up (Job-Helfer _dsar_collect_user_data ist der Erweiterungspunkt); Kernpflichten Art. 15/17 sind jetzt funktionsfähig | — |
|
| G1-b | dsgvo-export-Endpoint-Docstring versprach Mail-Accounts/Tasks/Calendar/Comm-Messages — geliefert wurden nie welche (Docstring-Fiktion) | ⏳ Export erweitern auf die fehlenden Kategorien als Follow-up (Job-Helfer _dsar_collect_user_data ist der Erweiterungspunkt); Kernpflichten Art. 15/17 sind jetzt funktionsfähig | — |
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from email.utils import formataddr, formatdate, make_msgid
|
|||||||
|
|
||||||
import aiofiles
|
import aiofiles
|
||||||
import aioimaplib
|
import aioimaplib
|
||||||
import aiosmtplib
|
import aiosmtplib # noqa: F401 — test_mail.py patches services.aiosmtplib.SMTP
|
||||||
from sqlalchemy import and_, or_, select
|
from sqlalchemy import and_, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
@@ -43,7 +43,6 @@ from app.plugins.builtins.mail.models import (
|
|||||||
MailFolder,
|
MailFolder,
|
||||||
MailLabelAssignment,
|
MailLabelAssignment,
|
||||||
MailRule,
|
MailRule,
|
||||||
MailSignature,
|
|
||||||
VacationSentLog,
|
VacationSentLog,
|
||||||
)
|
)
|
||||||
from app.plugins.builtins.mail.pgp import ( # noqa: E402,F401
|
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,
|
signature_to_response,
|
||||||
template_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
|
from app.plugins.builtins.mail.text_utils import ( # noqa: E402,F401
|
||||||
_strip_html,
|
_strip_html,
|
||||||
extract_email_addresses,
|
extract_email_addresses,
|
||||||
@@ -242,327 +246,6 @@ async def update_mail_account(db: AsyncSession, account: MailAccount, data: dict
|
|||||||
return account
|
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"<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
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Template Service (F-MAIL-06) ───
|
# ─── Template Service (F-MAIL-06) ───
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"<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
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user