v0.0.4: e-mailtemplates met velden ({voornaam}, {model}, {afzender}…), te kiezen bij nieuwe e-mail, antwoord en contact; inbox met nieuwe e-mail, doorsturen, CC en bijlagen, Verzonden, Archief, zoeken, als ongelezen markeren en alles gelezen; agenda per gebruiker met CalDAV (/dav/, app-wachtwoord in Mijn account); zoeken en Nieuw in de bovenbalk; e-mailinstellingen met voorinstellingen (OVHcloud, Gmail), beveiliging per server, kopie in Verzonden en verbindingstest; offerte in het klantportaal

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Machiel
2026-10-16 17:29:47 +02:00
co-authored by Claude Opus 5.5
parent 81fc27fa86
commit 366a3f9d17
26 changed files with 2095 additions and 271 deletions
+121 -23
View File
@@ -1,9 +1,16 @@
"""IMAP ophalen en SMTP versturen, alleen met de standaardbibliotheek."""
"""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.message import EmailMessage
from email.policy import default as policy
@@ -28,17 +35,98 @@ def _text(msg):
return re.sub(r'\n{3,}', '\n\n', content).strip()
# 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)
if port == 993:
conn = imaplib.IMAP4_SSL(host, port, timeout=30)
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)
conn.starttls()
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<flags>[^)]*)\) (?:"[^"]*"|NIL) (?P<name>.+)$', 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):
"""Haal berichten van de laatste `imap_days` dagen op die nog niet in `known` staan."""
folder = cfg.get('imap_folder') or 'INBOX'
@@ -48,7 +136,7 @@ def fetch(cfg, known, limit=200):
conn = _connect_imap(cfg)
try:
typ, _ = conn.select(f'"{folder}"', readonly=True)
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)
@@ -85,32 +173,42 @@ def fetch(cfg, known, limit=200):
pass
def send(cfg, to, subject, body, in_reply_to=None):
host = cfg.get('smtp_host')
if not host:
raise RuntimeError('Geen SMTP-server ingesteld')
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)]."""
user = cfg.get('smtp_user') or cfg.get('imap_user', '')
password = cfg.get('smtp_pass') or cfg.get('imap_pass', '')
port = int(cfg.get('smtp_port') or 587)
sender = cfg.get('smtp_from') or user
msg = EmailMessage()
msg['From'] = cfg.get('smtp_from') or user
msg['From'] = sender
msg['To'] = to
if cc:
msg['Cc'] = cc
msg['Subject'] = subject
msg['Date'] = formatdate(localtime=True)
msg['Message-ID'] = make_msgid()
domain = parseaddr(sender)[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
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)
if port == 465:
server = smtplib.SMTP_SSL(host, port, timeout=30)
else:
server = smtplib.SMTP(host, port, timeout=30)
server.starttls()
with server:
if user:
server.login(user, password)
with _connect_smtp(cfg) as server:
server.send_message(msg)
return msg['Message-ID']
warning = None
folder = cfg.get('smtp_sent_folder')
if folder and cfg.get('imap_host'):
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