3 Commits

7 changed files with 149 additions and 34 deletions

114
deploy_configs/README.md Normal file
View File

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

View File

@ -1,17 +1,17 @@
# NOTE: Sample config is provided in yaml format for easy editing # NOTE: Sample config is provided in yaml format for easy editing
# mail4one needs a json config, Please convert the config to json before passing to app # mail4one needs a json config, Please convert the config to json before passing to app
# This is to avoid yaml depependency in the app # This is to avoid yaml dependency in the app
# #
# Some tools to convert to json: # Some tools to convert to json:
# If you have go in your system (https://go.dev/) # If you have `go` in your system (https://go.dev/)
# go run github.com/mikefarah/yq/v4@latest -oj -P . config.sample > config.json # go run github.com/mikefarah/yq/v4@latest -oj -P . config.sample > config.json
# #
# If you have pipx in your system (https://pypa.github.io/pipx/) # If you have `pipx` in your system (https://pypa.github.io/pipx/)
# pipx run yq . config.sample > config.json # pipx run yq . config.sample > config.json
# #
# or a browser: # or a browser:
# https://onlineyamltools.com/convert-yaml-to-json # https://onlineyamltools.com/convert-yaml-to-json
#
default_tls: # Will be used by both pop and smtp servers default_tls: # Will be used by both pop and smtp servers
# If using certbot(https://certbot.eff.org/), # If using certbot(https://certbot.eff.org/),
# the following files will be here /etc/letsencrypt/live/<domain name> # the following files will be here /etc/letsencrypt/live/<domain name>

View File

@ -1,14 +1,14 @@
import json import json
import re import re
import logging import logging
from typing import Callable, Union, Optional from typing import Callable, Union, Optional, List, Tuple
from jata import Jata, MutableDefault from jata import Jata, MutableDefault
class Match(Jata): class Match(Jata):
name: str name: str
addrs: list[str] = MutableDefault(lambda: []) # type: ignore addrs: List[str] = MutableDefault(lambda: []) # type: ignore
addr_rexs: list[str] = MutableDefault(lambda: []) # type: ignore addr_rexs: List[str] = MutableDefault(lambda: []) # type: ignore
DEFAULT_MATCH_ALL = "default_match_all" DEFAULT_MATCH_ALL = "default_match_all"
@ -23,7 +23,7 @@ class Rule(Jata):
class Mbox(Jata): class Mbox(Jata):
name: str name: str
rules: list[Rule] rules: List[Rule]
DEFAULT_NULL_MBOX = "default_null_mbox" DEFAULT_NULL_MBOX = "default_null_mbox"
@ -77,18 +77,18 @@ class Config(Jata):
logging: Optional[LogCfg] = None logging: Optional[LogCfg] = None
mails_path: str mails_path: str
matches: list[Match] matches: List[Match]
boxes: list[Mbox] boxes: List[Mbox]
users: list[User] users: List[User]
servers: list[ServerCfg] servers: List[ServerCfg]
CheckerFn = Callable[[str], bool] CheckerFn = Callable[[str], bool]
Checker = tuple[str, CheckerFn, bool] Checker = Tuple[str, CheckerFn, bool]
def parse_checkers(cfg: Config) -> list[Checker]: def parse_checkers(cfg: Config) -> List[Checker]:
def make_match_fn(m: Match): def make_match_fn(m: Match):
if m.addrs and m.addr_rexs: if m.addrs and m.addr_rexs:
raise Exception("Both addrs and addr_rexs is set") raise Exception("Both addrs and addr_rexs is set")
@ -118,7 +118,7 @@ def parse_checkers(cfg: Config) -> list[Checker]:
] ]
def get_mboxes(addr: str, checks: list[Checker]) -> list[str]: def get_mboxes(addr: str, checks: List[Checker]) -> List[str]:
def inner(): def inner():
for mbox, match_fn, stop_check in checks: for mbox, match_fn, stop_check in checks:
if match_fn(addr): if match_fn(addr):
@ -130,7 +130,7 @@ def get_mboxes(addr: str, checks: list[Checker]) -> list[str]:
return list(inner()) return list(inner())
def gen_addr_to_mboxes(cfg: Config) -> Callable[[str], list[str]]: def gen_addr_to_mboxes(cfg: Config) -> Callable[[str], List[str]]:
checks = parse_checkers(cfg) checks = parse_checkers(cfg)
logging.info(f"Parsed checkers from config, {len(checks)=}") logging.info(f"Parsed checkers from config, {len(checks)=}")
return lambda addr: get_mboxes(addr, checks) return lambda addr: get_mboxes(addr, checks)

View File

@ -13,7 +13,7 @@ from .pwhash import parse_hash, check_pass, PWInfo
from asyncio import StreamReader, StreamWriter from asyncio import StreamReader, StreamWriter
import random import random
from typing import Optional from typing import Optional, List, Tuple, Dict
from .poputils import ( from .poputils import (
InvalidCommand, InvalidCommand,
@ -46,7 +46,7 @@ class State:
class SharedState: class SharedState:
def __init__(self, mails_path: Path, users: dict[str, tuple[PWInfo, str]]): def __init__(self, mails_path: Path, users: dict[str, Tuple[PWInfo, str]]):
self.mails_path = mails_path self.mails_path = mails_path
self.users = users self.users = users
self.loggedin_users: set[str] = set() self.loggedin_users: set[str] = set()
@ -237,7 +237,7 @@ def trans_command_noop(_, __) -> None:
write(ok("Hmm")) write(ok("Hmm"))
async def process_transactions(mails_list: list[MailEntry]) -> set[str]: async def process_transactions(mails_list: List[MailEntry]) -> set[str]:
mails = MailList(mails_list) mails = MailList(mails_list)
def reset(_, __): def reset(_, __):
@ -328,7 +328,7 @@ async def start_session() -> None:
scfg().loggedin_users.remove(state().username) scfg().loggedin_users.remove(state().username)
def parse_users(users: list[User]) -> dict[str, tuple[PWInfo, str]]: def parse_users(users: List[User]) -> Dict[str, Tuple[PWInfo, str]]:
def inner(): def inner():
for user in users: for user in users:
user = User(user) user = User(user)
@ -338,7 +338,7 @@ def parse_users(users: list[User]) -> dict[str, tuple[PWInfo, str]]:
return dict(inner()) return dict(inner())
def make_pop_server_callback(mails_path: Path, users: list[User], timeout_seconds: int): def make_pop_server_callback(mails_path: Path, users: List[User], timeout_seconds: int):
scfg = SharedState(mails_path=mails_path, users=parse_users(users)) scfg = SharedState(mails_path=mails_path, users=parse_users(users))
async def session_cb(reader: StreamReader, writer: StreamWriter): async def session_cb(reader: StreamReader, writer: StreamWriter):
@ -362,7 +362,7 @@ async def create_pop_server(
host: str, host: str,
port: int, port: int,
mails_path: Path, mails_path: Path,
users: list[User], users: List[User],
ssl_context: Optional[ssl.SSLContext] = None, ssl_context: Optional[ssl.SSLContext] = None,
timeout_seconds: int = 60, timeout_seconds: int = 60,
) -> asyncio.Server: ) -> asyncio.Server:

View File

@ -2,6 +2,7 @@ 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 typing import List
class ClientError(Exception): class ClientError(Exception):
@ -112,13 +113,13 @@ def files_in_path(path):
return [] return []
def get_mails_list(dirpath: Path) -> list[MailEntry]: def get_mails_list(dirpath: Path) -> List[MailEntry]:
files = files_in_path(dirpath) files = files_in_path(dirpath)
entries = [MailEntry(filename, path) for filename, path in files] entries = [MailEntry(filename, path) for filename, path in files]
return entries return entries
def set_nid(entries: list[MailEntry]): def set_nid(entries: List[MailEntry]):
entries.sort(reverse=True, key=lambda e: (e.c_time, e.uid)) entries.sort(reverse=True, key=lambda e: (e.c_time, e.uid))
for i, entry in enumerate(entries, start=1): for i, entry in enumerate(entries, start=1):
entry.nid = i entry.nid = i
@ -130,7 +131,7 @@ def get_mail(entry: MailEntry) -> bytes:
class MailList: class MailList:
def __init__(self, entries: list[MailEntry]): def __init__(self, entries: List[MailEntry]):
self.entries = entries self.entries = entries
set_nid(self.entries) set_nid(self.entries)
self.mails_map = {str(e.nid): e for e in entries} self.mails_map = {str(e.nid): e for e in entries}

View File

@ -57,7 +57,7 @@ async def a_main(cfg: config.Config) -> None:
return host return host
mbox_finder = config.gen_addr_to_mboxes(cfg) mbox_finder = config.gen_addr_to_mboxes(cfg)
servers: list[asyncio.Server] = [] servers: List[asyncio.Server] = []
if not cfg.servers: if not cfg.servers:
logging.warning("Nothing to do!") logging.warning("Nothing to do!")

View File

@ -7,7 +7,7 @@ import uuid
import shutil import shutil
from functools import partial from functools import partial
from pathlib import Path from pathlib import Path
from typing import Callable, Optional from typing import Callable, Optional, List
from . import config from . import config
from email.message import Message from email.message import Message
import email.policy import email.policy
@ -25,7 +25,7 @@ logger = logging.getLogger("smtp")
class MyHandler(AsyncMessage): class MyHandler(AsyncMessage):
def __init__(self, mails_path: Path, mbox_finder: Callable[[str], list[str]]): def __init__(self, mails_path: Path, mbox_finder: Callable[[str], List[str]]):
super().__init__() super().__init__()
self.mails_path = mails_path self.mails_path = mails_path
self.mbox_finder = mbox_finder self.mbox_finder = mbox_finder
@ -63,7 +63,7 @@ class MyHandler(AsyncMessage):
def protocol_factory_starttls( def protocol_factory_starttls(
mails_path: Path, mbox_finder: Callable[[str], list[str]], context: ssl.SSLContext mails_path: Path, mbox_finder: Callable[[str], List[str]], context: ssl.SSLContext
): ):
logger.info("Got smtp client cb starttls") logger.info("Got smtp client cb starttls")
try: try:
@ -80,7 +80,7 @@ def protocol_factory_starttls(
return smtp return smtp
def protocol_factory(mails_path: Path, mbox_finder: Callable[[str], list[str]]): def protocol_factory(mails_path: Path, mbox_finder: Callable[[str], List[str]]):
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)
@ -95,9 +95,9 @@ async def create_smtp_server_starttls(
host: str, host: str,
port: int, port: int,
mails_path: Path, mails_path: Path,
mbox_finder: Callable[[str], list[str]], mbox_finder: Callable[[str], List[str]],
ssl_context: ssl.SSLContext, ssl_context: ssl.SSLContext,
) -> asyncio.Server: ):
logging.info( logging.info(
f"Starting SMTP STARTTLS server {host=}, {port=}, {mails_path=!s}, {ssl_context != None=}" f"Starting SMTP STARTTLS server {host=}, {port=}, {mails_path=!s}, {ssl_context != None=}"
) )
@ -114,9 +114,9 @@ async def create_smtp_server(
host: str, host: str,
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] = None, ssl_context: Optional[ssl.SSLContext] = None,
) -> asyncio.Server: ):
logging.info( logging.info(
f"Starting SMTP server {host=}, {port=}, {mails_path=!s}, {ssl_context != None=}" f"Starting SMTP server {host=}, {port=}, {mails_path=!s}, {ssl_context != None=}"
) )