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

47

48

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

59

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)

74

75

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

87

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

106

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

109

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)

116

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

121

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

129

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)

133

134 # stop SendGrid from replacing all links in the email

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

136

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)

146

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

153

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

158

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

164

165 return res.status_code

166

167

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

177

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)

180

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

186

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)

204

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

218

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

223

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

229

230 return 202