337 lines
12 KiB
Python
337 lines
12 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 sys
|
||
import ssl
|
||
import smtplib
|
||
import re
|
||
import html as html_lib
|
||
import urllib.parse
|
||
import config
|
||
from datetime import date, timedelta, datetime
|
||
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 babel.dates import format_date
|
||
import babel
|
||
import random
|
||
|
||
# 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="")
|
||
|
||
|
||
def fetch_body(data) -> bytes | None:
|
||
"""Return the first bytes payload from an IMAP FETCH response."""
|
||
if not data:
|
||
return None
|
||
for item in data:
|
||
if isinstance(item, tuple) and len(item) >= 2 and isinstance(item[1], bytes):
|
||
return item[1]
|
||
return None
|
||
|
||
def invalid_locale(code):
|
||
return babel.Locale(code).language_name is None
|
||
|
||
def get_random_locale() -> (str, str, str):
|
||
"""Return the locale code, the local name and the english name of a language."""
|
||
all_ids = babel.localedata.locale_identifiers()
|
||
code = all_ids[random.randrange(0, len(all_ids))]
|
||
while (invalid_locale(code)):
|
||
code = all_ids[random.randrange(0, len(all_ids))]
|
||
return (code, babel.Locale(code).language_name, babel.Locale(code).english_name)
|
||
|
||
def create_mail_subject(zoekdatum, random = False) -> str:
|
||
|
||
# Als random = true, toon dan de datum ook in een willekeurige andere taal
|
||
# Anders alleen in het Nederlands
|
||
|
||
datum_nl = format_date(zoekdatum, "EEEE d MMMM yyyy", locale="nl")
|
||
if random:
|
||
random_locale, random_lang, random_elang = get_random_locale()
|
||
datum_bl = format_date(zoekdatum, locale=random_locale)
|
||
return f"Mailoverzicht van {datum_bl} ({random_lang} - {random_elang}) of {datum_nl}"
|
||
else:
|
||
return f"Mailoverzicht van {datum_nl}"
|
||
|
||
# ── IMAP ophalen ──────────────────────────────────────────────────────────────
|
||
|
||
def haal_berichten_op(mail: imaplib.IMAP4_SSL, zoekdatum: date) -> dict[str, list[dict]]:
|
||
"""
|
||
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 = format_date(zoekdatum, "EEEE d MMMM yyyy", locale="nl") # zoekdatum.strftime("%-d %B %Y")
|
||
# random_locale, random_lang, random_elang = get_random_locale()
|
||
# datum_bl = format_date(zoekdatum, locale=random_locale)
|
||
msg = MIMEMultipart("alternative")
|
||
# msg["Subject"] = f"Mailoverzicht van {datum_bl} ({random_lang} - {random_elang}) of {datum_nl}"
|
||
msg["Subject"] = create_mail_subject(zoekdatum, False)
|
||
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()
|