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 # pylint: disable=import-error

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 base64 import b64encode

10 from os.path import basename

11

12

13 logger = logging.getLogger(__name__)

14

15

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

30

31

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

49

50

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

65

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)

82

83

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

99

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

118

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

121

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)

128

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

133

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

141

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)

149

150 # stop SendGrid from replacing all links in the email

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

152

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)

162

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

169

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 )

179

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

185

186 return res.status_code

187

188

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

202

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)

205

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

211

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)

229

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

243

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

248

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

254

255 return 202