Coverage for /home/runner/work/viur-core/viur-core/viur/src/viur/core/email.py: 30%

379 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-25 15:02 +0000

1import base64 

2import datetime 

3import json 

4import logging 

5import os 

6import smtplib 

7import ssl 

8import typing as t 

9from abc import ABC, abstractmethod 

10from email import encoders 

11from email.message import EmailMessage 

12from email.mime.base import MIMEBase 

13from urllib import request 

14 

15import requests 

16from deprecated.sphinx import deprecated 

17from google.appengine.api.mail import Attachment as GAE_Attachment, SendMail as GAE_SendMail 

18 

19from viur.core import db, utils 

20from viur.core.bones.text import HtmlSerializer 

21from viur.core.config import conf 

22from viur.core.tasks import CallDeferred, DeleteEntitiesIter, PeriodicTask 

23 

24if t.TYPE_CHECKING: 24 ↛ 25line 24 didn't jump to line 25 because the condition on line 24 was never true

25 from viur.core.skeleton import SkeletonInstance 

26 

27mailjet_dependencies = True 

28try: 

29 import mailjet_rest 

30except ModuleNotFoundError: 

31 mailjet_dependencies = False 

32 

33""" 

34This module implements an email delivery system for ViUR. 

35Emails will be queued so that we don't overwhelm the email service. 

36As the App Engine does provide only an limited email api, we recommend to use 

37a 3rd party service to actually deliver the email in production. 

38 

39This module includes implementation for various services, but own 

40implementations are possible too. 

41To enable a service, assign an instance of one of the implementation to 

42:attr:`core.config.conf.email.transport_class`. 

43By default :class:`EmailTransportAppengine` is enabled. 

44 

45This module needs a custom queue (viur-emails, :attr:`EMAIL_KINDNAME`) 

46with a larger backoff value (so that we don't try to deliver the same email 

47multiple times within a short timeframe). 

48 

49A suggested configuration for your `queue.yaml` would be: 

50 

51.. code-block:: yaml 

52 

53 - name: viur-emails 

54 rate: 1/s 

55 retry_parameters: 

56 min_backoff_seconds: 3600 

57 max_backoff_seconds: 3600 

58""" 

59 

60EMAIL_KINDNAME: t.Final[str] = "viur-emails" 

61"""Kindname for the email-queue entities in datastore""" 

62 

63EMAIL_QUEUE: t.Final[str] = "viur-emails" 

64"""Name of the Cloud Tasks queue""" 

65 

66AttachmentInline = t.TypedDict("AttachmentInline", { 

67 "filename": str, 

68 "content": bytes, 

69 "mimetype": str, 

70}) 

71AttachmentViurFile = t.TypedDict("AttachmentViurFile", { 

72 "filename": str, 

73 "file_key": db.Key | str, 

74}) 

75AttachmentGscFile = t.TypedDict("AttachmentGscFile", { 

76 "filename": str, 

77 "gcsfile": db.Key | str, 

78}) 

79Attachment: t.TypeAlias = AttachmentInline | AttachmentViurFile | AttachmentGscFile 

80 

81AddressPair = t.TypedDict("AddressPair", { 

82 "email": str, 

83 "name": t.NotRequired[str], 

84}) 

85 

86 

87@PeriodicTask(interval=datetime.timedelta(days=1)) 

88def clean_old_emails_from_log(*args, **kwargs): 

89 """Periodically delete sent emails, which are older than :attr:`conf.email.log_retention` from datastore queue""" 

90 qry = ( 

91 db.Query(EMAIL_KINDNAME) 

92 .filter("isSend =", True) 

93 .filter("creationDate <", utils.utcNow() - conf.email.log_retention) 

94 ) 

95 DeleteEntitiesIter.startIterOnQuery(qry) 

96 

97 

98class EmailTransport(ABC): 

99 """Transport handler to deliver emails. 

100 

101 Implement for a specific service and set the instance to :attr:`conf.email.transport_class` 

102 """ 

103 @abstractmethod 

104 def deliver_email( 

105 self, 

106 *, 

107 sender: str, 

108 dests: list[str], 

109 cc: list[str], 

110 bcc: list[str], 

111 subject: str, 

112 body: str, 

113 headers: dict[str, str], 

114 attachments: list[Attachment], 

115 **kwargs: t.Any, 

116 ) -> t.Any: 

117 """ 

118 This method handles the actual sending of emails. 

119 

120 It must be implemented by each type. All email-addresses can be either in the form of 

121 "mm@example.com" or "Max Mustermann <mm@example.com>". If the delivery was successful, this method 

122 should return normally, if there was an error delivering the message it *must* raise an exception. 

123 

124 :param sender: The sender to be used on the outgoing email 

125 :param dests: List of recipients 

126 :param cc: List of carbon copy-recipients 

127 :param bcc: List of blind carbon copy-recipients 

128 :param subject: The subject of this email 

129 :param body: The contents of this email (may be text/plain or text/html) 

130 :param headers: Custom headers to send along with this email 

131 :param attachments: List of attachments to include in this email 

132 

133 :return: Any value that can be stored in the datastore in the queue entity as `transportFuncResult`. 

134 """ 

135 ... 

136 

137 def validate_queue_entity(self, entity: db.Entity) -> None: 

138 """ 

139 This function can be implemented to pre-validate the queue entity before it's deferred into the queue. 

140 Must raise an exception if the email cannot be send (f.e. if it contains an invalid attachment) 

141 :param entity: The entity to validate 

142 """ 

143 ... 

144 

145 def transport_successful_callback(self, entity: db.Entity): 

146 """ 

147 This callback can be implemented to execute additional tasks after an email 

148 has been successfully send. 

149 :param entity: The entity which has been sent 

150 """ 

151 ... 

152 

153 def split_address(self, address: str) -> AddressPair: 

154 """ 

155 Splits a Name/Address Pair into a dict, 

156 i.e. "Max Mustermann <mm@example.com>" into 

157 {"name": "Max Mustermann", "email": "mm@example.com"} 

158 :param address: Name/Address pair 

159 :return: split dict 

160 """ 

161 pos_lt = address.rfind("<") 

162 pos_gt = address.rfind(">") 

163 if -1 < pos_lt < pos_gt: 

164 email = address[pos_lt + 1:pos_gt] 

165 name = address.replace(f"<{email}>", "", 1).strip() 

166 return {"name": name, "email": email} 

167 else: 

168 return {"email": address} 

169 

170 def validate_attachment(self, attachment: Attachment) -> None: 

171 """Validate attachment before queueing the email""" 

172 if not isinstance(attachment, dict): 

173 raise TypeError(f"Attachment must be a dict, not {type(attachment)}") 

174 if "filename" not in attachment: 

175 raise ValueError(f"Attachment {attachment} must have a filename") 

176 if not any(prop in attachment for prop in ("content", "file_key", "gcsfile")): 

177 raise ValueError(f"Attachment {attachment} must have content, file_key or gcsfile") 

178 if "content" in attachment and not isinstance(attachment["content"], bytes): 

179 raise ValueError(f"Attachment content must be bytes, not {type(attachment['content'])}") 

180 

181 def fetch_attachment(self, attachment: Attachment) -> AttachmentInline: 

182 """Fetch attachment (if necessary) in send_email_deferred deferred task 

183 

184 This allows sending emails with large attachments, 

185 and prevents the queue entry from exceeding the maximum datastore Entity size. 

186 """ 

187 # We need a copy of the attachments to keep the content apart from the db.Entity, 

188 # which will be re-written later with the response. 

189 attachment = attachment.copy() 

190 if file_key := attachment.get("file_key"): 

191 if attachment.get("content"): 

192 raise ValueError(f'Got {file_key=} but also content in attachment {attachment.get("filename")=}') 

193 blob, content_type = conf.main_app.vi.file.read(key=file_key) 

194 attachment["content"] = blob.getvalue() 

195 attachment["mimetype"] = content_type 

196 elif gcsfile := attachment.get("gcsfile"): 

197 if attachment.get("content"): 

198 raise ValueError(f'Got {gcsfile=} but also content in attachment {attachment.get("filename")=}') 

199 blob, content_type = conf.main_app.vi.file.read(path=gcsfile) 

200 attachment["content"] = blob.getvalue() 

201 attachment["mimetype"] = content_type 

202 return attachment 

203 

204 

205@CallDeferred 

206def send_email_deferred(key: db.Key): 

207 """ 

208 Task that send an email. 

209 

210 This task is enqueued into the Cloud Tasks queue viur-email (see :attr:`EMAIL_QUEUE`) by :meth:`send_email`. 

211 Send the email by calling the implemented :meth:`EmailTransport.deliver_email` 

212 of the configures :attr:`conf.email.transport_class`. 

213 

214 :param key: Datastore key of the email to send 

215 """ 

216 logging.debug(f"Sending deferred email {key!r}") 

217 if not (queued_email := db.get(key)): 

218 raise ValueError(f"Email queue entity with {key=!r} went missing!") 

219 

220 if queued_email["isSend"]: 

221 return True 

222 

223 transport_class = conf.email.transport_class # First, ensure we're able to send email at all 

224 if not isinstance(transport_class, EmailTransport): 

225 raise ValueError(f"No or invalid email transportclass specified! ({transport_class=})") 

226 

227 try: 

228 # A datastore entity has no empty lists or dicts, these values always 

229 # become `None`. Therefore, the type must be restored here with `or []`. 

230 result_data = transport_class.deliver_email( 

231 dests=queued_email["dests"] or [], 

232 sender=queued_email["sender"], 

233 cc=queued_email["cc"] or [], 

234 bcc=queued_email["bcc"] or [], 

235 subject=queued_email["subject"], 

236 body=queued_email["body"], 

237 headers=queued_email["headers"] or {}, 

238 attachments=queued_email["attachments"] or [], 

239 ) 

240 except Exception: 

241 # Increase the errorCount and bail out 

242 queued_email["errorCount"] += 1 

243 db.put(queued_email) 

244 raise 

245 

246 # If that transportFunction did not raise an error that email has been successfully send 

247 queued_email["isSend"] = True 

248 queued_email["sendDate"] = utils.utcNow() 

249 queued_email["transportFuncResult"] = result_data 

250 queued_email.exclude_from_indexes.add("transportFuncResult") 

251 

252 db.put(queued_email) 

253 

254 try: 

255 transport_class.transport_successful_callback(queued_email) 

256 except Exception as e: 

257 logging.exception(e) 

258 

259 

260def normalize_to_list(value: None | t.Any | list[t.Any] | t.Callable[[], list]) -> list[t.Any]: 

261 """ 

262 Convert the given value to a list. 

263 

264 If the value parameter is callable, it will be called first to get the actual value. 

265 """ 

266 if callable(value): 266 ↛ 267line 266 didn't jump to line 267 because the condition on line 266 was never true

267 value = value() 

268 if value is None: 

269 return [] 

270 if isinstance(value, list): 270 ↛ 271line 270 didn't jump to line 271 because the condition on line 270 was never true

271 return value 

272 return [value] 

273 

274 

275def send_email( 

276 *, 

277 tpl: str = None, 

278 stringTemplate: str = None, 

279 skel: t.Union[None, dict, "SkeletonInstance", list["SkeletonInstance"]] = None, 

280 sender: str = None, 

281 dests: str | list[str] = None, 

282 cc: str | list[str] = None, 

283 bcc: str | list[str] = None, 

284 headers: dict[str, str] = None, 

285 attachments: list[Attachment] = None, 

286 context: db.DATASTORE_BASE_TYPES | list[db.DATASTORE_BASE_TYPES] | db.Entity = None, 

287 **kwargs, 

288) -> bool: 

289 """ 

290 General purpose function for sending email. 

291 This function allows for sending emails, also with generated content using the Jinja2 template engine. 

292 Your have to implement a method which should be called to send the prepared email finally. For this you have 

293 to allocate *viur.email.transport_class* in conf. 

294 

295 :param tpl: The name of a template from the deploy/emails directory. 

296 :param stringTemplate: This string is interpreted as the template contents. Alternative to load from template file. 

297 :param skel: The data made available to the template. In case of a Skeleton or SkelList, its parsed the usual way;\ 

298 Dictionaries are passed unchanged. 

299 :param sender: The address sending this email. 

300 :param dests: A list of addresses to send this email to. A bare string will be treated as a list with 1 address. 

301 :param cc: Carbon-copy recipients. A bare string will be treated as a list with 1 address. 

302 :param bcc: Blind carbon-copy recipients. A bare string will be treated as a list with 1 address. 

303 :param headers: Specify headers for this email. 

304 :param attachments: 

305 List of files to be sent within the email as attachments. Each attachment must be a dictionary with these keys: 

306 - filename (string): Name of the file that's attached. Always required 

307 - content (bytes): Content of the attachment as bytes. 

308 - mimetype (string): Mimetype of the file. Suggested parameter for other implementations (not used by SIB) 

309 - gcsfile (string): Path to a GCS-File to include instead of content. 

310 - file_key (string): Key of a FileSkeleton to include instead of content. 

311 

312 :param context: Arbitrary data that can be stored along the queue entry to be evaluated in 

313 transport_successful_callback (useful for tracking delivery / opening events etc). 

314 

315 .. warning:: 

316 As emails will be queued (and not send directly) you cannot exceed 1MB in total 

317 (for all text and attachments combined)! 

318 """ 

319 # First, ensure we're able to send email at all 

320 transport_class = conf.email.transport_class # First, ensure we're able to send email at all 

321 if not isinstance(transport_class, EmailTransport): 321 ↛ 322line 321 didn't jump to line 322 because the condition on line 321 was never true

322 raise ValueError( 

323 f"No or invalid email transport class specified! ({transport_class=}). " 

324 "In ViUR-core >= 3.7 the transport_class must be an instanced object, so maybe it's " 

325 f"`conf.email.transport_class = {transport_class.__name__}()` which must be assigned." 

326 ) 

327 

328 # Ensure that all recipient parameters (dest, cc, bcc) are a list 

329 dests = normalize_to_list(dests) 

330 cc = normalize_to_list(cc) 

331 bcc = normalize_to_list(bcc) 

332 

333 assert dests or cc or bcc, "No destination address given" 

334 assert all(isinstance(x, str) and x for x in dests), "Found non-string or empty destination address" 

335 assert all(isinstance(x, str) and x for x in cc), "Found non-string or empty cc address" 

336 assert all(isinstance(x, str) and x for x in bcc), "Found non-string or empty bcc address" 

337 

338 if not (bool(stringTemplate) ^ bool(tpl)): 338 ↛ 339line 338 didn't jump to line 339 because the condition on line 338 was never true

339 raise ValueError("You have to set the params 'tpl' xor a 'stringTemplate'.") 

340 

341 if attachments := normalize_to_list(attachments): 341 ↛ 344line 341 didn't jump to line 344 because the condition on line 341 was never true

342 # Ensure each attachment has the filename key and rewrite each dict to db.Entity so we can exclude 

343 # it from being indexed 

344 for _ in range(0, len(attachments)): 

345 attachment = attachments.pop(0) 

346 transport_class.validate_attachment(attachment) 

347 

348 if "mimetype" not in attachment: 

349 attachment["mimetype"] = "application/octet-stream" 

350 

351 entity = db.Entity() 

352 for k, v in attachment.items(): 

353 entity[k] = v 

354 entity.exclude_from_indexes.add(k) 

355 

356 attachments.append(entity) 

357 

358 # If conf.email.recipient_override is set we'll redirect any email to these address(es) 

359 if conf.email.recipient_override: 359 ↛ 360line 359 didn't jump to line 360 because the condition on line 359 was never true

360 logging.warning(f"Overriding destination {dests!r} with {conf.email.recipient_override!r}") 

361 old_dests = dests 

362 new_dests = normalize_to_list(conf.email.recipient_override) 

363 dests = [] 

364 for new_dest in new_dests: 

365 if new_dest.startswith("@"): 

366 for old_dest in old_dests: 

367 dests.append(old_dest.replace(".", "_dot_").replace("@", "_at_") + new_dest) 

368 else: 

369 dests.append(new_dest) 

370 cc = bcc = [] 

371 

372 elif conf.email.recipient_override is False: 372 ↛ 373line 372 didn't jump to line 373 because the condition on line 372 was never true

373 logging.warning("Sending emails disabled by config[viur.email.recipientOverride]") 

374 return False 

375 

376 if conf.email.sender_override: 376 ↛ 377line 376 didn't jump to line 377 because the condition on line 376 was never true

377 sender = conf.email.sender_override 

378 elif sender is None: 378 ↛ 381line 378 didn't jump to line 381 because the condition on line 378 was always true

379 sender = conf.email.sender_default 

380 

381 subject, body = conf.emailRenderer(dests, tpl, stringTemplate, skel, **kwargs) 

382 

383 # Push that email to the outgoing queue 

384 queued_email = db.Entity(db.Key(EMAIL_KINDNAME)) 

385 

386 queued_email["isSend"] = False 

387 queued_email["errorCount"] = 0 

388 queued_email["creationDate"] = utils.utcNow() 

389 queued_email["sender"] = sender 

390 queued_email["dests"] = dests 

391 queued_email["cc"] = cc 

392 queued_email["bcc"] = bcc 

393 queued_email["subject"] = subject 

394 queued_email["body"] = body 

395 queued_email["headers"] = headers 

396 queued_email["attachments"] = attachments 

397 queued_email["context"] = context 

398 queued_email.exclude_from_indexes = {"body", "attachments", "context"} 

399 

400 transport_class.validate_queue_entity(queued_email) # Will raise an exception if the entity is not valid 

401 

402 if conf.instance.is_dev_server: 402 ↛ 411line 402 didn't jump to line 411 because the condition on line 402 was always true

403 if (not conf.email.send_from_local_development_server 

404 or isinstance(transport_class, EmailTransportAppengine)): 

405 logging.info("Not sending email from local development server") 

406 logging.info(f"""Subject: {queued_email["subject"]}""") 

407 logging.info(f"""Body: {queued_email["body"]}""") 

408 logging.info(f"""Recipients: {queued_email["dests"]}""") 

409 return False 

410 

411 db.put(queued_email) 

412 send_email_deferred(queued_email.key, _queue=EMAIL_QUEUE) 

413 return True 

414 

415 

416@deprecated(version="3.7.0", reason="Use send_email instead") 

417def sendEMail(*args, **kwargs): 

418 return send_email(*args, **kwargs) 

419 

420 

421def send_email_to_admins(subject: str, body: str, *args, **kwargs) -> bool: 

422 """ 

423 Sends an email to the root users of the current app. 

424 

425 If :attr:`conf.email.admin_recipients` is set, these recipients 

426 will be used instead of the root users. 

427 

428 :param subject: Defines the subject of the message. 

429 :param body: Defines the message body. 

430 """ 

431 success = False 

432 try: 

433 users = [] 

434 if conf.email.admin_recipients is not None: 

435 users = normalize_to_list(conf.email.admin_recipients) 

436 elif "user" in dir(conf.main_app.vi): 

437 for user_skel in conf.main_app.vi.user.viewSkel().all().filter("access =", "root").fetch(): 

438 users.append(user_skel["name"]) 

439 

440 # Prefix the instance's project_id to subject 

441 subject = f"{conf.instance.project_id}: {subject}" 

442 

443 if users: 

444 ret = send_email(dests=users, stringTemplate=os.linesep.join((subject, body)), *args, **kwargs) 

445 success = True 

446 return ret 

447 else: 

448 logging.warning("There are no recipients for admin emails available.") 

449 

450 finally: 

451 if not success: 

452 logging.critical("Cannot send email to admins.") 

453 logging.debug(f"{subject = }, {body = }") 

454 

455 return False 

456 

457 

458@deprecated(version="3.7.0", reason="Use send_email_to_admins instead") 

459def sendEMailToAdmins(*args, **kwargs): 

460 return send_email_to_admins(*args, **kwargs) 

461 

462 

463class EmailTransportBrevo(EmailTransport): 

464 """Send emails with `Brevo`_, formerly Sendinblue. 

465 

466 .. _Brevo: https://www.brevo.com 

467 """ 

468 

469 allowed_extensions = {"gif", "png", "bmp", "cgm", "jpg", "jpeg", "tif", 

470 "tiff", "rtf", "txt", "css", "shtml", "html", "htm", 

471 "csv", "zip", "pdf", "xml", "doc", "docx", "ics", 

472 "xls", "xlsx", "ppt", "tar", "ez"} 

473 """List of allowed file extensions that can be send from Brevo""" 

474 

475 def __init__( 

476 self, 

477 *, 

478 api_key: str, 

479 thresholds: tuple[int] | list[int] = (1000, 500, 100), 

480 ) -> None: 

481 """ 

482 :param api_key: API key 

483 :param thresholds: Warning thresholds for remaining email quota. 

484 """ 

485 super().__init__() 

486 self.api_key = api_key 

487 self.thresholds = thresholds 

488 

489 def deliver_email( 

490 self, 

491 *, 

492 sender: str, 

493 dests: list[str], 

494 cc: list[str], 

495 bcc: list[str], 

496 subject: str, 

497 body: str, 

498 headers: dict[str, str], 

499 attachments: list[Attachment], 

500 **kwargs: t.Any, 

501 ) -> str: 

502 """ 

503 Internal function for delivering emails using Brevo. 

504 """ 

505 dataDict = { 

506 "sender": self.split_address(sender), 

507 "to": [], 

508 "htmlContent": body, 

509 "subject": subject, 

510 } 

511 for dest in dests: 

512 dataDict["to"].append(self.split_address(dest)) 

513 # initialize bcc and cc lists in dataDict 

514 if bcc: 

515 dataDict["bcc"] = [] 

516 for dest in bcc: 

517 dataDict["bcc"].append(self.split_address(dest)) 

518 if cc: 

519 dataDict["cc"] = [] 

520 for dest in cc: 

521 dataDict["cc"].append(self.split_address(dest)) 

522 if headers: 

523 if "Reply-To" in headers: 

524 dataDict["replyTo"] = self.split_address(headers["Reply-To"]) 

525 del headers["Reply-To"] 

526 if headers: 

527 dataDict["headers"] = headers 

528 if attachments: 

529 dataDict["attachment"] = [] 

530 for attachment in attachments: 

531 attachment = self.fetch_attachment(attachment) 

532 dataDict["attachment"].append({ 

533 "name": attachment["filename"], 

534 "content": base64.b64encode(attachment["content"]).decode("ASCII") 

535 }) 

536 payload = json.dumps(dataDict).encode("UTF-8") 

537 headers = { 

538 "api-key": self.api_key, 

539 "Content-Type": "application/json; charset=utf-8" 

540 } 

541 reqObj = request.Request(url="https://api.brevo.com/v3/smtp/email", 

542 data=payload, headers=headers, method="POST") 

543 try: 

544 response = request.urlopen(reqObj) 

545 except request.HTTPError as e: 

546 logging.error("Sending email failed!") 

547 logging.error(dataDict) 

548 logging.error(e.read()) 

549 raise 

550 assert str(response.code)[0] == "2", "Received a non 2XX Status Code!" 

551 return response.read().decode("UTF-8") 

552 

553 def validate_queue_entity(self, entity: db.Entity) -> None: 

554 """ 

555 Validate the attachments (if any) against the list of supported file extensions by Brevo. 

556 

557 :raises ValueError: If the attachment was not allowed 

558 

559 .. seealso:: :attr:`allowed_extensions` 

560 """ 

561 for attachment in entity.get("attachments") or []: 

562 ext = attachment["filename"].split(".")[-1].lower() 

563 if ext not in self.allowed_extensions: 

564 raise ValueError(f"The file-extension {ext} cannot be send using Brevo") 

565 

566 @PeriodicTask(interval=datetime.timedelta(hours=1)) 

567 @staticmethod 

568 def check_sib_quota() -> None: 

569 """Periodically checks the remaining Brevo email quota. 

570 

571 This task does not have to be enabled. 

572 It automatically checks if the apiKey is configured. 

573 

574 There are three default thresholds: 1000, 500, 100 

575 Others can be set via :attr:`thresholds`. 

576 An email will be sent for the lowest threshold that has been undercut. 

577 

578 .. seealso:: https://developers.brevo.com/reference/getaccount 

579 """ 

580 if not isinstance(conf.email.transport_class, EmailTransportBrevo): 

581 return # no SIB key, we cannot check 

582 

583 req = requests.get( 

584 "https://api.brevo.com/v3/account", 

585 headers={"api-key": conf.email.transport_class.api_key}, 

586 ) 

587 if not req.ok: 587 ↛ 590line 587 didn't jump to line 590 because the condition on line 587 was always true

588 logging.error("Failed to fetch SIB account information") 

589 return 

590 data = req.json() 

591 logging.debug(f"SIB account data: {data}") 

592 for plan in data["plan"]: 

593 if plan["type"] == "payAsYouGo": 

594 credits = plan["credits"] 

595 break 

596 else: 

597 credits = -1 

598 logging.info(f"Brevo email credits: {credits}") 

599 

600 # Keep track of the last credits and the limit for which a email has 

601 # already been sent. This way, emails for the same limit will not be 

602 # sent more than once and the remaining email credits will not be wasted. 

603 key = db.Key("viur-email-conf", "sib-credits") 

604 if not (entity := db.get(key)): 

605 logging.debug(f"{entity = }") 

606 entity = db.Entity(key) 

607 logging.debug(f"{entity = }") 

608 logging.debug(f"{entity = }") 

609 entity.setdefault("latest_warning_for", None) 

610 entity["credits"] = credits 

611 entity["email"] = data["email"] 

612 

613 thresholds = sorted(conf.email.transport_class.thresholds, reverse=True) 

614 for idx, limit in list(enumerate(thresholds, 1))[::-1]: 

615 if credits < limit: 

616 if entity["latest_warning_for"] == limit: 

617 logging.info(f"Already send an email for {limit = }.") 

618 break 

619 

620 send_email_to_admins( 

621 f"SendInBlue email budget {credits} ({idx}. warning)", 

622 f"The SendInBlue email budget reached {credits} credits " 

623 f"for {data['email']}. Please increase soon.", 

624 ) 

625 entity["latest_warning_for"] = limit 

626 break 

627 else: 

628 # Credits are above all limits 

629 entity["latest_warning_for"] = None 

630 

631 db.put(entity) 

632 

633 

634@deprecated(version="3.7.0", reason="Sendinblue is now Brevo; Use EmailTransportBrevo instead") 

635class EmailTransportSendInBlue(EmailTransportBrevo): 

636 ... 

637 

638 

639if mailjet_dependencies: 639 ↛ 640line 639 didn't jump to line 640 because the condition on line 639 was never true

640 class EmailTransportMailjet(EmailTransport): 

641 """Send emails with `Mailjet`_. 

642 

643 .. _Mailjet: https://www.mailjet.com/products/email-api/ 

644 """ 

645 

646 def __init__( 

647 self, 

648 *, 

649 api_key: str, 

650 secret_key: str, 

651 ) -> None: 

652 super().__init__() 

653 self.api_key = api_key 

654 self.secret_key = secret_key 

655 

656 def deliver_email( 

657 self, 

658 *, 

659 sender: str, 

660 dests: list[str], 

661 cc: list[str], 

662 bcc: list[str], 

663 subject: str, 

664 body: str, 

665 headers: dict[str, str], 

666 attachments: list[Attachment], 

667 **kwargs: t.Any, 

668 ) -> str: 

669 if not (self.api_key and self.secret_key): 

670 raise RuntimeError("Mailjet config invalid, check 'api_key' and 'secret_key'") 

671 

672 email = { 

673 "from": self.split_address(sender), 

674 "htmlpart": body, 

675 "subject": subject, 

676 "to": [self.split_address(dest) for dest in dests], 

677 } 

678 

679 if bcc: 

680 email["bcc"] = [self.split_address(b) for b in bcc] 

681 

682 if cc: 

683 email["cc"] = [self.split_address(c) for c in cc] 

684 

685 if headers: 

686 email["headers"] = headers 

687 

688 if attachments: 

689 email["attachments"] = [] 

690 

691 for attachment in attachments: 

692 attachment = self.fetch_attachment(attachment) 

693 email["attachments"].append({ 

694 "filename": attachment["filename"], 

695 "base64content": base64.b64encode(attachment["content"]).decode("ASCII"), 

696 "contenttype": attachment["mimetype"] 

697 }) 

698 

699 mj_client = mailjet_rest.Client( 

700 auth=(self.api_key, self.secret_key), 

701 version="v3.1", 

702 ) 

703 

704 result = mj_client.send.create(data={"messages": [email]}) 

705 assert 200 <= result.status_code < 300, f"Received {result.status_code=} {result.reason=}" 

706 return result.content.decode("UTF-8") 

707 

708 

709class EmailTransportSendgrid(EmailTransport): 

710 """Send emails with `SendGrid`_. 

711 

712 .. _SendGrid: https://sendgrid.com/en-us/solutions/email-api 

713 """ 

714 

715 def __init__( 

716 self, 

717 *, 

718 api_key: str, 

719 ) -> None: 

720 super().__init__() 

721 self.api_key = api_key 

722 

723 def deliver_email( 

724 self, 

725 *, 

726 sender: str, 

727 dests: list[str], 

728 cc: list[str], 

729 bcc: list[str], 

730 subject: str, 

731 body: str, 

732 headers: dict[str, str], 

733 attachments: list[Attachment], 

734 **kwargs: t.Any, 

735 ) -> dict[str, str]: 

736 data = { 

737 "personalizations": [ 

738 personalization := { 

739 "to": [self.split_address(val) for val in dests], 

740 "subject": subject, 

741 } 

742 ], 

743 "from": self.split_address(sender), 

744 "content": [{ 

745 "type": "text/html", 

746 "value": body, 

747 }], 

748 "tracking_settings": { # TODO: make the settings configurable 

749 "click_tracking": { 

750 "enable": False, 

751 } 

752 }, 

753 } 

754 

755 if cc: 

756 personalization["cc"] = [self.split_address(val) for val in cc] 

757 if bcc: 

758 personalization["bcc"] = [self.split_address(val) for val in bcc] 

759 

760 if attachments: 

761 assert isinstance(attachments, list) 

762 data["attachments"] = [ 

763 { 

764 "filename": attachment["filename"], 

765 "content": base64.b64encode(attachment["content"]).decode(), 

766 "type": attachment["mimetype"], 

767 "disposition": "attachment", 

768 } 

769 for attachment in map(self.fetch_attachment, attachments) 

770 ] 

771 

772 if headers: 

773 assert isinstance(headers, dict) 

774 data["headers"] = headers 

775 

776 req = requests.post( 

777 "https://api.sendgrid.com/v3/mail/send", 

778 headers={ 

779 "Authorization": f"Bearer {self.api_key}", 

780 "Accept": "application/json" 

781 }, 

782 json=data, 

783 ) 

784 if not req.ok: 

785 raise ValueError(f"{req.status_code} {req.reason} {req.json()}", req) 

786 return {k: v for k, v in req.headers.items() if k.startswith("X-")} # X-Message-Id and maybe more in future 

787 

788 

789class EmailTransportSmtp(EmailTransport): 

790 """ 

791 Send emails using the Simple Mail Transfer Protocol (SMTP). 

792 

793 Needs an email server. 

794 """ 

795 

796 def __init__( 

797 self, 

798 *, 

799 host: str, 

800 port: int = smtplib.SMTP_SSL_PORT, 

801 user: str, 

802 password: str, 

803 ) -> None: 

804 super().__init__() 

805 self.host = host 

806 self.port = port 

807 self.user = user 

808 self.password = password 

809 self.context = ssl.create_default_context() 

810 

811 def deliver_email( 

812 self, 

813 *, 

814 sender: str, 

815 dests: list[str], 

816 cc: list[str], 

817 bcc: list[str], 

818 subject: str, 

819 body: str, 

820 headers: dict[str, str], 

821 attachments: list[Attachment], 

822 **kwargs: t.Any, 

823 ) -> dict[str, tuple[int, bytes]]: 

824 message = EmailMessage() 

825 message["Subject"] = subject 

826 message["From"] = sender 

827 message["To"] = ", ".join(dests) 

828 message["Cc"] = ", ".join(cc) 

829 message["Bcc"] = ", ".join(bcc) 

830 for key, value in headers.items(): 

831 message.add_header(key, value) 

832 

833 message.set_content(body, subtype="html") 

834 message.add_alternative(HtmlSerializer().sanitize(body), subtype="text") 

835 

836 for attachment in attachments: 

837 attachment = self.fetch_attachment(attachment) 

838 part = MIMEBase(*attachment["mimetype"].split("/", 1)) 

839 part.set_payload(attachment["content"]) 

840 encoders.encode_base64(part) 

841 part.add_header( 

842 "Content-Disposition", 

843 f'attachment; filename="{attachment["filename"]}"', 

844 ) 

845 message.add_alternative(part) 

846 

847 with smtplib.SMTP_SSL(self.host, self.port, context=self.context) as server: 

848 server.login(self.user, self.password) 

849 return server.sendmail(sender, (dests + cc + bcc), message.as_string()) 

850 

851 

852class EmailTransportAppengine(EmailTransport): 

853 """ 

854 Abstraction of the Google AppEngine Mail API for email transportation. 

855 

856 .. warning: Works only in a deployed Google Cloud environment. 

857 

858 .. seealso:: https://cloud.google.com/appengine/docs/standard/python3/services/mail 

859 """ 

860 

861 def deliver_email( 

862 self, 

863 *, 

864 sender: str, 

865 dests: list[str], 

866 cc: list[str], 

867 bcc: list[str], 

868 subject: str, 

869 body: str, 

870 headers: dict[str, str], 

871 attachments: list[Attachment], 

872 **kwargs: t.Any, 

873 ) -> None: 

874 # need to build a silly dict because the google.appengine mail api doesn't accept None or empty values ... 

875 params = { 

876 "to": [self.split_address(dest)["email"] for dest in dests], 

877 "sender": sender, 

878 "subject": subject, 

879 "body": HtmlSerializer().sanitize(body), 

880 "html": body, 

881 } 

882 

883 if cc: 

884 params["cc"] = [self.split_address(c)["email"] for c in cc] 

885 

886 if bcc: 

887 params["bcc"] = [self.split_address(c)["email"] for c in bcc] 

888 

889 if attachments: 

890 params["attachments"] = [] 

891 

892 for attachment in attachments: 

893 attachment = self.fetch_attachment(attachment) 

894 params["attachments"].append( 

895 GAE_Attachment(attachment["filename"], attachment["content"]) 

896 ) 

897 

898 GAE_SendMail(**params) 

899 

900 

901# Set (limited, but free) Google AppEngine Mail API as default 

902if conf.email.transport_class is None: 902 ↛ exitline 902 didn't exit the module because the condition on line 902 was always true

903 conf.email.transport_class = EmailTransportAppengine()