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
parent
2aed450f4c
commit
984069b368
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
|
|
|
|||
Loading…
Reference in New Issue