Almanac architecture

Every component, and how the mail gets out.

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 lines SQLite ⇄ Postgres commit b0a26d3 4 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

TriggerClientProviderMessage 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.

MethodPathWho

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.

VariableDefaultWhat 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.