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:
@@ -25,6 +25,7 @@ from sqlalchemy import and_, or_, select, text
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
from app.core.notifications import create_notification
|
||||||
from app.plugins.builtins.mail.models import (
|
from app.plugins.builtins.mail.models import (
|
||||||
Mail,
|
Mail,
|
||||||
MailAccount,
|
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 ───
|
# ─── Mail Account Service ───
|
||||||
|
|
||||||
|
|
||||||
@@ -454,13 +488,81 @@ async def imap_sync_account(
|
|||||||
if not account:
|
if not account:
|
||||||
return {"synced": 0, "error": "Account not found"}
|
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)
|
password = await get_account_password(account)
|
||||||
|
|
||||||
|
# ── IMAP connection ──
|
||||||
try:
|
try:
|
||||||
client = aioimaplib.IMAP4_SSL(host=account.imap_host, port=account.imap_port)
|
client = aioimaplib.IMAP4_SSL(host=account.imap_host, port=account.imap_port)
|
||||||
await client.wait_hello_from_server()
|
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
|
# 1) LIST all folders from IMAP server
|
||||||
list_response = await client.list('""', '"*"')
|
list_response = await client.list('""', '"*"')
|
||||||
imap_folders = _parse_imap_list_response(list_response)
|
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}
|
folder_by_imap = {f.imap_name: f for f in db_folders}
|
||||||
|
|
||||||
synced_count = 0
|
synced_count = 0
|
||||||
|
new_mails: list[dict] = []
|
||||||
|
|
||||||
# 4) Sync emails for each folder (limit to last 50 per folder)
|
# 4) Sync emails for each folder (limit to last 50 per folder)
|
||||||
for imap_name, folder in folder_by_imap.items():
|
for imap_name, folder in folder_by_imap.items():
|
||||||
@@ -665,6 +768,12 @@ async def imap_sync_account(
|
|||||||
await db.flush()
|
await db.flush()
|
||||||
synced_count += 1
|
synced_count += 1
|
||||||
|
|
||||||
|
# Collect for new-mail notifications
|
||||||
|
new_mails.append({
|
||||||
|
"from": from_addr,
|
||||||
|
"subject": subject,
|
||||||
|
})
|
||||||
|
|
||||||
# Save attachments to storage and DB
|
# Save attachments to storage and DB
|
||||||
for att_data in attachments:
|
for att_data in attachments:
|
||||||
try:
|
try:
|
||||||
@@ -714,6 +823,34 @@ async def imap_sync_account(
|
|||||||
# Skip folders that can't be selected (e.g. no select permission)
|
# Skip folders that can't be selected (e.g. no select permission)
|
||||||
continue
|
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 db.flush()
|
||||||
await client.logout()
|
await client.logout()
|
||||||
return {"synced": synced_count}
|
return {"synced": synced_count}
|
||||||
@@ -864,8 +1001,31 @@ async def send_mail_via_smtp(
|
|||||||
db.add(sent_mail)
|
db.add(sent_mail)
|
||||||
await db.flush()
|
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}
|
return {"status": "sent", "message_id": msg_id}
|
||||||
except Exception as e:
|
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)}
|
return {"status": "error", "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
@@ -1702,6 +1862,18 @@ async def save_draft(
|
|||||||
db.add(mail)
|
db.add(mail)
|
||||||
await db.flush()
|
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
|
# 3. Build RFC822 message and APPEND to IMAP Drafts folder
|
||||||
password = await get_account_password(account)
|
password = await get_account_password(account)
|
||||||
client = None
|
client = None
|
||||||
@@ -1909,6 +2081,16 @@ async def imap_create_folder(
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.info("imap_create_folder: created folder %s on IMAP", folder_name)
|
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:
|
except Exception as exc:
|
||||||
logger.warning("imap_create_folder: failed (non-critical): %s", exc)
|
logger.warning("imap_create_folder: failed (non-critical): %s", exc)
|
||||||
@@ -1972,6 +2154,16 @@ async def imap_delete_folder(
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.info("imap_delete_folder: deleted folder %s on IMAP", folder.imap_name)
|
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:
|
except Exception as exc:
|
||||||
logger.warning("imap_delete_folder: failed (non-critical): %s", exc)
|
logger.warning("imap_delete_folder: failed (non-critical): %s", exc)
|
||||||
@@ -2026,6 +2218,15 @@ async def auto_sync_all_accounts() -> None:
|
|||||||
account.id,
|
account.id,
|
||||||
exc,
|
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
|
# commit per-account so partial progress is saved
|
||||||
try:
|
try:
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|||||||
Reference in New Issue
Block a user