Initial commit

Claude has done a lot of work, but there are still some errors which
cause the mails to be distributed to wrong mail boxes. I am now going to
let codex and claude compete. They will have their own branches and
merge to main as soon as we are somewhere where we can switch AI.
This commit is contained in:
2026-07-04 11:18:05 +02:00
commit 4780c18d70
27 changed files with 43087 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
__pycache__/
*.py[cod]
.DS_Store
config.json
**/*.eml
+399
View File
@@ -0,0 +1,399 @@
#!/usr/bin/env python3
"""
Fase 1: Analyseer lokale mailbox-kopie en genereer overzichten.
Output: reports/mailinglijsten.md, facturen_mailbox.md, facturen_links.md, prullenbak.md
"""
import email
import email.header
import email.utils
import re
import sys
from pathlib import Path
from collections import defaultdict
from datetime import datetime, timezone
MAILBOX_DIR = Path(__file__).parent / "mailbox"
REPORTS_DIR = Path(__file__).parent / "reports"
INVOICE_SUBJECT_KW = [
"factuur", "invoice", "rekening", "nota", "kwitantie", "receipt",
"betaalverzoek", "betalingsverzoek", "betaling ontvangen",
"payment received", "order confirmation", "orderbevestiging",
"your order", "je bestelling", "proforma",
]
INVOICE_ATTACH_KW = [
"factuur", "invoice", "rekening", "nota", "receipt", "bill",
]
INVOICE_LINK_KW = [
"bekijk je factuur", "download je factuur", "download your invoice",
"view your invoice", "your invoice is ready", "je factuur staat klaar",
"factuur is beschikbaar", "invoice is available", "factuur online",
"invoice online", "mijn facturen", "my invoices", "portaal", "portal",
"mijn account", "my account", "log in to view", "inloggen om te bekijken",
]
TRASH_SENDER_RE = re.compile(
r"(noreply|no-reply|donotreply|do-not-reply|notifications?|newsletter|"
r"nieuwsbrief|alerts?|updates?|mailer-daemon|postmaster|bounce|"
r"auto-?reply|automatisch)@",
re.I,
)
TRASH_SUBJECT_RE = re.compile(
r"(out of office|automatisch antwoord|afwezig|buiten kantoor|"
r"delivery (status notification|failed|failure)|undeliverable|"
r"mail delivery|mailer-daemon|bounced|returned mail)",
re.I,
)
# ── helpers ─────────────────────────────────────────────────────────────────
def decode_hdr(value: str | None) -> str:
if not value:
return ""
try:
parts = email.header.decode_header(value)
out = []
for raw, cs in parts:
if isinstance(raw, bytes):
out.append(raw.decode(cs or "utf-8", errors="replace"))
else:
out.append(str(raw))
return " ".join(out).strip()
except Exception:
return str(value or "")
def parse_dt(date_str: str | None) -> datetime | None:
if not date_str:
return None
try:
return email.utils.parsedate_to_datetime(date_str)
except Exception:
return None
def sender_domain(from_addr: str) -> str:
m = re.search(r"@([\w.\-]+)", from_addr)
return m.group(1).lower() if m else "onbekend"
def get_body(msg) -> str:
parts = []
try:
for part in (msg.walk() if msg.is_multipart() else [msg]):
if part.get_content_type() in ("text/plain", "text/html"):
cs = part.get_content_charset() or "utf-8"
try:
parts.append(part.get_payload(decode=True).decode(cs, errors="replace"))
except Exception:
pass
except Exception:
pass
return "\n".join(parts)[:4000]
def get_attachments(msg) -> list[dict]:
result = []
try:
for part in msg.walk():
disp = (part.get("Content-Disposition") or "").lower()
ct = part.get_content_type() or ""
fn = decode_hdr(part.get_filename() or "")
if "attachment" in disp or (fn and ct == "application/pdf"):
result.append({"filename": fn, "type": ct})
except Exception:
pass
return result
def extract_urls(text: str) -> list[str]:
return list(dict.fromkeys(re.findall(r"https?://[^\s<>\"')]+", text)))
def url_domain(url: str) -> str:
m = re.match(r"https?://([^/]+)", url)
return m.group(1).lower() if m else ""
# ── main analysis ────────────────────────────────────────────────────────────
def analyze():
REPORTS_DIR.mkdir(exist_ok=True)
eml_files = list(MAILBOX_DIR.rglob("*.eml"))
total = len(eml_files)
print(f"Analyseren van {total} e-mails...\n")
# mailinglijsten: key → {count, list_id, unsubscribe, senders, dates, folder}
ml: dict[str, dict] = defaultdict(lambda: {
"list_id": "", "unsubscribe": [], "senders": set(),
"count": 0, "dates": [], "folder": "",
})
invoices_in_mb: list[dict] = []
invoice_links: list[dict] = []
trash: list[dict] = []
now_utc = datetime.now(timezone.utc)
for i, path in enumerate(eml_files, 1):
if i % 1000 == 0:
print(f" {i}/{total}...", flush=True)
try:
with open(path, "rb") as f:
msg = email.message_from_bytes(f.read())
except Exception:
continue
folder = path.parent.name
from_raw = decode_hdr(msg.get("From", ""))
subject = decode_hdr(msg.get("Subject", ""))
date = parse_dt(msg.get("Date"))
list_id = (msg.get("List-ID") or "").strip()
list_us = (msg.get("List-Unsubscribe") or "").strip()
prec = (msg.get("Precedence") or "").lower()
from_lo = from_raw.lower()
subj_lo = subject.lower()
domain = sender_domain(from_raw)
attachments = get_attachments(msg)
body = get_body(msg)
body_lo = body.lower()
# ── mailinglijsten ────────────────────────────────────────────────
ml_key = None
if list_id:
ml_key = re.sub(r"[<>]", "", list_id).strip()
elif "mailinglists" in folder.lower() or "mailinglist" in folder.lower():
ml_key = folder # use the subfolder name as key
if ml_key:
entry = ml[ml_key]
entry["list_id"] = list_id or ml_key
entry["count"] += 1
entry["folder"] = folder
entry["senders"].add(from_raw)
if date:
entry["dates"].append(date)
if list_us and list_us not in entry["unsubscribe"]:
entry["unsubscribe"].append(list_us)
# ── facturen in mailbox ───────────────────────────────────────────
invoice_reasons: list[str] = []
for att in attachments:
fn_lo = att["filename"].lower()
if att["type"] == "application/pdf" and any(kw in fn_lo for kw in INVOICE_ATTACH_KW):
invoice_reasons.append(f"PDF: {att['filename']}")
if any(kw in subj_lo for kw in INVOICE_SUBJECT_KW):
invoice_reasons.append("factuurtermen in onderwerp")
# always include what's already in the Facturen folders
if "facturen" in folder.lower():
invoice_reasons.append(f"staat in map {folder}")
if invoice_reasons:
invoices_in_mb.append({
"from": from_raw,
"domain": domain,
"subject": subject,
"date": date,
"folder": folder,
"reasons": invoice_reasons,
"attachments": [a["filename"] for a in attachments if a["type"] == "application/pdf"],
})
# ── factuurlinks ──────────────────────────────────────────────────
has_link_kw = any(kw in body_lo for kw in INVOICE_LINK_KW)
has_no_pdf = not any(a["type"] == "application/pdf" for a in attachments)
if has_link_kw and has_no_pdf:
urls = extract_urls(body)
if urls:
invoice_links.append({
"from": from_raw,
"domain": domain,
"subject": subject,
"date": date,
"folder": folder,
"urls": urls[:5],
"url_domains": list(dict.fromkeys(url_domain(u) for u in urls[:5])),
})
# ── prullenbak ────────────────────────────────────────────────────
trash_reasons: list[str] = []
if TRASH_SENDER_RE.search(from_lo):
trash_reasons.append("automatisch afzenderadres")
if TRASH_SUBJECT_RE.search(subj_lo):
trash_reasons.append("automatisch/fout-onderwerp")
if prec in ("bulk", "junk"):
trash_reasons.append(f"Precedence: {prec}")
# Old bulk mail sitting in INBOX
if folder == "INBOX" and prec in ("bulk", "list", "junk") and date:
age = (now_utc - date.astimezone(timezone.utc)).days
if age > 180:
trash_reasons.append(f"bulk in INBOX, {age} dagen oud")
if trash_reasons:
trash.append({
"from": from_raw,
"domain": domain,
"subject": subject,
"date": date,
"folder": folder,
"reasons": trash_reasons,
"path": str(path),
})
print(f" {total}/{total} klaar.\n")
write_mailinglist_report(ml)
write_invoice_report(invoices_in_mb)
write_invoice_links_report(invoice_links)
write_trash_report(trash)
print(f"\nRapporten opgeslagen in: {REPORTS_DIR.resolve()}")
# ── report writers ───────────────────────────────────────────────────────────
def fmt_date(d: datetime | None) -> str:
return d.strftime("%Y-%m-%d") if d else "?"
def write_mailinglist_report(ml: dict):
lines = [
"# Mailinglijsten\n",
f"Totaal gevonden: **{len(ml)}** lijsten.\n",
"_Advies: > 30 mails = hoog volume, overweeg afmelden._\n",
]
for key, entry in sorted(ml.items(), key=lambda x: -x[1]["count"]):
dates = sorted(entry["dates"])
oldest = fmt_date(dates[0]) if dates else "?"
newest = fmt_date(dates[-1]) if dates else "?"
senders = sorted(entry["senders"])[:2]
unsub = entry["unsubscribe"]
if entry["count"] > 30:
advice = "⚠️ Hoog volume overweeg afmelden"
elif entry["count"] > 10:
advice = "Gemiddeld volume beoordeel zelf"
else:
advice = "Laag volume waarschijnlijk houden"
can_unsub = "Ja" if unsub else "Nee (handmatig opzoeken)"
lines += [
f"## {key}",
f"- **Mails:** {entry['count']} | **Periode:** {oldest} {newest}",
f"- **Map:** {entry['folder']}",
f"- **Afzender:** {', '.join(senders)}",
f"- **Afmelden mogelijk:** {can_unsub}",
]
if unsub:
lines.append(f"- **Afmeldlink:** `{unsub[0][:120]}`")
lines += [f"- **Advies:** {advice}", ""]
path = REPORTS_DIR / "mailinglijsten.md"
path.write_text("\n".join(lines), encoding="utf-8")
print(f"✓ mailinglijsten.md ({len(ml)} lijsten)")
def write_invoice_report(invoices: list[dict]):
lines = [
"# Facturen in mailbox\n",
f"Totaal: **{len(invoices)}** factuurmails gevonden.\n",
]
by_domain: dict[str, list] = defaultdict(list)
for inv in invoices:
by_domain[inv["domain"]].append(inv)
for domain, items in sorted(by_domain.items(), key=lambda x: -len(x[1])):
items_sorted = sorted(items, key=lambda x: x["date"] or datetime.min.replace(tzinfo=timezone.utc))
lines.append(f"## {domain} ({len(items)} mails)")
for item in items_sorted[:25]:
atts = f"{', '.join(item['attachments'])}" if item["attachments"] else ""
lines.append(f"- {fmt_date(item['date'])} | {item['subject'][:80]}{atts}")
if len(items) > 25:
lines.append(f" _...en {len(items) - 25} meer_")
lines.append("")
(REPORTS_DIR / "facturen_mailbox.md").write_text("\n".join(lines), encoding="utf-8")
print(f"✓ facturen_mailbox.md ({len(invoices)} facturen)")
def write_invoice_links_report(invoice_links: list[dict]):
lines = [
"# Factuurlinks verwijzingen naar portalen\n",
f"Totaal: **{len(invoice_links)}** mails met factuurverwijzingen (zonder PDF-bijlage).\n",
"_Dit zijn mails die zeggen 'bekijk je factuur online' de factuur staat op een website._\n",
]
by_url_domain: dict[str, list] = defaultdict(list)
for item in invoice_links:
for ud in (item["url_domains"] or ["onbekend"]):
by_url_domain[ud].append(item)
for ud, items in sorted(by_url_domain.items(), key=lambda x: -len(x[1])):
unique_senders = sorted({i["domain"] for i in items})
lines.append(f"## {ud} ({len(items)} mails)")
lines.append(f" _Afzenders: {', '.join(unique_senders[:5])}_")
for item in sorted(items, key=lambda x: x["date"] or datetime.min.replace(tzinfo=timezone.utc))[:10]:
lines.append(f"- {fmt_date(item['date'])} | {item['subject'][:80]}")
for url in item["urls"][:2]:
lines.append(f" `{url[:120]}`")
if len(items) > 10:
lines.append(f" _...en {len(items) - 10} meer_")
lines.append("")
(REPORTS_DIR / "facturen_links.md").write_text("\n".join(lines), encoding="utf-8")
print(f"✓ facturen_links.md ({len(invoice_links)} mails)")
def write_trash_report(trash: list[dict]):
lines = [
"# Prullenbak-kandidaten\n",
f"Totaal: **{len(trash)}** kandidaten gevonden.\n",
]
by_reason: dict[str, list] = defaultdict(list)
for item in trash:
by_reason[item["reasons"][0]].append(item)
for reason, items in sorted(by_reason.items(), key=lambda x: -len(x[1])):
lines.append(f"## {reason} ({len(items)} mails)")
by_domain: dict[str, list] = defaultdict(list)
for item in items:
by_domain[item["domain"]].append(item)
def _sort_key(x):
d = x["date"]
if d is None:
return datetime.min.replace(tzinfo=timezone.utc)
return d if d.tzinfo else d.replace(tzinfo=timezone.utc)
for domain, ditems in sorted(by_domain.items(), key=lambda x: -len(x[1])):
lines.append(f"### {domain} ({len(ditems)})")
for item in sorted(ditems, key=_sort_key)[:10]:
lines.append(f"- {fmt_date(item['date'])} | {item['folder']} | {item['subject'][:70]}")
if len(ditems) > 10:
lines.append(f" _...en {len(ditems) - 10} meer_")
lines.append("")
(REPORTS_DIR / "prullenbak.md").write_text("\n".join(lines), encoding="utf-8")
print(f"✓ prullenbak.md ({len(trash)} kandidaten)")
if __name__ == "__main__":
analyze()
+40
View File
@@ -0,0 +1,40 @@
{
"voltooide_mappen": [
"INBOX.Archief",
"INBOX.Archief.2021.inkomend",
"INBOX.Archief.2022.inkomend",
"INBOX.Facturen - te verwerken",
"INBOX.Mailinglists.Storytelling with Data",
"INBOX.Archief.2022.verzonden",
"INBOX.Mailinglists.Ben Tiggelaar",
"INBOX.Technisch",
"INBOX.Mailinglists",
"INBOX.Kennis",
"INBOX.Archief.2022",
"INBOX.Mailinglists.Lendahand",
"INBOX.Kennis.Coursera",
"INBOX.Hulp",
"INBOX.Sent",
"INBOX.Facturen - verwerkt",
"INBOX.Mailinglists.BASB",
"INBOX.Mailinglists.Formspree",
"INBOX.Archief.2020.inkomend",
"INBOX.Kennis.Leeslijst",
"INBOX.Hulp.Gespreksverslagen",
"INBOX.Archief.2020.verzonden",
"INBOX.Mailinglists.MIT Technology Review",
"INBOX.Drafts",
"INBOX.Mailinglists.Brian Keating",
"INBOX.Mailinglists.Calmer Notes",
"INBOX.Technisch.dmarc",
"INBOX.Archief.2021",
"INBOX.Mailinglists.Eva Keiffenheim",
"INBOX.Archief.2021.verzonden",
"INBOX.Mailinglists.LYT - Nick Milo",
"INBOX.Archief.2020",
"INBOX.Notes",
"INBOX"
],
"totaal_gekopieerd": 11188,
"laatste_run": "2026-07-04T00:08:25.493919"
}
+162
View File
@@ -0,0 +1,162 @@
#!/usr/bin/env python3
"""
Centrale configuratie voor alle mailcat-scripts.
Setup: python3 config.py --setup
Gebruik in andere scripts:
import config
cfg = config.load() # leest --account uit sys.argv, anders actief account
mail.login(cfg.username, cfg.password)
Wisselen van account zonder config te wijzigen:
python3 maak_mappen.py --account backup
python3 verplaats_bestaand.py --account backup --uitvoeren
"""
import json
import sys
import getpass
from pathlib import Path
from dataclasses import dataclass
CONFIG_FILE = Path(__file__).parent / "config.json"
@dataclass
class Account:
name: str
username: str
password: str
imap_host: str
imap_port: int
smtp_host: str
smtp_port: int
sieve_host: str
sieve_port: int
def load(account_name: str | None = None) -> Account:
"""
Laad het opgegeven account (of het actieve account uit config.json).
account_name kan ook via --account <naam> op de commandoregel worden opgegeven.
"""
# --account vlag heeft prioriteit boven argument
args = sys.argv[1:]
if "--account" in args:
idx = args.index("--account")
if idx + 1 < len(args):
account_name = args[idx + 1]
if not CONFIG_FILE.exists():
print("Geen config gevonden. Draai eerst: python3 config.py --setup")
sys.exit(1)
raw = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
name = account_name or raw.get("active", "")
if not name:
print("Geen actief account ingesteld. Gebruik --account <naam> of stel 'active' in config.json in.")
sys.exit(1)
accounts = raw.get("accounts", {})
if name not in accounts:
beschikbaar = ", ".join(accounts.keys())
print(f"Account '{name}' niet gevonden. Beschikbaar: {beschikbaar}")
sys.exit(1)
acc = accounts[name]
return Account(
name = name,
username = acc["username"],
password = acc["password"],
imap_host = raw.get("imap_host", "australius.nl"),
imap_port = raw.get("imap_port", 993),
smtp_host = raw.get("smtp_host", "australius.nl"),
smtp_port = raw.get("smtp_port", 587),
sieve_host = raw.get("sieve_host", "australius.nl"),
sieve_port = raw.get("sieve_port", 4190),
)
def active_account_name() -> str:
"""Geef de naam van het actief te gebruiken account (voor weergave)."""
args = sys.argv[1:]
if "--account" in args:
idx = args.index("--account")
if idx + 1 < len(args):
return args[idx + 1]
if CONFIG_FILE.exists():
raw = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
return raw.get("active", "?")
return "?"
# ── setup ─────────────────────────────────────────────────────────────────────
def setup():
"""Interactief: voeg een account toe of wijzig een bestaand account."""
raw: dict = {}
if CONFIG_FILE.exists():
raw = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
raw.setdefault("imap_host", "australius.nl")
raw.setdefault("imap_port", 993)
raw.setdefault("smtp_host", "australius.nl")
raw.setdefault("smtp_port", 587)
raw.setdefault("sieve_host", "australius.nl")
raw.setdefault("sieve_port", 4190)
raw.setdefault("accounts", {})
print("" * 50)
print("Mailcat account setup")
print("" * 50)
bestaande = list(raw["accounts"].keys())
if bestaande:
print(f"Bestaande accounts: {', '.join(bestaande)}")
naam = input("\nAccountnaam (bijv. hans of backup): ").strip()
if not naam:
print("Afgebroken.")
return
huidig = raw["accounts"].get(naam, {})
huidig_user = huidig.get("username", "")
username = input(f"E-mailadres [{huidig_user}]: ").strip() or huidig_user
password = getpass.getpass("Wachtwoord: ")
raw["accounts"][naam] = {"username": username, "password": password}
# Vraag of dit het actieve account moet zijn
huidig_actief = raw.get("active", "")
instellen = input(f"\nDit als actief account instellen? (huidig: {huidig_actief or 'geen'}) [J/n]: ").strip().lower()
if instellen != "n":
raw["active"] = naam
CONFIG_FILE.write_text(json.dumps(raw, indent=2, ensure_ascii=False), encoding="utf-8")
CONFIG_FILE.chmod(0o600)
print(f"\n✓ Account '{naam}' opgeslagen. Actief: {raw['active']}")
print(f" Config: {CONFIG_FILE}")
if len(raw["accounts"]) > 1:
print(f"\nWisselen: voeg --account <naam> toe aan elk script.")
print(f" Bijv: python3 maak_mappen.py --account backup")
if __name__ == "__main__":
if "--setup" in sys.argv:
setup()
else:
# Toon huidige status
if not CONFIG_FILE.exists():
print("Geen config. Draai: python3 config.py --setup")
else:
raw = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
actief = raw.get("active", "?")
accounts = list(raw.get("accounts", {}).keys())
print(f"Actief account : {actief}")
print(f"Alle accounts : {', '.join(accounts)}")
print(f"IMAP : {raw.get('imap_host')}:{raw.get('imap_port')}")
print(f"\nSetup: python3 config.py --setup")
print(f"Wisselen in script: python3 <script>.py --account <naam>")
+34
View File
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>nl.australius.dagelijks-overzicht</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/python3</string>
<string>/Users/hanswienen/Documents/Development/Vibes/mailcat/dagelijks_overzicht.py</string>
</array>
<!-- Elke ochtend om 07:00 -->
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key>
<integer>7</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
<!-- Log eventuele fouten -->
<key>StandardOutPath</key>
<string>/Users/hanswienen/Library/Logs/dagelijks_overzicht.log</string>
<key>StandardErrorPath</key>
<string>/Users/hanswienen/Library/Logs/dagelijks_overzicht.log</string>
<!-- Alleen draaien als de Mac aan staat; inhalen bij gemiste run -->
<key>RunAtLoad</key>
<false/>
</dict>
</plist>
+278
View File
@@ -0,0 +1,278 @@
#!/usr/bin/env python3
"""
Dagelijks overzicht van automatisch gesorteerde mail.
Zoekt in alle IMAP-mappen (behalve Inbox, Sent, Trash, Spam, Drafts) naar
berichten die gisteren zijn binnengekomen, en stuurt een HTML-samenvatting
naar hans@australius.nl.
Eenmalige setup: python3 dagelijks_overzicht.py --setup
Handmatig testen: python3 dagelijks_overzicht.py [--gisteren | --datum 2026-07-01]
Plannen (macOS): zie dagelijks_overzicht.plist
"""
import imaplib
import email
import email.header
import email.utils
import json
import sys
import ssl
import smtplib
import re
import config
from datetime import date, timedelta, datetime
from pathlib import Path
from collections import defaultdict
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
# Mappen die NIET in het overzicht komen (systeem + inbox zelf)
UITSLUITINGEN = {
"INBOX", "INBOX.Sent", "INBOX.Trash", "INBOX.Spam",
"INBOX.Drafts", "INBOX.Spam", "INBOX.Archief",
"INBOX.Archief.2020", "INBOX.Archief.2021", "INBOX.Archief.2022",
"INBOX.Archief.2023", "INBOX.Archief.2024", "INBOX.Archief.2025",
"INBOX.Archief.2026",
}
# Patroon: archief-mappen en technisch-interne mappen overslaan
UITSLUIT_PATRONEN = re.compile(
r"^INBOX\.(Archief|Trash|Spam|Sent|Drafts|Technisch)", re.I
)
# ── config ────────────────────────────────────────────────────────────────────
def load_config():
"""Laad account via centrale config.py. Gebruik --account om te wisselen."""
cfg = config.load()
return {
"username": cfg.username,
"password": cfg.password,
"imap_host": cfg.imap_host,
"imap_port": cfg.imap_port,
"smtp_host": cfg.smtp_host,
"smtp_port": cfg.smtp_port,
"from": cfg.username,
"to": cfg.username, # stuur digest naar zichzelf
}
# ── 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 imap_date(d: date) -> str:
"""Zet date om naar IMAP-zoekformaat: 02-Jul-2026"""
return d.strftime("%d-%b-%Y")
def leesbare_map(imap_naam: str) -> str:
"""Maak de mapnaam leesbaar door INBOX. te verwijderen."""
return imap_naam.removeprefix("INBOX.").replace(".", " ")
def parse_folder_name(raw: bytes) -> str | None:
decoded = raw.decode("utf-8", errors="replace")
m = re.match(r'\(.*?\)\s+".*?"\s+(.*)', decoded)
if m:
return m.group(1).strip().strip('"')
return None
# ── IMAP ophalen ──────────────────────────────────────────────────────────────
def haal_berichten_op(mail: imaplib.IMAP4_SSL, zoekdatum: date) -> dict[str, list[dict]]:
"""
Doorzoek alle relevante mappen op berichten van zoekdatum.
Geeft {mapnaam: [{from, subject, time}, ...]} terug.
"""
since = imap_date(zoekdatum)
before = imap_date(zoekdatum + timedelta(days=1))
status, raw_folders = mail.list()
if status != "OK":
return {}
alle_mappen = [parse_folder_name(f) for f in raw_folders if f]
alle_mappen = [f for f in alle_mappen if f]
resultaat: dict[str, list[dict]] = defaultdict(list)
for folder in sorted(alle_mappen):
if folder in UITSLUITINGEN:
continue
if UITSLUIT_PATRONEN.match(folder):
continue
status, data = mail.select(f'"{folder}"', readonly=True)
if status != "OK":
continue
# Zoek op INTERNALDATE (serverontvangst, niet Date-header)
status, data = mail.search(
None, f'SINCE {since} BEFORE {before}'
)
if status != "OK" or not data[0]:
continue
ids = data[0].split()
if not ids:
continue
for uid in ids:
status, msg_data = mail.fetch(
uid, "(BODY.PEEK[HEADER.FIELDS (FROM SUBJECT DATE)])"
)
if status != "OK" or not msg_data or not msg_data[0]:
continue
msg = email.message_from_bytes(msg_data[0][1])
from_raw = decode_hdr(msg.get("From", ""))
subject = decode_hdr(msg.get("Subject", "(geen onderwerp)"))
date_str = msg.get("Date", "")
# Tijdstip opmaken
try:
dt = email.utils.parsedate_to_datetime(date_str)
tijdstip = dt.strftime("%H:%M")
except Exception:
tijdstip = "?"
# Afzendernaam uit From-header
m = re.match(r'^"?([^"<@\n]{2,})"?\s*<', from_raw)
afzender = m.group(1).strip() if m else from_raw.split("@")[0]
resultaat[folder].append({
"from": afzender[:40],
"subject": subject[:80],
"time": tijdstip,
})
return resultaat
# ── HTML opbouwen ─────────────────────────────────────────────────────────────
def bouw_html(berichten: dict[str, list[dict]], zoekdatum: date) -> str:
datum_nl = zoekdatum.strftime("%-d %B %Y")
totaal = sum(len(v) for v in berichten.values())
html = f"""<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="utf-8">
<style>
body {{ font-family: -apple-system, Arial, sans-serif; font-size: 14px;
color: #222; max-width: 700px; margin: 20px auto; }}
h1 {{ font-size: 18px; color: #1a1a2e; margin-bottom: 4px; }}
.meta {{ color: #666; font-size: 13px; margin-bottom: 24px; }}
h2 {{ font-size: 14px; font-weight: 600; color: #333;
background: #f4f4f8; padding: 6px 10px;
border-left: 3px solid #5c6bc0; margin: 20px 0 6px; }}
table {{ width: 100%; border-collapse: collapse; margin-bottom: 4px; }}
td {{ padding: 4px 6px; vertical-align: top; }}
.time {{ color: #888; width: 42px; white-space: nowrap; }}
.from {{ color: #555; width: 160px; }}
.subject {{ color: #222; }}
.footer {{ margin-top: 32px; font-size: 12px; color: #aaa;
border-top: 1px solid #eee; padding-top: 10px; }}
</style>
</head>
<body>
<h1>Automatisch gesorteerde mail — {datum_nl}</h1>
<p class="meta">{totaal} bericht{'en' if totaal != 1 else ''} in {len(berichten)} map{'pen' if len(berichten) != 1 else ''}</p>
"""
for folder in sorted(berichten.keys()):
items = berichten[folder]
label = leesbare_map(folder)
html += f'<h2>{label} <span style="font-weight:normal;color:#888">({len(items)})</span></h2>\n'
html += '<table>\n'
for item in items:
html += (
f'<tr>'
f'<td class="time">{item["time"]}</td>'
f'<td class="from">{item["from"]}</td>'
f'<td class="subject">{item["subject"]}</td>'
f'</tr>\n'
)
html += '</table>\n'
html += f'<p class="footer">Gegenereerd door dagelijks_overzicht.py · {datetime.now().strftime("%Y-%m-%d %H:%M")}</p>'
html += '</body></html>'
return html
# ── e-mail versturen ──────────────────────────────────────────────────────────
def stuur_overzicht(html: str, zoekdatum: date, config: dict):
datum_nl = zoekdatum.strftime("%-d %B %Y")
msg = MIMEMultipart("alternative")
msg["Subject"] = f"Mailbak {datum_nl}"
msg["From"] = config["from"]
msg["To"] = config["to"]
msg.attach(MIMEText(html, "html", "utf-8"))
context = ssl.create_default_context()
with smtplib.SMTP(config["smtp_host"], config["smtp_port"]) as server:
server.starttls(context=context)
server.login(config["username"], config["password"])
server.sendmail(config["from"], [config["to"]], msg.as_string())
print(f"Overzicht verstuurd naar {config['to']}")
# ── main ──────────────────────────────────────────────────────────────────────
def run():
args = sys.argv[1:]
cfg_dict = load_config()
print(f"Account: {cfg_dict['username']}\n")
# Datum bepalen
if "--datum" in args:
idx = args.index("--datum")
zoekdatum = date.fromisoformat(args[idx + 1])
else:
zoekdatum = date.today() - timedelta(days=1) # standaard: gisteren
print(f"Ophalen berichten van {zoekdatum} ...")
try:
mail = imaplib.IMAP4_SSL(cfg_dict["imap_host"], cfg_dict["imap_port"])
mail.login(cfg_dict["username"], cfg_dict["password"])
except Exception as e:
print(f"IMAP verbinding mislukt: {e}")
sys.exit(1)
berichten = haal_berichten_op(mail, zoekdatum)
mail.logout()
totaal = sum(len(v) for v in berichten.values())
print(f"{totaal} berichten gevonden in {len(berichten)} mappen.")
if totaal == 0:
print("Niets te sturen.")
return
html = bouw_html(berichten, zoekdatum)
stuur_overzicht(html, zoekdatum, cfg_dict)
if __name__ == "__main__":
run()
+6275
View File
File diff suppressed because it is too large Load Diff
+6240
View File
File diff suppressed because it is too large Load Diff
+129
View File
@@ -0,0 +1,129 @@
#!/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
IMAP_HOST = "australius.nl"
IMAP_PORT = 993
LOCAL_DIR = Path(__file__).parent / "mailbox"
def parse_folder_name(line: bytes) -> str | None:
"""Haal mapnaam op uit IMAP LIST-response."""
decoded = line.decode("utf-8", errors="replace")
# Formaat: (\Flags) "delimiter" "naam" of (\Flags) "delimiter" naam
match = re.match(r'\(.*?\)\s+".*?"\s+(.*)', decoded)
if not match:
return None
name = match.group(1).strip().strip('"')
return name if name else None
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")
# Alle mappen ophalen
status, raw_folders = mail.list()
if status != "OK":
print("Kan mappenlijst niet ophalen.")
sys.exit(1)
folders = [parse_folder_name(f) for f in raw_folders if f]
folders = [f for f in folders if f]
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}"
quoted = f'"{folder}"'
status, data = mail.select(quoted, 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()
+288
View File
@@ -0,0 +1,288 @@
#!/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
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_folder_name(raw: bytes) -> str | None:
decoded = raw.decode("utf-8", errors="replace")
m = re.match(r'\(.*?\)\s+".*?"\s+(.*)', decoded)
if m:
return m.group(1).strip().strip('"')
return None
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(f'"{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]:
"""
Kopieer berichten van bron-folder naar doel-folder.
Geeft (gekopieerd, overgeslagen) terug.
"""
# Selecteer bronmap
status, data = bron.select(f'"{folder}"', readonly=True)
if status != "OK":
print(f" Kan bronmap niet openen: {folder}")
return 0, 0
totaal = int(data[0])
if totaal == 0:
return 0, 0
# Haal alle bron-UIDs op
status, data = bron.search(None, "ALL")
if status != "OK" or not data[0]:
return 0, 0
ids = data[0].split()
# Zorg dat doelmap bestaat
doel.create(f'"{folder}"') # negeer fout als al bestaat
# Welke Message-IDs staan al in de doelmap?
bestaande = haal_bestaande_message_ids(doel, folder)
gekopieerd = 0
overgeslagen = 0
for i, uid in enumerate(ids, 1):
# Voortgang op één regel
print(f"\r {folder}: {i}/{totaal} ({gekopieerd} gekopieerd, {overgeslagen} skip) ",
end="", flush=True)
# Haal volledige bericht op + vlaggen + datum
status, data = bron.fetch(
uid, "(FLAGS INTERNALDATE BODY.PEEK[])"
)
if status != "OK" or not 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:
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, _ = doel.append(
f'"{folder}"',
flag_str,
internaldate,
raw_message,
)
if status == "OK":
gekopieerd += 1
if mid:
bestaande.add(mid)
else:
pass # stil doorgaan bij incidentele fout
# 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 ")
return gekopieerd, overgeslagen
# ── 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
status, raw_folders = bron.list()
alle_mappen = [parse_folder_name(f) for f in raw_folders if f]
alle_mappen = [f for f in alle_mappen if f and f 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
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 = kopieer_map(bron, doel, folder, log)
totaal_gekopieerd += gekopieerd
totaal_overgeslagen += overgeslagen
log["voltooide_mappen"] = list(al_klaar | {folder})
al_klaar.add(folder)
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" Log : {LOG_FILE}")
if __name__ == "__main__":
run()
+253
View File
@@ -0,0 +1,253 @@
#!/usr/bin/env python3
"""
Maak alle nieuwe IMAP-mappen aan op australius.nl.
Bestaande mappen worden overgeslagen.
Gebruik: python3 maak_mappen.py
"""
import imaplib
import base64
import re
import sys
import config
# Sommige Dovecot-servers eisen "INBOX." als prefix voor submappen.
# Zet op "" als de server mappen zonder prefix accepteert.
PREFIX = "INBOX."
# ── mUTF-7 helpers (RFC 3501) ─────────────────────────────────────────────────
def to_mutf7(s: str) -> str:
"""Codeer een mapnaam naar Modified UTF-7 voor IMAP-aanroepen (RFC 3501).
Verschillen met standaard base64: geen padding, '/' vervangen door ','."""
res = []
buf = []
for c in s:
if 0x20 <= ord(c) <= 0x7e and c != "&":
if buf:
b64 = (
base64.b64encode("".join(buf).encode("utf-16-be"))
.decode("ascii")
.rstrip("=")
.replace("/", ",")
)
res.append(f"&{b64}-")
buf.clear()
res.append(c)
elif c == "&":
if buf:
b64 = (
base64.b64encode("".join(buf).encode("utf-16-be"))
.decode("ascii")
.rstrip("=")
.replace("/", ",")
)
res.append(f"&{b64}-")
buf.clear()
res.append("&-")
else:
buf.append(c)
if buf:
b64 = (
base64.b64encode("".join(buf).encode("utf-16-be"))
.decode("ascii")
.rstrip("=")
.replace("/", ",")
)
res.append(f"&{b64}-")
return "".join(res)
def from_mutf7(s: str) -> str:
"""Decodeer een mUTF-7-mapnaam (van server) terug naar Unicode."""
res = []
i = 0
while i < len(s):
if s[i] == "&":
j = s.index("-", i + 1)
if j == i + 1:
res.append("&")
else:
encoded = s[i + 1 : j].replace(",", "/")
pad = (4 - len(encoded) % 4) % 4
res.append(
base64.b64decode(encoded + "=" * pad).decode("utf-16-be")
)
i = j + 1
else:
res.append(s[i])
i += 1
return "".join(res)
def mappen() -> list[str]:
"""Alle te maken mappen, zonder prefix (prefix wordt automatisch toegevoegd)."""
folders = [
# Nieuwsbrieven
"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",
"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",
"Financieel.Facturen",
# Facturen per kwartaal: 2020 t/m huidig jaar + 1
*[
f"Financieel.Facturen.{jaar}.{kwartaal}"
for jaar in range(2020, 2027)
for kwartaal in ["Q1", "Q2", "Q3", "Q4"]
],
# Werk
"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",
"Diensten.Hosting",
"Diensten.Hosting.Backblaze",
"Diensten.Hosting.Lets 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",
"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",
"Mobiliteit.OV & Reizen.Flitsmeister",
"Mobiliteit.OV & Reizen.NS",
# Bestellingen
"Bestellingen",
"Bestellingen.123inkt",
"Bestellingen.Allekabels",
"Bestellingen.bol",
"Bestellingen.Coolblue",
"Bestellingen.DHL",
"Bestellingen.GLS",
"Bestellingen.Makro",
"Bestellingen.Office Centre",
"Bestellingen.PostNL",
"Bestellingen.Sligro",
"Bestellingen.Smartphonehoesjes",
"Bestellingen.Viking",
# Archief nieuwe jaren
"Archief.2023",
"Archief.2024",
"Archief.2025",
"Archief.2026",
]
return folders
def run():
cfg = config.load()
print(f"Account: {cfg.username}\n")
print(f"Verbinden met {cfg.imap_host}:{cfg.imap_port} ...")
try:
mail = imaplib.IMAP4_SSL(cfg.imap_host, cfg.imap_port)
mail.login(cfg.username, cfg.password)
except Exception as e:
print(f"Verbinding mislukt: {e}")
sys.exit(1)
# Bestaande mappen ophalen en decoderen vanuit mUTF-7
status, raw = mail.list()
existing = set()
for item in raw:
if not item:
continue
# Serverresponse is ASCII (mUTF-7); decodeer naar leesbare naam
raw_str = item.decode("ascii", errors="replace")
m = re.search(r'"?" (.*?)$', raw_str)
if m:
raw_name = m.group(1).strip('"')
try:
existing.add(from_mutf7(raw_name))
except Exception:
existing.add(raw_name)
te_maken = mappen()
print(f"\n{len(te_maken)} mappen te controleren ({len(existing)} bestaan al op server)\n")
aangemaakt = 0
overgeslagen = 0
fouten = 0
for folder in te_maken:
volledige_naam = PREFIX + folder
if volledige_naam in existing or folder in existing:
print(f" ✓ bestaat {folder}")
overgeslagen += 1
continue
# Codeer naar mUTF-7 voor IMAP (puur ASCII, ook voor &, é, enz.)
gecodeerd = to_mutf7(volledige_naam)
status, data = mail.create(f'"{gecodeerd}"')
if status == "OK":
print(f" + aangemaakt {folder}")
aangemaakt += 1
else:
print(f" ✗ FOUT {folder}: {data}")
fouten += 1
mail.logout()
print(f"\nKlaar: {aangemaakt} aangemaakt, {overgeslagen} bestonden al, {fouten} fouten.")
if __name__ == "__main__":
run()
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+242
View File
@@ -0,0 +1,242 @@
# mailrules.sieve
# Automatische mailsortering voor hans@australius.nl
# Upload via: python3 upload_sieve.py
#
# Noot: fileinto-paden zijn relatief aan de persoonlijke namespace.
# Op Dovecot-servers met INBOX. als prefix: voeg "INBOX." toe aan elke
# fileinto-waarde als mail niet aankomt (zie SIEVE_PREFIX in upload_sieve.py).
require [
"fileinto", "redirect", "copy",
"date", "variables",
"mime", "foreverypart",
"imap4flags"
];
# ════════════════════════════════════════════════════════════════════════════
# 1. FACTUREN (altijd eerst — vóór nieuwsbrieven en diensten)
# PDF-bijlagen worden doorgestuurd naar boekhouding@australius.nl
# (alleen voor nieuwe mail, geen terugwerkende kracht)
# ════════════════════════════════════════════════════════════════════════════
if anyof (
header :contains "Subject" ["factuur", "Factuur",
"invoice", "Invoice",
"rekening", "Rekening",
"nota", "Nota",
"betaalverzoek", "Betaalverzoek",
"receipt", "Receipt",
"orderbevestiging", "Orderbevestiging",
"order confirmation",
"betaling ontvangen",
"payment received",
"uw factuur", "Uw factuur",
"your invoice", "Your invoice",
"je factuur"]
) {
# Kwartaal bepalen op basis van ontvangstdatum
if currentdate :matches "year" "*" { set "jaar" "${1}"; }
if anyof (currentdate :is "month" "1",
currentdate :is "month" "2",
currentdate :is "month" "3") { set "kwartaal" "Q1"; }
elsif anyof (currentdate :is "month" "4",
currentdate :is "month" "5",
currentdate :is "month" "6") { set "kwartaal" "Q2"; }
elsif anyof (currentdate :is "month" "7",
currentdate :is "month" "8",
currentdate :is "month" "9") { set "kwartaal" "Q3"; }
else { set "kwartaal" "Q4"; }
# PDF aanwezig → doorsturen naar boekhouding (nieuwe mail vanaf activatiedatum)
foreverypart {
if anyof (
header :mime :param "filename" :matches "Content-Disposition" ["*.pdf", "*.PDF"],
header :mime :is "Content-Type" "application/pdf"
) {
redirect :copy "boekhouding@australius.nl";
break;
}
}
fileinto "Financieel.Facturen.${jaar}.${kwartaal}";
stop;
}
# ════════════════════════════════════════════════════════════════════════════
# 2. BANK & VERZEKERING
# ════════════════════════════════════════════════════════════════════════════
if address :domain :is "From" ["nl.abnamro.com", "abnamro.nl"] {
fileinto "Financieel.Bank.ABN AMRO"; stop;
}
if address :domain :is "From" ["icscards.nl", "icsmarketing.icscards.nl",
"service.icscards.nl"] {
fileinto "Financieel.Bank.ICS"; stop;
}
if address :domain :is "From" ["npex.nl"] { fileinto "Financieel.Bank.NPEX"; stop; }
if address :domain :is "From" ["snelstart.nl"] { fileinto "Financieel.Bank.SnelStart"; stop; }
if address :domain :is "From" ["tellow.nl"] { fileinto "Financieel.Bank.Tellow"; stop; }
if address :domain :is "From" ["unive.nl", "nieuwsbrief.unive.nl"] {
fileinto "Financieel.Verzekering.Univé"; stop;
}
if address :domain :is "From" ["heinenoord.nl"] {
fileinto "Financieel.Verzekering.VEZA"; stop;
}
# ════════════════════════════════════════════════════════════════════════════
# 3. WERK / OPDRACHTEN
# ════════════════════════════════════════════════════════════════════════════
if address :domain :is "From" ["flextender.nl"] { fileinto "Werk.Opdrachten.Flextender"; stop; }
if address :domain :is "From" ["onestopsourcing.nl"] { fileinto "Werk.Opdrachten.OneStopSourcing"; stop; }
if address :domain :is "From" ["immens.nu"] { fileinto "Werk.Opdrachten.IMMENS"; stop; }
if address :domain :is "From" ["circle8.nl"] { fileinto "Werk.Opdrachten.Circle8"; stop; }
if address :domain :is "From" ["getthere.nl"] { fileinto "Werk.Opdrachten.Get There"; stop; }
if address :domain :is "From" ["huckr.ai"] { fileinto "Werk.Opdrachten.Huckr"; stop; }
if address :domain :is "From" ["hey.meetjamie.ai"] { fileinto "Werk.Opdrachten.Jamie"; stop; }
if address :domain :is "From" ["futurexl.nl"] { fileinto "Werk.Opdrachten.FutureXL"; stop; }
if address :domain :is "From" ["boip.int"] { fileinto "Werk.Overig.BOIP"; stop; }
if address :domain :is "From" ["groningen.nl", "groningenbereikbaar.nl"] {
fileinto "Werk.Overig.Gemeente Groningen"; stop;
}
# ════════════════════════════════════════════════════════════════════════════
# 4. MOBILITEIT / EV
# ════════════════════════════════════════════════════════════════════════════
if address :domain :is "From" ["eneco-emobility.com", "eneco.nl"] {
fileinto "Mobiliteit.EV.Eneco eMobility"; stop;
}
if address :domain :is "From" ["shellrecharge.com"] { fileinto "Mobiliteit.EV.Shell Recharge"; stop; }
if address :domain :is "From" ["fastned.nl"] { fileinto "Mobiliteit.EV.FastNed"; stop; }
if address :domain :is "From" ["opngo.com"] { fileinto "Mobiliteit.EV.OPnGO"; stop; }
if address :domain :is "From" ["tesla.com"] { fileinto "Mobiliteit.EV.Tesla"; stop; }
if address :domain :is "From" ["mmsn.nl"] { fileinto "Mobiliteit.EV.Mitsubishi"; stop; }
if address :domain :is "From" ["alfen.com"] { fileinto "Mobiliteit.EV.Alfen"; stop; }
# ════════════════════════════════════════════════════════════════════════════
# 5. MOBILITEIT / OV & REIZEN
# ════════════════════════════════════════════════════════════════════════════
if address :domain :is "From" ["ns.nl", "email.ns.nl"] {
fileinto "Mobiliteit.OV & Reizen.NS"; stop;
}
if address :domain :is "From" ["booking.com"] { fileinto "Mobiliteit.OV & Reizen.Booking"; stop; }
if address :domain :is "From" ["flitsmeister.nl"]{ fileinto "Mobiliteit.OV & Reizen.Flitsmeister"; stop; }
# ════════════════════════════════════════════════════════════════════════════
# 6. BESTELLINGEN & BEZORGING
# ════════════════════════════════════════════════════════════════════════════
if address :domain :is "From" ["postnl.nl", "edm.postnl.nl",
"notificatie.postnl.nl"] { fileinto "Bestellingen.PostNL"; stop; }
if address :domain :is "From" ["dhlparcel.nl", "dhlecommerce.nl"] { fileinto "Bestellingen.DHL"; stop; }
if address :domain :is "From" ["gls-netherlands.com"] { fileinto "Bestellingen.GLS"; stop; }
if address :domain :is "From" ["bol.com", "feedback.bol.com"] { fileinto "Bestellingen.bol"; stop; }
if address :domain :is "From" ["coolblue.nl", "coolblue.eu"] { fileinto "Bestellingen.Coolblue"; stop; }
if address :domain :is "From" ["allekabels.nl"] { fileinto "Bestellingen.Allekabels"; stop; }
if address :domain :is "From" ["vikingdirect.nl",
"delivery.vikingdirect.nl",
"online.vikingdirect.nl"] { fileinto "Bestellingen.Viking"; stop; }
if address :domain :is "From" ["123inkt.nl", "m.123inkt.nl"] { fileinto "Bestellingen.123inkt"; stop; }
if address :domain :is "From" ["officecentre.nl"] { fileinto "Bestellingen.Office Centre"; stop; }
if address :domain :is "From" ["sligro.nl"] { fileinto "Bestellingen.Sligro"; stop; }
if address :domain :is "From" ["makro.nl"] { fileinto "Bestellingen.Makro"; stop; }
if address :domain :is "From" ["smartphonehoesjes.nl"] { fileinto "Bestellingen.Smartphonehoesjes"; stop; }
# ════════════════════════════════════════════════════════════════════════════
# 7. DIENSTEN / HOSTING
# ════════════════════════════════════════════════════════════════════════════
if address :domain :is "From" ["strato.com", "news.strato.com",
"marketingradar.strato.com"] { fileinto "Diensten.Hosting.STRATO"; stop; }
if address :domain :is "From" ["transip.nl"] { fileinto "Diensten.Hosting.TransIP"; stop; }
if address :domain :is "From" ["letsencrypt.org"] { fileinto "Diensten.Hosting.Lets Encrypt"; stop; }
if address :domain :is "From" ["backblaze.com"] { fileinto "Diensten.Hosting.Backblaze"; stop; }
# ════════════════════════════════════════════════════════════════════════════
# 8. DIENSTEN / TELECOM
# ════════════════════════════════════════════════════════════════════════════
if address :domain :is "From" ["kpn.com", "campagnes.kpn.com"] { fileinto "Diensten.Telecom.KPN"; stop; }
if address :domain :is "From" ["zakelijk.vodafone.nl",
"vodafone.nl"] { fileinto "Diensten.Telecom.Vodafone"; stop; }
# ════════════════════════════════════════════════════════════════════════════
# 9. DIENSTEN / SOFTWARE
# ════════════════════════════════════════════════════════════════════════════
if address :domain :is "From" ["cursor.com"] { fileinto "Diensten.Software.Cursor"; stop; }
if address :domain :is "From" ["1password.com"] { fileinto "Diensten.Software.1Password"; stop; }
if address :domain :is "From" ["home.sophos.com",
"cleverbridge.com"] { fileinto "Diensten.Software.Sophos"; stop; }
if address :domain :is "From" ["cogsciapps.com"] { fileinto "Diensten.Software.CogSci Apps"; stop; }
if address :domain :is "From" ["claude.com"] { fileinto "Diensten.Software.Claude"; stop; }
if address :domain :is "From" ["microsoft365.com"] { fileinto "Diensten.Software.Microsoft Teams"; stop; }
if address :domain :is "From" ["getmatter.com"] { fileinto "Diensten.Software.Matter"; stop; }
if address :domain :is "From" ["mindnode.com"] { fileinto "Diensten.Software.MindNode"; stop; }
if address :domain :is "From" ["papersapp.com"] { fileinto "Diensten.Software.Papers"; stop; }
if address :domain :is "From" ["team.kahoot.com"] { fileinto "Diensten.Software.Kahoot"; stop; }
if address :domain :is "From" ["perplexity.ai"] { fileinto "Diensten.Software.Perplexity"; stop; }
# ════════════════════════════════════════════════════════════════════════════
# 10. NIEUWSBRIEVEN / LEREN
# ════════════════════════════════════════════════════════════════════════════
if address :domain :is "From" ["coursera.org", "email.coursera.org",
"m.mail.coursera.org", "m.learn.coursera.org"] {
fileinto "Nieuwsbrieven.Leren.Coursera"; stop;
}
if address :domain :is "From" ["technologyreview.com",
"fulfillment.technologyreview.com"] {
fileinto "Nieuwsbrieven.Leren.MIT Technology Review"; stop;
}
if address :domain :is "From" ["mit.edu"] { fileinto "Nieuwsbrieven.Leren.MIT Open Learning"; stop; }
if address :domain :is "From" ["storytellingwithdata.com"]{ fileinto "Nieuwsbrieven.Leren.Storytelling with Data"; stop; }
if address :domain :is "From" ["linkingyourthinking.com"] { fileinto "Nieuwsbrieven.Leren.Nick Milo"; stop; }
if anyof (
address :domain :is "From" ["evakeiffenheim.com"],
header :contains "List-ID" "evakeiffenheim"
) {
fileinto "Nieuwsbrieven.Leren.Eva Keiffenheim"; stop;
}
if address :domain :is "From" ["tiggelaar.nl"] { fileinto "Nieuwsbrieven.Leren.Ben Tiggelaar"; stop; }
if address :domain :is "From" ["briankeating.com"] { fileinto "Nieuwsbrieven.Leren.Brian Keating"; stop; }
if address :domain :is "From" ["beehiiv.com", "mail.beehiiv.com"] {
fileinto "Nieuwsbrieven.Leren.AI Report"; stop;
}
if address :domain :is "From" ["elizabethbutlermd.com"] { fileinto "Nieuwsbrieven.Leren.Elizabeth Butler"; stop; }
# ════════════════════════════════════════════════════════════════════════════
# 11. NIEUWSBRIEVEN / NIEUWS & VAKBLADEN
# ════════════════════════════════════════════════════════════════════════════
if address :domain :is "From" ["crisismanager.nl"] { fileinto "Nieuwsbrieven.Nieuws & Vakbladen.Vakblad Crisismanager"; stop; }
if address :domain :is "From" ["jaarbeurs.nl"] { fileinto "Nieuwsbrieven.Nieuws & Vakbladen.Computable"; stop; }
if address :domain :is "From" ["presentation-guru.com"]{ fileinto "Nieuwsbrieven.Nieuws & Vakbladen.The Presentation Guru"; stop; }
# ════════════════════════════════════════════════════════════════════════════
# 12. NIEUWSBRIEVEN / OVERIG
# ════════════════════════════════════════════════════════════════════════════
if address :domain :is "From" ["lendahand.com"] { fileinto "Nieuwsbrieven.Overig.Lendahand"; stop; }
if address :domain :is "From" ["thegoodroll.nl"] { fileinto "Nieuwsbrieven.Overig.The Good Roll"; stop; }
# ════════════════════════════════════════════════════════════════════════════
# 13. TECHNISCH
# ════════════════════════════════════════════════════════════════════════════
if anyof (
header :contains "Subject" ["DMARC", "SPF", "DKIM"],
header :contains "From" "dmarc"
) {
fileinto "Technisch.DMARC"; stop;
}
if anyof (
header :contains "Subject" ["delivery failed", "undeliverable",
"mailer-daemon", "mail delivery"],
address :is "From" "mailer-daemon@australius.nl"
) {
fileinto "Technisch"; stop;
}
+19
View File
@@ -0,0 +1,19 @@
Ik wil dat je een bepaalde mailbox van mij helemaal gaat herordenen. Ik wil onder andere het volgende:
- een overzicht van mailinglijsten waar ik op geabonneerd ben en een advies of ik me moet afmelden;
- een kant en klaar script dat ik kan afdraaien om me af te melden van de mailinglijsten waar ik me van wil afmelden
- een overzicht van alle facturen in de mailbox
- een overzicht van alle facturen die niet in de mailbox zitten, maar waarvan een mail verwijst naar de website
- een plan hoe ik deze facturen geautomatiseerd van de betreffende website kan halen
- een overzicht van mails die niet relevant zijn en die zo naar de prullenbak kunnen
Ik wil dat je werkt in een offline kopie van de mailbox die ik geordend wil hebben.
Geef me:
- een plan hoe je dit gaat aanpakken
- een manier om een bepaalde mailbox helemaal te kopieren naar mijn lokale harde schijf op zo'n manier dat jij ermee uit
de voeten kunt en ook de acties klaar kunt zetten die ik hierboven benoemd heb
- als ik je plan geakkordeerd heb wil ik dat je eerst overzichten gaat maken, dan scripts gaat maken en me dan vertelt
hoe ik dit op de eenvoudigste manier daadwerkelijk kan uitvoeren op de echte mailbox
Je gewone instructies blijven verder ook gelden.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+582
View File
@@ -0,0 +1,582 @@
# Voorstel mappenstructuur & mailrules
_Gebaseerd op analyse van 11.854 e-mails en jouw beslissingen over 174 mailinglijsten._
_3 niveaus diep: categorie → subcategorie → afzender._
---
## Voorgestelde mappenstructuur
```
INBOX ← alleen wat echt jouw actie vraagt
Nieuwsbrieven/
Leren/
AI Report
Ben Tiggelaar
Brian Keating
Coursera
Eva Keiffenheim
MIT Open Learning
MIT Technology Review
Nick Milo
Storytelling with Data
Nieuws & Vakbladen/
Computable
The Presentation Guru
Vakblad Crisismanager
Overig/
Lendahand
The Good Roll
Financieel/
Facturen/
2024.Q1 ← automatisch gesorteerd op ontvangstdatum
2024.Q2 PDF-facturen worden ook doorgestuurd
2024.Q3 naar boekhouding@australius.nl
2024.Q4
2025.Q1
2025.Q2
2025.Q3
2025.Q4
(per jaar aanmaken)
Bank/
ABN AMRO
ICS
NPEX
SnelStart
Tellow
Verzekering/
Univé
VEZA
Werk/
Opdrachten/
Circle8
Flextender
FutureXL
Get There
Huckr
IMMENS
Jamie (MeetJamie)
OneStopSourcing
Overig/
BOIP
Gemeente Groningen
Diensten/
Hosting/
Backblaze
Let's Encrypt
STRATO
TransIP
Software/
1Password
CogSci Apps
Claude
Cursor
Kahoot
Matter
Microsoft Teams
MindNode
Papers
Perplexity
Sophos
Telecom/
KPN
Vodafone
Mobiliteit/
EV/
Alfen
Eneco eMobility
FastNed
Mitsubishi
OPnGO
Shell Recharge
Tesla
OV & Reizen/
Booking.com
Flitsmeister
NS
Bestellingen/
123inkt
Allekabels
bol.com
Coolblue
DHL
GLS
Makro
Office Centre
PostNL
Sligro
Smartphonehoesjes
Viking
Technisch/
DMARC
Archief/
2023
2024
2025
Drafts
Sent
Spam
Trash
```
---
## Vergelijking huidige vs nieuwe structuur
| Huidig | Nieuw |
|---|---|
| `Mailinglists/BASB` | vervalt (afgemeld) |
| `Mailinglists/Ben Tiggelaar` | `Nieuwsbrieven/Leren/Ben Tiggelaar` |
| `Mailinglists/Brian Keating` | `Nieuwsbrieven/Leren/Brian Keating` |
| `Mailinglists/Calmer Notes` | vervalt (afgemeld) |
| `Mailinglists/Eva Keiffenheim` | `Nieuwsbrieven/Leren/Eva Keiffenheim` |
| `Mailinglists/Formspree` | `Nieuwsbrieven/Overig/The Good Roll` (Cole) |
| `Mailinglists/Lendahand` | `Nieuwsbrieven/Overig/Lendahand` |
| `Mailinglists/LYT - Nick Milo` | `Nieuwsbrieven/Leren/Nick Milo` |
| `Mailinglists/MIT Technology Review` | `Nieuwsbrieven/Leren/MIT Technology Review` |
| `Mailinglists/Storytelling with Data` | `Nieuwsbrieven/Leren/Storytelling with Data` |
| `Kennis/Coursera` | `Nieuwsbrieven/Leren/Coursera` |
| `Kennis/Leeslijst` | vervalt of handmatig overzetten |
| `Facturen - te verwerken` | `Financieel/Facturen` |
| `Facturen - verwerkt` | `Financieel/Facturen.Archief` |
| `Hulp/Gespreksverslagen` | `Werk/Opdrachten/...` (handmatig overzetten) |
| `Archief/202x/inkomend` | `Archief/202x` |
| `Archief/202x/verzonden` | `Archief/202x` |
| `Notes` | vervalt (leeg) |
| `Technisch/dmarc` | `Technisch/DMARC` |
---
## Sieve-mailrules
Volgorde is prioriteit: eerste match wint.
```sieve
require ["fileinto", "imap4flags", "envelope", "address", "comparator-i;ascii-casemap"];
# ── 1. Facturen (altijd eerst, vóór nieuwsbrieven/diensten) ──────────────────
#
# Vereiste extensies: date, variables, foreverypart, mime, copy, fileinto, redirect
# Als de server date+variables niet ondersteunt → gebruik het Python-script
# (zie fase 3) dat kwartaalindeling en forwarding via IMAP afhandelt.
#
if anyof (
header :contains "Subject" ["factuur", "invoice", "rekening", "nota",
"betaalverzoek", "receipt", "orderbevestiging",
"order confirmation", "betaling ontvangen",
"payment received", "uw factuur", "your invoice",
"je factuur"]
) {
# Kwartaal bepalen op basis van ontvangstdatum
if currentdate :matches "year" "*" { set "jaar" "${1}"; }
if anyof (currentdate :is "month" "1",
currentdate :is "month" "2",
currentdate :is "month" "3") { set "kwartaal" "Q1"; }
elsif anyof (currentdate :is "month" "4",
currentdate :is "month" "5",
currentdate :is "month" "6") { set "kwartaal" "Q2"; }
elsif anyof (currentdate :is "month" "7",
currentdate :is "month" "8",
currentdate :is "month" "9") { set "kwartaal" "Q3"; }
else { set "kwartaal" "Q4"; }
# PDF-bijlage → doorsturen naar boekhouding (alleen nieuwe mail, geen terugwerkende kracht)
foreverypart {
if anyof (
header :mime :param "filename" :matches "Content-Disposition" ["*.pdf", "*.PDF"],
header :mime :is "Content-Type" "application/pdf"
) {
redirect :copy "boekhouding@australius.nl";
break;
}
}
fileinto "Financieel.Facturen.${jaar}.${kwartaal}";
stop;
}
# ── 2. Bank ──────────────────────────────────────────────────────────────────
if address :domain :is "From" ["nl.abnamro.com", "abnamro.nl"] {
fileinto "Financieel.Bank.ABN AMRO"; stop;
}
if address :domain :is "From" ["icscards.nl", "icsmarketing.icscards.nl",
"service.icscards.nl"] {
fileinto "Financieel.Bank.ICS"; stop;
}
if address :domain :is "From" ["npex.nl"] {
fileinto "Financieel.Bank.NPEX"; stop;
}
if address :domain :is "From" ["snelstart.nl"] {
fileinto "Financieel.Bank.SnelStart"; stop;
}
if address :domain :is "From" ["tellow.nl"] {
fileinto "Financieel.Bank.Tellow"; stop;
}
# ── 3. Verzekering ───────────────────────────────────────────────────────────
if address :domain :is "From" ["unive.nl", "nieuwsbrief.unive.nl"] {
fileinto "Financieel.Verzekering.Univé"; stop;
}
if address :domain :is "From" ["heinenoord.nl"] {
fileinto "Financieel.Verzekering.VEZA"; stop;
}
# ── 4. Werk / Opdrachten ─────────────────────────────────────────────────────
if address :domain :is "From" ["flextender.nl"] {
fileinto "Werk.Opdrachten.Flextender"; stop;
}
if address :domain :is "From" ["onestopsourcing.nl"] {
fileinto "Werk.Opdrachten.OneStopSourcing"; stop;
}
if address :domain :is "From" ["immens.nu"] {
fileinto "Werk.Opdrachten.IMMENS"; stop;
}
if address :domain :is "From" ["circle8.nl"] {
fileinto "Werk.Opdrachten.Circle8"; stop;
}
if address :domain :is "From" ["getthere.nl"] {
fileinto "Werk.Opdrachten.Get There"; stop;
}
if address :domain :is "From" ["huckr.ai"] {
fileinto "Werk.Opdrachten.Huckr"; stop;
}
if address :domain :is "From" ["hey.meetjamie.ai"] {
fileinto "Werk.Opdrachten.Jamie"; stop;
}
if address :domain :is "From" ["futurexl.nl"] {
fileinto "Werk.Opdrachten.FutureXL"; stop;
}
if address :domain :is "From" ["boip.int"] {
fileinto "Werk.Overig.BOIP"; stop;
}
if address :domain :is "From" ["groningen.nl", "groningenbereikbaar.nl"] {
fileinto "Werk.Overig.Gemeente Groningen"; stop;
}
# ── 5. Mobiliteit / EV ───────────────────────────────────────────────────────
if address :domain :is "From" ["eneco-emobility.com", "eneco.nl"] {
fileinto "Mobiliteit.EV.Eneco eMobility"; stop;
}
if address :domain :is "From" ["shellrecharge.com"] {
fileinto "Mobiliteit.EV.Shell Recharge"; stop;
}
if address :domain :is "From" ["fastned.nl"] {
fileinto "Mobiliteit.EV.FastNed"; stop;
}
if address :domain :is "From" ["opngo.com"] {
fileinto "Mobiliteit.EV.OPnGO"; stop;
}
if address :domain :is "From" ["tesla.com"] {
fileinto "Mobiliteit.EV.Tesla"; stop;
}
if address :domain :is "From" ["mmsn.nl"] {
fileinto "Mobiliteit.EV.Mitsubishi"; stop;
}
if address :domain :is "From" ["alfen.com"] {
fileinto "Mobiliteit.EV.Alfen"; stop;
}
# ── 6. Mobiliteit / OV & Reizen ──────────────────────────────────────────────
if address :domain :is "From" ["ns.nl", "email.ns.nl"] {
fileinto "Mobiliteit.OV & Reizen.NS"; stop;
}
if address :domain :is "From" ["booking.com"] {
fileinto "Mobiliteit.OV & Reizen.Booking.com"; stop;
}
if address :domain :is "From" ["flitsmeister.nl"] {
fileinto "Mobiliteit.OV & Reizen.Flitsmeister"; stop;
}
# ── 7. Bestellingen & bezorging ──────────────────────────────────────────────
if address :domain :is "From" ["postnl.nl", "edm.postnl.nl",
"notificatie.postnl.nl"] {
fileinto "Bestellingen.PostNL"; stop;
}
if address :domain :is "From" ["dhlparcel.nl", "dhlecommerce.nl"] {
fileinto "Bestellingen.DHL"; stop;
}
if address :domain :is "From" ["gls-netherlands.com"] {
fileinto "Bestellingen.GLS"; stop;
}
if address :domain :is "From" ["bol.com", "feedback.bol.com"] {
fileinto "Bestellingen.bol.com"; stop;
}
if address :domain :is "From" ["coolblue.nl", "coolblue.eu"] {
fileinto "Bestellingen.Coolblue"; stop;
}
if address :domain :is "From" ["allekabels.nl"] {
fileinto "Bestellingen.Allekabels"; stop;
}
if address :domain :is "From" ["vikingdirect.nl", "delivery.vikingdirect.nl",
"online.vikingdirect.nl"] {
fileinto "Bestellingen.Viking"; stop;
}
if address :domain :is "From" ["123inkt.nl", "m.123inkt.nl"] {
fileinto "Bestellingen.123inkt"; stop;
}
if address :domain :is "From" ["officecentre.nl"] {
fileinto "Bestellingen.Office Centre"; stop;
}
if address :domain :is "From" ["sligro.nl"] {
fileinto "Bestellingen.Sligro"; stop;
}
if address :domain :is "From" ["makro.nl"] {
fileinto "Bestellingen.Makro"; stop;
}
if address :domain :is "From" ["smartphonehoesjes.nl"] {
fileinto "Bestellingen.Smartphonehoesjes"; stop;
}
# ── 8. Diensten / Hosting ────────────────────────────────────────────────────
if address :domain :is "From" ["strato.com", "news.strato.com",
"marketingradar.strato.com"] {
fileinto "Diensten.Hosting.STRATO"; stop;
}
if address :domain :is "From" ["transip.nl"] {
fileinto "Diensten.Hosting.TransIP"; stop;
}
if address :domain :is "From" ["letsencrypt.org"] {
fileinto "Diensten.Hosting.Let's Encrypt"; stop;
}
if address :domain :is "From" ["backblaze.com"] {
fileinto "Diensten.Hosting.Backblaze"; stop;
}
# ── 9. Diensten / Telecom ────────────────────────────────────────────────────
if address :domain :is "From" ["kpn.com", "campagnes.kpn.com"] {
fileinto "Diensten.Telecom.KPN"; stop;
}
if address :domain :is "From" ["zakelijk.vodafone.nl", "vodafone.nl"] {
fileinto "Diensten.Telecom.Vodafone"; stop;
}
# ── 10. Diensten / Software ──────────────────────────────────────────────────
if address :domain :is "From" ["cursor.com"] {
fileinto "Diensten.Software.Cursor"; stop;
}
if address :domain :is "From" ["1password.com"] {
fileinto "Diensten.Software.1Password"; stop;
}
if address :domain :is "From" ["home.sophos.com", "cleverbridge.com"] {
fileinto "Diensten.Software.Sophos"; stop;
}
if address :domain :is "From" ["cogsciapps.com"] {
fileinto "Diensten.Software.CogSci Apps"; stop;
}
if address :domain :is "From" ["claude.com"] {
fileinto "Diensten.Software.Claude"; stop;
}
if address :domain :is "From" ["microsoft365.com"] {
fileinto "Diensten.Software.Microsoft Teams"; stop;
}
if address :domain :is "From" ["getmatter.com"] {
fileinto "Diensten.Software.Matter"; stop;
}
if address :domain :is "From" ["mindnode.com"] {
fileinto "Diensten.Software.MindNode"; stop;
}
if address :domain :is "From" ["papersapp.com"] {
fileinto "Diensten.Software.Papers"; stop;
}
if address :domain :is "From" ["team.kahoot.com"] {
fileinto "Diensten.Software.Kahoot"; stop;
}
if address :domain :is "From" ["perplexity.ai"] {
fileinto "Diensten.Software.Perplexity"; stop;
}
# ── 11. Nieuwsbrieven / Leren ────────────────────────────────────────────────
if address :domain :is "From" ["coursera.org", "email.coursera.org",
"m.mail.coursera.org", "m.learn.coursera.org"] {
fileinto "Nieuwsbrieven.Leren.Coursera"; stop;
}
if address :domain :is "From" ["technologyreview.com",
"fulfillment.technologyreview.com"] {
fileinto "Nieuwsbrieven.Leren.MIT Technology Review"; stop;
}
if address :domain :is "From" ["mit.edu"] {
fileinto "Nieuwsbrieven.Leren.MIT Open Learning"; stop;
}
if address :domain :is "From" ["storytellingwithdata.com"] {
fileinto "Nieuwsbrieven.Leren.Storytelling with Data"; stop;
}
if address :domain :is "From" ["linkingyourthinking.com"] {
fileinto "Nieuwsbrieven.Leren.Nick Milo"; stop;
}
if anyof (
address :domain :is "From" ["evakeiffenheim.com"],
header :contains "List-ID" "evakeiffenheim"
) {
fileinto "Nieuwsbrieven.Leren.Eva Keiffenheim"; stop;
}
if address :domain :is "From" ["tiggelaar.nl"] {
fileinto "Nieuwsbrieven.Leren.Ben Tiggelaar"; stop;
}
if address :domain :is "From" ["briankeating.com"] {
fileinto "Nieuwsbrieven.Leren.Brian Keating"; stop;
}
if address :domain :is "From" ["beehiiv.com", "mail.beehiiv.com"] {
fileinto "Nieuwsbrieven.Leren.AI Report"; stop;
}
if address :domain :is "From" ["elizabethbutlermd.com"] {
fileinto "Nieuwsbrieven.Leren.Elizabeth Butler"; stop;
}
# ── 12. Nieuwsbrieven / Nieuws & Vakbladen ───────────────────────────────────
if address :domain :is "From" ["crisismanager.nl"] {
fileinto "Nieuwsbrieven.Nieuws & Vakbladen.Vakblad Crisismanager"; stop;
}
if address :domain :is "From" ["jaarbeurs.nl"] {
fileinto "Nieuwsbrieven.Nieuws & Vakbladen.Computable"; stop;
}
if address :domain :is "From" ["presentation-guru.com"] {
fileinto "Nieuwsbrieven.Nieuws & Vakbladen.The Presentation Guru"; stop;
}
# ── 13. Nieuwsbrieven / Overig ───────────────────────────────────────────────
if address :domain :is "From" ["lendahand.com"] {
fileinto "Nieuwsbrieven.Overig.Lendahand"; stop;
}
if address :domain :is "From" ["thegoodroll.nl"] {
fileinto "Nieuwsbrieven.Overig.The Good Roll"; stop;
}
# ── 14. Technisch ────────────────────────────────────────────────────────────
if anyof (
header :contains "Subject" ["DMARC", "SPF", "DKIM"],
header :contains "From" "dmarc"
) {
fileinto "Technisch.DMARC"; stop;
}
if anyof (
header :contains "Subject" ["delivery failed", "undeliverable",
"mailer-daemon", "mail delivery"],
address :is "From" "mailer-daemon@australius.nl"
) {
fileinto "Technisch"; stop;
}
```
---
## Te maken mappen (nieuw)
De volgende mappen moeten aangemaakt worden op de IMAP-server
(het maak-script doet dit automatisch):
```
Nieuwsbrieven
Nieuwsbrieven.Leren
Nieuwsbrieven.Leren.AI Report
Nieuwsbrieven.Leren.Ben Tiggelaar
Nieuwsbrieven.Leren.Brian Keating
Nieuwsbrieven.Leren.Coursera
Nieuwsbrieven.Leren.Elizabeth Butler
Nieuwsbrieven.Leren.Eva Keiffenheim
Nieuwsbrieven.Leren.MIT Open Learning
Nieuwsbrieven.Leren.MIT Technology Review
Nieuwsbrieven.Leren.Nick Milo
Nieuwsbrieven.Leren.Storytelling with Data
Nieuwsbrieven.Nieuws & Vakbladen
Nieuwsbrieven.Nieuws & Vakbladen.Computable
Nieuwsbrieven.Nieuws & Vakbladen.The Presentation Guru
Nieuwsbrieven.Nieuws & Vakbladen.Vakblad Crisismanager
Nieuwsbrieven.Overig
Nieuwsbrieven.Overig.Lendahand
Nieuwsbrieven.Overig.The Good Roll
Financieel
Financieel.Facturen
Financieel.Facturen.2023.Q1
Financieel.Facturen.2023.Q2
Financieel.Facturen.2023.Q3
Financieel.Facturen.2023.Q4
Financieel.Facturen.2024.Q1
Financieel.Facturen.2024.Q2
Financieel.Facturen.2024.Q3
Financieel.Facturen.2024.Q4
Financieel.Facturen.2025.Q1
Financieel.Facturen.2025.Q2
Financieel.Facturen.2025.Q3
Financieel.Facturen.2025.Q4
Financieel.Bank
Financieel.Bank.ABN AMRO
Financieel.Bank.ICS
Financieel.Bank.NPEX
Financieel.Bank.SnelStart
Financieel.Bank.Tellow
Financieel.Verzekering
Financieel.Verzekering.Univé
Financieel.Verzekering.VEZA
Werk
Werk.Opdrachten
Werk.Opdrachten.Circle8
Werk.Opdrachten.Flextender
Werk.Opdrachten.FutureXL
Werk.Opdrachten.Get There
Werk.Opdrachten.Huckr
Werk.Opdrachten.IMMENS
Werk.Opdrachten.Jamie
Werk.Opdrachten.OneStopSourcing
Werk.Overig
Werk.Overig.BOIP
Werk.Overig.Gemeente Groningen
Diensten
Diensten.Hosting
Diensten.Hosting.Backblaze
Diensten.Hosting.Let's Encrypt
Diensten.Hosting.STRATO
Diensten.Hosting.TransIP
Diensten.Software
Diensten.Software.1Password
Diensten.Software.CogSci Apps
Diensten.Software.Claude
Diensten.Software.Cursor
Diensten.Software.Kahoot
Diensten.Software.Matter
Diensten.Software.Microsoft Teams
Diensten.Software.MindNode
Diensten.Software.Papers
Diensten.Software.Perplexity
Diensten.Software.Sophos
Diensten.Telecom
Diensten.Telecom.KPN
Diensten.Telecom.Vodafone
Mobiliteit
Mobiliteit.EV
Mobiliteit.EV.Alfen
Mobiliteit.EV.Eneco eMobility
Mobiliteit.EV.FastNed
Mobiliteit.EV.Mitsubishi
Mobiliteit.EV.OPnGO
Mobiliteit.EV.Shell Recharge
Mobiliteit.EV.Tesla
Mobiliteit.OV & Reizen
Mobiliteit.OV & Reizen.Booking.com
Mobiliteit.OV & Reizen.Flitsmeister
Mobiliteit.OV & Reizen.NS
Bestellingen
Bestellingen.123inkt
Bestellingen.Allekabels
Bestellingen.bol.com
Bestellingen.Coolblue
Bestellingen.DHL
Bestellingen.GLS
Bestellingen.Makro
Bestellingen.Office Centre
Bestellingen.PostNL
Bestellingen.Sligro
Bestellingen.Smartphonehoesjes
Bestellingen.Viking
Archief.2023
Archief.2024
Archief.2025
```
File diff suppressed because it is too large Load Diff
+382
View File
@@ -0,0 +1,382 @@
# Script review: mailbox creation and redistribution
Date: 2026-07-04
Scope reviewed:
- `maak_mappen.py`
- `verplaats_bestaand.py`
- `mailrules.sieve`
- `upload_sieve.py`
- Supporting scripts: `download_mailbox.py`, `analyse_mailbox.py`, `review_mailinglists.py`, `unsubscribe.py`, `kopieer_naar_backup.py`, `dagelijks_overzicht.py`
Static checks:
- `python3 -m py_compile *.py` passes.
- `verplaats_log.json` currently contains 14 completed step-1 folders and 3166 processed INBOX UIDs, so at least one real redistribution run has been attempted.
## Executive summary
The folder creation script mostly works, but redistribution has several real defects that can explain failed or incomplete moves.
The highest-risk issue is in `verplaats_bestaand.py`: step 1 opens source folders read-only and then tries to mark messages as deleted. On a normal IMAP server, this makes a true move impossible. The script may copy messages but fail to delete originals, while still logging the folder as completed. That can leave duplicates and prevent a later rerun from correcting the folder.
There is also a folder contract mismatch: `maak_mappen.py` does not create `INBOX.Technisch` or `INBOX.Technisch.DMARC`, while both the Sieve rules and redistribution route mail there. That will fail for those categories unless the folders already exist.
The logs are not strong enough for safe recovery: they record intended work as complete without validating that the source message disappeared and the destination message exists. If the first real run failed partially, the safest next action is to audit actual server folder counts before rerunning with the current log.
## Findings
### P0: Step 1 selects source folders read-only, then tries to delete messages
File: `verplaats_bestaand.py`
Relevant lines:
- Line 284: `mail.select(..., readonly=True)`
- Lines 301-303: copy the message and then `STORE +FLAGS \Deleted`
- Line 306: `EXPUNGE`
- Lines 307-310: reports success and logs the source folder as done
Problem:
Step 1 always selects the source folder with `readonly=True`, including during real execution. In read-only mode, `COPY` can succeed, but `STORE +FLAGS \Deleted` should fail or be ignored by the server. That means this code is not reliably moving messages; it may only copy them.
Impact:
- Messages may remain in their original folders.
- Duplicates may be created in destination folders.
- The script still appends the folder to `stap1_klaar`, so reruns skip it.
- This matches the symptom: folder creation works, redistribution appears to fail.
Recommended fix:
- Select read-only only in dry-run mode:
- `mail.select(..., readonly=dry)`
- Check both `COPY` and `STORE` results.
- Only log a folder as complete if all expected messages were copied and deleted.
- Prefer UID-based operations in step 1, consistent with step 2.
### P0: The redistribution log can mark failed or partial moves as complete
File: `verplaats_bestaand.py`
Relevant lines:
- Lines 301-304: `STORE` result is ignored.
- Lines 307-310: folder is logged as done regardless of whether `gekopieerd == n`.
- Lines 365-369: step 2 logs a UID as processed when `COPY` succeeds, even though `STORE` result is not checked inside `move_message`.
- Lines 256-268: `move_message()` returns success based only on `COPY`.
Problem:
The code treats `COPY OK` as a successful move. A move is only complete if:
1. The message was copied to the destination.
2. The source message was marked deleted.
3. The deletion was expunged, or a later expunge is guaranteed.
The current code does not verify steps 2 and 3 before writing progress to `verplaats_log.json`.
Impact:
- A rerun may skip messages that are still in the source folder.
- Failures from missing destination folders, read-only source folders, or permission issues can be hidden.
- `verplaats_log.json` is currently not a reliable source of truth.
Recommended fix:
- Make `move_message()` return false unless both `UID COPY` and `UID STORE +FLAGS \Deleted` return `OK`.
- Save detailed failures in the log with source, UID, destination, and server response.
- Do not append to `stap1_klaar` unless the final moved count equals the expected count.
- Before rerunning after the current failure, either archive/reset `verplaats_log.json` or write a repair mode that reconciles the log against actual server state.
### P1: `maak_mappen.py` omits folders targeted by redistribution and Sieve
Files:
- `maak_mappen.py`
- `verplaats_bestaand.py`
- `mailrules.sieve`
Relevant lines:
- `verplaats_bestaand.py` line 165 targets `Technisch.DMARC`.
- `mailrules.sieve` lines 230-241 targets `Technisch.DMARC` and `Technisch`.
- `maak_mappen.py` lines 85-193 contains no `Technisch` or `Technisch.DMARC`.
Problem:
The folder creator and routing targets are inconsistent. Static comparison found these Sieve targets missing from `maak_mappen.py`:
- `Technisch`
- `Technisch.DMARC`
The dynamic Sieve invoice path `Financieel.Facturen.${jaar}.${kwartaal}` is not directly comparable, but the concrete year/quarter folders for 2020-2026 are created.
Impact:
- Technical/DMARC messages fail to move if those folders do not already exist.
- Sieve `fileinto` for those folders can fail for new mail.
Recommended fix:
- Add `Technisch` and `Technisch.DMARC` to `maak_mappen.py`.
- Add a test or check that every static `fileinto` destination and every Python route destination exists in the folder creation list.
### P1: Step 1 assumes archive parent folders already exist
Files:
- `maak_mappen.py`
- `verplaats_bestaand.py`
Relevant lines:
- `verplaats_bestaand.py` lines 192-198 moves old archive subfolders into `INBOX.Archief.2020`, `INBOX.Archief.2021`, and `INBOX.Archief.2022`.
- `maak_mappen.py` lines 188-192 creates only `Archief.2023` through `Archief.2026`.
Problem:
The redistribution script targets `INBOX.Archief.2020`, `INBOX.Archief.2021`, and `INBOX.Archief.2022`, but the folder creator does not create them. In the current offline mailbox they exist, so this may work on the original account. It can fail on a fresh backup account or after recreating the target structure from scratch.
Impact:
- Archive redistribution may fail on accounts where those parent folders do not already exist.
- If `COPY` fails because the destination does not exist, step 1 still risks logging the source as done.
Recommended fix:
- Include `Archief`, `Archief.2020`, `Archief.2021`, and `Archief.2022` in `maak_mappen.py`, or have `verplaats_bestaand.py` create missing destination folders before moving.
### P1: `verplaats_bestaand.py` only routes INBOX, despite claiming broader routing
File: `verplaats_bestaand.py`
Relevant lines:
- Lines 8-9: docstring says "INBOX + overige mappen".
- Line 204: comment says folders can be routed per message.
- Line 205: `ROUTE_FOLDERS = ["INBOX"]`
- Lines 315-377: function is hard-coded to INBOX.
Problem:
The documentation implies broader per-message routing, but the implementation only processes `INBOX`. Existing messages in other folders are only moved if they are covered by the hard-coded whole-folder renames or by the invoice step.
Impact:
- Many existing messages outside INBOX remain unsorted.
- The user may interpret this as redistribution failing, when the script never attempted those folders.
Recommended fix:
- Either make the documentation explicit that only INBOX is routed, or implement routing for a configured set of non-system folders.
- If broad routing is implemented, exclude destination folders to avoid repeatedly reprocessing already sorted mail.
### P1: Routing exact-domain matching is weaker than the reports suggest
File: `verplaats_bestaand.py`
Relevant lines:
- Lines 226-228 extracts the exact sender domain.
- Lines 244-253 uses direct lookup only: `DOMAIN_LOOKUP.get(domain)`.
- Lines 75-166 contains hand-curated domains, including some but not all subdomains.
Problem:
The redistribution routes only exact domains. If mail comes from `mail.example.com`, but the route table contains only `example.com`, it will not match. Some subdomains are listed manually, but this is incomplete by design.
Impact:
- Valid messages can remain in INBOX as "Geen match".
- This can look like the redistribution failed even though the script ran.
Recommended fix:
- Add a parent-domain fallback, for example try `a.b.example.com`, then `b.example.com`, then `example.com`.
- Keep exceptions for multi-tenant senders where parent-domain matching is unsafe.
- Log unmatched high-volume domains for review.
### P1: Sieve paths may use a different namespace prefix than created folders
Files:
- `maak_mappen.py`
- `mailrules.sieve`
- `upload_sieve.py`
Relevant lines:
- `maak_mappen.py` line 16 uses `PREFIX = "INBOX."`.
- `mailrules.sieve` lines 5-7 notes this ambiguity.
- `upload_sieve.py` line 21 uses `SIEVE_PREFIX = ""`.
Problem:
Folder creation creates `INBOX.<path>`, while the uploaded Sieve file currently files into `<path>` without `INBOX.`. On some Dovecot configurations this is correct; on others, Sieve needs `INBOX.<path>`.
Impact:
- New incoming mail may not land in the created folders even if existing-mail redistribution is fixed.
- This affects Sieve/new mail more than `verplaats_bestaand.py`, which does use `INBOX.`.
Recommended fix:
- Verify the active server namespace with a test message.
- If Sieve delivery fails, set `SIEVE_PREFIX = "INBOX."` before upload.
- Consider adding a `--prefix` flag instead of editing a constant.
### P2: Step 3 has no per-message progress log
File: `verplaats_bestaand.py`
Relevant lines:
- Lines 382-427 handles invoice folders.
- No `log` writes are made for step 3.
Problem:
Invoice redistribution can move a large number of messages, but the script does not record which UIDs were successfully processed.
Impact:
- If the run is interrupted, it starts over for whatever remains.
- That is mostly safe if moves are truly atomic, but unsafe with the current weak move verification.
Recommended fix:
- Add the same robust move result logging recommended for step 2.
- Include source folder and UIDVALIDITY or use Message-ID based reconciliation.
### P2: Folder and IMAP LIST parsing is fragile for encoded or unusual names
Files:
- `download_mailbox.py`
- `kopieer_naar_backup.py`
- `dagelijks_overzicht.py`
- `maak_mappen.py`
Relevant examples:
- `download_mailbox.py` lines 22-30
- `kopieer_naar_backup.py` lines 36-41
- `dagelijks_overzicht.py` lines 89-94
- `maak_mappen.py` lines 216-223
Problem:
Several scripts parse `LIST` responses with regular expressions and inconsistent modified UTF-7 decoding. `maak_mappen.py` decodes names, but other scripts mostly do not.
Impact:
- Non-ASCII folders such as `Univé` can be mishandled in scripts that list/select folders from server responses.
- This is less likely to be the main redistribution failure because `verplaats_bestaand.py` mostly uses hard-coded folder names, but it is a reliability issue across the toolkit.
Recommended fix:
- Centralize IMAP folder encoding/decoding and LIST parsing in one helper module.
- Use that helper from all IMAP scripts.
### P2: `kopieer_naar_backup.py` silently ignores append failures
File: `kopieer_naar_backup.py`
Relevant lines:
- Lines 168-179 appends a message to the destination.
- Line 179 silently does `pass` on failure.
- Lines 270-274 marks the folder complete after one pass.
Problem:
A folder can be marked complete even if some message appends failed.
Impact:
- Backup completeness cannot be trusted from `backup_log.json` alone.
- This matters if the backup account is used as a safety net before live redistribution.
Recommended fix:
- Count append failures.
- Do not mark a folder complete if failures occurred.
- Log failed message IDs and server responses.
### P2: `upload_sieve.py` has an incomplete ManageSieve parser
File: `upload_sieve.py`
Relevant lines:
- Lines 38-39 expects to read capabilities.
- Lines 104-109 `_read_capabilities()` is `pass`.
- Lines 58-64 uses a non-synchronizing literal `{len+}`.
Problem:
The ManageSieve client is intentionally minimal. If the server sends multi-line capabilities before `OK`, or does not support non-synchronizing literals, this can behave unpredictably.
Impact:
- Upload may fail on stricter servers.
- If it appears to work on the current server, this is not the likely redistribution failure, because redistribution uses IMAP, not ManageSieve.
Recommended fix:
- Use a tested ManageSieve library, or implement response parsing properly.
- At minimum, capture and print full server responses when upload fails.
## Likely explanation of the current failure
Given the observed state:
- Folder creation appears to work.
- `verplaats_log.json` says all 14 step-1 folder renames were completed.
- `verplaats_log.json` says 3166 INBOX UIDs were processed.
- Step 1 selected folders read-only and then attempted deletion.
The most likely scenario is:
1. Destination folders were created.
2. Step 1 copied messages from old folders into new folders.
3. Deletion from the source folders failed because the source folder was selected read-only.
4. The script still wrote those old folders to `stap1_klaar`.
5. Rerunning the script now skips those old folders, so the failed move is not repaired.
Step 2 may have moved many INBOX messages, but because its log is UID-only and only checks `COPY`, its success should also be verified against actual mailbox state.
## Recommended recovery plan
1. Do not run `verplaats_bestaand.py --uitvoeren` again with the current script and current log.
2. Inspect actual server counts for:
- Old source folders in `stap1_klaar`
- Their intended destination folders
- INBOX
- Invoice folders
3. Archive `verplaats_log.json` before any repair run.
4. Fix move semantics:
- No read-only select during real moves.
- Check `COPY`, `STORE`, and final expunge behavior.
- Log failures explicitly.
5. Add missing folders to `maak_mappen.py`.
6. Add a dry-run audit mode that reports source and destination counts without modifying mail.
7. Only then run a repair mode that moves remaining source messages and avoids creating duplicates, preferably by comparing `Message-ID` in source and destination.
## Minimal code changes to prioritize
1. In `verplaats_bestaand.py`, change step-1 select from `readonly=True` to `readonly=dry`.
2. In `move_message()`, require `UID STORE` to return `OK` before returning success.
3. In step 1, use UID-based search/copy/store and log the folder complete only when all messages moved.
4. In `maak_mappen.py`, add:
- `Technisch`
- `Technisch.DMARC`
- `Archief`
- `Archief.2020`
- `Archief.2021`
- `Archief.2022`
5. Add a destination-existence preflight to `verplaats_bestaand.py` before any real move.
+356
View File
@@ -0,0 +1,356 @@
#!/usr/bin/env python3
"""
Interactief script om mailinglijsten te beoordelen.
h = houden | a = afmelden | s = overslaan | q = stoppen
Eerste run: scant mailbox en slaat cache op in mailing_lists_cache.json.
Volgende runs: laadt direct uit cache.
Beslissingen worden opgeslagen in decisions.json (herstart-safe).
"""
import email
import email.header
import email.utils
import re
import sys
import tty
import termios
import json
from pathlib import Path
from collections import defaultdict, Counter
from datetime import timezone
MAILBOX_DIR = Path(__file__).parent / "mailbox"
CACHE_FILE = Path(__file__).parent / "mailing_lists_cache.json"
DECISIONS_FILE = Path(__file__).parent / "decisions.json"
# Domeinen die door meerdere afzenders worden gedeeld → groeperen op naam, niet domein
MULTITENANT_DOMAINS = {
"substack.com", "mcsv.net", "list-manage.com",
"sparkpostmail.com", "eu.sparkpostmail.com",
"convertkit-mail.com", "convertkit-mail2.com", "convertkit-mail4.com",
"sendgrid.net", "mailgun.org", "dripemail.com", "dripemail3.com",
"mailchimpapp.net", "rsgsv.net",
}
MIN_COUNT = 3 # lijsten met minder dan dit aantal mails worden overgeslagen
# ── helpers ──────────────────────────────────────────────────────────────────
def decode_hdr(value) -> str:
if not value:
return ""
try:
parts = email.header.decode_header(value)
out = []
for raw, cs in parts:
if isinstance(raw, bytes):
out.append(raw.decode(cs or "utf-8", errors="replace"))
else:
out.append(str(raw))
return " ".join(out).strip()
except Exception:
return str(value or "")
def parse_dt(s):
if not s:
return None
try:
return email.utils.parsedate_to_datetime(s)
except Exception:
return None
def to_utc_ts(d) -> float | None:
if d is None:
return None
if d.tzinfo is None:
d = d.replace(tzinfo=timezone.utc)
return d.timestamp()
_EMAIL_SUBDOMAINS = re.compile(
r"^(email|mail|e|m|news|newsletter|noreply|no-reply|lists?|"
r"mailing|bounce|notifications?|updates?|info|reply|send|smtp|"
r"mg|sg|em|learn|go|click|edm|cdn|app|get)\.",
re.I,
)
def sender_domain(from_addr: str) -> str:
m = re.search(r"@([\w.\-]+)", from_addr)
if not m:
return "onbekend"
domain = m.group(1).lower()
# Strip bekende e-mail-subdomeinen zodat bijv. email.coursera.org → coursera.org
parts = domain.split(".")
while len(parts) > 2 and _EMAIL_SUBDOMAINS.match(parts[0] + "."):
parts = parts[1:]
return ".".join(parts)
def display_name(from_addr: str) -> str:
"""Haal leesbare naam op; val terug op domein."""
m = re.match(r'^"?([^"<@\n]{3,})"?\s*<', from_addr)
if m:
name = m.group(1).strip().strip('"').strip()
if len(name) > 2:
return name
return sender_domain(from_addr)
def normalize_name(name: str) -> str:
"""Normaliseer naam voor groepering (lowercase, alleen woorden)."""
return " ".join(re.findall(r"[a-z0-9]+", name.lower()))
def fmt_date(ts: float | None) -> str:
if ts is None:
return "?"
from datetime import datetime
return datetime.utcfromtimestamp(ts).strftime("%Y-%m-%d")
def getch() -> str:
fd = sys.stdin.fileno()
old = termios.tcgetattr(fd)
try:
tty.setraw(fd)
return sys.stdin.read(1)
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old)
# ── cache bouwen ─────────────────────────────────────────────────────────────
def build_cache() -> list[dict]:
"""
Scan mailbox headers en groepeer op weergavenaam.
Elke entry in de cache is één logische mailinglijst.
"""
eml_files = list(MAILBOX_DIR.rglob("*.eml"))
total = len(eml_files)
print(f"Scannen van {total} e-mails (alleen headers)...", flush=True)
# Per weergavenaam (genormaliseerd) verzamelen we alle info
by_name: dict[str, dict] = defaultdict(lambda: {
"names": [], # alle aangetroffen weergavenamen
"domains": [], # alle aangetroffen domeinen
"unsubscribe": [], # List-Unsubscribe waarden
"count": 0,
"timestamps": [], # als float (UTC)
"subjects": [],
"folders": [],
})
for i, path in enumerate(eml_files, 1):
if i % 2000 == 0:
print(f" {i}/{total}...", flush=True)
try:
with open(path, "rb") as f:
chunk = f.read(8192)
msg = email.message_from_bytes(chunk)
except Exception:
continue
from_raw = decode_hdr(msg.get("From", ""))
subject = decode_hdr(msg.get("Subject", ""))
date = parse_dt(msg.get("Date"))
list_id = (msg.get("List-ID") or "").strip()
list_us = (msg.get("List-Unsubscribe") or "").strip()
prec = (msg.get("Precedence") or "").lower()
folder = path.parent.name
is_ml = bool(
list_id or list_us
or prec in ("bulk", "list")
or "mailinglist" in folder.lower()
)
if not is_ml:
continue
name = display_name(from_raw)
domain = sender_domain(from_raw)
# Multi-tenant domeinen: groepeer op genormaliseerde naam
# Gewone domeinen: groepeer op domein (voorkomt splits zoals "LinkedIn" vs "LinkedIn Job Alerts")
if domain in MULTITENANT_DOMAINS:
key = normalize_name(name) or domain
else:
key = domain
e = by_name[key]
e["names"].append(name)
e["domains"].append(domain)
e["count"] += 1
e["folders"].append(folder)
ts = to_utc_ts(date)
if ts:
e["timestamps"].append(ts)
if len(e["subjects"]) < 3:
e["subjects"].append(subject)
if list_us and list_us not in e["unsubscribe"]:
e["unsubscribe"].append(list_us)
print(f" {total}/{total} klaar.\n")
# Omzetten naar gesorteerde lijst met mooie namen
result = []
for key, e in by_name.items():
best_name = Counter(e["names"]).most_common(1)[0][0]
best_domain = Counter(e["domains"]).most_common(1)[0][0]
best_folder = Counter(e["folders"]).most_common(1)[0][0]
ts_sorted = sorted(e["timestamps"])
result.append({
"key": key,
"name": best_name,
"domain": best_domain,
"folder": best_folder,
"count": e["count"],
"oldest": ts_sorted[0] if ts_sorted else None,
"newest": ts_sorted[-1] if ts_sorted else None,
"subjects": e["subjects"],
"unsubscribe": e["unsubscribe"],
"domains": list(set(e["domains"])),
})
result.sort(key=lambda x: -x["count"])
result = [e for e in result if e["count"] >= MIN_COUNT]
CACHE_FILE.write_text(
json.dumps(result, indent=2, ensure_ascii=False), encoding="utf-8"
)
print(f"{len(result)} unieke mailinglijsten opgeslagen in cache.\n")
return result
def load_cache() -> list[dict]:
return json.loads(CACHE_FILE.read_text(encoding="utf-8"))
# ── beslissingen ─────────────────────────────────────────────────────────────
def load_decisions() -> dict:
if not DECISIONS_FILE.exists():
return {}
try:
data = json.loads(DECISIONS_FILE.read_text(encoding="utf-8"))
return {k: (v["keuze"] if isinstance(v, dict) else v) for k, v in data.items()}
except Exception:
return {}
def save_decisions(decisions: dict, lists: list[dict]):
by_key = {e["key"]: e for e in lists}
output = {}
for key, keuze in decisions.items():
e = by_key.get(key, {})
output[key] = {
"keuze": keuze,
"naam": e.get("name", key),
"count": e.get("count", 0),
"domains": e.get("domains", []),
"unsubscribe": e.get("unsubscribe", []),
}
DECISIONS_FILE.write_text(
json.dumps(output, indent=2, ensure_ascii=False), encoding="utf-8"
)
# ── interactieve review ───────────────────────────────────────────────────────
KEYS = {"h", "a", "s", "q"}
LABELS = {
"h": ("houden", "\033[32m"),
"a": ("afmelden", "\033[31m"),
"s": ("overslaan", "\033[33m"),
}
def run_review(lists: list[dict], decisions: dict):
total = len(lists)
remaining = [e for e in lists if e["key"] not in decisions]
print(f"{''*64}")
print(f" Totaal: {total} | Al beoordeeld: {len(decisions)} | Te gaan: {len(remaining)}")
print(f" \033[32mh\033[0m houden \033[31ma\033[0m afmelden \033[33ms\033[0m overslaan \033[90mq\033[0m stoppen")
print(f"{''*64}\n")
done = sum(1 for e in lists if e["key"] in decisions)
for entry in remaining:
unsub = "✓ beschikbaar" if entry["unsubscribe"] else "✗ handmatig opzoeken"
domains_str = ", ".join(entry["domains"][:3])
if len(entry["domains"]) > 3:
domains_str += f" (+{len(entry['domains'])-3})"
print(f"\033[1m[{done+1}/{total}] {entry['name']}\033[0m")
print(f" \033[90mmails :\033[0m {entry['count']} "
f"({fmt_date(entry['oldest'])} {fmt_date(entry['newest'])})")
print(f" \033[90mdomein :\033[0m {domains_str}")
print(f" \033[90mmap :\033[0m {entry['folder']}")
print(f" \033[90mafmeld :\033[0m {unsub}")
if entry["subjects"]:
print(f" \033[90mvoorbeeld:\033[0m {entry['subjects'][0][:68]}")
print()
print(" > ", end="", flush=True)
ch = ""
while ch not in KEYS:
ch = getch().lower()
if ch == "q":
print("\n\nGestopt. Voortgang opgeslagen.\n")
save_decisions(decisions, lists)
print_summary(decisions)
return
label, color = LABELS[ch]
print(f"{color}{label}\033[0m\n")
decisions[entry["key"]] = ch
done += 1
save_decisions(decisions, lists)
print(f"{''*64}")
print(f"Klaar! Alle {total} lijsten beoordeeld.")
save_decisions(decisions, lists)
print_summary(decisions)
def print_summary(decisions: dict):
a = sum(1 for c in decisions.values() if c == "a")
h = sum(1 for c in decisions.values() if c == "h")
s = sum(1 for c in decisions.values() if c == "s")
print(f"\n Afmelden : {a}")
print(f" Houden : {h}")
print(f" Overgeslagen: {s}")
print(f"\nOpgeslagen in: {DECISIONS_FILE}")
if a:
print("Draai daarna: python3 unsubscribe.py")
# ── main ──────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
if CACHE_FILE.exists():
print(f"Cache gevonden, laden...\n")
lists = load_cache()
else:
lists = build_cache()
decisions = load_decisions()
if decisions:
active_keys = {e["key"] for e in lists}
active = {k: v for k, v in decisions.items() if k in active_keys}
a = sum(1 for c in active.values() if c == "a")
h = sum(1 for c in active.values() if c == "h")
s = sum(1 for c in active.values() if c == "s")
stale = len(decisions) - len(active)
msg = f"Vorige sessie: {a} afmelden, {h} houden, {s} overgeslagen"
if stale:
msg += f" ({stale} verouderde beslissingen genegeerd)"
print(msg + ".\n")
run_review(lists, decisions)
+14
View File
@@ -0,0 +1,14 @@
Eenmalige setup (credentials opslaan):
python3 dagelijks_overzicht.py --setup
Testen met gisteren:
python3 dagelijks_overzicht.py
Testen met een specifieke datum:
python3 dagelijks_overzicht.py --datum 2026-07-01
Installeren als dagelijkse taak (07:00):
cp dagelijks_overzicht.plist ~/Library/LaunchAgents/
launchctl load ~/Library/LaunchAgents/dagelijks_overzicht.plist
Het overzicht komt binnen met onderwerp "Mailbak 3 juli 2026" — een HTML-mail met per gesorteerde map de berichten (tijdstip · afzender · onderwerp). Archief- en Technisch-mappen zijn uitgesloten.
+217
View File
@@ -0,0 +1,217 @@
#!/usr/bin/env python3
"""
Afmelden van mailinglijsten op basis van decisions.json.
Verwerkt List-Unsubscribe headers: HTTP (GET/POST) en mailto.
Gebruik: python3 unsubscribe.py
Log: unsubscribe_log.json (resultaat per lijst)
"""
import json
import re
import sys
import time
import smtplib
import ssl
import urllib.request
import urllib.parse
import urllib.error
import config
from email.mime.text import MIMEText
from pathlib import Path
DECISIONS_FILE = Path(__file__).parent / "decisions.json"
LOG_FILE = Path(__file__).parent / "unsubscribe_log.json"
REQUEST_TIMEOUT = 10 # seconden per HTTP-verzoek
DELAY_BETWEEN = 1.5 # seconden tussen verzoeken (beleefd)
# ── helpers ──────────────────────────────────────────────────────────────────
def parse_unsub_header(raw: str) -> dict[str, list[str]]:
"""
Parseer List-Unsubscribe header naar {'http': [...], 'mailto': [...]}.
Formaat: <url>, <url>, ... (RFC 2369)
"""
result = {"http": [], "mailto": []}
for match in re.finditer(r"<([^>]+)>", raw):
url = match.group(1).strip()
if url.startswith("http"):
result["http"].append(url)
elif url.startswith("mailto:"):
result["mailto"].append(url)
return result
def try_http_unsub(url: str) -> tuple[bool, str]:
"""Probeer afmelden via HTTP GET. Geeft (succes, melding)."""
try:
req = urllib.request.Request(
url,
headers={"User-Agent": "Mozilla/5.0 (compatible; list-unsubscribe)"},
)
with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT) as resp:
code = resp.getcode()
return (code == 200), f"HTTP {code}"
except urllib.error.HTTPError as e:
return False, f"HTTP {e.code}"
except Exception as e:
return False, str(e)
def try_mailto_unsub(mailto: str, smtp: smtplib.SMTP, from_addr: str) -> tuple[bool, str]:
"""Stuur een lege e-mail naar het afmeldadres."""
try:
parsed = urllib.parse.urlparse(mailto)
to_addr = parsed.path
params = urllib.parse.parse_qs(parsed.query)
subject = params.get("subject", ["unsubscribe"])[0]
msg = MIMEText("")
msg["From"] = from_addr
msg["To"] = to_addr
msg["Subject"] = subject
smtp.sendmail(from_addr, [to_addr], msg.as_string())
return True, f"mailto verzonden → {to_addr}"
except Exception as e:
return False, str(e)
# ── main ──────────────────────────────────────────────────────────────────────
def run():
if not DECISIONS_FILE.exists():
print("decisions.json niet gevonden. Draai eerst review_mailinglists.py.")
sys.exit(1)
decisions = json.loads(DECISIONS_FILE.read_text(encoding="utf-8"))
te_afmelden = {
domain: info
for domain, info in decisions.items()
if isinstance(info, dict) and info.get("keuze") == "a"
and info.get("unsubscribe")
}
handmatig = {
domain: info
for domain, info in decisions.items()
if isinstance(info, dict) and info.get("keuze") == "a"
and not info.get("unsubscribe")
}
print(f"Afmelden: {len(te_afmelden)} via List-Unsubscribe, "
f"{len(handmatig)} handmatig (geen header).\n")
# Laad bestaand log (herstart-safe)
log: dict[str, dict] = {}
if LOG_FILE.exists():
try:
log = json.loads(LOG_FILE.read_text(encoding="utf-8"))
al_klaar = sum(1 for v in log.values() if v.get("succes"))
print(f"Bestaand log geladen: {al_klaar} al verwerkt.\n")
except Exception:
pass
cfg = config.load()
# SMTP verbinding voor mailto-afmeldingen
smtp = None
heeft_mailto = any(
any(u.startswith("mailto:") for u in info.get("unsubscribe", []))
for info in te_afmelden.values()
)
if heeft_mailto:
print(f"Sommige afmeldingen gaan via e-mail (mailto:). Account: {cfg.username}")
try:
context = ssl.create_default_context()
smtp = smtplib.SMTP(cfg.smtp_host, cfg.smtp_port)
smtp.starttls(context=context)
smtp.login(cfg.username, cfg.password)
print("SMTP verbonden.\n")
except Exception as e:
print(f"SMTP mislukt: {e}\n mailto-afmeldingen worden overgeslagen.")
smtp = None
# Verwerk afmeldingen
total = len(te_afmelden)
for idx, (domain, info) in enumerate(te_afmelden.items(), 1):
naam = info.get("naam", domain)
if domain in log and log[domain].get("succes"):
print(f"[{idx}/{total}] {naam} — al afgemeld, overgeslagen.")
continue
print(f"[{idx}/{total}] {naam} ({domain})")
succes = False
methode = ""
melding = ""
for raw_unsub in info.get("unsubscribe", []):
parsed = parse_unsub_header(raw_unsub)
# Probeer HTTP eerst
for url in parsed["http"]:
ok, msg = try_http_unsub(url)
if ok:
succes = True
methode = "HTTP GET"
melding = f"{msg}{url[:80]}"
break
else:
melding = f"{msg}{url[:60]}"
if succes:
break
# Dan mailto
if smtp:
for mailto in parsed["mailto"]:
ok, msg = try_mailto_unsub(mailto, smtp, cfg.username)
if ok:
succes = True
methode = "mailto"
melding = msg
break
if succes:
break
status = "" if succes else ""
print(f" {status} {methode or 'mislukt'} {melding}")
log[domain] = {
"naam": naam,
"succes": succes,
"methode": methode,
"melding": melding,
}
LOG_FILE.write_text(json.dumps(log, indent=2, ensure_ascii=False), encoding="utf-8")
time.sleep(DELAY_BETWEEN)
if smtp:
smtp.quit()
# Samenvatting
gelukt = sum(1 for v in log.values() if v.get("succes"))
mislukt = sum(1 for v in log.values() if not v.get("succes"))
print(f"\n{''*50}")
print(f"Klaar. {gelukt} afgemeld, {mislukt} mislukt.")
if mislukt:
print("\nMislukt (handmatig afmelden):")
for domain, v in log.items():
if not v.get("succes"):
print(f" {v['naam']} ({domain})")
if handmatig:
print(f"\nGeen List-Unsubscribe header gevonden ({len(handmatig)}) — handmatig afmelden:")
for domain, info in handmatig.items():
print(f" {info.get('naam', domain)} ({domain})")
print(f"\nLog opgeslagen in: {LOG_FILE}")
if __name__ == "__main__":
run()
+190
View File
@@ -0,0 +1,190 @@
#!/usr/bin/env python3
"""
Upload en activeer de Sieve-mailrule op australius.nl via ManageSieve (RFC 5804).
Poort: 4190 met STARTTLS.
Gebruik: python3 upload_sieve.py
"""
import socket
import ssl
import sys
import base64
import config
from pathlib import Path
SCRIPT_NAME = "mailrules"
SCRIPT_FILE = Path(__file__).parent / "mailrules.sieve"
# Als fileinto-paden "INBOX." als prefix nodig hebben op deze server:
# Zet SIEVE_PREFIX op "INBOX." en het script past de paden automatisch aan.
# Zet op "" als de server paden zonder prefix accepteert (meest gangbaar).
SIEVE_PREFIX = ""
class ManageSieveClient:
"""Minimale ManageSieve-client (RFC 5804)."""
def __init__(self, host: str, port: int):
self._sock = None
self._host = host
self._port = port
def connect(self):
raw = socket.create_connection((self._host, self._port), timeout=15)
self._sock = raw
greeting = self._readline()
if not greeting.startswith("OK"):
raise ConnectionError(f"Onverwachte greeting: {greeting!r}")
# Lees eventuele capability-regels na de greeting
self._read_capabilities()
def starttls(self):
self._send("STARTTLS")
resp = self._readline()
if not resp.startswith("OK"):
raise ConnectionError(f"STARTTLS geweigerd: {resp!r}")
context = ssl.create_default_context()
self._sock = context.wrap_socket(self._sock, server_hostname=self._host)
# Nieuwe capabilities na TLS
self._read_capabilities()
def authenticate(self, username: str, password: str):
creds = base64.b64encode(f"\x00{username}\x00{password}".encode()).decode()
self._send(f'AUTHENTICATE "PLAIN" "{creds}"')
resp = self._readline()
if not resp.startswith("OK"):
raise PermissionError(f"Authenticatie mislukt: {resp!r}")
def put_script(self, name: str, content: str):
encoded = content.encode("utf-8")
self._send(f'PUTSCRIPT "{name}" {{{len(encoded)}+}}')
self._sock.sendall(encoded)
resp = self._readline()
if not resp.startswith("OK"):
raise RuntimeError(f"PUTSCRIPT mislukt: {resp!r}")
def set_active(self, name: str):
self._send(f'SETACTIVE "{name}"')
resp = self._readline()
if not resp.startswith("OK"):
raise RuntimeError(f"SETACTIVE mislukt: {resp!r}")
def list_scripts(self) -> list[str]:
self._send("LISTSCRIPTS")
scripts = []
while True:
line = self._readline()
if line.startswith("OK"):
break
scripts.append(line)
return scripts
def logout(self):
try:
self._send("LOGOUT")
except Exception:
pass
try:
self._sock.close()
except Exception:
pass
def _send(self, cmd: str):
self._sock.sendall((cmd + "\r\n").encode())
def _readline(self) -> str:
buf = b""
while not buf.endswith(b"\n"):
chunk = self._sock.recv(1)
if not chunk:
break
buf += chunk
return buf.decode("utf-8", errors="replace").strip()
def _read_capabilities(self):
"""Lees en negeer capability-regels tot een lege regel of einde."""
# Capabilities eindigen met een lege regel of worden gevolgd door authenticatie
# We lezen tot we iets tegenkomen dat niet op een capability lijkt
# (dit is een vereenvoudiging; volledige RFC 5804 parsing is complexer)
pass
def apply_prefix(script: str, prefix: str) -> str:
"""Voeg SIEVE_PREFIX toe aan alle fileinto-paden als dat nodig is."""
if not prefix:
return script
import re
def add_prefix(m):
path = m.group(1)
if path.startswith(prefix):
return m.group(0) # al aanwezig
return f'fileinto "{prefix}{path}"'
return re.sub(r'fileinto "([^"]+)"', add_prefix, script)
def run():
if not SCRIPT_FILE.exists():
print(f"Script niet gevonden: {SCRIPT_FILE}")
sys.exit(1)
script_content = SCRIPT_FILE.read_text(encoding="utf-8")
script_content = apply_prefix(script_content, SIEVE_PREFIX)
cfg = config.load()
print(f"Script: {SCRIPT_FILE.name} ({len(script_content)} bytes)")
print(f"Server: {cfg.sieve_host}:{cfg.sieve_port}")
print(f"Account:{cfg.username}")
print(f"Naam: {SCRIPT_NAME}\n")
client = ManageSieveClient(cfg.sieve_host, cfg.sieve_port)
print("Verbinden ...")
try:
client.connect()
print(" STARTTLS ...")
client.starttls()
print(" Authenticeren ...")
client.authenticate(cfg.username, cfg.password)
except Exception as e:
print(f"Verbinding mislukt: {e}")
sys.exit(1)
# Toon bestaande scripts
scripts = client.list_scripts()
if scripts:
print(f"\nBestaande scripts op server:")
for s in scripts:
print(f" {s}")
else:
print("\nGeen bestaande scripts.")
# Bevestiging vragen
print(f"\nScript '{SCRIPT_NAME}' wordt geüpload en geactiveerd.")
bevestig = input("Typ JA om door te gaan: ").strip()
if bevestig != "JA":
print("Afgebroken.")
client.logout()
sys.exit(0)
print("\nUploaden ...")
try:
client.put_script(SCRIPT_NAME, script_content)
print(f"{SCRIPT_NAME} geüpload")
client.set_active(SCRIPT_NAME)
print(f"{SCRIPT_NAME} geactiveerd")
except Exception as e:
print(f" ✗ Mislukt: {e}")
client.logout()
sys.exit(1)
client.logout()
print("\nKlaar. Nieuwe inkomende mail wordt nu automatisch gesorteerd.")
print(f"\nAls mail niet aankomt in de verwachte mappen:")
print(f" 1. Controleer of alle mappen bestaan (maak_mappen.py)")
print(f" 2. Probeer SIEVE_PREFIX = \"INBOX.\" in dit script")
print(f" 3. Controleer server-logs via webhosting-paneel")
if __name__ == "__main__":
run()
+481
View File
@@ -0,0 +1,481 @@
#!/usr/bin/env python3
"""
Herindeling van bestaande mail naar de nieuwe mappenstructuur.
Stap 1 Vaste mapverplaatsingen (hele mappen in één keer):
Mailinglists/*, Kennis/Coursera, Archief/*/inkomend, enz.
Stap 2 Per-bericht routing (INBOX + overige mappen):
Op basis van afzenderdomein en onderwerpkeywords.
Stap 3 Facturen per kwartaal sorteren (Facturen - te verwerken / verwerkt).
Standaard: DRY RUN (laat zien wat er zou gebeuren, verplaatst niets).
Echt uitvoeren: python3 verplaats_bestaand.py --uitvoeren
Log: verplaats_log.json
"""
import imaplib
import base64
import email
import email.header
import email.utils
import re
import sys
import json
import config
from pathlib import Path
from datetime import datetime, timezone
PREFIX = "INBOX."
def to_mutf7(s: str) -> str:
"""Codeer mapnaam naar Modified UTF-7 voor IMAP-aanroepen (RFC 3501)."""
res = []
buf = []
for c in s:
if 0x20 <= ord(c) <= 0x7e and c != "&":
if buf:
b64 = (
base64.b64encode("".join(buf).encode("utf-16-be"))
.decode("ascii").rstrip("=").replace("/", ",")
)
res.append(f"&{b64}-")
buf.clear()
res.append(c)
elif c == "&":
if buf:
b64 = (
base64.b64encode("".join(buf).encode("utf-16-be"))
.decode("ascii").rstrip("=").replace("/", ",")
)
res.append(f"&{b64}-")
buf.clear()
res.append("&-")
else:
buf.append(c)
if buf:
b64 = (
base64.b64encode("".join(buf).encode("utf-16-be"))
.decode("ascii").rstrip("=").replace("/", ",")
)
res.append(f"&{b64}-")
return "".join(res)
LOG_FILE = Path(__file__).parent / "verplaats_log.json"
DRY_RUN = "--uitvoeren" not in sys.argv
# ── routeringstabel ──────────────────────────────────────────────────────────
# (domain, doelmap) — zelfde logica als mailrules.sieve
# Facturen worden apart behandeld (kwartaalsortering).
DOMAIN_ROUTES: list[tuple[list[str], str]] = [
# Bank
(["nl.abnamro.com", "abnamro.nl"], "Financieel.Bank.ABN AMRO"),
(["icscards.nl", "icsmarketing.icscards.nl",
"service.icscards.nl"], "Financieel.Bank.ICS"),
(["npex.nl"], "Financieel.Bank.NPEX"),
(["snelstart.nl"], "Financieel.Bank.SnelStart"),
(["tellow.nl"], "Financieel.Bank.Tellow"),
# Verzekering
(["unive.nl", "nieuwsbrief.unive.nl"], "Financieel.Verzekering.Univé"),
(["heinenoord.nl"], "Financieel.Verzekering.VEZA"),
# Werk
(["flextender.nl"], "Werk.Opdrachten.Flextender"),
(["onestopsourcing.nl"], "Werk.Opdrachten.OneStopSourcing"),
(["immens.nu"], "Werk.Opdrachten.IMMENS"),
(["circle8.nl"], "Werk.Opdrachten.Circle8"),
(["getthere.nl"], "Werk.Opdrachten.Get There"),
(["huckr.ai"], "Werk.Opdrachten.Huckr"),
(["hey.meetjamie.ai"], "Werk.Opdrachten.Jamie"),
(["futurexl.nl"], "Werk.Opdrachten.FutureXL"),
(["boip.int"], "Werk.Overig.BOIP"),
(["groningen.nl", "groningenbereikbaar.nl"], "Werk.Overig.Gemeente Groningen"),
# Mobiliteit EV
(["eneco-emobility.com", "eneco.nl"], "Mobiliteit.EV.Eneco eMobility"),
(["shellrecharge.com"], "Mobiliteit.EV.Shell Recharge"),
(["fastned.nl"], "Mobiliteit.EV.FastNed"),
(["opngo.com"], "Mobiliteit.EV.OPnGO"),
(["tesla.com"], "Mobiliteit.EV.Tesla"),
(["mmsn.nl"], "Mobiliteit.EV.Mitsubishi"),
(["alfen.com"], "Mobiliteit.EV.Alfen"),
# Mobiliteit OV
(["ns.nl", "email.ns.nl"], "Mobiliteit.OV & Reizen.NS"),
(["booking.com"], "Mobiliteit.OV & Reizen.Booking"),
(["flitsmeister.nl"], "Mobiliteit.OV & Reizen.Flitsmeister"),
# Bestellingen
(["postnl.nl", "edm.postnl.nl", "notificatie.postnl.nl"], "Bestellingen.PostNL"),
(["dhlparcel.nl", "dhlecommerce.nl"], "Bestellingen.DHL"),
(["gls-netherlands.com"], "Bestellingen.GLS"),
(["bol.com", "feedback.bol.com"], "Bestellingen.bol"),
(["coolblue.nl", "coolblue.eu"], "Bestellingen.Coolblue"),
(["allekabels.nl"], "Bestellingen.Allekabels"),
(["vikingdirect.nl", "delivery.vikingdirect.nl",
"online.vikingdirect.nl"], "Bestellingen.Viking"),
(["123inkt.nl", "m.123inkt.nl"], "Bestellingen.123inkt"),
(["officecentre.nl"], "Bestellingen.Office Centre"),
(["sligro.nl"], "Bestellingen.Sligro"),
(["makro.nl"], "Bestellingen.Makro"),
(["smartphonehoesjes.nl"], "Bestellingen.Smartphonehoesjes"),
# Diensten Hosting
(["strato.com", "news.strato.com",
"marketingradar.strato.com"], "Diensten.Hosting.STRATO"),
(["transip.nl"], "Diensten.Hosting.TransIP"),
(["letsencrypt.org"], "Diensten.Hosting.Lets Encrypt"),
(["backblaze.com"], "Diensten.Hosting.Backblaze"),
# Diensten Telecom
(["kpn.com", "campagnes.kpn.com"], "Diensten.Telecom.KPN"),
(["zakelijk.vodafone.nl", "vodafone.nl"], "Diensten.Telecom.Vodafone"),
# Diensten Software
(["cursor.com"], "Diensten.Software.Cursor"),
(["1password.com"], "Diensten.Software.1Password"),
(["home.sophos.com", "cleverbridge.com"], "Diensten.Software.Sophos"),
(["cogsciapps.com"], "Diensten.Software.CogSci Apps"),
(["claude.com"], "Diensten.Software.Claude"),
(["microsoft365.com"], "Diensten.Software.Microsoft Teams"),
(["getmatter.com"], "Diensten.Software.Matter"),
(["mindnode.com"], "Diensten.Software.MindNode"),
(["papersapp.com"], "Diensten.Software.Papers"),
(["team.kahoot.com"], "Diensten.Software.Kahoot"),
(["perplexity.ai"], "Diensten.Software.Perplexity"),
# Nieuwsbrieven Leren
(["coursera.org", "email.coursera.org",
"m.mail.coursera.org", "m.learn.coursera.org"], "Nieuwsbrieven.Leren.Coursera"),
(["technologyreview.com",
"fulfillment.technologyreview.com"], "Nieuwsbrieven.Leren.MIT Technology Review"),
(["mit.edu"], "Nieuwsbrieven.Leren.MIT Open Learning"),
(["storytellingwithdata.com"], "Nieuwsbrieven.Leren.Storytelling with Data"),
(["linkingyourthinking.com"], "Nieuwsbrieven.Leren.Nick Milo"),
(["evakeiffenheim.com"], "Nieuwsbrieven.Leren.Eva Keiffenheim"),
(["tiggelaar.nl"], "Nieuwsbrieven.Leren.Ben Tiggelaar"),
(["briankeating.com"], "Nieuwsbrieven.Leren.Brian Keating"),
(["beehiiv.com", "mail.beehiiv.com"], "Nieuwsbrieven.Leren.AI Report"),
(["elizabethbutlermd.com"], "Nieuwsbrieven.Leren.Elizabeth Butler"),
# Nieuwsbrieven Nieuws & Vakbladen
(["crisismanager.nl"], "Nieuwsbrieven.Nieuws & Vakbladen.Vakblad Crisismanager"),
(["jaarbeurs.nl"], "Nieuwsbrieven.Nieuws & Vakbladen.Computable"),
(["presentation-guru.com"], "Nieuwsbrieven.Nieuws & Vakbladen.The Presentation Guru"),
# Nieuwsbrieven Overig
(["lendahand.com"], "Nieuwsbrieven.Overig.Lendahand"),
(["thegoodroll.nl"], "Nieuwsbrieven.Overig.The Good Roll"),
# Technisch
(["dmarc.postmarkapp.com"], "Technisch.DMARC"),
]
# Bouw snelle lookup: domain → doelmap
DOMAIN_LOOKUP: dict[str, str] = {}
for domains, target in DOMAIN_ROUTES:
for d in domains:
DOMAIN_LOOKUP[d.lower()] = target
FACTUUR_KEYWORDS = [
"factuur", "invoice", "rekening", "nota", "betaalverzoek",
"receipt", "orderbevestiging", "order confirmation",
"betaling ontvangen", "payment received",
"uw factuur", "your invoice", "je factuur",
]
# Mappen die in één keer worden hernoemd/verplaatst (oud → nieuw)
MAP_RENAMES: list[tuple[str, str]] = [
("INBOX.Mailinglists.Ben Tiggelaar", "INBOX.Nieuwsbrieven.Leren.Ben Tiggelaar"),
("INBOX.Mailinglists.Brian Keating", "INBOX.Nieuwsbrieven.Leren.Brian Keating"),
("INBOX.Mailinglists.Eva Keiffenheim", "INBOX.Nieuwsbrieven.Leren.Eva Keiffenheim"),
("INBOX.Mailinglists.Lendahand", "INBOX.Nieuwsbrieven.Overig.Lendahand"),
("INBOX.Mailinglists.LYT - Nick Milo", "INBOX.Nieuwsbrieven.Leren.Nick Milo"),
("INBOX.Mailinglists.MIT Technology Review", "INBOX.Nieuwsbrieven.Leren.MIT Technology Review"),
("INBOX.Mailinglists.Storytelling with Data", "INBOX.Nieuwsbrieven.Leren.Storytelling with Data"),
("INBOX.Kennis.Coursera", "INBOX.Nieuwsbrieven.Leren.Coursera"),
# Archief: verwijder inkomend/verzonden laag
("INBOX.Archief.2020.inkomend", "INBOX.Archief.2020"),
("INBOX.Archief.2021.inkomend", "INBOX.Archief.2021"),
("INBOX.Archief.2022.inkomend", "INBOX.Archief.2022"),
# Verzonden archief ook samenvoegen
("INBOX.Archief.2020.verzonden", "INBOX.Archief.2020"),
("INBOX.Archief.2021.verzonden", "INBOX.Archief.2021"),
("INBOX.Archief.2022.verzonden", "INBOX.Archief.2022"),
# Facturen omzetten (per-kwartaal via stap 3)
("INBOX.Facturen - te verwerken", "__FACTUREN__"),
("INBOX.Facturen - verwerkt", "__FACTUREN__"),
]
# Mappen waarvan de inhoud per bericht gerouteerd moet worden
ROUTE_FOLDERS = ["INBOX"]
# ── 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 sender_domain(from_addr: str) -> str:
m = re.search(r"@([\w.\-]+)", from_addr)
return m.group(1).lower() if m else ""
def quarter_folder(date_str: str) -> str:
"""Geef de doelmap terug op basis van de e-maildatum."""
try:
dt = email.utils.parsedate_to_datetime(date_str)
jaar = dt.year
maand = dt.month
kwartaal = (maand - 1) // 3 + 1
return f"Financieel.Facturen.{jaar}.Q{kwartaal}"
except Exception:
jaar = datetime.now().year
return f"Financieel.Facturen.{jaar}.Q1"
def route_from_subject(from_addr: str, subject: str) -> str | None:
"""Bepaal doelmap op basis van afzender en onderwerp. None = geen match."""
domain = sender_domain(from_addr)
# Facturen hebben prioriteit
subj_lo = subject.lower()
if any(kw in subj_lo for kw in FACTUUR_KEYWORDS):
return "__FACTUUR_DATE__" # behandeld apart (datum nodig)
return DOMAIN_LOOKUP.get(domain)
def move_message(mail, uid: bytes, src: str, dst_full: str, dry: bool) -> bool:
"""Kopieer bericht naar dst en markeer als verwijderd in src."""
if dry:
return True
try:
mail.select(f'"{to_mutf7(src)}"')
res, _ = mail.uid("COPY", uid, f'"{to_mutf7(dst_full)}"')
if res == "OK":
mail.uid("STORE", uid, "+FLAGS", "\\Deleted")
return res == "OK"
except Exception as e:
print(f" FOUT bij verplaatsen: {e}")
return False
# ── stap 1: vaste mapverplaatsingen ─────────────────────────────────────────
def stap1_mapverplaatsingen(mail, log: dict, dry: bool):
print("\n── Stap 1: vaste mapverplaatsingen ──")
for oud, nieuw in MAP_RENAMES:
if nieuw == "__FACTUREN__":
continue # wordt in stap 3 verwerkt
if oud in log.get("stap1_klaar", []):
print(f" ✓ al gedaan: {oud}")
continue
# Controleer of bronmap bestaat
status, data = mail.select(f'"{to_mutf7(oud)}"', readonly=True)
if status != "OK":
print(f" bestaat niet: {oud}")
continue
n = int(data[0])
nieuw_full = nieuw if nieuw.startswith("INBOX.") else PREFIX + nieuw
if dry:
print(f" [DRY] {oud}{nieuw} ({n} berichten)")
continue
# Berichten kopiëren
status, data = mail.search(None, "ALL")
ids = data[0].split()
gekopieerd = 0
for uid in ids:
res, _ = mail.copy(uid.decode(), f'"{to_mutf7(nieuw_full)}"')
if res == "OK":
mail.store(uid.decode(), "+FLAGS", "\\Deleted")
gekopieerd += 1
mail.expunge()
print(f"{oud}{nieuw} ({gekopieerd}/{n})")
log.setdefault("stap1_klaar", []).append(oud)
_save_log(log)
# ── stap 2: per-bericht routing (INBOX) ─────────────────────────────────────
def stap2_inbox_routing(mail, log: dict, dry: bool):
print("\n── Stap 2: INBOX per-bericht routing ──")
status, data = mail.select('"INBOX"', readonly=dry)
if status != "OK":
print(" Kan INBOX niet openen.")
return
n = int(data[0])
print(f" {n} berichten in INBOX")
# UID-gebaseerde search: stabiel ook na expunge
status, data = mail.uid("SEARCH", "ALL")
if status != "OK" or not data or not data[0]:
print(" Geen berichten gevonden (search mislukt of leeg).")
return
ids = [uid for uid in data[0].split() if uid]
verplaatst = 0
geen_match = 0
al_verwerkt = set(log.get("stap2_verwerkt", []))
for uid in ids:
uid_str = uid.decode()
if uid_str in al_verwerkt:
continue
# Haal alleen headers op via UID
status, data = mail.uid("FETCH", uid, "(BODY.PEEK[HEADER.FIELDS (FROM SUBJECT DATE)])")
if status != "OK" or not data or not data[0]:
continue
msg = email.message_from_bytes(data[0][1])
from_raw = decode_hdr(msg.get("From", ""))
subject = decode_hdr(msg.get("Subject", ""))
date_str = msg.get("Date", "")
doel = route_from_subject(from_raw, subject)
if doel == "__FACTUUR_DATE__":
doel = PREFIX + quarter_folder(date_str)
elif doel:
doel = PREFIX + doel
else:
geen_match += 1
continue
if dry:
print(f" [DRY] → {doel} | {subject[:60]}")
else:
ok = move_message(mail, uid, "INBOX", doel, dry=False)
if ok:
verplaatst += 1
log.setdefault("stap2_verwerkt", []).append(uid_str)
if verplaatst % 50 == 0:
mail.expunge()
_save_log(log)
if not dry:
mail.expunge()
_save_log(log)
print(f" Verplaatst: {verplaatst} | Geen match (blijft in INBOX): {geen_match}")
# ── stap 3: facturen per kwartaal ────────────────────────────────────────────
def stap3_facturen(mail, log: dict, dry: bool):
print("\n── Stap 3: facturen per kwartaal ──")
bron_mappen = [
"INBOX.Facturen - te verwerken",
"INBOX.Facturen - verwerkt",
"INBOX.Financieel.Facturen", # als ze er al in zitten
]
for bron in bron_mappen:
status, data = mail.select(f'"{to_mutf7(bron)}"', readonly=dry)
if status != "OK":
continue
n = int(data[0])
if n == 0:
print(f" {bron}: leeg")
continue
print(f" {bron}: {n} berichten")
status, data = mail.uid("SEARCH", "ALL")
if status != "OK" or not data or not data[0]:
continue
ids = [uid for uid in data[0].split() if uid]
verplaatst = 0
for uid in ids:
status, data = mail.uid("FETCH", uid, "(BODY.PEEK[HEADER.FIELDS (DATE SUBJECT)])")
if status != "OK" or not data or not data[0]:
continue
msg = email.message_from_bytes(data[0][1])
date_str = msg.get("Date", "")
doel = PREFIX + quarter_folder(date_str)
if dry:
dt_kort = date_str[:16] if date_str else "?"
print(f" [DRY] {dt_kort}{doel}")
else:
ok = move_message(mail, uid, bron, doel, dry=False)
if ok:
verplaatst += 1
if not dry:
mail.expunge()
print(f" {verplaatst}/{n} verplaatst")
# ── log ──────────────────────────────────────────────────────────────────────
def _save_log(log: dict):
LOG_FILE.write_text(json.dumps(log, indent=2, ensure_ascii=False), encoding="utf-8")
def load_log() -> dict:
if LOG_FILE.exists():
try:
return json.loads(LOG_FILE.read_text(encoding="utf-8"))
except Exception:
pass
return {}
# ── main ──────────────────────────────────────────────────────────────────────
def run():
if DRY_RUN:
print("DRY RUN — er wordt niets verplaatst.")
print("Voeg --uitvoeren toe om echt uit te voeren.\n")
else:
print("LET OP: dit verplaatst berichten op de echte server.")
bevestig = input("Typ JA om door te gaan: ").strip()
if bevestig != "JA":
print("Afgebroken.")
sys.exit(0)
cfg = config.load()
print(f"Account: {cfg.username}\n")
print(f"Verbinden met {cfg.imap_host}:{cfg.imap_port} ...")
try:
mail = imaplib.IMAP4_SSL(cfg.imap_host, cfg.imap_port)
mail.login(cfg.username, cfg.password)
except Exception as e:
print(f"Verbinding mislukt: {e}")
sys.exit(1)
log = load_log()
stap1_mapverplaatsingen(mail, log, DRY_RUN)
stap2_inbox_routing(mail, log, DRY_RUN)
stap3_facturen(mail, log, DRY_RUN)
mail.logout()
print("\nKlaar.")
if DRY_RUN:
print("Draai met --uitvoeren om de wijzigingen door te voeren.")
if __name__ == "__main__":
run()
+3186
View File
File diff suppressed because it is too large Load Diff