1# Copyright (C) 2011-2012  Patrick Totzke <patricktotzke@gmail.com>
2# This file is released under the GNU GPL, version 3 or a later revision.
3# For further details see the COPYING file
4import glob
5import logging
6import os
7import re
8import email
9import email.policy
10from email.encoders import encode_7or8bit
11from email.mime.text import MIMEText
12from email.mime.multipart import MIMEMultipart
13from email.mime.application import MIMEApplication
14import email.charset as charset
15import gpg
16
17from .attachment import Attachment
18from .. import __version__
19from .. import helper
20from .. import crypto
21from ..settings.const import settings
22from ..errors import GPGProblem, GPGCode
23
24charset.add_charset('utf-8', charset.QP, charset.QP, 'utf-8')
25
26
27class Envelope:
28    """
29    a message that is not yet sent and still editable.
30
31    It holds references to unencoded! body text and mail headers among other
32    things.  Envelope implements the python container API for easy access of
33    header values.  So `e['To']`, `e['To'] = 'foo@bar.baz'` and
34    'e.get_all('To')' would work for an envelope `e`..
35    """
36
37    headers = None
38    """
39    dict containing the mail headers (a list of strings for each header key)
40    """
41    body_txt = None
42    """mail body (plaintext) as unicode string"""
43    body_html = None
44    """mail body (html) as unicode string"""
45    tmpfile = None
46    """template text for initial content"""
47    attachments = None
48    """list of :class:`Attachments <alot.db.attachment.Attachment>`"""
49    tags = []
50    """tags to add after successful sendout"""
51    account = None
52    """account to send from"""
53
54    def __init__(
55            self, template=None, bodytext=None, headers=None, attachments=None,
56            sign=False, sign_key=None, encrypt=False, tags=None, replied=None,
57            passed=None, account=None):
58        """
59        :param template: if not None, the envelope will be initialised by
60                         :meth:`parsing <parse_template>` this string before
61                         setting any other values given to this constructor.
62        :type template: str
63        :param bodytext: text used as body part
64        :type bodytext: str
65        :param headers: unencoded header values
66        :type headers: dict (str -> [unicode])
67        :param attachments: file attachments to include
68        :type attachments: list of :class:`~alot.db.attachment.Attachment`
69        :param tags: tags to add after successful sendout and saving this msg
70        :type tags: list of str
71        :param replied: message being replied to
72        :type replied: :class:`~alot.db.message.Message`
73        :param passed: message being passed on
74        :type replied: :class:`~alot.db.message.Message`
75        :param account: account to send from
76        :type account: :class:`Account`
77        """
78        logging.debug('TEMPLATE: %s', template)
79        if template:
80            self.parse_template(template)
81            logging.debug('PARSED TEMPLATE: %s', template)
82            logging.debug('BODY: %s', self.body_txt)
83        self.body_txt = bodytext or ''
84        # TODO: if this was as collections.defaultdict a number of methods
85        # could be simplified.
86        self.headers = headers or {}
87        self.attachments = list(attachments) if attachments is not None else []
88        self.sign = sign
89        self.sign_key = sign_key
90        self.encrypt = encrypt
91        self.encrypt_keys = {}
92        self.tags = tags or []  # tags to add after successful sendout
93        self.replied = replied  # message being replied to
94        self.passed = passed  # message being passed on
95        self.sent_time = None
96        self.modified_since_sent = False
97        self.sending = False  # semaphore to avoid accidental double sendout
98        self.account = account
99
100    def __str__(self):
101        return "Envelope (%s)\n%s" % (self.headers, self.body_txt)
102
103    def __setitem__(self, name, val):
104        """setter for header values. This allows adding header like so:
105        envelope['Subject'] = 'sm\xf8rebr\xf8d'
106        """
107        if name not in self.headers:
108            self.headers[name] = []
109        self.headers[name].append(val)
110
111        if self.sent_time:
112            self.modified_since_sent = True
113
114    def __getitem__(self, name):
115        """getter for header values.
116        :raises: KeyError if undefined
117        """
118        return self.headers[name][0]
119
120    def __delitem__(self, name):
121        del self.headers[name]
122
123        if self.sent_time:
124            self.modified_since_sent = True
125
126    def __contains__(self, name):
127        return name in self.headers
128
129    def get(self, key, fallback=None):
130        """secure getter for header values that allows specifying a `fallback`
131        return string (defaults to None). This returns the first matching value
132        and doesn't raise KeyErrors"""
133        if key in self.headers:
134            value = self.headers[key][0]
135        else:
136            value = fallback
137        return value
138
139    def get_all(self, key, fallback=None):
140        """returns all header values for given key"""
141        if key in self.headers:
142            value = self.headers[key]
143        else:
144            value = fallback or []
145        return value
146
147    def add(self, key, value):
148        """add header value"""
149        if key not in self.headers:
150            self.headers[key] = []
151        self.headers[key].append(value)
152
153        if self.sent_time:
154            self.modified_since_sent = True
155
156    def attach(self, attachment, filename=None, ctype=None):
157        """
158        attach a file
159
160        :param attachment: File to attach, given as
161            :class:`~alot.db.attachment.Attachment` object or path to a file.
162        :type attachment: :class:`~alot.db.attachment.Attachment` or str
163        :param filename: filename to use in content-disposition.
164            Will be ignored if `path` matches multiple files
165        :param ctype: force content-type to be used for this attachment
166        :type ctype: str
167        """
168
169        if isinstance(attachment, Attachment):
170            self.attachments.append(attachment)
171        elif isinstance(attachment, str):
172            path = os.path.expanduser(attachment)
173            part = helper.mimewrap(path, filename, ctype)
174            self.attachments.append(Attachment(part))
175        else:
176            raise TypeError('attach accepts an Attachment or str')
177
178        if self.sent_time:
179            self.modified_since_sent = True
180
181    def construct_mail(self):
182        """
183        Compiles the information contained in this envelope into a
184        :class:`email.Message`.
185        """
186        # Build body text part. To properly sign/encrypt messages later on, we
187        # convert the text to its canonical format (as per RFC 2015).
188        canonical_format = self.body_txt.encode('utf-8')
189        textpart = MIMEText(canonical_format, 'plain', 'utf-8')
190        inner_msg = textpart
191
192        if self.body_html:
193            htmlpart = MIMEText(self.body_html, 'html', 'utf-8')
194            inner_msg = MIMEMultipart('alternative')
195            inner_msg.attach(textpart)
196            inner_msg.attach(htmlpart)
197
198        # wrap everything in a multipart container if there are attachments
199        if self.attachments:
200            msg = MIMEMultipart('mixed')
201            msg.attach(inner_msg)
202            # add attachments
203            for a in self.attachments:
204                msg.attach(a.get_mime_representation())
205            inner_msg = msg
206
207        if self.sign:
208            plaintext = inner_msg.as_bytes(policy=email.policy.SMTP)
209            logging.debug('signing plaintext: %s', plaintext)
210
211            try:
212                signatures, signature_str = crypto.detached_signature_for(
213                    plaintext, [self.sign_key])
214                if len(signatures) != 1:
215                    raise GPGProblem("Could not sign message (GPGME "
216                                     "did not return a signature)",
217                                     code=GPGCode.KEY_CANNOT_SIGN)
218            except gpg.errors.GPGMEError as e:
219                if e.getcode() == gpg.errors.BAD_PASSPHRASE:
220                    # If GPG_AGENT_INFO is unset or empty, the user just does
221                    # not have gpg-agent running (properly).
222                    if os.environ.get('GPG_AGENT_INFO', '').strip() == '':
223                        msg = "Got invalid passphrase and GPG_AGENT_INFO\
224                                not set. Please set up gpg-agent."
225                        raise GPGProblem(msg, code=GPGCode.BAD_PASSPHRASE)
226                    else:
227                        raise GPGProblem("Bad passphrase. Is gpg-agent "
228                                         "running?",
229                                         code=GPGCode.BAD_PASSPHRASE)
230                raise GPGProblem(str(e), code=GPGCode.KEY_CANNOT_SIGN)
231
232            micalg = crypto.RFC3156_micalg_from_algo(signatures[0].hash_algo)
233            unencrypted_msg = MIMEMultipart(
234                'signed', micalg=micalg, protocol='application/pgp-signature')
235
236            # wrap signature in MIMEcontainter
237            stype = 'pgp-signature; name="signature.asc"'
238            signature_mime = MIMEApplication(
239                _data=signature_str.decode('ascii'),
240                _subtype=stype,
241                _encoder=encode_7or8bit)
242            signature_mime['Content-Description'] = 'signature'
243            signature_mime.set_charset('us-ascii')
244
245            # add signed message and signature to outer message
246            unencrypted_msg.attach(inner_msg)
247            unencrypted_msg.attach(signature_mime)
248            unencrypted_msg['Content-Disposition'] = 'inline'
249        else:
250            unencrypted_msg = inner_msg
251
252        if self.encrypt:
253            plaintext = unencrypted_msg.as_bytes(policy=email.policy.SMTP)
254            logging.debug('encrypting plaintext: %s', plaintext)
255
256            try:
257                encrypted_str = crypto.encrypt(
258                    plaintext, list(self.encrypt_keys.values()))
259            except gpg.errors.GPGMEError as e:
260                raise GPGProblem(str(e), code=GPGCode.KEY_CANNOT_ENCRYPT)
261
262            outer_msg = MIMEMultipart('encrypted',
263                                      protocol='application/pgp-encrypted')
264
265            version_str = 'Version: 1'
266            encryption_mime = MIMEApplication(_data=version_str,
267                                              _subtype='pgp-encrypted',
268                                              _encoder=encode_7or8bit)
269            encryption_mime.set_charset('us-ascii')
270
271            encrypted_mime = MIMEApplication(
272                _data=encrypted_str.decode('ascii'),
273                _subtype='octet-stream',
274                _encoder=encode_7or8bit)
275            encrypted_mime.set_charset('us-ascii')
276            outer_msg.attach(encryption_mime)
277            outer_msg.attach(encrypted_mime)
278
279        else:
280            outer_msg = unencrypted_msg
281
282        headers = self.headers.copy()
283
284        # add Date header
285        if 'Date' not in headers:
286            headers['Date'] = [email.utils.formatdate(localtime=True)]
287
288        # add Message-ID
289        if 'Message-ID' not in headers:
290            domain = self.account.message_id_domain
291            headers['Message-ID'] = [email.utils.make_msgid(domain=domain)]
292
293        if 'User-Agent' in headers:
294            uastring_format = headers['User-Agent'][0]
295        else:
296            uastring_format = settings.get('user_agent').strip()
297        uastring = uastring_format.format(version=__version__)
298        if uastring:
299            headers['User-Agent'] = [uastring]
300
301        # copy headers from envelope to mail
302        for k, vlist in headers.items():
303            for v in vlist:
304                outer_msg.add_header(k, v)
305
306        return outer_msg
307
308    def parse_template(self, raw, reset=False, only_body=False,
309                       target_body='plaintext'):
310        """Parse a template or user edited string to fills this envelope.
311
312        :param raw: the string to parse.
313        :type raw: str
314        :param reset: remove previous envelope content
315        :type reset: bool
316        :param only_body: do not parse headers
317        :type only_body: bool
318        :param target_body: body text alternative this should be stored in;
319            can be 'plaintext' or 'html'
320        :type reset: str
321        """
322        logging.debug('GoT: """\n%s\n"""', raw)
323
324        if self.sent_time:
325            self.modified_since_sent = True
326
327        if reset:
328            self.headers = {}
329
330        headerEndPos = 0
331        if not only_body:
332            # go through multiline, utf-8 encoded headers
333            # locally, lines are separated by a simple LF, not CRLF
334            # we decode the edited text ourselves here as
335            # email.message_from_file can't deal with raw utf8 header values
336            headerRe = re.compile(r'^(?P<k>.+?):(?P<v>(.|\n[ \t\r\f\v])+)$',
337                                  re.MULTILINE)
338            for header in headerRe.finditer(raw):
339                if header.start() > headerEndPos + 1:
340                    break  # switched to body
341
342                key = header.group('k')
343                # simple unfolding as decribed in
344                # https://tools.ietf.org/html/rfc2822#section-2.2.3
345                unfoldedValue = header.group('v').replace('\n', '')
346                self.add(key, unfoldedValue.strip())
347                headerEndPos = header.end()
348
349            # interpret 'Attach' pseudo header
350            if 'Attach' in self:
351                to_attach = []
352                for line in self.get_all('Attach'):
353                    gpath = os.path.expanduser(line.strip())
354                    to_attach += [g for g in glob.glob(gpath)
355                                  if os.path.isfile(g)]
356                logging.debug('Attaching: %s', to_attach)
357                for path in to_attach:
358                    self.attach(path)
359                del self['Attach']
360
361        body = raw[headerEndPos:].strip()
362        if target_body == 'html':
363            self.body_html = body
364        else:
365            self.body_txt = body
366