Skip to content

Commit 112bc6c

Browse files
committed
Show version on frontend. Add auto respond feature. Redesign flags on frontend. Bump version
1 parent fc7c555 commit 112bc6c

File tree

18 files changed

+333
-123
lines changed

18 files changed

+333
-123
lines changed

.pre-commit-config.yaml

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,13 @@ repos:
1414
rev: v1.3.0
1515
hooks:
1616
- id: mypy
17-
args: [--disable-error-code, attr-defined, --disable-error-code, union-attr]
17+
# args: [--disable-error-code, union-attr]
18+
additional_dependencies:
19+
- aiohttp
20+
- aiosmtpd
21+
- nox
22+
- passlib
23+
- pymap~=0.36
24+
- pytest
25+
- setuptools
26+
- typing-extensions

pyproject.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,3 @@ skip-magic-trailing-comma = false
1818
line-ending = "auto"
1919

2020
[tool.mypy]
21-
disable_error_code = 'attr-defined,import,union-attr'

setup.cfg

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[metadata]
22
name = mail-devel
3-
version = attr: mail_devel.VERSION
3+
version = attr: mail_devel.utils.VERSION
44
author = Florian Kantelberg
55
author_email = [email protected]
66
description = IMAP and SMTP in memory test server

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
from setuptools import setup
1+
from setuptools import setup # type: ignore
22

33
setup()

src/mail_devel/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
from .service import Service
2+
from .utils import VERSION
23

3-
VERSION = "0.11.0"
4+
__all__ = ["Service", "VERSION"]

src/mail_devel/auth.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ async def authenticate(self, credentials: ServerCredentials) -> Identity:
2525
if authcid not in self.users_dict:
2626
self.users_dict[authcid] = UserMetadata(self.config, authcid)
2727

28-
if self.multi_user:
28+
if self.multi_user and isinstance(credentials, PlainCredentials):
2929
credentials = PlainCredentials(self.config.demo_user, credentials._secret)
3030

3131
ident = await super().authenticate(credentials)

src/mail_devel/automation/__init__.py

Whitespace-only changes.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from email.message import Message
2+
from logging import Logger
3+
4+
from mail_devel.builder import Builder
5+
from mail_devel.smtp import Reply
6+
7+
8+
def reply(message: Message, flags: set[str], _logger: Logger) -> Reply | None:
9+
if "@mail-devel" not in message.get("References", ""):
10+
return Reply(Builder.reply_mail(message), flags - {"Seen"})
11+
12+
return None

src/mail_devel/builder.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import secrets
2+
import uuid
3+
from email.message import Message
4+
from email.mime.multipart import MIMEMultipart
5+
from email.mime.text import MIMEText
6+
7+
8+
class Builder:
9+
"""Helper class to generate mails and randomized values"""
10+
11+
@staticmethod
12+
def message_id() -> str:
13+
return f"<{uuid.uuid4()}@mail-devel>"
14+
15+
@staticmethod
16+
def mail_address() -> str:
17+
return f"{secrets.token_hex(8)}@mail-devel"
18+
19+
@staticmethod
20+
def reply_mail(message: Message) -> Message:
21+
body = ""
22+
if message.is_multipart():
23+
for part in message.walk():
24+
if part.get_content_type() == "text/plain":
25+
body = part.get_payload(decode=True).decode()
26+
elif message.get_content_type() == "text/plain":
27+
body = message.get_payload(decode=True).decode()
28+
29+
reply = MIMEMultipart()
30+
if body:
31+
body = "\n\n> " + body.replace("\n", "\n> ")
32+
33+
reply.attach(MIMEText("Reply" + body))
34+
35+
reply.add_header("Message-Id", Builder.message_id())
36+
msg_id = message["Message-Id"]
37+
if msg_id:
38+
reply.add_header("In-Reply-To", msg_id)
39+
reply.add_header("References", f"{msg_id} {message.get('References', '')}")
40+
41+
reply.add_header("Subject", f"Re: {message['Subject']}")
42+
reply.add_header("To", message["From"])
43+
reply.add_header("From", Builder.mail_address())
44+
if message["CC"]:
45+
reply.add_header("CC", message["CC"])
46+
47+
return reply

src/mail_devel/http.py

Lines changed: 33 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import uuid
88
from email import header, message_from_bytes, message_from_string, policy
99
from email.errors import MessageDefect
10+
from email.message import Message
1011
from email.mime.base import MIMEBase
1112
from email.mime.multipart import MIMEMultipart
1213
from email.mime.text import MIMEText
@@ -16,11 +17,13 @@
1617
import aiohttp
1718
from aiohttp import web
1819
from aiohttp.web import Request, Response, WebSocketResponse
19-
from pymap.backend.dict.mailbox import Message
20+
from pymap.backend.dict.mailbox import Message as PyMapMessage
2021
from pymap.parsing.specials import FetchRequirement
2122
from pymap.parsing.specials.flag import Flag
2223

24+
from .builder import Builder
2325
from .mailbox import TestMailboxDict
26+
from .utils import VERSION
2427

2528
_logger = logging.getLogger(__name__)
2629

@@ -54,6 +57,7 @@ async def run_app(
5457
ssl_context=ssl_context,
5558
)
5659
await site.start()
60+
return app
5761

5862

5963
class Frontend:
@@ -94,7 +98,7 @@ def load_resource(self, resource: str) -> str:
9498

9599
return res.read_text(encoding="utf-8")
96100

97-
async def start(self) -> None:
101+
async def start(self) -> web.AppRunner:
98102
self.api = web.Application(client_max_size=self.client_max_size)
99103

100104
self.api.add_routes(
@@ -168,7 +172,7 @@ async def _page_static(self, request: Request) -> Response:
168172
_logger.error(f"File {static!r} not in resources")
169173
raise web.HTTPNotFound() from e
170174

171-
async def _message_content(self, msg: Message) -> bytes:
175+
async def _message_content(self, msg: PyMapMessage) -> bytes:
172176
return bytes((await msg.load_content(FetchRequirement.CONTENT)).content)
173177

174178
def message_hash(self, content: bytes | str) -> str:
@@ -178,11 +182,20 @@ def message_hash(self, content: bytes | str) -> str:
178182
return hashlib.sha512(content).hexdigest()
179183

180184
async def _convert_message(
181-
self, msg: Message, *, account: str, mailbox: str, full: bool = False
185+
self,
186+
msg: PyMapMessage,
187+
*,
188+
account: str,
189+
mailbox: str,
190+
full: bool = False,
191+
message: Message | None = None,
182192
) -> dict:
183-
content = await self._message_content(msg)
193+
if not message:
194+
content = await self._message_content(msg)
195+
message = message_from_bytes(content)
196+
else:
197+
content = message.as_bytes()
184198

185-
message = message_from_bytes(content)
186199
result = {
187200
"uid": msg.uid,
188201
"flags": flags_to_api(msg.permanent_flags),
@@ -196,15 +209,15 @@ async def _convert_message(
196209
msg_hash = self.message_hash(content)
197210
self.mail_cache[msg_hash] = (account, mailbox, msg.uid)
198211

199-
result["attachments"] = []
212+
attachments = []
200213
if message.is_multipart():
201214
for part in message.walk():
202215
ctype = part.get_content_type()
203216
cdispo = part.get_content_disposition()
204217

205218
if cdispo == "attachment":
206219
name = part.get_filename()
207-
result["attachments"].append(
220+
attachments.append(
208221
{"name": name, "url": f"/attachment/{msg_hash}/{name}"}
209222
)
210223
elif ctype == "text/plain":
@@ -216,6 +229,7 @@ async def _convert_message(
216229
else:
217230
result["body_plain"] = message.get_payload(decode=True).decode()
218231

232+
result["attachments"] = attachments
219233
result["content"] = bytes(content).decode()
220234
return result
221235

@@ -226,6 +240,7 @@ async def on_config(self, ws: WebSocketResponse) -> None:
226240
"data": {
227241
"multi_user": self.multi_user,
228242
"flagged_seen": self.flagged_seen,
243+
"version": VERSION,
229244
},
230245
}
231246
)
@@ -315,9 +330,9 @@ async def on_random_mail(
315330
) -> None:
316331
headers = {
317332
"subject": f"Random Subject [{secrets.token_hex(8)}]",
318-
"message-id": f"{uuid.uuid4()}@mail-devel",
333+
"message-id": Builder.message_id(),
319334
"to": account,
320-
"from": f"{secrets.token_hex(8)}@mail-devel",
335+
"from": Builder.mail_address(),
321336
}
322337
_logger.info("Randomized mail")
323338
await ws.send_json(
@@ -344,27 +359,17 @@ async def on_reply_mail(
344359

345360
async for msg in mbox.messages():
346361
if msg.uid == uid:
362+
content = await self._message_content(msg)
363+
reply = Builder.reply_mail(message_from_bytes(content))
364+
347365
message = await self._convert_message(
348366
msg,
349367
account=account,
350368
mailbox=mailbox,
351369
full=True,
370+
message=reply,
352371
)
353372

354-
headers = message["header"]
355-
headers["subject"] = f"RE: {headers['subject']}"
356-
msg_id = headers.get("message-id", None)
357-
if msg_id:
358-
headers["in-reply-to"] = msg_id
359-
headers["references"] = f"{msg_id} {headers.get('references', '')}"
360-
headers["message-id"] = f"{uuid.uuid4()}@mail-devel"
361-
headers["to"] = headers["from"]
362-
headers["from"] = self.user
363-
headers.pop("content-type", None)
364-
for key in list(headers):
365-
if key.startswith("x-"):
366-
headers.pop(key, None)
367-
368373
await ws.send_json(
369374
{
370375
"command": "reply_mail",
@@ -428,7 +433,7 @@ async def on_upload_mails(
428433
msg = message_from_string(mail["data"], policy=compat_strict)
429434

430435
if not msg["Message-Id"] and self.ensure_message_id:
431-
msg.add_header("Message-Id", f"{uuid.uuid4()}@mail-devel")
436+
msg.add_header("Message-Id", Builder.message_id())
432437

433438
await self.mailboxes.append(
434439
msg,
@@ -462,7 +467,7 @@ async def on_send_mail(
462467
message.add_header(key.title(), value)
463468

464469
if not message["Message-Id"] and self.ensure_message_id:
465-
message.add_header("Message-Id", f"{uuid.uuid4()}@mail-devel")
470+
message.add_header("Message-Id", Builder.message_id())
466471

467472
for att in mail.get("attachments", []):
468473
part = MIMEBase(*(att["mimetype"] or "text/plain").split("/"))
@@ -514,8 +519,8 @@ async def _download_attachment(self, request: Request) -> Response:
514519
return web.Response(
515520
body=body,
516521
headers={
517-
"Content-Type": part.get("Content-Type"),
518-
"Content-Disposition": part.get("Content-Disposition"),
522+
"Content-Type": part.get("Content-Type", ""),
523+
"Content-Disposition": part.get("Content-Disposition", ""),
519524
},
520525
)
521526

0 commit comments

Comments
 (0)