1# mail.py - mail sending bits for mercurial 2# 3# Copyright 2006 Olivia Mackall <olivia@selenic.com> 4# 5# This software may be used and distributed according to the terms of the 6# GNU General Public License version 2 or any later version. 7 8from __future__ import absolute_import 9 10import email 11import email.charset 12import email.generator 13import email.header 14import email.message 15import email.parser 16import io 17import os 18import smtplib 19import socket 20import time 21 22from .i18n import _ 23from .pycompat import ( 24 getattr, 25 open, 26) 27from . import ( 28 encoding, 29 error, 30 pycompat, 31 sslutil, 32 util, 33) 34from .utils import ( 35 procutil, 36 stringutil, 37 urlutil, 38) 39 40if pycompat.TYPE_CHECKING: 41 from typing import Any, List, Tuple, Union 42 43 # keep pyflakes happy 44 assert all((Any, List, Tuple, Union)) 45 46 47class STARTTLS(smtplib.SMTP): 48 """Derived class to verify the peer certificate for STARTTLS. 49 50 This class allows to pass any keyword arguments to SSL socket creation. 51 """ 52 53 def __init__(self, ui, host=None, **kwargs): 54 smtplib.SMTP.__init__(self, **kwargs) 55 self._ui = ui 56 self._host = host 57 58 def starttls(self, keyfile=None, certfile=None): 59 if not self.has_extn("starttls"): 60 msg = b"STARTTLS extension not supported by server" 61 raise smtplib.SMTPException(msg) 62 (resp, reply) = self.docmd("STARTTLS") 63 if resp == 220: 64 self.sock = sslutil.wrapsocket( 65 self.sock, 66 keyfile, 67 certfile, 68 ui=self._ui, 69 serverhostname=self._host, 70 ) 71 self.file = self.sock.makefile("rb") 72 self.helo_resp = None 73 self.ehlo_resp = None 74 self.esmtp_features = {} 75 self.does_esmtp = 0 76 return (resp, reply) 77 78 79class SMTPS(smtplib.SMTP): 80 """Derived class to verify the peer certificate for SMTPS. 81 82 This class allows to pass any keyword arguments to SSL socket creation. 83 """ 84 85 def __init__(self, ui, keyfile=None, certfile=None, host=None, **kwargs): 86 self.keyfile = keyfile 87 self.certfile = certfile 88 smtplib.SMTP.__init__(self, **kwargs) 89 self._host = host 90 self.default_port = smtplib.SMTP_SSL_PORT 91 self._ui = ui 92 93 def _get_socket(self, host, port, timeout): 94 if self.debuglevel > 0: 95 self._ui.debug(b'connect: %r\n' % ((host, port),)) 96 new_socket = socket.create_connection((host, port), timeout) 97 new_socket = sslutil.wrapsocket( 98 new_socket, 99 self.keyfile, 100 self.certfile, 101 ui=self._ui, 102 serverhostname=self._host, 103 ) 104 self.file = new_socket.makefile('rb') 105 return new_socket 106 107 108def _pyhastls(): 109 # type: () -> bool 110 """Returns true iff Python has TLS support, false otherwise.""" 111 try: 112 import ssl 113 114 getattr(ssl, 'HAS_TLS', False) 115 return True 116 except ImportError: 117 return False 118 119 120def _smtp(ui): 121 '''build an smtp connection and return a function to send mail''' 122 local_hostname = ui.config(b'smtp', b'local_hostname') 123 tls = ui.config(b'smtp', b'tls') 124 # backward compatible: when tls = true, we use starttls. 125 starttls = tls == b'starttls' or stringutil.parsebool(tls) 126 smtps = tls == b'smtps' 127 if (starttls or smtps) and not _pyhastls(): 128 raise error.Abort(_(b"can't use TLS: Python SSL support not installed")) 129 mailhost = ui.config(b'smtp', b'host') 130 if not mailhost: 131 raise error.Abort(_(b'smtp.host not configured - cannot send mail')) 132 if smtps: 133 ui.note(_(b'(using smtps)\n')) 134 s = SMTPS(ui, local_hostname=local_hostname, host=mailhost) 135 elif starttls: 136 s = STARTTLS(ui, local_hostname=local_hostname, host=mailhost) 137 else: 138 s = smtplib.SMTP(local_hostname=local_hostname) 139 if smtps: 140 defaultport = 465 141 else: 142 defaultport = 25 143 mailport = urlutil.getport(ui.config(b'smtp', b'port', defaultport)) 144 ui.note(_(b'sending mail: smtp host %s, port %d\n') % (mailhost, mailport)) 145 s.connect(host=mailhost, port=mailport) 146 if starttls: 147 ui.note(_(b'(using starttls)\n')) 148 s.ehlo() 149 s.starttls() 150 s.ehlo() 151 if starttls or smtps: 152 ui.note(_(b'(verifying remote certificate)\n')) 153 sslutil.validatesocket(s.sock) 154 155 try: 156 _smtp_login(ui, s, mailhost, mailport) 157 except smtplib.SMTPException as inst: 158 raise error.Abort(stringutil.forcebytestr(inst)) 159 160 def send(sender, recipients, msg): 161 try: 162 return s.sendmail(sender, recipients, msg) 163 except smtplib.SMTPRecipientsRefused as inst: 164 recipients = [r[1] for r in inst.recipients.values()] 165 raise error.Abort(b'\n' + b'\n'.join(recipients)) 166 except smtplib.SMTPException as inst: 167 raise error.Abort(stringutil.forcebytestr(inst)) 168 169 return send 170 171 172def _smtp_login(ui, smtp, mailhost, mailport): 173 """A hook for the keyring extension to perform the actual SMTP login. 174 175 An already connected SMTP object of the proper type is provided, based on 176 the current configuration. The host and port to which the connection was 177 established are provided for accessibility, since the SMTP object doesn't 178 provide an accessor. ``smtplib.SMTPException`` is raised on error. 179 """ 180 username = ui.config(b'smtp', b'username') 181 password = ui.config(b'smtp', b'password') 182 if username: 183 if password: 184 password = encoding.strfromlocal(password) 185 else: 186 password = ui.getpass() 187 if password is not None: 188 password = encoding.strfromlocal(password) 189 if username and password: 190 ui.note(_(b'(authenticating to mail server as %s)\n') % username) 191 username = encoding.strfromlocal(username) 192 smtp.login(username, password) 193 194 195def _sendmail(ui, sender, recipients, msg): 196 '''send mail using sendmail.''' 197 program = ui.config(b'email', b'method') 198 199 def stremail(x): 200 return procutil.shellquote(stringutil.email(encoding.strtolocal(x))) 201 202 cmdline = b'%s -f %s %s' % ( 203 program, 204 stremail(sender), 205 b' '.join(map(stremail, recipients)), 206 ) 207 ui.note(_(b'sending mail: %s\n') % cmdline) 208 fp = procutil.popen(cmdline, b'wb') 209 fp.write(util.tonativeeol(msg)) 210 ret = fp.close() 211 if ret: 212 raise error.Abort( 213 b'%s %s' 214 % ( 215 os.path.basename(procutil.shellsplit(program)[0]), 216 procutil.explainexit(ret), 217 ) 218 ) 219 220 221def _mbox(mbox, sender, recipients, msg): 222 '''write mails to mbox''' 223 # TODO: use python mbox library for proper locking 224 with open(mbox, b'ab+') as fp: 225 # Should be time.asctime(), but Windows prints 2-characters day 226 # of month instead of one. Make them print the same thing. 227 date = time.strftime('%a %b %d %H:%M:%S %Y', time.localtime()) 228 fp.write( 229 b'From %s %s\n' 230 % (encoding.strtolocal(sender), encoding.strtolocal(date)) 231 ) 232 fp.write(msg) 233 fp.write(b'\n\n') 234 235 236def connect(ui, mbox=None): 237 """make a mail connection. return a function to send mail. 238 call as sendmail(sender, list-of-recipients, msg).""" 239 if mbox: 240 open(mbox, b'wb').close() 241 return lambda s, r, m: _mbox(mbox, s, r, m) 242 if ui.config(b'email', b'method') == b'smtp': 243 return _smtp(ui) 244 return lambda s, r, m: _sendmail(ui, s, r, m) 245 246 247def sendmail(ui, sender, recipients, msg, mbox=None): 248 send = connect(ui, mbox=mbox) 249 return send(sender, recipients, msg) 250 251 252def validateconfig(ui): 253 '''determine if we have enough config data to try sending email.''' 254 method = ui.config(b'email', b'method') 255 if method == b'smtp': 256 if not ui.config(b'smtp', b'host'): 257 raise error.Abort( 258 _( 259 b'smtp specified as email transport, ' 260 b'but no smtp host configured' 261 ) 262 ) 263 else: 264 if not procutil.findexe(method): 265 raise error.Abort( 266 _(b'%r specified as email transport, but not in PATH') % method 267 ) 268 269 270def codec2iana(cs): 271 # type: (str) -> str 272 ''' ''' 273 cs = email.charset.Charset(cs).input_charset.lower() 274 275 # "latin1" normalizes to "iso8859-1", standard calls for "iso-8859-1" 276 if cs.startswith("iso") and not cs.startswith("iso-"): 277 return "iso-" + cs[3:] 278 return cs 279 280 281def mimetextpatch(s, subtype='plain', display=False): 282 # type: (bytes, str, bool) -> email.message.Message 283 """Return MIME message suitable for a patch. 284 Charset will be detected by first trying to decode as us-ascii, then utf-8, 285 and finally the global encodings. If all those fail, fall back to 286 ISO-8859-1, an encoding with that allows all byte sequences. 287 Transfer encodings will be used if necessary.""" 288 289 cs = [ 290 'us-ascii', 291 'utf-8', 292 pycompat.sysstr(encoding.encoding), 293 pycompat.sysstr(encoding.fallbackencoding), 294 ] 295 if display: 296 cs = ['us-ascii'] 297 for charset in cs: 298 try: 299 s.decode(charset) 300 return mimetextqp(s, subtype, codec2iana(charset)) 301 except UnicodeDecodeError: 302 pass 303 304 return mimetextqp(s, subtype, "iso-8859-1") 305 306 307def mimetextqp(body, subtype, charset): 308 # type: (bytes, str, str) -> email.message.Message 309 """Return MIME message. 310 Quoted-printable transfer encoding will be used if necessary. 311 """ 312 cs = email.charset.Charset(charset) 313 msg = email.message.Message() 314 msg.set_type('text/' + subtype) 315 316 for line in body.splitlines(): 317 if len(line) > 950: 318 cs.body_encoding = email.charset.QP 319 break 320 321 # On Python 2, this simply assigns a value. Python 3 inspects 322 # body and does different things depending on whether it has 323 # encode() or decode() attributes. We can get the old behavior 324 # if we pass a str and charset is None and we call set_charset(). 325 # But we may get into trouble later due to Python attempting to 326 # encode/decode using the registered charset (or attempting to 327 # use ascii in the absence of a charset). 328 msg.set_payload(body, cs) 329 330 return msg 331 332 333def _charsets(ui): 334 # type: (Any) -> List[str] 335 '''Obtains charsets to send mail parts not containing patches.''' 336 charsets = [ 337 pycompat.sysstr(cs.lower()) 338 for cs in ui.configlist(b'email', b'charsets') 339 ] 340 fallbacks = [ 341 pycompat.sysstr(encoding.fallbackencoding.lower()), 342 pycompat.sysstr(encoding.encoding.lower()), 343 'utf-8', 344 ] 345 for cs in fallbacks: # find unique charsets while keeping order 346 if cs not in charsets: 347 charsets.append(cs) 348 return [cs for cs in charsets if not cs.endswith('ascii')] 349 350 351def _encode(ui, s, charsets): 352 # type: (Any, bytes, List[str]) -> Tuple[bytes, str] 353 """Returns (converted) string, charset tuple. 354 Finds out best charset by cycling through sendcharsets in descending 355 order. Tries both encoding and fallbackencoding for input. Only as 356 last resort send as is in fake ascii. 357 Caveat: Do not use for mail parts containing patches!""" 358 sendcharsets = charsets or _charsets(ui) 359 if not isinstance(s, bytes): 360 # We have unicode data, which we need to try and encode to 361 # some reasonable-ish encoding. Try the encodings the user 362 # wants, and fall back to garbage-in-ascii. 363 for ocs in sendcharsets: 364 try: 365 return s.encode(ocs), ocs 366 except UnicodeEncodeError: 367 pass 368 except LookupError: 369 ui.warn( 370 _(b'ignoring invalid sendcharset: %s\n') 371 % pycompat.sysbytes(ocs) 372 ) 373 else: 374 # Everything failed, ascii-armor what we've got and send it. 375 return s.encode('ascii', 'backslashreplace'), 'us-ascii' 376 # We have a bytes of unknown encoding. We'll try and guess a valid 377 # encoding, falling back to pretending we had ascii even though we 378 # know that's wrong. 379 try: 380 s.decode('ascii') 381 except UnicodeDecodeError: 382 for ics in (encoding.encoding, encoding.fallbackencoding): 383 ics = pycompat.sysstr(ics) 384 try: 385 u = s.decode(ics) 386 except UnicodeDecodeError: 387 continue 388 for ocs in sendcharsets: 389 try: 390 return u.encode(ocs), ocs 391 except UnicodeEncodeError: 392 pass 393 except LookupError: 394 ui.warn( 395 _(b'ignoring invalid sendcharset: %s\n') 396 % pycompat.sysbytes(ocs) 397 ) 398 # if ascii, or all conversion attempts fail, send (broken) ascii 399 return s, 'us-ascii' 400 401 402def headencode(ui, s, charsets=None, display=False): 403 # type: (Any, Union[bytes, str], List[str], bool) -> str 404 '''Returns RFC-2047 compliant header from given string.''' 405 if not display: 406 # split into words? 407 s, cs = _encode(ui, s, charsets) 408 return email.header.Header(s, cs).encode() 409 return encoding.strfromlocal(s) 410 411 412def _addressencode(ui, name, addr, charsets=None): 413 # type: (Any, str, str, List[str]) -> str 414 addr = encoding.strtolocal(addr) 415 name = headencode(ui, name, charsets) 416 try: 417 acc, dom = addr.split(b'@') 418 acc.decode('ascii') 419 dom = dom.decode(pycompat.sysstr(encoding.encoding)).encode('idna') 420 addr = b'%s@%s' % (acc, dom) 421 except UnicodeDecodeError: 422 raise error.Abort(_(b'invalid email address: %s') % addr) 423 except ValueError: 424 try: 425 # too strict? 426 addr.decode('ascii') 427 except UnicodeDecodeError: 428 raise error.Abort(_(b'invalid local address: %s') % addr) 429 return email.utils.formataddr((name, encoding.strfromlocal(addr))) 430 431 432def addressencode(ui, address, charsets=None, display=False): 433 # type: (Any, bytes, List[str], bool) -> str 434 '''Turns address into RFC-2047 compliant header.''' 435 if display or not address: 436 return encoding.strfromlocal(address or b'') 437 name, addr = email.utils.parseaddr(encoding.strfromlocal(address)) 438 return _addressencode(ui, name, addr, charsets) 439 440 441def addrlistencode(ui, addrs, charsets=None, display=False): 442 # type: (Any, List[bytes], List[str], bool) -> List[str] 443 """Turns a list of addresses into a list of RFC-2047 compliant headers. 444 A single element of input list may contain multiple addresses, but output 445 always has one address per item""" 446 straddrs = [] 447 for a in addrs: 448 assert isinstance(a, bytes), '%r unexpectedly not a bytestr' % a 449 straddrs.append(encoding.strfromlocal(a)) 450 if display: 451 return [a.strip() for a in straddrs if a.strip()] 452 453 result = [] 454 for name, addr in email.utils.getaddresses(straddrs): 455 if name or addr: 456 r = _addressencode(ui, name, addr, charsets) 457 result.append(r) 458 return result 459 460 461def mimeencode(ui, s, charsets=None, display=False): 462 # type: (Any, bytes, List[str], bool) -> email.message.Message 463 """creates mime text object, encodes it if needed, and sets 464 charset and transfer-encoding accordingly.""" 465 cs = 'us-ascii' 466 if not display: 467 s, cs = _encode(ui, s, charsets) 468 return mimetextqp(s, 'plain', cs) 469 470 471if pycompat.ispy3: 472 473 Generator = email.generator.BytesGenerator 474 475 def parse(fp): 476 # type: (Any) -> email.message.Message 477 ep = email.parser.Parser() 478 # disable the "universal newlines" mode, which isn't binary safe. 479 # I have no idea if ascii/surrogateescape is correct, but that's 480 # what the standard Python email parser does. 481 fp = io.TextIOWrapper( 482 fp, encoding='ascii', errors='surrogateescape', newline=chr(10) 483 ) 484 try: 485 return ep.parse(fp) 486 finally: 487 fp.detach() 488 489 def parsebytes(data): 490 # type: (bytes) -> email.message.Message 491 ep = email.parser.BytesParser() 492 return ep.parsebytes(data) 493 494 495else: 496 497 Generator = email.generator.Generator 498 499 def parse(fp): 500 # type: (Any) -> email.message.Message 501 ep = email.parser.Parser() 502 return ep.parse(fp) 503 504 def parsebytes(data): 505 # type: (str) -> email.message.Message 506 ep = email.parser.Parser() 507 return ep.parsestr(data) 508 509 510def headdecode(s): 511 # type: (Union[email.header.Header, bytes]) -> bytes 512 '''Decodes RFC-2047 header''' 513 uparts = [] 514 for part, charset in email.header.decode_header(s): 515 if charset is not None: 516 try: 517 uparts.append(part.decode(charset)) 518 continue 519 except (UnicodeDecodeError, LookupError): 520 pass 521 # On Python 3, decode_header() may return either bytes or unicode 522 # depending on whether the header has =?<charset>? or not 523 if isinstance(part, type(u'')): 524 uparts.append(part) 525 continue 526 try: 527 uparts.append(part.decode('UTF-8')) 528 continue 529 except UnicodeDecodeError: 530 pass 531 uparts.append(part.decode('ISO-8859-1')) 532 return encoding.unitolocal(u' '.join(uparts)) 533