Skip to content

Commit b4622ce

Browse files
committed
fix: localize notification messages before delivery
1 parent 7dd9961 commit b4622ce

13 files changed

Lines changed: 519 additions & 83 deletions

package-lock.json

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767
"@iconify/react": "^6.0.2",
6868
"@noble/hashes": "^2.2.0",
6969
"@number-flow/react": "^0.6.0",
70+
"@react-email/render": "2.0.9",
7071
"@remixicon/react": "^4.9.0",
7172
"boring-avatars": "^2.0.4",
7273
"class-variance-authority": "^0.7.1",

src/components/email/notification-email.tsx

Lines changed: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,21 @@ import {
1616
import type { Locale } from "@/lib/i18n/config";
1717
import type { NotificationContent } from "@/lib/notifications/content";
1818
import {
19-
formatNotificationNumber,
2019
notificationMetricLabel,
2120
notificationWindowLabel,
2221
} from "@/lib/notifications/content";
22+
import {
23+
formatNotificationDateTime,
24+
formatNotificationNumber,
25+
} from "@/lib/notifications/email-format";
2326
import { NOTIFICATION_EMAIL_MESSAGES } from "@/lib/notifications/email-i18n";
2427
import type { NotificationMessage } from "@/lib/notifications/message-store";
2528

2629
export interface NotificationEmailProps {
2730
locale: Locale;
2831
content: NotificationContent;
2932
message: NotificationMessage;
33+
timeZone?: string | null;
3034
}
3135

3236
function record(value: unknown): Record<string, unknown> {
@@ -48,11 +52,15 @@ function textValue(value: unknown, fallback = ""): string {
4852
return typeof value === "string" && value.trim() ? value.trim() : fallback;
4953
}
5054

51-
function formatLastSeen(value: unknown, locale: Locale): string {
55+
function formatLastSeen(
56+
value: unknown,
57+
locale: Locale,
58+
timeZone?: string | null,
59+
): string {
5260
const messages = NOTIFICATION_EMAIL_MESSAGES[locale];
53-
const seconds = Number(value);
54-
if (!Number.isFinite(seconds) || seconds <= 0) return messages.common.never;
55-
return new Date(Math.trunc(seconds) * 1000).toISOString();
61+
return (
62+
formatNotificationDateTime(value, locale, timeZone) || messages.common.never
63+
);
5664
}
5765

5866
function Intro({ content }: { content: NotificationContent }) {
@@ -91,11 +99,11 @@ function ReportEmail({ locale, message }: NotificationEmailProps) {
9199
const topReferrers = rows(message.data.topReferrers);
92100
const pageRows: EmailTableRow[] = topPages.map((page, index) => ({
93101
label: `${index + 1}. ${textValue(page.path, "/")}`,
94-
value: `${formatNotificationNumber(page.views)} ${messages.common.viewsUnit}`,
102+
value: `${formatNotificationNumber(page.views, locale)} ${messages.common.viewsUnit}`,
95103
}));
96104
const referrerRows: EmailTableRow[] = topReferrers.map((referrer, index) => ({
97105
label: `${index + 1}. ${textValue(referrer.referrer, messages.common.direct)}`,
98-
value: `${formatNotificationNumber(referrer.visits)} ${messages.common.visits}`,
106+
value: `${formatNotificationNumber(referrer.visits, locale)} ${messages.common.visits}`,
99107
}));
100108

101109
return (
@@ -111,15 +119,15 @@ function ReportEmail({ locale, message }: NotificationEmailProps) {
111119
<EmailMetricGrid>
112120
<EmailMetricCard
113121
label={messages.common.views}
114-
value={formatNotificationNumber(metrics.views)}
122+
value={formatNotificationNumber(metrics.views, locale)}
115123
/>
116124
<EmailMetricCard
117125
label={messages.common.visitors}
118-
value={formatNotificationNumber(metrics.visitors)}
126+
value={formatNotificationNumber(metrics.visitors, locale)}
119127
/>
120128
<EmailMetricCard
121129
label={messages.common.sessions}
122-
value={formatNotificationNumber(metrics.sessions)}
130+
value={formatNotificationNumber(metrics.sessions, locale)}
123131
/>
124132
</EmailMetricGrid>
125133
<EmailListTable
@@ -141,7 +149,9 @@ function ThresholdEmail({ locale, message }: NotificationEmailProps) {
141149
const operator = textValue(message.data.operator, ">=");
142150
return (
143151
<>
144-
<EmailBadge severity="warning">warning</EmailBadge>
152+
<EmailBadge severity="warning">
153+
{messages.common.severity.warning}
154+
</EmailBadge>
145155
<EmailTable
146156
rows={[
147157
{
@@ -154,28 +164,35 @@ function ThresholdEmail({ locale, message }: NotificationEmailProps) {
154164
},
155165
{
156166
label: messages.common.currentValue,
157-
value: formatNotificationNumber(message.data.value),
167+
value: formatNotificationNumber(message.data.value, locale),
158168
},
159169
{
160170
label: messages.common.threshold,
161-
value: `${operator} ${formatNotificationNumber(message.data.target)}`,
171+
value: `${operator} ${formatNotificationNumber(message.data.target, locale)}`,
162172
},
163173
]}
164174
/>
165175
</>
166176
);
167177
}
168178

169-
function HealthEmail({ locale, content, message }: NotificationEmailProps) {
179+
function HealthEmail({
180+
locale,
181+
content,
182+
message,
183+
timeZone,
184+
}: NotificationEmailProps) {
170185
const messages = NOTIFICATION_EMAIL_MESSAGES[locale];
171186
return (
172187
<>
173-
<EmailBadge severity="critical">critical</EmailBadge>
188+
<EmailBadge severity="critical">
189+
{messages.common.severity.critical}
190+
</EmailBadge>
174191
<EmailTable
175192
rows={[
176193
{
177194
label: messages.common.lastSeen,
178-
value: formatLastSeen(message.data.lastSeenAt, locale),
195+
value: formatLastSeen(message.data.lastSeenAt, locale, timeZone),
179196
},
180197
]}
181198
/>
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { formatNotificationDateTime } from "@/lib/notifications/email-format";
4+
import { renderNotificationPlainText } from "@/lib/notifications/email-text";
5+
import type { NotificationMessageDraft } from "@/lib/notifications/evaluator";
6+
import { buildLocalizedNotificationMessageFields } from "@/lib/notifications/localized-message";
7+
import type { NotificationMessage } from "@/lib/notifications/message-store";
8+
9+
function draft(
10+
input: Partial<NotificationMessageDraft>,
11+
): NotificationMessageDraft {
12+
return {
13+
type: "report",
14+
severity: "info",
15+
requiresAttention: false,
16+
title: "Fallback title",
17+
summary: "Fallback summary",
18+
bodyText: "Fallback body",
19+
data: {},
20+
...input,
21+
};
22+
}
23+
24+
function message(input: Partial<NotificationMessage>): NotificationMessage {
25+
return {
26+
id: "msg-1",
27+
teamId: "team-1",
28+
siteId: "site-1",
29+
userId: "user-1",
30+
ruleId: null,
31+
runId: null,
32+
batchId: null,
33+
type: "system",
34+
severity: "info",
35+
requiresAttention: false,
36+
title: "Fallback",
37+
summary: "Summary",
38+
bodyText: "Body",
39+
bodyHtml: "",
40+
data: {},
41+
channels: {},
42+
deliveryStatus: "created",
43+
deliveryResults: {},
44+
errorMessage: "",
45+
readAt: null,
46+
dismissedAt: null,
47+
archivedAt: null,
48+
triggeredAt: null,
49+
createdAt: 1,
50+
updatedAt: 1,
51+
sentAt: null,
52+
failedAt: null,
53+
expiresAt: null,
54+
...input,
55+
};
56+
}
57+
58+
describe("localized notification messages", () => {
59+
it("builds report fields in each recipient locale", () => {
60+
const input = draft({
61+
type: "report",
62+
data: {
63+
siteDomain: "example.com",
64+
range: { label: "2026-06-29" },
65+
metrics: { views: 1234, visitors: 567, sessions: 89 },
66+
topPages: [{ path: "/pricing", views: 123 }],
67+
topReferrers: [{ referrer: "Search", visits: 45 }],
68+
},
69+
});
70+
71+
const en = buildLocalizedNotificationMessageFields({
72+
draft: input,
73+
locale: "en",
74+
});
75+
const zh = buildLocalizedNotificationMessageFields({
76+
draft: input,
77+
locale: "zh",
78+
});
79+
80+
expect(en.title).toBe("example.com daily traffic report");
81+
expect(en.bodyText).toContain("Core metrics");
82+
expect(en.bodyText).toContain("1. /pricing - 123 views");
83+
expect(zh.title).toBe("example.com 每日访问报告");
84+
expect(zh.bodyText).toContain("核心指标");
85+
expect(zh.bodyText).toContain("1. /pricing - 123 次浏览");
86+
});
87+
88+
it("builds threshold and health fields without English draft body leakage", () => {
89+
const threshold = draft({
90+
type: "threshold",
91+
severity: "warning",
92+
bodyText: "Metric: visitors",
93+
data: {
94+
siteDomain: "example.com",
95+
metric: "visitors",
96+
window: "last_1h",
97+
value: 1240,
98+
operator: ">=",
99+
target: 1000,
100+
},
101+
});
102+
const health = draft({
103+
type: "health",
104+
severity: "critical",
105+
bodyText: "Last seen: never",
106+
data: {
107+
siteDomain: "example.com",
108+
hours: 6,
109+
lastSeenAt: 1_782_793_800,
110+
},
111+
});
112+
113+
const zhThreshold = buildLocalizedNotificationMessageFields({
114+
draft: threshold,
115+
locale: "zh",
116+
});
117+
const enHealth = buildLocalizedNotificationMessageFields({
118+
draft: health,
119+
locale: "en",
120+
timeZone: "Asia/Shanghai",
121+
});
122+
const zhHealth = buildLocalizedNotificationMessageFields({
123+
draft: health,
124+
locale: "zh",
125+
timeZone: "Asia/Shanghai",
126+
});
127+
128+
expect(zhThreshold.bodyText).toContain("指标:访客数");
129+
expect(zhThreshold.bodyText).not.toContain("Metric:");
130+
expect(enHealth.bodyText).toContain("Last seen:");
131+
expect(zhHealth.bodyText).toContain("最后收到数据:");
132+
expect(zhHealth.bodyText).not.toContain("Last seen:");
133+
});
134+
135+
it("keeps fallback messages on unsupported types", () => {
136+
const localized = buildLocalizedNotificationMessageFields({
137+
draft: draft({
138+
type: "system",
139+
title: "System title",
140+
summary: "System summary",
141+
bodyText: "System body",
142+
}),
143+
locale: "zh",
144+
});
145+
146+
expect(localized).toMatchObject({
147+
locale: "zh",
148+
title: "System title",
149+
summary: "System summary",
150+
bodyText: "System title\n\nSystem body",
151+
});
152+
});
153+
});
154+
155+
describe("notification plain text and email rendering", () => {
156+
it("formats health timestamps with locale and timezone", () => {
157+
expect(
158+
formatNotificationDateTime(1_782_793_800, "en", "Asia/Shanghai"),
159+
).toContain("Jun");
160+
expect(
161+
formatNotificationDateTime(1_782_793_800, "zh", "Asia/Shanghai"),
162+
).toContain("2026");
163+
});
164+
165+
it("renders plain text without html or object string output", () => {
166+
const text = renderNotificationPlainText({
167+
locale: "zh",
168+
timeZone: "Asia/Shanghai",
169+
content: {
170+
subject: "example.com 访问量达到阈值",
171+
title: "example.com 访问量达到阈值",
172+
summary: "过去 1 小时的访客数为 1,240,已匹配阈值 >= 1,000。",
173+
bodyText: "",
174+
},
175+
message: message({
176+
type: "threshold",
177+
severity: "warning",
178+
data: {
179+
metric: "visitors",
180+
window: "last_1h",
181+
value: 1240,
182+
operator: ">=",
183+
target: 1000,
184+
},
185+
}),
186+
});
187+
188+
expect(text).toContain("指标:访客数");
189+
expect(text).not.toContain("<html");
190+
expect(text).not.toContain("[object Object]");
191+
});
192+
193+
it("renders localized health plain text with timezone", () => {
194+
const localized = buildLocalizedNotificationMessageFields({
195+
locale: "zh",
196+
timeZone: "Asia/Shanghai",
197+
draft: draft({
198+
type: "health",
199+
severity: "critical",
200+
data: {
201+
siteDomain: "example.com",
202+
hours: 6,
203+
lastSeenAt: 1_782_793_800,
204+
},
205+
}),
206+
});
207+
208+
expect(localized.title).toContain("没有收到访问数据");
209+
expect(localized.bodyText).toContain("最后收到数据:");
210+
expect(localized.bodyText).not.toContain("<html");
211+
expect(localized.bodyText).not.toContain("[object Object]");
212+
});
213+
});

0 commit comments

Comments
 (0)