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
« prev ^ index » next coverage.py v6.5.0, created at 2022-11-08 23:14 +0000
1#!/usr/bin/env python
3"""
4camcops_server/cc_modules/cc_email.py
6===============================================================================
8 Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
9 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).
11 This file is part of CamCOPS.
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.
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.
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/>.
26===============================================================================
28**Email functions/log class.**
30"""
32import email.utils
33import logging
34from typing import List, Sequence, Tuple
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)
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)
71log = BraceStyleAdapter(logging.getLogger(__name__))
74# =============================================================================
75# Email class
76# =============================================================================
79class Email(Base):
80 """
81 Class representing an e-mail sent from CamCOPS.
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 """
88 __tablename__ = "_emails"
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 )
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
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
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:
197 attachment_filenames: filenames of attachments to add
198 attachments_binary: binary attachments to add, as a list of
199 ``filename, bytes`` tuples
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.
207 # ---------------------------------------------------------------------
208 # Timestamp
209 # ---------------------------------------------------------------------
210 now_local = get_now_localtz_pendulum()
211 self.created_at_utc = convert_datetime_to_utc(now_local)
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
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 )
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()
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
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
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
308 # Password not always required (for insecure servers...)
310 if self.sent:
311 log.info("Resending message")
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
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)
355 return False