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:
co-authored by
Claude Opus 5.5
parent
8f6e1ab443
commit
cad838f1f4
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user