Files
mailcat/dagelijks_overzicht.py
T

337 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()