Almanac is a multi-role appointment booking platform — Flask and JWT on the back,
a vanilla single-page frontend served by the same app. This page maps every file in
it, and takes the newest piece, the automatic email notifications, apart.
18 files · 3,217 linesSQLite ⇄ Postgrescommit b0a26d34 September 2026
Python + JS
3,217
lines across 18 files
API endpoints
19
17 behind a JWT
Database tables
5
2 unique guards
Automatic emails
8
from 5 triggers
Runtime deps
2
Flask · PyJWT
01The stack, top to bottom
Each band below is one layer of the app. A request enters at the top and falls
straight through; nothing skips a layer, and nothing below the routes knows what
HTTP is.
Interface
One HTML shell; the dashboard is drawn in JavaScript per role.
1,381 lines · 4 files
static/js/app.js
Routing · 6 dashboard views
705 lines
static/css/style.css
Design system
545 lines
templates/index.html
SPA shell
82 lines
static/js/api.js
fetch + JWT storage
49 lines
↓ fetch /api/… · Authorization: Bearer <token>
Application
The factory that wires everything together on boot.
56 lines · 1 file
app.py
config · db · mail worker · blueprints · scheduler
56 lines
↓ blueprint dispatch
HTTP layer
Validates input, checks the token and the role, returns JSON. No business rules live here.
459 lines · 5 route modules
routes/appointment_routes.py
book · list · cancel · complete
136 lines
routes/availability_routes.py
hours · blocks · slots
111 lines
routes/auth_routes.py
register · login · me
77 lines
routes/email_routes.py new
log · test · run reminders
74 lines
routes/user_routes.py
providers · admin users
61 lines
↓ plain function calls
domain/ · notify/ · accounts/
The actual rules: who you are (accounts/), when you’re free (domain/), what gets mailed (notify/). Three packages, no HTTP in any of them.
925 lines · 4 files
notify/notifications.py new
what is mailed, and when
500 lines
notify/mailer.py new
how it is delivered
252 lines
domain/calendar_logic.py
free-slot engine
93 lines
accounts/auth.py
hashing · JWT · RBAC
80 lines
↓ db.query() / db.execute() / db.insert()
core/
One abstraction over two databases; every setting comes from the environment. seed_data.py stays at the root with the other things you run rather than import.
396 lines · 3 files
core/database.py
SQLite ⇄ Postgres · schema
306 lines
core/config.py
environment settings
58 lines
seed_data.py
first admin account
32 lines
02The email component
Five things that happen in the app cause mail to go out on their own. Nobody
presses a send button, and no request ever waits for a mail server.
Who gets mailed, and when
Trigger
Client
Provider
Message kinds
An account is created
✓the new user, whoever they are
welcome
A client books a slot
✓ confirmation
✓ heads-up
booked_client · booked_provider
An appointment is cancelled
✓ notice
✓ notice
cancelled_client · cancelled_provider
A provider marks one complete
✓ thank-you
—
completed_client
REMINDER_HOURS_BEFORE the start (default 24 h)
✓ reminder
✓ reminder
reminder_client · reminder_provider
What happens when one is triggered
01
Trigger
A route calls notifications.notify_booked() and friends — a side effect that swallows its own errors.
02
Render
One description of the message produces both halves — plain text and styled HTML — so they can't drift apart.
03
Claim
A row is inserted in email_log. The unique index is the gate: if somebody already claimed this message, we stop here.
04
Queue
The message goes to a background worker thread. The HTTP request returns now — it never waits.
05
Deliver
Over SMTP — or printed to the server log when SMTP_HOST is unset, which is the local default.
06
Record
The row is marked sent, or failed with the error — and a failed row is retried by the next scan.
The unique index is the whole de-duplication story.email_log (appointment_id, kind, recipient) is unique, so two overlapping
reminder scans, a double-clicked button, or four web workers running at once all
collapse into exactly one message per person per reason.
The reminder loop
every REMINDER_SCAN_MINUTES
Scheduler thread
A daemon thread started by the app factory. Under Flask's reloader only the child process owns it, so it never runs twice.
↓
send_due_reminders()
One SQL window query: confirmed appointments whose date || ' ' || start_time falls between now and the cutoff.
↓
Straight into the pipeline above
Claim, queue, deliver, record — the same six steps, with the same guarantee that nobody is reminded twice.
Prefer cron?
Set REMINDERS_ENABLED=0 and point it at
POST /api/admin/emails/run-reminders instead. Same function, same guarantees.
How it holds up
Nothing blocks on the mail server. Delivery happens on a worker thread; a refused SMTP connection is a logged failed row, never a 500 for the person who just booked.
Nobody is mailed twice. The claim-before-send pattern plus a unique index, rather than a flag someone has to remember to check.
Failures come back. A failed row is re-claimed by the next scan, so a mail outage delays reminders instead of swallowing them.
Zero setup locally. No SMTP server? The message prints to the terminal running python app.py, so the whole flow is watchable on a laptop.
No new dependencies.smtplib and email.message from the standard library — the deploy story doesn't change.
Visible. Admins get an Email log view: what was sent, to whom, whether it landed, plus a test-send button for checking credentials.
03Where the code actually is
Every file, by length, grouped by layer. The email component added three modules
totalling 826 lines; the rest of the change was wiring — 1,289 insertions in all. (17 bars; the package markers are left out — routes/__init__.py holds only the camelCase helper, and the __init__.py in each of core/, accounts/, domain/ and notify/ is a docstring saying what the package holds and which way its imports point.)
New in this change Existing code
0200400600800
04Data model
Five tables. Two of them carry a partial unique index that enforces a rule the
application would otherwise have to remember — no double-booking, and no
duplicate mail.
users
idpk
nametext
emailunique
password_hashtext
roleadmin|provider|client
specialtytext?
is_activebool
created_atts
availability
idpk
provider_id→ users
day_of_week0–6
start_timeHH:MM
end_timeHH:MM
slot_minutesint
A provider's recurring weekly hours.
blocked_slots
idpk
provider_id→ users
dateYYYY-MM-DD
start_timeHH:MM?
end_timeHH:MM?
reasontext?
No start/end means the whole day is off.
appointments
idpk
provider_id→ users
client_id→ users
dateYYYY-MM-DD
start_timeHH:MM
end_timeHH:MM
statusconfirmed|cancelled|completed
notestext?
UNIQUE (provider_id, date, start_time) WHERE status = 'confirmed' — the race-condition guard.
email_log new
idpk
kindwelcome, reminder_client…
recipientemail
subjecttext
appointment_id→ appointments?
user_id→ users?
statusqueued|sent|failed
errortext?
created_at / sent_atts
UNIQUE (appointment_id, kind, recipient) WHERE appointment_id IS NOT NULL — the de-duplication gate.
05API surface
19 endpoints. Everything except register and login needs
Authorization: Bearer <token>, and most also need a role.
Method
Path
Who
06Configuration
Every setting is an environment variable with a working default, so the same code
runs on a laptop and in the cloud. Nothing below needs to be set to start the app.
Variable
Default
What it does
07What was verified
The email component was exercised end to end against a throwaway database and a
fake SMTP server, not just read over.
Checks that passed
A full run — register → book → reminder scan → complete → cancel — produced exactly the 11 expected email_log rows, all sent.
A second reminder scan queued 0: nobody is reminded twice.
Multipart text + HTML confirmed on the wire against a fake SMTP server, with correct From, To, and Auto-Submitted headers.
Unreachable SMTP → row marked failed → retried on the next pass → suppressed once sent, leaving one row.
A non-admin gets 403 from /api/admin/emails.
Exactly one mailer worker and one scheduler thread, no duplicates.
Fixes made along the way
db.insert() — the booking route relied on lastrowid, which is None on Postgres. Now uses RETURNING id there.
db.rollback() — Postgres refuses every further statement on a connection after an error, so the IntegrityError branches need it.
JWT sub must be a string. PyJWT ≥ 2.10 rejects the integer, so every authenticated request was failing with "Invalid token" until accounts/auth.py encoded it as text.