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