aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/mail.py
blob: 837320409277e30c7594df11f496b8b05087bdf3 (plain) (blame)
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
"""
MeshBay Hub — email sending via localhost Postfix.

Postfix listens on loopback only (inet_interfaces = loopback-only), so no
authentication is needed. See docs/MAIL-SERVER.md for the full setup.
"""

import asyncio
import logging
import smtplib
from email.message import EmailMessage

log = logging.getLogger(__name__)

_hub_domain: str = "meshbay.org"
_hub_url: str = "https://meshbay.org"


def configure(hub_id: str) -> None:
    global _hub_domain, _hub_url
    _hub_domain = hub_id
    _hub_url = f"https://{hub_id}"


def _send(msg: EmailMessage) -> bool:
    """Blocking. Every caller in an async handler must use `send_off_loop`."""
    try:
        with smtplib.SMTP("localhost", 25, timeout=10) as s:
            s.send_message(msg)
        return True
    except Exception:
        log.exception("Failed to send email to %s", msg["To"])
        return False


async def send_off_loop(fn, *args, **kwargs) -> None:
    """Run one of the `send_*` functions below in a worker thread.

    `smtplib` is synchronous and this one waits up to ten seconds. Called
    directly from an async handler — which is what all four call sites did —
    that ten seconds is not one request's, it is **the whole hub's**: no other
    request is served, no node socket is read, no WebRTC offer is relayed,
    for as long as the MTA takes to answer. An unreachable mail server made
    the instance stop responding to everyone, and one of the three paths that
    reaches it (`PATCH /v1/users/me`) had no rate limit at all.

    So the cost of a slow MTA is one request now, not the instance.
    """
    await asyncio.to_thread(fn, *args, **kwargs)


def send_verification_code(to: str, code: str, recovery_key: str | None = None) -> None:
    """
    Registration verification e-mail. When `recovery_key` is given it is
    appended to the body so the recipient's mailbox becomes the backup for it
    (docs/auth-confirm.md §4.4).

    `recovery_key` is a **pass-through**: it is generated on the client, never
    stored anywhere on the hub, and never logged — only whether one was present.
    """
    body = (
        f"Your verification code is: {code}\n"
        "\n"
        "Enter this code to verify your email address.\n"
        "This code expires in 24 hours.\n"
    )
    if recovery_key:
        body += (
            "\n"
            "---- Account recovery key ----\n"
            "\n"
            "Keep this message. If you ever forget your passphrase, this key is\n"
            "what restores your access to your groups. It is not stored on the\n"
            f"server and nobody at {_hub_domain} can recover it for you.\n"
            "\n"
            f"    {recovery_key}\n"
        )
    body += (
        "\n"
        "If you did not create a MeshBay account, ignore this email.\n"
        "\n"
        f"{_hub_url}\n"
    )
    msg = EmailMessage()
    msg["From"] = f"noreply@{_hub_domain}"
    msg["To"] = to
    msg["Subject"] = f"MeshBay — Your verification code: {code}"
    msg.set_content(body)
    _send(msg)
    log.info("Verification code sent to %s (recovery_key=%s)",
             _mask_email(to), bool(recovery_key))


def send_email_change_code(to: str, code: str) -> None:
    msg = EmailMessage()
    msg["From"] = f"noreply@{_hub_domain}"
    msg["To"] = to
    msg["Subject"] = f"MeshBay — Confirm your new email: {code}"
    msg.set_content(
        f"Your verification code is: {code}\n"
        "\n"
        "Enter this code to confirm your new email address.\n"
        "This code expires in 24 hours.\n"
        "\n"
        "If you did not request this change, ignore this email.\n"
        "\n"
        f"{_hub_url}\n"
    )
    _send(msg)
    log.info("Email change code sent to %s", _mask_email(to))


def send_password_reset_code(to: str, code: str) -> None:
    """
    Passphrase-reset code (docs/auth-confirm.md §4.2). This only re-opens hub
    login; it recovers no group content — that needs the recovery key.
    """
    msg = EmailMessage()
    msg["From"] = f"noreply@{_hub_domain}"
    msg["To"] = to
    msg["Subject"] = f"MeshBay — Passphrase reset code: {code}"
    msg.set_content(
        f"Your passphrase reset code is: {code}\n"
        "\n"
        "Enter it to set a new passphrase. This code expires in 1 hour.\n"
        "\n"
        "This restores your sign-in only. If you also have your recovery key,\n"
        "you can restore access to your groups in the same step.\n"
        "\n"
        "If you did not request this, ignore this email — your account is\n"
        "unchanged.\n"
        "\n"
        f"{_hub_url}\n"
    )
    _send(msg)
    log.info("Passphrase reset code sent to %s", _mask_email(to))


def send_invite_notification(
    to: str, code: str, inviter: str, group_name: str,
) -> None:
    msg = EmailMessage()
    msg["From"] = f"noreply@{_hub_domain}"
    msg["To"] = to
    msg["Subject"] = f"MeshBay — {inviter} invited you to {group_name}"
    msg.set_content(
        f"{inviter} invited you to the group \"{group_name}\" on MeshBay.\n"
        "\n"
        f"Your one-time code is: {code}\n"
        "\n"
        "Open the group and enter this code when prompted.\n"
        "The code works once and expires in 7 days.\n"
        "\n"
        f"{_hub_url}\n"
    )
    _send(msg)
    log.info("Invite notification sent to %s", _mask_email(to))


def _mask_email(email: str) -> str:
    local, _, domain = email.partition("@")
    if len(local) <= 2:
        return f"{'*' * len(local)}@{domain}"
    return f"{local[0]}{'*' * (len(local) - 2)}{local[-1]}@{domain}"