This commit is contained in:
jpmvaz
2026-09-13 20:09:20 +01:00
commit 9b5cc30fb4
482 changed files with 57569 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
# NOTE: Docker Compose treats '$' in this file as variable substitution.
# If any value (e.g. a password) contains a literal '$', write it as '$$':
# MAIL_PASSWORD=xK9$$vh4pQ2 -> the container receives xK9$vh4pQ2
# Otherwise you'll see a warning like: The "..." variable is not set.
# --- 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
# --- Purelymail account (only login and password are needed) ---
# Server settings are preconfigured: smtp.purelymail.com, port 465, SSL/TLS.
# If Two Factor Authentication is enabled on the account, use an App Password.
# Leave MAIL_USER empty to print outgoing mail to the console (dev mode).
MAIL_USER=you@yourdomain.com
MAIL_PASSWORD=secret
+9
View File
@@ -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"]
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 %}
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,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 %}
Binary file not shown.
@@ -0,0 +1,13 @@
# --- 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
# --- Purelymail account (only login and password are needed) ---
# Server settings are preconfigured: smtp.purelymail.com, port 465, SSL/TLS.
# If Two Factor Authentication is enabled on the account, use an App Password.
# Leave MAIL_USER empty to print outgoing mail to the console (dev mode).
MAIL_USER=you@yourdomain.com
MAIL_PASSWORD=secret
@@ -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,86 @@
# ApprovalFlow
A self-hosted approval-workflow app. Requests are created on the site (login required), move through each workflow's ordered chain of approvers by email, and once approved or denied, **everyone involved up to that point** is notified. Every request keeps a full audit log of everything that happened to it.
Outgoing mail is preconfigured for **Purelymail** — you only provide the account's login and password.
## What it does
- **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 and every approver involved **up to that point** receive the outcome email.
- **Full log.** Every request records: received, 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, MAIL_USER, MAIL_PASSWORD
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 `MAIL_USER` empty prints outgoing mail to the console instead of sending it — handy for trying the app before entering the account.
## Mail server
The app is preconfigured for Purelymail's servers — you never enter host or port settings:
| | |
|---|---|
| Outgoing (SMTP) | `smtp.purelymail.com`, port `465`, SSL/TLS |
Only two settings are asked for in `.env`: `MAIL_USER` (the email address) and `MAIL_PASSWORD`. If the account uses Two Factor Authentication, use an **App Password** instead of the real password.
## The request lifecycle
```
web form (login required)
│ 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 + 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 chain + decision/notification logic
mailer.py Purelymail SMTP sending + template rendering
templates/ pages static/style.css styling
```
@@ -0,0 +1,296 @@
"""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
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()
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 DEFAULT 'web', -- requests are created on the site
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,53 @@
"""Outbound email via Purelymail SMTP, and template rendering.
The server is preconfigured for Purelymail (smtp.purelymail.com:465, SSL/TLS).
Only the account's email address and password need to be provided:
MAIL_USER=you@yourdomain.com (or you@purelymail.com)
MAIL_PASSWORD=... (use an App Password if 2FA is enabled)
"""
import os
import smtplib
from email.message import EmailMessage
SMTP_HOST = "smtp.purelymail.com"
SMTP_PORT = 465 # SSL/TLS
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 through Purelymail. 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"
user = os.environ.get("MAIL_USER", "")
password = os.environ.get("MAIL_PASSWORD", "")
msg = EmailMessage()
msg["From"] = user or "approvalflow@localhost"
msg["To"] = ", ".join(to_addrs)
msg["Subject"] = subject
msg.set_content(body)
if not user:
# Dev mode: print instead of sending so the app is testable without an account.
print(f"[mail:DEV] To: {msg['To']}\nSubject: {subject}\n{body}\n{'-'*50}")
return True, "dev mode (MAIL_USER not set) — printed to console"
try:
with smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, timeout=30) as server:
server.login(user, password)
server.send_message(msg)
return True, f"sent to {', '.join(to_addrs)}"
except Exception as e:
return False, f"SMTP error: {e}"
@@ -0,0 +1,153 @@
"""Request pipeline for requests created on the site.
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,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,50 @@
{% 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>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">{% 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="4" 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>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="stamp {{ r.status }}">{{ r.status }}</span></td>
<td class="mono muted">{{ r.created_at }}</td>
</tr>
{% else %}
<tr><td colspan="6" 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> · 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,54 @@
{% 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>
</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 %}
@@ -0,0 +1,13 @@
# --- 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
# --- Purelymail account (only login and password are needed) ---
# Server settings are preconfigured: smtp.purelymail.com, port 465, SSL/TLS.
# If Two Factor Authentication is enabled on the account, use an App Password.
# Leave MAIL_USER empty to print outgoing mail to the console (dev mode).
MAIL_USER=you@yourdomain.com
MAIL_PASSWORD=secret
@@ -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,90 @@
# ApprovalFlow
A self-hosted approval-workflow app. Requests are created on the site (login required), move through each workflow's ordered chain of approvers by email, and once approved or denied, **everyone involved up to that point** is notified. Every request keeps a full audit log of everything that happened to it.
Outgoing mail is preconfigured for **Purelymail** — you only provide the account's login and password.
## What it does
- **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 and every approver involved **up to that point** receive the outcome email.
- **Full log.** Every request records: received, each approval request sent, each step's decision, notifications, and any mail errors — each with a timestamp.
- **Mail status page (admin only).** Every outgoing email is recorded with its delivery result and whether the Sent-folder copy succeeded, so you can confirm mail is going out and spot errors at a glance.
- **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, MAIL_USER, MAIL_PASSWORD
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 `MAIL_USER` empty prints outgoing mail to the console instead of sending it — handy for trying the app before entering the account.
## Mail server
The app is preconfigured for Purelymail's servers — you never enter host or port settings:
| | |
|---|---|
| Outgoing (SMTP) | `smtp.purelymail.com`, port `465`, SSL/TLS |
| Sent-folder copy (IMAP) | `imap.purelymail.com`, port `993`, SSL/TLS |
Every email the app sends is also saved as a copy to the account's **Sent** folder over IMAP, so the mailbox keeps a complete record of what went out.
Only two settings are asked for in `.env`: `MAIL_USER` (the email address) and `MAIL_PASSWORD`. If the account uses Two Factor Authentication, use an **App Password** instead of the real password.
## The request lifecycle
```
web form (login required)
│ 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 + 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/mail-log helpers
pipeline.py approval chain + decision/notification logic
mailer.py Purelymail SMTP sending, Sent-folder copy, mail log
templates/ pages static/style.css styling
```
@@ -0,0 +1,311 @@
"""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
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: mail status
@app.route("/admin/mail")
@admin_required
def mail_status():
rows = g.db.execute("SELECT * FROM mail_log ORDER BY id DESC LIMIT 300").fetchall()
stats = {s["status"]: s["c"] for s in
g.db.execute("SELECT status, COUNT(*) c FROM mail_log GROUP BY status")}
copy_fails = g.db.execute(
"SELECT COUNT(*) c FROM mail_log WHERE sent_copy LIKE 'failed%'").fetchone()["c"]
configured = bool(os.environ.get("MAIL_USER"))
return render_template("mail_status.html", rows=rows, stats=stats,
copy_fails=copy_fails, configured=configured)
# ---------------------------------------------------------------- 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()
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,189 @@
"""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 DEFAULT 'web', -- requests are created on the site
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 ''
);
CREATE TABLE IF NOT EXISTS mail_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
request_id INTEGER, -- related request, if any
recipients TEXT NOT NULL,
subject TEXT NOT NULL,
status TEXT NOT NULL, -- sent / error / dev
detail TEXT DEFAULT '', -- SMTP error message, if any
sent_copy TEXT DEFAULT '' -- result of saving to the Sent folder
);
"""
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, timeout=15)
con.row_factory = sqlite3.Row
con.execute("PRAGMA foreign_keys = ON")
con.execute("PRAGMA journal_mode = WAL")
con.execute("PRAGMA busy_timeout = 15000")
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,109 @@
"""Outbound email via Purelymail, and template rendering.
Preconfigured for Purelymail — only the account's login and password are needed:
MAIL_USER=you@yourdomain.com (or you@purelymail.com)
MAIL_PASSWORD=... (use an App Password if 2FA is enabled)
Every message is sent through SMTP (smtp.purelymail.com:465, SSL/TLS) and a
copy is saved to the account's Sent folder over IMAP
(imap.purelymail.com:993, SSL/TLS). Every attempt — success or failure — is
recorded in the mail_log table shown on the admin "Mail status" page.
"""
import imaplib
import os
import smtplib
import time
from email.message import EmailMessage
import db
SMTP_HOST = "smtp.purelymail.com"
SMTP_PORT = 465 # SSL/TLS
IMAP_HOST = "imap.purelymail.com"
IMAP_PORT = 993 # SSL/TLS
SENT_FOLDER_CANDIDATES = ("Sent", "INBOX.Sent", "Sent Messages", "Sent Items")
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 _save_to_sent(user, password, msg) -> str:
"""Append a copy of the message to the account's Sent folder. Returns a status string."""
try:
with imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT) as imap:
imap.login(user, password)
raw = msg.as_bytes()
stamp = imaplib.Time2Internaldate(time.time())
last_err = ""
for folder in SENT_FOLDER_CANDIDATES:
status, resp = imap.append(folder, r"(\Seen)", stamp, raw)
if status == "OK":
return f"saved to '{folder}'"
last_err = (resp[0] or b"").decode("utf-8", "replace") if resp else ""
# No known folder worked — create "Sent" and try once more.
imap.create("Sent")
status, resp = imap.append("Sent", r"(\Seen)", stamp, raw)
if status == "OK":
return "saved to 'Sent' (folder created)"
return f"failed: {last_err or 'could not append to any Sent folder'}"
except Exception as e:
return f"failed: {e}"
def _record(request_id, recipients, subject, status, detail, sent_copy):
try:
con = db.get_db()
con.execute(
"INSERT INTO mail_log (at, request_id, recipients, subject, status, detail, sent_copy)"
" VALUES (?,?,?,?,?,?,?)",
(db.now(), request_id, ", ".join(recipients), subject, status, detail, sent_copy))
con.commit()
con.close()
except Exception as e:
print(f"[mail] Could not record mail log entry: {e}")
def send_mail(to_addrs, subject, body, request_id=None):
"""Send a plain-text email through Purelymail, keep a copy in the Sent
folder, and record the attempt in the mail log. 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"
user = os.environ.get("MAIL_USER", "")
password = os.environ.get("MAIL_PASSWORD", "")
msg = EmailMessage()
msg["From"] = user or "approvalflow@localhost"
msg["To"] = ", ".join(to_addrs)
msg["Subject"] = subject
msg.set_content(body)
if not user:
# Dev mode: print instead of sending so the app is testable without an account.
print(f"[mail:DEV] To: {msg['To']}\nSubject: {subject}\n{body}\n{'-'*50}")
_record(request_id, to_addrs, subject, "dev",
"MAIL_USER not set — printed to console", "skipped (dev mode)")
return True, "dev mode (MAIL_USER not set) — printed to console"
try:
with smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, timeout=30) as server:
server.login(user, password)
server.send_message(msg)
except Exception as e:
detail = f"SMTP error: {e}"
_record(request_id, to_addrs, subject, "error", detail, "skipped (send failed)")
return False, detail
sent_copy = _save_to_sent(user, password, msg)
_record(request_id, to_addrs, subject, "sent", "", sent_copy)
return True, f"sent to {', '.join(to_addrs)}"
@@ -0,0 +1,158 @@
"""Request pipeline for requests created on the site.
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
con.commit() # release any pending writes before slow mail I/O
v = _vars(wf, req)
ok, detail = send_mail(approver,
render(wf["approval_subject"], **v),
render(wf["approval_body"], **v),
request_id=request_id)
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])
con.commit() # release the write lock before slow mail I/O (mail_log uses its own connection)
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),
request_id=req["id"])
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])
con.commit()
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,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,50 @@
{% 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>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">{% 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="4" 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>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="stamp {{ r.status }}">{{ r.status }}</span></td>
<td class="mono muted">{{ r.created_at }}</td>
</tr>
{% else %}
<tr><td colspan="6" class="muted">No requests yet.</td></tr>
{% endfor %}
</table>
</div>
{% endblock %}
@@ -0,0 +1,36 @@
<!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('mail_status') }}" {% if request.endpoint=='mail_status' %}class="on"{% endif %}>Mail status</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,44 @@
{% extends "base.html" %}
{% block title %}Mail status — ApprovalFlow{% endblock %}
{% block content %}
<h1>Mail status</h1>
<p class="sub">Every email the app tried to send, with its delivery result and whether a copy was saved to the Sent folder.</p>
{% if not configured %}
<div class="flash error">Mail account not configured — <span class="mono">MAIL_USER</span> is empty, so messages are printed to the server console (dev mode) instead of being sent.</div>
{% endif %}
<div class="stats">
<div class="stat"><b>{{ stats.get('sent', 0) }}</b><span>Sent</span></div>
<div class="stat"><b>{{ stats.get('error', 0) }}</b><span>Errors</span></div>
<div class="stat"><b>{{ copy_fails }}</b><span>Sent-copy failures</span></div>
<div class="stat"><b>{{ stats.get('dev', 0) }}</b><span>Dev mode</span></div>
</div>
<div class="card">
<table>
<tr><th>When</th><th>To</th><th>Subject</th><th>Request</th><th>Delivery</th><th>Sent folder</th></tr>
{% for m in rows %}
<tr>
<td class="mono muted">{{ m.at }}</td>
<td class="mono">{{ m.recipients }}</td>
<td>{{ m.subject }}
{% if m.detail %}<br><span class="muted">{{ m.detail }}</span>{% endif %}</td>
<td class="mono">{% if m.request_id %}<a href="{{ url_for('admin_request', rid=m.request_id) }}">#{{ m.request_id }}</a>{% else %}—{% endif %}</td>
<td>
{% if m.status == 'sent' %}<span class="stamp approved">sent</span>
{% elif m.status == 'error' %}<span class="stamp denied">error</span>
{% else %}<span class="stamp pending">dev</span>{% endif %}
</td>
<td>
{% if m.sent_copy.startswith('saved') %}<span class="chip">{{ m.sent_copy }}</span>
{% elif m.sent_copy.startswith('failed') %}<span class="stamp denied">copy failed</span><br><span class="muted">{{ m.sent_copy }}</span>
{% else %}<span class="muted">{{ m.sent_copy }}</span>{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="6" class="muted">No mail has been sent yet.</td></tr>
{% endfor %}
</table>
</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> · 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,54 @@
{% 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>
</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 %}
@@ -0,0 +1,13 @@
# --- 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
# --- Purelymail account (only login and password are needed) ---
# Server settings are preconfigured: smtp.purelymail.com, port 465, SSL/TLS.
# If Two Factor Authentication is enabled on the account, use an App Password.
# Leave MAIL_USER empty to print outgoing mail to the console (dev mode).
MAIL_USER=you@yourdomain.com
MAIL_PASSWORD=secret
@@ -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,90 @@
# ApprovalFlow
A self-hosted approval-workflow app. Requests are created on the site (login required), move through each workflow's ordered chain of approvers by email, and once approved or denied, **everyone involved up to that point** is notified. Every request keeps a full audit log of everything that happened to it.
Outgoing mail is preconfigured for **Purelymail** — you only provide the account's login and password.
## What it does
- **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 and every approver involved **up to that point** receive the outcome email.
- **Full log.** Every request records: received, each approval request sent, each step's decision, notifications, and any mail errors — each with a timestamp.
- **Mail status page (admin only).** Every outgoing email is recorded with its delivery result and whether the Sent-folder copy succeeded, so you can confirm mail is going out and spot errors at a glance.
- **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, MAIL_USER, MAIL_PASSWORD
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 `MAIL_USER` empty prints outgoing mail to the console instead of sending it — handy for trying the app before entering the account.
## Mail server
The app is preconfigured for Purelymail's servers — you never enter host or port settings:
| | |
|---|---|
| Outgoing (SMTP) | `smtp.purelymail.com`, port `465`, SSL/TLS |
| Sent-folder copy (IMAP) | `imap.purelymail.com`, port `993`, SSL/TLS |
Every email the app sends is also saved as a copy to the account's **Sent** folder over IMAP, so the mailbox keeps a complete record of what went out.
Only two settings are asked for in `.env`: `MAIL_USER` (the email address) and `MAIL_PASSWORD`. If the account uses Two Factor Authentication, use an **App Password** instead of the real password.
## The request lifecycle
```
web form (login required)
│ 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 + 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}`, `{summary}` (creation + each approval with timestamps, for the approved notice), `{deny_reason}`, `{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/mail-log helpers
pipeline.py approval chain + decision/notification logic
mailer.py Purelymail SMTP sending, Sent-folder copy, mail log
templates/ pages static/style.css styling
```
@@ -0,0 +1,322 @@
"""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
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"])
user = g.db.execute("SELECT * FROM users WHERE id=?", (session["user_id"],)).fetchone()
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
requester = request.form.get("request_email", "").strip().lower()
subject = request.form.get("subject", "").strip()
body = request.form.get("body", "").strip()
if not requester or "@" not in requester:
flash("Request's e-mail is required and must be a valid address.", "error")
elif not subject or not body:
flash("Subject and message are both required.", "error")
else:
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.requester=? OR r.requester=? ORDER BY r.id DESC LIMIT 15",
(session["username"], (user["email"] or "-").lower())).fetchall()
return render_template("index.html", workflows=workflows, my_requests=my_requests,
user_email=user["email"] or "")
# ---------------------------------------------------------------- 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
reason = request.form.get("reason", "").strip()
if action == "deny" and not reason:
return render_template("decision.html", stage="confirm", req=req, action=action,
approver=approver, chain=chain,
error="A reason for denial is required."), 400
status, req2, message = pipeline.decide(token, action, decided_by, reason=reason)
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")
reason = request.form.get("reason", "").strip()
if action == "deny" and not reason:
flash("A reason for denial is required.", "error")
return redirect(url_for("admin_request", rid=rid))
if action in ("approve", "deny"):
pipeline.decide(req["token"], action, f"{session['username']} (admin)", reason=reason)
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: mail status
@app.route("/admin/mail")
@admin_required
def mail_status():
rows = g.db.execute("SELECT * FROM mail_log ORDER BY id DESC LIMIT 300").fetchall()
stats = {s["status"]: s["c"] for s in
g.db.execute("SELECT status, COUNT(*) c FROM mail_log GROUP BY status")}
copy_fails = g.db.execute(
"SELECT COUNT(*) c FROM mail_log WHERE sent_copy LIKE 'failed%'").fetchone()["c"]
configured = bool(os.environ.get("MAIL_USER"))
return render_template("mail_status.html", rows=rows, stats=stats,
copy_fails=copy_fails, configured=configured)
# ---------------------------------------------------------------- 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()
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,204 @@
"""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 DEFAULT 'web', -- requests are created on the site
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 '',
deny_reason TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
decided_at TEXT
);
CREATE TABLE IF NOT EXISTS decisions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
request_id INTEGER NOT NULL REFERENCES requests(id) ON DELETE CASCADE,
step INTEGER NOT NULL, -- 1-based position in the chain
decided_by TEXT NOT NULL,
decision TEXT NOT NULL, -- approved / denied
at TEXT NOT NULL
);
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 ''
);
CREATE TABLE IF NOT EXISTS mail_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
request_id INTEGER, -- related request, if any
recipients TEXT NOT NULL,
subject TEXT NOT NULL,
status TEXT NOT NULL, -- sent / error / dev
detail TEXT DEFAULT '', -- SMTP error message, if any
sent_copy TEXT DEFAULT '' -- result of saving to the Sent folder
);
"""
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.\n\n"
"Workflow: {workflow}\n"
"From: {requester}\n"
"Subject: {subject}\n\n"
"Message:\n{body}\n\n"
"Summary:\n{summary}\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\n"
"Reason for denial:\n{deny_reason}\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, timeout=15)
con.row_factory = sqlite3.Row
con.execute("PRAGMA foreign_keys = ON")
con.execute("PRAGMA journal_mode = WAL")
con.execute("PRAGMA busy_timeout = 15000")
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.")
if rcols and "deny_reason" not in rcols:
con.execute("ALTER TABLE requests ADD COLUMN deny_reason TEXT NOT NULL DEFAULT ''")
print("[migrate] Added deny_reason 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,109 @@
"""Outbound email via Purelymail, and template rendering.
Preconfigured for Purelymail — only the account's login and password are needed:
MAIL_USER=you@yourdomain.com (or you@purelymail.com)
MAIL_PASSWORD=... (use an App Password if 2FA is enabled)
Every message is sent through SMTP (smtp.purelymail.com:465, SSL/TLS) and a
copy is saved to the account's Sent folder over IMAP
(imap.purelymail.com:993, SSL/TLS). Every attempt — success or failure — is
recorded in the mail_log table shown on the admin "Mail status" page.
"""
import imaplib
import os
import smtplib
import time
from email.message import EmailMessage
import db
SMTP_HOST = "smtp.purelymail.com"
SMTP_PORT = 465 # SSL/TLS
IMAP_HOST = "imap.purelymail.com"
IMAP_PORT = 993 # SSL/TLS
SENT_FOLDER_CANDIDATES = ("Sent", "INBOX.Sent", "Sent Messages", "Sent Items")
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 _save_to_sent(user, password, msg) -> str:
"""Append a copy of the message to the account's Sent folder. Returns a status string."""
try:
with imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT) as imap:
imap.login(user, password)
raw = msg.as_bytes()
stamp = imaplib.Time2Internaldate(time.time())
last_err = ""
for folder in SENT_FOLDER_CANDIDATES:
status, resp = imap.append(folder, r"(\Seen)", stamp, raw)
if status == "OK":
return f"saved to '{folder}'"
last_err = (resp[0] or b"").decode("utf-8", "replace") if resp else ""
# No known folder worked — create "Sent" and try once more.
imap.create("Sent")
status, resp = imap.append("Sent", r"(\Seen)", stamp, raw)
if status == "OK":
return "saved to 'Sent' (folder created)"
return f"failed: {last_err or 'could not append to any Sent folder'}"
except Exception as e:
return f"failed: {e}"
def _record(request_id, recipients, subject, status, detail, sent_copy):
try:
con = db.get_db()
con.execute(
"INSERT INTO mail_log (at, request_id, recipients, subject, status, detail, sent_copy)"
" VALUES (?,?,?,?,?,?,?)",
(db.now(), request_id, ", ".join(recipients), subject, status, detail, sent_copy))
con.commit()
con.close()
except Exception as e:
print(f"[mail] Could not record mail log entry: {e}")
def send_mail(to_addrs, subject, body, request_id=None):
"""Send a plain-text email through Purelymail, keep a copy in the Sent
folder, and record the attempt in the mail log. 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"
user = os.environ.get("MAIL_USER", "")
password = os.environ.get("MAIL_PASSWORD", "")
msg = EmailMessage()
msg["From"] = user or "approvalflow@localhost"
msg["To"] = ", ".join(to_addrs)
msg["Subject"] = subject
msg.set_content(body)
if not user:
# Dev mode: print instead of sending so the app is testable without an account.
print(f"[mail:DEV] To: {msg['To']}\nSubject: {subject}\n{body}\n{'-'*50}")
_record(request_id, to_addrs, subject, "dev",
"MAIL_USER not set — printed to console", "skipped (dev mode)")
return True, "dev mode (MAIL_USER not set) — printed to console"
try:
with smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, timeout=30) as server:
server.login(user, password)
server.send_message(msg)
except Exception as e:
detail = f"SMTP error: {e}"
_record(request_id, to_addrs, subject, "error", detail, "skipped (send failed)")
return False, detail
sent_copy = _save_to_sent(user, password, msg)
_record(request_id, to_addrs, subject, "sent", "", sent_copy)
return True, f"sent to {', '.join(to_addrs)}"
@@ -0,0 +1,189 @@
"""Request pipeline for requests created on the site.
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 _record_decision(con, request_id, step, decided_by, decision):
con.execute("INSERT INTO decisions (request_id, step, decided_by, decision, at) VALUES (?,?,?,?,?)",
(request_id, step, decided_by, decision, db.now()))
def build_summary(con, req, total_steps):
"""Human-readable history: when the request was created and who
approved (or denied) each step, with timestamps."""
lines = [f" Created {req['created_at']} by {req['requester']}"]
for d in con.execute("SELECT * FROM decisions WHERE request_id=? ORDER BY id", (req["id"],)):
verb = "Approved" if d["decision"] == "approved" else "DENIED"
lines.append(f" Step {d['step']}/{total_steps} {verb} {d['at']} by {d['decided_by']}")
return "\n".join(lines)
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
con.commit() # release any pending writes before slow mail I/O
v = _vars(wf, req)
ok, detail = send_mail(approver,
render(wf["approval_subject"], **v),
render(wf["approval_body"], **v),
request_id=request_id)
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])
summary = build_summary(con, req, len(approver_chain(wf)))
con.commit() # release the write lock before slow mail I/O (mail_log uses its own connection)
v = _vars(wf, req, decided_by=decided_by, decision=new_status.upper(),
summary=summary, deny_reason=req["deny_reason"])
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"]
# These are mandatory in the outcome notice: append them if the workflow's
# custom template doesn't include the placeholder.
if new_status == "approved" and "{summary}" not in body_t:
body_t += "\n\nSummary:\n{summary}\n"
if new_status == "denied" and "{deny_reason}" not in body_t:
body_t += "\n\nReason for denial:\n{deny_reason}\n"
ok, detail = send_mail(participants, render(subj_t, **v), render(body_t, **v),
request_id=req["id"])
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, reason=""):
"""Handle a decision at the request's current step.
A denial requires a reason (callers enforce it as a mandatory field;
this is a second line of defense).
Returns (status, request_row_or_None, message).
"""
assert action in ("approve", "deny")
reason = (reason or "").strip()
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":
if not reason:
return "needreason", req, "A reason for denial is required."
# STOP the workflow, notify everyone involved so far.
con.execute("UPDATE requests SET status='denied', decided_by=?, decided_at=?, deny_reason=? WHERE id=?",
(decided_by, db.now(), reason, req["id"]))
_record_decision(con, req["id"], step + 1, decided_by, "denied")
db.log(con, req["id"], "denied",
f"Denied at step {step + 1}/{len(chain)} by {decided_by} — workflow stopped. Reason: {reason}")
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
_record_decision(con, req["id"], step + 1, decided_by, "approved")
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])
con.commit()
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,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,50 @@
{% 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>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">{% 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="4" 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>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="stamp {{ r.status }}">{{ r.status }}</span></td>
<td class="mono muted">{{ r.created_at }}</td>
</tr>
{% else %}
<tr><td colspan="6" class="muted">No requests yet.</td></tr>
{% endfor %}
</table>
</div>
{% endblock %}
@@ -0,0 +1,36 @@
<!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('mail_status') }}" {% if request.endpoint=='mail_status' %}class="on"{% endif %}>Mail status</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,60 @@
{% 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">
{% if error %}<div class="flash error">{{ error }}</div>{% endif %}
<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 }}">
{% if action == 'deny' %}
<label for="rr">Reason for denial <small>(required — included in the notice everyone receives)</small></label>
<textarea id="rr" name="reason" required></textarea>
{% endif %}
<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,48 @@
{% 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="re">Request's e-mail <small>(required — this address receives the outcome)</small></label>
<input id="re" type="email" name="request_email" value="{{ user_email }}" required>
<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,44 @@
{% extends "base.html" %}
{% block title %}Mail status — ApprovalFlow{% endblock %}
{% block content %}
<h1>Mail status</h1>
<p class="sub">Every email the app tried to send, with its delivery result and whether a copy was saved to the Sent folder.</p>
{% if not configured %}
<div class="flash error">Mail account not configured — <span class="mono">MAIL_USER</span> is empty, so messages are printed to the server console (dev mode) instead of being sent.</div>
{% endif %}
<div class="stats">
<div class="stat"><b>{{ stats.get('sent', 0) }}</b><span>Sent</span></div>
<div class="stat"><b>{{ stats.get('error', 0) }}</b><span>Errors</span></div>
<div class="stat"><b>{{ copy_fails }}</b><span>Sent-copy failures</span></div>
<div class="stat"><b>{{ stats.get('dev', 0) }}</b><span>Dev mode</span></div>
</div>
<div class="card">
<table>
<tr><th>When</th><th>To</th><th>Subject</th><th>Request</th><th>Delivery</th><th>Sent folder</th></tr>
{% for m in rows %}
<tr>
<td class="mono muted">{{ m.at }}</td>
<td class="mono">{{ m.recipients }}</td>
<td>{{ m.subject }}
{% if m.detail %}<br><span class="muted">{{ m.detail }}</span>{% endif %}</td>
<td class="mono">{% if m.request_id %}<a href="{{ url_for('admin_request', rid=m.request_id) }}">#{{ m.request_id }}</a>{% else %}—{% endif %}</td>
<td>
{% if m.status == 'sent' %}<span class="stamp approved">sent</span>
{% elif m.status == 'error' %}<span class="stamp denied">error</span>
{% else %}<span class="stamp pending">dev</span>{% endif %}
</td>
<td>
{% if m.sent_copy.startswith('saved') %}<span class="chip">{{ m.sent_copy }}</span>
{% elif m.sent_copy.startswith('failed') %}<span class="stamp denied">copy failed</span><br><span class="muted">{{ m.sent_copy }}</span>
{% else %}<span class="muted">{{ m.sent_copy }}</span>{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="6" class="muted">No mail has been sent yet.</td></tr>
{% endfor %}
</table>
</div>
{% endblock %}
@@ -0,0 +1,69 @@
{% 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> · 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 == 'denied' and req.deny_reason %}
<p><b>Reason for denial:</b></p>
<div class="pre">{{ req.deny_reason }}</div>
{% endif %}
{% if req.status == 'pending' %}
<form method="post">
<div class="rowline">
<button class="btn approve small" name="action" value="approve">Approve this step</button>
<span class="muted">Same effect as the current approver's email links.</span>
</div>
</form>
<form method="post" style="margin-top:.8rem">
<label for="rr" style="margin-top:0">Reason for denial <small>(required to deny)</small></label>
<textarea id="rr" name="reason" required style="min-height:70px"></textarea>
<button class="btn deny small" name="action" value="deny" style="margin-top:.6rem">Deny &amp; stop workflow</button>
</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 %}

Some files were not shown because too many files have changed in this diff Show More