Coverage for /home/runner/work/viur-core/viur-core/viur/src/viur/core/email.py: 15%
379 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-01 22:44 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-01 22:44 +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):
267 value = value()
268 if value is None:
269 return []
270 if isinstance(value, list):
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):
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)):
339 raise ValueError("You have to set the params 'tpl' xor a 'stringTemplate'.")
341 if attachments := normalize_to_list(attachments):
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:
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:
373 logging.warning("Sending emails disabled by config[viur.email.recipientOverride]")
374 return False
376 if conf.email.sender_override:
377 sender = conf.email.sender_override
378 elif sender is None:
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:
403 if not conf.email.send_from_local_development_server or transport_class is EmailTransportAppengine:
404 logging.info("Not sending email from local development server")
405 logging.info(f"""Subject: {queued_email["subject"]}""")
406 logging.info(f"""Body: {queued_email["body"]}""")
407 logging.info(f"""Recipients: {queued_email["dests"]}""")
408 return False
410 db.put(queued_email)
411 send_email_deferred(queued_email.key, _queue=EMAIL_QUEUE)
412 return True
415@deprecated(version="3.7.0", reason="Use send_email instead")
416def sendEMail(*args, **kwargs):
417 return send_email(*args, **kwargs)
420def send_email_to_admins(subject: str, body: str, *args, **kwargs) -> bool:
421 """
422 Sends an email to the root users of the current app.
424 If :attr:`conf.email.admin_recipients` is set, these recipients
425 will be used instead of the root users.
427 :param subject: Defines the subject of the message.
428 :param body: Defines the message body.
429 """
430 success = False
431 try:
432 users = []
433 if conf.email.admin_recipients is not None:
434 users = normalize_to_list(conf.email.admin_recipients)
435 elif "user" in dir(conf.main_app.vi):
436 for user_skel in conf.main_app.vi.user.viewSkel().all().filter("access =", "root").fetch():
437 users.append(user_skel["name"])
439 # Prefix the instance's project_id to subject
440 subject = f"{conf.instance.project_id}: {subject}"
442 if users:
443 ret = send_email(dests=users, stringTemplate=os.linesep.join((subject, body)), *args, **kwargs)
444 success = True
445 return ret
446 else:
447 logging.warning("There are no recipients for admin emails available.")
449 finally:
450 if not success:
451 logging.critical("Cannot send email to admins.")
452 logging.debug(f"{subject = }, {body = }")
454 return False
457@deprecated(version="3.7.0", reason="Use send_email_to_admins instead")
458def sendEMailToAdmins(*args, **kwargs):
459 return send_email_to_admins(*args, **kwargs)
462class EmailTransportBrevo(EmailTransport):
463 """Send emails with `Brevo`_, formerly Sendinblue.
465 .. _Brevo: https://www.brevo.com
466 """
468 allowed_extensions = {"gif", "png", "bmp", "cgm", "jpg", "jpeg", "tif",
469 "tiff", "rtf", "txt", "css", "shtml", "html", "htm",
470 "csv", "zip", "pdf", "xml", "doc", "docx", "ics",
471 "xls", "xlsx", "ppt", "tar", "ez"}
472 """List of allowed file extensions that can be send from Brevo"""
474 def __init__(
475 self,
476 *,
477 api_key: str,
478 thresholds: tuple[int] | list[int] = (1000, 500, 100),
479 ) -> None:
480 """
481 :param api_key: API key
482 :param thresholds: Warning thresholds for remaining email quota.
483 """
484 super().__init__()
485 self.api_key = api_key
486 self.thresholds = thresholds
488 def deliver_email(
489 self,
490 *,
491 sender: str,
492 dests: list[str],
493 cc: list[str],
494 bcc: list[str],
495 subject: str,
496 body: str,
497 headers: dict[str, str],
498 attachments: list[Attachment],
499 **kwargs: t.Any,
500 ) -> str:
501 """
502 Internal function for delivering emails using Brevo.
503 """
504 dataDict = {
505 "sender": self.split_address(sender),
506 "to": [],
507 "htmlContent": body,
508 "subject": subject,
509 }
510 for dest in dests:
511 dataDict["to"].append(self.split_address(dest))
512 # initialize bcc and cc lists in dataDict
513 if bcc:
514 dataDict["bcc"] = []
515 for dest in bcc:
516 dataDict["bcc"].append(self.split_address(dest))
517 if cc:
518 dataDict["cc"] = []
519 for dest in cc:
520 dataDict["cc"].append(self.split_address(dest))
521 if headers:
522 if "Reply-To" in headers:
523 dataDict["replyTo"] = self.split_address(headers["Reply-To"])
524 del headers["Reply-To"]
525 if headers:
526 dataDict["headers"] = headers
527 if attachments:
528 dataDict["attachment"] = []
529 for attachment in attachments:
530 attachment = self.fetch_attachment(attachment)
531 dataDict["attachment"].append({
532 "name": attachment["filename"],
533 "content": base64.b64encode(attachment["content"]).decode("ASCII")
534 })
535 payload = json.dumps(dataDict).encode("UTF-8")
536 headers = {
537 "api-key": self.api_key,
538 "Content-Type": "application/json; charset=utf-8"
539 }
540 reqObj = request.Request(url="https://api.brevo.com/v3/smtp/email",
541 data=payload, headers=headers, method="POST")
542 try:
543 response = request.urlopen(reqObj)
544 except request.HTTPError as e:
545 logging.error("Sending email failed!")
546 logging.error(dataDict)
547 logging.error(e.read())
548 raise
549 assert str(response.code)[0] == "2", "Received a non 2XX Status Code!"
550 return response.read().decode("UTF-8")
552 def validate_queue_entity(self, entity: db.Entity) -> None:
553 """
554 Validate the attachments (if any) against the list of supported file extensions by Brevo.
556 :raises ValueError: If the attachment was not allowed
558 .. seealso:: :attr:`allowed_extensions`
559 """
560 for attachment in entity.get("attachments") or []:
561 ext = attachment["filename"].split(".")[-1].lower()
562 if ext not in self.allowed_extensions:
563 raise ValueError(f"The file-extension {ext} cannot be send using Brevo")
565 @PeriodicTask(interval=datetime.timedelta(hours=1))
566 @staticmethod
567 def check_sib_quota() -> None:
568 """Periodically checks the remaining Brevo email quota.
570 This task does not have to be enabled.
571 It automatically checks if the apiKey is configured.
573 There are three default thresholds: 1000, 500, 100
574 Others can be set via :attr:`thresholds`.
575 An email will be sent for the lowest threshold that has been undercut.
577 .. seealso:: https://developers.brevo.com/reference/getaccount
578 """
579 if not isinstance(conf.email.transport_class, EmailTransportSendInBlue):
580 return # no SIB key, we cannot check
582 req = requests.get(
583 "https://api.brevo.com/v3/account",
584 headers={"api-key": conf.email.transport_class.api_key},
585 )
586 if not req.ok:
587 logging.error("Failed to fetch SIB account information")
588 return
589 data = req.json()
590 logging.debug(f"SIB account data: {data}")
591 for plan in data["plan"]:
592 if plan["type"] == "payAsYouGo":
593 credits = plan["credits"]
594 break
595 else:
596 credits = -1
597 logging.info(f"Brevo email credits: {credits}")
599 # Keep track of the last credits and the limit for which a email has
600 # already been sent. This way, emails for the same limit will not be
601 # sent more than once and the remaining email credits will not be wasted.
602 key = db.Key("viur-email-conf", "sib-credits")
603 if not (entity := db.get(key)):
604 logging.debug(f"{entity = }")
605 entity = db.Entity(key)
606 logging.debug(f"{entity = }")
607 logging.debug(f"{entity = }")
608 entity.setdefault("latest_warning_for", None)
609 entity["credits"] = credits
610 entity["email"] = data["email"]
612 thresholds = sorted(conf.email.transport_class.thresholds, reverse=True)
613 for idx, limit in list(enumerate(thresholds, 1))[::-1]:
614 if credits < limit:
615 if entity["latest_warning_for"] == limit:
616 logging.info(f"Already send an email for {limit = }.")
617 break
619 send_email_to_admins(
620 f"SendInBlue email budget {credits} ({idx}. warning)",
621 f"The SendInBlue email budget reached {credits} credits "
622 f"for {data['email']}. Please increase soon.",
623 )
624 entity["latest_warning_for"] = limit
625 break
626 else:
627 # Credits are above all limits
628 entity["latest_warning_for"] = None
630 db.put(entity)
633@deprecated(version="3.7.0", reason="Sendinblue is now Brevo; Use EmailTransportBrevo instead")
634class EmailTransportSendInBlue(EmailTransportBrevo):
635 ...
638if mailjet_dependencies: 638 ↛ 639line 638 didn't jump to line 639 because the condition on line 638 was never true
639 class EmailTransportMailjet(EmailTransport):
640 """Send emails with `Mailjet`_.
642 .. _Mailjet: https://www.mailjet.com/products/email-api/
643 """
645 def __init__(
646 self,
647 *,
648 api_key: str,
649 secret_key: str,
650 ) -> None:
651 super().__init__()
652 self.api_key = api_key
653 self.secret_key = secret_key
655 def deliver_email(
656 self,
657 *,
658 sender: str,
659 dests: list[str],
660 cc: list[str],
661 bcc: list[str],
662 subject: str,
663 body: str,
664 headers: dict[str, str],
665 attachments: list[Attachment],
666 **kwargs: t.Any,
667 ) -> str:
668 if not (self.api_key and self.secret_key):
669 raise RuntimeError("Mailjet config invalid, check 'api_key' and 'secret_key'")
671 email = {
672 "from": self.split_address(sender),
673 "htmlpart": body,
674 "subject": subject,
675 "to": [self.split_address(dest) for dest in dests],
676 }
678 if bcc:
679 email["bcc"] = [self.split_address(b) for b in bcc]
681 if cc:
682 email["cc"] = [self.split_address(c) for c in cc]
684 if headers:
685 email["headers"] = headers
687 if attachments:
688 email["attachments"] = []
690 for attachment in attachments:
691 attachment = self.fetch_attachment(attachment)
692 email["attachments"].append({
693 "filename": attachment["filename"],
694 "base64content": base64.b64encode(attachment["content"]).decode("ASCII"),
695 "contenttype": attachment["mimetype"]
696 })
698 mj_client = mailjet_rest.Client(
699 auth=(self.api_key, self.secret_key),
700 version="v3.1",
701 )
703 result = mj_client.send.create(data={"messages": [email]})
704 assert 200 <= result.status_code < 300, f"Received {result.status_code=} {result.reason=}"
705 return result.content.decode("UTF-8")
708class EmailTransportSendgrid(EmailTransport):
709 """Send emails with `SendGrid`_.
711 .. _SendGrid: https://sendgrid.com/en-us/solutions/email-api
712 """
714 def __init__(
715 self,
716 *,
717 api_key: str,
718 ) -> None:
719 super().__init__()
720 self.api_key = api_key
722 def deliver_email(
723 self,
724 *,
725 sender: str,
726 dests: list[str],
727 cc: list[str],
728 bcc: list[str],
729 subject: str,
730 body: str,
731 headers: dict[str, str],
732 attachments: list[Attachment],
733 **kwargs: t.Any,
734 ) -> dict[str, str]:
735 data = {
736 "personalizations": [
737 personalization := {
738 "to": [self.split_address(val) for val in dests],
739 "subject": subject,
740 }
741 ],
742 "from": self.split_address(sender),
743 "content": [{
744 "type": "text/html",
745 "value": body,
746 }],
747 "tracking_settings": { # TODO: make the settings configurable
748 "click_tracking": {
749 "enable": False,
750 }
751 },
752 }
754 if cc:
755 personalization["cc"] = [self.split_address(val) for val in cc]
756 if bcc:
757 personalization["bcc"] = [self.split_address(val) for val in bcc]
759 if attachments:
760 assert isinstance(attachments, list)
761 data["attachments"] = [
762 {
763 "filename": attachment["filename"],
764 "content": base64.b64encode(attachment["content"]).decode(),
765 "type": attachment["mimetype"],
766 "disposition": "attachment",
767 }
768 for attachment in map(self.fetch_attachment, attachments)
769 ]
771 if headers:
772 assert isinstance(headers, dict)
773 data["headers"] = headers
775 req = requests.post(
776 "https://api.sendgrid.com/v3/mail/send",
777 headers={
778 "Authorization": f"Bearer {self.api_key}",
779 "Accept": "application/json"
780 },
781 json=data,
782 )
783 if not req.ok:
784 raise ValueError(f"{req.status_code} {req.reason} {req.json()}", req)
785 return {k: v for k, v in req.headers.items() if k.startswith("X-")} # X-Message-Id and maybe more in future
788class EmailTransportSmtp(EmailTransport):
789 """
790 Send emails using the Simple Mail Transfer Protocol (SMTP).
792 Needs an email server.
793 """
795 def __init__(
796 self,
797 *,
798 host: str,
799 port: int = smtplib.SMTP_SSL_PORT,
800 user: str,
801 password: str,
802 ) -> None:
803 super().__init__()
804 self.host = host
805 self.port = port
806 self.user = user
807 self.password = password
808 self.context = ssl.create_default_context()
810 def deliver_email(
811 self,
812 *,
813 sender: str,
814 dests: list[str],
815 cc: list[str],
816 bcc: list[str],
817 subject: str,
818 body: str,
819 headers: dict[str, str],
820 attachments: list[Attachment],
821 **kwargs: t.Any,
822 ) -> dict[str, tuple[int, bytes]]:
823 message = EmailMessage()
824 message["Subject"] = subject
825 message["From"] = sender
826 message["To"] = ", ".join(dests)
827 message["Cc"] = ", ".join(cc)
828 message["Bcc"] = ", ".join(bcc)
829 for key, value in headers.items():
830 message.add_header(key, value)
832 message.set_content(body, subtype="html")
833 message.add_alternative(HtmlSerializer().sanitize(body), subtype="text")
835 for attachment in attachments:
836 attachment = self.fetch_attachment(attachment)
837 part = MIMEBase(*attachment["mimetype"].split("/", 1))
838 part.set_payload(attachment["content"])
839 encoders.encode_base64(part)
840 part.add_header(
841 "Content-Disposition",
842 f'attachment; filename="{attachment["filename"]}"',
843 )
844 message.add_alternative(part)
846 with smtplib.SMTP_SSL(self.host, self.port, context=self.context) as server:
847 server.login(self.user, self.password)
848 return server.sendmail(sender, (dests + cc + bcc), message.as_string())
851class EmailTransportAppengine(EmailTransport):
852 """
853 Abstraction of the Google AppEngine Mail API for email transportation.
855 .. warning: Works only in a deployed Google Cloud environment.
857 .. seealso:: https://cloud.google.com/appengine/docs/standard/python3/services/mail
858 """
860 def deliver_email(
861 self,
862 *,
863 sender: str,
864 dests: list[str],
865 cc: list[str],
866 bcc: list[str],
867 subject: str,
868 body: str,
869 headers: dict[str, str],
870 attachments: list[Attachment],
871 **kwargs: t.Any,
872 ) -> None:
873 # need to build a silly dict because the google.appengine mail api doesn't accept None or empty values ...
874 params = {
875 "to": [self.split_address(dest)["email"] for dest in dests],
876 "sender": sender,
877 "subject": subject,
878 "body": HtmlSerializer().sanitize(body),
879 "html": body,
880 }
882 if cc:
883 params["cc"] = [self.split_address(c)["email"] for c in cc]
885 if bcc:
886 params["bcc"] = [self.split_address(c)["email"] for c in bcc]
888 if attachments:
889 params["attachments"] = []
891 for attachment in attachments:
892 attachment = self.fetch_attachment(attachment)
893 params["attachments"].append(
894 GAE_Attachment(attachment["filename"], attachment["content"])
895 )
897 GAE_SendMail(**params)
900# Set (limited, but free) Google AppEngine Mail API as default
901if conf.email.transport_class is None: 901 ↛ exitline 901 didn't exit the module because the condition on line 901 was always true
902 conf.email.transport_class = EmailTransportAppengine()