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