108 lines
4.4 KiB
Python
108 lines
4.4 KiB
Python
"""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.",
|
|
)
|