Hide keyboard shortcuts

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 

3 

4""" 

5=============================================================================== 

6 

7 Original code copyright (C) 2009-2021 Rudolf Cardinal (rudolf@pobox.com). 

8 

9 This file is part of cardinal_pythonlib. 

10 

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 

14 

15 https://www.apache.org/licenses/LICENSE-2.0 

16 

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. 

22 

23=============================================================================== 

24 

25**Sends e-mails from the command line.** 

26 

27""" 

28 

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 

42 

43from cardinal_pythonlib.logs import get_brace_style_log_with_null_handler 

44 

45log = get_brace_style_log_with_null_handler(__name__) 

46 

47 

48# ============================================================================= 

49# Constants 

50# ============================================================================= 

51 

52CONTENT_TYPE_TEXT = "text/plain" 

53CONTENT_TYPE_HTML = "text/html" 

54 

55COMMA = "," 

56COMMASPACE = ", " 

57 

58STANDARD_SMTP_PORT = 25 

59STANDARD_TLS_PORT = 587 

60 

61 

62# ============================================================================= 

63# Make e-mail message 

64# ============================================================================= 

65 

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. 

83 

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. 

87 

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 

93 

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 

97 

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`` 

102 

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? 

108 

109 Returns: 

110 a :class:`email.mime.multipart.MIMEMultipart` 

111 

112 Raises: 

113 :exc:`AssertionError`, :exc:`ValueError` 

114 

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] 

119 

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 ) 

127 

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 ) 

169 

170 # ------------------------------------------------------------------------- 

171 # Make message 

172 # ------------------------------------------------------------------------- 

173 msg = email.mime.multipart.MIMEMultipart() 

174 

175 # Headers: mandatory 

176 msg["From"] = from_addr 

177 msg["Date"] = date 

178 msg["Subject"] = subject 

179 

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) 

191 

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) 

200 

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}") 

239 

240 return msg 

241 

242 

243# ============================================================================= 

244# Send message 

245# ============================================================================= 

246 

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. 

258 

259 Args: 

260 from_addr: e-mail address for 'From:' field 

261 to_addrs: address or list of addresses to transmit to 

262 

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? 

268 

269 msg: a :class:`email.mime.multipart.MIMEMultipart` 

270 msg_string: alternative: specify the message as a raw string 

271 

272 Raises: 

273 :exc:`RuntimeError` 

274 

275 See also: 

276 

277 - https://tools.ietf.org/html/rfc3207 

278 

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}") 

291 

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}") 

298 

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 

309 

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}") 

315 

316 # Log out 

317 session.quit() 

318 

319 

320# ============================================================================= 

321# Send e-mail 

322# ============================================================================= 

323 

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. 

346 

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? 

353  

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

355  

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 

359  

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 

363  

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`` 

368  

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? 

374 

375 Returns: 

376 tuple: ``(success, error_or_success_message)`` 

377 

378 See 

379 

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 

385 

386 Re security: 

387 

388 - TLS supersedes SSL: 

389 https://en.wikipedia.org/wiki/Transport_Layer_Security 

390  

391 - https://en.wikipedia.org/wiki/Email_encryption 

392  

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 

396  

397 - https://tools.ietf.org/html/rfc8314 

398  

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). 

402  

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. 

408 

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] 

416 

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 

442 

443 # ------------------------------------------------------------------------- 

444 # Send it 

445 # ------------------------------------------------------------------------- 

446 

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 

463 

464 return True, "Success" 

465 

466 

467# ============================================================================= 

468# Misc 

469# ============================================================================= 

470 

471_SIMPLE_EMAIL_REGEX = re.compile(r"[^@]+@[^@]+\.[^@]+") 

472 

473 

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 

480 

481 

482def get_email_domain(email_: str) -> str: 

483 """ 

484 Returns the domain part of an e-mail address. 

485 """ 

486 return email_.split("@")[1] 

487 

488 

489# ============================================================================= 

490# Parse command line 

491# ============================================================================= 

492 

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) 

542 

543 

544if __name__ == '__main__': 

545 main()