Hide keyboard shortcuts

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 # 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.timezone import now 

8from django.utils.translation import gettext as _ 

9from base64 import b64encode 

10from os.path import basename 

11 

12 

13logger = logging.getLogger(__name__) 

14 

15 

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 

30 

31 

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

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

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

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