Compare commits

..

10 Commits

25 changed files with 883 additions and 301 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 # 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 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
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/mail4one/__pycache__
rm -rf build/*.dist-info rm -rf build/*.dist-info
python3 -m zipapp \ python3 -m zipapp \
@ -13,58 +11,35 @@ mail4one.pyz: requirements.txt mail4one/*py
--main mail4one.server:main \ --main mail4one.server:main \
--compress build --compress build
.PHONY: build
build: clean mail4one.pyz
.PHONY: test
test: mail4one.pyz
PYTHONPATH=mail4one.pyz python3 -m unittest discover
.PHONY: clean
clean: clean:
rm -rf build rm -rf build
rm -rf mail4one.pyz rm -rf mail4one.pyz
.PHONY: docker-tests
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.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.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.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.10 sh scripts/runtests.sh
docker run --pull=always -v `pwd`:/app -w /app --rm python:3.9 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 requirements.txt: Pipfile.lock
pipenv requirements > requirements.txt pipenv requirements > requirements.txt
.PHONY: format
format: format:
black mail4one/*py tests/*py black mail4one/*py
.PHONY: build-dev
build-dev: requirements.txt build build-dev: requirements.txt build
.PHONY: setup
setup: setup:
pipenv install pipenv install
.PHONY: cleanup
cleanup: cleanup:
pipenv --rm pipenv --rm
.PHONY: update
update:
rm requirements.txt Pipfile.lock
pipenv update
pipenv requirements > requirements.txt
.PHONY: shell
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 test:
dev-test:
pipenv run python -m unittest discover pipenv run python -m unittest discover

View File

@ -8,6 +8,7 @@ name = "pypi"
[packages] [packages]
aiosmtpd = "*" aiosmtpd = "*"
python-jata = "*" python-jata = "*"
asyncbasehttp = "*"
[requires] [requires]
python_version = "3" python_version = "3"

30
Pipfile.lock generated
View File

@ -1,7 +1,7 @@
{ {
"_meta": { "_meta": {
"hash": { "hash": {
"sha256": "5c16e20a67c73101d465516d657e21c6c1d3f853ae16dcfe782b7f9e8ba139e5" "sha256": "6b224d3d72187a39e4fba357ffb4b162dc28994bd869c7960fcfd5acbb56c959"
}, },
"pipfile-spec": 6, "pipfile-spec": 6,
"requires": { "requires": {
@ -18,28 +18,35 @@
"default": { "default": {
"aiosmtpd": { "aiosmtpd": {
"hashes": [ "hashes": [
"sha256:78d7b14f859ad0e6de252b47f9cf1ca6f1c82a8b0f10a9e39bec7e915a6aa5fe", "sha256:f821fe424b703b2ea391dc2df11d89d2afd728af27393e13cf1a3530f19fdc5e",
"sha256:a196922f1903e54c4d37c53415b7613056d39e2b1e8249f324b9ee7a439be0f1" "sha256:f9243b7dfe00aaf567da8728d891752426b51392174a34d2cf5c18053b63dcbc"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.8'", "version": "==1.4.4.post2"
"version": "==1.4.5" },
"asyncbasehttp": {
"hashes": [
"sha256:2cea7b61113bb1e69d1c90f32919f004e7f56a7e1e2955e8a6286ce40f9da188",
"sha256:70fb983c4df630a9b63081cb8ef6d2e06beb51c89f42a88da6c9a9c7ebf9fb89"
],
"index": "pypi",
"version": "==1.0"
}, },
"atpublic": { "atpublic": {
"hashes": [ "hashes": [
"sha256:d1c8cd931af7461f6d18bc6063383e8654d9e9ef19d58ee6dc01e8515bbf55df", "sha256:0f40433219e124edf115c6c363808ca6f0e1cfa7d160d86b2fb94793086d1294",
"sha256:df90de1162b1a941ee486f484691dc7c33123ee638ea5d6ca604061306e0fdde" "sha256:80057c55641253b86dcb68b524f82328172371b6547d4c7462a9127fbfbbabfc"
], ],
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==4.1.0" "version": "==4.0"
}, },
"attrs": { "attrs": {
"hashes": [ "hashes": [
"sha256:935dc3b529c262f6cf76e50877d35a4bd3c1de194fd41f47a2b7ae8f19971f30", "sha256:1f28b4522cdc2fb4256ac1a020c78acf9cba2c6b461ccd2c126f3aa8e8335d04",
"sha256:99b87a485a5820b23b879f04c2305b44b951b502fd64be915879d77a7e8fc6f1" "sha256:6279836d581513a26f1bf235f9acd333bc9115683f14f7e8fae46c98fc50e015"
], ],
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.7'",
"version": "==23.2.0" "version": "==23.1.0"
}, },
"python-jata": { "python-jata": {
"hashes": [ "hashes": [
@ -47,7 +54,6 @@
"sha256:ff4cd7ca75c9a8306b69ef6e878c296a5602f3279c6f9a82b6105b8eba764760" "sha256:ff4cd7ca75c9a8306b69ef6e878c296a5602f3279c6f9a82b6105b8eba764760"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.8'",
"version": "==1.2" "version": "==1.2"
} }
}, },

View File

@ -1,6 +1,6 @@
# Mail4one # 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. Designed for dynamic alias based workflow where a different alias is used for each purpose.
# Getting started # 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. 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 # Community
@ -62,7 +62,7 @@ This should generate `mail4one.pyz` in current folder. This is a [executable pyt
* Write dedicated documentation * Write dedicated documentation
* Test with more email clients ([Thunderbird](https://www.thunderbird.net/) and [k9mail](https://k9mail.app/) are tested now) * Test with more email clients ([Thunderbird](https://www.thunderbird.net/) and [k9mail](https://k9mail.app/) are tested now)
* IMAP support * 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 * Support email submission from client to forward to other senders or direct delivery
* Optional SPAM filtering * Optional SPAM filtering
* Optional DMARC,SPF,DKIM verification * 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. Above command should fail as the TLS certificates don't exist yet.
## Setup TLS certificates ## 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 ```sh
sudo certbot certonly 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 cp mail4one_cert_copy.sh /etc/letsencrypt/renewal-hooks/deploy/
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/mail4one_cert_copy.sh 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 ## Restart service and check logs
```sh ```sh
@ -110,6 +109,6 @@ python3 -m http.server 25
In local machine or a browser 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 ```sh
curl http://mail.mydomain.com:25 curl http://mail.example.com:25
``` ```
If not working, refer to VPS settings and OS firewall settings. 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. # certbot deploy hook to copy certificates to mail4one when renewed.
# Initial setup, Install certbot(https://certbot.eff.org/) and run `certbot certonly` as root # 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/ # 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
set -eu set -x
if [ "$RENEWED_DOMAINS" = "mail.mydomain.com" ] if [ "$RENEWED_DOMAINS" = "mail.mydomain.com" ]
then then
app=mail4one mkdir -p /var/lib/mail4one/certs
appuser=$app chmod 750 /var/lib/mail4one/certs
certpath="/var/lib/$app/certs" chown mail4one:mail4one /var/lib/mail4one/certs
cp "$RENEWED_LINEAGE/fullchain.pem" /var/lib/mail4one/certs/
mkdir -p "$certpath" cp "$RENEWED_LINEAGE/privkey.pem" /var/lib/mail4one/certs/
chmod 750 "$certpath" systemctl restart mail4one.service
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 fi

1
jsdev/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
tmp

44
jsdev/air.toml Normal file
View File

@ -0,0 +1,44 @@
root = "."
testdata_dir = "testdata"
tmp_dir = "tmp"
[build]
args_bin = []
bin = "./tmp/main"
cmd = "cp ./restart.sh ./tmp/main"
delay = 0
exclude_dir = ["assets", "tmp", "vendor", "testdata"]
exclude_file = []
exclude_regex = ["_test.go"]
exclude_unchanged = false
follow_symlink = false
full_bin = ""
include_dir = []
include_ext = ["go", "tpl", "tmpl", "html"]
include_file = []
kill_delay = "0s"
log = "build-errors.log"
poll = false
poll_interval = 0
rerun = false
rerun_delay = 500
send_interrupt = false
stop_on_error = false
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
main_only = false
time = false
[misc]
clean_on_exit = false
[screen]
clear_on_rebuild = false
keep_scroll = true

473
jsdev/index.html Normal file
View File

@ -0,0 +1,473 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="data:;base64,iVBORw0KGgo=">
<title>Mail4one Web config</title>
<script type="application/json" id="m41config">
{
"matches": [
{
"name": "mydomain",
"addr_rexs": [
".*@mydomain.com",
".*@m.mydomain.com"
]
},
{
"name": "personal",
"addrs": [
"first.last@mydomain.com",
"secret.name@mydomain.com"
]
}
],
"boxes": [
{
"name": "spam",
"rules": [
{
"match_name": "mydomain",
"negate": true,
"stop_check": true
}
]
},
{
"name": "important",
"rules": [
{
"match_name": "personal"
}
]
},
{
"name": "all",
"rules": [
{
"match_name": "default_match_all"
}
]
}
],
"users": [
{
"username": "mymobile",
"password_hash": "AFTY5EVN7AX47ZL7UMH3BETYWFBTAV3XHR73CEFAJBPN2NIHPWDZHV2UQSMSPHSQQ2A2BFQBNC77VL7F2UKATQNJZGYLCSU6C43UQDAQXWXSWNGAEPGIMG2F3QDKBXL3MRHY6K2BPID64ZR6LABLPVSF",
"mbox": "important"
},
{
"username": "mydesk",
"password_hash": "AFTY5EVN7AX47ZL7UMH3BETYWFBTAV3XHR73CEFAJBPN2NIHPWDZHV2UQSMSPHSQQ2A2BFQBNC77VL7F2UKATQNJZGYLCSU6C43UQDAQXWXSWNGAEPGIMG2F3QDKBXL3MRHY6K2BPID64ZR6LABLPVSF",
"mbox": "all"
}
]
}
</script>
<style>
td {
padding: 5px;
}
table {
border: 2px solid;
}
thead {
background-color: orange;
}
h1.page-title {
background-color: orange;
}
h3 {
text-align: center;
}
.outer {
display: flex;
justify-content: center;
background: grey;
}
.inner {
display: flex;
flex-direction: column;
align-items: center;
border: 2px solid;
background-color: lightyellow;
padding: 10px;
justify-content: space-around;
margin: 10px;
width: 1200px;
}
.m41-box {
display: grid;
grid-template-columns: 1fr 3fr;
margin: 10px;
// background-color: lightblue;
}
.multiline {
text-align: left;
padding: 5px;
padding-left: 15px;
}
#web-cfg-matches {
width: 90%;
text-align: center;
}
#web-cfg-matches tbody tr:nth-of-type(odd) {
background: lightblue;
}
#web-cfg-matches tbody tr:nth-of-type(even) {
background: lightgrey;
}
#web-cfg-boxes {
text-align: center;
width: 90%;
// display: grid;
// grid-template-columns: 1fr ;
//justify-content: space-around;
// justify-content: flex-start;
// background-color: blue;
}
#web-cfg-boxes tbody:nth-of-type(odd) {
background: lightblue;
}
#web-cfg-boxes tbody:nth-of-type(even) {
background: lightgrey;
}
#web-cfg-boxes tbody tr td div.dummy{
display: none;
}
#web-cfg-boxes tbody tr td div button.dummy {
display: none;
}
#web-cfg-boxes tbody:first-of-type tr td.box div button.up.dummy {
display: block;
}
#web-cfg-boxes tbody:first-of-type tr td.box div button.up.real {
display: none;
}
#web-cfg-boxes tbody:last-of-type tr td.box div button.down.dummy {
display: block;
}
#web-cfg-boxes tbody:last-of-type tr td.box div button.down.real {
display: none;
}
#web-cfg-boxes tbody:only-of-type tr td.box div.real {
display: none;
}
#web-cfg-boxes tbody:only-of-type tr td.box div.dummy {
display: flex;
}
td.box {
display: flex;
justify-content: center;
}
.button-group {
display: flex;
justify-content: center;
}
</style>
<script type="application/javascript">
"use strict"
// Globals
let server_config
let matches_table
let match_row_template
let boxes_table
let box_template
// let rule_template
function initGlobals() {
server_config = JSON.parse(document.getElementById('m41config').text)
matches_table = document.getElementById("web-cfg-matches")
match_row_template = document.getElementById("web-cfg-matches-row")
boxes_table = document.getElementById("web-cfg-boxes")
box_template = document.getElementById("web-cfg-box")
// rule_template = document.getElementById("web-cfg-boxes-rule-li")
}
function populate_match_table(matches_config) {
for (const { name: match_name, addrs, addr_rexs } of matches_config) {
const [match_type, match_values] = (() => {
if (addrs != undefined) {
return ["addrs", addrs]
} else {
return ["addr_rexs", addr_rexs]
}
})()
addMatchRow()
const last_row = matches_table.tBodies[0].lastElementChild
const [ ,name_cell, {firstElementChild: type_select}, value_cell ] = last_row.cells
name_cell.innerText = match_name
type_select.value = match_type
value_cell.innerText = match_values.join("\n")
}
}
function extract_match_table() {
let matches = []
for (let row of matches_table.tBodies[0].rows) {
const [ ,name_cell, {firstElementChild: type_select}, value_cell ] = row.cells
let m = {"name" : name_cell.innerText}
switch (type_select.value) {
case "addrs":
m["addrs"] = value_cell.innerText.split("\n")
break
case "addr_rexs":
m["addr_rexs"] = value_cell.innerText.split("\n")
break
}
matches.push(m)
}
return matches
}
function addMatchRow() {
let row_clone = match_row_template.content.cloneNode(true)
matches_table.tBodies[0].appendChild(row_clone)
}
function populate_boxes_list(boxes_config) {
for (const {name:box_name, rules} of boxes_config) {
addBox()
const tbody = boxes_table.lastElementChild
const box = tbody.firstElementChild
// console.log(box)
const [,
{children: [box_text, ]},
,
{firstElementChild: match_select},
{firstElementChild: negate_check},
{firstElementChild: stop_check},
] = box.children
box_text.value = box_name
const [first_rule, ...rest] = rules
const {match_name, negate = false, stop = false} = first_rule
match_select.value = match_name
negate_check.checked = negate
stop_check.checked = stop
for (const {match_name, negate = false, stop_check = false} of rest ) {
addRule(box)
const rule = tbody.lastElementChild
const [,
{firstElementChild: match_select},
{firstElementChild: negateCheck},
{firstElementChild: stopCheck}
] = rule.children
match_select.value = match_name
negate_check.checked = negate
stop_check.checked = stop
}
}
}
function addRule(box) {
box.parentElement.appendChild(box.cloneNode(true))
box.children[0].rowSpan++;
box.children[1].rowSpan++;
const newrule = box.parentElement.lastElementChild
newrule.removeClass("fist-tr")
newrule.firstElementChild.remove()
newrule.firstElementChild.remove()
}
function addBox() {
let box_clone = box_template.content.cloneNode(true)
boxes_table.appendChild(box_clone)
}
function moveUp(button) {
const li = button.parentElement.parentElement.parentElement
if (li.previousElementSibling != null) {
li.parentNode.insertBefore(li, li.previousElementSibling)
}
}
function moveDown(button) {
const li = button.parentElement.parentElement.parentElement
if (li.nextElementSibling != null) {
li.parentNode.insertBefore(li.nextElementSibling, li)
}
}
function main() {
initGlobals()
populate_match_table(server_config["matches"])
save()
document.getElementById("before").innerText = JSON.stringify(server_config["matches"], null, 2)
populate_boxes_list(server_config["boxes"])
}
function save() {
const matches = extract_match_table()
document.getElementById("after").innerText = JSON.stringify(matches, null, 2)
}
</script>
</head>
<body onload="main()">
<template id="web-cfg-matches-row">
<tr>
<td><button onClick="this.parentElement.parentElement.remove()"></button></td>
<td contentEditable></td>
<td>
<select>
<option value="addrs">List of addresses</option>
<option value="addr_rexs">List of regexes for addresses</option>
</select>
</td>
<td contentEditable class="multiline"></td>
</tr>
</template>
<template id="web-cfg-box">
<tbody>
<tr>
<td class="box">
<div class="button-group dummy">
<button disabled></button>
<button disabled></button>
<button disabled></button>
</div>
<div class="button-group real">
<button onClick="this.parentElement.parentElement.parentElement.parentElement.remove()"></button>
<button onClick="moveBoxUp(this)" class="real up"></button>
<button disabled class="dummy up"></button>
<button onClick="moveBoxDown(this)" class="real down"></button>
<button disabled class="dummy down"></button>
</div>
<button onClick="addRule(this.parentElement.parentElement)">+</button>
</td>
<td contentEditable>
<input type="text" contentEditable>
</td>
<td class="rule">
<div class="button-group dummy">
<button disabled></button>
<button disabled></button>
<button disabled></button>
</div>
<div class="button-group real">
<button onClick="this.parentElement.parentElement.parentElement.remove()"></button>
<button onClick="moveRuleUp(this)" class="real up"></button>
<button disabled class="dummy up"></button>
<button onClick="moveRuleDown(this)" class="real down"></button>
<button disabled class="dummy down"></button>
</div>
</td>
<td>
<select></select>
</td>
<td>
<input type=checkbox>
</td>
<td>
<input type=checkbox>
</td>
</tr>
</tbody>
</template>
<div class="outer">
<div class="inner">
<h1 class="page-title">Mail4one Web config</h1>
<div id="top-menu">
<button onClick="save()">Previous</button>
<button onClick="save()">Matches</button>
<button onClick="save()">Boxes</button>
<button onClick="save()">Users</button>
<button onClick="save()">JSON</button>
<button onClick="save()">Next</button>
</div>
<div id="match-page">
<h3> Matches </h3>
<table id="web-cfg-matches">
<thead>
<tr>
<th>
<button onClick="addMatchRow()">+</button>
</th>
<th>Match</th>
<th>Type</th>
<th>Values</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
<div id=box-page">
<h3>Boxes</h3>
<table id="web-cfg-boxes">
<thead>
<tr>
<th><button onClick="addBox()">+</button></th>
<th>Mailbox</th>
<th></th>
<th>Match</th>
<th>Invert</th>
<th>Stop</th>
</tr>
</thead>
</table>
</div>
<hr>
<h3>Before</h3>
<pre id="before"></pre>
<hr>
<h3>After</h3>
<pre id="after"></pre>
<hr>
</div>
</div>
</body>
</html>

11
jsdev/restart.sh Executable file
View File

@ -0,0 +1,11 @@
#!/bin/sh
# kill "$(cat current.pid)"
# echo "starting python server"
python3 -m http.server
# echo $! > current.pid
# echo "started python server"

View File

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

View File

@ -2,15 +2,18 @@ import asyncio
import contextlib import contextlib
import contextvars import contextvars
import logging import logging
import os
import ssl import ssl
import random import uuid
from typing import Optional
from asyncio import StreamReader, StreamWriter
from dataclasses import dataclass from dataclasses import dataclass
from hashlib import sha256
from pathlib import Path from pathlib import Path
from .config import User from .config import User
from .pwhash import parse_hash, check_pass, PWInfo from .pwhash import parse_hash, check_pass, PWInfo
from asyncio import StreamReader, StreamWriter
import random
from typing import Optional
from .poputils import ( from .poputils import (
InvalidCommand, InvalidCommand,
@ -26,7 +29,7 @@ from .poputils import (
end, end,
Request, Request,
MailEntry, MailEntry,
get_mail_fp, get_mail,
get_mails_list, get_mails_list,
MailList, MailList,
) )
@ -72,14 +75,14 @@ class PopLogger(logging.LoggerAdapter):
def __init__(self): def __init__(self):
super().__init__(logging.getLogger("pop3"), None) super().__init__(logging.getLogger("pop3"), None)
def process(self, log_msg, kwargs): def process(self, msg, kwargs):
st: State = c_state.get(None) state: State = c_state.get(None)
if not st: if not state:
return super().process(log_msg, kwargs) return super().process(msg, kwargs)
user = "NA" user = "NA"
if st.username: if state.username:
user = st.username user = state.username
return super().process(f"{st.ip} {st.req_id} {user} {log_msg}", kwargs) return super().process(f"{state.ip} {state.req_id} {user} {msg}", kwargs)
logger = PopLogger() logger = PopLogger()
@ -101,6 +104,7 @@ async def next_req() -> Request:
if request.cmd == Command.QUIT: if request.cmd == Command.QUIT:
raise ClientQuit raise ClientQuit
return request return request
else:
raise ClientError(f"Bad command {InvalidCommand.RETRIES} times") raise ClientError(f"Bad command {InvalidCommand.RETRIES} times")
@ -149,22 +153,24 @@ async def auth_stage() -> None:
write(ok("Following are supported")) write(ok("Following are supported"))
write(msg("USER")) write(msg("USER"))
write(end()) write(end())
continue else:
await handle_user_pass_auth(req) await handle_user_pass_auth(req)
if state().username in scfg().loggedin_users: if state().username in scfg().loggedin_users:
logger.warning( logger.warning(
f"User: {state().username} already has an active session" f"User: {state().username} already has an active session"
) )
raise AuthError("Already logged in") raise AuthError("Already logged in")
else:
scfg().loggedin_users.add(state().username) scfg().loggedin_users.add(state().username)
write(ok("Login successful")) write(ok("Login successful"))
return return
except AuthError as ae: except AuthError as ae:
write(err(f"Auth Failed: {ae}")) write(err(f"Auth Failed: {ae}"))
except ClientQuit: except ClientQuit as c:
write(ok("Bye")) write(ok("Bye"))
logger.warning("Client has QUIT before auth succeeded") logger.warning("Client has QUIT before auth succeeded")
raise raise
else:
raise ClientError("Failed to authenticate") raise ClientError("Failed to authenticate")
@ -211,12 +217,7 @@ def trans_command_retr(mails: MailList, req: Request) -> None:
entry = mails.get(req.arg1) entry = mails.get(req.arg1)
if entry: if entry:
write(ok("Contents follow")) write(ok("Contents follow"))
with get_mail_fp(entry) as fp: write(get_mail(entry))
for line in fp:
if line.startswith(b"."):
write(b".") # prepend dot
write(line)
# write(get_mail(entry)) # no prepend dot
write(end()) write(end())
mails.delete(req.arg1) mails.delete(req.arg1)
else: else:
@ -266,6 +267,7 @@ async def process_transactions(mails_list: list[MailEntry]) -> set[str]:
except KeyError: except KeyError:
write(err("Not implemented")) write(err("Not implemented"))
raise ClientError("We shouldn't reach here") raise ClientError("We shouldn't reach here")
else:
func(mails, req) func(mails, req)
await state().writer.drain() await state().writer.drain()
@ -298,7 +300,7 @@ async def transaction_stage() -> None:
deleted_items_path, existing_deleted_items.union(new_deleted_items) 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: async def start_session() -> None:
@ -309,10 +311,12 @@ async def start_session() -> None:
assert state().mbox assert state().mbox
await transaction_stage() await transaction_stage()
logger.info(f"User:{state().username} done") logger.info(f"User:{state().username} done")
except ClientDisconnected: except ClientDisconnected as c:
logger.info("Client disconnected") logger.info("Client disconnected")
pass
except ClientQuit: except ClientQuit:
logger.info("Client QUIT") logger.info("Client QUIT")
pass
except ClientError as c: except ClientError as c:
write(err("Something went wrong")) write(err("Something went wrong"))
logger.error(f"Unexpected client error: {c}") logger.error(f"Unexpected client error: {c}")
@ -335,13 +339,13 @@ def parse_users(users: list[User]) -> dict[str, tuple[PWInfo, str]]:
def make_pop_server_callback(mails_path: Path, users: list[User], timeout_seconds: int): 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)) 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(s_state) c_shared_state.set(scfg)
ip, _ = writer.get_extra_info("peername") ip, _ = writer.get_extra_info("peername")
c_state.set(State(reader=reader, writer=writer, ip=ip, req_id=s_state.next_id())) c_state.set(State(reader=reader, writer=writer, ip=ip, req_id=scfg.next_id()))
logger.info("Got pop server callback") logger.info(f"Got pop server callback")
try: try:
try: try:
return await asyncio.wait_for(start_session(), timeout_seconds) return await asyncio.wait_for(start_session(), timeout_seconds)
@ -363,7 +367,7 @@ async def create_pop_server(
timeout_seconds: int = 60, timeout_seconds: int = 60,
) -> asyncio.Server: ) -> asyncio.Server:
logging.info( 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( return await asyncio.start_server(
make_pop_server_callback(mails_path, users, timeout_seconds), make_pop_server_callback(mails_path, users, timeout_seconds),
@ -382,14 +386,13 @@ def debug_main():
logging.basicConfig(level=logging.DEBUG) logging.basicConfig(level=logging.DEBUG)
import sys import sys
from .pwhash import gen_pwhash
_, mails_path, mbox = sys.argv _, mails_path, port, password = sys.argv
mails_path = Path(mails_path) 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__": if __name__ == "__main__":

View File

@ -2,7 +2,6 @@ import os
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum, auto from enum import Enum, auto
from pathlib import Path from pathlib import Path
from contextlib import contextmanager
class ClientError(Exception): class ClientError(Exception):
@ -20,10 +19,12 @@ class ClientDisconnected(ClientError):
class InvalidCommand(ClientError): class InvalidCommand(ClientError):
RETRIES = 3 RETRIES = 3
"""WIll allow NUM_BAD_COMMANDS times""" """WIll allow NUM_BAD_COMMANDS times"""
pass
class AuthError(ClientError): class AuthError(ClientError):
RETRIES = 3 RETRIES = 3
pass
class Command(Enum): class Command(Enum):
@ -123,12 +124,6 @@ def set_nid(entries: list[MailEntry]):
entry.nid = i 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: def get_mail(entry: MailEntry) -> bytes:
with open(entry.path, mode="rb") as fp: with open(entry.path, mode="rb") as fp:
return fp.read() return fp.read()

View File

@ -16,6 +16,8 @@ VERSION = b"\x01"
SALT_LEN = 30 SALT_LEN = 30
KEY_LEN = 64 # This is python default KEY_LEN = 64 # This is python default
# len(VERSION) + SALT_LEN + KEY_LEN should be multiple of 5 to avoid base32 padding
def gen_pwhash(password: str) -> str: def gen_pwhash(password: str) -> str:
salt = os.urandom(SALT_LEN) salt = os.urandom(SALT_LEN)

View File

@ -1,17 +1,20 @@
import asyncio import asyncio
import logging import logging
import os
import ssl import ssl
import sys
from argparse import ArgumentParser from argparse import ArgumentParser
from pathlib import Path from pathlib import Path
from getpass import getpass from getpass import getpass
from typing import Optional, Union
from .smtp import create_smtp_server_starttls, create_smtp_server from .smtp import create_smtp_server_starttls, create_smtp_server
from .pop3 import create_pop_server from .pop3 import create_pop_server
from .version import VERSION from .version import VERSION
from .web_config import create_web_config_server
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:
@ -42,14 +45,16 @@ async def a_main(cfg: config.Config) -> None:
def get_tls_context(tls: Union[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
if tls == "disable": elif tls == "disable":
return None return None
else:
tls_cfg = config.TLSCfg(tls) tls_cfg = config.TLSCfg(tls)
return create_tls_context(tls_cfg.certfile, tls_cfg.keyfile) return create_tls_context(tls_cfg.certfile, tls_cfg.keyfile)
def get_host(host): def get_host(host):
if host == "default": if host == "default":
return cfg.default_host return cfg.default_host
else:
return host return host
mbox_finder = config.gen_addr_to_mboxes(cfg) mbox_finder = config.gen_addr_to_mboxes(cfg)
@ -82,8 +87,6 @@ async def a_main(cfg: config.Config) -> None:
mails_path=Path(cfg.mails_path), mails_path=Path(cfg.mails_path),
mbox_finder=mbox_finder, mbox_finder=mbox_finder,
ssl_context=stls_context, ssl_context=stls_context,
require_starttls=stls.require_starttls,
smtputf8=stls.smtputf8,
) )
servers.append(smtp_server_starttls) servers.append(smtp_server_starttls)
elif scfg.server_type == "smtp": elif scfg.server_type == "smtp":
@ -94,9 +97,16 @@ async def a_main(cfg: config.Config) -> None:
mails_path=Path(cfg.mails_path), mails_path=Path(cfg.mails_path),
mbox_finder=mbox_finder, mbox_finder=mbox_finder,
ssl_context=get_tls_context(smtp.tls), ssl_context=get_tls_context(smtp.tls),
smtputf8=smtp.smtputf8,
) )
servers.append(smtp_server) servers.append(smtp_server)
elif scfg.server_type == "web_config":
web = config.ServerCfg(scfg)
web_server = await create_web_config_server(
host=get_host(web.host),
port=web.port,
ssl_context=get_tls_context(web.tls),
)
servers.append(web_server)
else: else:
logging.error(f"Unknown server {scfg.server_type=}") logging.error(f"Unknown server {scfg.server_type=}")

View File

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

View File

@ -0,0 +1,88 @@
<!doctype html>
<html>
<head>
<title>Mail4one Web config</title>
<script type="application/json" id="m41config">
{
"matches": [
{
"name": "mydomain",
"addr_rexs": [
".*@mydomain.com",
".*@m.mydomain.com"
]
},
{
"name": "personal",
"addrs": [
"first.last@mydomain.com",
"secret.name@mydomain.com"
]
}
],
"boxes": [
{
"name": "spam",
"rules": [
{
"match_name": "mydomain",
"negate": true,
"stop_check": true
}
]
},
{
"name": "important",
"rules": [
{
"match_name": "personal"
}
]
},
{
"name": "all",
"rules": [
{
"match_name": "default_match_all"
}
]
}
],
"users": [
{
"username": "mymobile",
"password_hash": "AFTY5EVN7AX47ZL7UMH3BETYWFBTAV3XHR73CEFAJBPN2NIHPWDZHV2UQSMSPHSQQ2A2BFQBNC77VL7F2UKATQNJZGYLCSU6C43UQDAQXWXSWNGAEPGIMG2F3QDKBXL3MRHY6K2BPID64ZR6LABLPVSF",
"mbox": "important"
},
{
"username": "mydesk",
"password_hash": "AFTY5EVN7AX47ZL7UMH3BETYWFBTAV3XHR73CEFAJBPN2NIHPWDZHV2UQSMSPHSQQ2A2BFQBNC77VL7F2UKATQNJZGYLCSU6C43UQDAQXWXSWNGAEPGIMG2F3QDKBXL3MRHY6K2BPID64ZR6LABLPVSF",
"mbox": "all"
}
]
}
</script>
<script type="application/javascript">
function main() {
document.write("hello world")
}
</script>
</head>
<body onload="main()">
<template id="web-cfg-matches-row">
<tr>
<td>Name</td>
<td>Type</td>
<td>Values</td>
</tr>
</template>
<h1>Mail4one Web config</h1>
<table id="web-cfg-matches">
<tr>
<th>Name</th>
<th>Type</th>
<th>Values</th>
</tr>
</table>
</body>
</html>

80
mail4one/web_config.py Normal file
View File

@ -0,0 +1,80 @@
import asyncio
from asyncbasehttp import request_handler, Request, Response, RequestHandler
from typing import Optional, Tuple
import ssl
import logging
from pprint import pprint
import http
from base64 import b64decode
from .pwhash import gen_pwhash, parse_hash, PWInfo, check_pass
import pkgutil
def get_template() -> bytes:
if data:= pkgutil.get_data('mail4one', 'template_web_config.html'):
return data
raise Exception("Failed to get template data from 'template_web_config.html'")
def get_dummy_pwinfo() -> PWInfo:
pwhash = gen_pwhash("world")
return parse_hash(pwhash)
class WebonfigHandler(RequestHandler):
def __init__(self, username: str, pwinfo: PWInfo):
self.username = username.encode()
self.pwinfo = pwinfo
self.auth_required = True
def do_auth(self, req: Request) -> Tuple[bool, Optional[Response]]:
def resp_unauthorized():
resp = Response.no_body_response(http.HTTPStatus.UNAUTHORIZED)
resp.add_header("WWW-Authenticate", 'Basic realm="Mail4one"')
return resp
auth_header = req.headers["Authorization"]
if not auth_header:
return False, resp_unauthorized()
if not auth_header.startswith("Basic "):
logging.error("Authorization header malformed")
return False, Response.no_body_response(http.HTTPStatus.BAD_REQUEST)
userpassb64 = auth_header[len("Basic ") :]
try:
userpass = b64decode(userpassb64)
username, password = userpass.split(b":")
except:
logging.exception("bad request")
return False, Response.no_body_response(http.HTTPStatus.BAD_REQUEST)
if username == self.username and check_pass(password.decode(), self.pwinfo):
return True, None
return False, resp_unauthorized()
async def process_request(self, req: Request) -> Response:
if self.auth_required:
ok, resp = self.do_auth(req)
if not ok:
if resp:
return resp
else: # To silence mypy
raise Exception("Something went wrong!")
return Response.create_ok_response(get_template())
async def create_web_config_server(
host: str, port: int, ssl_context: Optional[ssl.SSLContext]
) -> asyncio.Server:
logging.info(f"template: {get_template().decode()}")
logging.info(f"Starting Webconfig server {host=}, {port=}, {ssl_context != None=}")
return await asyncio.start_server(
WebonfigHandler("hello", get_dummy_pwinfo()),
host=host,
port=port,
ssl=ssl_context,
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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