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

1"""SMTP email sender + Jinja2 template renderer for deployment notifications. 

2 

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. 

6 

7Sending strategy: 

8 

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. 

26 

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""" 

31 

32from __future__ import annotations 

33 

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 

41 

42from jinja2 import Environment, FileSystemLoader, select_autoescape 

43 

44from app.config import settings 

45 

46logger = logging.getLogger(__name__) 

47 

48 

49_TEMPLATES_DIR = Path(__file__).parent.parent / "templates" / "email" 

50 

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) 

58 

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) 

71 

72 

73def render(template_name: str, **context: Any) -> str: 

74 """Render a Jinja2 template with the given context. 

75 

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) 

82 

83 

84def is_smtp_enabled() -> bool: 

85 """Effective SMTP availability — the predicate every caller should use. 

86 

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. 

95 

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 ) 

107 

108 

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. 

117 

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 

128 

129 recipients = [to] if isinstance(to, str) else list(to) 

130 if not recipients: 

131 return False 

132 

133 from_email = settings.SMTP_FROM_EMAIL or settings.SMTP_USER 

134 

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")) 

143 

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