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