Compare commits

..

1 Commits
main ... py38

Author SHA1 Message Date
2da8f13ebd typehint changes for python 3.8 2023-06-30 17:42:39 -04:00
17 changed files with 193 additions and 327 deletions

View File

@ -1,38 +0,0 @@
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,8 @@
# Needs python3 >= 3.9, sed, git for build
mail4one.pyz: requirements.txt mail4one/*py
build: clean
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 build/aiosmtpd/{docs,tests,qa}
rm -rf build/mail4one/__pycache__
rm -rf build/*.dist-info
python3 -m zipapp \
@ -13,58 +11,35 @@ mail4one.pyz: requirements.txt mail4one/*py
--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
docker run --pull=always -v `pwd`:/app -w /app --rm python:3.12 sh scripts/runtests.sh
docker run --pull=always -v `pwd`:/app -w /app --rm python:3.11 sh scripts/runtests.sh
docker run --pull=always -v `pwd`:/app -w /app --rm python:3.10 sh scripts/runtests.sh
docker run --pull=always -v `pwd`:/app -w /app --rm python:3.9 sh scripts/runtests.sh
# ============================================================================
# Below targets for devs. Need pipenv, black installed
requirements.txt: Pipfile.lock
pipenv requirements > requirements.txt
.PHONY: format
format:
black mail4one/*py tests/*py
black mail4one/*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=$(shell ls -d `pipenv --venv`/lib/python3*/site-packages) pipenv shell
MYPYPATH=`pipenv --venv`/lib/python3.11/site-packages pipenv shell
.PHONY: dev-test
dev-test:
test:
pipenv run python -m unittest discover

20
Pipfile.lock generated
View File

@ -18,28 +18,27 @@
"default": {
"aiosmtpd": {
"hashes": [
"sha256:78d7b14f859ad0e6de252b47f9cf1ca6f1c82a8b0f10a9e39bec7e915a6aa5fe",
"sha256:a196922f1903e54c4d37c53415b7613056d39e2b1e8249f324b9ee7a439be0f1"
"sha256:f821fe424b703b2ea391dc2df11d89d2afd728af27393e13cf1a3530f19fdc5e",
"sha256:f9243b7dfe00aaf567da8728d891752426b51392174a34d2cf5c18053b63dcbc"
],
"index": "pypi",
"markers": "python_version >= '3.8'",
"version": "==1.4.5"
"version": "==1.4.4.post2"
},
"atpublic": {
"hashes": [
"sha256:d1c8cd931af7461f6d18bc6063383e8654d9e9ef19d58ee6dc01e8515bbf55df",
"sha256:df90de1162b1a941ee486f484691dc7c33123ee638ea5d6ca604061306e0fdde"
"sha256:0f40433219e124edf115c6c363808ca6f0e1cfa7d160d86b2fb94793086d1294",
"sha256:80057c55641253b86dcb68b524f82328172371b6547d4c7462a9127fbfbbabfc"
],
"markers": "python_version >= '3.8'",
"version": "==4.1.0"
"version": "==4.0"
},
"attrs": {
"hashes": [
"sha256:935dc3b529c262f6cf76e50877d35a4bd3c1de194fd41f47a2b7ae8f19971f30",
"sha256:99b87a485a5820b23b879f04c2305b44b951b502fd64be915879d77a7e8fc6f1"
"sha256:1f28b4522cdc2fb4256ac1a020c78acf9cba2c6b461ccd2c126f3aa8e8335d04",
"sha256:6279836d581513a26f1bf235f9acd333bc9115683f14f7e8fae46c98fc50e015"
],
"markers": "python_version >= '3.7'",
"version": "==23.2.0"
"version": "==23.1.0"
},
"python-jata": {
"hashes": [
@ -47,7 +46,6 @@
"sha256:ff4cd7ca75c9a8306b69ef6e878c296a5602f3279c6f9a82b6105b8eba764760"
],
"index": "pypi",
"markers": "python_version >= '3.8'",
"version": "==1.2"
}
},

View File

@ -1,6 +1,6 @@
# Mail4one
Personal mail server for a single user or a small family. Written in pure python with [minimal dependencies](Pipfile).
Personal mail server for a single user or a small family. Written in pure python with minimal dependencies.
Designed for dynamic alias based workflow where a different alias is used for each purpose.
# Getting started
@ -23,7 +23,7 @@ Mail4one only takes care of receiving and serving email. For sending email, use
Most of them have generous free tier which is more than enough for personal use.
Sending email is tricky. Even if everything is correctly setup (DMARC, DKIM, SPF), popular email vendors like google, microsoft may mark emails sent from your IP as spam for no reason. Hence using a dedicated service is the only reliable way to send emails.
Sending email is tricky. Even if everything is correctly setup (DMARC, DKIM, SPF), popular email vendors like google, microsoft may mark emails sent from your IP as spam for no reason.
# Community
@ -62,7 +62,7 @@ This should generate `mail4one.pyz` in current folder. This is a [executable pyt
* Write dedicated documentation
* Test with more email clients ([Thunderbird](https://www.thunderbird.net/) and [k9mail](https://k9mail.app/) are tested now)
* IMAP support
* Web UI for editing config ([WIP](https://github.com/mail4one/mail4one/tree/webform))
* Web UI for editing config
* Support email submission from client to forward to other senders or direct delivery
* Optional SPAM filtering
* Optional DMARC,SPF,DKIM verification

View File

@ -81,16 +81,15 @@ systemctl status mail4one
Above command should fail as the TLS certificates don't exist yet.
## Setup TLS certificates
Install [certbot](https://certbot.eff.org/) and run below command. Follow instructions to create TLS certificates. Usually you want certificate for domain name like `mail.mydomain.com`
Install [certbot](https://certbot.eff.org/) and run below command. Follow instructions to create TLS certificates. Usually you want certificate for domain name like `mail.example.com`
```sh
sudo certbot certonly
sudo cp /etc/letsencrypt/live/mail.example.com/{fullchain,privkey}.pem /var/lib/mail4one/certs/
sudo chown mail4one:mail4one /var/lib/mail4one/certs/{fullchain,privkey}.pem
# **Edit** mail4one_cert_copy.sh to update your domain name
# Edit mail4one_cert_copy.sh to update your domain name
sudo cp mail4one_cert_copy.sh /etc/letsencrypt/renewal-hooks/deploy/
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/mail4one_cert_copy.sh
# This will create and copy the certificates to the right path with correct permissions and ownership
sudo certbot certonly -d mail.mydomain.com --run-deploy-hooks --dry-run
```
## Restart service and check logs
```sh
@ -108,8 +107,8 @@ cd test_dir
python3 -m http.server 25
```
In local machine or a browser
You should see file listing a, b, c. Repeat for port 465, 995 to make sure firewall rules and dns is working
You should see file listing a, b, c. Repeat for port 465, 995 to make sure firewall rules and dns is working
```sh
curl http://mail.mydomain.com:25
curl http://mail.example.com:25
```
If not working, refer to VPS settings and OS firewall settings.

View File

@ -2,26 +2,19 @@
# certbot deploy hook to copy certificates to mail4one when renewed.
# Initial setup, Install certbot(https://certbot.eff.org/) and run `certbot certonly` as root
# Doc: https://eff-certbot.readthedocs.io/en/latest/using.html#renewing-certificates
#
# This file is supposed to be copied to /etc/letsencrypt/renewal-hooks/deploy/
# Change the mail domain to the one on MX record
set -eu
set -x
if [ "$RENEWED_DOMAINS" = "mail.mydomain.com" ]
then
app=mail4one
appuser=$app
certpath="/var/lib/$app/certs"
mkdir -p "$certpath"
chmod 750 "$certpath"
chown $appuser:$appuser "$certpath"
install -o "$appuser" -g "$appuser" -m 444 "$RENEWED_LINEAGE/fullchain.pem" -t "$certpath"
install -o "$appuser" -g "$appuser" -m 400 "$RENEWED_LINEAGE/privkey.pem" -t "$certpath"
systemctl restart $app.service
echo "$(date) Renewed and deployed certificates for $app" >> /var/log/cert-renew.log
mkdir -p /var/lib/mail4one/certs
chmod 750 /var/lib/mail4one/certs
chown mail4one:mail4one /var/lib/mail4one/certs
cp "$RENEWED_LINEAGE/fullchain.pem" /var/lib/mail4one/certs/
cp "$RENEWED_LINEAGE/privkey.pem" /var/lib/mail4one/certs/
systemctl restart mail4one.service
fi

View File

@ -1,15 +1,14 @@
"""Module for parsing mail4one config.json"""
import json
import re
import logging
from typing import Callable, Union, Optional
from typing import Callable, Union, Optional, List, Tuple
from jata import Jata, MutableDefault
class Match(Jata):
name: str
addrs: list[str] = MutableDefault(lambda: []) # type: ignore
addr_rexs: list[str] = MutableDefault(lambda: []) # type: ignore
addrs: List[str] = MutableDefault(lambda: []) # type: ignore
addr_rexs: List[str] = MutableDefault(lambda: []) # type: ignore
DEFAULT_MATCH_ALL = "default_match_all"
@ -24,7 +23,7 @@ class Rule(Jata):
class Mbox(Jata):
name: str
rules: list[Rule]
rules: List[Rule]
DEFAULT_NULL_MBOX = "default_null_mbox"
@ -57,14 +56,13 @@ class PopCfg(ServerCfg):
class SmtpStartTLSCfg(ServerCfg):
server_type = "smtp_starttls"
require_starttls = True
smtputf8 = True
smtputf8 = True # Not used yet
port = 25
class SmtpCfg(ServerCfg):
server_type = "smtp"
smtputf8 = True
server_type = "smtp_starttls"
smtputf8 = True # Not used yet
port = 465
@ -79,27 +77,28 @@ class Config(Jata):
logging: Optional[LogCfg] = None
mails_path: str
matches: list[Match]
boxes: list[Mbox]
users: list[User]
matches: List[Match]
boxes: List[Mbox]
users: List[User]
servers: list[ServerCfg]
servers: List[ServerCfg]
CheckerFn = Callable[[str], bool]
Checker = tuple[str, CheckerFn, bool]
Checker = Tuple[str, CheckerFn, bool]
def parse_checkers(cfg: Config) -> list[Checker]:
def parse_checkers(cfg: Config) -> List[Checker]:
def make_match_fn(m: Match):
if m.addrs and m.addr_rexs:
raise Exception("Both addrs and addr_rexs is set")
if m.addrs:
return lambda malias: malias in m.addrs
if m.addr_rexs:
elif 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)
raise Exception("Neither addrs nor addr_rexs is set")
else:
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
@ -119,7 +118,7 @@ def parse_checkers(cfg: Config) -> list[Checker]:
]
def get_mboxes(addr: str, checks: list[Checker]) -> list[str]:
def get_mboxes(addr: str, checks: List[Checker]) -> List[str]:
def inner():
for mbox, match_fn, stop_check in checks:
if match_fn(addr):
@ -131,7 +130,7 @@ def get_mboxes(addr: str, checks: list[Checker]) -> list[str]:
return list(inner())
def gen_addr_to_mboxes(cfg: Config) -> Callable[[str], list[str]]:
def gen_addr_to_mboxes(cfg: Config) -> Callable[[str], List[str]]:
checks = parse_checkers(cfg)
logging.info(f"Parsed checkers from config, {len(checks)=}")
return lambda addr: get_mboxes(addr, checks)

View File

@ -2,15 +2,18 @@ import asyncio
import contextlib
import contextvars
import logging
import os
import ssl
import random
from typing import Optional
from asyncio import StreamReader, StreamWriter
import uuid
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, List, Tuple, Dict
from .poputils import (
InvalidCommand,
@ -26,7 +29,7 @@ from .poputils import (
end,
Request,
MailEntry,
get_mail_fp,
get_mail,
get_mails_list,
MailList,
)
@ -43,7 +46,7 @@ class State:
class SharedState:
def __init__(self, mails_path: Path, users: dict[str, tuple[PWInfo, str]]):
def __init__(self, mails_path: Path, users: dict[str, Tuple[PWInfo, str]]):
self.mails_path = mails_path
self.users = users
self.loggedin_users: set[str] = set()
@ -72,14 +75,14 @@ class PopLogger(logging.LoggerAdapter):
def __init__(self):
super().__init__(logging.getLogger("pop3"), None)
def process(self, log_msg, kwargs):
st: State = c_state.get(None)
if not st:
return super().process(log_msg, kwargs)
def process(self, msg, kwargs):
state: State = c_state.get(None)
if not state:
return super().process(msg, kwargs)
user = "NA"
if st.username:
user = st.username
return super().process(f"{st.ip} {st.req_id} {user} {log_msg}", kwargs)
if state.username:
user = state.username
return super().process(f"{state.ip} {state.req_id} {user} {msg}", kwargs)
logger = PopLogger()
@ -101,7 +104,8 @@ async def next_req() -> Request:
if request.cmd == Command.QUIT:
raise ClientQuit
return request
raise ClientError(f"Bad command {InvalidCommand.RETRIES} times")
else:
raise ClientError(f"Bad command {InvalidCommand.RETRIES} times")
async def expect_cmd(*commands: Command) -> Request:
@ -149,23 +153,25 @@ async def auth_stage() -> None:
write(ok("Following are supported"))
write(msg("USER"))
write(end())
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
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
except AuthError as ae:
write(err(f"Auth Failed: {ae}"))
except ClientQuit:
except ClientQuit as c:
write(ok("Bye"))
logger.warning("Client has QUIT before auth succeeded")
raise
raise ClientError("Failed to authenticate")
else:
raise ClientError("Failed to authenticate")
def trans_command_capa(_, __) -> None:
@ -211,12 +217,7 @@ def trans_command_retr(mails: MailList, req: Request) -> None:
entry = mails.get(req.arg1)
if entry:
write(ok("Contents follow"))
with get_mail_fp(entry) as fp:
for line in fp:
if line.startswith(b"."):
write(b".") # prepend dot
write(line)
# write(get_mail(entry)) # no prepend dot
write(get_mail(entry))
write(end())
mails.delete(req.arg1)
else:
@ -236,7 +237,7 @@ def trans_command_noop(_, __) -> None:
write(ok("Hmm"))
async def process_transactions(mails_list: list[MailEntry]) -> set[str]:
async def process_transactions(mails_list: List[MailEntry]) -> set[str]:
mails = MailList(mails_list)
def reset(_, __):
@ -266,8 +267,9 @@ async def process_transactions(mails_list: list[MailEntry]) -> set[str]:
except KeyError:
write(err("Not implemented"))
raise ClientError("We shouldn't reach here")
func(mails, req)
await state().writer.drain()
else:
func(mails, req)
await state().writer.drain()
def get_deleted_items(deleted_items_path: Path) -> set[str]:
@ -298,7 +300,7 @@ async def transaction_stage() -> None:
deleted_items_path, existing_deleted_items.union(new_deleted_items)
)
logger.info("Saved deleted items")
logger.info(f"Saved deleted items")
async def start_session() -> None:
@ -309,10 +311,12 @@ async def start_session() -> None:
assert state().mbox
await transaction_stage()
logger.info(f"User:{state().username} done")
except ClientDisconnected:
except ClientDisconnected as c:
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}")
@ -324,7 +328,7 @@ async def start_session() -> None:
scfg().loggedin_users.remove(state().username)
def parse_users(users: list[User]) -> dict[str, tuple[PWInfo, str]]:
def parse_users(users: List[User]) -> Dict[str, Tuple[PWInfo, str]]:
def inner():
for user in users:
user = User(user)
@ -334,14 +338,14 @@ def parse_users(users: list[User]) -> dict[str, tuple[PWInfo, str]]:
return dict(inner())
def make_pop_server_callback(mails_path: Path, users: list[User], timeout_seconds: int):
s_state = SharedState(mails_path=mails_path, users=parse_users(users))
def make_pop_server_callback(mails_path: Path, users: List[User], timeout_seconds: int):
scfg = SharedState(mails_path=mails_path, users=parse_users(users))
async def session_cb(reader: StreamReader, writer: StreamWriter):
c_shared_state.set(s_state)
c_shared_state.set(scfg)
ip, _ = writer.get_extra_info("peername")
c_state.set(State(reader=reader, writer=writer, ip=ip, req_id=s_state.next_id()))
logger.info("Got pop server callback")
c_state.set(State(reader=reader, writer=writer, ip=ip, req_id=scfg.next_id()))
logger.info(f"Got pop server callback")
try:
try:
return await asyncio.wait_for(start_session(), timeout_seconds)
@ -358,12 +362,12 @@ async def create_pop_server(
host: str,
port: int,
mails_path: Path,
users: list[User],
users: List[User],
ssl_context: Optional[ssl.SSLContext] = None,
timeout_seconds: int = 60,
) -> asyncio.Server:
logging.info(
f"Starting POP3 server {host=}, {port=}, {mails_path=!s}, {len(users)=}, {bool(ssl_context)=}, {timeout_seconds=}"
f"Starting POP3 server {host=}, {port=}, {mails_path=!s}, {len(users)=}, {ssl_context != None=}, {timeout_seconds=}"
)
return await asyncio.start_server(
make_pop_server_callback(mails_path, users, timeout_seconds),
@ -382,14 +386,13 @@ def debug_main():
logging.basicConfig(level=logging.DEBUG)
import sys
from .pwhash import gen_pwhash
_, mails_path, mbox = sys.argv
_, mails_path, port, password = sys.argv
mails_path = Path(mails_path)
users = [User(username="dummy", password_hash=gen_pwhash("dummy"), mbox=mbox)]
port = int(port)
asyncio.run(a_main("127.0.0.1", 1101, mails_path, users=users))
asyncio.run(a_main(mails_path, port, password_hash=password_hash))
if __name__ == "__main__":

View File

@ -2,7 +2,7 @@ import os
from dataclasses import dataclass
from enum import Enum, auto
from pathlib import Path
from contextlib import contextmanager
from typing import List
class ClientError(Exception):
@ -20,10 +20,12 @@ class ClientDisconnected(ClientError):
class InvalidCommand(ClientError):
RETRIES = 3
"""WIll allow NUM_BAD_COMMANDS times"""
pass
class AuthError(ClientError):
RETRIES = 3
pass
class Command(Enum):
@ -111,31 +113,25 @@ def files_in_path(path):
return []
def get_mails_list(dirpath: Path) -> list[MailEntry]:
def get_mails_list(dirpath: Path) -> List[MailEntry]:
files = files_in_path(dirpath)
entries = [MailEntry(filename, path) for filename, path in files]
return entries
def set_nid(entries: list[MailEntry]):
def set_nid(entries: List[MailEntry]):
entries.sort(reverse=True, key=lambda e: (e.c_time, e.uid))
for i, entry in enumerate(entries, start=1):
entry.nid = i
@contextmanager
def get_mail_fp(entry: MailEntry):
with open(entry.path, mode="rb") as fp:
yield fp
def get_mail(entry: MailEntry) -> bytes:
with open(entry.path, mode="rb") as fp:
return fp.read()
class MailList:
def __init__(self, entries: list[MailEntry]):
def __init__(self, entries: List[MailEntry]):
self.entries = entries
set_nid(self.entries)
self.mails_map = {str(e.nid): e for e in entries}

View File

@ -1,10 +1,11 @@
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
@ -12,6 +13,7 @@ from .version import VERSION
from . import config
from . import pwhash
from typing import Optional, Union
def create_tls_context(certfile, keyfile) -> ssl.SSLContext:
@ -42,18 +44,20 @@ async def a_main(cfg: config.Config) -> None:
def get_tls_context(tls: Union[config.TLSCfg, str]):
if tls == "default":
return default_tls_context
if tls == "disable":
elif tls == "disable":
return None
tls_cfg = config.TLSCfg(tls)
return create_tls_context(tls_cfg.certfile, tls_cfg.keyfile)
else:
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
return host
else:
return host
mbox_finder = config.gen_addr_to_mboxes(cfg)
servers: list[asyncio.Server] = []
servers: List[asyncio.Server] = []
if not cfg.servers:
logging.warning("Nothing to do!")
@ -82,8 +86,6 @@ 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":
@ -94,7 +96,6 @@ 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,18 +1,23 @@
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 typing import Callable, Optional, List
from . import config
from email.message import Message
import email.policy
from email.generator import BytesGenerator
import tempfile
import random
from aiosmtpd.handlers import AsyncMessage
from aiosmtpd.smtp import SMTP
from aiosmtpd.handlers import Mailbox, AsyncMessage
from aiosmtpd.smtp import SMTP, DATA_SIZE_DEFAULT
from aiosmtpd.smtp import SMTP as SMTPServer
from aiosmtpd.smtp import Envelope as SMTPEnvelope
from aiosmtpd.smtp import Session as SMTPSession
@ -20,21 +25,19 @@ 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]]):
super().__init__()
self.mails_path = mails_path
self.mbox_finder = mbox_finder
self.rcpt_tos = []
self.peer = None
async def handle_DATA(
self, server: SMTP, session: SMTPSession, envelope: SMTPEnvelope
self, server: SMTPServer, 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, message: Message): # type: ignore[override]
async def handle_message(self, m: Message): # type: ignore[override]
all_mboxes: set[str] = set()
for addr in self.rcpt_tos:
for mbox in self.mbox_finder(addr.lower()):
@ -51,7 +54,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(message)
gen.flatten(m)
for mbox in all_mboxes:
shutil.copy(temp_email_path, self.mails_path / mbox / "new")
logger.info(
@ -60,20 +63,16 @@ class MyHandler(AsyncMessage):
def protocol_factory_starttls(
mails_path: Path,
mbox_finder: Callable[[str], list[str]],
context: ssl.SSLContext,
require_starttls: bool,
smtputf8: bool,
mails_path: Path, mbox_finder: Callable[[str], List[str]], context: ssl.SSLContext
):
logger.info("Got smtp client cb starttls")
try:
handler = MyHandler(mails_path, mbox_finder)
smtp = SMTP(
handler=handler,
require_starttls=require_starttls,
require_starttls=True,
tls_context=context,
enable_SMTPUTF8=smtputf8,
enable_SMTPUTF8=True,
)
except:
logger.exception("Something went wrong")
@ -81,13 +80,11 @@ def protocol_factory_starttls(
return smtp
def protocol_factory(
mails_path: Path, mbox_finder: Callable[[str], list[str]], smtputf8: bool
):
def protocol_factory(mails_path: Path, mbox_finder: Callable[[str], List[str]]):
logger.info("Got smtp client cb")
try:
handler = MyHandler(mails_path, mbox_finder)
smtp = SMTP(handler=handler, enable_SMTPUTF8=smtputf8)
smtp = SMTP(handler=handler, enable_SMTPUTF8=True)
except:
logger.exception("Something went wrong")
raise
@ -98,24 +95,15 @@ async def create_smtp_server_starttls(
host: str,
port: int,
mails_path: Path,
mbox_finder: Callable[[str], list[str]],
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}, {bool(ssl_context)=}"
f"Starting SMTP STARTTLS server {host=}, {port=}, {mails_path=!s}, {ssl_context != None=}"
)
loop = asyncio.get_event_loop()
return await loop.create_server(
partial(
protocol_factory_starttls,
mails_path,
mbox_finder,
ssl_context,
require_starttls,
smtputf8,
),
partial(protocol_factory_starttls, mails_path, mbox_finder, ssl_context),
host=host,
port=port,
start_serving=False,
@ -126,16 +114,15 @@ async def create_smtp_server(
host: str,
port: int,
mails_path: Path,
mbox_finder: Callable[[str], list[str]],
ssl_context: Optional[ssl.SSLContext],
smtputf8: bool,
) -> asyncio.Server:
mbox_finder: Callable[[str], List[str]],
ssl_context: Optional[ssl.SSLContext] = None,
):
logging.info(
f"Starting SMTP server {host=}, {port=}, {mails_path=!s}, {bool(ssl_context)=}"
f"Starting SMTP server {host=}, {port=}, {mails_path=!s}, {ssl_context != None=}"
)
loop = asyncio.get_event_loop()
return await loop.create_server(
partial(protocol_factory, mails_path, mbox_finder, smtputf8),
partial(protocol_factory, mails_path, mbox_finder),
host=host,
port=port,
ssl=ssl_context,

View File

@ -1,5 +1,5 @@
-i https://pypi.org/simple
aiosmtpd==1.4.5; 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'
aiosmtpd==1.4.4.post2
atpublic==4.0 ; python_version >= '3.8'
attrs==23.1.0 ; python_version >= '3.7'
python-jata==1.2

View File

@ -8,7 +8,7 @@ then
tag_val=$(git describe --dirty=DIRTY --exact-match)
case "$tag_val" in
*DIRTY)
echo "git-$commit-changes"
echo "git=$commit-changes"
;;
v*) # Only consider tags starting with v
echo "$tag_val"

View File

@ -67,11 +67,10 @@ class TestConfig(unittest.TestCase):
def test_get_mboxes(self) -> None:
cfg = config.Config(TEST_CONFIG)
rules = config.parse_checkers(cfg)
self.assertEqual(config.get_mboxes("foo@bar.com", rules), ["spam"])
self.assertEqual(config.get_mboxes("foo@mydomain.com", rules), ["all"])
self.assertEqual(
config.get_mboxes("first.last@mydomain.com", rules), ["important", "all"]
)
self.assertEqual(config.get_mboxes("foo@bar.com", rules), ['spam'])
self.assertEqual(config.get_mboxes("foo@mydomain.com", rules), ['all'])
self.assertEqual(config.get_mboxes("first.last@mydomain.com", rules),
['important', 'all'])
if __name__ == "__main__":

View File

@ -4,29 +4,20 @@ import logging
import tempfile
import time
import os
import poplib
from mail4one.pop3 import create_pop_server
from mail4one.config import User
from pathlib import Path
TEST_HASH = "".join(
"""
TEST_HASH = "".join(c for c in """
AFTY5EVN7AX47ZL7UMH3BETYWFBTAV3XHR73CEFAJBPN2NIHPWD
ZHV2UQSMSPHSQQ2A2BFQBNC77VL7F2UKATQNJZGYLCSU6C43UQD
AQXWXSWNGAEPGIMG2F3QDKBXL3MRHY6K2BPID64ZR6LABLPVSF
""".split()
)
""" if not c.isspace())
TEST_USER = "foobar"
TEST_MBOX = "foobar_mails"
TEST_USER = 'foobar'
TEST_MBOX = 'foobar_mails'
TEST_USER2 = "foo2"
TEST_MBOX2 = "foo2mails"
USERS = [
User(username=TEST_USER, password_hash=TEST_HASH, mbox=TEST_MBOX),
User(username=TEST_USER2, password_hash=TEST_HASH, mbox=TEST_MBOX2),
]
USERS = [User(username=TEST_USER, password_hash=TEST_HASH, mbox=TEST_MBOX)]
MAILS_PATH: Path
@ -47,8 +38,7 @@ Hello bro\r
IlzVOJqu9Zp7twFAtzcV\r
yQVk36B0mGU2gtWxXLr\r
PeF0RtbI0mAuVPLQDHCi\r
\r
"""
\r\n"""
def setUpModule() -> None:
@ -57,21 +47,13 @@ def setUpModule() -> None:
td = tempfile.TemporaryDirectory(prefix="m41.pop.")
unittest.addModuleCleanup(td.cleanup)
MAILS_PATH = Path(td.name)
for mbox in (TEST_MBOX, TEST_MBOX2):
os.mkdir(MAILS_PATH / mbox)
for md in ("new", "cur", "tmp"):
os.mkdir(MAILS_PATH / mbox / md)
with open(MAILS_PATH / TEST_MBOX / "new/msg1.eml", "wb") as f:
os.mkdir(MAILS_PATH / TEST_MBOX)
for md in ('new', 'cur', 'tmp'):
os.mkdir(MAILS_PATH / TEST_MBOX / md)
with open(MAILS_PATH / TEST_MBOX/ 'new/msg1.eml', 'wb') as f:
f.write(TESTMAIL)
with open(MAILS_PATH / TEST_MBOX / "new/msg2.eml", "wb") as f:
with open(MAILS_PATH / TEST_MBOX/ 'new/msg2.eml', 'wb') as f:
f.write(TESTMAIL)
with open(MAILS_PATH / TEST_MBOX2 / "new/msg1.eml", "wb") as f:
f.write(TESTMAIL)
f.write(b"More lines to follow\r\n")
f.write(b".Line starts with a dot\r\n")
f.write(b"some more lines\r\n")
f.write(b".\r\n")
f.write(b"Previous line just has a dot\r\n")
logging.debug(MAILS_PATH)
@ -83,14 +65,13 @@ class TestPop3(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self) -> None:
logging.debug("at asyncSetUp")
pop_server = await create_pop_server(
host="127.0.0.1", port=7995, mails_path=MAILS_PATH, users=USERS
)
pop_server = await create_pop_server(host='127.0.0.1',
port=7995,
mails_path=MAILS_PATH,
users=USERS)
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] = []
self.reader, self.writer = await asyncio.open_connection(
'127.0.0.1', 7995)
async def test_QUIT(self) -> None:
dialog = """
@ -134,9 +115,8 @@ class TestPop3(unittest.IsolatedAsyncioTestCase):
await self.dialog_checker(dialog)
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
r1, w1 = await asyncio.open_connection('127.0.0.1', 7995)
r2, w2 = await asyncio.open_connection('127.0.0.1', 7995)
dialog = """
S: +OK Server Ready
C: USER foobar
@ -217,36 +197,18 @@ class TestPop3(unittest.IsolatedAsyncioTestCase):
"""
await self.dialog_checker(dialog)
async def test_poplib(self) -> None:
def run_poplib():
pc = poplib.POP3("127.0.0.1", 7995)
try:
self.assertEqual(b"+OK Server Ready", pc.getwelcome())
self.assertEqual(b"+OK Welcome", pc.user("foo2"))
self.assertEqual(b"+OK Login successful", pc.pass_("helloworld"))
_, eml, oc = pc.retr(1)
self.assertIn(b"Previous line just has a dot", eml)
self.assertIn(b".Line starts with a dot", eml)
self.assertIn(b".", eml)
finally:
pc.quit()
await asyncio.to_thread(run_poplib)
async def asyncTearDown(self) -> None:
logging.debug("at teardown")
for w in self.ws + [self.writer]:
w.close()
await w.wait_closed()
self.ws.clear()
self.writer.close()
await self.writer.wait_closed()
self.task.cancel("test done")
async def dialog_checker(self, dialog: str) -> None:
await self.dialog_checker_impl(self.reader, self.writer, dialog)
async def dialog_checker_impl(
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, dialog: str
) -> None:
async def dialog_checker_impl(self, reader: asyncio.StreamReader,
writer: asyncio.StreamWriter,
dialog: str) -> None:
for line in dialog.splitlines():
line = line.strip()
if not line:
@ -260,5 +222,5 @@ class TestPop3(unittest.IsolatedAsyncioTestCase):
self.assertEqual(data, resp)
if __name__ == "__main__":
if __name__ == '__main__':
unittest.main()

View File

@ -10,31 +10,27 @@ class TestPWHash(unittest.TestCase):
pwinfo = parse_hash(pwhash)
self.assertEqual(len(pwinfo.salt), SALT_LEN)
self.assertEqual(len(pwinfo.scrypt_hash), KEY_LEN)
self.assertTrue(
check_pass(password, pwinfo), "check pass with correct password"
)
self.assertFalse(check_pass("foobar", pwinfo), "check pass with wrong password")
self.assertTrue(check_pass(password, pwinfo),
"check pass with correct password")
self.assertFalse(check_pass("foobar", pwinfo),
"check pass with wrong password")
def test_hardcoded_hash(self):
test_hash = "".join(
c
for c in """
test_hash = "".join(c for c in """
AFTY5EVN7AX47ZL7UMH3BETYWFBTAV3XHR73CEFAJBPN2NIHPWD
ZHV2UQSMSPHSQQ2A2BFQBNC77VL7F2UKATQNJZGYLCSU6C43UQD
AQXWXSWNGAEPGIMG2F3QDKBXL3MRHY6K2BPID64ZR6LABLPVSF
"""
if not c.isspace()
)
""" if not c.isspace())
pwinfo = parse_hash(test_hash)
self.assertTrue(
check_pass("helloworld", pwinfo), "check pass with correct password"
)
self.assertFalse(check_pass("foobar", pwinfo), "check pass with wrong password")
self.assertTrue(check_pass("helloworld", pwinfo),
"check pass with correct password")
self.assertFalse(check_pass("foobar", pwinfo),
"check pass with wrong password")
def test_invalid_hash(self):
with self.assertRaises(Exception):
parse_hash("sdlfkjdsklfjdsk")
if __name__ == "__main__":
if __name__ == '__main__':
unittest.main()

View File

@ -10,7 +10,7 @@ from pathlib import Path
from mail4one.smtp import create_smtp_server
TEST_MBOX = "foobar_mails"
TEST_MBOX = 'foobar_mails'
MAILS_PATH: Path
@ -21,7 +21,7 @@ def setUpModule() -> None:
unittest.addModuleCleanup(td.cleanup)
MAILS_PATH = Path(td.name)
os.mkdir(MAILS_PATH / TEST_MBOX)
for md in ("new", "cur", "tmp"):
for md in ('new', 'cur', 'tmp'):
os.mkdir(MAILS_PATH / TEST_MBOX / md)
@ -32,10 +32,7 @@ class TestSMTP(unittest.IsolatedAsyncioTestCase):
host="127.0.0.1",
port=7996,
mails_path=MAILS_PATH,
mbox_finder=lambda addr: [TEST_MBOX],
ssl_context=None,
smtputf8=True,
)
mbox_finder=lambda addr: [TEST_MBOX])
self.task = asyncio.create_task(smtp_server.serve_forever())
async def test_send_mail(self) -> None:
@ -48,9 +45,8 @@ class TestSMTP(unittest.IsolatedAsyncioTestCase):
msg = b"".join(l.strip() + b"\r\n" for l in msg.splitlines())
def send_mail():
with contextlib.closing(
smtplib.SMTP(host="127.0.0.1", port=7996)
) as client:
with contextlib.closing(smtplib.SMTP(host="127.0.0.1",
port=7996)) as client:
client.sendmail("foo@sender.com", "foo@bar.com", msg)
_, local_port = client.sock.getsockname()
return local_port
@ -66,7 +62,7 @@ class TestSMTP(unittest.IsolatedAsyncioTestCase):
Byee
"""
expected = "".join(l.strip() + "\r\n" for l in expected.splitlines())
mails = list((MAILS_PATH / TEST_MBOX / "new").glob("*"))
mails = list((MAILS_PATH / TEST_MBOX / 'new').glob("*"))
self.assertEqual(len(mails), 1)
self.assertEqual(mails[0].read_bytes(), expected.encode())