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:
co-authored by
Claude Opus 5.5
parent
8f6e1ab443
commit
cad838f1f4
@@ -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
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -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,17 +220,20 @@ 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
|
||||||
|
|
||||||
with _connect_smtp(cfg) as server:
|
|
||||||
server.send_message(msg)
|
|
||||||
|
|
||||||
warning = None
|
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')
|
folder = cfg.get('smtp_sent_folder')
|
||||||
if folder and cfg.get('imap_host'):
|
if not (folder and cfg.get('imap_host')):
|
||||||
|
return None
|
||||||
try:
|
try:
|
||||||
conn = _connect_imap(cfg)
|
conn = _connect_imap(cfg)
|
||||||
try:
|
try:
|
||||||
@@ -220,5 +243,43 @@ def send(cfg, to, subject, body, in_reply_to=None, cc='', attachments=()):
|
|||||||
finally:
|
finally:
|
||||||
conn.logout()
|
conn.logout()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
warning = f'Verstuurd, maar niet bewaard in "{folder}": {e}'
|
return f'Verstuurd, maar niet bewaard in "{folder}": {e}'
|
||||||
return msg['Message-ID'], warning
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def send(cfg, to, subject, body, in_reply_to=None, cc='', attachments=()):
|
||||||
|
"""Eén bericht versturen; geeft (Message-ID, waarschuwing of None). Een mislukte kopie in Verzonden is alleen een
|
||||||
|
waarschuwing."""
|
||||||
|
msg = build(cfg, to, subject, body, in_reply_to, cc, attachments)
|
||||||
|
with _connect_smtp(cfg) as server:
|
||||||
|
server.send_message(msg)
|
||||||
|
return msg['Message-ID'], save_sent(cfg, msg)
|
||||||
|
|
||||||
|
|
||||||
|
class Outbox:
|
||||||
|
"""Veel berichten over één SMTP-verbinding (nieuwsbrieven), zonder kopie in Verzonden:
|
||||||
|
|
||||||
|
with mail.Outbox(cfg) as out:
|
||||||
|
out.send(mail.build(cfg, …))
|
||||||
|
|
||||||
|
Bij een verbroken verbinding wordt één keer opnieuw verbonden."""
|
||||||
|
|
||||||
|
def __init__(self, cfg):
|
||||||
|
self.cfg, self.server = cfg, None
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
self.server = _connect_smtp(self.cfg)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def send(self, msg):
|
||||||
|
try:
|
||||||
|
self.server.send_message(msg)
|
||||||
|
except smtplib.SMTPServerDisconnected:
|
||||||
|
self.server = _connect_smtp(self.cfg)
|
||||||
|
self.server.send_message(msg)
|
||||||
|
|
||||||
|
def __exit__(self, *exc):
|
||||||
|
try:
|
||||||
|
self.server.quit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|||||||
+106
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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(); });
|
||||||
|
})();
|
||||||
@@ -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
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 }} <{{ e.from_addr }}>{% else %}Aan {{ e.to_addr }}{% endif %}
|
{% if incoming %}Van {{ e.from_name }} <{{ e.from_addr }}>{% 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
@@ -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 %}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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
@@ -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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
Reference in New Issue
Block a user