예제 #1
0
    def update_mailbox(self,
                       id,
                       name=None,
                       parentId=None,
                       isSubscribed=None,
                       sortOrder=None,
                       **update):
        mailbox = self.mailboxes.get(id, None)
        if not mailbox:
            raise errors.notFound('mailbox not found')
        imapname = mailbox['imapname']

        if (name is not None and name != mailbox['name']) or \
           (parentId is not None and parentId != mailbox['parentId']):
            if not name:
                raise errors.invalidProperties('name is required')
            newimapname = self.mailbox_imapname(parentId, name)
            res = self.imap.rename_folder(imapname, newimapname)
            if b'NO' in res or b'BAD' in res:
                raise errors.serverFail(res.encode())

        if isSubscribed is not None and isSubscribed != mailbox['isSubscribed']:
            if isSubscribed:
                res = self.imap.subscribe_folder(imapname)
            else:
                res = self.imap.unsubscribe_folder(imapname)
            if b'NO' in res or b'BAD' in res:
                raise errors.serverFail(res.encode())

        if sortOrder is not None and sortOrder != mailbox['sortOrder']:
            # TODO: update in persistent storage
            mailbox['sortOrder'] = sortOrder
        self.sync_mailboxes()
예제 #2
0
    async def create_email(self, data):
        try:
            mailboxid, = data['mailboxIds']
        except KeyError:
            raise errors.invalidArguments(
                'mailboxIds is required when creating email')
        except ValueError:
            raise errors.tooManyMailboxes('Max mailboxIds is 1')

        mailbox = self.mailboxes.get(mailboxid, None)
        if not mailbox or mailbox['deleted']:
            raise errors.notFound(f"Mailbox {mailboxid} not found")
        msg = ImapEmail(data)
        blobs = {}
        for attachment in data.get('attachments', ()):
            blobId = attachment.get('blobId', None)
            if blobId is not None:
                blobs[blobId] = await self.download(blobId)
        body = msg.make_body(blobs)
        flags = "(%s)" % (''.join(msg['FLAGS']))
        imapname = mailbox['imapname']
        uid, msg['X-GUID'] = await self._imap_append(body, imapname, flags)
        msg['id'] = self.format_email_id(uid)
        self.emails[id] = msg
        return {
            'id': msg['id'],
            'blobId': msg['blobId'],
        }
예제 #3
0
 async def download(self, blobId):
     async with self.http.get(f"{self.base}{self.id}/{blobId}") as res:
         if res.status == 200:
             return await res.read()
         elif res.status // 100 == 5:
             raise errors.serverFail()
     raise errors.notFound(f'Blob {blobId} not found')
예제 #4
0
 def destroy_mailbox(self, id):
     mailbox = self.mailboxes.get(id, None)
     if not mailbox:
         raise errors.notFound('mailbox not found')
     res = self.imap.delete_folder(mailbox['imapname'])
     if b'NO' in res or b'BAD' in res:
         raise errors.serverFail(res.encode())
     mailbox['deleted'] = datetime.now().timestamp()
     self.sync_mailboxes()
예제 #5
0
 async def download(self, blobId):
     search = self.as_imap_search({'blobId': blobId[1:]})
     ok, lines = await self.imap.uid_search(search.decode(), ret='ALL')
     uidset = parse_esearch(lines).get('ALL', '')
     for uid in iter_messageset(uidset):
         ok, lines = await self.imap.uid_fetch(str(uid), '(BODY.PEEK[])')
         for seq, data in parse_fetch(lines[:-1]):
             return data['BODY[]']
     raise errors.notFound(f"Blob {blobId} not found")
예제 #6
0
 async def download(self, blobId):
     try:
         async with open(f'data/{blobId}', 'r') as file:
             body = bytearray()
             chunk = range(self.chunk_size)
             while len(chunk) == self.chunk_size:
                 chunk = await file.read(self.chunk_size)
                 body += chunk
             return body
     except FileNotFoundError:
         raise errors.notFound()
예제 #7
0
 async def update_emails(self, update):
     updated = {}
     notUpdated = {}
     await self.fill_emails(('keywords', 'mailboxIds'), update.keys())
     for id, patch in update.items():
         try:
             updated[id] = await self.update_email(self.emails[id], patch)
         except KeyError:
             notUpdated[id] = errors.notFound().to_dict()
         except errors.JmapError as e:
             notUpdated[id] = e.to_dict()
     return updated, notUpdated
예제 #8
0
    async def update_email(self, msg, patch):
        values = patch.pop('keywords', {})
        store = {
            True: [keyword2flag(k) for k, v in values.items() if v],
            False: [keyword2flag(k) for k, v in values.items() if not v]
        }
        values = patch.pop('mailboxIds', {})
        mids = {
            True: [k for k, v in values.items() if v],
            False: [k for k, v in values.items() if not v]
        }

        for path, value in patch.items():
            prop, _, key = path.partition('/')
            if prop == 'keywords':
                store[bool(value)].append(keyword2flag(key))
            elif prop == 'mailboxIds':
                mids[bool(value)].append(key)
            else:
                raise errors.invalidArguments(f"Unknown update {path}")

        uid = str(self.parse_email_id(msg['id']))
        for add, flags in store.items():
            flags = f"({' '.join(flags)})"
            ok, lines = await self.imap.uid_store(
                uid, '+FLAGS' if add else '-FLAGS', flags)
            if ok != 'OK':
                raise errors.serverFail('\n'.join(lines))
            for seq, data in parse_fetch(lines[:-1]):
                if uid == data['UID']:
                    msg['FLAGS'] = data['FLAGS']
                    msg.pop('keywords', None)

        # if msg is already there, ignore invalid False folders
        if (mids[True] or mids[False]) and \
            msg['mailboxIds'] != mids[True]:
            if len(mids[True]) > 1 or \
               len(mids[False]) > 1 or \
               msg['mailboxIds'] != mids[False]:
                raise errors.tooManyMailboxes(
                    "Email must be always in exactly 1 mailbox")
            try:
                mailbox_to = self.mailboxes[mids[True][0]]
            except KeyError:
                raise errors.notFound('Mailbox not found')
            ok, lines = await self.imap.uid_move(
                uid, quoted(mailbox_to['imapname']))
            if ok != 'OK':
                raise errors.serverFail('\n'.join(lines))
예제 #9
0
 async def destroy_emails(self, ids):
     destroyed = []
     notDestroyed = {}
     uids = []
     for id in ids:
         try:
             uids.append(self.parse_email_id(id))
             self.emails.pop(id, None)
             destroyed.append(id)
         except ValueError:
             notDestroyed[id] = errors.notFound().to_dict()
     uidset = encode_messageset(uids).decode()
     await self.imap.uid_store(uidset, '+FLAGS', '(\\Deleted)')
     await self.imap.uid_expunge(uidset)
     # TODO: notDestroyed[id] = errors.notFound().to_dict()
     return destroyed, notDestroyed
예제 #10
0
    async def email_import(self, ifInState=None, emails=()):
        oldState = await self.thread_state()
        if ifInState and ifInState != oldState:
            raise errors.stateMismatch({'newState': oldState})

        created = {}
        notCreated = {}
        for id, email in emails.items():
            try:
                blobId = email.get('blobId', None)
                if not blobId:
                    raise errors.invalidArguments()
                body = await self.download(blobId)
                mailboxIds = email.get('mailboxIds', None)
                if not mailboxIds:
                    raise errors.invalidArguments('mailboxIds are required')
                elif len(mailboxIds) > 1:
                    raise errors.invalidArguments('Max 1 mailboxIds allowed')
                try:
                    imapname = self.mailboxes[mailboxIds[0]]['imapname']
                except KeyError:
                    raise errors.notFound(
                        f"mailboxId {mailboxIds[0]} not found")
                flags = "(%s)" % (' '.join(
                    keyword2flag(kw) for kw in email['keywords']))
                date = email.get('receivedAt', datetime.now())
                uid, guid = await self._imap_append(body, imapname, flags,
                                                    date)
                created[id] = self.format_email_id(uid)
            except errors.JmapError as e:
                notCreated[id] = e.to_dict()
            except Exception as e:
                notCreated[id] = errors.serverPartialFail(str(e))

        return {
            'accountId': self.id,
            'oldState': oldState,
            'newState': await self.email_state(),
            'created': created,
            'notCreated': notCreated,
        }
예제 #11
0
 def mailbox_imapname(self, parentId, name):
     parent = self.mailboxes.get(parentId, None)
     if not parent:
         raise errors.notFound('parent folder not found')
     return parent['imapname'] + parent['sep'] + name
예제 #12
0
    def get_messages(self,
                     properties=(),
                     sort={},
                     inMailbox=None,
                     inMailboxOtherThan=(),
                     id__in=None,
                     threadId__in=None,
                     **criteria):
        # XXX: id == threadId for now
        if id__in is None and threadId__in is not None:
            id__in = [id[1:] for id in threadId__in]
        if id__in is None:
            messages = []
        else:
            # try get everything from cache
            messages, id__in, properties = self.get_messages_cached(
                properties, id__in=id__in)

        fetch_fields = {
            f
            for prop, f in FIELDS_MAP.items() if prop in properties
        }
        if 'RFC822' in fetch_fields:
            # remove redundand fields
            fetch_fields.discard('RFC822.HEADER')
            fetch_fields.discard('RFC822.SIZE')

        if inMailbox:
            mailbox = self.mailboxes.get(inMailbox, None)
            if not mailbox:
                raise errors.notFound(f'Mailbox {inMailbox} not found')
            mailboxes = [mailbox]
        elif inMailboxOtherThan:
            mailboxes = [
                m for m in self.mailboxes.values()
                if m['id'] not in inMailboxOtherThan
            ]
        else:
            mailboxes = self.mailboxes.values()

        search_criteria = as_imap_search(criteria)
        sort_criteria = as_imap_sort(sort) or '' if sort else None

        mailbox_uids = {}
        if id__in is not None:
            if len(id__in) == 0:
                return messages  # no messages matches empty ids
            if not fetch_fields and not sort_criteria:
                # when we don't need anything new from IMAP, create empty messages
                # useful when requested conditions can be calculated from id (threadId)
                messages.extend(
                    self.messages.get(id, 0) or ImapMessage(id=id)
                    for id in id__in)
                return messages

            for id in id__in:
                # TODO: check uidvalidity
                mailboxid, uidvalidity, uid = parse_message_id(id)
                uids = mailbox_uids.get(mailboxid, [])
                if not uids:
                    mailbox_uids[mailboxid] = uids
                uids.append(uid)
            # filter out unnecessary mailboxes
            mailboxes = [m for m in mailboxes if m['id'] in mailbox_uids]

        for mailbox in mailboxes:
            imapname = mailbox['imapname']
            if self.selected_folder[0] != imapname:
                self.imap.select_folder(imapname, readonly=True)
                self.selected_folder = (imapname, True)

            uids = mailbox_uids.get(mailbox['id'], None)
            # uids are now None or not empty
            # fetch all
            if sort_criteria:
                if uids:
                    search = f'{",".join(map(str, uids))} {search_criteria}'
                else:
                    search = search_criteria or 'ALL'
                uids = self.imap.sort(sort_criteria, search)
            elif search_criteria:
                if uids:
                    search = f'{",".join(map(str, uids))} {search_criteria}'
                uids = self.imap.search(search)
            if uids is None:
                uids = '1:*'
            fetch_fields.add('UID')
            fetches = self.imap.fetch(uids, fetch_fields)

            for uid, data in fetches.items():
                id = format_message_id(mailbox['id'], mailbox['uidvalidity'],
                                       uid)
                msg = self.messages.get(id, None)
                if not msg:
                    msg = ImapMessage(id=id, mailboxIds=[mailbox['id']])
                    self.messages[id] = msg
                for k, v in data.items():
                    msg[k.decode()] = v
                messages.append(msg)
        return messages
예제 #13
0
    async def mailbox_set(self,
                          idmap,
                          ifInState=None,
                          create=None,
                          update=None,
                          destroy=None,
                          onDestroyRemoveEmails=False):
        """https://jmap.io/spec-mail.html#mailboxset"""
        oldState = await self.mailbox_state()
        if ifInState is not None and ifInState != oldState:
            raise errors.stateMismatch()

        # CREATE
        created = {}
        notCreated = {}
        created_imapnames = {}
        for cid, mailbox in (create or {}).items():
            mbox = ImapMailbox(mailbox)
            mbox.db = self
            try:
                imapname = mbox['imapname']
            except KeyError:
                raise errors.notFound("Parent mailbox not found")
            try:
                ok, lines = await self.imap.create(quoted(imapname))
                if ok != 'OK':
                    if '[ALREADYEXISTS]' in lines[0]:
                        raise errors.invalidArguments(lines[0])
                    else:
                        raise errors.serverFail(lines[0])
                # OBJECTID extension returns MAILBOXID on create
                match = re.search(r'\[MAILBOXID \(([^)]+)\)\]', lines[0])
                if match:
                    id = match.group(1)
                    mbox[id] = id
                    mbox['sep'] = '/'
                    mbox['flags'] = set()
                    self.mailboxes[id] = mbox
                    self.byimapname[imapname] = mbox
                    created[cid] = {'id': id}
                else:
                    # set created[cid] after sync_mailboxes()
                    created_imapnames[cid] = imapname
                if not mailbox.get('isSubscribed', True):
                    ok, lines = await self.imap.unsubscribe(imapname)
                    # TODO: handle failed unsubscribe
            except KeyError:
                notCreated[cid] = errors.invalidArguments().to_dict()
            except errors.JmapError as e:
                notCreated[cid] = e.to_dict()

        # UPDATE
        updated = {}
        notUpdated = {}
        for id, update in (update or {}).items():
            try:
                mailbox = self.mailboxes.get(id, None)
                if not mailbox or mailbox['deleted']:
                    raise errors.notFound(f'Mailbox {id} not found')
                await self.update_mailbox(mailbox, update)
                updated[id] = update
            except errors.JmapError as e:
                notUpdated[id] = e.to_dict()

        # DESTROY
        destroyed = []
        notDestroyed = {}
        for id in destroy or ():
            try:
                mailbox = self.mailboxes.get(id, None)
                if not mailbox or mailbox['deleted']:
                    raise errors.notFound('mailbox not found')
                ok, lines = await self.imap.delete(quoted(mailbox['imapname']))
                if ok != 'OK':
                    raise errors.serverFail(lines[0])
                mailbox['deleted'] = True
                destroyed.append(id)
            except errors.JmapError as e:
                notDestroyed[id] = e.to_dict()

        await self.sync_mailboxes({'id', 'imapname'})
        for cid, imapname in created_imapnames.values():
            mbox = self.byimapname.get(imapname, None)
            if mbox:
                created[cid] = {'id': mbox['id']}
                idmap.set(cid, mbox['id'])
            else:
                notCreated[cid] = errors.serverFail().to_dict()

        return {
            'accountId': self.id,
            'oldState': oldState,
            'newState': await self.mailbox_state(),
            'created': created,
            'notCreated': notCreated,
            'updated': updated,
            'notUpdated': notUpdated,
            'destroyed': destroyed,
            'notDestroyed': notDestroyed,
        }
예제 #14
0
    def as_imap_search(self, criteria):
        out = bytearray()
        if 'operator' in criteria:
            operator = criteria['operator']
            conds = criteria['conditions']
            if operator == 'NOT' or operator == 'OR':
                if len(conds) > 0:
                    if operator == 'NOT':
                        out += b'NOT '
                    lastcond = len(conds) - 1
                    for i, cond in enumerate(conds):
                        # OR needs 2 args, we can omit last OR
                        if i < lastcond:
                            out += b'OR '
                        out += self.as_imap_search(cond)
                        out += b' '
                    return out
                else:
                    raise errors.unsupportedFilter(f"Empty filter conditions")
            elif operator == 'AND':
                for c in conds:
                    out += self.as_imap_search(c)
                    out += b' '
                return out
            raise errors.unsupportedFilter(f"Invalid operator {operator}")

        for crit, value in criteria.items():
            search, func = SEARCH_MAP.get(crit, (None, None))
            if search:
                out += search
                out += b' '
                out += func(value) if func else value
                out += b' '
            elif 'deleted' == crit:
                if not value:
                    out += b'NOT '
                out += b'DELETED '
            elif 'header' == crit:
                out += b'HEADER '
                out += value[0].encode()
                out += b' '
                out += value[1].encode()
                out += b' '
            elif 'hasAttachment' == crit:
                if not value:
                    out += b'NOT '
                # needs Dovecot flag attachments on save
                out += b'KEYWORD $HasAttachment '
                # or out += b'MIMEPART (DISPOSITION TYPE attachment)')
            elif 'inMailbox' == crit:
                out += b'X-MAILBOX '
                try:
                    out += quoted(self.mailboxes[value]["imapname"].encode())
                    out += b' '
                except KeyError:
                    raise errors.notFound(f"Mailbox {value} not found")
            elif 'threadIds' == crit:
                if value:
                    out += b'INTHREAD REFS'
                    i = len(value)
                    for id in value:
                        if i > 1:
                            out += b' OR'
                        out += b' X-GUID '
                        out += id.encode()
                        i -= 1
                    out += b' '
            elif 'inMailboxOtherThan' == crit:
                try:
                    for id in value:
                        out += b'NOT X-MAILBOX '
                        out += quoted(self.mailboxes[id]["imapname"].encode())
                        out += b' '
                except KeyError:
                    raise errors.notFound(f"Mailbox {value} not found")
            else:
                raise UserWarning(f'Filter {crit} not supported')
        if out:
            out.pop()
        return out
예제 #15
0
    async def emailsubmission_set(self, idmap, ifInState=None,
                                  create=None, update=None, destroy=None,
                                  onSuccessUpdateEmail=None,
                                  onSuccessDestroyEmail=None):
        oldState = await self.emailsubmission_state()
        if ifInState and ifInState != oldState:
            raise errors.stateMismatch({"newState": oldState})

        # CREATE
        created = {}
        notCreated = {}
        if create:
            emailIds = [e['emailId'] for e in create.values()]
            await self.fill_emails(['blobId'], emailIds)
        else:
            create = {}
        for cid, submission in create.items():
            identity = self.identities.get(submission['identityId'], None)
            if identity is None:
                raise errors.notFound(f"Identity {submission['identityId']} not found")
            email = self.emails.get(submission['emailId'], None)
            if not email:
                raise errors.notFound(f"EmailId {submission['emailId']} not found")
            envelope = submission.get('envelope', None)
            if envelope:
                sender = envelope['mailFrom']['email']
                recipients = [to['email'] for to in envelope['rcptTo']]
            else:
                # TODO: If multiple addresses are present in one of these header fields,
                #       or there is more than one Sender/From header field, the server
                #       SHOULD reject the EmailSubmission as invalid; otherwise,
                #       it MUST take the first address in the last Sender/From header field.
                sender = (email['sender'] or email['from'])[0]['email']
                recipients = set(to['email'] for to in email['to'] or ())
                recipients.update(to['email'] for to in email['cc'] or ())
                recipients.update(to['email'] for to in email['bcc'] or ())

            body = await self.download(email['blobId'])

            await aiosmtplib.send(
                body,
                sender=sender,
                recipients=recipients,
                hostname=self.smtp_host,
                port=self.smtp_port,
                # username=self.smtp_user,
                # password=self.smtp_pass,
            )
            id = 'fOobAr'
            idmap.set(cid, id)
            created[cid] = {'id': id}

        updated = []
        destroyed = []
        notDestroyed = []

        result = {
            "accountId": self.id,
            "oldState": oldState,
            "newState": await self.emailsubmission_state(),
            "created": created,
            "notCreated": notCreated,
            "destroyed": destroyed,
            "notDestroyed": notDestroyed,
        }

        if onSuccessUpdateEmail or onSuccessDestroyEmail:
            successfull = set(created.keys())
            successfull.update(updated, destroyed)

            updateEmail = {}
            for id in successfull:
                patch = onSuccessUpdateEmail.get(f"#{id}", None)
                if patch:
                    updateEmail[create[id]['emailId']] = patch
            destroyEmail = [id for id in successfull if f"#{id}" in onSuccessDestroyEmail]

            if updateEmail or destroyEmail:
                update_result = await self.email_set(
                    idmap,
                    update=updateEmail,
                    destroy=destroyEmail,
                )
                update_result['method_name'] = 'Email/set'
                return result, update_result
        return result