File size: 19,886 Bytes
45dc2a1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
"""
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