Coverage for cc_modules/cc_email.py: 40%

99 statements  

« prev     ^ index     » next       coverage.py v7.9.2, created at 2025-07-15 14:23 +0100

1""" 

2camcops_server/cc_modules/cc_email.py 

3 

4=============================================================================== 

5 

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

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

8 

9 This file is part of CamCOPS. 

10 

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

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

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

14 (at your option) any later version. 

15 

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

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

18 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

19 GNU General Public License for more details. 

20 

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

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

23 

24=============================================================================== 

25 

26**Email functions/log class.** 

27 

28""" 

29 

30import datetime 

31import email.utils 

32import logging 

33from typing import Optional, 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.httpconst import MimeType 

48from cardinal_pythonlib.logs import BraceStyleAdapter 

49from sqlalchemy.orm import Mapped, mapped_column, reconstructor 

50from sqlalchemy.sql.sqltypes import ( 

51 BigInteger, 

52 Integer, 

53 Text, 

54) 

55 

56from camcops_server.cc_modules.cc_sqlalchemy import Base 

57from camcops_server.cc_modules.cc_sqla_coltypes import ( 

58 CharsetColType, 

59 EmailAddressColType, 

60 HostnameColType, 

61 LongText, 

62 MimeTypeColType, 

63 Rfc2822DateColType, 

64 UserNameExternalColType, 

65) 

66 

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

68 

69 

70# ============================================================================= 

71# Email class 

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

73 

74 

75class Email(Base): 

76 """ 

77 Class representing an e-mail sent from CamCOPS. 

78 

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

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

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

82 """ 

83 

84 __tablename__ = "_emails" 

85 

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

87 # Basic things 

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

89 id: Mapped[int] = mapped_column( 

90 # SQLite doesn't support autoincrement with BigInteger 

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

92 primary_key=True, 

93 autoincrement=True, 

94 comment="Arbitrary primary key", 

95 ) 

96 created_at_utc: Mapped[Optional[datetime.datetime]] = mapped_column( 

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

98 ) 

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

100 # Headers 

101 # ------------------------------------------------------------------------- 

102 date: Mapped[Optional[str]] = mapped_column( 

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

104 ) 

105 from_addr: Mapped[Optional[str]] = mapped_column( 

106 EmailAddressColType, comment="Email 'From:' field" 

107 ) 

108 sender: Mapped[Optional[str]] = mapped_column( 

109 EmailAddressColType, comment="Email 'Sender:' field" 

110 ) 

111 reply_to: Mapped[Optional[str]] = mapped_column( 

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

113 ) 

114 to: Mapped[Optional[str]] = mapped_column( 

115 Text, comment="Email 'To:' field" 

116 ) 

117 cc: Mapped[Optional[str]] = mapped_column( 

118 Text, comment="Email 'Cc:' field" 

119 ) 

120 bcc: Mapped[Optional[str]] = mapped_column( 

121 Text, comment="Email 'Bcc:' field" 

122 ) 

123 subject: Mapped[Optional[str]] = mapped_column( 

124 Text, comment="Email 'Subject:' field" 

125 ) 

126 # ------------------------------------------------------------------------- 

127 # Body, message 

128 # ------------------------------------------------------------------------- 

129 body: Mapped[Optional[str]] = mapped_column(Text, comment="Email body") 

130 content_type: Mapped[Optional[str]] = mapped_column( 

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

132 ) 

133 charset: Mapped[Optional[str]] = mapped_column( 

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

135 ) 

136 msg_string: Mapped[Optional[str]] = mapped_column( 

137 LongText, comment="Full encoded e-mail" 

138 ) 

139 # ------------------------------------------------------------------------- 

140 # Server 

141 # ------------------------------------------------------------------------- 

142 host: Mapped[Optional[str]] = mapped_column( 

143 HostnameColType, comment="Email server" 

144 ) 

145 port: Mapped[Optional[int]] = mapped_column( 

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

147 ) 

148 username: Mapped[Optional[str]] = mapped_column( 

149 UserNameExternalColType, 

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

151 ) 

152 use_tls: Mapped[Optional[bool]] = mapped_column(comment="Use TLS?") 

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

154 # Status 

155 # ------------------------------------------------------------------------- 

156 sent: Mapped[bool] = mapped_column(default=False, comment="Sent?") 

157 sent_at_utc: Mapped[Optional[datetime.datetime]] = mapped_column( 

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

159 ) 

160 sending_failure_reason: Mapped[Optional[str]] = mapped_column( 

161 Text, comment="Reason for sending failure" 

162 ) 

163 

164 def __init__( 

165 self, 

166 from_addr: str = "", 

167 date: str = None, 

168 sender: str = "", 

169 reply_to: str = "", 

170 to: str = "", 

171 cc: str = "", 

172 bcc: str = "", 

173 subject: str = "", 

174 body: str = "", 

175 content_type: str = MimeType.TEXT, 

176 charset: str = "utf8", 

177 attachment_filenames: Sequence[str] = None, 

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

179 save_msg_string: bool = False, 

180 ) -> None: 

181 """ 

182 Args: 

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

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

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

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

187 

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

189 CSV list 

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

191 CSV list 

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

193 CSV list 

194 

195 subject: e-mail subject 

196 body: e-mail body 

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

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

199 charset: 

200 

201 attachment_filenames: filenames of attachments to add 

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

203 ``filename, bytes`` tuples 

204 

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

206 significant space in the database). 

207 """ 

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

209 # copying. 

210 

211 # --------------------------------------------------------------------- 

212 # Timestamp 

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

214 now_local = get_now_localtz_pendulum() 

215 self.created_at_utc = convert_datetime_to_utc(now_local) 

216 

217 # ------------------------------------------------------------------------- 

218 # Arguments 

219 # ------------------------------------------------------------------------- 

220 if not date: 

221 date = email.utils.format_datetime(now_local) 

222 attachment_filenames = attachment_filenames or [] 

223 attachments_binary = attachments_binary or [] 

224 if attachments_binary: 

225 attachment_binary_filenames, attachment_binaries = zip( 

226 *attachments_binary 

227 ) 

228 else: 

229 attachment_binary_filenames = [] # type: ignore[assignment] # type: ignore[no-redef] # noqa: E501 

230 attachment_binaries = [] # type: ignore[assignment] # type: ignore[no-redef] # noqa: E501 

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 "", 

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