Compare commits

...

8 Commits
v1.1 ... main

9 changed files with 150 additions and 86 deletions

38
DEVNOTES.md Normal file
View File

@ -0,0 +1,38 @@
Notes for developers
## Running just one test
```
python -m unittest tests.test_pop.TestPop3.test_CAPA
```
## Patch for enable logging in test
Patch generated using below
```
git diff --patch -U1 tests >> ./DEVNOTES.md
```
Apply with below
```bash
git apply - <<PATCH
diff --git a/tests/test_pop.py b/tests/test_pop.py
index 55c1a91..a825665 100644
--- a/tests/test_pop.py
+++ b/tests/test_pop.py
@@ -55,3 +55,3 @@ def setUpModule() -> None:
global MAILS_PATH
- logging.basicConfig(level=logging.CRITICAL)
+ logging.basicConfig(level=logging.DEBUG)
td = tempfile.TemporaryDirectory(prefix="m41.pop.")
PATCH
```
## pylint
```
pylint mail4one/*py > /tmp/errs
vim +"cfile /tmp/errs"
```

View File

@ -1,10 +1,10 @@
# Needs python3 >= 3.9, sed, git for build, docker for tests
build: clean
# Needs python3 >= 3.9, sed, git for build
mail4one.pyz: requirements.txt mail4one/*py
python3 -m pip install -r requirements.txt --no-compile --target build
cp -r mail4one/ build/
sed -i "s/DEVELOMENT/$(shell scripts/get_version.sh)/" build/mail4one/version.py
find build -name "*.pyi" -o -name "py.typed" | xargs -I typefile rm typefile
rm -rf build/bin
rm -rf build/bin build/aiosmtpd/{docs,tests,qa}
rm -rf build/mail4one/__pycache__
rm -rf build/*.dist-info
python3 -m zipapp \
@ -13,10 +13,19 @@ build: clean
--main mail4one.server:main \
--compress build
.PHONY: build
build: clean mail4one.pyz
.PHONY: test
test: mail4one.pyz
PYTHONPATH=mail4one.pyz python3 -m unittest discover
.PHONY: clean
clean:
rm -rf build
rm -rf mail4one.pyz
.PHONY: docker-tests
docker-tests:
docker run --pull=always -v `pwd`:/app -w /app --rm python:3.11-alpine sh scripts/runtests.sh
docker run --pull=always -v `pwd`:/app -w /app --rm python:3.10-alpine sh scripts/runtests.sh
@ -31,24 +40,31 @@ docker-tests:
requirements.txt: Pipfile.lock
pipenv requirements > requirements.txt
.PHONY: format
format:
black mail4one/*py tests/*py
.PHONY: build-dev
build-dev: requirements.txt build
.PHONY: setup
setup:
pipenv install
.PHONY: cleanup
cleanup:
pipenv --rm
.PHONY: update
update:
rm requirements.txt Pipfile.lock
pipenv update
pipenv requirements > requirements.txt
.PHONY: shell
shell:
MYPYPATH=`pipenv --venv`/lib/python3.11/site-packages pipenv shell
MYPYPATH=$(shell ls -d `pipenv --venv`/lib/python3*/site-packages) pipenv shell
test:
.PHONY: dev-test
dev-test:
pipenv run python -m unittest discover

View File

@ -1,4 +1,5 @@
import json
"""Module for parsing mail4one config.json"""
import re
import logging
from typing import Callable, Union, Optional
@ -56,13 +57,14 @@ class PopCfg(ServerCfg):
class SmtpStartTLSCfg(ServerCfg):
server_type = "smtp_starttls"
smtputf8 = True # Not used yet
require_starttls = True
smtputf8 = True
port = 25
class SmtpCfg(ServerCfg):
server_type = "smtp_starttls"
smtputf8 = True # Not used yet
server_type = "smtp"
smtputf8 = True
port = 465
@ -94,11 +96,10 @@ def parse_checkers(cfg: Config) -> list[Checker]:
raise Exception("Both addrs and addr_rexs is set")
if m.addrs:
return lambda malias: malias in m.addrs
elif m.addr_rexs:
if m.addr_rexs:
compiled_res = [re.compile(reg) for reg in m.addr_rexs]
return lambda malias: any(reg.match(malias) for reg in compiled_res)
else:
raise Exception("Neither addrs nor addr_rexs is set")
raise Exception("Neither addrs nor addr_rexs is set")
matches = {m.name: make_match_fn(Match(m)) for m in cfg.matches or []}
matches[DEFAULT_MATCH_ALL] = lambda _: True

View File

@ -2,18 +2,15 @@ import asyncio
import contextlib
import contextvars
import logging
import os
import ssl
import uuid
import random
from typing import Optional
from asyncio import StreamReader, StreamWriter
from dataclasses import dataclass
from hashlib import sha256
from pathlib import Path
from .config import User
from .pwhash import parse_hash, check_pass, PWInfo
from asyncio import StreamReader, StreamWriter
import random
from typing import Optional
from .poputils import (
InvalidCommand,
@ -75,14 +72,14 @@ class PopLogger(logging.LoggerAdapter):
def __init__(self):
super().__init__(logging.getLogger("pop3"), None)
def process(self, msg, kwargs):
state: State = c_state.get(None)
if not state:
return super().process(msg, kwargs)
def process(self, log_msg, kwargs):
st: State = c_state.get(None)
if not st:
return super().process(log_msg, kwargs)
user = "NA"
if state.username:
user = state.username
return super().process(f"{state.ip} {state.req_id} {user} {msg}", kwargs)
if st.username:
user = st.username
return super().process(f"{st.ip} {st.req_id} {user} {log_msg}", kwargs)
logger = PopLogger()
@ -104,8 +101,7 @@ async def next_req() -> Request:
if request.cmd == Command.QUIT:
raise ClientQuit
return request
else:
raise ClientError(f"Bad command {InvalidCommand.RETRIES} times")
raise ClientError(f"Bad command {InvalidCommand.RETRIES} times")
async def expect_cmd(*commands: Command) -> Request:
@ -153,25 +149,23 @@ async def auth_stage() -> None:
write(ok("Following are supported"))
write(msg("USER"))
write(end())
else:
await handle_user_pass_auth(req)
if state().username in scfg().loggedin_users:
logger.warning(
f"User: {state().username} already has an active session"
)
raise AuthError("Already logged in")
else:
scfg().loggedin_users.add(state().username)
write(ok("Login successful"))
return
continue
await handle_user_pass_auth(req)
if state().username in scfg().loggedin_users:
logger.warning(
f"User: {state().username} already has an active session"
)
raise AuthError("Already logged in")
scfg().loggedin_users.add(state().username)
write(ok("Login successful"))
return
except AuthError as ae:
write(err(f"Auth Failed: {ae}"))
except ClientQuit as c:
except ClientQuit:
write(ok("Bye"))
logger.warning("Client has QUIT before auth succeeded")
raise
else:
raise ClientError("Failed to authenticate")
raise ClientError("Failed to authenticate")
def trans_command_capa(_, __) -> None:
@ -272,9 +266,8 @@ async def process_transactions(mails_list: list[MailEntry]) -> set[str]:
except KeyError:
write(err("Not implemented"))
raise ClientError("We shouldn't reach here")
else:
func(mails, req)
await state().writer.drain()
func(mails, req)
await state().writer.drain()
def get_deleted_items(deleted_items_path: Path) -> set[str]:
@ -305,7 +298,7 @@ async def transaction_stage() -> None:
deleted_items_path, existing_deleted_items.union(new_deleted_items)
)
logger.info(f"Saved deleted items")
logger.info("Saved deleted items")
async def start_session() -> None:
@ -316,12 +309,10 @@ async def start_session() -> None:
assert state().mbox
await transaction_stage()
logger.info(f"User:{state().username} done")
except ClientDisconnected as c:
except ClientDisconnected:
logger.info("Client disconnected")
pass
except ClientQuit:
logger.info("Client QUIT")
pass
except ClientError as c:
write(err("Something went wrong"))
logger.error(f"Unexpected client error: {c}")
@ -344,13 +335,13 @@ def parse_users(users: list[User]) -> dict[str, tuple[PWInfo, str]]:
def make_pop_server_callback(mails_path: Path, users: list[User], timeout_seconds: int):
scfg = SharedState(mails_path=mails_path, users=parse_users(users))
s_state = SharedState(mails_path=mails_path, users=parse_users(users))
async def session_cb(reader: StreamReader, writer: StreamWriter):
c_shared_state.set(scfg)
c_shared_state.set(s_state)
ip, _ = writer.get_extra_info("peername")
c_state.set(State(reader=reader, writer=writer, ip=ip, req_id=scfg.next_id()))
logger.info(f"Got pop server callback")
c_state.set(State(reader=reader, writer=writer, ip=ip, req_id=s_state.next_id()))
logger.info("Got pop server callback")
try:
try:
return await asyncio.wait_for(start_session(), timeout_seconds)
@ -372,7 +363,7 @@ async def create_pop_server(
timeout_seconds: int = 60,
) -> asyncio.Server:
logging.info(
f"Starting POP3 server {host=}, {port=}, {mails_path=!s}, {len(users)=}, {ssl_context != None=}, {timeout_seconds=}"
f"Starting POP3 server {host=}, {port=}, {mails_path=!s}, {len(users)=}, {bool(ssl_context)=}, {timeout_seconds=}"
)
return await asyncio.start_server(
make_pop_server_callback(mails_path, users, timeout_seconds),

View File

@ -20,12 +20,10 @@ class ClientDisconnected(ClientError):
class InvalidCommand(ClientError):
RETRIES = 3
"""WIll allow NUM_BAD_COMMANDS times"""
pass
class AuthError(ClientError):
RETRIES = 3
pass
class Command(Enum):

View File

@ -1,11 +1,10 @@
import asyncio
import logging
import os
import ssl
import sys
from argparse import ArgumentParser
from pathlib import Path
from getpass import getpass
from typing import Optional, Union
from .smtp import create_smtp_server_starttls, create_smtp_server
from .pop3 import create_pop_server
@ -13,7 +12,6 @@ from .version import VERSION
from . import config
from . import pwhash
from typing import Optional, Union
def create_tls_context(certfile, keyfile) -> ssl.SSLContext:
@ -44,17 +42,15 @@ async def a_main(cfg: config.Config) -> None:
def get_tls_context(tls: Union[config.TLSCfg, str]):
if tls == "default":
return default_tls_context
elif tls == "disable":
if tls == "disable":
return None
else:
tls_cfg = config.TLSCfg(tls)
return create_tls_context(tls_cfg.certfile, tls_cfg.keyfile)
tls_cfg = config.TLSCfg(tls)
return create_tls_context(tls_cfg.certfile, tls_cfg.keyfile)
def get_host(host):
if host == "default":
return cfg.default_host
else:
return host
return host
mbox_finder = config.gen_addr_to_mboxes(cfg)
servers: list[asyncio.Server] = []
@ -86,6 +82,8 @@ async def a_main(cfg: config.Config) -> None:
mails_path=Path(cfg.mails_path),
mbox_finder=mbox_finder,
ssl_context=stls_context,
require_starttls=stls.require_starttls,
smtputf8=stls.smtputf8,
)
servers.append(smtp_server_starttls)
elif scfg.server_type == "smtp":
@ -96,6 +94,7 @@ async def a_main(cfg: config.Config) -> None:
mails_path=Path(cfg.mails_path),
mbox_finder=mbox_finder,
ssl_context=get_tls_context(smtp.tls),
smtputf8=smtp.smtputf8,
)
servers.append(smtp_server)
else:

View File

@ -1,23 +1,18 @@
import asyncio
import io
import logging
import mailbox
import ssl
import uuid
import shutil
from functools import partial
from pathlib import Path
from typing import Callable, Optional
from . import config
from email.message import Message
import email.policy
from email.generator import BytesGenerator
import tempfile
import random
from aiosmtpd.handlers import Mailbox, AsyncMessage
from aiosmtpd.smtp import SMTP, DATA_SIZE_DEFAULT
from aiosmtpd.smtp import SMTP as SMTPServer
from aiosmtpd.handlers import AsyncMessage
from aiosmtpd.smtp import SMTP
from aiosmtpd.smtp import Envelope as SMTPEnvelope
from aiosmtpd.smtp import Session as SMTPSession
@ -29,15 +24,17 @@ class MyHandler(AsyncMessage):
super().__init__()
self.mails_path = mails_path
self.mbox_finder = mbox_finder
self.rcpt_tos = []
self.peer = None
async def handle_DATA(
self, server: SMTPServer, session: SMTPSession, envelope: SMTPEnvelope
self, server: SMTP, session: SMTPSession, envelope: SMTPEnvelope
) -> str:
self.rcpt_tos = envelope.rcpt_tos
self.peer = session.peer
return await super().handle_DATA(server, session, envelope)
async def handle_message(self, m: Message): # type: ignore[override]
async def handle_message(self, message: Message): # type: ignore[override]
all_mboxes: set[str] = set()
for addr in self.rcpt_tos:
for mbox in self.mbox_finder(addr.lower()):
@ -54,7 +51,7 @@ class MyHandler(AsyncMessage):
temp_email_path = Path(tmpdir) / filename
with open(temp_email_path, "wb") as fp:
gen = BytesGenerator(fp, policy=email.policy.SMTP)
gen.flatten(m)
gen.flatten(message)
for mbox in all_mboxes:
shutil.copy(temp_email_path, self.mails_path / mbox / "new")
logger.info(
@ -63,16 +60,20 @@ class MyHandler(AsyncMessage):
def protocol_factory_starttls(
mails_path: Path, mbox_finder: Callable[[str], list[str]], context: ssl.SSLContext
mails_path: Path,
mbox_finder: Callable[[str], list[str]],
context: ssl.SSLContext,
require_starttls: bool,
smtputf8: bool,
):
logger.info("Got smtp client cb starttls")
try:
handler = MyHandler(mails_path, mbox_finder)
smtp = SMTP(
handler=handler,
require_starttls=True,
require_starttls=require_starttls,
tls_context=context,
enable_SMTPUTF8=True,
enable_SMTPUTF8=smtputf8,
)
except:
logger.exception("Something went wrong")
@ -80,11 +81,13 @@ def protocol_factory_starttls(
return smtp
def protocol_factory(mails_path: Path, mbox_finder: Callable[[str], list[str]]):
def protocol_factory(
mails_path: Path, mbox_finder: Callable[[str], list[str]], smtputf8: bool
):
logger.info("Got smtp client cb")
try:
handler = MyHandler(mails_path, mbox_finder)
smtp = SMTP(handler=handler, enable_SMTPUTF8=True)
smtp = SMTP(handler=handler, enable_SMTPUTF8=smtputf8)
except:
logger.exception("Something went wrong")
raise
@ -97,13 +100,22 @@ async def create_smtp_server_starttls(
mails_path: Path,
mbox_finder: Callable[[str], list[str]],
ssl_context: ssl.SSLContext,
require_starttls: bool,
smtputf8: bool,
) -> asyncio.Server:
logging.info(
f"Starting SMTP STARTTLS server {host=}, {port=}, {mails_path=!s}, {ssl_context != None=}"
f"Starting SMTP STARTTLS server {host=}, {port=}, {mails_path=!s}, {bool(ssl_context)=}"
)
loop = asyncio.get_event_loop()
return await loop.create_server(
partial(protocol_factory_starttls, mails_path, mbox_finder, ssl_context),
partial(
protocol_factory_starttls,
mails_path,
mbox_finder,
ssl_context,
require_starttls,
smtputf8,
),
host=host,
port=port,
start_serving=False,
@ -115,14 +127,15 @@ async def create_smtp_server(
port: int,
mails_path: Path,
mbox_finder: Callable[[str], list[str]],
ssl_context: Optional[ssl.SSLContext] = None,
ssl_context: Optional[ssl.SSLContext],
smtputf8: bool,
) -> asyncio.Server:
logging.info(
f"Starting SMTP server {host=}, {port=}, {mails_path=!s}, {ssl_context != None=}"
f"Starting SMTP server {host=}, {port=}, {mails_path=!s}, {bool(ssl_context)=}"
)
loop = asyncio.get_event_loop()
return await loop.create_server(
partial(protocol_factory, mails_path, mbox_finder),
partial(protocol_factory, mails_path, mbox_finder, smtputf8),
host=host,
port=port,
ssl=ssl_context,

View File

@ -89,6 +89,9 @@ class TestPop3(unittest.IsolatedAsyncioTestCase):
self.task = asyncio.create_task(pop_server.serve_forever())
self.reader, self.writer = await asyncio.open_connection("127.0.0.1", 7995)
# Additional writers to close
self.ws: list[asyncio.StreamWriter] = []
async def test_QUIT(self) -> None:
dialog = """
S: +OK Server Ready
@ -133,6 +136,7 @@ class TestPop3(unittest.IsolatedAsyncioTestCase):
async def test_dupe_AUTH(self) -> None:
r1, w1 = await asyncio.open_connection("127.0.0.1", 7995)
r2, w2 = await asyncio.open_connection("127.0.0.1", 7995)
self.ws += w1, w2
dialog = """
S: +OK Server Ready
C: USER foobar
@ -231,8 +235,10 @@ class TestPop3(unittest.IsolatedAsyncioTestCase):
async def asyncTearDown(self) -> None:
logging.debug("at teardown")
self.writer.close()
await self.writer.wait_closed()
for w in self.ws + [self.writer]:
w.close()
await w.wait_closed()
self.ws.clear()
self.task.cancel("test done")
async def dialog_checker(self, dialog: str) -> None:

View File

@ -33,6 +33,8 @@ class TestSMTP(unittest.IsolatedAsyncioTestCase):
port=7996,
mails_path=MAILS_PATH,
mbox_finder=lambda addr: [TEST_MBOX],
ssl_context=None,
smtputf8=True,
)
self.task = asyncio.create_task(smtp_server.serve_forever())