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
@@ -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,84 @@
# 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. Incoming messages become requests and enter the workflow's **approval chain** with one-click **Approve** / **Deny** links.
- **Sequential multi-step approval.** Every workflow has an ordered chain of approvers. The request goes to the first person; each approval sends it on to the next; the last approval completes it. **A denial at any step stops the workflow immediately** — nobody later in the chain is contacted.
- **Decisions notify everyone.** On the final outcome (approved or denied), the requester, anyone CC'd on the original email, and every approver involved **up to that point** receive the outcome email.
- **Full log.** Every request records: received, matched workflow, each approval request sent, each step's decision, notifications, and any mail errors — each with a timestamp.
- **Multiple named workflows**, each personalized: its own approval chain 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:** if exactly one active workflow exists, it is used.
Unmatched mail is ignored.
## The request lifecycle
```
email / web form
│ received + logged
approval request → approver 1 ── deny ──► STOP: DENIED notice to
│ approve everyone involved so far
approval request → approver 2 ── deny ──► STOP: DENIED notice ...
│ approve
⋮ (… every approver in the chain, in order)
last approver approves ──► APPROVED notice to everyone involved
(requester + CCs + all approvers)
```
Each step gets a fresh secret link, so an earlier approver's link cannot decide later steps.
## Personalization placeholders
Usable in every workflow's subject and body templates:
`{workflow}`, `{requester}`, `{subject}`, `{body}`, `{request_id}`, `{step}`, `{total_steps}`, `{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,298 @@
"""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
@app.template_filter("fromjson")
def fromjson_filter(s):
try:
return json.loads(s or "[]")
except Exception:
return []
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.approvers 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
chain = pipeline.approver_chain(req)
approver = chain[min(req["current_step"], len(chain) - 1)] if chain else ""
if request.method == "POST":
decided_by = request.form.get("decided_by", "").strip() or approver
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,
approver=approver, chain=chain)
# ---------------------------------------------------------------- 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, w.approvers 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))
req = g.db.execute("SELECT r.*, w.name wf_name, w.approvers FROM requests r "
"JOIN workflows w ON w.id=r.workflow_id WHERE r.id=?", (rid,)).fetchone()
logs = g.db.execute("SELECT * FROM request_log WHERE request_id=? ORDER BY id", (rid,)).fetchall()
participants = json.loads(req["participants"])
chain = pipeline.approver_chain(req)
return render_template("request_detail.html", req=req, logs=logs,
participants=participants, chain=chain)
# ---------------------------------------------------------------- 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()
approvers = [a.strip() for a in f.get("approvers", "").splitlines() if a.strip()]
if not name or not approvers:
flash("A name and at least one approver 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, approvers, active,"
" approval_subject, approval_body, approved_subject, approved_body,"
" denied_subject, denied_body, created_at)"
" VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
(name, slugify(name), f.get("description", "").strip(), json.dumps(approvers),
1 if f.get("active") else 0,
*[templates[k] for k in WF_FIELDS], db.now()))
flash(f"Workflow “{name}” created with {len(approvers)} approval step(s).", "ok")
else:
g.db.execute(
"UPDATE workflows SET name=?, slug=?, description=?, approvers=?,"
" active=?, approval_subject=?, approval_body=?,"
" approved_subject=?, approved_body=?, denied_subject=?, denied_body=? WHERE id=?",
(name, slugify(name), f.get("description", "").strip(), json.dumps(approvers),
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,176 @@
"""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 '',
approvers TEXT NOT NULL DEFAULT '[]', -- JSON list, in approval order
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
current_step INTEGER NOT NULL DEFAULT 0, -- index into the workflow's approver chain
token TEXT UNIQUE NOT NULL, -- secret for the CURRENT step's 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 ({step}/{total_steps}): {subject}",
"approval_body": (
"A request needs your decision (approval step {step} of {total_steps}).\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\n"
"If you approve, the request moves on to the next approver.\n"
"If you deny, the workflow stops and everyone involved is notified.\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 _migrate(con):
"""Upgrade a database created by the single-approver version, if present."""
cols = {r["name"] for r in con.execute("PRAGMA table_info(workflows)")}
if "approver_email" in cols and "approvers" not in cols:
con.execute("ALTER TABLE workflows ADD COLUMN approvers TEXT NOT NULL DEFAULT '[]'")
for w in con.execute("SELECT id, approver_email FROM workflows").fetchall():
con.execute("UPDATE workflows SET approvers=? WHERE id=?",
(json.dumps([w["approver_email"]]), w["id"]))
print("[migrate] Converted single approver_email to approver chains.")
rcols = {r["name"] for r in con.execute("PRAGMA table_info(requests)")}
if rcols and "current_step" not in rcols:
con.execute("ALTER TABLE requests ADD COLUMN current_step INTEGER NOT NULL DEFAULT 0")
print("[migrate] Added current_step to requests.")
con.commit()
def init_db():
con = get_db()
_migrate(con)
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,153 @@
"""Request pipeline shared by the web form and the email poller.
Each workflow has an ordered chain of approvers. A request moves through the
chain one approver at a time:
- DENY at any step -> the workflow STOPS; everyone involved so far gets
the denied notice.
- APPROVE -> the request moves to the next approver in the chain.
- APPROVE (last) -> the request is fully approved; everyone involved so
far gets the approved notice.
Each step gets a fresh secret token, so an earlier approver's links cannot
decide later steps.
"""
import json
import os
import secrets
import db
from mailer import render, send_mail
BASE_URL = os.environ.get("BASE_URL", "http://localhost:8000").rstrip("/")
def approver_chain(wf):
try:
chain = json.loads(wf["approvers"])
except Exception:
chain = []
return [a.strip() for a in chain if a and a.strip()]
def _vars(wf, req, **extra):
chain = approver_chain(wf)
v = {
"workflow": wf["name"],
"requester": req["requester"],
"subject": req["subject"],
"body": req["body"],
"request_id": req["id"],
"step": min(req["current_step"], max(len(chain) - 1, 0)) + 1,
"total_steps": len(chain),
"approve_url": f"{BASE_URL}/decision/{req['token']}/approve",
"deny_url": f"{BASE_URL}/decision/{req['token']}/deny",
}
v.update(extra)
return v
def current_approver(wf, req):
chain = approver_chain(wf)
if not chain:
return None
return chain[min(req["current_step"], len(chain) - 1)]
def send_approval_request(request_id):
"""Send the approval request to the approver of the CURRENT step."""
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()
chain = approver_chain(wf)
approver = current_approver(wf, req)
if approver is None:
db.log(con, request_id, "error", f"Workflow '{wf['name']}' has no approvers configured")
con.commit()
return False
v = _vars(wf, req)
ok, detail = send_mail(approver,
render(wf["approval_subject"], **v),
render(wf["approval_body"], **v))
db.add_participants(con, request_id, [approver])
db.log(con, request_id,
"approval requested" if ok else "mail error",
f"Step {req['current_step'] + 1}/{len(chain)}: approval request to {approver}{detail}")
con.commit()
return ok
finally:
con.close()
def _notify_outcome(con, wf, req, new_status, 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}")
def decide(token, action, decided_by):
"""Handle a decision at the request's current step.
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 — it may belong to a step that was already decided."
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()
chain = approver_chain(wf)
step = req["current_step"]
if action == "deny":
# STOP the workflow, notify everyone involved so far.
con.execute("UPDATE requests SET status='denied', decided_by=?, decided_at=? WHERE id=?",
(decided_by, db.now(), req["id"]))
db.log(con, req["id"], "denied",
f"Denied at step {step + 1}/{len(chain)} by {decided_by} — workflow stopped")
req = con.execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone()
_notify_outcome(con, wf, req, "denied", decided_by)
con.commit()
req = con.execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone()
return "ok", req, (f"Request #{req['id']} denied at step {step + 1} of {len(chain)}. "
"The workflow has stopped and everyone involved has been notified.")
# approve
db.log(con, req["id"], "step approved",
f"Step {step + 1}/{len(chain)} approved by {decided_by}")
db.add_participants(con, req["id"], [decided_by])
if step + 1 >= len(chain):
# Last approver — fully approved.
con.execute("UPDATE requests SET status='approved', decided_by=?, decided_at=? WHERE id=?",
(decided_by, db.now(), req["id"]))
db.log(con, req["id"], "approved",
f"Final approval ({len(chain)}/{len(chain)}) by {decided_by}")
req = con.execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone()
_notify_outcome(con, wf, req, "approved", decided_by)
con.commit()
req = con.execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone()
return "ok", req, (f"Request #{req['id']} fully approved ({len(chain)} of {len(chain)} steps). "
"Everyone involved has been notified.")
# Move to the next approver with a fresh token (old links stop working).
new_token = secrets.token_urlsafe(32)
con.execute("UPDATE requests SET current_step=?, token=? WHERE id=?",
(step + 1, new_token, req["id"]))
con.commit()
send_approval_request(req["id"])
req = db.get_db().execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone()
return "ok", req, (f"Step {step + 1} of {len(chain)} approved. The request was sent to the "
f"next approver ({chain[step + 1]}).")
finally:
con.close()
@@ -0,0 +1,132 @@
"""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: if exactly one active workflow exists, it is used.
Unmatched mail is ignored.
"""
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, 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. single active workflow fallback
if len(workflows) == 1:
return workflows[0], "only active workflow"
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, recipients, subject)
if wf is None:
print(f"[poller] No workflow matched mail from {sender} — ignored.")
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,52 @@
{% 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>Approval chain</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">{% for a in w.approvers | fromjson %}{{ loop.index }}. {{ a }}{% if not loop.last %}<br>{% endif %}{% endfor %}</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="5" 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,55 @@
{% 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 }} ·
approval step <b>{{ req.current_step + 1 }} of {{ chain | length }}</b></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="{{ approver }}">
<button type="submit" class="btn {{ 'approve' if action == 'approve' else 'deny' }}">
{% if action == 'approve' %}Approve request{% else %}Deny request{% endif %}
</button>
</form>
{% if action == 'approve' %}
{% if req.current_step + 1 < chain | length %}
<p class="muted">Approving sends the request to the next approver: <span class="mono">{{ chain[req.current_step + 1] }}</span>.</p>
{% else %}
<p class="muted">You are the last approver — approving completes the workflow and notifies everyone involved.</p>
{% endif %}
{% else %}
<p class="muted">Denying stops the workflow immediately and notifies everyone involved so far.</p>
{% endif %}
{% endif %}
{% elif stage == 'done' %}
{% if status == 'ok' %}
<p style="text-align:center;margin:1.5rem 0">
{% if req.status == 'pending' %}
<span class="stamp big approved">step approved</span>
{% else %}
<span class="stamp big {{ req.status }}">{{ req.status }}</span>
{% endif %}
</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,59 @@
{% 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">Final decision 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 this step</button>
<button class="btn deny small" name="action" value="deny">Deny &amp; stop workflow</button>
<span class="muted">Same effect as the current approver's email links.</span>
</form>
{% endif %}
</div>
<h2>Approval chain</h2>
<div class="card">
<ul class="ledger">
{% for a in chain %}
<li>
<span class="t mono">step {{ loop.index }} / {{ chain | length }}</span>
<span class="e mono">{{ a }}</span>
<span>
{% if req.status == 'denied' and loop.index0 == req.current_step %}<span class="stamp denied">denied here</span>
{% elif loop.index0 < req.current_step or req.status == 'approved' %}<span class="stamp approved">approved</span>
{% elif loop.index0 == req.current_step and req.status == 'pending' %}<span class="stamp pending">waiting</span>
{% elif req.status == 'denied' %}<span class="muted">never reached — workflow stopped</span>
{% else %}<span class="muted">not yet reached</span>{% endif %}
</span>
</li>
{% endfor %}
</ul>
</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,55 @@
{% 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="ap">Approval chain <small>(one email per line, in order — the request goes to the first person; each approval sends it to the next; any denial stops the workflow)</small></label>
<textarea id="ap" name="approvers" class="tpl" required
placeholder="team-lead@example.com&#10;manager@example.com&#10;director@example.com">{% if wf %}{{ '\n'.join(wf.approvers | fromjson) }}{% endif %}</textarea>
<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} {step} {total_steps} {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 %}