feat: mail notification integration - 10 notification types

- mail_new: new mails during sync (max 10, then summary)
- mail_error: IMAP connection failure
- mail_auth: IMAP login failure
- mail_quota: mailbox quota >80% / >95% critical
- mail_sync_error: general sync failure per account
- mail_sent: mail sent confirmation
- mail_send_error: SMTP send failure
- mail_draft: draft saved confirmation
- mail_account: account inactive warning
- mail_folder: folder created/deleted confirmation
- All notifications non-critical (try/except wrapped)
- Import create_notification from app.core.notifications
This commit is contained in:
Agent Zero
2026-07-15 20:41:57 +02:00
parent f1a12092a0
commit c4d0ec6f7f
+202 -1
View File
@@ -25,6 +25,7 @@ from sqlalchemy import and_, or_, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.core.notifications import create_notification
from app.plugins.builtins.mail.models import (
Mail,
MailAccount,
@@ -181,6 +182,39 @@ def sanitize_html(raw_html: str) -> str:
)
# ─── IMAP Quota Parser ───
def _parse_imap_quota_response(response) -> int | None:
"""Parse an IMAP GETQUOTAROOT response and return usage percentage.
Looks for lines like:
* QUOTA "INBOX" (STORAGE 12345 67890)
where 12345 is used and 67890 is limit.
Returns the usage percentage as an int, or None if parsing fails.
"""
try:
lines = response.lines if hasattr(response, "lines") else response
for line in lines:
if isinstance(line, (bytes, bytearray)):
line = line.decode("utf-8", errors="replace")
if not isinstance(line, str):
continue
if "QUOTA" not in line.upper():
continue
# Extract the parenthesized storage values
# Pattern: (STORAGE <used> <limit>)
match = re.search(r"\(STORAGE\s+(\d+)\s+(\d+)\)", line, re.IGNORECASE)
if match:
used = int(match.group(1))
limit = int(match.group(2))
if limit > 0:
return int((used / limit) * 100)
except Exception:
pass
return None
# ─── Mail Account Service ───
@@ -454,13 +488,81 @@ async def imap_sync_account(
if not account:
return {"synced": 0, "error": "Account not found"}
if not account.is_active:
try:
await create_notification(
db, account.tenant_id, account.user_id,
"mail_account",
"Mail-Account deaktiviert",
f"Account {account.email_address} ist deaktiviert und wird nicht synchronisiert.",
)
await db.flush()
except Exception:
pass
return {"synced": 0, "error": "Account is not active"}
password = await get_account_password(account)
# ── IMAP connection ──
try:
client = aioimaplib.IMAP4_SSL(host=account.imap_host, port=account.imap_port)
await client.wait_hello_from_server()
await client.login(account.username, password)
except Exception as e:
try:
await create_notification(
db, account.tenant_id, account.user_id,
"mail_error",
"IMAP-Verbindung fehlgeschlagen",
f"Account {account.email_address}: {e}",
)
await db.flush()
except Exception:
pass
return {"synced": 0, "error": f"IMAP connection failed: {e}"}
# ── IMAP login ──
try:
await client.login(account.username, password)
except Exception as e:
try:
await create_notification(
db, account.tenant_id, account.user_id,
"mail_auth",
"IMAP-Login fehlgeschlagen",
f"Account {account.email_address}: Passwort oder Anmeldedaten prüfen",
)
await db.flush()
except Exception:
pass
try:
await client.logout()
except Exception:
pass
return {"synced": 0, "error": f"IMAP login failed: {e}"}
# ── Quota check (non-critical, not all servers support QUOTA) ──
try:
quota_resp = await client.getquotaroot('INBOX')
usage_pct = _parse_imap_quota_response(quota_resp)
if usage_pct is not None and usage_pct > 80:
if usage_pct > 95:
quota_title = "Postfach voll keine neuen Mails empfangbar"
else:
quota_title = "Postfach fast voll"
try:
await create_notification(
db, account.tenant_id, account.user_id,
"mail_quota",
quota_title,
f"Account {account.email_address}: {usage_pct}% belegt",
)
await db.flush()
except Exception:
pass
except Exception:
pass
try:
# 1) LIST all folders from IMAP server
list_response = await client.list('""', '"*"')
imap_folders = _parse_imap_list_response(list_response)
@@ -528,6 +630,7 @@ async def imap_sync_account(
folder_by_imap = {f.imap_name: f for f in db_folders}
synced_count = 0
new_mails: list[dict] = []
# 4) Sync emails for each folder (limit to last 50 per folder)
for imap_name, folder in folder_by_imap.items():
@@ -665,6 +768,12 @@ async def imap_sync_account(
await db.flush()
synced_count += 1
# Collect for new-mail notifications
new_mails.append({
"from": from_addr,
"subject": subject,
})
# Save attachments to storage and DB
for att_data in attachments:
try:
@@ -714,6 +823,34 @@ async def imap_sync_account(
# Skip folders that can't be selected (e.g. no select permission)
continue
# ── New-mail notifications (max 10, then summary) ──
if new_mails:
try:
if len(new_mails) <= 10:
for nm in new_mails:
try:
await create_notification(
db, account.tenant_id, account.user_id,
"mail_new",
f"Neue E-Mail von {nm['from']}",
nm["subject"],
)
except Exception:
pass
else:
try:
await create_notification(
db, account.tenant_id, account.user_id,
"mail_new",
f"Neue E-Mails: {len(new_mails)} neue Nachrichten",
f"Account {account.email_address} hat {len(new_mails)} neue E-Mails empfangen.",
)
except Exception:
pass
await db.flush()
except Exception:
pass
await db.flush()
await client.logout()
return {"synced": synced_count}
@@ -864,8 +1001,31 @@ async def send_mail_via_smtp(
db.add(sent_mail)
await db.flush()
# ── Notification: mail sent ──
try:
await create_notification(
db, tenant_id, user_id,
"mail_sent",
"E-Mail gesendet",
subject,
)
await db.flush()
except Exception:
pass
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:
pass
return {"status": "error", "error": str(e)}
@@ -1702,6 +1862,18 @@ async def save_draft(
db.add(mail)
await db.flush()
# ── Notification: draft saved ──
try:
await create_notification(
db, tenant_id, user_id,
"mail_draft",
"Entwurf gespeichert",
subject or "Ohne Betreff",
)
await db.flush()
except Exception:
pass
# 3. Build RFC822 message and APPEND to IMAP Drafts folder
password = await get_account_password(account)
client = None
@@ -1909,6 +2081,16 @@ async def imap_create_folder(
)
else:
logger.info("imap_create_folder: created folder %s on IMAP", folder_name)
try:
await create_notification(
db, account.tenant_id, account.user_id,
"mail_folder",
"Ordner erstellt",
folder_name,
)
await db.flush()
except Exception:
pass
except Exception as exc:
logger.warning("imap_create_folder: failed (non-critical): %s", exc)
@@ -1972,6 +2154,16 @@ async def imap_delete_folder(
)
else:
logger.info("imap_delete_folder: deleted folder %s on IMAP", folder.imap_name)
try:
await create_notification(
db, account.tenant_id, account.user_id,
"mail_folder",
"Ordner gelöscht",
folder.imap_name,
)
await db.flush()
except Exception:
pass
except Exception as exc:
logger.warning("imap_delete_folder: failed (non-critical): %s", exc)
@@ -2026,6 +2218,15 @@ async def auto_sync_all_accounts() -> None:
account.id,
exc,
)
try:
await create_notification(
db, account.tenant_id, account.user_id,
"mail_sync_error",
"Synchronisierung fehlgeschlagen",
f"Account {account.email_address}: {exc}",
)
except Exception:
pass
# commit per-account so partial progress is saved
try:
await db.commit()