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
« 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
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:
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 max_retries = 3
104 """maximum number of attempts to send a email."""
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.
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.
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
136 :return: Any value that can be stored in the datastore in the queue entity as `transportFuncResult`.
137 """
138 ...
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 ...
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 ...
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}
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'])}")
184 def fetch_attachment(self, attachment: Attachment) -> AttachmentInline:
185 """Fetch attachment (if necessary) in send_email_deferred deferred task
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
208@CallDeferred
209def send_email_deferred(key: db.Key):
210 """
211 Task that send an email.
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`.
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!")
223 if queued_email["isSend"]:
224 return True
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=})")
230 if queued_email["errorCount"] > transport_class.max_retries:
231 raise ChildProcessError("Error-Count exceeded")
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
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")
258 db.Put(queued_email)
260 try:
261 transport_class.transport_successful_callback(queued_email)
262 except Exception as e:
263 logging.exception(e)
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.
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]
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.
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.
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).
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 )
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)
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"
344 if not (bool(stringTemplate) ^ bool(tpl)):
345 raise ValueError("You have to set the params 'tpl' xor a 'stringTemplate'.")
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)
354 if "mimetype" not in attachment:
355 attachment["mimetype"] = "application/octet-stream"
357 entity = db.Entity()
358 for k, v in attachment.items():
359 entity[k] = v
360 entity.exclude_from_indexes.add(k)
362 attachments.append(entity)
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 = []
378 elif conf.email.recipient_override is False:
379 logging.warning("Sending emails disabled by config[viur.email.recipientOverride]")
380 return False
382 if conf.email.sender_override:
383 sender = conf.email.sender_override
384 elif sender is None:
385 sender = conf.email.sender_default
387 subject, body = conf.emailRenderer(dests, tpl, stringTemplate, skel, **kwargs)
389 # Push that email to the outgoing queue
390 queued_email = db.Entity(db.Key(EMAIL_KINDNAME))
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"}
406 transport_class.validate_queue_entity(queued_email) # Will raise an exception if the entity is not valid
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
416 db.Put(queued_email)
417 send_email_deferred(queued_email.key, _queue=EMAIL_QUEUE)
418 return True
421@deprecated(version="3.7.0", reason="Use send_email instead", action="always")
422def sendEMail(*args, **kwargs):
423 return send_email(*args, **kwargs)
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.
430 If :attr:`conf.email.admin_recipients` is set, these recipients
431 will be used instead of the root users.
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"])
445 # Prefix the instance's project_id to subject
446 subject = f"{conf.instance.project_id}: {subject}"
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.")
455 finally:
456 if not success:
457 logging.critical("Cannot send email to admins.")
458 logging.debug(f"{subject = }, {body = }")
460 return False
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)
468class EmailTransportBrevo(EmailTransport):
469 """Send emails with `Brevo`_, formerly Sendinblue.
471 .. _Brevo: https://www.brevo.com
472 """
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"""
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
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")
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.
562 :raises ValueError: If the attachment was not allowed
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")
571 @PeriodicTask(interval=datetime.timedelta(hours=1))
572 @staticmethod
573 def check_sib_quota() -> None:
574 """Periodically checks the remaining Brevo email quota.
576 This task does not have to be enabled.
577 It automatically checks if the apiKey is configured.
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.
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
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}")
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"]
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
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
636 db.Put(entity)
639@deprecated(version="3.7.0", reason="Sendinblue is now Brevo; Use EmailTransportBrevo instead", action="always")
640class EmailTransportSendInBlue(EmailTransportBrevo):
641 ...
644if mailjet_dependencies:
645 class EmailTransportMailjet(EmailTransport):
646 """Send emails with `Mailjet`_.
648 .. _Mailjet: https://www.mailjet.com/products/email-api/
649 """
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
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'")
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 }
684 if bcc:
685 email["bcc"] = [self.split_address(b) for b in bcc]
687 if cc:
688 email["cc"] = [self.split_address(c) for c in cc]
690 if headers:
691 email["headers"] = headers
693 if attachments:
694 email["attachments"] = []
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 })
704 mj_client = mailjet_rest.Client(
705 auth=(self.api_key, self.secret_key),
706 version="v3.1",
707 )
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")
714class EmailTransportSendgrid(EmailTransport):
715 """Send emails with `SendGrid`_.
717 .. _SendGrid: https://sendgrid.com/en-us/solutions/email-api
718 """
720 def __init__(
721 self,
722 *,
723 api_key: str,
724 ) -> None:
725 super().__init__()
726 self.api_key = api_key
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 }
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]
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 ]
777 if headers:
778 assert isinstance(headers, dict)
779 data["headers"] = headers
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
794class EmailTransportSmtp(EmailTransport):
795 """
796 Send emails using the Simple Mail Transfer Protocol (SMTP).
798 Needs an email server.
799 """
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()
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)
838 message.set_content(body, subtype="html")
839 message.add_alternative(HtmlSerializer().sanitize(body), subtype="text")
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)
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())
857class EmailTransportAppengine(EmailTransport):
858 """
859 Abstraction of the Google AppEngine Mail API for email transportation.
861 .. warning: Works only in a deployed Google Cloud environment.
863 .. seealso:: https://cloud.google.com/appengine/docs/standard/python3/services/mail
864 """
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 }
888 if cc:
889 params["cc"] = [self.split_address(c)["email"] for c in cc]
891 if bcc:
892 params["bcc"] = [self.split_address(c)["email"] for c in bcc]
894 if attachments:
895 params["attachments"] = []
897 for attachment in attachments:
898 attachment = self.fetch_attachment(attachment)
899 params["attachments"].append(
900 GAE_Attachment(attachment["filename"], attachment["content"])
901 )
903 GAE_SendMail(**params)
906# Set (limited, but free) Google AppEngine Mail API as default
907if conf.email.transport_class is None:
908 conf.email.transport_class = EmailTransportAppengine()