"""IMAP ophalen en SMTP versturen, alleen met de standaardbibliotheek.
Beveiliging per server: 'ssl' (direct TLS, IMAP 993 / SMTP 465), 'starttls' (IMAP 143 / SMTP 587) of 'none';
leeg = afleiden uit de poort. Met smtp_sent_folder komt een kopie van verstuurde mail in die IMAP-map, voor
servers die dat zelf niet doen (Zimbra, MX Plan; Gmail doet het wel zelf).
"""
import email
import html
import imaplib
import re
import smtplib
import ssl
import time
from datetime import datetime, timedelta
from email.headerregistry import Address
from email.message import EmailMessage
from email.policy import default as policy
from email.utils import formatdate, make_msgid, parseaddr, parsedate_to_datetime
MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
def _text(msg):
part = msg.get_body(preferencelist=('plain', 'html'))
if part is None:
return ''
try:
content = part.get_content()
except Exception:
content = (part.get_payload(decode=True) or b'').decode('utf-8', 'replace')
if part.get_content_type() == 'text/html':
content = re.sub(r'(?is)<(script|style|head).*?\1>', '', content)
content = re.sub(r'(?i)
|
||||', '\n', content)
content = html.unescape(re.sub(r'<[^>]+>', '', content))
content = re.sub(r'[ \t]+\n', '\n', content)
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',
'imap_security': 'ssl', 'smtp_host': 'smtp.mail.ovh.net', 'smtp_port': '465', 'smtp_security': 'ssl',
'smtp_sent_folder': 'Sent'},
'ovh-ca': {'label': 'OVHcloud Zimbra / MX Plan (Amerika, Azië)', 'imap_host': 'imap.mail.ovh.ca',
'imap_port': '993', 'imap_security': 'ssl', 'smtp_host': 'smtp.mail.ovh.ca', 'smtp_port': '465',
'smtp_security': 'ssl', 'smtp_sent_folder': 'Sent'},
'gmail': {'label': 'Gmail / Google Workspace (app-wachtwoord)', 'imap_host': 'imap.gmail.com', 'imap_port': '993',
'imap_security': 'ssl', 'smtp_host': 'smtp.gmail.com', 'smtp_port': '587', 'smtp_security': 'starttls',
'smtp_sent_folder': ''},
}
SECURITY = [('', 'Automatisch (uit de poort)'), ('ssl', 'SSL/TLS'), ('starttls', 'STARTTLS'), ('none', 'Geen')]
def _security(cfg, kind, ssl_port):
sec = (cfg.get(f'{kind}_security') or '').lower()
return sec if sec in ('ssl', 'starttls', 'none') else ('ssl' if int(cfg.get(f'{kind}_port') or ssl_port) == ssl_port else 'starttls')
def _connect_imap(cfg):
host, port = cfg['imap_host'], int(cfg.get('imap_port') or 993)
sec = _security(cfg, 'imap', 993)
if sec == 'ssl':
conn = imaplib.IMAP4_SSL(host, port, ssl_context=ssl.create_default_context(), timeout=30)
else:
conn = imaplib.IMAP4(host, port, timeout=30)
if sec == 'starttls':
conn.starttls(ssl.create_default_context())
conn.login(cfg['imap_user'], cfg.get('imap_pass', ''))
return conn
def _connect_smtp(cfg):
host, port = cfg.get('smtp_host'), int(cfg.get('smtp_port') or 587)
if not host:
raise RuntimeError('Geen SMTP-server ingesteld')
sec = _security(cfg, 'smtp', 465)
if sec == 'ssl':
server = smtplib.SMTP_SSL(host, port, timeout=30, context=ssl.create_default_context())
else:
server = smtplib.SMTP(host, port, timeout=30)
if sec == 'starttls':
server.starttls(context=ssl.create_default_context())
user = cfg.get('smtp_user') or cfg.get('imap_user', '')
if user:
server.login(user, cfg.get('smtp_pass') or cfg.get('imap_pass', ''))
return server
def _quote(folder):
return '"' + folder.replace('\\', '\\\\').replace('"', '\\"') + '"'
def folders(conn):
names = []
for line in conn.list()[1] or []:
m = re.match(rb'\((?P[^)]*)\) (?:"[^"]*"|NIL) (?P.+)$', line or b'')
if m and b'\\noselect' not in m.group('flags').lower():
names.append(m.group('name').decode('utf-8', 'replace').strip().strip('"'))
return names
def test(cfg):
"""Verbindingen proberen zonder iets te versturen: [(gelukt, melding)]."""
out = []
if cfg.get('imap_host'):
try:
conn = _connect_imap(cfg)
try:
names = folders(conn)
folder = cfg.get('imap_folder') or 'INBOX'
out.append((True, f"IMAP: ingelogd op {cfg['imap_host']}; mappen: {', '.join(names[:12]) or '—'}"))
if conn.select(_quote(folder), readonly=True)[0] != 'OK':
out.append((False, f'IMAP: map "{folder}" niet gevonden'))
sent = cfg.get('smtp_sent_folder')
if sent and sent not in names:
out.append((False, f'IMAP: map voor verzonden mail "{sent}" niet gevonden'))
finally:
conn.logout()
except Exception as e:
out.append((False, f"IMAP: {e or type(e).__name__}"))
else:
out.append((False, 'IMAP: geen server ingesteld'))
try:
with _connect_smtp(cfg):
out.append((True, f"SMTP: ingelogd op {cfg['smtp_host']}"))
except Exception as e:
out.append((False, f"SMTP: {e or type(e).__name__}"))
return out
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)
since_str = f'{since.day:02d}-{MONTHS[since.month - 1]}-{since.year}'
conn = _connect_imap(cfg)
try:
typ, _ = conn.select(_quote(folder), readonly=True)
if typ != 'OK':
raise RuntimeError(f'Map "{folder}" niet gevonden')
_, data = conn.uid('search', None, 'SINCE', since_str)
uids = data[0].split()[-limit:]
out = []
for uid in uids:
key = f'{prefix}{folder}:{uid.decode()}'
if key in known:
continue
_, msgdata = conn.uid('fetch', uid, '(BODY.PEEK[])')
if not msgdata or not isinstance(msgdata[0], tuple):
continue
msg = email.message_from_bytes(msgdata[0][1], policy=policy)
name, addr = parseaddr(str(msg.get('From', '')))
try:
date = parsedate_to_datetime(msg['Date']).astimezone().strftime('%Y-%m-%d %H:%M:%S')
except Exception:
date = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
out.append({
'uid': key,
'message_id': str(msg.get('Message-ID', '')),
'from_name': name,
'from_addr': addr.lower(),
'to_addr': str(msg.get('To', '')),
'subject': str(msg.get('Subject', '')) or '(geen onderwerp)',
'body': _text(msg),
'date': date,
'bounced': bounced(msg),
})
return out
finally:
try:
conn.logout()
except Exception:
pass
def sender(cfg):
"""Afzender voor From: smtp_from, of de inlognaam. Alleen een naam in smtp_from ('Machiel Heinen') wordt
'Machiel Heinen ': zonder adres weigert de ontvangende server het bericht."""
user = cfg.get('smtp_user') or cfg.get('imap_user', '')
raw = (cfg.get('smtp_from') or '').strip()
name, addr = parseaddr(raw)
if '@' not in addr:
name, addr = raw, user
return str(Address(display_name=name, addr_spec=addr)) if name and addr else addr
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
if cc:
msg['Cc'] = cc
msg['Subject'] = subject
msg['Date'] = formatdate(localtime=True)
domain = parseaddr(msg['From'])[1].rpartition('@')[2] or None
msg['Message-ID'] = make_msgid(domain=domain)
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)
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:
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