Compare commits

..

3 Commits

Author SHA1 Message Date
mailcat d70112d766 Made the randam locale date optional, default just Dutch 2026-08-17 09:21:11 +02:00
mailcat 0b4e702bac Pre-merge between origin and dagda 2026-07-20 20:56:00 +02:00
mailcat 337378a362 Initial status for developed files on dagda 2026-07-20 20:49:36 +02:00
89 changed files with 28390 additions and 7 deletions
Executable
BIN
View File
Binary file not shown.
Executable
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Executable
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
Executable
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Executable
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+8
View File
@@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"Bash(python *)",
"Bash(python3 *)"
]
}
}
+399
View File
@@ -0,0 +1,399 @@
#!/usr/bin/env python3
"""
Fase 1: Analyseer lokale mailbox-kopie en genereer overzichten.
Output: reports/mailinglijsten.md, facturen_mailbox.md, facturen_links.md, prullenbak.md
"""
import email
import email.header
import email.utils
import re
import sys
from pathlib import Path
from collections import defaultdict
from datetime import datetime, timezone
MAILBOX_DIR = Path(__file__).parent / "mailbox"
REPORTS_DIR = Path(__file__).parent / "reports"
INVOICE_SUBJECT_KW = [
"factuur", "invoice", "rekening", "nota", "kwitantie", "receipt",
"betaalverzoek", "betalingsverzoek", "betaling ontvangen",
"payment received", "order confirmation", "orderbevestiging",
"your order", "je bestelling", "proforma",
]
INVOICE_ATTACH_KW = [
"factuur", "invoice", "rekening", "nota", "receipt", "bill",
]
INVOICE_LINK_KW = [
"bekijk je factuur", "download je factuur", "download your invoice",
"view your invoice", "your invoice is ready", "je factuur staat klaar",
"factuur is beschikbaar", "invoice is available", "factuur online",
"invoice online", "mijn facturen", "my invoices", "portaal", "portal",
"mijn account", "my account", "log in to view", "inloggen om te bekijken",
]
TRASH_SENDER_RE = re.compile(
r"(noreply|no-reply|donotreply|do-not-reply|notifications?|newsletter|"
r"nieuwsbrief|alerts?|updates?|mailer-daemon|postmaster|bounce|"
r"auto-?reply|automatisch)@",
re.I,
)
TRASH_SUBJECT_RE = re.compile(
r"(out of office|automatisch antwoord|afwezig|buiten kantoor|"
r"delivery (status notification|failed|failure)|undeliverable|"
r"mail delivery|mailer-daemon|bounced|returned mail)",
re.I,
)
# ── helpers ─────────────────────────────────────────────────────────────────
def decode_hdr(value: str | None) -> str:
if not value:
return ""
try:
parts = email.header.decode_header(value)
out = []
for raw, cs in parts:
if isinstance(raw, bytes):
out.append(raw.decode(cs or "utf-8", errors="replace"))
else:
out.append(str(raw))
return " ".join(out).strip()
except Exception:
return str(value or "")
def parse_dt(date_str: str | None) -> datetime | None:
if not date_str:
return None
try:
return email.utils.parsedate_to_datetime(date_str)
except Exception:
return None
def sender_domain(from_addr: str) -> str:
m = re.search(r"@([\w.\-]+)", from_addr)
return m.group(1).lower() if m else "onbekend"
def get_body(msg) -> str:
parts = []
try:
for part in (msg.walk() if msg.is_multipart() else [msg]):
if part.get_content_type() in ("text/plain", "text/html"):
cs = part.get_content_charset() or "utf-8"
try:
parts.append(part.get_payload(decode=True).decode(cs, errors="replace"))
except Exception:
pass
except Exception:
pass
return "\n".join(parts)[:4000]
def get_attachments(msg) -> list[dict]:
result = []
try:
for part in msg.walk():
disp = (part.get("Content-Disposition") or "").lower()
ct = part.get_content_type() or ""
fn = decode_hdr(part.get_filename() or "")
if "attachment" in disp or (fn and ct == "application/pdf"):
result.append({"filename": fn, "type": ct})
except Exception:
pass
return result
def extract_urls(text: str) -> list[str]:
return list(dict.fromkeys(re.findall(r"https?://[^\s<>\"')]+", text)))
def url_domain(url: str) -> str:
m = re.match(r"https?://([^/]+)", url)
return m.group(1).lower() if m else ""
# ── main analysis ────────────────────────────────────────────────────────────
def analyze():
REPORTS_DIR.mkdir(exist_ok=True)
eml_files = list(MAILBOX_DIR.rglob("*.eml"))
total = len(eml_files)
print(f"Analyseren van {total} e-mails...\n")
# mailinglijsten: key → {count, list_id, unsubscribe, senders, dates, folder}
ml: dict[str, dict] = defaultdict(lambda: {
"list_id": "", "unsubscribe": [], "senders": set(),
"count": 0, "dates": [], "folder": "",
})
invoices_in_mb: list[dict] = []
invoice_links: list[dict] = []
trash: list[dict] = []
now_utc = datetime.now(timezone.utc)
for i, path in enumerate(eml_files, 1):
if i % 1000 == 0:
print(f" {i}/{total}...", flush=True)
try:
with open(path, "rb") as f:
msg = email.message_from_bytes(f.read())
except Exception:
continue
folder = path.parent.name
from_raw = decode_hdr(msg.get("From", ""))
subject = decode_hdr(msg.get("Subject", ""))
date = parse_dt(msg.get("Date"))
list_id = (msg.get("List-ID") or "").strip()
list_us = (msg.get("List-Unsubscribe") or "").strip()
prec = (msg.get("Precedence") or "").lower()
from_lo = from_raw.lower()
subj_lo = subject.lower()
domain = sender_domain(from_raw)
attachments = get_attachments(msg)
body = get_body(msg)
body_lo = body.lower()
# ── mailinglijsten ────────────────────────────────────────────────
ml_key = None
if list_id:
ml_key = re.sub(r"[<>]", "", list_id).strip()
elif "mailinglists" in folder.lower() or "mailinglist" in folder.lower():
ml_key = folder # use the subfolder name as key
if ml_key:
entry = ml[ml_key]
entry["list_id"] = list_id or ml_key
entry["count"] += 1
entry["folder"] = folder
entry["senders"].add(from_raw)
if date:
entry["dates"].append(date)
if list_us and list_us not in entry["unsubscribe"]:
entry["unsubscribe"].append(list_us)
# ── facturen in mailbox ───────────────────────────────────────────
invoice_reasons: list[str] = []
for att in attachments:
fn_lo = att["filename"].lower()
if att["type"] == "application/pdf" and any(kw in fn_lo for kw in INVOICE_ATTACH_KW):
invoice_reasons.append(f"PDF: {att['filename']}")
if any(kw in subj_lo for kw in INVOICE_SUBJECT_KW):
invoice_reasons.append("factuurtermen in onderwerp")
# always include what's already in the Facturen folders
if "facturen" in folder.lower():
invoice_reasons.append(f"staat in map {folder}")
if invoice_reasons:
invoices_in_mb.append({
"from": from_raw,
"domain": domain,
"subject": subject,
"date": date,
"folder": folder,
"reasons": invoice_reasons,
"attachments": [a["filename"] for a in attachments if a["type"] == "application/pdf"],
})
# ── factuurlinks ──────────────────────────────────────────────────
has_link_kw = any(kw in body_lo for kw in INVOICE_LINK_KW)
has_no_pdf = not any(a["type"] == "application/pdf" for a in attachments)
if has_link_kw and has_no_pdf:
urls = extract_urls(body)
if urls:
invoice_links.append({
"from": from_raw,
"domain": domain,
"subject": subject,
"date": date,
"folder": folder,
"urls": urls[:5],
"url_domains": list(dict.fromkeys(url_domain(u) for u in urls[:5])),
})
# ── prullenbak ────────────────────────────────────────────────────
trash_reasons: list[str] = []
if TRASH_SENDER_RE.search(from_lo):
trash_reasons.append("automatisch afzenderadres")
if TRASH_SUBJECT_RE.search(subj_lo):
trash_reasons.append("automatisch/fout-onderwerp")
if prec in ("bulk", "junk"):
trash_reasons.append(f"Precedence: {prec}")
# Old bulk mail sitting in INBOX
if folder == "INBOX" and prec in ("bulk", "list", "junk") and date:
age = (now_utc - date.astimezone(timezone.utc)).days
if age > 180:
trash_reasons.append(f"bulk in INBOX, {age} dagen oud")
if trash_reasons:
trash.append({
"from": from_raw,
"domain": domain,
"subject": subject,
"date": date,
"folder": folder,
"reasons": trash_reasons,
"path": str(path),
})
print(f" {total}/{total} klaar.\n")
write_mailinglist_report(ml)
write_invoice_report(invoices_in_mb)
write_invoice_links_report(invoice_links)
write_trash_report(trash)
print(f"\nRapporten opgeslagen in: {REPORTS_DIR.resolve()}")
# ── report writers ───────────────────────────────────────────────────────────
def fmt_date(d: datetime | None) -> str:
return d.strftime("%Y-%m-%d") if d else "?"
def write_mailinglist_report(ml: dict):
lines = [
"# Mailinglijsten\n",
f"Totaal gevonden: **{len(ml)}** lijsten.\n",
"_Advies: > 30 mails = hoog volume, overweeg afmelden._\n",
]
for key, entry in sorted(ml.items(), key=lambda x: -x[1]["count"]):
dates = sorted(entry["dates"])
oldest = fmt_date(dates[0]) if dates else "?"
newest = fmt_date(dates[-1]) if dates else "?"
senders = sorted(entry["senders"])[:2]
unsub = entry["unsubscribe"]
if entry["count"] > 30:
advice = "⚠️ Hoog volume overweeg afmelden"
elif entry["count"] > 10:
advice = "Gemiddeld volume beoordeel zelf"
else:
advice = "Laag volume waarschijnlijk houden"
can_unsub = "Ja" if unsub else "Nee (handmatig opzoeken)"
lines += [
f"## {key}",
f"- **Mails:** {entry['count']} | **Periode:** {oldest} {newest}",
f"- **Map:** {entry['folder']}",
f"- **Afzender:** {', '.join(senders)}",
f"- **Afmelden mogelijk:** {can_unsub}",
]
if unsub:
lines.append(f"- **Afmeldlink:** `{unsub[0][:120]}`")
lines += [f"- **Advies:** {advice}", ""]
path = REPORTS_DIR / "mailinglijsten.md"
path.write_text("\n".join(lines), encoding="utf-8")
print(f"✓ mailinglijsten.md ({len(ml)} lijsten)")
def write_invoice_report(invoices: list[dict]):
lines = [
"# Facturen in mailbox\n",
f"Totaal: **{len(invoices)}** factuurmails gevonden.\n",
]
by_domain: dict[str, list] = defaultdict(list)
for inv in invoices:
by_domain[inv["domain"]].append(inv)
for domain, items in sorted(by_domain.items(), key=lambda x: -len(x[1])):
items_sorted = sorted(items, key=lambda x: x["date"] or datetime.min.replace(tzinfo=timezone.utc))
lines.append(f"## {domain} ({len(items)} mails)")
for item in items_sorted[:25]:
atts = f"{', '.join(item['attachments'])}" if item["attachments"] else ""
lines.append(f"- {fmt_date(item['date'])} | {item['subject'][:80]}{atts}")
if len(items) > 25:
lines.append(f" _...en {len(items) - 25} meer_")
lines.append("")
(REPORTS_DIR / "facturen_mailbox.md").write_text("\n".join(lines), encoding="utf-8")
print(f"✓ facturen_mailbox.md ({len(invoices)} facturen)")
def write_invoice_links_report(invoice_links: list[dict]):
lines = [
"# Factuurlinks verwijzingen naar portalen\n",
f"Totaal: **{len(invoice_links)}** mails met factuurverwijzingen (zonder PDF-bijlage).\n",
"_Dit zijn mails die zeggen 'bekijk je factuur online' de factuur staat op een website._\n",
]
by_url_domain: dict[str, list] = defaultdict(list)
for item in invoice_links:
for ud in (item["url_domains"] or ["onbekend"]):
by_url_domain[ud].append(item)
for ud, items in sorted(by_url_domain.items(), key=lambda x: -len(x[1])):
unique_senders = sorted({i["domain"] for i in items})
lines.append(f"## {ud} ({len(items)} mails)")
lines.append(f" _Afzenders: {', '.join(unique_senders[:5])}_")
for item in sorted(items, key=lambda x: x["date"] or datetime.min.replace(tzinfo=timezone.utc))[:10]:
lines.append(f"- {fmt_date(item['date'])} | {item['subject'][:80]}")
for url in item["urls"][:2]:
lines.append(f" `{url[:120]}`")
if len(items) > 10:
lines.append(f" _...en {len(items) - 10} meer_")
lines.append("")
(REPORTS_DIR / "facturen_links.md").write_text("\n".join(lines), encoding="utf-8")
print(f"✓ facturen_links.md ({len(invoice_links)} mails)")
def write_trash_report(trash: list[dict]):
lines = [
"# Prullenbak-kandidaten\n",
f"Totaal: **{len(trash)}** kandidaten gevonden.\n",
]
by_reason: dict[str, list] = defaultdict(list)
for item in trash:
by_reason[item["reasons"][0]].append(item)
for reason, items in sorted(by_reason.items(), key=lambda x: -len(x[1])):
lines.append(f"## {reason} ({len(items)} mails)")
by_domain: dict[str, list] = defaultdict(list)
for item in items:
by_domain[item["domain"]].append(item)
def _sort_key(x):
d = x["date"]
if d is None:
return datetime.min.replace(tzinfo=timezone.utc)
return d if d.tzinfo else d.replace(tzinfo=timezone.utc)
for domain, ditems in sorted(by_domain.items(), key=lambda x: -len(x[1])):
lines.append(f"### {domain} ({len(ditems)})")
for item in sorted(ditems, key=_sort_key)[:10]:
lines.append(f"- {fmt_date(item['date'])} | {item['folder']} | {item['subject'][:70]}")
if len(ditems) > 10:
lines.append(f" _...en {len(ditems) - 10} meer_")
lines.append("")
(REPORTS_DIR / "prullenbak.md").write_text("\n".join(lines), encoding="utf-8")
print(f"✓ prullenbak.md ({len(trash)} kandidaten)")
if __name__ == "__main__":
analyze()
+12
View File
@@ -0,0 +1,12 @@
#!/bin/sh
set -eu
APP_DIR="/share/homes/mailcat/mailcat"
ACCOUNT="backup"
PYTHON="/opt/bin/python3"
LOG_DIR="/share/homes/mailcat/mailcat/log"
MAIL_TO="hans@australius.nl"
MKDIR="/bin/mkdir"
"$MKDIR" -p "$LOG_DIR"
cd "$APP_DIR"
"$PYTHON" "$APP_DIR/dagelijks_overzicht.py" --account "$ACCOUNT" --mail-to "$MAIL_TO" >> "$LOG_DIR/daily_report.out" 2>&1
+26
View File
@@ -0,0 +1,26 @@
#!/bin/sh
set -eu
APP_DIR="/share/homes/mailcat/mailcat"
ACCOUNT="backup"
PYTHON="/opt/bin/python3"
STATE_DIR="/share/homes/mailcat/mailcat/state"
LOG_DIR="/share/homes/mailcat/mailcat/log"
RUN_DIR="/share/homes/mailcat/mailcat/run"
PID_FILE="/share/homes/mailcat/mailcat/run/mailcat.pid"
MKDIR="/bin/mkdir"
"$MKDIR" -p "$STATE_DIR" "$LOG_DIR" "$RUN_DIR"
if [ -f "$PID_FILE" ]; then
old_pid=""
IFS= read -r old_pid < "$PID_FILE" || true
if [ -n "$old_pid" ] && kill -0 "$old_pid" 2>/dev/null; then
echo "mailcat already running: $old_pid"
exit 0
fi
fi
cd "$APP_DIR"
"$PYTHON" "$APP_DIR/sort_mail_daemon.py" --account "$ACCOUNT" --folder INBOX --state-file "$STATE_DIR/sort_mail_daemon_state.json" --log-file "$LOG_DIR/sort_mail_daemon.log" >> "$LOG_DIR/daemon.out" 2>&1 &
pid="$!"
echo "$pid" > "$PID_FILE"
echo "mailcat started: $pid"
+32
View File
@@ -0,0 +1,32 @@
#!/bin/sh
set -eu
APP_DIR="/share/homes/mailcat/mailcat"
PYTHON="/opt/bin/python3"
LOG_DIR="/share/homes/mailcat/mailcat/log"
RUN_DIR="/share/homes/mailcat/mailcat/run"
WEB_PID_FILE="/share/homes/mailcat/mailcat/run/mailcat_web.pid"
WEB_HOST="0.0.0.0"
WEB_PORT="4321"
WEB_CONFIG="/share/homes/mailcat/mailcat/web_config.json"
MKDIR="/bin/mkdir"
if [ ! -f "$WEB_CONFIG" ]; then
echo "missing web config: $WEB_CONFIG"
exit 1
fi
"$MKDIR" -p "$LOG_DIR" "$RUN_DIR"
if [ -f "$WEB_PID_FILE" ]; then
old_pid=""
IFS= read -r old_pid < "$WEB_PID_FILE" || true
if [ -n "$old_pid" ] && kill -0 "$old_pid" 2>/dev/null; then
echo "mailcat web already running: $old_pid"
exit 0
fi
fi
cd "$APP_DIR"
"$PYTHON" "$APP_DIR/manage_routes_web.py" --host "$WEB_HOST" --port "$WEB_PORT" --config "$WEB_CONFIG" >> "$LOG_DIR/web.out" 2>&1 &
pid="$!"
echo "$pid" > "$WEB_PID_FILE"
echo "mailcat web started: $pid"
+18
View File
@@ -0,0 +1,18 @@
#!/bin/sh
set -eu
PID_FILE="/share/homes/mailcat/mailcat/run/mailcat.pid"
LOG_DIR="/share/homes/mailcat/mailcat/log"
if [ -f "$PID_FILE" ]; then
pid=""
IFS= read -r pid < "$PID_FILE" || true
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
echo "mailcat running: $pid"
exit 0
fi
echo "mailcat not running: stale pid file ($pid)"
exit 1
fi
echo "mailcat not running"
echo "logs: $LOG_DIR"
exit 1
+21
View File
@@ -0,0 +1,21 @@
#!/bin/sh
set -eu
WEB_PID_FILE="/share/homes/mailcat/mailcat/run/mailcat_web.pid"
WEB_HOST="0.0.0.0"
WEB_PORT="4321"
LOG_DIR="/share/homes/mailcat/mailcat/log"
if [ -f "$WEB_PID_FILE" ]; then
pid=""
IFS= read -r pid < "$WEB_PID_FILE" || true
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
echo "mailcat web running: $pid"
echo "url: http://$WEB_HOST:$WEB_PORT/"
exit 0
fi
echo "mailcat web not running: stale pid file ($pid)"
exit 1
fi
echo "mailcat web not running"
echo "logs: $LOG_DIR/web.out"
exit 1
+41
View File
@@ -0,0 +1,41 @@
#!/bin/sh
set -eu
PID_FILE="/share/homes/mailcat/mailcat/run/mailcat.pid"
RM="/bin/rm"
if [ ! -f "$PID_FILE" ]; then
echo "mailcat not running: no pid file"
exit 0
fi
pid=""
IFS= read -r pid < "$PID_FILE" || true
if [ -z "$pid" ]; then
"$RM" -f "$PID_FILE"
echo "mailcat not running: empty pid file removed"
exit 0
fi
if kill -0 "$pid" 2>/dev/null; then
kill "$pid"
timeout=20
while [ "$timeout" -gt 0 ] && kill -0 "$pid" 2>/dev/null; do
sleep 1
timeout=$((timeout - 1))
done
if kill -0 "$pid" 2>/dev/null; then
echo "mailcat still running after SIGTERM, sending SIGKILL: $pid"
kill -KILL "$pid" 2>/dev/null || true
timeout=5
while [ "$timeout" -gt 0 ] && kill -0 "$pid" 2>/dev/null; do
sleep 1
timeout=$((timeout - 1))
done
if kill -0 "$pid" 2>/dev/null; then
echo "mailcat still running after SIGKILL: $pid"
exit 1
fi
fi
fi
"$RM" -f "$PID_FILE"
echo "mailcat stopped"
+41
View File
@@ -0,0 +1,41 @@
#!/bin/sh
set -eu
WEB_PID_FILE="/share/homes/mailcat/mailcat/run/mailcat_web.pid"
RM="/bin/rm"
if [ ! -f "$WEB_PID_FILE" ]; then
echo "mailcat web not running: no pid file"
exit 0
fi
pid=""
IFS= read -r pid < "$WEB_PID_FILE" || true
if [ -z "$pid" ]; then
"$RM" -f "$WEB_PID_FILE"
echo "mailcat web not running: empty pid file removed"
exit 0
fi
if kill -0 "$pid" 2>/dev/null; then
kill "$pid"
timeout=20
while [ "$timeout" -gt 0 ] && kill -0 "$pid" 2>/dev/null; do
sleep 1
timeout=$((timeout - 1))
done
if kill -0 "$pid" 2>/dev/null; then
echo "mailcat web still running after SIGTERM, sending SIGKILL: $pid"
kill -KILL "$pid" 2>/dev/null || true
timeout=5
while [ "$timeout" -gt 0 ] && kill -0 "$pid" 2>/dev/null; do
sleep 1
timeout=$((timeout - 1))
done
if kill -0 "$pid" 2>/dev/null; then
echo "mailcat web still running after SIGKILL: $pid"
exit 1
fi
fi
fi
"$RM" -f "$WEB_PID_FILE"
echo "mailcat web stopped"
+34
View File
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>nl.australius.dagelijks-overzicht</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/python3</string>
<string>/Users/hanswienen/Documents/Development/Vibes/mailcat/dagelijks_overzicht.py</string>
</array>
<!-- Elke ochtend om 07:00 -->
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key>
<integer>7</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
<!-- Log eventuele fouten -->
<key>StandardOutPath</key>
<string>/Users/hanswienen/Library/Logs/dagelijks_overzicht.log</string>
<key>StandardErrorPath</key>
<string>/Users/hanswienen/Library/Logs/dagelijks_overzicht.log</string>
<!-- Alleen draaien als de Mac aan staat; inhalen bij gemiste run -->
<key>RunAtLoad</key>
<false/>
</dict>
</plist>
+18 -4
View File
@@ -131,6 +131,19 @@ def get_random_locale() -> (str, str, str):
code = all_ids[random.randrange(0, len(all_ids))]
return (code, babel.Locale(code).language_name, babel.Locale(code).english_name)
def create_mail_subject(zoekdatum, random = False) -> str:
# Als random = true, toon dan de datum ook in een willekeurige andere taal
# Anders alleen in het Nederlands
datum_nl = format_date(zoekdatum, "EEEE d MMMM yyyy", locale="nl")
if random:
random_locale, random_lang, random_elang = get_random_locale()
datum_bl = format_date(zoekdatum, locale=random_locale)
return f"Mailoverzicht van {datum_bl} ({random_lang} - {random_elang}) of {datum_nl}"
else:
return f"Mailoverzicht van {datum_nl}"
# ── IMAP ophalen ──────────────────────────────────────────────────────────────
def haal_berichten_op(mail: imaplib.IMAP4_SSL, zoekdatum: date) -> dict[str, list[dict]]:
@@ -266,11 +279,12 @@ def bouw_html(berichten: dict[str, list[dict]], zoekdatum: date) -> str:
# ── e-mail versturen ──────────────────────────────────────────────────────────
def stuur_overzicht(html: str, zoekdatum: date, config: dict):
datum_nl = format_date(zoekdatum, "EEEE d MMMM yyyy", locale="nl") # zoekdatum.strftime("%-d %B %Y")
random_locale, random_lang, random_elang = get_random_locale()
datum_bl = format_date(zoekdatum, locale=random_locale)
# datum_nl = format_date(zoekdatum, "EEEE d MMMM yyyy", locale="nl") # zoekdatum.strftime("%-d %B %Y")
# random_locale, random_lang, random_elang = get_random_locale()
# datum_bl = format_date(zoekdatum, locale=random_locale)
msg = MIMEMultipart("alternative")
msg["Subject"] = f"Mailoverzicht van {datum_bl} ({random_lang} - {random_elang}) of {datum_nl}"
# msg["Subject"] = f"Mailoverzicht van {datum_bl} ({random_lang} - {random_elang}) of {datum_nl}"
msg["Subject"] = create_mail_subject(zoekdatum, False)
msg["From"] = config["from"]
msg["To"] = config["to"]
msg.attach(MIMEText(html, "html", "utf-8"))
+257
View File
@@ -0,0 +1,257 @@
sh: line 1: hans: command not found
Account: hans@australius.nl
Ophalen berichten van 2026-07-04 ...
2 berichten gevonden in 2 mappen.
Overzicht verstuurd naar hans@australius.nl
Account: hans@australius.nl
Ophalen berichten van 2026-07-04 ...
2 berichten gevonden in 2 mappen.
Overzicht verstuurd naar hans@australius.nl
Account: hans@australius.nl
Ophalen berichten van 2026-07-05 ...
4 berichten gevonden in 3 mappen.
Overzicht verstuurd naar hans@australius.nl
Account: hans@australius.nl
Ophalen berichten van 2026-07-05 ...
4 berichten gevonden in 3 mappen.
Overzicht verstuurd naar hans@australius.nl
Account: hans@australius.nl
Ophalen berichten van 2026-07-06 ...
6 berichten gevonden in 5 mappen.
Overzicht verstuurd naar hans@australius.nl
Account: hans@australius.nl
Ophalen berichten van 2026-07-06 ...
6 berichten gevonden in 5 mappen.
Overzicht verstuurd naar hans@australius.nl
Account 'hand' niet gevonden. Beschikbaar: backup, hans
Account: hans@australius.nl
2026-07-08 10:49:01.846690: Ophalen berichten van 2026-07-07 ...
12 berichten gevonden in 7 mappen.
Overzicht verstuurd naar hans@australius.nl
Account: hans@australius.nl
2026-07-08 13:35:02.222744: Ophalen berichten van 2026-07-07 ...
12 berichten gevonden in 7 mappen.
Overzicht verstuurd naar hans@australius.nl
Account: hans@australius.nl
2026-07-08 13:36:01.725869: Ophalen berichten van 2026-07-07 ...
12 berichten gevonden in 7 mappen.
Overzicht verstuurd naar hans@australius.nl
Account: hans@australius.nl
2026-07-08 13:38:01.565000: Ophalen berichten van 2026-07-07 ...
12 berichten gevonden in 7 mappen.
Overzicht verstuurd naar hans@australius.nl
Account: hans@australius.nl
2026-07-08 13:39:01.933965: Ophalen berichten van 2026-07-07 ...
12 berichten gevonden in 7 mappen.
Overzicht verstuurd naar hans@australius.nl
Account: hans@australius.nl
2026-07-09 06:00:02.179821: Ophalen berichten van 2026-07-08 ...
10 berichten gevonden in 9 mappen.
Overzicht verstuurd naar hans@australius.nl
Account: hans@australius.nl
2026-07-10 06:00:02.302178: Ophalen berichten van 2026-07-09 ...
10 berichten gevonden in 8 mappen.
Overzicht verstuurd naar hans@australius.nl
Account: hans@australius.nl
2026-07-11 06:00:01.530567: Ophalen berichten van 2026-07-10 ...
11 berichten gevonden in 8 mappen.
Overzicht verstuurd naar hans@australius.nl
Account: hans@australius.nl
Ophalen berichten van 2026-07-11 ...
4 berichten gevonden in 4 mappen.
Overzicht verstuurd naar hans@australius.nl
Account: hans@australius.nl
Ophalen berichten van 2026-07-12 ...
3 berichten gevonden in 2 mappen.
Overzicht verstuurd naar hans@australius.nl
Account: hans@australius.nl
Ophalen berichten van 2026-07-13 ...
12 berichten gevonden in 10 mappen.
Overzicht verstuurd naar hans@australius.nl
Account: hans@australius.nl
Ophalen berichten van 2026-07-14 ...
12 berichten gevonden in 5 mappen.
Overzicht verstuurd naar hans@australius.nl
Account: hans@australius.nl
Ophalen berichten van 2026-07-15 ...
10 berichten gevonden in 8 mappen.
Overzicht verstuurd naar hans@australius.nl
Account: hans@australius.nl
Ophalen berichten van 2026-07-16 ...
4 berichten gevonden in 4 mappen.
Overzicht verstuurd naar hans@australius.nl
Account: hans@australius.nl
Ophalen berichten van 2026-07-17 ...
4 berichten gevonden in 3 mappen.
Overzicht verstuurd naar hans@australius.nl
Account: hans@australius.nl
Ophalen berichten van 2026-07-18 ...
1 berichten gevonden in 1 mappen.
Overzicht verstuurd naar hans@australius.nl
Account: hans@australius.nl
Ophalen berichten van 2026-07-19 ...
2 berichten gevonden in 2 mappen.
Overzicht verstuurd naar hans@australius.nl
Account: hans@australius.nl
Ophalen berichten van 2026-07-20 ...
7 berichten gevonden in 5 mappen.
Overzicht verstuurd naar hans@australius.nl
Account: hans@australius.nl
Ophalen berichten van 2026-07-21 ...
6 berichten gevonden in 6 mappen.
Overzicht verstuurd naar hans@australius.nl
Account: hans@australius.nl
Ophalen berichten van 2026-07-22 ...
7 berichten gevonden in 5 mappen.
Overzicht verstuurd naar hans@australius.nl
Account: hans@australius.nl
Ophalen berichten van 2026-07-23 ...
5 berichten gevonden in 5 mappen.
Overzicht verstuurd naar hans@australius.nl
Account: hans@australius.nl
Ophalen berichten van 2026-07-24 ...
8 berichten gevonden in 6 mappen.
Overzicht verstuurd naar hans@australius.nl
Account: hans@australius.nl
Ophalen berichten van 2026-07-25 ...
5 berichten gevonden in 4 mappen.
Overzicht verstuurd naar hans@australius.nl
Account: hans@australius.nl
Ophalen berichten van 2026-07-26 ...
1 berichten gevonden in 1 mappen.
Overzicht verstuurd naar hans@australius.nl
Account: hans@australius.nl
Ophalen berichten van 2026-07-27 ...
7 berichten gevonden in 5 mappen.
Overzicht verstuurd naar hans@australius.nl
Account: hans@australius.nl
Ophalen berichten van 2026-07-28 ...
6 berichten gevonden in 4 mappen.
Overzicht verstuurd naar hans@australius.nl
Account: hans@australius.nl
Ophalen berichten van 2026-07-29 ...
3 berichten gevonden in 2 mappen.
Overzicht verstuurd naar hans@australius.nl
Account: hans@australius.nl
Ophalen berichten van 2026-07-30 ...
9 berichten gevonden in 8 mappen.
Overzicht verstuurd naar hans@australius.nl
Account: hans@australius.nl
Ophalen berichten van 2026-07-31 ...
4 berichten gevonden in 3 mappen.
Overzicht verstuurd naar hans@australius.nl
Account: hans@australius.nl
Ophalen berichten van 2026-08-01 ...
2 berichten gevonden in 2 mappen.
Overzicht verstuurd naar hans@australius.nl
Account: hans@australius.nl
Ophalen berichten van 2026-08-02 ...
4 berichten gevonden in 2 mappen.
Overzicht verstuurd naar hans@australius.nl
Account: hans@australius.nl
Ophalen berichten van 2026-08-03 ...
5 berichten gevonden in 4 mappen.
Overzicht verstuurd naar hans@australius.nl
Account: hans@australius.nl
Ophalen berichten van 2026-08-04 ...
4 berichten gevonden in 4 mappen.
Overzicht verstuurd naar hans@australius.nl
Account: hans@australius.nl
Ophalen berichten van 2026-08-05 ...
4 berichten gevonden in 3 mappen.
Overzicht verstuurd naar hans@australius.nl
Account: hans@australius.nl
Ophalen berichten van 2026-08-06 ...
7 berichten gevonden in 6 mappen.
Overzicht verstuurd naar hans@australius.nl
Account: hans@australius.nl
Ophalen berichten van 2026-08-07 ...
5 berichten gevonden in 4 mappen.
Overzicht verstuurd naar hans@australius.nl
Account: hans@australius.nl
Ophalen berichten van 2026-08-08 ...
6 berichten gevonden in 5 mappen.
Overzicht verstuurd naar hans@australius.nl
Account: hans@australius.nl
Ophalen berichten van 2026-08-09 ...
3 berichten gevonden in 3 mappen.
Overzicht verstuurd naar hans@australius.nl
Account: hans@australius.nl
Ophalen berichten van 2026-08-10 ...
4 berichten gevonden in 3 mappen.
Overzicht verstuurd naar hans@australius.nl
Account: hans@australius.nl
Ophalen berichten van 2026-08-11 ...
5 berichten gevonden in 4 mappen.
Overzicht verstuurd naar hans@australius.nl
Account: hans@australius.nl
Ophalen berichten van 2026-08-12 ...
7 berichten gevonden in 6 mappen.
Overzicht verstuurd naar hans@australius.nl
Account: hans@australius.nl
Ophalen berichten van 2026-08-13 ...
7 berichten gevonden in 5 mappen.
Overzicht verstuurd naar hans@australius.nl
Account: hans@australius.nl
Ophalen berichten van 2026-08-14 ...
6 berichten gevonden in 3 mappen.
Overzicht verstuurd naar hans@australius.nl
Account: hans@australius.nl
Ophalen berichten van 2026-08-15 ...
1 berichten gevonden in 1 mappen.
Overzicht verstuurd naar hans@australius.nl
Account: hans@australius.nl
Ophalen berichten van 2026-08-16 ...
3 berichten gevonden in 2 mappen.
Overzicht verstuurd naar hans@australius.nl
+6240
View File
File diff suppressed because it is too large Load Diff
Executable
+394
View File
@@ -0,0 +1,394 @@
#!/usr/bin/env bash
set -euo pipefail
REMOTE_HOST="${REMOTE_HOST:-${VPS_HOST:-}}"
REMOTE_USER="${REMOTE_USER:-${VPS_USER:-}}"
if [ -z "$REMOTE_HOST" ]; then
echo "Set REMOTE_HOST to the SSH host or IP address, for example a local NAS hostname." >&2
exit 2
fi
if [ -z "$REMOTE_USER" ]; then
echo "Set REMOTE_USER to the non-root sudo SSH user." >&2
exit 2
fi
APP_USER="${APP_USER:-mailcat}"
SERVICE_NAME="${SERVICE_NAME:-mailcat-sort-backup}"
ACCOUNT="${ACCOUNT:-backup}"
DAILY_REPORT_TO="${DAILY_REPORT_TO:-hans@australius.nl}"
WEB_HOST="${WEB_HOST:-0.0.0.0}"
WEB_PORT="${WEB_PORT:-4321}"
REMOTE_PATH="${REMOTE_PATH:-/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin}"
SERVICE_MANAGER="${SERVICE_MANAGER:-systemd}"
MAILCAT_HOME="${MAILCAT_HOME:-/share/homes/$APP_USER}"
SUDO="${SUDO:-/usr/bin/sudo}"
USERADD="${USERADD:-/usr/local/bin/useradd}"
INSTALL="${INSTALL:-/usr/bin/install}"
SED="${SED:-/bin/sed}"
TAR="${TAR:-/bin/tar}"
MKDIR="${MKDIR:-/bin/mkdir}"
CHOWN="${CHOWN:-/bin/chown}"
CHMOD="${CHMOD:-/bin/chmod}"
RM="${RM:-/bin/rm}"
SYSTEMCTL="${SYSTEMCTL:-/bin/systemctl}"
if [ -z "${APP_DIR:-}" ]; then
if [ "$SERVICE_MANAGER" = "plain" ]; then
APP_DIR="$MAILCAT_HOME/mailcat"
else
APP_DIR="/opt/mailcat"
fi
fi
if [ "$SERVICE_MANAGER" = "plain" ]; then
STATE_DIR="${STATE_DIR:-$APP_DIR/state}"
LOG_DIR="${LOG_DIR:-$APP_DIR/log}"
RUN_DIR="${RUN_DIR:-$APP_DIR/run}"
PID_FILE="${PID_FILE:-$RUN_DIR/mailcat.pid}"
WEB_PID_FILE="${WEB_PID_FILE:-$RUN_DIR/mailcat_web.pid}"
WEB_CONFIG="${WEB_CONFIG:-$APP_DIR/web_config.json}"
PYTHON="${PYTHON:-/opt/bin/python3}"
else
STATE_DIR="${STATE_DIR:-/var/lib/mailcat}"
LOG_DIR="${LOG_DIR:-/var/log/mailcat}"
RUN_DIR="${RUN_DIR:-/run/mailcat}"
PID_FILE="${PID_FILE:-$RUN_DIR/mailcat.pid}"
WEB_PID_FILE="${WEB_PID_FILE:-$RUN_DIR/mailcat_web.pid}"
WEB_CONFIG="${WEB_CONFIG:-$APP_DIR/web_config.json}"
PYTHON="${PYTHON:-/usr/bin/python3}"
fi
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ARCHIVE="$(mktemp -t mailcat-deploy.XXXXXX.tar.gz)"
REMOTE_SCRIPT="$(mktemp -t mailcat-remote-deploy.XXXXXX.sh)"
cleanup() {
rm -f "$ARCHIVE"
rm -f "$REMOTE_SCRIPT"
}
trap cleanup EXIT
tar \
--exclude ".git" \
--exclude "__pycache__" \
--exclude "mailbox" \
--exclude "config.json" \
--exclude "web_config.json" \
--exclude "*.log" \
--exclude "backup_log.json" \
--exclude "verplaats_log*.json" \
--exclude "sort_mail_daemon_state.json" \
--exclude "mailing_lists_cache*.json" \
--exclude "*.pyc" \
-C "$ROOT_DIR" \
-czf "$ARCHIVE" .
cat > "$REMOTE_SCRIPT" <<'REMOTE'
set -eu
export PATH="$REMOTE_PATH:$PATH"
"$SUDO" -v
find_command() {
name="$1"
shift
if command -v "$name" >/dev/null 2>&1; then
command -v "$name"
return 0
fi
for candidate in "$@"; do
if [ -x "$candidate" ]; then
printf '%s\n' "$candidate"
return 0
fi
done
return 1
}
if ! id "$APP_USER" >/dev/null 2>&1; then
if [ -x "$USERADD" ] || USERADD="$(find_command useradd /usr/local/bin/useradd /usr/local/sbin/useradd /usr/sbin/useradd /sbin/useradd)"; then
NOLOGIN="$(find_command nologin /usr/local/bin/nologin /usr/local/sbin/nologin /usr/sbin/nologin /sbin/nologin || true)"
if [ -n "$NOLOGIN" ]; then
"$SUDO" "$USERADD" -d "$MAILCAT_HOME" -s "$NOLOGIN" "$APP_USER"
else
"$SUDO" "$USERADD" -d "$MAILCAT_HOME" "$APP_USER"
fi
else
APP_USER="$(id -un)"
echo "useradd not found on remote host; using existing SSH user '$APP_USER' for the service." >&2
fi
fi
APP_GROUP="$(id -gn "$APP_USER")"
"$SUDO" "$MKDIR" -p "$APP_DIR" "$STATE_DIR" "$LOG_DIR" "$RUN_DIR" "$APP_DIR/bin"
"$SUDO" "$TAR" -xzf /tmp/mailcat-deploy.tar.gz -C "$APP_DIR"
"$SUDO" "$CHOWN" -R "$APP_USER:$APP_GROUP" "$MAILCAT_HOME" "$APP_DIR" "$STATE_DIR" "$LOG_DIR" "$RUN_DIR"
"$SUDO" "$CHMOD" 750 "$APP_DIR"
if [ ! -f "$APP_DIR/config.json" ]; then
echo "Missing $APP_DIR/config.json on remote host. Create it with mode 600 before starting the service." >&2
fi
"$SUDO" "$CHMOD" 600 "$APP_DIR/web_config.json" 2>/dev/null || true
"$SUDO" "$CHOWN" "$APP_USER:$APP_GROUP" "$APP_DIR/web_config.json" 2>/dev/null || true
if [ ! -f "$APP_DIR/web_config.json" ]; then
echo "Missing $APP_DIR/web_config.json on remote host. Create it with mode 600 before starting the web UI." >&2
fi
"$SUDO" "$CHMOD" 600 "$APP_DIR/config.json" 2>/dev/null || true
"$SUDO" "$CHOWN" "$APP_USER:$APP_GROUP" "$APP_DIR/config.json" 2>/dev/null || true
if [ "$SERVICE_MANAGER" = "plain" ]; then
cat > /tmp/mailcat-start.sh <<START_SCRIPT
#!/bin/sh
set -eu
APP_DIR="$APP_DIR"
ACCOUNT="$ACCOUNT"
PYTHON="$PYTHON"
STATE_DIR="$STATE_DIR"
LOG_DIR="$LOG_DIR"
RUN_DIR="$RUN_DIR"
PID_FILE="$PID_FILE"
MKDIR="$MKDIR"
"\$MKDIR" -p "\$STATE_DIR" "\$LOG_DIR" "\$RUN_DIR"
if [ -f "\$PID_FILE" ]; then
old_pid=""
IFS= read -r old_pid < "\$PID_FILE" || true
if [ -n "\$old_pid" ] && kill -0 "\$old_pid" 2>/dev/null; then
echo "mailcat already running: \$old_pid"
exit 0
fi
fi
cd "\$APP_DIR"
"\$PYTHON" "\$APP_DIR/sort_mail_daemon.py" --account "\$ACCOUNT" --folder INBOX --state-file "\$STATE_DIR/sort_mail_daemon_state.json" --log-file "\$LOG_DIR/sort_mail_daemon.log" >> "\$LOG_DIR/daemon.out" 2>&1 &
pid="\$!"
echo "\$pid" > "\$PID_FILE"
echo "mailcat started: \$pid"
START_SCRIPT
cat > /tmp/mailcat-stop.sh <<STOP_SCRIPT
#!/bin/sh
set -eu
PID_FILE="$PID_FILE"
RM="$RM"
if [ ! -f "\$PID_FILE" ]; then
echo "mailcat not running: no pid file"
exit 0
fi
pid=""
IFS= read -r pid < "\$PID_FILE" || true
if [ -z "\$pid" ]; then
"\$RM" -f "\$PID_FILE"
echo "mailcat not running: empty pid file removed"
exit 0
fi
if kill -0 "\$pid" 2>/dev/null; then
kill "\$pid"
timeout=20
while [ "\$timeout" -gt 0 ] && kill -0 "\$pid" 2>/dev/null; do
sleep 1
timeout=\$((timeout - 1))
done
if kill -0 "\$pid" 2>/dev/null; then
echo "mailcat still running after SIGTERM, sending SIGKILL: \$pid"
kill -KILL "\$pid" 2>/dev/null || true
timeout=5
while [ "\$timeout" -gt 0 ] && kill -0 "\$pid" 2>/dev/null; do
sleep 1
timeout=\$((timeout - 1))
done
if kill -0 "\$pid" 2>/dev/null; then
echo "mailcat still running after SIGKILL: \$pid"
exit 1
fi
fi
fi
"\$RM" -f "\$PID_FILE"
echo "mailcat stopped"
STOP_SCRIPT
cat > /tmp/mailcat-status.sh <<STATUS_SCRIPT
#!/bin/sh
set -eu
PID_FILE="$PID_FILE"
LOG_DIR="$LOG_DIR"
if [ -f "\$PID_FILE" ]; then
pid=""
IFS= read -r pid < "\$PID_FILE" || true
if [ -n "\$pid" ] && kill -0 "\$pid" 2>/dev/null; then
echo "mailcat running: \$pid"
exit 0
fi
echo "mailcat not running: stale pid file (\$pid)"
exit 1
fi
echo "mailcat not running"
echo "logs: \$LOG_DIR"
exit 1
STATUS_SCRIPT
cat > /tmp/mailcat-daily-report.sh <<REPORT_SCRIPT
#!/bin/sh
set -eu
APP_DIR="$APP_DIR"
ACCOUNT="$ACCOUNT"
PYTHON="$PYTHON"
LOG_DIR="$LOG_DIR"
MAIL_TO="$DAILY_REPORT_TO"
MKDIR="$MKDIR"
"\$MKDIR" -p "\$LOG_DIR"
cd "\$APP_DIR"
"\$PYTHON" "\$APP_DIR/dagelijks_overzicht.py" --account "\$ACCOUNT" --mail-to "\$MAIL_TO" >> "\$LOG_DIR/daily_report.out" 2>&1
REPORT_SCRIPT
cat > /tmp/mailcat-web-start.sh <<WEB_START_SCRIPT
#!/bin/sh
set -eu
APP_DIR="$APP_DIR"
PYTHON="$PYTHON"
LOG_DIR="$LOG_DIR"
RUN_DIR="$RUN_DIR"
WEB_PID_FILE="$WEB_PID_FILE"
WEB_HOST="$WEB_HOST"
WEB_PORT="$WEB_PORT"
WEB_CONFIG="$WEB_CONFIG"
MKDIR="$MKDIR"
if [ ! -f "\$WEB_CONFIG" ]; then
echo "missing web config: \$WEB_CONFIG"
exit 1
fi
"\$MKDIR" -p "\$LOG_DIR" "\$RUN_DIR"
if [ -f "\$WEB_PID_FILE" ]; then
old_pid=""
IFS= read -r old_pid < "\$WEB_PID_FILE" || true
if [ -n "\$old_pid" ] && kill -0 "\$old_pid" 2>/dev/null; then
echo "mailcat web already running: \$old_pid"
exit 0
fi
fi
cd "\$APP_DIR"
"\$PYTHON" "\$APP_DIR/manage_routes_web.py" --host "\$WEB_HOST" --port "\$WEB_PORT" --config "\$WEB_CONFIG" >> "\$LOG_DIR/web.out" 2>&1 &
pid="\$!"
echo "\$pid" > "\$WEB_PID_FILE"
echo "mailcat web started: \$pid"
WEB_START_SCRIPT
cat > /tmp/mailcat-web-stop.sh <<WEB_STOP_SCRIPT
#!/bin/sh
set -eu
WEB_PID_FILE="$WEB_PID_FILE"
RM="$RM"
if [ ! -f "\$WEB_PID_FILE" ]; then
echo "mailcat web not running: no pid file"
exit 0
fi
pid=""
IFS= read -r pid < "\$WEB_PID_FILE" || true
if [ -z "\$pid" ]; then
"\$RM" -f "\$WEB_PID_FILE"
echo "mailcat web not running: empty pid file removed"
exit 0
fi
if kill -0 "\$pid" 2>/dev/null; then
kill "\$pid"
timeout=20
while [ "\$timeout" -gt 0 ] && kill -0 "\$pid" 2>/dev/null; do
sleep 1
timeout=\$((timeout - 1))
done
if kill -0 "\$pid" 2>/dev/null; then
echo "mailcat web still running after SIGTERM, sending SIGKILL: \$pid"
kill -KILL "\$pid" 2>/dev/null || true
timeout=5
while [ "\$timeout" -gt 0 ] && kill -0 "\$pid" 2>/dev/null; do
sleep 1
timeout=\$((timeout - 1))
done
if kill -0 "\$pid" 2>/dev/null; then
echo "mailcat web still running after SIGKILL: \$pid"
exit 1
fi
fi
fi
"\$RM" -f "\$WEB_PID_FILE"
echo "mailcat web stopped"
WEB_STOP_SCRIPT
cat > /tmp/mailcat-web-status.sh <<WEB_STATUS_SCRIPT
#!/bin/sh
set -eu
WEB_PID_FILE="$WEB_PID_FILE"
WEB_HOST="$WEB_HOST"
WEB_PORT="$WEB_PORT"
LOG_DIR="$LOG_DIR"
if [ -f "\$WEB_PID_FILE" ]; then
pid=""
IFS= read -r pid < "\$WEB_PID_FILE" || true
if [ -n "\$pid" ] && kill -0 "\$pid" 2>/dev/null; then
echo "mailcat web running: \$pid"
echo "url: http://\$WEB_HOST:\$WEB_PORT/"
exit 0
fi
echo "mailcat web not running: stale pid file (\$pid)"
exit 1
fi
echo "mailcat web not running"
echo "logs: \$LOG_DIR/web.out"
exit 1
WEB_STATUS_SCRIPT
"$SUDO" "$INSTALL" -m 0755 /tmp/mailcat-start.sh "$APP_DIR/bin/start_mailcat.sh"
"$SUDO" "$INSTALL" -m 0755 /tmp/mailcat-stop.sh "$APP_DIR/bin/stop_mailcat.sh"
"$SUDO" "$INSTALL" -m 0755 /tmp/mailcat-status.sh "$APP_DIR/bin/status_mailcat.sh"
"$SUDO" "$INSTALL" -m 0755 /tmp/mailcat-daily-report.sh "$APP_DIR/bin/run_daily_report.sh"
"$SUDO" "$INSTALL" -m 0755 /tmp/mailcat-web-start.sh "$APP_DIR/bin/start_web.sh"
"$SUDO" "$INSTALL" -m 0755 /tmp/mailcat-web-stop.sh "$APP_DIR/bin/stop_web.sh"
"$SUDO" "$INSTALL" -m 0755 /tmp/mailcat-web-status.sh "$APP_DIR/bin/status_web.sh"
"$SUDO" "$CHOWN" "$APP_USER:$APP_GROUP" "$APP_DIR/bin/start_mailcat.sh" "$APP_DIR/bin/stop_mailcat.sh" "$APP_DIR/bin/status_mailcat.sh" "$APP_DIR/bin/run_daily_report.sh" "$APP_DIR/bin/start_web.sh" "$APP_DIR/bin/stop_web.sh" "$APP_DIR/bin/status_web.sh"
"$RM" -f /tmp/mailcat-start.sh /tmp/mailcat-stop.sh /tmp/mailcat-status.sh /tmp/mailcat-daily-report.sh /tmp/mailcat-web-start.sh /tmp/mailcat-web-stop.sh /tmp/mailcat-web-status.sh
else
"$SUDO" "$INSTALL" -m 0644 "$APP_DIR/systemd/mailcat-sort.service" "/etc/systemd/system/$SERVICE_NAME.service"
"$SUDO" "$SED" -i \
-e "s#__APP_USER__#$APP_USER#g" \
-e "s#__APP_GROUP__#$APP_GROUP#g" \
-e "s#__APP_DIR__#$APP_DIR#g" \
-e "s#__ACCOUNT__#$ACCOUNT#g" \
-e "s#__PYTHON__#$PYTHON#g" \
-e "s#__STATE_DIR__#$STATE_DIR#g" \
-e "s#__LOG_DIR__#$LOG_DIR#g" \
"/etc/systemd/system/$SERVICE_NAME.service"
"$SUDO" "$SYSTEMCTL" daemon-reload
"$SUDO" "$SYSTEMCTL" enable "$SERVICE_NAME.service"
fi
"$RM" -f /tmp/mailcat-deploy.tar.gz /tmp/mailcat-remote-deploy.sh
echo "Deployment complete."
echo "Create/check $APP_DIR/config.json, then run:"
if [ "$SERVICE_MANAGER" = "plain" ]; then
echo " sudo -u $APP_USER $APP_DIR/bin/start_mailcat.sh"
echo " sudo -u $APP_USER $APP_DIR/bin/status_mailcat.sh"
echo " sudo -u $APP_USER $APP_DIR/bin/run_daily_report.sh"
echo " sudo -u $APP_USER $APP_DIR/bin/start_web.sh"
echo " sudo -u $APP_USER $APP_DIR/bin/status_web.sh"
echo " tail -f $LOG_DIR/sort_mail_daemon.log"
else
echo " sudo systemctl start $SERVICE_NAME.service"
echo " sudo journalctl -u $SERVICE_NAME.service -f"
fi
REMOTE
scp "$ARCHIVE" "$REMOTE_USER@$REMOTE_HOST:/tmp/mailcat-deploy.tar.gz"
scp "$REMOTE_SCRIPT" "$REMOTE_USER@$REMOTE_HOST:/tmp/mailcat-remote-deploy.sh"
REMOTE_COMMAND=$(printf "APP_USER=%q APP_DIR=%q SERVICE_NAME=%q ACCOUNT=%q DAILY_REPORT_TO=%q WEB_HOST=%q WEB_PORT=%q REMOTE_PATH=%q SERVICE_MANAGER=%q MAILCAT_HOME=%q STATE_DIR=%q LOG_DIR=%q RUN_DIR=%q PID_FILE=%q WEB_PID_FILE=%q WEB_CONFIG=%q PYTHON=%q SUDO=%q USERADD=%q INSTALL=%q SED=%q TAR=%q MKDIR=%q CHOWN=%q CHMOD=%q RM=%q SYSTEMCTL=%q /bin/sh /tmp/mailcat-remote-deploy.sh" "$APP_USER" "$APP_DIR" "$SERVICE_NAME" "$ACCOUNT" "$DAILY_REPORT_TO" "$WEB_HOST" "$WEB_PORT" "$REMOTE_PATH" "$SERVICE_MANAGER" "$MAILCAT_HOME" "$STATE_DIR" "$LOG_DIR" "$RUN_DIR" "$PID_FILE" "$WEB_PID_FILE" "$WEB_CONFIG" "$PYTHON" "$SUDO" "$USERADD" "$INSTALL" "$SED" "$TAR" "$MKDIR" "$CHOWN" "$CHMOD" "$RM" "$SYSTEMCTL")
ssh -tt "$REMOTE_USER@$REMOTE_HOST" "$REMOTE_COMMAND"
+10
View File
@@ -0,0 +1,10 @@
Account: hans@australius.nl
2026-07-08 10:50:02.099347: Ophalen berichten van 2026-07-07 ...
12 berichten gevonden in 7 mappen.
Overzicht verstuurd naar hans@australius.nl
Account: hans@australius.nl
2026-07-08 13:35:09.360753: Ophalen berichten van 2026-07-07 ...
12 berichten gevonden in 7 mappen.
Overzicht verstuurd naar hans@australius.nl
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""
Download alle e-mails van een IMAP-account naar lokale .eml-bestanden.
Gebruik: python download_mailbox.py
Resultaat: mailbox/<mapnaam>/<id>.eml
Het script is herstart-safe: al gedownloade mails worden overgeslagen.
"""
import imaplib
import os
import re
import sys
import getpass
from pathlib import Path
from imap_utils import list_folders, quote_mailbox
IMAP_HOST = "australius.nl"
IMAP_PORT = 993
LOCAL_DIR = Path(__file__).parent / "mailbox"
def safe_path(name: str) -> str:
"""Zet mapnaam om naar een veilige directorynaam."""
return re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", name)
def download():
username = input("E-mailadres: ").strip()
password = getpass.getpass("Wachtwoord: ")
print(f"\nVerbinden met {IMAP_HOST}:{IMAP_PORT} ...")
try:
mail = imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT)
mail.login(username, password)
except Exception as exc:
print(f"Mislukt: {exc}")
sys.exit(1)
print("Ingelogd.\n")
folders = list_folders(mail)
if not folders:
print("Kan mappenlijst niet ophalen.")
sys.exit(1)
print(f"Gevonden mappen ({len(folders)}):")
for f in folders:
print(f" {f}")
print()
total_new = total_skip = total_err = 0
num_folders = len(folders)
for folder_idx, folder in enumerate(folders, 1):
folder_prefix = f"[map {folder_idx}/{num_folders}] {folder}"
status, data = mail.select(quote_mailbox(folder), readonly=True)
if status != "OK":
print(f"{folder_prefix}: kan niet openen overgeslagen.")
continue
num = int(data[0])
local_dir = LOCAL_DIR / safe_path(folder)
local_dir.mkdir(parents=True, exist_ok=True)
if num == 0:
print(f"{folder_prefix}: leeg.")
continue
status, data = mail.search(None, "ALL")
if status != "OK":
print(f"{folder_prefix}: zoeken mislukt overgeslagen.")
continue
ids = data[0].split()
num_to_fetch = sum(1 for uid in ids if not (local_dir / f"{uid.decode()}.eml").exists())
new = skip = err = 0
for msg_idx, uid in enumerate(ids, 1):
path = local_dir / f"{uid.decode()}.eml"
if path.exists():
skip += 1
continue
done = new + err + 1
print(f"\r{folder_prefix}: bericht {done}/{num_to_fetch} ", end="", flush=True)
try:
status, msg_data = mail.fetch(uid, "(RFC822)")
if status == "OK" and msg_data and msg_data[0]:
path.write_bytes(msg_data[0][1])
new += 1
else:
err += 1
except Exception:
err += 1
total_new += new
total_skip += skip
total_err += err
parts = [f"{new} nieuw", f"{skip} al aanwezig"]
if err:
parts.append(f"{err} fout")
print(f"\r{folder_prefix}: " + ", ".join(parts) + " ")
mail.logout()
print(f"\nKlaar. {total_new} gedownload, {total_skip} overgeslagen", end="")
if total_err:
print(f", {total_err} fouten", end="")
print(f".\nOpgeslagen in: {LOCAL_DIR.resolve()}")
if __name__ == "__main__":
LOCAL_DIR.mkdir(exist_ok=True)
download()
+341
View File
@@ -0,0 +1,341 @@
#!/usr/bin/env python3
"""
Kopieert alle mail van een bronaccount naar een doelaccount.
Gebruik: python3 kopieer_naar_backup.py --van hans --naar backup
Bron en doel worden opgezocht in config.json (aanmaken via python3 config.py --setup).
- Mappenstructuur wordt gespiegeld
- Vlaggen (gelezen, gemarkeerd, etc.) en ontvangstdatum worden bewaard
- Herstart-safe: berichten die al in het doel staan (zelfde Message-ID)
worden overgeslagen
- Niets wordt verwijderd of gewijzigd in de bronmailbox
"""
import imaplib
import email
import email.header
import email.utils
import re
import sys
import json
import time
import config
from pathlib import Path
from datetime import datetime, timezone
from imap_utils import list_folders, quote_mailbox
LOG_FILE = Path(__file__).parent / "backup_log.json"
# Mappen die overgeslagen worden (Trash en Spam zijn optioneel)
SKIP_MAPPEN = {"INBOX.Trash", "INBOX.Spam"}
# ── helpers ───────────────────────────────────────────────────────────────────
def parse_flags(flag_str: str) -> list[str]:
"""Haal standaard IMAP-vlaggen op uit fetch-response."""
return re.findall(r'\\[A-Za-z]+', flag_str)
def parse_internaldate(response: bytes) -> str | None:
"""Haal INTERNALDATE op uit fetch-response voor gebruik in APPEND."""
m = re.search(rb'INTERNALDATE "([^"]+)"', response)
if m:
try:
dt = email.utils.parsedate_to_datetime(m.group(1).decode())
return imaplib.Time2Internaldate(dt.timestamp())
except Exception:
pass
return None
def haal_bestaande_message_ids(mail: imaplib.IMAP4_SSL, folder: str) -> set[str]:
"""Geef alle Message-IDs terug die al in een doelmap staan."""
status, data = mail.select(quote_mailbox(folder), readonly=True)
if status != "OK" or int(data[0]) == 0:
return set()
status, data = mail.search(None, "ALL")
if status != "OK" or not data[0]:
return set()
ids = data[0].split()
msg_ids: set[str] = set()
# Batch ophalen in stukken van 100
for i in range(0, len(ids), 100):
batch = b",".join(ids[i:i+100])
status, data = mail.fetch(batch, "(BODY.PEEK[HEADER.FIELDS (MESSAGE-ID)])")
if status != "OK":
continue
for item in data:
if isinstance(item, tuple):
msg = email.message_from_bytes(item[1])
mid = (msg.get("Message-ID") or "").strip()
if mid:
msg_ids.add(mid)
return msg_ids
# ── kopieerlogica ─────────────────────────────────────────────────────────────
def kopieer_map(
bron: imaplib.IMAP4_SSL,
doel: imaplib.IMAP4_SSL,
folder: str,
log: dict,
) -> tuple[int, int, int]:
"""
Kopieer berichten van bron-folder naar doel-folder.
Geeft (gekopieerd, overgeslagen, mislukt) terug.
"""
# Selecteer bronmap
status, data = bron.select(quote_mailbox(folder), readonly=True)
if status != "OK":
print(f" Kan bronmap niet openen: {folder}")
log.setdefault("fouten", []).append({
"folder": folder,
"actie": "select_bron",
"status": status,
"response": repr(data),
})
return 0, 0, 1
totaal = int(data[0])
if totaal == 0:
return 0, 0, 0
# Haal alle bron-UIDs op
status, data = bron.search(None, "ALL")
if status != "OK" or not data[0]:
log.setdefault("fouten", []).append({
"folder": folder,
"actie": "search_bron",
"status": status,
"response": repr(data),
})
return 0, 0, 1
ids = data[0].split()
# Zorg dat doelmap bestaat
create_status, create_data = doel.create(quote_mailbox(folder))
if create_status not in {"OK", "NO"}:
log.setdefault("fouten", []).append({
"folder": folder,
"actie": "create_doel",
"status": create_status,
"response": repr(create_data),
})
return 0, 0, 1
select_status, select_data = doel.select(quote_mailbox(folder), readonly=True)
if select_status != "OK":
log.setdefault("fouten", []).append({
"folder": folder,
"actie": "select_doel_na_create",
"status": select_status,
"response": repr(select_data),
})
return 0, 0, 1
# Welke Message-IDs staan al in de doelmap?
bestaande = haal_bestaande_message_ids(doel, folder)
gekopieerd = 0
overgeslagen = 0
mislukt = 0
for i, uid in enumerate(ids, 1):
# Voortgang op één regel
print(f"\r {folder}: {i}/{totaal} ({gekopieerd} gekopieerd, {overgeslagen} skip, {mislukt} fout) ",
end="", flush=True)
# Haal volledige bericht op + vlaggen + datum
status, data = bron.fetch(
uid, "(FLAGS INTERNALDATE BODY.PEEK[])"
)
if status != "OK" or not data:
mislukt += 1
log.setdefault("fouten", []).append({
"folder": folder,
"uid": uid.decode(errors="replace"),
"actie": "fetch_bron",
"status": status,
"response": repr(data),
})
continue
# Parse fetch-response
raw_response = b""
raw_message = b""
flags = []
internaldate = None
for item in data:
if isinstance(item, tuple):
header_part = item[0].decode("utf-8", errors="replace")
raw_message = item[1]
flags = parse_flags(header_part)
internaldate = parse_internaldate(item[0])
if not raw_message:
mislukt += 1
log.setdefault("fouten", []).append({
"folder": folder,
"uid": uid.decode(errors="replace"),
"actie": "fetch_lege_body",
"status": status,
"response": repr(data),
})
continue
# Controleer Message-ID voor deduplicatie
try:
msg = email.message_from_bytes(raw_message[:2048])
mid = (msg.get("Message-ID") or "").strip()
except Exception:
mid = ""
if mid and mid in bestaande:
overgeslagen += 1
continue
# Schrijf naar doelmap
flag_str = "(" + " ".join(flags) + ")" if flags else "()"
status, append_data = doel.append(
quote_mailbox(folder),
flag_str,
internaldate,
raw_message,
)
if status == "OK":
gekopieerd += 1
if mid:
bestaande.add(mid)
else:
mislukt += 1
log.setdefault("fouten", []).append({
"folder": folder,
"uid": uid.decode(errors="replace"),
"actie": "append_doel",
"message_id": mid,
"status": status,
"response": repr(append_data),
})
# Even pauzeren elke 50 berichten om server te ontlasten
if gekopieerd % 50 == 0 and gekopieerd > 0:
time.sleep(0.2)
print(f"\r {folder}: {totaal}/{totaal}{gekopieerd} gekopieerd, {overgeslagen} al aanwezig, {mislukt} fout ")
return gekopieerd, overgeslagen, mislukt
# ── log ───────────────────────────────────────────────────────────────────────
def load_log() -> dict:
if LOG_FILE.exists():
try:
return json.loads(LOG_FILE.read_text(encoding="utf-8"))
except Exception:
pass
return {"voltooide_mappen": [], "totaal_gekopieerd": 0}
def save_log(log: dict):
LOG_FILE.write_text(json.dumps(log, indent=2, ensure_ascii=False), encoding="utf-8")
# ── main ──────────────────────────────────────────────────────────────────────
def _account_name(flag: str, default: str) -> str:
args = sys.argv[1:]
if flag in args:
idx = args.index(flag)
if idx + 1 < len(args):
return args[idx + 1]
return default
def run():
bron_naam = _account_name("--van", "hans")
doel_naam = _account_name("--naar", "backup")
bron_cfg = config.load(bron_naam)
doel_cfg = config.load(doel_naam)
print(f"Bron : {bron_cfg.username}")
print(f"Doel : {doel_cfg.username}")
print(f"Host : {bron_cfg.imap_host}:{bron_cfg.imap_port}\n")
print("Verbinden ...")
try:
bron = imaplib.IMAP4_SSL(bron_cfg.imap_host, bron_cfg.imap_port)
bron.login(bron_cfg.username, bron_cfg.password)
print(f"{bron_cfg.username}")
except Exception as e:
print(f" ✗ Bron: {e}")
sys.exit(1)
try:
doel = imaplib.IMAP4_SSL(doel_cfg.imap_host, doel_cfg.imap_port)
doel.login(doel_cfg.username, doel_cfg.password)
print(f"{doel_cfg.username}")
except Exception as e:
print(f" ✗ Doel: {e}")
bron.logout()
sys.exit(1)
# Mappenlijst van bron
alle_mappen = [folder for folder in list_folders(bron) if folder not in SKIP_MAPPEN]
alle_mappen.sort()
log = load_log()
al_klaar = set(log.get("voltooide_mappen", []))
print(f"\n{len(alle_mappen)} mappen gevonden "
f"({len(al_klaar)} al eerder voltooid)\n")
totaal_gekopieerd = log.get("totaal_gekopieerd", 0)
totaal_overgeslagen = 0
totaal_mislukt = 0
for i, folder in enumerate(alle_mappen, 1):
print(f"[{i}/{len(alle_mappen)}] {folder}")
if folder in al_klaar:
print(f" ✓ al volledig gekopieerd, overgeslagen.\n")
continue
gekopieerd, overgeslagen, mislukt = kopieer_map(bron, doel, folder, log)
totaal_gekopieerd += gekopieerd
totaal_overgeslagen += overgeslagen
totaal_mislukt += mislukt
if mislukt == 0:
log["voltooide_mappen"] = list(al_klaar | {folder})
al_klaar.add(folder)
else:
log["voltooide_mappen"] = list(al_klaar)
print(f" ! niet als voltooid gemarkeerd door {mislukt} fout(en)")
log["totaal_gekopieerd"] = totaal_gekopieerd
log["laatste_run"] = datetime.now().isoformat()
save_log(log)
print()
bron.logout()
doel.logout()
print("" * 50)
print(f"Klaar.")
print(f" Gekopieerd : {totaal_gekopieerd}")
print(f" Al aanwezig : {totaal_overgeslagen}")
print(f" Fouten : {totaal_mislukt}")
print(f" Log : {LOG_FILE}")
if __name__ == "__main__":
run()
+1593
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -0,0 +1,10 @@
Account: backup@australius.nl
Ophalen berichten van 2026-07-03 ...
10 berichten gevonden in 7 mappen.
Overzicht verstuurd naar hans@australius.nl
Account: backup@australius.nl
Ophalen berichten van 2026-07-04 ...
3 berichten gevonden in 3 mappen.
Overzicht verstuurd naar hans@australius.nl
View File
+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
File diff suppressed because it is too large Load Diff
+242
View File
@@ -0,0 +1,242 @@
# mailrules.sieve
# Automatische mailsortering voor hans@australius.nl
# Upload via: python3 upload_sieve.py
#
# Noot: fileinto-paden zijn relatief aan de persoonlijke namespace.
# Op Dovecot-servers met INBOX. als prefix: voeg "INBOX." toe aan elke
# fileinto-waarde als mail niet aankomt (zie SIEVE_PREFIX in upload_sieve.py).
require [
"fileinto", "redirect", "copy",
"date", "variables",
"mime", "foreverypart",
"imap4flags"
];
# ════════════════════════════════════════════════════════════════════════════
# 1. FACTUREN (altijd eerst — vóór nieuwsbrieven en diensten)
# PDF-bijlagen worden doorgestuurd naar boekhouding@australius.nl
# (alleen voor nieuwe mail, geen terugwerkende kracht)
# ════════════════════════════════════════════════════════════════════════════
if anyof (
header :contains "Subject" ["factuur", "Factuur",
"invoice", "Invoice",
"rekening", "Rekening",
"nota", "Nota",
"betaalverzoek", "Betaalverzoek",
"receipt", "Receipt",
"orderbevestiging", "Orderbevestiging",
"order confirmation",
"betaling ontvangen",
"payment received",
"uw factuur", "Uw factuur",
"your invoice", "Your invoice",
"je factuur"]
) {
# Kwartaal bepalen op basis van ontvangstdatum
if currentdate :matches "year" "*" { set "jaar" "${1}"; }
if anyof (currentdate :is "month" "1",
currentdate :is "month" "2",
currentdate :is "month" "3") { set "kwartaal" "Q1"; }
elsif anyof (currentdate :is "month" "4",
currentdate :is "month" "5",
currentdate :is "month" "6") { set "kwartaal" "Q2"; }
elsif anyof (currentdate :is "month" "7",
currentdate :is "month" "8",
currentdate :is "month" "9") { set "kwartaal" "Q3"; }
else { set "kwartaal" "Q4"; }
# PDF aanwezig → doorsturen naar boekhouding (nieuwe mail vanaf activatiedatum)
foreverypart {
if anyof (
header :mime :param "filename" :matches "Content-Disposition" ["*.pdf", "*.PDF"],
header :mime :is "Content-Type" "application/pdf"
) {
redirect :copy "boekhouding@australius.nl";
break;
}
}
fileinto "Financieel.Facturen.${jaar}.${kwartaal}";
stop;
}
# ════════════════════════════════════════════════════════════════════════════
# 2. BANK & VERZEKERING
# ════════════════════════════════════════════════════════════════════════════
if address :domain :is "From" ["nl.abnamro.com", "abnamro.nl"] {
fileinto "Financieel.Bank.ABN AMRO"; stop;
}
if address :domain :is "From" ["icscards.nl", "icsmarketing.icscards.nl",
"service.icscards.nl"] {
fileinto "Financieel.Bank.ICS"; stop;
}
if address :domain :is "From" ["npex.nl"] { fileinto "Financieel.Bank.NPEX"; stop; }
if address :domain :is "From" ["snelstart.nl"] { fileinto "Financieel.Bank.SnelStart"; stop; }
if address :domain :is "From" ["tellow.nl"] { fileinto "Financieel.Bank.Tellow"; stop; }
if address :domain :is "From" ["unive.nl", "nieuwsbrief.unive.nl"] {
fileinto "Financieel.Verzekering.Univé"; stop;
}
if address :domain :is "From" ["heinenoord.nl"] {
fileinto "Financieel.Verzekering.VEZA"; stop;
}
# ════════════════════════════════════════════════════════════════════════════
# 3. WERK / OPDRACHTEN
# ════════════════════════════════════════════════════════════════════════════
if address :domain :is "From" ["flextender.nl"] { fileinto "Werk.Opdrachten.Flextender"; stop; }
if address :domain :is "From" ["onestopsourcing.nl"] { fileinto "Werk.Opdrachten.OneStopSourcing"; stop; }
if address :domain :is "From" ["immens.nu"] { fileinto "Werk.Opdrachten.IMMENS"; stop; }
if address :domain :is "From" ["circle8.nl"] { fileinto "Werk.Opdrachten.Circle8"; stop; }
if address :domain :is "From" ["getthere.nl"] { fileinto "Werk.Opdrachten.Get There"; stop; }
if address :domain :is "From" ["huckr.ai"] { fileinto "Werk.Opdrachten.Huckr"; stop; }
if address :domain :is "From" ["hey.meetjamie.ai"] { fileinto "Werk.Opdrachten.Jamie"; stop; }
if address :domain :is "From" ["futurexl.nl"] { fileinto "Werk.Opdrachten.FutureXL"; stop; }
if address :domain :is "From" ["boip.int"] { fileinto "Werk.Overig.BOIP"; stop; }
if address :domain :is "From" ["groningen.nl", "groningenbereikbaar.nl"] {
fileinto "Werk.Overig.Gemeente Groningen"; stop;
}
# ════════════════════════════════════════════════════════════════════════════
# 4. MOBILITEIT / EV
# ════════════════════════════════════════════════════════════════════════════
if address :domain :is "From" ["eneco-emobility.com", "eneco.nl"] {
fileinto "Mobiliteit.EV.Eneco eMobility"; stop;
}
if address :domain :is "From" ["shellrecharge.com"] { fileinto "Mobiliteit.EV.Shell Recharge"; stop; }
if address :domain :is "From" ["fastned.nl"] { fileinto "Mobiliteit.EV.FastNed"; stop; }
if address :domain :is "From" ["opngo.com"] { fileinto "Mobiliteit.EV.OPnGO"; stop; }
if address :domain :is "From" ["tesla.com"] { fileinto "Mobiliteit.EV.Tesla"; stop; }
if address :domain :is "From" ["mmsn.nl"] { fileinto "Mobiliteit.EV.Mitsubishi"; stop; }
if address :domain :is "From" ["alfen.com"] { fileinto "Mobiliteit.EV.Alfen"; stop; }
# ════════════════════════════════════════════════════════════════════════════
# 5. MOBILITEIT / OV & REIZEN
# ════════════════════════════════════════════════════════════════════════════
if address :domain :is "From" ["ns.nl", "email.ns.nl"] {
fileinto "Mobiliteit.OV & Reizen.NS"; stop;
}
if address :domain :is "From" ["booking.com"] { fileinto "Mobiliteit.OV & Reizen.Booking"; stop; }
if address :domain :is "From" ["flitsmeister.nl"]{ fileinto "Mobiliteit.OV & Reizen.Flitsmeister"; stop; }
# ════════════════════════════════════════════════════════════════════════════
# 6. BESTELLINGEN & BEZORGING
# ════════════════════════════════════════════════════════════════════════════
if address :domain :is "From" ["postnl.nl", "edm.postnl.nl",
"notificatie.postnl.nl"] { fileinto "Bestellingen.PostNL"; stop; }
if address :domain :is "From" ["dhlparcel.nl", "dhlecommerce.nl"] { fileinto "Bestellingen.DHL"; stop; }
if address :domain :is "From" ["gls-netherlands.com"] { fileinto "Bestellingen.GLS"; stop; }
if address :domain :is "From" ["bol.com", "feedback.bol.com"] { fileinto "Bestellingen.bol"; stop; }
if address :domain :is "From" ["coolblue.nl", "coolblue.eu"] { fileinto "Bestellingen.Coolblue"; stop; }
if address :domain :is "From" ["allekabels.nl"] { fileinto "Bestellingen.Allekabels"; stop; }
if address :domain :is "From" ["vikingdirect.nl",
"delivery.vikingdirect.nl",
"online.vikingdirect.nl"] { fileinto "Bestellingen.Viking"; stop; }
if address :domain :is "From" ["123inkt.nl", "m.123inkt.nl"] { fileinto "Bestellingen.123inkt"; stop; }
if address :domain :is "From" ["officecentre.nl"] { fileinto "Bestellingen.Office Centre"; stop; }
if address :domain :is "From" ["sligro.nl"] { fileinto "Bestellingen.Sligro"; stop; }
if address :domain :is "From" ["makro.nl"] { fileinto "Bestellingen.Makro"; stop; }
if address :domain :is "From" ["smartphonehoesjes.nl"] { fileinto "Bestellingen.Smartphonehoesjes"; stop; }
# ════════════════════════════════════════════════════════════════════════════
# 7. DIENSTEN / HOSTING
# ════════════════════════════════════════════════════════════════════════════
if address :domain :is "From" ["strato.com", "news.strato.com",
"marketingradar.strato.com"] { fileinto "Diensten.Hosting.STRATO"; stop; }
if address :domain :is "From" ["transip.nl"] { fileinto "Diensten.Hosting.TransIP"; stop; }
if address :domain :is "From" ["letsencrypt.org"] { fileinto "Diensten.Hosting.Lets Encrypt"; stop; }
if address :domain :is "From" ["backblaze.com"] { fileinto "Diensten.Hosting.Backblaze"; stop; }
# ════════════════════════════════════════════════════════════════════════════
# 8. DIENSTEN / TELECOM
# ════════════════════════════════════════════════════════════════════════════
if address :domain :is "From" ["kpn.com", "campagnes.kpn.com"] { fileinto "Diensten.Telecom.KPN"; stop; }
if address :domain :is "From" ["zakelijk.vodafone.nl",
"vodafone.nl"] { fileinto "Diensten.Telecom.Vodafone"; stop; }
# ════════════════════════════════════════════════════════════════════════════
# 9. DIENSTEN / SOFTWARE
# ════════════════════════════════════════════════════════════════════════════
if address :domain :is "From" ["cursor.com"] { fileinto "Diensten.Software.Cursor"; stop; }
if address :domain :is "From" ["1password.com"] { fileinto "Diensten.Software.1Password"; stop; }
if address :domain :is "From" ["home.sophos.com",
"cleverbridge.com"] { fileinto "Diensten.Software.Sophos"; stop; }
if address :domain :is "From" ["cogsciapps.com"] { fileinto "Diensten.Software.CogSci Apps"; stop; }
if address :domain :is "From" ["claude.com"] { fileinto "Diensten.Software.Claude"; stop; }
if address :domain :is "From" ["microsoft365.com"] { fileinto "Diensten.Software.Microsoft Teams"; stop; }
if address :domain :is "From" ["getmatter.com"] { fileinto "Diensten.Software.Matter"; stop; }
if address :domain :is "From" ["mindnode.com"] { fileinto "Diensten.Software.MindNode"; stop; }
if address :domain :is "From" ["papersapp.com"] { fileinto "Diensten.Software.Papers"; stop; }
if address :domain :is "From" ["team.kahoot.com"] { fileinto "Diensten.Software.Kahoot"; stop; }
if address :domain :is "From" ["perplexity.ai"] { fileinto "Diensten.Software.Perplexity"; stop; }
# ════════════════════════════════════════════════════════════════════════════
# 10. NIEUWSBRIEVEN / LEREN
# ════════════════════════════════════════════════════════════════════════════
if address :domain :is "From" ["coursera.org", "email.coursera.org",
"m.mail.coursera.org", "m.learn.coursera.org"] {
fileinto "Nieuwsbrieven.Leren.Coursera"; stop;
}
if address :domain :is "From" ["technologyreview.com",
"fulfillment.technologyreview.com"] {
fileinto "Nieuwsbrieven.Leren.MIT Technology Review"; stop;
}
if address :domain :is "From" ["mit.edu"] { fileinto "Nieuwsbrieven.Leren.MIT Open Learning"; stop; }
if address :domain :is "From" ["storytellingwithdata.com"]{ fileinto "Nieuwsbrieven.Leren.Storytelling with Data"; stop; }
if address :domain :is "From" ["linkingyourthinking.com"] { fileinto "Nieuwsbrieven.Leren.Nick Milo"; stop; }
if anyof (
address :domain :is "From" ["evakeiffenheim.com"],
header :contains "List-ID" "evakeiffenheim"
) {
fileinto "Nieuwsbrieven.Leren.Eva Keiffenheim"; stop;
}
if address :domain :is "From" ["tiggelaar.nl"] { fileinto "Nieuwsbrieven.Leren.Ben Tiggelaar"; stop; }
if address :domain :is "From" ["briankeating.com"] { fileinto "Nieuwsbrieven.Leren.Brian Keating"; stop; }
if address :domain :is "From" ["beehiiv.com", "mail.beehiiv.com"] {
fileinto "Nieuwsbrieven.Leren.AI Report"; stop;
}
if address :domain :is "From" ["elizabethbutlermd.com"] { fileinto "Nieuwsbrieven.Leren.Elizabeth Butler"; stop; }
# ════════════════════════════════════════════════════════════════════════════
# 11. NIEUWSBRIEVEN / NIEUWS & VAKBLADEN
# ════════════════════════════════════════════════════════════════════════════
if address :domain :is "From" ["crisismanager.nl"] { fileinto "Nieuwsbrieven.Nieuws & Vakbladen.Vakblad Crisismanager"; stop; }
if address :domain :is "From" ["jaarbeurs.nl"] { fileinto "Nieuwsbrieven.Nieuws & Vakbladen.Computable"; stop; }
if address :domain :is "From" ["presentation-guru.com"]{ fileinto "Nieuwsbrieven.Nieuws & Vakbladen.The Presentation Guru"; stop; }
# ════════════════════════════════════════════════════════════════════════════
# 12. NIEUWSBRIEVEN / OVERIG
# ════════════════════════════════════════════════════════════════════════════
if address :domain :is "From" ["lendahand.com"] { fileinto "Nieuwsbrieven.Overig.Lendahand"; stop; }
if address :domain :is "From" ["thegoodroll.nl"] { fileinto "Nieuwsbrieven.Overig.The Good Roll"; stop; }
# ════════════════════════════════════════════════════════════════════════════
# 13. TECHNISCH
# ════════════════════════════════════════════════════════════════════════════
if anyof (
header :contains "Subject" ["DMARC", "SPF", "DKIM"],
header :contains "From" "dmarc"
) {
fileinto "Technisch.DMARC"; stop;
}
if anyof (
header :contains "Subject" ["delivery failed", "undeliverable",
"mailer-daemon", "mail delivery"],
address :is "From" "mailer-daemon@australius.nl"
) {
fileinto "Technisch"; stop;
}
+453
View File
@@ -0,0 +1,453 @@
"""Small Basic Auth web UI for managing mailcat domain routes."""
from __future__ import annotations
import argparse
import base64
import html
import json
import os
import secrets
import shutil
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlparse
from mail_routes import DOMAIN_ROUTES_FILE, domain_lookup, domain_routes, normalize_domain_route
DEFAULT_CONFIG_FILE = Path(__file__).parent / "web_config.json"
DEFAULT_HOST = "0.0.0.0"
DEFAULT_PORT = 4321
def validate_routes(raw_routes: list[dict]) -> list[dict]:
"""Return normalized route dictionaries and reject duplicate domains."""
if not isinstance(raw_routes, list):
raise ValueError("Routes must be a list")
normalized: list[dict] = []
seen: dict[str, int] = {}
for index, raw_rule in enumerate(raw_routes):
domains, mailbox = normalize_domain_route(raw_rule)
for domain in domains:
if domain in seen:
other = seen[domain] + 1
raise ValueError(f"Domain {domain!r} is already used by route {other}")
seen[domain] = index
normalized.append({"domains": domains, "mailbox": mailbox})
return normalized
def load_routes(path: Path = DOMAIN_ROUTES_FILE) -> list[dict]:
raw = json.loads(path.read_text(encoding="utf-8"))
return validate_routes(raw)
def save_routes(routes: list[dict], path: Path = DOMAIN_ROUTES_FILE) -> list[dict]:
normalized = validate_routes(routes)
tmp_path = path.with_name(f".{path.name}.tmp")
backup_path = path.with_suffix(f"{path.suffix}.bak")
tmp_path.write_text(json.dumps(normalized, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
if path.exists():
shutil.copy2(path, backup_path)
os.replace(tmp_path, path)
domain_routes.cache_clear()
domain_lookup.cache_clear()
return normalized
def load_web_config(path: Path) -> tuple[str, str]:
config = {}
if path.exists():
config = json.loads(path.read_text(encoding="utf-8"))
username = os.environ.get("MAILCAT_WEB_USER") or config.get("username")
password = os.environ.get("MAILCAT_WEB_PASSWORD") or config.get("password")
if not username or not password:
raise SystemExit(
f"Missing web credentials. Create {path} or set MAILCAT_WEB_USER and MAILCAT_WEB_PASSWORD."
)
return str(username), str(password)
def basic_auth_ok(header: str | None, username: str, password: str) -> bool:
if not header or not header.startswith("Basic "):
return False
try:
decoded = base64.b64decode(header[6:], validate=True).decode("utf-8")
supplied_user, supplied_password = decoded.split(":", 1)
except Exception:
return False
return secrets.compare_digest(supplied_user, username) and secrets.compare_digest(
supplied_password, password
)
def page_html() -> bytes:
return f"""<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Mailcat routes</title>
<style>
:root {{
color-scheme: light;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #f5f6f8;
color: #20242a;
}}
body {{ margin: 0; }}
header {{
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 20px;
background: #17324d;
color: #fff;
}}
h1 {{ font-size: 18px; line-height: 1.2; margin: 0; font-weight: 650; }}
main {{ padding: 18px 20px 28px; max-width: 1280px; margin: 0 auto; }}
.toolbar {{
display: grid;
grid-template-columns: minmax(180px, 1fr) auto auto;
gap: 10px;
align-items: center;
margin-bottom: 14px;
}}
input, textarea {{
width: 100%;
box-sizing: border-box;
font: inherit;
border: 1px solid #b9c1cc;
border-radius: 6px;
padding: 8px 10px;
background: #fff;
color: #20242a;
}}
textarea {{ min-height: 72px; resize: vertical; }}
button {{
border: 1px solid #245782;
background: #245782;
color: #fff;
border-radius: 6px;
padding: 8px 12px;
font: inherit;
cursor: pointer;
white-space: nowrap;
}}
button.secondary {{ background: #fff; color: #245782; }}
button.danger {{ background: #8c2634; border-color: #8c2634; }}
button:disabled {{ opacity: .55; cursor: default; }}
.layout {{ display: grid; grid-template-columns: 1fr 360px; gap: 18px; align-items: start; }}
table {{ width: 100%; border-collapse: collapse; background: #fff; border: 1px solid #d7dce2; }}
th, td {{ text-align: left; vertical-align: top; border-bottom: 1px solid #e2e6eb; padding: 8px 10px; }}
th {{ background: #edf1f5; font-size: 13px; color: #34495e; }}
tr.selected {{ background: #eaf4ff; }}
td.index {{ width: 56px; color: #596775; }}
td.actions {{ width: 96px; text-align: right; }}
.panel {{ background: #fff; border: 1px solid #d7dce2; padding: 14px; }}
.panel h2 {{ margin: 0 0 12px; font-size: 15px; }}
label {{ display: block; margin: 10px 0 6px; font-size: 13px; color: #34495e; }}
.form-actions {{ display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; }}
.status {{ min-height: 20px; font-size: 13px; color: #34495e; }}
.error {{ color: #8c2634; }}
.domains {{ line-height: 1.45; }}
.mailbox {{ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; }}
@media (max-width: 860px) {{
.layout {{ grid-template-columns: 1fr; }}
.toolbar {{ grid-template-columns: 1fr; }}
main {{ padding: 14px; }}
}}
</style>
</head>
<body>
<header>
<h1>Mailcat routes</h1>
<div id="count"></div>
</header>
<main>
<div class="toolbar">
<input id="search" type="search" placeholder="Zoek domein of map">
<button id="newButton" type="button" class="secondary">Nieuwe regel</button>
<button id="reloadButton" type="button" class="secondary">Herladen</button>
</div>
<div class="layout">
<table>
<thead><tr><th>#</th><th>Domeinen</th><th>Map</th><th></th></tr></thead>
<tbody id="routesBody"></tbody>
</table>
<section class="panel">
<h2 id="formTitle">Nieuwe regel</h2>
<form id="routeForm">
<input id="routeIndex" type="hidden">
<label for="domains">Domeinen, een per regel of komma-gescheiden</label>
<textarea id="domains" required></textarea>
<label for="mailbox">Doelmap zonder INBOX.</label>
<input id="mailbox" required placeholder="Diensten.AI.OpenAI">
<div class="form-actions">
<button id="saveButton" type="submit">Opslaan</button>
<button id="deleteButton" type="button" class="danger" disabled>Verwijderen</button>
<button id="clearButton" type="button" class="secondary">Leegmaken</button>
</div>
</form>
<p id="status" class="status"></p>
</section>
</div>
</main>
<script>
let routes = [];
let selectedIndex = null;
const $ = (id) => document.getElementById(id);
function splitDomains(value) {{
return value.split(/[\\n,]/).map((item) => item.trim()).filter(Boolean);
}}
function setStatus(text, isError = false) {{
$("status").textContent = text;
$("status").className = isError ? "status error" : "status";
}}
function clearForm() {{
selectedIndex = null;
$("routeIndex").value = "";
$("domains").value = "";
$("mailbox").value = "";
$("formTitle").textContent = "Nieuwe regel";
$("deleteButton").disabled = true;
renderRoutes();
}}
function editRoute(index) {{
const route = routes[index];
selectedIndex = index;
$("routeIndex").value = String(index);
$("domains").value = route.domains.join("\\n");
$("mailbox").value = route.mailbox;
$("formTitle").textContent = `Regel ${{index + 1}} bewerken`;
$("deleteButton").disabled = false;
renderRoutes();
}}
async function requestJson(url, options = {{}}) {{
const response = await fetch(url, {{
headers: {{ "Content-Type": "application/json", ...(options.headers || {{}}) }},
...options,
}});
const payload = await response.json().catch(() => ({{ error: response.statusText }}));
if (!response.ok) throw new Error(payload.error || response.statusText);
return payload;
}}
async function loadRoutes() {{
const payload = await requestJson("/api/routes");
routes = payload.routes;
$("count").textContent = `${{routes.length}} regels`;
renderRoutes();
setStatus("Geladen.");
}}
function renderRoutes() {{
const needle = $("search").value.trim().toLowerCase();
const rows = routes
.map((route, index) => ({{ route, index }}))
.filter(({{ route }}) => !needle || route.mailbox.toLowerCase().includes(needle) || route.domains.join(" ").toLowerCase().includes(needle))
.map(({{ route, index }}) => `
<tr class="${{selectedIndex === index ? "selected" : ""}}">
<td class="index">${{index + 1}}</td>
<td class="domains">${{route.domains.map(escapeHtml).join("<br>")}}</td>
<td class="mailbox">${{escapeHtml(route.mailbox)}}</td>
<td class="actions"><button type="button" class="secondary" onclick="editRoute(${{index}})">Bewerk</button></td>
</tr>`);
$("routesBody").innerHTML = rows.join("");
}}
function escapeHtml(value) {{
return value.replace(/[&<>"']/g, (ch) => ({{ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }}[ch]));
}}
$("routeForm").addEventListener("submit", async (event) => {{
event.preventDefault();
const route = {{ domains: splitDomains($("domains").value), mailbox: $("mailbox").value.trim() }};
const indexValue = $("routeIndex").value;
try {{
const method = indexValue === "" ? "POST" : "PUT";
const url = indexValue === "" ? "/api/routes" : `/api/routes/${{indexValue}}`;
const payload = await requestJson(url, {{ method, body: JSON.stringify(route) }});
routes = payload.routes;
clearForm();
setStatus("Opgeslagen.");
}} catch (error) {{
setStatus(error.message, true);
}}
}});
$("deleteButton").addEventListener("click", async () => {{
if (selectedIndex === null) return;
if (!confirm(`Regel ${{selectedIndex + 1}} verwijderen?`)) return;
try {{
const payload = await requestJson(`/api/routes/${{selectedIndex}}`, {{ method: "DELETE" }});
routes = payload.routes;
clearForm();
setStatus("Verwijderd.");
}} catch (error) {{
setStatus(error.message, true);
}}
}});
$("search").addEventListener("input", renderRoutes);
$("newButton").addEventListener("click", clearForm);
$("clearButton").addEventListener("click", clearForm);
$("reloadButton").addEventListener("click", loadRoutes);
loadRoutes().catch((error) => setStatus(error.message, true));
</script>
</body>
</html>
""".encode("utf-8")
class RouteHandler(BaseHTTPRequestHandler):
routes_file = DOMAIN_ROUTES_FILE
username = ""
password = ""
def log_message(self, fmt: str, *args: object) -> None:
print(f"{self.address_string()} - {fmt % args}")
def _require_auth(self) -> bool:
if basic_auth_ok(self.headers.get("Authorization"), self.username, self.password):
return True
self.send_response(HTTPStatus.UNAUTHORIZED)
self.send_header("WWW-Authenticate", 'Basic realm="mailcat"')
self.send_header("Content-Length", "0")
self.end_headers()
return False
def _send(self, status: HTTPStatus, body: bytes, content_type: str) -> None:
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _json(self, status: HTTPStatus, payload: dict) -> None:
self._send(
status,
json.dumps(payload, indent=2, ensure_ascii=False).encode("utf-8"),
"application/json; charset=utf-8",
)
def _error(self, status: HTTPStatus, message: str) -> None:
self._json(status, {"error": message})
def _read_route_body(self) -> dict:
length = int(self.headers.get("Content-Length", "0"))
body = self.rfile.read(length)
payload = json.loads(body.decode("utf-8"))
if not isinstance(payload, dict):
raise ValueError("Request body must contain one route object")
return payload
def _route_index(self) -> int | None:
parts = [part for part in urlparse(self.path).path.split("/") if part]
if len(parts) != 3 or parts[:2] != ["api", "routes"]:
return None
try:
return int(parts[2])
except ValueError:
return None
def do_GET(self) -> None:
if not self._require_auth():
return
path = urlparse(self.path).path
if path == "/":
self._send(HTTPStatus.OK, page_html(), "text/html; charset=utf-8")
return
if path == "/api/routes":
try:
self._json(HTTPStatus.OK, {"routes": load_routes(self.routes_file)})
except Exception as exc:
self._error(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc))
return
self._error(HTTPStatus.NOT_FOUND, "Not found")
def do_POST(self) -> None:
if not self._require_auth():
return
if urlparse(self.path).path != "/api/routes":
self._error(HTTPStatus.NOT_FOUND, "Not found")
return
try:
routes = load_routes(self.routes_file)
routes.append(self._read_route_body())
self._json(HTTPStatus.CREATED, {"routes": save_routes(routes, self.routes_file)})
except json.JSONDecodeError:
self._error(HTTPStatus.BAD_REQUEST, "Invalid JSON")
except ValueError as exc:
self._error(HTTPStatus.BAD_REQUEST, str(exc))
except Exception as exc:
self._error(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc))
def do_PUT(self) -> None:
if not self._require_auth():
return
index = self._route_index()
if index is None:
self._error(HTTPStatus.NOT_FOUND, "Not found")
return
try:
routes = load_routes(self.routes_file)
if index < 0 or index >= len(routes):
raise ValueError("Route index out of range")
routes[index] = self._read_route_body()
self._json(HTTPStatus.OK, {"routes": save_routes(routes, self.routes_file)})
except json.JSONDecodeError:
self._error(HTTPStatus.BAD_REQUEST, "Invalid JSON")
except ValueError as exc:
self._error(HTTPStatus.BAD_REQUEST, str(exc))
except Exception as exc:
self._error(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc))
def do_DELETE(self) -> None:
if not self._require_auth():
return
index = self._route_index()
if index is None:
self._error(HTTPStatus.NOT_FOUND, "Not found")
return
try:
routes = load_routes(self.routes_file)
if index < 0 or index >= len(routes):
raise ValueError("Route index out of range")
del routes[index]
self._json(HTTPStatus.OK, {"routes": save_routes(routes, self.routes_file)})
except ValueError as exc:
self._error(HTTPStatus.BAD_REQUEST, str(exc))
except Exception as exc:
self._error(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc))
def main() -> None:
parser = argparse.ArgumentParser(description="Run the mailcat route management web UI.")
parser.add_argument("--host", default=DEFAULT_HOST)
parser.add_argument("--port", type=int, default=DEFAULT_PORT)
parser.add_argument("--config", type=Path, default=DEFAULT_CONFIG_FILE)
parser.add_argument("--routes-file", type=Path, default=DOMAIN_ROUTES_FILE)
args = parser.parse_args()
username, password = load_web_config(args.config)
RouteHandler.routes_file = args.routes_file
RouteHandler.username = username
RouteHandler.password = password
server = ThreadingHTTPServer((args.host, args.port), RouteHandler)
print(f"mailcat route web listening on http://{html.escape(args.host)}:{args.port}/")
server.serve_forever()
if __name__ == "__main__":
main()
+19
View File
@@ -0,0 +1,19 @@
Ik wil dat je een bepaalde mailbox van mij helemaal gaat herordenen. Ik wil onder andere het volgende:
- een overzicht van mailinglijsten waar ik op geabonneerd ben en een advies of ik me moet afmelden;
- een kant en klaar script dat ik kan afdraaien om me af te melden van de mailinglijsten waar ik me van wil afmelden
- een overzicht van alle facturen in de mailbox
- een overzicht van alle facturen die niet in de mailbox zitten, maar waarvan een mail verwijst naar de website
- een plan hoe ik deze facturen geautomatiseerd van de betreffende website kan halen
- een overzicht van mails die niet relevant zijn en die zo naar de prullenbak kunnen
Ik wil dat je werkt in een offline kopie van de mailbox die ik geordend wil hebben.
Geef me:
- een plan hoe je dit gaat aanpakken
- een manier om een bepaalde mailbox helemaal te kopieren naar mijn lokale harde schijf op zo'n manier dat jij ermee uit
de voeten kunt en ook de acties klaar kunt zetten die ik hierboven benoemd heb
- als ik je plan geakkordeerd heb wil ik dat je eerst overzichten gaat maken, dan scripts gaat maken en me dan vertelt
hoe ik dit op de eenvoudigste manier daadwerkelijk kan uitvoeren op de echte mailbox
Je gewone instructies blijven verder ook gelden.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,284 @@
# Decisions Mailing List Routing Report
Date: 2026-07-04
Source: `decisions.json`
Purpose: interpret `decisions.json` as the user intent for mailing-list handling and translate it into mailbox structure proposals.
## Summary
`decisions.json` contains 185 sender/list decision entries:
- `h` ("houden"): 95 entries, 2,762 messages.
- `a` ("afmelden"): 89 entries, 2,683 messages.
- `s`: 1 entry, 26 messages.
The sorter still does not read `decisions.json` directly. The decisions have been normalized into `mailinglist_routes.json`, which is now loaded by `mail_routes.py` for routing and destination-folder generation.
The normalized policy contains 153 grouped rules and covers every key in `decisions.json` through `source_decision_keys`.
## Interpretation
Recommended interpretation:
- `keuze: "h"` means keep the list and route it to a normal functional mailbox.
- `keuze: "a"` means isolate it under `Afmelden`, so it can be reviewed/unsubscribed without polluting normal destination folders.
- `keuze: "s"` is ambiguous. Treat it as "separate/review" until confirmed; for now it should be kept in a functional mailbox or a review mailbox, not under `Afmelden`.
For `Afmelden`, use a separate top-level tree:
- `INBOX.Afmelden.<Category>.<Organization>`
Do not place final folders under `Archief`.
## Combined Groups
The following entries should be combined because they appear to come from the same organization/person even when domains differ:
- Coursera: `coursera.org`, `email.coursera.org`, `m.mail.coursera.org`, `m.learn.coursera.org`.
- MIT Technology Review: `technologyreview.com`, `fulfillment.technologyreview.com`.
- Eva Keiffenheim: `evakeiffenheim.com`, `eva keiffenheim s lifelong learning club`, `eva keiffenheim from the learn letter`, and their `substack.com` transport.
- KPN: `kpn.com`, `campagnes.kpn.com`.
- PostNL: `postnl.nl`, `notificatie.postnl.nl`, `edm.postnl.nl`.
- STRATO: `strato.com`, `news.strato.com`, `marketingradar.strato.com`.
- Unive: `nieuwsbrief.unive.nl` and any future `unive.nl`.
- ICS: `icsmarketing.icscards.nl` and any future `icscards.nl`/`service.icscards.nl`.
- Viking: `delivery.vikingdirect.nl`, `online.vikingdirect.nl`, and any future `vikingdirect.nl`.
- bol: `bol.com`, `feedback.bol.com`.
- Coolblue: `coolblue.nl`, `coolblue.eu`.
- Vodafone: split by intent: keep `zakelijk.vodafone.nl` in normal telecom, but place `vodafone.nl` under `Afmelden` because it is marked `a`.
- Sophos/Cleverbridge: `home.sophos.com`, `cleverbridge.com`.
- Forte Labs/BASB: `fortelabs.co`, `fortelabs.com`.
- CIONET: `mn.co`, `cionet.com`.
- Gusti: `gusti-leer.nl`, `gusti-leder.de`, `gusti-pelle.it`.
- Nord: `nordvpn.com`, `nordpass.com`.
- DHL: split by intent: keep `dhlparcel.nl`, but place `dhlecommerce.nl` under `Afmelden` because it is marked `a`; `proxy.privault.rpip.nl` should be treated as DHL if retained.
- Proton: `proton.me`, `notify.proton.me`, `mail.proton.me`.
- Vonage/Nexmo: `vonage.com`, `insights.vonage.com`, `nexmo.com`.
- Visme: `accounts.visme.com`, `visme.co`.
- ClickUp: `clickup.com`, `team.wrike.com` are not the same organization; keep separate.
- Pleio/IBDS: `pleio.nl`, `mail.pleio.nl`.
## Keep: Proposed Functional Mailboxes
These `h` entries should be routed to normal destination folders.
| Proposed mailbox | Entries/domains | Count | Notes |
| --- | --- | ---: | --- |
| `INBOX.Nieuwsbrieven.Leren.Coursera` | `coursera.org`, `email.coursera.org`, `m.mail.coursera.org`, `m.learn.coursera.org` | 492 | Already mostly represented in `mail_routes.py`; zero-count aliases should remain aliases. |
| `INBOX.Nieuwsbrieven.Leren.Storytelling with Data` | `storytellingwithdata.com` | 319 | Already represented. |
| `INBOX.Nieuwsbrieven.Leren.MIT Technology Review` | `technologyreview.com`, `fulfillment.technologyreview.com` | 287 | Already represented. |
| `INBOX.Nieuwsbrieven.Overig.Lendahand` | `lendahand.com` | 256 | Already represented. |
| `INBOX.Nieuwsbrieven.Leren.Eva Keiffenheim` | `evakeiffenheim.com`, Keiffenheim Substack entries via `substack.com` | 204 | Needs sender-name or List-ID handling for Substack; domain-only routing is insufficient. |
| `INBOX.Nieuwsbrieven.Overig.The Good Roll` | `thegoodroll.nl` | 89 | Already represented. |
| `INBOX.Mobiliteit.EV.Eneco eMobility` | `eneco-emobility.com`, `eneco.nl` | 96 | Already represented. |
| `INBOX.Nieuwsbrieven.Leren.Nick Milo` | `linkingyourthinking.com` | 76 | Already represented. |
| `INBOX.Mobiliteit.OV & Reizen.NS` | `ns.nl`, `email.ns.nl` | 63 | Already represented. |
| `INBOX.Werk.Opdrachten.OneStopSourcing` | `onestopsourcing.nl` | 51 | Already represented. |
| `INBOX.Nieuwsbrieven.Leren.AI Report` | `beehiiv.com`, `mail.beehiiv.com` | 39 | Already represented by domain aliases. |
| `INBOX.Werk.Opdrachten.Flextender` | `flextender.nl` | 37 | Already represented. |
| `INBOX.Financieel.Verzekering.Unive` | `nieuwsbrief.unive.nl` | 37 | Existing route uses `Univé`; prefer ASCII folder spelling if normalizing new folder names. |
| `INBOX.Financieel.Bank.ICS` | `icsmarketing.icscards.nl` | 36 | Already represented with aliases. |
| `INBOX.Nieuwsbrieven.Nieuws & Vakbladen.Vakblad Crisismanager` | `crisismanager.nl` | 35 | Already represented. |
| `INBOX.Diensten.Software.CogSci Apps` | `cogsciapps.com` | 33 | Already represented. |
| `INBOX.Nieuwsbrieven.Leren.Ben Tiggelaar` | `tiggelaar.nl` | 31 | Already represented. |
| `INBOX.Werk.Opdrachten.IMMENS` | `immens.nu` | 30 | Already represented. |
| `INBOX.Financieel.Bank.ABN AMRO` | `nl.abnamro.com` | 26 | Already represented; include `abnamro.nl` alias. |
| `INBOX.Bestellingen.PostNL` | `postnl.nl`, `notificatie.postnl.nl`, `edm.postnl.nl` | 48 | Already represented; zero-count alias should remain. |
| `INBOX.Bestellingen.123inkt` | `123inkt.nl`, `m.123inkt.nl` | 24 | Already represented; zero-count alias should remain. |
| `INBOX.Bestellingen.DHL` | `dhlparcel.nl`, `proxy.privault.rpip.nl` | 25 | Keep parcel notifications; do not merge with `dhlecommerce.nl` because that entry is marked `a`. |
| `INBOX.Diensten.Hosting.STRATO` | `strato.com`, `news.strato.com`, `marketingradar.strato.com` | 27 | Already represented. |
| `INBOX.Diensten.Telecom.Vodafone` | `zakelijk.vodafone.nl` | 20 | Keep business Vodafone; consumer `vodafone.nl` is marked `a`. |
| `INBOX.Diensten.Software.Formspree` | `formspree.io` | 19 | New functional folder. |
| `INBOX.Diensten.Telecom.KPN` | `campagnes.kpn.com`, `kpn.com` | 22 | Already represented. |
| `INBOX.Diensten.Hosting.Lets Encrypt` | `letsencrypt.org` | 16 | Already represented. |
| `INBOX.Diensten.Backup.Backblaze` | `backblaze.com` | 14 | Existing route is `Diensten.Hosting.Backblaze`; consider `Diensten.Backup.Backblaze`. |
| `INBOX.Nieuwsbrieven.Leren.Brian Keating` | `briankeating.com` | 14 | Already represented. |
| `INBOX.Werk.Opdrachten.Get There` | `getthere.nl` | 13 | Already represented. |
| `INBOX.Werk.Opdrachten.Jamie` | `hey.meetjamie.ai` | 13 | Already represented. |
| `INBOX.Werk.Opdrachten.Huckr` | `huckr.ai` | 13 | Already represented. |
| `INBOX.Nieuwsbrieven.Leren.MIT Open Learning` | `mit.edu` | 13 | Already represented. |
| `INBOX.Bestellingen.Allekabels` | `allekabels.nl` | 10 | Already represented. |
| `INBOX.Mobiliteit.OV & Reizen.Booking` | `booking.com` | 10 | Already represented. |
| `INBOX.Diensten.Software.Sophos` | `cleverbridge.com`, `home.sophos.com` | 12 | Already represented. |
| `INBOX.Mobiliteit.EV.FastNed` | `fastned.nl` | 9 | Already represented. |
| `INBOX.Mobiliteit.EV.OPnGO` | `opngo.com` | 9 | Already represented. |
| `INBOX.Mobiliteit.EV.Tesla` | `tesla.com` | 9 | Already represented. |
| `INBOX.Diensten.Software.Cursor` | `cursor.com` | 8 | Already represented. |
| `INBOX.Diensten.Software.1Password` | `1password.com` | 7 | Already represented. |
| `INBOX.Bestellingen.Viking` | `delivery.vikingdirect.nl`, `online.vikingdirect.nl` | 13 | Already represented; include `vikingdirect.nl` alias. |
| `INBOX.Diensten.Software.Matter` | `getmatter.com` | 7 | Already represented. |
| `INBOX.Werk.Overig.Gemeente Groningen` | `groningen.nl`, `groningenbereikbaar.nl` | 10 | Already represented. |
| `INBOX.Financieel.Verzekering.VEZA` | `heinenoord.nl` | 7 | Already represented. |
| `INBOX.Diensten.Software.Kahoot` | `team.kahoot.com` | 7 | Already represented. |
| `INBOX.Diensten.Hosting.TransIP` | `transip.nl` | 7 | Already represented. |
| `INBOX.Diensten.Software.Indigo Neo` | `indigoneo.com` | 6 | New functional folder. |
| `INBOX.Bestellingen.Office Centre` | `officecentre.nl` | 6 | Already represented. |
| `INBOX.Mobiliteit.EV.Shell Recharge` | `shellrecharge.com` | 6 | Already represented. |
| `INBOX.Bestellingen.Smartphonehoesjes` | `smartphonehoesjes.nl` | 6 | Already represented. |
| `INBOX.Werk.Overig.BOIP` | `boip.int` | 5 | Already represented. |
| `INBOX.Nieuwsbrieven.Leren.Elizabeth Butler` | `elizabethbutlermd.com` | 5 | Already represented. |
| `INBOX.Bestellingen.bol` | `bol.com`, `feedback.bol.com` | 9 | Already represented. |
| `INBOX.Mobiliteit.OV & Reizen.Flitsmeister` | `flitsmeister.nl` | 5 | Already represented. |
| `INBOX.Werk.Opdrachten.FutureXL` | `futurexl.nl` | 5 | Already represented. |
| `INBOX.Financieel.Bank.NPEX` | `npex.nl` | 5 | Already represented. |
| `INBOX.Nieuwsbrieven.Nieuws & Vakbladen.The Presentation Guru` | `presentation-guru.com` | 5 | Already represented. |
| `INBOX.Bestellingen.Sligro` | `sligro.nl` | 5 | Already represented. |
| `INBOX.Financieel.Bank.SnelStart` | `snelstart.nl` | 5 | Already represented. |
| `INBOX.Financieel.Bank.Tellow` | `tellow.nl` | 5 | Already represented. |
| `INBOX.Bestellingen.Coolblue` | `coolblue.nl`, `coolblue.eu` | 7 | Already represented. |
| `INBOX.Nieuwsbrieven.Nieuws & Vakbladen.Computable` | `jaarbeurs.nl` | 4 | Already represented. |
| `INBOX.Diensten.Software.Microsoft Teams` | `microsoft365.com` | 4 | Already represented. |
| `INBOX.Mobiliteit.EV.Mitsubishi` | `mmsn.nl` | 4 | Already represented. |
| `INBOX.Diensten.Software.Claude` | `claude.com` | 3 | Already represented. |
| `INBOX.Bestellingen.Makro` | `makro.nl` | 3 | Already represented. |
| `INBOX.Diensten.Software.MindNode` | `mindnode.com` | 3 | Already represented. |
| `INBOX.Diensten.Netwerk.Netherfind` | `netherfind.com` | 3 | New functional folder. |
| `INBOX.Diensten.Software.Papers` | `papersapp.com` | 3 | Already represented. |
| `INBOX.Diensten.Software.Perplexity` | `perplexity.ai` | 3 | Already represented. |
| `INBOX.Diensten.Email.Proton` | `mail.proton.me` | 0 | Zero-count keep alias; useful only if future Proton keep-routing is desired. |
## Afmelden: Proposed Mailboxes
These `a` entries should be routed below `INBOX.Afmelden`.
| Proposed mailbox | Entries/domains | Count | Notes |
| --- | --- | ---: | --- |
| `INBOX.Afmelden.Netwerken.ResearchGate` | `researchgatemail.net` | 674 | Highest-volume unsubscribe candidate. |
| `INBOX.Afmelden.Fitness.STARS Personal Training` | `virtuagym.com` | 413 | Includes S.T.A.R.S via Virtuagym. |
| `INBOX.Afmelden.Netwerken.LinkedIn` | `linkedin.com` | 363 | High-volume unsubscribe candidate. |
| `INBOX.Afmelden.Nieuws.Het Financieele Dagblad` | `messagent.fdmediagroep.nl` | 108 | FD Techzaken/news. |
| `INBOX.Afmelden.Werk.FixedToday` | `fixedtoday.nl` | 97 | Armand/FixedToday. |
| `INBOX.Afmelden.Werk.Centre of Excellence Data Sharing Cloud` | `coe-dsc.nl` | 78 | Combine spelling variants. |
| `INBOX.Afmelden.Netwerken.CIONET` | `mn.co`, `cionet.com` | 83 | Same organization. |
| `INBOX.Afmelden.Winkels.Gusti` | `gusti-leer.nl`, `gusti-leder.de`, `gusti-pelle.it` | 88 | Same brand across country domains. |
| `INBOX.Afmelden.Leren.Forte Labs` | `fortelabs.co`, `fortelabs.com` | 76 | Same organization; current old folder `BASB` should be treated as source if unsubscribing. |
| `INBOX.Afmelden.Telecom.Vonage` | `vonage.com`, `insights.vonage.com`, `nexmo.com` | 50 | Same company/product family. |
| `INBOX.Afmelden.Beveiliging.Nord` | `nordvpn.com`, `nordpass.com` | 40 | Same vendor. |
| `INBOX.Afmelden.Bestellingen.DHL eCommerce` | `dhlecommerce.nl` | 29 | Do not merge with kept `dhlparcel.nl` unless user changes decision. |
| `INBOX.Afmelden.Leren.Taproot` | `taproot.com` | 27 | Anne Roberts/Taproot. |
| `INBOX.Afmelden.Productiviteit.Readwise` | `readwise.io` | 22 | Note historical overlap with Eva Keiffenheim sender names. |
| `INBOX.Afmelden.Leren.Scrimba` | `scrimba.com` | 21 | Coding education. |
| `INBOX.Afmelden.Software.Kilo Code` | `kilocode.ai` | 18 | Development tool. |
| `INBOX.Afmelden.Werk.Jooble` | `nl.jooble.org` | 18 | Job alerts. |
| `INBOX.Afmelden.Werk.Pleio IBDS` | `pleio.nl`, `mail.pleio.nl` | 18 | Combine real and alias domain. |
| `INBOX.Afmelden.Leren.FUN MOOC` | `fun-mooc.fr` | 17 | Course platform. |
| `INBOX.Afmelden.Software.Stackfield` | `stackfield.help` | 17 | Collaboration software. |
| `INBOX.Afmelden.Werk.De Staffing Groep` | `destaffinggroep.nl` | 16 | Work/recruitment. |
| `INBOX.Afmelden.Leren.Kaggle` | `kaggle.com` | 16 | Learning/data platform. |
| `INBOX.Afmelden.Diensten.Proton` | `proton.me`, `notify.proton.me` | 19 | Do not merge with zero-count keep alias without clarification. |
| `INBOX.Afmelden.Software.Genially` | `genially.com` | 14 | Presentation/creative software. |
| `INBOX.Afmelden.Backup.IDrive` | `idrive.com` | 14 | Backup service. |
| `INBOX.Afmelden.Projectmanagement.PMRGI` | `oppmi.com` | 14 | Project management resource group. |
| `INBOX.Afmelden.Energie.Zelf Energie Produceren` | `zelfenergieproduceren.nl` | 13 | Energy. |
| `INBOX.Afmelden.Software.Nuclino` | `nuclino.com` | 12 | Knowledge base software. |
| `INBOX.Afmelden.Apparaten.reMarkable` | `remarkable.com` | 12 | Device/vendor. |
| `INBOX.Afmelden.Software.Olares` | `olares.com` | 11 | Software/service. |
| `INBOX.Afmelden.Schrijven.Schrijf.be` | `schrijf.be` | 11 | Writing/training. |
| `INBOX.Afmelden.Software.VMware` | `connect.vmware.com` | 10 | VMware marketing. |
| `INBOX.Afmelden.Software.Audiogest` | `loops.audiogest.app` | 10 | Software/service. |
| `INBOX.Afmelden.Software.SmallCubed` | `smallcubed.com` | 10 | Software vendor. |
| `INBOX.Afmelden.Energie.Energie Collectief Nederland` | `titanomon.com` | 10 | Energy. |
| `INBOX.Afmelden.Telecom.Vodafone` | `vodafone.nl` | 10 | Consumer Vodafone; separate from kept business Vodafone. |
| `INBOX.Afmelden.Mobiliteit.Fulli` | `fulli.com` | 9 | Travel/toll service. |
| `INBOX.Afmelden.Advertenties.Google Ads` | `google.com` | 9 | Ads/marketing. |
| `INBOX.Afmelden.Software.Saga` | `saga.so` | 9 | Software/service. |
| `INBOX.Afmelden.Financieel.Triodos` | `triodos.nl` | 9 | Bank marketing. |
| `INBOX.Afmelden.Software.TurboScribe` | `turboscribe.ai` | 9 | Transcription service. |
| `INBOX.Afmelden.Financieel.bunq` | `bunq.com` | 8 | Bank marketing. |
| `INBOX.Afmelden.Winkels.Airo Paradise` | `ekwb.nl` | 8 | Retail/vendor. |
| `INBOX.Afmelden.Software.Parallels` | `parallels-universe.com` | 8 | Software vendor. |
| `INBOX.Afmelden.Leren.OReilly` | `et.oreilly.com` | 7 | Learning/publisher. |
| `INBOX.Afmelden.Software.Visme` | `accounts.visme.com`, `visme.co` | 9 | Same vendor. |
| `INBOX.Afmelden.Winkels.De Vulpenwereld` | `devulpenwereld.nl` | 6 | Retail. |
| `INBOX.Afmelden.Privacy.Incogni` | `incogni.com` | 6 | Privacy service. |
| `INBOX.Afmelden.Software.Smartsheet` | `smartsheet.com` | 6 | Software. |
| `INBOX.Afmelden.Netwerken.YourConnector` | `yourconnector.nl` | 6 | Networking/business. |
| `INBOX.Afmelden.Software.Zapier` | `zapier.com` | 6 | Software. |
| `INBOX.Afmelden.Software.ClickUp` | `clickup.com` | 5 | Software. |
| `INBOX.Afmelden.Presenteren.Duarte` | `duarte.com` | 5 | Presentation/training. |
| `INBOX.Afmelden.Winkels.PrintAbout` | `printabout.nl` | 5 | Retail. |
| `INBOX.Afmelden.Cloud.Microsoft Azure` | `promomail.microsoft.com` | 5 | Azure promotions. |
| `INBOX.Afmelden.Productiviteit.Tana` | `tana.inc` | 5 | Productivity software. |
| `INBOX.Afmelden.Leren.TIAS` | `tias.edu` | 5 | Education. |
| `INBOX.Afmelden.Media.VALUE IPTV VOD` | `weblinux.de` | 5 | Media/service. |
| `INBOX.Afmelden.Productiviteit.Capacities` | `capacities.io` | 4 | Productivity software. |
| `INBOX.Afmelden.Nieuws.Computable Enigma Research` | `enigmaresearch.nl` | 4 | Related to Computable but marked unsubscribe. |
| `INBOX.Afmelden.Winkels.Euroflorist` | `euroflorist.nl` | 4 | Retail. |
| `INBOX.Afmelden.Financieel.IEX Cloud` | `iexcloud.io` | 4 | Finance/API. |
| `INBOX.Afmelden.Diensten.Jetpack` | `jetpack.com` | 4 | WordPress/Automattic service. |
| `INBOX.Afmelden.Software.Linearity` | `linearity.io` | 4 | Design software. |
| `INBOX.Afmelden.Software.LiquidText` | `liquidtext.net` | 4 | Software. |
| `INBOX.Afmelden.Software.MURAL` | `mural.co` | 4 | Software. |
| `INBOX.Afmelden.Winkels.Office Noord` | `officenoord.nl` | 4 | Office retail. |
| `INBOX.Afmelden.Presenteren.Prezent` | `prezent.ai` | 4 | Presentation software. |
| `INBOX.Afmelden.Projectmanagement.Wrike` | `team.wrike.com` | 4 | Software. |
| `INBOX.Afmelden.Mobiliteit.Alfen` | `alfen.com` | 3 | Marked unsubscribe, despite current active route keeping Alfen under EV. Needs policy decision. |
| `INBOX.Afmelden.Productiviteit.GRID` | `gridnewsletter.com` | 3 | Productivity/newsletter. |
| `INBOX.Afmelden.Legal.Henchman` | `henchman.io` | 3 | Legal software. |
| `INBOX.Afmelden.Presenteren.Pitch` | `hi.pitch.com` | 3 | Presentation software. |
| `INBOX.Afmelden.Wonen.How To Buy A Home` | `howtobuyahome.com` | 3 | Real estate/education. |
| `INBOX.Afmelden.Leren.Kodeco` | `kodeco.com` | 3 | Learning platform. |
| `INBOX.Afmelden.Nieuws.Medium` | `medium.com` | 3 | Publishing platform. |
| `INBOX.Afmelden.Productiviteit.MindMeister` | `mindmeister.com` | 3 | Productivity software. |
| `INBOX.Afmelden.Fitness.NEXT Personal Gym` | `next-sports.nl` | 3 | Fitness. |
| `INBOX.Afmelden.Projectmanagement.TeamGantt` | `teamgantt.com` | 3 | Project management software. |
## Separate/Review
`circle8.nl` is marked `s` with 26 messages.
Recommended temporary mailbox:
- `INBOX.Werk.Opdrachten.Circle8`
Reason: Circle8 is already present as a work/opportunity route in `mail_routes.py`. The meaning of `s` should be confirmed before moving it under `Afmelden`.
## Conflicts With Current Routes
These decisions conflict with the current hardcoded route table or expose incomplete routing:
- `researchgatemail.net` is a high-volume `a` decision but has no active route, so it currently remains in place.
- Keiffenheim Substack messages are `h` but cannot be routed by domain alone because `substack.com` is shared. Need List-ID, sender address, or display-name matching.
- `fortelabs.co`/`fortelabs.com` are marked `a`, but old mailbox naming suggests they may currently live under `INBOX.Mailinglists.BASB`; they should not become a normal final folder if the decision is to unsubscribe.
- `alfen.com` is in the current active EV route but is marked `a` in `decisions.json`. This should be resolved before applying generated routes.
- `vodafone.nl` is marked `a`, while `zakelijk.vodafone.nl` is marked `h`. The route table currently maps both consumer and business Vodafone into `Diensten.Telecom.Vodafone`; this should be split.
- `dhlecommerce.nl` is marked `a`, while `dhlparcel.nl` is marked `h`. The route table currently maps both to `Bestellingen.DHL`; this should be split.
- `proton.me` and `notify.proton.me` are marked `a`, but `mail.proton.me` is a zero-count `h` placeholder. Keep these separate until the user clarifies.
## Plan To Add This Structure
1. Introduce a structured policy file generated from `decisions.json`. Done.
- Suggested file: `mailinglist_routes.json`.
- Fields: `action`, `mailbox`, `domains`, `from_contains`, `source_decision_keys`, `notes`.
- Do not make `decisions.json` itself the runtime file; it contains unsubscribe URLs and analysis artifacts.
2. Extend `mail_routes.py` to load mailing-list policy. Done for domain and `From` substring matching.
- Keep `DOMAIN_ROUTES` for stable functional/business mail.
- Add a runtime lookup for generated mailing-list rules.
- Support domain matching, exact sender address matching, display-name matching, and List-ID/header matching.
3. Add `Afmelden` to destination folder creation. Done through `destination_folders()`.
- `maak_mappen.py` should create all `INBOX.Afmelden.*` folders from the generated policy.
- Existing `destination_folders()` should include both functional keep folders and unsubscribe review folders.
4. Update routing precedence. Done for invoice, mailing-list policy, then legacy domain fallback.
- Invoice keyword routing should remain first.
- Exact/list-specific mailing-list rules should run before broad domain rules, especially for shared senders such as `substack.com`.
- Domain rules should remain the fallback for normal service/vendor mail.
5. Add audit output before moving.
- Dry-run should report counts per proposed destination, including `Afmelden`.
- It should also report unmatched `decisions.json` entries and current-route conflicts.
6. Resolve conflicts before actual execution.
- Confirm `circle8.nl` meaning for `s`.
- Confirm `alfen.com`, `vodafone.nl`, `dhlecommerce.nl`, and Proton split behavior.
- Confirm whether zero-count aliases should be kept as future aliases.
7. Test only on `backup@australius.nl`.
- Create the new folder structure in backup.
- Run dry-run sorter on backup.
- Review planned moves.
- Run actual sorter only after explicit confirmation.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+582
View File
@@ -0,0 +1,582 @@
# Voorstel mappenstructuur & mailrules
_Gebaseerd op analyse van 11.854 e-mails en jouw beslissingen over 174 mailinglijsten._
_3 niveaus diep: categorie → subcategorie → afzender._
---
## Voorgestelde mappenstructuur
```
INBOX ← alleen wat echt jouw actie vraagt
Nieuwsbrieven/
Leren/
AI Report
Ben Tiggelaar
Brian Keating
Coursera
Eva Keiffenheim
MIT Open Learning
MIT Technology Review
Nick Milo
Storytelling with Data
Nieuws & Vakbladen/
Computable
The Presentation Guru
Vakblad Crisismanager
Overig/
Lendahand
The Good Roll
Financieel/
Facturen/
2024.Q1 ← automatisch gesorteerd op ontvangstdatum
2024.Q2 PDF-facturen worden ook doorgestuurd
2024.Q3 naar boekhouding@australius.nl
2024.Q4
2025.Q1
2025.Q2
2025.Q3
2025.Q4
(per jaar aanmaken)
Bank/
ABN AMRO
ICS
NPEX
SnelStart
Tellow
Verzekering/
Univé
VEZA
Werk/
Opdrachten/
Circle8
Flextender
FutureXL
Get There
Huckr
IMMENS
Jamie (MeetJamie)
OneStopSourcing
Overig/
BOIP
Gemeente Groningen
Diensten/
Hosting/
Backblaze
Let's Encrypt
STRATO
TransIP
Software/
1Password
CogSci Apps
Claude
Cursor
Kahoot
Matter
Microsoft Teams
MindNode
Papers
Perplexity
Sophos
Telecom/
KPN
Vodafone
Mobiliteit/
EV/
Alfen
Eneco eMobility
FastNed
Mitsubishi
OPnGO
Shell Recharge
Tesla
OV & Reizen/
Booking.com
Flitsmeister
NS
Bestellingen/
123inkt
Allekabels
bol.com
Coolblue
DHL
GLS
Makro
Office Centre
PostNL
Sligro
Smartphonehoesjes
Viking
Technisch/
DMARC
Archief/
2023
2024
2025
Drafts
Sent
Spam
Trash
```
---
## Vergelijking huidige vs nieuwe structuur
| Huidig | Nieuw |
|---|---|
| `Mailinglists/BASB` | vervalt (afgemeld) |
| `Mailinglists/Ben Tiggelaar` | `Nieuwsbrieven/Leren/Ben Tiggelaar` |
| `Mailinglists/Brian Keating` | `Nieuwsbrieven/Leren/Brian Keating` |
| `Mailinglists/Calmer Notes` | vervalt (afgemeld) |
| `Mailinglists/Eva Keiffenheim` | `Nieuwsbrieven/Leren/Eva Keiffenheim` |
| `Mailinglists/Formspree` | `Nieuwsbrieven/Overig/The Good Roll` (Cole) |
| `Mailinglists/Lendahand` | `Nieuwsbrieven/Overig/Lendahand` |
| `Mailinglists/LYT - Nick Milo` | `Nieuwsbrieven/Leren/Nick Milo` |
| `Mailinglists/MIT Technology Review` | `Nieuwsbrieven/Leren/MIT Technology Review` |
| `Mailinglists/Storytelling with Data` | `Nieuwsbrieven/Leren/Storytelling with Data` |
| `Kennis/Coursera` | `Nieuwsbrieven/Leren/Coursera` |
| `Kennis/Leeslijst` | vervalt of handmatig overzetten |
| `Facturen - te verwerken` | `Financieel/Facturen` |
| `Facturen - verwerkt` | `Financieel/Facturen.Archief` |
| `Hulp/Gespreksverslagen` | `Werk/Opdrachten/...` (handmatig overzetten) |
| `Archief/202x/inkomend` | `Archief/202x` |
| `Archief/202x/verzonden` | `Archief/202x` |
| `Notes` | vervalt (leeg) |
| `Technisch/dmarc` | `Technisch/DMARC` |
---
## Sieve-mailrules
Volgorde is prioriteit: eerste match wint.
```sieve
require ["fileinto", "imap4flags", "envelope", "address", "comparator-i;ascii-casemap"];
# ── 1. Facturen (altijd eerst, vóór nieuwsbrieven/diensten) ──────────────────
#
# Vereiste extensies: date, variables, foreverypart, mime, copy, fileinto, redirect
# Als de server date+variables niet ondersteunt → gebruik het Python-script
# (zie fase 3) dat kwartaalindeling en forwarding via IMAP afhandelt.
#
if anyof (
header :contains "Subject" ["factuur", "invoice", "rekening", "nota",
"betaalverzoek", "receipt", "orderbevestiging",
"order confirmation", "betaling ontvangen",
"payment received", "uw factuur", "your invoice",
"je factuur"]
) {
# Kwartaal bepalen op basis van ontvangstdatum
if currentdate :matches "year" "*" { set "jaar" "${1}"; }
if anyof (currentdate :is "month" "1",
currentdate :is "month" "2",
currentdate :is "month" "3") { set "kwartaal" "Q1"; }
elsif anyof (currentdate :is "month" "4",
currentdate :is "month" "5",
currentdate :is "month" "6") { set "kwartaal" "Q2"; }
elsif anyof (currentdate :is "month" "7",
currentdate :is "month" "8",
currentdate :is "month" "9") { set "kwartaal" "Q3"; }
else { set "kwartaal" "Q4"; }
# PDF-bijlage → doorsturen naar boekhouding (alleen nieuwe mail, geen terugwerkende kracht)
foreverypart {
if anyof (
header :mime :param "filename" :matches "Content-Disposition" ["*.pdf", "*.PDF"],
header :mime :is "Content-Type" "application/pdf"
) {
redirect :copy "boekhouding@australius.nl";
break;
}
}
fileinto "Financieel.Facturen.${jaar}.${kwartaal}";
stop;
}
# ── 2. Bank ──────────────────────────────────────────────────────────────────
if address :domain :is "From" ["nl.abnamro.com", "abnamro.nl"] {
fileinto "Financieel.Bank.ABN AMRO"; stop;
}
if address :domain :is "From" ["icscards.nl", "icsmarketing.icscards.nl",
"service.icscards.nl"] {
fileinto "Financieel.Bank.ICS"; stop;
}
if address :domain :is "From" ["npex.nl"] {
fileinto "Financieel.Bank.NPEX"; stop;
}
if address :domain :is "From" ["snelstart.nl"] {
fileinto "Financieel.Bank.SnelStart"; stop;
}
if address :domain :is "From" ["tellow.nl"] {
fileinto "Financieel.Bank.Tellow"; stop;
}
# ── 3. Verzekering ───────────────────────────────────────────────────────────
if address :domain :is "From" ["unive.nl", "nieuwsbrief.unive.nl"] {
fileinto "Financieel.Verzekering.Univé"; stop;
}
if address :domain :is "From" ["heinenoord.nl"] {
fileinto "Financieel.Verzekering.VEZA"; stop;
}
# ── 4. Werk / Opdrachten ─────────────────────────────────────────────────────
if address :domain :is "From" ["flextender.nl"] {
fileinto "Werk.Opdrachten.Flextender"; stop;
}
if address :domain :is "From" ["onestopsourcing.nl"] {
fileinto "Werk.Opdrachten.OneStopSourcing"; stop;
}
if address :domain :is "From" ["immens.nu"] {
fileinto "Werk.Opdrachten.IMMENS"; stop;
}
if address :domain :is "From" ["circle8.nl"] {
fileinto "Werk.Opdrachten.Circle8"; stop;
}
if address :domain :is "From" ["getthere.nl"] {
fileinto "Werk.Opdrachten.Get There"; stop;
}
if address :domain :is "From" ["huckr.ai"] {
fileinto "Werk.Opdrachten.Huckr"; stop;
}
if address :domain :is "From" ["hey.meetjamie.ai"] {
fileinto "Werk.Opdrachten.Jamie"; stop;
}
if address :domain :is "From" ["futurexl.nl"] {
fileinto "Werk.Opdrachten.FutureXL"; stop;
}
if address :domain :is "From" ["boip.int"] {
fileinto "Werk.Overig.BOIP"; stop;
}
if address :domain :is "From" ["groningen.nl", "groningenbereikbaar.nl"] {
fileinto "Werk.Overig.Gemeente Groningen"; stop;
}
# ── 5. Mobiliteit / EV ───────────────────────────────────────────────────────
if address :domain :is "From" ["eneco-emobility.com", "eneco.nl"] {
fileinto "Mobiliteit.EV.Eneco eMobility"; stop;
}
if address :domain :is "From" ["shellrecharge.com"] {
fileinto "Mobiliteit.EV.Shell Recharge"; stop;
}
if address :domain :is "From" ["fastned.nl"] {
fileinto "Mobiliteit.EV.FastNed"; stop;
}
if address :domain :is "From" ["opngo.com"] {
fileinto "Mobiliteit.EV.OPnGO"; stop;
}
if address :domain :is "From" ["tesla.com"] {
fileinto "Mobiliteit.EV.Tesla"; stop;
}
if address :domain :is "From" ["mmsn.nl"] {
fileinto "Mobiliteit.EV.Mitsubishi"; stop;
}
if address :domain :is "From" ["alfen.com"] {
fileinto "Mobiliteit.EV.Alfen"; stop;
}
# ── 6. Mobiliteit / OV & Reizen ──────────────────────────────────────────────
if address :domain :is "From" ["ns.nl", "email.ns.nl"] {
fileinto "Mobiliteit.OV & Reizen.NS"; stop;
}
if address :domain :is "From" ["booking.com"] {
fileinto "Mobiliteit.OV & Reizen.Booking.com"; stop;
}
if address :domain :is "From" ["flitsmeister.nl"] {
fileinto "Mobiliteit.OV & Reizen.Flitsmeister"; stop;
}
# ── 7. Bestellingen & bezorging ──────────────────────────────────────────────
if address :domain :is "From" ["postnl.nl", "edm.postnl.nl",
"notificatie.postnl.nl"] {
fileinto "Bestellingen.PostNL"; stop;
}
if address :domain :is "From" ["dhlparcel.nl", "dhlecommerce.nl"] {
fileinto "Bestellingen.DHL"; stop;
}
if address :domain :is "From" ["gls-netherlands.com"] {
fileinto "Bestellingen.GLS"; stop;
}
if address :domain :is "From" ["bol.com", "feedback.bol.com"] {
fileinto "Bestellingen.bol.com"; stop;
}
if address :domain :is "From" ["coolblue.nl", "coolblue.eu"] {
fileinto "Bestellingen.Coolblue"; stop;
}
if address :domain :is "From" ["allekabels.nl"] {
fileinto "Bestellingen.Allekabels"; stop;
}
if address :domain :is "From" ["vikingdirect.nl", "delivery.vikingdirect.nl",
"online.vikingdirect.nl"] {
fileinto "Bestellingen.Viking"; stop;
}
if address :domain :is "From" ["123inkt.nl", "m.123inkt.nl"] {
fileinto "Bestellingen.123inkt"; stop;
}
if address :domain :is "From" ["officecentre.nl"] {
fileinto "Bestellingen.Office Centre"; stop;
}
if address :domain :is "From" ["sligro.nl"] {
fileinto "Bestellingen.Sligro"; stop;
}
if address :domain :is "From" ["makro.nl"] {
fileinto "Bestellingen.Makro"; stop;
}
if address :domain :is "From" ["smartphonehoesjes.nl"] {
fileinto "Bestellingen.Smartphonehoesjes"; stop;
}
# ── 8. Diensten / Hosting ────────────────────────────────────────────────────
if address :domain :is "From" ["strato.com", "news.strato.com",
"marketingradar.strato.com"] {
fileinto "Diensten.Hosting.STRATO"; stop;
}
if address :domain :is "From" ["transip.nl"] {
fileinto "Diensten.Hosting.TransIP"; stop;
}
if address :domain :is "From" ["letsencrypt.org"] {
fileinto "Diensten.Hosting.Let's Encrypt"; stop;
}
if address :domain :is "From" ["backblaze.com"] {
fileinto "Diensten.Hosting.Backblaze"; stop;
}
# ── 9. Diensten / Telecom ────────────────────────────────────────────────────
if address :domain :is "From" ["kpn.com", "campagnes.kpn.com"] {
fileinto "Diensten.Telecom.KPN"; stop;
}
if address :domain :is "From" ["zakelijk.vodafone.nl", "vodafone.nl"] {
fileinto "Diensten.Telecom.Vodafone"; stop;
}
# ── 10. Diensten / Software ──────────────────────────────────────────────────
if address :domain :is "From" ["cursor.com"] {
fileinto "Diensten.Software.Cursor"; stop;
}
if address :domain :is "From" ["1password.com"] {
fileinto "Diensten.Software.1Password"; stop;
}
if address :domain :is "From" ["home.sophos.com", "cleverbridge.com"] {
fileinto "Diensten.Software.Sophos"; stop;
}
if address :domain :is "From" ["cogsciapps.com"] {
fileinto "Diensten.Software.CogSci Apps"; stop;
}
if address :domain :is "From" ["claude.com"] {
fileinto "Diensten.Software.Claude"; stop;
}
if address :domain :is "From" ["microsoft365.com"] {
fileinto "Diensten.Software.Microsoft Teams"; stop;
}
if address :domain :is "From" ["getmatter.com"] {
fileinto "Diensten.Software.Matter"; stop;
}
if address :domain :is "From" ["mindnode.com"] {
fileinto "Diensten.Software.MindNode"; stop;
}
if address :domain :is "From" ["papersapp.com"] {
fileinto "Diensten.Software.Papers"; stop;
}
if address :domain :is "From" ["team.kahoot.com"] {
fileinto "Diensten.Software.Kahoot"; stop;
}
if address :domain :is "From" ["perplexity.ai"] {
fileinto "Diensten.Software.Perplexity"; stop;
}
# ── 11. Nieuwsbrieven / Leren ────────────────────────────────────────────────
if address :domain :is "From" ["coursera.org", "email.coursera.org",
"m.mail.coursera.org", "m.learn.coursera.org"] {
fileinto "Nieuwsbrieven.Leren.Coursera"; stop;
}
if address :domain :is "From" ["technologyreview.com",
"fulfillment.technologyreview.com"] {
fileinto "Nieuwsbrieven.Leren.MIT Technology Review"; stop;
}
if address :domain :is "From" ["mit.edu"] {
fileinto "Nieuwsbrieven.Leren.MIT Open Learning"; stop;
}
if address :domain :is "From" ["storytellingwithdata.com"] {
fileinto "Nieuwsbrieven.Leren.Storytelling with Data"; stop;
}
if address :domain :is "From" ["linkingyourthinking.com"] {
fileinto "Nieuwsbrieven.Leren.Nick Milo"; stop;
}
if anyof (
address :domain :is "From" ["evakeiffenheim.com"],
header :contains "List-ID" "evakeiffenheim"
) {
fileinto "Nieuwsbrieven.Leren.Eva Keiffenheim"; stop;
}
if address :domain :is "From" ["tiggelaar.nl"] {
fileinto "Nieuwsbrieven.Leren.Ben Tiggelaar"; stop;
}
if address :domain :is "From" ["briankeating.com"] {
fileinto "Nieuwsbrieven.Leren.Brian Keating"; stop;
}
if address :domain :is "From" ["beehiiv.com", "mail.beehiiv.com"] {
fileinto "Nieuwsbrieven.Leren.AI Report"; stop;
}
if address :domain :is "From" ["elizabethbutlermd.com"] {
fileinto "Nieuwsbrieven.Leren.Elizabeth Butler"; stop;
}
# ── 12. Nieuwsbrieven / Nieuws & Vakbladen ───────────────────────────────────
if address :domain :is "From" ["crisismanager.nl"] {
fileinto "Nieuwsbrieven.Nieuws & Vakbladen.Vakblad Crisismanager"; stop;
}
if address :domain :is "From" ["jaarbeurs.nl"] {
fileinto "Nieuwsbrieven.Nieuws & Vakbladen.Computable"; stop;
}
if address :domain :is "From" ["presentation-guru.com"] {
fileinto "Nieuwsbrieven.Nieuws & Vakbladen.The Presentation Guru"; stop;
}
# ── 13. Nieuwsbrieven / Overig ───────────────────────────────────────────────
if address :domain :is "From" ["lendahand.com"] {
fileinto "Nieuwsbrieven.Overig.Lendahand"; stop;
}
if address :domain :is "From" ["thegoodroll.nl"] {
fileinto "Nieuwsbrieven.Overig.The Good Roll"; stop;
}
# ── 14. Technisch ────────────────────────────────────────────────────────────
if anyof (
header :contains "Subject" ["DMARC", "SPF", "DKIM"],
header :contains "From" "dmarc"
) {
fileinto "Technisch.DMARC"; stop;
}
if anyof (
header :contains "Subject" ["delivery failed", "undeliverable",
"mailer-daemon", "mail delivery"],
address :is "From" "mailer-daemon@australius.nl"
) {
fileinto "Technisch"; stop;
}
```
---
## Te maken mappen (nieuw)
De volgende mappen moeten aangemaakt worden op de IMAP-server
(het maak-script doet dit automatisch):
```
Nieuwsbrieven
Nieuwsbrieven.Leren
Nieuwsbrieven.Leren.AI Report
Nieuwsbrieven.Leren.Ben Tiggelaar
Nieuwsbrieven.Leren.Brian Keating
Nieuwsbrieven.Leren.Coursera
Nieuwsbrieven.Leren.Elizabeth Butler
Nieuwsbrieven.Leren.Eva Keiffenheim
Nieuwsbrieven.Leren.MIT Open Learning
Nieuwsbrieven.Leren.MIT Technology Review
Nieuwsbrieven.Leren.Nick Milo
Nieuwsbrieven.Leren.Storytelling with Data
Nieuwsbrieven.Nieuws & Vakbladen
Nieuwsbrieven.Nieuws & Vakbladen.Computable
Nieuwsbrieven.Nieuws & Vakbladen.The Presentation Guru
Nieuwsbrieven.Nieuws & Vakbladen.Vakblad Crisismanager
Nieuwsbrieven.Overig
Nieuwsbrieven.Overig.Lendahand
Nieuwsbrieven.Overig.The Good Roll
Financieel
Financieel.Facturen
Financieel.Facturen.2023.Q1
Financieel.Facturen.2023.Q2
Financieel.Facturen.2023.Q3
Financieel.Facturen.2023.Q4
Financieel.Facturen.2024.Q1
Financieel.Facturen.2024.Q2
Financieel.Facturen.2024.Q3
Financieel.Facturen.2024.Q4
Financieel.Facturen.2025.Q1
Financieel.Facturen.2025.Q2
Financieel.Facturen.2025.Q3
Financieel.Facturen.2025.Q4
Financieel.Bank
Financieel.Bank.ABN AMRO
Financieel.Bank.ICS
Financieel.Bank.NPEX
Financieel.Bank.SnelStart
Financieel.Bank.Tellow
Financieel.Verzekering
Financieel.Verzekering.Univé
Financieel.Verzekering.VEZA
Werk
Werk.Opdrachten
Werk.Opdrachten.Circle8
Werk.Opdrachten.Flextender
Werk.Opdrachten.FutureXL
Werk.Opdrachten.Get There
Werk.Opdrachten.Huckr
Werk.Opdrachten.IMMENS
Werk.Opdrachten.Jamie
Werk.Opdrachten.OneStopSourcing
Werk.Overig
Werk.Overig.BOIP
Werk.Overig.Gemeente Groningen
Diensten
Diensten.Hosting
Diensten.Hosting.Backblaze
Diensten.Hosting.Let's Encrypt
Diensten.Hosting.STRATO
Diensten.Hosting.TransIP
Diensten.Software
Diensten.Software.1Password
Diensten.Software.CogSci Apps
Diensten.Software.Claude
Diensten.Software.Cursor
Diensten.Software.Kahoot
Diensten.Software.Matter
Diensten.Software.Microsoft Teams
Diensten.Software.MindNode
Diensten.Software.Papers
Diensten.Software.Perplexity
Diensten.Software.Sophos
Diensten.Telecom
Diensten.Telecom.KPN
Diensten.Telecom.Vodafone
Mobiliteit
Mobiliteit.EV
Mobiliteit.EV.Alfen
Mobiliteit.EV.Eneco eMobility
Mobiliteit.EV.FastNed
Mobiliteit.EV.Mitsubishi
Mobiliteit.EV.OPnGO
Mobiliteit.EV.Shell Recharge
Mobiliteit.EV.Tesla
Mobiliteit.OV & Reizen
Mobiliteit.OV & Reizen.Booking.com
Mobiliteit.OV & Reizen.Flitsmeister
Mobiliteit.OV & Reizen.NS
Bestellingen
Bestellingen.123inkt
Bestellingen.Allekabels
Bestellingen.bol.com
Bestellingen.Coolblue
Bestellingen.DHL
Bestellingen.GLS
Bestellingen.Makro
Bestellingen.Office Centre
Bestellingen.PostNL
Bestellingen.Sligro
Bestellingen.Smartphonehoesjes
Bestellingen.Viking
Archief.2023
Archief.2024
Archief.2025
```
File diff suppressed because it is too large Load Diff
+382
View File
@@ -0,0 +1,382 @@
# Script review: mailbox creation and redistribution
Date: 2026-07-04
Scope reviewed:
- `maak_mappen.py`
- `verplaats_bestaand.py`
- `mailrules.sieve`
- `upload_sieve.py`
- Supporting scripts: `download_mailbox.py`, `analyse_mailbox.py`, `review_mailinglists.py`, `unsubscribe.py`, `kopieer_naar_backup.py`, `dagelijks_overzicht.py`
Static checks:
- `python3 -m py_compile *.py` passes.
- `verplaats_log.json` currently contains 14 completed step-1 folders and 3166 processed INBOX UIDs, so at least one real redistribution run has been attempted.
## Executive summary
The folder creation script mostly works, but redistribution has several real defects that can explain failed or incomplete moves.
The highest-risk issue is in `verplaats_bestaand.py`: step 1 opens source folders read-only and then tries to mark messages as deleted. On a normal IMAP server, this makes a true move impossible. The script may copy messages but fail to delete originals, while still logging the folder as completed. That can leave duplicates and prevent a later rerun from correcting the folder.
There is also a folder contract mismatch: `maak_mappen.py` does not create `INBOX.Technisch` or `INBOX.Technisch.DMARC`, while both the Sieve rules and redistribution route mail there. That will fail for those categories unless the folders already exist.
The logs are not strong enough for safe recovery: they record intended work as complete without validating that the source message disappeared and the destination message exists. If the first real run failed partially, the safest next action is to audit actual server folder counts before rerunning with the current log.
## Findings
### P0: Step 1 selects source folders read-only, then tries to delete messages
File: `verplaats_bestaand.py`
Relevant lines:
- Line 284: `mail.select(..., readonly=True)`
- Lines 301-303: copy the message and then `STORE +FLAGS \Deleted`
- Line 306: `EXPUNGE`
- Lines 307-310: reports success and logs the source folder as done
Problem:
Step 1 always selects the source folder with `readonly=True`, including during real execution. In read-only mode, `COPY` can succeed, but `STORE +FLAGS \Deleted` should fail or be ignored by the server. That means this code is not reliably moving messages; it may only copy them.
Impact:
- Messages may remain in their original folders.
- Duplicates may be created in destination folders.
- The script still appends the folder to `stap1_klaar`, so reruns skip it.
- This matches the symptom: folder creation works, redistribution appears to fail.
Recommended fix:
- Select read-only only in dry-run mode:
- `mail.select(..., readonly=dry)`
- Check both `COPY` and `STORE` results.
- Only log a folder as complete if all expected messages were copied and deleted.
- Prefer UID-based operations in step 1, consistent with step 2.
### P0: The redistribution log can mark failed or partial moves as complete
File: `verplaats_bestaand.py`
Relevant lines:
- Lines 301-304: `STORE` result is ignored.
- Lines 307-310: folder is logged as done regardless of whether `gekopieerd == n`.
- Lines 365-369: step 2 logs a UID as processed when `COPY` succeeds, even though `STORE` result is not checked inside `move_message`.
- Lines 256-268: `move_message()` returns success based only on `COPY`.
Problem:
The code treats `COPY OK` as a successful move. A move is only complete if:
1. The message was copied to the destination.
2. The source message was marked deleted.
3. The deletion was expunged, or a later expunge is guaranteed.
The current code does not verify steps 2 and 3 before writing progress to `verplaats_log.json`.
Impact:
- A rerun may skip messages that are still in the source folder.
- Failures from missing destination folders, read-only source folders, or permission issues can be hidden.
- `verplaats_log.json` is currently not a reliable source of truth.
Recommended fix:
- Make `move_message()` return false unless both `UID COPY` and `UID STORE +FLAGS \Deleted` return `OK`.
- Save detailed failures in the log with source, UID, destination, and server response.
- Do not append to `stap1_klaar` unless the final moved count equals the expected count.
- Before rerunning after the current failure, either archive/reset `verplaats_log.json` or write a repair mode that reconciles the log against actual server state.
### P1: `maak_mappen.py` omits folders targeted by redistribution and Sieve
Files:
- `maak_mappen.py`
- `verplaats_bestaand.py`
- `mailrules.sieve`
Relevant lines:
- `verplaats_bestaand.py` line 165 targets `Technisch.DMARC`.
- `mailrules.sieve` lines 230-241 targets `Technisch.DMARC` and `Technisch`.
- `maak_mappen.py` lines 85-193 contains no `Technisch` or `Technisch.DMARC`.
Problem:
The folder creator and routing targets are inconsistent. Static comparison found these Sieve targets missing from `maak_mappen.py`:
- `Technisch`
- `Technisch.DMARC`
The dynamic Sieve invoice path `Financieel.Facturen.${jaar}.${kwartaal}` is not directly comparable, but the concrete year/quarter folders for 2020-2026 are created.
Impact:
- Technical/DMARC messages fail to move if those folders do not already exist.
- Sieve `fileinto` for those folders can fail for new mail.
Recommended fix:
- Add `Technisch` and `Technisch.DMARC` to `maak_mappen.py`.
- Add a test or check that every static `fileinto` destination and every Python route destination exists in the folder creation list.
### P1: Step 1 assumes archive parent folders already exist
Files:
- `maak_mappen.py`
- `verplaats_bestaand.py`
Relevant lines:
- `verplaats_bestaand.py` lines 192-198 moves old archive subfolders into `INBOX.Archief.2020`, `INBOX.Archief.2021`, and `INBOX.Archief.2022`.
- `maak_mappen.py` lines 188-192 creates only `Archief.2023` through `Archief.2026`.
Problem:
The redistribution script targets `INBOX.Archief.2020`, `INBOX.Archief.2021`, and `INBOX.Archief.2022`, but the folder creator does not create them. In the current offline mailbox they exist, so this may work on the original account. It can fail on a fresh backup account or after recreating the target structure from scratch.
Impact:
- Archive redistribution may fail on accounts where those parent folders do not already exist.
- If `COPY` fails because the destination does not exist, step 1 still risks logging the source as done.
Recommended fix:
- Include `Archief`, `Archief.2020`, `Archief.2021`, and `Archief.2022` in `maak_mappen.py`, or have `verplaats_bestaand.py` create missing destination folders before moving.
### P1: `verplaats_bestaand.py` only routes INBOX, despite claiming broader routing
File: `verplaats_bestaand.py`
Relevant lines:
- Lines 8-9: docstring says "INBOX + overige mappen".
- Line 204: comment says folders can be routed per message.
- Line 205: `ROUTE_FOLDERS = ["INBOX"]`
- Lines 315-377: function is hard-coded to INBOX.
Problem:
The documentation implies broader per-message routing, but the implementation only processes `INBOX`. Existing messages in other folders are only moved if they are covered by the hard-coded whole-folder renames or by the invoice step.
Impact:
- Many existing messages outside INBOX remain unsorted.
- The user may interpret this as redistribution failing, when the script never attempted those folders.
Recommended fix:
- Either make the documentation explicit that only INBOX is routed, or implement routing for a configured set of non-system folders.
- If broad routing is implemented, exclude destination folders to avoid repeatedly reprocessing already sorted mail.
### P1: Routing exact-domain matching is weaker than the reports suggest
File: `verplaats_bestaand.py`
Relevant lines:
- Lines 226-228 extracts the exact sender domain.
- Lines 244-253 uses direct lookup only: `DOMAIN_LOOKUP.get(domain)`.
- Lines 75-166 contains hand-curated domains, including some but not all subdomains.
Problem:
The redistribution routes only exact domains. If mail comes from `mail.example.com`, but the route table contains only `example.com`, it will not match. Some subdomains are listed manually, but this is incomplete by design.
Impact:
- Valid messages can remain in INBOX as "Geen match".
- This can look like the redistribution failed even though the script ran.
Recommended fix:
- Add a parent-domain fallback, for example try `a.b.example.com`, then `b.example.com`, then `example.com`.
- Keep exceptions for multi-tenant senders where parent-domain matching is unsafe.
- Log unmatched high-volume domains for review.
### P1: Sieve paths may use a different namespace prefix than created folders
Files:
- `maak_mappen.py`
- `mailrules.sieve`
- `upload_sieve.py`
Relevant lines:
- `maak_mappen.py` line 16 uses `PREFIX = "INBOX."`.
- `mailrules.sieve` lines 5-7 notes this ambiguity.
- `upload_sieve.py` line 21 uses `SIEVE_PREFIX = ""`.
Problem:
Folder creation creates `INBOX.<path>`, while the uploaded Sieve file currently files into `<path>` without `INBOX.`. On some Dovecot configurations this is correct; on others, Sieve needs `INBOX.<path>`.
Impact:
- New incoming mail may not land in the created folders even if existing-mail redistribution is fixed.
- This affects Sieve/new mail more than `verplaats_bestaand.py`, which does use `INBOX.`.
Recommended fix:
- Verify the active server namespace with a test message.
- If Sieve delivery fails, set `SIEVE_PREFIX = "INBOX."` before upload.
- Consider adding a `--prefix` flag instead of editing a constant.
### P2: Step 3 has no per-message progress log
File: `verplaats_bestaand.py`
Relevant lines:
- Lines 382-427 handles invoice folders.
- No `log` writes are made for step 3.
Problem:
Invoice redistribution can move a large number of messages, but the script does not record which UIDs were successfully processed.
Impact:
- If the run is interrupted, it starts over for whatever remains.
- That is mostly safe if moves are truly atomic, but unsafe with the current weak move verification.
Recommended fix:
- Add the same robust move result logging recommended for step 2.
- Include source folder and UIDVALIDITY or use Message-ID based reconciliation.
### P2: Folder and IMAP LIST parsing is fragile for encoded or unusual names
Files:
- `download_mailbox.py`
- `kopieer_naar_backup.py`
- `dagelijks_overzicht.py`
- `maak_mappen.py`
Relevant examples:
- `download_mailbox.py` lines 22-30
- `kopieer_naar_backup.py` lines 36-41
- `dagelijks_overzicht.py` lines 89-94
- `maak_mappen.py` lines 216-223
Problem:
Several scripts parse `LIST` responses with regular expressions and inconsistent modified UTF-7 decoding. `maak_mappen.py` decodes names, but other scripts mostly do not.
Impact:
- Non-ASCII folders such as `Univé` can be mishandled in scripts that list/select folders from server responses.
- This is less likely to be the main redistribution failure because `verplaats_bestaand.py` mostly uses hard-coded folder names, but it is a reliability issue across the toolkit.
Recommended fix:
- Centralize IMAP folder encoding/decoding and LIST parsing in one helper module.
- Use that helper from all IMAP scripts.
### P2: `kopieer_naar_backup.py` silently ignores append failures
File: `kopieer_naar_backup.py`
Relevant lines:
- Lines 168-179 appends a message to the destination.
- Line 179 silently does `pass` on failure.
- Lines 270-274 marks the folder complete after one pass.
Problem:
A folder can be marked complete even if some message appends failed.
Impact:
- Backup completeness cannot be trusted from `backup_log.json` alone.
- This matters if the backup account is used as a safety net before live redistribution.
Recommended fix:
- Count append failures.
- Do not mark a folder complete if failures occurred.
- Log failed message IDs and server responses.
### P2: `upload_sieve.py` has an incomplete ManageSieve parser
File: `upload_sieve.py`
Relevant lines:
- Lines 38-39 expects to read capabilities.
- Lines 104-109 `_read_capabilities()` is `pass`.
- Lines 58-64 uses a non-synchronizing literal `{len+}`.
Problem:
The ManageSieve client is intentionally minimal. If the server sends multi-line capabilities before `OK`, or does not support non-synchronizing literals, this can behave unpredictably.
Impact:
- Upload may fail on stricter servers.
- If it appears to work on the current server, this is not the likely redistribution failure, because redistribution uses IMAP, not ManageSieve.
Recommended fix:
- Use a tested ManageSieve library, or implement response parsing properly.
- At minimum, capture and print full server responses when upload fails.
## Likely explanation of the current failure
Given the observed state:
- Folder creation appears to work.
- `verplaats_log.json` says all 14 step-1 folder renames were completed.
- `verplaats_log.json` says 3166 INBOX UIDs were processed.
- Step 1 selected folders read-only and then attempted deletion.
The most likely scenario is:
1. Destination folders were created.
2. Step 1 copied messages from old folders into new folders.
3. Deletion from the source folders failed because the source folder was selected read-only.
4. The script still wrote those old folders to `stap1_klaar`.
5. Rerunning the script now skips those old folders, so the failed move is not repaired.
Step 2 may have moved many INBOX messages, but because its log is UID-only and only checks `COPY`, its success should also be verified against actual mailbox state.
## Recommended recovery plan
1. Do not run `verplaats_bestaand.py --uitvoeren` again with the current script and current log.
2. Inspect actual server counts for:
- Old source folders in `stap1_klaar`
- Their intended destination folders
- INBOX
- Invoice folders
3. Archive `verplaats_log.json` before any repair run.
4. Fix move semantics:
- No read-only select during real moves.
- Check `COPY`, `STORE`, and final expunge behavior.
- Log failures explicitly.
5. Add missing folders to `maak_mappen.py`.
6. Add a dry-run audit mode that reports source and destination counts without modifying mail.
7. Only then run a repair mode that moves remaining source messages and avoids creating duplicates, preferably by comparing `Message-ID` in source and destination.
## Minimal code changes to prioritize
1. In `verplaats_bestaand.py`, change step-1 select from `readonly=True` to `readonly=dry`.
2. In `move_message()`, require `UID STORE` to return `OK` before returning success.
3. In step 1, use UID-based search/copy/store and log the folder complete only when all messages moved.
4. In `maak_mappen.py`, add:
- `Technisch`
- `Technisch.DMARC`
- `Archief`
- `Archief.2020`
- `Archief.2021`
- `Archief.2022`
5. Add a destination-existence preflight to `verplaats_bestaand.py` before any real move.
+247 -3
View File
@@ -13,6 +13,12 @@ The repository is intentionally reduced to the minimum tooling needed to:
3. Continue unsubscribe work.
4. Run the daily report.
## Project Goal
Build a reliable mailbox cleanup and automation toolkit for `australius.nl`.
The operational direction is Python over IMAP/SMTP because Sieve is not viable with the provider. The current test target is `backup@australius.nl`. The user wants to test always-on automation first on a local QNAP-like NAS named `hades`, not on `vps.australius.nl`.
Do not run mailbox-affecting scripts yourself unless the user explicitly authorizes it. The user prefers to run those scripts personally.
## Collaboration Rules
@@ -76,6 +82,97 @@ Roundcube/Sieve:
- Rules have Roundcube-compatible `# rule:[...]` names.
- Financial rules are first; the invoice rule remains one dynamic quarter rule, then remaining financial rules and all other rules are alphabetically ordered by rule name.
- Duplicate destination names are disambiguated with `/ Mailing-list` or `/ Domain` suffixes.
=======
- Work on branch `codex` unless the user says otherwise.
- Before every commit, rewrite this file from scratch as the current reconstruction prompt.
- Commit after each user-prompted code change using the required three-section commit message format.
- The user asked: first provide a plan and wait for approval before future broad work. For narrow requested edits, implement directly when the request is explicit.
- Do not stage local runtime output such as `verplaats_log.json` unless the user explicitly asks.
## Local Data And Secrets
Ignored or local-only data includes:
- `mailbox/`
- `**/*.eml`
- `config.json`
- `web_config.json`
- `__pycache__/`
- bytecode files
- `*.log`
- `.DS_Store`
- generated runtime state/log JSON files
Do not commit credentials.
## Routing Policy
Sorting policy:
- `INBOX.Facturen - verwerkt` is the only folder treated as already sorted.
- `Sent`, `Drafts`, `Trash`, and `Spam` are excluded from sorting.
- Every other mailbox folder is treated as a source.
- Do not use `Archief` as a final destination.
- Existing `Archief.*` folders are source folders. Matching messages should move out into functional folders.
- If an archived/source message does not match a rule, leave it in place.
Destination policy:
- Final destination folders are functional folders such as `Financieel`, `Werk`, `Diensten`, `Nieuwsbrieven`, `Bestellingen`, `Mobiliteit`, `Administratie`, and `Technisch`.
- `Technisch` and `Technisch.DMARC` are valid destinations.
- `Administratie.KvK` is the destination for KvK messages.
- Former assignment senders route under `Werk.Opdrachten.*`.
- `zuiderzee.net` and generic `gmail.com` intentionally remain unmatched.
- AI providers route under `Diensten.AI`; confirmed AI providers include Canva, Claude/Anthropic, Cursor, Huckr, Jamie, Mistral, OpenAI, and Perplexity.
- Microsoft, Zapier, Envato, reMarkable, Nord, and ExpressVPN are not treated as AI providers unless the user reclassifies them.
## Current Implementation
Shared modules:
- `imap_utils.py` centralizes IMAP modified UTF-7 folder encoding/decoding, quoted mailbox names, LIST parsing, and folder listing.
- `mail_routes.py` centralizes routing behavior: invoice keyword detection, mailing-list routing, domain routing, source-folder exclusions, already-sorted folders, invoice quarter routing, and destination folder generation.
- `mail_imap_ops.py` centralizes message header extraction, destination computation, destination mailbox creation, and UID COPY plus UID STORE move semantics.
Domain routes:
- Hardcoded domain routes have been moved out of Python into `domain_routes.json`.
- `mail_routes.domain_routes()` loads and validates `domain_routes.json`.
- `mail_routes.domain_lookup()` builds the cached domain lookup.
- `DOMAIN_ROUTES` and `DOMAIN_LOOKUP` still exist for compatibility, but active routing uses `domain_lookup()`.
- If a long-running sorter process is already running, route edits on disk require restarting that process before it sees the new rules.
Mailing-list routes:
- `mailinglist_routes.json` is still separate from domain routes.
- `mail_routes.py` checks mailing-list routes before domain routes.
- `mailinglist_routes.json` covers normalized policy from `decisions.json`, including `Afmelden.*` destinations.
- Mailing-list routing supports domain and `From` substring matching; it does not yet fetch or match `List-ID`.
Historical sorting:
- `verplaats_bestaand.py` defaults to dry-run. Real moves require `--uitvoeren` and interactive `JA`.
- Account selection is explicit with `--account <naam>`, for example `--account backup`.
- `--audit` is available in dry-run mode.
- It logs to `verplaats_log.json`, retries IMAP aborts where implemented, and parses FETCH responses defensively.
Folder creation:
- `maak_mappen.py` creates destination folders generated from `mail_routes.destination_folders()`.
- It creates parent folders as needed and avoids `Archief.*` destinations.
Backup mirror:
- `kopieer_naar_backup.py` mirrors configured IMAP accounts, deduplicating by `Message-ID` per destination folder.
- It uses `backup_log.json` for progress and does not mark a folder complete if failures occurred.
Live daemon:
- `sort_mail_daemon.py` defaults to `--account backup --folder INBOX`.
- It uses shared routing through `mail_imap_ops.destination_from_header()`.
- It maintains processed UID state and resets when UIDVALIDITY changes.
- It supports `--once` and `--dry-run`.
- It uses IMAP IDLE where available, falls back conservatively, reconnects on failures, and logs to `sort_mail_daemon.log` by default.
Daily report:
@@ -127,12 +224,159 @@ If `/tmp/mailcat-sieve-test/dovecot.conf` does not exist, recreate it for Doveco
```sh
mkdir -p /tmp/mailcat-sieve-test
printf 'dovecot_config_version = 2.4.0\ndovecot_storage_version = 2.4.0\nssl = no\n' > /tmp/mailcat-sieve-test/dovecot.conf
=======
- The NAS daily report target is `hans@australius.nl`.
- It sends a report even when there are zero messages.
- Report rows fetch `Message-ID` and render the subject as an Apple Mail `message://` link when a `Message-ID` is present.
- Apple Mail links are macOS-oriented. They depend on Apple Mail having the message synced/indexed locally; iOS Mail should not be treated as reliable for these links.
- Report HTML escapes folder labels, senders, subjects, and link attributes.
## Web Route Management
The route-management web interface is implemented in `manage_routes_web.py`.
Current decisions:
- LAN binding: `0.0.0.0`
- Port: `4321`
- Authentication: HTTP Basic Auth
- Credentials come from ignored `web_config.json` or environment variables `MAILCAT_WEB_USER` and `MAILCAT_WEB_PASSWORD`.
- Example config is in `web_config.example.json`.
Scope:
- The web UI manages all domain routes in `domain_routes.json`, including the previous built-in routes.
- Mailing-list routes remain in `mailinglist_routes.json` and are not yet managed by the web UI.
Behavior:
- `GET /` serves a dense table/form UI.
- `GET /api/routes` returns all routes.
- `POST /api/routes` appends a route.
- `PUT /api/routes/<index>` updates a route.
- `DELETE /api/routes/<index>` deletes a route.
- Domains and mailbox names are validated server-side.
- Duplicate domains across routes are rejected.
- Writes are atomic and create `domain_routes.json.bak`.
Security note:
- This is Basic Auth on the internal network. It is not HTTPS by itself. If exposed beyond the LAN, put it behind TLS or a VPN.
## NAS Deployment
The files have already been deployed to the NAS and the webserver is running.
`deploy_vps.sh` is host-neutral despite its historical filename.
For the QNAP-like NAS, use:
```sh
SERVICE_MANAGER=plain
MAILCAT_HOME=/share/homes/mailcat
PYTHON=/opt/bin/python3
APP_USER=mailcat
```
Known NAS command paths supplied by the user:
- `SUDO=/usr/bin/sudo`
- `USERADD=/usr/local/bin/useradd`
- `PYTHON=/opt/bin/python3`
- `INSTALL=/usr/bin/install`
- `SED=/bin/sed`
- `TAR=/bin/tar`
- `MKDIR=/bin/mkdir`
- `CHOWN=/bin/chown`
- `CHMOD=/bin/chmod`
- `RM=/bin/rm`
In `SERVICE_MANAGER=plain` mode:
- Default `APP_DIR` is `/share/homes/mailcat/mailcat`.
- Default `STATE_DIR` is `$APP_DIR/state`.
- Default `LOG_DIR` is `$APP_DIR/log`.
- Default `RUN_DIR` is `$APP_DIR/run`.
- The sorter PID file is `$RUN_DIR/mailcat.pid`.
- The web PID file is `$RUN_DIR/mailcat_web.pid`.
- The deploy script installs:
- `$APP_DIR/bin/start_mailcat.sh`
- `$APP_DIR/bin/stop_mailcat.sh`
- `$APP_DIR/bin/status_mailcat.sh`
- `$APP_DIR/bin/run_daily_report.sh`
- `$APP_DIR/bin/start_web.sh`
- `$APP_DIR/bin/stop_web.sh`
- `$APP_DIR/bin/status_web.sh`
- The sorter appends wrapper stdout/stderr to `$LOG_DIR/daemon.out`.
- The web UI appends stdout/stderr to `$LOG_DIR/web.out`.
- The daily report wrapper appends stdout/stderr to `$LOG_DIR/daily_report.out`.
- `stop_mailcat.sh` and `stop_web.sh` in plain mode now send SIGTERM, wait up to 20 seconds, then fall back to SIGKILL and wait up to 5 seconds before reporting failure.
Before starting services on the NAS:
- Create `/share/homes/mailcat/mailcat/config.json` with mode `600`.
- Create `/share/homes/mailcat/mailcat/web_config.json` with mode `600`, for example:
```json
{
"username": "mailcat",
"password": "use-a-real-password"
}
```
After route edits through the web UI, restart `start_mailcat.sh`/`stop_mailcat.sh` if the live sorter should use the new rules.
Systemd mode still exists for a future VPS deployment using `systemd/mailcat-sort.service`.
## Current Backup Test Status
The backup mirror completed earlier:
- Source eligible mirror scope: 34 folders, 11,203 messages.
- Script copied: 11,190 messages.
- Duplicate skips: 13.
- Backup account after mirror: 159 folders, 11,190 messages.
The user later ran the backup historical sorter successfully and reported:
```text
Totaal gepland: 0 | Totaal geen match: 2218 | Totaal overgeslagen: 8219 | Totaal fouten: 0
```
Treat that as a successful backup historical-sorter run with no immediate recovery work needed.
## Verification Commands
Latest local verification included:
```sh
python3 -m unittest tests.test_dagelijks_overzicht
python3 -m py_compile *.py tests/*.py
bash -n deploy_vps.sh
python3 -m unittest discover -s tests
git diff --check
```
After the plain-mode stop wrapper change, `bash -n deploy_vps.sh` was rerun successfully.
Useful routing sanity check:
```sh
python3 - <<'PY'
from mail_routes import destination_folders, domain_routes, route_from_subject
print("routes", len(domain_routes()))
print("folders", len(destination_folders()))
print("openai", route_from_subject("noreply@openai.com", "update"))
print("huckr", route_from_subject("hello@huckr.ai", "update"))
PY
```
## Next Expected Work
Likely next steps:
1. Finish unsubscribe work, with the user running `unsubscribe.py`.
2. Deploy the reduced project to the NAS if desired.
3. Schedule `dagelijks_overzicht.py` daily at 06:00 on the NAS.
1. Deploy the Apple Mail link update and strengthened stop wrappers to the NAS.
2. If an already-running sorter will not stop with the old wrapper, stop it manually by reading `$RUN_DIR/mailcat.pid` and sending TERM/KILL to that PID, then remove the stale PID file.
3. Run `/share/homes/mailcat/mailcat/bin/run_daily_report.sh` manually on the NAS to send a test digest.
4. Open the digest on macOS Mail and verify subject links open local Apple Mail messages.
5. If iOS/web reliability is needed later, add Roundcube links using mailbox plus IMAP UID, or add a read-only Mailcat message viewer.
+356
View File
@@ -0,0 +1,356 @@
#!/usr/bin/env python3
"""
Interactief script om mailinglijsten te beoordelen.
h = houden | a = afmelden | s = overslaan | q = stoppen
Eerste run: scant mailbox en slaat cache op in mailing_lists_cache.json.
Volgende runs: laadt direct uit cache.
Beslissingen worden opgeslagen in decisions.json (herstart-safe).
"""
import email
import email.header
import email.utils
import re
import sys
import tty
import termios
import json
from pathlib import Path
from collections import defaultdict, Counter
from datetime import timezone
MAILBOX_DIR = Path(__file__).parent / "mailbox"
CACHE_FILE = Path(__file__).parent / "mailing_lists_cache.json"
DECISIONS_FILE = Path(__file__).parent / "decisions.json"
# Domeinen die door meerdere afzenders worden gedeeld → groeperen op naam, niet domein
MULTITENANT_DOMAINS = {
"substack.com", "mcsv.net", "list-manage.com",
"sparkpostmail.com", "eu.sparkpostmail.com",
"convertkit-mail.com", "convertkit-mail2.com", "convertkit-mail4.com",
"sendgrid.net", "mailgun.org", "dripemail.com", "dripemail3.com",
"mailchimpapp.net", "rsgsv.net",
}
MIN_COUNT = 3 # lijsten met minder dan dit aantal mails worden overgeslagen
# ── helpers ──────────────────────────────────────────────────────────────────
def decode_hdr(value) -> str:
if not value:
return ""
try:
parts = email.header.decode_header(value)
out = []
for raw, cs in parts:
if isinstance(raw, bytes):
out.append(raw.decode(cs or "utf-8", errors="replace"))
else:
out.append(str(raw))
return " ".join(out).strip()
except Exception:
return str(value or "")
def parse_dt(s):
if not s:
return None
try:
return email.utils.parsedate_to_datetime(s)
except Exception:
return None
def to_utc_ts(d) -> float | None:
if d is None:
return None
if d.tzinfo is None:
d = d.replace(tzinfo=timezone.utc)
return d.timestamp()
_EMAIL_SUBDOMAINS = re.compile(
r"^(email|mail|e|m|news|newsletter|noreply|no-reply|lists?|"
r"mailing|bounce|notifications?|updates?|info|reply|send|smtp|"
r"mg|sg|em|learn|go|click|edm|cdn|app|get)\.",
re.I,
)
def sender_domain(from_addr: str) -> str:
m = re.search(r"@([\w.\-]+)", from_addr)
if not m:
return "onbekend"
domain = m.group(1).lower()
# Strip bekende e-mail-subdomeinen zodat bijv. email.coursera.org → coursera.org
parts = domain.split(".")
while len(parts) > 2 and _EMAIL_SUBDOMAINS.match(parts[0] + "."):
parts = parts[1:]
return ".".join(parts)
def display_name(from_addr: str) -> str:
"""Haal leesbare naam op; val terug op domein."""
m = re.match(r'^"?([^"<@\n]{3,})"?\s*<', from_addr)
if m:
name = m.group(1).strip().strip('"').strip()
if len(name) > 2:
return name
return sender_domain(from_addr)
def normalize_name(name: str) -> str:
"""Normaliseer naam voor groepering (lowercase, alleen woorden)."""
return " ".join(re.findall(r"[a-z0-9]+", name.lower()))
def fmt_date(ts: float | None) -> str:
if ts is None:
return "?"
from datetime import datetime
return datetime.utcfromtimestamp(ts).strftime("%Y-%m-%d")
def getch() -> str:
fd = sys.stdin.fileno()
old = termios.tcgetattr(fd)
try:
tty.setraw(fd)
return sys.stdin.read(1)
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old)
# ── cache bouwen ─────────────────────────────────────────────────────────────
def build_cache() -> list[dict]:
"""
Scan mailbox headers en groepeer op weergavenaam.
Elke entry in de cache is één logische mailinglijst.
"""
eml_files = list(MAILBOX_DIR.rglob("*.eml"))
total = len(eml_files)
print(f"Scannen van {total} e-mails (alleen headers)...", flush=True)
# Per weergavenaam (genormaliseerd) verzamelen we alle info
by_name: dict[str, dict] = defaultdict(lambda: {
"names": [], # alle aangetroffen weergavenamen
"domains": [], # alle aangetroffen domeinen
"unsubscribe": [], # List-Unsubscribe waarden
"count": 0,
"timestamps": [], # als float (UTC)
"subjects": [],
"folders": [],
})
for i, path in enumerate(eml_files, 1):
if i % 2000 == 0:
print(f" {i}/{total}...", flush=True)
try:
with open(path, "rb") as f:
chunk = f.read(8192)
msg = email.message_from_bytes(chunk)
except Exception:
continue
from_raw = decode_hdr(msg.get("From", ""))
subject = decode_hdr(msg.get("Subject", ""))
date = parse_dt(msg.get("Date"))
list_id = (msg.get("List-ID") or "").strip()
list_us = (msg.get("List-Unsubscribe") or "").strip()
prec = (msg.get("Precedence") or "").lower()
folder = path.parent.name
is_ml = bool(
list_id or list_us
or prec in ("bulk", "list")
or "mailinglist" in folder.lower()
)
if not is_ml:
continue
name = display_name(from_raw)
domain = sender_domain(from_raw)
# Multi-tenant domeinen: groepeer op genormaliseerde naam
# Gewone domeinen: groepeer op domein (voorkomt splits zoals "LinkedIn" vs "LinkedIn Job Alerts")
if domain in MULTITENANT_DOMAINS:
key = normalize_name(name) or domain
else:
key = domain
e = by_name[key]
e["names"].append(name)
e["domains"].append(domain)
e["count"] += 1
e["folders"].append(folder)
ts = to_utc_ts(date)
if ts:
e["timestamps"].append(ts)
if len(e["subjects"]) < 3:
e["subjects"].append(subject)
if list_us and list_us not in e["unsubscribe"]:
e["unsubscribe"].append(list_us)
print(f" {total}/{total} klaar.\n")
# Omzetten naar gesorteerde lijst met mooie namen
result = []
for key, e in by_name.items():
best_name = Counter(e["names"]).most_common(1)[0][0]
best_domain = Counter(e["domains"]).most_common(1)[0][0]
best_folder = Counter(e["folders"]).most_common(1)[0][0]
ts_sorted = sorted(e["timestamps"])
result.append({
"key": key,
"name": best_name,
"domain": best_domain,
"folder": best_folder,
"count": e["count"],
"oldest": ts_sorted[0] if ts_sorted else None,
"newest": ts_sorted[-1] if ts_sorted else None,
"subjects": e["subjects"],
"unsubscribe": e["unsubscribe"],
"domains": list(set(e["domains"])),
})
result.sort(key=lambda x: -x["count"])
result = [e for e in result if e["count"] >= MIN_COUNT]
CACHE_FILE.write_text(
json.dumps(result, indent=2, ensure_ascii=False), encoding="utf-8"
)
print(f"{len(result)} unieke mailinglijsten opgeslagen in cache.\n")
return result
def load_cache() -> list[dict]:
return json.loads(CACHE_FILE.read_text(encoding="utf-8"))
# ── beslissingen ─────────────────────────────────────────────────────────────
def load_decisions() -> dict:
if not DECISIONS_FILE.exists():
return {}
try:
data = json.loads(DECISIONS_FILE.read_text(encoding="utf-8"))
return {k: (v["keuze"] if isinstance(v, dict) else v) for k, v in data.items()}
except Exception:
return {}
def save_decisions(decisions: dict, lists: list[dict]):
by_key = {e["key"]: e for e in lists}
output = {}
for key, keuze in decisions.items():
e = by_key.get(key, {})
output[key] = {
"keuze": keuze,
"naam": e.get("name", key),
"count": e.get("count", 0),
"domains": e.get("domains", []),
"unsubscribe": e.get("unsubscribe", []),
}
DECISIONS_FILE.write_text(
json.dumps(output, indent=2, ensure_ascii=False), encoding="utf-8"
)
# ── interactieve review ───────────────────────────────────────────────────────
KEYS = {"h", "a", "s", "q"}
LABELS = {
"h": ("houden", "\033[32m"),
"a": ("afmelden", "\033[31m"),
"s": ("overslaan", "\033[33m"),
}
def run_review(lists: list[dict], decisions: dict):
total = len(lists)
remaining = [e for e in lists if e["key"] not in decisions]
print(f"{''*64}")
print(f" Totaal: {total} | Al beoordeeld: {len(decisions)} | Te gaan: {len(remaining)}")
print(f" \033[32mh\033[0m houden \033[31ma\033[0m afmelden \033[33ms\033[0m overslaan \033[90mq\033[0m stoppen")
print(f"{''*64}\n")
done = sum(1 for e in lists if e["key"] in decisions)
for entry in remaining:
unsub = "✓ beschikbaar" if entry["unsubscribe"] else "✗ handmatig opzoeken"
domains_str = ", ".join(entry["domains"][:3])
if len(entry["domains"]) > 3:
domains_str += f" (+{len(entry['domains'])-3})"
print(f"\033[1m[{done+1}/{total}] {entry['name']}\033[0m")
print(f" \033[90mmails :\033[0m {entry['count']} "
f"({fmt_date(entry['oldest'])} {fmt_date(entry['newest'])})")
print(f" \033[90mdomein :\033[0m {domains_str}")
print(f" \033[90mmap :\033[0m {entry['folder']}")
print(f" \033[90mafmeld :\033[0m {unsub}")
if entry["subjects"]:
print(f" \033[90mvoorbeeld:\033[0m {entry['subjects'][0][:68]}")
print()
print(" > ", end="", flush=True)
ch = ""
while ch not in KEYS:
ch = getch().lower()
if ch == "q":
print("\n\nGestopt. Voortgang opgeslagen.\n")
save_decisions(decisions, lists)
print_summary(decisions)
return
label, color = LABELS[ch]
print(f"{color}{label}\033[0m\n")
decisions[entry["key"]] = ch
done += 1
save_decisions(decisions, lists)
print(f"{''*64}")
print(f"Klaar! Alle {total} lijsten beoordeeld.")
save_decisions(decisions, lists)
print_summary(decisions)
def print_summary(decisions: dict):
a = sum(1 for c in decisions.values() if c == "a")
h = sum(1 for c in decisions.values() if c == "h")
s = sum(1 for c in decisions.values() if c == "s")
print(f"\n Afmelden : {a}")
print(f" Houden : {h}")
print(f" Overgeslagen: {s}")
print(f"\nOpgeslagen in: {DECISIONS_FILE}")
if a:
print("Draai daarna: python3 unsubscribe.py")
# ── main ──────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
if CACHE_FILE.exists():
print(f"Cache gevonden, laden...\n")
lists = load_cache()
else:
lists = build_cache()
decisions = load_decisions()
if decisions:
active_keys = {e["key"] for e in lists}
active = {k: v for k, v in decisions.items() if k in active_keys}
a = sum(1 for c in active.values() if c == "a")
h = sum(1 for c in active.values() if c == "h")
s = sum(1 for c in active.values() if c == "s")
stale = len(decisions) - len(active)
msg = f"Vorige sessie: {a} afmelden, {h} houden, {s} overgeslagen"
if stale:
msg += f" ({stale} verouderde beslissingen genegeerd)"
print(msg + ".\n")
run_review(lists, decisions)
+1
View File
@@ -0,0 +1 @@
12747
+246
View File
@@ -0,0 +1,246 @@
#!/usr/bin/env python3
"""IMAP IDLE daemon for routing new mail through mailcat rules."""
from __future__ import annotations
import argparse
import imaplib
import json
import logging
import select
import signal
import sys
import time
from pathlib import Path
import config
from imap_utils import quote_mailbox
from mail_imap_ops import HEADER_FETCH, destination_from_header, ensure_mailbox, fetch_body, move_message
DEFAULT_STATE_FILE = Path(__file__).parent / "sort_mail_daemon_state.json"
DEFAULT_LOG_FILE = Path(__file__).parent / "sort_mail_daemon.log"
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Sort new IMAP mail using mailcat routes.")
parser.add_argument("--account", default="backup", help="config.json account name")
parser.add_argument("--folder", default="INBOX", help="folder to watch")
parser.add_argument("--state-file", type=Path, default=DEFAULT_STATE_FILE)
parser.add_argument("--log-file", type=Path, default=DEFAULT_LOG_FILE)
parser.add_argument("--idle-timeout", type=int, default=1740)
parser.add_argument("--reconnect-delay", type=int, default=30)
parser.add_argument("--once", action="store_true", help="process once and exit")
parser.add_argument("--dry-run", action="store_true", help="log intended moves without changing IMAP")
return parser
def load_state(path: Path) -> dict:
if not path.exists():
return {"folder_state": {}}
try:
state = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return {"folder_state": {}}
state.setdefault("folder_state", {})
return state
def save_state(path: Path, state: dict):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(state, indent=2, ensure_ascii=False), encoding="utf-8")
def folder_state(state: dict, folder: str) -> dict:
folders = state.setdefault("folder_state", {})
entry = folders.setdefault(folder, {})
entry.setdefault("uidvalidity", None)
entry.setdefault("processed_uids", [])
return entry
def reset_on_uidvalidity_change(state: dict, folder: str, uidvalidity: str | None):
entry = folder_state(state, folder)
if entry.get("uidvalidity") != uidvalidity:
entry["uidvalidity"] = uidvalidity
entry["processed_uids"] = []
def processed_set(state: dict, folder: str) -> set[str]:
return set(folder_state(state, folder).get("processed_uids", []))
def mark_processed(state: dict, folder: str, uid: str):
entry = folder_state(state, folder)
processed = set(entry.get("processed_uids", []))
processed.add(uid)
entry["processed_uids"] = sorted(processed, key=lambda value: int(value) if value.isdigit() else value)
def setup_logging(log_file: Path):
log_file.parent.mkdir(parents=True, exist_ok=True)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
handlers=[logging.FileHandler(log_file, encoding="utf-8"), logging.StreamHandler(sys.stdout)],
)
def connect(account_name: str):
cfg = config.load(account_name)
mail = imaplib.IMAP4_SSL(cfg.imap_host, cfg.imap_port)
mail.login(cfg.username, cfg.password)
logging.info("connected account=%s username=%s host=%s", cfg.name, cfg.username, cfg.imap_host)
return mail
def select_folder(mail, folder: str) -> str | None:
status, data = mail.select(quote_mailbox(folder))
if status != "OK":
raise RuntimeError(f"Cannot select {folder}: {data!r}")
_status, data = mail.response("UIDVALIDITY")
if data and data[0]:
return data[0].decode(errors="replace") if isinstance(data[0], bytes) else str(data[0])
return None
def uid_search_all(mail) -> list[bytes]:
status, data = mail.uid("SEARCH", None, "ALL")
if status != "OK" or not data or not data[0]:
return []
return [uid for uid in data[0].split() if uid]
def process_folder(mail, state: dict, folder: str, dry_run: bool) -> dict[str, int]:
uidvalidity = select_folder(mail, folder)
reset_on_uidvalidity_change(state, folder, uidvalidity)
processed = processed_set(state, folder)
counts = {"seen": 0, "matched": 0, "moved": 0, "unmatched": 0, "failed": 0, "skipped": 0}
for uid in uid_search_all(mail):
uid_text = uid.decode(errors="replace")
counts["seen"] += 1
if uid_text in processed:
counts["skipped"] += 1
continue
status, data = mail.uid("FETCH", uid, HEADER_FETCH)
raw_header = fetch_body(data)
if status != "OK" or raw_header is None:
counts["failed"] += 1
logging.warning("fetch_failed folder=%s uid=%s status=%s data=%r", folder, uid_text, status, data)
continue
destination = destination_from_header(raw_header)
if not destination or destination == folder:
counts["unmatched"] += 1
mark_processed(state, folder, uid_text)
logging.info("no_match folder=%s uid=%s", folder, uid_text)
continue
counts["matched"] += 1
if dry_run:
logging.info("dry_move folder=%s uid=%s destination=%s", folder, uid_text, destination)
mark_processed(state, folder, uid_text)
continue
if not ensure_mailbox(mail, destination):
counts["failed"] += 1
logging.error("ensure_destination_failed folder=%s uid=%s destination=%s", folder, uid_text, destination)
continue
ok, failure = move_message(mail, uid, folder, destination)
if ok:
counts["moved"] += 1
mark_processed(state, folder, uid_text)
logging.info("moved folder=%s uid=%s destination=%s", folder, uid_text, destination)
else:
counts["failed"] += 1
logging.error("move_failed folder=%s uid=%s destination=%s failure=%r", folder, uid_text, destination, failure)
if not dry_run:
mail.expunge()
return counts
def wait_for_mail(mail, timeout: int) -> bool:
if hasattr(mail, "idle"):
with mail.idle(duration=timeout) as idler:
for typ, _data in idler:
if typ == "EXISTS":
return True
return False
return wait_for_mail_private_idle(mail, timeout)
def wait_for_mail_private_idle(mail, timeout: int) -> bool:
tag = mail._new_tag()
mail.send(tag + b" IDLE\r\n")
continuation = mail.readline()
if not continuation.startswith(b"+"):
raise RuntimeError(f"IDLE rejected: {continuation!r}")
changed = False
try:
ready, _, _ = select.select([mail.sock], [], [], timeout)
if ready:
line = mail.readline()
changed = b"EXISTS" in line or b"RECENT" in line
finally:
mail.send(b"DONE\r\n")
while True:
line = mail.readline()
if line.startswith(tag):
break
return changed
def run(args) -> int:
setup_logging(args.log_file)
stop = {"requested": False}
def request_stop(_signum, _frame):
stop["requested"] = True
signal.signal(signal.SIGTERM, request_stop)
signal.signal(signal.SIGINT, request_stop)
state = load_state(args.state_file)
while not stop["requested"]:
mail = None
try:
mail = connect(args.account)
counts = process_folder(mail, state, args.folder, args.dry_run)
save_state(args.state_file, state)
logging.info("scan_complete folder=%s counts=%s", args.folder, counts)
if args.once:
return 0
while not stop["requested"]:
select_folder(mail, args.folder)
changed = wait_for_mail(mail, args.idle_timeout)
if changed:
counts = process_folder(mail, state, args.folder, args.dry_run)
save_state(args.state_file, state)
logging.info("idle_scan_complete folder=%s counts=%s", args.folder, counts)
else:
logging.info("idle_timeout folder=%s", args.folder)
except Exception:
logging.exception("daemon_error reconnecting_after=%ss", args.reconnect_delay)
if args.once:
return 1
time.sleep(args.reconnect_delay)
finally:
if mail is not None:
try:
mail.logout()
except Exception:
pass
save_state(args.state_file, state)
logging.info("stopped")
return 0
if __name__ == "__main__":
raise SystemExit(run(build_parser().parse_args()))
Binary file not shown.
+21
View File
@@ -0,0 +1,21 @@
[Unit]
Description=Mailcat IMAP sorting daemon
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=__APP_USER__
Group=__APP_GROUP__
WorkingDirectory=__APP_DIR__
ExecStart=__PYTHON__ __APP_DIR__/sort_mail_daemon.py --account __ACCOUNT__ --folder INBOX --state-file __STATE_DIR__/sort_mail_daemon_state.json --log-file __LOG_DIR__/sort_mail_daemon.log
Restart=always
RestartSec=30
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
ReadWritePaths=__APP_DIR__ __STATE_DIR__ __LOG_DIR__
[Install]
WantedBy=multi-user.target
+14
View File
@@ -0,0 +1,14 @@
Eenmalige setup (credentials opslaan):
python3 dagelijks_overzicht.py --setup
Testen met gisteren:
python3 dagelijks_overzicht.py
Testen met een specifieke datum:
python3 dagelijks_overzicht.py --datum 2026-07-01
Installeren als dagelijkse taak (07:00):
cp dagelijks_overzicht.plist ~/Library/LaunchAgents/
launchctl load ~/Library/LaunchAgents/dagelijks_overzicht.plist
Het overzicht komt binnen met onderwerp "Mailbak 3 juli 2026" — een HTML-mail met per gesorteerde map de berichten (tijdstip · afzender · onderwerp). Archief- en Technisch-mappen zijn uitgesloten.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+51
View File
@@ -0,0 +1,51 @@
import base64
import json
import tempfile
import unittest
from pathlib import Path
from manage_routes_web import basic_auth_ok, load_routes, save_routes, validate_routes
class ManageRoutesWebTests(unittest.TestCase):
def test_validate_routes_normalizes_domains_and_mailbox(self):
routes = validate_routes([
{"domains": [" Example.COM ", "example.com"], "mailbox": " Diensten.Test "}
])
self.assertEqual(routes, [{"domains": ["example.com"], "mailbox": "Diensten.Test"}])
def test_validate_routes_rejects_duplicate_domain_across_routes(self):
with self.assertRaisesRegex(ValueError, "already used"):
validate_routes([
{"domains": ["example.com"], "mailbox": "Diensten.Een"},
{"domains": ["EXAMPLE.com"], "mailbox": "Diensten.Twee"},
])
def test_save_routes_writes_backup_and_normalized_json(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "domain_routes.json"
path.write_text(
json.dumps([{"domains": ["old.example"], "mailbox": "Diensten.Oud"}]) + "\n",
encoding="utf-8",
)
saved = save_routes(
[{"domains": [" New.EXAMPLE "], "mailbox": " Diensten.Nieuw "}],
path,
)
self.assertEqual(saved, [{"domains": ["new.example"], "mailbox": "Diensten.Nieuw"}])
self.assertEqual(load_routes(path), saved)
backup = path.with_suffix(".json.bak")
self.assertTrue(backup.exists())
self.assertIn("old.example", backup.read_text(encoding="utf-8"))
def test_basic_auth_ok_accepts_exact_credentials(self):
token = base64.b64encode(b"mailcat:secret").decode("ascii")
self.assertTrue(basic_auth_ok(f"Basic {token}", "mailcat", "secret"))
self.assertFalse(basic_auth_ok(f"Basic {token}", "mailcat", "wrong"))
self.assertFalse(basic_auth_ok(None, "mailcat", "secret"))
if __name__ == "__main__":
unittest.main()
+50
View File
@@ -0,0 +1,50 @@
import unittest
from mail_imap_ops import destination_from_header
from sort_mail_daemon import mark_processed, processed_set, reset_on_uidvalidity_change
class SortMailDaemonTests(unittest.TestCase):
def test_destination_from_header_routes_ai_provider(self):
header = (
b"From: Claude Team <hello@claude.com>\r\n"
b"Subject: Product update\r\n"
b"Date: Tue, 01 Jul 2025 10:00:00 +0000\r\n"
b"\r\n"
)
self.assertEqual(destination_from_header(header), "INBOX.Diensten.AI.Claude")
def test_destination_from_header_routes_invoice_by_quarter(self):
header = (
b"From: Billing <billing@example.com>\r\n"
b"Subject: Your invoice\r\n"
b"Date: Tue, 15 Apr 2025 10:00:00 +0000\r\n"
b"\r\n"
)
self.assertEqual(destination_from_header(header), "INBOX.Financieel.Facturen.2025.Q2")
def test_destination_from_header_returns_none_for_unmatched(self):
header = (
b"From: Person <person@example.invalid>\r\n"
b"Subject: Hello\r\n"
b"Date: Tue, 01 Jul 2025 10:00:00 +0000\r\n"
b"\r\n"
)
self.assertIsNone(destination_from_header(header))
def test_uidvalidity_change_resets_processed_uids(self):
state = {"folder_state": {"INBOX": {"uidvalidity": "1", "processed_uids": ["10"]}}}
reset_on_uidvalidity_change(state, "INBOX", "2")
self.assertEqual(processed_set(state, "INBOX"), set())
self.assertEqual(state["folder_state"]["INBOX"]["uidvalidity"], "2")
def test_mark_processed_is_idempotent(self):
state = {"folder_state": {}}
mark_processed(state, "INBOX", "2")
mark_processed(state, "INBOX", "2")
mark_processed(state, "INBOX", "1")
self.assertEqual(state["folder_state"]["INBOX"]["processed_uids"], ["1", "2"])
if __name__ == "__main__":
unittest.main()
+190
View File
@@ -0,0 +1,190 @@
#!/usr/bin/env python3
"""
Upload en activeer de Sieve-mailrule op australius.nl via ManageSieve (RFC 5804).
Poort: 4190 met STARTTLS.
Gebruik: python3 upload_sieve.py
"""
import socket
import ssl
import sys
import base64
import config
from pathlib import Path
SCRIPT_NAME = "mailrules"
SCRIPT_FILE = Path(__file__).parent / "mailrules.sieve"
# Als fileinto-paden "INBOX." als prefix nodig hebben op deze server:
# Zet SIEVE_PREFIX op "INBOX." en het script past de paden automatisch aan.
# Zet op "" als de server paden zonder prefix accepteert (meest gangbaar).
SIEVE_PREFIX = ""
class ManageSieveClient:
"""Minimale ManageSieve-client (RFC 5804)."""
def __init__(self, host: str, port: int):
self._sock = None
self._host = host
self._port = port
def connect(self):
raw = socket.create_connection((self._host, self._port), timeout=15)
self._sock = raw
greeting = self._readline()
if not greeting.startswith("OK"):
raise ConnectionError(f"Onverwachte greeting: {greeting!r}")
# Lees eventuele capability-regels na de greeting
self._read_capabilities()
def starttls(self):
self._send("STARTTLS")
resp = self._readline()
if not resp.startswith("OK"):
raise ConnectionError(f"STARTTLS geweigerd: {resp!r}")
context = ssl.create_default_context()
self._sock = context.wrap_socket(self._sock, server_hostname=self._host)
# Nieuwe capabilities na TLS
self._read_capabilities()
def authenticate(self, username: str, password: str):
creds = base64.b64encode(f"\x00{username}\x00{password}".encode()).decode()
self._send(f'AUTHENTICATE "PLAIN" "{creds}"')
resp = self._readline()
if not resp.startswith("OK"):
raise PermissionError(f"Authenticatie mislukt: {resp!r}")
def put_script(self, name: str, content: str):
encoded = content.encode("utf-8")
self._send(f'PUTSCRIPT "{name}" {{{len(encoded)}+}}')
self._sock.sendall(encoded)
resp = self._readline()
if not resp.startswith("OK"):
raise RuntimeError(f"PUTSCRIPT mislukt: {resp!r}")
def set_active(self, name: str):
self._send(f'SETACTIVE "{name}"')
resp = self._readline()
if not resp.startswith("OK"):
raise RuntimeError(f"SETACTIVE mislukt: {resp!r}")
def list_scripts(self) -> list[str]:
self._send("LISTSCRIPTS")
scripts = []
while True:
line = self._readline()
if line.startswith("OK"):
break
scripts.append(line)
return scripts
def logout(self):
try:
self._send("LOGOUT")
except Exception:
pass
try:
self._sock.close()
except Exception:
pass
def _send(self, cmd: str):
self._sock.sendall((cmd + "\r\n").encode())
def _readline(self) -> str:
buf = b""
while not buf.endswith(b"\n"):
chunk = self._sock.recv(1)
if not chunk:
break
buf += chunk
return buf.decode("utf-8", errors="replace").strip()
def _read_capabilities(self):
"""Lees en negeer capability-regels tot een lege regel of einde."""
# Capabilities eindigen met een lege regel of worden gevolgd door authenticatie
# We lezen tot we iets tegenkomen dat niet op een capability lijkt
# (dit is een vereenvoudiging; volledige RFC 5804 parsing is complexer)
pass
def apply_prefix(script: str, prefix: str) -> str:
"""Voeg SIEVE_PREFIX toe aan alle fileinto-paden als dat nodig is."""
if not prefix:
return script
import re
def add_prefix(m):
path = m.group(1)
if path.startswith(prefix):
return m.group(0) # al aanwezig
return f'fileinto "{prefix}{path}"'
return re.sub(r'fileinto "([^"]+)"', add_prefix, script)
def run():
if not SCRIPT_FILE.exists():
print(f"Script niet gevonden: {SCRIPT_FILE}")
sys.exit(1)
script_content = SCRIPT_FILE.read_text(encoding="utf-8")
script_content = apply_prefix(script_content, SIEVE_PREFIX)
cfg = config.load()
print(f"Script: {SCRIPT_FILE.name} ({len(script_content)} bytes)")
print(f"Server: {cfg.sieve_host}:{cfg.sieve_port}")
print(f"Account:{cfg.username}")
print(f"Naam: {SCRIPT_NAME}\n")
client = ManageSieveClient(cfg.sieve_host, cfg.sieve_port)
print("Verbinden ...")
try:
client.connect()
print(" STARTTLS ...")
client.starttls()
print(" Authenticeren ...")
client.authenticate(cfg.username, cfg.password)
except Exception as e:
print(f"Verbinding mislukt: {e}")
sys.exit(1)
# Toon bestaande scripts
scripts = client.list_scripts()
if scripts:
print(f"\nBestaande scripts op server:")
for s in scripts:
print(f" {s}")
else:
print("\nGeen bestaande scripts.")
# Bevestiging vragen
print(f"\nScript '{SCRIPT_NAME}' wordt geüpload en geactiveerd.")
bevestig = input("Typ JA om door te gaan: ").strip()
if bevestig != "JA":
print("Afgebroken.")
client.logout()
sys.exit(0)
print("\nUploaden ...")
try:
client.put_script(SCRIPT_NAME, script_content)
print(f"{SCRIPT_NAME} geüpload")
client.set_active(SCRIPT_NAME)
print(f"{SCRIPT_NAME} geactiveerd")
except Exception as e:
print(f" ✗ Mislukt: {e}")
client.logout()
sys.exit(1)
client.logout()
print("\nKlaar. Nieuwe inkomende mail wordt nu automatisch gesorteerd.")
print(f"\nAls mail niet aankomt in de verwachte mappen:")
print(f" 1. Controleer of alle mappen bestaan (maak_mappen.py)")
print(f" 2. Probeer SIEVE_PREFIX = \"INBOX.\" in dit script")
print(f" 3. Controleer server-logs via webhosting-paneel")
if __name__ == "__main__":
run()
+4
View File
@@ -0,0 +1,4 @@
{
"username": "mailcat",
"password": "change-me"
}