Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0af27236e5 | |||
| da481678b6 | |||
| 7b345b8795 | |||
| 12a82f570a | |||
| db209ac246 | |||
| 35d78a27aa | |||
| 0811a6ca5a | |||
| edcad4560c | |||
| d0e78e728a | |||
| 99cd891b1e |
@@ -7,3 +7,5 @@ web_config.json
|
|||||||
**/*.eml
|
**/*.eml
|
||||||
sort_mail_daemon_state.json
|
sort_mail_daemon_state.json
|
||||||
verplaats_log_*.json
|
verplaats_log_*.json
|
||||||
|
unsubscribe_log.json
|
||||||
|
codex.session
|
||||||
|
|||||||
@@ -1,399 +0,0 @@
|
|||||||
#!/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()
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
<?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>
|
|
||||||
+56
-12
@@ -15,19 +15,21 @@ import imaplib
|
|||||||
import email
|
import email
|
||||||
import email.header
|
import email.header
|
||||||
import email.utils
|
import email.utils
|
||||||
import json
|
|
||||||
import sys
|
import sys
|
||||||
import ssl
|
import ssl
|
||||||
import smtplib
|
import smtplib
|
||||||
import re
|
import re
|
||||||
|
import html as html_lib
|
||||||
|
import urllib.parse
|
||||||
import config
|
import config
|
||||||
from datetime import date, timedelta, datetime
|
from datetime import date, timedelta, datetime
|
||||||
from pathlib import Path
|
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from email.mime.multipart import MIMEMultipart
|
from email.mime.multipart import MIMEMultipart
|
||||||
from email.mime.text import MIMEText
|
from email.mime.text import MIMEText
|
||||||
from imap_utils import list_folders, quote_mailbox
|
from imap_utils import list_folders, quote_mailbox
|
||||||
from mail_imap_ops import fetch_body
|
from babel.dates import format_date
|
||||||
|
import babel
|
||||||
|
import random
|
||||||
|
|
||||||
# Mappen die NIET in het overzicht komen (systeem + inbox zelf)
|
# Mappen die NIET in het overzicht komen (systeem + inbox zelf)
|
||||||
UITSLUITINGEN = {
|
UITSLUITINGEN = {
|
||||||
@@ -99,6 +101,36 @@ def leesbare_map(imap_naam: str) -> str:
|
|||||||
return imap_naam.removeprefix("INBOX.").replace(".", " › ")
|
return imap_naam.removeprefix("INBOX.").replace(".", " › ")
|
||||||
|
|
||||||
|
|
||||||
|
def apple_mail_url(message_id: str) -> str:
|
||||||
|
"""Return an Apple Mail message:// URL for a Message-ID header."""
|
||||||
|
clean = (message_id or "").strip()
|
||||||
|
if not clean:
|
||||||
|
return ""
|
||||||
|
if not (clean.startswith("<") and clean.endswith(">")):
|
||||||
|
clean = f"<{clean.strip('<>')}>"
|
||||||
|
return "message://" + urllib.parse.quote(clean, safe="")
|
||||||
|
|
||||||
|
|
||||||
|
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 invalid_locale(code):
|
||||||
|
return babel.Locale(code).language_name is None
|
||||||
|
|
||||||
|
def get_random_locale() -> (str, str, str):
|
||||||
|
"""Return the locale code, the local name and the english name of a language."""
|
||||||
|
all_ids = babel.localedata.locale_identifiers()
|
||||||
|
code = all_ids[random.randrange(0, len(all_ids))]
|
||||||
|
while (invalid_locale(code)):
|
||||||
|
code = all_ids[random.randrange(0, len(all_ids))]
|
||||||
|
return (code, babel.Locale(code).language_name, babel.Locale(code).english_name)
|
||||||
|
|
||||||
# ── IMAP ophalen ──────────────────────────────────────────────────────────────
|
# ── IMAP ophalen ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def haal_berichten_op(mail: imaplib.IMAP4_SSL, zoekdatum: date) -> dict[str, list[dict]]:
|
def haal_berichten_op(mail: imaplib.IMAP4_SSL, zoekdatum: date) -> dict[str, list[dict]]:
|
||||||
@@ -138,7 +170,7 @@ def haal_berichten_op(mail: imaplib.IMAP4_SSL, zoekdatum: date) -> dict[str, lis
|
|||||||
|
|
||||||
for uid in ids:
|
for uid in ids:
|
||||||
status, msg_data = mail.fetch(
|
status, msg_data = mail.fetch(
|
||||||
uid, "(BODY.PEEK[HEADER.FIELDS (FROM SUBJECT DATE)])"
|
uid, "(BODY.PEEK[HEADER.FIELDS (FROM SUBJECT DATE MESSAGE-ID)])"
|
||||||
)
|
)
|
||||||
raw_header = fetch_body(msg_data)
|
raw_header = fetch_body(msg_data)
|
||||||
if status != "OK" or raw_header is None:
|
if status != "OK" or raw_header is None:
|
||||||
@@ -148,6 +180,7 @@ def haal_berichten_op(mail: imaplib.IMAP4_SSL, zoekdatum: date) -> dict[str, lis
|
|||||||
from_raw = decode_hdr(msg.get("From", ""))
|
from_raw = decode_hdr(msg.get("From", ""))
|
||||||
subject = decode_hdr(msg.get("Subject", "(geen onderwerp)"))
|
subject = decode_hdr(msg.get("Subject", "(geen onderwerp)"))
|
||||||
date_str = msg.get("Date", "")
|
date_str = msg.get("Date", "")
|
||||||
|
message_id = (msg.get("Message-ID") or "").strip()
|
||||||
|
|
||||||
# Tijdstip opmaken
|
# Tijdstip opmaken
|
||||||
try:
|
try:
|
||||||
@@ -161,9 +194,10 @@ def haal_berichten_op(mail: imaplib.IMAP4_SSL, zoekdatum: date) -> dict[str, lis
|
|||||||
afzender = m.group(1).strip() if m else from_raw.split("@")[0]
|
afzender = m.group(1).strip() if m else from_raw.split("@")[0]
|
||||||
|
|
||||||
resultaat[folder].append({
|
resultaat[folder].append({
|
||||||
"from": afzender[:40],
|
"from": afzender[:40],
|
||||||
"subject": subject[:80],
|
"subject": subject[:80],
|
||||||
"time": tijdstip,
|
"time": tijdstip,
|
||||||
|
"message_id": message_id,
|
||||||
})
|
})
|
||||||
|
|
||||||
return resultaat
|
return resultaat
|
||||||
@@ -203,15 +237,23 @@ def bouw_html(berichten: dict[str, list[dict]], zoekdatum: date) -> str:
|
|||||||
|
|
||||||
for folder in sorted(berichten.keys()):
|
for folder in sorted(berichten.keys()):
|
||||||
items = berichten[folder]
|
items = berichten[folder]
|
||||||
label = leesbare_map(folder)
|
label = html_lib.escape(leesbare_map(folder))
|
||||||
html += f'<h2>{label} <span style="font-weight:normal;color:#888">({len(items)})</span></h2>\n'
|
html += f'<h2>{label} <span style="font-weight:normal;color:#888">({len(items)})</span></h2>\n'
|
||||||
html += '<table>\n'
|
html += '<table>\n'
|
||||||
for item in items:
|
for item in items:
|
||||||
|
time = html_lib.escape(item["time"])
|
||||||
|
sender = html_lib.escape(item["from"])
|
||||||
|
subject = html_lib.escape(item["subject"])
|
||||||
|
url = apple_mail_url(item.get("message_id", ""))
|
||||||
|
if url:
|
||||||
|
subject_html = f'<a href="{html_lib.escape(url, quote=True)}">{subject}</a>'
|
||||||
|
else:
|
||||||
|
subject_html = subject
|
||||||
html += (
|
html += (
|
||||||
f'<tr>'
|
f'<tr>'
|
||||||
f'<td class="time">{item["time"]}</td>'
|
f'<td class="time">{time}</td>'
|
||||||
f'<td class="from">{item["from"]}</td>'
|
f'<td class="from">{sender}</td>'
|
||||||
f'<td class="subject">{item["subject"]}</td>'
|
f'<td class="subject">{subject_html}</td>'
|
||||||
f'</tr>\n'
|
f'</tr>\n'
|
||||||
)
|
)
|
||||||
html += '</table>\n'
|
html += '</table>\n'
|
||||||
@@ -224,9 +266,11 @@ def bouw_html(berichten: dict[str, list[dict]], zoekdatum: date) -> str:
|
|||||||
# ── e-mail versturen ──────────────────────────────────────────────────────────
|
# ── e-mail versturen ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def stuur_overzicht(html: str, zoekdatum: date, config: dict):
|
def stuur_overzicht(html: str, zoekdatum: date, config: dict):
|
||||||
datum_nl = zoekdatum.strftime("%-d %B %Y")
|
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 = MIMEMultipart("alternative")
|
||||||
msg["Subject"] = f"Mailbak {datum_nl}"
|
msg["Subject"] = f"Mailoverzicht van {datum_bl} ({random_lang} - {random_elang}) of {datum_nl}"
|
||||||
msg["From"] = config["from"]
|
msg["From"] = config["from"]
|
||||||
msg["To"] = config["to"]
|
msg["To"] = config["to"]
|
||||||
msg.attach(MIMEText(html, "html", "utf-8"))
|
msg.attach(MIMEText(html, "html", "utf-8"))
|
||||||
|
|||||||
-6240
File diff suppressed because it is too large
Load Diff
-368
@@ -1,368 +0,0 @@
|
|||||||
#!/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"
|
|
||||||
sleep 2
|
|
||||||
if kill -0 "\$pid" 2>/dev/null; then
|
|
||||||
echo "mailcat still running after SIGTERM: \$pid"
|
|
||||||
exit 1
|
|
||||||
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"
|
|
||||||
sleep 2
|
|
||||||
if kill -0 "\$pid" 2>/dev/null; then
|
|
||||||
echo "mailcat web still running after SIGTERM: \$pid"
|
|
||||||
exit 1
|
|
||||||
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"
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
#!/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()
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# mailcat_roundcube.sieve
|
||||||
|
# Generated from domain_routes.json and mailinglist_routes.json.
|
||||||
|
# Intended for Roundcube/Managesieve import for hans@australius.nl.
|
||||||
|
# Do not use :copy: these rules sort incoming mail into destination folders.
|
||||||
|
# Historical mailbox reordering still requires verplaats_bestaand.py.
|
||||||
|
|
||||||
|
require ["fileinto", "date", "variables"];
|
||||||
|
|
||||||
|
# Financial rules are first. The invoice rule stays dynamic, so individual quarters are not separately ordered.
|
||||||
|
# rule:[Financieel / Facturen / Dynamisch kwartaal]
|
||||||
|
if header :contains "Subject" ["factuur", "invoice", "rekening", "nota", "betaalverzoek", "receipt", "orderbevestiging", "order confirmation", "betaling ontvangen", "payment received", "uw factuur", "your invoice", "je factuur"] {
|
||||||
|
if currentdate :matches "year" "*" {
|
||||||
|
set "jaar" "${1}";
|
||||||
|
}
|
||||||
|
if anyof (currentdate :is "month" "1", currentdate :is "month" "2", currentdate :is "month" "3") {
|
||||||
|
fileinto "INBOX.Financieel.Facturen.${jaar}.Q1";
|
||||||
|
} elsif anyof (currentdate :is "month" "4", currentdate :is "month" "5", currentdate :is "month" "6") {
|
||||||
|
fileinto "INBOX.Financieel.Facturen.${jaar}.Q2";
|
||||||
|
} elsif anyof (currentdate :is "month" "7", currentdate :is "month" "8", currentdate :is "month" "9") {
|
||||||
|
fileinto "INBOX.Financieel.Facturen.${jaar}.Q3";
|
||||||
|
} else {
|
||||||
|
fileinto "INBOX.Financieel.Facturen.${jaar}.Q4";
|
||||||
|
}
|
||||||
|
stop;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# invoice_dynamic_quarter.sieve
|
||||||
|
# Single Roundcube/Managesieve rule for dynamic invoice quarter routing.
|
||||||
|
# Import this as a standalone filter set or copy the rule into an existing set.
|
||||||
|
|
||||||
|
require ["fileinto", "date", "variables", "regex"];
|
||||||
|
|
||||||
|
# rule:[Financieel / Facturen / Dynamisch kwartaal]
|
||||||
|
if allof (
|
||||||
|
header :contains "Subject" [
|
||||||
|
"factuur",
|
||||||
|
"invoice",
|
||||||
|
"rekening",
|
||||||
|
"nota",
|
||||||
|
"betaalverzoek",
|
||||||
|
"receipt",
|
||||||
|
"orderbevestiging",
|
||||||
|
"order confirmation",
|
||||||
|
"betaling ontvangen",
|
||||||
|
"payment received",
|
||||||
|
"uw factuur",
|
||||||
|
"your invoice",
|
||||||
|
"je factuur"
|
||||||
|
],
|
||||||
|
date :matches "Date" "year" "*"
|
||||||
|
) {
|
||||||
|
set "jaar" "${1}";
|
||||||
|
|
||||||
|
if date :regex "Date" "month" "^(0?[123])$" {
|
||||||
|
set "kwartaal" "Q1";
|
||||||
|
} elsif date :regex "Date" "month" "^(0?[456])$" {
|
||||||
|
set "kwartaal" "Q2";
|
||||||
|
} elsif date :regex "Date" "month" "^(0?[789])$" {
|
||||||
|
set "kwartaal" "Q3";
|
||||||
|
} else {
|
||||||
|
set "kwartaal" "Q4";
|
||||||
|
}
|
||||||
|
|
||||||
|
fileinto "INBOX.Financieel.Facturen.${jaar}.${kwartaal}";
|
||||||
|
stop;
|
||||||
|
}
|
||||||
@@ -1,341 +0,0 @@
|
|||||||
#!/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()
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
"""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
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
-242
@@ -1,242 +0,0 @@
|
|||||||
# 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;
|
|
||||||
}
|
|
||||||
@@ -1,453 +0,0 @@
|
|||||||
"""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) => ({{ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }}[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()
|
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
---
|
||||||
|
Plan
|
||||||
|
|
||||||
|
Fase 0 — Download (jij voert uit)
|
||||||
|
|
||||||
|
Draai python download_mailbox.py in de projectmap. Het script:
|
||||||
|
- vraagt om je e-mailadres + wachtwoord (lokaal, gaat niet via mij)
|
||||||
|
- downloadt alle mappen en mails naar mailbox/<mapnaam>/<id>.eml
|
||||||
|
- is herstart-safe: al gedownloade mails worden overgeslagen
|
||||||
|
:q
|
||||||
|
Fase 1 — Overzichten (ik maak na download)
|
||||||
|
|
||||||
|
1. Mailinglijsten — via List-ID / List-Unsubscribe headers; per lijst: volume, laatste mail, advies (afmelden / houden)
|
||||||
|
2. Facturen in mailbox — PDF-bijlagen + mails met factuurtermen; per afzender gesorteerd
|
||||||
|
3. Factuurlinks — mails die verwijzen naar een portaal ("bekijk je factuur op..."); per website
|
||||||
|
4. Prullenbak-kandidaten — auto-notificaties, lege threads, doublures, oude nieuwsbrieven
|
||||||
|
|
||||||
|
Fase 2 — Mappenindeling + mailrules (ik stel voor na analyse)
|
||||||
|
|
||||||
|
Op basis van wat ik in de mailbox aantref, maak ik een voorstel voor:
|
||||||
|
- een logische, intuïtieve mappenstructuur (bijv. Financieel / Projecten / Abonnementen / Archief)
|
||||||
|
- server-side mailrules (Sieve-formaat, het meest gangbare bij IMAP-providers) om inkomende mail automatisch te sorteren
|
||||||
|
|
||||||
|
Fase 3 — Scripts (ik schrijf, jij voert uit op echte mailbox)
|
||||||
|
|
||||||
|
- Unsubscribe-script — per mailinglijst: mailto of HTTP List-Unsubscribe aanroepen
|
||||||
|
- Opruim-script — geselecteerde mails via IMAP naar Trash verplaatsen
|
||||||
|
- Factuur-plan — per website een aanpak voor geautomatiseerd ophalen (login-flow, download-URL)
|
||||||
|
- Mailrules-implementatie — instructies of script om de Sieve-rules op de server te zetten
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
|
|
||||||
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.
|
|
||||||
@@ -1,284 +0,0 @@
|
|||||||
# 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
@@ -1,582 +0,0 @@
|
|||||||
# 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
@@ -1,382 +0,0 @@
|
|||||||
# 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.
|
|
||||||
|
|
||||||
+75
-193
@@ -2,255 +2,137 @@
|
|||||||
|
|
||||||
You are continuing the `mailcat` project in `/Users/hanswienen/Documents/Development/Vibes/mailcat`.
|
You are continuing the `mailcat` project in `/Users/hanswienen/Documents/Development/Vibes/mailcat`.
|
||||||
|
|
||||||
## Project Goal
|
## Current Goal
|
||||||
|
|
||||||
Build a reliable mailbox cleanup and automation toolkit for `australius.nl`.
|
The real `hans` mailbox has been completely reordered, and Roundcube/Managesieve filters have been uploaded for ongoing incoming-mail sorting. The Python live sorter and web route-management interface are no longer needed.
|
||||||
|
|
||||||
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`.
|
The repository is intentionally reduced to the minimum tooling needed to:
|
||||||
|
|
||||||
|
1. Repeat folder creation and historical reordering if needed.
|
||||||
|
2. Keep/generate the Roundcube Sieve policy.
|
||||||
|
3. Continue unsubscribe work.
|
||||||
|
4. Run the daily report.
|
||||||
|
|
||||||
Do not run mailbox-affecting scripts yourself unless the user explicitly authorizes it. The user prefers to run those scripts personally.
|
Do not run mailbox-affecting scripts yourself unless the user explicitly authorizes it. The user prefers to run those scripts personally.
|
||||||
|
|
||||||
## Collaboration Rules
|
## Collaboration Rules
|
||||||
|
|
||||||
- Follow the git workflow from `AGENTS.md`.
|
- Follow the git workflow from `AGENTS.md`.
|
||||||
|
- Before making file, code, or configuration changes, present a plan and wait for explicit approval.
|
||||||
- Work on branch `codex` unless the user says otherwise.
|
- Work on branch `codex` unless the user says otherwise.
|
||||||
- Before every commit, rewrite this file from scratch as the current reconstruction prompt.
|
- 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.
|
- 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 doing future work.
|
- Keep runtime logs/state files unless the user explicitly says to delete them.
|
||||||
- Do not stage local runtime output such as `verplaats_log.json` unless the user explicitly asks.
|
- Do not commit credentials.
|
||||||
|
|
||||||
## Local Data And Secrets
|
## Kept Files
|
||||||
|
|
||||||
Ignored or local-only data includes:
|
Core configuration and IMAP helpers:
|
||||||
|
|
||||||
- `mailbox/`
|
- `config.py`
|
||||||
- `**/*.eml`
|
- `imap_utils.py`
|
||||||
- `config.json`
|
- `config.json` is local-only and ignored; it contains account credentials.
|
||||||
- `web_config.json`
|
|
||||||
- `__pycache__/`
|
|
||||||
- bytecode files
|
|
||||||
- `*.log`
|
|
||||||
- `.DS_Store`
|
|
||||||
- generated runtime state/log JSON files
|
|
||||||
|
|
||||||
Do not commit credentials.
|
Routing/reorder policy:
|
||||||
|
|
||||||
## Routing Policy
|
- `mail_routes.py`
|
||||||
|
- `domain_routes.json`
|
||||||
|
- `mailinglist_routes.json`
|
||||||
|
- `maak_mappen.py`
|
||||||
|
- `verplaats_bestaand.py`
|
||||||
|
|
||||||
Sorting policy:
|
Roundcube/Sieve:
|
||||||
|
|
||||||
- `INBOX.Facturen - verwerkt` is the only folder treated as already sorted.
|
- `mailcat_roundcube.sieve`
|
||||||
- `Sent`, `Drafts`, `Trash`, and `Spam` are excluded from sorting.
|
- `roundcube.txt` is local-only user export/test evidence and should not be treated as production policy.
|
||||||
- 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:
|
Unsubscribe:
|
||||||
|
|
||||||
- Final destination folders are functional folders such as `Financieel`, `Werk`, `Diensten`, `Nieuwsbrieven`, `Bestellingen`, `Mobiliteit`, `Administratie`, and `Technisch`.
|
- `unsubscribe.py`
|
||||||
- `Technisch` and `Technisch.DMARC` are valid destinations.
|
- `decisions.json`
|
||||||
- `Administratie.KvK` is the destination for KvK messages.
|
- `unsubscribe_log.json` is runtime state and should not be committed unless the user explicitly asks.
|
||||||
- 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
|
Daily report:
|
||||||
|
|
||||||
Shared modules:
|
- `dagelijks_overzicht.py`
|
||||||
|
- `tests/test_dagelijks_overzicht.py`
|
||||||
|
|
||||||
- `imap_utils.py` centralizes IMAP modified UTF-7 folder encoding/decoding, quoted mailbox names, LIST parsing, and folder listing.
|
Logs/state kept:
|
||||||
- `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:
|
- `backup_log.json`
|
||||||
|
- `verplaats_log.json`
|
||||||
|
- `verplaats_log_voor_aiwijzigingen.json`
|
||||||
|
- `verplaats_bestaand.log`
|
||||||
|
- `unsubscribe_log.json`
|
||||||
|
|
||||||
- Hardcoded domain routes have been moved out of Python into `domain_routes.json`.
|
## Current Behavior
|
||||||
- `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:
|
Roundcube/Sieve:
|
||||||
|
|
||||||
- `mailinglist_routes.json` is still separate from domain routes.
|
- `mailcat_roundcube.sieve` is the generated Roundcube/Managesieve Sieve file for the current routing setup.
|
||||||
- `mail_routes.py` checks mailing-list routes before domain routes.
|
- `invoice_dynamic_quarter.sieve` is a standalone Roundcube/Managesieve Sieve file containing only the dynamic invoice-quarter rule. It uses the message `Date` header to set `jaar`, regex month tests to set `kwartaal`, then files into `INBOX.Financieel.Facturen.${jaar}.${kwartaal}`.
|
||||||
- `mailinglist_routes.json` covers normalized policy from `decisions.json`, including `Afmelden.*` destinations.
|
- It uses `INBOX.`-prefixed mailbox names because the user's Roundcube export used `fileinto :copy "INBOX.Notes";`.
|
||||||
- Mailing-list routing supports domain and `From` substring matching; it does not yet fetch or match `List-ID`.
|
- It includes `Afmelden.*` routes and uses `fileinto` without `:copy`, so matching incoming messages are sorted instead of duplicated.
|
||||||
|
- Rules have Roundcube-compatible `# rule:[...]` names.
|
||||||
Historical sorting:
|
- 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.
|
||||||
- `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:
|
Daily report:
|
||||||
|
|
||||||
- `dagelijks_overzicht.py` builds an HTML digest for a selected date, defaulting to yesterday.
|
- `dagelijks_overzicht.py` builds an HTML digest for a selected date, defaulting to yesterday.
|
||||||
- It supports `--account` and `--mail-to`.
|
- It supports `--account` and `--mail-to`.
|
||||||
- The NAS daily report target is `hans@australius.nl`.
|
- It searches sorted folders, excluding inbox/system/archive/technical folders.
|
||||||
- It sends a report even when there are zero messages.
|
- It sends an HTML email via SMTP.
|
||||||
|
- 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 and depend on Apple Mail having the message synced/indexed locally.
|
||||||
|
- Report HTML escapes folder labels, senders, subjects, and link attributes.
|
||||||
|
|
||||||
## Web Route Management
|
Historical reorder:
|
||||||
|
|
||||||
The route-management web interface is implemented in `manage_routes_web.py`.
|
- `verplaats_bestaand.py` defaults to dry-run. Real moves require `--uitvoeren` and interactive `JA`.
|
||||||
|
- Account selection is explicit with `--account <naam>`, for example `--account hans`.
|
||||||
|
- `--audit` is available in dry-run mode.
|
||||||
|
- It logs to `verplaats_log.json`.
|
||||||
|
|
||||||
Current decisions:
|
Unsubscribe:
|
||||||
|
|
||||||
- LAN binding: `0.0.0.0`
|
- `unsubscribe.py` reads `decisions.json`.
|
||||||
- Port: `4321`
|
- It processes entries with `keuze == "a"` and a `List-Unsubscribe` value.
|
||||||
- Authentication: HTTP Basic Auth
|
- It logs progress to `unsubscribe_log.json`.
|
||||||
- Credentials come from ignored `web_config.json` or environment variables `MAILCAT_WEB_USER` and `MAILCAT_WEB_PASSWORD`.
|
- It may perform HTTP unsubscribe requests or send mailto unsubscribe messages via SMTP, so the user should run it personally.
|
||||||
- Example config is in `web_config.example.json`.
|
|
||||||
|
|
||||||
Scope:
|
## NAS Daily Report
|
||||||
|
|
||||||
- The web UI manages all domain routes in `domain_routes.json`, including the previous built-in routes.
|
For the current NAS setup, only the daily report needs scheduling. A simple cron entry is enough:
|
||||||
- Mailing-list routes remain in `mailinglist_routes.json` and are not yet managed by the web UI.
|
|
||||||
|
|
||||||
Behavior:
|
```cron
|
||||||
|
0 6 * * * cd /share/homes/mailcat/mailcat && /opt/bin/python3 dagelijks_overzicht.py --account hans --mail-to hans@australius.nl >> /share/homes/mailcat/mailcat/daily_report.out 2>&1
|
||||||
- `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
|
|
||||||
|
|
||||||
`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:
|
Use the NAS-specific crontab mechanism if QNAP overwrites normal crontabs after reboot.
|
||||||
|
|
||||||
- `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`.
|
|
||||||
|
|
||||||
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
|
## Verification Commands
|
||||||
|
|
||||||
Latest local verification should include:
|
Latest local verification should include:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
python3 -m py_compile *.py tests/*.py
|
python3 -m py_compile config.py imap_utils.py mail_routes.py maak_mappen.py verplaats_bestaand.py dagelijks_overzicht.py unsubscribe.py
|
||||||
bash -n deploy_vps.sh
|
|
||||||
python3 -m unittest discover -s tests
|
python3 -m unittest discover -s tests
|
||||||
|
sievec -c /tmp/mailcat-sieve-test/dovecot.conf mailcat_roundcube.sieve /tmp/mailcat_roundcube.svbin
|
||||||
git diff --check
|
git diff --check
|
||||||
```
|
```
|
||||||
|
|
||||||
Useful routing sanity check:
|
If `/tmp/mailcat-sieve-test/dovecot.conf` does not exist, recreate it for Dovecot 2.4.0:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
python3 - <<'PY'
|
mkdir -p /tmp/mailcat-sieve-test
|
||||||
from mail_routes import destination_folders, domain_routes, route_from_subject
|
printf 'dovecot_config_version = 2.4.0\ndovecot_storage_version = 2.4.0\nssl = no\n' > /tmp/mailcat-sieve-test/dovecot.conf
|
||||||
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
|
## Next Expected Work
|
||||||
|
|
||||||
Likely next steps:
|
Likely next steps:
|
||||||
|
|
||||||
1. Deploy the updated code to the NAS in `SERVICE_MANAGER=plain` mode.
|
1. Finish unsubscribe work, with the user running `unsubscribe.py`.
|
||||||
2. Create remote `web_config.json`.
|
2. Deploy the reduced project to the NAS if desired.
|
||||||
3. Start the web UI with `/share/homes/mailcat/mailcat/bin/start_web.sh`.
|
3. Schedule `dagelijks_overzicht.py` daily at 06:00 on the NAS.
|
||||||
4. Open `http://<nas-host-or-ip>:4321/` from the internal network and verify Basic Auth.
|
|
||||||
5. Make a harmless route edit, verify `domain_routes.json` and `domain_routes.json.bak`, then restart the sorter if the live daemon is running.
|
|
||||||
|
|||||||
@@ -1,356 +0,0 @@
|
|||||||
#!/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)
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
require ["copy","date","fileinto","regex"];
|
||||||
|
# rule:[kwartaalmail]
|
||||||
|
if allof (date :regex "Date" "month" "[789]", date :is "Date" "year" "2026")
|
||||||
|
{
|
||||||
|
fileinto :copy "INBOX.Notes";
|
||||||
|
}
|
||||||
|
# rule:[zuiderzee]
|
||||||
|
if allof (header :contains "from" "zuiderzee")
|
||||||
|
{
|
||||||
|
fileinto :copy "INBOX.Notes";
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
#!/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()))
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
[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
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
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.
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
from dagelijks_overzicht import apple_mail_url, bouw_html
|
||||||
|
|
||||||
|
|
||||||
|
class DagelijksOverzichtTests(unittest.TestCase):
|
||||||
|
def test_apple_mail_url_encodes_message_id(self):
|
||||||
|
self.assertEqual(
|
||||||
|
apple_mail_url("<abc.123@example.com>"),
|
||||||
|
"message://%3Cabc.123%40example.com%3E",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_apple_mail_url_wraps_bare_message_id(self):
|
||||||
|
self.assertEqual(
|
||||||
|
apple_mail_url("abc.123@example.com"),
|
||||||
|
"message://%3Cabc.123%40example.com%3E",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_bouw_html_links_subject_and_escapes_headers(self):
|
||||||
|
html = bouw_html(
|
||||||
|
{
|
||||||
|
"INBOX.Test & Folder": [
|
||||||
|
{
|
||||||
|
"time": "08:15",
|
||||||
|
"from": "Alice <admin>",
|
||||||
|
"subject": "Factuur <juli>",
|
||||||
|
"message_id": "<msg@example.com>",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
__import__("datetime").date(2026, 7, 5),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("message://%3Cmsg%40example.com%3E", html)
|
||||||
|
self.assertIn("Alice <admin>", html)
|
||||||
|
self.assertIn("Factuur <juli>", html)
|
||||||
|
self.assertIn("Test & Folder", html)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
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()
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
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
@@ -1,190 +0,0 @@
|
|||||||
#!/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()
|
|
||||||
+7977
-3182
File diff suppressed because it is too large
Load Diff
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"username": "mailcat",
|
|
||||||
"password": "change-me"
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user