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

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

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

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