diff --git a/src/mail_devel/__init__.py b/src/mail_devel/__init__.py index 7f2d9b1..f80b806 100644 --- a/src/mail_devel/__init__.py +++ b/src/mail_devel/__init__.py @@ -1,3 +1,3 @@ from .service import Service -VERSION = "0.10.2" +VERSION = "0.11.0" diff --git a/src/mail_devel/http.py b/src/mail_devel/http.py index 6b961db..52ebe28 100644 --- a/src/mail_devel/http.py +++ b/src/mail_devel/http.py @@ -1,5 +1,8 @@ +import hashlib +import json import logging import os +import secrets import ssl import uuid from email import header, message_from_bytes, message_from_string, policy @@ -8,9 +11,11 @@ from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from importlib import resources +from typing import Tuple +import aiohttp from aiohttp import web -from aiohttp.web import Request, Response +from aiohttp.web import Request, Response, WebSocketResponse from pymap.backend.dict.mailbox import Message from pymap.parsing.specials import FetchRequirement from pymap.parsing.specials.flag import Flag @@ -60,6 +65,7 @@ def __init__( port: int = 8080, devel: str = "", flagged_seen: bool = False, + ensure_message_id: bool = True, client_max_size: int = 1 << 20, multi_user: bool = False, ): @@ -70,9 +76,12 @@ def __init__( self.user: str = user self.devel: str = devel self.flagged_seen: bool = flagged_seen + self.ensure_message_id = ensure_message_id self.client_max_size: int = client_max_size self.multi_user: bool = multi_user + self.mail_cache: dict[str, Tuple[str, str, int]] = {} + def load_resource(self, resource: str) -> str: if self.devel: # pragma: no cover with open(os.path.join(self.devel, resource), encoding="utf-8") as fp: @@ -91,35 +100,10 @@ async def start(self) -> None: self.api.add_routes( [ web.get("/", self._page_index), - web.get("/config", self._api_config), web.get(r"/{static:.*\.(css|js)}", self._page_static), - web.post(r"/api/{account:\d+}/{mailbox:\d+}", self._api_post), - web.post(r"/api/upload/{account:\d+}/{mailbox:\d+}", self._api_upload), - web.get(r"/api", self._api_index), - web.get(r"/api/{account:\d+}", self._api_account), - web.get(r"/api/{account:\d+}/{mailbox:\d+}", self._api_mailbox), - web.get( - r"/api/{account:\d+}/{mailbox:\d+}/{uid:\d+}", - self._api_message, - ), - web.get( - r"/api/{account:\d+}/{mailbox:\d+}/{uid:\d+}/reply", - self._api_reply, - ), + web.get("/websocket", self._websocket), web.get( - r"/api/{account:\d+}/{mailbox:\d+}/{uid:\d+}/attachment/{attachment}", - self._api_attachment, - ), - web.get( - r"/api/{account:\d+}/{mailbox:\d+}/{uid:\d+}/flags", self._api_flag - ), - web.put( - r"/api/{account:\d+}/{mailbox:\d+}/{uid:\d+}/flags/{flag:[a-z]+}", - self._api_flag, - ), - web.delete( - r"/api/{account:\d+}/{mailbox:\d+}/{uid:\d+}/flags/{flag:[a-z]+}", - self._api_flag, + r"/attachment/{mail:.*}/{attachment:.*}", self._download_attachment ), ] ) @@ -130,6 +114,35 @@ async def start(self) -> None: port=self.port, ) + async def _websocket(self, request: Request) -> WebSocketResponse: + ws = WebSocketResponse() + await ws.prepare(request) + + _logger.info(f"Connected websocket: {request.remote}") + async for msg in ws: + if msg.type != aiohttp.WSMsgType.TEXT: + continue + + try: + data = json.loads(msg.data) + except json.JSONDecodeError: + continue + + if not isinstance(data, dict) or not data.get("command"): + continue + + command = data.pop("command") + if command == "close": + await ws.close() + continue + + func = getattr(self, f"on_{command}", None) + if callable(func): + await func(ws, **data) + + _logger.info(f"Disconnected websocket: {request.remote}") + return ws + async def _page_index(self, request: Request) -> Response: # pylint: disable=W0613 try: return Response( @@ -158,7 +171,15 @@ async def _page_static(self, request: Request) -> Response: async def _message_content(self, msg: Message) -> bytes: return bytes((await msg.load_content(FetchRequirement.CONTENT)).content) - async def _convert_message(self, msg: Message, full: bool = False) -> dict: + def message_hash(self, content: bytes | str) -> str: + if isinstance(content, str): + content = content.encode() + + return hashlib.sha512(content).hexdigest() + + async def _convert_message( + self, msg: Message, *, account: str, mailbox: str, full: bool = False + ) -> dict: content = await self._message_content(msg) message = message_from_bytes(content) @@ -172,6 +193,9 @@ async def _convert_message(self, msg: Message, full: bool = False) -> dict: if not full: return result + msg_hash = self.message_hash(content) + self.mail_cache[msg_hash] = (account, mailbox, msg.uid) + result["attachments"] = [] if message.is_multipart(): for part in message.walk(): @@ -179,7 +203,10 @@ async def _convert_message(self, msg: Message, full: bool = False) -> dict: cdispo = part.get_content_disposition() if cdispo == "attachment": - result["attachments"].append(part.get_filename()) + name = part.get_filename() + result["attachments"].append( + {"name": name, "url": f"/attachment/{msg_hash}/{name}"} + ) elif ctype == "text/plain": result["body_plain"] = part.get_payload(decode=True).decode() elif ctype == "text/html": @@ -192,75 +219,240 @@ async def _convert_message(self, msg: Message, full: bool = False) -> dict: result["content"] = bytes(content).decode() return result - async def _api_config(self, request: Request) -> Response: # pylint: disable=W0613 - return web.json_response( + async def on_config(self, ws: WebSocketResponse) -> None: + await ws.send_json( + { + "command": "config", + "data": { + "multi_user": self.multi_user, + "flagged_seen": self.flagged_seen, + }, + } + ) + + async def on_list_accounts(self, ws: WebSocketResponse) -> None: + await ws.send_json( { - "multi_user": self.multi_user, - "flagged_seen": self.flagged_seen, + "command": "list_accounts", + "data": { + "accounts": await self.mailboxes.list(), + }, } ) - async def _api_index(self, request: Request) -> Response: # pylint: disable=W0613 - accounts = await self.mailboxes.list() - return web.json_response( - {self.mailboxes.id_of_user(user): user for user in accounts} + async def on_list_mailboxes(self, ws: WebSocketResponse, account: str) -> None: + acc = await self.mailboxes.get(account) + mailboxes = await acc.list_mailboxes() + await ws.send_json( + { + "command": "list_mailboxes", + "data": { + "account": account, + "mailboxes": [m.name for m in mailboxes.list()], + }, + } ) - async def _api_account(self, request: Request) -> Response: # pylint: disable=W0613 + async def on_list_mails( + self, ws: WebSocketResponse, account: str | None, mailbox: str | None + ) -> None: + if not account: + return + + if not mailbox: + mailbox = "INBOX" + try: - account_id = int(request.match_info["account"]) - account = await self.mailboxes.get_by_id(account_id) - except KeyError as e: # pragma: no cover - raise web.HTTPNotFound() from e + mbox = await self.mailboxes[account].get_mailbox(mailbox) + except (IndexError, KeyError): # pragma: no cover + return - mailboxes = await account.list_mailboxes() - return web.json_response( - {account.id_of_mailbox(e.name): e.name for e in mailboxes.list()} + result = [] + async for msg in mbox.messages(): + result.append( + await self._convert_message(msg, account=account, mailbox=mailbox) + ) + + result.sort(key=lambda x: x["date"], reverse=True) + + await ws.send_json( + { + "command": "list_mails", + "data": {"account": account, "mailbox": mailbox, "mails": result}, + } ) - async def _api_upload(self, request: Request) -> Response: # pylint: disable=W0613 + async def on_get_mail( + self, ws: WebSocketResponse, account: str, mailbox: str, uid: int + ) -> None: try: - account_id = int(request.match_info["account"]) - account = self.mailboxes.user_mapping[account_id] - mailbox_id = int(request.match_info["mailbox"]) - mailbox = self.mailboxes[account].get_mailbox_name(mailbox_id) - except KeyError as e: # pragma: no cover - raise web.HTTPNotFound() from e + mbox = await self.mailboxes[account].get_mailbox(mailbox) + except (IndexError, KeyError): # pragma: no cover + return + + async for msg in mbox.messages(): + if msg.uid == uid: + await ws.send_json( + { + "command": "get_mail", + "data": { + "account": account, + "mailbox": mailbox, + "uid": uid, + "mail": await self._convert_message( + msg, + account=account, + mailbox=mailbox, + full=True, + ), + }, + } + ) + break + + async def on_random_mail( + self, ws: WebSocketResponse, account: str, mailbox: str + ) -> None: + headers = { + "subject": f"Random Subject [{secrets.token_hex(8)}]", + "message-id": f"{uuid.uuid4()}@mail-devel", + "to": account, + "from": f"{secrets.token_hex(8)}@mail-devel", + } + _logger.info("Randomized mail") + await ws.send_json( + { + "command": "random_mail", + "data": { + "account": account, + "mailbox": mailbox, + "mail": { + "header": headers, + "body_plain": f"Body {uuid.uuid4()}", + }, + }, + } + ) + + async def on_reply_mail( + self, ws: WebSocketResponse, account: str, mailbox: str, uid: int + ) -> None: + try: + mbox = await self.mailboxes[account].get_mailbox(mailbox) + except (IndexError, KeyError): # pragma: no cover + return + + async for msg in mbox.messages(): + if msg.uid == uid: + message = await self._convert_message( + msg, + account=account, + mailbox=mailbox, + full=True, + ) + + headers = message["header"] + headers["subject"] = f"RE: {headers['subject']}" + msg_id = headers.get("message-id", None) + if msg_id: + headers["in-reply-to"] = msg_id + headers["references"] = f"{msg_id} {headers.get('references', '')}" + headers["message-id"] = f"{uuid.uuid4()}@mail-devel" + headers["to"] = headers["from"] + headers["from"] = self.user + headers.pop("content-type", None) + for key in list(headers): + if key.startswith("x-"): + headers.pop(key, None) - data = await request.json() - if not isinstance(data, list): - raise web.HTTPBadRequest() + await ws.send_json( + { + "command": "reply_mail", + "data": { + "account": account, + "mailbox": mailbox, + "uid": uid, + "mail": message, + }, + } + ) + break + async def on_flag_mail( + self, + ws: WebSocketResponse, + account: str, + mailbox: str, + uid: int, + method: str, + flag: str, + ) -> None: + try: + mbox = await self.mailboxes[account].get_mailbox(mailbox) + method = method.lower() + + if method in ("unset", "set"): + flags = [Flag(b"\\" + flag.title().encode())] + else: + flags = [] + except (IndexError, KeyError): # pragma: no cover + return + + async for msg in mbox.messages(): + if msg.uid != uid: + continue + + if method == "unset": + msg.permanent_flags = msg.permanent_flags.difference(flags) + elif method == "set": + msg.permanent_flags = msg.permanent_flags.union(flags) + + _logger.info( + f"{method.title()} flag {flag} of mail {uid}: {flags}: {msg.permanent_flags}" + ) + + await self.on_list_mails(ws, account, mailbox) + break + + async def on_upload_mails( + self, + ws: WebSocketResponse, + account: str | None, + mailbox: str | None, + mails: list[dict], + ) -> None: compat_strict = policy.compat32.clone(raise_on_defect=True) - for mail in data: + counter = 0 + for mail in mails: try: msg = message_from_string(mail["data"], policy=compat_strict) + + if not msg["Message-Id"] and self.ensure_message_id: + msg.add_header("Message-Id", f"{uuid.uuid4()}@mail-devel") + await self.mailboxes.append( msg, flags=frozenset({Flag(b"\\Seen")} if self.flagged_seen else []), - mailbox=mailbox, + mailbox=mailbox or "INBOX", ) + counter += 1 except MessageDefect: continue - return web.json_response({}) - - async def _api_post(self, request: Request) -> Response: - try: - account_id = int(request.match_info["account"]) - account = self.mailboxes.user_mapping[account_id] - mailbox_id = int(request.match_info["mailbox"]) - mailbox = self.mailboxes[account].get_mailbox_name(mailbox_id) - except KeyError as e: # pragma: no cover - raise web.HTTPNotFound() from e - - data = await request.json() - if not isinstance(data, dict): - raise web.HTTPBadRequest() + if counter: + _logger.info(f"Uploaded {counter} mails") + await self.on_list_mails(ws, account, mailbox) - header, body = map(data.get, ("header", "body")) + async def on_send_mail( + self, + ws: WebSocketResponse, + account: str | None, + mailbox: str | None, + mail: dict, + ) -> None: + header, body = map(mail.get, ("header", "body")) if not isinstance(header, dict) or not isinstance(body, str): - raise web.HTTPBadRequest() + return message = MIMEMultipart() message.attach(MIMEText(body)) @@ -269,7 +461,10 @@ async def _api_post(self, request: Request) -> Response: if key.strip() and value.strip(): message.add_header(key.title(), value) - for att in data.get("attachments", []): + if not message["Message-Id"] and self.ensure_message_id: + message.add_header("Message-Id", f"{uuid.uuid4()}@mail-devel") + + for att in mail.get("attachments", []): part = MIMEBase(*(att["mimetype"] or "text/plain").split("/")) part.set_payload(att["content"]) part.add_header("Content-Transfer-Encoding", "base64") @@ -282,84 +477,26 @@ async def _api_post(self, request: Request) -> Response: await self.mailboxes.append( message, flags=frozenset({Flag(b"\\Seen")} if self.flagged_seen else []), - mailbox=mailbox, + mailbox=mailbox or "INBOX", ) - return web.json_response({"status": "ok"}) - - async def _api_mailbox(self, request: Request) -> Response: - try: - account_id = int(request.match_info["account"]) - account = self.mailboxes.user_mapping[account_id] - mailbox_id = int(request.match_info["mailbox"]) - mailbox = await self.mailboxes[account].get_mailbox_by_id(mailbox_id) - except KeyError as e: # pragma: no cover - raise web.HTTPNotFound() from e - - result = [] - async for msg in mailbox.messages(): - result.append(await self._convert_message(msg)) - result.sort(key=lambda x: x["date"], reverse=True) - return web.json_response(result) - - async def _api_message(self, request: Request) -> Response: - try: - account_id = int(request.match_info["account"]) - account = self.mailboxes.user_mapping[account_id] - mailbox_id = int(request.match_info["mailbox"]) - uid = int(request.match_info["uid"]) - mailbox = await self.mailboxes[account].get_mailbox_by_id(mailbox_id) - except (IndexError, KeyError) as e: # pragma: no cover - raise web.HTTPNotFound() from e - - async for msg in mailbox.messages(): - if msg.uid == uid: - return web.json_response(await self._convert_message(msg, True)) - - raise web.HTTPNotFound() + _logger.info("New mail sent") + await self.on_list_mails(ws, account, mailbox) - async def _api_reply(self, request: Request) -> Response: + async def _download_attachment(self, request: Request) -> Response: try: - account_id = int(request.match_info["account"]) - mailbox_id = int(request.match_info["mailbox"]) - uid = int(request.match_info["uid"]) - account = await self.mailboxes.get_by_id(account_id) - mailbox = await account.get_mailbox_by_id(mailbox_id) - except (IndexError, KeyError) as e: # pragma: no cover - raise web.HTTPNotFound() from e - - async for msg in mailbox.messages(): - if msg.uid == uid: - message = await self._convert_message(msg, True) - headers = message["header"] - headers["subject"] = f"RE: {headers['subject']}" - msg_id = headers.get("message-id", None) - if msg_id: - headers["in-reply-to"] = msg_id - headers["references"] = f"{msg_id} {headers.get('references', '')}" - headers["message-id"] = f"{uuid.uuid4()}@mail-devel" - headers["to"] = headers["from"] - headers["from"] = self.user - headers.pop("content-type", None) - for key in list(headers): - if key.startswith("x-"): - headers.pop(key, None) - return web.json_response(message) + mail_hash = request.match_info["mail"] + if mail_hash not in self.mail_cache: + raise web.HTTPNotFound() - raise web.HTTPNotFound() - - async def _api_attachment(self, request: Request) -> Response: - try: - account_id = int(request.match_info["account"]) - mailbox_id = int(request.match_info["mailbox"]) - uid = int(request.match_info["uid"]) + account, mailbox, uid = self.mail_cache[mail_hash] attachment = request.match_info["attachment"] - account = await self.mailboxes.get_by_id(account_id) - mailbox = await account.get_mailbox_by_id(mailbox_id) + mbox = await self.mailboxes[account].get_mailbox(mailbox) except (IndexError, KeyError) as e: # pragma: no cover + self.mail_cache.pop(mail_hash, None) raise web.HTTPNotFound() from e message = None - async for msg in mailbox.messages(): + async for msg in mbox.messages(): if msg.uid == uid: content = await self._message_content(msg) message = message_from_bytes(content) @@ -383,31 +520,3 @@ async def _api_attachment(self, request: Request) -> Response: ) raise web.HTTPNotFound() - - async def _api_flag(self, request: Request) -> Response: - try: - account_id = int(request.match_info["account"]) - mailbox_id = int(request.match_info["mailbox"]) - account = await self.mailboxes.get_by_id(account_id) - mailbox = await account.get_mailbox_by_id(mailbox_id) - uid = int(request.match_info["uid"]) - - if request.method in ("DELETE", "PUT"): - flags = [Flag(b"\\" + request.match_info["flag"].title().encode())] - else: - flags = [] - except (IndexError, KeyError) as e: # pragma: no cover - raise web.HTTPNotFound() from e - - async for msg in mailbox.messages(): - if msg.uid != uid: - continue - - if request.method == "DELETE": - msg.permanent_flags = msg.permanent_flags.difference(flags) - elif request.method == "PUT": - msg.permanent_flags = msg.permanent_flags.union(flags) - - return web.json_response(flags_to_api(msg.permanent_flags)) - - raise web.HTTPNotFound() diff --git a/src/mail_devel/mailbox.py b/src/mail_devel/mailbox.py index 33220a5..9f76944 100644 --- a/src/mail_devel/mailbox.py +++ b/src/mail_devel/mailbox.py @@ -14,26 +14,6 @@ class TestMailboxSet(MailboxSet): """This MailboxSet creates the mailboxes automatically""" - def __init__(self): - super().__init__() - self.mailbox_mapping: dict[int, str] = {} - - def id_of_mailbox(self, name: str) -> int: - for mailbox_id, mailbox_name in self.mailbox_mapping.items(): - if mailbox_name == name: - return mailbox_id - - new_id = len(self.mailbox_mapping) + 1 - self.mailbox_mapping[new_id] = name - return new_id - - async def get_mailbox_by_id(self, mailbox_id: int) -> MailboxData: - mailbox_name = self.mailbox_mapping[mailbox_id] - return await self.get_mailbox(mailbox_name) - - def get_mailbox_name(self, mailbox_id: int) -> str: - return self.mailbox_mapping[mailbox_id] - async def get_mailbox(self, name: str) -> MailboxData: if name not in self._set: await self.add_mailbox(name) @@ -49,7 +29,6 @@ def __init__( self.config: IMAPConfig = config self.filter_set: FilterSet = filter_set self.multi_user: bool = multi_user - self.user_mapping: dict[int, str] = {} def __contains__(self, user: str) -> bool: return not self.multi_user or user in self.config.set_cache @@ -57,15 +36,6 @@ def __contains__(self, user: str) -> bool: def __getitem__(self, user: str) -> TestMailboxSet: return self.config.set_cache[user][0] - def id_of_user(self, name: str) -> int: - for uid, user_name in self.user_mapping.items(): - if user_name == name: - return uid - - new_id = len(self.user_mapping) + 1 - self.user_mapping[new_id] = name - return new_id - async def inbox_stats(self) -> dict[str, int]: stats = {} for user, (mset, _fset) in self.config.set_cache.items(): @@ -80,13 +50,6 @@ async def list(self): return list(self.config.set_cache) - async def get_by_id(self, user_id: int) -> TestMailboxSet: - if not self.multi_user: - return await self.get(self.config.demo_user) - - user_name = self.user_mapping[user_id] - return await self.get(user_name) - async def get(self, user: str) -> TestMailboxSet: """Get the mailbox for the user or the main mailbox if single user mode""" if not self.multi_user: @@ -96,6 +59,7 @@ async def get(self, user: str) -> TestMailboxSet: mbox = TestMailboxSet() await mbox.get_mailbox("INBOX") self.config.set_cache[user] = mbox, self.filter_set + return self.config.set_cache[user][0] async def append( diff --git a/src/mail_devel/resources/index.html b/src/mail_devel/resources/index.html index e94b28e..c3f4cf0 100644 --- a/src/mail_devel/resources/index.html +++ b/src/mail_devel/resources/index.html @@ -39,11 +39,14 @@
Summary | Date | Read | -Deleted | +Deleted | @@ -82,6 +85,8 @@
---|
From: | diff --git a/src/mail_devel/resources/main.css b/src/mail_devel/resources/main.css index e56a2fd..5c0f046 100644 --- a/src/mail_devel/resources/main.css +++ b/src/mail_devel/resources/main.css @@ -80,9 +80,13 @@ button, .btn { #wrapper { display: grid; height: 100%; - grid-template-areas: "nav mailbox" "nav header" "nav content"; - grid-template-columns: 250px auto; - grid-template-rows: 30% max-content auto; + grid-template-areas: + "nav nav-drag mailbox" + "nav nav-drag mailbox-drag" + "nav nav-drag header" + "nav nav-drag content"; + grid-template-columns: 250px 3px auto; + grid-template-rows: 30% 3px max-content auto; } *:focus-visible {outline: none} @@ -104,6 +108,8 @@ button, .btn { display: flex; flex-direction: column; grid-area: nav; + overflow: auto; + resize: horizontal; padding: 0 15px; } #header { @@ -112,6 +118,16 @@ button, .btn { border: 2px solid var(--border); } #accounts {padding: 5pt 2pt} +#nav-dragbar { + background-color: var(--primary); + grid-area: nav-drag; + cursor: ew-resize; +} +#mailbox-dragbar { + background-color: var(--primary); + grid-area: mailbox-drag; + cursor: ns-resize; +} #accounts, #nav-options, #mailboxes { margin: 10px 0; @@ -178,7 +194,6 @@ button, .btn { .header { background-color: var(--highlight); -/* border: 2px solid var(--border);*/ } .header th { text-align: right; @@ -210,7 +225,7 @@ button, .btn { display: none; } -#btn-new, #uploader { +#btn-new, #uploader, #btn-random { font-size: var(--font-big); padding: 5pt 2pt; margin: 2px 0; diff --git a/src/mail_devel/resources/main.js b/src/mail_devel/resources/main.js index f640768..43fcfc4 100644 --- a/src/mail_devel/resources/main.js +++ b/src/mail_devel/resources/main.js @@ -1,3 +1,7 @@ +function clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); +} + function element(tag, attrs) { const ele = document.createElement(tag); if (!attrs) @@ -31,19 +35,24 @@ function vis(selector, visible) { class MailClient { constructor() { - this.users = document.getElementById("accounts"); + this.accounts = document.getElementById("accounts"); this.connection = document.querySelector("#connection input[type=checkbox]"); this.mailboxes = document.getElementById("mailboxes"); this.mailbox = document.querySelector("#mailbox table tbody"); + this.wrapper = document.querySelector("#wrapper"); this.fixed_headers = ["from", "to", "cc", "bcc", "subject"]; - this.user_id = null; - this.mailbox_id = null; + this.account_name = null; + this.mailbox_name = null; this.mail_uid = null; this.mail_selected = null; this.content_mode = "html"; this.editor_mode = "simple"; this.config = {}; + this.reset_drag(); + + this.connect_socket(); + const toggle = document.querySelector("#color-scheme input"); if (document.documentElement.classList.contains("dark")) toggle.checked = true; @@ -55,19 +64,6 @@ class MailClient { this.reorder(false); } - async swap_theme() { - const toggle = document.querySelector("#color-scheme input"); - if (toggle.checked) { - document.documentElement.classList.add("light"); - document.documentElement.classList.remove("dark"); - } else { - document.documentElement.classList.add("dark"); - document.documentElement.classList.remove("light"); - } - - toggle.checked = !toggle.checked; - } - async visibility() { vis("#accounts", Boolean(this.config?.multi_user)); vis("#btn-html", this.content_mode !== "html"); @@ -87,162 +83,94 @@ class MailClient { async idle() { const self = this; - if (this.connection.checked) { - await this.fetch_accounts(); + await this.load_accounts(); - if (this.user_name) - await this.fetch_mailboxes(this.user_name); + if (this.account_name) + await this.load_mailboxes(); - if (this.mailbox_id) - await this.fetch_mailbox(this.mailbox_id); - } + if (this.mailbox_name) + await this.load_mailbox(); setTimeout(() => {self.idle();}, 2000); } - async set_flag(flag, method, uid = null) { + async flag_mail(flag, method, uid = null) { const mail_uid = uid || this.mail_uid; - if (this.mailbox_id && mail_uid) { - await fetch( - `/api/${this.user_id}/${this.mailbox_id}/${mail_uid}/flags/${flag}`, - {method: method}, + if (this.mailbox_name && mail_uid) { + await this.socket_send( + { + command: "flag_mail", + account: this.account_name, + mailbox: this.mailbox_name, + uid: uid, + method: method, + flag: flag + } ); } } - async fetch_json(path) { - try { - const response = await fetch(path); - if (response.status !== 200) - return null; + connect_socket() { + if (this.socket) + return; - return await response.json(); - } catch (TypeError) { - return null; - } - } + const proto = (window.location.protocol === "https:") ? "wss:" : "ws:"; + const url = `${proto}//${window.location.host}/websocket`; + this.socket = new WebSocket(url); - async fetch_data(...path) { - return await this.fetch_json(path.length ? `/api/${path.join('/')}` : "/api"); + const self = this; + this.socket.onmessage = this.on_socket_message.bind(this); + this.socket.onclose = this.on_socket_close.bind(this); + this.socket.onerror = this.on_socket_error.bind(this); + this.socket.onopen = this.on_socket_open.bind(this); } - async post_data(data, ...path) { - try { - const response = await fetch( - path.length ? `/api/${path.join('/')}` : "/api", - { - method: "POST", - headers: { - "Accept": "application/json", - "Content-Type": "application/json", - }, - body: JSON.stringify(data), - }, - ); - if (response.status !== 200) - return null; + close_socket() { + if (this.socket) + this.socket.close(); - return await response.json(); - } catch (TypeError) { - return null; - } + this.socket = null; } - async _mail_row_fill(row, msg) { - const self = this; - - if ((msg?.flags || []).indexOf("seen") < 0) { - row.classList.add("unseen"); - row.querySelector("td.read input").checked = false; - } else { - row.classList.remove("unseen"); - row.querySelector("td.read input").checked = true; - } - - if ((msg?.flags || []).indexOf("deleted") < 0) { - row.classList.remove("is_deleted"); - row.querySelector("td.deleted input").checked = false; - } else { - row.classList.add("is_deleted"); - row.querySelector("td.deleted input").checked = true; - } - - function content(selector, val) { - row.querySelector(selector).innerHTML = (val || "").replace("<", "<"); + async on_socket_message(event) { + /* Dispatch the data to the correct handler */ + const data = JSON.parse(event.data); + if (data.command) { + const func = this[`on_${data.command}`]; + if (typeof func === "function") { + func.call(this, data.data); + } } - - content(".from", msg.header?.from); - content(".to", msg.header?.to); - content(".subject", msg.header?.subject); - content(".date", (new Date(msg.date)).toLocaleString()); } - async _mail_row_init(template, msg) { - const self = this; - - const row = template.cloneNode(10); - row.removeAttribute("id"); - row.classList.remove("hidden"); - - row.querySelector(".read input").addEventListener("click", (ev) => { - ev.preventDefault(); - ev.stopPropagation(); - self._mail_row_click(ev.target, "read"); - }); - - row.querySelector(".deleted input").addEventListener("click", (ev) => { - ev.preventDefault(); - ev.stopPropagation(); - self._mail_row_click(ev.target, "deleted"); - }); - - row.addEventListener("click", (ev) => { - ev.preventDefault(); - ev.stopPropagation(); - self._mail_row_click(ev.target, "swap"); - }); - - return row; + async on_socket_open(event) { + await this.load_config(); } - async _mail_row_click(element, type) { - const self = this; - - let row = element; - while (row && !row.uid) { - row = row.parentElement; - } - - if (!row.uid) - return; - + async on_socket_close(event) { + if (!event.wasClean) + console.log("Websocket connection died"); - switch (type) { - case "swap": - if (self.mail_selected) - self.mail_selected.classList.remove("selected"); - - self.mail_selected = row; - self.mail_selected.classList.add("selected"); - await self.fetch_mail(row.uid); - break; + this.socket = null; + if (this.connection.checked) + setTimeout(1000, this.connect_socket.bind(this)); + } - case "read": - await self.set_flag("seen", element.checked ? "PUT" : "DELETE", row.uid); - element.checked = !element.checked; - break; + async on_socket_error(event) { + console.error("Websocket error", event); + } - case "deleted": - await self.set_flag("deleted", element.checked ? "PUT" : "DELETE", row.uid); - element.checked = !element.checked; - break; - } + async socket_send(data) { + if (this.socket && this.socket.readyState) + this.socket.send(JSON.stringify(data)); } async load_config() { - const data = await this.fetch_json("/config"); - if (data !== null) - this.config = data; + await this.socket_send({command: "config"}); + } + + async on_config(data) { + this.config = data; } async upload_files(element) { @@ -251,95 +179,111 @@ class MailClient { files.push({name: file.name, data: await file.text()}); } - await this.post_data(files, "upload", this.user_id, this.mailbox_id); + await this.socket_send( + { + command: "upload_mails", + account: this.account_name, + mailbox: this.mailbox_name, + mails: files, + }, + ); // Clear the files element.value = null; } - async fetch_accounts() { + async load_accounts() { + await this.socket_send({command: "list_accounts"}); + } + + async on_list_accounts(data) { const self = this; - const data = await this.fetch_data(); - if (data === null) + const accounts = data.accounts; + if (!accounts) return; - for (const opt of this.users.options) { - const user = data[opt.value]; - if (user !== undefined) - delete data[opt.value]; - else + for (const opt of this.accounts.options) { + if (accounts.indexOf(opt.value) < 0) opt.remove(); + else + delete accounts[opt.value]; } - for (const uid in data) - this.users.add(new Option(data[uid], uid)); + for (const account of accounts) + this.accounts.add(new Option(account, account)); - if (this.users.selectedIndex < 0) { - this.users.selectedIndex = 0; - if (this.users.options[0]) { - await self.fetch_mailboxes(this.users.options[0].value); + if (this.accounts.selectedIndex < 0) { + this.accounts.selectedIndex = 0; + if (this.accounts.options[0]) { + await self.load_mailboxes(this.accounts.options[0].value); } } else { - const selected = this.users.options[this.users.selectedIndex].value; - if (this.user_id !== selected) { - await self.fetch_mailboxes(selected); + const selected = this.accounts.options[this.accounts.selectedIndex].value; + if (this.account_name !== selected) { + await self.load_mailboxes(selected); } } } - async fetch_mailboxes(user_id) { + async load_mailboxes(account_name) { + if (!account_name) + account_name = this.account_name; + + if (account_name) + await this.socket_send({command: "list_mailboxes", account: account_name}); + } + + async on_list_mailboxes(data) { const self = this; - const data = await this.fetch_data(user_id); - if (data === null) - return; + const account_name = data.account; - if (this.user_id !== user_id) { - this.mailbox_id = null; + if (this.account_name !== account_name) { + this.mailbox_name = null; this.mail_uid = null; this.mailbox.innerHTML = ""; this.mailboxes.selectedIndex = -1; } - this.user_id = user_id; + this.account_name = account_name; for (const opt of this.mailboxes.options) { - const name = data[opt.uid]; - if (name) - data.splice(idx, 1); - else + if (data.mailboxes.indexOf(opt.uid) < 0) opt.remove() + else + data.splice(idx, 1); } - for (const uid in data) { - this.mailboxes.add(new Option(data[uid], uid, true, this.mailbox_id === uid)); + for (const mailbox of data.mailboxes) { + this.mailboxes.add( + new Option(mailbox, mailbox, true, this.mailbox_name === mailbox) + ); } if (this.mailboxes.selectedIndex < 0) { this.mailboxes.selectedIndex = 0; if (this.mailboxes.options[0]) { - await self.fetch_mailbox(this.mailboxes.options[0].value); + await self.load_mailbox(this.mailboxes.options[0].value); } } } - _display_mail(data) { - document.querySelector("#header-from input").value = data?.header?.from || ""; - document.querySelector("#header-to input").value = data?.header?.to || ""; - document.querySelector("#header-cc input").value = data?.header?.cc || ""; - document.querySelector("#header-bcc input").value = data?.header?.bcc || ""; - document.querySelector("#header-subject input").value = data?.header?.subject || ""; - document.querySelector("#content textarea#source").value = data?.content || ""; - document.querySelector("#content textarea#plain").value = data?.body_plain || ""; - document.querySelector("#content iframe#html").srcdoc = data?.body_html || ""; + async load_mailbox(mailbox_name) { + if (!mailbox_name) + mailbox_name = this.mailbox_name; + + if (mailbox_name) + await this.socket_send( + {command: "list_mails", account: this.account_name, mailbox: mailbox_name} + ); } - async fetch_mailbox(mailbox_id) { - const self = this; - const data = await this.fetch_data(this.user_id, mailbox_id); - if (data === null) + async on_list_mails(data) { + const mailbox_name = data.mailbox; + const mails = data.mails; + if (!mailbox_name) return; - if (this.mailbox_id !== mailbox_id) { + if (this.mailbox_name !== mailbox_name) { this.mail_uid = null; this.mailbox.innerHTML = ""; @@ -347,11 +291,12 @@ class MailClient { this._display_mail(undefined); } - this.mailbox_id = mailbox_id; + this.mailbox_name = mailbox_name; const missing_msg = []; const uids = []; const lines = []; - for (const msg of data) { + const self = this; + for (const msg of mails) { uids.push(msg.uid); let found = false; @@ -387,32 +332,222 @@ class MailClient { this.mailbox.append(line); } - async fetch_mail(uid) { + async load_mail(uid) { + await this.socket_send( + { + "command": "get_mail", + "account": this.account_name, + "mailbox": this.mailbox_name, + "uid": uid, + } + ); + } + + async on_get_mail(data) { const self = this; - const data = await this.fetch_data(this.user_id, this.mailbox_id, uid); - if (data === null) + const uid = data.uid; + const mail = data.mail; + if (!uid || !mail) return; self.mail_uid = uid; - - self._display_mail(data); + self._display_mail(mail); const dropdown = document.querySelector("#btn-dropdown div"); dropdown.innerHTML = ""; - for (const attachment of data?.attachments || []) { + for (const attachment of mail?.attachments || []) { const link = document.createElement("a"); - link.href = `/api/${this.user_id}/${this.mailbox_id}/${uid}/attachment/${attachment}`; - link.innerHTML = attachment; + link.href = attachment.url; + link.innerHTML = attachment.name; dropdown.append(link); } - if (!data?.body_html && self.content_mode === "html") + if (!mail?.body_html && self.content_mode === "html") self.content_mode = "plain"; await self.visibility(); } + async send_mail() { + const headers = {}; + for (const key of this.fixed_headers) + headers[key] = document.querySelector(`#editor-${key} input`).value; + + for (const row of document.querySelectorAll("#editor .header .extra")) { + const inputs = row.querySelectorAll("input"); + if (inputs.length < 2) + continue; + + const key = inputs[0].value.trim().toLowerCase(); + const value = inputs[1].value.trim().toLowerCase(); + if (key && value && !this.fixed_headers.includes(key)) + headers[key] = value; + } + + const attachments = []; + for (const file of document.querySelector("#editor-attachments input").files) { + attachments.push({ + size: file.size, + mimetype: file.type, + name: file.name, + content: await file_to_base64(file), + }); + } + + await this.socket_send({ + command: "send_mail", + account: this.account_name, + mailbox: this.mailbox_name, + mail: { + header: headers, + body: document.querySelector("#editor-content textarea").value, + attachments: attachments, + }, + }); + + document.querySelector("#editor").classList.add("hidden"); + await this.reset_editor(); + } + + async on_send_mail() { + await this.load_mailbox(); + } + + async load_random_mail() { + if (!this.account_name || !this.mailbox_name) + return; + + await this.socket_send( + { + command: "random_mail", + account: this.account_name, + mailbox: this.mailbox_name, + } + ); + } + + async on_random_mail(data) { + await this.reset_editor(data?.mail?.header || {}, data?.mail?.body_plain || ""); + document.querySelector("#editor").classList.remove("hidden"); + } + + async load_reply_mail() { + if (!this.mailbox_name || !this.mail_uid) + return; + + await this.socket_send( + { + command: "reply_mail", + account: this.account_name, + mailbox: this.mailbox_name, + uid: this.mail_uid, + } + ); + } + + async on_reply_mail(data) { + await this.reset_editor(data?.mail?.header || {}); + document.querySelector("#editor").classList.remove("hidden"); + } + + async _mail_row_fill(row, msg) { + const self = this; + + if ((msg?.flags || []).indexOf("seen") < 0) { + row.classList.add("unseen"); + row.querySelector("td.read input").checked = false; + } else { + row.classList.remove("unseen"); + row.querySelector("td.read input").checked = true; + } + + if ((msg?.flags || []).indexOf("deleted") < 0) { + row.classList.remove("is_deleted"); + row.querySelector("td.deleted input").checked = false; + } else { + row.classList.add("is_deleted"); + row.querySelector("td.deleted input").checked = true; + } + + function content(selector, val) { + row.querySelector(selector).innerHTML = (val || "").replace("<", "<"); + } + + content(".from", msg.header?.from); + content(".to", msg.header?.to); + content(".subject", msg.header?.subject); + content(".date", (new Date(msg.date)).toLocaleString()); + } + + async _mail_row_init(template, msg) { + const self = this; + + const row = template.cloneNode(10); + row.removeAttribute("id"); + row.classList.remove("hidden"); + + row.querySelector(".read input").addEventListener("click", (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + self._mail_row_click(ev.target, "seen"); + }); + + row.querySelector(".deleted input").addEventListener("click", (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + self._mail_row_click(ev.target, "deleted"); + }); + + row.addEventListener("click", (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + self._mail_row_click(ev.target, "swap"); + }); + + return row; + } + + async _mail_row_click(element, type) { + const self = this; + + let row = element; + while (row && !row.uid) { + row = row.parentElement; + } + + if (!row.uid) + return; + + + switch (type) { + case "swap": + if (self.mail_selected) + self.mail_selected.classList.remove("selected"); + + self.mail_selected = row; + self.mail_selected.classList.add("selected"); + await self.load_mail(row.uid); + break; + + case "seen": case "deleted": + await self.flag_mail(type, element.checked ? "set" : "unset", row.uid); + element.checked = !element.checked; + break; + } + } + + _display_mail(data) { + document.querySelector("#header-from input").value = data?.header?.from || ""; + document.querySelector("#header-to input").value = data?.header?.to || ""; + document.querySelector("#header-cc input").value = data?.header?.cc || ""; + document.querySelector("#header-bcc input").value = data?.header?.bcc || ""; + document.querySelector("#header-subject input").value = data?.header?.subject || ""; + document.querySelector("#content textarea#source").value = data?.content || ""; + document.querySelector("#content textarea#plain").value = data?.body_plain || ""; + document.querySelector("#content iframe#html").srcdoc = data?.body_html || ""; + } + async add_header(key = null, value = null) { const table = document.getElementById("editor-header"); const row = element("tr", {"class": "extra"}); @@ -456,57 +591,7 @@ class MailClient { element.classList.remove("asc"); } - if (this.mailbox_id) await this.fetch_mailbox(this.mailbox_id); - } - - async send_mail() { - const headers = {}; - for (const key of this.fixed_headers) - headers[key] = document.querySelector(`#editor-${key} input`).value; - - for (const row of document.querySelectorAll("#editor .header .extra")) { - const inputs = row.querySelectorAll("input"); - if (inputs.length < 2) - continue; - - const key = inputs[0].value.trim().toLowerCase(); - const value = inputs[1].value.trim().toLowerCase(); - if (key && value && !this.fixed_headers.includes(key)) - headers[key] = value; - } - - const attachments = []; - for (const file of document.querySelector("#editor-attachments input").files) { - attachments.push({ - size: file.size, - mimetype: file.type, - name: file.name, - content: await file_to_base64(file), - }); - } - - await this.post_data({ - header: headers, - body: document.querySelector("#editor-content textarea").value, - attachments: attachments, - }, this.user_id, this.mailbox_id); - - document.querySelector("#editor").classList.add("hidden"); - await this.reset_editor(); - } - - async reply_mail() { - if (!this.mailbox_id || !this.mail_uid) - return; - - const data = await this.fetch_data( - this.user_id, this.mailbox_id, this.mail_uid, "reply" - ); - if (data === null) - return; - - await this.reset_editor(data.header || {}); - document.querySelector("#editor").classList.remove("hidden"); + if (this.mailbox_name) await this.load_mailbox(this.mailbox_name); } async reset_editor(header = null, body = null) { @@ -530,66 +615,159 @@ class MailClient { async initialize() { const self = this; + this.mailboxes.addEventListener("change", (ev) => { ev.preventDefault(); - self.fetch_mailbox(ev.target.value); + self.load_mailbox(ev.target.value); }); + document.getElementById("btn-html").addEventListener("click", (ev) => { ev.preventDefault(); self.content_mode = "html"; self.visibility(); }); + document.getElementById("btn-plain").addEventListener("click", (ev) => { ev.preventDefault(); self.content_mode = "plain"; self.visibility(); }); + document.getElementById("btn-source").addEventListener("click", (ev) => { ev.preventDefault(); self.content_mode = "source"; self.visibility(); }); + document.getElementById("btn-new").addEventListener("click", (ev) => { ev.preventDefault(); document.querySelector("#editor").classList.remove("hidden"); }); + + document.getElementById("btn-random").addEventListener("click", (ev) => { + ev.preventDefault(); + self.load_random_mail(); + }); + document.getElementById("btn-cancel").addEventListener("click", (ev) => { ev.preventDefault(); document.querySelector("#editor").classList.add("hidden"); self.reset_editor(); }); + document.getElementById("btn-send").addEventListener("click", (ev) => { ev.preventDefault(); self.send_mail(); }); + document.getElementById("btn-reply").addEventListener("click", (ev) => { ev.preventDefault(); - self.reply_mail(); + self.load_reply_mail(); }); + document.getElementById("btn-advanced").addEventListener("click", (ev) => { ev.preventDefault(); self.editor_mode = (self.editor_mode === "simple") ? "advanced" : "simple"; self.visibility(); }); + document.getElementById("btn-add-header").addEventListener("click", (ev) => { ev.preventDefault(); self.add_header(); }); + document.querySelector("#color-scheme").addEventListener("click", (ev) => { ev.preventDefault(); - self.swap_theme(); + const toggle = document.querySelector("#color-scheme input"); + if (toggle.checked) { + document.documentElement.classList.add("light"); + document.documentElement.classList.remove("dark"); + } else { + document.documentElement.classList.add("dark"); + document.documentElement.classList.remove("light"); + } + + toggle.checked = !toggle.checked; + }); + + document.querySelector("#connection").addEventListener("click", (ev) => { + ev.preventDefault(); + + const toggle = document.querySelector("#connection input"); + if (toggle.checked) + self.close_socket(); + else + self.connect_socket(); + + toggle.checked = !toggle.checked; }); + document.querySelector("#uploader input").addEventListener("change", (ev) => { ev.preventDefault(); self.upload_files(ev.target); }); + document.querySelector("#mailbox .date").addEventListener("click", (ev) => { ev.preventDefault(); self.reorder(!self.sort_asc); }); - await this.load_config(); + document.querySelector("#nav-dragbar").addEventListener("mousedown", (ev) => { + ev.preventDefault(); + self.drag.nav = true; + self.wrapper.style.cursor = "ew-resize"; + }); + + document.querySelector("#mailbox-dragbar").addEventListener("mousedown", (ev) => { + ev.preventDefault(); + self.drag.mailbox = true; + self.wrapper.style.cursor = "ns-resize"; + }); + + this.wrapper.addEventListener("mouseup", self.reset_drag.bind(self)); + this.wrapper.addEventListener("mousemove", (ev) => { + ev.preventDefault(); + self.ondrag(ev); + }); + await this.visibility(); await this.idle(); } + + reset_drag() { + this.drag = {nav: false, mailbox: false}; + self.wrapper.style.cursor = "auto"; + } + + ondrag(ev) { + if (this.drag.nav) { + const dragbar = document.querySelector("#nav-dragbar"); + const width = clamp(event.clientX, 250, 300); + const sizes = [ + `${width}px`, + `${dragbar.clientWidth}px`, + "auto", + ]; + + this.wrapper.style.gridTemplateColumns = sizes.join(" "); + } + + if (this.drag.mailbox) { + const dragbar = document.querySelector("#mailbox-dragbar"); + const header = document.querySelector("#header"); + const height = clamp( + event.clientY, + 50, + this.wrapper.clientHeight - header.clientHeight - dragbar.clientHeight, + ); + const sizes = [ + `${height}px`, + `${dragbar.clientHeight}px`, + `${header.clientHeight}px`, + "auto", + ]; + + this.wrapper.style.gridTemplateRows = sizes.join(" "); + } + } } diff --git a/src/mail_devel/service.py b/src/mail_devel/service.py index 2863934..a07dd1b 100644 --- a/src/mail_devel/service.py +++ b/src/mail_devel/service.py @@ -102,7 +102,8 @@ async def init(cls, args: argparse.Namespace) -> Self: # Create the SMTP and optionally SMTPS service service.handler = MemoryHandler( service.mailboxes, - args.flagged_seen, + flagged_seen=args.flagged_seen or args.smtp_flagged_seen, + ensure_message_id=not args.no_message_id or not args.smtp_no_message_id, multi_user=args.multi_user, ) service.smtp = Controller( @@ -145,7 +146,8 @@ async def init(cls, args: argparse.Namespace) -> Self: host=args.http_host or args.host, port=args.http_port, devel=args.devel, - flagged_seen=args.flagged_seen, + flagged_seen=args.flagged_seen or args.http_flagged_seen, + ensure_message_id=not args.no_message_id or not args.http_no_message_id, client_max_size=args.client_max_size, multi_user=args.multi_user, ) @@ -242,6 +244,16 @@ def parse(cls, args: list[str] | None = None) -> argparse.Namespace: type=utils.convert_size, help="Max body size for POST requests", ) + group.add_argument( + "--http-flagged-seen", + action="store_true", + help="Flag messages in the INBOX as seen if they arrive via HTTP", + ) + group.add_argument( + "--http-no-message-id", + action="store_true", + help="Ensure that a Message-ID exists via HTTP", + ) group = parser.add_argument_group("SMTP") parser.add_argument( @@ -280,10 +292,15 @@ def parse(cls, args: list[str] | None = None) -> argparse.Namespace: help="Require STARTTLS", ) group.add_argument( - "--flagged-seen", + "--smtp-flagged-seen", action="store_true", help="Flag messages in the INBOX as seen if they arrive via SMTP", ) + group.add_argument( + "--smtp-no-message-id", + action="store_true", + help="Ensure that a Message-ID exists via SMTP", + ) group = parser.add_argument_group("Options") group.add_argument("--debug", action="store_true", help="Verbose logging") @@ -293,6 +310,16 @@ def parse(cls, args: list[str] | None = None) -> argparse.Namespace: help="Use files from the working directory instead of the resources for " "the HTTP frontend. Useful for own frontends or development", ) + group.add_argument( + "--flagged-seen", + action="store_true", + help="Flag messages in the INBOX as seen if they arrive via SMTP and HTTP", + ) + group.add_argument( + "--no-message-id", + action="store_true", + help="Ensure that a Message-ID exists via SMTP and HTTP", + ) group = parser.add_argument_group("Security") group.add_argument( diff --git a/src/mail_devel/smtp.py b/src/mail_devel/smtp.py index 7bb26d5..4bfa2fa 100644 --- a/src/mail_devel/smtp.py +++ b/src/mail_devel/smtp.py @@ -1,4 +1,5 @@ import logging +import uuid from email.message import Message from typing import Type @@ -15,12 +16,14 @@ def __init__( self, mailboxes: TestMailboxDict, flagged_seen: bool = False, + ensure_message_id: bool = True, message_class: Type[Message] | None = None, multi_user: bool = False, ): super().__init__(message_class) self.mailboxes: TestMailboxDict = mailboxes self.flagged_seen: bool = flagged_seen + self.ensure_message_id = ensure_message_id self.multi_user: bool = multi_user def prepare_message(self, session, envelope): @@ -34,6 +37,9 @@ def prepare_message(self, session, envelope): async def handle_message(self, message: Message) -> None: # type: ignore _logger.info(f"Got message {message['From']} -> {message['To']}") + if not message["Message-Id"] and self.ensure_message_id: + message.add_header("Message-Id", f"{uuid.uuid4()}@mail-devel") + await self.mailboxes.append( message, flags=frozenset({Flag(b"\\Seen")} if self.flagged_seen else []), diff --git a/tests/test_mail.py b/tests/test_mail.py index 04a5b2e..44f1e1b 100644 --- a/tests/test_mail.py +++ b/tests/test_mail.py @@ -10,7 +10,6 @@ import pytest from aiohttp import ClientSession - from mail_devel import Service from mail_devel import __main__ as main @@ -103,11 +102,8 @@ async def prepare_http_test(): pw, imap_port=imap_port, smtp_port=smtp_port, http_port=http_port ) account = await service.mailboxes.get(service.demo_user) - service.mailboxes.id_of_user(service.demo_user) mailbox = await account.get_mailbox("INBOX") - account.id_of_mailbox("INBOX") await account.get_mailbox("SENT") - account.id_of_mailbox("INBOX") assert service.frontend.load_resource("index.html") assert service.frontend.load_resource("main.css") @@ -126,13 +122,13 @@ async def prepare_http_test(): @pytest.mark.asyncio -async def test_mail_devel_no_password(): +async def test_no_password(): with pytest.raises(argparse.ArgumentError): Service.parse([]) @pytest.mark.asyncio -async def test_mail_devel_service(): +async def test_service(): smtp_port = unused_ports() pw = token_hex(10) service = await build_test_service(pw, smtp_port=smtp_port, no_http=None) @@ -153,7 +149,7 @@ async def test_mail_devel_service(): @pytest.mark.asyncio -async def test_mail_devel_http_static(): +async def test_http_static(): async with prepare_http_test() as (session, _service): for route in ["/", "/main.css", "/main.js"]: async with session.get(route) as response: @@ -164,115 +160,287 @@ async def test_mail_devel_http_static(): @pytest.mark.asyncio -async def test_mail_devel_http(): - async with prepare_http_test() as (session, service): - async with session.get("/api") as response: - assert response.status == 200 - users = await response.json() - assert len(users) == 1 - assert service.demo_user in users.values() - - async with session.get("/api/1") as response: - assert response.status == 200 - users = await response.json() - assert len(users) == 2 - assert "INBOX" in users.values() - - async with session.get("/api/1/1") as response: - assert response.status == 200 - mbox = await response.json() - assert len(mbox) == 1 - assert mbox[0]["uid"] == 101 - - async with session.get("/api/1/1/101") as response: - assert response.status == 200 - msg = await response.json() - assert msg - assert msg["attachments"] - - async with session.get("/api/1/1/999") as response: - assert response.status == 404 +async def test_websocket(): + async with prepare_http_test() as (session, _service): + async with session.ws_connect("/websocket") as ws: + await ws.send_str("invalid json") + await ws.send_json([]) + + await ws.send_json({"command": "config"}) + data = await ws.receive_json() + assert data["command"] == "config" + assert data["data"] + + await ws.send_json({"command": "close"}) @pytest.mark.asyncio -async def test_mail_devel_http_reply(): - async with prepare_http_test() as (session, _service): - async with session.get("/api/1/1/101/reply") as response: - assert response.status == 200 - msg = await response.json() - assert msg["header"]["to"] == "test
---|