Coverage for jutil/email.py : 25%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
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.timezone import now
8from django.utils.translation import gettext as _
9from base64 import b64encode
10from os.path import basename
13logger = logging.getLogger(__name__)
16def make_email_recipient(val: Union[str, Tuple[str, str]]) -> Tuple[str, str]:
17 """
18 Returns (name, email) tuple.
19 :param val:
20 :return: (name, email)
21 """
22 if isinstance(val, str):
23 res = parseaddr(val.strip())
24 if len(res) != 2 or not res[1]: 24 ↛ 25line 24 didn't jump to line 25, because the condition on line 24 was never true
25 raise ValidationError(_("Invalid email recipient: {}".format(val)))
26 return res[0] or res[1], res[1]
27 if len(val) != 2: 27 ↛ 28line 27 didn't jump to line 28, because the condition on line 27 was never true
28 raise ValidationError(_("Invalid email recipient: {}".format(val)))
29 return val
32def make_email_recipient_list(
33 recipients: Optional[Union[str, Sequence[Union[str, Tuple[str, str]]]]]
34) -> List[Tuple[str, str]]:
35 """
36 Returns list of (name, email) tuples.
37 :param recipients:
38 :return: list of (name, email)
39 """
40 out: List[Tuple[str, str]] = []
41 if recipients is not None: 41 ↛ 48line 41 didn't jump to line 48, because the condition on line 41 was never false
42 if isinstance(recipients, str): 42 ↛ 43line 42 didn't jump to line 43, because the condition on line 42 was never true
43 recipients = recipients.split(",")
44 for val in recipients:
45 if not val: 45 ↛ 46line 45 didn't jump to line 46, because the condition on line 45 was never true
46 continue
47 out.append(make_email_recipient(val))
48 return out
51def send_email( # noqa
52 recipients: Sequence[Union[str, Tuple[str, str]]],
53 subject: str,
54 text: str = "",
55 html: str = "",
56 sender: Union[str, Tuple[str, str]] = "",
57 files: Optional[Sequence[str]] = None,
58 cc_recipients: Optional[Sequence[Union[str, Tuple[str, str]]]] = None,
59 bcc_recipients: Optional[Sequence[Union[str, Tuple[str, str]]]] = None,
60 exceptions: bool = False,
61) -> int:
62 """
63 Sends email. Supports both SendGrid API client and SMTP connection.
64 See send_email_sendgrid() for SendGrid specific requirements.
66 :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
67 :param subject: Subject of the email
68 :param text: Body (text), optional
69 :param html: Body (html), optional
70 :param sender: Sender email, or settings.DEFAULT_FROM_EMAIL if missing
71 :param files: Paths to files to attach
72 :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
73 :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
74 :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
75 :return: Status code 202 if emails were sent successfully
76 """
77 if hasattr(settings, "EMAIL_SENDGRID_API_KEY") and settings.EMAIL_SENDGRID_API_KEY:
78 return send_email_sendgrid(
79 recipients, subject, text, html, sender, files, cc_recipients, bcc_recipients, exceptions
80 )
81 return send_email_smtp(recipients, subject, text, html, sender, files, cc_recipients, bcc_recipients, exceptions)
84def send_email_sendgrid( # noqa
85 recipients: Sequence[Union[str, Tuple[str, str]]],
86 subject: str,
87 text: str = "",
88 html: str = "",
89 sender: Union[str, Tuple[str, str]] = "",
90 files: Optional[Sequence[str]] = None,
91 cc_recipients: Optional[Sequence[Union[str, Tuple[str, str]]]] = None,
92 bcc_recipients: Optional[Sequence[Union[str, Tuple[str, str]]]] = None,
93 exceptions: bool = False,
94) -> int:
95 """
96 Sends email using SendGrid API. Following requirements:
97 * pip install sendgrid>=6.3.1,<7.0.0
98 * settings.EMAIL_SENDGRID_API_KEY must be set and
100 :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
101 :param subject: Subject of the email
102 :param text: Body (text), optional
103 :param html: Body (html), optional
104 :param sender: Sender email, or settings.DEFAULT_FROM_EMAIL if missing
105 :param files: Paths to files to attach
106 :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
107 :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
108 :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
109 :return: Status code 202 if emails were sent successfully
110 """
111 try:
112 import sendgrid # type: ignore # pylint: disable=import-outside-toplevel
113 from sendgrid.helpers.mail import Content, Mail, Attachment # type: ignore # pylint: disable=import-outside-toplevel
114 from sendgrid import ClickTracking, FileType, FileName, TrackingSettings # type: ignore # pylint: disable=import-outside-toplevel
115 from sendgrid import Personalization, FileContent, ContentId, Disposition # type: ignore # pylint: disable=import-outside-toplevel
116 except Exception:
117 raise Exception("Using send_email_sendgrid() requires sendgrid pip install sendgrid>=6.3.1,<7.0.0")
119 if not hasattr(settings, "EMAIL_SENDGRID_API_KEY") or not settings.EMAIL_SENDGRID_API_KEY:
120 raise Exception("EMAIL_SENDGRID_API_KEY not defined in Django settings")
122 if files is None:
123 files = []
124 from_clean = make_email_recipient(sender or settings.DEFAULT_FROM_EMAIL)
125 recipients_clean = make_email_recipient_list(recipients)
126 cc_recipients_clean = make_email_recipient_list(cc_recipients)
127 bcc_recipients_clean = make_email_recipient_list(bcc_recipients)
129 try:
130 sg = sendgrid.SendGridAPIClient(api_key=settings.EMAIL_SENDGRID_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 with open(filename, "rb") as fp:
155 attachment = Attachment()
156 attachment.file_type = FileType("application/octet-stream")
157 attachment.file_name = FileName(basename(filename))
158 attachment.file_content = FileContent(b64encode(fp.read()).decode())
159 attachment.content_id = ContentId(basename(filename))
160 attachment.disposition = Disposition("attachment")
161 mail.add_attachment(attachment)
163 send_time = now()
164 mail_body = mail.get()
165 if hasattr(settings, "EMAIL_SENDGRID_API_DEBUG") and settings.EMAIL_SENDGRID_API_DEBUG:
166 logger.info("SendGrid API payload: %s", mail_body)
167 res = sg.client.mail.send.post(request_body=mail_body)
168 send_dt = (now() - send_time).total_seconds()
170 if res.status_code == 202:
171 logger.info(
172 "EMAIL_SENT %s", {"time": send_dt, "to": recipients, "subject": subject, "status": res.status_code}
173 )
174 else:
175 logger.info(
176 "EMAIL_ERROR %s",
177 {"time": send_dt, "to": recipients, "subject": subject, "status": res.status_code, "body": res.body},
178 )
180 except Exception as e:
181 logger.error("EMAIL_ERROR %s", {"to": recipients, "subject": subject, "exception": str(e)})
182 if exceptions:
183 raise
184 return -1
186 return res.status_code
189def send_email_smtp( # noqa
190 recipients: Sequence[Union[str, Tuple[str, str]]],
191 subject: str,
192 text: str = "",
193 html: str = "",
194 sender: Union[str, Tuple[str, str]] = "",
195 files: Optional[Sequence[str]] = None,
196 cc_recipients: Optional[Sequence[Union[str, Tuple[str, str]]]] = None,
197 bcc_recipients: Optional[Sequence[Union[str, Tuple[str, str]]]] = None,
198 exceptions: bool = False,
199) -> int:
200 """
201 Sends email using SMTP connection using standard Django email settings.
203 For example, to send email via Gmail:
204 (Note that you might need to generate app-specific password at https://myaccount.google.com/apppasswords)
206 EMAIL_HOST = 'smtp.gmail.com'
207 EMAIL_PORT = 587
208 EMAIL_HOST_USER = 'xxxx@gmail.com'
209 EMAIL_HOST_PASSWORD = 'xxxx' # noqa
210 EMAIL_USE_TLS = True
212 :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
213 :param subject: Subject of the email
214 :param text: Body (text), optional
215 :param html: Body (html), optional
216 :param sender: Sender email, or settings.DEFAULT_FROM_EMAIL if missing
217 :param files: Paths to files to attach
218 :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
219 :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
220 :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
221 :return: Status code 202 if emails were sent successfully
222 """
223 if files is None:
224 files = []
225 from_clean = make_email_recipient(sender or settings.DEFAULT_FROM_EMAIL)
226 recipients_clean = make_email_recipient_list(recipients)
227 cc_recipients_clean = make_email_recipient_list(cc_recipients)
228 bcc_recipients_clean = make_email_recipient_list(bcc_recipients)
230 try:
231 mail = EmailMultiAlternatives(
232 subject=subject,
233 body=text,
234 from_email='"{}" <{}>'.format(*from_clean),
235 to=['"{}" <{}>'.format(*r) for r in recipients_clean],
236 bcc=['"{}" <{}>'.format(*r) for r in bcc_recipients_clean],
237 cc=['"{}" <{}>'.format(*r) for r in cc_recipients_clean],
238 )
239 for filename in files:
240 mail.attach_file(filename)
241 if html:
242 mail.attach_alternative(content=html, mimetype="text/html")
244 send_time = now()
245 mail.send(fail_silently=False)
246 send_dt = (now() - send_time).total_seconds()
247 logger.info("EMAIL_SENT %s", {"time": send_dt, "to": recipients, "subject": subject})
249 except Exception as e:
250 logger.error("EMAIL_ERROR %s", {"to": recipients, "subject": subject, "exception": str(e)})
251 if exceptions:
252 raise
253 return -1
255 return 202