diff --git a/api/src/email_templates/change_tracker_email.html.jinja2 b/api/src/email_templates/change_tracker_email.html.jinja2
new file mode 100644
index 000000000..607ed8322
--- /dev/null
+++ b/api/src/email_templates/change_tracker_email.html.jinja2
@@ -0,0 +1,970 @@
+
+{#-
+ GTFS Diff Email Notification — Jinja2 Template
+ ═══════════════════════════════════════════════
+
+ REQUIRED CONTEXT VARIABLES
+ ───────────────────────────
+ notification_date : str – Formatted date shown in the email (e.g. "June 5, 2026")
+ subscriptions_url : str – URL to the user's subscriptions management page
+ feeds : list[dict] – One or more feed diffs; schema per item below
+
+ PER-FEED DICT (maps to GTFS Diff v2 schema — gtfs-diff-schema.json)
+ ─────────────────────────────────────────────────────────────────────
+ feed_id str Feed ID used for anchor links (e.g. "mdb-2126")
+ feed_name str Human-readable agency/feed name
+ feed_url str URL to the full diff report page
+ unsubscribe_url str Feed-specific unsubscribe URL
+ notification_date str Per-feed update date, formatted (e.g. "June 5, 2026")
+ base_feed_version str Base feed identifier (metadata.base_feed.source)
+ new_feed_version str New feed identifier (metadata.new_feed.source)
+ total_changes int summary.total_changes
+ files_modified_count int summary.files_modified_count
+
+ breaking_changes list Analytical alerts: breaking changes (omit or [] if none)
+ .type str Alert type label (e.g. "REMOVED ENTITY")
+ .file str Affected GTFS file (e.g. "trips.txt")
+ .description str Human-readable description
+
+ suspicious_changes list Analytical alerts: suspicious changes (omit or [] if none)
+ .type str Alert type label (e.g. "LARGE ENTITY COUNT DELTA")
+ .file str Affected GTFS file
+ .description str Human-readable description
+
+ diff_files list Per-file row-change stats (from file_diffs[*].stats)
+ .file_name str GTFS file name (e.g. "calendar.txt")
+ .rows_added_count int|None stats.rows_added_count – None renders as "—"
+ .rows_deleted_count int|None stats.rows_deleted_count
+ .rows_modified_count int|None stats.rows_modified_count
+
+ features_changed list GTFS feature-level changes (omit or [] if none)
+ .name str Feature name (e.g. "Stops Wheelchair Accessibility")
+ .change str "Added" | "Removed" | "Changed"
+
+ validation_changes list Validation report diff entries (omit or [] if none)
+ .change_type str "New" | "Fixed" | "Occurrences"
+ .notice_code str Validator notice code
+ .severity str "WARNING" | "ERROR" | "INFO"
+ .older_count int|None Count in base report; None for "New" entries
+ .latest_count int|None Count in new report; None for "Fixed" entries
+
+ REQUIRED CUSTOM FILTER
+ ───────────────────────
+ thousands_sep(n) – Formats an integer with thousands separator;
+ returns "—" for None/null.
+ Python example:
+ def thousands_sep(n):
+ return "{:,}".format(int(n)) if n is not None else "—"
+ env.filters["thousands_sep"] = thousands_sep
+-#}
+{#- ─── Macros ──────────────────────────────────────────────────────────────── #}
+
+{%- macro severity_badge(severity) -%}
+ {%- if severity == "WARNING" -%}
+ WARNING
+ {%- elif severity == "ERROR" -%}
+ ERROR
+ {%- elif severity == "INFO" -%}
+ INFO
+ {%- else -%}
+ {{ severity }}
+ {%- endif -%}
+{%- endmacro -%}
+
+{%- macro num(value) -%}
+ {%- if value is not none and value is defined -%}{{ value | thousands_sep }}{%- else -%}—{%- endif -%}
+{%- endmacro -%}
+
+{#- Validation row: "New" and worsening "Occurrences" are red; "Fixed" and improving "Occurrences" are green #}
+{%- macro validation_row_is_bad(change) -%}
+ {%- if change.change_type == "New" -%}true
+ {%- elif change.change_type == "Fixed" -%}false
+ {%- elif change.change_type == "Occurrences" and change.latest_count is not none and change.older_count is not none and change.latest_count > change.older_count -%}true
+ {%- else -%}false
+ {%- endif -%}
+{%- endmacro -%}
+
+{%- macro feature_change_color(change) -%}
+ {%- if change == "Added" -%}#2e7d32
+ {%- elif change == "Removed" -%}#d32f2f
+ {%- else -%}#ed6c02
+ {%- endif -%}
+{%- endmacro -%}
+
+{%- macro feature_row_bg(change) -%}
+ {%- if change == "Added" -%}#eef6ef
+ {%- elif change == "Removed" -%}#fdeeee
+ {%- else -%}#fff3e0
+ {%- endif -%}
+{%- endmacro -%}
+
+{#- ─── Document ───────────────────────────────────────────────────────────── #}
+
+
+
+
+
+
+
+
+ {%- if feeds | length == 1 -%}
+ GTFS Feed Update — {{ feeds[0].feed_name }}
+ {%- else -%}
+ GTFS Feed Updates — {{ feeds | length }} feeds
+ {%- endif -%}
+
+
+
+
+
+
+
+ {%- if feeds | length == 1 -%}
+ {%- set f = feeds[0] -%}
+ {{ f.feed_name }} GTFS feed updated — {{ f.total_changes | thousands_sep }} changes across {{ f.files_modified_count }} file{{ "s" if f.files_modified_count != 1 }}{% if f.breaking_changes %}, {{ f.breaking_changes | length }} breaking change{{ "s" if f.breaking_changes | length != 1 }}{% endif %}{% if f.suspicious_changes %} and {{ f.suspicious_changes | length }} suspicious change{{ "s" if f.suspicious_changes | length != 1 }}{% endif %} detected.
+ {%- else -%}
+ {{ feeds | length }} GTFS feed updates — {% for f in feeds %}{{ f.feed_name }}{% if not loop.last %}, {% endif %}{% endfor %}.
+ {%- endif -%}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+ Mobility Database
+ |
+
+
+ |
+
+ Feed Update Notification
+ |
+
+
+ |
+
+
+ {# ============ TABLE OF CONTENTS (multiple feeds only) ============ #}
+ {% if feeds | length > 1 %}
+
+ |
+
+ {{ feeds | length }} feeds updated · {{ notification_date }}
+
+
+ {% for feed in feeds %}
+
+
+
+
+ |
+ {{ feed.feed_name }}
+ |
+
+ {{ feed.total_changes | thousands_sep }} changes
+ {% if feed.breaking_changes %}
+ {{ feed.breaking_changes | length }} breaking
+ {% endif %}
+ |
+
+
+ |
+
+ {% endfor %}
+
+
+ |
+
+ {% endif %}
+
+ {# ============ FEED SECTIONS (loop) ============ #}
+ {% for feed in feeds %}
+
+ {# Inter-feed divider (not before the first feed, and not when TOC provides separation) #}
+ {% if not loop.first %}
+
+ |
+
+ |
+
+ {% endif %}
+
+ {# Anchor target for TOC links #}
+
+ |
+
+ |
+
+
+
+
+ |
+ {% if feeds | length > 1 %}
+ Feed {{ loop.index }} of {{ feeds | length }}
+ {% endif %}
+
+ {{ feed.feed_name }}
+
+
+ {{ feed.notification_date }}
+
+
+ {{ feed.base_feed_version }}
+ →
+ {{ feed.new_feed_version }}
+
+ |
+
+
+ {# ============ BREAKING CHANGES (conditional) ============ #}
+ {% if feed.breaking_changes %}
+
+ |
+
+ Breaking Changes
+
+ {% for change in feed.breaking_changes %}
+
+
+ |
+
+ {{ change.type }}{{ change.file }}
+
+
+ {{ change.description }}
+
+ |
+
+
+ {% endfor %}
+ |
+
+ {% endif %}
+
+ {# ============ SUSPICIOUS CHANGES (conditional) ============ #}
+ {% if feed.suspicious_changes %}
+
+ |
+
+ Suspicious Changes
+
+ {% for change in feed.suspicious_changes %}
+
+
+ |
+
+ {{ change.type }}{{ change.file }}
+
+
+ {{ change.description }}
+
+ |
+
+
+ {% endfor %}
+ |
+
+ {% endif %}
+
+
+
+ |
+
+ Diff Summary
+
+
+ {{ feed.total_changes | thousands_sep }} total changes ·
+ {{ feed.files_modified_count }} file{{ "s" if feed.files_modified_count != 1 }} modified
+
+
+
+
+ |
+ File
+ |
+
+ Added
+ |
+
+ Deleted
+ |
+
+ Modified
+ |
+
+
+
+ {% for file in feed.diff_files %}
+ {%- set is_even = loop.index0 % 2 == 1 -%}
+ {%- set has_border = not loop.last -%}
+
+ | {{ file.file_name }} |
+ {{ num(file.rows_added_count) }} |
+ {{ num(file.rows_deleted_count) }} |
+ {{ num(file.rows_modified_count) }} |
+
+ {% endfor %}
+
+
+ |
+
+
+ {# ============ FEATURES CHANGED (conditional) ============ #}
+ {% if feed.features_changed %}
+
+ |
+
+ Features Changed
+
+
+
+
+ | Feature |
+ Change |
+
+
+
+ {% for feature in feed.features_changed %}
+
+ | {{ feature.name }} |
+ {{ feature.change }} |
+
+ {% endfor %}
+
+
+ |
+
+ {% endif %}
+
+ {# ============ VALIDATION REPORT DIFF (conditional) ============ #}
+ {% if feed.validation_changes %}
+
+ |
+
+ Validation Report Diff
+
+
+
+
+ | Change |
+ Notice code |
+ Severity |
+ Older |
+ Latest |
+
+
+
+ {% for change in feed.validation_changes %}
+ {%- set is_bad = validation_row_is_bad(change) | trim == "true" -%}
+ {%- set row_bg = "#fdeeee" if is_bad else "#eef6ef" -%}
+ {%- set label_color = "#d32f2f" if is_bad else "#2e7d32" -%}
+ {%- set has_border = not loop.last -%}
+
+ | {{ change.change_type }} |
+ {{ change.notice_code }} |
+ {{ severity_badge(change.severity) }} |
+ {{ num(change.older_count) }} |
+ {{ num(change.latest_count) }} |
+
+ {% endfor %}
+
+
+ |
+
+ {% endif %}
+
+
+
+ |
+
+
+
+ View Full Diff Report
+
+
+ |
+
+
+ {% endfor %}{# end feeds loop #}
+
+
+
+ |
+
+ |
+
+
+
+
+ |
+
+
+ Manage subscription{{ "s" if feeds | length > 1 }}
+
+ {% if feeds | length == 1 %}
+
+ Unsubscribe
+
+ {% endif %}
+
+
+ You're receiving this because you subscribe to updates for
+ {% if feeds | length == 1 %}this feed{% else %}these feeds{% endif %} on the Mobility Database.
+ © {{ notification_date.split(", ")[-1] }} MobilityData · Montréal, QC, Canada
+
+ |
+
+
+
+ |
+
+
+
+