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 

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 jutil.logs import log_event 

10from base64 import b64encode 

11from os.path import basename 

12 

13 

14logger = logging.getLogger(__name__) 

15 

16 

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

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

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

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

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