Coverage for jutil/email.py: 24%

111 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2022-10-07 16:40 -0500

1import logging 

2from email.utils import parseaddr # pylint: disable=import-error 

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.html import strip_tags 

8from django.utils.translation import gettext as _ 

9from base64 import b64encode 

10from os.path import basename 

11 

12logger = logging.getLogger(__name__) 

13 

14 

15def make_email_recipient(val: Union[str, Tuple[str, str]]) -> Tuple[str, str]: 

16 """ 

17 Returns (name, email) tuple. 

18 :param val: 

19 :return: (name, email) 

20 """ 

21 if isinstance(val, str): 

22 res = parseaddr(val.strip()) 

23 if len(res) != 2 or not res[1]: 23 ↛ 24line 23 didn't jump to line 24, because the condition on line 23 was never true

24 raise ValidationError(_("Invalid email recipient: {}".format(val))) 

25 return res[0] or res[1], res[1] 

26 if len(val) != 2: 26 ↛ 27line 26 didn't jump to line 27, because the condition on line 26 was never true

27 raise ValidationError(_("Invalid email recipient: {}".format(val))) 

28 return val 

29 

30 

31def make_email_recipient_list(recipients: Optional[Union[str, Sequence[Union[str, Tuple[str, str]]]]]) -> List[Tuple[str, str]]: 

32 """ 

33 Returns list of (name, email) tuples. 

34 :param recipients: 

35 :return: list of (name, email) 

36 """ 

37 out: List[Tuple[str, str]] = [] 

38 if recipients is not None: 38 ↛ 45line 38 didn't jump to line 45, because the condition on line 38 was never false

39 if isinstance(recipients, str): 39 ↛ 40line 39 didn't jump to line 40, because the condition on line 39 was never true

40 recipients = recipients.split(",") 

41 for val in recipients: 

42 if not val: 42 ↛ 43line 42 didn't jump to line 43, because the condition on line 42 was never true

43 continue 

44 out.append(make_email_recipient(val)) 

45 return out 

46 

47 

48def send_email( # noqa 

49 recipients: Sequence[Union[str, Tuple[str, str]]], 

50 subject: str, 

51 text: str = "", 

52 html: str = "", 

53 sender: Union[str, Tuple[str, str]] = "", 

54 files: Optional[Sequence[str]] = None, 

55 cc_recipients: Optional[Sequence[Union[str, Tuple[str, str]]]] = None, 

56 bcc_recipients: Optional[Sequence[Union[str, Tuple[str, str]]]] = None, 

57 exceptions: bool = False, 

58) -> int: 

59 """ 

60 Sends email. Supports both SendGrid API client and SMTP connection. 

61 See send_email_sendgrid() for SendGrid specific requirements. 

62 

63 :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 

64 :param subject: Subject of the email 

65 :param text: Body (text), optional 

66 :param html: Body (html), optional 

67 :param sender: Sender email, or settings.DEFAULT_FROM_EMAIL if missing 

68 :param files: Paths to files to attach 

69 :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 

70 :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 

71 :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 

72 :return: Status code 202 if emails were sent successfully 

73 """ 

74 if hasattr(settings, "EMAIL_SENDGRID_API_KEY") and settings.EMAIL_SENDGRID_API_KEY: 

75 return send_email_sendgrid(recipients, subject, text, html, sender, files, cc_recipients, bcc_recipients, exceptions) 

76 return send_email_smtp(recipients, subject, text, html, sender, files, cc_recipients, bcc_recipients, exceptions) 

77 

78 

79def send_email_sendgrid( # noqa 

80 recipients: Sequence[Union[str, Tuple[str, str]]], 

81 subject: str, 

82 text: str = "", 

83 html: str = "", 

84 sender: Union[str, Tuple[str, str]] = "", 

85 files: Optional[Sequence[str]] = None, 

86 cc_recipients: Optional[Sequence[Union[str, Tuple[str, str]]]] = None, 

87 bcc_recipients: Optional[Sequence[Union[str, Tuple[str, str]]]] = None, 

88 exceptions: bool = False, 

89 api_key: str = "", 

90) -> int: 

91 """ 

92 Sends email using SendGrid API. Following requirements: 

93 * pip install sendgrid>=6.3.1,<7.0.0 

94 * settings.EMAIL_SENDGRID_API_KEY must be set and 

95 

96 :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 

97 :param subject: Subject of the email 

98 :param text: Body (text), optional 

99 :param html: Body (html), optional 

100 :param sender: Sender email, or settings.DEFAULT_FROM_EMAIL if missing 

101 :param files: Paths to files to attach 

102 :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 

103 :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 

104 :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 

105 :param api_key: Optional Sendgrid API key. Default settings.EMAIL_SENDGRID_API_KEY. 

106 :return: Status code 202 if emails were sent successfully 

107 """ 

108 try: 

109 import sendgrid # type: ignore # pylint: disable=import-outside-toplevel 

110 from sendgrid.helpers.mail import Content, Mail, Attachment # type: ignore # pylint: disable=import-outside-toplevel 

111 from sendgrid import ClickTracking, FileType, FileName, TrackingSettings # type: ignore # pylint: disable=import-outside-toplevel 

112 from sendgrid import Personalization, FileContent, ContentId, Disposition # type: ignore # pylint: disable=import-outside-toplevel 

113 except Exception as err: 

114 raise Exception("Using send_email_sendgrid() requires sendgrid pip install sendgrid>=6.3.1,<7.0.0") from err 

115 

116 if not api_key and hasattr(settings, "EMAIL_SENDGRID_API_KEY"): 

117 api_key = settings.EMAIL_SENDGRID_API_KEY 

118 if not api_key: 

119 raise Exception("EMAIL_SENDGRID_API_KEY not defined in Django settings and API key not passed in to send_email_sendgrid() either") 

120 

121 if files is None: 

122 files = [] 

123 from_clean = make_email_recipient(sender or settings.DEFAULT_FROM_EMAIL) 

124 recipients_clean = make_email_recipient_list(recipients) 

125 cc_recipients_clean = make_email_recipient_list(cc_recipients) 

126 bcc_recipients_clean = make_email_recipient_list(bcc_recipients) 

127 subject = strip_tags(subject) 

128 

129 try: 

130 sg = sendgrid.SendGridAPIClient(api_key=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 if filename: 

155 with open(filename, "rb") as fp: 

156 attachment = Attachment() 

157 attachment.file_type = FileType("application/octet-stream") 

158 attachment.file_name = FileName(basename(filename)) 

159 attachment.file_content = FileContent(b64encode(fp.read()).decode()) 

160 attachment.content_id = ContentId(basename(filename)) 

161 attachment.disposition = Disposition("attachment") 

162 mail.add_attachment(attachment) 

163 

164 mail_body = mail.get() 

165 res = sg.client.mail.send.post(request_body=mail_body) 

166 

167 if res.status_code == 202: 

168 logger.info("EMAIL_SENT %s", {"to": recipients, "subject": subject, "status": res.status_code}) 

169 else: 

170 logger.info( 

171 "EMAIL_ERROR %s", 

172 {"to": recipients, "subject": subject, "status": res.status_code, "body": res.body}, 

173 ) 

174 

175 except Exception as err: 

176 logger.error("EMAIL_ERROR %s", {"to": recipients, "subject": subject, "exception": str(err)}) 

177 if exceptions: 

178 raise 

179 return -1 

180 

181 return res.status_code 

182 

183 

184def send_email_smtp( # noqa 

185 recipients: Sequence[Union[str, Tuple[str, str]]], 

186 subject: str, 

187 text: str = "", 

188 html: str = "", 

189 sender: Union[str, Tuple[str, str]] = "", 

190 files: Optional[Sequence[str]] = None, 

191 cc_recipients: Optional[Sequence[Union[str, Tuple[str, str]]]] = None, 

192 bcc_recipients: Optional[Sequence[Union[str, Tuple[str, str]]]] = None, 

193 exceptions: bool = False, 

194) -> int: 

195 """ 

196 Sends email using SMTP connection using standard Django email settings. 

197 

198 For example, to send email via Gmail: 

199 (Note that you might need to generate app-specific password at https://myaccount.google.com/apppasswords) 

200 

201 EMAIL_HOST = 'smtp.gmail.com' 

202 EMAIL_PORT = 587 

203 EMAIL_HOST_USER = 'xxxx@gmail.com' 

204 EMAIL_HOST_PASSWORD = 'xxxx' # noqa 

205 EMAIL_USE_TLS = True 

206 

207 :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 

208 :param subject: Subject of the email 

209 :param text: Body (text), optional 

210 :param html: Body (html), optional 

211 :param sender: Sender email, or settings.DEFAULT_FROM_EMAIL if missing 

212 :param files: Paths to files to attach 

213 :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 

214 :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 

215 :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 

216 :return: Status code 202 if emails were sent successfully 

217 """ 

218 if files is None: 

219 files = [] 

220 from_clean = make_email_recipient(sender or settings.DEFAULT_FROM_EMAIL) 

221 recipients_clean = make_email_recipient_list(recipients) 

222 cc_recipients_clean = make_email_recipient_list(cc_recipients) 

223 bcc_recipients_clean = make_email_recipient_list(bcc_recipients) 

224 subject = strip_tags(subject) 

225 

226 try: 

227 mail = EmailMultiAlternatives( 

228 subject=subject, 

229 body=text, 

230 from_email='"{}" <{}>'.format(*from_clean), 

231 to=['"{}" <{}>'.format(*r) for r in recipients_clean], 

232 bcc=['"{}" <{}>'.format(*r) for r in bcc_recipients_clean], 

233 cc=['"{}" <{}>'.format(*r) for r in cc_recipients_clean], 

234 ) 

235 for filename in files: 

236 if filename: 

237 mail.attach_file(filename) 

238 if html: 

239 mail.attach_alternative(content=html, mimetype="text/html") 

240 

241 mail.send(fail_silently=False) 

242 logger.info("EMAIL_SENT %s", {"to": recipients, "subject": subject}) 

243 

244 except Exception as e: 

245 logger.error("EMAIL_ERROR %s", {"to": recipients, "subject": subject, "exception": str(e)}) 

246 if exceptions: 

247 raise 

248 return -1 

249 

250 return 202