From cad838f1f4cda8225a65d235a1c971374227d929 Mon Sep 17 00:00:00 2001 From: Machiel Date: Fri, 16 Oct 2026 18:25:44 +0200 Subject: [PATCH] =?UTF-8?q?v0.0.5:=20gedeelde=20mailboxen=20(info@,=20fact?= =?UTF-8?q?uren@,=20nieuwsbrief@,=20persoonlijk)=20met=20eigen=20IMAP/SMTP?= =?UTF-8?q?,=20afzender,=20rol=20en=20toegang=20per=20gebruiker;=20inbox?= =?UTF-8?q?=20per=20mailbox=20en=20versturen=20vanuit=20een=20gekozen=20ma?= =?UTF-8?q?ilbox;=20routines=20(jobs.py:=20types=20met=20@jobs.register,?= =?UTF-8?q?=20interval,=20instellingen,=20logboek,=20slot=20tegen=20dubbel?= =?UTF-8?q?=20draaien;=20app.py=20scheduler/tick)=20voor=20e-mail=20ophale?= =?UTF-8?q?n,=20nieuwsbrieven=20versturen=20en=20opschonen;=20meldingen=20?= =?UTF-8?q?(bel,=20bureaubladmelding);=20nieuwsbrieven=20met=20segmenten,?= =?UTF-8?q?=20testmail,=20inplannen=20en=20versturen=20per=20batch=20over?= =?UTF-8?q?=20=C3=A9=C3=A9n=20verbinding,=20afmeldlink=20met=20List-Unsubs?= =?UTF-8?q?cribe=20en=20bounceverwerking;=20oude=20mailinstellingen=20word?= =?UTF-8?q?en=20de=20eerste=20mailbox?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- README.md | 43 +- app.py | 911 +++++++++++++++++++++++++++++++---- jobs.py | 88 ++++ mail.py | 99 +++- schema.sql | 106 ++++ server/setup.sh | 9 +- static/notify.js | 32 ++ static/style.css | 19 + templates/_compose.html | 12 +- templates/base.html | 24 +- templates/campaign_form.html | 95 ++++ templates/campaigns.html | 33 ++ templates/contact.html | 8 +- templates/email.html | 6 +- templates/inbox.html | 34 +- templates/mail_new.html | 2 +- templates/mailbox_form.html | 91 ++++ templates/notifications.html | 24 + templates/routine_form.html | 55 +++ templates/routines.html | 35 ++ templates/segment_form.html | 46 ++ templates/segments.html | 26 + templates/settings.html | 77 +-- templates/unsubscribe.html | 20 + 24 files changed, 1710 insertions(+), 185 deletions(-) create mode 100644 jobs.py create mode 100644 static/notify.js create mode 100644 templates/campaign_form.html create mode 100644 templates/campaigns.html create mode 100644 templates/mailbox_form.html create mode 100644 templates/notifications.html create mode 100644 templates/routine_form.html create mode 100644 templates/routines.html create mode 100644 templates/segment_form.html create mode 100644 templates/segments.html create mode 100644 templates/unsubscribe.html diff --git a/README.md b/README.md index ef91006..2bce28d 100644 --- a/README.md +++ b/README.md @@ -33,14 +33,27 @@ Eenvoudige CRM in Python (Flask) met SQLite, in de Van Emous-huisstijl. Versie 0 Voorbeelden staan onder Instellingen. - **Import** van CSV en JSON voor leads/contactpersonen, relaties en producten, met een voorbeeld en kolomkoppeling. Het formaat van de configurator (`;`, komma-decimalen, BOM, `#`-regels) werkt direct. -- **Inbox**: IMAP ophalen en SMTP versturen, mails automatisch gekoppeld aan contacten (op e-mailadres), - contact aanmaken vanuit een mail, beantwoorden. Voorinstellingen voor OVHcloud (Zimbra / MX Plan) en Gmail, - beveiliging per server (SSL/TLS of STARTTLS), een kopie van verstuurde mail in een IMAP-map (`Sent` bij Zimbra) - en een knop om de verbinding te testen (Instellingen › E-mail). Nieuwe e-mail opstellen (ook doorsturen), met CC - en bijlagen; mappen Verzonden en Archief (archiveren raakt de mailbox op de server niet), zoeken, als ongelezen - markeren en alles als gelezen markeren. +- **Mailboxen** (Instellingen › Mailboxen): meerdere accounts naast elkaar (info@, facturen@, nieuwsbrief@, + persoonlijk), elk met eigen IMAP/SMTP, afzender en rol (inbox, facturen, nieuwsbrief). Toegang per gebruiker; + zonder gekozen gebruikers is een mailbox gedeeld met iedereen. Voorinstellingen voor OVHcloud (Zimbra / MX Plan) + en Gmail, beveiliging per server, een kopie van verstuurde mail in een IMAP-map en een verbindingstest. +- **Inbox**: e-mail uit de eigen mailboxen (per mailbox of samen), automatisch gekoppeld aan contacten (op + e-mailadres), contact aanmaken vanuit een mail, beantwoorden vanuit dezelfde mailbox. Nieuwe e-mail opstellen + (ook doorsturen) met keuze van de mailbox, CC en bijlagen; mappen Verzonden en Archief (archiveren raakt de + mailbox op de server niet), zoeken, als ongelezen markeren en alles als gelezen markeren. +- **Meldingen**: bel in de bovenbalk (bijv. nieuwe e-mail in een mailbox, nieuwsbrief verzonden), met toestemming + ook als bureaubladmelding. Routines melden via `notify()`. +- **Routines** (beheer): terugkerende taken met een interval en eigen instellingen, met logboek en "Nu uitvoeren": + e-mail ophalen, nieuwsbrieven versturen, opschonen. Nieuwe soorten komen erbij met `@jobs.register` (zie + `jobs.py`). Ze draaien via `python app.py scheduler` (Docker: container `crm-routines`) of cron met + `python app.py tick`; een routine draait nooit twee keer tegelijk. +- **Nieuwsbrieven**: segmenten (filters op soort, status, bron, toegewezen, plaats, datum, ontwerp) en + nieuwsbrieven met velden per ontvanger, testmail, inplannen en versturen per batch over één verbinding (de + limiet van de mailserver blijft buiten bereik), pauzeren en stoppen. Elke mail heeft een afmeldlink en + `List-Unsubscribe` (één klik); afgemelde en onbestelbare adressen (afleverberichten in een mailbox) vallen buiten + elk segment, per adres gaat er één mail uit. - **E-mailtemplates**: onderwerp en tekst met velden als `{voornaam}`, `{model}` en `{afzender}`, te kiezen bij een - nieuwe e-mail, een antwoord en op de pagina van een contact. Velden worden direct ingevuld als het contact bekend + nieuwe e-mail, een antwoord, op de pagina van een contact en bij een nieuwsbrief. Velden worden direct ingevuld als het contact bekend is, anders bij het versturen (contact op het eerste adres in Aan). - **Agenda**: een agenda per gebruiker, samen in een maandoverzicht met de taken die een datum hebben; afspraken aan een contact koppelen (ook op de contactpagina en het dashboard), eenvoudige herhaling. Via **CalDAV** @@ -95,6 +108,14 @@ tools/configurator-sync.sh [../configurator] # kopieert js/, assets/, kosten/ Opnieuw draaien als er in de configurator iets aan de weergave verandert; `deploy.sh` doet dit zelf bij elke deploy. +Routines en e-mail vanaf de opdrachtregel: + +```sh +.venv/bin/python app.py scheduler # blijft draaien en voert de routines uit die aan de beurt zijn +.venv/bin/python app.py tick # één keer (voor cron elke minuut) +.venv/bin/python app.py sync # nu e-mail ophalen uit alle actieve mailboxen +``` + Demoleads (lorem ipsum, elk met een standaardmodel als gekozen model): ```sh @@ -117,7 +138,7 @@ ssh ubuntu@SERVER sudo bash server/setup.sh crm.voorbeeld.nl jij@voorbeeld.nl `server/setup.sh` installeert Python, nginx, certbot, rsync en sqlite3, zet de tijdzone op Europe/Amsterdam, maakt de gebruiker `crm` met `/srv/vanemous-crm` (`releases/`, `current`, `data/`), de systemd-dienst `vanemous-crm` (gunicorn op 127.0.0.1:5050, `CRM_PROXY=1`), de nginx-site met HTTPS, de firewall (SSH, 80, 443), -automatische beveiligingsupdates en cron (e-mail ophalen elke 10 minuten, elke nacht een databaseback-up, 7 dagen rond). +automatische beveiligingsupdates en cron (elke minuut de routines, elke nacht een databaseback-up, 7 dagen rond). Een versie uitrollen (of terugrollen) vanaf je eigen machine: @@ -172,11 +193,13 @@ mailwachtwoorden in. ``` app.py routes en logica importer.py CSV/JSON inlezen en kolommen herkennen -mail.py IMAP/SMTP (voorinstellingen, verbindingstest, kopie in Verzonden) +mail.py IMAP/SMTP (voorinstellingen, verbindingstest, kopie in Verzonden, bulk via Outbox, bounces) +jobs.py routines: types registreren en hun instellingen (uitvoeren: run_job in app.py) ical.py iCalendar lezen/schrijven en herhalingen uitvouwen (agenda en CalDAV) schema.sql database templates/ pagina's (Jinja) -static/ style.css (huisstijltokens), fx.js (GSAP-effecten), search.js (zoekveld), logo.svg, viewer.js (3D-viewer), bestek.js +static/ style.css (huisstijltokens), fx.js (GSAP-effecten), search.js (zoekveld), notify.js (bel), compose.js + (e-mail opstellen), logo.svg, viewer.js (3D-viewer), bestek.js static/configurator/ engine van de configurator (tools/configurator-sync.sh, niet in git) tools/ configurator-sync.sh, export-models.js (standaardmodellen als JSON) demo/modellen standaardmodellen als house-design-JSON voor de demoleads diff --git a/app.py b/app.py index 8333d02..628f6e4 100644 --- a/app.py +++ b/app.py @@ -4,10 +4,11 @@ Starten: python app.py E-mail ophalen: python app.py sync (bijv. elke 10 minuten via cron) Demoleads: python app.py demo (standaardmodellen uit demo/modellen als gekozen model) """ -__version__ = '0.0.4' +__version__ = '0.0.5' import csv import hashlib +import hmac import io import json import mimetypes @@ -30,6 +31,7 @@ from werkzeug.utils import secure_filename import ical import importer +import jobs import mail BASE = os.path.dirname(os.path.abspath(__file__)) @@ -73,18 +75,24 @@ DEMO_MODELS = os.path.join(BASE, 'demo', 'modellen') SETTING_KEYS = ['company_name', 'company_address', 'company_postcode_city', 'company_phone', 'company_email', 'company_website', 'company_kvk', 'company_btw', 'company_iban', - 'quote_prefix', 'quote_valid_days', 'quote_intro', 'quote_footer', - 'imap_host', 'imap_port', 'imap_user', 'imap_folder', 'imap_days', 'imap_security', - 'smtp_host', 'smtp_port', 'smtp_user', 'smtp_from', 'smtp_security', 'smtp_sent_folder', 'allowed_origins', 'api_assign_user', + 'quote_prefix', 'quote_valid_days', 'quote_intro', 'quote_footer', 'allowed_origins', 'api_assign_user', 'klant_types', 'partner_types'] -SECRET_KEYS = ['imap_pass', 'smtp_pass'] + +# Mailboxen (tabel mailboxes): de verbindingsvelden heten zoals mail.py ze verwacht, zodat een rij direct de cfg is. +MAILBOX_FIELDS = ['imap_host', 'imap_port', 'imap_security', 'imap_user', 'imap_folder', 'imap_days', + 'smtp_host', 'smtp_port', 'smtp_security', 'smtp_user', 'smtp_sent_folder', 'smtp_from'] +MAILBOX_SECRETS = ['imap_pass', 'smtp_pass'] +# Rol van een mailbox: bepaalt waar hij voor wordt aangeboden (nieuwsbrieven kiezen standaard een +# nieuwsbriefmailbox); routines kunnen er later op sturen (bijv. facturen@ verwerken). +MAILBOX_PURPOSES = [('inbox', 'Inbox'), ('facturen', 'Facturen'), ('nieuwsbrief', 'Nieuwsbrief')] FIELD_LOOKUP = importer.lookup(importer.FIELD_ALIASES) API_SKIP = {'api_key', 'redirect', '_gotcha', 'website_hp', 'g-recaptcha-response'} -PUBLIC = {'static', 'login', 'setup', 'api_leads', 'api_products', 'dav', 'dav_wellknown'} +PUBLIC = {'static', 'login', 'setup', 'api_leads', 'api_products', 'dav', 'dav_wellknown', 'unsubscribe'} PORTAL_PUBLIC = {'portal_login', 'portal_invite'} -ADMIN_ONLY = {'settings', 'users', 'user_edit'} +ADMIN_ONLY = {'settings', 'users', 'user_edit', 'mailbox_edit', 'routines', 'routine_edit', 'routine_run', + 'routine_delete'} def _secret_key(): @@ -135,11 +143,14 @@ MIGRATIONS = { 'supplier_id': 'INTEGER REFERENCES relations(id) ON DELETE SET NULL'}, 'contacts': {'relation_id': 'INTEGER REFERENCES relations(id) ON DELETE SET NULL', 'relation_role': "TEXT DEFAULT ''", - 'design_file_id': 'INTEGER REFERENCES files(id) ON DELETE SET NULL'}, + 'design_file_id': 'INTEGER REFERENCES files(id) ON DELETE SET NULL', + # nieuwsbrieven: afgemeld (afmeldlink) of onbestelbaar (bounce); beide vallen buiten elk segment + 'unsubscribed_at': 'TEXT', 'bounced_at': 'TEXT'}, 'files': {'title': "TEXT DEFAULT ''", 'kind': "TEXT DEFAULT ''", 'design_id': 'INTEGER'}, 'quotes': {'bestek': "TEXT DEFAULT ''", 'design_file_id': 'INTEGER REFERENCES files(id) ON DELETE SET NULL'}, 'users': {'dav_password_hash': 'TEXT', 'cal_sync': 'INTEGER NOT NULL DEFAULT 0'}, - 'emails': {'archived': 'INTEGER NOT NULL DEFAULT 0', 'cc': "TEXT DEFAULT ''", 'attachments': "TEXT DEFAULT ''"}, + 'emails': {'archived': 'INTEGER NOT NULL DEFAULT 0', 'cc': "TEXT DEFAULT ''", 'attachments': "TEXT DEFAULT ''", + 'mailbox_id': 'INTEGER REFERENCES mailboxes(id) ON DELETE SET NULL'}, } @@ -175,6 +186,20 @@ def init_db(): title = str((design.get('project') or {}).get('naam') or '').strip() or 'Woningontwerp' con.execute('UPDATE files SET title=? WHERE id=?', (title, fid)) con.execute('UPDATE contacts SET design_file_id=? WHERE id=? AND design_file_id IS NULL', (fid, cid)) + # vóór de mailboxen stond één mailbox in de instellingen: die wordt de eerste (gedeelde) mailbox, met de + # bestaande e-mail erin (uid krijgt het mailbox-voorvoegsel, zoals do_sync het nu maakt) + if not con.execute('SELECT COUNT(*) FROM mailboxes').fetchone()[0]: + old = dict(con.execute('SELECT key, value FROM settings').fetchall()) + if old.get('imap_host') or old.get('smtp_host'): + cols = MAILBOX_FIELDS + MAILBOX_SECRETS + mbid = con.execute( + f'INSERT INTO mailboxes (name, last_sync, created_at, updated_at, {", ".join(cols)}) ' + f'VALUES (?, ?, ?, ?, {", ".join("?" * len(cols))})', + ((old.get('imap_user') or old.get('smtp_user') or 'e-mail').split('@')[0].capitalize(), + old.get('last_sync'), now(), now(), + *[old.get(c) or '' for c in cols])).lastrowid + con.execute("UPDATE emails SET uid=? || '/' || uid WHERE uid IS NOT NULL AND mailbox_id IS NULL", (str(mbid),)) + con.execute('UPDATE emails SET mailbox_id=? WHERE mailbox_id IS NULL', (mbid,)) con.execute('PRAGMA journal_mode=WAL') con.commit() con.close() @@ -523,10 +548,15 @@ def fmt_number(value): def inject(): ctx = dict(RELATION_KINDS=RELATION_KINDS, RKIND=dict(RELATION_KINDS), STATUSES=STATUSES, STATUS=dict(STATUSES), QUOTE_STATUSES=QUOTE_STATUSES, TYPES=TYPES, TYPE=dict(TYPES), QSTATUS=dict(QUOTE_STATUSES), ROLES=ROLES, ROLE=dict(ROLES), - ACT=ACTIVITY_TYPES, LABELS=PRODUCT_LABELS, unread=0, USERS=[], today=date.today().isoformat()) + ACT=ACTIVITY_TYPES, LABELS=PRODUCT_LABELS, unread=0, USERS=[], today=date.today().isoformat(), + bell=[], bell_unread=0) if g.get('user'): - ctx['unread'] = scalar("SELECT COUNT(*) FROM emails WHERE direction='in' AND is_read=0") + ctx['unread'] = scalar(f"SELECT COUNT(*) FROM emails e WHERE e.direction='in' AND e.is_read=0 AND e.archived=0 " + f"AND {visible_mail()}") ctx['USERS'] = q('SELECT id, name FROM users WHERE active=1 ORDER BY name') + ctx['bell'] = q('SELECT * FROM notifications WHERE user_id=? ORDER BY id DESC LIMIT 8', g.user['id']) + ctx['bell_unread'] = scalar('SELECT COUNT(*) FROM notifications WHERE user_id=? AND read_at IS NULL', + g.user['id']) return ctx @@ -571,8 +601,7 @@ def setup(): "VALUES (?, ?, ?, 'admin', ?)", (f['name'].strip(), f['email'].strip().lower(), generate_password_hash(pw), now())) set_setting('api_key', secrets.token_urlsafe(24)) - for key, value in [('quote_prefix', 'OFF'), ('quote_valid_days', '30'), ('imap_port', '993'), - ('imap_folder', 'INBOX'), ('imap_days', '30'), ('smtp_port', '587'), + for key, value in [('quote_prefix', 'OFF'), ('quote_valid_days', '30'), ('company_name', 'Van Emous Mantelzorgwoningen')]: set_setting(key, value) db().commit() @@ -721,7 +750,8 @@ def dashboard(): source_max=max([r['n'] for r in sources] + [1]), recent=q("SELECT c.*, u.name AS user_name FROM contacts c LEFT JOIN users u ON u.id=c.assigned_user_id " "WHERE c.type='lead' ORDER BY c.created_at DESC LIMIT 8"), - mails=q("SELECT * FROM emails WHERE direction='in' AND is_read=0 ORDER BY date DESC LIMIT 5")) + mails=q(f"SELECT * FROM emails e WHERE e.direction='in' AND e.is_read=0 AND e.archived=0 AND {visible_mail()} " + "ORDER BY e.date DESC LIMIT 5")) # ---------------------------------------------------------------- contacten @@ -823,7 +853,8 @@ def contact_view(cid): activities=q('SELECT a.*, u.name AS user_name FROM activities a LEFT JOIN users u ON u.id=a.user_id ' 'WHERE a.contact_id=? ORDER BY a.created_at DESC, a.id DESC', cid), files=q('SELECT * FROM files WHERE contact_id=? ORDER BY created_at DESC', cid), - emails=q('SELECT * FROM emails WHERE contact_id=? ORDER BY date DESC LIMIT 50', cid), + emails=q(f'SELECT * FROM emails e WHERE e.contact_id=? AND {visible_mail()} ORDER BY e.date DESC LIMIT 50', cid), + mailboxes=user_mailboxes(sending=True), tasks=q('SELECT t.*, u.name AS user_name FROM tasks t LEFT JOIN users u ON u.id=t.user_id ' 'WHERE t.contact_id=? ORDER BY t.done, t.due_date IS NULL, t.due_date', cid), designs=designs, design=design, design_meta=design_meta, @@ -942,7 +973,10 @@ def contact_portal(cid): f"bestanden met ons delen. Stel via deze link uw wachtwoord in:\n\n{link}\n\n" f"Met vriendelijke groet,\n{company}") try: - mail.send(settings_dict(), c['email'], f'Uw toegang tot het klantportaal van {company}', body) + mb = default_mailbox() + if not mb: + raise RuntimeError('er is geen mailbox waarmee je mag versturen') + mail.send(mailbox_cfg(mb), c['email'], f'Uw toegang tot het klantportaal van {company}', body) log(cid, 'portal', 'Uitnodiging voor het klantportaal gemaild') flash('Uitnodiging verstuurd.') except Exception as e: @@ -1288,101 +1322,285 @@ def import_preview(token): return redirect(url_for('contacts' if kind == 'contacts' else 'products')) -# ---------------------------------------------------------------- inbox +# ---------------------------------------------------------------- mailboxen en meldingen +# +# Elke mailbox is een eigen account (info@, facturen@, nieuwsbrief@, persoonlijk). Wie een mailbox mag zien en +# gebruiken staat in mailbox_users; zonder regels is hij gedeeld met iedereen. E-mail is alleen zichtbaar via de +# mailbox waarin hij staat (visible_mail). -def do_sync(): - cfg = settings_dict() - if not cfg.get('imap_host'): - return 0, 'Er is nog geen IMAP-server ingesteld.' - known = {r[0] for r in q('SELECT uid FROM emails WHERE uid IS NOT NULL')} +def mailbox_cfg(mb): + return {k: '' if mb[k] is None else mb[k] for k in mb.keys()} + + +def user_mailboxes(user=None, sending=False, inactive=False): + """Mailboxen waar de gebruiker bij mag (standaard de ingelogde), op naam; sending = alleen met SMTP, de + mailboxen met de rol inbox eerst (die staat dan voorgekozen bij opstellen).""" + user = user or g.user + cache = g.setdefault('_mailboxes', {}) + key = (user['id'], inactive) + if key not in cache: + cache[key] = q('SELECT m.* FROM mailboxes m WHERE (m.active=1 OR ?) AND (NOT EXISTS ' + '(SELECT 1 FROM mailbox_users mu WHERE mu.mailbox_id=m.id) OR EXISTS ' + '(SELECT 1 FROM mailbox_users mu WHERE mu.mailbox_id=m.id AND mu.user_id=?)) ' + 'ORDER BY m.name COLLATE NOCASE', 1 if inactive else 0, user['id']) + rows = cache[key] + return sorted((m for m in rows if m['smtp_host']), key=lambda m: m['purpose'] != 'inbox') if sending else rows + + +def default_mailbox(): + """Mailbox voor mail vanuit de CRM zelf (bijv. portaaluitnodiging): de eerste inbox waarmee de gebruiker mag + versturen.""" + boxes = user_mailboxes(sending=True) + return boxes[0] if boxes else None + + +def visible_mail(alias='e'): + """SQL-voorwaarde: e-mail uit de mailboxen van de ingelogde gebruiker (ook inactieve).""" + ids = [str(m['id']) for m in user_mailboxes(inactive=True)] + return f"{alias}.mailbox_id IN ({', '.join(ids) or 'NULL'})" + + +def mailbox_user_ids(mb): + """Gebruikers met toegang tot een mailbox (voor meldingen).""" + rows = q('SELECT user_id FROM mailbox_users WHERE mailbox_id=?', mb['id']) + if not rows: + rows = q('SELECT id AS user_id FROM users WHERE active=1') + return [r['user_id'] for r in rows] + + +def notify(user_ids, title, body='', url='', kind=''): + """Melding voor gebruikers (bel in de bovenbalk; met toestemming ook als bureaubladmelding).""" + for uid in set(user_ids): + db().execute('INSERT INTO notifications (user_id, kind, title, body, url, created_at) VALUES (?, ?, ?, ?, ?, ?)', + (uid, kind, title, body, url, now())) + + +def mark_bounced(addresses): + """Onbestelbaar (afleverbericht): het contact valt buiten de nieuwsbrieven, de laatst verstuurde nieuwsbrief + naar dat adres telt als mislukt.""" + for addr in set(addresses): + for c in q('SELECT id FROM contacts WHERE email=? COLLATE NOCASE AND bounced_at IS NULL', addr): + db().execute('UPDATE contacts SET bounced_at=? WHERE id=?', (now(), c['id'])) + log(c['id'], 'email', f'E-mailadres {addr} is onbestelbaar (bounce); geen nieuwsbrieven meer') + db().execute("UPDATE campaign_recipients SET status='mislukt', error='Onbestelbaar (bounce)' WHERE id=" + "(SELECT id FROM campaign_recipients WHERE email=? COLLATE NOCASE AND status='verzonden' " + "ORDER BY sent_at DESC LIMIT 1)", (addr,)) + + +def fetch_mailbox(mb): + """Nieuwe e-mail van één mailbox ophalen: (aantal, fout of None). Meldt nieuwe e-mail aan wie toegang heeft.""" + known = {r[0] for r in q('SELECT uid FROM emails WHERE mailbox_id=? AND uid IS NOT NULL', mb['id'])} try: - messages = mail.fetch(cfg, known) - except Exception as e: # netwerk-, login- of mapfouten tonen we aan de gebruiker - return 0, f'Ophalen mislukt: {e}' + messages = mail.fetch(mailbox_cfg(mb), known, prefix=f"{mb['id']}/") + except Exception as e: # netwerk-, login- of mapfouten: bij de mailbox bewaren en melden + error = str(e) or type(e).__name__ + db().execute('UPDATE mailboxes SET last_error=? WHERE id=?', (error, mb['id'])) + db().commit() + return 0, f"{mb['name']}: ophalen mislukt: {error}" + new = [] for m in messages: c = q1('SELECT id FROM contacts WHERE email=? COLLATE NOCASE', m['from_addr']) if m['from_addr'] else None - db().execute('INSERT OR IGNORE INTO emails (uid, message_id, direction, contact_id, from_name, ' - 'from_addr, to_addr, subject, body, date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', - (m['uid'], m['message_id'], 'in', c['id'] if c else None, m['from_name'], + cur = db().execute('INSERT OR IGNORE INTO emails (uid, message_id, direction, mailbox_id, contact_id, from_name, ' + 'from_addr, to_addr, subject, body, date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + (m['uid'], m['message_id'], 'in', mb['id'], c['id'] if c else None, m['from_name'], m['from_addr'], m['to_addr'], m['subject'], m['body'], m['date'])) + if not cur.rowcount: + continue + new.append(m) if c: - log(c['id'], 'email', f"E-mail ontvangen: {m['subject']}") - set_setting('last_sync', now()) + log(c['id'], 'email', f"E-mail ontvangen in {mb['name']}: {m['subject']}") + mark_bounced(m['bounced']) + db().execute("UPDATE mailboxes SET last_sync=?, last_error='' WHERE id=?", (now(), mb['id'])) + if new and mb['notify']: + n = len(new) + notify(mailbox_user_ids(mb), f"{n} nieuwe e-mail{'s' if n != 1 else ''} in {mb['name']}", + ' · '.join(m['subject'] for m in new[:3]), url_for('inbox', mb=mb['id']), 'mail') db().commit() - return len(messages), None + return len(new), None +def do_sync(mailboxes=None): + """E-mail ophalen uit de gegeven mailboxen (standaard alle actieve met IMAP): (aantal, [fouten]).""" + if mailboxes is None: + mailboxes = q("SELECT * FROM mailboxes WHERE active=1 AND imap_host<>'' ORDER BY id") + total, errors = 0, [] + for mb in mailboxes: + if not mb['imap_host']: + continue + n, error = fetch_mailbox(mb) + total += n + if error: + errors.append(error) + return total, errors + + +@app.route('/settings/mailboxes/new', methods=['GET', 'POST'], defaults={'mbid': None}) +@app.route('/settings/mailboxes/', methods=['GET', 'POST']) +def mailbox_edit(mbid): + mb = get_or_404('mailboxes', mbid) if mbid else None + if request.method == 'POST': + f = request.form + data = {k: f.get(k, '').strip() for k in MAILBOX_FIELDS} + data.update(name=f.get('name', '').strip(), active=1 if f.get('active') else 0, + notify=1 if f.get('notify') else 0, + purpose=f.get('purpose') if f.get('purpose') in dict(MAILBOX_PURPOSES) else 'inbox') + for key in MAILBOX_SECRETS: + if f.get(key): + data[key] = f[key] + users = [int(u) for u in f.getlist('users') if u.isdigit()] + if not data['name']: + flash('Geef de mailbox een naam.', 'error') + return render_template('mailbox_form.html', mb={**(dict(mb) if mb else {}), **data}, access=users, + PROVIDERS=mail.PROVIDERS, SECURITY=mail.SECURITY, PURPOSES=MAILBOX_PURPOSES) + data['updated_at'] = now() + if mb: + db().execute(f'UPDATE mailboxes SET {", ".join(f"{k}=?" for k in data)} WHERE id=?', [*data.values(), mbid]) + else: + data['created_at'] = data['updated_at'] + mbid = db().execute(f'INSERT INTO mailboxes ({", ".join(data)}) VALUES ({", ".join("?" * len(data))})', + list(data.values())).lastrowid + db().execute('DELETE FROM mailbox_users WHERE mailbox_id=?', (mbid,)) + for uid in users: + db().execute('INSERT OR IGNORE INTO mailbox_users (mailbox_id, user_id) VALUES (?, ?)', (mbid, uid)) + db().commit() + if f.get('action') == 'test': + for ok, message in mail.test(mailbox_cfg(get_or_404('mailboxes', mbid))): + flash(message, 'message' if ok else 'error') + return redirect(url_for('mailbox_edit', mbid=mbid)) + flash('Mailbox opgeslagen.') + return redirect(url_for('settings', _anchor='mail')) + access = [r['user_id'] for r in q('SELECT user_id FROM mailbox_users WHERE mailbox_id=?', mbid)] if mb else [] + return render_template('mailbox_form.html', mb=mb or {'active': 1, 'notify': 1, 'purpose': 'inbox'}, + access=access, PROVIDERS=mail.PROVIDERS, SECURITY=mail.SECURITY, PURPOSES=MAILBOX_PURPOSES) + + +@app.route('/notifications') +def notifications(): + rows = q('SELECT * FROM notifications WHERE user_id=? ORDER BY id DESC LIMIT 100', g.user['id']) + return render_template('notifications.html', rows=rows) + + +@app.route('/notifications/') +def notification_open(nid): + n = q1('SELECT * FROM notifications WHERE id=? AND user_id=?', nid, g.user['id']) or abort(404) + db().execute('UPDATE notifications SET read_at=? WHERE id=? AND read_at IS NULL', (now(), nid)) + db().commit() + return redirect(safe_next(n['url']) or url_for('notifications')) + + +@app.post('/notifications/read-all') +def notifications_read_all(): + db().execute('UPDATE notifications SET read_at=? WHERE user_id=? AND read_at IS NULL', (now(), g.user['id'])) + db().commit() + return redirect(back(url_for('notifications'))) + + +@app.route('/notifications/poll') +def notifications_poll(): + """Voor de bel (static/notify.js): aantal ongelezen en de nieuwe meldingen na `after` (id).""" + after = request.args.get('after', type=int) or 0 + rows = q('SELECT id, title, body FROM notifications WHERE user_id=? AND read_at IS NULL AND id>? ' + 'ORDER BY id DESC LIMIT 5', g.user['id'], after) + return jsonify(count=scalar('SELECT COUNT(*) FROM notifications WHERE user_id=? AND read_at IS NULL', g.user['id']), + items=[{'id': r['id'], 'title': r['title'], 'body': r['body'], + 'url': url_for('notification_open', nid=r['id'])} for r in rows]) + + +# ---------------------------------------------------------------- inbox + @app.route('/inbox') def inbox(): flt = request.args.get('f', 'all') term = request.args.get('q', '').strip() + mbid = request.args.get('mb', type=int) where = {'unread': "e.direction='in' AND e.archived=0 AND e.is_read=0", 'unlinked': "e.direction='in' AND e.archived=0 AND e.contact_id IS NULL", 'sent': "e.direction='out'", 'archived': "e.direction='in' AND e.archived=1"}.get(flt) if not where: flt, where = 'all', "e.direction='in' AND e.archived=0" + mailboxes = user_mailboxes(inactive=True) + if mbid not in [m['id'] for m in mailboxes]: + mbid = None + where += f' AND e.mailbox_id={mbid}' if mbid else f' AND {visible_mail()}' args = [] if term: cols = ('subject', 'from_name', 'from_addr', 'to_addr', 'body') where += ' AND (' + ' OR '.join(f'e.{col} LIKE ?' for col in cols) + ')' args = [f'%{term}%'] * len(cols) - rows = q(f'SELECT e.*, c.name AS contact_name FROM emails e LEFT JOIN contacts c ON c.id=e.contact_id ' + rows = q(f'SELECT e.*, c.name AS contact_name, m.name AS mailbox_name FROM emails e ' + f'LEFT JOIN contacts c ON c.id=e.contact_id LEFT JOIN mailboxes m ON m.id=e.mailbox_id ' f'WHERE {where} ORDER BY e.date DESC LIMIT 300', *args) - return render_template('inbox.html', rows=rows, f=flt, term=term, last_sync=get_setting('last_sync'), - configured=bool(get_setting('imap_host'))) + unread_by = {r['mailbox_id']: r['n'] for r in q( + f"SELECT e.mailbox_id, COUNT(*) AS n FROM emails e WHERE e.direction='in' AND e.is_read=0 AND e.archived=0 " + f"AND {visible_mail()} GROUP BY e.mailbox_id")} + current = next((m for m in mailboxes if m['id'] == mbid), None) + return render_template('inbox.html', rows=rows, f=flt, term=term, mb=mbid, current=current, + mailboxes=[m for m in mailboxes if m['active'] or m['id'] == mbid], unread_by=unread_by) @app.post('/inbox/sync') def inbox_sync(): - count, error = do_sync() - if error: + mbid = request.form.get('mb', type=int) + boxes = [m for m in user_mailboxes() if m['imap_host'] and (not mbid or m['id'] == mbid)] + if not boxes: + flash('Er is geen mailbox met IMAP om op te halen.', 'error') + return redirect(url_for('inbox', mb=mbid)) + count, errors = do_sync(boxes) + for error in errors: flash(error, 'error') - else: - flash(f'{count} nieuwe e-mail(s) opgehaald.') - return redirect(url_for('inbox')) + flash(f'{count} nieuwe e-mail(s) opgehaald.') + return redirect(url_for('inbox', mb=mbid)) @app.post('/inbox/read-all') def inbox_read_all(): - db().execute("UPDATE emails SET is_read=1 WHERE direction='in' AND is_read=0") + mbid = request.form.get('mb', type=int) + cond = f'mailbox_id={mbid}' if mbid in [m['id'] for m in user_mailboxes(inactive=True)] else visible_mail('emails') + db().execute(f"UPDATE emails SET is_read=1 WHERE direction='in' AND is_read=0 AND {cond}") db().commit() flash('Alle e-mail is als gelezen gemarkeerd.') return redirect(back(url_for('inbox'))) +def get_email(eid): + """E-mail uit een mailbox van de gebruiker, anders 404.""" + return q1(f'SELECT e.*, m.name AS mailbox_name FROM emails e LEFT JOIN mailboxes m ON m.id=e.mailbox_id ' + f'WHERE e.id=? AND {visible_mail()}', eid) or abort(404) + + @app.route('/inbox/') def inbox_view(eid): - e = get_or_404('emails', eid) + e = get_email(eid) if not e['is_read']: db().execute('UPDATE emails SET is_read=1 WHERE id=?', (eid,)) db().commit() contact = q1('SELECT * FROM contacts WHERE id=?', e['contact_id']) if e['contact_id'] else None return render_template('email.html', e=e, contact=contact, templates=all_mail_templates(), - vars=mail_vars(contact)) + vars=mail_vars(contact), mailboxes=user_mailboxes(sending=True)) @app.post('/inbox//unread') def inbox_unread(eid): - get_or_404('emails', eid) + e = get_email(eid) db().execute('UPDATE emails SET is_read=0 WHERE id=?', (eid,)) db().commit() - return redirect(url_for('inbox')) + return redirect(url_for('inbox', mb=e['mailbox_id'])) # Archiveren haalt een bericht alleen uit de inbox van de CRM; de mailbox op de server blijft zoals hij is. @app.post('/inbox//archive') def inbox_archive(eid): - e = get_or_404('emails', eid) + e = get_email(eid) db().execute('UPDATE emails SET archived=?, is_read=1 WHERE id=?', (0 if e['archived'] else 1, eid)) db().commit() flash('E-mail teruggezet in de inbox.' if e['archived'] else 'E-mail gearchiveerd.') - return redirect(url_for('inbox')) + return redirect(url_for('inbox', mb=e['mailbox_id'])) @app.post('/inbox//contact') def inbox_contact(eid): - e = get_or_404('emails', eid) + e = get_email(eid) c = q1('SELECT id FROM contacts WHERE email=? COLLATE NOCASE', e['from_addr']) if c: cid = c['id'] @@ -1406,12 +1624,14 @@ MAIL_FIELDS = [('voornaam', 'Voornaam van het contact'), ('naam', 'Volledige naa ('bedrijf', 'Bedrijf van het contact'), ('adres', 'Straat en huisnummer'), ('postcode', 'Postcode'), ('plaats', 'Plaats'), ('model', 'Gekozen woningontwerp'), ('afzender', 'Jouw naam'), ('bedrijfsnaam', 'Onze bedrijfsnaam'), ('bedrijfstelefoon', 'Ons telefoonnummer'), - ('website', 'Onze website')] + ('website', 'Onze website'), ('afmeldlink', 'Afmeldlink (alleen in nieuwsbrieven)')] -def mail_vars(contact=None): - """Waarden voor de velden: afzender en bedrijf altijd, die van het contact alleen als het contact bekend is.""" - values = {'afzender': g.user['name'], 'bedrijfsnaam': get_setting('company_name'), +def mail_vars(contact=None, user=None): + """Waarden voor de velden: afzender (de ingelogde gebruiker, of `user` in een routine) en bedrijf altijd, die van + het contact alleen als het contact bekend is.""" + user = user or g.get('user') + values = {'afzender': user['name'] if user else '', 'bedrijfsnaam': get_setting('company_name'), 'bedrijfstelefoon': get_setting('company_phone'), 'website': get_setting('company_website')} if contact: name = (contact['name'] or '').strip() @@ -1423,6 +1643,12 @@ def mail_vars(contact=None): return values +def send_values(contact=None, user=None, **extra): + """Alle velden voor het versturen: wat niet bekend is, wordt leeg (geen {voornaam} in een verstuurde mail).""" + values = {**dict.fromkeys(k for k, _ in MAIL_FIELDS), **mail_vars(contact, user), **extra} + return {k: v or '' for k, v in values.items()} + + def fill_mail(text, values): return re.sub(r'\{(\w+)\}', lambda m: values.get(m.group(1), m.group(0)), text or '') @@ -1433,7 +1659,7 @@ def all_mail_templates(): def compose_page(m, contact=None): return render_template('mail_new.html', m=m, contact=contact, templates=all_mail_templates(), - vars=mail_vars(contact), + vars=mail_vars(contact), mailboxes=user_mailboxes(sending=True), contacts=q("SELECT name, email FROM contacts WHERE email<>'' ORDER BY updated_at DESC LIMIT 1000")) @@ -1446,12 +1672,13 @@ def mail_new(): a = request.args contact = q1('SELECT * FROM contacts WHERE id=?', a.get('contact', type=int)) if a.get('contact') else None m = {'to': contact['email'] if contact else a.get('to', ''), 'contact_id': contact['id'] if contact else None, - 'template': a.get('template', type=int), + 'template': a.get('template', type=int), 'mailbox_id': a.get('mb', type=int), 'next': url_for('contact_view', cid=contact['id']) if contact else url_for('inbox', f='sent')} - fwd = q1('SELECT * FROM emails WHERE id=?', a.get('forward', type=int)) if a.get('forward') else None + fwd = get_email(a.get('forward', type=int)) if a.get('forward') else None if fwd: subject = fwd['subject'] or '' m['subject'] = subject if subject.lower().startswith(('fwd:', 'fw:')) else f'Fwd: {subject}' + m['mailbox_id'] = fwd['mailbox_id'] sender = f"{fwd['from_name']} <{fwd['from_addr']}>" if fwd['from_name'] else fwd['from_addr'] m['body'] = '\n\n'.join(['', '---------- Doorgestuurd bericht ----------\n' + f"Van: {sender}\nDatum: {fmt_dt(fwd['date'])}\nOnderwerp: {subject}\nAan: {fwd['to_addr']}", @@ -1464,33 +1691,36 @@ def mail_send(): f = request.form to, cc = f.get('to', '').strip(), f.get('cc', '').strip() cid = f.get('contact_id', type=int) - reply = q1('SELECT * FROM emails WHERE id=?', f.get('reply_to', type=int)) if f.get('reply_to') else None + reply = get_email(f.get('reply_to', type=int)) if f.get('reply_to') else None + boxes = user_mailboxes(sending=True) + mb = next((b for b in boxes if b['id'] == f.get('mailbox_id', type=int)), boxes[0] if boxes else None) first = next((addr for _, addr in getaddresses([to]) if addr), '') contact = (q1('SELECT * FROM contacts WHERE id=?', cid) if cid else None) or \ (q1('SELECT * FROM contacts WHERE email=? COLLATE NOCASE', first) if first else None) - values = {**dict.fromkeys(k for k, _ in MAIL_FIELDS), **mail_vars(contact)} - values = {k: v or '' for k, v in values.items()} + values = send_values(contact) subject, body = fill_mail(f.get('subject', '').strip(), values), fill_mail(f.get('body', ''), values) files = [(os.path.basename(u.filename.replace('\\', '/')), u.read(), u.mimetype) for u in request.files.getlist('attachments') if u and u.filename] nxt = safe_next(f.get('next', '')) try: - message_id, warning = mail.send(settings_dict(), to, subject, body, reply['message_id'] if reply else None, - cc, files) + if not mb: + raise RuntimeError('er is geen mailbox met SMTP waarmee je mag versturen (Instellingen › Mailboxen)') + cfg = mailbox_cfg(mb) + message_id, warning = mail.send(cfg, to, subject, body, reply['message_id'] if reply else None, cc, files) except Exception as e: flash(f'Versturen mislukt: {e}' + (' Voeg de bijlagen opnieuw toe.' if files else ''), 'error') return compose_page({'to': to, 'cc': cc, 'subject': subject, 'body': body, 'next': nxt, - 'contact_id': contact['id'] if contact else None, + 'contact_id': contact['id'] if contact else None, 'mailbox_id': mb['id'] if mb else None, 'reply_to': reply['id'] if reply else None}, contact) if warning: flash(warning, 'error') cid = contact['id'] if contact else None - db().execute("INSERT INTO emails (message_id, direction, contact_id, from_addr, to_addr, cc, subject, body, " - "attachments, date, is_read) VALUES (?, 'out', ?, ?, ?, ?, ?, ?, ?, ?, 1)", - (message_id, cid, mail.sender(settings_dict()), to, cc, subject, body, + db().execute("INSERT INTO emails (message_id, direction, mailbox_id, contact_id, from_addr, to_addr, cc, subject, " + "body, attachments, date, is_read) VALUES (?, 'out', ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)", + (message_id, mb['id'], cid, mail.sender(cfg), to, cc, subject, body, ', '.join(name for name, _, _ in files), now())) if cid: - log(cid, 'email', f'E-mail verstuurd: {subject}') + log(cid, 'email', f"E-mail verstuurd vanuit {mb['name']}: {subject}") db().commit() flash('E-mail verstuurd.') return redirect(nxt or back(url_for('inbox'))) @@ -1534,6 +1764,514 @@ def mail_template_delete(tid): return redirect(url_for('mail_templates')) +# ---------------------------------------------------------------- routines +# +# Het raamwerk staat in jobs.py (types registreren, instellingen); hier het uitvoeren en de types van de CRM. +# Een routine draait in een request-context (url_for werkt, g.user ontbreekt: geef een gebruiker mee waar nodig). + +def run_job(job, manual=False): + """Eén routine uitvoeren: (status, melding), of None als hij al draait.""" + started = now() + stale = (datetime.now() - timedelta(minutes=jobs.STALE_MINUTES)).strftime('%Y-%m-%d %H:%M:%S') + claimed = db().execute('UPDATE jobs SET running_since=? WHERE id=? AND (running_since IS NULL OR running_since'' ORDER BY id") + if config.get('mailboxes'): + boxes = [b for b in boxes if b['id'] in config['mailboxes']] + if not boxes: + return 'Geen actieve mailbox met IMAP' + total, errors = do_sync(boxes) + if errors: + raise RuntimeError(f'{total} nieuw; ' + '; '.join(errors)) + return f"{total} nieuwe e-mail(s) uit {len(boxes)} mailbox(en)" + + +@jobs.register('campaigns', 'Nieuwsbrieven versturen', + 'Verstuurt ingeplande nieuwsbrieven per batch over één verbinding, zodat de mailserver de limiet niet ' + 'raakt. Een nieuwsbrief zonder wachtende ontvangers is klaar; de maker krijgt een melding.', + interval=10, default=True, + fields=[jobs.Field('batch', 'Maximaal aantal e-mails per keer', 'number', 30, + 'OVHcloud staat ongeveer 200 e-mails per uur per mailbox toe: 30 per 10 minuten ' + 'blijft daar ruim onder.')]) +def job_campaigns(job, config): + budget = max(1, int(config.get('batch') or 30)) + parts = [] + for c in q("SELECT * FROM campaigns WHERE status IN ('gepland', 'bezig') AND scheduled_at<=? ORDER BY scheduled_at", + now()): + if budget <= 0: + break + sent, failed = send_campaign_batch(c, budget) + budget -= sent + failed + parts.append(f"{c['name']}: {sent} verstuurd" + (f', {failed} mislukt' if failed else '')) + return '; '.join(parts) or 'Geen nieuwsbrief aan de beurt' + + +@jobs.register('cleanup', 'Opschonen', 'Verwijdert gelezen meldingen die ouder zijn dan het ingestelde aantal dagen.', + interval=24 * 60, default=True, + fields=[jobs.Field('days', 'Gelezen meldingen bewaren (dagen)', 'number', 30)]) +def job_cleanup(job, config): + limit = (datetime.now() - timedelta(days=max(1, int(config.get('days') or 30)))).strftime('%Y-%m-%d %H:%M:%S') + n = db().execute('DELETE FROM notifications WHERE read_at IS NOT NULL AND created_at', methods=['GET', 'POST'], defaults={'jid': None}) +@app.route('/routines/', methods=['GET', 'POST'], defaults={'kind': None}) +def routine_edit(jid, kind): + job = get_or_404('jobs', jid) if jid else None + kind = job['kind'] if job else kind + jt = jobs.TYPES.get(kind) or abort(404) + if request.method == 'POST': + f = request.form + unit = f.get('unit', type=int) if f.get('unit', type=int) in dict(jobs.UNITS) else 1 + data = {'name': f.get('name', '').strip() or jt.label, 'active': 1 if f.get('active') else 0, + 'interval_minutes': max(1, (f.get('every', type=int) or 1) * unit), + 'config': json.dumps(jobs.clean_config(kind, f), ensure_ascii=False)} + if job: + # nieuw interval: de volgende keer rekent vanaf de laatste keer + data['next_run'] = ((datetime.fromisoformat(job['last_run']) + timedelta(minutes=data['interval_minutes'])) + .strftime('%Y-%m-%d %H:%M:%S') if job['last_run'] else None) + db().execute(f'UPDATE jobs SET {", ".join(f"{k}=?" for k in data)} WHERE id=?', [*data.values(), jid]) + else: + data.update(kind=kind, created_at=now()) + jid = db().execute(f'INSERT INTO jobs ({", ".join(data)}) VALUES ({", ".join("?" * len(data))})', + list(data.values())).lastrowid + db().commit() + flash('Routine opgeslagen.') + return redirect(url_for('routine_edit', jid=jid)) + config = {**jobs.defaults(kind), **json.loads(job['config'] if job else '{}')} + minutes = job['interval_minutes'] if job else jt.interval + unit = next(size for size, _ in reversed(jobs.UNITS) if minutes % size == 0) + return render_template('routine_form.html', job=job, jt=jt, config=config, every=minutes // unit, unit=unit, + UNITS=jobs.UNITS, mailboxes=q('SELECT id, name FROM mailboxes ORDER BY name COLLATE NOCASE'), + runs=q('SELECT * FROM job_runs WHERE job_id=? ORDER BY id DESC LIMIT 30', jid) if job else []) + + +@app.post('/routines//run') +def routine_run(jid): + result = run_job(get_or_404('jobs', jid), manual=True) + if result is None: + flash('Deze routine draait al.', 'error') + else: + flash(f'Uitgevoerd: {result[1]}' if result[0] == 'ok' else f'Mislukt: {result[1]}', + 'message' if result[0] == 'ok' else 'error') + return redirect(back(url_for('routines'))) + + +@app.post('/routines//delete') +def routine_delete(jid): + get_or_404('jobs', jid) + db().execute('DELETE FROM jobs WHERE id=?', (jid,)) + db().commit() + flash('Routine verwijderd.') + return redirect(url_for('routines')) + + +# ---------------------------------------------------------------- segmenten en nieuwsbrieven +# +# Een segment is een opgeslagen filter op contacten. Een nieuwsbrief legt bij het inplannen de ontvangers vast +# (campaign_recipients); de routine "Nieuwsbrieven versturen" verstuurt ze per batch. Afgemelde en onbestelbare +# contacten vallen altijd buiten een segment; per e-mailadres gaat er maar één mail uit. + +CAMPAIGN_STATUSES = [('concept', 'Concept'), ('gepland', 'Gepland'), ('bezig', 'Bezig'), ('gepauzeerd', 'Gepauzeerd'), + ('verzonden', 'Verzonden'), ('gestopt', 'Gestopt')] +RECIPIENT_STATUSES = [('wacht', 'Wacht'), ('verzonden', 'Verzonden'), ('mislukt', 'Mislukt'), + ('overgeslagen', 'Overgeslagen')] + + +def segment_where(f): + """SQL-voorwaarde op contacten (alias c) uit de filters van een segment.""" + where, args = ["c.email<>''", 'c.unsubscribed_at IS NULL', 'c.bounced_at IS NULL'], [] + + def among(col, values): + if values: + where.append(f"{col} IN ({', '.join('?' * len(values))})") + args.extend(values) + among('c.type', f.get('types')) + among('c.status', f.get('statuses')) + among('c.source', f.get('sources')) + among('c.assigned_user_id', f.get('users')) + if f.get('city'): + where.append('c.city LIKE ?') + args.append(f"%{f['city']}%") + if f.get('created_from'): + where.append('c.created_at>=?') + args.append(f['created_from']) + if f.get('created_to'): + where.append('c.created_at', methods=['GET', 'POST']) +def segment_edit(sid): + seg = get_or_404('segments', sid) if sid else None + filters = json.loads(seg['filters'] or '{}') if seg else {} + name = seg['name'] if seg else '' + if request.method == 'POST': + filters, name = segment_filters(request.form), request.form.get('name', '').strip() + if request.form.get('action') == 'save': + if not name: + flash('Geef het segment een naam.', 'error') + else: + data = (name, json.dumps(filters, ensure_ascii=False), now()) + if seg: + db().execute('UPDATE segments SET name=?, filters=?, updated_at=? WHERE id=?', (*data, sid)) + else: + sid = db().execute('INSERT INTO segments (name, filters, updated_at, created_at) VALUES (?, ?, ?, ?)', + (*data, now())).lastrowid + db().commit() + flash('Segment opgeslagen.') + return redirect(url_for('segment_edit', sid=sid)) + ids = segment_ids(filters) + sample = q(f"SELECT * FROM contacts WHERE id IN ({', '.join(map(str, ids[:25])) or 'NULL'}) ORDER BY name") if ids else [] + return render_template('segment_form.html', seg=seg, name=name, filters=filters, count=len(ids), sample=sample, + sources=[(r[0], r[0]) for r in q("SELECT DISTINCT source FROM contacts WHERE source<>'' ORDER BY source")], + users=[(u['id'], u['name']) for u in q('SELECT id, name FROM users ORDER BY name')]) + + +@app.post('/segments//delete') +def segment_delete(sid): + get_or_404('segments', sid) + db().execute('DELETE FROM segments WHERE id=?', (sid,)) + db().commit() + flash('Segment verwijderd.') + return redirect(url_for('segments')) + + +def unsubscribe_token(cid): + return hmac.new(app.secret_key.encode(), f'afmelden:{cid}'.encode(), hashlib.sha256).hexdigest()[:32] + + +def campaign_message(c, cfg, contact, email, creator, base_url): + """Het bericht voor één ontvanger: velden ingevuld, afmeldlink (ook als List-Unsubscribe, één klik).""" + link = base_url.rstrip('/') + url_for('unsubscribe', cid=contact['id'], token=unsubscribe_token(contact['id'])) + values = send_values(contact, creator, afmeldlink=link) + body = fill_mail(c['body'], values) + if '{afmeldlink}' not in (c['body'] or ''): + body += f'\n\n--\nGeen nieuwsbrieven meer ontvangen? Meld u af: {link}' + return mail.build(cfg, email, fill_mail(c['subject'], values), body, + headers={'List-Unsubscribe': f'<{link}>', 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click', + 'Precedence': 'bulk'}) + + +def send_campaign_batch(c, limit): + """Volgende ontvangers van een nieuwsbrief versturen: (verstuurd, mislukt). Een weigering van de server voor + een adres telt als mislukt; andere SMTP-fouten (limiet, verbinding) stoppen de batch, de rest wacht.""" + mb = q1('SELECT * FROM mailboxes WHERE id=?', c['mailbox_id']) if c['mailbox_id'] else None + creator = q1('SELECT * FROM users WHERE id=?', c['created_by']) if c['created_by'] else None + if not mb or not mb['smtp_host'] or not mb['active']: + db().execute("UPDATE campaigns SET status='gepauzeerd', updated_at=? WHERE id=?", (now(), c['id'])) + if creator: + notify([creator['id']], f"Nieuwsbrief {c['name']} gepauzeerd", 'De mailbox kan niet versturen.', + url_for('campaign_edit', cid=c['id']), 'campaign') + db().commit() + return 0, 0 + if c['status'] == 'gepland': + db().execute("UPDATE campaigns SET status='bezig', started_at=?, updated_at=? WHERE id=?", (now(), now(), c['id'])) + db().commit() + rows = q("SELECT * FROM campaign_recipients WHERE campaign_id=? AND status='wacht' ORDER BY id LIMIT ?", + c['id'], limit) + cfg, sent, failed, stop = mailbox_cfg(mb), 0, 0, None + if rows: + with mail.Outbox(cfg) as out: + for r in rows: + contact = q1('SELECT * FROM contacts WHERE id=?', r['contact_id']) if r['contact_id'] else None + if not contact or contact['unsubscribed_at'] or contact['bounced_at']: + db().execute("UPDATE campaign_recipients SET status='overgeslagen' WHERE id=?", (r['id'],)) + db().commit() + continue + try: + out.send(campaign_message(c, cfg, contact, r['email'], creator, c['base_url'])) + except mail.smtplib.SMTPRecipientsRefused as e: + failed += 1 + db().execute("UPDATE campaign_recipients SET status='mislukt', error=? WHERE id=?", + (str(e)[:500], r['id'])) + except mail.smtplib.SMTPException as e: + stop = str(e) or type(e).__name__ + break + else: + sent += 1 + db().execute("UPDATE campaign_recipients SET status='verzonden', sent_at=? WHERE id=?", + (now(), r['id'])) + log(contact['id'], 'email', f"Nieuwsbrief verstuurd: {c['name']}") + db().commit() + if stop: + raise RuntimeError(f"{c['name']}: {sent} verstuurd, daarna gestopt: {stop}") + if not scalar("SELECT COUNT(*) FROM campaign_recipients WHERE campaign_id=? AND status='wacht'", c['id']): + db().execute("UPDATE campaigns SET status='verzonden', finished_at=?, updated_at=? WHERE id=?", + (now(), now(), c['id'])) + if creator: + stats = campaign_stats(c['id']) + notify([creator['id']], f"Nieuwsbrief {c['name']} is verzonden", + f"{stats['verzonden']} verzonden, {stats['mislukt']} mislukt, {stats['overgeslagen']} overgeslagen", + url_for('campaign_edit', cid=c['id']), 'campaign') + db().commit() + return sent, failed + + +def campaign_stats(cid): + stats = dict.fromkeys(dict(RECIPIENT_STATUSES), 0) + stats.update({r['status']: r['n'] for r in q('SELECT status, COUNT(*) AS n FROM campaign_recipients ' + 'WHERE campaign_id=? GROUP BY status', cid)}) + stats['totaal'] = sum(stats.values()) + return stats + + +def newsletter_mailboxes(): + """Mailboxen waarmee de gebruiker kan versturen, nieuwsbriefmailboxen eerst.""" + return sorted(user_mailboxes(sending=True), key=lambda m: m['purpose'] != 'nieuwsbrief') + + +@app.route('/campaigns') +def campaigns(): + rows = [dict(r, stats=campaign_stats(r['id'])) for r in q( + 'SELECT c.*, s.name AS segment_name, m.name AS mailbox_name FROM campaigns c ' + 'LEFT JOIN segments s ON s.id=c.segment_id LEFT JOIN mailboxes m ON m.id=c.mailbox_id ORDER BY c.id DESC')] + return render_template('campaigns.html', rows=rows, CSTATUS=dict(CAMPAIGN_STATUSES)) + + +@app.post('/campaigns/new') +def campaign_new(): + boxes = newsletter_mailboxes() + cid = db().execute('INSERT INTO campaigns (name, mailbox_id, created_by, created_at, updated_at) ' + 'VALUES (?, ?, ?, ?, ?)', (f"Nieuwsbrief {date.today().strftime('%d-%m-%Y')}", + boxes[0]['id'] if boxes else None, g.user['id'], now(), now())).lastrowid + db().commit() + return redirect(url_for('campaign_edit', cid=cid)) + + +@app.route('/campaigns/', methods=['GET', 'POST']) +def campaign_edit(cid): + c = get_or_404('campaigns', cid) + if request.method == 'POST': + f, action = request.form, request.form.get('action') + if c['status'] == 'concept' and action in ('save', 'test', 'schedule'): + boxes = {m['id'] for m in newsletter_mailboxes()} + mbid = f.get('mailbox_id', type=int) + db().execute('UPDATE campaigns SET name=?, mailbox_id=?, segment_id=?, subject=?, body=?, updated_at=? ' + 'WHERE id=?', (f.get('name', '').strip() or c['name'], mbid if mbid in boxes else None, + f.get('segment_id', type=int), f.get('subject', '').strip(), f.get('body', ''), + now(), cid)) + db().commit() + c = get_or_404('campaigns', cid) + if action == 'test': + campaign_test(c) + elif action == 'schedule' and c['status'] == 'concept': + campaign_schedule(c, f.get('scheduled_at', '')) + elif action == 'pause' and c['status'] in ('gepland', 'bezig'): + db().execute("UPDATE campaigns SET status='gepauzeerd', updated_at=? WHERE id=?", (now(), cid)) + flash('Nieuwsbrief gepauzeerd.') + elif action == 'resume' and c['status'] == 'gepauzeerd': + db().execute("UPDATE campaigns SET status=?, updated_at=? WHERE id=?", + ('bezig' if c['started_at'] else 'gepland', now(), cid)) + flash('Nieuwsbrief gaat verder bij de volgende ronde van de routine.') + elif action == 'stop' and c['status'] in ('gepland', 'bezig', 'gepauzeerd'): + db().execute("UPDATE campaign_recipients SET status='overgeslagen' WHERE campaign_id=? AND status='wacht'", + (cid,)) + db().execute("UPDATE campaigns SET status='gestopt', finished_at=?, updated_at=? WHERE id=?", + (now(), now(), cid)) + flash('Nieuwsbrief gestopt; wie nog niets kreeg, krijgt hem niet meer.') + elif action == 'copy': + new = db().execute('INSERT INTO campaigns (name, mailbox_id, segment_id, subject, body, created_by, ' + 'created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + (f"{c['name']} (kopie)", c['mailbox_id'], c['segment_id'], c['subject'], c['body'], + g.user['id'], now(), now())).lastrowid + db().commit() + return redirect(url_for('campaign_edit', cid=new)) + elif action == 'delete' and c['status'] == 'concept': + db().execute('DELETE FROM campaigns WHERE id=?', (cid,)) + db().commit() + flash('Nieuwsbrief verwijderd.') + return redirect(url_for('campaigns')) + elif action == 'save': + flash('Nieuwsbrief opgeslagen.') + db().commit() + return redirect(url_for('campaign_edit', cid=cid)) + seg = q1('SELECT * FROM segments WHERE id=?', c['segment_id']) if c['segment_id'] else None + page = q('SELECT r.*, ct.name FROM campaign_recipients r LEFT JOIN contacts ct ON ct.id=r.contact_id ' + 'WHERE r.campaign_id=? ORDER BY r.status=\'wacht\', r.sent_at DESC, r.id LIMIT 300', cid) + return render_template('campaign_form.html', c=c, CSTATUS=dict(CAMPAIGN_STATUSES), RSTATUS=dict(RECIPIENT_STATUSES), + mailboxes=newsletter_mailboxes(), segments=q('SELECT * FROM segments ORDER BY name'), + seg_count=len(segment_ids(json.loads(seg['filters'] or '{}'))) if seg else 0, + templates=all_mail_templates(), FIELDS=MAIL_FIELDS, stats=campaign_stats(cid), + recipients=page, mailbox=q1('SELECT * FROM mailboxes WHERE id=?', c['mailbox_id'])) + + +def campaign_ready(c): + """Fout als de nieuwsbrief niet verstuurd kan worden, anders None.""" + mb = q1('SELECT * FROM mailboxes WHERE id=?', c['mailbox_id']) if c['mailbox_id'] else None + if not mb or not mb['smtp_host']: + return 'Kies een mailbox die kan versturen.' + if not c['subject'] or not (c['body'] or '').strip(): + return 'Vul een onderwerp en tekst in.' + return None + + +def campaign_test(c): + """Testmail naar de ingelogde gebruiker, gevuld met het eerste contact van het segment.""" + error = campaign_ready(c) or (None if g.user['email'] else 'Je account heeft geen e-mailadres.') + if error: + flash(error, 'error') + return + seg = q1('SELECT * FROM segments WHERE id=?', c['segment_id']) if c['segment_id'] else None + ids = segment_ids(json.loads(seg['filters'] or '{}')) if seg else [] + contact = q1('SELECT * FROM contacts WHERE id=?', ids[0]) if ids else None + contact = contact or {'id': 0, 'name': g.user['name'], 'email': g.user['email'], 'phone': '', 'company': '', + 'address': '', 'postcode': '', 'city': '', 'design_file_id': None} + cfg = mailbox_cfg(q1('SELECT * FROM mailboxes WHERE id=?', c['mailbox_id'])) + msg = campaign_message(c, cfg, contact, g.user['email'], g.user, request.url_root) + msg.replace_header('Subject', f"[Test] {msg['Subject']}") + try: + with mail.Outbox(cfg) as out: + out.send(msg) + except Exception as e: + flash(f'Testmail mislukt: {e}', 'error') + return + flash(f"Testmail verstuurd naar {g.user['email']}" + (f" (gevuld met {contact['name']})" if ids else '') + '.') + + +def campaign_schedule(c, when): + """Ontvangers vastleggen en inplannen; de routine verstuurt vanaf `when` (leeg = zo snel mogelijk).""" + error = campaign_ready(c) + seg = q1('SELECT * FROM segments WHERE id=?', c['segment_id']) if c['segment_id'] else None + if not error and not seg: + error = 'Kies een segment.' + ids = segment_ids(json.loads(seg['filters'] or '{}')) if seg and not error else [] + if not error and not ids: + error = 'Het segment heeft geen ontvangers.' + try: + at = datetime.fromisoformat(when).strftime('%Y-%m-%d %H:%M:%S') if when else now() + except ValueError: + error = error or 'Ongeldig tijdstip.' + if error: + flash(error, 'error') + return + for cid in ids: + db().execute('INSERT OR IGNORE INTO campaign_recipients (campaign_id, contact_id, email) ' + 'SELECT ?, id, lower(email) FROM contacts WHERE id=?', (c['id'], cid)) + db().execute("UPDATE campaigns SET status='gepland', scheduled_at=?, base_url=?, updated_at=? WHERE id=?", + (at, request.url_root, now(), c['id'])) + if not q1("SELECT id FROM jobs WHERE kind='campaigns' AND active=1"): + flash('Let op: de routine "Nieuwsbrieven versturen" staat uit; zet hem aan bij Routines.', 'error') + flash(f'{len(ids)} ontvanger(s) vastgelegd; versturen begint {fmt_dt(at)} via de routine.') + + +@app.route('/afmelden//', methods=['GET', 'POST']) +def unsubscribe(cid, token): + c = q1('SELECT * FROM contacts WHERE id=?', cid) + valid = c is not None and hmac.compare_digest(token, unsubscribe_token(cid)) + done = False + if valid and request.method == 'POST': + if not c['unsubscribed_at']: + db().execute('UPDATE contacts SET unsubscribed_at=? WHERE id=?', (now(), cid)) + log(cid, 'email', 'Afgemeld voor nieuwsbrieven (afmeldlink)') + db().commit() + done = True + return render_template('unsubscribe.html', valid=valid, done=done or (valid and bool(c['unsubscribed_at'])), + company=get_setting('company_name', 'Van Emous')) + + +@app.post('/contacts//mailstatus') +def contact_mailstatus(cid): + get_or_404('contacts', cid) + db().execute('UPDATE contacts SET unsubscribed_at=NULL, bounced_at=NULL WHERE id=?', (cid,)) + log(cid, 'email', 'Weer aangemeld voor nieuwsbrieven') + db().commit() + flash('Het contact krijgt weer nieuwsbrieven.') + return redirect(url_for('contact_view', cid=cid)) + + # ---------------------------------------------------------------- producten # # Een product wordt herkend aan zijn SKU (hoofdletterongevoelig); zonder SKU aan zijn naam. @@ -2000,20 +2738,14 @@ def settings(): else: for key in SETTING_KEYS: set_setting(key, request.form.get(key, '').strip()) - for key in SECRET_KEYS: - if request.form.get(key): - set_setting(key, request.form[key]) - if action == 'mailtest': - db().commit() - for ok, message in mail.test(settings_dict()): - flash(message, 'message' if ok else 'error') - else: - flash('Instellingen opgeslagen.') + flash('Instellingen opgeslagen.') db().commit() - return redirect(url_for('settings', _anchor='mail' if action == 'mailtest' else None)) + return redirect(url_for('settings')) + mailboxes = q('SELECT m.*, (SELECT GROUP_CONCAT(u.name, \', \') FROM mailbox_users mu JOIN users u ON u.id=mu.user_id ' + 'WHERE mu.mailbox_id=m.id) AS access FROM mailboxes m ORDER BY m.active DESC, m.name COLLATE NOCASE') return render_template('settings.html', s=settings_dict(), api_url=url_for('api_leads', _external=True), - portal_url=url_for('portal_login', _external=True), PROVIDERS=mail.PROVIDERS, - SECURITY=mail.SECURITY) + portal_url=url_for('portal_login', _external=True), mailboxes=mailboxes, + PURPOSE=dict(MAILBOX_PURPOSES)) # ---------------------------------------------------------------- klantportaal @@ -2296,8 +3028,8 @@ def search_groups(term, limit): lambda r: {'title': r['name'], 'url': url_for('product_edit', pid=r['id']), 'sub': ' · '.join(filter(None, [r['sku'], euro(r['price']), '' if r['active'] else 'inactief']))}) cond, args = match('subject', 'from_addr', 'from_name', 'to_addr') - add('E-mail', q(f'SELECT id, subject, from_name, from_addr, to_addr, direction, date FROM emails WHERE {cond} ' - f'ORDER BY date DESC LIMIT ?', *args, limit + 1), + add('E-mail', q(f'SELECT id, subject, from_name, from_addr, to_addr, direction, date FROM emails e ' + f'WHERE ({cond}) AND {visible_mail()} ORDER BY date DESC LIMIT ?', *args, limit + 1), lambda r: {'title': r['subject'], 'url': url_for('inbox_view', eid=r['id']), 'sub': ' · '.join(filter(None, [r['from_name'] or r['from_addr'] if r['direction'] == 'in' else 'aan ' + (r['to_addr'] or ''), fmt_dt(r['date'], '%d-%m-%Y')]))}) @@ -2870,15 +3602,30 @@ def seed_demo(): print(f'{made} demolead(s) aangemaakt') +ensure_default_jobs() + if __name__ == '__main__': if len(sys.argv) > 1 and sys.argv[1] == 'demo': with app.app_context(): seed_demo() sys.exit(0) if len(sys.argv) > 1 and sys.argv[1] == 'sync': - with app.app_context(): - n, err = do_sync() - print(err or f'{n} nieuwe e-mail(s) opgehaald') - sys.exit(1 if err else 0) + with app.test_request_context(): + n, errors = do_sync() + print('\n'.join(errors) or f'{n} nieuwe e-mail(s) opgehaald') + sys.exit(1 if errors else 0) + if len(sys.argv) > 1 and sys.argv[1] in ('tick', 'scheduler'): + # tick: één keer de routines die aan de beurt zijn (cron elke minuut); scheduler: blijft draaien + import signal + import time + signal.signal(signal.SIGTERM, lambda *_: sys.exit(0)) + while True: + with app.test_request_context(): + done = run_due_jobs() + if sys.argv[1] == 'tick' or done: + print(f'{now()} {done} routine(s) uitgevoerd', flush=True) + if sys.argv[1] == 'tick': + sys.exit(0) + time.sleep(30) app.run(host=os.environ.get('HOST', '127.0.0.1'), port=int(os.environ.get('PORT', 5000)), debug=bool(os.environ.get('DEBUG'))) diff --git a/jobs.py b/jobs.py new file mode 100644 index 0000000..11f0dfb --- /dev/null +++ b/jobs.py @@ -0,0 +1,88 @@ +"""Routines: terugkerende taken van de CRM (e-mail ophalen, nieuwsbrieven versturen, …). + +Een routinetype is een functie met @register; een routine (tabel jobs) is een ingestelde instantie van een type +met een interval en eigen instellingen (config, JSON). Er kunnen meerdere routines van hetzelfde type zijn, bijv. +twee keer e-mail ophalen met andere mailboxen en een ander interval. + +Uitvoeren: `python app.py scheduler` (blijft draaien, kijkt elke 30 s; de Docker-container crm-routines) of +`python app.py tick` (één keer, voor cron elke minuut). Een routine draait nooit twee keer tegelijk (running_since +werkt als slot; na STALE_MINUTES geldt een slot als achtergebleven na een crash). + +Een nieuw type toevoegen, in app.py: + + @jobs.register('rapport', 'Weekrapport', 'Mailt elke week de nieuwe leads.', interval=7 * 24 * 60, + fields=[jobs.Field('to', 'Naar', 'email')]) + def job_rapport(job, config): + ... # gewone app-context: db(), q(), notify(), mail… + return '12 leads gemeld' # komt in het logboek; een exception = status fout + +Een standaardroutine die er altijd moet zijn, krijgt `default=True`: die maakt app.py bij het starten één keer +aan (actief, met het standaardinterval). +""" +from dataclasses import dataclass, field + +STALE_MINUTES = 60 +KEEP_RUNS = 200 # logregels per routine + +UNITS = [(1, 'minuten'), (60, 'uur'), (24 * 60, 'dagen')] + + +@dataclass +class Field: + """Instelling van een routinetype. kind: text, number, email, bool, mailboxes (meerkeuze uit de mailboxen).""" + key: str + label: str + kind: str = 'text' + default: object = '' + hint: str = '' + + +@dataclass +class JobType: + key: str + label: str + description: str + run: object + interval: int = 60 # standaardinterval in minuten + fields: list = field(default_factory=list) + default: bool = False # bij het starten één keer aanmaken + + +TYPES = {} + + +def register(key, label, description='', interval=60, fields=(), default=False): + def wrap(fn): + TYPES[key] = JobType(key, label, description, fn, interval, list(fields), default) + return fn + return wrap + + +def defaults(kind): + return {f.key: f.default for f in TYPES[kind].fields} + + +def clean_config(kind, form): + """Instellingen uit een formulier (request.form) volgens de velden van het type.""" + out = {} + for f in TYPES[kind].fields: + if f.kind == 'bool': + out[f.key] = bool(form.get(f.key)) + elif f.kind == 'number': + try: + out[f.key] = int(form.get(f.key) or f.default or 0) + except ValueError: + out[f.key] = f.default + elif f.kind == 'mailboxes': + out[f.key] = [int(v) for v in form.getlist(f.key) if v.isdigit()] + else: + out[f.key] = (form.get(f.key) or '').strip() + return out + + +def interval_text(minutes): + for size, unit in reversed(UNITS): + if minutes % size == 0 and minutes >= size: + n = minutes // size + return {'minuten': f'{n} min', 'uur': f'{n} uur', 'dagen': f'{n} dag' + ('en' if n != 1 else '')}[unit] + return f'{minutes} min' diff --git a/mail.py b/mail.py index d58dedb..9f010e4 100644 --- a/mail.py +++ b/mail.py @@ -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 diff --git a/schema.sql b/schema.sql index a582d04..9e2a1e5 100644 --- a/schema.sql +++ b/schema.sql @@ -198,3 +198,109 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_events_href ON events(user_id, href); CREATE UNIQUE INDEX IF NOT EXISTS idx_events_uid ON events(user_id, uid); CREATE INDEX IF NOT EXISTS idx_events_start ON events(starts_at); CREATE INDEX IF NOT EXISTS idx_events_contact ON events(contact_id); + +-- Mailboxen: elk een eigen IMAP/SMTP-account (info@, facturen@, nieuwsbrief@, persoonlijk). purpose = rol +-- (MAILBOX_PURPOSES in app.py); toegang via mailbox_users, zonder regels is de mailbox gedeeld met iedereen. +CREATE TABLE IF NOT EXISTS mailboxes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + purpose TEXT NOT NULL DEFAULT 'inbox', + active INTEGER NOT NULL DEFAULT 1, + notify INTEGER NOT NULL DEFAULT 1, + imap_host TEXT DEFAULT '', imap_port TEXT DEFAULT '', imap_security TEXT DEFAULT '', imap_user TEXT DEFAULT '', + imap_pass TEXT DEFAULT '', imap_folder TEXT DEFAULT '', imap_days TEXT DEFAULT '', + smtp_host TEXT DEFAULT '', smtp_port TEXT DEFAULT '', smtp_security TEXT DEFAULT '', smtp_user TEXT DEFAULT '', + smtp_pass TEXT DEFAULT '', smtp_sent_folder TEXT DEFAULT '', smtp_from TEXT DEFAULT '', + signature TEXT DEFAULT '', + last_sync TEXT, + last_error TEXT DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS mailbox_users ( + mailbox_id INTEGER NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + PRIMARY KEY (mailbox_id, user_id) +); + +-- Meldingen per gebruiker (bel in de bovenbalk); url = waar de melding heen leidt. +CREATE TABLE IF NOT EXISTS notifications ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + kind TEXT DEFAULT '', + title TEXT NOT NULL, + body TEXT DEFAULT '', + url TEXT DEFAULT '', + created_at TEXT NOT NULL, + read_at TEXT +); +CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications(user_id, read_at); + +-- Routines (jobs.py): een type met eigen instellingen (config, JSON) en een interval in minuten. +-- running_since = slot tegen dubbel draaien. +CREATE TABLE IF NOT EXISTS jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + kind TEXT NOT NULL, + name TEXT NOT NULL, + config TEXT NOT NULL DEFAULT '{}', + interval_minutes INTEGER NOT NULL DEFAULT 60, + active INTEGER NOT NULL DEFAULT 1, + next_run TEXT, + last_run TEXT, + last_status TEXT DEFAULT '', + last_message TEXT DEFAULT '', + running_since TEXT, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS job_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + job_id INTEGER NOT NULL REFERENCES jobs(id) ON DELETE CASCADE, + started_at TEXT NOT NULL, + finished_at TEXT, + status TEXT NOT NULL, + message TEXT DEFAULT '', + manual INTEGER NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_job_runs_job ON job_runs(job_id, started_at); + +-- Segmenten: een opgeslagen selectie van contacten (filters als JSON, zie segment_where in app.py). +CREATE TABLE IF NOT EXISTS segments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + filters TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +-- Nieuwsbrieven (bulkmail): bij het inplannen worden de ontvangers uit het segment vastgelegd; de routine +-- "Nieuwsbrieven versturen" verstuurt ze per batch. base_url = adres van de CRM voor de afmeldlink. +CREATE TABLE IF NOT EXISTS campaigns ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + mailbox_id INTEGER REFERENCES mailboxes(id) ON DELETE SET NULL, + segment_id INTEGER REFERENCES segments(id) ON DELETE SET NULL, + subject TEXT DEFAULT '', + body TEXT DEFAULT '', + status TEXT NOT NULL DEFAULT 'concept', + scheduled_at TEXT, + started_at TEXT, + finished_at TEXT, + base_url TEXT DEFAULT '', + created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS campaign_recipients ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + campaign_id INTEGER NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE, + contact_id INTEGER REFERENCES contacts(id) ON DELETE SET NULL, + email TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'wacht', + error TEXT DEFAULT '', + sent_at TEXT, + UNIQUE (campaign_id, email) +); +CREATE INDEX IF NOT EXISTS idx_campaign_recipients ON campaign_recipients(campaign_id, status); diff --git a/server/setup.sh b/server/setup.sh index ba3fe32..551b046 100755 --- a/server/setup.sh +++ b/server/setup.sh @@ -10,7 +10,8 @@ # Wat het doet: pakketten (Python, nginx, certbot, rsync, sqlite3), tijdzone Europe/Amsterdam, gebruiker `crm` # met /srv/vanemous-crm, SSH-toegang voor releases (zelfde sleutels als de sudo-gebruiker), systemd-dienst, # nginx-site met HTTPS (Let's Encrypt), firewall (alleen SSH, 80, 443), automatische beveiligingsupdates, -# cron voor e-mail ophalen (elke 10 min) en een dagelijkse databaseback-up (7 dagen rond). +# cron voor de routines (elke minuut `app.py tick`: e-mail ophalen, nieuwsbrieven, …) en een dagelijkse +# databaseback-up (7 dagen rond). set -euo pipefail DOMAIN="${1:-}" @@ -100,11 +101,11 @@ else say "let op: certificaat mislukt. Klopt de DNS? Daarna opnieuw: sudo certbot --nginx -d $DOMAIN -m $EMAIL --agree-tos --redirect" fi -# --- cron: e-mail en back-up ------------------------------------------------------------------- -say "cron: e-mail elke 10 minuten, database-back-up elke nacht" +# --- cron: routines en back-up ----------------------------------------------------------------- +say "cron: routines elke minuut, database-back-up elke nacht" cat > /etc/cron.d/$APP <&1 | logger -t $APP-sync +* * * * * crm [ -d $ROOT/current ] && cd $ROOT/current && CRM_DATA=$ROOT/data .venv/bin/python app.py tick 2>&1 | logger -t $APP-routines 15 3 * * * crm [ -f $ROOT/data/crm.db ] && sqlite3 $ROOT/data/crm.db ".backup '$ROOT/data/backups/nacht-\$(date +\%a).db'" EOF chmod 644 /etc/cron.d/$APP diff --git a/static/notify.js b/static/notify.js new file mode 100644 index 0000000..f7b7343 --- /dev/null +++ b/static/notify.js @@ -0,0 +1,32 @@ +// Bel in de bovenbalk: elke minuut het aantal ongelezen meldingen ophalen (/notifications/poll). Met toestemming +// van de browser verschijnen nieuwe meldingen ook als bureaubladmelding (zolang de CRM ergens open staat). +(() => { + const bell = document.querySelector('.bell[data-poll]'); + if (!bell) return; + const count = bell.querySelector('.bell-count'); + const desktop = bell.querySelector('.bell-desktop'); + let last = Number(bell.dataset.last) || 0; + const canNotify = 'Notification' in window; + if (canNotify && Notification.permission === 'default') { + desktop.hidden = false; + desktop.addEventListener('click', () => Notification.requestPermission().then(() => { desktop.hidden = true; })); + } + async function poll() { + try { + const r = await fetch(`${bell.dataset.poll}?after=${last}`, { headers: { Accept: 'application/json' } }); + if (!r.ok) return; + const d = await r.json(); + count.textContent = d.count; + count.hidden = !d.count; + d.items.slice().reverse().forEach((n) => { + last = Math.max(last, n.id); + if (canNotify && Notification.permission === 'granted') { + const note = new Notification(n.title, { body: n.body || '', tag: `crm-${n.id}` }); + note.onclick = () => { window.focus(); location = n.url; }; + } + }); + } catch (e) { /* offline of uitgelogd: volgende keer opnieuw */ } + } + setInterval(() => { if (!document.hidden) poll(); }, 60000); + document.addEventListener('visibilitychange', () => { if (!document.hidden) poll(); }); +})(); diff --git a/static/style.css b/static/style.css index 95b8f48..bab7bfb 100644 --- a/static/style.css +++ b/static/style.css @@ -371,3 +371,22 @@ tr.link td { transition: background-color 0.2s; } .mail-fields { list-style: none; padding: 0; margin: 0; display: grid; gap: 0.4rem; } .mail-fields li { display: flex; align-items: center; gap: 0.6rem; font-size: 0.9rem; } .mail-fields .btn { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; flex: none; } + +/* Meldingen (bel in de bovenbalk) */ +.bell .icon-btn { position: relative; } +.bell-count { position: absolute; top: -4px; right: -4px; min-width: 18px; height: 18px; padding: 0 5px; border-radius: 999px; background: var(--orange); color: var(--white); font-size: 0.7rem; font-weight: 700; line-height: 18px; text-align: center; } +.bell-menu { width: min(360px, calc(100vw - 2rem)); padding: 0; overflow: hidden; } +.bell-head, .bell-foot { display: flex; justify-content: space-between; align-items: center; gap: 1rem; padding: 0.6rem 1rem; } +.bell-head { border-bottom: 1px solid var(--line); } +.bell-foot { border-top: 1px solid var(--line); font-size: 0.875rem; } +.bell-menu .dropdown-item { display: flex; flex-direction: column; white-space: normal; padding: 0.6rem 1rem; border-bottom: 1px solid var(--line); } +.bell-menu .dropdown-item span { color: var(--ink-muted); font-size: 0.875rem; overflow-wrap: anywhere; } +.bell-menu .dropdown-item small { color: var(--ink-muted); font-size: 0.75rem; } +.bell-menu .dropdown-item.unread { background: var(--orange-tint-soft); } + +/* Nieuwsbrieven */ +.chip.campaign-gepland, .chip.campaign-bezig { background: var(--orange-tint); color: var(--orange-dark); } +.chip.campaign-verzonden { background: var(--orange); color: var(--white); } +.chip.campaign-gestopt, .chip.campaign-gepauzeerd { background: var(--light); color: var(--ink-muted); } +.schedule { margin-top: 1.5rem; padding-top: 1.25rem; border-top: 1px solid var(--line); } +.progress-bar { background: var(--orange); } diff --git a/templates/_compose.html b/templates/_compose.html index 601cbec..c179737 100644 --- a/templates/_compose.html +++ b/templates/_compose.html @@ -1,12 +1,20 @@ {# E-mail opstellen (nieuw, beantwoorden, vanaf een contact). m = {to, cc, subject, body, contact_id, reply_to, next, - template}. Een template zet zijn tekst boven het bericht; velden als {voornaam} vult static/compose.js meteen in met + template, mailbox_id}; mailboxes = waarmee de gebruiker mag versturen (Van). Een template zet zijn tekst boven het bericht; velden als {voornaam} vult static/compose.js meteen in met vars, wat overblijft vult mail_send bij het versturen in. #} -{% macro compose(m, templates, vars, id='m', datalist='', button='Versturen') %} +{% macro compose(m, templates, vars, mailboxes, id='m', datalist='', button='Versturen') %}
{% if m.reply_to %}{% endif %} {% if m.contact_id %}{% endif %} {% if m.next %}{% endif %}
+ {% if not mailboxes %} +
Er is geen mailbox waarmee je mag versturen. Een beheerder stelt die in bij Instellingen › Mailboxen.
+ {% elif mailboxes|length == 1 %} + + {% else %} +
+
+ {% endif %} {% if templates %}
+
+
+
+ +

{% if c.segment_id and editable %}Nu {{ seg_count }} ontvanger(s). {% endif %}Segmenten beheren

+ {% if templates and editable %} +
+
+ {% endif %} +
+
+

Velden worden per ontvanger ingevuld: {% for k, l in FIELDS %}{{ '{' ~ k ~ '}' }} {% endfor %}. Zonder {afmeldlink} komt de afmeldlink onderaan.

+
+ +
+ {% if editable %} + + + {% endif %} +
+ {% if editable %} +
+

Versturen

+

Bij inplannen worden de ontvangers uit het segment vastgelegd. Daarna verstuurt de routine "Nieuwsbrieven versturen" ze per batch.

+
+
+
+
+
+ {% endif %} +
+ {% if editable %} +
+ {% endif %} + + {% if recipients %} +
+
+

Ontvangers

+
    {% for r in recipients %}
  • {% if r.contact_id %}{{ r.name or r.email }}{% else %}{{ r.email }}{% endif %} + {{ RSTATUS.get(r.status, r.status) }}
    + {{ r.email }}{% if r.sent_at %} · {{ r.sent_at|dt }}{% endif %}{% if r.error %} · {{ r.error }}{% endif %}
  • {% endfor %}
+ {% if stats.totaal > recipients|length %}

De eerste {{ recipients|length }} van {{ stats.totaal }}.

{% endif %} +
+
+ {% endif %} + +{% endblock %} +{% block scripts %}{% if editable %}{% endif %}{% endblock %} diff --git a/templates/campaigns.html b/templates/campaigns.html new file mode 100644 index 0000000..7cf4b35 --- /dev/null +++ b/templates/campaigns.html @@ -0,0 +1,33 @@ +{% extends 'base.html' %} +{% block title %}Nieuwsbrieven{% endblock %} +{% block content %} +
+
E-mail

Nieuwsbrieven

+
+ Segmenten + Templates +
+
+
+
+ {% if rows %} +
+ + + {% for c in rows %} + + + + + + + + + {% endfor %} + +
NaamSegmentVanStatusVerzondenGepland
+ {% else %}

Nog geen nieuwsbrieven. Maak eerst een segment (wie krijgt hem) en eventueel een template.

{% endif %} +
+

Nieuwsbrieven gaan per batch de deur uit via de routine "Nieuwsbrieven versturen". Elke mail krijgt een afmeldlink; + wie zich afmeldt of onbestelbaar blijkt, valt voortaan buiten elk segment.

+{% endblock %} diff --git a/templates/contact.html b/templates/contact.html index 68f40d7..9961961 100644 --- a/templates/contact.html +++ b/templates/contact.html @@ -122,11 +122,17 @@ + {% if c.unsubscribed_at or c.bounced_at %} +
+ {% if c.bounced_at %}Het e-mailadres is onbestelbaar (sinds {{ c.bounced_at|dt }}){% else %}Afgemeld voor nieuwsbrieven op {{ c.unsubscribed_at|dt }}{% endif %}; dit contact krijgt geen nieuwsbrieven. +
+
+ {% endif %} {% if c.email %}
E-mail sturen - {{ compose({'to': c.email, 'contact_id': c.id}, mail_templates, mail_vars) }} + {{ compose({'to': c.email, 'contact_id': c.id}, mail_templates, mail_vars, mailboxes) }}
{% endif %} diff --git a/templates/email.html b/templates/email.html index 325953c..ea210a7 100644 --- a/templates/email.html +++ b/templates/email.html @@ -5,12 +5,12 @@ {% set incoming = e.direction == 'in' %}
- ← {{ 'Verzonden' if not incoming else ('Archief' if e.archived else 'Inbox') }} + ← {{ 'Verzonden' if not incoming else ('Archief' if e.archived else 'Inbox') }}

{{ e.subject }}

{% if incoming %}Van {{ e.from_name }} <{{ e.from_addr }}>{% else %}Aan {{ e.to_addr }}{% endif %} {% if e.cc %}· CC {{ e.cc }}{% endif %} - · {{ e.date|dt }} + · {{ e.date|dt }}{% if e.mailbox_name %} · {{ e.mailbox_name }}{% endif %}
{% if e.attachments %}
Bijlagen: {{ e.attachments }}
{% endif %}
@@ -34,7 +34,7 @@

Beantwoorden

{{ compose({'to': e.from_addr, 'subject': e.subject if e.subject.lower().startswith('re:') else 'Re: ' ~ e.subject, - 'reply_to': e.id, 'contact_id': contact.id if contact else None}, templates, vars) }} + 'reply_to': e.id, 'contact_id': contact.id if contact else None, 'mailbox_id': e.mailbox_id}, templates, vars, mailboxes) }}
{% endif %} {% endblock %} diff --git a/templates/inbox.html b/templates/inbox.html index 9d00633..c02c94d 100644 --- a/templates/inbox.html +++ b/templates/inbox.html @@ -3,41 +3,55 @@ {% block content %} {% set sent = f == 'sent' %}
-
E-mail

{{ {'sent': 'Verzonden', 'archived': 'Archief'}.get(f, 'Inbox') }}

{% if last_sync %}
Laatst opgehaald: {{ last_sync|dt }}
{% endif %}
+
E-mail{% if current %} · {{ current.name }}{% endif %}

{{ {'sent': 'Verzonden', 'archived': 'Archief'}.get(f, 'Inbox') }}

+ {% if current and current.last_sync %}
Laatst opgehaald: {{ current.last_sync|dt }}
{% endif %}
Templates -
- Nieuwe e-mail +
{% if mb %}{% endif %}
+ Nieuwe e-mail
-{% if not configured %} -
Koppel eerst je mailbox bij Instellingen (IMAP voor ontvangen, SMTP voor versturen).
+{% if not mailboxes %} +
Er is nog geen mailbox voor jou. {% if g.user.role == 'admin' %}Voeg er een toe bij Instellingen.{% else %}Vraag een beheerder om er een in te stellen.{% endif %}
+{% elif current and current.last_error %} +
{{ current.name }}: laatste ophaalpoging mislukt: {{ current.last_error }}
+{% endif %} + +{% if mailboxes|length > 1 %} + {% endif %}
{% for k, l in [('all', 'Inbox'), ('unread', 'Ongelezen'), ('unlinked', 'Niet gekoppeld aan contact'), ('sent', 'Verzonden'), ('archived', 'Archief')] %} - {{ l }}{% if k == 'unread' and unread %} {{ unread }}{% endif %} + {{ l }} {% endfor %} - {% if unread and not sent %} -
+ {% if (unread_by.get(mb) if mb else unread) and not sent %} +
{% if mb %}{% endif %}
{% endif %}
{% if rows %}
- + {% if not mb and mailboxes|length > 1 %}{% endif %} {% for e in rows %} + {% if not mb and mailboxes|length > 1 %}{% endif %} {% endfor %} @@ -45,5 +59,5 @@
{{ 'Aan' if sent else 'Van' }}OnderwerpContactDatum
{{ 'Aan' if sent else 'Van' }}OnderwerpContactMailboxDatum
{% else %}

{{ 'Niets gevonden.' if term else 'Geen berichten.' }}

{% endif %}
-{% if f == 'archived' %}

Archiveren haalt een bericht alleen uit de inbox van de CRM; in je mailbox blijft het staan.

{% endif %} +{% if f == 'archived' %}

Archiveren haalt een bericht alleen uit de inbox van de CRM; in de mailbox op de server blijft het staan.

{% endif %} {% endblock %} diff --git a/templates/mail_new.html b/templates/mail_new.html index e611d57..9ea2dd2 100644 --- a/templates/mail_new.html +++ b/templates/mail_new.html @@ -8,7 +8,7 @@
- {{ compose(m, templates, vars, datalist='mail-contacts') }} + {{ compose(m, templates, vars, mailboxes, datalist='mail-contacts') }} {% if not templates %}

Nog geen templates. Maak een template voor berichten die je vaker stuurt.

{% endif %}
{% for c in contacts %}{% endfor %} diff --git a/templates/mailbox_form.html b/templates/mailbox_form.html new file mode 100644 index 0000000..fa186db --- /dev/null +++ b/templates/mailbox_form.html @@ -0,0 +1,91 @@ +{% extends 'base.html' %} +{% block title %}{{ mb.name or 'Nieuwe mailbox' }}{% endblock %} +{% macro field(key, label, type='text', placeholder='', full=false) %} +
+{% endmacro %} +{% macro security(key) %} +
+
+{% endmacro %} +{% macro secret(key, label, empty='') %} +
+{% endmacro %} +{% block content %} +
+
← Instellingen

{{ mb.name if mb.id else 'Nieuwe mailbox' }}

+ {% if mb.last_error %}
Laatste fout bij ophalen: {{ mb.last_error }}
{% endif %}
+
+
+
+

Algemeen

+
+
+
+ +

Nieuwsbrieven kiezen standaard een nieuwsbriefmailbox.

+ {{ field('smtp_from', 'Afzender', placeholder='Van Emous ') }} +
+
+
+
+
+

Toegang

+

Niemand aangevinkt = gedeeld met iedereen. Vink alleen jezelf aan voor een persoonlijke mailbox.

+
+ {% for u in USERS %}
{% endfor %} +
+
+ +
+

Verbinding

+

Bij OVHcloud (Zimbra of MX Plan) is de gebruikersnaam het volledige e-mailadres en heeft elke mailbox een eigen wachtwoord. + Gmail werkt met een app-wachtwoord. Wachtwoorden staan in de database: houd de map data/ privé.

+
+
+ +

Vult server, poort en beveiliging in; daarna kun je alles nog aanpassen.

+
+
+

Ontvangen (IMAP)

+
+ {{ field('imap_host', 'Server', placeholder='imap.mail.ovh.net') }} + {{ field('imap_port', 'Poort', 'number', '993') }} + {{ security('imap_security') }} + {{ field('imap_user', 'Gebruikersnaam', placeholder='info@vanemouswoningen.nl') }} + {{ secret('imap_pass', 'Wachtwoord') }} + {{ field('imap_folder', 'Map', placeholder='INBOX') }} + {{ field('imap_days', 'Aantal dagen terug ophalen', 'number', '30') }} +
+

Versturen (SMTP)

+
+ {{ field('smtp_host', 'Server', placeholder='smtp.mail.ovh.net') }} + {{ field('smtp_port', 'Poort', 'number', '465') }} + {{ security('smtp_security') }} + {{ field('smtp_user', 'Gebruikersnaam', placeholder='leeg = zelfde als IMAP') }} + {{ secret('smtp_pass', 'Wachtwoord', 'leeg = zelfde als IMAP') }} +

Bij Zimbra/MX Plan Sent, zodat verstuurde mail ook in de webmail staat. Gmail doet dit zelf. Nieuwsbrieven komen er niet in.

+
+
+ +
+ + + Annuleren +
+
+{% endblock %} +{% block scripts %} + +{% endblock %} diff --git a/templates/notifications.html b/templates/notifications.html new file mode 100644 index 0000000..94d7f17 --- /dev/null +++ b/templates/notifications.html @@ -0,0 +1,24 @@ +{% extends 'base.html' %} +{% block title %}Meldingen{% endblock %} +{% block content %} +
+
Voor jou

Meldingen

+ {% if bell_unread %}
{% endif %} +
+
+ {% if rows %} +
+ + + {% for n in rows %} + + + + + + {% endfor %} + +
MeldingDetailsWanneer
+ {% else %}

Geen meldingen.

{% endif %} +
+{% endblock %} diff --git a/templates/routine_form.html b/templates/routine_form.html new file mode 100644 index 0000000..846d072 --- /dev/null +++ b/templates/routine_form.html @@ -0,0 +1,55 @@ +{% extends 'base.html' %} +{% block title %}{{ job.name if job else jt.label }}{% endblock %} +{% block content %} +
+
← Routines

{{ job.name if job else 'Nieuwe routine' }}

{{ jt.label }}
+ {% if job %}
{% endif %} +
+
+
+
+ {% if jt.description %}

{{ jt.description }}

{% endif %} +
+
+
+
+
+ {% for f in jt.fields %} + {% if f.kind == 'bool' %} +
{% if f.hint %}

{{ f.hint }}

{% endif %}
+ {% elif f.kind == 'mailboxes' %} +
{{ f.label }} +
{% for m in mailboxes %}
{% else %}Nog geen mailboxen.{% endfor %}
+ {% if f.hint %}

{{ f.hint }}

{% endif %}
+ {% else %} +
{% if f.hint %}

{{ f.hint }}

{% endif %}
+ {% endif %} + {% endfor %} +
+
+
+ + Annuleren +
+
+ {% if job %} +
+ +
+ {% endif %} +
+ {% if job %} +
+
+

Logboek

+

Volgende keer: {{ (job.next_run|dt if job.next_run else 'zo snel mogelijk') if job.active else 'staat uit' }}

+ {% if runs %} +
    + {% for r in runs %}
  • {{ r.started_at|dt }} {% if r.status == 'fout' %}Fout{% endif %}{% if r.manual %} (handmatig){% endif %}
    {{ r.message }}
  • {% endfor %} +
+ {% else %}

Nog niet uitgevoerd.

{% endif %} +
+
+ {% endif %} +
+{% endblock %} diff --git a/templates/routines.html b/templates/routines.html new file mode 100644 index 0000000..11ab16e --- /dev/null +++ b/templates/routines.html @@ -0,0 +1,35 @@ +{% extends 'base.html' %} +{% block title %}Routines{% endblock %} +{% block content %} +
+
Beheer

Routines

+
+ +
+
+
+ {% if rows %} +
+ + + {% for j in rows %} + + + + + + + + + + {% endfor %} + +
NaamSoortElkeLaatstVolgendeResultaat
+ {% else %}

Nog geen routines.

{% endif %} +
+

Routines draaien op de achtergrond (Docker: container crm-routines; zonder Docker: cron elke minuut + python app.py tick). Elke halve minuut wordt gekeken welke aan de beurt zijn; een routine draait nooit twee keer tegelijk.

+{% endblock %} diff --git a/templates/segment_form.html b/templates/segment_form.html new file mode 100644 index 0000000..e398cee --- /dev/null +++ b/templates/segment_form.html @@ -0,0 +1,46 @@ +{% extends 'base.html' %} +{% block title %}{{ name or 'Nieuw segment' }}{% endblock %} +{% macro checks(key, options, selected) %} +
{% for v, l in options %}
{% endfor %}
+{% endmacro %} +{% block content %} +
+
← Segmenten

{{ name or 'Nieuw segment' }}

+
+
+
+
+
+
+
Soort{{ checks('types', TYPES, filters.types or []) }}
+
Status{{ checks('statuses', STATUSES, filters.statuses or []) }}
+ {% if sources %}
Bron{{ checks('sources', sources, filters.sources or []) }}
{% endif %} +
Toegewezen aan{{ checks('users', users, filters.users or []) }}
+
+
+
+
+
+

Niets aangevinkt bij een onderdeel = geen beperking daarop.

+
+ + +
+
+ {% if seg %} +
+ +
+ {% endif %} +
+
+
+

{{ count }} ontvanger{{ '' if count == 1 else 's' }}

+ {% if sample %} + + {% if count > sample|length %}

En nog {{ count - sample|length }}.

{% endif %} + {% else %}

Geen contacten met deze filters.

{% endif %} +
+
+
+{% endblock %} diff --git a/templates/segments.html b/templates/segments.html new file mode 100644 index 0000000..8625631 --- /dev/null +++ b/templates/segments.html @@ -0,0 +1,26 @@ +{% extends 'base.html' %} +{% block title %}Segmenten{% endblock %} +{% block content %} + +
+ {% if rows %} +
+ + + {% for s in rows %} + + + + + + {% endfor %} + +
NaamOntvangers nuBijgewerkt
+ {% else %}

Nog geen segmenten.

{% endif %} +
+

Een segment is een selectie van contacten met een e-mailadres. Afgemelde en onbestelbare adressen tellen nooit mee; + per e-mailadres gaat er één nieuwsbrief uit.

+{% endblock %} diff --git a/templates/settings.html b/templates/settings.html index 5022c78..f076d50 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -3,13 +3,35 @@ {% macro field(key, label, type='text', placeholder='', full=false) %}
{% endmacro %} -{% macro security(key) %} -
-
-{% endmacro %} {% block content %}
Beheer

Instellingen

+
+
+

Mailboxen

+

Elke mailbox (info@, facturen@, nieuwsbrief@ of een persoonlijke) heeft een eigen account. Een mailbox + zonder gekozen gebruikers is gedeeld met iedereen. Ophalen gaat via de routine "E-mail ophalen" (Routines).

+ Mailbox toevoegen +
+ {% if mailboxes %} +
+ + + {% for m in mailboxes %} + + + + + + + + + {% endfor %} + +
NaamAdresRolToegangLaatst opgehaaldStatus
+ {% else %}

Nog geen mailbox.

{% endif %} +
+

Bedrijfsgegevens

@@ -37,43 +59,6 @@
-
-

E-mail koppelen

-

Eén gedeelde mailbox (bijv. info@) voor de inbox en het versturen vanuit de CRM. Bij OVHcloud - (Zimbra of MX Plan) is de gebruikersnaam het volledige e-mailadres; de afzender moet dat adres (of een alias ervan) zijn. - Gmail werkt met een app-wachtwoord. Wachtwoorden staan in de database: houd de map data/ privé.

-
-
- -

Vult server, poort en beveiliging in; daarna kun je alles nog aanpassen.

-
-
-

Ontvangen (IMAP)

-
- {{ field('imap_host', 'Server', placeholder='imap.mail.ovh.net') }} - {{ field('imap_port', 'Poort', 'number', '993') }} - {{ security('imap_security') }} - {{ field('imap_user', 'Gebruikersnaam', placeholder='info@vanemouswoningen.nl') }} -
- {{ field('imap_folder', 'Map', placeholder='INBOX') }} - {{ field('imap_days', 'Aantal dagen terug ophalen', 'number', '30') }} -
-

Versturen (SMTP)

-
- {{ field('smtp_host', 'Server', placeholder='smtp.mail.ovh.net') }} - {{ field('smtp_port', 'Poort', 'number', '465') }} - {{ security('smtp_security') }} - {{ field('smtp_user', 'Gebruikersnaam', placeholder='leeg = zelfde als IMAP') }} -
-

Bij Zimbra/MX Plan Sent, zodat verstuurde mail ook in de webmail staat. Gmail doet dit zelf.

- {{ field('smtp_from', 'Afzender', placeholder='Van Emous ', full=true) }} -
-
-
-

Formulieren en API

@@ -145,14 +130,4 @@ const { ok, id } = await res.json();{% endfilter %}
- {% endblock %} diff --git a/templates/unsubscribe.html b/templates/unsubscribe.html new file mode 100644 index 0000000..7ad6e1f --- /dev/null +++ b/templates/unsubscribe.html @@ -0,0 +1,20 @@ +{% extends 'base.html' %} +{% block title %}Afmelden{% endblock %} +{% block body %} +
+
+ {{ company }} +
+

Afmelden

+ {% if not valid %} +

Deze afmeldlink is niet geldig. Neem contact met ons op als u geen nieuwsbrieven meer wilt ontvangen.

+ {% elif done %} +

U bent afgemeld. U ontvangt geen nieuwsbrieven meer van {{ company }}.

+ {% else %} +

Wilt u geen nieuwsbrieven meer ontvangen van {{ company }}?

+
+ {% endif %} +
+
+
+{% endblock %}