Add VPS IMAP sorting automation

Prompts used since the previous commit:
- Hieronder staat je oorspronkelijke plan. Volgens mij moet stap 'Add VPS automation' nog worden uitgevoerd, inclusief de testen.

Special observations:
- Added a testable IMAP sorting daemon, shared IMAP operation helpers, systemd service template, and SSH deployment script.
- Local tests passed; no live VPS deployment or mailbox-affecting daemon test was run.
- verplaats_log.json remains modified from the user's run and was intentionally not staged.
This commit is contained in:
2026-07-04 20:00:17 +02:00
parent 011b180224
commit 5c0e345fd8
7 changed files with 513 additions and 13 deletions
+88
View File
@@ -0,0 +1,88 @@
"""Shared IMAP operations used by mailcat sorters."""
from __future__ import annotations
import email
import email.header
from email.message import Message
from imap_utils import quote_mailbox
from mail_routes import PREFIX, quarter_folder, route_from_subject
HEADER_FETCH = "(BODY.PEEK[HEADER.FIELDS (FROM SUBJECT DATE)])"
def decode_header_value(value) -> str:
"""Decode an RFC 2047 header value into display text."""
if not value:
return ""
try:
parts = email.header.decode_header(value)
out = []
for raw, charset in parts:
if isinstance(raw, bytes):
out.append(raw.decode(charset or "utf-8", errors="replace"))
else:
out.append(str(raw))
return " ".join(out).strip()
except Exception:
return str(value or "")
def fetch_body(data) -> bytes | None:
"""Return the first bytes payload from an IMAP FETCH response."""
if not data:
return None
for item in data:
if isinstance(item, tuple) and len(item) >= 2 and isinstance(item[1], bytes):
return item[1]
return None
def parse_header(raw_header: bytes) -> Message:
"""Parse an IMAP header payload into an email Message."""
return email.message_from_bytes(raw_header)
def destination_from_header(raw_header: bytes) -> str | None:
"""Return the full IMAP destination folder for a fetched header."""
msg = parse_header(raw_header)
from_raw = decode_header_value(msg.get("From", ""))
subject = decode_header_value(msg.get("Subject", ""))
date_str = msg.get("Date", "")
destination = route_from_subject(from_raw, subject)
if destination == "__FACTUUR_DATE__":
destination = quarter_folder(date_str)
if destination:
return PREFIX + destination
return None
def ensure_mailbox(mail, folder: str) -> bool:
"""Create a mailbox and its parents if they do not already exist."""
parts = folder.split(".")
for index in range(1, len(parts) + 1):
parent = ".".join(parts[:index])
status, _ = mail.create(quote_mailbox(parent))
if status not in {"OK", "NO"}:
return False
return True
def move_message(mail, uid: bytes, src: str, dst: str):
"""Copy a message to dst and mark it deleted in src."""
select_status, select_data = mail.select(quote_mailbox(src))
if select_status != "OK":
return False, ("select_source", select_status, select_data)
copy_status, copy_data = mail.uid("COPY", uid, quote_mailbox(dst))
if copy_status != "OK":
return False, ("copy", copy_status, copy_data)
store_status, store_data = mail.uid("STORE", uid, "+FLAGS", "\\Deleted")
if store_status != "OK":
return False, ("store_deleted", store_status, store_data)
return True, None