v0.0.1: eerste versie van de Van Emous CRM
Flask + SQLite, in de Van Emous-huisstijl (tokens uit het design system, GSAP-effecten volgens de motion-regels, uit bij prefers-reduced-motion). - Leads en contactpersonen: status, toewijzen, verwachte waarde, omzetten naar klant, tijdlijn, bestanden, taken/opvolging - Relaties (zoals accounts in EspoCRM): klantbundels (huishouden/familie) en partners met type (leverancier, aannemer, ...); leveranciers van producten worden automatisch partners - API voor formulieren (POST /api/leads) met dubbele-leaddetectie, bestanden en configurator-ontwerpen (house-design JSON) - Import van CSV/JSON (leads, relaties, producten) met kolomkoppeling; configurator-formaat - Producten met SKU als vaste sleutel, mutatiehistorie, POST /api/products, CSV-export; offerteregels houden hun eigen prijs - Offertes met btw per tarief en afdrukversie (PDF via de browser) - Inbox via IMAP/SMTP, gekoppeld aan contacten - Gebruikers (beheerder/medewerker) en klantportaal (offertes accepteren, bestanden, berichten) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
"""IMAP ophalen en SMTP versturen, alleen met de standaardbibliotheek."""
|
||||
import email
|
||||
import html
|
||||
import imaplib
|
||||
import re
|
||||
import smtplib
|
||||
from datetime import datetime, timedelta
|
||||
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)<br\s*/?>|</p>|</div>|</tr>|</h\d>|</li>', '\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 _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)
|
||||
else:
|
||||
conn = imaplib.IMAP4(host, port, timeout=30)
|
||||
conn.starttls()
|
||||
conn.login(cfg['imap_user'], cfg.get('imap_pass', ''))
|
||||
return conn
|
||||
|
||||
|
||||
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'
|
||||
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(f'"{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'{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,
|
||||
})
|
||||
return out
|
||||
finally:
|
||||
try:
|
||||
conn.logout()
|
||||
except Exception:
|
||||
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')
|
||||
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)
|
||||
|
||||
msg = EmailMessage()
|
||||
msg['From'] = cfg.get('smtp_from') or user
|
||||
msg['To'] = to
|
||||
msg['Subject'] = subject
|
||||
msg['Date'] = formatdate(localtime=True)
|
||||
msg['Message-ID'] = make_msgid()
|
||||
if in_reply_to:
|
||||
msg['In-Reply-To'] = in_reply_to
|
||||
msg['References'] = in_reply_to
|
||||
msg.set_content(body)
|
||||
|
||||
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)
|
||||
server.send_message(msg)
|
||||
return msg['Message-ID']
|
||||
Reference in New Issue
Block a user