99cd891b1e
The prompts used to arrive at this change since the previous commit User asked whether daily report emails could include links, then chose Apple Mail links as the first implementation. Any special observations that may be relevant for version management for this version. Be brief. Apple Mail links use Message-ID-based message:// URLs and depend on macOS Mail having the messages synced/indexed locally. verplaats_log.json and plan.md were left unstaged.
301 lines
11 KiB
Python
301 lines
11 KiB
Python
#!/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] [--mail-to adres]
|
||
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 html as html_lib
|
||
import urllib.parse
|
||
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
|
||
from imap_utils import list_folders, quote_mailbox
|
||
from mail_imap_ops import fetch_body
|
||
|
||
# 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
|
||
}
|
||
|
||
|
||
def arg_value(args: list[str], flag: str) -> str | None:
|
||
"""Return the value after a simple command-line flag."""
|
||
if flag not in args:
|
||
return None
|
||
idx = args.index(flag)
|
||
if idx + 1 >= len(args):
|
||
print(f"Ontbrekende waarde voor {flag}")
|
||
sys.exit(2)
|
||
return args[idx + 1]
|
||
|
||
|
||
# ── 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 apple_mail_url(message_id: str) -> str:
|
||
"""Return an Apple Mail message:// URL for a Message-ID header."""
|
||
clean = (message_id or "").strip()
|
||
if not clean:
|
||
return ""
|
||
if not (clean.startswith("<") and clean.endswith(">")):
|
||
clean = f"<{clean.strip('<>')}>"
|
||
return "message://" + urllib.parse.quote(clean, safe="")
|
||
|
||
|
||
# ── 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))
|
||
|
||
alle_mappen = list_folders(mail)
|
||
if not alle_mappen:
|
||
return {}
|
||
|
||
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(quote_mailbox(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 MESSAGE-ID)])"
|
||
)
|
||
raw_header = fetch_body(msg_data)
|
||
if status != "OK" or raw_header is None:
|
||
continue
|
||
|
||
msg = email.message_from_bytes(raw_header)
|
||
from_raw = decode_hdr(msg.get("From", ""))
|
||
subject = decode_hdr(msg.get("Subject", "(geen onderwerp)"))
|
||
date_str = msg.get("Date", "")
|
||
message_id = (msg.get("Message-ID") or "").strip()
|
||
|
||
# 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,
|
||
"message_id": message_id,
|
||
})
|
||
|
||
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 = html_lib.escape(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:
|
||
time = html_lib.escape(item["time"])
|
||
sender = html_lib.escape(item["from"])
|
||
subject = html_lib.escape(item["subject"])
|
||
url = apple_mail_url(item.get("message_id", ""))
|
||
if url:
|
||
subject_html = f'<a href="{html_lib.escape(url, quote=True)}">{subject}</a>'
|
||
else:
|
||
subject_html = subject
|
||
html += (
|
||
f'<tr>'
|
||
f'<td class="time">{time}</td>'
|
||
f'<td class="from">{sender}</td>'
|
||
f'<td class="subject">{subject_html}</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()
|
||
if mail_to := arg_value(args, "--mail-to"):
|
||
cfg_dict["to"] = mail_to
|
||
print(f"Account: {cfg_dict['username']}\n")
|
||
|
||
# Datum bepalen
|
||
if datum := arg_value(args, "--datum"):
|
||
zoekdatum = date.fromisoformat(datum)
|
||
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.")
|
||
|
||
html = bouw_html(berichten, zoekdatum)
|
||
stuur_overzicht(html, zoekdatum, cfg_dict)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
run()
|