Spaces:
Sleeping
Sleeping
Download backend/mailboxes.py from Success-dev/receipter-pro: direct link, hf CLI and curl.
- Browser
- Download file 19.9 kB
-
https://huggingface.co/spaces/Success-dev/receipter-pro/resolve/main/backend/mailboxes.py
- Command line
-
hf download hf://spaces/Success-dev/receipter-pro/backend/mailboxes.py
-
curl -L -o mailboxes.py https://huggingface.co/spaces/Success-dev/receipter-pro/resolve/main/backend/mailboxes.py
19.9 kB
| """ | |
| Mailbox connections β the relay identity an org sends through (docs/blueprint/adr/0001). | |
| Two kinds, both stored the same way once connected: "oauth" (Gmail/Outlook/Yahoo β see | |
| backend/core/constants.py SUPPORTED_OAUTH_PROVIDERS) and "smtp" (hosting-provider mailboxes, | |
| raw host/port/username/password). Credentials are encrypted at rest via backend.crypto_utils | |
| (the prototype stand-in for ADR-0002's GCP KMS envelope encryption). | |
| Verification is the 3-OTP round-trip from SRS FR-6: send unique OTPs from the connected | |
| mailbox to M8ion's own test mailbox, then confirm receipt/content/metadata match before the | |
| connection is usable for real sends. The five failure states below are SRS FR-7, verbatim. | |
| """ | |
| from __future__ import annotations | |
| import imaplib | |
| import json | |
| import re | |
| import uuid | |
| from datetime import datetime, timedelta, timezone | |
| from backend import delivery | |
| from backend import firestore_client as db | |
| from backend import mailer_client | |
| from backend import orgs | |
| from backend.core.constants import ( | |
| MAILBOX_CREDENTIAL_CHANGE_DAYS, | |
| MAILBOX_DAILY_LIMIT_MAX, | |
| SYSTEM_SENDER_ADDRESS_PROTO, | |
| SYSTEM_TEST_MAILBOX_ADDRESS_PROTO, | |
| SUPPORTED_OAUTH_PROVIDERS, | |
| VERIFICATION_OTP_COUNT, | |
| ) | |
| from backend.crypto_utils import decrypt, encrypt | |
| from backend.mock_mailer import AuthError, ConnectionFailure, MockInbox, MockMailer | |
| # Stands in for "M8ion's own test mailbox" until a real domain/inbox exists β used for the | |
| # mock round-trip (REAL_SEND_ENABLED=false). The real round-trip (true) has no inbox to | |
| # read back yet (that needs IMAP, unbuilt β see _verify_real's docstring), so it sends to | |
| # SYSTEM_SENDER_ADDRESS_PROTO instead, the one real mailbox this project actually holds. | |
| SYSTEM_TEST_INBOX = MockInbox() | |
| # SRS FR-7, worded exactly as specified. | |
| FAILURE_MESSAGES = { | |
| "auth_rejected": "We couldn't sign in to that mailbox. Double-check the email and password, then try again.", | |
| "connection_failed": "We couldn't reach that mail server. Check the server address and port, or confirm with your email provider that SMTP access is enabled.", | |
| "silent_failure": "We couldn't verify delivery. Contact support and we'll help you troubleshoot.", | |
| "unreliable": "This mailbox is only delivering some of the time, which usually means intermittent throttling from your provider. We recommend resolving this before relying on it for real sends.", | |
| "content_mismatch": "Something didn't match during verification. This connection has been flagged for manual review.", | |
| "imap_failed": "Sending works, but we couldn't sign in to the incoming (IMAP) side. Check the IMAP server, port, username and password. We need it to show you delays, bounces and replies.", | |
| } | |
| # Honest scope for the real (non-mocked) path: auth + send are genuinely verified; | |
| # arrival isn't, since reading a real inbox back needs IMAP (Phase 2, unbuilt). | |
| VERIFIED_REAL_MESSAGE = ( | |
| "Your credentials were accepted and a test message was sent through your mail server. " | |
| "We can't yet confirm it arrived β check the inbox yourself for full certainty." | |
| ) | |
| SEND_FAILED_MESSAGE = ( | |
| "We signed in to that mailbox, but your provider refused the test message. " | |
| "Check that sending is allowed for this account, then try again." | |
| ) | |
| class ValidationError(Exception): | |
| pass | |
| def _now() -> str: | |
| return datetime.now(timezone.utc).isoformat() | |
| def _connection_path(org_id: str, connection_id: str) -> str: | |
| return f"orgs/{org_id}/mailboxes/{connection_id}" | |
| def connect_mailbox(org_id: str, actor_uid: str, kind: str, credentials: dict) -> str: | |
| """Registers a new, not-yet-verified mailbox connection. An org can have more than one | |
| (SRS FR-5). Requires admin or owner (SRS: mailbox management is an org-management | |
| action, not a day-to-day sending one).""" | |
| orgs.require_permission(org_id, actor_uid, "manage_mailboxes") | |
| if kind == "oauth": | |
| provider = credentials.get("provider") | |
| if provider not in SUPPORTED_OAUTH_PROVIDERS: | |
| raise ValidationError(f"Unsupported OAuth provider {provider!r}") | |
| elif kind == "smtp": | |
| required = {"host", "port", "username", "password"} | |
| missing = required - credentials.keys() | |
| if missing: | |
| raise ValidationError(f"SMTP connection missing fields: {missing}") | |
| credentials = _clean_imap(credentials, required=True) | |
| # Spaces in an app password are display grouping, never part of it. | |
| credentials = {**credentials, "password": "".join(str(credentials["password"]).split())} | |
| else: | |
| raise ValidationError(f"Unknown mailbox kind {kind!r}") | |
| # The sending address isn't a secret β it's the From: on every message this connection | |
| # sends β so it's kept plain alongside the encrypted blob rather than requiring a | |
| # decrypt just to list connections (SRS FR-5's list view needs this on every load). | |
| email = credentials.get("username") if kind == "smtp" else credentials.get("email") | |
| connection_id = uuid.uuid4().hex | |
| db.set_doc( | |
| _connection_path(org_id, connection_id), | |
| { | |
| "kind": kind, | |
| "email": email, | |
| "has_imap": bool(credentials.get("imap_host")), | |
| "encrypted_credentials": encrypt(json.dumps(credentials)), | |
| "status": "pending_verification", | |
| "status_message": None, | |
| "created_at": _now(), | |
| "verified_at": None, | |
| }, | |
| ) | |
| return connection_id | |
| def _clean_imap(credentials: dict, required: bool) -> dict: | |
| """Checks the incoming (IMAP) half of an SMTP mailbox. Both halves are needed: SMTP sends, | |
| IMAP reads back what the provider did with the message (delays, bounces, replies). The IMAP | |
| user and password may be left out when they are the same as the SMTP ones.""" | |
| host = str(credentials.get("imap_host") or "").strip() | |
| if not host: | |
| if required: | |
| raise ValidationError("Add the incoming (IMAP) server too. M8ion needs it to show delays, bounces and replies.") | |
| return credentials | |
| try: | |
| port = int(credentials.get("imap_port") or 993) | |
| except (TypeError, ValueError) as exc: | |
| raise ValidationError("The IMAP port must be a number, usually 993") from exc | |
| if not 1 <= port <= 65535: | |
| raise ValidationError("The IMAP port must be a number, usually 993") | |
| cleaned = {**credentials, "imap_host": host, "imap_port": port} | |
| for key in ("imap_username", "imap_password"): | |
| if not str(cleaned.get(key) or "").strip(): | |
| cleaned.pop(key, None) | |
| if cleaned.get("imap_password"): | |
| cleaned["imap_password"] = "".join(str(cleaned["imap_password"]).split()) | |
| return cleaned | |
| def get_mailbox(org_id: str, connection_id: str) -> dict: | |
| doc = db.get_doc(_connection_path(org_id, connection_id)) | |
| if doc is None: | |
| raise orgs.NotFoundError(f"No mailbox connection {connection_id!r} for org {org_id!r}") | |
| return doc | |
| def list_mailboxes(org_id: str) -> dict[str, dict]: | |
| prefix = f"orgs/{org_id}/mailboxes/" | |
| docs = db.query_prefix(prefix) | |
| return {path[len(prefix):]: data for path, data in docs.items()} | |
| def set_dismissed(org_id: str, actor_uid: str, connection_id: str, dismissed: bool) -> None: | |
| """Hides (or restores) a failed try from the Mailboxes page and the attention counts. The | |
| connection itself is kept, so this is fully reversible.""" | |
| orgs.require_permission(org_id, actor_uid, "manage_mailboxes") | |
| connection = get_mailbox(org_id, connection_id) | |
| if dismissed and connection.get("status") in ("verified", "pending_verification"): | |
| raise ValidationError("Only a mailbox that did not connect can be dismissed") | |
| db.update_doc(_connection_path(org_id, connection_id), {"dismissed": dismissed}) | |
| # Per-mailbox choices. Empty / None means "use the organisation's setting". | |
| DEFAULT_MAILBOX_SETTINGS = {"label": "", "from_name": "", "reply_to": "", "daily_limit": 0, "save_sent_copy": None} | |
| _EMAIL = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") | |
| def mailbox_settings(connection: dict) -> dict: | |
| return DEFAULT_MAILBOX_SETTINGS | {k: v for k, v in (connection.get("settings") or {}).items() if k in DEFAULT_MAILBOX_SETTINGS} | |
| def update_settings(org_id: str, actor_uid: str, connection_id: str, values: dict) -> dict: | |
| """Saves this mailbox's own sender name, reply-to, daily limit, Sent-copy choice and label.""" | |
| orgs.require_permission(org_id, actor_uid, "manage_mailboxes") | |
| connection = get_mailbox(org_id, connection_id) | |
| unknown = set(values) - set(DEFAULT_MAILBOX_SETTINGS) | |
| if unknown: | |
| raise ValidationError(f"Unknown setting: {', '.join(sorted(unknown))}") | |
| merged = mailbox_settings(connection) | values | |
| for key in ("label", "from_name"): | |
| merged[key] = " ".join(str(merged[key] or "").split()) | |
| if len(merged[key]) > 80: | |
| raise ValidationError("Keep names to 80 characters or fewer") | |
| merged["reply_to"] = str(merged["reply_to"] or "").strip() | |
| if merged["reply_to"] and not _EMAIL.match(merged["reply_to"]): | |
| raise ValidationError("Replies address doesn't look like an email address") | |
| try: | |
| merged["daily_limit"] = int(merged["daily_limit"] or 0) | |
| except (TypeError, ValueError) as exc: | |
| raise ValidationError("Daily limit must be a whole number") from exc | |
| if not 0 <= merged["daily_limit"] <= MAILBOX_DAILY_LIMIT_MAX: | |
| raise ValidationError(f"Daily limit must be between 0 (no limit) and {MAILBOX_DAILY_LIMIT_MAX:,}") | |
| if merged["save_sent_copy"] is not None: | |
| merged["save_sent_copy"] = bool(merged["save_sent_copy"]) | |
| db.update_doc(_connection_path(org_id, connection_id), {"settings": merged}) | |
| return merged | |
| def next_credential_change(connection: dict) -> str | None: | |
| """When the sign-in details may next be changed, or None if they may be changed now.""" | |
| last = connection.get("credentials_changed_at") | |
| if not last: | |
| return None | |
| allowed = datetime.fromisoformat(last) + timedelta(days=MAILBOX_CREDENTIAL_CHANGE_DAYS) | |
| return allowed.isoformat() if allowed > datetime.now(timezone.utc) else None | |
| def change_credentials(org_id: str, actor_uid: str, connection_id: str, credentials: dict) -> dict: | |
| """Replaces an SMTP mailbox's server and sign-in details, at most once every | |
| MAILBOX_CREDENTIAL_CHANGE_DAYS. The new details are tested first; if the test fails | |
| the old ones stay in place and the change doesn't count. Returns | |
| {status, status_message, changed}.""" | |
| orgs.require_permission(org_id, actor_uid, "manage_mailboxes") | |
| connection = get_mailbox(org_id, connection_id) | |
| if connection["kind"] != "smtp": | |
| raise ValidationError("Only mailboxes connected with server details can be changed here") | |
| wait = next_credential_change(connection) | |
| # Adding the incoming (IMAP) side to a mailbox that has none is allowed at any time: it only | |
| # turns on delivery tracking and does not touch how the mailbox signs in to send. | |
| adding_imap = not connection.get("has_imap") and bool(credentials) and all(k.startswith("imap_") for k in credentials) | |
| if wait and not adding_imap: | |
| raise ValidationError(f"These details were changed recently. They can be changed again from {wait[:10]}.") | |
| current = _decrypted_credentials(connection) | |
| merged = {**current, **{k: v for k, v in credentials.items() if v not in (None, "")}} | |
| missing = {"host", "port", "username", "password"} - merged.keys() | |
| if missing: | |
| raise ValidationError(f"SMTP connection missing fields: {missing}") | |
| merged["password"] = "".join(str(merged["password"]).split()) | |
| merged = _clean_imap(merged, required=False) | |
| if merged == current: | |
| raise ValidationError("Nothing changed") | |
| path = _connection_path(org_id, connection_id) | |
| if adding_imap: | |
| # Only the incoming side is new: test just that. Re-sending the test emails would be slow | |
| # and is not needed, since sending is unchanged. | |
| if mailer_client.real_send_enabled(): | |
| details = delivery.imap_details(merged) | |
| try: | |
| delivery.open_inbox(details).close() | |
| except (imaplib.IMAP4.error, OSError): | |
| return {"status": "imap_failed", "status_message": FAILURE_MESSAGES["imap_failed"], "changed": False} | |
| db.update_doc(path, {"has_imap": True, "encrypted_credentials": encrypt(json.dumps(merged))}) | |
| return {"status": connection.get("status"), "status_message": connection.get("status_message"), "changed": True} | |
| keep = {k: connection.get(k) for k in ("email", "has_imap", "encrypted_credentials", "status", "status_message", "verified_at")} | |
| db.update_doc(path, {"email": merged["username"], "has_imap": bool(merged.get("imap_host")), "encrypted_credentials": encrypt(json.dumps(merged)), "status": "pending_verification"}) | |
| status = verify_mailbox(org_id, connection_id) | |
| if status != "verified": | |
| message = get_mailbox(org_id, connection_id).get("status_message") | |
| db.update_doc(path, keep) | |
| return {"status": status, "status_message": message, "changed": False} | |
| if not adding_imap: | |
| db.update_doc(path, {"credentials_changed_at": _now()}) | |
| return {"status": status, "status_message": get_mailbox(org_id, connection_id).get("status_message"), "changed": True} | |
| def remove_mailbox(org_id: str, actor_uid: str, connection_id: str) -> None: | |
| """Deletes the connection and its stored sign-in details. Past sends keep their record. | |
| Refused while a sign-up form sends its confirmation emails through it.""" | |
| orgs.require_permission(org_id, actor_uid, "manage_mailboxes") | |
| get_mailbox(org_id, connection_id) | |
| users = [ | |
| data.get("name") or "a sign-up form" | |
| for data in db.query_prefix(f"orgs/{org_id}/collect_keys/").values() | |
| if not data.get("revoked") and data.get("mailbox_connection_id") == connection_id | |
| ] | |
| if users: | |
| raise ValidationError(f"{users[0]} sends its confirmation emails from this mailbox. Pick another mailbox there first.") | |
| db.delete_doc(_connection_path(org_id, connection_id)) | |
| for path, brand in db.query_prefix(f"orgs/{org_id}/brands/").items(): | |
| if connection_id in brand.get("mailbox_ids", []): | |
| db.update_doc(path, {"mailbox_ids": [i for i in brand["mailbox_ids"] if i != connection_id]}) | |
| emails_path = f"orgs/{org_id}/settings/emails" | |
| prefs = db.get_doc(emails_path) | |
| if prefs and prefs.get("default_mailbox_id") == connection_id: | |
| db.set_doc(emails_path, {**prefs, "default_mailbox_id": ""}) | |
| def get_decrypted_credentials(org_id: str, connection_id: str) -> dict: | |
| """The only sanctioned way to read a connection's plaintext credentials β callers | |
| outside this module should never touch encrypted_credentials directly.""" | |
| return _decrypted_credentials(get_mailbox(org_id, connection_id)) | |
| def _decrypted_credentials(connection: dict) -> dict: | |
| return json.loads(decrypt(connection["encrypted_credentials"])) | |
| def verify_mailbox(org_id: str, connection_id: str) -> str: | |
| """Runs the verification round-trip and updates the connection's status. Returns the | |
| final status string. Never raises for an expected failure mode β those are all | |
| recorded as a status, per SRS FR-7 ("robust support", not a bare exception). | |
| Branches on mailer_client.real_send_enabled(): mocked (default) runs the full 5-state | |
| round-trip against an in-memory inbox; real runs _verify_real, which can only reach | |
| 3 of those 5 states β see its docstring for exactly why.""" | |
| connection = get_mailbox(org_id, connection_id) | |
| credentials = _decrypted_credentials(connection) | |
| if mailer_client.real_send_enabled(): | |
| return _verify_real(org_id, connection_id, connection["kind"], credentials) | |
| return _verify_mock(org_id, connection_id, credentials) | |
| def _verify_mock(org_id: str, connection_id: str, credentials: dict) -> str: | |
| try: | |
| mailer = MockMailer(credentials, SYSTEM_TEST_INBOX) | |
| except AuthError: | |
| return _settle(org_id, connection_id, "auth_rejected") | |
| except ConnectionFailure: | |
| return _settle(org_id, connection_id, "connection_failed") | |
| otps = [uuid.uuid4().hex[:8] for _ in range(VERIFICATION_OTP_COUNT)] | |
| for i, otp in enumerate(otps): | |
| mailer.send( | |
| to_addr=SYSTEM_TEST_MAILBOX_ADDRESS_PROTO, | |
| subject=f"M8ion verification {connection_id} #{i}", | |
| body=f"OTP:{otp}", | |
| ) | |
| arrived = [ | |
| m for m in SYSTEM_TEST_INBOX.messages_to(SYSTEM_TEST_MAILBOX_ADDRESS_PROTO) | |
| if connection_id in m.subject | |
| ] | |
| if not arrived: | |
| return _settle(org_id, connection_id, "silent_failure") | |
| if len(arrived) < len(otps): | |
| _delete_test_messages(arrived) | |
| return _settle(org_id, connection_id, "unreliable") | |
| expected_bodies = {f"OTP:{otp}" for otp in otps} | |
| arrived_bodies = {m.body for m in arrived} | |
| if arrived_bodies != expected_bodies: | |
| _delete_test_messages(arrived) | |
| return _settle(org_id, connection_id, "content_mismatch") | |
| _delete_test_messages(arrived) | |
| return _settle(org_id, connection_id, "verified") | |
| def _verify_real(org_id: str, connection_id: str, kind: str, credentials: dict) -> str: | |
| """Real SMTP auth + a real send attempt against SYSTEM_SENDER_ADDRESS_PROTO β the one | |
| real mailbox this project holds credentials for. Can only ever reach "auth_rejected", | |
| "connection_failed", or "verified" β "silent_failure"/"unreliable"/"content_mismatch" | |
| all require reading the destination inbox back (IMAP) to detect, which isn't built | |
| (Phase 2's bounce-detection work). A "verified" here means auth + send succeeded, not | |
| that arrival was confirmed β VERIFIED_REAL_MESSAGE says so explicitly in the UI.""" | |
| try: | |
| mailer = mailer_client.get_mailer(kind, credentials) | |
| except AuthError: | |
| return _settle(org_id, connection_id, "auth_rejected") | |
| except (ConnectionFailure, NotImplementedError): | |
| return _settle(org_id, connection_id, "connection_failed") | |
| try: | |
| for i in range(VERIFICATION_OTP_COUNT): | |
| otp = uuid.uuid4().hex[:8] | |
| mailer.send( | |
| to_addr=SYSTEM_SENDER_ADDRESS_PROTO, | |
| subject=f"M8ion verification {connection_id} #{i}", | |
| body=f"OTP:{otp}", | |
| ) | |
| except (AuthError, ConnectionFailure): | |
| # Sign-in already worked, so telling the person the server was unreachable would be wrong. | |
| return _settle(org_id, connection_id, "connection_failed", message_override=SEND_FAILED_MESSAGE) | |
| details = delivery.imap_details(credentials) | |
| if details is not None: | |
| try: | |
| delivery.open_inbox(details).close() | |
| except (imaplib.IMAP4.error, OSError): | |
| return _settle(org_id, connection_id, "imap_failed") | |
| return _settle(org_id, connection_id, "verified", message_override=VERIFIED_REAL_MESSAGE) | |
| def _delete_test_messages(messages) -> None: | |
| """The test mailbox doesn't keep verification OTPs around once a round-trip is | |
| evaluated, matching the "delete the test mail after test complete" requirement.""" | |
| for m in messages: | |
| if m in SYSTEM_TEST_INBOX.messages: | |
| SYSTEM_TEST_INBOX.messages.remove(m) | |
| def _settle(org_id: str, connection_id: str, status: str, message_override: str | None = None) -> str: | |
| path = _connection_path(org_id, connection_id) | |
| db.update_doc( | |
| path, | |
| { | |
| "status": status, | |
| "status_message": message_override if message_override is not None else FAILURE_MESSAGES.get(status), | |
| "verified_at": _now() if status == "verified" else None, | |
| "dismissed": False, # a fresh result is news again, even if the last failure was dismissed | |
| }, | |
| ) | |
| return status | |