Coverage for jutil/email.py: 24%
111 statements
« prev ^ index » next coverage.py v6.5.0, created at 2022-10-07 16:40 -0500
« prev ^ index » next coverage.py v6.5.0, created at 2022-10-07 16:40 -0500
1import logging
2from email.utils import parseaddr # pylint: disable=import-error
3from typing import Optional, Union, Tuple, Sequence, List
4from django.conf import settings
5from django.core.exceptions import ValidationError
6from django.core.mail import EmailMultiAlternatives
7from django.utils.html import strip_tags
8from django.utils.translation import gettext as _
9from base64 import b64encode
10from os.path import basename
12logger = logging.getLogger(__name__)
15def make_email_recipient(val: Union[str, Tuple[str, str]]) -> Tuple[str, str]:
16 """
17 Returns (name, email) tuple.
18 :param val:
19 :return: (name, email)
20 """
21 if isinstance(val, str):
22 res = parseaddr(val.strip())
23 if len(res) != 2 or not res[1]: 23 ↛ 24line 23 didn't jump to line 24, because the condition on line 23 was never true
24 raise ValidationError(_("Invalid email recipient: {}".format(val)))
25 return res[0] or res[1], res[1]
26 if len(val) != 2: 26 ↛ 27line 26 didn't jump to line 27, because the condition on line 26 was never true
27 raise ValidationError(_("Invalid email recipient: {}".format(val)))
28 return val
31def make_email_recipient_list(recipients: Optional[Union[str, Sequence[Union[str, Tuple[str, str]]]]]) -> List[Tuple[str, str]]:
32 """
33 Returns list of (name, email) tuples.
34 :param recipients:
35 :return: list of (name, email)
36 """
37 out: List[Tuple[str, str]] = []
38 if recipients is not None: 38 ↛ 45line 38 didn't jump to line 45, because the condition on line 38 was never false
39 if isinstance(recipients, str): 39 ↛ 40line 39 didn't jump to line 40, because the condition on line 39 was never true
40 recipients = recipients.split(",")
41 for val in recipients:
42 if not val: 42 ↛ 43line 42 didn't jump to line 43, because the condition on line 42 was never true
43 continue
44 out.append(make_email_recipient(val))
45 return out
48def send_email( # noqa
49 recipients: Sequence[Union[str, Tuple[str, str]]],
50 subject: str,
51 text: str = "",
52 html: str = "",
53 sender: Union[str, Tuple[str, str]] = "",
54 files: Optional[Sequence[str]] = None,
55 cc_recipients: Optional[Sequence[Union[str, Tuple[str, str]]]] = None,
56 bcc_recipients: Optional[Sequence[Union[str, Tuple[str, str]]]] = None,
57 exceptions: bool = False,
58) -> int:
59 """
60 Sends email. Supports both SendGrid API client and SMTP connection.
61 See send_email_sendgrid() for SendGrid specific requirements.
63 :param recipients: List of "To" recipients. Single email (str); or comma-separated email list (str); or list of name-email pairs (e.g. settings.ADMINS) # noqa
64 :param subject: Subject of the email
65 :param text: Body (text), optional
66 :param html: Body (html), optional
67 :param sender: Sender email, or settings.DEFAULT_FROM_EMAIL if missing
68 :param files: Paths to files to attach
69 :param cc_recipients: List of "Cc" recipients (if any). Single email (str); or comma-separated email list (str); or list of name-email pairs (e.g. settings.ADMINS) # noqa
70 :param bcc_recipients: List of "Bcc" recipients (if any). Single email (str); or comma-separated email list (str); or list of name-email pairs (e.g. settings.ADMINS) # noqa
71 :param exceptions: Raise exception if email sending fails. List of recipients; or single email (str); or comma-separated email list (str); or list of name-email pairs (e.g. settings.ADMINS) # noqa
72 :return: Status code 202 if emails were sent successfully
73 """
74 if hasattr(settings, "EMAIL_SENDGRID_API_KEY") and settings.EMAIL_SENDGRID_API_KEY:
75 return send_email_sendgrid(recipients, subject, text, html, sender, files, cc_recipients, bcc_recipients, exceptions)
76 return send_email_smtp(recipients, subject, text, html, sender, files, cc_recipients, bcc_recipients, exceptions)
79def send_email_sendgrid( # noqa
80 recipients: Sequence[Union[str, Tuple[str, str]]],
81 subject: str,
82 text: str = "",
83 html: str = "",
84 sender: Union[str, Tuple[str, str]] = "",
85 files: Optional[Sequence[str]] = None,
86 cc_recipients: Optional[Sequence[Union[str, Tuple[str, str]]]] = None,
87 bcc_recipients: Optional[Sequence[Union[str, Tuple[str, str]]]] = None,
88 exceptions: bool = False,
89 api_key: str = "",
90) -> int:
91 """
92 Sends email using SendGrid API. Following requirements:
93 * pip install sendgrid>=6.3.1,<7.0.0
94 * settings.EMAIL_SENDGRID_API_KEY must be set and
96 :param recipients: List of "To" recipients. Single email (str); or comma-separated email list (str); or list of name-email pairs (e.g. settings.ADMINS) # noqa
97 :param subject: Subject of the email
98 :param text: Body (text), optional
99 :param html: Body (html), optional
100 :param sender: Sender email, or settings.DEFAULT_FROM_EMAIL if missing
101 :param files: Paths to files to attach
102 :param cc_recipients: List of "Cc" recipients (if any). Single email (str); or comma-separated email list (str); or list of name-email pairs (e.g. settings.ADMINS) # noqa
103 :param bcc_recipients: List of "Bcc" recipients (if any). Single email (str); or comma-separated email list (str); or list of name-email pairs (e.g. settings.ADMINS) # noqa
104 :param exceptions: Raise exception if email sending fails. List of recipients; or single email (str); or comma-separated email list (str); or list of name-email pairs (e.g. settings.ADMINS) # noqa
105 :param api_key: Optional Sendgrid API key. Default settings.EMAIL_SENDGRID_API_KEY.
106 :return: Status code 202 if emails were sent successfully
107 """
108 try:
109 import sendgrid # type: ignore # pylint: disable=import-outside-toplevel
110 from sendgrid.helpers.mail import Content, Mail, Attachment # type: ignore # pylint: disable=import-outside-toplevel
111 from sendgrid import ClickTracking, FileType, FileName, TrackingSettings # type: ignore # pylint: disable=import-outside-toplevel
112 from sendgrid import Personalization, FileContent, ContentId, Disposition # type: ignore # pylint: disable=import-outside-toplevel
113 except Exception as err:
114 raise Exception("Using send_email_sendgrid() requires sendgrid pip install sendgrid>=6.3.1,<7.0.0") from err
116 if not api_key and hasattr(settings, "EMAIL_SENDGRID_API_KEY"):
117 api_key = settings.EMAIL_SENDGRID_API_KEY
118 if not api_key:
119 raise Exception("EMAIL_SENDGRID_API_KEY not defined in Django settings and API key not passed in to send_email_sendgrid() either")
121 if files is None:
122 files = []
123 from_clean = make_email_recipient(sender or settings.DEFAULT_FROM_EMAIL)
124 recipients_clean = make_email_recipient_list(recipients)
125 cc_recipients_clean = make_email_recipient_list(cc_recipients)
126 bcc_recipients_clean = make_email_recipient_list(bcc_recipients)
127 subject = strip_tags(subject)
129 try:
130 sg = sendgrid.SendGridAPIClient(api_key=api_key)
131 text_content = Content("text/plain", text) if text else None
132 html_content = Content("text/html", html) if html else None
134 personalization = Personalization()
135 for recipient in recipients_clean:
136 personalization.add_email(sendgrid.To(email=recipient[1], name=recipient[0]))
137 for recipient in cc_recipients_clean:
138 personalization.add_email(sendgrid.Cc(email=recipient[1], name=recipient[0]))
139 for recipient in bcc_recipients_clean:
140 personalization.add_email(sendgrid.Bcc(email=recipient[1], name=recipient[0]))
142 mail = Mail(
143 from_email=sendgrid.From(email=from_clean[1], name=from_clean[0]),
144 subject=subject,
145 plain_text_content=text_content,
146 html_content=html_content,
147 )
148 mail.add_personalization(personalization)
150 # stop SendGrid from replacing all links in the email
151 mail.tracking_settings = TrackingSettings(click_tracking=ClickTracking(enable=False))
153 for filename in files:
154 if filename:
155 with open(filename, "rb") as fp:
156 attachment = Attachment()
157 attachment.file_type = FileType("application/octet-stream")
158 attachment.file_name = FileName(basename(filename))
159 attachment.file_content = FileContent(b64encode(fp.read()).decode())
160 attachment.content_id = ContentId(basename(filename))
161 attachment.disposition = Disposition("attachment")
162 mail.add_attachment(attachment)
164 mail_body = mail.get()
165 res = sg.client.mail.send.post(request_body=mail_body)
167 if res.status_code == 202:
168 logger.info("EMAIL_SENT %s", {"to": recipients, "subject": subject, "status": res.status_code})
169 else:
170 logger.info(
171 "EMAIL_ERROR %s",
172 {"to": recipients, "subject": subject, "status": res.status_code, "body": res.body},
173 )
175 except Exception as err:
176 logger.error("EMAIL_ERROR %s", {"to": recipients, "subject": subject, "exception": str(err)})
177 if exceptions:
178 raise
179 return -1
181 return res.status_code
184def send_email_smtp( # noqa
185 recipients: Sequence[Union[str, Tuple[str, str]]],
186 subject: str,
187 text: str = "",
188 html: str = "",
189 sender: Union[str, Tuple[str, str]] = "",
190 files: Optional[Sequence[str]] = None,
191 cc_recipients: Optional[Sequence[Union[str, Tuple[str, str]]]] = None,
192 bcc_recipients: Optional[Sequence[Union[str, Tuple[str, str]]]] = None,
193 exceptions: bool = False,
194) -> int:
195 """
196 Sends email using SMTP connection using standard Django email settings.
198 For example, to send email via Gmail:
199 (Note that you might need to generate app-specific password at https://myaccount.google.com/apppasswords)
201 EMAIL_HOST = 'smtp.gmail.com'
202 EMAIL_PORT = 587
203 EMAIL_HOST_USER = 'xxxx@gmail.com'
204 EMAIL_HOST_PASSWORD = 'xxxx' # noqa
205 EMAIL_USE_TLS = True
207 :param recipients: List of "To" recipients. Single email (str); or comma-separated email list (str); or list of name-email pairs (e.g. settings.ADMINS) # noqa
208 :param subject: Subject of the email
209 :param text: Body (text), optional
210 :param html: Body (html), optional
211 :param sender: Sender email, or settings.DEFAULT_FROM_EMAIL if missing
212 :param files: Paths to files to attach
213 :param cc_recipients: List of "Cc" recipients (if any). Single email (str); or comma-separated email list (str); or list of name-email pairs (e.g. settings.ADMINS) # noqa
214 :param bcc_recipients: List of "Bcc" recipients (if any). Single email (str); or comma-separated email list (str); or list of name-email pairs (e.g. settings.ADMINS) # noqa
215 :param exceptions: Raise exception if email sending fails. List of recipients; or single email (str); or comma-separated email list (str); or list of name-email pairs (e.g. settings.ADMINS) # noqa
216 :return: Status code 202 if emails were sent successfully
217 """
218 if files is None:
219 files = []
220 from_clean = make_email_recipient(sender or settings.DEFAULT_FROM_EMAIL)
221 recipients_clean = make_email_recipient_list(recipients)
222 cc_recipients_clean = make_email_recipient_list(cc_recipients)
223 bcc_recipients_clean = make_email_recipient_list(bcc_recipients)
224 subject = strip_tags(subject)
226 try:
227 mail = EmailMultiAlternatives(
228 subject=subject,
229 body=text,
230 from_email='"{}" <{}>'.format(*from_clean),
231 to=['"{}" <{}>'.format(*r) for r in recipients_clean],
232 bcc=['"{}" <{}>'.format(*r) for r in bcc_recipients_clean],
233 cc=['"{}" <{}>'.format(*r) for r in cc_recipients_clean],
234 )
235 for filename in files:
236 if filename:
237 mail.attach_file(filename)
238 if html:
239 mail.attach_alternative(content=html, mimetype="text/html")
241 mail.send(fail_silently=False)
242 logger.info("EMAIL_SENT %s", {"to": recipients, "subject": subject})
244 except Exception as e:
245 logger.error("EMAIL_ERROR %s", {"to": recipients, "subject": subject, "exception": str(e)})
246 if exceptions:
247 raise
248 return -1
250 return 202