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