diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-31 17:19:17 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-31 17:19:17 +0200 |
| commit | c6fd7ea89b6e0a96eb1d81989de891b4768b1044 (patch) | |
| tree | 12e869044c80c889f83b588210cc0ac500cd7a6a /packages/meshbay-hub/src/meshbay_hub/mail.py | |
| parent | f4c6628c8e85513d9fd110ead95682368a15a0fd (diff) | |
| download | meshbay-c6fd7ea89b6e0a96eb1d81989de891b4768b1044.tar.gz | |
feat: email verification for registration, email change, and invitations
Registration now creates a pending account and sends a 6-digit code via
email; the account activates only after verification. Email changes on
the profile page follow the same flow. Group invitations send a
notification email to the invitee (without revealing their address to
the inviter) containing the invite code and hub link.
Backend: blind HMAC-SHA256 email index for uniqueness without decryption,
mail.py for localhost Postfix delivery, verification endpoints, cleanup
of expired codes and stale pending accounts, startup backfill of
email_hash for existing users.
Frontend: 3-phase register page, inline email change verification on
profile, invite-notify call with status display. All 10 locales updated.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/mail.py')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/mail.py | 97 |
1 files changed, 97 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/mail.py b/packages/meshbay-hub/src/meshbay_hub/mail.py new file mode 100644 index 0000000..ce6e264 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/mail.py @@ -0,0 +1,97 @@ +""" +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 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: + 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 + + +def send_verification_code(to: str, code: str) -> None: + msg = EmailMessage() + msg["From"] = f"noreply@{_hub_domain}" + msg["To"] = to + msg["Subject"] = f"MeshBay — Your verification code: {code}" + msg.set_content( + f"Your verification code is: {code}\n" + "\n" + "Enter this code to verify your email address.\n" + "This code expires in 24 hours.\n" + "\n" + "If you did not create a MeshBay account, ignore this email.\n" + "\n" + f"{_hub_url}\n" + ) + _send(msg) + log.info("Verification code sent to %s", _mask_email(to)) + + +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_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}" |