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:
@@ -0,0 +1,5 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.DS_Store
|
||||
config.json
|
||||
**/*.eml
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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>")
|
||||
@@ -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>
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+6240
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user