[feat] display the mixnet status on incomming emails
[pixelated-user-agent.git] / service / src / pixelated / adapter / mailstore / leap_mailstore.py
1 #
2 # Copyright (c) 2015 ThoughtWorks, Inc.
3 #
4 # Pixelated is free software: you can redistribute it and/or modify
5 # it under the terms of the GNU Affero General Public License as published by
6 # the Free Software Foundation, either version 3 of the License, or
7 # (at your option) any later version.
8 #
9 # Pixelated is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 # GNU Affero General Public License for more details.
13 #
14 # You should have received a copy of the GNU Affero General Public License
15 # along with Pixelated. If not, see <http://www.gnu.org/licenses/>.
16 import re
17 from email.header import decode_header
18 from uuid import uuid4
19
20 from leap.bitmask.mail.adaptors.soledad import SoledadMailAdaptor
21 from leap.bitmask.mail.mail import Message
22 from twisted.internet import defer
23 from twisted.internet.defer import FirstError, DeferredList
24
25 from pixelated.adapter.mailstore.body_parser import BodyParser
26 from pixelated.adapter.mailstore.mailstore import MailStore, underscore_uuid
27 from pixelated.adapter.model.mail import Mail, InputMail
28 from pixelated.support.functional import to_unicode
29 from pixelated.support import date
30
31
32 class AttachmentInfo(object):
33     def __init__(self, ident, name, encoding=None, ctype='application/octet-stream', size=0):
34         self.ident = ident
35         self.name = name
36         self.encoding = encoding
37         self.ctype = ctype
38         self.size = size
39
40     def __repr__(self):
41         return 'AttachmentInfo[%s, %s, %s]' % (self.ident, self.name, self.encoding)
42
43     def __str__(self):
44         return 'AttachmentInfo[%s, %s, %s]' % (self.ident, self.name, self.encoding)
45
46     def as_dict(self):
47         return {'ident': self.ident, 'name': self.name, 'encoding': self.encoding, 'size': self.size, 'content-type': self.ctype}
48
49
50 class LeapMail(Mail):
51
52     def __init__(self, mail_id, mailbox_name, headers=None, tags=set(), flags=set(), body=None, attachments=[]):
53         self._mail_id = mail_id
54         self._mailbox_name = mailbox_name
55         self._headers = headers if headers is not None else {}
56         self._body = to_unicode(body)
57         self.tags = set(tags)   # TODO test that asserts copy
58         self._flags = set(flags)  # TODO test that asserts copy
59         self._attachments = attachments
60
61     @property
62     def headers(self):
63         cpy = dict(self._headers)
64         for name in set(self._headers.keys()).intersection(['To', 'Cc', 'Bcc']):
65             cpy[name] = [address.strip() for address in (self._headers[name].split(',') if self._headers[name] else [])]
66
67         return cpy
68
69     @property
70     def ident(self):
71         return self._mail_id
72
73     @property
74     def mail_id(self):
75         return self._mail_id
76
77     @property
78     def body(self):
79         return self._body
80
81     @property
82     def flags(self):
83         return self._flags
84
85     @property
86     def mailbox_name(self):
87         return self._mailbox_name
88
89     @property
90     def security_casing(self):
91         casing = dict(imprints=self._signature_information(), locks=[],
92                       mixnet=self._mixnet())
93         if self._encrypted() == "decrypted":
94             casing["locks"] = [{"state": "valid"}]
95         return casing
96
97     def _encrypted(self):
98         return self.headers.get("X-Leap-Encryption", "false")
99
100     def _signature_information(self):
101         signature = self.headers.get("X-Leap-Signature", None)
102         if signature is None or signature.startswith("could not verify"):
103             return [{"state": "no_signature_information"}]
104         else:
105             if signature.startswith("valid"):
106                 return [{"state": "valid", "seal": {"validity": "valid"}}]
107             else:
108                 return []
109
110     def _mixnet(self):
111         mixnet = self.headers.get("X-Leap-Mixnet", "")
112         if mixnet == "":
113             return []
114
115         return [{"state": "valid", "mixnet": mixnet}]
116
117     @property
118     def raw(self):
119         result = u''
120         for k, v in self._headers.items():
121             content, encoding = decode_header(v)[0]
122             if encoding:
123                 result += '%s: %s\n' % (k, unicode(content, encoding=encoding))
124             else:
125                 result += '%s: %s\n' % (k, v)
126         result += '\n'
127
128         if self._body:
129             result = result + self._body
130
131         return result
132
133     def _remove_duplicates(self, values):
134         return list(set(values))
135
136     def _decoded_header_utf_8(self, header_value):
137         if isinstance(header_value, list):
138             return self._remove_duplicates([self._decoded_header_utf_8(v) for v in header_value])
139         elif header_value is not None:
140             def encode_chunk(content, encoding):
141                 return unicode(content.strip(), encoding=encoding or 'ascii', errors='ignore')
142
143             try:
144                 encoded_chunks = [encode_chunk(content, encoding) for content, encoding in decode_header(header_value)]
145                 return ' '.join(encoded_chunks)  # decode_header strips whitespaces on all chunks, joining over ' ' is only a workaround, not a proper fix
146             except UnicodeEncodeError:
147                 return unicode(header_value.encode('ascii', errors='ignore'))
148
149     def as_dict(self):
150         return {
151             'header': {k.lower(): self._decoded_header_utf_8(v) for k, v in self.headers.items()},
152             'ident': self._mail_id,
153             'tags': self.tags,
154             'status': list(self.status),
155             'body': self._body,
156             'security_casing': self.security_casing,
157             'textPlainBody': self._body,
158             'mailbox': self._mailbox_name.lower(),
159             'attachments': [attachment.as_dict() for attachment in self._attachments]
160         }
161
162     @staticmethod
163     def from_dict(mail_dict):
164         # TODO: implement this method and also write tests for it
165         headers = {key.capitalize(): value for key, value in mail_dict.get('header', {}).items()}
166         headers['Date'] = date.mail_date_now()
167         body = mail_dict.get('body', '')
168         tags = set(mail_dict.get('tags', []))
169         status = set(mail_dict.get('status', []))
170         attachments = []
171
172         # mail_id, mailbox_name, headers=None, tags=set(), flags=set(), body=None, attachments=[]
173         return LeapMail(None, None, headers, tags, set(), body, attachments)
174
175
176 def _extract_filename(headers, default_filename='UNNAMED'):
177     content_disposition = headers.get('Content-Disposition') or headers.get('content-disposition', '')
178     filename = _extract_filename_from_name_header_part(content_disposition)
179     if not filename:
180         filename = headers.get('Content-Description', '')
181     if not filename:
182         content_type = headers.get('Content-Type', '')
183         filename = _extract_filename_from_name_header_part(content_type)
184
185     if not filename:
186         filename = default_filename
187
188     return filename
189
190
191 def _extract_filename_from_name_header_part(header_value):
192     match = re.compile('.*name=\"?(.*[^\"\'])').search(header_value)
193     filename = ''
194     if match:
195         filename = match.group(1)
196     return filename
197
198
199 class LeapMailStore(MailStore):
200     __slots__ = ('soledad')
201
202     def __init__(self, soledad):
203         self.soledad = soledad
204
205     @defer.inlineCallbacks
206     def get_mail(self, mail_id, include_body=False):
207         message = yield self._fetch_msg_from_soledad(mail_id)
208         if not _is_empty_message(message):
209             leap_mail = yield self._leap_message_to_leap_mail(mail_id, message, include_body)
210         else:
211             leap_mail = None
212
213         defer.returnValue(leap_mail)
214
215     @defer.inlineCallbacks
216     def get_mails(self, mail_ids, gracefully_ignore_errors=False, include_body=False):
217         deferreds = []
218         for mail_id in mail_ids:
219             deferreds.append(self.get_mail(mail_id, include_body=include_body))
220
221         if gracefully_ignore_errors:
222             results = yield DeferredList(deferreds, consumeErrors=True)
223             defer.returnValue([mail for ok, mail in results if ok and mail is not None])
224         else:
225             result = yield defer.gatherResults(deferreds, consumeErrors=True)
226             defer.returnValue(result)
227
228     @defer.inlineCallbacks
229     def update_mail(self, mail):
230         message = yield self._fetch_msg_from_soledad(mail.mail_id)
231         message.get_wrapper().set_tags(tuple(mail.tags))
232         message.get_wrapper().set_flags(tuple(mail.flags))
233         yield self._update_mail(message)  # TODO assert this is yielded (otherwise asynchronous)
234
235     @defer.inlineCallbacks
236     def all_mails(self, gracefully_ignore_errors=False):
237         mdocs = yield self.soledad.get_from_index('by-type', 'meta')
238
239         mail_ids = map(lambda doc: doc.doc_id, mdocs)
240
241         mails = yield self.get_mails(mail_ids, gracefully_ignore_errors=gracefully_ignore_errors, include_body=True)
242         defer.returnValue(mails)
243
244     @defer.inlineCallbacks
245     def add_mailbox(self, mailbox_name):
246         mailbox = yield self._get_or_create_mailbox(mailbox_name)
247         defer.returnValue(mailbox)
248
249     @defer.inlineCallbacks
250     def get_mailbox_names(self):
251         mbox_map = set((yield self._mailbox_uuid_to_name_map()).values())
252
253         defer.returnValue(mbox_map.union({'INBOX'}))
254
255     @defer.inlineCallbacks
256     def _mailbox_uuid_to_name_map(self):
257         map = {}
258         mbox_docs = yield self.soledad.get_from_index('by-type', 'mbox')
259         for doc in mbox_docs:
260             map[underscore_uuid(doc.content.get('uuid'))] = doc.content.get('mbox')
261
262         defer.returnValue(map)
263
264     @defer.inlineCallbacks
265     def add_mail(self, mailbox_name, raw_msg):
266         mailbox = yield self._get_or_create_mailbox(mailbox_name)
267         message = SoledadMailAdaptor().get_msg_from_string(Message, raw_msg)
268         message.get_wrapper().set_mbox_uuid(mailbox.uuid)
269
270         yield SoledadMailAdaptor().create_msg(self.soledad, message)
271
272         # add behavious from insert_mdoc_id from mail.py
273         mail = yield self._leap_message_to_leap_mail(message.get_wrapper().mdoc.doc_id, message, include_body=True)  # TODO test that asserts include_body
274         defer.returnValue(mail)
275
276     @defer.inlineCallbacks
277     def delete_mail(self, mail_id):
278         message = yield self._fetch_msg_from_soledad(mail_id)
279         if message and message.get_wrapper().mdoc.doc_id:
280             yield message.get_wrapper().delete(self.soledad)
281             defer.returnValue(True)
282         defer.returnValue(False)
283
284     @defer.inlineCallbacks
285     def get_mailbox_mail_ids(self, mailbox_name):
286         mailbox = yield self._get_or_create_mailbox(mailbox_name)
287         fdocs = yield self.soledad.get_from_index('by-type-and-mbox-uuid', 'flags', underscore_uuid(mailbox.uuid))
288
289         mail_ids = map(lambda doc: _fdoc_id_to_mdoc_id(doc.doc_id), fdocs)
290
291         defer.returnValue(mail_ids)
292
293     @defer.inlineCallbacks
294     def delete_mailbox(self, mailbox_name):
295         mbx_wrapper = yield self._get_or_create_mailbox(mailbox_name)
296         yield SoledadMailAdaptor().delete_mbox(self.soledad, mbx_wrapper)
297
298     @defer.inlineCallbacks
299     def copy_mail_to_mailbox(self, mail_id, mailbox_name):
300         message = yield self._fetch_msg_from_soledad(mail_id, load_body=True)
301         mailbox = yield self._get_or_create_mailbox(mailbox_name)
302         copy_wrapper = yield message.get_wrapper().copy(self.soledad, mailbox.uuid)
303
304         leap_message = Message(copy_wrapper)
305
306         mail = yield self._leap_message_to_leap_mail(copy_wrapper.mdoc.doc_id, leap_message, include_body=False)
307
308         defer.returnValue(mail)
309
310     @defer.inlineCallbacks
311     def move_mail_to_mailbox(self, mail_id, mailbox_name):
312         mail_copy = yield self.copy_mail_to_mailbox(mail_id, mailbox_name)
313         yield self.delete_mail(mail_id)
314         defer.returnValue(mail_copy)
315
316     def _update_mail(self, message):
317         return message.get_wrapper().update(self.soledad)
318
319     @defer.inlineCallbacks
320     def _leap_message_to_leap_mail(self, mail_id, message, include_body):
321         if include_body:
322             # TODO use body from message if available
323             body = yield self._raw_message_body(message)
324         else:
325             body = None
326
327         # fetch mailbox name by mbox_uuid
328         mbox_uuid = message.get_wrapper().fdoc.mbox_uuid
329         mbox_name = yield self._mailbox_name_from_uuid(mbox_uuid)
330         attachments = self._extract_attachment_info_from(message)
331         mail = LeapMail(mail_id, mbox_name, message.get_wrapper().hdoc.headers, set(message.get_tags()), set(message.get_flags()), body=body, attachments=attachments)   # TODO assert flags are passed on
332
333         defer.returnValue(mail)
334
335     @defer.inlineCallbacks
336     def _raw_message_body(self, message):
337         content_doc = (yield message.get_wrapper().get_body(self.soledad))
338         parser = BodyParser('', content_type='text/plain', content_transfer_encoding='UTF-8')
339         # It fix the problem when leap doesn'r found body_phash and returns empty string
340         if not isinstance(content_doc, str):
341             parser = BodyParser(content_doc.raw, content_type=content_doc.content_type,
342                                 content_transfer_encoding=content_doc.content_transfer_encoding, charset=content_doc.charset)
343
344         defer.returnValue(parser.parsed_content())
345
346     @defer.inlineCallbacks
347     def _mailbox_name_from_uuid(self, uuid):
348         map = (yield self._mailbox_uuid_to_name_map())
349         defer.returnValue(map.get(uuid, ''))
350
351     @defer.inlineCallbacks
352     def _get_or_create_mailbox(self, mailbox_name):
353         mailbox_name_upper = mailbox_name.upper()
354         mbx = yield SoledadMailAdaptor().get_or_create_mbox(self.soledad, mailbox_name_upper)
355         if mbx.uuid is None:
356             mbx.uuid = str(uuid4())
357             yield mbx.update(self.soledad)
358         defer.returnValue(mbx)
359
360     def _fetch_msg_from_soledad(self, mail_id, load_body=False):
361         return SoledadMailAdaptor().get_msg_from_mdoc_id(Message, self.soledad, mail_id, get_cdocs=load_body)
362
363     @defer.inlineCallbacks
364     def _dump_soledad(self):
365         gen, docs = yield self.soledad.get_all_docs()
366         for doc in docs:
367             print '\n%s\n' % doc
368
369     def _extract_attachment_info_from(self, message):
370         wrapper = message.get_wrapper()
371         part_maps = wrapper.hdoc.part_map
372         return self._extract_part_map(part_maps)
373
374     def _is_attachment(self, part_map, headers):
375         disposition = headers.get('Content-Disposition') or headers.get('content-disposition')
376         content_type = part_map['ctype']
377
378         if 'multipart' in content_type:
379             return False
380
381         if 'text/plain' == content_type and ((disposition == 'inline') or (disposition is None)):
382             return False
383
384         return True
385
386     def _create_attachment_info_from(self, part_map, headers):
387         ident = part_map['phash']
388         name = _extract_filename(headers)
389         encoding = headers.get('Content-Transfer-Encoding', None)
390         ctype = part_map.get('ctype') or headers.get('Content-Type')
391         size = part_map.get('size', 0)
392
393         return AttachmentInfo(ident, name, encoding, ctype, size)
394
395     def _extract_part_map(self, part_maps):
396         result = []
397
398         for nr, part_map in part_maps.items():
399             if 'headers' in part_map and 'phash' in part_map:
400                 headers = {header[0]: header[1] for header in part_map['headers']}
401                 if self._is_attachment(part_map, headers):
402                     result.append(self._create_attachment_info_from(part_map, headers))
403             if 'part_map' in part_map:
404                 result += self._extract_part_map(part_map['part_map'])
405
406         return result
407
408
409 def _is_empty_message(message):
410     return (message is None) or (message.get_wrapper().mdoc.doc_id is None)
411
412
413 def _fdoc_id_to_mdoc_id(fdoc_id):
414     return 'M' + fdoc_id[1:]