288223ddd9c98844a4aa08f364d36c32fd5a08a4
[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         if self._encrypted() == "decrypted":
93             casing["locks"] = [{"state": "valid"}]
94         return casing
95
96     def _encrypted(self):
97         return self.headers.get("X-Leap-Encryption", "false")
98
99     def _signature_information(self):
100         signature = self.headers.get("X-Leap-Signature", None)
101         if signature is None or signature.startswith("could not verify"):
102             return [{"state": "no_signature_information"}]
103         else:
104             if signature.startswith("valid"):
105                 return [{"state": "valid", "seal": {"validity": "valid"}}]
106             else:
107                 return []
108
109     @property
110     def raw(self):
111         result = u''
112         for k, v in self._headers.items():
113             content, encoding = decode_header(v)[0]
114             if encoding:
115                 result += '%s: %s\n' % (k, unicode(content, encoding=encoding))
116             else:
117                 result += '%s: %s\n' % (k, v)
118         result += '\n'
119
120         if self._body:
121             result = result + self._body
122
123         return result
124
125     def _remove_duplicates(self, values):
126         return list(set(values))
127
128     def _decoded_header_utf_8(self, header_value):
129         if isinstance(header_value, list):
130             return self._remove_duplicates([self._decoded_header_utf_8(v) for v in header_value])
131         elif header_value is not None:
132             def encode_chunk(content, encoding):
133                 return unicode(content.strip(), encoding=encoding or 'ascii', errors='ignore')
134
135             try:
136                 encoded_chunks = [encode_chunk(content, encoding) for content, encoding in decode_header(header_value)]
137                 return ' '.join(encoded_chunks)  # decode_header strips whitespaces on all chunks, joining over ' ' is only a workaround, not a proper fix
138             except UnicodeEncodeError:
139                 return unicode(header_value.encode('ascii', errors='ignore'))
140
141     def as_dict(self):
142         return {
143             'header': {k.lower(): self._decoded_header_utf_8(v) for k, v in self.headers.items()},
144             'ident': self._mail_id,
145             'tags': self.tags,
146             'status': list(self.status),
147             'body': self._body,
148             'security_casing': self.security_casing,
149             'textPlainBody': self._body,
150             'mailbox': self._mailbox_name.lower(),
151             'attachments': [attachment.as_dict() for attachment in self._attachments]
152         }
153
154     @staticmethod
155     def from_dict(mail_dict):
156         # TODO: implement this method and also write tests for it
157         headers = {key.capitalize(): value for key, value in mail_dict.get('header', {}).items()}
158         headers['Date'] = date.mail_date_now()
159         body = mail_dict.get('body', '')
160         tags = set(mail_dict.get('tags', []))
161         status = set(mail_dict.get('status', []))
162         attachments = []
163
164         # mail_id, mailbox_name, headers=None, tags=set(), flags=set(), body=None, attachments=[]
165         return LeapMail(None, None, headers, tags, set(), body, attachments)
166
167
168 def _extract_filename(headers, default_filename='UNNAMED'):
169     content_disposition = headers.get('Content-Disposition') or headers.get('content-disposition', '')
170     filename = _extract_filename_from_name_header_part(content_disposition)
171     if not filename:
172         filename = headers.get('Content-Description', '')
173     if not filename:
174         content_type = headers.get('Content-Type', '')
175         filename = _extract_filename_from_name_header_part(content_type)
176
177     if not filename:
178         filename = default_filename
179
180     return filename
181
182
183 def _extract_filename_from_name_header_part(header_value):
184     match = re.compile('.*name=\"?(.*[^\"\'])').search(header_value)
185     filename = ''
186     if match:
187         filename = match.group(1)
188     return filename
189
190
191 class LeapMailStore(MailStore):
192     __slots__ = ('soledad')
193
194     def __init__(self, soledad):
195         self.soledad = soledad
196
197     @defer.inlineCallbacks
198     def get_mail(self, mail_id, include_body=False):
199         message = yield self._fetch_msg_from_soledad(mail_id)
200         if not _is_empty_message(message):
201             leap_mail = yield self._leap_message_to_leap_mail(mail_id, message, include_body)
202         else:
203             leap_mail = None
204
205         defer.returnValue(leap_mail)
206
207     @defer.inlineCallbacks
208     def get_mails(self, mail_ids, gracefully_ignore_errors=False, include_body=False):
209         deferreds = []
210         for mail_id in mail_ids:
211             deferreds.append(self.get_mail(mail_id, include_body=include_body))
212
213         if gracefully_ignore_errors:
214             results = yield DeferredList(deferreds, consumeErrors=True)
215             defer.returnValue([mail for ok, mail in results if ok and mail is not None])
216         else:
217             result = yield defer.gatherResults(deferreds, consumeErrors=True)
218             defer.returnValue(result)
219
220     @defer.inlineCallbacks
221     def update_mail(self, mail):
222         message = yield self._fetch_msg_from_soledad(mail.mail_id)
223         message.get_wrapper().set_tags(tuple(mail.tags))
224         message.get_wrapper().set_flags(tuple(mail.flags))
225         yield self._update_mail(message)  # TODO assert this is yielded (otherwise asynchronous)
226
227     @defer.inlineCallbacks
228     def all_mails(self, gracefully_ignore_errors=False):
229         mdocs = yield self.soledad.get_from_index('by-type', 'meta')
230
231         mail_ids = map(lambda doc: doc.doc_id, mdocs)
232
233         mails = yield self.get_mails(mail_ids, gracefully_ignore_errors=gracefully_ignore_errors, include_body=True)
234         defer.returnValue(mails)
235
236     @defer.inlineCallbacks
237     def add_mailbox(self, mailbox_name):
238         mailbox = yield self._get_or_create_mailbox(mailbox_name)
239         defer.returnValue(mailbox)
240
241     @defer.inlineCallbacks
242     def get_mailbox_names(self):
243         mbox_map = set((yield self._mailbox_uuid_to_name_map()).values())
244
245         defer.returnValue(mbox_map.union({'INBOX'}))
246
247     @defer.inlineCallbacks
248     def _mailbox_uuid_to_name_map(self):
249         map = {}
250         mbox_docs = yield self.soledad.get_from_index('by-type', 'mbox')
251         for doc in mbox_docs:
252             map[underscore_uuid(doc.content.get('uuid'))] = doc.content.get('mbox')
253
254         defer.returnValue(map)
255
256     @defer.inlineCallbacks
257     def add_mail(self, mailbox_name, raw_msg):
258         mailbox = yield self._get_or_create_mailbox(mailbox_name)
259         message = SoledadMailAdaptor().get_msg_from_string(Message, raw_msg)
260         message.get_wrapper().set_mbox_uuid(mailbox.uuid)
261
262         yield SoledadMailAdaptor().create_msg(self.soledad, message)
263
264         # add behavious from insert_mdoc_id from mail.py
265         mail = yield self._leap_message_to_leap_mail(message.get_wrapper().mdoc.doc_id, message, include_body=True)  # TODO test that asserts include_body
266         defer.returnValue(mail)
267
268     @defer.inlineCallbacks
269     def delete_mail(self, mail_id):
270         message = yield self._fetch_msg_from_soledad(mail_id)
271         if message and message.get_wrapper().mdoc.doc_id:
272             yield message.get_wrapper().delete(self.soledad)
273             defer.returnValue(True)
274         defer.returnValue(False)
275
276     @defer.inlineCallbacks
277     def get_mailbox_mail_ids(self, mailbox_name):
278         mailbox = yield self._get_or_create_mailbox(mailbox_name)
279         fdocs = yield self.soledad.get_from_index('by-type-and-mbox-uuid', 'flags', underscore_uuid(mailbox.uuid))
280
281         mail_ids = map(lambda doc: _fdoc_id_to_mdoc_id(doc.doc_id), fdocs)
282
283         defer.returnValue(mail_ids)
284
285     @defer.inlineCallbacks
286     def delete_mailbox(self, mailbox_name):
287         mbx_wrapper = yield self._get_or_create_mailbox(mailbox_name)
288         yield SoledadMailAdaptor().delete_mbox(self.soledad, mbx_wrapper)
289
290     @defer.inlineCallbacks
291     def copy_mail_to_mailbox(self, mail_id, mailbox_name):
292         message = yield self._fetch_msg_from_soledad(mail_id, load_body=True)
293         mailbox = yield self._get_or_create_mailbox(mailbox_name)
294         copy_wrapper = yield message.get_wrapper().copy(self.soledad, mailbox.uuid)
295
296         leap_message = Message(copy_wrapper)
297
298         mail = yield self._leap_message_to_leap_mail(copy_wrapper.mdoc.doc_id, leap_message, include_body=False)
299
300         defer.returnValue(mail)
301
302     @defer.inlineCallbacks
303     def move_mail_to_mailbox(self, mail_id, mailbox_name):
304         mail_copy = yield self.copy_mail_to_mailbox(mail_id, mailbox_name)
305         yield self.delete_mail(mail_id)
306         defer.returnValue(mail_copy)
307
308     def _update_mail(self, message):
309         return message.get_wrapper().update(self.soledad)
310
311     @defer.inlineCallbacks
312     def _leap_message_to_leap_mail(self, mail_id, message, include_body):
313         if include_body:
314             # TODO use body from message if available
315             body = yield self._raw_message_body(message)
316         else:
317             body = None
318
319         # fetch mailbox name by mbox_uuid
320         mbox_uuid = message.get_wrapper().fdoc.mbox_uuid
321         mbox_name = yield self._mailbox_name_from_uuid(mbox_uuid)
322         attachments = self._extract_attachment_info_from(message)
323         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
324
325         defer.returnValue(mail)
326
327     @defer.inlineCallbacks
328     def _raw_message_body(self, message):
329         content_doc = (yield message.get_wrapper().get_body(self.soledad))
330         parser = BodyParser('', content_type='text/plain', content_transfer_encoding='UTF-8')
331         # It fix the problem when leap doesn'r found body_phash and returns empty string
332         if not isinstance(content_doc, str):
333             parser = BodyParser(content_doc.raw, content_type=content_doc.content_type,
334                                 content_transfer_encoding=content_doc.content_transfer_encoding, charset=content_doc.charset)
335
336         defer.returnValue(parser.parsed_content())
337
338     @defer.inlineCallbacks
339     def _mailbox_name_from_uuid(self, uuid):
340         map = (yield self._mailbox_uuid_to_name_map())
341         defer.returnValue(map.get(uuid, ''))
342
343     @defer.inlineCallbacks
344     def _get_or_create_mailbox(self, mailbox_name):
345         mailbox_name_upper = mailbox_name.upper()
346         mbx = yield SoledadMailAdaptor().get_or_create_mbox(self.soledad, mailbox_name_upper)
347         if mbx.uuid is None:
348             mbx.uuid = str(uuid4())
349             yield mbx.update(self.soledad)
350         defer.returnValue(mbx)
351
352     def _fetch_msg_from_soledad(self, mail_id, load_body=False):
353         return SoledadMailAdaptor().get_msg_from_mdoc_id(Message, self.soledad, mail_id, get_cdocs=load_body)
354
355     @defer.inlineCallbacks
356     def _dump_soledad(self):
357         gen, docs = yield self.soledad.get_all_docs()
358         for doc in docs:
359             print '\n%s\n' % doc
360
361     def _extract_attachment_info_from(self, message):
362         wrapper = message.get_wrapper()
363         part_maps = wrapper.hdoc.part_map
364         return self._extract_part_map(part_maps)
365
366     def _is_attachment(self, part_map, headers):
367         disposition = headers.get('Content-Disposition') or headers.get('content-disposition')
368         content_type = part_map['ctype']
369
370         if 'multipart' in content_type:
371             return False
372
373         if 'text/plain' == content_type and ((disposition == 'inline') or (disposition is None)):
374             return False
375
376         return True
377
378     def _create_attachment_info_from(self, part_map, headers):
379         ident = part_map['phash']
380         name = _extract_filename(headers)
381         encoding = headers.get('Content-Transfer-Encoding', None)
382         ctype = part_map.get('ctype') or headers.get('Content-Type')
383         size = part_map.get('size', 0)
384
385         return AttachmentInfo(ident, name, encoding, ctype, size)
386
387     def _extract_part_map(self, part_maps):
388         result = []
389
390         for nr, part_map in part_maps.items():
391             if 'headers' in part_map and 'phash' in part_map:
392                 headers = {header[0]: header[1] for header in part_map['headers']}
393                 if self._is_attachment(part_map, headers):
394                     result.append(self._create_attachment_info_from(part_map, headers))
395             if 'part_map' in part_map:
396                 result += self._extract_part_map(part_map['part_map'])
397
398         return result
399
400
401 def _is_empty_message(message):
402     return (message is None) or (message.get_wrapper().mdoc.doc_id is None)
403
404
405 def _fdoc_id_to_mdoc_id(fdoc_id):
406     return 'M' + fdoc_id[1:]