Coverage for cc_modules/cc_email.py: 64%

100 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2022-11-08 23:14 +0000

1#!/usr/bin/env python 

2 

3""" 

4camcops_server/cc_modules/cc_email.py 

5 

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

7 

8 Copyright (C) 2012, University of Cambridge, Department of Psychiatry. 

9 Created by Rudolf Cardinal (rnc1001@cam.ac.uk). 

10 

11 This file is part of CamCOPS. 

12 

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

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

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

16 (at your option) any later version. 

17 

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

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

20 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

21 GNU General Public License for more details. 

22 

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

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

25 

26=============================================================================== 

27 

28**Email functions/log class.** 

29 

30""" 

31 

32import email.utils 

33import logging 

34from typing import List, Sequence, Tuple 

35 

36from cardinal_pythonlib.datetimefunc import ( 

37 convert_datetime_to_utc, 

38 get_now_localtz_pendulum, 

39 get_now_utc_pendulum, 

40) 

41from cardinal_pythonlib.email.sendmail import ( 

42 COMMASPACE, 

43 make_email, 

44 send_msg, 

45 STANDARD_SMTP_PORT, 

46 STANDARD_TLS_PORT, 

47) 

48from cardinal_pythonlib.httpconst import MimeType 

49from cardinal_pythonlib.logs import BraceStyleAdapter 

50from sqlalchemy.orm import reconstructor 

51from sqlalchemy.sql.schema import Column 

52from sqlalchemy.sql.sqltypes import ( 

53 Boolean, 

54 BigInteger, 

55 DateTime, 

56 Integer, 

57 Text, 

58) 

59 

60from camcops_server.cc_modules.cc_sqlalchemy import Base 

61from camcops_server.cc_modules.cc_sqla_coltypes import ( 

62 CharsetColType, 

63 EmailAddressColType, 

64 HostnameColType, 

65 LongText, 

66 MimeTypeColType, 

67 Rfc2822DateColType, 

68 UserNameExternalColType, 

69) 

70 

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

72 

73 

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

75# Email class 

76# ============================================================================= 

77 

78 

79class Email(Base): 

80 """ 

81 Class representing an e-mail sent from CamCOPS. 

82 

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

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

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

86 """ 

87 

88 __tablename__ = "_emails" 

89 

90 # ------------------------------------------------------------------------- 

91 # Basic things 

92 # ------------------------------------------------------------------------- 

93 id = Column( 

94 # SQLite doesn't support autoincrement with BigInteger 

95 "id", 

96 BigInteger().with_variant(Integer, "sqlite"), 

97 primary_key=True, 

98 autoincrement=True, 

99 comment="Arbitrary primary key", 

100 ) 

101 created_at_utc = Column( 

102 "created_at_utc", 

103 DateTime, 

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

105 ) 

106 # ------------------------------------------------------------------------- 

107 # Headers 

108 # ------------------------------------------------------------------------- 

109 date = Column( 

110 "date", Rfc2822DateColType, comment="Email date in RFC 2822 format" 

111 ) 

112 from_addr = Column( 

113 "from_addr", EmailAddressColType, comment="Email 'From:' field" 

114 ) 

115 sender = Column( 

116 "sender", EmailAddressColType, comment="Email 'Sender:' field" 

117 ) 

118 reply_to = Column( 

119 "reply_to", EmailAddressColType, comment="Email 'Reply-To:' field" 

120 ) 

121 to = Column("to", Text, comment="Email 'To:' field") 

122 cc = Column("cc", Text, comment="Email 'Cc:' field") 

123 bcc = Column("bcc", Text, comment="Email 'Bcc:' field") 

124 subject = Column("subject", Text, comment="Email 'Subject:' field") 

125 # ------------------------------------------------------------------------- 

126 # Body, message 

127 # ------------------------------------------------------------------------- 

128 body = Column("body", Text, comment="Email body") 

129 content_type = Column( 

130 "content_type", MimeTypeColType, comment="MIME type for e-mail body" 

131 ) 

132 charset = Column( 

133 "charset", CharsetColType, comment="Character set for e-mail body" 

134 ) 

135 msg_string = Column("msg_string", LongText, comment="Full encoded e-mail") 

136 # ------------------------------------------------------------------------- 

137 # Server 

138 # ------------------------------------------------------------------------- 

139 host = Column("host", HostnameColType, comment="Email server") 

140 port = Column("port", Integer, comment="Port number on e-mail server") 

141 username = Column( 

142 "username", 

143 UserNameExternalColType, 

144 comment="Username on e-mail server", 

145 ) 

146 use_tls = Column("use_tls", Boolean, comment="Use TLS?") 

147 # ------------------------------------------------------------------------- 

148 # Status 

149 # ------------------------------------------------------------------------- 

150 sent = Column( 

151 "sent", Boolean, default=False, nullable=False, comment="Sent?" 

152 ) 

153 sent_at_utc = Column( 

154 "sent_at_utc", DateTime, comment="Date/time message was sent (UTC)" 

155 ) 

156 sending_failure_reason = Column( 

157 "sending_failure_reason", Text, comment="Reason for sending failure" 

158 ) 

159 

160 def __init__( 

161 self, 

162 from_addr: str = "", 

163 date: str = None, 

164 sender: str = "", 

165 reply_to: str = "", 

166 to: str = "", 

167 cc: str = "", 

168 bcc: str = "", 

169 subject: str = "", 

170 body: str = "", 

171 content_type: str = MimeType.TEXT, 

172 charset: str = "utf8", 

173 attachment_filenames: Sequence[str] = None, 

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

175 save_msg_string: bool = False, 

176 ) -> None: 

177 """ 

178 Args: 

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

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

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

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

183 

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

185 CSV list 

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

187 CSV list 

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

189 CSV list 

190 

191 subject: e-mail subject 

192 body: e-mail body 

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

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

195 charset: 

196 

197 attachment_filenames: filenames of attachments to add 

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

199 ``filename, bytes`` tuples 

200 

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

202 significant space in the database). 

203 """ 

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

205 # copying. 

206 

207 # --------------------------------------------------------------------- 

208 # Timestamp 

209 # --------------------------------------------------------------------- 

210 now_local = get_now_localtz_pendulum() 

211 self.created_at_utc = convert_datetime_to_utc(now_local) 

212 

213 # ------------------------------------------------------------------------- 

214 # Arguments 

215 # ------------------------------------------------------------------------- 

216 if not date: 

217 date = email.utils.format_datetime(now_local) 

218 attachment_filenames = ( 

219 attachment_filenames or [] 

220 ) # type: Sequence[str] 

221 attachments_binary = ( 

222 attachments_binary or [] 

223 ) # type: Sequence[Tuple[str, bytes]] 

224 if attachments_binary: 

225 attachment_binary_filenames, attachment_binaries = zip( 

226 *attachments_binary 

227 ) 

228 else: 

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

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

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

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

233 

234 # --------------------------------------------------------------------- 

235 # Transient fields 

236 # --------------------------------------------------------------------- 

237 self.password = None 

238 self.msg = ( 

239 make_email( 

240 from_addr=from_addr, 

241 date=date, 

242 sender=sender, 

243 reply_to=reply_to, 

244 to=to, 

245 cc=cc, 

246 bcc=bcc, 

247 subject=subject, 

248 body=body, 

249 content_type=content_type, 

250 attachment_filenames=attachment_filenames, 

251 attachment_binaries=attachment_binaries, 

252 attachment_binary_filenames=attachment_binary_filenames, 

253 ) 

254 if from_addr 

255 else None 

256 ) 

257 

258 # --------------------------------------------------------------------- 

259 # Database fields 

260 # --------------------------------------------------------------------- 

261 self.date = date 

262 self.from_addr = from_addr 

263 self.sender = sender 

264 self.reply_to = reply_to 

265 self.to = to 

266 self.cc = cc 

267 self.bcc = bcc 

268 self.subject = subject 

269 self.body = body 

270 self.content_type = content_type 

271 self.charset = charset 

272 if save_msg_string: 

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

274 

275 @reconstructor 

276 def init_on_load(self) -> None: 

277 """ 

278 Called when SQLAlchemy recreates an object; see 

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

280 """ 

281 self.password = None 

282 self.msg = None 

283 

284 def send( 

285 self, 

286 host: str, 

287 username: str, 

288 password: str, 

289 port: int = None, 

290 use_tls: bool = True, 

291 ) -> bool: 

292 """ 

293 Sends message and returns success. 

294 """ 

295 if port is None: 

296 port = STANDARD_TLS_PORT if use_tls else STANDARD_SMTP_PORT 

297 

298 msg = None 

299 msg_string = None 

300 if self.msg: 

301 msg = self.msg 

302 elif self.msg_string: 

303 msg_string = self.msg_string 

304 else: 

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

306 return False 

307 

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

309 

310 if self.sent: 

311 log.info("Resending message") 

312 

313 self.host = host 

314 self.port = port 

315 self.username = username 

316 # don't save password 

317 self.use_tls = use_tls 

318 to_addrs = COMMASPACE.join( 

319 x for x in (self.to, self.cc, self.bcc) if x 

320 ) 

321 header_components = filter( 

322 None, 

323 ( 

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

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

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

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

328 ), 

329 ) 

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

331 try: 

332 send_msg( 

333 from_addr=self.from_addr, 

334 to_addrs=to_addrs, 

335 host=host, 

336 user=username, 

337 password=password, 

338 port=port, 

339 use_tls=use_tls, 

340 msg=msg, 

341 msg_string=msg_string, 

342 ) 

343 log.debug("... sent") 

344 self.sent = True 

345 self.sent_at_utc = get_now_utc_pendulum() 

346 self.sending_failure_reason = None 

347 

348 return True 

349 except RuntimeError as e: 

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

351 if not self.sent: 

352 self.sent = False 

353 self.sending_failure_reason = str(e) 

354 

355 return False