v0.0.5: gedeelde mailboxen (info@, facturen@, nieuwsbrief@, persoonlijk) met eigen IMAP/SMTP, afzender, rol en toegang per gebruiker; inbox per mailbox en versturen vanuit een gekozen mailbox; routines (jobs.py: types met @jobs.register, interval, instellingen, logboek, slot tegen dubbel draaien; app.py scheduler/tick) voor e-mail ophalen, nieuwsbrieven versturen en opschonen; meldingen (bel, bureaubladmelding); nieuwsbrieven met segmenten, testmail, inplannen en versturen per batch over één verbinding, afmeldlink met List-Unsubscribe en bounceverwerking; oude mailinstellingen worden de eerste mailbox

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Machiel
2026-10-16 18:25:44 +02:00
co-authored by Claude Opus 5.5
parent 8f6e1ab443
commit cad838f1f4
24 changed files with 1710 additions and 185 deletions
+80 -19
View File
@@ -36,6 +36,25 @@ def _text(msg):
return re.sub(r'\n{3,}', '\n\n', content).strip()
def bounced(msg):
"""Adressen waarvoor een afleverbericht (DSN, multipart/report) meldt dat bezorgen definitief mislukt is."""
if msg.get_content_type() != 'multipart/report':
return []
out = []
for part in msg.walk():
if part.get_content_type() != 'message/delivery-status':
continue
for block in part.get_payload() or []:
if not hasattr(block, 'get'):
continue
action = str(block.get('Action', '')).strip().lower()
rcpt = str(block.get('Final-Recipient') or block.get('Original-Recipient') or '')
addr = rcpt.partition(';')[2].strip().strip('<>').lower()
if action == 'failed' and '@' in addr:
out.append(addr)
return out
# Voorinstellingen voor Instellingen › E-mail (de pagina vult de velden in; daarna blijft alles aanpasbaar).
PROVIDERS = {
'ovh': {'label': 'OVHcloud Zimbra / MX Plan (Europa)', 'imap_host': 'imap.mail.ovh.net', 'imap_port': '993',
@@ -128,8 +147,9 @@ def test(cfg):
return out
def fetch(cfg, known, limit=200):
"""Haal berichten van de laatste `imap_days` dagen op die nog niet in `known` staan."""
def fetch(cfg, known, limit=200, prefix=''):
"""Haal berichten van de laatste `imap_days` dagen op die nog niet in `known` staan. Sleutel (uid) =
prefix + map:uid, zodat meerdere mailboxen naast elkaar passen."""
folder = cfg.get('imap_folder') or 'INBOX'
days = int(cfg.get('imap_days') or 30)
since = datetime.now() - timedelta(days=days)
@@ -144,7 +164,7 @@ def fetch(cfg, known, limit=200):
uids = data[0].split()[-limit:]
out = []
for uid in uids:
key = f'{folder}:{uid.decode()}'
key = f'{prefix}{folder}:{uid.decode()}'
if key in known:
continue
_, msgdata = conn.uid('fetch', uid, '(BODY.PEEK[])')
@@ -165,6 +185,7 @@ def fetch(cfg, known, limit=200):
'subject': str(msg.get('Subject', '')) or '(geen onderwerp)',
'body': _text(msg),
'date': date,
'bounced': bounced(msg),
})
return out
finally:
@@ -185,9 +206,8 @@ def sender(cfg):
return str(Address(display_name=name, addr_spec=addr)) if name and addr else addr
def send(cfg, to, subject, body, in_reply_to=None, cc='', attachments=()):
"""Versturen; geeft (Message-ID, waarschuwing of None). Een mislukte kopie in Verzonden is alleen een waarschuwing.
attachments = [(bestandsnaam, bytes, mimetype)]."""
def build(cfg, to, subject, body, in_reply_to=None, cc='', attachments=(), headers=None):
"""Een bericht opbouwen. attachments = [(bestandsnaam, bytes, mimetype)]; headers = extra kopregels."""
msg = EmailMessage()
msg['From'] = sender(cfg)
msg['To'] = to
@@ -200,25 +220,66 @@ def send(cfg, to, subject, body, in_reply_to=None, cc='', attachments=()):
if in_reply_to:
msg['In-Reply-To'] = in_reply_to
msg['References'] = in_reply_to
for key, value in (headers or {}).items():
msg[key] = value
msg.set_content(body)
for name, data, ctype in attachments:
maintype, _, subtype = (ctype or 'application/octet-stream').partition('/')
msg.add_attachment(data, maintype=maintype, subtype=subtype or 'octet-stream', filename=name)
return msg
def save_sent(cfg, msg):
"""Kopie in de map voor verzonden mail (smtp_sent_folder); geeft een waarschuwing of None."""
folder = cfg.get('smtp_sent_folder')
if not (folder and cfg.get('imap_host')):
return None
try:
conn = _connect_imap(cfg)
try:
typ, data = conn.append(_quote(folder), '\\Seen', imaplib.Time2Internaldate(time.time()), msg.as_bytes())
if typ != 'OK':
raise RuntimeError((data[0] or b'').decode('utf-8', 'replace') if data else typ)
finally:
conn.logout()
except Exception as e:
return f'Verstuurd, maar niet bewaard in "{folder}": {e}'
return None
def send(cfg, to, subject, body, in_reply_to=None, cc='', attachments=()):
"""Eén bericht versturen; geeft (Message-ID, waarschuwing of None). Een mislukte kopie in Verzonden is alleen een
waarschuwing."""
msg = build(cfg, to, subject, body, in_reply_to, cc, attachments)
with _connect_smtp(cfg) as server:
server.send_message(msg)
return msg['Message-ID'], save_sent(cfg, msg)
warning = None
folder = cfg.get('smtp_sent_folder')
if folder and cfg.get('imap_host'):
class Outbox:
"""Veel berichten over één SMTP-verbinding (nieuwsbrieven), zonder kopie in Verzonden:
with mail.Outbox(cfg) as out:
out.send(mail.build(cfg, …))
Bij een verbroken verbinding wordt één keer opnieuw verbonden."""
def __init__(self, cfg):
self.cfg, self.server = cfg, None
def __enter__(self):
self.server = _connect_smtp(self.cfg)
return self
def send(self, msg):
try:
conn = _connect_imap(cfg)
try:
typ, data = conn.append(_quote(folder), '\\Seen', imaplib.Time2Internaldate(time.time()), msg.as_bytes())
if typ != 'OK':
raise RuntimeError((data[0] or b'').decode('utf-8', 'replace') if data else typ)
finally:
conn.logout()
except Exception as e:
warning = f'Verstuurd, maar niet bewaard in "{folder}": {e}'
return msg['Message-ID'], warning
self.server.send_message(msg)
except smtplib.SMTPServerDisconnected:
self.server = _connect_smtp(self.cfg)
self.server.send_message(msg)
def __exit__(self, *exc):
try:
self.server.quit()
except Exception:
pass