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

387 statements  

« prev     ^ index     » next       coverage.py v7.6.12, created at 2025-02-27 07:59 +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: 

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 max_retries = 3 

104 """maximum number of attempts to send a email.""" 

105 

106 @abstractmethod 

107 def deliver_email( 

108 self, 

109 *, 

110 sender: str, 

111 dests: list[str], 

112 cc: list[str], 

113 bcc: list[str], 

114 subject: str, 

115 body: str, 

116 headers: dict[str, str], 

117 attachments: list[Attachment], 

118 **kwargs: t.Any, 

119 ) -> t.Any: 

120 """ 

121 This method handles the actual sending of emails. 

122 

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

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

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

126 

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

128 :param dests: List of recipients 

129 :param cc: List of carbon copy-recipients 

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

131 :param subject: The subject of this email 

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

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

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

135 

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

137 """ 

138 ... 

139 

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

141 """ 

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

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

144 :param entity: The entity to validate 

145 """ 

146 ... 

147 

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

149 """ 

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

151 has been successfully send. 

152 :param entity: The entity which has been sent 

153 """ 

154 ... 

155 

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

157 """ 

158 Splits a Name/Address Pair into a dict, 

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

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

161 :param address: Name/Address pair 

162 :return: split dict 

163 """ 

164 pos_lt = address.rfind("<") 

165 pos_gt = address.rfind(">") 

166 if -1 < pos_lt < pos_gt: 

167 email = address[pos_lt + 1:pos_gt] 

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

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

170 else: 

171 return {"email": address} 

172 

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

174 """Validate attachment before queueing the email""" 

175 if not isinstance(attachment, dict): 

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

177 if "filename" not in attachment: 

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

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

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

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

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

183 

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

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

186 

187 This allows sending emails with large attachments, 

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

189 """ 

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

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

192 attachment = attachment.copy() 

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

194 if attachment.get("content"): 

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

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

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

198 attachment["mimetype"] = content_type 

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

200 if attachment.get("content"): 

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

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

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

204 attachment["mimetype"] = content_type 

205 return attachment 

206 

207 

208@CallDeferred 

209def send_email_deferred(key: db.Key): 

210 """ 

211 Task that send an email. 

212 

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

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

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

216 

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

218 """ 

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

220 if not (queued_email := db.Get(key)): 

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

222 

223 if queued_email["isSend"]: 

224 return True 

225 

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

227 if not isinstance(transport_class, EmailTransport): 

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

229 

230 if queued_email["errorCount"] > transport_class.max_retries: 

231 raise ChildProcessError("Error-Count exceeded") 

232 

233 try: 

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

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

236 result_data = transport_class.deliver_email( 

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

238 sender=queued_email["sender"], 

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

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

241 subject=queued_email["subject"], 

242 body=queued_email["body"], 

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

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

245 ) 

246 except Exception: 

247 # Increase the errorCount and bail out 

248 queued_email["errorCount"] += 1 

249 db.Put(queued_email) 

250 raise 

251 

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

253 queued_email["isSend"] = True 

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

255 queued_email["transportFuncResult"] = result_data 

256 queued_email.exclude_from_indexes.add("transportFuncResult") 

257 

258 db.Put(queued_email) 

259 

260 try: 

261 transport_class.transport_successful_callback(queued_email) 

262 except Exception as e: 

263 logging.exception(e) 

264 

265 

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

267 """ 

268 Convert the given value to a list. 

269 

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

271 """ 

272 if callable(value): 

273 value = value() 

274 if value is None: 

275 return [] 

276 if isinstance(value, list): 

277 return value 

278 return [value] 

279 

280 

281def send_email( 

282 *, 

283 tpl: str = None, 

284 stringTemplate: str = None, 

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

286 sender: str = None, 

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

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

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

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

291 attachments: list[Attachment] = None, 

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

293 **kwargs, 

294) -> bool: 

295 """ 

296 General purpose function for sending email. 

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

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

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

300 

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

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

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

304 Dictionaries are passed unchanged. 

305 :param sender: The address sending this email. 

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

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

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

309 :param headers: Specify headers for this email. 

310 :param attachments: 

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

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

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

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

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

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

317 

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

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

320 

321 .. warning:: 

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

323 (for all text and attachments combined)! 

324 """ 

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

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

327 if not isinstance(transport_class, EmailTransport): 

328 raise ValueError( 

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

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

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

332 ) 

333 

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

335 dests = normalize_to_list(dests) 

336 cc = normalize_to_list(cc) 

337 bcc = normalize_to_list(bcc) 

338 

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

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

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

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

343 

344 if not (bool(stringTemplate) ^ bool(tpl)): 

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

346 

347 if attachments := normalize_to_list(attachments): 

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

349 # it from being indexed 

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

351 attachment = attachments.pop(0) 

352 transport_class.validate_attachment(attachment) 

353 

354 if "mimetype" not in attachment: 

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

356 

357 entity = db.Entity() 

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

359 entity[k] = v 

360 entity.exclude_from_indexes.add(k) 

361 

362 attachments.append(entity) 

363 

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

365 if conf.email.recipient_override: 

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

367 old_dests = dests 

368 new_dests = normalize_to_list(conf.email.recipient_override) 

369 dests = [] 

370 for new_dest in new_dests: 

371 if new_dest.startswith("@"): 

372 for old_dest in old_dests: 

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

374 else: 

375 dests.append(new_dest) 

376 cc = bcc = [] 

377 

378 elif conf.email.recipient_override is False: 

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

380 return False 

381 

382 if conf.email.sender_override: 

383 sender = conf.email.sender_override 

384 elif sender is None: 

385 sender = conf.email.sender_default 

386 

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

388 

389 # Push that email to the outgoing queue 

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

391 

392 queued_email["isSend"] = False 

393 queued_email["errorCount"] = 0 

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

395 queued_email["sender"] = sender 

396 queued_email["dests"] = dests 

397 queued_email["cc"] = cc 

398 queued_email["bcc"] = bcc 

399 queued_email["subject"] = subject 

400 queued_email["body"] = body 

401 queued_email["headers"] = headers 

402 queued_email["attachments"] = attachments 

403 queued_email["context"] = context 

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

405 

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

407 

408 if conf.instance.is_dev_server: 

409 if not conf.email.send_from_local_development_server or transport_class is EmailTransportAppengine: 

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

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

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

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

414 return False 

415 

416 db.Put(queued_email) 

417 send_email_deferred(queued_email.key, _queue=EMAIL_QUEUE) 

418 return True 

419 

420 

421@deprecated(version="3.7.0", reason="Use send_email instead", action="always") 

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

423 return send_email(*args, **kwargs) 

424 

425 

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

427 """ 

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

429 

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

431 will be used instead of the root users. 

432 

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

434 :param body: Defines the message body. 

435 """ 

436 success = False 

437 try: 

438 users = [] 

439 if conf.email.admin_recipients is not None: 

440 users = normalize_to_list(conf.email.admin_recipients) 

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

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

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

444 

445 # Prefix the instance's project_id to subject 

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

447 

448 if users: 

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

450 success = True 

451 return ret 

452 else: 

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

454 

455 finally: 

456 if not success: 

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

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

459 

460 return False 

461 

462 

463@deprecated(version="3.7.0", reason="Use send_email_to_admins instead", action="always") 

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

465 return send_email_to_admins(*args, **kwargs) 

466 

467 

468class EmailTransportBrevo(EmailTransport): 

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

470 

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

472 """ 

473 

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

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

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

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

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

479 

480 def __init__( 

481 self, 

482 *, 

483 api_key: str, 

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

485 ) -> None: 

486 """ 

487 :param api_key: API key 

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

489 """ 

490 super().__init__() 

491 self.api_key = api_key 

492 self.thresholds = thresholds 

493 

494 def deliver_email( 

495 self, 

496 *, 

497 sender: str, 

498 dests: list[str], 

499 cc: list[str], 

500 bcc: list[str], 

501 subject: str, 

502 body: str, 

503 headers: dict[str, str], 

504 attachments: list[Attachment], 

505 **kwargs: t.Any, 

506 ) -> str: 

507 """ 

508 Internal function for delivering emails using Brevo. 

509 """ 

510 dataDict = { 

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

512 "to": [], 

513 "htmlContent": body, 

514 "subject": subject, 

515 } 

516 for dest in dests: 

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

518 # initialize bcc and cc lists in dataDict 

519 if bcc: 

520 dataDict["bcc"] = [] 

521 for dest in bcc: 

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

523 if cc: 

524 dataDict["cc"] = [] 

525 for dest in cc: 

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

527 if headers: 

528 if "Reply-To" in headers: 

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

530 del headers["Reply-To"] 

531 if headers: 

532 dataDict["headers"] = headers 

533 if attachments: 

534 dataDict["attachment"] = [] 

535 for attachment in attachments: 

536 attachment = self.fetch_attachment(attachment) 

537 dataDict["attachment"].append({ 

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

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

540 }) 

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

542 headers = { 

543 "api-key": self.api_key, 

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

545 } 

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

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

548 try: 

549 response = request.urlopen(reqObj) 

550 except request.HTTPError as e: 

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

552 logging.error(dataDict) 

553 logging.error(e.read()) 

554 raise 

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

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

557 

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

559 """ 

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

561 

562 :raises ValueError: If the attachment was not allowed 

563 

564 .. seealso:: :attr:`allowed_extensions` 

565 """ 

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

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

568 if ext not in self.allowed_extensions: 

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

570 

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

572 @staticmethod 

573 def check_sib_quota() -> None: 

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

575 

576 This task does not have to be enabled. 

577 It automatically checks if the apiKey is configured. 

578 

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

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

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

582 

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

584 """ 

585 if not isinstance(conf.email.transport_class, EmailTransportSendInBlue): 

586 return # no SIB key, we cannot check 

587 

588 req = requests.get( 

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

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

591 ) 

592 if not req.ok: 

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

594 return 

595 data = req.json() 

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

597 for plan in data["plan"]: 

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

599 credits = plan["credits"] 

600 break 

601 else: 

602 credits = -1 

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

604 

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

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

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

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

609 if not (entity := db.Get(key)): 

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

611 entity = db.Entity(key) 

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

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

614 entity.setdefault("latest_warning_for", None) 

615 entity["credits"] = credits 

616 entity["email"] = data["email"] 

617 

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

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

620 if credits < limit: 

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

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

623 break 

624 

625 send_email_to_admins( 

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

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

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

629 ) 

630 entity["latest_warning_for"] = limit 

631 break 

632 else: 

633 # Credits are above all limits 

634 entity["latest_warning_for"] = None 

635 

636 db.Put(entity) 

637 

638 

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

640class EmailTransportSendInBlue(EmailTransportBrevo): 

641 ... 

642 

643 

644if mailjet_dependencies: 

645 class EmailTransportMailjet(EmailTransport): 

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

647 

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

649 """ 

650 

651 def __init__( 

652 self, 

653 *, 

654 api_key: str, 

655 secret_key: str, 

656 ) -> None: 

657 super().__init__() 

658 self.api_key = api_key 

659 self.secret_key = secret_key 

660 

661 def deliver_email( 

662 self, 

663 *, 

664 sender: str, 

665 dests: list[str], 

666 cc: list[str], 

667 bcc: list[str], 

668 subject: str, 

669 body: str, 

670 headers: dict[str, str], 

671 attachments: list[Attachment], 

672 **kwargs: t.Any, 

673 ) -> str: 

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

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

676 

677 email = { 

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

679 "htmlpart": body, 

680 "subject": subject, 

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

682 } 

683 

684 if bcc: 

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

686 

687 if cc: 

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

689 

690 if headers: 

691 email["headers"] = headers 

692 

693 if attachments: 

694 email["attachments"] = [] 

695 

696 for attachment in attachments: 

697 attachment = self.fetch_attachment(attachment) 

698 email["attachments"].append({ 

699 "filename": attachment["filename"], 

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

701 "contenttype": attachment["mimetype"] 

702 }) 

703 

704 mj_client = mailjet_rest.Client( 

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

706 version="v3.1", 

707 ) 

708 

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

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

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

712 

713 

714class EmailTransportSendgrid(EmailTransport): 

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

716 

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

718 """ 

719 

720 def __init__( 

721 self, 

722 *, 

723 api_key: str, 

724 ) -> None: 

725 super().__init__() 

726 self.api_key = api_key 

727 

728 def deliver_email( 

729 self, 

730 *, 

731 sender: str, 

732 dests: list[str], 

733 cc: list[str], 

734 bcc: list[str], 

735 subject: str, 

736 body: str, 

737 headers: dict[str, str], 

738 attachments: list[Attachment], 

739 **kwargs: t.Any, 

740 ) -> dict[str, str]: 

741 data = { 

742 "personalizations": [ 

743 personalization := { 

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

745 "subject": subject, 

746 } 

747 ], 

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

749 "content": [{ 

750 "type": "text/html", 

751 "value": body, 

752 }], 

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

754 "click_tracking": { 

755 "enable": False, 

756 } 

757 }, 

758 } 

759 

760 if cc: 

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

762 if bcc: 

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

764 

765 if attachments: 

766 assert isinstance(attachments, list) 

767 data["attachments"] = [ 

768 { 

769 "filename": attachment["filename"], 

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

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

772 "disposition": "attachment", 

773 } 

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

775 ] 

776 

777 if headers: 

778 assert isinstance(headers, dict) 

779 data["headers"] = headers 

780 

781 req = requests.post( 

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

783 headers={ 

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

785 "Accept": "application/json" 

786 }, 

787 json=data, 

788 ) 

789 if not req.ok: 

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

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

792 

793 

794class EmailTransportSmtp(EmailTransport): 

795 """ 

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

797 

798 Needs an email server. 

799 """ 

800 

801 def __init__( 

802 self, 

803 *, 

804 host: str, 

805 port: int = smtplib.SMTP_SSL_PORT, 

806 user: str, 

807 password: str, 

808 ) -> None: 

809 super().__init__() 

810 self.host = host 

811 self.port = port 

812 self.user = user 

813 self.password = password 

814 self.context = ssl.create_default_context() 

815 

816 def deliver_email( 

817 self, 

818 *, 

819 sender: str, 

820 dests: list[str], 

821 cc: list[str], 

822 bcc: list[str], 

823 subject: str, 

824 body: str, 

825 headers: dict[str, str], 

826 attachments: list[Attachment], 

827 **kwargs: t.Any, 

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

829 message = EmailMessage() 

830 message["Subject"] = subject 

831 message["From"] = sender 

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

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

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

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

836 message.add_header(key, value) 

837 

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

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

840 

841 for attachment in attachments: 

842 attachment = self.fetch_attachment(attachment) 

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

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

845 encoders.encode_base64(part) 

846 part.add_header( 

847 "Content-Disposition", 

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

849 ) 

850 message.add_alternative(part) 

851 

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

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

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

855 

856 

857class EmailTransportAppengine(EmailTransport): 

858 """ 

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

860 

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

862 

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

864 """ 

865 

866 def deliver_email( 

867 self, 

868 *, 

869 sender: str, 

870 dests: list[str], 

871 cc: list[str], 

872 bcc: list[str], 

873 subject: str, 

874 body: str, 

875 headers: dict[str, str], 

876 attachments: list[Attachment], 

877 **kwargs: t.Any, 

878 ) -> None: 

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

880 params = { 

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

882 "sender": sender, 

883 "subject": subject, 

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

885 "html": body, 

886 } 

887 

888 if cc: 

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

890 

891 if bcc: 

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

893 

894 if attachments: 

895 params["attachments"] = [] 

896 

897 for attachment in attachments: 

898 attachment = self.fetch_attachment(attachment) 

899 params["attachments"].append( 

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

901 ) 

902 

903 GAE_SendMail(**params) 

904 

905 

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

907if conf.email.transport_class is None: 

908 conf.email.transport_class = EmailTransportAppengine()