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