Compare commits

..

31 Commits

Author SHA1 Message Date
57d0eeaf0f pylint (#8)
Current score:

Your code has been rated at 8.08/10 (previous run: 7.98/10, +0.09)

Reviewed-on: #8
2024-05-24 21:20:09 -04:00
64dcc74b8d Merge pull request 'Makefile cleanup' (#7) from cleanup into main
Reviewed-on: #7
2024-04-17 20:41:13 -04:00
1d644a9bcc add test target 2024-04-16 18:14:09 -04:00
e853cfe62d make file improvements and some pylint cleanup 2024-04-16 18:14:09 -04:00
cb15ec15e4 Merge pull request 'Add option to not enforce starttls and smtputf8' (#6) from starttls_opt into main
Reviewed-on: #6
2024-04-16 18:11:36 -04:00
b8858085ab Add option to not enforce starttls and smtputf8 2024-04-16 18:06:16 -04:00
2c6d98ce2d fix warning in tests 2024-04-02 16:47:41 -04:00
520dfd7b14 remove unused imports 2024-04-02 16:35:47 -04:00
fbb2cf38ae Upgrade dependencies 2024-04-01 18:12:44 -04:00
4df53ce247 Improve cert_copy script to work better for the first time 2024-04-01 18:11:38 -04:00
eb5c71968a pop3 should byte-stuff lines starting with dot (#5)
fixes #4

Reviewed-on: #5
2024-04-01 17:51:22 -04:00
f3e80c43ae Fix debug_main and minor cleanup 2024-04-01 17:45:37 -04:00
829be8413b black format test files too 2024-04-01 11:14:20 -04:00
121a02b8ae Upgrade dependencies 2024-01-12 10:59:27 -05:00
e18f1c7a96 Remove type information files from executable 2024-01-12 10:48:17 -05:00
cc5ad89977 Improve readme doc 2024-01-09 11:25:42 -05:00
a1fd586dbd Add log in cert renew script 2023-12-20 17:21:57 -05:00
8fe42e9163 fix typo 2023-06-28 23:46:55 -04:00
937992a72e [Documentation] Add example deploy session (#2)
Reviewed-on: #2
2023-06-28 22:37:09 -04:00
e1a78d560d add type hint for pop3.write 2023-06-28 17:48:51 -04:00
b9fbc9138c reorg files 2023-06-28 17:15:18 -04:00
5469659f1e Add sample configuration and documentation (#1)
Reviewed-on: #1
2023-06-28 16:54:50 -04:00
5f56a1256b support old python - 3.9 2023-06-26 17:31:00 -04:00
59abf24ce5 format using black 2023-06-26 17:20:50 -04:00
7cb1b69744 more systemd hardening 2023-06-24 21:17:47 -04:00
95423ebf63 Remove pyc files
pyc files are not compatible across multiple python versions
Harden systemd service files
2023-06-24 20:12:20 -04:00
2bcc807b91 Allow multiple servers of same type 2023-06-23 22:03:58 -04:00
bcd250d2b0 change STDOUT to CONSOLE 2023-06-23 21:42:00 -04:00
9107474d31 Add version info from git tags 2023-06-23 21:30:31 -04:00
2bf809c454 Remove PosixPath() in logs and add proper exception logging 2023-06-22 00:49:23 -04:00
1e6655a715 pop3 catch and log exceptions 2023-06-22 00:31:05 -04:00
27 changed files with 789 additions and 344 deletions

3
.gitignore vendored
View File

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

38
DEVNOTES.md Normal file
View File

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

View File

@ -1,24 +1,11 @@
shell:
MYPYPATH=`pipenv --venv`/lib/python3.11/site-packages pipenv shell
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 --target build
# 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/
python3 -m compileall build/mail4one -f
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 \
--output mail4one.pyz \
@ -26,6 +13,58 @@ build: clean requirements.txt
--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
.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
.PHONY: dev-test
dev-test:
pipenv run python -m unittest discover

20
Pipfile.lock generated
View File

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

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](Pipfile).
Designed for dynamic alias based workflow where a different alias is used for each purpose.
## Features
# Getting started
* smtp server with STARTTLS
* pop3 server with TLS
* Both running on single thread using asyncio
* Saves mails in simple Maildir format (i.e one file per email message)
* After opening port, drops root privileges. So the process will not running as `nobody`
1. Get a domain name
1. Get a VPS (or a home server). Setup firewall rules for receive on port 25, 995, 465
1. Setup [MX record](#dns-records-receiving)
1. [Build](#building-from-source) / Download latest release - `mail4one.pyz`
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
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>
Mail4one only takes care of receiving and serving email. For sending email, use an external service like below
## 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. Hence using a dedicated service is the only reliable way to send emails.
* Support sending emails - Also support for popular services like mailgun/sendgrid
* 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.
# Community
## Goals
* 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
Original source is at https://gitea.balki.me/balki/mail4one
## Known to work
* 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
For issues, pull requests, discussions, please use github mirror: https://github.com/mail4one/mail4one
## 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 ([WIP](https://github.com/mail4one/mail4one/tree/webform))
* 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

115
deploy_configs/README.md Normal file
View File

@ -0,0 +1,115 @@
# 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.mydomain.com`
```sh
sudo certbot certonly
# **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
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.mydomain.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 dependency 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.
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]
Description=Personal Mail server
@ -7,16 +10,25 @@ After=network.target network-online.target
Requires=network-online.target
[Service]
# This user should already exist. See mail4one.conf for creating user with sysusers
User=mail4one
ExecStart=/usr/local/bin/mail4one --config /etc/mail4one/config.json
PrivateTmp=true
ProtectSystem=full
AmbientCapabilities=CAP_NET_BIND_SERVICE
StateDirectory=mail4one
# Below allows to bind to port < 1024. Standard ports are 25, 465, 995
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
StateDirectory=mail4one/certs mail4one/mails
StateDirectoryMode=0750
LogsDirectory=mail4one
WorkingDirectory=/var/lib/mail4one
ProtectSystem=strict
PrivateTmp=true
ProtectHome=yes
ProtectProc=invisible
NoNewPrivileges=yes
[Install]
WantedBy=multi-user.target

View File

@ -1,18 +1,27 @@
#!/bin/sh
# 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 -x
set -eu
if [ "$RENEWED_DOMAINS" = "mail.mydomain.com" ]
then
mkdir -p /var/lib/mail4one/certs
chmod 500 /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
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
fi

View File

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

View File

@ -1,7 +1,8 @@
import json
"""Module for parsing mail4one config.json"""
import re
import logging
from typing import Callable
from typing import Callable, Union, Optional
from jata import Jata, MutableDefault
@ -41,46 +42,48 @@ class TLSCfg(Jata):
class ServerCfg(Jata):
server_type: str
host: str = "default"
port: int
# disabled: bool = False
tls: TLSCfg | str = "default"
tls: Union[TLSCfg, str] = "default"
class PopCfg(ServerCfg):
server_type = "pop"
port = 995
timeout_seconds = 60
class SmtpStartTLSCfg(ServerCfg):
server_type = "smtp_starttls"
require_starttls = True
smtputf8 = True
port = 25
class SmtpCfg(ServerCfg):
server_type = "smtp"
smtputf8 = True
port = 465
class LogCfg(Jata):
logfile = "STDOUT"
logfile = "CONSOLE"
level = "INFO"
class Config(Jata):
default_tls: TLSCfg | None
default_tls: Optional[TLSCfg] = None
default_host: str = "0.0.0.0"
logging: LogCfg | None = None
logging: Optional[LogCfg] = None
mails_path: str
matches: list[Match]
boxes: list[Mbox]
users: list[User]
pop: PopCfg | None = None
smtp_starttls: SmtpStartTLSCfg | None = None
smtp: SmtpCfg | None = None
# smtp_port_submission = 587
servers: list[ServerCfg]
CheckerFn = Callable[[str], bool]
@ -88,18 +91,15 @@ Checker = tuple[str, CheckerFn, bool]
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
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")
return lambda malias: any(reg.match(malias) for reg in compiled_res)
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
@ -113,13 +113,13 @@ def parse_checkers(cfg: Config) -> list[Checker]:
return mbox_name, match_fn, rule.stop_check
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
]
def get_mboxes(addr: str, checks: list[Checker]) -> list[str]:
def inner():
for mbox, match_fn, stop_check in checks:
if match_fn(addr):

View File

@ -2,16 +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 .poputils import (
InvalidCommand,
@ -27,7 +26,7 @@ from .poputils import (
end,
Request,
MailEntry,
get_mail,
get_mail_fp,
get_mails_list,
MailList,
)
@ -44,7 +43,6 @@ class State:
class SharedState:
def __init__(self, mails_path: Path, users: dict[str, tuple[PWInfo, str]]):
self.mails_path = mails_path
self.users = users
@ -56,8 +54,7 @@ class SharedState:
return self.counter
c_shared_state: contextvars.ContextVar = contextvars.ContextVar(
"pop_shared_state")
c_shared_state: contextvars.ContextVar = contextvars.ContextVar("pop_shared_state")
def scfg() -> SharedState:
@ -72,18 +69,17 @@ def state() -> State:
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()
@ -105,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:
@ -117,8 +112,8 @@ async def expect_cmd(*commands: Command) -> Request:
return req
def write(data) -> None:
logger.debug(f"Server: {data}")
def write(data: bytes) -> None:
logger.debug(f"Server: {data!r}")
state().writer.write(data)
@ -154,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:
@ -218,7 +211,12 @@ def trans_command_retr(mails: MailList, req: Request) -> None:
entry = mails.get(req.arg1)
if entry:
write(ok("Contents follow"))
write(get_mail(entry))
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(end())
mails.delete(req.arg1)
else:
@ -268,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]:
@ -280,8 +277,7 @@ def get_deleted_items(deleted_items_path: Path) -> set[str]:
return set()
def save_deleted_items(deleted_items_path: Path,
deleted_items: set[str]) -> None:
def save_deleted_items(deleted_items_path: Path, deleted_items: set[str]) -> None:
with deleted_items_path.open(mode="w") as f:
f.writelines(f"{did}\n" for did in deleted_items)
@ -298,10 +294,11 @@ async def transaction_stage() -> None:
new_deleted_items: set[str] = await process_transactions(mails_list)
logger.info(f"completed transactions. Deleted:{len(new_deleted_items)}")
if new_deleted_items:
save_deleted_items(deleted_items_path,
existing_deleted_items.union(new_deleted_items))
save_deleted_items(
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:
@ -312,17 +309,15 @@ 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}")
except Exception as e:
logger.error(f"Serious client error: {e}")
except:
logger.exception("Serious client error")
raise
finally:
with contextlib.suppress(KeyError):
@ -330,7 +325,6 @@ async def start_session() -> None:
def parse_users(users: list[User]) -> dict[str, tuple[PWInfo, str]]:
def inner():
for user in users:
user = User(user)
@ -340,21 +334,22 @@ 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):
scfg = SharedState(mails_path=mails_path, users=parse_users(users))
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))
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:
return await asyncio.wait_for(start_session(), timeout_seconds)
finally:
writer.close()
await writer.wait_closed()
try:
return await asyncio.wait_for(start_session(), timeout_seconds)
finally:
writer.close()
await writer.wait_closed()
except:
logger.exception("unexpected exception")
return session_cb
@ -364,11 +359,11 @@ async def create_pop_server(
port: int,
mails_path: Path,
users: list[User],
ssl_context: ssl.SSLContext | None = None,
ssl_context: Optional[ssl.SSLContext] = None,
timeout_seconds: int = 60,
) -> asyncio.Server:
logging.info(
f"Starting POP3 server {host=}, {port=}, {mails_path=}, {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),
@ -387,13 +382,14 @@ def debug_main():
logging.basicConfig(level=logging.DEBUG)
import sys
from .pwhash import gen_pwhash
_, mails_path, port, password = sys.argv
_, mails_path, mbox = sys.argv
mails_path = Path(mails_path)
port = int(port)
users = [User(username="dummy", password_hash=gen_pwhash("dummy"), mbox=mbox)]
asyncio.run(a_main(mails_path, port, password_hash=password_hash))
asyncio.run(a_main("127.0.0.1", 1101, mails_path, users=users))
if __name__ == "__main__":

View File

@ -2,6 +2,7 @@ import os
from dataclasses import dataclass
from enum import Enum, auto
from pathlib import Path
from contextlib import contextmanager
class ClientError(Exception):
@ -19,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):
@ -86,7 +85,7 @@ def parse_command(bline: bytes) -> Request:
if parts:
request.arg2, *parts = parts
if parts:
(request.rest, ) = parts
(request.rest,) = parts
return request
@ -124,13 +123,18 @@ def set_nid(entries: list[MailEntry]):
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]):
self.entries = entries
set_nid(self.entries)

View File

@ -19,17 +19,13 @@ KEY_LEN = 64 # This is python default
def gen_pwhash(password: str) -> str:
salt = os.urandom(SALT_LEN)
sh = scrypt(password.encode(),
salt=salt,
n=SCRYPT_N,
r=SCRYPT_R,
p=SCRYPT_P,
dklen=KEY_LEN)
sh = scrypt(
password.encode(), salt=salt, n=SCRYPT_N, r=SCRYPT_R, p=SCRYPT_P, dklen=KEY_LEN
)
return b32encode(VERSION + salt + sh).decode()
class PWInfo:
def __init__(self, salt: bytes, sh: bytes):
self.salt = salt
self.scrypt_hash = sh
@ -40,12 +36,13 @@ def parse_hash(pwhash_str: str) -> PWInfo:
if not len(pwhash) == 1 + SALT_LEN + KEY_LEN:
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:
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)

View File

@ -1,14 +1,14 @@
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
from .version import VERSION
from . import config
from . import pwhash
@ -21,80 +21,89 @@ def create_tls_context(certfile, keyfile) -> ssl.SSLContext:
def setup_logging(cfg: config.LogCfg):
logging_format = "%(asctime)s %(name)s %(levelname)s %(message)s @ %(filename)s:%(lineno)d"
if cfg.logfile == "STDOUT":
logging_format = (
"%(asctime)s %(name)s %(levelname)s %(message)s @ %(filename)s:%(lineno)d"
)
if cfg.logfile == "CONSOLE":
logging.basicConfig(level=cfg.level, format=logging_format)
else:
logging.basicConfig(filename=cfg.logfile, level=cfg.level, format=logging_format)
logging.basicConfig(
filename=cfg.logfile, level=cfg.level, format=logging_format
)
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:
logging.info(f"Initializing default tls {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":
return default_tls_context
elif tls == "disable":
if tls == "disable":
return None
else:
tls_cfg = config.TLSCfg(pop.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] = []
if cfg.pop:
pop = config.PopCfg(cfg.pop)
pop_server = await create_pop_server(
host=get_host(pop.host),
port=pop.port,
mails_path=Path(cfg.mails_path),
users=cfg.users,
ssl_context=get_tls_context(pop.tls),
timeout_seconds=pop.timeout_seconds,
)
servers.append(pop_server)
if not cfg.servers:
logging.warning("Nothing to do!")
return
if cfg.smtp_starttls:
stls = config.SmtpStartTLSCfg(cfg.smtp_starttls)
stls_context = get_tls_context(stls.tls)
if not stls_context:
raise Exception("starttls requires ssl_context")
smtp_server_starttls = await create_smtp_server_starttls(
host=get_host(stls.host),
port=stls.port,
mails_path=Path(cfg.mails_path),
mbox_finder=mbox_finder,
ssl_context=stls_context,
)
servers.append(smtp_server_starttls)
if cfg.smtp:
smtp = config.SmtpCfg(cfg.smtp)
smtp_server = await create_smtp_server(
host=get_host(smtp.host),
port=smtp.port,
mails_path=Path(cfg.mails_path),
mbox_finder=mbox_finder,
ssl_context=get_tls_context(smtp.tls),
)
servers.append(smtp_server)
for scfg in cfg.servers:
if scfg.server_type == "pop":
pop = config.PopCfg(scfg)
pop_server = await create_pop_server(
host=get_host(pop.host),
port=pop.port,
mails_path=Path(cfg.mails_path),
users=cfg.users,
ssl_context=get_tls_context(pop.tls),
timeout_seconds=pop.timeout_seconds,
)
servers.append(pop_server)
elif scfg.server_type == "smtp_starttls":
stls = config.SmtpStartTLSCfg(scfg)
stls_context = get_tls_context(stls.tls)
if not stls_context:
raise Exception("starttls requires ssl_context")
smtp_server_starttls = await create_smtp_server_starttls(
host=get_host(stls.host),
port=stls.port,
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":
smtp = config.SmtpCfg(scfg)
smtp_server = await create_smtp_server(
host=get_host(smtp.host),
port=smtp.port,
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:
logging.error(f"Unknown server {scfg.server_type=}")
if servers:
await asyncio.gather(*[server.serve_forever() for server in servers])
else:
logging.warn("Nothing to do!")
logging.warning("Nothing to do!")
def main() -> None:
@ -102,6 +111,7 @@ def main() -> None:
description="Personal Mail Server",
epilog="See https://gitea.balki.me/balki/mail4one for more info",
)
parser.add_argument("-v", "--version", action="version", version=VERSION)
parser.add_argument(
"-e",
"--echo_password",
@ -150,7 +160,7 @@ def main() -> None:
else:
cfg = config.Config(args.config.read_text())
setup_logging(config.LogCfg(cfg.logging))
logging.info(f"Starting mail4one {args.config=}")
logging.info(f"Starting mail4one {VERSION} {args.config=!s}")
asyncio.run(a_main(cfg))

View File

@ -1,23 +1,18 @@
import asyncio
import io
import logging
import mailbox
import ssl
import uuid
import shutil
from functools import partial
from pathlib import Path
from typing import Callable
from . import config
from typing import Callable, Optional
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,20 +20,21 @@ 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: SMTPServer, session: SMTPSession,
envelope: SMTPEnvelope) -> str:
async def handle_DATA(
self, server: SMTP, session: SMTPSession, envelope: SMTPEnvelope
) -> str:
self.rcpt_tos = envelope.rcpt_tos
self.peer = session.peer
return await super().handle_DATA(server, session, envelope)
async def handle_message(self, m: Message): # type: ignore[override]
async def handle_message(self, message: Message): # type: ignore[override]
all_mboxes: set[str] = set()
for addr in self.rcpt_tos:
for mbox in self.mbox_finder(addr.lower()):
@ -55,7 +51,7 @@ class MyHandler(AsyncMessage):
temp_email_path = Path(tmpdir) / filename
with open(temp_email_path, "wb") as fp:
gen = BytesGenerator(fp, policy=email.policy.SMTP)
gen.flatten(m)
gen.flatten(message)
for mbox in all_mboxes:
shutil.copy(temp_email_path, self.mails_path / mbox / "new")
logger.info(
@ -63,32 +59,37 @@ class MyHandler(AsyncMessage):
)
def protocol_factory_starttls(mails_path: Path,
mbox_finder: Callable[[str], list[str]],
context: ssl.SSLContext):
def protocol_factory_starttls(
mails_path: Path,
mbox_finder: Callable[[str], list[str]],
context: ssl.SSLContext,
require_starttls: bool,
smtputf8: bool,
):
logger.info("Got smtp client cb starttls")
try:
handler = MyHandler(mails_path, mbox_finder)
smtp = SMTP(
handler=handler,
require_starttls=True,
require_starttls=require_starttls,
tls_context=context,
enable_SMTPUTF8=True,
enable_SMTPUTF8=smtputf8,
)
except Exception as e:
logger.error("Something went wrong", e)
except:
logger.exception("Something went wrong")
raise
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)
except Exception as e:
logger.error("Something went wrong", e)
smtp = SMTP(handler=handler, enable_SMTPUTF8=smtputf8)
except:
logger.exception("Something went wrong")
raise
return smtp
@ -99,14 +100,22 @@ async def create_smtp_server_starttls(
mails_path: Path,
mbox_finder: Callable[[str], list[str]],
ssl_context: ssl.SSLContext,
require_starttls: bool,
smtputf8: bool,
) -> asyncio.Server:
logging.info(
f"Starting SMTP STARTTLS server {host=}, {port=}, {mails_path=}, {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,
@ -118,14 +127,15 @@ async def create_smtp_server(
port: int,
mails_path: Path,
mbox_finder: Callable[[str], list[str]],
ssl_context: ssl.SSLContext | None = None,
ssl_context: Optional[ssl.SSLContext],
smtputf8: bool,
) -> asyncio.Server:
logging.info(
f"Starting SMTP server {host=}, {port=}, {mails_path=}, {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
mail4one/version.py Normal file
View File

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

View File

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

3
run.py
View File

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

29
scripts/get_version.sh Executable file
View File

@ -0,0 +1,29 @@
#!/bin/sh
commit=$(git rev-parse --short HEAD)
# This is true if there is a tag on current HEAD
if git describe --exact-match > /dev/null 2>&1
then
tag_val=$(git describe --dirty=DIRTY --exact-match)
case "$tag_val" in
*DIRTY)
echo "git-$commit-changes"
;;
v*) # Only consider tags starting with v
echo "$tag_val"
;;
*)
echo "git-$commit"
esac
else
tag_val=$(git describe --dirty=DIRTY)
case "$tag_val" in
*DIRTY)
echo "git-$commit-changes"
;;
*)
echo "git-$commit"
esac
fi

View File

@ -67,10 +67,11 @@ 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,20 +4,29 @@ 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(c for c in """
TEST_HASH = "".join(
"""
AFTY5EVN7AX47ZL7UMH3BETYWFBTAV3XHR73CEFAJBPN2NIHPWD
ZHV2UQSMSPHSQQ2A2BFQBNC77VL7F2UKATQNJZGYLCSU6C43UQD
AQXWXSWNGAEPGIMG2F3QDKBXL3MRHY6K2BPID64ZR6LABLPVSF
""" if not c.isspace())
""".split()
)
TEST_USER = 'foobar'
TEST_MBOX = 'foobar_mails'
TEST_USER = "foobar"
TEST_MBOX = "foobar_mails"
USERS = [User(username=TEST_USER, password_hash=TEST_HASH, mbox=TEST_MBOX)]
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),
]
MAILS_PATH: Path
@ -38,7 +47,8 @@ Hello bro\r
IlzVOJqu9Zp7twFAtzcV\r
yQVk36B0mGU2gtWxXLr\r
PeF0RtbI0mAuVPLQDHCi\r
\r\n"""
\r
"""
def setUpModule() -> None:
@ -47,13 +57,21 @@ def setUpModule() -> None:
td = tempfile.TemporaryDirectory(prefix="m41.pop.")
unittest.addModuleCleanup(td.cleanup)
MAILS_PATH = Path(td.name)
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:
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:
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)
@ -65,13 +83,14 @@ 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)
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 = """
@ -115,8 +134,9 @@ 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)
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
@ -197,18 +217,36 @@ 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")
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:
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:
@ -222,5 +260,5 @@ class TestPop3(unittest.IsolatedAsyncioTestCase):
self.assertEqual(data, resp)
if __name__ == '__main__':
if __name__ == "__main__":
unittest.main()

View File

@ -10,27 +10,31 @@ 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,7 +32,10 @@ class TestSMTP(unittest.IsolatedAsyncioTestCase):
host="127.0.0.1",
port=7996,
mails_path=MAILS_PATH,
mbox_finder=lambda addr: [TEST_MBOX])
mbox_finder=lambda addr: [TEST_MBOX],
ssl_context=None,
smtputf8=True,
)
self.task = asyncio.create_task(smtp_server.serve_forever())
async def test_send_mail(self) -> None:
@ -45,8 +48,9 @@ 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
@ -62,7 +66,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())