6 Commits

Author SHA1 Message Date
d608c507f7 Merge pull request 'Add X-SSL header to note which type of ssl was used when receiving message' (#9) from starttls_header into main
Reviewed-on: #9
2025-06-15 11:50:56 -04:00
c66fe37eb4 black format 2024-05-26 12:49:48 -04:00
1d54e2232f update deps 2024-05-24 22:10:43 -04:00
d4efe91593 add devnotes 2024-05-24 22:09:31 -04:00
92dc1ab713 Add X-SSL header to email
1. Type of listener the client used
2. If starttls was called
2024-05-24 22:00:28 -04:00
57d0eeaf0f pylint (#8)
Current score:

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

Reviewed-on: #8
2024-05-24 21:20:09 -04:00
9 changed files with 115 additions and 54 deletions

55
DEVNOTES.md Normal file
View File

@ -0,0 +1,55 @@
Notes for developers
## Running just one test
```
python -m unittest tests.test_pop.TestPop3.test_CAPA
python -m unittest tests.test_smtp.TestSMTP
```
## Patch for enable logging in test
Patch generated using below
```
git diff --patch -U1 tests >> ./DEVNOTES.md
```
Apply with below. Disables smtp test mail dir cleanup.
```
ls -ltd /tmp/m41*
git checkout tests
```
```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.")
diff --git a/tests/test_smtp.py b/tests/test_smtp.py
index 0554d4c..52d147b 100644
--- a/tests/test_smtp.py
+++ b/tests/test_smtp.py
@@ -18,5 +18,5 @@ def setUpModule() -> None:
global MAILS_PATH
- logging.basicConfig(level=logging.CRITICAL)
+ logging.basicConfig(level=logging.DEBUG)
td = tempfile.TemporaryDirectory(prefix="m41.smtp.")
- unittest.addModuleCleanup(td.cleanup)
+ # unittest.addModuleCleanup(td.cleanup)
MAILS_PATH = Path(td.name)
PATCH
```
## pylint
```
pylint mail4one/*py > /tmp/errs
vim +"cfile /tmp/errs"
```

6
Pipfile.lock generated
View File

@ -18,12 +18,12 @@
"default": { "default": {
"aiosmtpd": { "aiosmtpd": {
"hashes": [ "hashes": [
"sha256:78d7b14f859ad0e6de252b47f9cf1ca6f1c82a8b0f10a9e39bec7e915a6aa5fe", "sha256:5a811826e1a5a06c25ebc3e6c4a704613eb9a1bcf6b78428fbe865f4f6c9a4b8",
"sha256:a196922f1903e54c4d37c53415b7613056d39e2b1e8249f324b9ee7a439be0f1" "sha256:72c99179ba5aa9ae0abbda6994668239b64a5ce054471955fe75f581d2592475"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==1.4.5" "version": "==1.4.6"
}, },
"atpublic": { "atpublic": {
"hashes": [ "hashes": [

View File

@ -96,10 +96,9 @@ 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
elif m.addr_rexs: if 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

@ -72,14 +72,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, msg, kwargs): def process(self, log_msg, kwargs):
state: State = c_state.get(None) st: State = c_state.get(None)
if not state: if not st:
return super().process(msg, kwargs) return super().process(log_msg, kwargs)
user = "NA" user = "NA"
if state.username: if st.username:
user = state.username user = st.username
return super().process(f"{state.ip} {state.req_id} {user} {msg}", kwargs) return super().process(f"{st.ip} {st.req_id} {user} {log_msg}", kwargs)
logger = PopLogger() logger = PopLogger()
@ -101,7 +101,6 @@ 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")
@ -150,24 +149,22 @@ 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())
else: continue
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 as c: except ClientQuit:
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")
@ -269,7 +266,6 @@ 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()
@ -302,7 +298,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(f"Saved deleted items") logger.info("Saved deleted items")
async def start_session() -> None: async def start_session() -> None:
@ -339,13 +335,15 @@ 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):
scfg = SharedState(mails_path=mails_path, users=parse_users(users)) s_state = SharedState(mails_path=mails_path, users=parse_users(users))
async def session_cb(reader: StreamReader, writer: StreamWriter): async def session_cb(reader: StreamReader, writer: StreamWriter):
c_shared_state.set(scfg) c_shared_state.set(s_state)
ip, _ = writer.get_extra_info("peername") ip, _ = writer.get_extra_info("peername")
c_state.set(State(reader=reader, writer=writer, ip=ip, req_id=scfg.next_id())) c_state.set(
logger.info(f"Got pop server callback") State(reader=reader, writer=writer, ip=ip, req_id=s_state.next_id())
)
logger.info("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)
@ -367,7 +365,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)=}, {ssl_context != None=}, {timeout_seconds=}" f"Starting POP3 server {host=}, {port=}, {mails_path=!s}, {len(users)=}, {bool(ssl_context)=}, {timeout_seconds=}"
) )
return await asyncio.start_server( return await asyncio.start_server(
make_pop_server_callback(mails_path, users, timeout_seconds), make_pop_server_callback(mails_path, users, timeout_seconds),

View File

@ -20,12 +20,10 @@ 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):

View File

@ -42,16 +42,14 @@ 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
elif tls == "disable": if 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)

View File

@ -20,19 +20,31 @@ 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]],
listener_type: 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
self.rcpt_tos = []
self.peer = None
self.starttls = False
self.listener_type = listener_type
async def handle_DATA( async def handle_DATA(
self, server: SMTP, session: SMTPSession, envelope: SMTPEnvelope self, server: SMTP, 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
if session.ssl:
self.starttls = True
return await super().handle_DATA(server, session, envelope) return await super().handle_DATA(server, session, envelope)
async def handle_message(self, m: Message): # type: ignore[override] async def handle_message(self, message: Message): # type: ignore[override]
message["X-SSL"] = f"Type: {self.listener_type}, STARTTLS: {self.starttls}"
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()):
@ -49,7 +61,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(m) gen.flatten(message)
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(
@ -66,7 +78,7 @@ def protocol_factory_starttls(
): ):
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, "starttls")
smtp = SMTP( smtp = SMTP(
handler=handler, handler=handler,
require_starttls=require_starttls, require_starttls=require_starttls,
@ -84,7 +96,7 @@ def protocol_factory(
): ):
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, "plain")
smtp = SMTP(handler=handler, enable_SMTPUTF8=smtputf8) smtp = SMTP(handler=handler, enable_SMTPUTF8=smtputf8)
except: except:
logger.exception("Something went wrong") logger.exception("Something went wrong")
@ -102,7 +114,7 @@ async def create_smtp_server_starttls(
smtputf8: bool, smtputf8: bool,
) -> asyncio.Server: ) -> 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}, {bool(ssl_context)=}"
) )
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
return await loop.create_server( return await loop.create_server(
@ -129,7 +141,7 @@ async def create_smtp_server(
smtputf8: bool, smtputf8: bool,
) -> asyncio.Server: ) -> asyncio.Server:
logging.info( logging.info(
f"Starting SMTP server {host=}, {port=}, {mails_path=!s}, {ssl_context != None=}" f"Starting SMTP server {host=}, {port=}, {mails_path=!s}, {bool(ssl_context)=}"
) )
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
return await loop.create_server( return await loop.create_server(

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.6; python_version >= '3.8'
atpublic==4.1.0; python_version >= '3.8' atpublic==4.1.0; python_version >= '3.8'
attrs==23.2.0; python_version >= '3.7' attrs==23.2.0; python_version >= '3.7'
python-jata==1.2; python_version >= '3.8' python-jata==1.2; python_version >= '3.8'

View File

@ -61,6 +61,7 @@ class TestSMTP(unittest.IsolatedAsyncioTestCase):
X-Peer: ('127.0.0.1', {local_port}) X-Peer: ('127.0.0.1', {local_port})
X-MailFrom: foo@sender.com X-MailFrom: foo@sender.com
X-RcptTo: foo@bar.com X-RcptTo: foo@bar.com
X-SSL: Type: plain, STARTTLS: False
Hello world Hello world
Byee Byee