diff --git a/src/backend/core/templates/emails/_invitation_layout.html b/src/backend/core/templates/emails/_invitation_layout.html
new file mode 100644
index 0000000..d25fdf4
--- /dev/null
+++ b/src/backend/core/templates/emails/_invitation_layout.html
@@ -0,0 +1,66 @@
+{% comment %}
+Shared layout for calendar invitation / update / cancel / reply emails.
+Email-client-safe: table-based, inline styles, no external CSS.
+Blocks:
+ header_color — background color of the header strip (default blue).
+ badge_color — text color of the uppercase header badge (default light blue tint).
+ body — the per-template body content (after the header, before the footer).
+{% endcomment %}
+
+
+
+
+
+ {{ content.title }}
+
+
+
+
+
+
+ {# ── header ── #}
+ |
+ {% if content.badge %}
+
+ {{ content.badge }}
+
+ {% endif %}
+
+ {{ summary }}
+
+ |
+
+ {# ── body (per-template) ── #}
+ |
+ {% block body %}{% endblock %}
+ |
+
+ {# ── divider ── #}
+ |
+
+ {# ── footer ── #}
+ |
+ {% if instructions %}
+
+ {{ instructions }}
+
+ {% endif %}
+
+ {{ footer }}
+
+ |
+
+
+ |
+
+
+
diff --git a/src/backend/core/templates/emails/calendar_invitation.html b/src/backend/core/templates/emails/calendar_invitation.html
index d684b9d..6203982 100644
--- a/src/backend/core/templates/emails/calendar_invitation.html
+++ b/src/backend/core/templates/emails/calendar_invitation.html
@@ -1,158 +1,79 @@
-
-
-
-
-
- {{ content.title }}
-
-
-
-
-
+{% extends "emails/_invitation_layout.html" %}
-
{{ content.body }}
+{# Header defaults (blue) inherited from the base layout. #}
-
{{ summary }}
+{% block body %}
+ {# date/time #}
+
+ {{ start_date }}
+
+
+ {{ time_str }}{% if start_date != end_date %} · {{ labels.until }} {{ end_date }}{% endif %}
+
-
-
-
- | {{ labels.when }} |
-
- {{ start_date }}
- {{ time_str }}
- {% if start_date != end_date %} {{ labels.until }} {{ end_date }}{% endif %}
- |
-
- {% if event.location %}
-
- | {{ labels.location }} |
- {{ event.location }} |
-
- {% endif %}
- {% if event.url %}
-
- | {{ labels.videoConference }} |
- {{ event.url }} |
-
- {% endif %}
-
- | {{ labels.organizer }} |
- {{ organizer_display }} |
-
-
-
+ {% if event.location %}
+
+ {{ labels.location }}: {{ event.location }}
+
+ {% endif %}
- {% if event.description %}
-
-
{{ labels.description }}
-
{{ event.description|linebreaks }}
-
- {% endif %}
+ {% if event.url %}
+
+ {{ labels.videoConference }}:
+ {{ event.url }}
+
+ {% endif %}
- {% if rsvp_accepted_url %}
-
- {% endif %}
-
+ {# organizer / body sentence (content.body is i18n-rendered with {{organizer}}) #}
+
+ {{ content.body }}
+
-
-
-
-
+ {% if event.description %}
+
+ {{ event.description|linebreaksbr }}
+
+ {% endif %}
+
+ {# RSVP buttons — only present for REQUEST-method emails #}
+ {% if rsvp_accepted_url %}
+
+ {% endif %}
+{% endblock %}
diff --git a/src/backend/core/templates/emails/calendar_invitation_cancel.html b/src/backend/core/templates/emails/calendar_invitation_cancel.html
index b1aaacd..20d3d4d 100644
--- a/src/backend/core/templates/emails/calendar_invitation_cancel.html
+++ b/src/backend/core/templates/emails/calendar_invitation_cancel.html
@@ -1,139 +1,44 @@
-
-
-
-
-
- {{ content.title }}
-
-
-
-
-
+{% extends "emails/_invitation_layout.html" %}
-
{{ content.badge }}
+{% block header_color %}#d2212f{% endblock %}
+{% block badge_color %}#f5c2c6{% endblock %}
-
{{ content.body }}
+{% block body %}
+ {# small label above the strikethrough date (cancelled event was scheduled for…) #}
+
+ {{ labels.wasScheduledFor }}
+
+
+ {{ start_date }}
+
+
+ {{ time_str }}{% if start_date != end_date %} · {{ labels.until }} {{ end_date }}{% endif %}
+
-
{{ summary }}
+ {% if event.location %}
+
+ {{ labels.location }}: {{ event.location }}
+
+ {% endif %}
-
-
-
- | {{ labels.wasScheduledFor }} |
-
- {{ start_date }}
- {{ time_str }}
- {% if start_date != end_date %} {{ labels.until }} {{ end_date }}{% endif %}
- |
-
- {% if event.location %}
-
- | {{ labels.location }} |
- {{ event.location }} |
-
- {% endif %}
- {% if event.url %}
-
- | {{ labels.videoConference }} |
- {{ event.url }} |
-
- {% endif %}
-
- | {{ labels.organizer }} |
- {{ organizer_display }} |
-
-
-
+ {% if event.url %}
+
+ {{ labels.videoConference }}:
+ {{ event.url }}
+
+ {% endif %}
-
-
-
-
-
-
+
+ {{ content.body }}
+
+{% endblock %}
diff --git a/src/backend/core/templates/emails/calendar_invitation_reply.html b/src/backend/core/templates/emails/calendar_invitation_reply.html
index d6858a3..c3c8229 100644
--- a/src/backend/core/templates/emails/calendar_invitation_reply.html
+++ b/src/backend/core/templates/emails/calendar_invitation_reply.html
@@ -1,126 +1,37 @@
-
-
-
-
-
- {{ content.title }}
-
-
-
-
-
+{% extends "emails/_invitation_layout.html" %}
-
{{ content.body }}
+{% block header_color %}#16a34a{% endblock %}
+{% block badge_color %}#bbe5c5{% endblock %}
-
{{ summary }}
+{% block body %}
+ {# date/time #}
+
+ {{ start_date }}
+
+
+ {{ time_str }}{% if start_date != end_date %} · {{ labels.until }} {{ end_date }}{% endif %}
+
-
-
-
- | {{ labels.when }} |
-
- {{ start_date }}
- {{ time_str }}
- {% if start_date != end_date %} {{ labels.until }} {{ end_date }}{% endif %}
- |
-
- {% if event.location %}
-
- | {{ labels.location }} |
- {{ event.location }} |
-
- {% endif %}
- {% if event.url %}
-
- | {{ labels.videoConference }} |
- {{ event.url }} |
-
- {% endif %}
-
- | {{ labels.attendee }} |
- {{ attendee_display }} |
-
-
-
+ {% if event.location %}
+
+ {{ labels.location }}: {{ event.location }}
+
+ {% endif %}
-
+ {% if event.url %}
+
+ {{ labels.videoConference }}:
+ {{ event.url }}
+
+ {% endif %}
-
-
-
-
+ {# attendee response sentence (content.body interpolates {{attendee}}) #}
+
+ {{ content.body }}
+
+{% endblock %}
diff --git a/src/backend/core/templates/emails/calendar_invitation_update.html b/src/backend/core/templates/emails/calendar_invitation_update.html
index dec29d9..5a87e8f 100644
--- a/src/backend/core/templates/emails/calendar_invitation_update.html
+++ b/src/backend/core/templates/emails/calendar_invitation_update.html
@@ -1,163 +1,77 @@
-
-
-
-
-
- {{ content.title }}
-
-
-
-
-
+{% extends "emails/_invitation_layout.html" %}
-
{{ content.badge }}
+{# Header defaults (blue) inherited; content.badge resolves to "UPDATED" via i18n. #}
-
{{ content.body }}
+{% block body %}
+ {# date/time #}
+
+ {{ start_date }}
+
+
+ {{ time_str }}{% if start_date != end_date %} · {{ labels.until }} {{ end_date }}{% endif %}
+
-
{{ summary }}
+ {% if event.location %}
+
+ {{ labels.location }}: {{ event.location }}
+
+ {% endif %}
-
-
-
- | {{ labels.when }} |
-
- {{ start_date }}
- {{ time_str }}
- {% if start_date != end_date %} {{ labels.until }} {{ end_date }}{% endif %}
- |
-
- {% if event.location %}
-
- | {{ labels.location }} |
- {{ event.location }} |
-
- {% endif %}
- {% if event.url %}
-
- | {{ labels.videoConference }} |
- {{ event.url }} |
-
- {% endif %}
-
- | {{ labels.organizer }} |
- {{ organizer_display }} |
-
-
-
+ {% if event.url %}
+
+ {{ labels.videoConference }}:
+ {{ event.url }}
+
+ {% endif %}
- {% if event.description %}
-
-
{{ labels.description }}
-
{{ event.description|linebreaks }}
-
- {% endif %}
+
+ {{ content.body }}
+
- {% if rsvp_accepted_url %}
-
- {% endif %}
-
+ {% if event.description %}
+
+ {{ event.description|linebreaksbr }}
+
+ {% endif %}
-
-
-
-
+ {% if rsvp_accepted_url %}
+
+ {% endif %}
+{% endblock %}
diff --git a/src/backend/core/templates/rsvp/_common_styles.html b/src/backend/core/templates/rsvp/_common_styles.html
new file mode 100644
index 0000000..c1a1aa6
--- /dev/null
+++ b/src/backend/core/templates/rsvp/_common_styles.html
@@ -0,0 +1,25 @@
+{% comment %}
+ Shared CSS for rsvp/confirm.html and rsvp/response.html. Page-specific
+ styles (e.g. .submit-btn, .event-date) live in each template.
+{% endcomment %}
+
diff --git a/src/backend/core/templates/rsvp/confirm.html b/src/backend/core/templates/rsvp/confirm.html
index b5b00cd..8a3652c 100644
--- a/src/backend/core/templates/rsvp/confirm.html
+++ b/src/backend/core/templates/rsvp/confirm.html
@@ -1,70 +1,41 @@
-
-
+
+
-
-
- {{ page_title }}
-
+
+
+ {{ page_title }}
+ {% include "rsvp/_common_styles.html" %}
+
-
-
{{ status_icon|safe }}
-
{{ heading }}
+
+
diff --git a/src/backend/core/templates/rsvp/response.html b/src/backend/core/templates/rsvp/response.html
index 4affa87..b0af50b 100644
--- a/src/backend/core/templates/rsvp/response.html
+++ b/src/backend/core/templates/rsvp/response.html
@@ -1,76 +1,43 @@
-
-
+
+
-
-
-
{{ page_title }}
-
+
+
+
{{ page_title }}
+ {% include "rsvp/_common_styles.html" %}
+
-
- {% if error %}
-
❌
-
{{ error_title }}
-
{{ error }}
- {% else %}
-
{{ status_icon|safe }}
-
{{ heading }}
- {% if event_summary %}
-
{{ event_summary }}
- {% endif %}
- {% if event_date %}
-
{{ event_date }}
- {% endif %}
-
{{ message }}
- {% endif %}
+
+
+ {% if error %}
+
+
+ {% else %}
+
+
+ {% if event_date %}
{{ event_date }}
{% endif %}
+ {% if message %}
{{ message }}
{% endif %}
+
+ {% endif %}
+
+
+
diff --git a/src/backend/core/tests/test_rsvp.py b/src/backend/core/tests/test_rsvp.py
index c4c7dd4..8b54be6 100644
--- a/src/backend/core/tests/test_rsvp.py
+++ b/src/backend/core/tests/test_rsvp.py
@@ -584,8 +584,8 @@ def test_email_to_rsvp_accept_flow(self):
alt[0] for alt in mail.outbox[0].alternatives if alt[1] == "text/html"
)
- # Find accept link by green button color (#16a34a)
- accept_url = self._extract_rsvp_link(html_body, "#16a34a")
+ # Find accept link by blue button color (#435de6)
+ accept_url = self._extract_rsvp_link(html_body, "#435de6")
token = self._extract_token_from_url(accept_url)
# GET the confirm page
@@ -615,8 +615,8 @@ def test_email_to_rsvp_decline_flow(self):
alt[0] for alt in mail.outbox[0].alternatives if alt[1] == "text/html"
)
- # Find decline link by red button color (#dc2626)
- decline_url = self._extract_rsvp_link(html_body, "#dc2626")
+ # Find decline link by red button color (#d2212f)
+ decline_url = self._extract_rsvp_link(html_body, "#d2212f")
token = self._extract_token_from_url(decline_url)
with patch.object(CalDAVHTTPClient, "internal_request") as mock_internal:
@@ -640,9 +640,9 @@ def test_email_contains_all_three_rsvp_links(self):
# Each button has a distinct color
colors = {
- "accept": "#16a34a", # green
- "tentative": "#d97706", # amber
- "decline": "#dc2626", # red
+ "accept": "#435de6", # blue
+ "tentative": "#626a80", # gray
+ "decline": "#d2212f", # red
}
for label, color in colors.items():
pattern = rf'
]*background-color:\s*{re.escape(color)}'
@@ -670,7 +670,7 @@ def test_rsvp_link_for_past_event_fails(self, mock_internal):
html_body = next(
alt[0] for alt in mail.outbox[0].alternatives if alt[1] == "text/html"
)
- accept_url = self._extract_rsvp_link(html_body, "#16a34a")
+ accept_url = self._extract_rsvp_link(html_body, "#435de6")
token = self._extract_token_from_url(accept_url)
mock_internal.return_value = _mock_resp(404, {"error": "Event not found"})
diff --git a/src/frontend/apps/calendars/src/features/i18n/translations.json b/src/frontend/apps/calendars/src/features/i18n/translations.json
index bdd783b..a7215f8 100644
--- a/src/frontend/apps/calendars/src/features/i18n/translations.json
+++ b/src/frontend/apps/calendars/src/features/i18n/translations.json
@@ -416,6 +416,7 @@
"invitation": {
"title": "Event invitation",
"heading": "Event invitation",
+ "badge": "Event invitation",
"body": "{{organizer}} invites you to an event"
},
"update": {
@@ -433,6 +434,7 @@
"reply": {
"title": "Event reply",
"heading": "Reply received",
+ "badge": "Response",
"body": "{{attendee}} has replied to your event"
},
"labels": {
@@ -1336,6 +1338,7 @@
"invitation": {
"title": "Invitation à un événement",
"heading": "Invitation à un événement",
+ "badge": "Invitation à un événement",
"body": "{{organizer}} vous invite à un événement"
},
"update": {
@@ -1353,6 +1356,7 @@
"reply": {
"title": "Réponse à l'événement",
"heading": "Réponse reçue",
+ "badge": "Réponse",
"body": "{{attendee}} a répondu à votre événement"
},
"labels": {
@@ -1998,6 +2002,7 @@
"invitation": {
"title": "Uitnodiging voor evenement",
"heading": "Uitnodiging voor evenement",
+ "badge": "Uitnodiging voor evenement",
"body": "{{organizer}} nodigt u uit voor een evenement"
},
"update": {
@@ -2015,6 +2020,7 @@
"reply": {
"title": "Antwoord op evenement",
"heading": "Antwoord ontvangen",
+ "badge": "Antwoord",
"body": "{{attendee}} heeft gereageerd op uw evenement"
},
"labels": {