Coverage for functions \ flipdare \ mailer \ _mailer.py: 84%

197 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2026-05-08 12:22 +1000

1#!/usr/bin/env python 

2# Copyright (c) 2026 Flipdare Pty Ltd. All rights reserved. 

3# 

4# This file is part of Flipdare's proprietary software and contains 

5# confidential and copyrighted material. Unauthorised copying, 

6# modification, distribution, or use of this file is strictly 

7# prohibited without prior written permission from Flipdare Pty Ltd. 

8# 

9# This software includes third-party components licensed under MIT, 

10# BSD, and Apache 2.0 licences. See THIRD_PARTY_NOTICES for details. 

11# 

12 

13import email.mime.text as mime 

14import smtplib 

15import ssl 

16from abc import ABC, abstractmethod 

17from email.mime import multipart 

18from email.mime.image import MIMEImage 

19from typing import Any, NoReturn 

20import minify_html as minifier 

21from dataclasses import dataclass 

22from flipdare.mailer.email_image import EmailImage 

23from flipdare.app_log import LOG 

24from flipdare.constants import INTERNAL_ADMIN_EMAIL, IS_DEBUG, IS_TRACE, SMTP_STATUS_OK 

25from flipdare.core.singleton import Singleton 

26from flipdare.mailer._jinja_email_template import JinjaEmailTemplate 

27from flipdare.error.app_error import ServerError 

28from flipdare.generated.shared.app_error_code import AppErrorCode 

29from flipdare.util.mail_reply_type import MailReplyType 

30 

31__all__ = ["MailerOptions"] 

32 

33 

34@dataclass 

35class MailerOptions: 

36 smtp_gateway: str 

37 smtp_port: int 

38 smtp_username: str 

39 smtp_password: str 

40 smtp_timeout: int 

41 internal_service_email: str 

42 def_mail_from: str 

43 def_mail_reply: str 

44 def_no_reply: str 

45 

46 @classmethod 

47 def create( 

48 cls, 

49 smtp_gateway: str | None = None, 

50 smtp_port: int | None = None, 

51 smtp_username: str | None = None, 

52 smtp_password: str | None = None, 

53 smtp_timeout: int | None = None, 

54 internal_service_email: str | None = None, 

55 def_mail_from: str | None = None, 

56 def_mail_reply: str | None = None, 

57 def_no_reply: str | None = None, 

58 ) -> "MailerOptions": 

59 from flipdare.app_config import AppConfig, get_app_config 

60 

61 config: AppConfig = get_app_config() 

62 return cls( 

63 smtp_gateway=smtp_gateway or config.smtp_gateway, 

64 smtp_port=smtp_port or config.smtp_port, 

65 smtp_username=smtp_username or config.smtp_username, 

66 smtp_password=smtp_password or config.smtp_password, 

67 smtp_timeout=smtp_timeout or config.smtp_timeout, 

68 internal_service_email=internal_service_email or INTERNAL_ADMIN_EMAIL, 

69 def_mail_from=def_mail_from or MailReplyType.ADMIN.formatted, 

70 def_mail_reply=def_mail_reply or MailReplyType.SUPPORT.formatted, 

71 def_no_reply=def_no_reply or MailReplyType.NO_REPLY.formatted, 

72 ) 

73 

74 

75class Mailer(ABC, Singleton): 

76 def __init__( 

77 self, 

78 options: MailerOptions | None = None, 

79 smtp_client: smtplib.SMTP_SSL | None = None, 

80 ) -> None: 

81 if options is None: 

82 options = MailerOptions.create() 

83 self._options = options 

84 self._manager = ClientManager(options=options, smtp_client=smtp_client) 

85 

86 @abstractmethod 

87 def send(self, *args: Any, **kwargs: Any) -> bool: ... 

88 

89 @property 

90 def options(self) -> MailerOptions: 

91 return self._options 

92 

93 @property 

94 def smtp_client(self) -> smtplib.SMTP_SSL: 

95 return self._manager.client 

96 

97 @staticmethod 

98 def minify_html(html_content: str) -> str: 

99 try: 

100 # pylint: disable=no-member 

101 return minifier.minify( 

102 html_content, 

103 # NOTE: default is false for noncompliant attributes 

104 # NOTE: should keep that, unless we find a use case otherwise 

105 # allow_noncompliant_unquoted_attribute_values=True 

106 keep_html_and_head_opening_tags=True, 

107 keep_closing_tags=True, 

108 minify_js=True, 

109 minify_css=True, 

110 ) 

111 except Exception as e: 

112 LOG().warning(f"HTML minification failed: {e}. Returning original HTML content.") 

113 return html_content 

114 

115 def _send_template( 

116 self, 

117 email: str, 

118 email_template: JinjaEmailTemplate[Any], 

119 should_minify: bool, 

120 no_reply: bool = True, 

121 images: list[EmailImage] | None = None, 

122 ) -> bool: 

123 

124 if IS_TRACE: 

125 msg = f"Rendering email template {email_template.__class__.__name__} for recipient {email}" 

126 LOG().trace(msg) 

127 

128 return self._send_raw( 

129 email=email, 

130 subject=email_template.subject, 

131 html_str=email_template.html, 

132 text_str=email_template.text, 

133 should_minify=should_minify, 

134 no_reply=no_reply, 

135 images=images, 

136 ) 

137 

138 def _send_raw( 

139 self, 

140 email: str, 

141 subject: str, 

142 html_str: str, 

143 text_str: str, 

144 should_minify: bool, 

145 no_reply: bool = True, 

146 images: list[EmailImage] | None = None, 

147 ) -> bool: 

148 if IS_TRACE: 

149 LOG().trace(f"Sending email to {email} with subject '{subject}'") 

150 

151 email_msg: multipart.MIMEMultipart | None = None 

152 try: 

153 email_msg = self._build( 

154 email, 

155 subject, 

156 html_str, 

157 text_str, 

158 should_minify=should_minify, 

159 no_reply=no_reply, 

160 images=images, 

161 ) 

162 except Exception as e: 

163 msg = f"Error building email message for {email}: {e}" 

164 self._raise_error( 

165 message=msg, 

166 error_code=AppErrorCode.EMAIL_TEMPLATE, 

167 ex=e, 

168 ) 

169 

170 try: 

171 smtp_client = self.smtp_client 

172 smtp_client.send_message(email_msg) 

173 if IS_DEBUG: 

174 LOG().debug(f"Message successfully sent to {email}") 

175 

176 return True 

177 except smtplib.SMTPException as smtp_ex: 

178 msg = f"SMTP error sending email to {email}: {smtp_ex}" 

179 self._raise_error( 

180 message=msg, 

181 error_code=AppErrorCode.EMAIL_SMTP, 

182 ex=smtp_ex, 

183 ) 

184 except Exception as ex: 

185 msg = f"Error sending email to {email}: {ex}" 

186 self._raise_error(message=msg, ex=ex) 

187 

188 def _raise_error( 

189 self, 

190 message: str, 

191 ex: Exception, 

192 error_code: AppErrorCode = AppErrorCode.EMAIL, 

193 ) -> NoReturn: 

194 LOG().error(f"{message}: {ex}") 

195 raise ServerError( 

196 message=message, 

197 error_code=error_code, 

198 ) from ex 

199 

200 def _build( 

201 self, 

202 email: str, 

203 subject: str, 

204 html_str: str, 

205 text_str: str, 

206 should_minify: bool, 

207 no_reply: bool = True, 

208 images: list[EmailImage] | None = None, 

209 ) -> multipart.MIMEMultipart: 

210 mail_from = self.options.def_mail_from 

211 no_reply_email = self.options.def_no_reply 

212 mail_reply_email = self.options.def_mail_reply 

213 

214 if IS_TRACE: 

215 LOG().trace( 

216 f"Building email message: From={mail_from}, To={email}, Subject='{subject}', " 

217 f"Reply-To={no_reply_email if no_reply else mail_reply_email}, " 

218 f"HTML Length={len(html_str)}, Text Length={len(text_str)}", 

219 ) 

220 

221 try: 

222 actual_html = self.minify_html(html_str) if should_minify else html_str 

223 reply_to = no_reply_email if no_reply else mail_reply_email 

224 

225 text_part = mime.MIMEText(text_str, "plain") 

226 html_part = mime.MIMEText(actual_html, "html") 

227 

228 if not images: 

229 if IS_TRACE: 

230 LOG().trace( 

231 "Email has no images; building simple multipart/alternative message" 

232 ) 

233 

234 msg = multipart.MIMEMultipart("alternative") 

235 msg["Subject"] = subject 

236 msg["From"] = mail_from 

237 msg["Reply-To"] = reply_to 

238 msg["To"] = email 

239 msg.attach(text_part) 

240 msg.attach(html_part) 

241 else: 

242 if IS_TRACE: 

243 LOG().trace(f"Email has {len(images)} images to embed") 

244 

245 # multipart/related wraps HTML + inline CID images 

246 related = multipart.MIMEMultipart("related") 

247 related.attach(html_part) 

248 for img in images: 

249 mime_img = MIMEImage(img.buffer_bytes) 

250 mime_img["Content-ID"] = f"<{img.cid}>" 

251 mime_img.add_header("Content-Disposition", "inline", filename=f"{img.cid}.png") 

252 related.attach(mime_img) 

253 

254 # multipart/alternative provides text/plain fallback 

255 alternative = multipart.MIMEMultipart("alternative") 

256 alternative.attach(text_part) 

257 alternative.attach(related) 

258 

259 msg = multipart.MIMEMultipart("mixed") 

260 msg["Subject"] = subject 

261 msg["From"] = mail_from 

262 msg["Reply-To"] = reply_to 

263 msg["To"] = email 

264 msg.attach(alternative) 

265 

266 return msg 

267 

268 except Exception as e: 

269 error_msg = f"Error building email message for {email}: {e}" 

270 self._raise_error( 

271 message=error_msg, 

272 error_code=AppErrorCode.EMAIL_TEMPLATE, 

273 ex=e, 

274 ) 

275 

276 

277class ClientManager: 

278 __slots__ = ( 

279 "_client", 

280 "password", 

281 "smtp_gateway", 

282 "smtp_port", 

283 "smtp_timeout", 

284 "username", 

285 ) 

286 

287 def __init__( 

288 self, 

289 options: MailerOptions, 

290 smtp_client: smtplib.SMTP_SSL | None = None, 

291 ) -> None: 

292 self._client = smtp_client 

293 self.smtp_gateway = options.smtp_gateway 

294 self.smtp_port = options.smtp_port 

295 self.smtp_timeout = options.smtp_timeout 

296 self.username = options.smtp_username 

297 self.password = options.smtp_password 

298 

299 @property 

300 def client(self) -> smtplib.SMTP_SSL: 

301 client = self._client 

302 

303 if client is None: 

304 LOG().warning("No existing SMTP client; creating a new connection") 

305 client = self._create() 

306 else: 

307 is_connected = self._is_connected() 

308 if is_connected: 

309 LOG().debug("SMTP server connection is active") 

310 return client 

311 else: 

312 client = None # force re-creation 

313 client = self._create() 

314 msg = "SMTP server connection was closed; creating a new connection" 

315 LOG().warning(msg) 

316 

317 self._client = client 

318 return client 

319 

320 @client.setter 

321 def client(self, client: smtplib.SMTP_SSL) -> None: 

322 self._client = client 

323 

324 def _create(self) -> smtplib.SMTP_SSL: 

325 """ 

326 Establishes a new connection to the SMTP server. 

327 """ 

328 smtp_gateway = self.smtp_gateway 

329 smtp_port = self.smtp_port 

330 smtp_timeout = self.smtp_timeout 

331 username, password = self._validate_credentials() 

332 

333 msg = f"Creating SMTP connection to {smtp_gateway}:{smtp_port} with timeout {smtp_timeout}" 

334 LOG().info(msg) 

335 

336 try: 

337 ctx = ssl.create_default_context() 

338 conn = smtplib.SMTP_SSL(smtp_gateway, smtp_port, timeout=smtp_timeout, context=ctx) 

339 conn.login(username, password) 

340 

341 if IS_TRACE: 

342 LOG().trace(f"Starting TLS for SMTP connection to server {conn.local_hostname}") 

343 

344 return conn 

345 except Exception as e: 

346 msg = f"Error creating SMTP connection to {smtp_gateway}:{smtp_port} - {e}" 

347 LOG().error(msg) 

348 raise ServerError(message=msg, error_code=AppErrorCode.EMAIL_SMTP) from e 

349 

350 def _validate_credentials(self) -> tuple[str, str]: 

351 username = self.username 

352 password = self.password 

353 if not username or not password: 

354 msg = "SMTP username or password not set; cannot create SMTP connection" 

355 raise ServerError(message=msg, error_code=AppErrorCode.EMAIL) 

356 

357 return username, password 

358 

359 def _is_connected(self) -> bool: 

360 """ 

361 Checks if the SMTP connection is still open by sending a NOOP command. 

362 Returns True if connected, False otherwise. 

363 """ 

364 try: 

365 client = self._client 

366 if client is None: 

367 return False 

368 

369 status = client.noop()[0] 

370 if IS_TRACE: 

371 LOG().trace(f"SMTP NOOP command returned status {status}") 

372 

373 except smtplib.SMTPServerDisconnected: 

374 status = -1 # A non-250 status 

375 LOG().warning("SMTP server disconnected during NOOP check") 

376 except Exception as e: 

377 LOG().error(f"An unexpected error occurred while checking connection: {e}") 

378 status = -1 

379 

380 ok = status == SMTP_STATUS_OK 

381 msg = f"SMTP connection check: status={status}, connected={ok}" 

382 if IS_TRACE: 

383 LOG().trace(msg) 

384 

385 return ok