"""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).*?', '', 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