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()
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'], }
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')
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()
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")
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()
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
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))
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
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, }
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
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
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, }
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
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