Coverage for /home/martinb/.local/share/virtualenvs/camcops/lib/python3.6/site-packages/cardinal_pythonlib/email/sendmail.py : 17%

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# cardinal_pythonlib/email/sendmail.py
4"""
5===============================================================================
7 Original code copyright (C) 2009-2021 Rudolf Cardinal (rudolf@pobox.com).
9 This file is part of cardinal_pythonlib.
11 Licensed under the Apache License, Version 2.0 (the "License");
12 you may not use this file except in compliance with the License.
13 You may obtain a copy of the License at
15 https://www.apache.org/licenses/LICENSE-2.0
17 Unless required by applicable law or agreed to in writing, software
18 distributed under the License is distributed on an "AS IS" BASIS,
19 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
20 See the License for the specific language governing permissions and
21 limitations under the License.
23===============================================================================
25**Sends e-mails from the command line.**
27"""
29import argparse
30import email.encoders
31import email.mime.base
32import email.mime.text
33import email.mime.multipart
34import email.header
35import email.utils
36import logging
37import os
38import re
39import smtplib
40import sys
41from typing import List, NoReturn, Sequence, Tuple, Union
43from cardinal_pythonlib.logs import get_brace_style_log_with_null_handler
45log = get_brace_style_log_with_null_handler(__name__)
48# =============================================================================
49# Constants
50# =============================================================================
52CONTENT_TYPE_TEXT = "text/plain"
53CONTENT_TYPE_HTML = "text/html"
55COMMA = ","
56COMMASPACE = ", "
58STANDARD_SMTP_PORT = 25
59STANDARD_TLS_PORT = 587
62# =============================================================================
63# Make e-mail message
64# =============================================================================
66def make_email(from_addr: str,
67 date: str = None,
68 sender: str = "",
69 reply_to: Union[str, List[str]] = "",
70 to: Union[str, List[str]] = "",
71 cc: Union[str, List[str]] = "",
72 bcc: Union[str, List[str]] = "",
73 subject: str = "",
74 body: str = "",
75 content_type: str = CONTENT_TYPE_TEXT,
76 charset: str = "utf8",
77 attachment_filenames: Sequence[str] = None,
78 attachment_binaries: Sequence[bytes] = None,
79 attachment_binary_filenames: Sequence[str] = None,
80 verbose: bool = False) -> email.mime.multipart.MIMEMultipart:
81 """
82 Makes an e-mail message.
84 Arguments that can be multiple e-mail addresses are (a) a single e-mail
85 address as a string, or (b) a list of strings (each a single e-mail
86 address), or (c) a comma-separated list of multiple e-mail addresses.
88 Args:
89 from_addr: name of the sender for the "From:" field
90 date: e-mail date in RFC 2822 format, or ``None`` for "now"
91 sender: name of the sender for the "Sender:" field
92 reply_to: name of the sender for the "Reply-To:" field
94 to: e-mail address(es) of the recipients for "To:" field
95 cc: e-mail address(es) of the recipients for "Cc:" field
96 bcc: e-mail address(es) of the recipients for "Bcc:" field
98 subject: e-mail subject
99 body: e-mail body
100 content_type: MIME type for body content, default ``text/plain``
101 charset: character set for body; default ``utf8``
103 attachment_filenames: filenames of attachments to add
104 attachment_binaries: binary objects to add as attachments
105 attachment_binary_filenames: filenames corresponding to
106 ``attachment_binaries``
107 verbose: be verbose?
109 Returns:
110 a :class:`email.mime.multipart.MIMEMultipart`
112 Raises:
113 :exc:`AssertionError`, :exc:`ValueError`
115 """
116 def _csv_list_to_list(x: str) -> List[str]:
117 stripped = [item.strip() for item in x.split(COMMA)]
118 return [item for item in stripped if item]
120 def _assert_nocomma(x: Union[str, List[str]]) -> None:
121 if isinstance(x, str):
122 x = [x]
123 for _addr in x:
124 assert COMMA not in _addr, (
125 f"Commas not allowed in e-mail addresses: {_addr!r}"
126 )
128 # -------------------------------------------------------------------------
129 # Arguments
130 # -------------------------------------------------------------------------
131 if not date:
132 date = email.utils.formatdate(localtime=True)
133 assert isinstance(from_addr, str), (
134 f"'From:' can only be a single address "
135 f"(for Python sendmail, not RFC 2822); was {from_addr!r}"
136 )
137 _assert_nocomma(from_addr)
138 assert isinstance(sender, str), (
139 f"'Sender:' can only be a single address; was {sender!r}"
140 )
141 _assert_nocomma(sender)
142 if isinstance(reply_to, str):
143 reply_to = [reply_to] if reply_to else [] # type: List[str]
144 _assert_nocomma(reply_to)
145 if isinstance(to, str):
146 to = _csv_list_to_list(to)
147 if isinstance(cc, str):
148 cc = _csv_list_to_list(cc)
149 if isinstance(bcc, str):
150 bcc = _csv_list_to_list(bcc)
151 assert to or cc or bcc, "No recipients (must have some of: To, Cc, Bcc)"
152 _assert_nocomma(to)
153 _assert_nocomma(cc)
154 _assert_nocomma(bcc)
155 attachment_filenames = attachment_filenames or [] # type: List[str]
156 assert all(attachment_filenames), (
157 f"Missing attachment filenames: {attachment_filenames!r}"
158 )
159 attachment_binaries = attachment_binaries or [] # type: List[bytes]
160 attachment_binary_filenames = attachment_binary_filenames or [] # type: List[str] # noqa
161 assert len(attachment_binaries) == len(attachment_binary_filenames), (
162 "If you specify attachment_binaries or attachment_binary_filenames, "
163 "they must be iterables of the same length."
164 )
165 assert all(attachment_binary_filenames), (
166 f"Missing filenames for attached binaries: "
167 f"{attachment_binary_filenames!r}"
168 )
170 # -------------------------------------------------------------------------
171 # Make message
172 # -------------------------------------------------------------------------
173 msg = email.mime.multipart.MIMEMultipart()
175 # Headers: mandatory
176 msg["From"] = from_addr
177 msg["Date"] = date
178 msg["Subject"] = subject
180 # Headers: optional
181 if sender:
182 msg["Sender"] = sender # Single only, not a list
183 if reply_to:
184 msg["Reply-To"] = COMMASPACE.join(reply_to)
185 if to:
186 msg["To"] = COMMASPACE.join(to)
187 if cc:
188 msg["Cc"] = COMMASPACE.join(cc)
189 if bcc:
190 msg["Bcc"] = COMMASPACE.join(bcc)
192 # Body
193 if content_type == CONTENT_TYPE_TEXT:
194 msgbody = email.mime.text.MIMEText(body, "plain", charset)
195 elif content_type == CONTENT_TYPE_HTML:
196 msgbody = email.mime.text.MIMEText(body, "html", charset)
197 else:
198 raise ValueError("unknown content_type")
199 msg.attach(msgbody)
201 # Attachments
202 # noinspection PyPep8,PyBroadException
203 try:
204 if attachment_filenames:
205 # -----------------------------------------------------------------
206 # Attach things by filename
207 # -----------------------------------------------------------------
208 if verbose:
209 log.debug("attachment_filenames: {}", attachment_filenames)
210 # noinspection PyTypeChecker
211 for f in attachment_filenames:
212 part = email.mime.base.MIMEBase("application", "octet-stream")
213 part.set_payload(open(f, "rb").read())
214 email.encoders.encode_base64(part)
215 part.add_header(
216 'Content-Disposition',
217 'attachment; filename="%s"' % os.path.basename(f)
218 )
219 msg.attach(part)
220 if attachment_binaries:
221 # -----------------------------------------------------------------
222 # Binary attachments, which have a notional filename
223 # -----------------------------------------------------------------
224 if verbose:
225 log.debug("attachment_binary_filenames: {}",
226 attachment_binary_filenames)
227 for i in range(len(attachment_binaries)):
228 blob = attachment_binaries[i]
229 filename = attachment_binary_filenames[i]
230 part = email.mime.base.MIMEBase("application", "octet-stream")
231 part.set_payload(blob)
232 email.encoders.encode_base64(part)
233 part.add_header(
234 'Content-Disposition',
235 'attachment; filename="%s"' % filename)
236 msg.attach(part)
237 except Exception as e:
238 raise ValueError(f"send_email: Failed to attach files: {e}")
240 return msg
243# =============================================================================
244# Send message
245# =============================================================================
247def send_msg(from_addr: str,
248 to_addrs: Union[str, List[str]],
249 host: str,
250 user: str,
251 password: str,
252 port: int = None,
253 use_tls: bool = True,
254 msg: email.mime.multipart.MIMEMultipart = None,
255 msg_string: str = None) -> None:
256 """
257 Sends a pre-built e-mail message.
259 Args:
260 from_addr: e-mail address for 'From:' field
261 to_addrs: address or list of addresses to transmit to
263 host: mail server host
264 user: username on mail server
265 password: password for username on mail server
266 port: port to use, or ``None`` for protocol default
267 use_tls: use TLS, rather than plain SMTP?
269 msg: a :class:`email.mime.multipart.MIMEMultipart`
270 msg_string: alternative: specify the message as a raw string
272 Raises:
273 :exc:`RuntimeError`
275 See also:
277 - https://tools.ietf.org/html/rfc3207
279 """
280 assert bool(msg) != bool(msg_string), "Specify either msg or msg_string"
281 # Connect
282 try:
283 session = smtplib.SMTP(host, port)
284 except smtplib.SMTPException as e:
285 raise RuntimeError(
286 f"send_msg: Failed to connect to host {host}, port {port}: {e}")
287 try:
288 session.ehlo()
289 except smtplib.SMTPException as e:
290 raise RuntimeError(f"send_msg: Failed to issue EHLO: {e}")
292 if use_tls:
293 try:
294 session.starttls()
295 session.ehlo()
296 except smtplib.SMTPException as e:
297 raise RuntimeError(f"send_msg: Failed to initiate TLS: {e}")
299 # Log in
300 if user:
301 try:
302 session.login(user, password)
303 except smtplib.SMTPException as e:
304 raise RuntimeError(
305 f"send_msg: Failed to login as user {user}: {e}")
306 else:
307 log.debug("Not using SMTP AUTH; no user specified")
308 # For systems with... lax... security requirements
310 # Send
311 try:
312 session.sendmail(from_addr, to_addrs, msg.as_string())
313 except smtplib.SMTPException as e:
314 raise RuntimeError(f"send_msg: Failed to send e-mail: {e}")
316 # Log out
317 session.quit()
320# =============================================================================
321# Send e-mail
322# =============================================================================
324def send_email(from_addr: str,
325 host: str,
326 user: str,
327 password: str,
328 port: int = None,
329 use_tls: bool = True,
330 date: str = None,
331 sender: str = "",
332 reply_to: Union[str, List[str]] = "",
333 to: Union[str, List[str]] = "",
334 cc: Union[str, List[str]] = "",
335 bcc: Union[str, List[str]] = "",
336 subject: str = "",
337 body: str = "",
338 content_type: str = CONTENT_TYPE_TEXT,
339 charset: str = "utf8",
340 attachment_filenames: Sequence[str] = None,
341 attachment_binaries: Sequence[bytes] = None,
342 attachment_binary_filenames: Sequence[str] = None,
343 verbose: bool = False) -> Tuple[bool, str]:
344 """
345 Sends an e-mail in text/html format using SMTP via TLS.
347 Args:
348 host: mail server host
349 user: username on mail server
350 password: password for username on mail server
351 port: port to use, or ``None`` for protocol default
352 use_tls: use TLS, rather than plain SMTP?
354 date: e-mail date in RFC 2822 format, or ``None`` for "now"
356 from_addr: name of the sender for the "From:" field
357 sender: name of the sender for the "Sender:" field
358 reply_to: name of the sender for the "Reply-To:" field
360 to: e-mail address(es) of the recipients for "To:" field
361 cc: e-mail address(es) of the recipients for "Cc:" field
362 bcc: e-mail address(es) of the recipients for "Bcc:" field
364 subject: e-mail subject
365 body: e-mail body
366 content_type: MIME type for body content, default ``text/plain``
367 charset: character set for body; default ``utf8``
369 attachment_filenames: filenames of attachments to add
370 attachment_binaries: binary objects to add as attachments
371 attachment_binary_filenames: filenames corresponding to
372 ``attachment_binaries``
373 verbose: be verbose?
375 Returns:
376 tuple: ``(success, error_or_success_message)``
378 See
380 - https://tools.ietf.org/html/rfc2822
381 - https://tools.ietf.org/html/rfc5322
382 - http://segfault.in/2010/12/sending-gmail-from-python/
383 - https://stackoverflow.com/questions/64505
384 - https://stackoverflow.com/questions/3362600
386 Re security:
388 - TLS supersedes SSL:
389 https://en.wikipedia.org/wiki/Transport_Layer_Security
391 - https://en.wikipedia.org/wiki/Email_encryption
393 - SMTP connections on ports 25 and 587 are commonly secured via TLS using
394 the ``STARTTLS`` command:
395 https://en.wikipedia.org/wiki/Simple_Mail_Transfer_Protocol
397 - https://tools.ietf.org/html/rfc8314
399 - "STARTTLS on port 587" is one common method. Django refers to this as
400 "explicit TLS" (its ``E_MAIL_USE_TLS`` setting; see
401 https://docs.djangoproject.com/en/2.1/ref/settings/#std:setting-EMAIL_USE_TLS).
403 - Port 465 is also used for "implicit TLS" (3.3 in
404 https://tools.ietf.org/html/rfc8314). Django refers to this as "implicit
405 TLS" too, or SSL; see its ``EMAIL_USE_SSL`` setting at
406 https://docs.djangoproject.com/en/2.1/ref/settings/#email-use-ssl). We
407 don't support that here.
409 """ # noqa
410 if isinstance(to, str):
411 to = [to]
412 if isinstance(cc, str):
413 cc = [cc]
414 if isinstance(bcc, str):
415 bcc = [bcc]
417 # -------------------------------------------------------------------------
418 # Make it
419 # -------------------------------------------------------------------------
420 try:
421 msg = make_email(
422 from_addr=from_addr,
423 date=date,
424 sender=sender,
425 reply_to=reply_to,
426 to=to,
427 cc=cc,
428 bcc=bcc,
429 subject=subject,
430 body=body,
431 content_type=content_type,
432 charset=charset,
433 attachment_filenames=attachment_filenames,
434 attachment_binaries=attachment_binaries,
435 attachment_binary_filenames=attachment_binary_filenames,
436 verbose=verbose,
437 )
438 except (AssertionError, ValueError) as e:
439 errmsg = str(e)
440 log.error("{}", errmsg)
441 return False, errmsg
443 # -------------------------------------------------------------------------
444 # Send it
445 # -------------------------------------------------------------------------
447 to_addrs = to + cc + bcc
448 try:
449 send_msg(
450 msg=msg,
451 from_addr=from_addr,
452 to_addrs=to_addrs,
453 host=host,
454 user=user,
455 password=password,
456 port=port,
457 use_tls=use_tls,
458 )
459 except RuntimeError as e:
460 errmsg = str(e)
461 log.error("{}", e)
462 return False, errmsg
464 return True, "Success"
467# =============================================================================
468# Misc
469# =============================================================================
471_SIMPLE_EMAIL_REGEX = re.compile(r"[^@]+@[^@]+\.[^@]+")
474def is_email_valid(email_: str) -> bool:
475 """
476 Performs a very basic check that a string appears to be an e-mail address.
477 """
478 # Very basic checks!
479 return _SIMPLE_EMAIL_REGEX.match(email_) is not None
482def get_email_domain(email_: str) -> str:
483 """
484 Returns the domain part of an e-mail address.
485 """
486 return email_.split("@")[1]
489# =============================================================================
490# Parse command line
491# =============================================================================
493def main() -> NoReturn:
494 """
495 Command-line processor. See ``--help`` for details.
496 """
497 logging.basicConfig()
498 log.setLevel(logging.DEBUG)
499 parser = argparse.ArgumentParser(
500 description="Send an e-mail from the command line.")
501 parser.add_argument("sender", action="store",
502 help="Sender's e-mail address")
503 parser.add_argument("host", action="store",
504 help="SMTP server hostname")
505 parser.add_argument("user", action="store",
506 help="SMTP username")
507 parser.add_argument("password", action="store",
508 help="SMTP password")
509 parser.add_argument("recipient", action="append",
510 help="Recipient e-mail address(es)")
511 parser.add_argument("subject", action="store",
512 help="Message subject")
513 parser.add_argument("body", action="store",
514 help="Message body")
515 parser.add_argument("--attach", nargs="*",
516 help="Filename(s) to attach")
517 parser.add_argument("--tls", action="store_false",
518 help="Use TLS connection security")
519 parser.add_argument("--verbose", action="store_true",
520 help="Be verbose")
521 parser.add_argument("-h --help", action="help",
522 help="Prints this help")
523 args = parser.parse_args()
524 (result, msg) = send_email(
525 from_addr=args.sender,
526 to=args.recipient,
527 subject=args.subject,
528 body=args.body,
529 host=args.host,
530 user=args.user,
531 password=args.password,
532 use_tls=args.tls,
533 attachment_filenames=args.attach,
534 verbose=args.verbose,
535 )
536 if result:
537 log.info("Success")
538 else:
539 log.info("Failure")
540 # log.error(msg)
541 sys.exit(0 if result else 1)
544if __name__ == '__main__':
545 main()