Coverage for app/services/email_service.py: 33.96%
53 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-07-25 15:51 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-07-25 15:51 +0000
1"""SMTP email sender + Jinja2 template renderer for deployment notifications.
3Sits in the backend (not the worker) because the listener that picks up
4``task-succeeded`` already lives there and has DB access for resolving
5user emails. The worker stays infrastructure-only.
7Sending strategy:
9* Gmail SMTP via ``settings.SMTP_*`` — App password required when 2FA is
10 on. The kill-switch is ``settings.SMTP_ENABLED`` (off by default); set
11 it to ``True`` and provide ``SMTP_USER`` / ``SMTP_PASSWORD`` to turn
12 delivery on. With either condition missing, ``send_email`` becomes a
13 no-op and returns ``False`` without raising.
14* Port-aware connection: ``465`` opens an implicit-TLS connection
15 (``SMTP_SSL``); anything else (typically ``587``) uses ``SMTP`` and
16 upgrades via STARTTLS. Some corporate networks (SAP intranet
17 included) block outbound 587 but allow 465 — switching is a config
18 change, no code edit.
19* MIME ``multipart/alternative`` with both an HTML and a plain-text
20 body so clients without HTML rendering still see something legible.
21 Templates live next to this file in ``templates/email/``.
22* Each ``send_email`` is its own SMTP connection (no pooling). Gmail
23 drops idle connections aggressively and the volume here is low —
24 one mail per user per deploy. Reuse would just add reconnect-on-
25 expired complexity.
27Failures are logged at ``warning`` and swallowed: a failed mail must
28never roll back a successful deployment. The listener checks the
29return value if it wants to surface a "mail not sent" badge.
30"""
32from __future__ import annotations
34import logging
35import smtplib
36import ssl
37from email.mime.multipart import MIMEMultipart
38from email.mime.text import MIMEText
39from pathlib import Path
40from typing import Any
42from jinja2 import Environment, FileSystemLoader, select_autoescape
44from app.config import settings
46logger = logging.getLogger(__name__)
49_TEMPLATES_DIR = Path(__file__).parent.parent / "templates" / "email"
51# HTML templates: autoescape, trim/lstrip blocks for clean indentation.
52_jinja_html = Environment(
53 loader=FileSystemLoader(_TEMPLATES_DIR),
54 autoescape=select_autoescape(["html", "xml"]),
55 trim_blocks=True,
56 lstrip_blocks=True,
57)
59# Plain-text templates: no autoescape (escaping ``&`` inside passwords
60# would mangle them), and no block stripping — Jinja's whitespace
61# control removes critical newlines around ``{% if %}`` / ``{% for %}``
62# blocks in plain text. Authors use ``{%- ... -%}`` explicitly when
63# they want trimming.
64_jinja_text = Environment(
65 loader=FileSystemLoader(_TEMPLATES_DIR),
66 autoescape=False,
67 trim_blocks=False,
68 lstrip_blocks=False,
69 keep_trailing_newline=True,
70)
73def render(template_name: str, **context: Any) -> str:
74 """Render a Jinja2 template with the given context.
76 Picks the HTML or text environment based on the template's
77 extension so plain-text templates aren't HTML-escaped and
78 HTML templates still get tidy whitespace.
79 """
80 env = _jinja_text if template_name.endswith(".txt") else _jinja_html
81 return env.get_template(template_name).render(**context)
84def is_smtp_enabled() -> bool:
85 """Effective SMTP availability — the predicate every caller should use.
87 Both conditions must hold for mail delivery to even be attempted:
88 * ``SMTP_ENABLED`` is the explicit operator kill-switch (default
89 ``False``). It exists so a dev / CI environment can leave the
90 Gmail app-password in ``.env`` for later but keep delivery off.
91 * ``SMTP_USER`` and ``SMTP_PASSWORD`` must be populated.
92 ``SMTP_ENABLED=True`` with empty credentials is treated as
93 "configuration in progress" and still skips delivery — better
94 than crashing at submit-time with an auth error.
96 Returning a single boolean lets the resend-access endpoint
97 short-circuit BEFORE accessing the deployment / notifier pipeline,
98 so a 503 response carries the right semantic ("we chose not to
99 send") instead of leaking a 502 ("we tried and failed") when the
100 cause is purely a configuration choice.
101 """
102 return bool(
103 settings.SMTP_ENABLED
104 and settings.SMTP_USER
105 and settings.SMTP_PASSWORD
106 )
109def send_email(
110 *,
111 to: str | list[str],
112 subject: str,
113 html_body: str,
114 text_body: str,
115) -> bool:
116 """Send a multipart HTML+text email via Gmail SMTP.
118 Returns ``True`` on success, ``False`` if SMTP isn't configured or
119 sending raised. Never raises — the deployment notification flow
120 must keep going even if mail is broken.
121 """
122 if not settings.SMTP_ENABLED:
123 logger.info("SMTP disabled (SMTP_ENABLED=false), skipping email to %s", to)
124 return False
125 if not settings.SMTP_USER or not settings.SMTP_PASSWORD:
126 logger.info("SMTP not configured (SMTP_USER empty), skipping email to %s", to)
127 return False
129 recipients = [to] if isinstance(to, str) else list(to)
130 if not recipients:
131 return False
133 from_email = settings.SMTP_FROM_EMAIL or settings.SMTP_USER
135 msg = MIMEMultipart("alternative")
136 msg["Subject"] = subject
137 msg["From"] = f"{settings.SMTP_FROM_NAME} <{from_email}>"
138 msg["To"] = ", ".join(recipients)
139 # Plain part first so MIME-spec-compliant clients prefer the HTML
140 # part (they pick the *last* alternative they can render).
141 msg.attach(MIMEText(text_body, "plain", _charset="utf-8"))
142 msg.attach(MIMEText(html_body, "html", _charset="utf-8"))
144 try:
145 # 30s timeout: Gmail's STARTTLS handshake can take 10-15s on
146 # first contact. Port 465 = implicit TLS (SMTPS); anything else
147 # = STARTTLS. Gmail accepts either.
148 if settings.SMTP_PORT == 465:
149 ctx = ssl.create_default_context()
150 with smtplib.SMTP_SSL(
151 settings.SMTP_HOST, settings.SMTP_PORT, timeout=30, context=ctx,
152 ) as smtp:
153 smtp.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
154 smtp.sendmail(from_email, recipients, msg.as_string())
155 else:
156 with smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT, timeout=30) as smtp:
157 smtp.ehlo()
158 smtp.starttls()
159 smtp.ehlo()
160 smtp.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
161 smtp.sendmail(from_email, recipients, msg.as_string())
162 logger.info("Sent email to %s — subject=%r", recipients, subject)
163 return True
164 except Exception as e:
165 # Log full message so the operator can diagnose (auth fail vs.
166 # timeout vs. blocked recipient) without re-running the mail.
167 logger.warning("Failed to send email to %s: %s", recipients, e)
168 return False