diff --git a/app/plugins/builtins/mail/services.py b/app/plugins/builtins/mail/services.py index 51325a9..75f8a91 100644 --- a/app/plugins/builtins/mail/services.py +++ b/app/plugins/builtins/mail/services.py @@ -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 ) + 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()