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
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 jutil.logs import log_event
10from base64 import b64encode
11from os.path import basename
14logger = logging.getLogger(__name__)
17def make_email_recipient(val: Union[str, Tuple[str, str]]) -> Tuple[str, str]:
18 """
19 Returns (name, email) tuple.
20 :param val:
21 :return: (name, email)
22 """
23 if isinstance(val, str):
24 res = parseaddr(val.strip())
25 if len(res) != 2 or not res[1]: 25 ↛ 26line 25 didn't jump to line 26, because the condition on line 25 was never true
26 raise ValidationError(_('Invalid email recipient: {}'.format(val)))
27 return res[0] or res[1], res[1]
28 if len(val) != 2: 28 ↛ 29line 28 didn't jump to line 29, because the condition on line 28 was never true
29 raise ValidationError(_('Invalid email recipient: {}'.format(val)))
30 return val
33def make_email_recipient_list(recipients: Optional[Union[str, Sequence[Union[str, Tuple[str, str]]]]]) -> List[Tuple[str, str]]:
34 """
35 Returns list of (name, email) tuples.
36 :param recipients:
37 :return: list of (name, email)
38 """
39 out: List[Tuple[str, str]] = []
40 if recipients is not None: 40 ↛ 47line 40 didn't jump to line 47, because the condition on line 40 was never false
41 if isinstance(recipients, str): 41 ↛ 42line 41 didn't jump to line 42, because the condition on line 41 was never true
42 recipients = recipients.split(',')
43 for val in recipients:
44 if not val: 44 ↛ 45line 44 didn't jump to line 45, because the condition on line 44 was never true
45 continue
46 out.append(make_email_recipient(val))
47 return out
50def send_email(recipients: Sequence[Union[str, Tuple[str, str]]], # noqa
51 subject: str, text: str = '', html: str = '',
52 sender: Union[str, Tuple[str, str]] = '',
53 files: Optional[Sequence[str]] = None,
54 cc_recipients: Optional[Sequence[Union[str, Tuple[str, str]]]] = None,
55 bcc_recipients: Optional[Sequence[Union[str, Tuple[str, str]]]] = None,
56 exceptions: bool = False):
57 """
58 Sends email. Supports both SendGrid API client and SMTP connection.
59 See send_email_sendgrid() for SendGrid specific requirements.
61 :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
62 :param subject: Subject of the email
63 :param text: Body (text), optional
64 :param html: Body (html), optional
65 :param sender: Sender email, or settings.DEFAULT_FROM_EMAIL if missing
66 :param files: Paths to files to attach
67 :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
68 :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
69 :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
70 :return: Status code 202 if emails were sent successfully
71 """
72 if hasattr(settings, 'EMAIL_SENDGRID_API_KEY') and settings.EMAIL_SENDGRID_API_KEY:
73 return send_email_sendgrid(recipients, subject, text, html, sender, files, cc_recipients, bcc_recipients, exceptions)
74 return send_email_smtp(recipients, subject, text, html, sender, files, cc_recipients, bcc_recipients, exceptions)
77def send_email_sendgrid(recipients: Sequence[Union[str, Tuple[str, str]]], subject: str, # noqa
78 text: str = '', html: str = '',
79 sender: Union[str, Tuple[str, str]] = '',
80 files: Optional[Sequence[str]] = None,
81 cc_recipients: Optional[Sequence[Union[str, Tuple[str, str]]]] = None,
82 bcc_recipients: Optional[Sequence[Union[str, Tuple[str, str]]]] = None,
83 exceptions: bool = False):
84 """
85 Sends email using SendGrid API. Following requirements:
86 * pip install sendgrid==6.3.1
87 * settings.EMAIL_SENDGRID_API_KEY must be set and
89 :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
90 :param subject: Subject of the email
91 :param text: Body (text), optional
92 :param html: Body (html), optional
93 :param sender: Sender email, or settings.DEFAULT_FROM_EMAIL if missing
94 :param files: Paths to files to attach
95 :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
96 :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
97 :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
98 :return: Status code 202 if emails were sent successfully
99 """
100 import sendgrid # type: ignore # pylint: disable=import-outside-toplevel
101 from sendgrid.helpers.mail import Content, Mail, Attachment # type: ignore # pylint: disable=import-outside-toplevel
102 from sendgrid import ClickTracking, FileType, FileName, TrackingSettings # type: ignore # pylint: disable=import-outside-toplevel
103 from sendgrid import Personalization, FileContent, ContentId, Disposition # type: ignore # pylint: disable=import-outside-toplevel
105 if not hasattr(settings, 'EMAIL_SENDGRID_API_KEY') or not settings.EMAIL_SENDGRID_API_KEY:
106 raise Exception('EMAIL_SENDGRID_API_KEY not defined in Django settings')
108 if files is None:
109 files = []
110 from_clean = make_email_recipient(sender or settings.DEFAULT_FROM_EMAIL)
111 recipients_clean = make_email_recipient_list(recipients)
112 cc_recipients_clean = make_email_recipient_list(cc_recipients)
113 bcc_recipients_clean = make_email_recipient_list(bcc_recipients)
115 try:
116 sg = sendgrid.SendGridAPIClient(api_key=settings.EMAIL_SENDGRID_API_KEY)
117 text_content = Content('text/plain', text) if text else None
118 html_content = Content('text/html', html) if html else None
120 personalization = Personalization()
121 for recipient in recipients_clean:
122 personalization.add_email(sendgrid.To(email=recipient[1], name=recipient[0]))
123 for recipient in cc_recipients_clean:
124 personalization.add_email(sendgrid.Cc(email=recipient[1], name=recipient[0]))
125 for recipient in bcc_recipients_clean:
126 personalization.add_email(sendgrid.Bcc(email=recipient[1], name=recipient[0]))
128 mail = Mail(from_email=sendgrid.From(email=from_clean[1], name=from_clean[0]),
129 subject=subject, plain_text_content=text_content, html_content=html_content)
130 mail.add_personalization(personalization)
132 # stop SendGrid from replacing all links in the email
133 mail.tracking_settings = TrackingSettings(click_tracking=ClickTracking(enable=False))
135 for filename in files:
136 with open(filename, 'rb') as fp:
137 attachment = Attachment()
138 attachment.file_type = FileType("application/octet-stream")
139 attachment.file_name = FileName(basename(filename))
140 attachment.file_content = FileContent(b64encode(fp.read()).decode())
141 attachment.content_id = ContentId(basename(filename))
142 attachment.disposition = Disposition("attachment")
143 mail.add_attachment(attachment)
145 send_time = now()
146 mail_body = mail.get()
147 if hasattr(settings, 'EMAIL_SENDGRID_API_DEBUG') and settings.EMAIL_SENDGRID_API_DEBUG:
148 logger.info('SendGrid API payload: %s', mail_body)
149 res = sg.client.mail.send.post(request_body=mail_body)
150 send_dt = (now() - send_time).total_seconds()
152 if res.status_code == 202:
153 log_event('EMAIL_SENT', data={'time': send_dt, 'to': recipients, 'subject': subject, 'status': res.status_code})
154 else:
155 log_event('EMAIL_ERROR', data={'time': send_dt, 'to': recipients, 'subject': subject, 'status': res.status_code, 'body': res.body})
157 except Exception as e:
158 logger.error('Failed to send email (SendGrid) to %s: %s', recipients, e)
159 log_event('EMAIL_ERROR', data={'to': recipients, 'subject': subject, 'exception': str(e)})
160 if exceptions:
161 raise
162 return -1
164 return res.status_code
167def send_email_smtp(recipients: Sequence[Union[str, Tuple[str, str]]], # noqa
168 subject: str, text: str = '', html: str = '',
169 sender: Union[str, Tuple[str, str]] = '',
170 files: Optional[Sequence[str]] = None,
171 cc_recipients: Optional[Sequence[Union[str, Tuple[str, str]]]] = None,
172 bcc_recipients: Optional[Sequence[Union[str, Tuple[str, str]]]] = None,
173 exceptions: bool = False):
174 """
175 Sends email using SMTP connection using standard Django email settings.
177 For example, to send email via Gmail:
178 (Note that you might need to generate app-specific password at https://myaccount.google.com/apppasswords)
180 EMAIL_HOST = 'smtp.gmail.com'
181 EMAIL_PORT = 587
182 EMAIL_HOST_USER = 'xxxx@gmail.com'
183 EMAIL_HOST_PASSWORD = 'xxxx' # noqa
184 EMAIL_USE_TLS = True
186 :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
187 :param subject: Subject of the email
188 :param text: Body (text), optional
189 :param html: Body (html), optional
190 :param sender: Sender email, or settings.DEFAULT_FROM_EMAIL if missing
191 :param files: Paths to files to attach
192 :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
193 :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
194 :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
195 :return: Status code 202 if emails were sent successfully
196 """
197 if files is None:
198 files = []
199 from_clean = make_email_recipient(sender or settings.DEFAULT_FROM_EMAIL)
200 recipients_clean = make_email_recipient_list(recipients)
201 cc_recipients_clean = make_email_recipient_list(cc_recipients)
202 bcc_recipients_clean = make_email_recipient_list(bcc_recipients)
204 try:
205 mail = EmailMultiAlternatives(
206 subject=subject,
207 body=text,
208 from_email='"{}" <{}>'.format(*from_clean),
209 to=['"{}" <{}>'.format(*r) for r in recipients_clean],
210 bcc=['"{}" <{}>'.format(*r) for r in bcc_recipients_clean],
211 cc=['"{}" <{}>'.format(*r) for r in cc_recipients_clean],
212 )
213 for filename in files:
214 mail.attach_file(filename)
215 if html:
216 mail.attach_alternative(content=html, mimetype='text/html')
218 send_time = now()
219 mail.send(fail_silently=False)
220 send_dt = (now() - send_time).total_seconds()
221 log_event('EMAIL_SENT', data={'time': send_dt, 'to': recipients, 'subject': subject})
223 except Exception as e:
224 logger.error('Failed to send email (SMTP) to %s: %s', recipients, e)
225 log_event('EMAIL_ERROR', data={'to': recipients, 'subject': subject, 'exception': str(e)})
226 if exceptions:
227 raise
228 return -1
230 return 202