Compare commits
10 Commits
Author | SHA1 | Date | |
---|---|---|---|
d90a3218b2 | |||
b73ab237e3 | |||
1e045964fa | |||
b159820f6c | |||
690484a097 | |||
fe5b9e1f25 | |||
71f84cd0a9 | |||
e4752fd039 | |||
117c93deaf | |||
6d0040415b |
38
DEVNOTES.md
38
DEVNOTES.md
@ -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"
|
||||
```
|
33
Makefile
33
Makefile
@ -1,10 +1,8 @@
|
||||
# Needs python3 >= 3.9, sed, git for build
|
||||
mail4one.pyz: requirements.txt mail4one/*py
|
||||
build: clean
|
||||
python3 -m pip install -r requirements.txt --no-compile --target build
|
||||
cp -r mail4one/ build/
|
||||
sed -i "s/DEVELOMENT/$(shell scripts/get_version.sh)/" build/mail4one/version.py
|
||||
find build -name "*.pyi" -o -name "py.typed" | xargs -I typefile rm typefile
|
||||
rm -rf build/bin build/aiosmtpd/{docs,tests,qa}
|
||||
rm -rf build/mail4one/__pycache__
|
||||
rm -rf build/*.dist-info
|
||||
python3 -m zipapp \
|
||||
@ -13,58 +11,35 @@ mail4one.pyz: requirements.txt mail4one/*py
|
||||
--main mail4one.server:main \
|
||||
--compress build
|
||||
|
||||
.PHONY: build
|
||||
build: clean mail4one.pyz
|
||||
|
||||
.PHONY: test
|
||||
test: mail4one.pyz
|
||||
PYTHONPATH=mail4one.pyz python3 -m unittest discover
|
||||
|
||||
.PHONY: clean
|
||||
clean:
|
||||
rm -rf build
|
||||
rm -rf mail4one.pyz
|
||||
|
||||
.PHONY: docker-tests
|
||||
docker-tests:
|
||||
docker run --pull=always -v `pwd`:/app -w /app --rm python:3.11-alpine sh scripts/runtests.sh
|
||||
docker run --pull=always -v `pwd`:/app -w /app --rm python:3.10-alpine sh scripts/runtests.sh
|
||||
docker run --pull=always -v `pwd`:/app -w /app --rm python:3.12 sh scripts/runtests.sh
|
||||
docker run --pull=always -v `pwd`:/app -w /app --rm python:3.11 sh scripts/runtests.sh
|
||||
docker run --pull=always -v `pwd`:/app -w /app --rm python:3.10 sh scripts/runtests.sh
|
||||
docker run --pull=always -v `pwd`:/app -w /app --rm python:3.9 sh scripts/runtests.sh
|
||||
|
||||
# ============================================================================
|
||||
# Below targets for devs. Need pipenv, black installed
|
||||
|
||||
requirements.txt: Pipfile.lock
|
||||
pipenv requirements > requirements.txt
|
||||
|
||||
.PHONY: format
|
||||
format:
|
||||
black mail4one/*py tests/*py
|
||||
black mail4one/*py
|
||||
|
||||
.PHONY: build-dev
|
||||
build-dev: requirements.txt build
|
||||
|
||||
.PHONY: setup
|
||||
setup:
|
||||
pipenv install
|
||||
|
||||
.PHONY: cleanup
|
||||
cleanup:
|
||||
pipenv --rm
|
||||
|
||||
.PHONY: update
|
||||
update:
|
||||
rm requirements.txt Pipfile.lock
|
||||
pipenv update
|
||||
pipenv requirements > requirements.txt
|
||||
|
||||
.PHONY: shell
|
||||
shell:
|
||||
MYPYPATH=$(shell ls -d `pipenv --venv`/lib/python3*/site-packages) pipenv shell
|
||||
MYPYPATH=`pipenv --venv`/lib/python3.11/site-packages pipenv shell
|
||||
|
||||
.PHONY: dev-test
|
||||
dev-test:
|
||||
test:
|
||||
pipenv run python -m unittest discover
|
||||
|
1
Pipfile
1
Pipfile
@ -8,6 +8,7 @@ name = "pypi"
|
||||
[packages]
|
||||
aiosmtpd = "*"
|
||||
python-jata = "*"
|
||||
asyncbasehttp = "*"
|
||||
|
||||
[requires]
|
||||
python_version = "3"
|
||||
|
30
Pipfile.lock
generated
30
Pipfile.lock
generated
@ -1,7 +1,7 @@
|
||||
{
|
||||
"_meta": {
|
||||
"hash": {
|
||||
"sha256": "5c16e20a67c73101d465516d657e21c6c1d3f853ae16dcfe782b7f9e8ba139e5"
|
||||
"sha256": "6b224d3d72187a39e4fba357ffb4b162dc28994bd869c7960fcfd5acbb56c959"
|
||||
},
|
||||
"pipfile-spec": 6,
|
||||
"requires": {
|
||||
@ -18,28 +18,35 @@
|
||||
"default": {
|
||||
"aiosmtpd": {
|
||||
"hashes": [
|
||||
"sha256:78d7b14f859ad0e6de252b47f9cf1ca6f1c82a8b0f10a9e39bec7e915a6aa5fe",
|
||||
"sha256:a196922f1903e54c4d37c53415b7613056d39e2b1e8249f324b9ee7a439be0f1"
|
||||
"sha256:f821fe424b703b2ea391dc2df11d89d2afd728af27393e13cf1a3530f19fdc5e",
|
||||
"sha256:f9243b7dfe00aaf567da8728d891752426b51392174a34d2cf5c18053b63dcbc"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==1.4.5"
|
||||
"version": "==1.4.4.post2"
|
||||
},
|
||||
"asyncbasehttp": {
|
||||
"hashes": [
|
||||
"sha256:2cea7b61113bb1e69d1c90f32919f004e7f56a7e1e2955e8a6286ce40f9da188",
|
||||
"sha256:70fb983c4df630a9b63081cb8ef6d2e06beb51c89f42a88da6c9a9c7ebf9fb89"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.0"
|
||||
},
|
||||
"atpublic": {
|
||||
"hashes": [
|
||||
"sha256:d1c8cd931af7461f6d18bc6063383e8654d9e9ef19d58ee6dc01e8515bbf55df",
|
||||
"sha256:df90de1162b1a941ee486f484691dc7c33123ee638ea5d6ca604061306e0fdde"
|
||||
"sha256:0f40433219e124edf115c6c363808ca6f0e1cfa7d160d86b2fb94793086d1294",
|
||||
"sha256:80057c55641253b86dcb68b524f82328172371b6547d4c7462a9127fbfbbabfc"
|
||||
],
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==4.1.0"
|
||||
"version": "==4.0"
|
||||
},
|
||||
"attrs": {
|
||||
"hashes": [
|
||||
"sha256:935dc3b529c262f6cf76e50877d35a4bd3c1de194fd41f47a2b7ae8f19971f30",
|
||||
"sha256:99b87a485a5820b23b879f04c2305b44b951b502fd64be915879d77a7e8fc6f1"
|
||||
"sha256:1f28b4522cdc2fb4256ac1a020c78acf9cba2c6b461ccd2c126f3aa8e8335d04",
|
||||
"sha256:6279836d581513a26f1bf235f9acd333bc9115683f14f7e8fae46c98fc50e015"
|
||||
],
|
||||
"markers": "python_version >= '3.7'",
|
||||
"version": "==23.2.0"
|
||||
"version": "==23.1.0"
|
||||
},
|
||||
"python-jata": {
|
||||
"hashes": [
|
||||
@ -47,7 +54,6 @@
|
||||
"sha256:ff4cd7ca75c9a8306b69ef6e878c296a5602f3279c6f9a82b6105b8eba764760"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==1.2"
|
||||
}
|
||||
},
|
||||
|
@ -1,6 +1,6 @@
|
||||
# Mail4one
|
||||
|
||||
Personal mail server for a single user or a small family. Written in pure python with [minimal dependencies](Pipfile).
|
||||
Personal mail server for a single user or a small family. Written in pure python with minimal dependencies.
|
||||
Designed for dynamic alias based workflow where a different alias is used for each purpose.
|
||||
|
||||
# Getting started
|
||||
@ -23,7 +23,7 @@ Mail4one only takes care of receiving and serving email. For sending email, use
|
||||
|
||||
Most of them have generous free tier which is more than enough for personal use.
|
||||
|
||||
Sending email is tricky. Even if everything is correctly setup (DMARC, DKIM, SPF), popular email vendors like google, microsoft may mark emails sent from your IP as spam for no reason. Hence using a dedicated service is the only reliable way to send emails.
|
||||
Sending email is tricky. Even if everything is correctly setup (DMARC, DKIM, SPF), popular email vendors like google, microsoft may mark emails sent from your IP as spam for no reason.
|
||||
|
||||
# Community
|
||||
|
||||
@ -62,7 +62,7 @@ This should generate `mail4one.pyz` in current folder. This is a [executable pyt
|
||||
* Write dedicated documentation
|
||||
* Test with more email clients ([Thunderbird](https://www.thunderbird.net/) and [k9mail](https://k9mail.app/) are tested now)
|
||||
* IMAP support
|
||||
* Web UI for editing config ([WIP](https://github.com/mail4one/mail4one/tree/webform))
|
||||
* Web UI for editing config
|
||||
* Support email submission from client to forward to other senders or direct delivery
|
||||
* Optional SPAM filtering
|
||||
* Optional DMARC,SPF,DKIM verification
|
||||
|
@ -81,16 +81,15 @@ systemctl status mail4one
|
||||
Above command should fail as the TLS certificates don't exist yet.
|
||||
|
||||
## Setup TLS certificates
|
||||
Install [certbot](https://certbot.eff.org/) and run below command. Follow instructions to create TLS certificates. Usually you want certificate for domain name like `mail.mydomain.com`
|
||||
Install [certbot](https://certbot.eff.org/) and run below command. Follow instructions to create TLS certificates. Usually you want certificate for domain name like `mail.example.com`
|
||||
```sh
|
||||
sudo certbot certonly
|
||||
sudo cp /etc/letsencrypt/live/mail.example.com/{fullchain,privkey}.pem /var/lib/mail4one/certs/
|
||||
sudo chown mail4one:mail4one /var/lib/mail4one/certs/{fullchain,privkey}.pem
|
||||
|
||||
# **Edit** mail4one_cert_copy.sh to update your domain name
|
||||
# Edit mail4one_cert_copy.sh to update your domain name
|
||||
sudo cp mail4one_cert_copy.sh /etc/letsencrypt/renewal-hooks/deploy/
|
||||
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/mail4one_cert_copy.sh
|
||||
|
||||
# This will create and copy the certificates to the right path with correct permissions and ownership
|
||||
sudo certbot certonly -d mail.mydomain.com --run-deploy-hooks --dry-run
|
||||
```
|
||||
## Restart service and check logs
|
||||
```sh
|
||||
@ -110,6 +109,6 @@ 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
|
||||
curl http://mail.example.com:25
|
||||
```
|
||||
If not working, refer to VPS settings and OS firewall settings.
|
||||
|
@ -2,26 +2,19 @@
|
||||
|
||||
# certbot deploy hook to copy certificates to mail4one when renewed.
|
||||
# Initial setup, Install certbot(https://certbot.eff.org/) and run `certbot certonly` as root
|
||||
# Doc: https://eff-certbot.readthedocs.io/en/latest/using.html#renewing-certificates
|
||||
#
|
||||
# This file is supposed to be copied to /etc/letsencrypt/renewal-hooks/deploy/
|
||||
# Change the mail domain to the one on MX record
|
||||
|
||||
set -eu
|
||||
set -x
|
||||
|
||||
|
||||
if [ "$RENEWED_DOMAINS" = "mail.mydomain.com" ]
|
||||
then
|
||||
app=mail4one
|
||||
appuser=$app
|
||||
certpath="/var/lib/$app/certs"
|
||||
|
||||
mkdir -p "$certpath"
|
||||
chmod 750 "$certpath"
|
||||
|
||||
chown $appuser:$appuser "$certpath"
|
||||
install -o "$appuser" -g "$appuser" -m 444 "$RENEWED_LINEAGE/fullchain.pem" -t "$certpath"
|
||||
install -o "$appuser" -g "$appuser" -m 400 "$RENEWED_LINEAGE/privkey.pem" -t "$certpath"
|
||||
|
||||
systemctl restart $app.service
|
||||
echo "$(date) Renewed and deployed certificates for $app" >> /var/log/cert-renew.log
|
||||
mkdir -p /var/lib/mail4one/certs
|
||||
chmod 750 /var/lib/mail4one/certs
|
||||
chown mail4one:mail4one /var/lib/mail4one/certs
|
||||
cp "$RENEWED_LINEAGE/fullchain.pem" /var/lib/mail4one/certs/
|
||||
cp "$RENEWED_LINEAGE/privkey.pem" /var/lib/mail4one/certs/
|
||||
systemctl restart mail4one.service
|
||||
fi
|
||||
|
1
jsdev/.gitignore
vendored
Normal file
1
jsdev/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
tmp
|
44
jsdev/air.toml
Normal file
44
jsdev/air.toml
Normal 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
473
jsdev/index.html
Normal 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
11
jsdev/restart.sh
Executable 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"
|
@ -1,5 +1,4 @@
|
||||
"""Module for parsing mail4one config.json"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import logging
|
||||
from typing import Callable, Union, Optional
|
||||
@ -57,14 +56,13 @@ class PopCfg(ServerCfg):
|
||||
|
||||
class SmtpStartTLSCfg(ServerCfg):
|
||||
server_type = "smtp_starttls"
|
||||
require_starttls = True
|
||||
smtputf8 = True
|
||||
smtputf8 = True # Not used yet
|
||||
port = 25
|
||||
|
||||
|
||||
class SmtpCfg(ServerCfg):
|
||||
server_type = "smtp"
|
||||
smtputf8 = True
|
||||
server_type = "smtp_starttls"
|
||||
smtputf8 = True # Not used yet
|
||||
port = 465
|
||||
|
||||
|
||||
@ -96,10 +94,11 @@ def parse_checkers(cfg: Config) -> list[Checker]:
|
||||
raise Exception("Both addrs and addr_rexs is set")
|
||||
if m.addrs:
|
||||
return lambda malias: malias in m.addrs
|
||||
if m.addr_rexs:
|
||||
elif m.addr_rexs:
|
||||
compiled_res = [re.compile(reg) for reg in m.addr_rexs]
|
||||
return lambda malias: any(reg.match(malias) for reg in compiled_res)
|
||||
raise Exception("Neither addrs nor addr_rexs is set")
|
||||
else:
|
||||
raise Exception("Neither addrs nor addr_rexs is set")
|
||||
|
||||
matches = {m.name: make_match_fn(Match(m)) for m in cfg.matches or []}
|
||||
matches[DEFAULT_MATCH_ALL] = lambda _: True
|
||||
|
@ -2,15 +2,18 @@ import asyncio
|
||||
import contextlib
|
||||
import contextvars
|
||||
import logging
|
||||
import os
|
||||
import ssl
|
||||
import random
|
||||
from typing import Optional
|
||||
from asyncio import StreamReader, StreamWriter
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from hashlib import sha256
|
||||
from pathlib import Path
|
||||
from .config import User
|
||||
from .pwhash import parse_hash, check_pass, PWInfo
|
||||
from asyncio import StreamReader, StreamWriter
|
||||
import random
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from .poputils import (
|
||||
InvalidCommand,
|
||||
@ -26,7 +29,7 @@ from .poputils import (
|
||||
end,
|
||||
Request,
|
||||
MailEntry,
|
||||
get_mail_fp,
|
||||
get_mail,
|
||||
get_mails_list,
|
||||
MailList,
|
||||
)
|
||||
@ -72,14 +75,14 @@ class PopLogger(logging.LoggerAdapter):
|
||||
def __init__(self):
|
||||
super().__init__(logging.getLogger("pop3"), None)
|
||||
|
||||
def process(self, log_msg, kwargs):
|
||||
st: State = c_state.get(None)
|
||||
if not st:
|
||||
return super().process(log_msg, kwargs)
|
||||
def process(self, msg, kwargs):
|
||||
state: State = c_state.get(None)
|
||||
if not state:
|
||||
return super().process(msg, kwargs)
|
||||
user = "NA"
|
||||
if st.username:
|
||||
user = st.username
|
||||
return super().process(f"{st.ip} {st.req_id} {user} {log_msg}", kwargs)
|
||||
if state.username:
|
||||
user = state.username
|
||||
return super().process(f"{state.ip} {state.req_id} {user} {msg}", kwargs)
|
||||
|
||||
|
||||
logger = PopLogger()
|
||||
@ -101,7 +104,8 @@ async def next_req() -> Request:
|
||||
if request.cmd == Command.QUIT:
|
||||
raise ClientQuit
|
||||
return request
|
||||
raise ClientError(f"Bad command {InvalidCommand.RETRIES} times")
|
||||
else:
|
||||
raise ClientError(f"Bad command {InvalidCommand.RETRIES} times")
|
||||
|
||||
|
||||
async def expect_cmd(*commands: Command) -> Request:
|
||||
@ -149,23 +153,25 @@ async def auth_stage() -> None:
|
||||
write(ok("Following are supported"))
|
||||
write(msg("USER"))
|
||||
write(end())
|
||||
continue
|
||||
await handle_user_pass_auth(req)
|
||||
if state().username in scfg().loggedin_users:
|
||||
logger.warning(
|
||||
f"User: {state().username} already has an active session"
|
||||
)
|
||||
raise AuthError("Already logged in")
|
||||
scfg().loggedin_users.add(state().username)
|
||||
write(ok("Login successful"))
|
||||
return
|
||||
else:
|
||||
await handle_user_pass_auth(req)
|
||||
if state().username in scfg().loggedin_users:
|
||||
logger.warning(
|
||||
f"User: {state().username} already has an active session"
|
||||
)
|
||||
raise AuthError("Already logged in")
|
||||
else:
|
||||
scfg().loggedin_users.add(state().username)
|
||||
write(ok("Login successful"))
|
||||
return
|
||||
except AuthError as ae:
|
||||
write(err(f"Auth Failed: {ae}"))
|
||||
except ClientQuit:
|
||||
except ClientQuit as c:
|
||||
write(ok("Bye"))
|
||||
logger.warning("Client has QUIT before auth succeeded")
|
||||
raise
|
||||
raise ClientError("Failed to authenticate")
|
||||
else:
|
||||
raise ClientError("Failed to authenticate")
|
||||
|
||||
|
||||
def trans_command_capa(_, __) -> None:
|
||||
@ -211,12 +217,7 @@ def trans_command_retr(mails: MailList, req: Request) -> None:
|
||||
entry = mails.get(req.arg1)
|
||||
if entry:
|
||||
write(ok("Contents follow"))
|
||||
with get_mail_fp(entry) as fp:
|
||||
for line in fp:
|
||||
if line.startswith(b"."):
|
||||
write(b".") # prepend dot
|
||||
write(line)
|
||||
# write(get_mail(entry)) # no prepend dot
|
||||
write(get_mail(entry))
|
||||
write(end())
|
||||
mails.delete(req.arg1)
|
||||
else:
|
||||
@ -266,8 +267,9 @@ async def process_transactions(mails_list: list[MailEntry]) -> set[str]:
|
||||
except KeyError:
|
||||
write(err("Not implemented"))
|
||||
raise ClientError("We shouldn't reach here")
|
||||
func(mails, req)
|
||||
await state().writer.drain()
|
||||
else:
|
||||
func(mails, req)
|
||||
await state().writer.drain()
|
||||
|
||||
|
||||
def get_deleted_items(deleted_items_path: Path) -> set[str]:
|
||||
@ -298,7 +300,7 @@ async def transaction_stage() -> None:
|
||||
deleted_items_path, existing_deleted_items.union(new_deleted_items)
|
||||
)
|
||||
|
||||
logger.info("Saved deleted items")
|
||||
logger.info(f"Saved deleted items")
|
||||
|
||||
|
||||
async def start_session() -> None:
|
||||
@ -309,10 +311,12 @@ async def start_session() -> None:
|
||||
assert state().mbox
|
||||
await transaction_stage()
|
||||
logger.info(f"User:{state().username} done")
|
||||
except ClientDisconnected:
|
||||
except ClientDisconnected as c:
|
||||
logger.info("Client disconnected")
|
||||
pass
|
||||
except ClientQuit:
|
||||
logger.info("Client QUIT")
|
||||
pass
|
||||
except ClientError as c:
|
||||
write(err("Something went wrong"))
|
||||
logger.error(f"Unexpected client error: {c}")
|
||||
@ -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):
|
||||
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):
|
||||
c_shared_state.set(s_state)
|
||||
c_shared_state.set(scfg)
|
||||
ip, _ = writer.get_extra_info("peername")
|
||||
c_state.set(State(reader=reader, writer=writer, ip=ip, req_id=s_state.next_id()))
|
||||
logger.info("Got pop server callback")
|
||||
c_state.set(State(reader=reader, writer=writer, ip=ip, req_id=scfg.next_id()))
|
||||
logger.info(f"Got pop server callback")
|
||||
try:
|
||||
try:
|
||||
return await asyncio.wait_for(start_session(), timeout_seconds)
|
||||
@ -363,7 +367,7 @@ async def create_pop_server(
|
||||
timeout_seconds: int = 60,
|
||||
) -> asyncio.Server:
|
||||
logging.info(
|
||||
f"Starting POP3 server {host=}, {port=}, {mails_path=!s}, {len(users)=}, {bool(ssl_context)=}, {timeout_seconds=}"
|
||||
f"Starting POP3 server {host=}, {port=}, {mails_path=!s}, {len(users)=}, {ssl_context != None=}, {timeout_seconds=}"
|
||||
)
|
||||
return await asyncio.start_server(
|
||||
make_pop_server_callback(mails_path, users, timeout_seconds),
|
||||
@ -382,14 +386,13 @@ def debug_main():
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
import sys
|
||||
from .pwhash import gen_pwhash
|
||||
|
||||
_, mails_path, mbox = sys.argv
|
||||
_, mails_path, port, password = sys.argv
|
||||
|
||||
mails_path = Path(mails_path)
|
||||
users = [User(username="dummy", password_hash=gen_pwhash("dummy"), mbox=mbox)]
|
||||
port = int(port)
|
||||
|
||||
asyncio.run(a_main("127.0.0.1", 1101, mails_path, users=users))
|
||||
asyncio.run(a_main(mails_path, port, password_hash=password_hash))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
@ -2,7 +2,6 @@ import os
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum, auto
|
||||
from pathlib import Path
|
||||
from contextlib import contextmanager
|
||||
|
||||
|
||||
class ClientError(Exception):
|
||||
@ -20,10 +19,12 @@ class ClientDisconnected(ClientError):
|
||||
class InvalidCommand(ClientError):
|
||||
RETRIES = 3
|
||||
"""WIll allow NUM_BAD_COMMANDS times"""
|
||||
pass
|
||||
|
||||
|
||||
class AuthError(ClientError):
|
||||
RETRIES = 3
|
||||
pass
|
||||
|
||||
|
||||
class Command(Enum):
|
||||
@ -123,12 +124,6 @@ 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()
|
||||
|
@ -16,6 +16,8 @@ VERSION = b"\x01"
|
||||
SALT_LEN = 30
|
||||
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:
|
||||
salt = os.urandom(SALT_LEN)
|
||||
|
@ -1,17 +1,20 @@
|
||||
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 .web_config import create_web_config_server
|
||||
|
||||
from . import config
|
||||
from . import pwhash
|
||||
from typing import Optional, Union
|
||||
|
||||
|
||||
def create_tls_context(certfile, keyfile) -> ssl.SSLContext:
|
||||
@ -42,15 +45,17 @@ async def a_main(cfg: config.Config) -> None:
|
||||
def get_tls_context(tls: Union[config.TLSCfg, str]):
|
||||
if tls == "default":
|
||||
return default_tls_context
|
||||
if tls == "disable":
|
||||
elif tls == "disable":
|
||||
return None
|
||||
tls_cfg = config.TLSCfg(tls)
|
||||
return create_tls_context(tls_cfg.certfile, tls_cfg.keyfile)
|
||||
else:
|
||||
tls_cfg = config.TLSCfg(tls)
|
||||
return create_tls_context(tls_cfg.certfile, tls_cfg.keyfile)
|
||||
|
||||
def get_host(host):
|
||||
if host == "default":
|
||||
return cfg.default_host
|
||||
return host
|
||||
else:
|
||||
return host
|
||||
|
||||
mbox_finder = config.gen_addr_to_mboxes(cfg)
|
||||
servers: list[asyncio.Server] = []
|
||||
@ -82,8 +87,6 @@ async def a_main(cfg: config.Config) -> None:
|
||||
mails_path=Path(cfg.mails_path),
|
||||
mbox_finder=mbox_finder,
|
||||
ssl_context=stls_context,
|
||||
require_starttls=stls.require_starttls,
|
||||
smtputf8=stls.smtputf8,
|
||||
)
|
||||
servers.append(smtp_server_starttls)
|
||||
elif scfg.server_type == "smtp":
|
||||
@ -94,9 +97,16 @@ async def a_main(cfg: config.Config) -> None:
|
||||
mails_path=Path(cfg.mails_path),
|
||||
mbox_finder=mbox_finder,
|
||||
ssl_context=get_tls_context(smtp.tls),
|
||||
smtputf8=smtp.smtputf8,
|
||||
)
|
||||
servers.append(smtp_server)
|
||||
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:
|
||||
logging.error(f"Unknown server {scfg.server_type=}")
|
||||
|
||||
|
@ -1,18 +1,23 @@
|
||||
import asyncio
|
||||
import io
|
||||
import logging
|
||||
import mailbox
|
||||
import ssl
|
||||
import uuid
|
||||
import shutil
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
from . import config
|
||||
from email.message import Message
|
||||
import email.policy
|
||||
from email.generator import BytesGenerator
|
||||
import tempfile
|
||||
import random
|
||||
|
||||
from aiosmtpd.handlers import AsyncMessage
|
||||
from aiosmtpd.smtp import SMTP
|
||||
from aiosmtpd.handlers import Mailbox, AsyncMessage
|
||||
from aiosmtpd.smtp import SMTP, DATA_SIZE_DEFAULT
|
||||
from aiosmtpd.smtp import SMTP as SMTPServer
|
||||
from aiosmtpd.smtp import Envelope as SMTPEnvelope
|
||||
from aiosmtpd.smtp import Session as SMTPSession
|
||||
|
||||
@ -24,17 +29,15 @@ class MyHandler(AsyncMessage):
|
||||
super().__init__()
|
||||
self.mails_path = mails_path
|
||||
self.mbox_finder = mbox_finder
|
||||
self.rcpt_tos = []
|
||||
self.peer = None
|
||||
|
||||
async def handle_DATA(
|
||||
self, server: SMTP, session: SMTPSession, envelope: SMTPEnvelope
|
||||
self, server: SMTPServer, session: SMTPSession, envelope: SMTPEnvelope
|
||||
) -> str:
|
||||
self.rcpt_tos = envelope.rcpt_tos
|
||||
self.peer = session.peer
|
||||
return await super().handle_DATA(server, session, envelope)
|
||||
|
||||
async def handle_message(self, message: Message): # type: ignore[override]
|
||||
async def handle_message(self, m: Message): # type: ignore[override]
|
||||
all_mboxes: set[str] = set()
|
||||
for addr in self.rcpt_tos:
|
||||
for mbox in self.mbox_finder(addr.lower()):
|
||||
@ -51,7 +54,7 @@ class MyHandler(AsyncMessage):
|
||||
temp_email_path = Path(tmpdir) / filename
|
||||
with open(temp_email_path, "wb") as fp:
|
||||
gen = BytesGenerator(fp, policy=email.policy.SMTP)
|
||||
gen.flatten(message)
|
||||
gen.flatten(m)
|
||||
for mbox in all_mboxes:
|
||||
shutil.copy(temp_email_path, self.mails_path / mbox / "new")
|
||||
logger.info(
|
||||
@ -60,20 +63,16 @@ class MyHandler(AsyncMessage):
|
||||
|
||||
|
||||
def protocol_factory_starttls(
|
||||
mails_path: Path,
|
||||
mbox_finder: Callable[[str], list[str]],
|
||||
context: ssl.SSLContext,
|
||||
require_starttls: bool,
|
||||
smtputf8: bool,
|
||||
mails_path: Path, mbox_finder: Callable[[str], list[str]], context: ssl.SSLContext
|
||||
):
|
||||
logger.info("Got smtp client cb starttls")
|
||||
try:
|
||||
handler = MyHandler(mails_path, mbox_finder)
|
||||
smtp = SMTP(
|
||||
handler=handler,
|
||||
require_starttls=require_starttls,
|
||||
require_starttls=True,
|
||||
tls_context=context,
|
||||
enable_SMTPUTF8=smtputf8,
|
||||
enable_SMTPUTF8=True,
|
||||
)
|
||||
except:
|
||||
logger.exception("Something went wrong")
|
||||
@ -81,13 +80,11 @@ def protocol_factory_starttls(
|
||||
return smtp
|
||||
|
||||
|
||||
def protocol_factory(
|
||||
mails_path: Path, mbox_finder: Callable[[str], list[str]], smtputf8: bool
|
||||
):
|
||||
def protocol_factory(mails_path: Path, mbox_finder: Callable[[str], list[str]]):
|
||||
logger.info("Got smtp client cb")
|
||||
try:
|
||||
handler = MyHandler(mails_path, mbox_finder)
|
||||
smtp = SMTP(handler=handler, enable_SMTPUTF8=smtputf8)
|
||||
smtp = SMTP(handler=handler, enable_SMTPUTF8=True)
|
||||
except:
|
||||
logger.exception("Something went wrong")
|
||||
raise
|
||||
@ -100,22 +97,13 @@ async def create_smtp_server_starttls(
|
||||
mails_path: Path,
|
||||
mbox_finder: Callable[[str], list[str]],
|
||||
ssl_context: ssl.SSLContext,
|
||||
require_starttls: bool,
|
||||
smtputf8: bool,
|
||||
) -> asyncio.Server:
|
||||
logging.info(
|
||||
f"Starting SMTP STARTTLS server {host=}, {port=}, {mails_path=!s}, {bool(ssl_context)=}"
|
||||
f"Starting SMTP STARTTLS server {host=}, {port=}, {mails_path=!s}, {ssl_context != None=}"
|
||||
)
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.create_server(
|
||||
partial(
|
||||
protocol_factory_starttls,
|
||||
mails_path,
|
||||
mbox_finder,
|
||||
ssl_context,
|
||||
require_starttls,
|
||||
smtputf8,
|
||||
),
|
||||
partial(protocol_factory_starttls, mails_path, mbox_finder, ssl_context),
|
||||
host=host,
|
||||
port=port,
|
||||
start_serving=False,
|
||||
@ -127,15 +115,14 @@ async def create_smtp_server(
|
||||
port: int,
|
||||
mails_path: Path,
|
||||
mbox_finder: Callable[[str], list[str]],
|
||||
ssl_context: Optional[ssl.SSLContext],
|
||||
smtputf8: bool,
|
||||
ssl_context: Optional[ssl.SSLContext] = None,
|
||||
) -> asyncio.Server:
|
||||
logging.info(
|
||||
f"Starting SMTP server {host=}, {port=}, {mails_path=!s}, {bool(ssl_context)=}"
|
||||
f"Starting SMTP server {host=}, {port=}, {mails_path=!s}, {ssl_context != None=}"
|
||||
)
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.create_server(
|
||||
partial(protocol_factory, mails_path, mbox_finder, smtputf8),
|
||||
partial(protocol_factory, mails_path, mbox_finder),
|
||||
host=host,
|
||||
port=port,
|
||||
ssl=ssl_context,
|
||||
|
88
mail4one/template_web_config.html
Normal file
88
mail4one/template_web_config.html
Normal 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
80
mail4one/web_config.py
Normal 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,
|
||||
)
|
@ -1,5 +1,5 @@
|
||||
-i https://pypi.org/simple
|
||||
aiosmtpd==1.4.5; python_version >= '3.8'
|
||||
atpublic==4.1.0; python_version >= '3.8'
|
||||
attrs==23.2.0; python_version >= '3.7'
|
||||
python-jata==1.2; python_version >= '3.8'
|
||||
aiosmtpd==1.4.4.post2
|
||||
atpublic==4.0 ; python_version >= '3.8'
|
||||
attrs==23.1.0 ; python_version >= '3.7'
|
||||
python-jata==1.2
|
||||
|
@ -8,7 +8,7 @@ then
|
||||
tag_val=$(git describe --dirty=DIRTY --exact-match)
|
||||
case "$tag_val" in
|
||||
*DIRTY)
|
||||
echo "git-$commit-changes"
|
||||
echo "git=$commit-changes"
|
||||
;;
|
||||
v*) # Only consider tags starting with v
|
||||
echo "$tag_val"
|
||||
|
@ -67,11 +67,10 @@ class TestConfig(unittest.TestCase):
|
||||
def test_get_mboxes(self) -> None:
|
||||
cfg = config.Config(TEST_CONFIG)
|
||||
rules = config.parse_checkers(cfg)
|
||||
self.assertEqual(config.get_mboxes("foo@bar.com", rules), ["spam"])
|
||||
self.assertEqual(config.get_mboxes("foo@mydomain.com", rules), ["all"])
|
||||
self.assertEqual(
|
||||
config.get_mboxes("first.last@mydomain.com", rules), ["important", "all"]
|
||||
)
|
||||
self.assertEqual(config.get_mboxes("foo@bar.com", rules), ['spam'])
|
||||
self.assertEqual(config.get_mboxes("foo@mydomain.com", rules), ['all'])
|
||||
self.assertEqual(config.get_mboxes("first.last@mydomain.com", rules),
|
||||
['important', 'all'])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
@ -4,29 +4,20 @@ import logging
|
||||
import tempfile
|
||||
import time
|
||||
import os
|
||||
import poplib
|
||||
from mail4one.pop3 import create_pop_server
|
||||
from mail4one.config import User
|
||||
from pathlib import Path
|
||||
|
||||
TEST_HASH = "".join(
|
||||
"""
|
||||
TEST_HASH = "".join(c for c in """
|
||||
AFTY5EVN7AX47ZL7UMH3BETYWFBTAV3XHR73CEFAJBPN2NIHPWD
|
||||
ZHV2UQSMSPHSQQ2A2BFQBNC77VL7F2UKATQNJZGYLCSU6C43UQD
|
||||
AQXWXSWNGAEPGIMG2F3QDKBXL3MRHY6K2BPID64ZR6LABLPVSF
|
||||
""".split()
|
||||
)
|
||||
""" if not c.isspace())
|
||||
|
||||
TEST_USER = "foobar"
|
||||
TEST_MBOX = "foobar_mails"
|
||||
TEST_USER = 'foobar'
|
||||
TEST_MBOX = 'foobar_mails'
|
||||
|
||||
TEST_USER2 = "foo2"
|
||||
TEST_MBOX2 = "foo2mails"
|
||||
|
||||
USERS = [
|
||||
User(username=TEST_USER, password_hash=TEST_HASH, mbox=TEST_MBOX),
|
||||
User(username=TEST_USER2, password_hash=TEST_HASH, mbox=TEST_MBOX2),
|
||||
]
|
||||
USERS = [User(username=TEST_USER, password_hash=TEST_HASH, mbox=TEST_MBOX)]
|
||||
|
||||
MAILS_PATH: Path
|
||||
|
||||
@ -47,8 +38,7 @@ Hello bro\r
|
||||
IlzVOJqu9Zp7twFAtzcV\r
|
||||
yQVk36B0mGU2gtWxXLr\r
|
||||
PeF0RtbI0mAuVPLQDHCi\r
|
||||
\r
|
||||
"""
|
||||
\r\n"""
|
||||
|
||||
|
||||
def setUpModule() -> None:
|
||||
@ -57,21 +47,13 @@ def setUpModule() -> None:
|
||||
td = tempfile.TemporaryDirectory(prefix="m41.pop.")
|
||||
unittest.addModuleCleanup(td.cleanup)
|
||||
MAILS_PATH = Path(td.name)
|
||||
for mbox in (TEST_MBOX, TEST_MBOX2):
|
||||
os.mkdir(MAILS_PATH / mbox)
|
||||
for md in ("new", "cur", "tmp"):
|
||||
os.mkdir(MAILS_PATH / mbox / md)
|
||||
with open(MAILS_PATH / TEST_MBOX / "new/msg1.eml", "wb") as f:
|
||||
os.mkdir(MAILS_PATH / TEST_MBOX)
|
||||
for md in ('new', 'cur', 'tmp'):
|
||||
os.mkdir(MAILS_PATH / TEST_MBOX / md)
|
||||
with open(MAILS_PATH / TEST_MBOX/ 'new/msg1.eml', 'wb') as f:
|
||||
f.write(TESTMAIL)
|
||||
with open(MAILS_PATH / TEST_MBOX / "new/msg2.eml", "wb") as f:
|
||||
with open(MAILS_PATH / TEST_MBOX/ 'new/msg2.eml', 'wb') as f:
|
||||
f.write(TESTMAIL)
|
||||
with open(MAILS_PATH / TEST_MBOX2 / "new/msg1.eml", "wb") as f:
|
||||
f.write(TESTMAIL)
|
||||
f.write(b"More lines to follow\r\n")
|
||||
f.write(b".Line starts with a dot\r\n")
|
||||
f.write(b"some more lines\r\n")
|
||||
f.write(b".\r\n")
|
||||
f.write(b"Previous line just has a dot\r\n")
|
||||
logging.debug(MAILS_PATH)
|
||||
|
||||
|
||||
@ -83,14 +65,13 @@ class TestPop3(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
async def asyncSetUp(self) -> None:
|
||||
logging.debug("at asyncSetUp")
|
||||
pop_server = await create_pop_server(
|
||||
host="127.0.0.1", port=7995, mails_path=MAILS_PATH, users=USERS
|
||||
)
|
||||
pop_server = await create_pop_server(host='127.0.0.1',
|
||||
port=7995,
|
||||
mails_path=MAILS_PATH,
|
||||
users=USERS)
|
||||
self.task = asyncio.create_task(pop_server.serve_forever())
|
||||
self.reader, self.writer = await asyncio.open_connection("127.0.0.1", 7995)
|
||||
|
||||
# Additional writers to close
|
||||
self.ws: list[asyncio.StreamWriter] = []
|
||||
self.reader, self.writer = await asyncio.open_connection(
|
||||
'127.0.0.1', 7995)
|
||||
|
||||
async def test_QUIT(self) -> None:
|
||||
dialog = """
|
||||
@ -134,9 +115,8 @@ class TestPop3(unittest.IsolatedAsyncioTestCase):
|
||||
await self.dialog_checker(dialog)
|
||||
|
||||
async def test_dupe_AUTH(self) -> None:
|
||||
r1, w1 = await asyncio.open_connection("127.0.0.1", 7995)
|
||||
r2, w2 = await asyncio.open_connection("127.0.0.1", 7995)
|
||||
self.ws += w1, w2
|
||||
r1, w1 = await asyncio.open_connection('127.0.0.1', 7995)
|
||||
r2, w2 = await asyncio.open_connection('127.0.0.1', 7995)
|
||||
dialog = """
|
||||
S: +OK Server Ready
|
||||
C: USER foobar
|
||||
@ -217,36 +197,18 @@ class TestPop3(unittest.IsolatedAsyncioTestCase):
|
||||
"""
|
||||
await self.dialog_checker(dialog)
|
||||
|
||||
async def test_poplib(self) -> None:
|
||||
def run_poplib():
|
||||
pc = poplib.POP3("127.0.0.1", 7995)
|
||||
try:
|
||||
self.assertEqual(b"+OK Server Ready", pc.getwelcome())
|
||||
self.assertEqual(b"+OK Welcome", pc.user("foo2"))
|
||||
self.assertEqual(b"+OK Login successful", pc.pass_("helloworld"))
|
||||
_, eml, oc = pc.retr(1)
|
||||
self.assertIn(b"Previous line just has a dot", eml)
|
||||
self.assertIn(b".Line starts with a dot", eml)
|
||||
self.assertIn(b".", eml)
|
||||
finally:
|
||||
pc.quit()
|
||||
|
||||
await asyncio.to_thread(run_poplib)
|
||||
|
||||
async def asyncTearDown(self) -> None:
|
||||
logging.debug("at teardown")
|
||||
for w in self.ws + [self.writer]:
|
||||
w.close()
|
||||
await w.wait_closed()
|
||||
self.ws.clear()
|
||||
self.writer.close()
|
||||
await self.writer.wait_closed()
|
||||
self.task.cancel("test done")
|
||||
|
||||
async def dialog_checker(self, dialog: str) -> None:
|
||||
await self.dialog_checker_impl(self.reader, self.writer, dialog)
|
||||
|
||||
async def dialog_checker_impl(
|
||||
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, dialog: str
|
||||
) -> None:
|
||||
async def dialog_checker_impl(self, reader: asyncio.StreamReader,
|
||||
writer: asyncio.StreamWriter,
|
||||
dialog: str) -> None:
|
||||
for line in dialog.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
@ -260,5 +222,5 @@ class TestPop3(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual(data, resp)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
@ -10,31 +10,27 @@ class TestPWHash(unittest.TestCase):
|
||||
pwinfo = parse_hash(pwhash)
|
||||
self.assertEqual(len(pwinfo.salt), SALT_LEN)
|
||||
self.assertEqual(len(pwinfo.scrypt_hash), KEY_LEN)
|
||||
self.assertTrue(
|
||||
check_pass(password, pwinfo), "check pass with correct password"
|
||||
)
|
||||
self.assertFalse(check_pass("foobar", pwinfo), "check pass with wrong password")
|
||||
self.assertTrue(check_pass(password, pwinfo),
|
||||
"check pass with correct password")
|
||||
self.assertFalse(check_pass("foobar", pwinfo),
|
||||
"check pass with wrong password")
|
||||
|
||||
def test_hardcoded_hash(self):
|
||||
test_hash = "".join(
|
||||
c
|
||||
for c in """
|
||||
test_hash = "".join(c for c in """
|
||||
AFTY5EVN7AX47ZL7UMH3BETYWFBTAV3XHR73CEFAJBPN2NIHPWD
|
||||
ZHV2UQSMSPHSQQ2A2BFQBNC77VL7F2UKATQNJZGYLCSU6C43UQD
|
||||
AQXWXSWNGAEPGIMG2F3QDKBXL3MRHY6K2BPID64ZR6LABLPVSF
|
||||
"""
|
||||
if not c.isspace()
|
||||
)
|
||||
""" if not c.isspace())
|
||||
pwinfo = parse_hash(test_hash)
|
||||
self.assertTrue(
|
||||
check_pass("helloworld", pwinfo), "check pass with correct password"
|
||||
)
|
||||
self.assertFalse(check_pass("foobar", pwinfo), "check pass with wrong password")
|
||||
self.assertTrue(check_pass("helloworld", pwinfo),
|
||||
"check pass with correct password")
|
||||
self.assertFalse(check_pass("foobar", pwinfo),
|
||||
"check pass with wrong password")
|
||||
|
||||
def test_invalid_hash(self):
|
||||
with self.assertRaises(Exception):
|
||||
parse_hash("sdlfkjdsklfjdsk")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
@ -10,7 +10,7 @@ from pathlib import Path
|
||||
|
||||
from mail4one.smtp import create_smtp_server
|
||||
|
||||
TEST_MBOX = "foobar_mails"
|
||||
TEST_MBOX = 'foobar_mails'
|
||||
MAILS_PATH: Path
|
||||
|
||||
|
||||
@ -21,7 +21,7 @@ def setUpModule() -> None:
|
||||
unittest.addModuleCleanup(td.cleanup)
|
||||
MAILS_PATH = Path(td.name)
|
||||
os.mkdir(MAILS_PATH / TEST_MBOX)
|
||||
for md in ("new", "cur", "tmp"):
|
||||
for md in ('new', 'cur', 'tmp'):
|
||||
os.mkdir(MAILS_PATH / TEST_MBOX / md)
|
||||
|
||||
|
||||
@ -32,10 +32,7 @@ class TestSMTP(unittest.IsolatedAsyncioTestCase):
|
||||
host="127.0.0.1",
|
||||
port=7996,
|
||||
mails_path=MAILS_PATH,
|
||||
mbox_finder=lambda addr: [TEST_MBOX],
|
||||
ssl_context=None,
|
||||
smtputf8=True,
|
||||
)
|
||||
mbox_finder=lambda addr: [TEST_MBOX])
|
||||
self.task = asyncio.create_task(smtp_server.serve_forever())
|
||||
|
||||
async def test_send_mail(self) -> None:
|
||||
@ -48,9 +45,8 @@ class TestSMTP(unittest.IsolatedAsyncioTestCase):
|
||||
msg = b"".join(l.strip() + b"\r\n" for l in msg.splitlines())
|
||||
|
||||
def send_mail():
|
||||
with contextlib.closing(
|
||||
smtplib.SMTP(host="127.0.0.1", port=7996)
|
||||
) as client:
|
||||
with contextlib.closing(smtplib.SMTP(host="127.0.0.1",
|
||||
port=7996)) as client:
|
||||
client.sendmail("foo@sender.com", "foo@bar.com", msg)
|
||||
_, local_port = client.sock.getsockname()
|
||||
return local_port
|
||||
@ -66,7 +62,7 @@ class TestSMTP(unittest.IsolatedAsyncioTestCase):
|
||||
Byee
|
||||
"""
|
||||
expected = "".join(l.strip() + "\r\n" for l in expected.splitlines())
|
||||
mails = list((MAILS_PATH / TEST_MBOX / "new").glob("*"))
|
||||
mails = list((MAILS_PATH / TEST_MBOX / 'new').glob("*"))
|
||||
self.assertEqual(len(mails), 1)
|
||||
self.assertEqual(mails[0].read_bytes(), expected.encode())
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user