Password codes via the company Mail API (primary), SMTP stays fallback

Same internal mail service the TikTok dashboard uses for its
verification codes: bearer-token multipart POST (stdlib urllib, no new
deps). Configured with AR_MAIL_API_URL/TOKEN; credentials live only in
the gitignored env files. Live send verified ({status:sent}).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
main
Talha Ahmed 2026-08-19 19:54:50 +05:00
parent 2aed450f4c
commit 984069b368
4 changed files with 69 additions and 13 deletions

View File

@ -27,9 +27,13 @@ AR_CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173,http://localhost:517
# Exchange rates: frankfurter = free, keyless, central-bank rates
AR_FX_PROVIDER=frankfurter
# Email (optional) — enables "email me a code" for password resets. Any SMTP account:
# Email (optional) — enables "email me a code" for password resets.
# Preferred: the company's internal Mail API (bearer token; ask Talha/IT for the values).
#AR_MAIL_API_URL=
#AR_MAIL_API_TOKEN=
# Fallback: any SMTP account (used only if AR_MAIL_API_URL is unset):
# Office365: smtp.office365.com : 587 Gmail: smtp.gmail.com : 587 (app password)
# Unset -> passwords change via current password / admin reset instead.
# Both unset -> passwords change via current password / admin reset instead.
#AR_SMTP_HOST=
#AR_SMTP_PORT=587
#AR_SMTP_USER=
@ -69,7 +73,9 @@ AR_RETENTION_DAYS=90
# AR_FX_PROVIDER=exchangerate-api # paid fallback ($10/mo) — then set:
# AR_FX_API_KEY=
# Email for password codes (see the LOCAL section for provider examples)
# Email for password codes (see the LOCAL section for the transports)
#AR_MAIL_API_URL=
#AR_MAIL_API_TOKEN=
#AR_SMTP_HOST=
#AR_SMTP_PORT=587
#AR_SMTP_USER=

View File

@ -116,10 +116,17 @@ SECRET_KEY = os.environ.get("AR_SECRET_KEY", "")
# Token lifetime (hours).
AUTH_TOKEN_HOURS = int(os.environ.get("AR_AUTH_TOKEN_HOURS", "12"))
# --------------------------------------------------------------------------- email (SMTP)
# --------------------------------------------------------------------------- email
# Used ONLY for password codes ("email me a code" on the login/Settings screens).
# Unset -> the email-code flow is hidden and passwords change via the current-password
# form (or manage.py set-password by the admin). Any standard SMTP account works
# form (or manage.py set-password by the admin).
#
# Preferred transport: the company's internal Mail API (the same service the TikTok
# dashboard uses for its verification codes) — a bearer-token multipart POST.
MAIL_API_URL = os.environ.get("AR_MAIL_API_URL", "")
MAIL_API_TOKEN = os.environ.get("AR_MAIL_API_TOKEN", "")
# Fallback transport: any standard SMTP account
# (Office365: smtp.office365.com:587, Gmail: smtp.gmail.com:587 with an app password).
SMTP_HOST = os.environ.get("AR_SMTP_HOST", "")
SMTP_PORT = int(os.environ.get("AR_SMTP_PORT", "587"))
@ -130,7 +137,7 @@ SMTP_STARTTLS = os.environ.get("AR_SMTP_STARTTLS", "true").strip().lower() != "f
def email_enabled() -> bool:
return bool(SMTP_HOST and SMTP_FROM)
return bool(MAIL_API_URL) or bool(SMTP_HOST and SMTP_FROM)
# --------------------------------------------------------------------------- FX provider
# frankfurter (default; free, keyless, central-bank rates) | exchangerate-api (paid, needs

View File

@ -1,16 +1,25 @@
"""Outbound email — used ONLY for password codes. Stdlib smtplib, no new dependencies.
"""Outbound email — used ONLY for password codes. Stdlib only, no new dependencies.
Configured via AR_SMTP_* (see config.py). When unconfigured, callers get MailerError and
the UI falls back to the current-password / admin-reset flows."""
Transports (first configured wins; see config.py):
* Mail API the company's internal mail service (bearer-token multipart POST; the
same service the TikTok dashboard uses for its verification codes).
* SMTP any standard account (Office365 / Gmail app password / relay).
When neither is configured, callers get MailerError and the UI falls back to the
current-password / admin-reset flows."""
from __future__ import annotations
import json
import logging
import smtplib
import ssl
import urllib.error
import urllib.request
import uuid
from email.message import EmailMessage
from ..config import (SMTP_FROM, SMTP_HOST, SMTP_PASSWORD, SMTP_PORT, SMTP_STARTTLS,
SMTP_USER, email_enabled)
from ..config import (MAIL_API_TOKEN, MAIL_API_URL, SMTP_FROM, SMTP_HOST, SMTP_PASSWORD,
SMTP_PORT, SMTP_STARTTLS, SMTP_USER, email_enabled)
logger = logging.getLogger(__name__)
@ -19,6 +28,34 @@ class MailerError(RuntimeError):
"""Email could not be sent (unconfigured, auth failure, network...)."""
def _send_via_mail_api(to: str, subject: str, body: str) -> None:
"""POST multipart/form-data to the team mail service (same field set the TikTok
dashboard's mailer sends: subject, body, to, cc, bcc, content_type, save_to_sent)."""
boundary = f"----ar-aging-{uuid.uuid4().hex}"
fields = {"subject": subject, "body": body, "to": to, "cc": "", "bcc": "",
"content_type": "text", "save_to_sent_items": "true"}
parts = []
for name, value in fields.items():
parts.append(f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="{name}"\r\n\r\n'
f"{value}\r\n")
payload = ("".join(parts) + f"--{boundary}--\r\n").encode("utf-8")
req = urllib.request.Request(MAIL_API_URL, data=payload, method="POST", headers={
"Content-Type": f"multipart/form-data; boundary={boundary}",
"Authorization": f"Bearer {MAIL_API_TOKEN}",
"accept": "application/json",
})
try:
with urllib.request.urlopen(req, timeout=25) as resp:
raw = resp.read().decode("utf-8", "replace")
logger.info("mail api sent to %s: %s (%s)", to, subject, raw[:120])
except urllib.error.HTTPError as e:
detail = e.read().decode("utf-8", "replace")[:200]
raise MailerError(f"Mail service answered HTTP {e.code}: {detail}") from e
except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as e:
raise MailerError(f"Could not reach the mail service: {e}") from e
def _ssl_context() -> ssl.SSLContext:
"""certifi CA bundle when available — the Windows OS cert store is unreliable on some
machines (same workaround as the FX service)."""
@ -31,7 +68,11 @@ def _ssl_context() -> ssl.SSLContext:
def send_email(to: str, subject: str, body: str) -> None:
if not email_enabled():
raise MailerError("Email is not configured on this server (AR_SMTP_* settings).")
raise MailerError("Email is not configured on this server "
"(AR_MAIL_API_* or AR_SMTP_* settings).")
if MAIL_API_URL:
_send_via_mail_api(to, subject, body)
return
msg = EmailMessage()
msg["From"] = SMTP_FROM
msg["To"] = to

View File

@ -195,7 +195,9 @@ def test_email_code_reset_flow(clean_users, monkeypatch):
assert auth_module.CODE_MAX_ATTEMPTS >= 3 # sanity: lockout exists
def test_request_code_without_email_configured(clean_users):
def test_request_code_without_email_configured(clean_users, monkeypatch):
# Force the unconfigured state — the dev .env may carry real mail settings.
monkeypatch.setattr("app.config.email_enabled", lambda: False)
_add_user("noemail@utopiabrands.com", "No Email", "some-password-1")
with TestClient(app) as c:
r = c.post("/api/auth/request-code", json={"username": "noemail@utopiabrands.com"})