v0.0.5: gedeelde mailboxen (info@, facturen@, nieuwsbrief@, persoonlijk) met eigen IMAP/SMTP, afzender, rol en toegang per gebruiker; inbox per mailbox en versturen vanuit een gekozen mailbox; routines (jobs.py: types met @jobs.register, interval, instellingen, logboek, slot tegen dubbel draaien; app.py scheduler/tick) voor e-mail ophalen, nieuwsbrieven versturen en opschonen; meldingen (bel, bureaubladmelding); nieuwsbrieven met segmenten, testmail, inplannen en versturen per batch over één verbinding, afmeldlink met List-Unsubscribe en bounceverwerking; oude mailinstellingen worden de eerste mailbox

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Machiel
2026-10-16 18:25:44 +02:00
co-authored by Claude Opus 5.5
parent 8f6e1ab443
commit cad838f1f4
24 changed files with 1710 additions and 185 deletions
+33 -10
View File
@@ -33,14 +33,27 @@ Eenvoudige CRM in Python (Flask) met SQLite, in de Van Emous-huisstijl. Versie 0
Voorbeelden staan onder Instellingen. Voorbeelden staan onder Instellingen.
- **Import** van CSV en JSON voor leads/contactpersonen, relaties en producten, met een voorbeeld en kolomkoppeling. - **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. 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), - **Mailboxen** (Instellingen › Mailboxen): meerdere accounts naast elkaar (info@, facturen@, nieuwsbrief@,
contact aanmaken vanuit een mail, beantwoorden. Voorinstellingen voor OVHcloud (Zimbra / MX Plan) en Gmail, persoonlijk), elk met eigen IMAP/SMTP, afzender en rol (inbox, facturen, nieuwsbrief). Toegang per gebruiker;
beveiliging per server (SSL/TLS of STARTTLS), een kopie van verstuurde mail in een IMAP-map (`Sent` bij Zimbra) zonder gekozen gebruikers is een mailbox gedeeld met iedereen. Voorinstellingen voor OVHcloud (Zimbra / MX Plan)
en een knop om de verbinding te testen (Instellingen › E-mail). Nieuwe e-mail opstellen (ook doorsturen), met CC en Gmail, beveiliging per server, een kopie van verstuurde mail in een IMAP-map en een verbindingstest.
en bijlagen; mappen Verzonden en Archief (archiveren raakt de mailbox op de server niet), zoeken, als ongelezen - **Inbox**: e-mail uit de eigen mailboxen (per mailbox of samen), automatisch gekoppeld aan contacten (op
markeren en alles als gelezen markeren. 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 - **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). 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 - **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** 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. 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): Demoleads (lorem ipsum, elk met een standaardmodel als gekozen model):
```sh ```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, `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 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), `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: Een versie uitrollen (of terugrollen) vanaf je eigen machine:
@@ -172,11 +193,13 @@ mailwachtwoorden in.
``` ```
app.py routes en logica app.py routes en logica
importer.py CSV/JSON inlezen en kolommen herkennen 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) ical.py iCalendar lezen/schrijven en herhalingen uitvouwen (agenda en CalDAV)
schema.sql database schema.sql database
templates/ pagina's (Jinja) 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) static/configurator/ engine van de configurator (tools/configurator-sync.sh, niet in git)
tools/ configurator-sync.sh, export-models.js (standaardmodellen als JSON) tools/ configurator-sync.sh, export-models.js (standaardmodellen als JSON)
demo/modellen standaardmodellen als house-design-JSON voor de demoleads demo/modellen standaardmodellen als house-design-JSON voor de demoleads
+829 -82
View File
File diff suppressed because it is too large Load Diff
+88
View File
@@ -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'
+80 -19
View File
@@ -36,6 +36,25 @@ def _text(msg):
return re.sub(r'\n{3,}', '\n\n', content).strip() 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). # Voorinstellingen voor Instellingen › E-mail (de pagina vult de velden in; daarna blijft alles aanpasbaar).
PROVIDERS = { PROVIDERS = {
'ovh': {'label': 'OVHcloud Zimbra / MX Plan (Europa)', 'imap_host': 'imap.mail.ovh.net', 'imap_port': '993', '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 return out
def fetch(cfg, known, limit=200): def fetch(cfg, known, limit=200, prefix=''):
"""Haal berichten van de laatste `imap_days` dagen op die nog niet in `known` staan.""" """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' folder = cfg.get('imap_folder') or 'INBOX'
days = int(cfg.get('imap_days') or 30) days = int(cfg.get('imap_days') or 30)
since = datetime.now() - timedelta(days=days) since = datetime.now() - timedelta(days=days)
@@ -144,7 +164,7 @@ def fetch(cfg, known, limit=200):
uids = data[0].split()[-limit:] uids = data[0].split()[-limit:]
out = [] out = []
for uid in uids: for uid in uids:
key = f'{folder}:{uid.decode()}' key = f'{prefix}{folder}:{uid.decode()}'
if key in known: if key in known:
continue continue
_, msgdata = conn.uid('fetch', uid, '(BODY.PEEK[])') _, 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)', 'subject': str(msg.get('Subject', '')) or '(geen onderwerp)',
'body': _text(msg), 'body': _text(msg),
'date': date, 'date': date,
'bounced': bounced(msg),
}) })
return out return out
finally: finally:
@@ -185,9 +206,8 @@ def sender(cfg):
return str(Address(display_name=name, addr_spec=addr)) if name and addr else addr 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=()): def build(cfg, to, subject, body, in_reply_to=None, cc='', attachments=(), headers=None):
"""Versturen; geeft (Message-ID, waarschuwing of None). Een mislukte kopie in Verzonden is alleen een waarschuwing. """Een bericht opbouwen. attachments = [(bestandsnaam, bytes, mimetype)]; headers = extra kopregels."""
attachments = [(bestandsnaam, bytes, mimetype)]."""
msg = EmailMessage() msg = EmailMessage()
msg['From'] = sender(cfg) msg['From'] = sender(cfg)
msg['To'] = to msg['To'] = to
@@ -200,25 +220,66 @@ def send(cfg, to, subject, body, in_reply_to=None, cc='', attachments=()):
if in_reply_to: if in_reply_to:
msg['In-Reply-To'] = in_reply_to msg['In-Reply-To'] = in_reply_to
msg['References'] = in_reply_to msg['References'] = in_reply_to
for key, value in (headers or {}).items():
msg[key] = value
msg.set_content(body) msg.set_content(body)
for name, data, ctype in attachments: for name, data, ctype in attachments:
maintype, _, subtype = (ctype or 'application/octet-stream').partition('/') maintype, _, subtype = (ctype or 'application/octet-stream').partition('/')
msg.add_attachment(data, maintype=maintype, subtype=subtype or 'octet-stream', filename=name) 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: with _connect_smtp(cfg) as server:
server.send_message(msg) server.send_message(msg)
return msg['Message-ID'], save_sent(cfg, msg)
warning = None
folder = cfg.get('smtp_sent_folder') class Outbox:
if folder and cfg.get('imap_host'): """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: try:
conn = _connect_imap(cfg) self.server.send_message(msg)
try: except smtplib.SMTPServerDisconnected:
typ, data = conn.append(_quote(folder), '\\Seen', imaplib.Time2Internaldate(time.time()), msg.as_bytes()) self.server = _connect_smtp(self.cfg)
if typ != 'OK': self.server.send_message(msg)
raise RuntimeError((data[0] or b'').decode('utf-8', 'replace') if data else typ)
finally: def __exit__(self, *exc):
conn.logout() try:
except Exception as e: self.server.quit()
warning = f'Verstuurd, maar niet bewaard in "{folder}": {e}' except Exception:
return msg['Message-ID'], warning pass
+106
View File
@@ -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 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_start ON events(starts_at);
CREATE INDEX IF NOT EXISTS idx_events_contact ON events(contact_id); 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);
+5 -4
View File
@@ -10,7 +10,8 @@
# Wat het doet: pakketten (Python, nginx, certbot, rsync, sqlite3), tijdzone Europe/Amsterdam, gebruiker `crm` # 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, # 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, # 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 set -euo pipefail
DOMAIN="${1:-}" 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" say "let op: certificaat mislukt. Klopt de DNS? Daarna opnieuw: sudo certbot --nginx -d $DOMAIN -m $EMAIL --agree-tos --redirect"
fi fi
# --- cron: e-mail en back-up ------------------------------------------------------------------- # --- cron: routines en back-up -----------------------------------------------------------------
say "cron: e-mail elke 10 minuten, database-back-up elke nacht" say "cron: routines elke minuut, database-back-up elke nacht"
cat > /etc/cron.d/$APP <<EOF cat > /etc/cron.d/$APP <<EOF
# Van Emous CRM (server/setup.sh) # Van Emous CRM (server/setup.sh)
*/10 * * * * crm [ -d $ROOT/current ] && cd $ROOT/current && CRM_DATA=$ROOT/data .venv/bin/python app.py sync 2>&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'" 15 3 * * * crm [ -f $ROOT/data/crm.db ] && sqlite3 $ROOT/data/crm.db ".backup '$ROOT/data/backups/nacht-\$(date +\%a).db'"
EOF EOF
chmod 644 /etc/cron.d/$APP chmod 644 /etc/cron.d/$APP
+32
View File
@@ -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(); });
})();
+19
View File
@@ -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 { 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 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; } .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); }
+10 -2
View File
@@ -1,12 +1,20 @@
{# E-mail opstellen (nieuw, beantwoorden, vanaf een contact). m = {to, cc, subject, body, contact_id, reply_to, next, {# 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. #} 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') %}
<form method="post" action="{{ url_for('mail_send') }}" enctype="multipart/form-data" class="compose" data-vars='{{ vars|tojson }}'> <form method="post" action="{{ url_for('mail_send') }}" enctype="multipart/form-data" class="compose" data-vars='{{ vars|tojson }}'>
{% if m.reply_to %}<input type="hidden" name="reply_to" value="{{ m.reply_to }}">{% endif %} {% if m.reply_to %}<input type="hidden" name="reply_to" value="{{ m.reply_to }}">{% endif %}
{% if m.contact_id %}<input type="hidden" name="contact_id" value="{{ m.contact_id }}">{% endif %} {% if m.contact_id %}<input type="hidden" name="contact_id" value="{{ m.contact_id }}">{% endif %}
{% if m.next %}<input type="hidden" name="next" value="{{ m.next }}">{% endif %} {% if m.next %}<input type="hidden" name="next" value="{{ m.next }}">{% endif %}
<div class="row g-3"> <div class="row g-3">
{% if not mailboxes %}
<div class="col-12"><div class="flash error mb-0">Er is geen mailbox waarmee je mag versturen. Een beheerder stelt die in bij Instellingen › Mailboxen.</div></div>
{% elif mailboxes|length == 1 %}
<input type="hidden" name="mailbox_id" value="{{ mailboxes[0].id }}">
{% else %}
<div class="col-12"><label class="form-label" for="{{ id }}-from">Van</label>
<select class="form-select" id="{{ id }}-from" name="mailbox_id">{% for b in mailboxes %}<option value="{{ b.id }}" {{ 'selected' if m.mailbox_id == b.id }}>{{ b.name }}{% if b.smtp_from or b.smtp_user or b.imap_user %} · {{ b.smtp_from or b.smtp_user or b.imap_user }}{% endif %}</option>{% endfor %}</select></div>
{% endif %}
{% if templates %} {% if templates %}
<div class="col-12"><label class="form-label" for="{{ id }}-template">Template</label> <div class="col-12"><label class="form-label" for="{{ id }}-template">Template</label>
<select class="form-select" id="{{ id }}-template" name="template" autocomplete="off"> <select class="form-select" id="{{ id }}-template" name="template" autocomplete="off">
+22 -2
View File
@@ -20,7 +20,8 @@
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous" defer></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js" defer></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js" defer></script>
<script src="{{ url_for('static', filename='fx.js') }}" defer></script> <script src="{{ url_for('static', filename='fx.js') }}" defer></script>
{% if g.user %}<script src="{{ url_for('static', filename='search.js') }}" defer></script>{% endif %} {% if g.user %}<script src="{{ url_for('static', filename='search.js') }}" defer></script>
<script src="{{ url_for('static', filename='notify.js') }}" defer></script>{% endif %}
{% block styles %}{% endblock %} {% block styles %}{% endblock %}
</head> </head>
<body> <body>
@@ -46,12 +47,14 @@
<a href="{{ url_for('calendar_view') }}" class="{{ 'active' if ep.startswith('calendar') }}">Agenda</a> <a href="{{ url_for('calendar_view') }}" class="{{ 'active' if ep.startswith('calendar') }}">Agenda</a>
<a href="{{ url_for('inbox') }}" class="{{ 'active' if ep.startswith('inbox') or ep in ('mail_new', 'mail_send') }}">Inbox {% if unread %}<span class="count">{{ unread }}</span>{% endif %}</a> <a href="{{ url_for('inbox') }}" class="{{ 'active' if ep.startswith('inbox') or ep in ('mail_new', 'mail_send') }}">Inbox {% if unread %}<span class="count">{{ unread }}</span>{% endif %}</a>
<a href="{{ url_for('mail_templates') }}" class="{{ 'active' if ep.startswith('mail_template') }}">E-mailtemplates</a> <a href="{{ url_for('mail_templates') }}" class="{{ 'active' if ep.startswith('mail_template') }}">E-mailtemplates</a>
<a href="{{ url_for('campaigns') }}" class="{{ 'active' if ep.startswith('campaign') or ep.startswith('segment') }}">Nieuwsbrieven</a>
<a href="{{ url_for('products') }}" class="{{ 'active' if ep.startswith('product') }}">Producten</a> <a href="{{ url_for('products') }}" class="{{ 'active' if ep.startswith('product') }}">Producten</a>
<a href="{{ url_for('quotes') }}" class="{{ 'active' if ep.startswith('quote') }}">Offertes</a> <a href="{{ url_for('quotes') }}" class="{{ 'active' if ep.startswith('quote') }}">Offertes</a>
<a href="{{ url_for('import_start') }}" class="{{ 'active' if ep.startswith('import') }}">Importeren</a> <a href="{{ url_for('import_start') }}" class="{{ 'active' if ep.startswith('import') }}">Importeren</a>
{% if g.user.role == 'admin' %} {% if g.user.role == 'admin' %}
<a href="{{ url_for('users') }}" class="{{ 'active' if ep.startswith('user') }}">Gebruikers</a> <a href="{{ url_for('users') }}" class="{{ 'active' if ep.startswith('user') }}">Gebruikers</a>
<a href="{{ url_for('settings') }}" class="{{ 'active' if ep == 'settings' }}">Instellingen</a> <a href="{{ url_for('routines') }}" class="{{ 'active' if ep.startswith('routine') }}">Routines</a>
<a href="{{ url_for('settings') }}" class="{{ 'active' if ep == 'settings' or ep.startswith('mailbox') }}">Instellingen</a>
{% endif %} {% endif %}
</nav> </nav>
<a href="{{ url_for('account') }}" class="logout">{{ g.user.name }}</a> <a href="{{ url_for('account') }}" class="logout">{{ g.user.name }}</a>
@@ -82,6 +85,22 @@
<div class="dropdown-menu dropdown-menu-end tsearch-pop">{{ search_form('q-pop', sq) }}</div> <div class="dropdown-menu dropdown-menu-end tsearch-pop">{{ search_form('q-pop', sq) }}</div>
</div> </div>
{% if unread %}<a href="{{ url_for('inbox') }}" class="count d-lg-none" aria-label="{{ unread }} ongelezen e-mail">{{ unread }}</a>{% endif %} {% if unread %}<a href="{{ url_for('inbox') }}" class="count d-lg-none" aria-label="{{ unread }} ongelezen e-mail">{{ unread }}</a>{% endif %}
<div class="dropdown bell" data-poll="{{ url_for('notifications_poll') }}" data-last="{{ bell[0].id if bell else 0 }}">
<button class="icon-btn" type="button" data-bs-toggle="dropdown" aria-expanded="false" aria-label="Meldingen{% if bell_unread %}: {{ bell_unread }} ongelezen{% endif %}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>
<span class="bell-count" {{ 'hidden' if not bell_unread }}>{{ bell_unread }}</span>
</button>
<div class="dropdown-menu dropdown-menu-end bell-menu">
<div class="bell-head"><b>Meldingen</b>
{% if bell_unread %}<form method="post" action="{{ url_for('notifications_read_all') }}" class="inline"><button class="btn btn-link btn-sm p-0">Alles gelezen</button></form>{% endif %}</div>
{% for n in bell %}
<a class="dropdown-item {{ 'unread' if not n.read_at }}" href="{{ url_for('notification_open', nid=n.id) }}"><b>{{ n.title }}</b>{% if n.body %}<span>{{ n.body }}</span>{% endif %}<small>{{ n.created_at|dt }}</small></a>
{% else %}
<p class="empty px-3">Geen meldingen.</p>
{% endfor %}
<div class="bell-foot"><a href="{{ url_for('notifications') }}">Alle meldingen</a><button type="button" class="btn btn-link btn-sm p-0 bell-desktop" hidden>Bureaubladmeldingen aanzetten</button></div>
</div>
</div>
<div class="dropdown"> <div class="dropdown">
<button class="btn btn-primary btn-sm new-btn" type="button" data-bs-toggle="dropdown" aria-expanded="false" aria-label="Nieuw"> <button class="btn btn-primary btn-sm new-btn" type="button" data-bs-toggle="dropdown" aria-expanded="false" aria-label="Nieuw">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg><span class="d-none d-sm-inline">Nieuw</span> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg><span class="d-none d-sm-inline">Nieuw</span>
@@ -96,6 +115,7 @@
<li><a class="dropdown-item" href="{{ url_for('product_edit') }}">Product</a></li> <li><a class="dropdown-item" href="{{ url_for('product_edit') }}">Product</a></li>
<li><a class="dropdown-item" href="{{ url_for('calendar_edit') }}">Afspraak</a></li> <li><a class="dropdown-item" href="{{ url_for('calendar_edit') }}">Afspraak</a></li>
<li><a class="dropdown-item" href="{{ url_for('mail_new') }}">E-mail</a></li> <li><a class="dropdown-item" href="{{ url_for('mail_new') }}">E-mail</a></li>
<li><form method="post" action="{{ url_for('campaign_new') }}"><button class="dropdown-item">Nieuwsbrief</button></form></li>
</ul> </ul>
</div> </div>
</div> </div>
+95
View File
@@ -0,0 +1,95 @@
{% extends 'base.html' %}
{% block title %}{{ c.name }}{% endblock %}
{% block content %}
{% set editable = c.status == 'concept' %}
<div class="head">
<div><a class="label" href="{{ url_for('campaigns') }}">← Nieuwsbrieven</a><h1>{{ c.name }}</h1>
<div class="muted"><span class="chip campaign-{{ c.status }}">{{ CSTATUS.get(c.status, c.status) }}</span>
{% if c.scheduled_at and not editable %} · vanaf {{ c.scheduled_at|dt }}{% endif %}{% if c.finished_at %} · klaar {{ c.finished_at|dt }}{% endif %}</div></div>
<div class="actions">
<form method="post" class="inline">
{% if c.status in ('gepland', 'bezig') %}<button class="btn btn-outline-primary" name="action" value="pause">Pauzeren</button>{% endif %}
{% if c.status == 'gepauzeerd' %}<button class="btn btn-primary" name="action" value="resume">Hervatten</button>{% endif %}
{% if c.status in ('gepland', 'bezig', 'gepauzeerd') %}<button class="btn btn-outline-secondary" name="action" value="stop" onclick="return confirm('Stoppen? Wie hem nog niet kreeg, krijgt hem niet meer.')">Stoppen</button>{% endif %}
<button class="btn btn-outline-secondary" name="action" value="copy">Kopie maken</button>
</form>
</div>
</div>
{% if not editable %}
<div class="card"><div class="card-body">
<h2>Voortgang</h2>
{% set pct = (100 * (stats.totaal - stats.wacht) / stats.totaal)|round|int if stats.totaal else 0 %}
<div class="progress mb-3" role="progressbar" aria-label="Voortgang" aria-valuenow="{{ pct }}" aria-valuemin="0" aria-valuemax="100"><div class="progress-bar" style="width: {{ pct }}%"></div></div>
<div class="d-flex flex-wrap gap-4">
{% for k, l in RSTATUS.items() %}<div><div class="muted">{{ l }}</div><b>{{ stats[k] }}</b></div>{% endfor %}
</div>
</div></div>
{% endif %}
<div class="row g-4">
<div class="col-lg-8">
<form method="post" class="card compose" data-vars='{}'><div class="card-body">
<fieldset {{ 'disabled' if not editable }}>
<div class="row g-3">
<div class="col-12"><label class="form-label" for="name">Naam (intern)</label><input class="form-control" id="name" name="name" value="{{ c.name }}" required></div>
<div class="col-md-6"><label class="form-label" for="mailbox_id">Van</label>
<select class="form-select" id="mailbox_id" name="mailbox_id">
{% if not mailboxes %}<option value="">Geen mailbox om mee te versturen</option>{% endif %}
{% for m in mailboxes %}<option value="{{ m.id }}" {{ 'selected' if c.mailbox_id == m.id }}>{{ m.name }}{% if m.smtp_from or m.smtp_user or m.imap_user %} · {{ m.smtp_from or m.smtp_user or m.imap_user }}{% endif %}</option>{% endfor %}
{% if mailbox and mailbox.id not in mailboxes|map(attribute='id')|list %}<option value="{{ mailbox.id }}" selected>{{ mailbox.name }}</option>{% endif %}
</select></div>
<div class="col-md-6"><label class="form-label" for="segment_id">Aan (segment)</label>
<select class="form-select" id="segment_id" name="segment_id" aria-describedby="seg-hint">
<option value="">Kies een segment…</option>
{% for s in segments %}<option value="{{ s.id }}" {{ 'selected' if c.segment_id == s.id }}>{{ s.name }}</option>{% endfor %}
</select>
<p class="hint" id="seg-hint">{% if c.segment_id and editable %}Nu {{ seg_count }} ontvanger(s). {% endif %}<a href="{{ url_for('segments') }}">Segmenten beheren</a></p></div>
{% if templates and editable %}
<div class="col-12"><label class="form-label" for="template">Template</label>
<select class="form-select" id="template" name="template" autocomplete="off">
<option value="">Geen template</option>
{% for t in templates %}<option value="{{ t.id }}" data-subject="{{ t.subject }}" data-body="{{ t.body }}">{{ t.name }}</option>{% endfor %}
</select></div>
{% endif %}
<div class="col-12"><label class="form-label" for="subject">Onderwerp</label><input class="form-control" id="subject" name="subject" value="{{ c.subject or '' }}"></div>
<div class="col-12"><label class="form-label" for="body">Tekst</label><textarea class="form-control" id="body" name="body" rows="16">
{{ c.body or '' }}</textarea>
<p class="hint">Velden worden per ontvanger ingevuld: {% for k, l in FIELDS %}<code title="{{ l }}">{{ '{' ~ k ~ '}' }}</code> {% endfor %}. Zonder {afmeldlink} komt de afmeldlink onderaan.</p></div>
</div>
</fieldset>
<div class="actions">
{% if editable %}
<button class="btn btn-primary" name="action" value="save">Opslaan</button>
<button class="btn btn-outline-primary" name="action" value="test">Testmail naar mij</button>
{% endif %}
</div>
{% if editable %}
<div class="schedule">
<h2>Versturen</h2>
<p class="hint">Bij inplannen worden de ontvangers uit het segment vastgelegd. Daarna verstuurt de routine "Nieuwsbrieven versturen" ze per batch.</p>
<div class="row g-3 align-items-end">
<div class="col-md-6"><label class="form-label" for="scheduled_at">Vanaf (leeg = zo snel mogelijk)</label><input class="form-control" id="scheduled_at" type="datetime-local" name="scheduled_at"></div>
<div class="col-md-6"><button class="btn btn-primary" name="action" value="schedule" onclick="return confirm('Nieuwsbrief inplannen? Daarna kun je de tekst niet meer wijzigen.')">Opslaan en inplannen</button></div>
</div>
</div>
{% endif %}
</div></form>
{% if editable %}
<form method="post" onsubmit="return confirm('Nieuwsbrief verwijderen?')"><button class="btn btn-outline-secondary btn-sm" name="action" value="delete">Nieuwsbrief verwijderen</button></form>
{% endif %}
</div>
{% if recipients %}
<div class="col-lg-4">
<div class="card"><div class="card-body">
<h2>Ontvangers</h2>
<ul class="timeline">{% for r in recipients %}<li>{% if r.contact_id %}<a href="{{ url_for('contact_view', cid=r.contact_id) }}">{{ r.name or r.email }}</a>{% else %}{{ r.email }}{% endif %}
<span class="chip {{ 'verloren' if r.status in ('mislukt', 'overgeslagen') else '' }}">{{ RSTATUS.get(r.status, r.status) }}</span><br>
<span class="muted">{{ r.email }}{% if r.sent_at %} · {{ r.sent_at|dt }}{% endif %}{% if r.error %} · {{ r.error }}{% endif %}</span></li>{% endfor %}</ul>
{% if stats.totaal > recipients|length %}<p class="hint">De eerste {{ recipients|length }} van {{ stats.totaal }}.</p>{% endif %}
</div></div>
</div>
{% endif %}
</div>
{% endblock %}
{% block scripts %}{% if editable %}<script src="{{ url_for('static', filename='compose.js') }}" defer></script>{% endif %}{% endblock %}
+33
View File
@@ -0,0 +1,33 @@
{% extends 'base.html' %}
{% block title %}Nieuwsbrieven{% endblock %}
{% block content %}
<div class="head">
<div><div class="kicker">E-mail</div><h1>Nieuwsbrieven</h1></div>
<div class="actions">
<a class="btn btn-outline-primary" href="{{ url_for('segments') }}">Segmenten</a>
<a class="btn btn-outline-primary" href="{{ url_for('mail_templates') }}">Templates</a>
<form method="post" action="{{ url_for('campaign_new') }}" class="inline"><button class="btn btn-primary">Nieuwe nieuwsbrief</button></form>
</div>
</div>
<div class="card"><div class="card-body">
{% if rows %}
<div class="table-responsive"><table class="table table-hover table-stack">
<thead><tr><th>Naam</th><th>Segment</th><th>Van</th><th>Status</th><th class="num">Verzonden</th><th>Gepland</th></tr></thead>
<tbody>
{% for c in rows %}
<tr class="link" onclick="location='{{ url_for('campaign_edit', cid=c.id) }}'">
<td class="stack-title"><a href="{{ url_for('campaign_edit', cid=c.id) }}">{{ c.name }}</a></td>
<td data-label="Segment">{{ c.segment_name or '—' }}</td>
<td data-label="Van">{{ c.mailbox_name or '—' }}</td>
<td data-label="Status"><span class="chip campaign-{{ c.status }}">{{ CSTATUS.get(c.status, c.status) }}</span></td>
<td data-label="Verzonden" class="num">{% if c.stats.totaal %}{{ c.stats.verzonden }} / {{ c.stats.totaal }}{% else %}—{% endif %}</td>
<td data-label="Gepland" class="muted">{{ c.scheduled_at|dt if c.scheduled_at else '' }}</td>
</tr>
{% endfor %}
</tbody>
</table></div>
{% else %}<p class="empty">Nog geen nieuwsbrieven. Maak eerst een segment (wie krijgt hem) en eventueel een template.</p>{% endif %}
</div></div>
<p class="muted">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.</p>
{% endblock %}
+7 -1
View File
@@ -122,11 +122,17 @@
</form> </form>
</div></div> </div></div>
{% if c.unsubscribed_at or c.bounced_at %}
<div class="flash error d-flex justify-content-between align-items-center gap-3 flex-wrap">
<span>{% 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.</span>
<form method="post" action="{{ url_for('contact_mailstatus', cid=c.id) }}" class="inline" onsubmit="return confirm('Dit contact weer nieuwsbrieven sturen?')"><button class="btn btn-outline-secondary btn-sm">Weer aanmelden</button></form>
</div>
{% endif %}
{% if c.email %} {% if c.email %}
<div class="card"><div class="card-body"> <div class="card"><div class="card-body">
<details> <details>
<summary>E-mail sturen</summary> <summary>E-mail sturen</summary>
{{ 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) }}
</details> </details>
</div></div> </div></div>
{% endif %} {% endif %}
+3 -3
View File
@@ -5,12 +5,12 @@
{% set incoming = e.direction == 'in' %} {% set incoming = e.direction == 'in' %}
<div class="head"> <div class="head">
<div> <div>
<a class="label" href="{{ url_for('inbox', f=None if incoming and not e.archived else ('archived' if incoming else 'sent')) }}">← {{ 'Verzonden' if not incoming else ('Archief' if e.archived else 'Inbox') }}</a> <a class="label" href="{{ url_for('inbox', mb=e.mailbox_id, f=None if incoming and not e.archived else ('archived' if incoming else 'sent')) }}">← {{ 'Verzonden' if not incoming else ('Archief' if e.archived else 'Inbox') }}</a>
<h1>{{ e.subject }}</h1> <h1>{{ e.subject }}</h1>
<div class="muted" style="overflow-wrap:anywhere"> <div class="muted" style="overflow-wrap:anywhere">
{% if incoming %}Van {{ e.from_name }} &lt;{{ e.from_addr }}&gt;{% else %}Aan {{ e.to_addr }}{% endif %} {% if incoming %}Van {{ e.from_name }} &lt;{{ e.from_addr }}&gt;{% else %}Aan {{ e.to_addr }}{% endif %}
{% if e.cc %}· CC {{ e.cc }}{% endif %} {% if e.cc %}· CC {{ e.cc }}{% endif %}
· {{ e.date|dt }} · {{ e.date|dt }}{% if e.mailbox_name %} · {{ e.mailbox_name }}{% endif %}
</div> </div>
{% if e.attachments %}<div class="muted" style="overflow-wrap:anywhere">Bijlagen: {{ e.attachments }}</div>{% endif %} {% if e.attachments %}<div class="muted" style="overflow-wrap:anywhere">Bijlagen: {{ e.attachments }}</div>{% endif %}
</div> </div>
@@ -34,7 +34,7 @@
<div class="card"><div class="card-body"> <div class="card"><div class="card-body">
<h2>Beantwoorden</h2> <h2>Beantwoorden</h2>
{{ compose({'to': e.from_addr, 'subject': e.subject if e.subject.lower().startswith('re:') else 'Re: ' ~ e.subject, {{ 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) }}
</div></div> </div></div>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
+24 -10
View File
@@ -3,41 +3,55 @@
{% block content %} {% block content %}
{% set sent = f == 'sent' %} {% set sent = f == 'sent' %}
<div class="head"> <div class="head">
<div><div class="kicker">E-mail</div><h1>{{ {'sent': 'Verzonden', 'archived': 'Archief'}.get(f, 'Inbox') }}</h1>{% if last_sync %}<div class="muted">Laatst opgehaald: {{ last_sync|dt }}</div>{% endif %}</div> <div><div class="kicker">E-mail{% if current %} · {{ current.name }}{% endif %}</div><h1>{{ {'sent': 'Verzonden', 'archived': 'Archief'}.get(f, 'Inbox') }}</h1>
{% if current and current.last_sync %}<div class="muted">Laatst opgehaald: {{ current.last_sync|dt }}</div>{% endif %}</div>
<div class="actions"> <div class="actions">
<form class="search" method="get"> <form class="search" method="get">
<input type="hidden" name="f" value="{{ f }}"> <input type="hidden" name="f" value="{{ f }}">
{% if mb %}<input type="hidden" name="mb" value="{{ mb }}">{% endif %}
<input class="form-control" type="search" name="q" value="{{ term }}" placeholder="Zoek op afzender, onderwerp, tekst…" aria-label="Zoeken in e-mail"> <input class="form-control" type="search" name="q" value="{{ term }}" placeholder="Zoek op afzender, onderwerp, tekst…" aria-label="Zoeken in e-mail">
</form> </form>
<a class="btn btn-outline-primary" href="{{ url_for('mail_templates') }}">Templates</a> <a class="btn btn-outline-primary" href="{{ url_for('mail_templates') }}">Templates</a>
<form method="post" action="{{ url_for('inbox_sync') }}" class="inline"><button class="btn btn-outline-primary">Nieuwe e-mail ophalen</button></form> <form method="post" action="{{ url_for('inbox_sync') }}" class="inline">{% if mb %}<input type="hidden" name="mb" value="{{ mb }}">{% endif %}<button class="btn btn-outline-primary">Nieuwe e-mail ophalen</button></form>
<a class="btn btn-primary" href="{{ url_for('mail_new') }}">Nieuwe e-mail</a> <a class="btn btn-primary" href="{{ url_for('mail_new', mb=mb) }}">Nieuwe e-mail</a>
</div> </div>
</div> </div>
{% if not configured %} {% if not mailboxes %}
<div class="flash">Koppel eerst je mailbox bij <a href="{{ url_for('settings') }}#mail">Instellingen</a> (IMAP voor ontvangen, SMTP voor versturen).</div> <div class="flash">Er is nog geen mailbox voor jou. {% if g.user.role == 'admin' %}Voeg er een toe bij <a href="{{ url_for('settings') }}#mail">Instellingen</a>.{% else %}Vraag een beheerder om er een in te stellen.{% endif %}</div>
{% elif current and current.last_error %}
<div class="flash error">{{ current.name }}: laatste ophaalpoging mislukt: {{ current.last_error }}</div>
{% endif %}
{% if mailboxes|length > 1 %}
<div class="filters" aria-label="Mailboxen">
<a href="{{ url_for('inbox', f=f if f != 'all' else None, q=term or None) }}" class="{{ 'on' if not mb }}">Alle mailboxen{% if unread %} <span class="muted">{{ unread }}</span>{% endif %}</a>
{% for m in mailboxes %}
<a href="{{ url_for('inbox', mb=m.id, f=f if f != 'all' else None, q=term or None) }}" class="{{ 'on' if mb == m.id }}">{{ m.name }}{% if unread_by.get(m.id) %} <span class="muted">{{ unread_by[m.id] }}</span>{% endif %}</a>
{% endfor %}
</div>
{% endif %} {% endif %}
<div class="filters"> <div class="filters">
{% for k, l in [('all', 'Inbox'), ('unread', 'Ongelezen'), ('unlinked', 'Niet gekoppeld aan contact'), ('sent', 'Verzonden'), ('archived', 'Archief')] %} {% for k, l in [('all', 'Inbox'), ('unread', 'Ongelezen'), ('unlinked', 'Niet gekoppeld aan contact'), ('sent', 'Verzonden'), ('archived', 'Archief')] %}
<a href="{{ url_for('inbox', f=k if k != 'all' else None, q=term or None) }}" class="{{ 'on' if f == k }}">{{ l }}{% if k == 'unread' and unread %} <span class="muted">{{ unread }}</span>{% endif %}</a> <a href="{{ url_for('inbox', f=k if k != 'all' else None, mb=mb, q=term or None) }}" class="{{ 'on' if f == k }}">{{ l }}</a>
{% endfor %} {% endfor %}
{% if unread and not sent %} {% if (unread_by.get(mb) if mb else unread) and not sent %}
<form method="post" action="{{ url_for('inbox_read_all') }}" class="inline"><button class="btn btn-link btn-sm">Alles als gelezen markeren</button></form> <form method="post" action="{{ url_for('inbox_read_all') }}" class="inline">{% if mb %}<input type="hidden" name="mb" value="{{ mb }}">{% endif %}<button class="btn btn-link btn-sm">Alles als gelezen markeren</button></form>
{% endif %} {% endif %}
</div> </div>
<div class="card"><div class="card-body"> <div class="card"><div class="card-body">
{% if rows %} {% if rows %}
<div class="table-responsive"><table class="table table-hover table-stack"> <div class="table-responsive"><table class="table table-hover table-stack">
<thead><tr><th>{{ 'Aan' if sent else 'Van' }}</th><th>Onderwerp</th><th>Contact</th><th class="num">Datum</th></tr></thead> <thead><tr><th>{{ 'Aan' if sent else 'Van' }}</th><th>Onderwerp</th><th>Contact</th>{% if not mb and mailboxes|length > 1 %}<th>Mailbox</th>{% endif %}<th class="num">Datum</th></tr></thead>
<tbody> <tbody>
{% for e in rows %} {% for e in rows %}
<tr class="link {{ 'unread' if not e.is_read }}" onclick="location='{{ url_for('inbox_view', eid=e.id) }}'"> <tr class="link {{ 'unread' if not e.is_read }}" onclick="location='{{ url_for('inbox_view', eid=e.id) }}'">
<td class="stack-title">{% if not e.is_read %}<span class="dot" aria-label="Ongelezen"></span>{% endif %}{{ e.to_addr if sent else e.from_name or e.from_addr }}<a class="d-block d-md-none" href="{{ url_for('inbox_view', eid=e.id) }}">{{ e.subject }}</a></td> <td class="stack-title">{% if not e.is_read %}<span class="dot" aria-label="Ongelezen"></span>{% endif %}{{ e.to_addr if sent else e.from_name or e.from_addr }}<a class="d-block d-md-none" href="{{ url_for('inbox_view', eid=e.id) }}">{{ e.subject }}</a></td>
<td class="d-none d-md-table-cell"><a href="{{ url_for('inbox_view', eid=e.id) }}">{{ e.subject }}</a>{% if e.attachments %} <span class="muted" title="{{ e.attachments }}">· bijlage</span>{% endif %}</td> <td class="d-none d-md-table-cell"><a href="{{ url_for('inbox_view', eid=e.id) }}">{{ e.subject }}</a>{% if e.attachments %} <span class="muted" title="{{ e.attachments }}">· bijlage</span>{% endif %}</td>
<td data-label="Contact">{{ e.contact_name or '' }}</td> <td data-label="Contact">{{ e.contact_name or '' }}</td>
{% if not mb and mailboxes|length > 1 %}<td data-label="Mailbox" class="muted">{{ e.mailbox_name or '' }}</td>{% endif %}
<td data-label="Datum" class="muted num">{{ e.date|dt }}</td> <td data-label="Datum" class="muted num">{{ e.date|dt }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
@@ -45,5 +59,5 @@
</table></div> </table></div>
{% else %}<p class="empty">{{ 'Niets gevonden.' if term else 'Geen berichten.' }}</p>{% endif %} {% else %}<p class="empty">{{ 'Niets gevonden.' if term else 'Geen berichten.' }}</p>{% endif %}
</div></div> </div></div>
{% if f == 'archived' %}<p class="muted">Archiveren haalt een bericht alleen uit de inbox van de CRM; in je mailbox blijft het staan.</p>{% endif %} {% if f == 'archived' %}<p class="muted">Archiveren haalt een bericht alleen uit de inbox van de CRM; in de mailbox op de server blijft het staan.</p>{% endif %}
{% endblock %} {% endblock %}
+1 -1
View File
@@ -8,7 +8,7 @@
<div class="actions"><a class="btn btn-outline-primary" href="{{ url_for('mail_templates') }}">Templates</a></div> <div class="actions"><a class="btn btn-outline-primary" href="{{ url_for('mail_templates') }}">Templates</a></div>
</div> </div>
<div class="card"><div class="card-body"> <div class="card"><div class="card-body">
{{ compose(m, templates, vars, datalist='mail-contacts') }} {{ compose(m, templates, vars, mailboxes, datalist='mail-contacts') }}
{% if not templates %}<p class="hint">Nog geen templates. <a href="{{ url_for('mail_template_edit') }}">Maak een template</a> voor berichten die je vaker stuurt.</p>{% endif %} {% if not templates %}<p class="hint">Nog geen templates. <a href="{{ url_for('mail_template_edit') }}">Maak een template</a> voor berichten die je vaker stuurt.</p>{% endif %}
</div></div> </div></div>
<datalist id="mail-contacts">{% for c in contacts %}<option value="{{ c.email }}">{{ c.name }}</option>{% endfor %}</datalist> <datalist id="mail-contacts">{% for c in contacts %}<option value="{{ c.email }}">{{ c.name }}</option>{% endfor %}</datalist>
+91
View File
@@ -0,0 +1,91 @@
{% extends 'base.html' %}
{% block title %}{{ mb.name or 'Nieuwe mailbox' }}{% endblock %}
{% macro field(key, label, type='text', placeholder='', full=false) %}
<div class="{{ 'col-12' if full else 'col-md-6 col-xl-4' }}"><label class="form-label" for="{{ key }}">{{ label }}</label><input class="form-control" id="{{ key }}" type="{{ type }}" name="{{ key }}" value="{{ mb[key] or '' if mb[key] is defined else '' }}" placeholder="{{ placeholder }}"></div>
{% endmacro %}
{% macro security(key) %}
<div class="col-md-6 col-xl-4"><label class="form-label" for="{{ key }}">Beveiliging</label>
<select class="form-select" id="{{ key }}" name="{{ key }}">{% for v, label in SECURITY %}<option value="{{ v }}" {{ 'selected' if (mb[key] or '') == v }}>{{ label }}</option>{% endfor %}</select></div>
{% endmacro %}
{% macro secret(key, label, empty='') %}
<div class="col-md-6 col-xl-4"><label class="form-label" for="{{ key }}">{{ label }}</label><input class="form-control" id="{{ key }}" type="password" name="{{ key }}" placeholder="{{ '•••••••• (ingesteld)' if mb[key] else empty }}" autocomplete="new-password"></div>
{% endmacro %}
{% block content %}
<div class="head">
<div><a class="label" href="{{ url_for('settings', _anchor='mail') }}">← Instellingen</a><h1>{{ mb.name if mb.id else 'Nieuwe mailbox' }}</h1>
{% if mb.last_error %}<div class="flash error mt-2">Laatste fout bij ophalen: {{ mb.last_error }}</div>{% endif %}</div>
</div>
<form method="post">
<div class="card"><div class="card-body">
<h2>Algemeen</h2>
<div class="row g-3">
<div class="col-md-6 col-xl-4"><label class="form-label" for="name">Naam *</label><input class="form-control" id="name" name="name" value="{{ mb.name or '' }}" placeholder="Info" required></div>
<div class="col-md-6 col-xl-4"><label class="form-label" for="purpose">Rol</label>
<select class="form-select" id="purpose" name="purpose" aria-describedby="purpose-hint">{% for k, l in PURPOSES %}<option value="{{ k }}" {{ 'selected' if mb.purpose == k }}>{{ l }}</option>{% endfor %}</select>
<p class="hint" id="purpose-hint">Nieuwsbrieven kiezen standaard een nieuwsbriefmailbox.</p></div>
{{ field('smtp_from', 'Afzender', placeholder='Van Emous <info@vanemouswoningen.nl>') }}
<div class="col-12">
<div class="form-check"><input class="form-check-input" type="checkbox" id="active" name="active" value="1" {{ 'checked' if mb.active }}><label class="form-check-label" for="active">Actief (ophalen en versturen)</label></div>
<div class="form-check"><input class="form-check-input" type="checkbox" id="notify" name="notify" value="1" {{ 'checked' if mb.notify }}><label class="form-check-label" for="notify">Melding bij nieuwe e-mail voor wie toegang heeft</label></div>
</div>
</div>
<h3>Toegang</h3>
<p class="hint">Niemand aangevinkt = gedeeld met iedereen. Vink alleen jezelf aan voor een persoonlijke mailbox.</p>
<div class="d-flex flex-wrap gap-3">
{% for u in USERS %}<div class="form-check"><input class="form-check-input" type="checkbox" id="user-{{ u.id }}" name="users" value="{{ u.id }}" {{ 'checked' if u.id in access }}><label class="form-check-label" for="user-{{ u.id }}">{{ u.name }}</label></div>{% endfor %}
</div>
</div></div>
<div class="card"><div class="card-body">
<h2>Verbinding</h2>
<p class="hint" style="margin:-.4rem 0 1rem">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 <code>data/</code> privé.</p>
<div class="row g-3">
<div class="col-md-6 col-xl-4"><label class="form-label" for="mail-provider">Aanbieder</label>
<select class="form-select" id="mail-provider" aria-describedby="mail-provider-hint">
<option value="">Kies om de servers in te vullen…</option>
{% for key, p in PROVIDERS.items() %}<option value="{{ key }}" data-preset="{{ p|tojson|forceescape }}">{{ p.label }}</option>{% endfor %}
</select>
<p class="hint" id="mail-provider-hint">Vult server, poort en beveiliging in; daarna kun je alles nog aanpassen.</p>
</div>
</div>
<h3>Ontvangen (IMAP)</h3>
<div class="row g-3">
{{ 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') }}
</div>
<h3>Versturen (SMTP)</h3>
<div class="row g-3">
{{ 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') }}
<div class="col-md-6 col-xl-4"><label class="form-label" for="smtp_sent_folder">Kopie in map</label><input class="form-control" id="smtp_sent_folder" name="smtp_sent_folder" value="{{ mb.smtp_sent_folder or '' }}" placeholder="leeg = geen kopie" aria-describedby="sent-hint"><p class="hint" id="sent-hint">Bij Zimbra/MX Plan <code>Sent</code>, zodat verstuurde mail ook in de webmail staat. Gmail doet dit zelf. Nieuwsbrieven komen er niet in.</p></div>
</div>
</div></div>
<div class="actions">
<button class="btn btn-primary" name="action" value="save">Opslaan</button>
<button class="btn btn-outline-primary" name="action" value="test">Opslaan en verbinding testen</button>
<a class="btn btn-outline-secondary" href="{{ url_for('settings', _anchor='mail') }}">Annuleren</a>
</div>
</form>
{% endblock %}
{% block scripts %}
<script>
document.getElementById('mail-provider').addEventListener('change', (e) => {
const opt = e.target.selectedOptions[0];
if (!opt || !opt.dataset.preset) return;
Object.entries(JSON.parse(opt.dataset.preset)).forEach(([key, value]) => {
const el = document.getElementById(key);
if (el) el.value = value;
});
});
</script>
{% endblock %}
+24
View File
@@ -0,0 +1,24 @@
{% extends 'base.html' %}
{% block title %}Meldingen{% endblock %}
{% block content %}
<div class="head">
<div><div class="kicker">Voor jou</div><h1>Meldingen</h1></div>
{% if bell_unread %}<div class="actions"><form method="post" action="{{ url_for('notifications_read_all') }}" class="inline"><button class="btn btn-outline-primary">Alles als gelezen markeren</button></form></div>{% endif %}
</div>
<div class="card"><div class="card-body">
{% if rows %}
<div class="table-responsive"><table class="table table-hover table-stack">
<thead><tr><th>Melding</th><th>Details</th><th class="num">Wanneer</th></tr></thead>
<tbody>
{% for n in rows %}
<tr class="link {{ 'unread' if not n.read_at }}" onclick="location='{{ url_for('notification_open', nid=n.id) }}'">
<td class="stack-title">{% if not n.read_at %}<span class="dot" aria-label="Ongelezen"></span>{% endif %}<a href="{{ url_for('notification_open', nid=n.id) }}">{{ n.title }}</a></td>
<td data-label="Details" class="muted">{{ n.body }}</td>
<td data-label="Wanneer" class="muted num">{{ n.created_at|dt }}</td>
</tr>
{% endfor %}
</tbody>
</table></div>
{% else %}<p class="empty">Geen meldingen.</p>{% endif %}
</div></div>
{% endblock %}
+55
View File
@@ -0,0 +1,55 @@
{% extends 'base.html' %}
{% block title %}{{ job.name if job else jt.label }}{% endblock %}
{% block content %}
<div class="head">
<div><a class="label" href="{{ url_for('routines') }}">← Routines</a><h1>{{ job.name if job else 'Nieuwe routine' }}</h1><div class="muted">{{ jt.label }}</div></div>
{% if job %}<div class="actions"><form method="post" action="{{ url_for('routine_run', jid=job.id) }}" class="inline"><button class="btn btn-outline-primary">Nu uitvoeren</button></form></div>{% endif %}
</div>
<div class="row g-4">
<div class="col-lg-7">
<form method="post" class="card"><div class="card-body">
{% if jt.description %}<p class="hint" style="margin:0 0 1rem">{{ jt.description }}</p>{% endif %}
<div class="row g-3">
<div class="col-12"><label class="form-label" for="name">Naam</label><input class="form-control" id="name" name="name" value="{{ job.name if job else jt.label }}"></div>
<div class="col-6 col-md-4"><label class="form-label" for="every">Elke</label><input class="form-control" id="every" type="number" min="1" name="every" value="{{ every }}" required></div>
<div class="col-6 col-md-4"><label class="form-label" for="unit">Eenheid</label>
<select class="form-select" id="unit" name="unit">{% for size, l in UNITS %}<option value="{{ size }}" {{ 'selected' if unit == size }}>{{ l }}</option>{% endfor %}</select></div>
{% for f in jt.fields %}
{% if f.kind == 'bool' %}
<div class="col-12"><div class="form-check"><input class="form-check-input" type="checkbox" id="f-{{ f.key }}" name="{{ f.key }}" value="1" {{ 'checked' if config[f.key] }}><label class="form-check-label" for="f-{{ f.key }}">{{ f.label }}</label></div>{% if f.hint %}<p class="hint">{{ f.hint }}</p>{% endif %}</div>
{% elif f.kind == 'mailboxes' %}
<div class="col-12"><span class="form-label d-block">{{ f.label }}</span>
<div class="d-flex flex-wrap gap-3">{% for m in mailboxes %}<div class="form-check"><input class="form-check-input" type="checkbox" id="f-{{ f.key }}-{{ m.id }}" name="{{ f.key }}" value="{{ m.id }}" {{ 'checked' if m.id in config[f.key] }}><label class="form-check-label" for="f-{{ f.key }}-{{ m.id }}">{{ m.name }}</label></div>{% else %}<span class="muted">Nog geen mailboxen.</span>{% endfor %}</div>
{% if f.hint %}<p class="hint">{{ f.hint }}</p>{% endif %}</div>
{% else %}
<div class="col-md-8"><label class="form-label" for="f-{{ f.key }}">{{ f.label }}</label><input class="form-control" id="f-{{ f.key }}" type="{{ 'number' if f.kind == 'number' else f.kind }}" name="{{ f.key }}" value="{{ config[f.key] }}">{% if f.hint %}<p class="hint">{{ f.hint }}</p>{% endif %}</div>
{% endif %}
{% endfor %}
<div class="col-12"><div class="form-check"><input class="form-check-input" type="checkbox" id="active" name="active" value="1" {{ 'checked' if not job or job.active }}><label class="form-check-label" for="active">Actief</label></div></div>
</div>
<div class="actions">
<button class="btn btn-primary">Opslaan</button>
<a class="btn btn-outline-secondary" href="{{ url_for('routines') }}">Annuleren</a>
</div>
</div></form>
{% if job %}
<form method="post" action="{{ url_for('routine_delete', jid=job.id) }}" onsubmit="return confirm('Routine verwijderen?')">
<button class="btn btn-outline-secondary btn-sm">Routine verwijderen</button>
</form>
{% endif %}
</div>
{% if job %}
<div class="col-lg-5">
<div class="card"><div class="card-body">
<h2>Logboek</h2>
<p class="hint">Volgende keer: {{ (job.next_run|dt if job.next_run else 'zo snel mogelijk') if job.active else 'staat uit' }}</p>
{% if runs %}
<ul class="timeline">
{% for r in runs %}<li><b>{{ r.started_at|dt }}</b> {% if r.status == 'fout' %}<span class="chip nieuw">Fout</span>{% endif %}{% if r.manual %} <span class="muted">(handmatig)</span>{% endif %}<br><span class="muted">{{ r.message }}</span></li>{% endfor %}
</ul>
{% else %}<p class="empty">Nog niet uitgevoerd.</p>{% endif %}
</div></div>
</div>
{% endif %}
</div>
{% endblock %}
+35
View File
@@ -0,0 +1,35 @@
{% extends 'base.html' %}
{% block title %}Routines{% endblock %}
{% block content %}
<div class="head">
<div><div class="kicker">Beheer</div><h1>Routines</h1></div>
<div class="actions">
<div class="dropdown">
<button class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">Nieuwe routine</button>
<ul class="dropdown-menu dropdown-menu-end">{% for k, t in TYPES.items() %}<li><a class="dropdown-item" href="{{ url_for('routine_edit', kind=k) }}">{{ t.label }}</a></li>{% endfor %}</ul>
</div>
</div>
</div>
<div class="card"><div class="card-body">
{% if rows %}
<div class="table-responsive"><table class="table table-hover table-stack">
<thead><tr><th>Naam</th><th>Soort</th><th>Elke</th><th>Laatst</th><th>Volgende</th><th>Resultaat</th><th></th></tr></thead>
<tbody>
{% for j in rows %}
<tr class="link" onclick="location='{{ url_for('routine_edit', jid=j.id) }}'">
<td class="stack-title"><a href="{{ url_for('routine_edit', jid=j.id) }}">{{ j.name }}</a>{% if not j.active %} <span class="chip verloren">Uit</span>{% endif %}{% if j.running_since %} <span class="chip nieuw">Bezig</span>{% endif %}</td>
<td data-label="Soort">{{ TYPES[j.kind].label if j.kind in TYPES else j.kind }}</td>
<td data-label="Elke">{{ interval_text(j.interval_minutes) }}</td>
<td data-label="Laatst" class="muted">{{ j.last_run|dt if j.last_run else '—' }}</td>
<td data-label="Volgende" class="muted">{{ (j.next_run|dt if j.next_run else 'zo snel mogelijk') if j.active else '—' }}</td>
<td data-label="Resultaat">{% if j.last_status == 'fout' %}<span class="chip nieuw">Fout</span> {% endif %}<span class="muted">{{ j.last_message|truncate(80) }}</span></td>
<td class="num"><form method="post" action="{{ url_for('routine_run', jid=j.id) }}" class="inline" onclick="event.stopPropagation()"><button class="btn btn-outline-primary btn-sm">Nu uitvoeren</button></form></td>
</tr>
{% endfor %}
</tbody>
</table></div>
{% else %}<p class="empty">Nog geen routines.</p>{% endif %}
</div></div>
<p class="muted">Routines draaien op de achtergrond (Docker: container <code>crm-routines</code>; zonder Docker: cron elke minuut
<code>python app.py tick</code>). Elke halve minuut wordt gekeken welke aan de beurt zijn; een routine draait nooit twee keer tegelijk.</p>
{% endblock %}
+46
View File
@@ -0,0 +1,46 @@
{% extends 'base.html' %}
{% block title %}{{ name or 'Nieuw segment' }}{% endblock %}
{% macro checks(key, options, selected) %}
<div class="d-flex flex-wrap gap-3">{% for v, l in options %}<div class="form-check"><input class="form-check-input" type="checkbox" id="{{ key }}-{{ loop.index }}" name="{{ key }}" value="{{ v }}" {{ 'checked' if v in selected }}><label class="form-check-label" for="{{ key }}-{{ loop.index }}">{{ l }}</label></div>{% endfor %}</div>
{% endmacro %}
{% block content %}
<div class="head">
<div><a class="label" href="{{ url_for('segments') }}">← Segmenten</a><h1>{{ name or 'Nieuw segment' }}</h1></div>
</div>
<div class="row g-4">
<div class="col-lg-7">
<form method="post" class="card"><div class="card-body">
<div class="row g-3">
<div class="col-12"><label class="form-label" for="name">Naam *</label><input class="form-control" id="name" name="name" value="{{ name }}" placeholder="Bijvoorbeeld: Leads met een ontwerp"></div>
<div class="col-12"><span class="form-label d-block">Soort</span>{{ checks('types', TYPES, filters.types or []) }}</div>
<div class="col-12"><span class="form-label d-block">Status</span>{{ checks('statuses', STATUSES, filters.statuses or []) }}</div>
{% if sources %}<div class="col-12"><span class="form-label d-block">Bron</span>{{ checks('sources', sources, filters.sources or []) }}</div>{% endif %}
<div class="col-12"><span class="form-label d-block">Toegewezen aan</span>{{ checks('users', users, filters.users or []) }}</div>
<div class="col-md-4"><label class="form-label" for="city">Plaats bevat</label><input class="form-control" id="city" name="city" value="{{ filters.city or '' }}"></div>
<div class="col-6 col-md-4"><label class="form-label" for="created_from">Aangemaakt vanaf</label><input class="form-control" id="created_from" type="date" name="created_from" value="{{ filters.created_from or '' }}"></div>
<div class="col-6 col-md-4"><label class="form-label" for="created_to">tot en met</label><input class="form-control" id="created_to" type="date" name="created_to" value="{{ filters.created_to or '' }}"></div>
<div class="col-12"><div class="form-check"><input class="form-check-input" type="checkbox" id="has_design" name="has_design" value="1" {{ 'checked' if filters.has_design }}><label class="form-check-label" for="has_design">Alleen met een gekozen woningontwerp</label></div></div>
</div>
<p class="hint">Niets aangevinkt bij een onderdeel = geen beperking daarop.</p>
<div class="actions">
<button class="btn btn-primary" name="action" value="save">Opslaan</button>
<button class="btn btn-outline-primary" name="action" value="preview">Ontvangers tellen</button>
</div>
</div></form>
{% if seg %}
<form method="post" action="{{ url_for('segment_delete', sid=seg.id) }}" onsubmit="return confirm('Segment verwijderen? Nieuwsbrieven die het gebruiken houden hun vastgelegde ontvangers.')">
<button class="btn btn-outline-secondary btn-sm">Segment verwijderen</button>
</form>
{% endif %}
</div>
<div class="col-lg-5">
<div class="card"><div class="card-body">
<h2>{{ count }} ontvanger{{ '' if count == 1 else 's' }}</h2>
{% if sample %}
<ul class="timeline">{% for c in sample %}<li><a href="{{ url_for('contact_view', cid=c.id) }}">{{ c.name or c.email }}</a><br><span class="muted">{{ c.email }}{% if c.city %} · {{ c.city }}{% endif %}</span></li>{% endfor %}</ul>
{% if count > sample|length %}<p class="hint">En nog {{ count - sample|length }}.</p>{% endif %}
{% else %}<p class="empty">Geen contacten met deze filters.</p>{% endif %}
</div></div>
</div>
</div>
{% endblock %}
+26
View File
@@ -0,0 +1,26 @@
{% extends 'base.html' %}
{% block title %}Segmenten{% endblock %}
{% block content %}
<div class="head">
<div><a class="label" href="{{ url_for('campaigns') }}">← Nieuwsbrieven</a><h1>Segmenten</h1></div>
<div class="actions"><a class="btn btn-primary" href="{{ url_for('segment_edit') }}">Nieuw segment</a></div>
</div>
<div class="card"><div class="card-body">
{% if rows %}
<div class="table-responsive"><table class="table table-hover table-stack">
<thead><tr><th>Naam</th><th class="num">Ontvangers nu</th><th>Bijgewerkt</th></tr></thead>
<tbody>
{% for s in rows %}
<tr class="link" onclick="location='{{ url_for('segment_edit', sid=s.id) }}'">
<td class="stack-title"><a href="{{ url_for('segment_edit', sid=s.id) }}">{{ s.name }}</a></td>
<td data-label="Ontvangers nu" class="num">{{ s.n }}</td>
<td data-label="Bijgewerkt" class="muted">{{ s.updated_at|dt }}</td>
</tr>
{% endfor %}
</tbody>
</table></div>
{% else %}<p class="empty">Nog geen segmenten.</p>{% endif %}
</div></div>
<p class="muted">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.</p>
{% endblock %}
+26 -51
View File
@@ -3,13 +3,35 @@
{% macro field(key, label, type='text', placeholder='', full=false) %} {% macro field(key, label, type='text', placeholder='', full=false) %}
<div class="{{ 'col-12' if full else 'col-md-6 col-xl-4' }}"><label class="form-label" for="{{ key }}">{{ label }}</label><input class="form-control" id="{{ key }}" type="{{ type }}" name="{{ key }}" value="{{ s.get(key, '') }}" placeholder="{{ placeholder }}"></div> <div class="{{ 'col-12' if full else 'col-md-6 col-xl-4' }}"><label class="form-label" for="{{ key }}">{{ label }}</label><input class="form-control" id="{{ key }}" type="{{ type }}" name="{{ key }}" value="{{ s.get(key, '') }}" placeholder="{{ placeholder }}"></div>
{% endmacro %} {% endmacro %}
{% macro security(key) %}
<div class="col-md-6 col-xl-4"><label class="form-label" for="{{ key }}">Beveiliging</label>
<select class="form-select" id="{{ key }}" name="{{ key }}">{% for v, label in SECURITY %}<option value="{{ v }}" {{ 'selected' if s.get(key, '') == v }}>{{ label }}</option>{% endfor %}</select></div>
{% endmacro %}
{% block content %} {% block content %}
<div class="head"><div><div class="kicker">Beheer</div><h1>Instellingen</h1></div></div> <div class="head"><div><div class="kicker">Beheer</div><h1>Instellingen</h1></div></div>
<div class="card" id="mail"><div class="card-body">
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap">
<div><h2>Mailboxen</h2>
<p class="hint" style="margin:-.4rem 0 1rem">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" (<a href="{{ url_for('routines') }}">Routines</a>).</p></div>
<a class="btn btn-primary" href="{{ url_for('mailbox_edit') }}">Mailbox toevoegen</a>
</div>
{% if mailboxes %}
<div class="table-responsive"><table class="table table-hover table-stack">
<thead><tr><th>Naam</th><th>Adres</th><th>Rol</th><th>Toegang</th><th>Laatst opgehaald</th><th>Status</th></tr></thead>
<tbody>
{% for m in mailboxes %}
<tr class="link" onclick="location='{{ url_for('mailbox_edit', mbid=m.id) }}'">
<td class="stack-title"><a href="{{ url_for('mailbox_edit', mbid=m.id) }}">{{ m.name }}</a></td>
<td data-label="Adres">{{ m.imap_user or m.smtp_user }}</td>
<td data-label="Rol">{{ PURPOSE.get(m.purpose, m.purpose) }}</td>
<td data-label="Toegang">{{ m.access or 'Iedereen' }}</td>
<td data-label="Laatst opgehaald" class="muted">{{ m.last_sync|dt if m.last_sync else '—' }}</td>
<td data-label="Status">{% if not m.active %}<span class="chip verloren">Inactief</span>{% elif m.last_error %}<span class="chip nieuw" title="{{ m.last_error }}">Fout</span>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table></div>
{% else %}<p class="empty">Nog geen mailbox.</p>{% endif %}
</div></div>
<form method="post"> <form method="post">
<div class="card"><div class="card-body"> <div class="card"><div class="card-body">
<h2>Bedrijfsgegevens</h2> <h2>Bedrijfsgegevens</h2>
@@ -37,43 +59,6 @@
</div> </div>
</div></div> </div></div>
<div class="card" id="mail"><div class="card-body">
<h2>E-mail koppelen</h2>
<p class="hint" style="margin:-.4rem 0 1rem">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 <code>data/</code> privé.</p>
<div class="row g-3">
<div class="col-md-6 col-xl-4"><label class="form-label" for="mail-provider">Aanbieder</label>
<select class="form-select" id="mail-provider" aria-describedby="mail-provider-hint">
<option value="">Kies om de servers in te vullen…</option>
{% for key, p in PROVIDERS.items() %}<option value="{{ key }}" data-preset="{{ p|tojson|forceescape }}">{{ p.label }}</option>{% endfor %}
</select>
<p class="hint" id="mail-provider-hint">Vult server, poort en beveiliging in; daarna kun je alles nog aanpassen.</p>
</div>
</div>
<h3>Ontvangen (IMAP)</h3>
<div class="row g-3">
{{ 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') }}
<div class="col-md-6 col-xl-4"><label class="form-label" for="imap_pass">Wachtwoord</label><input class="form-control" id="imap_pass" type="password" name="imap_pass" placeholder="{{ '•••••••• (ingesteld)' if s.imap_pass else '' }}" autocomplete="new-password"></div>
{{ field('imap_folder', 'Map', placeholder='INBOX') }}
{{ field('imap_days', 'Aantal dagen terug ophalen', 'number', '30') }}
</div>
<h3>Versturen (SMTP)</h3>
<div class="row g-3">
{{ 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') }}
<div class="col-md-6 col-xl-4"><label class="form-label" for="smtp_pass">Wachtwoord</label><input class="form-control" id="smtp_pass" type="password" name="smtp_pass" placeholder="{{ '•••••••• (ingesteld)' if s.smtp_pass else 'leeg = zelfde als IMAP' }}" autocomplete="new-password"></div>
<div class="col-md-6 col-xl-4"><label class="form-label" for="smtp_sent_folder">Kopie in map</label><input class="form-control" id="smtp_sent_folder" name="smtp_sent_folder" value="{{ s.smtp_sent_folder or '' }}" placeholder="leeg = geen kopie" aria-describedby="sent-hint"><p class="hint" id="sent-hint">Bij Zimbra/MX Plan <code>Sent</code>, zodat verstuurde mail ook in de webmail staat. Gmail doet dit zelf.</p></div>
{{ field('smtp_from', 'Afzender', placeholder='Van Emous <info@vanemouswoningen.nl>', full=true) }}
</div>
<div class="actions"><button class="btn btn-outline-primary" name="action" value="mailtest">Opslaan en verbinding testen</button></div>
</div></div>
<div class="card"><div class="card-body"> <div class="card"><div class="card-body">
<h2>Formulieren en API</h2> <h2>Formulieren en API</h2>
<div class="row g-3"> <div class="row g-3">
@@ -145,14 +130,4 @@ const { ok, id } = await res.json();{% endfilter %}</pre>
<button class="btn btn-outline-secondary btn-sm">Nieuwe API-sleutel maken</button> <button class="btn btn-outline-secondary btn-sm">Nieuwe API-sleutel maken</button>
</form> </form>
</div></div> </div></div>
<script>
document.getElementById('mail-provider').addEventListener('change', (e) => {
const opt = e.target.selectedOptions[0];
if (!opt || !opt.dataset.preset) return;
Object.entries(JSON.parse(opt.dataset.preset)).forEach(([key, value]) => {
const el = document.getElementById(key);
if (el) el.value = value;
});
});
</script>
{% endblock %} {% endblock %}
+20
View File
@@ -0,0 +1,20 @@
{% extends 'base.html' %}
{% block title %}Afmelden{% endblock %}
{% block body %}
<div class="auth-wrap">
<div class="auth">
<img src="{{ url_for('static', filename='logo.svg') }}" alt="{{ company }}">
<div class="card"><div class="card-body">
<h1>Afmelden</h1>
{% if not valid %}
<p>Deze afmeldlink is niet geldig. Neem contact met ons op als u geen nieuwsbrieven meer wilt ontvangen.</p>
{% elif done %}
<p>U bent afgemeld. U ontvangt geen nieuwsbrieven meer van {{ company }}.</p>
{% else %}
<p>Wilt u geen nieuwsbrieven meer ontvangen van {{ company }}?</p>
<form method="post"><button class="btn btn-primary">Afmelden</button></form>
{% endif %}
</div></div>
</div>
</div>
{% endblock %}