diff --git a/resources/views/email.phtml b/resources/views/email.phtml
index 93c99fe..a8bcb17 100644
--- a/resources/views/email.phtml
+++ b/resources/views/email.phtml
@@ -24,53 +24,137 @@ use Illuminate\Support\Collection;
* @var string $account_url
*/
-$avatar_size = 48;
+// ─── Aesthetic constants ────────────────────────────────────────────────
+$palette = [
+ 'paper' => '#f8f1e3', // warm ivory background
+ 'ink' => '#3a2820', // deep oxblood-brown body text
+ 'gold' => '#a17536', // aged-gold accents
+ 'rule' => '#d4be91', // hairline rule
+ 'mute' => '#7a6a5e', // muted secondary text
+ 'shadow' => '#5a4a3e', // for darker symbols (death)
+];
+
+$serif_display = "'EB Garamond', Georgia, 'Times New Roman', serif";
+$serif_body = "Georgia, 'Iowan Old Style', 'Palatino Linotype', serif";
+
+$avatar_size = 56;
+
+// ─── Helpers ────────────────────────────────────────────────────────────
+
+$linked_name = static function (Individual $individual): string {
+ $name = strip_tags($individual->fullName());
+ $url = $individual->url();
+ $style = 'color:inherit;text-decoration:none;border-bottom:1px solid #c8a96a;';
+
+ return '' . e($name) . ' ';
+};
+
+$record_label = static function (Fact $fact) use ($linked_name): string {
+ $record = $fact->record();
+
+ if ($record instanceof Individual) {
+ return $linked_name($record);
+ }
+
+ if ($record instanceof Family) {
+ $parts = [];
+
+ foreach ([$record->husband(), $record->wife()] as $spouse) {
+ if ($spouse instanceof Individual) {
+ $parts[] = $linked_name($spouse);
+ }
+ }
+
+ return implode(' & ', $parts);
+ }
+
+ return e($record->xref());
+};
+
+$event_date_display = static function (Fact $fact): string {
+ $date = $fact->date();
+
+ if (!$date instanceof Date || !$date->isOK()) {
+ return '';
+ }
+
+ return strip_tags($date->display());
+};
+
+$event_kind = static function (Fact $fact): string {
+ $tag = $fact->tag();
+ $parts = explode(':', $tag);
+
+ return end($parts);
+};
/**
- * Inline HTML for a single circular avatar.
- *
- * Renders an if the dispatch service was able to
- * resolve an image for the individual; otherwise renders a coloured
- * circle with the person's initials. The placeholder is intentionally
- * CSS-only — inline SVG and data: URIs are unreliable in Outlook /
- * some webmail clients.
+ * Compact inline SVG glyph for each event type. Rendered in modern
+ * webmail (Gmail web, Apple Mail, iOS, Outlook 365); Outlook desktop
+ * strips SVG silently — the textual event label still carries the
+ * meaning, so the email reads correctly without it.
*/
-$avatar = static function (Individual|null $individual) use ($avatar_cids, $avatar_size): string {
+$event_icon = static function (string $kind) use ($palette): string {
+ $svg_open = ' $svg_open . 'viewBox="0 0 24 24" width="18" height="18">'
+ . ' '
+ . ' ',
+
+ // Latin obelus/dagger — the typographic convention for "died" in
+ // obituaries since the 18th century. Restrained, narrow.
+ 'DEAT' => $svg_open . 'viewBox="0 0 24 24" width="14" height="18">'
+ . ' '
+ . ' '
+ . '',
+
+ // Two interlocking rings — universal heraldic mark of marriage.
+ 'MARR' => $svg_open . 'viewBox="0 0 36 22" width="28" height="18">'
+ . ' '
+ . ' '
+ . '',
+
+ default => '',
+ };
+};
+
+/**
+ * A single circular avatar — either the embedded image, or a coloured
+ * disc with the person's initials. Wraps in an linking to the
+ * individual's webtrees page when available.
+ */
+$avatar = static function (Individual|null $individual) use ($avatar_cids, $avatar_size, $palette): string {
if (!$individual instanceof Individual) {
return '';
}
$alt = e(strip_tags($individual->fullName()));
- if (isset($avatar_cids[$individual->xref()])) {
- $cid = $avatar_cids[$individual->xref()];
+ $shadow = 'box-shadow:0 0 0 1px ' . $palette['rule'] . ',0 2px 6px rgba(58,40,32,0.18);';
- return ' ';
+ if (isset($avatar_cids[$individual->xref()])) {
+ $cid = $avatar_cids[$individual->xref()];
+ $inner = ' ';
+ } else {
+ $hue = hexdec(substr(md5($individual->xref()), 0, 2)) * 360 / 255;
+ $first = strip_tags($individual->getAllNames()[0]['givn'] ?? $individual->xref());
+ $last = strip_tags($individual->getAllNames()[0]['surn'] ?? '');
+ $initials = e(mb_strtoupper(mb_substr($first, 0, 1) . mb_substr($last, 0, 1)));
+
+ $inner = '' . $initials . ' ';
}
- // CSS-only fallback: coloured circle with initials. Hash the xref
- // into a stable hue so each person keeps the same colour across
- // newsletters.
- $hue = hexdec(substr(md5($individual->xref()), 0, 2)) * 360 / 255;
- $first = strip_tags($individual->getAllNames()[0]['givn'] ?? $individual->xref());
- $last = strip_tags($individual->getAllNames()[0]['surn'] ?? '');
- $i1 = mb_substr($first, 0, 1);
- $i2 = mb_substr($last, 0, 1);
- $initials = e(mb_strtoupper($i1 . $i2));
-
- return '' . $initials . ' ';
+ return ' ' . $inner . ' ';
};
-/**
- * HTML for the avatar(s) attached to a Fact's primary record:
- * a single circle for Individual facts, side-by-side circles for
- * Family facts (anniversaries).
- */
$record_avatars = static function (Fact $fact) use ($avatar): string {
$record = $fact->record();
@@ -87,55 +171,41 @@ $record_avatars = static function (Fact $fact) use ($avatar): string {
}
}
- // Slight negative margin so the two circles overlap a touch —
- // visually communicates "couple" without needing extra glue.
- return ''
- . implode(' ', $parts)
- . ' ';
+ // Two circles overlapping by a few px reads as "couple" without
+ // needing additional glue characters.
+ return '
'
+ . '' . ($parts[0] ?? '') . ' '
+ . ' '
+ . (isset($parts[1])
+ ? '' . $parts[1] . ' '
+ : '')
+ . '
';
}
return '';
};
-$record_label = static function (Fact $fact): string {
- $record = $fact->record();
-
- if ($record instanceof Individual) {
- return strip_tags($record->fullName());
+// Locale-aware ordinal: English uses st/nd/rd/th; German just appends ".".
+$ordinal = static function (int $n): string {
+ if (str_starts_with(I18N::languageTag(), 'de')) {
+ return $n . '.';
}
- if ($record instanceof Family) {
- $husband = $record->husband();
- $wife = $record->wife();
- $names = array_filter([
- $husband !== null ? strip_tags($husband->fullName()) : '',
- $wife !== null ? strip_tags($wife->fullName()) : '',
- ]);
+ $abs = abs($n);
+ $mod100 = $abs % 100;
- return implode(' & ', $names);
+ if ($mod100 >= 11 && $mod100 <= 13) {
+ return $n . 'th';
}
- return $record->xref();
+ return $n . match ($abs % 10) {
+ 1 => 'st',
+ 2 => 'nd',
+ 3 => 'rd',
+ default => 'th',
+ };
};
-$event_date = static function (Fact $fact): string {
- $date = $fact->date();
-
- if (!$date instanceof Date || !$date->isOK()) {
- return '';
- }
-
- return strip_tags($date->display());
-};
-
-/**
- * Age the person/couple actually turns on the upcoming anniversary, not
- * their current age. We use the fact's own year (which on an anniversary
- * Fact is the year of the original event — birth or marriage) and the
- * year of the upcoming Julian day stored on the Fact ($fact->jd) so the
- * calculation handles people whose birthday falls before vs. after today
- * uniformly.
- */
$upcoming_age = static function (Fact $fact): int {
static $gregorian = null;
$gregorian ??= new \Fisharebest\ExtCalendar\GregorianCalendar();
@@ -158,131 +228,227 @@ $upcoming_age = static function (Fact $fact): int {
return max(0, $upcoming_year - $event_year);
};
-/**
- * Locale-aware ordinal. English uses st/nd/rd/th suffixes; German (and
- * most other European languages we currently support) just appends a
- * period to the digits.
- */
-$ordinal = static function (int $n): string {
- if (str_starts_with(I18N::languageTag(), 'de')) {
- return $n . '.';
- }
-
- $abs = abs($n);
- $mod100 = $abs % 100;
-
- if ($mod100 >= 11 && $mod100 <= 13) {
- return $n . 'th';
- }
-
- return $n . match ($abs % 10) {
- 1 => 'st',
- 2 => 'nd',
- 3 => 'rd',
- default => 'th',
- };
-};
-
$birthday_label = static function (int $age) use ($ordinal): string {
- if ($age <= 0) {
- return I18N::translate('Birthday');
- }
-
- return I18N::translate('%s birthday', $ordinal($age));
+ return $age > 0
+ ? I18N::translate('%s birthday', $ordinal($age))
+ : I18N::translate('Birthday');
};
$anniversary_label = static function (int $age) use ($ordinal): string {
- if ($age <= 0) {
- return I18N::translate('Wedding anniversary');
+ return $age > 0
+ ? I18N::translate('%s wedding anniversary', $ordinal($age))
+ : I18N::translate('Wedding anniversary');
+};
+
+// Styles reused across the event lists.
+$row_padding = '20px 0';
+$divider_style = 'border-bottom:1px solid ' . $palette['rule'] . ';';
+
+$section_title_style = 'margin:48px 0 8px;'
+ . 'font-family:' . $serif_display . ';'
+ . 'font-weight:500;font-size:28px;line-height:1.1;'
+ . 'color:' . $palette['ink'] . ';'
+ . 'letter-spacing:0.005em;';
+
+$section_kicker_style = 'margin:0 0 22px;'
+ . 'font-family:' . $serif_display . ';'
+ . 'font-style:italic;font-size:15px;'
+ . 'color:' . $palette['mute'] . ';'
+ . 'letter-spacing:0.02em;';
+
+$timeline_cell_style = 'width:170px;vertical-align:top;'
+ . 'padding:24px 0 24px 28px;'
+ . 'border-left:1px solid ' . $palette['rule'] . ';'
+ . 'font-family:' . $serif_body . ';'
+ . 'color:' . $palette['mute'] . ';'
+ . 'font-size:12px;letter-spacing:0.12em;text-transform:uppercase;'
+ . 'white-space:nowrap;';
+
+$timeline_dot_style = 'display:inline-block;'
+ . 'width:11px;height:11px;background:' . $palette['gold'] . ';'
+ . 'border-radius:50%;'
+ . 'margin-left:-34px;margin-right:14px;vertical-align:middle;';
+
+$content_cell_style = 'vertical-align:middle;'
+ . 'padding:24px 24px 24px 18px;'
+ . 'font-family:' . $serif_body . ';font-size:16px;line-height:1.4;'
+ . 'color:' . $palette['ink'] . ';';
+
+$avatar_cell_style = 'width:64px;vertical-align:middle;padding:18px 0;';
+
+// Renders one event row in the consistent 3-column layout shared by all
+// sections (avatar | content | timeline + date).
+/**
+ * Locale-aware long-form date for the masthead. German edition reads
+ * "15. Mai 2026" rather than the English "May 15, 2026".
+ */
+$masthead_date = static function (int $timestamp): string {
+ if (str_starts_with(I18N::languageTag(), 'de')) {
+ $months = [
+ 1 => 'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
+ 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
+ ];
+
+ return (int) date('j', $timestamp) . '. '
+ . $months[(int) date('n', $timestamp)] . ' '
+ . date('Y', $timestamp);
}
- return I18N::translate('%s wedding anniversary', $ordinal($age));
+ return date('F j, Y', $timestamp);
};
+$event_row = static function (Fact $fact, string $body_html)
+ use (
+ $record_avatars,
+ $event_date_display,
+ $avatar_cell_style,
+ $content_cell_style,
+ $timeline_cell_style,
+ $timeline_dot_style,
+ $divider_style,
+ ): string {
+ return ''
+ . '' . $record_avatars($fact) . ' '
+ . '' . $body_html . ' '
+ . ''
+ . ' '
+ . e($event_date_display($fact))
+ . ' '
+ . ' ';
+ };
+
?>
+
= e(I18N::translate('Family newsletter — %s', date('F j, Y', $generated_at))) ?>
+
-
+
-
- = e($tree->title()) ?>
-
+
+
+
-
- = e(I18N::translate('Events in the next %d days.', $lookahead_days)) ?>
-
+
-
+
+
+
+
+ = e(I18N::translate('Family Chronicle')) ?>
+
+
+ = e($tree->title()) ?>
+
+
+ = e($masthead_date($generated_at)) ?>
+
+
+ ❦
+
+
-isEmpty()) : ?>
- = e(I18N::translate('Upcoming birthdays')) ?>
-
-
-
-
- = $record_avatars($fact) ?>
-
- = e($record_label($fact)) ?>
- — = e($birthday_label($age)) ?>
- (= e($event_date($fact)) ?>)
-
-
-
-
-
+
+
+
+ = e(I18N::translate('Events in the next %d days.', $lookahead_days)) ?>
+
+
-isEmpty()) : ?>
- = e(I18N::translate('Upcoming marriage anniversaries')) ?>
-
-
-
-
- = $record_avatars($fact) ?>
-
- = e($record_label($fact)) ?>
- — = e($anniversary_label($age)) ?>
- (= e($event_date($fact)) ?>)
-
-
-
-
-
+ isEmpty()) : ?>
+
+ = e(I18N::translate('Upcoming birthdays')) ?>
+
+ = e(I18N::translate('Living kin who will celebrate this fortnight.')) ?>
+
+
+
+ '
+ . '' . $record_label($fact) . ' '
+ . ' — '
+ . e($birthday_label($age)) . ' '
+ . '';
+ echo $event_row($fact, $body);
+ ?>
+
+
+
+
-isEmpty()) : ?>
- = e(I18N::translate('On this month in history')) ?>
-
- = e(I18N::translate('Events in the next %d days for people who have passed away.', $historical_lookahead)) ?>
-
-
-
-
- = $record_avatars($fact) ?>
-
- = e($record_label($fact)) ?>
- — = e($fact->label()) ?>: = e($event_date($fact)) ?>
-
-
-
-
-
+ isEmpty()) : ?>
+
+ = e(I18N::translate('Upcoming marriage anniversaries')) ?>
+
+ = e(I18N::translate('Marriages still intact.')) ?>
+
+
+
+ '
+ . '' . $record_label($fact) . ' '
+ . ' — '
+ . e($anniversary_label($age)) . ' '
+ . '';
+ echo $event_row($fact, $body);
+ ?>
+
+
+
+
-
-
- = e(I18N::translate('You are receiving this email because you subscribed to the %s newsletter.', $tree->title())) ?>
-
- = I18N::translate(
- 'To change or cancel your subscription, edit the “Newsletter subscription” section on your %s page.',
- '' . e(I18N::translate('My account')) . ' ',
- ) ?>
-
+ isEmpty()) : ?>
+
+ = e(I18N::translate('On this month in history')) ?>
+
+ = e(I18N::translate('Events in the next %d days for people who have passed away.', $historical_lookahead)) ?>
+
+
+
+ '
+ . '' . $record_label($fact) . ' '
+ . ' — '
+ . e($fact->label()) . ' '
+ . '';
+ echo $event_row($fact, $body);
+ ?>
+
+
+
+
+
+
+
+ ❦
+
+ = e(I18N::translate('You are receiving this email because you subscribed to the %s newsletter.', $tree->title())) ?>
+
+ = I18N::translate(
+ 'To change or cancel your subscription, edit the “Newsletter subscription” section on your %s page.',
+ '' . e(I18N::translate('My account')) . ' ',
+ ) ?>
+
+
+
+
+
+
+
diff --git a/src/Module.php b/src/Module.php
index fd55f27..28930dd 100644
--- a/src/Module.php
+++ b/src/Module.php
@@ -118,6 +118,10 @@ class Module extends AbstractModule implements ModuleCustomInterface, ModuleConf
'Cron token' => 'Cron-Token',
'Regenerate token' => 'Token neu generieren',
'Your subscription has been updated.' => 'Ihr Abonnement wurde aktualisiert.',
+ 'Family Chronicle' => 'Familienchronik',
+ 'Living kin who will celebrate this fortnight.'
+ => 'Lebende Verwandte, die in den nächsten zwei Wochen feiern.',
+ 'Marriages still intact.' => 'Noch bestehende Ehen.',
'%s birthday' => '%s Geburtstag',
'%s wedding anniversary' => '%s Hochzeitstag',
'Birthday' => 'Geburtstag',