diff --git a/ar-aging-app/.env.example b/ar-aging-app/.env.example index cd93d13..312e00a 100644 --- a/ar-aging-app/.env.example +++ b/ar-aging-app/.env.example @@ -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= diff --git a/ar-aging-app/backend/app/config.py b/ar-aging-app/backend/app/config.py index 3a84882..ce93ff6 100644 --- a/ar-aging-app/backend/app/config.py +++ b/ar-aging-app/backend/app/config.py @@ -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 diff --git a/ar-aging-app/backend/app/services/mailer.py b/ar-aging-app/backend/app/services/mailer.py index 2c88649..732deca 100644 --- a/ar-aging-app/backend/app/services/mailer.py +++ b/ar-aging-app/backend/app/services/mailer.py @@ -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 diff --git a/ar-aging-app/backend/tests/test_auth.py b/ar-aging-app/backend/tests/test_auth.py index ed1d76c..d538426 100644 --- a/ar-aging-app/backend/tests/test_auth.py +++ b/ar-aging-app/backend/tests/test_auth.py @@ -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"})