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