##// END OF EJS Templates
mail: add a TODO about proper mbox locking...
Matt Harbison -
r47760:10640224 stable
parent child Browse files
Show More
@@ -1,531 +1,532 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 154
155 155 try:
156 156 _smtp_login(ui, s, mailhost, mailport)
157 157 except smtplib.SMTPException as inst:
158 158 raise error.Abort(stringutil.forcebytestr(inst))
159 159
160 160 def send(sender, recipients, msg):
161 161 try:
162 162 return s.sendmail(sender, recipients, msg)
163 163 except smtplib.SMTPRecipientsRefused as inst:
164 164 recipients = [r[1] for r in inst.recipients.values()]
165 165 raise error.Abort(b'\n' + b'\n'.join(recipients))
166 166 except smtplib.SMTPException as inst:
167 167 raise error.Abort(stringutil.forcebytestr(inst))
168 168
169 169 return send
170 170
171 171
172 172 def _smtp_login(ui, smtp, mailhost, mailport):
173 173 """A hook for the keyring extension to perform the actual SMTP login.
174 174
175 175 An already connected SMTP object of the proper type is provided, based on
176 176 the current configuration. The host and port to which the connection was
177 177 established are provided for accessibility, since the SMTP object doesn't
178 178 provide an accessor. ``smtplib.SMTPException`` is raised on error.
179 179 """
180 180 username = ui.config(b'smtp', b'username')
181 181 password = ui.config(b'smtp', b'password')
182 182 if username:
183 183 if password:
184 184 password = encoding.strfromlocal(password)
185 185 else:
186 186 password = ui.getpass()
187 187 if password is not None:
188 188 password = encoding.strfromlocal(password)
189 189 if username and password:
190 190 ui.note(_(b'(authenticating to mail server as %s)\n') % username)
191 191 username = encoding.strfromlocal(username)
192 192 smtp.login(username, password)
193 193
194 194
195 195 def _sendmail(ui, sender, recipients, msg):
196 196 '''send mail using sendmail.'''
197 197 program = ui.config(b'email', b'method')
198 198
199 199 def stremail(x):
200 200 return procutil.shellquote(stringutil.email(encoding.strtolocal(x)))
201 201
202 202 cmdline = b'%s -f %s %s' % (
203 203 program,
204 204 stremail(sender),
205 205 b' '.join(map(stremail, recipients)),
206 206 )
207 207 ui.note(_(b'sending mail: %s\n') % cmdline)
208 208 fp = procutil.popen(cmdline, b'wb')
209 209 fp.write(util.tonativeeol(msg))
210 210 ret = fp.close()
211 211 if ret:
212 212 raise error.Abort(
213 213 b'%s %s'
214 214 % (
215 215 os.path.basename(procutil.shellsplit(program)[0]),
216 216 procutil.explainexit(ret),
217 217 )
218 218 )
219 219
220 220
221 221 def _mbox(mbox, sender, recipients, msg):
222 222 '''write mails to mbox'''
223 # TODO: use python mbox library for proper locking
223 224 with open(mbox, b'ab+') as fp:
224 225 # Should be time.asctime(), but Windows prints 2-characters day
225 226 # of month instead of one. Make them print the same thing.
226 227 date = time.strftime('%a %b %d %H:%M:%S %Y', time.localtime())
227 228 fp.write(
228 229 b'From %s %s\n'
229 230 % (encoding.strtolocal(sender), encoding.strtolocal(date))
230 231 )
231 232 fp.write(msg)
232 233 fp.write(b'\n\n')
233 234
234 235
235 236 def connect(ui, mbox=None):
236 237 """make a mail connection. return a function to send mail.
237 238 call as sendmail(sender, list-of-recipients, msg)."""
238 239 if mbox:
239 240 open(mbox, b'wb').close()
240 241 return lambda s, r, m: _mbox(mbox, s, r, m)
241 242 if ui.config(b'email', b'method') == b'smtp':
242 243 return _smtp(ui)
243 244 return lambda s, r, m: _sendmail(ui, s, r, m)
244 245
245 246
246 247 def sendmail(ui, sender, recipients, msg, mbox=None):
247 248 send = connect(ui, mbox=mbox)
248 249 return send(sender, recipients, msg)
249 250
250 251
251 252 def validateconfig(ui):
252 253 '''determine if we have enough config data to try sending email.'''
253 254 method = ui.config(b'email', b'method')
254 255 if method == b'smtp':
255 256 if not ui.config(b'smtp', b'host'):
256 257 raise error.Abort(
257 258 _(
258 259 b'smtp specified as email transport, '
259 260 b'but no smtp host configured'
260 261 )
261 262 )
262 263 else:
263 264 if not procutil.findexe(method):
264 265 raise error.Abort(
265 266 _(b'%r specified as email transport, but not in PATH') % method
266 267 )
267 268
268 269
269 270 def codec2iana(cs):
270 271 # type: (str) -> str
271 272 ''''''
272 273 cs = email.charset.Charset(cs).input_charset.lower()
273 274
274 275 # "latin1" normalizes to "iso8859-1", standard calls for "iso-8859-1"
275 276 if cs.startswith("iso") and not cs.startswith("iso-"):
276 277 return "iso-" + cs[3:]
277 278 return cs
278 279
279 280
280 281 def mimetextpatch(s, subtype='plain', display=False):
281 282 # type: (bytes, str, bool) -> email.message.Message
282 283 """Return MIME message suitable for a patch.
283 284 Charset will be detected by first trying to decode as us-ascii, then utf-8,
284 285 and finally the global encodings. If all those fail, fall back to
285 286 ISO-8859-1, an encoding with that allows all byte sequences.
286 287 Transfer encodings will be used if necessary."""
287 288
288 289 cs = [
289 290 'us-ascii',
290 291 'utf-8',
291 292 pycompat.sysstr(encoding.encoding),
292 293 pycompat.sysstr(encoding.fallbackencoding),
293 294 ]
294 295 if display:
295 296 cs = ['us-ascii']
296 297 for charset in cs:
297 298 try:
298 299 s.decode(charset)
299 300 return mimetextqp(s, subtype, codec2iana(charset))
300 301 except UnicodeDecodeError:
301 302 pass
302 303
303 304 return mimetextqp(s, subtype, "iso-8859-1")
304 305
305 306
306 307 def mimetextqp(body, subtype, charset):
307 308 # type: (bytes, str, str) -> email.message.Message
308 309 """Return MIME message.
309 310 Quoted-printable transfer encoding will be used if necessary.
310 311 """
311 312 cs = email.charset.Charset(charset)
312 313 msg = email.message.Message()
313 314 msg.set_type('text/' + subtype)
314 315
315 316 for line in body.splitlines():
316 317 if len(line) > 950:
317 318 cs.body_encoding = email.charset.QP
318 319 break
319 320
320 321 # On Python 2, this simply assigns a value. Python 3 inspects
321 322 # body and does different things depending on whether it has
322 323 # encode() or decode() attributes. We can get the old behavior
323 324 # if we pass a str and charset is None and we call set_charset().
324 325 # But we may get into trouble later due to Python attempting to
325 326 # encode/decode using the registered charset (or attempting to
326 327 # use ascii in the absence of a charset).
327 328 msg.set_payload(body, cs)
328 329
329 330 return msg
330 331
331 332
332 333 def _charsets(ui):
333 334 # type: (Any) -> List[str]
334 335 '''Obtains charsets to send mail parts not containing patches.'''
335 336 charsets = [
336 337 pycompat.sysstr(cs.lower())
337 338 for cs in ui.configlist(b'email', b'charsets')
338 339 ]
339 340 fallbacks = [
340 341 pycompat.sysstr(encoding.fallbackencoding.lower()),
341 342 pycompat.sysstr(encoding.encoding.lower()),
342 343 'utf-8',
343 344 ]
344 345 for cs in fallbacks: # find unique charsets while keeping order
345 346 if cs not in charsets:
346 347 charsets.append(cs)
347 348 return [cs for cs in charsets if not cs.endswith('ascii')]
348 349
349 350
350 351 def _encode(ui, s, charsets):
351 352 # type: (Any, bytes, List[str]) -> Tuple[bytes, str]
352 353 """Returns (converted) string, charset tuple.
353 354 Finds out best charset by cycling through sendcharsets in descending
354 355 order. Tries both encoding and fallbackencoding for input. Only as
355 356 last resort send as is in fake ascii.
356 357 Caveat: Do not use for mail parts containing patches!"""
357 358 sendcharsets = charsets or _charsets(ui)
358 359 if not isinstance(s, bytes):
359 360 # We have unicode data, which we need to try and encode to
360 361 # some reasonable-ish encoding. Try the encodings the user
361 362 # wants, and fall back to garbage-in-ascii.
362 363 for ocs in sendcharsets:
363 364 try:
364 365 return s.encode(ocs), ocs
365 366 except UnicodeEncodeError:
366 367 pass
367 368 except LookupError:
368 369 ui.warn(
369 370 _(b'ignoring invalid sendcharset: %s\n')
370 371 % pycompat.sysbytes(ocs)
371 372 )
372 373 else:
373 374 # Everything failed, ascii-armor what we've got and send it.
374 375 return s.encode('ascii', 'backslashreplace'), 'us-ascii'
375 376 # We have a bytes of unknown encoding. We'll try and guess a valid
376 377 # encoding, falling back to pretending we had ascii even though we
377 378 # know that's wrong.
378 379 try:
379 380 s.decode('ascii')
380 381 except UnicodeDecodeError:
381 382 for ics in (encoding.encoding, encoding.fallbackencoding):
382 383 ics = pycompat.sysstr(ics)
383 384 try:
384 385 u = s.decode(ics)
385 386 except UnicodeDecodeError:
386 387 continue
387 388 for ocs in sendcharsets:
388 389 try:
389 390 return u.encode(ocs), ocs
390 391 except UnicodeEncodeError:
391 392 pass
392 393 except LookupError:
393 394 ui.warn(
394 395 _(b'ignoring invalid sendcharset: %s\n')
395 396 % pycompat.sysbytes(ocs)
396 397 )
397 398 # if ascii, or all conversion attempts fail, send (broken) ascii
398 399 return s, 'us-ascii'
399 400
400 401
401 402 def headencode(ui, s, charsets=None, display=False):
402 403 # type: (Any, Union[bytes, str], List[str], bool) -> str
403 404 '''Returns RFC-2047 compliant header from given string.'''
404 405 if not display:
405 406 # split into words?
406 407 s, cs = _encode(ui, s, charsets)
407 408 return email.header.Header(s, cs).encode()
408 409 return encoding.strfromlocal(s)
409 410
410 411
411 412 def _addressencode(ui, name, addr, charsets=None):
412 413 # type: (Any, str, str, List[str]) -> str
413 414 addr = encoding.strtolocal(addr)
414 415 name = headencode(ui, name, charsets)
415 416 try:
416 417 acc, dom = addr.split(b'@')
417 418 acc.decode('ascii')
418 419 dom = dom.decode(pycompat.sysstr(encoding.encoding)).encode('idna')
419 420 addr = b'%s@%s' % (acc, dom)
420 421 except UnicodeDecodeError:
421 422 raise error.Abort(_(b'invalid email address: %s') % addr)
422 423 except ValueError:
423 424 try:
424 425 # too strict?
425 426 addr.decode('ascii')
426 427 except UnicodeDecodeError:
427 428 raise error.Abort(_(b'invalid local address: %s') % addr)
428 429 return email.utils.formataddr((name, encoding.strfromlocal(addr)))
429 430
430 431
431 432 def addressencode(ui, address, charsets=None, display=False):
432 433 # type: (Any, bytes, List[str], bool) -> str
433 434 '''Turns address into RFC-2047 compliant header.'''
434 435 if display or not address:
435 436 return encoding.strfromlocal(address or b'')
436 437 name, addr = email.utils.parseaddr(encoding.strfromlocal(address))
437 438 return _addressencode(ui, name, addr, charsets)
438 439
439 440
440 441 def addrlistencode(ui, addrs, charsets=None, display=False):
441 442 # type: (Any, List[bytes], List[str], bool) -> List[str]
442 443 """Turns a list of addresses into a list of RFC-2047 compliant headers.
443 444 A single element of input list may contain multiple addresses, but output
444 445 always has one address per item"""
445 446 straddrs = []
446 447 for a in addrs:
447 448 assert isinstance(a, bytes), '%r unexpectedly not a bytestr' % a
448 449 straddrs.append(encoding.strfromlocal(a))
449 450 if display:
450 451 return [a.strip() for a in straddrs if a.strip()]
451 452
452 453 result = []
453 454 for name, addr in email.utils.getaddresses(straddrs):
454 455 if name or addr:
455 456 r = _addressencode(ui, name, addr, charsets)
456 457 result.append(r)
457 458 return result
458 459
459 460
460 461 def mimeencode(ui, s, charsets=None, display=False):
461 462 # type: (Any, bytes, List[str], bool) -> email.message.Message
462 463 """creates mime text object, encodes it if needed, and sets
463 464 charset and transfer-encoding accordingly."""
464 465 cs = 'us-ascii'
465 466 if not display:
466 467 s, cs = _encode(ui, s, charsets)
467 468 return mimetextqp(s, 'plain', cs)
468 469
469 470
470 471 if pycompat.ispy3:
471 472
472 473 Generator = email.generator.BytesGenerator
473 474
474 475 def parse(fp):
475 476 # type: (Any) -> email.message.Message
476 477 ep = email.parser.Parser()
477 478 # disable the "universal newlines" mode, which isn't binary safe.
478 479 # I have no idea if ascii/surrogateescape is correct, but that's
479 480 # what the standard Python email parser does.
480 481 fp = io.TextIOWrapper(
481 482 fp, encoding='ascii', errors='surrogateescape', newline=chr(10)
482 483 )
483 484 try:
484 485 return ep.parse(fp)
485 486 finally:
486 487 fp.detach()
487 488
488 489 def parsebytes(data):
489 490 # type: (bytes) -> email.message.Message
490 491 ep = email.parser.BytesParser()
491 492 return ep.parsebytes(data)
492 493
493 494
494 495 else:
495 496
496 497 Generator = email.generator.Generator
497 498
498 499 def parse(fp):
499 500 # type: (Any) -> email.message.Message
500 501 ep = email.parser.Parser()
501 502 return ep.parse(fp)
502 503
503 504 def parsebytes(data):
504 505 # type: (str) -> email.message.Message
505 506 ep = email.parser.Parser()
506 507 return ep.parsestr(data)
507 508
508 509
509 510 def headdecode(s):
510 511 # type: (Union[email.header.Header, bytes]) -> bytes
511 512 '''Decodes RFC-2047 header'''
512 513 uparts = []
513 514 for part, charset in email.header.decode_header(s):
514 515 if charset is not None:
515 516 try:
516 517 uparts.append(part.decode(charset))
517 518 continue
518 519 except (UnicodeDecodeError, LookupError):
519 520 pass
520 521 # On Python 3, decode_header() may return either bytes or unicode
521 522 # depending on whether the header has =?<charset>? or not
522 523 if isinstance(part, type(u'')):
523 524 uparts.append(part)
524 525 continue
525 526 try:
526 527 uparts.append(part.decode('UTF-8'))
527 528 continue
528 529 except UnicodeDecodeError:
529 530 pass
530 531 uparts.append(part.decode('ISO-8859-1'))
531 532 return encoding.unitolocal(u' '.join(uparts))
General Comments 0
You need to be logged in to leave comments. Login now