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

1#!/usr/bin/env python 

2 

3""" 

4camcops_server/cc_modules/cc_email.py 

5 

6=============================================================================== 

7 

8 Copyright (C) 2012-2020 Rudolf Cardinal (rudolf@pobox.com). 

9 

10 This file is part of CamCOPS. 

11 

12 CamCOPS is free software: you can redistribute it and/or modify 

13 it under the terms of the GNU General Public License as published by 

14 the Free Software Foundation, either version 3 of the License, or 

15 (at your option) any later version. 

16 

17 CamCOPS is distributed in the hope that it will be useful, 

18 but WITHOUT ANY WARRANTY; without even the implied warranty of 

19 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

20 GNU General Public License for more details. 

21 

22 You should have received a copy of the GNU General Public License 

23 along with CamCOPS. If not, see <https://www.gnu.org/licenses/>. 

24 

25=============================================================================== 

26 

27**Email functions/log class.** 

28 

29""" 

30 

31import email.utils 

32import logging 

33from typing import List, Sequence, Tuple 

34 

35from cardinal_pythonlib.datetimefunc import ( 

36 convert_datetime_to_utc, 

37 get_now_localtz_pendulum, 

38 get_now_utc_pendulum, 

39) 

40from cardinal_pythonlib.email.sendmail import ( 

41 COMMASPACE, 

42 make_email, 

43 send_msg, 

44 STANDARD_SMTP_PORT, 

45 STANDARD_TLS_PORT, 

46) 

47from cardinal_pythonlib.logs import BraceStyleAdapter 

48from sqlalchemy.orm import reconstructor 

49from sqlalchemy.sql.schema import Column 

50from sqlalchemy.sql.sqltypes import ( 

51 Boolean, 

52 BigInteger, 

53 DateTime, 

54 Integer, 

55 Text, 

56) 

57 

58from camcops_server.cc_modules.cc_sqlalchemy import Base 

59from camcops_server.cc_modules.cc_sqla_coltypes import ( 

60 CharsetColType, 

61 EmailAddressColType, 

62 HostnameColType, 

63 LongText, 

64 MimeTypeColType, 

65 Rfc2822DateColType, 

66 UserNameExternalColType, 

67) 

68 

69log = BraceStyleAdapter(logging.getLogger(__name__)) 

70 

71 

72# ============================================================================= 

73# Email class 

74# ============================================================================= 

75 

76class Email(Base): 

77 """ 

78 Class representing an e-mail sent from CamCOPS. 

79 

80 This is abstract, in that it doesn't care about the purpose of the e-mail. 

81 It's cross-referenced from classes that use it, such as 

82 :class:`camcops_server.cc_modules.cc_exportmodels.ExportedTaskEmail`. 

83 """ 

84 __tablename__ = "_emails" 

85 

86 # ------------------------------------------------------------------------- 

87 # Basic things 

88 # ------------------------------------------------------------------------- 

89 id = Column( 

90 "id", BigInteger, primary_key=True, autoincrement=True, 

91 comment="Arbitrary primary key" 

92 ) 

93 created_at_utc = Column( 

94 "created_at_utc", DateTime, 

95 comment="Date/time message was created (UTC)" 

96 ) 

97 # ------------------------------------------------------------------------- 

98 # Headers 

99 # ------------------------------------------------------------------------- 

100 date = Column( 

101 "date", Rfc2822DateColType, 

102 comment="Email date in RFC 2822 format" 

103 ) 

104 from_addr = Column( 

105 "from_addr", EmailAddressColType, 

106 comment="Email 'From:' field" 

107 ) 

108 sender = Column( 

109 "sender", EmailAddressColType, 

110 comment="Email 'Sender:' field" 

111 ) 

112 reply_to = Column( 

113 "reply_to", EmailAddressColType, 

114 comment="Email 'Reply-To:' field" 

115 ) 

116 to = Column( 

117 "to", Text, 

118 comment="Email 'To:' field" 

119 ) 

120 cc = Column( 

121 "cc", Text, 

122 comment="Email 'Cc:' field" 

123 ) 

124 bcc = Column( 

125 "bcc", Text, 

126 comment="Email 'Bcc:' field" 

127 ) 

128 subject = Column( 

129 "subject", Text, 

130 comment="Email 'Subject:' field" 

131 ) 

132 # ------------------------------------------------------------------------- 

133 # Body, message 

134 # ------------------------------------------------------------------------- 

135 body = Column( 

136 "body", Text, 

137 comment="Email body" 

138 ) 

139 content_type = Column( 

140 "content_type", MimeTypeColType, 

141 comment="MIME type for e-mail body" 

142 ) 

143 charset = Column( 

144 "charset", CharsetColType, 

145 comment="Character set for e-mail body" 

146 ) 

147 msg_string = Column( 

148 "msg_string", LongText, 

149 comment="Full encoded e-mail" 

150 ) 

151 # ------------------------------------------------------------------------- 

152 # Server 

153 # ------------------------------------------------------------------------- 

154 host = Column( 

155 "host", HostnameColType, 

156 comment="Email server" 

157 ) 

158 port = Column( 

159 "port", Integer, 

160 comment="Port number on e-mail server" 

161 ) 

162 username = Column( 

163 "username", UserNameExternalColType, 

164 comment="Username on e-mail server" 

165 ) 

166 use_tls = Column( 

167 "use_tls", Boolean, 

168 comment="Use TLS?" 

169 ) 

170 # ------------------------------------------------------------------------- 

171 # Status 

172 # ------------------------------------------------------------------------- 

173 sent = Column( 

174 "sent", Boolean, default=False, nullable=False, 

175 comment="Sent?" 

176 ) 

177 sent_at_utc = Column( 

178 "sent_at_utc", DateTime, 

179 comment="Date/time message was sent (UTC)" 

180 ) 

181 sending_failure_reason = Column( 

182 "sending_failure_reason", Text, 

183 comment="Reason for sending failure" 

184 ) 

185 

186 def __init__(self, 

187 from_addr: str = "", 

188 date: str = None, 

189 sender: str = "", 

190 reply_to: str = "", 

191 to: str = "", 

192 cc: str = "", 

193 bcc: str = "", 

194 subject: str = "", 

195 body: str = "", 

196 content_type: str = "text/plain", 

197 charset: str = "utf8", 

198 attachment_filenames: Sequence[str] = None, 

199 attachments_binary: Sequence[Tuple[str, bytes]] = None, 

200 save_msg_string: bool = False) -> None: 

201 """ 

202 Args: 

203 from_addr: name of the sender for the "From:" field 

204 date: e-mail date in RFC 2822 format, or ``None`` for "now" 

205 sender: name of the sender for the "Sender:" field 

206 reply_to: name of the sender for the "Reply-To:" field 

207 

208 to: e-mail address(es) of the recipients for "To:" field, as a 

209 CSV list 

210 cc: e-mail address(es) of the recipients for "Cc:" field, as a 

211 CSV list 

212 bcc: e-mail address(es) of the recipients for "Bcc:" field, as a 

213 CSV list 

214 

215 subject: e-mail subject 

216 body: e-mail body 

217 content_type: MIME type for body content, default ``text/plain`` 

218 charset: character set for body; default ``utf8`` 

219 charset: 

220 

221 attachment_filenames: filenames of attachments to add 

222 attachments_binary: binary attachments to add, as a list of 

223 ``filename, bytes`` tuples 

224 

225 save_msg_string: save the encoded message string? (May take 

226 significant space in the database). 

227 """ 

228 # Note: we permit from_addr to be blank only for automated database 

229 # copying. 

230 

231 # --------------------------------------------------------------------- 

232 # Timestamp 

233 # --------------------------------------------------------------------- 

234 now_local = get_now_localtz_pendulum() 

235 self.created_at_utc = convert_datetime_to_utc(now_local) 

236 

237 # ------------------------------------------------------------------------- 

238 # Arguments 

239 # ------------------------------------------------------------------------- 

240 if not date: 

241 date = email.utils.format_datetime(now_local) 

242 attachment_filenames = attachment_filenames or [] # type: List[str] 

243 attachments_binary = attachments_binary or [] # type: List[Tuple[str, bytes]] # noqa 

244 if attachments_binary: 

245 attachment_binary_filenames, attachment_binaries = zip( 

246 *attachments_binary) 

247 else: 

248 attachment_binary_filenames = [] # type: List[str] 

249 attachment_binaries = [] # type: List[bytes] 

250 # ... https://stackoverflow.com/questions/13635032/what-is-the-inverse-function-of-zip-in-python # noqa 

251 # Other checks performed by our e-mail function below 

252 

253 # --------------------------------------------------------------------- 

254 # Transient fields 

255 # --------------------------------------------------------------------- 

256 self.password = None 

257 self.msg = make_email( 

258 from_addr=from_addr, 

259 date=date, 

260 sender=sender, 

261 reply_to=reply_to, 

262 to=to, 

263 cc=cc, 

264 bcc=bcc, 

265 subject=subject, 

266 body=body, 

267 content_type=content_type, 

268 attachment_filenames=attachment_filenames, 

269 attachment_binaries=attachment_binaries, 

270 attachment_binary_filenames=attachment_binary_filenames, 

271 ) if from_addr else None 

272 

273 # --------------------------------------------------------------------- 

274 # Database fields 

275 # --------------------------------------------------------------------- 

276 self.date = date 

277 self.from_addr = from_addr 

278 self.sender = sender 

279 self.reply_to = reply_to 

280 self.to = to 

281 self.cc = cc 

282 self.bcc = bcc 

283 self.subject = subject 

284 self.body = body 

285 self.content_type = content_type 

286 self.charset = charset 

287 if save_msg_string: 

288 self.msg_string = self.msg.as_string() 

289 

290 @reconstructor 

291 def init_on_load(self) -> None: 

292 """ 

293 Called when SQLAlchemy recreates an object; see 

294 https://docs.sqlalchemy.org/en/latest/orm/constructors.html. 

295 """ 

296 self.password = None 

297 self.msg = None 

298 

299 def send(self, 

300 host: str, 

301 username: str, 

302 password: str, 

303 port: int = None, 

304 use_tls: bool = True) -> bool: 

305 """ 

306 Sends message and returns success. 

307 """ 

308 if port is None: 

309 port = STANDARD_TLS_PORT if use_tls else STANDARD_SMTP_PORT 

310 

311 msg = None 

312 msg_string = None 

313 if self.msg: 

314 msg = self.msg 

315 elif self.msg_string: 

316 msg_string = self.msg_string 

317 else: 

318 log.error("Can't send message; not present (not saved?)") 

319 return False 

320 

321 # Password not always required (for insecure servers...) 

322 

323 if self.sent: 

324 log.info("Resending message") 

325 

326 self.host = host 

327 self.port = port 

328 self.username = username 

329 # don't save password 

330 self.use_tls = use_tls 

331 to_addrs = COMMASPACE.join( 

332 x for x in [self.to, self.cc, self.bcc] if x) 

333 header_components = filter(None, [ 

334 f"To: {self.to}" if self.to else "", 

335 f"Cc: {self.cc}" if self.cc else "", 

336 f"Bcc: {self.bcc}" if self.bcc else "", # noqa 

337 f"Subject: {self.subject}" if self.subject else "", 

338 ]) 

339 log.info("Sending email -- {}", " -- ".join(header_components)) 

340 try: 

341 send_msg( 

342 from_addr=self.from_addr, 

343 to_addrs=to_addrs, 

344 host=host, 

345 user=username, 

346 password=password, 

347 port=port, 

348 use_tls=use_tls, 

349 msg=msg, 

350 msg_string=msg_string, 

351 ) 

352 log.debug("... sent") 

353 self.sent = True 

354 self.sent_at_utc = get_now_utc_pendulum() 

355 self.sending_failure_reason = None 

356 except RuntimeError as e: 

357 log.error("Failed to send e-mail: {!s}", e) 

358 if not self.sent: 

359 self.sent = False 

360 self.sending_failure_reason = str(e)