Coverage for jutil/email.py: 25%

# Coverage for jutil/email.py : 25%

## 111 statements

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

1 import logging

2 from email.utils import parseaddr

3 from typing import Optional, Union, Tuple, Sequence, List

4 from django.conf import settings

5 from django.core.exceptions import ValidationError

6 from django.core.mail import EmailMultiAlternatives

7 from django.utils.timezone import now

8 from django.utils.translation import gettext as _

9 from jutil.logs import log_event

10 from base64 import b64encode

11 from os.path import basename

12

13

14 logger = logging.getLogger(__name__)

15

16

17 def 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

31

32

33 def 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

48

49

50 def 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.

60

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)

75

76

77 def 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

88

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

104

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')

107

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)

114

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

119

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]))

127

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)

131

132 # stop SendGrid from replacing all links in the email

133 mail.tracking_settings = TrackingSettings(click_tracking=ClickTracking(enable=False))

134

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)

144

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()

151

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})

156

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

163

164 return res.status_code

165

166

167 def 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.

176

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)

179

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

185

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)

203

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')

217

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})

222

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

229

230 return 202