7 Commits

20 changed files with 413 additions and 152 deletions

3
.gitignore vendored
View File

@ -3,4 +3,5 @@
__pycache__ __pycache__
dummy.py dummy.py
build build
mail4one.pyz mail4one*.pyz
deploy_configs/config.json

View File

@ -1,19 +1,5 @@
shell: # Needs python3 >= 3.9, sed, git for build
MYPYPATH=`pipenv --venv`/lib/python3.11/site-packages pipenv shell build: clean
test:
pipenv run python -m unittest discover
docker-tests:
docker run --pull=always -v `pwd`:/app -w /app --rm -it python:3.11-alpine sh runtests.sh
docker run --pull=always -v `pwd`:/app -w /app --rm -it python:3.10-alpine sh runtests.sh
docker run --pull=always -v `pwd`:/app -w /app --rm -it python:3.11 sh runtests.sh
docker run --pull=always -v `pwd`:/app -w /app --rm -it python:3.10 sh runtests.sh
requirements.txt: Pipfile.lock
pipenv requirements > requirements.txt
build: clean requirements.txt
python3 -m pip install -r requirements.txt --no-compile --target build python3 -m pip install -r requirements.txt --no-compile --target build
cp -r mail4one/ build/ cp -r mail4one/ build/
sed -i "s/DEVELOMENT/$(shell scripts/get_version.sh)/" build/mail4one/version.py sed -i "s/DEVELOMENT/$(shell scripts/get_version.sh)/" build/mail4one/version.py
@ -28,3 +14,32 @@ build: clean requirements.txt
clean: clean:
rm -rf build rm -rf build
rm -rf mail4one.pyz rm -rf mail4one.pyz
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.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
# ============================================================================
requirements.txt: Pipfile.lock
pipenv requirements > requirements.txt
format:
black mail4one/*py
build-dev: requirements.txt build
setup:
pipenv install
cleanup:
pipenv --rm
shell:
MYPYPATH=`pipenv --venv`/lib/python3.11/site-packages pipenv shell
test:
pipenv run python -m unittest discover

View File

@ -1,44 +1,70 @@
# mail4one # Mail4one
Mail server for single user #asyncio #python 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.
## Features # Getting started
* smtp server with STARTTLS 1. Get a domain name
* pop3 server with TLS 1. Get a VPS (or a home server). Setup firewall rules for receive on port 25, 995, 465
* Both running on single thread using asyncio 1. Setup [MX record](#dns-records-receiving)
* Saves mails in simple Maildir format (i.e one file per email message) 1. [Build](#building-from-source) / Download latest release - `mail4one.pyz`
* After opening port, drops root privileges. So the process will not running as `nobody` 1. Generate `config.json` from [config.sample](deploy_configs/config.sample)
1. Run `./mail4one.pyz -c config.json`
1. Setup systemd service and TLS certificates. See [deploy_configs](deploy_configs/) for examples
## How to use # Sending email
echo -n "balki is awesome+<YOUR PASSWORD>" | sha256sum Mail4one only takes care of receiving and serving email. For sending email, use an external service like below
pipenv install
sudo $(pipenv --venv)/bin/python ./run.py --certfile /etc/letsencrypt/live/your.domain.com/fullchain.pem --keyfile /etc/letsencrypt/live/your.domain.com/privkey.pem /var/mails --password_hash <PASSWORD_HASH_FROM_ABOVE>
## Just pop server for debugging * https://www.smtp2go.com/pricing/
* https://www.mailgun.com/pricing/
* https://sendgrid.com/free/
pipenv run python -m mail4one.pop3 /path/to/mails 9995 your_password Most of them have generous free tier which is more than enough for personal use.
## Nextups 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.
* Support sending emails - Also support for popular services like mailgun/sendgrid # Community
* Smart assistant like functionality. For e.g.
* You don't need all emails of package deliver status. Just the latest one would be enough.
* Some type of emails can auto expire. Old newsletters are not very helpful
* Aggregate emails for weekend reading.
* Small webserver
* SPAM filtering - not that important as you can use unique addresses for each service. e.g. facebook@mydomian.com, bankac@mydomain.com, reddit@mydomain.com etc. You can easily figure out who sold your address to spammers and block it.
## Goals Original source is at https://gitea.balki.me/balki/mail4one
* Intended to be used for one person. So won't have features that don't make sense in this context. e.g. LDAP AUTH, Mail quota, etc,
* Supports only python3.7. No plans to support older versions
## Known to work For issues, pull requests, discussions, please use github mirror: https://github.com/mail4one/mail4one
* Server: Google Cloud f1-micro with Ubuntu 18.04 - Always Free instance
* Clients: thunderbird, evolution, k9mail
* smtp: Received email from all. Didn't see any drops. Tested from gmail, protonmail, reddit and few others
## Contribution # Documentation
Pull requests and issues welcome See files under [deploy_configs](deploy_configs/) for configuring and deploying to a standard systemd based linux system (e.g. debian, ubuntu, fedora, archlinux etc). [config.sample](deploy_configs/config.sample) has inline comments for more details. Feel free create github issue/discussions for support.
## DNS Records (Receiving)
If you want to receive email for `john@example.com` and your VPS IP address is `1.2.3.4`. Following record needs to be created
|Type | Name | Target | Notes |
|------|------------------|----------------------|------------------------------------------------------|
| A | mail.example.com | `1.2.3.4` | |
| AAAA | mail.example.com | `abcd:1234::1234::1` | Optional, add if available |
| MX | example.com | `mail.example.com` | |
| MX | sub.example.com | `mail.example.com` | Optional, to receive emails like foo@sub.example.com |
For sending emails `DMARC`, `DKIM` and `SPF` records need to be set. Please refer to email [sending](#sending-email) provider for details.
# Building from source
Make sure to have make, git, python >= 3.9, and pip installed in your system and run below
make build
This should generate `mail4one.pyz` in current folder. This is a [executable python archive](https://docs.python.org/3/library/zipapp.html). Should be runnable as `./mail4one.pyz` or as `python3 mail4one.pyz`.
# Roadmap (Planned features for future)
* Other ways to install and update (PIP, AUR, docker etc)
* 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
* Support email submission from client to forward to other senders or direct delivery
* Optional SPAM filtering
* Optional DMARC,SPF,DKIM verification
* Webmail Client
* Web UI to view graphs and smart reports

33
TODO.md
View File

@ -1,33 +0,0 @@
# TODO
DEV
1. unittests
1. Web interface
1. Custom email processing
1. Refactor smtp controller
1. pip installable package
1. Improve logging with timestamp, peer info, stats
1. custom smtp with auth
1. Filter domain name
2. Support multiple subdomains and save in different folders
1. smtp2go integration
1. metrics
2. imap
3. handle connection disconnect
4. rethink deleted items
MASTER
1. tests!
2. docker support
3. multi domain
4. [pop] auto delete
5. [pop] TOP command
6. merge with cloud_test branch
7. User timeout for POP
8. unittests
9. Web interface
10. Custom email processing
11. Refactor smtp controller
12. pip installable package
13. Listen on port 465 for smtp too

114
deploy_configs/README.md Normal file
View File

@ -0,0 +1,114 @@
# Deployment command line example
Example terminal session for deploying. ssh to your VPS and follow along. Minor differences may be required. e.g. if you are already root, skip `sudo`. If curl is missing, use wget.
## Check python version
Python version should be a supported (as of now 3.9 and above)
```sh
python3 -V
```
## Choose release
```sh
RELEASE=v1.0
```
## Download App
```sh
curl -OL "https://gitea.balki.me/balki/mail4one/releases/download/$RELEASE/mail4one.pyz"
chmod 555 mail4one.pyz
```
## Download sample configurations
```sh
curl -OL "https://gitea.balki.me/balki/mail4one/raw/tag/$RELEASE/deploy_configs/mail4one.service"
curl -OL "https://gitea.balki.me/balki/mail4one/raw/tag/$RELEASE/deploy_configs/mail4one.conf"
curl -OL "https://gitea.balki.me/balki/mail4one/raw/tag/$RELEASE/deploy_configs/mail4one_cert_copy.sh"
```
## Generate Password hash
This can be done in any machine. Do this once for each user. Every time a new hash is generated as a random salt is used. Even if you are using the same password for multiple clients, it is recommended to generate different hashes for each.
```sh
./mail4one.pyz -g
./mail4one.pyz -g <password> # also works but the password is saved in the shell commandline history
```
## Generate config.json
Edit [config.sample](config.sample) in your local machine and convert to config.json (See [here](./config.sample#L5) for some tools).
Then copy the config.json to your vps
```sh
scp config.json user@vps:~/
# or run below in vps terminal
cat > config.json
<paste json config from clibboard
<Ctrl + D>
# move to /etc
# This should show number of lines in your config
wc -l config.json
sudo mv config.json /etc/mail4one/config.json
```
## Create mail4one user
```sh
sudo mkdir -p /etc/sysusers.d/
sudo cp mail4one.conf /etc/sysusers.d/
sudo systemctl restart systemd-sysusers
# This should show the new user created
id mail4one
```
## Copy app
```sh
sudo cp mail4one.pyz /usr/local/bin/mail4one
# This should show executable permissions and should be owned by root
ls -l /usr/local/bin/mail4one
```
## Setup mail4one.service
```sh
sudo cp mail4one.service /etc/systemd/system/mail4one.service
sudo systemctl daemon-reload
sudo systemctl enable --now mail4one.service
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.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
sudo cp mail4one_cert_copy.sh /etc/letsencrypt/renewal-hooks/deploy/
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/mail4one_cert_copy.sh
```
## Restart service and check logs
```sh
sudo systemctl restart mail4one.service
systemctl status mail4one.service
cat /var/log/mail4one/mail4one.log
```
## Testing dns and firewall
In vps
```sh
mkdir test_dir
touch test_dir/{a,b,c}
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
```sh
curl http://mail.example.com:25
```
If not working, refer to VPS settings and OS firewall settings.

View File

@ -0,0 +1,144 @@
# NOTE: Sample config is provided in yaml format for easy editing
# mail4one needs a json config, Please convert the config to json before passing to app
# This is to avoid yaml depependency in the app
#
# Some tools to convert to json:
# If you have go in your system (https://go.dev/)
# go run github.com/mikefarah/yq/v4@latest -oj -P . config.sample > config.json
#
# If you have pipx in your system (https://pypa.github.io/pipx/)
# pipx run yq . config.sample > config.json
#
# or a browser:
# https://onlineyamltools.com/convert-yaml-to-json
#
default_tls: # Will be used by both pop and smtp servers
# If using certbot(https://certbot.eff.org/),
# the following files will be here /etc/letsencrypt/live/<domain name>
# Use mail4one_cert_copy.sh to automaticallly copy on renewal
certfile: /var/lib/mail4one/certs/fullchain.pem
keyfile: /var/lib/mail4one/certs/privkey.pem
# default_host: '0.0.0.0'
logging:
# Setup logrotate(https://github.com/logrotate/logrotate) if needed
logfile: /var/log/mail4one/mail4one.log
mails_path: /var/lib/mail4one/mails
matches:
# only <to> address is matched. (sent by smtp RCPT command)
# address is converted to lowercase before matching
- name: example.com
addr_rexs:
- .*@example.com
- name: promotion-spammers
addrs:
- twitter@example.com
- random-app-not-used-anymore@example.com
- flyer-walmart@example.com
- name: john
addrs:
- john.doe@example.com # Tip: Dont use this. Always use a different alias, this way there is no address for spammers
- secret.name@example.com
- john.facebook@example.com
- name: jane
addrs:
- jane.doe@example.com
- jane.instagram@example.com
- name: jane-all
addr_rexs:
- jane.*@example.com
- name: shared
addrs:
- kidschool@example.com
- mortgage@example.com
- water@example.com
- electricity@example.com
- airbnb@example.com
boxes:
# Mails saved in maildir format under <mails_path>/<name>/new
- name: default_null_mbox # Means, the mails are dropped
rules:
- match_name: example.com
negate: true # Anything mail that does not match '.*@example.com'
stop_check: true # No further rules will be checked, mail is dropped
# Mailbox to store non-interesting emails but occasionally have a useful mail
# Create a second account in your email client and disable notification
- name: promotion-box
rules:
- match_name: promotion-spammers
stop_check: true
- name: johnsmbox
rules:
- match_name: john
- match_name: shared
## To receive all emails excluding jane's personal emails
# - match_name: jane
# negate: true
- name: janesmbox
rules:
- match_name: jane
- match_name: shared
- match_name: jane-all
- name: all
rules:
# matches all emails except those are not for 'example.com', which are dropped before
- match_name: default_match_all
users: # Used only by the pop server, smtp is for receiving mails only. No auth is used
- username: johnmobile
# Generated using below command. Will produce different hash each time (salt is random)
# ./mail4one.pyz -g johnsecretpassword
password_hash: AEH6JG3IZR3ASA2ORJHQ62YTR6PHFRP6PAXQ6RI2VZFXAT5M6VAATE373PGCUHBJTLIDOQV6UJKICP2JTKDE3QXP7ROJ227QYFQDAXPP4LY4TLPTEHUZG7D7X6VKWZ4BVCASYCD3SSNQ555AZPIFMDAV
mbox: johnsmbox
# **NOTE** Use different username for each email client.
# Otherwise emails may appear in only one client
- username: johnlaptop
password_hash: AEH6JG3IZR3ASA2ORJHQ62YTR6PHFRP6PAXQ6RI2VZFXAT5M6VAATE373PGCUHBJTLIDOQV6UJKICP2JTKDE3QXP7ROJ227QYFQDAXPP4LY4TLPTEHUZG7D7X6VKWZ4BVCASYCD3SSNQ555AZPIFMDAV
mbox: johnsmbox
# Second account to not clutter main mailbox. Disable notification for this account
- username: john-mobile-promotion
password_hash: AGBD47ZYBA7BMUQY25YDTYQWVPJFDBTLIICKFP2IL2GI4M7AO2LIVIZXTY6N25KBRLOEC7TLXGAFW7SSQEBKCG7U3FJNKW6RZWZBS3ABSP2U53BBIOCXZNWPXJGWAQ6WFXIF7T4YQJZD5QLF2OO4JZ45
mbox: promotion-box
- username: janemobile
password_hash: AGQNPATXU7PP7LDD6DZ4HFLUUHRJDHFQKKKRLVLGOIIEHC7TPOZF7NTXGDAIGDNHF62RAH4N44DB46O3VC4TBOLE5XHY6S77YPLTWCNAHGONEOZYO6YWJ3NHLKOHFJLF6BOHNMCI3RCPWXWXQPHSFDQR
mbox: janesmbox
- username: family_desktop # Catch all for backup
password_hash: AGBD47ZYBA7BMUQY25YDTYQWVPJFDBTLIICKFP2IL2GI4M7AO2LIVIZXTY6N25KBRLOEC7TLXGAFW7SSQEBKCG7U3FJNKW6RZWZBS3ABSP2U53BBIOCXZNWPXJGWAQ6WFXIF7T4YQJZD5QLF2OO4JZ45
mbox: all
servers:
- server_type: pop
## default values
# port: 995
# host: '0.0.0.0'
# tls: default # Uses default_tls config
- server_type: smtp
## default values
# port: 465
# host: '0.0.0.0'
# tls: default # Uses default_tls config
# tls: disable # disable tls and receive emails in plain text only
- server_type: smtp_starttls
## default values
# port: 25
# host: '0.0.0.0'
# tls: default # Uses default_tls config
# vim: ft=yaml

View File

@ -1,3 +1,7 @@
# This file should be copied to /etc/sysusers.d/mail4one.conf
# Then either restart the system or run `systemctl restart systemd-sysusers`
# That should create a system user 'mail4one'
#
# See sysusers.d(5) for details. # See sysusers.d(5) for details.
u mail4one - "Personal Mail server" u mail4one - "Personal Mail server"

View File

@ -1,4 +1,7 @@
# mail4one.service # This file should be copied to /etc/systemd/system/mail4one.service
# Quickstart
# systemctl daemon-reload
# systemctl enable --now mail4one.service
[Unit] [Unit]
Description=Personal Mail server Description=Personal Mail server
@ -7,20 +10,24 @@ After=network.target network-online.target
Requires=network-online.target Requires=network-online.target
[Service] [Service]
# This user should already exist. See mail4one.conf for creating user with sysusers
User=mail4one User=mail4one
ExecStart=/usr/local/bin/mail4one --config /etc/mail4one/config.json ExecStart=/usr/local/bin/mail4one --config /etc/mail4one/config.json
# Below allows to bind to port < 1024. Standard ports are 25, 465, 995
AmbientCapabilities=CAP_NET_BIND_SERVICE AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
StateDirectory=mail4one/certs mail4one/mails StateDirectory=mail4one/certs mail4one/mails
StateDirectoryMode=0750 StateDirectoryMode=0750
UMask=
LogsDirectory=mail4one LogsDirectory=mail4one
WorkingDirectory=/var/lib/mail4one WorkingDirectory=/var/lib/mail4one
ProtectSystem=strict ProtectSystem=strict
PrivateTmp=true PrivateTmp=true
PrivateUsers=true
ProtectHome=yes ProtectHome=yes
ProtectProc=invisible
NoNewPrivileges=yes NoNewPrivileges=yes
[Install] [Install]

View File

@ -1,6 +1,8 @@
#!/bin/sh #!/bin/sh
# certbot deploy hook to copy certificates to mail4one when renewed. # certbot deploy hook to copy certificates to mail4one when renewed.
# Initial setup, Install certbot(https://certbot.eff.org/) and run `certbot certonly` as root
#
# This file is supposed to be copied to /etc/letsencrypt/renewal-hooks/deploy/ # This file is supposed to be copied to /etc/letsencrypt/renewal-hooks/deploy/
# Change the mail domain to the one on MX record # Change the mail domain to the one on MX record

View File

@ -1,3 +0,0 @@
from .server import main
main()

View File

@ -1,7 +1,7 @@
import json import json
import re import re
import logging import logging
from typing import Callable from typing import Callable, Union, Optional
from jata import Jata, MutableDefault from jata import Jata, MutableDefault
@ -45,7 +45,7 @@ class ServerCfg(Jata):
host: str = "default" host: str = "default"
port: int port: int
# disabled: bool = False # disabled: bool = False
tls: TLSCfg | str = "default" tls: Union[TLSCfg, str] = "default"
class PopCfg(ServerCfg): class PopCfg(ServerCfg):
@ -56,13 +56,13 @@ class PopCfg(ServerCfg):
class SmtpStartTLSCfg(ServerCfg): class SmtpStartTLSCfg(ServerCfg):
server_type = "smtp_starttls" server_type = "smtp_starttls"
smtputf8 = True # Not used yet smtputf8 = True # Not used yet
port = 25 port = 25
class SmtpCfg(ServerCfg): class SmtpCfg(ServerCfg):
server_type = "smtp_starttls" server_type = "smtp_starttls"
smtputf8 = True # Not used yet smtputf8 = True # Not used yet
port = 465 port = 465
@ -72,9 +72,9 @@ class LogCfg(Jata):
class Config(Jata): class Config(Jata):
default_tls: TLSCfg | None default_tls: Optional[TLSCfg] = None
default_host: str = "0.0.0.0" default_host: str = "0.0.0.0"
logging: LogCfg | None = None logging: Optional[LogCfg] = None
mails_path: str mails_path: str
matches: list[Match] matches: list[Match]
@ -89,7 +89,6 @@ 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): def make_match_fn(m: Match):
if m.addrs and m.addr_rexs: if m.addrs and m.addr_rexs:
raise Exception("Both addrs and addr_rexs is set") raise Exception("Both addrs and addr_rexs is set")
@ -97,8 +96,7 @@ def parse_checkers(cfg: Config) -> list[Checker]:
return lambda malias: malias in m.addrs return lambda malias: malias in m.addrs
elif m.addr_rexs: elif m.addr_rexs:
compiled_res = [re.compile(reg) for reg in m.addr_rexs] compiled_res = [re.compile(reg) for reg in m.addr_rexs]
return lambda malias: any( return lambda malias: any(reg.match(malias) for reg in compiled_res)
reg.match(malias) for reg in compiled_res)
else: else:
raise Exception("Neither addrs nor addr_rexs is set") raise Exception("Neither addrs nor addr_rexs is set")
@ -114,13 +112,13 @@ def parse_checkers(cfg: Config) -> list[Checker]:
return mbox_name, match_fn, rule.stop_check return mbox_name, match_fn, rule.stop_check
return [ return [
make_checker(mbox.name, Rule(rule)) for mbox in cfg.boxes or [] make_checker(mbox.name, Rule(rule))
for mbox in cfg.boxes or []
for rule in mbox.rules for rule in mbox.rules
] ]
def get_mboxes(addr: str, checks: list[Checker]) -> list[str]: def get_mboxes(addr: str, checks: list[Checker]) -> list[str]:
def inner(): def inner():
for mbox, match_fn, stop_check in checks: for mbox, match_fn, stop_check in checks:
if match_fn(addr): if match_fn(addr):

View File

@ -13,6 +13,8 @@ from .pwhash import parse_hash, check_pass, PWInfo
from asyncio import StreamReader, StreamWriter from asyncio import StreamReader, StreamWriter
import random import random
from typing import Optional
from .poputils import ( from .poputils import (
InvalidCommand, InvalidCommand,
parse_command, parse_command,
@ -44,7 +46,6 @@ class State:
class SharedState: 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.mails_path = mails_path
self.users = users self.users = users
@ -56,8 +57,7 @@ class SharedState:
return self.counter return self.counter
c_shared_state: contextvars.ContextVar = contextvars.ContextVar( c_shared_state: contextvars.ContextVar = contextvars.ContextVar("pop_shared_state")
"pop_shared_state")
def scfg() -> SharedState: def scfg() -> SharedState:
@ -72,7 +72,6 @@ def state() -> State:
class PopLogger(logging.LoggerAdapter): class PopLogger(logging.LoggerAdapter):
def __init__(self): def __init__(self):
super().__init__(logging.getLogger("pop3"), None) super().__init__(logging.getLogger("pop3"), None)
@ -117,8 +116,8 @@ async def expect_cmd(*commands: Command) -> Request:
return req return req
def write(data) -> None: def write(data: bytes) -> None:
logger.debug(f"Server: {data}") logger.debug(f"Server: {data!r}")
state().writer.write(data) state().writer.write(data)
@ -280,8 +279,7 @@ def get_deleted_items(deleted_items_path: Path) -> set[str]:
return set() return set()
def save_deleted_items(deleted_items_path: Path, def save_deleted_items(deleted_items_path: Path, deleted_items: set[str]) -> None:
deleted_items: set[str]) -> None:
with deleted_items_path.open(mode="w") as f: with deleted_items_path.open(mode="w") as f:
f.writelines(f"{did}\n" for did in deleted_items) f.writelines(f"{did}\n" for did in deleted_items)
@ -298,8 +296,9 @@ async def transaction_stage() -> None:
new_deleted_items: set[str] = await process_transactions(mails_list) new_deleted_items: set[str] = await process_transactions(mails_list)
logger.info(f"completed transactions. Deleted:{len(new_deleted_items)}") logger.info(f"completed transactions. Deleted:{len(new_deleted_items)}")
if new_deleted_items: if new_deleted_items:
save_deleted_items(deleted_items_path, save_deleted_items(
existing_deleted_items.union(new_deleted_items)) deleted_items_path, existing_deleted_items.union(new_deleted_items)
)
logger.info(f"Saved deleted items") logger.info(f"Saved deleted items")
@ -330,7 +329,6 @@ async def start_session() -> None:
def parse_users(users: list[User]) -> dict[str, tuple[PWInfo, str]]: def parse_users(users: list[User]) -> dict[str, tuple[PWInfo, str]]:
def inner(): def inner():
for user in users: for user in users:
user = User(user) user = User(user)
@ -340,15 +338,13 @@ def parse_users(users: list[User]) -> dict[str, tuple[PWInfo, str]]:
return dict(inner()) return dict(inner())
def make_pop_server_callback(mails_path: Path, users: list[User], def make_pop_server_callback(mails_path: Path, users: list[User], timeout_seconds: int):
timeout_seconds: int):
scfg = SharedState(mails_path=mails_path, users=parse_users(users)) scfg = SharedState(mails_path=mails_path, users=parse_users(users))
async def session_cb(reader: StreamReader, writer: StreamWriter): async def session_cb(reader: StreamReader, writer: StreamWriter):
c_shared_state.set(scfg) c_shared_state.set(scfg)
ip, _ = writer.get_extra_info("peername") ip, _ = writer.get_extra_info("peername")
c_state.set( c_state.set(State(reader=reader, writer=writer, ip=ip, req_id=scfg.next_id()))
State(reader=reader, writer=writer, ip=ip, req_id=scfg.next_id()))
logger.info(f"Got pop server callback") logger.info(f"Got pop server callback")
try: try:
try: try:
@ -367,7 +363,7 @@ async def create_pop_server(
port: int, port: int,
mails_path: Path, mails_path: Path,
users: list[User], users: list[User],
ssl_context: ssl.SSLContext | None = None, ssl_context: Optional[ssl.SSLContext] = None,
timeout_seconds: int = 60, timeout_seconds: int = 60,
) -> asyncio.Server: ) -> asyncio.Server:
logging.info( logging.info(

View File

@ -86,7 +86,7 @@ def parse_command(bline: bytes) -> Request:
if parts: if parts:
request.arg2, *parts = parts request.arg2, *parts = parts
if parts: if parts:
(request.rest, ) = parts (request.rest,) = parts
return request return request
@ -130,7 +130,6 @@ def get_mail(entry: MailEntry) -> bytes:
class MailList: class MailList:
def __init__(self, entries: list[MailEntry]): def __init__(self, entries: list[MailEntry]):
self.entries = entries self.entries = entries
set_nid(self.entries) set_nid(self.entries)

View File

@ -19,17 +19,13 @@ KEY_LEN = 64 # This is python default
def gen_pwhash(password: str) -> str: def gen_pwhash(password: str) -> str:
salt = os.urandom(SALT_LEN) salt = os.urandom(SALT_LEN)
sh = scrypt(password.encode(), sh = scrypt(
salt=salt, password.encode(), salt=salt, n=SCRYPT_N, r=SCRYPT_R, p=SCRYPT_P, dklen=KEY_LEN
n=SCRYPT_N, )
r=SCRYPT_R,
p=SCRYPT_P,
dklen=KEY_LEN)
return b32encode(VERSION + salt + sh).decode() return b32encode(VERSION + salt + sh).decode()
class PWInfo: class PWInfo:
def __init__(self, salt: bytes, sh: bytes): def __init__(self, salt: bytes, sh: bytes):
self.salt = salt self.salt = salt
self.scrypt_hash = sh self.scrypt_hash = sh
@ -40,12 +36,13 @@ def parse_hash(pwhash_str: str) -> PWInfo:
if not len(pwhash) == 1 + SALT_LEN + KEY_LEN: if not len(pwhash) == 1 + SALT_LEN + KEY_LEN:
raise Exception( raise Exception(
f"Invalid hash size, {len(pwhash)} != {1 + SALT_LEN + KEY_LEN}") f"Invalid hash size, {len(pwhash)} != {1 + SALT_LEN + KEY_LEN}"
)
if (ver := pwhash[0:1]) != VERSION: if (ver := pwhash[0:1]) != VERSION:
raise Exception(f"Invalid hash version, {ver!r} != {VERSION!r}") raise Exception(f"Invalid hash version, {ver!r} != {VERSION!r}")
salt, sh = pwhash[1:SALT_LEN + 1], pwhash[-KEY_LEN:] salt, sh = pwhash[1 : SALT_LEN + 1], pwhash[-KEY_LEN:]
return PWInfo(salt, sh) return PWInfo(salt, sh)

View File

@ -13,6 +13,7 @@ from .version import VERSION
from . import config from . import config
from . import pwhash from . import pwhash
from typing import Optional, Union
def create_tls_context(certfile, keyfile) -> ssl.SSLContext: def create_tls_context(certfile, keyfile) -> ssl.SSLContext:
@ -22,24 +23,25 @@ def create_tls_context(certfile, keyfile) -> ssl.SSLContext:
def setup_logging(cfg: config.LogCfg): def setup_logging(cfg: config.LogCfg):
logging_format = "%(asctime)s %(name)s %(levelname)s %(message)s @ %(filename)s:%(lineno)d" logging_format = (
"%(asctime)s %(name)s %(levelname)s %(message)s @ %(filename)s:%(lineno)d"
)
if cfg.logfile == "CONSOLE": if cfg.logfile == "CONSOLE":
logging.basicConfig(level=cfg.level, format=logging_format) logging.basicConfig(level=cfg.level, format=logging_format)
else: else:
logging.basicConfig(filename=cfg.logfile, logging.basicConfig(
level=cfg.level, filename=cfg.logfile, level=cfg.level, format=logging_format
format=logging_format) )
async def a_main(cfg: config.Config) -> None: async def a_main(cfg: config.Config) -> None:
default_tls_context: ssl.SSLContext | None = None default_tls_context: Optional[ssl.SSLContext] = None
if tls := cfg.default_tls: if tls := cfg.default_tls:
logging.info( logging.info(f"Initializing default tls {tls.certfile=}, {tls.keyfile=}")
f"Initializing default tls {tls.certfile=}, {tls.keyfile=}")
default_tls_context = create_tls_context(tls.certfile, tls.keyfile) default_tls_context = create_tls_context(tls.certfile, tls.keyfile)
def get_tls_context(tls: config.TLSCfg | str): def get_tls_context(tls: Union[config.TLSCfg, str]):
if tls == "default": if tls == "default":
return default_tls_context return default_tls_context
elif tls == "disable": elif tls == "disable":

View File

@ -7,7 +7,7 @@ import uuid
import shutil import shutil
from functools import partial from functools import partial
from pathlib import Path from pathlib import Path
from typing import Callable from typing import Callable, Optional
from . import config from . import config
from email.message import Message from email.message import Message
import email.policy import email.policy
@ -25,15 +25,14 @@ logger = logging.getLogger("smtp")
class MyHandler(AsyncMessage): 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__() super().__init__()
self.mails_path = mails_path self.mails_path = mails_path
self.mbox_finder = mbox_finder self.mbox_finder = mbox_finder
async def handle_DATA(self, server: SMTPServer, session: SMTPSession, async def handle_DATA(
envelope: SMTPEnvelope) -> str: self, server: SMTPServer, session: SMTPSession, envelope: SMTPEnvelope
) -> str:
self.rcpt_tos = envelope.rcpt_tos self.rcpt_tos = envelope.rcpt_tos
self.peer = session.peer self.peer = session.peer
return await super().handle_DATA(server, session, envelope) return await super().handle_DATA(server, session, envelope)
@ -63,9 +62,9 @@ class MyHandler(AsyncMessage):
) )
def protocol_factory_starttls(mails_path: Path, def protocol_factory_starttls(
mbox_finder: Callable[[str], list[str]], mails_path: Path, mbox_finder: Callable[[str], list[str]], context: ssl.SSLContext
context: ssl.SSLContext): ):
logger.info("Got smtp client cb starttls") logger.info("Got smtp client cb starttls")
try: try:
handler = MyHandler(mails_path, mbox_finder) handler = MyHandler(mails_path, mbox_finder)
@ -81,8 +80,7 @@ def protocol_factory_starttls(mails_path: Path,
return smtp return smtp
def protocol_factory(mails_path: Path, mbox_finder: Callable[[str], def protocol_factory(mails_path: Path, mbox_finder: Callable[[str], list[str]]):
list[str]]):
logger.info("Got smtp client cb") logger.info("Got smtp client cb")
try: try:
handler = MyHandler(mails_path, mbox_finder) handler = MyHandler(mails_path, mbox_finder)
@ -105,8 +103,7 @@ async def create_smtp_server_starttls(
) )
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
return await loop.create_server( return await loop.create_server(
partial(protocol_factory_starttls, mails_path, mbox_finder, partial(protocol_factory_starttls, mails_path, mbox_finder, ssl_context),
ssl_context),
host=host, host=host,
port=port, port=port,
start_serving=False, start_serving=False,
@ -118,7 +115,7 @@ async def create_smtp_server(
port: int, port: int,
mails_path: Path, mails_path: Path,
mbox_finder: Callable[[str], list[str]], mbox_finder: Callable[[str], list[str]],
ssl_context: ssl.SSLContext | None = None, ssl_context: Optional[ssl.SSLContext] = None,
) -> asyncio.Server: ) -> asyncio.Server:
logging.info( logging.info(
f"Starting SMTP server {host=}, {port=}, {mails_path=!s}, {ssl_context != None=}" f"Starting SMTP server {host=}, {port=}, {mails_path=!s}, {ssl_context != None=}"

View File

@ -1,2 +1 @@
VERSION = "DEVELOMENT" VERSION = "DEVELOMENT"

3
run.py
View File

@ -1,3 +0,0 @@
from mail4one.server import main
main()

View File

@ -9,7 +9,6 @@ then
case "$tag_val" in case "$tag_val" in
*DIRTY) *DIRTY)
echo "git=$commit-changes" echo "git=$commit-changes"
exit
;; ;;
v*) # Only consider tags starting with v v*) # Only consider tags starting with v
echo "$tag_val" echo "$tag_val"