Skip to content

Commit 137c9c5

Browse files
authored
Merge pull request #975 from mnfst/all-emails
feat: unify email templates and differentiate soft/hard alerts
2 parents a70b517 + 33d5c4e commit 137c9c5

13 files changed

+270
-43
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"manifest": minor
3+
---
4+
5+
Unify all email templates with PNG logo and copyright footer; differentiate soft (warning) and hard (blocking) threshold alerts with distinct colors and messaging

packages/backend/src/common/utils/period.util.spec.ts

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { computePeriodBoundaries } from './period.util';
1+
import { computePeriodBoundaries, computePeriodResetDate } from './period.util';
22

33
describe('computePeriodBoundaries', () => {
44
it('returns periodStart and periodEnd as formatted strings', () => {
@@ -58,3 +58,62 @@ describe('computePeriodBoundaries', () => {
5858
expect(end).toBeLessThanOrEqual(after + 1000);
5959
});
6060
});
61+
62+
describe('computePeriodResetDate', () => {
63+
it('returns a formatted datetime string', () => {
64+
const result = computePeriodResetDate('day');
65+
expect(result).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
66+
});
67+
68+
it('hour: returns start of next hour UTC', () => {
69+
const now = new Date();
70+
const result = computePeriodResetDate('hour');
71+
const reset = new Date(result.replace(' ', 'T') + 'Z');
72+
const expectedHour = (now.getUTCHours() + 1) % 24;
73+
expect(reset.getUTCHours()).toBe(expectedHour);
74+
expect(reset.getUTCMinutes()).toBe(0);
75+
expect(reset.getUTCSeconds()).toBe(0);
76+
});
77+
78+
it('day: returns midnight UTC next day', () => {
79+
const now = new Date();
80+
const result = computePeriodResetDate('day');
81+
const reset = new Date(result.replace(' ', 'T') + 'Z');
82+
expect(reset.getUTCHours()).toBe(0);
83+
expect(reset.getUTCMinutes()).toBe(0);
84+
const expectedDate = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1));
85+
expect(reset.getUTCDate()).toBe(expectedDate.getUTCDate());
86+
});
87+
88+
it('week: returns next Monday at midnight UTC', () => {
89+
const result = computePeriodResetDate('week');
90+
const reset = new Date(result.replace(' ', 'T') + 'Z');
91+
expect(reset.getUTCDay()).toBe(1); // Monday
92+
expect(reset.getUTCHours()).toBe(0);
93+
expect(reset.getUTCMinutes()).toBe(0);
94+
expect(reset.getTime()).toBeGreaterThan(Date.now());
95+
});
96+
97+
it('month: returns first of next month UTC', () => {
98+
const now = new Date();
99+
const result = computePeriodResetDate('month');
100+
const reset = new Date(result.replace(' ', 'T') + 'Z');
101+
expect(reset.getUTCDate()).toBe(1);
102+
expect(reset.getUTCHours()).toBe(0);
103+
const expectedMonth = (now.getUTCMonth() + 1) % 12;
104+
expect(reset.getUTCMonth()).toBe(expectedMonth);
105+
});
106+
107+
it('unknown period: defaults to hour-like behavior', () => {
108+
const result = computePeriodResetDate('unknown');
109+
expect(result).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
110+
});
111+
112+
it('reset date is always in the future', () => {
113+
for (const period of ['hour', 'day', 'week', 'month']) {
114+
const result = computePeriodResetDate(period);
115+
const reset = new Date(result.replace(' ', 'T') + 'Z');
116+
expect(reset.getTime()).toBeGreaterThan(Date.now() - 1000);
117+
}
118+
});
119+
});

packages/backend/src/common/utils/period.util.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ export interface PeriodBoundaries {
33
periodEnd: string;
44
}
55

6+
const fmt = (d: Date) => d.toISOString().replace('T', ' ').replace('Z', '').slice(0, 19);
7+
68
export function computePeriodBoundaries(period: string): PeriodBoundaries {
79
const now = new Date();
810
let start: Date;
@@ -28,7 +30,32 @@ export function computePeriodBoundaries(period: string): PeriodBoundaries {
2830
}
2931

3032
const end = new Date(now.getTime());
31-
const fmt = (d: Date) => d.toISOString().replace('T', ' ').replace('Z', '').slice(0, 19);
32-
3333
return { periodStart: fmt(start), periodEnd: fmt(end) };
3434
}
35+
36+
export function computePeriodResetDate(period: string): string {
37+
const now = new Date();
38+
let reset: Date;
39+
40+
switch (period) {
41+
case 'hour':
42+
reset = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), now.getUTCHours() + 1));
43+
break;
44+
case 'day':
45+
reset = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1));
46+
break;
47+
case 'week': {
48+
const dayOfWeek = now.getUTCDay();
49+
const daysUntilMonday = ((8 - dayOfWeek) % 7) || 7;
50+
reset = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + daysUntilMonday));
51+
break;
52+
}
53+
case 'month':
54+
reset = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1));
55+
break;
56+
default:
57+
reset = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), now.getUTCHours() + 1));
58+
}
59+
60+
return fmt(reset);
61+
}

packages/backend/src/notifications/emails/reset-password.tsx

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,18 @@ import {
99
Button,
1010
Preview,
1111
Hr,
12+
Img,
13+
Link,
1214
} from '@react-email/components';
1315

1416
export interface ResetPasswordEmailProps {
1517
userName: string;
1618
resetUrl: string;
19+
logoUrl?: string;
1720
}
1821

1922
export function ResetPasswordEmail(props: ResetPasswordEmailProps) {
20-
const { userName, resetUrl } = props;
23+
const { userName, resetUrl, logoUrl = 'https://app.manifest.build/manifest-logo.png' } = props;
2124

2225
return (
2326
<Html>
@@ -27,7 +30,7 @@ export function ResetPasswordEmail(props: ResetPasswordEmailProps) {
2730
<Container style={container}>
2831
{/* Logo */}
2932
<Section style={logoSection}>
30-
<Text style={logo}>manifest</Text>
33+
<Img src={logoUrl} alt="Manifest" width="140" height="32" style={logoImg} />
3134
</Section>
3235

3336
{/* Main content */}
@@ -63,7 +66,10 @@ export function ResetPasswordEmail(props: ResetPasswordEmailProps) {
6366
<Hr style={divider} />
6467
<Section style={footer}>
6568
<Text style={footerMuted}>
66-
manifest.build
69+
© 2026 MNFST Inc. All rights reserved.{' '}
70+
<Link href="https://manifest.build" style={footerLink}>
71+
manifest.build
72+
</Link>
6773
</Text>
6874
</Section>
6975
</Container>
@@ -102,12 +108,8 @@ const logoSection: React.CSSProperties = {
102108
paddingBottom: '32px',
103109
};
104110

105-
const logo: React.CSSProperties = {
106-
fontSize: '22px',
107-
fontWeight: 700,
108-
letterSpacing: '-0.03em',
109-
color: '#22110C',
110-
margin: 0,
111+
const logoImg: React.CSSProperties = {
112+
margin: '0 auto',
111113
};
112114

113115
const card: React.CSSProperties = {
@@ -189,3 +191,8 @@ const footerMuted: React.CSSProperties = {
189191
color: '#94a3b8',
190192
margin: 0,
191193
};
194+
195+
const footerLink: React.CSSProperties = {
196+
color: '#94a3b8',
197+
textDecoration: 'underline',
198+
};

packages/backend/src/notifications/emails/test-email.tsx

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,16 @@ import {
88
Text,
99
Preview,
1010
Hr,
11+
Img,
12+
Link,
1113
} from '@react-email/components';
1214

13-
export function TestEmail() {
15+
interface TestEmailProps {
16+
logoUrl?: string;
17+
}
18+
19+
export function TestEmail(props: TestEmailProps = {}) {
20+
const { logoUrl = 'https://app.manifest.build/manifest-logo.png' } = props;
1421
return (
1522
<Html>
1623
<Head />
@@ -19,7 +26,7 @@ export function TestEmail() {
1926
<Container style={container}>
2027
{/* Logo */}
2128
<Section style={logoSection}>
22-
<Text style={logo}>manifest</Text>
29+
<Img src={logoUrl} alt="Manifest" width="140" height="32" style={logoImg} />
2330
</Section>
2431

2532
{/* Main content */}
@@ -34,8 +41,8 @@ export function TestEmail() {
3441
email provider configuration is working correctly.
3542
</Text>
3643
<Text style={paragraph}>
37-
Notification emails — such as threshold alertswill be delivered
38-
to this address.
44+
Notification emails, like threshold alerts, will be delivered to
45+
this address.
3946
</Text>
4047
</Section>
4148

@@ -45,7 +52,12 @@ export function TestEmail() {
4552
<Text style={footerNote}>
4653
This is a one-time test email sent from Manifest.
4754
</Text>
48-
<Text style={footerMuted}>manifest.build</Text>
55+
<Text style={footerMuted}>
56+
© 2026 MNFST Inc. All rights reserved.{' '}
57+
<Link href="https://manifest.build" style={footerLink}>
58+
manifest.build
59+
</Link>
60+
</Text>
4961
</Section>
5062
</Container>
5163
</Body>
@@ -80,12 +92,8 @@ const logoSection: React.CSSProperties = {
8092
paddingBottom: '32px',
8193
};
8294

83-
const logo: React.CSSProperties = {
84-
fontSize: '22px',
85-
fontWeight: 700,
86-
letterSpacing: '-0.03em',
87-
color: '#22110C',
88-
margin: 0,
95+
const logoImg: React.CSSProperties = {
96+
margin: '0 auto',
8997
};
9098

9199
const card: React.CSSProperties = {
@@ -150,3 +158,8 @@ const footerMuted: React.CSSProperties = {
150158
color: '#94a3b8',
151159
margin: 0,
152160
};
161+
162+
const footerLink: React.CSSProperties = {
163+
color: '#94a3b8',
164+
textDecoration: 'underline',
165+
};

packages/backend/src/notifications/emails/threshold-alert.tsx

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ export interface ThresholdAlertProps {
2222
timestamp: string;
2323
agentUrl: string;
2424
logoUrl?: string;
25+
alertType?: 'soft' | 'hard';
26+
periodResetDate?: string;
2527
}
2628

2729
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
@@ -52,8 +54,15 @@ export function ThresholdAlertEmail(props: ThresholdAlertProps) {
5254
timestamp,
5355
agentUrl,
5456
logoUrl = 'https://app.manifest.build/manifest-logo.png',
57+
alertType = 'hard',
58+
periodResetDate,
5559
} = props;
5660

61+
const isSoft = alertType === 'soft';
62+
const accentColor = isSoft ? '#ea580c' : '#dc2626';
63+
const accentBg = isSoft ? '#fff7ed' : '#fef2f2';
64+
const accentBorder = isSoft ? '#fed7aa' : '#fecaca';
65+
5766
return (
5867
<Html>
5968
<Head />
@@ -71,7 +80,9 @@ export function ThresholdAlertEmail(props: ThresholdAlertProps) {
7180
<Section style={card}>
7281
{/* Alert badge */}
7382
<Section style={alertBadgeContainer}>
74-
<Text style={alertBadge}>Threshold exceeded</Text>
83+
<Text style={{ ...alertBadge, color: accentColor, backgroundColor: accentBg }}>
84+
{isSoft ? 'Warning' : 'Threshold exceeded'}
85+
</Text>
7586
</Section>
7687

7788
<Text style={heading}>
@@ -82,15 +93,27 @@ export function ThresholdAlertEmail(props: ThresholdAlertProps) {
8293
threshold for the current <strong>{period}</strong> period.
8394
</Text>
8495

96+
{/* Context message */}
97+
{isSoft ? (
98+
<Text style={paragraph}>Requests are still being processed normally.</Text>
99+
) : (
100+
<Section style={{ ...hardLimitBox, backgroundColor: accentBg, borderColor: accentBorder }}>
101+
<Text style={{ ...hardLimitText, color: accentColor }}>
102+
Requests are now blocked until the next period resets
103+
{periodResetDate ? ` on ${formatTimestamp(periodResetDate)}` : ''}.
104+
</Text>
105+
</Section>
106+
)}
107+
85108
{/* Stats row */}
86109
<Section style={statsRow}>
87110
<Section style={statBox}>
88111
<Text style={statLabel}>Threshold</Text>
89112
<Text style={statValue}>{formatValue(threshold, metricType)}</Text>
90113
</Section>
91-
<Section style={statBoxAlert}>
114+
<Section style={{ ...statBoxAlert, backgroundColor: accentBg, borderColor: accentBorder }}>
92115
<Text style={statLabel}>Actual usage</Text>
93-
<Text style={statValueAlert}>{formatValue(actualValue, metricType)}</Text>
116+
<Text style={{ ...statValueAlert, color: accentColor }}>{formatValue(actualValue, metricType)}</Text>
94117
</Section>
95118
</Section>
96119

@@ -176,8 +199,6 @@ const alertBadge: React.CSSProperties = {
176199
fontWeight: 600,
177200
textTransform: 'uppercase' as const,
178201
letterSpacing: '0.05em',
179-
color: '#dc2626',
180-
backgroundColor: '#fef2f2',
181202
padding: '4px 10px',
182203
borderRadius: '6px',
183204
margin: 0,
@@ -212,10 +233,23 @@ const statBox: React.CSSProperties = {
212233
};
213234

214235
const statBoxAlert: React.CSSProperties = {
215-
backgroundColor: '#fef2f2',
216236
borderRadius: '8px',
217237
padding: '16px 20px',
218-
border: '1px solid #fecaca',
238+
border: '1px solid',
239+
};
240+
241+
const hardLimitBox: React.CSSProperties = {
242+
padding: '12px 16px',
243+
borderRadius: '8px',
244+
border: '1px solid',
245+
marginBottom: '28px',
246+
};
247+
248+
const hardLimitText: React.CSSProperties = {
249+
fontSize: '14px',
250+
fontWeight: 700,
251+
margin: 0,
252+
lineHeight: '1.5',
219253
};
220254

221255
const statLabel: React.CSSProperties = {
@@ -238,7 +272,6 @@ const statValue: React.CSSProperties = {
238272
const statValueAlert: React.CSSProperties = {
239273
fontSize: '24px',
240274
fontWeight: 700,
241-
color: '#dc2626',
242275
margin: 0,
243276
letterSpacing: '-0.02em',
244277
};

0 commit comments

Comments
 (0)