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