Initial status for developed files on dagda
This commit is contained in:
@@ -0,0 +1,323 @@
|
||||
#!/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)
|
||||
|
||||
# ── 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["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()
|
||||
|
||||
Reference in New Issue
Block a user