This commit is contained in:
jpmvaz
2026-09-13 20:03:24 +01:00
commit 0ebb8e7f4b
270 changed files with 23789 additions and 0 deletions
Binary file not shown.
@@ -0,0 +1,21 @@
# --- Web app ---
SECRET_KEY=change-me-to-a-long-random-string
BASE_URL=http://localhost:8000 # public URL used in approve/deny links
PORT=8000
ADMIN_USERNAME=admin # created on first run only
ADMIN_PASSWORD=changeme
# --- Inbound email (IMAP) — leave IMAP_HOST empty to disable email intake ---
IMAP_HOST=imap.example.com
IMAP_USER=inbox@example.com
IMAP_PASSWORD=secret
IMAP_FOLDER=INBOX
IMAP_POLL_SECONDS=30
# --- Outbound email (SMTP) — leave SMTP_HOST empty to print mail to console (dev) ---
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_SECURITY=starttls # starttls | ssl | none
SMTP_USER=inbox@example.com
SMTP_PASSWORD=secret
SMTP_FROM=approvals@example.com
@@ -0,0 +1,9 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
ENV DATABASE_PATH=/data/approvalflow.db
VOLUME /data
EXPOSE 8000
CMD ["gunicorn", "-b", "0.0.0.0:8000", "--workers", "1", "--threads", "8", "app:app"]
@@ -0,0 +1,79 @@
# ApprovalFlow
A self-hosted approval-workflow app. Requests arrive by email (or through a login-protected web form), get redirected to an approver, and once approved or denied, **everyone involved up to that point** is notified by email. Every request keeps a full audit log of everything that happened to it.
## What it does
- **Email intake.** An IMAP poller watches a mailbox. When a message arrives from a workflow's allowed domain, a request is created and redirected to that workflow's approver with one-click **Approve** / **Deny** links. Mail from any other domain is rejected (and logged).
- **Decisions notify everyone.** On approve or deny, the requester, anyone CC'd on the original email, and the approver all receive the outcome email.
- **Full log.** Every request records: received, matched workflow, approval requested, decision, notifications sent, and any mail errors — each with a timestamp.
- **Multiple named workflows**, each personalized: its own allowed sender domain, approver, and the wording of all three emails (approval request, approved notice, denied notice) with `{placeholders}`.
- **Main page (login required):** users pick a workflow they have access to and submit a request from the browser.
- **Admin pages:** create/edit/pause workflows, monitor all requests and open their full logs, decide directly from the UI, manage users, and set which users can access which workflows.
## Quick start (Docker)
```bash
cp .env.example .env # edit it: SECRET_KEY, BASE_URL, IMAP_*, SMTP_*
docker compose up -d
```
Open http://localhost:8000 and sign in with `ADMIN_USERNAME` / `ADMIN_PASSWORD` from `.env` (defaults `admin` / `changeme` — change them). The SQLite database lives in `./data/`.
## Quick start (bare Python)
```bash
pip install -r requirements.txt
cp .env.example .env
set -a; source .env; set +a # or export the variables another way
python app.py
```
Leaving `SMTP_HOST` empty prints outgoing mail to the console and leaving `IMAP_HOST` empty disables email intake — handy for trying the app before wiring up a mailbox.
## How email intake targets a workflow
1. **Plus-addressing:** mail sent to `inbox+<workflow-slug>@your-domain` targets that workflow.
2. **Subject tag:** a subject containing `[<workflow-slug>]`.
3. **Fallback:** the first active workflow whose allowed domain matches the sender.
Whichever way a workflow is matched, the sender's domain must equal the workflow's allowed domain or the request is marked **rejected** and logged.
## The request lifecycle
```
email / web form
│ received + logged
approval request → approver (approve/deny links)
│ logged
approver clicks a link (or admin decides in the UI)
│ decision logged
APPROVED or DENIED notice → everyone involved so far
(requester + CCs + approver) logged
```
## Personalization placeholders
Usable in every workflow's subject and body templates:
`{workflow}`, `{requester}`, `{subject}`, `{body}`, `{request_id}`, `{decided_by}`, `{approve_url}`, `{deny_url}`.
## Security notes
- Set a strong `SECRET_KEY` and put the app behind HTTPS (a reverse proxy such as Caddy or nginx) — the approve/deny links are secrets.
- Decision links are unguessable random tokens and work only once; a second click shows "already decided."
- Passwords are stored hashed (Werkzeug PBKDF2). Change the bootstrap admin password immediately.
- Non-admin users only ever see and use the workflows they've been granted.
## Project layout
```
app.py Flask routes, auth, admin pages
db.py SQLite schema + audit-log helpers
pipeline.py approval request + decision/notification logic
poller.py IMAP inbox watcher (background thread)
mailer.py SMTP sending + template rendering
templates/ pages static/style.css styling
```
@@ -0,0 +1,284 @@
"""ApprovalFlow — self-hosted email approval workflows."""
import functools
import json
import os
import re
import secrets
from flask import (Flask, abort, flash, g, redirect, render_template,
request, session, url_for)
from werkzeug.security import check_password_hash, generate_password_hash
import db
import pipeline
import poller
from db import DEFAULT_TEMPLATES
app = Flask(__name__)
app.secret_key = os.environ.get("SECRET_KEY") or secrets.token_hex(32)
# ---------------------------------------------------------------- helpers
def slugify(name):
s = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
return s or "workflow"
def login_required(view):
@functools.wraps(view)
def wrapped(*a, **kw):
if "user_id" not in session:
return redirect(url_for("login", next=request.path))
return view(*a, **kw)
return wrapped
def admin_required(view):
@functools.wraps(view)
def wrapped(*a, **kw):
if "user_id" not in session:
return redirect(url_for("login", next=request.path))
if not session.get("is_admin"):
abort(403)
return view(*a, **kw)
return wrapped
@app.before_request
def open_db():
g.db = db.get_db()
@app.teardown_request
def close_db(exc):
d = g.pop("db", None)
if d:
d.close()
def user_workflows(user_id):
if session.get("is_admin"):
return g.db.execute("SELECT * FROM workflows WHERE active=1 ORDER BY name").fetchall()
return g.db.execute(
"SELECT w.* FROM workflows w JOIN user_workflows uw ON uw.workflow_id=w.id "
"WHERE uw.user_id=? AND w.active=1 ORDER BY w.name", (user_id,)).fetchall()
# ---------------------------------------------------------------- auth
@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
user = g.db.execute("SELECT * FROM users WHERE username=?",
(request.form.get("username", "").strip(),)).fetchone()
if user and check_password_hash(user["password_hash"], request.form.get("password", "")):
session.clear()
session["user_id"] = user["id"]
session["username"] = user["username"]
session["is_admin"] = bool(user["is_admin"])
return redirect(request.args.get("next") or url_for("index"))
flash("Wrong username or password.", "error")
return render_template("login.html")
@app.route("/logout")
def logout():
session.clear()
return redirect(url_for("login"))
# ---------------------------------------------------------------- main page
@app.route("/", methods=["GET", "POST"])
@login_required
def index():
workflows = user_workflows(session["user_id"])
if request.method == "POST":
wf_id = request.form.get("workflow_id", type=int)
wf = next((w for w in workflows if w["id"] == wf_id), None)
if wf is None:
abort(403) # not allowed to use this workflow
subject = request.form.get("subject", "").strip()
body = request.form.get("body", "").strip()
if not subject or not body:
flash("Subject and message are both required.", "error")
else:
user = g.db.execute("SELECT * FROM users WHERE id=?", (session["user_id"],)).fetchone()
requester = user["email"] or user["username"]
rid, _ = db.create_request(g.db, wf["id"], "web", requester, subject, body)
db.log(g.db, rid, "matched", f"Submitted on the web form by {session['username']}")
g.db.commit()
pipeline.send_approval_request(rid)
flash(f"Request #{rid} sent to the approver of “{wf['name']}”.", "ok")
return redirect(url_for("index"))
my_requests = g.db.execute(
"SELECT r.*, w.name wf_name FROM requests r JOIN workflows w ON w.id=r.workflow_id "
"WHERE r.source='web' AND (r.requester=? OR r.requester=?) ORDER BY r.id DESC LIMIT 15",
(session["username"],
(g.db.execute("SELECT email FROM users WHERE id=?", (session["user_id"],)).fetchone()["email"] or "-"),
)).fetchall()
return render_template("index.html", workflows=workflows, my_requests=my_requests)
# ---------------------------------------------------------------- decision links (from email)
@app.route("/decision/<token>/<action>", methods=["GET", "POST"])
def decision(token, action):
if action not in ("approve", "deny"):
abort(404)
req = g.db.execute("SELECT r.*, w.name wf_name, w.approver_email FROM requests r "
"JOIN workflows w ON w.id=r.workflow_id WHERE r.token=?", (token,)).fetchone()
if req is None:
return render_template("decision.html", stage="notfound"), 404
if request.method == "POST":
decided_by = request.form.get("decided_by", "").strip() or req["approver_email"]
status, req2, message = pipeline.decide(token, action, decided_by)
return render_template("decision.html", stage="done", req=req2, message=message,
action=action, status=status)
return render_template("decision.html", stage="confirm", req=req, action=action)
# ---------------------------------------------------------------- admin: dashboard & requests
@app.route("/admin")
@admin_required
def admin():
rows = g.db.execute(
"SELECT r.*, w.name wf_name FROM requests r JOIN workflows w ON w.id=r.workflow_id "
"ORDER BY r.id DESC LIMIT 200").fetchall()
stats = g.db.execute(
"SELECT status, COUNT(*) c FROM requests GROUP BY status").fetchall()
workflows = g.db.execute("SELECT * FROM workflows ORDER BY name").fetchall()
return render_template("admin.html", rows=rows, workflows=workflows,
stats={s["status"]: s["c"] for s in stats})
@app.route("/admin/request/<int:rid>", methods=["GET", "POST"])
@admin_required
def admin_request(rid):
req = g.db.execute("SELECT r.*, w.name wf_name FROM requests r "
"JOIN workflows w ON w.id=r.workflow_id WHERE r.id=?", (rid,)).fetchone()
if req is None:
abort(404)
if request.method == "POST" and req["status"] == "pending":
action = request.form.get("action")
if action in ("approve", "deny"):
pipeline.decide(req["token"], action, f"{session['username']} (admin)")
return redirect(url_for("admin_request", rid=rid))
logs = g.db.execute("SELECT * FROM request_log WHERE request_id=? ORDER BY id", (rid,)).fetchall()
participants = json.loads(req["participants"])
return render_template("request_detail.html", req=req, logs=logs, participants=participants)
# ---------------------------------------------------------------- admin: workflows
WF_FIELDS = ("approval_subject", "approval_body", "approved_subject",
"approved_body", "denied_subject", "denied_body")
@app.route("/admin/workflows/new", methods=["GET", "POST"])
@app.route("/admin/workflows/<int:wid>", methods=["GET", "POST"])
@admin_required
def workflow_edit(wid=None):
wf = g.db.execute("SELECT * FROM workflows WHERE id=?", (wid,)).fetchone() if wid else None
if wid and wf is None:
abort(404)
if request.method == "POST":
f = request.form
name = f.get("name", "").strip()
allowed_domain = f.get("allowed_domain", "").strip().lstrip("@").lower()
approver_email = f.get("approver_email", "").strip()
if not (name and allowed_domain and approver_email):
flash("Name, allowed domain and approver email are required.", "error")
else:
templates = {k: f.get(k, "").strip() or DEFAULT_TEMPLATES[k] for k in WF_FIELDS}
try:
if wf is None:
g.db.execute(
"INSERT INTO workflows (name, slug, description, allowed_domain, approver_email, active,"
" approval_subject, approval_body, approved_subject, approved_body,"
" denied_subject, denied_body, created_at)"
" VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
(name, slugify(name), f.get("description", "").strip(), allowed_domain,
approver_email, 1 if f.get("active") else 0,
*[templates[k] for k in WF_FIELDS], db.now()))
flash(f"Workflow “{name}” created.", "ok")
else:
g.db.execute(
"UPDATE workflows SET name=?, slug=?, description=?, allowed_domain=?,"
" approver_email=?, active=?, approval_subject=?, approval_body=?,"
" approved_subject=?, approved_body=?, denied_subject=?, denied_body=? WHERE id=?",
(name, slugify(name), f.get("description", "").strip(), allowed_domain,
approver_email, 1 if f.get("active") else 0,
*[templates[k] for k in WF_FIELDS], wid))
flash(f"Workflow “{name}” saved.", "ok")
g.db.commit()
return redirect(url_for("admin"))
except Exception as e:
flash(f"Could not save: {e}", "error")
return render_template("workflow_edit.html", wf=wf, defaults=DEFAULT_TEMPLATES)
# ---------------------------------------------------------------- admin: users & access
@app.route("/admin/users", methods=["GET", "POST"])
@admin_required
def users():
if request.method == "POST":
f = request.form
act = f.get("action")
try:
if act == "create":
if not f.get("username") or not f.get("password"):
flash("Username and password are required.", "error")
else:
g.db.execute(
"INSERT INTO users (username, password_hash, email, is_admin, created_at) VALUES (?,?,?,?,?)",
(f["username"].strip(), generate_password_hash(f["password"]),
f.get("email", "").strip(), 1 if f.get("is_admin") else 0, db.now()))
flash(f"User “{f['username']}” created.", "ok")
elif act == "delete":
uid = f.get("user_id", type=int)
if uid == session["user_id"]:
flash("You can't delete your own account.", "error")
else:
g.db.execute("DELETE FROM users WHERE id=?", (uid,))
flash("User deleted.", "ok")
elif act == "password":
g.db.execute("UPDATE users SET password_hash=? WHERE id=?",
(generate_password_hash(f["password"]), f.get("user_id", type=int)))
flash("Password updated.", "ok")
elif act == "access":
uid = f.get("user_id", type=int)
g.db.execute("DELETE FROM user_workflows WHERE user_id=?", (uid,))
for wid in request.form.getlist("workflow_ids", type=int):
g.db.execute("INSERT OR IGNORE INTO user_workflows (user_id, workflow_id) VALUES (?,?)",
(uid, wid))
flash("Access updated.", "ok")
g.db.commit()
except Exception as e:
flash(f"Error: {e}", "error")
return redirect(url_for("users"))
all_users = g.db.execute("SELECT * FROM users ORDER BY username").fetchall()
workflows = g.db.execute("SELECT * FROM workflows ORDER BY name").fetchall()
access = {}
for row in g.db.execute("SELECT * FROM user_workflows"):
access.setdefault(row["user_id"], set()).add(row["workflow_id"])
return render_template("users.html", users=all_users, workflows=workflows, access=access)
# ---------------------------------------------------------------- boot
db.init_db()
poller.start()
if __name__ == "__main__":
app.run(host=os.environ.get("HOST", "0.0.0.0"),
port=int(os.environ.get("PORT", "8000")),
debug=os.environ.get("FLASK_DEBUG") == "1")
@@ -0,0 +1,157 @@
"""SQLite data layer for ApprovalFlow."""
import json
import os
import secrets
import sqlite3
from datetime import datetime, timezone
from werkzeug.security import generate_password_hash
DB_PATH = os.environ.get("DATABASE_PATH", os.path.join(os.path.dirname(__file__), "data", "approvalflow.db"))
SCHEMA = """
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
email TEXT DEFAULT '',
is_admin INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS workflows (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
slug TEXT UNIQUE NOT NULL,
description TEXT DEFAULT '',
allowed_domain TEXT NOT NULL, -- only senders @this domain are accepted
approver_email TEXT NOT NULL, -- who receives the approval request
active INTEGER NOT NULL DEFAULT 1,
-- personalization (templates support {placeholders})
approval_subject TEXT NOT NULL,
approval_body TEXT NOT NULL,
approved_subject TEXT NOT NULL,
approved_body TEXT NOT NULL,
denied_subject TEXT NOT NULL,
denied_body TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS user_workflows (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
workflow_id INTEGER NOT NULL REFERENCES workflows(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, workflow_id)
);
CREATE TABLE IF NOT EXISTS requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workflow_id INTEGER NOT NULL REFERENCES workflows(id),
source TEXT NOT NULL, -- 'email' or 'web'
requester TEXT NOT NULL, -- email address (or username for web)
subject TEXT NOT NULL,
body TEXT NOT NULL,
participants TEXT NOT NULL DEFAULT '[]', -- JSON list of everyone involved so far
status TEXT NOT NULL DEFAULT 'pending', -- pending / approved / denied / rejected
token TEXT UNIQUE NOT NULL, -- secret for approve/deny links
decided_by TEXT DEFAULT '',
created_at TEXT NOT NULL,
decided_at TEXT
);
CREATE TABLE IF NOT EXISTS request_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
request_id INTEGER REFERENCES requests(id) ON DELETE CASCADE,
at TEXT NOT NULL,
event TEXT NOT NULL,
detail TEXT DEFAULT ''
);
"""
DEFAULT_TEMPLATES = {
"approval_subject": "[{workflow}] Approval needed: {subject}",
"approval_body": (
"A new request needs your decision.\n\n"
"Workflow: {workflow}\n"
"From: {requester}\n"
"Subject: {subject}\n\n"
"Message:\n{body}\n\n"
"Approve: {approve_url}\n"
"Deny: {deny_url}\n"
),
"approved_subject": "[{workflow}] Approved: {subject}",
"approved_body": (
"The following request was APPROVED by {decided_by}.\n\n"
"Workflow: {workflow}\n"
"From: {requester}\n"
"Subject: {subject}\n\n"
"Message:\n{body}\n"
),
"denied_subject": "[{workflow}] Denied: {subject}",
"denied_body": (
"The following request was DENIED by {decided_by}.\n\n"
"Workflow: {workflow}\n"
"From: {requester}\n"
"Subject: {subject}\n\n"
"Message:\n{body}\n"
),
}
def now():
return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
def get_db():
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
con = sqlite3.connect(DB_PATH)
con.row_factory = sqlite3.Row
con.execute("PRAGMA foreign_keys = ON")
return con
def init_db():
con = get_db()
con.executescript(SCHEMA)
# bootstrap admin user on first run
if con.execute("SELECT COUNT(*) c FROM users").fetchone()["c"] == 0:
username = os.environ.get("ADMIN_USERNAME", "admin")
password = os.environ.get("ADMIN_PASSWORD", "changeme")
con.execute(
"INSERT INTO users (username, password_hash, is_admin, created_at) VALUES (?,?,1,?)",
(username, generate_password_hash(password), now()),
)
print(f"[init] Created admin user '{username}'. Change the password immediately.")
con.commit()
con.close()
def log(con, request_id, event, detail=""):
con.execute(
"INSERT INTO request_log (request_id, at, event, detail) VALUES (?,?,?,?)",
(request_id, now(), event, detail),
)
def add_participants(con, request_id, emails):
"""Add people to the 'involved so far' list of a request."""
row = con.execute("SELECT participants FROM requests WHERE id=?", (request_id,)).fetchone()
current = json.loads(row["participants"])
for e in emails:
e = (e or "").strip().lower()
if e and e not in current:
current.append(e)
con.execute("UPDATE requests SET participants=? WHERE id=?", (json.dumps(current), request_id))
return current
def create_request(con, workflow_id, source, requester, subject, body, extra_participants=()):
token = secrets.token_urlsafe(32)
cur = con.execute(
"INSERT INTO requests (workflow_id, source, requester, subject, body, token, created_at) "
"VALUES (?,?,?,?,?,?,?)",
(workflow_id, source, requester, subject, body, token, now()),
)
rid = cur.lastrowid
add_participants(con, rid, [requester, *extra_participants])
log(con, rid, "received", f"Request received via {source} from {requester}")
return rid, token
@@ -0,0 +1,9 @@
services:
approvalflow:
build: .
ports:
- "8000:8000"
env_file: .env
volumes:
- ./data:/data
restart: unless-stopped
@@ -0,0 +1,54 @@
"""Outbound email (SMTP) and template rendering."""
import os
import smtplib
from email.message import EmailMessage
def render(template: str, **vars) -> str:
"""Safe {placeholder} substitution — unknown placeholders are left as-is."""
out = template
for k, v in vars.items():
out = out.replace("{" + k + "}", str(v))
return out
def send_mail(to_addrs, subject, body):
"""Send a plain-text email. Returns (ok, detail)."""
if isinstance(to_addrs, str):
to_addrs = [to_addrs]
to_addrs = [a for a in to_addrs if a]
if not to_addrs:
return False, "no recipients"
host = os.environ.get("SMTP_HOST", "")
port = int(os.environ.get("SMTP_PORT", "587"))
user = os.environ.get("SMTP_USER", "")
password = os.environ.get("SMTP_PASSWORD", "")
sender = os.environ.get("SMTP_FROM", user or "approvalflow@localhost")
security = os.environ.get("SMTP_SECURITY", "starttls").lower() # starttls / ssl / none
msg = EmailMessage()
msg["From"] = sender
msg["To"] = ", ".join(to_addrs)
msg["Subject"] = subject
msg.set_content(body)
if not host:
# Dev mode: print instead of sending so the app is testable without SMTP.
print(f"[mail:DEV] To: {msg['To']}\nSubject: {subject}\n{body}\n{'-'*50}")
return True, "dev mode (SMTP_HOST not set) — printed to console"
try:
if security == "ssl":
server = smtplib.SMTP_SSL(host, port, timeout=30)
else:
server = smtplib.SMTP(host, port, timeout=30)
if security == "starttls":
server.starttls()
if user:
server.login(user, password)
server.send_message(msg)
server.quit()
return True, f"sent to {', '.join(to_addrs)}"
except Exception as e:
return False, f"SMTP error: {e}"
@@ -0,0 +1,76 @@
"""Request pipeline shared by the web form and the email poller."""
import os
import db
from mailer import render, send_mail
BASE_URL = os.environ.get("BASE_URL", "http://localhost:8000").rstrip("/")
def _vars(wf, req, **extra):
v = {
"workflow": wf["name"],
"requester": req["requester"],
"subject": req["subject"],
"body": req["body"],
"request_id": req["id"],
"approve_url": f"{BASE_URL}/decision/{req['token']}/approve",
"deny_url": f"{BASE_URL}/decision/{req['token']}/deny",
}
v.update(extra)
return v
def send_approval_request(request_id):
"""Redirect the request to the approver, asking for approval."""
con = db.get_db()
try:
req = con.execute("SELECT * FROM requests WHERE id=?", (request_id,)).fetchone()
wf = con.execute("SELECT * FROM workflows WHERE id=?", (req["workflow_id"],)).fetchone()
v = _vars(wf, req)
ok, detail = send_mail(wf["approver_email"],
render(wf["approval_subject"], **v),
render(wf["approval_body"], **v))
db.add_participants(con, request_id, [wf["approver_email"]])
db.log(con, request_id,
"approval requested" if ok else "mail error",
f"Approval request to {wf['approver_email']}{detail}")
con.commit()
return ok
finally:
con.close()
def decide(token, action, decided_by):
"""Approve or deny a request; notify everyone involved so far.
Returns (status, request_row_or_None, message).
"""
assert action in ("approve", "deny")
con = db.get_db()
try:
req = con.execute("SELECT * FROM requests WHERE token=?", (token,)).fetchone()
if req is None:
return "notfound", None, "This link is not valid."
if req["status"] != "pending":
return "already", req, f"This request was already {req['status']}."
wf = con.execute("SELECT * FROM workflows WHERE id=?", (req["workflow_id"],)).fetchone()
new_status = "approved" if action == "approve" else "denied"
con.execute("UPDATE requests SET status=?, decided_by=?, decided_at=? WHERE id=?",
(new_status, decided_by, db.now(), req["id"]))
db.log(con, req["id"], new_status, f"Decision by {decided_by}")
participants = db.add_participants(con, req["id"], [decided_by])
v = _vars(wf, req, decided_by=decided_by, decision=new_status.upper())
subj_t = wf["approved_subject"] if new_status == "approved" else wf["denied_subject"]
body_t = wf["approved_body"] if new_status == "approved" else wf["denied_body"]
ok, detail = send_mail(participants, render(subj_t, **v), render(body_t, **v))
db.log(con, req["id"],
"participants notified" if ok else "mail error",
f"{new_status.capitalize()} notice to {', '.join(participants)}{detail}")
con.commit()
req = con.execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone()
return "ok", req, f"Request #{req['id']} {new_status}. Everyone involved has been notified."
finally:
con.close()
@@ -0,0 +1,146 @@
"""IMAP inbox poller.
Watches a mailbox for new messages and turns them into approval requests.
Workflow matching, in order:
1. Plus-addressing: mail sent to inbox+<workflow-slug>@domain targets that workflow.
2. Subject tag: a subject containing [<workflow-slug>] targets that workflow.
3. Fallback: the first active workflow whose allowed_domain matches the sender.
The sender's domain must match the matched workflow's allowed_domain,
otherwise the message is logged and rejected.
"""
import email
import email.utils
import imaplib
import os
import re
import threading
import time
import traceback
import db
import pipeline
def _addr_list(msg, header):
raw = msg.get_all(header, [])
return [a.lower() for _, a in email.utils.getaddresses(raw) if a]
def _body_text(msg):
if msg.is_multipart():
for part in msg.walk():
if part.get_content_type() == "text/plain" and not part.get("Content-Disposition"):
try:
return part.get_payload(decode=True).decode(part.get_content_charset() or "utf-8", "replace")
except Exception:
continue
return "(no readable text body)"
payload = msg.get_payload(decode=True)
if payload is None:
return str(msg.get_payload())
return payload.decode(msg.get_content_charset() or "utf-8", "replace")
def _match_workflow(con, sender, recipients, subject):
workflows = con.execute("SELECT * FROM workflows WHERE active=1").fetchall()
by_slug = {w["slug"]: w for w in workflows}
# 1. plus-addressing: inbox+slug@...
for addr in recipients:
m = re.match(r"^[^+@]+\+([a-z0-9\-]+)@", addr)
if m and m.group(1) in by_slug:
return by_slug[m.group(1)], "plus-address"
# 2. subject tag [slug]
for slug, w in by_slug.items():
if f"[{slug}]" in subject.lower():
return w, "subject tag"
# 3. sender-domain fallback
domain = sender.split("@")[-1].lower() if "@" in sender else ""
for w in workflows:
if w["allowed_domain"].lower().lstrip("@") == domain:
return w, "sender domain"
return None, None
def process_message(raw_bytes):
msg = email.message_from_bytes(raw_bytes)
sender = (email.utils.parseaddr(msg.get("From", ""))[1] or "").lower()
recipients = _addr_list(msg, "To") + _addr_list(msg, "Cc")
subject = msg.get("Subject", "(no subject)")
body = _body_text(msg).strip()
con = db.get_db()
try:
wf, how = _match_workflow(con, sender, recipients, subject)
if wf is None:
print(f"[poller] No workflow matched mail from {sender} — ignored.")
return
sender_domain = sender.split("@")[-1] if "@" in sender else ""
allowed = wf["allowed_domain"].lower().lstrip("@")
if sender_domain != allowed:
rid, _ = db.create_request(con, wf["id"], "email", sender, subject, body)
con.execute("UPDATE requests SET status='rejected' WHERE id=?", (rid,))
db.log(con, rid, "rejected",
f"Sender domain '{sender_domain}' is not the allowed domain '@{allowed}' for workflow '{wf['name']}'")
con.commit()
print(f"[poller] Rejected mail from {sender} for workflow {wf['name']} (wrong domain).")
return
# CC'd people are "involved up to this point" too — everyone on To/Cc
# except the sender and the intake mailbox itself (incl. its +tag variants)
inbox_local = (os.environ.get("IMAP_USER") or "").split("@")[0].lower()
def is_intake(addr):
base = addr.split("@")[0].split("+")[0]
return (inbox_local and base == inbox_local) or \
addr.split("@")[0].endswith("+" + wf["slug"])
ccs = [a for a in recipients if a != sender and not is_intake(a)]
rid, token = db.create_request(con, wf["id"], "email", sender, subject, body, extra_participants=ccs)
db.log(con, rid, "matched", f"Matched workflow '{wf['name']}' via {how}")
con.commit()
pipeline.send_approval_request(rid)
print(f"[poller] Request #{rid} created for workflow '{wf['name']}' from {sender}.")
except Exception:
traceback.print_exc()
finally:
con.close()
def poll_loop():
host = os.environ.get("IMAP_HOST", "")
user = os.environ.get("IMAP_USER", "")
password = os.environ.get("IMAP_PASSWORD", "")
folder = os.environ.get("IMAP_FOLDER", "INBOX")
interval = int(os.environ.get("IMAP_POLL_SECONDS", "30"))
if not host:
print("[poller] IMAP_HOST not set — email intake disabled (web form still works).")
return
print(f"[poller] Watching {user} on {host} every {interval}s.")
while True:
try:
imap = imaplib.IMAP4_SSL(host)
imap.login(user, password)
imap.select(folder)
_, data = imap.search(None, "UNSEEN")
for num in data[0].split():
_, msg_data = imap.fetch(num, "(RFC822)")
process_message(msg_data[0][1])
imap.store(num, "+FLAGS", "\\Seen")
imap.logout()
except Exception as e:
print(f"[poller] Error: {e}")
time.sleep(interval)
def start():
t = threading.Thread(target=poll_loop, daemon=True)
t.start()
@@ -0,0 +1,3 @@
flask>=3.0
werkzeug>=3.0
gunicorn>=21.0
@@ -0,0 +1,136 @@
/* ApprovalFlow — cool paper + ink, rubber-stamp statuses, mono ledger */
:root {
--paper: #fafbfc;
--card: #ffffff;
--ink: #16232e;
--ink-soft: #5a6b78;
--line: #dfe5ea;
--approve: #0e7c66;
--deny: #b3372f;
--pending: #b7791f;
--focus: #2563a8;
--radius: 8px;
font-size: 16px;
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--paper); color: var(--ink);
font-family: "IBM Plex Sans", system-ui, sans-serif; line-height: 1.55;
}
.mono, code, .ledger, .stamp, .chip { font-family: "IBM Plex Mono", ui-monospace, monospace; }
a { color: var(--focus); text-decoration: none; }
a:hover { text-decoration: underline; }
:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
/* top bar */
.topbar {
display: flex; align-items: center; gap: 2rem; flex-wrap: wrap;
padding: .8rem 1.5rem; background: var(--ink); color: #e9eef2;
}
.brand { font-weight: 700; font-size: 1.05rem; color: #fff; letter-spacing: .02em; }
.brand span { color: #7fd6c2; }
.topbar nav { display: flex; gap: 1.2rem; flex: 1; }
.topbar nav a { color: #b9c6cf; padding: .2rem 0; border-bottom: 2px solid transparent; }
.topbar nav a.on, .topbar nav a:hover { color: #fff; border-bottom-color: #7fd6c2; text-decoration: none; }
.who { display: flex; gap: 1rem; align-items: center; font-size: .9rem; }
.who a { color: #b9c6cf; }
.wrap { max-width: 980px; margin: 2rem auto 4rem; padding: 0 1.25rem; }
h1 { font-size: 1.6rem; margin: 0 0 .3rem; }
h2 { font-size: 1.15rem; margin: 2rem 0 .8rem; }
.sub { color: var(--ink-soft); margin: 0 0 1.6rem; }
.card {
background: var(--card); border: 1px solid var(--line);
border-radius: var(--radius); padding: 1.4rem 1.5rem; margin-bottom: 1.4rem;
}
/* forms */
label { display: block; font-weight: 600; font-size: .85rem; margin: 1rem 0 .3rem; }
label small { font-weight: 400; color: var(--ink-soft); }
input[type=text], input[type=password], input[type=email], select, textarea {
width: 100%; padding: .55rem .7rem; font: inherit; color: var(--ink);
border: 1px solid var(--line); border-radius: 6px; background: #fff;
}
textarea { min-height: 130px; resize: vertical; }
textarea.tpl { font-family: "IBM Plex Mono", monospace; font-size: .85rem; min-height: 110px; }
.check { display: flex; gap: .5rem; align-items: center; font-weight: 400; margin-top: 1rem; }
.check input { width: auto; }
button, .btn {
display: inline-block; margin-top: 1.1rem; padding: .55rem 1.3rem;
font: inherit; font-weight: 600; border-radius: 6px; cursor: pointer;
border: 1px solid var(--ink); background: var(--ink); color: #fff;
}
button:hover, .btn:hover { background: #223546; text-decoration: none; }
.btn.approve { background: var(--approve); border-color: var(--approve); }
.btn.deny { background: var(--deny); border-color: var(--deny); }
.btn.ghost { background: transparent; color: var(--ink); }
.btn.small, button.small { padding: .3rem .8rem; font-size: .82rem; margin-top: 0; }
/* the signature: rubber-stamp status */
.stamp {
display: inline-block; padding: .1rem .55rem; font-size: .72rem; font-weight: 600;
letter-spacing: .12em; text-transform: uppercase; border: 2px solid;
border-radius: 4px; transform: rotate(-2deg);
}
.stamp.approved { color: var(--approve); border-color: var(--approve); }
.stamp.denied, .stamp.rejected { color: var(--deny); border-color: var(--deny); }
.stamp.pending { color: var(--pending); border-color: var(--pending); }
.stamp.big { font-size: 1.1rem; padding: .3rem 1rem; }
.chip {
display: inline-block; font-size: .72rem; padding: .05rem .5rem;
border: 1px solid var(--line); border-radius: 999px; color: var(--ink-soft);
}
/* tables */
table { width: 100%; border-collapse: collapse; font-size: .9rem; }
th { text-align: left; font-size: .72rem; text-transform: uppercase; letter-spacing: .08em;
color: var(--ink-soft); padding: .5rem .6rem; border-bottom: 2px solid var(--line); }
td { padding: .55rem .6rem; border-bottom: 1px solid var(--line); vertical-align: top; }
tr:hover td { background: #f2f6f8; }
/* ledger (audit log) */
.ledger { list-style: none; margin: 0; padding: 0; font-size: .85rem; }
.ledger li {
display: grid; grid-template-columns: 175px 170px 1fr; gap: 1rem;
padding: .55rem .2rem; border-bottom: 1px dashed var(--line);
}
.ledger .t { color: var(--ink-soft); }
.ledger .e { font-weight: 600; }
@media (max-width: 700px) { .ledger li { grid-template-columns: 1fr; gap: .1rem; } }
/* stat row */
.stats { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.5rem; }
.stat { flex: 1 1 120px; background: var(--card); border: 1px solid var(--line);
border-radius: var(--radius); padding: .8rem 1rem; }
.stat b { display: block; font-size: 1.5rem; font-family: "IBM Plex Mono", monospace; }
.stat span { font-size: .78rem; text-transform: uppercase; letter-spacing: .08em; color: var(--ink-soft); }
.flash { padding: .7rem 1rem; border-radius: 6px; margin-bottom: 1rem; font-size: .92rem; }
.flash.ok { background: #e4f3ee; border: 1px solid #bfe0d5; color: #0b5c4c; }
.flash.error { background: #f9e8e6; border: 1px solid #edc7c3; color: #872a24; }
/* login */
.login-box { max-width: 380px; margin: 12vh auto 0; }
.login-box .brand-lg { font-size: 1.7rem; font-weight: 700; }
.login-box .brand-lg span { color: var(--approve); }
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0 1.5rem; }
@media (max-width: 700px) { .grid2 { grid-template-columns: 1fr; } }
.rowline { display: flex; gap: .6rem; align-items: center; flex-wrap: wrap; }
form.inline { display: inline; }
details { margin-top: .4rem; }
summary { cursor: pointer; font-size: .85rem; color: var(--focus); }
.muted { color: var(--ink-soft); font-size: .85rem; }
.pre { white-space: pre-wrap; font-size: .9rem; background: #f4f6f8; border: 1px solid var(--line);
border-radius: 6px; padding: .8rem 1rem; }
@media (prefers-reduced-motion: no-preference) {
.stamp.big { animation: stampIn .25s ease-out; }
@keyframes stampIn { from { transform: rotate(-2deg) scale(1.6); opacity: 0; }
to { transform: rotate(-2deg) scale(1); opacity: 1; } }
}
@@ -0,0 +1,53 @@
{% extends "base.html" %}
{% block title %}Workflows &amp; requests — ApprovalFlow{% endblock %}
{% block content %}
<h1>Workflows &amp; requests</h1>
<p class="sub">Create workflows, watch every request, and open any of them for the full audit trail.</p>
<div class="stats">
<div class="stat"><b>{{ stats.get('pending', 0) }}</b><span>Pending</span></div>
<div class="stat"><b>{{ stats.get('approved', 0) }}</b><span>Approved</span></div>
<div class="stat"><b>{{ stats.get('denied', 0) }}</b><span>Denied</span></div>
<div class="stat"><b>{{ stats.get('rejected', 0) }}</b><span>Rejected</span></div>
</div>
<h2>Workflows</h2>
<div class="card">
<table>
<tr><th>Name</th><th>Intake tag</th><th>Allowed domain</th><th>Approver</th><th>Status</th><th></th></tr>
{% for w in workflows %}
<tr>
<td><b>{{ w.name }}</b>{% if w.description %}<br><span class="muted">{{ w.description }}</span>{% endif %}</td>
<td class="mono">+{{ w.slug }} / [{{ w.slug }}]</td>
<td class="mono">@{{ w.allowed_domain }}</td>
<td class="mono">{{ w.approver_email }}</td>
<td>{% if w.active %}<span class="chip">active</span>{% else %}<span class="chip">paused</span>{% endif %}</td>
<td><a class="btn ghost small" href="{{ url_for('workflow_edit', wid=w.id) }}">Edit</a></td>
</tr>
{% else %}
<tr><td colspan="6" class="muted">No workflows yet — create the first one.</td></tr>
{% endfor %}
</table>
<a class="btn" href="{{ url_for('workflow_edit') }}">New workflow</a>
</div>
<h2>Requests</h2>
<div class="card">
<table>
<tr><th>#</th><th>Workflow</th><th>From</th><th>Subject</th><th>Source</th><th>Status</th><th>Received</th></tr>
{% for r in rows %}
<tr>
<td class="mono"><a href="{{ url_for('admin_request', rid=r.id) }}">{{ r.id }}</a></td>
<td>{{ r.wf_name }}</td>
<td class="mono">{{ r.requester }}</td>
<td><a href="{{ url_for('admin_request', rid=r.id) }}">{{ r.subject }}</a></td>
<td><span class="chip">{{ r.source }}</span></td>
<td><span class="stamp {{ r.status }}">{{ r.status }}</span></td>
<td class="mono muted">{{ r.created_at }}</td>
</tr>
{% else %}
<tr><td colspan="7" class="muted">No requests yet.</td></tr>
{% endfor %}
</table>
</div>
{% endblock %}
@@ -0,0 +1,35 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}ApprovalFlow{% endblock %}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
{% if session.get('user_id') %}
<header class="topbar">
<a class="brand" href="{{ url_for('index') }}">Approval<span>Flow</span></a>
<nav>
<a href="{{ url_for('index') }}" {% if request.endpoint=='index' %}class="on"{% endif %}>New request</a>
{% if session.get('is_admin') %}
<a href="{{ url_for('admin') }}" {% if request.endpoint in ('admin','admin_request','workflow_edit') %}class="on"{% endif %}>Workflows &amp; requests</a>
<a href="{{ url_for('users') }}" {% if request.endpoint=='users' %}class="on"{% endif %}>Users</a>
{% endif %}
</nav>
<div class="who">
<span class="mono">{{ session['username'] }}</span>
<a href="{{ url_for('logout') }}">Sign out</a>
</div>
</header>
{% endif %}
<main class="wrap">
{% with msgs = get_flashed_messages(with_categories=true) %}
{% for cat, m in msgs %}<div class="flash {{ cat }}">{{ m }}</div>{% endfor %}
{% endwith %}
{% block content %}{% endblock %}
</main>
</body>
</html>
@@ -0,0 +1,42 @@
{% extends "base.html" %}
{% block title %}Decision — ApprovalFlow{% endblock %}
{% block content %}
<div class="login-box card" style="max-width:560px">
{% if stage == 'notfound' %}
<h1>Link not valid</h1>
<p class="sub">This decision link doesn't match any request. It may have been mistyped or removed.</p>
{% elif stage == 'confirm' %}
{% if req.status != 'pending' %}
<h1>Already decided</h1>
<p class="sub">Request #{{ req.id }} was already
<span class="stamp {{ req.status }}">{{ req.status }}</span>
{% if req.decided_by %} by {{ req.decided_by }}{% endif %}.</p>
{% else %}
<h1>{% if action == 'approve' %}Approve{% else %}Deny{% endif %} this request?</h1>
<p class="sub">Workflow <b>{{ req.wf_name }}</b> · request #{{ req.id }}</p>
<p><b>From:</b> {{ req.requester }}<br><b>Subject:</b> {{ req.subject }}</p>
<div class="pre">{{ req.body }}</div>
<form method="post">
<label for="d">Your email <small>(recorded in the log and used to notify you)</small></label>
<input id="d" type="email" name="decided_by" value="{{ req.approver_email }}">
<button type="submit" class="btn {{ 'approve' if action == 'approve' else 'deny' }}">
{% if action == 'approve' %}Approve request{% else %}Deny request{% endif %}
</button>
</form>
<p class="muted">Everyone involved so far will be emailed the outcome.</p>
{% endif %}
{% elif stage == 'done' %}
{% if status == 'ok' %}
<p style="text-align:center;margin:1.5rem 0">
<span class="stamp big {{ req.status }}">{{ req.status }}</span>
</p>
<h1 style="text-align:center">Request #{{ req.id }}</h1>
<p class="sub" style="text-align:center">{{ message }}</p>
{% else %}
<h1>{{ message }}</h1>
{% endif %}
{% endif %}
</div>
{% endblock %}
@@ -0,0 +1,46 @@
{% extends "base.html" %}
{% block title %}New request — ApprovalFlow{% endblock %}
{% block content %}
<h1>Send a request for approval</h1>
<p class="sub">Pick a workflow, write your message, and it goes straight to that workflow's approver.</p>
{% if workflows %}
<div class="card">
<form method="post">
<label for="wf">Workflow</label>
<select id="wf" name="workflow_id" required>
{% for w in workflows %}
<option value="{{ w.id }}">{{ w.name }}{% if w.description %} — {{ w.description }}{% endif %}</option>
{% endfor %}
</select>
<label for="s">Subject</label>
<input id="s" type="text" name="subject" maxlength="200" required>
<label for="b">Message</label>
<textarea id="b" name="body" required></textarea>
<button type="submit">Send for approval</button>
</form>
</div>
{% else %}
<div class="card">
<p class="muted">You don't have access to any workflow yet. Ask an administrator to grant you access.</p>
</div>
{% endif %}
{% if my_requests %}
<h2>Your recent requests</h2>
<div class="card">
<table>
<tr><th>#</th><th>Workflow</th><th>Subject</th><th>Status</th><th>Sent</th></tr>
{% for r in my_requests %}
<tr>
<td class="mono">{{ r.id }}</td>
<td>{{ r.wf_name }}</td>
<td>{{ r.subject }}</td>
<td><span class="stamp {{ r.status }}">{{ r.status }}</span></td>
<td class="mono muted">{{ r.created_at }}</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
{% endblock %}
@@ -0,0 +1,15 @@
{% extends "base.html" %}
{% block title %}Sign in — ApprovalFlow{% endblock %}
{% block content %}
<div class="login-box card">
<div class="brand-lg">Approval<span>Flow</span></div>
<p class="sub">Sign in to send and track requests.</p>
<form method="post">
<label for="u">Username</label>
<input id="u" type="text" name="username" autofocus required>
<label for="p">Password</label>
<input id="p" type="password" name="password" required>
<button type="submit">Sign in</button>
</form>
</div>
{% endblock %}
@@ -0,0 +1,40 @@
{% extends "base.html" %}
{% block title %}Request #{{ req.id }} — ApprovalFlow{% endblock %}
{% block content %}
<p><a href="{{ url_for('admin') }}">&larr; All requests</a></p>
<div class="rowline">
<h1>Request #{{ req.id }}</h1>
<span class="stamp big {{ req.status }}">{{ req.status }}</span>
</div>
<p class="sub">Workflow <b>{{ req.wf_name }}</b> · via {{ req.source }} · received {{ req.created_at }}</p>
<div class="card">
<p><b>From:</b> <span class="mono">{{ req.requester }}</span><br>
<b>Subject:</b> {{ req.subject }}</p>
<div class="pre">{{ req.body }}</div>
{% if req.decided_by %}
<p class="muted">Decided by <span class="mono">{{ req.decided_by }}</span> at {{ req.decided_at }}</p>
{% endif %}
{% if req.status == 'pending' %}
<form method="post" class="rowline">
<button class="btn approve small" name="action" value="approve">Approve now</button>
<button class="btn deny small" name="action" value="deny">Deny now</button>
<span class="muted">Deciding here notifies everyone involved, same as the email links.</span>
</form>
{% endif %}
</div>
<h2>People involved so far</h2>
<div class="card">
{% for p in participants %}<span class="chip">{{ p }}</span> {% else %}<span class="muted">None recorded.</span>{% endfor %}
</div>
<h2>Full log</h2>
<div class="card">
<ul class="ledger">
{% for l in logs %}
<li><span class="t">{{ l.at }}</span><span class="e">{{ l.event }}</span><span>{{ l.detail }}</span></li>
{% endfor %}
</ul>
</div>
{% endblock %}
@@ -0,0 +1,66 @@
{% extends "base.html" %}
{% block title %}Users — ApprovalFlow{% endblock %}
{% block content %}
<h1>Users</h1>
<p class="sub">Create accounts and choose which workflows each user can send requests to. Admins can use every workflow.</p>
<div class="card">
<h2 style="margin-top:0">Add a user</h2>
<form method="post">
<input type="hidden" name="action" value="create">
<div class="grid2">
<div><label>Username</label><input type="text" name="username" required></div>
<div><label>Password</label><input type="password" name="password" required></div>
<div><label>Email <small>(used as requester address and for outcome notices)</small></label>
<input type="email" name="email"></div>
</div>
<label class="check"><input type="checkbox" name="is_admin"> Administrator</label>
<button type="submit">Create user</button>
</form>
</div>
{% for u in users %}
<div class="card">
<div class="rowline">
<b>{{ u.username }}</b>
{% if u.is_admin %}<span class="chip">admin</span>{% endif %}
{% if u.email %}<span class="mono muted">{{ u.email }}</span>{% endif %}
<span style="flex:1"></span>
{% if u.id != session['user_id'] %}
<form method="post" class="inline" onsubmit="return confirm('Delete {{ u.username }}?')">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="user_id" value="{{ u.id }}">
<button class="btn ghost small">Delete</button>
</form>
{% endif %}
</div>
{% if not u.is_admin %}
<form method="post">
<input type="hidden" name="action" value="access">
<input type="hidden" name="user_id" value="{{ u.id }}">
<label>Workflow access</label>
{% for w in workflows %}
<label class="check" style="margin-top:.3rem">
<input type="checkbox" name="workflow_ids" value="{{ w.id }}"
{% if w.id in access.get(u.id, ()) %}checked{% endif %}> {{ w.name }}
</label>
{% else %}
<span class="muted">No workflows exist yet.</span>
{% endfor %}
{% if workflows %}<button class="small" type="submit">Save access</button>{% endif %}
</form>
{% endif %}
<details>
<summary>Reset password</summary>
<form method="post" class="rowline" style="margin-top:.5rem">
<input type="hidden" name="action" value="password">
<input type="hidden" name="user_id" value="{{ u.id }}">
<input type="password" name="password" placeholder="New password" required style="max-width:240px">
<button class="small" type="submit">Set password</button>
</form>
</details>
</div>
{% endfor %}
{% endblock %}
@@ -0,0 +1,61 @@
{% extends "base.html" %}
{% block title %}{{ 'Edit' if wf else 'New' }} workflow — ApprovalFlow{% endblock %}
{% block content %}
<p><a href="{{ url_for('admin') }}">&larr; All workflows</a></p>
<h1>{{ 'Edit workflow' if wf else 'New workflow' }}</h1>
<p class="sub">Each workflow has its own allowed sender domain, its own approver, and its own email wording.</p>
<form method="post">
<div class="card">
<h2 style="margin-top:0">Basics</h2>
<div class="grid2">
<div>
<label for="n">Name</label>
<input id="n" type="text" name="name" value="{{ wf.name if wf else '' }}" required>
</div>
<div>
<label for="de">Description <small>(shown to users on the main page)</small></label>
<input id="de" type="text" name="description" value="{{ wf.description if wf else '' }}">
</div>
<div>
<label for="ad">Allowed sender domain <small>(only email from this domain is accepted)</small></label>
<input id="ad" type="text" name="allowed_domain" placeholder="example.com"
value="{{ wf.allowed_domain if wf else '' }}" required>
</div>
<div>
<label for="ap">Approver email</label>
<input id="ap" type="email" name="approver_email" value="{{ wf.approver_email if wf else '' }}" required>
</div>
</div>
<label class="check"><input type="checkbox" name="active" {% if not wf or wf.active %}checked{% endif %}> Active — accept new requests</label>
{% if wf %}<p class="muted">Email intake: send to <span class="mono">inbox+{{ wf.slug }}@your-domain</span> or put <span class="mono">[{{ wf.slug }}]</span> in the subject.</p>{% endif %}
</div>
<div class="card">
<h2 style="margin-top:0">Email wording</h2>
<p class="muted">Placeholders: <span class="mono">{workflow} {requester} {subject} {body} {request_id} {decided_by} {approve_url} {deny_url}</span>. Leave a field empty to use the default.</p>
<label>Approval request — subject</label>
<input type="text" name="approval_subject" value="{{ wf.approval_subject if wf else defaults.approval_subject }}">
<label>Approval request — body <small>(must include {approve_url} and {deny_url})</small></label>
<textarea class="tpl" name="approval_body">{{ wf.approval_body if wf else defaults.approval_body }}</textarea>
<div class="grid2">
<div>
<label>Approved notice — subject</label>
<input type="text" name="approved_subject" value="{{ wf.approved_subject if wf else defaults.approved_subject }}">
<label>Approved notice — body</label>
<textarea class="tpl" name="approved_body">{{ wf.approved_body if wf else defaults.approved_body }}</textarea>
</div>
<div>
<label>Denied notice — subject</label>
<input type="text" name="denied_subject" value="{{ wf.denied_subject if wf else defaults.denied_subject }}">
<label>Denied notice — body</label>
<textarea class="tpl" name="denied_body">{{ wf.denied_body if wf else defaults.denied_body }}</textarea>
</div>
</div>
</div>
<button type="submit">{{ 'Save changes' if wf else 'Create workflow' }}</button>
</form>
{% endblock %}