"""Outbound email — used ONLY for password codes. Stdlib only, no new dependencies. 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 (MAIL_API_TOKEN, MAIL_API_URL, SMTP_FROM, SMTP_HOST, SMTP_PASSWORD, SMTP_PORT, SMTP_STARTTLS, SMTP_USER, email_enabled) logger = logging.getLogger(__name__) 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).""" try: import certifi return ssl.create_default_context(cafile=certifi.where()) except ImportError: return ssl.create_default_context() def send_email(to: str, subject: str, body: str) -> None: if not email_enabled(): 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 msg["Subject"] = subject msg.set_content(body) try: if SMTP_STARTTLS: with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=20) as s: s.starttls(context=_ssl_context()) if SMTP_USER: s.login(SMTP_USER, SMTP_PASSWORD) s.send_message(msg) else: # implicit TLS (port 465) with smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, timeout=20, context=_ssl_context()) as s: if SMTP_USER: s.login(SMTP_USER, SMTP_PASSWORD) s.send_message(msg) logger.info("email sent to %s: %s", to, subject) except (smtplib.SMTPException, OSError) as e: raise MailerError(f"Could not send the email: {e}") from e def send_password_code(to: str, code: str, minutes: int) -> None: send_email( to, "Your password code — Amazon A/R Aging", f"Your verification code is:\n\n {code}\n\n" f"It expires in {minutes} minutes and works once.\n\n" f"If you didn't request a password change, ignore this email — " f"your password has not been changed.", )