Coverage for cc_modules/cc_email.py : 41%

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
3"""
4camcops_server/cc_modules/cc_email.py
6===============================================================================
8 Copyright (C) 2012-2020 Rudolf Cardinal (rudolf@pobox.com).
10 This file is part of CamCOPS.
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.
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.
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/>.
25===============================================================================
27**Email functions/log class.**
29"""
31import email.utils
32import logging
33from typing import List, Sequence, Tuple
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)
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)
69log = BraceStyleAdapter(logging.getLogger(__name__))
72# =============================================================================
73# Email class
74# =============================================================================
76class Email(Base):
77 """
78 Class representing an e-mail sent from CamCOPS.
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"
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 )
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
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
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:
221 attachment_filenames: filenames of attachments to add
222 attachments_binary: binary attachments to add, as a list of
223 ``filename, bytes`` tuples
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.
231 # ---------------------------------------------------------------------
232 # Timestamp
233 # ---------------------------------------------------------------------
234 now_local = get_now_localtz_pendulum()
235 self.created_at_utc = convert_datetime_to_utc(now_local)
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
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
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()
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
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
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
321 # Password not always required (for insecure servers...)
323 if self.sent:
324 log.info("Resending message")
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)