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
« 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
15import requests
16from deprecated.sphinx import deprecated
17from google.appengine.api.mail import Attachment as GAE_Attachment, SendMail as GAE_SendMail
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
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
27mailjet_dependencies = True
28try:
29 import mailjet_rest
30except ModuleNotFoundError:
31 mailjet_dependencies = False
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.
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.
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).
49A suggested configuration for your `queue.yaml` would be:
51.. code-block:: yaml
53 - name: viur-emails
54 rate: 1/s
55 retry_parameters:
56 min_backoff_seconds: 3600
57 max_backoff_seconds: 3600
58"""
60EMAIL_KINDNAME: t.Final[str] = "viur-emails"
61"""Kindname for the email-queue entities in datastore"""
63EMAIL_QUEUE: t.Final[str] = "viur-emails"
64"""Name of the Cloud Tasks queue"""
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
81AddressPair = t.TypedDict("AddressPair", {
82 "email": str,
83 "name": t.NotRequired[str],
84})
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)
98class EmailTransport(ABC):
99 """Transport handler to deliver emails.
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.
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.
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
133 :return: Any value that can be stored in the datastore in the queue entity as `transportFuncResult`.
134 """
135 ...
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 ...
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 ...
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}
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'])}")
181 def fetch_attachment(self, attachment: Attachment) -> AttachmentInline:
182 """Fetch attachment (if necessary) in send_email_deferred deferred task
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
205@CallDeferred
206def send_email_deferred(key: db.Key):
207 """
208 Task that send an email.
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`.
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!")
220 if queued_email["isSend"]:
221 return True
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=})")
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
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")
252 db.put(queued_email)
254 try:
255 transport_class.transport_successful_callback(queued_email)
256 except Exception as e:
257 logging.exception(e)
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.
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]
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.
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.
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).
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 )
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)
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"
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'.")
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)
348 if "mimetype" not in attachment:
349 attachment["mimetype"] = "application/octet-stream"
351 entity = db.Entity()
352 for k, v in attachment.items():
353 entity[k] = v
354 entity.exclude_from_indexes.add(k)
356 attachments.append(entity)
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 = []
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
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
381 subject, body = conf.emailRenderer(dests, tpl, stringTemplate, skel, **kwargs)
383 # Push that email to the outgoing queue
384 queued_email = db.Entity(db.Key(EMAIL_KINDNAME))
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"}
400 transport_class.validate_queue_entity(queued_email) # Will raise an exception if the entity is not valid
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
411 db.put(queued_email)
412 send_email_deferred(queued_email.key, _queue=EMAIL_QUEUE)
413 return True
416@deprecated(version="3.7.0", reason="Use send_email instead")
417def sendEMail(*args, **kwargs):
418 return send_email(*args, **kwargs)
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.
425 If :attr:`conf.email.admin_recipients` is set, these recipients
426 will be used instead of the root users.
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"])
440 # Prefix the instance's project_id to subject
441 subject = f"{conf.instance.project_id}: {subject}"
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.")
450 finally:
451 if not success:
452 logging.critical("Cannot send email to admins.")
453 logging.debug(f"{subject = }, {body = }")
455 return False
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)
463class EmailTransportBrevo(EmailTransport):
464 """Send emails with `Brevo`_, formerly Sendinblue.
466 .. _Brevo: https://www.brevo.com
467 """
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"""
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
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")
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.
557 :raises ValueError: If the attachment was not allowed
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")
566 @PeriodicTask(interval=datetime.timedelta(hours=1))
567 @staticmethod
568 def check_sib_quota() -> None:
569 """Periodically checks the remaining Brevo email quota.
571 This task does not have to be enabled.
572 It automatically checks if the apiKey is configured.
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.
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
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}")
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"]
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
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
631 db.put(entity)
634@deprecated(version="3.7.0", reason="Sendinblue is now Brevo; Use EmailTransportBrevo instead")
635class EmailTransportSendInBlue(EmailTransportBrevo):
636 ...
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`_.
643 .. _Mailjet: https://www.mailjet.com/products/email-api/
644 """
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
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'")
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 }
679 if bcc:
680 email["bcc"] = [self.split_address(b) for b in bcc]
682 if cc:
683 email["cc"] = [self.split_address(c) for c in cc]
685 if headers:
686 email["headers"] = headers
688 if attachments:
689 email["attachments"] = []
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 })
699 mj_client = mailjet_rest.Client(
700 auth=(self.api_key, self.secret_key),
701 version="v3.1",
702 )
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")
709class EmailTransportSendgrid(EmailTransport):
710 """Send emails with `SendGrid`_.
712 .. _SendGrid: https://sendgrid.com/en-us/solutions/email-api
713 """
715 def __init__(
716 self,
717 *,
718 api_key: str,
719 ) -> None:
720 super().__init__()
721 self.api_key = api_key
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 }
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]
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 ]
772 if headers:
773 assert isinstance(headers, dict)
774 data["headers"] = headers
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
789class EmailTransportSmtp(EmailTransport):
790 """
791 Send emails using the Simple Mail Transfer Protocol (SMTP).
793 Needs an email server.
794 """
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()
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)
833 message.set_content(body, subtype="html")
834 message.add_alternative(HtmlSerializer().sanitize(body), subtype="text")
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)
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())
852class EmailTransportAppengine(EmailTransport):
853 """
854 Abstraction of the Google AppEngine Mail API for email transportation.
856 .. warning: Works only in a deployed Google Cloud environment.
858 .. seealso:: https://cloud.google.com/appengine/docs/standard/python3/services/mail
859 """
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 }
883 if cc:
884 params["cc"] = [self.split_address(c)["email"] for c in cc]
886 if bcc:
887 params["bcc"] = [self.split_address(c)["email"] for c in bcc]
889 if attachments:
890 params["attachments"] = []
892 for attachment in attachments:
893 attachment = self.fetch_attachment(attachment)
894 params["attachments"].append(
895 GAE_Attachment(attachment["filename"], attachment["content"])
896 )
898 GAE_SendMail(**params)
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()