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