Compare commits
	
		
			11 Commits
		
	
	
		
			v1.1
			...
			1d54e2232f
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 1d54e2232f | |||
| d4efe91593 | |||
| 92dc1ab713 | |||
| 57d0eeaf0f | |||
| 64dcc74b8d | |||
| 1d644a9bcc | |||
| e853cfe62d | |||
| cb15ec15e4 | |||
| b8858085ab | |||
| 2c6d98ce2d | |||
| 520dfd7b14 | 
							
								
								
									
										55
									
								
								DEVNOTES.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								DEVNOTES.md
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,55 @@
 | 
			
		||||
Notes for developers
 | 
			
		||||
 | 
			
		||||
## Running just one test
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
python -m unittest tests.test_pop.TestPop3.test_CAPA
 | 
			
		||||
python -m unittest tests.test_smtp.TestSMTP
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## Patch for enable logging in test
 | 
			
		||||
 | 
			
		||||
Patch generated using below
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
git diff --patch -U1 tests >> ./DEVNOTES.md
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
Apply with below. Disables smtp test mail dir cleanup.
 | 
			
		||||
```
 | 
			
		||||
ls -ltd /tmp/m41*
 | 
			
		||||
git checkout tests
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
```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.")
 | 
			
		||||
diff --git a/tests/test_smtp.py b/tests/test_smtp.py
 | 
			
		||||
index 0554d4c..52d147b 100644
 | 
			
		||||
--- a/tests/test_smtp.py
 | 
			
		||||
+++ b/tests/test_smtp.py
 | 
			
		||||
@@ -18,5 +18,5 @@ def setUpModule() -> None:
 | 
			
		||||
     global MAILS_PATH
 | 
			
		||||
-    logging.basicConfig(level=logging.CRITICAL)
 | 
			
		||||
+    logging.basicConfig(level=logging.DEBUG)
 | 
			
		||||
     td = tempfile.TemporaryDirectory(prefix="m41.smtp.")
 | 
			
		||||
-    unittest.addModuleCleanup(td.cleanup)
 | 
			
		||||
+    # unittest.addModuleCleanup(td.cleanup)
 | 
			
		||||
     MAILS_PATH = Path(td.name)
 | 
			
		||||
PATCH
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## pylint
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
pylint mail4one/*py > /tmp/errs
 | 
			
		||||
vim +"cfile /tmp/errs"
 | 
			
		||||
```
 | 
			
		||||
							
								
								
									
										26
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						
									
										26
									
								
								Makefile
									
									
									
									
									
								
							@@ -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
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										6
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										6
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							@@ -18,12 +18,12 @@
 | 
			
		||||
    "default": {
 | 
			
		||||
        "aiosmtpd": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
                "sha256:78d7b14f859ad0e6de252b47f9cf1ca6f1c82a8b0f10a9e39bec7e915a6aa5fe",
 | 
			
		||||
                "sha256:a196922f1903e54c4d37c53415b7613056d39e2b1e8249f324b9ee7a439be0f1"
 | 
			
		||||
                "sha256:5a811826e1a5a06c25ebc3e6c4a704613eb9a1bcf6b78428fbe865f4f6c9a4b8",
 | 
			
		||||
                "sha256:72c99179ba5aa9ae0abbda6994668239b64a5ce054471955fe75f581d2592475"
 | 
			
		||||
            ],
 | 
			
		||||
            "index": "pypi",
 | 
			
		||||
            "markers": "python_version >= '3.8'",
 | 
			
		||||
            "version": "==1.4.5"
 | 
			
		||||
            "version": "==1.4.6"
 | 
			
		||||
        },
 | 
			
		||||
        "atpublic": {
 | 
			
		||||
            "hashes": [
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
 
 | 
			
		||||
@@ -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),
 | 
			
		||||
 
 | 
			
		||||
@@ -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):
 | 
			
		||||
 
 | 
			
		||||
@@ -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:
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
 | 
			
		||||
@@ -25,19 +20,26 @@ logger = logging.getLogger("smtp")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class MyHandler(AsyncMessage):
 | 
			
		||||
    def __init__(self, mails_path: Path, mbox_finder: Callable[[str], list[str]]):
 | 
			
		||||
    def __init__(self, mails_path: Path, mbox_finder: Callable[[str], list[str]], listener_type: str):
 | 
			
		||||
        super().__init__()
 | 
			
		||||
        self.mails_path = mails_path
 | 
			
		||||
        self.mbox_finder = mbox_finder
 | 
			
		||||
        self.rcpt_tos = []
 | 
			
		||||
        self.peer = None
 | 
			
		||||
        self.starttls = False
 | 
			
		||||
        self.listener_type = listener_type
 | 
			
		||||
 | 
			
		||||
    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
 | 
			
		||||
        if session.ssl:
 | 
			
		||||
            self.starttls = True
 | 
			
		||||
        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]
 | 
			
		||||
        message["X-SSL"] = f"Type: {self.listener_type}, STARTTLS: {self.starttls}"
 | 
			
		||||
        all_mboxes: set[str] = set()
 | 
			
		||||
        for addr in self.rcpt_tos:
 | 
			
		||||
            for mbox in self.mbox_finder(addr.lower()):
 | 
			
		||||
@@ -54,7 +56,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 +65,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)
 | 
			
		||||
        handler = MyHandler(mails_path, mbox_finder, "starttls")
 | 
			
		||||
        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 +86,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)
 | 
			
		||||
        handler = MyHandler(mails_path, mbox_finder, "plain")
 | 
			
		||||
        smtp = SMTP(handler=handler, enable_SMTPUTF8=smtputf8)
 | 
			
		||||
    except:
 | 
			
		||||
        logger.exception("Something went wrong")
 | 
			
		||||
        raise
 | 
			
		||||
@@ -97,13 +105,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 +132,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,
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
-i https://pypi.org/simple
 | 
			
		||||
aiosmtpd==1.4.5; python_version >= '3.8'
 | 
			
		||||
aiosmtpd==1.4.6; python_version >= '3.8'
 | 
			
		||||
atpublic==4.1.0; python_version >= '3.8'
 | 
			
		||||
attrs==23.2.0; python_version >= '3.7'
 | 
			
		||||
python-jata==1.2; python_version >= '3.8'
 | 
			
		||||
 
 | 
			
		||||
@@ -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:
 | 
			
		||||
 
 | 
			
		||||
@@ -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())
 | 
			
		||||
 | 
			
		||||
@@ -59,6 +61,7 @@ class TestSMTP(unittest.IsolatedAsyncioTestCase):
 | 
			
		||||
        X-Peer: ('127.0.0.1', {local_port})
 | 
			
		||||
        X-MailFrom: foo@sender.com
 | 
			
		||||
        X-RcptTo: foo@bar.com
 | 
			
		||||
        X-SSL: Type: plain, STARTTLS: False
 | 
			
		||||
 | 
			
		||||
        Hello world
 | 
			
		||||
        Byee
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user