Skip to content

Commit 7a5b545

Browse files
authored
Merge pull request #970 from mnfst/alerts
feat: improve threshold alert email and remove Cloud header badge
2 parents 2e772f7 + c6af5ce commit 7a5b545

File tree

10 files changed

+284
-124
lines changed

10 files changed

+284
-124
lines changed

.changeset/improve-alert-email.md

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+
Improve threshold alert email: format timestamps as MMM DD HH:MM:SS, add View Agent Dashboard button, update footer with copyright, replace text logo with PNG image. Remove Cloud badge from header.

Manifest-logo.png

-210 KB
Binary file not shown.

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

Lines changed: 70 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ import {
88
Text,
99
Preview,
1010
Hr,
11+
Link,
12+
Img,
13+
Button,
1114
} from '@react-email/components';
1215

1316
export interface ThresholdAlertProps {
@@ -17,6 +20,21 @@ export interface ThresholdAlertProps {
1720
actualValue: number;
1821
period: string;
1922
timestamp: string;
23+
agentUrl: string;
24+
logoUrl?: string;
25+
}
26+
27+
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
28+
29+
function formatTimestamp(raw: string): string {
30+
const [datePart, timePart] = raw.split(' ');
31+
if (!datePart || !timePart) return raw;
32+
const [, month, day] = datePart.split('-');
33+
if (!month || !day) return raw;
34+
const monthIdx = parseInt(month, 10) - 1;
35+
const monthName = MONTHS[monthIdx] ?? month;
36+
const dayNum = parseInt(day, 10);
37+
return `${monthName} ${dayNum}, ${timePart}`;
2038
}
2139

2240
function formatValue(value: number, metric: string): string {
@@ -25,8 +43,16 @@ function formatValue(value: number, metric: string): string {
2543
}
2644

2745
export function ThresholdAlertEmail(props: ThresholdAlertProps) {
28-
const { agentName, metricType, threshold, actualValue, period, timestamp } =
29-
props;
46+
const {
47+
agentName,
48+
metricType,
49+
threshold,
50+
actualValue,
51+
period,
52+
timestamp,
53+
agentUrl,
54+
logoUrl = 'https://app.manifest.build/manifest-logo.png',
55+
} = props;
3056

3157
return (
3258
<Html>
@@ -38,7 +64,7 @@ export function ThresholdAlertEmail(props: ThresholdAlertProps) {
3864
<Container style={container}>
3965
{/* Logo */}
4066
<Section style={logoSection}>
41-
<Text style={logo}>manifest</Text>
67+
<Img src={logoUrl} alt="Manifest" width="140" height="32" style={logoImg} />
4268
</Section>
4369

4470
{/* Main content */}
@@ -52,47 +78,47 @@ export function ThresholdAlertEmail(props: ThresholdAlertProps) {
5278
{agentName} exceeded the {metricType} limit
5379
</Text>
5480
<Text style={paragraph}>
55-
Your agent <strong>{agentName}</strong> has exceeded the{' '}
56-
<strong>{metricType}</strong> threshold for the current{' '}
57-
<strong>{period}</strong> period.
81+
Your agent <strong>{agentName}</strong> has exceeded the <strong>{metricType}</strong>{' '}
82+
threshold for the current <strong>{period}</strong> period.
5883
</Text>
5984

6085
{/* Stats row */}
6186
<Section style={statsRow}>
6287
<Section style={statBox}>
6388
<Text style={statLabel}>Threshold</Text>
64-
<Text style={statValue}>
65-
{formatValue(threshold, metricType)}
66-
</Text>
89+
<Text style={statValue}>{formatValue(threshold, metricType)}</Text>
6790
</Section>
6891
<Section style={statBoxAlert}>
6992
<Text style={statLabel}>Actual usage</Text>
70-
<Text style={statValueAlert}>
71-
{formatValue(actualValue, metricType)}
72-
</Text>
93+
<Text style={statValueAlert}>{formatValue(actualValue, metricType)}</Text>
7394
</Section>
7495
</Section>
7596

7697
{/* Meta info */}
7798
<Section style={metaRow}>
78-
<Text style={metaText}>
79-
Period: {period}
80-
</Text>
81-
<Text style={metaText}>
82-
Triggered: {timestamp}
83-
</Text>
99+
<Text style={metaText}>Period: {period}</Text>
100+
<Text style={metaText}>Triggered: {formatTimestamp(timestamp)}</Text>
101+
</Section>
102+
103+
{/* CTA Button */}
104+
<Section style={ctaContainer}>
105+
<Button style={ctaButton} href={agentUrl}>
106+
View Agent Dashboard →
107+
</Button>
84108
</Section>
85109
</Section>
86110

87111
{/* Footer */}
88112
<Hr style={divider} />
89113
<Section style={footer}>
90114
<Text style={footerNote}>
91-
You are receiving this because you set up a notification rule in
92-
Manifest.
115+
You are receiving this because you set up a notification rule in Manifest.
93116
</Text>
94117
<Text style={footerMuted}>
95-
manifest.build
118+
© 2026 MNFST Inc. All rights reserved.{' '}
119+
<Link href="https://manifest.build" style={footerLink}>
120+
manifest.build
121+
</Link>
96122
</Text>
97123
</Section>
98124
</Container>
@@ -129,12 +155,8 @@ const logoSection: React.CSSProperties = {
129155
paddingBottom: '32px',
130156
};
131157

132-
const logo: React.CSSProperties = {
133-
fontSize: '22px',
134-
fontWeight: 700,
135-
letterSpacing: '-0.03em',
136-
color: '#22110C',
137-
margin: 0,
158+
const logoImg: React.CSSProperties = {
159+
margin: '0 auto',
138160
};
139161

140162
const card: React.CSSProperties = {
@@ -231,6 +253,22 @@ const metaText: React.CSSProperties = {
231253
margin: '0 0 2px',
232254
};
233255

256+
const ctaContainer: React.CSSProperties = {
257+
textAlign: 'center' as const,
258+
marginTop: '28px',
259+
};
260+
261+
const ctaButton: React.CSSProperties = {
262+
backgroundColor: '#0f172a',
263+
color: '#ffffff',
264+
fontSize: '14px',
265+
fontWeight: 600,
266+
padding: '12px 28px',
267+
borderRadius: '8px',
268+
textDecoration: 'none',
269+
display: 'inline-block',
270+
};
271+
234272
const divider: React.CSSProperties = {
235273
borderColor: brandBorder,
236274
borderTop: 'none',
@@ -253,3 +291,8 @@ const footerMuted: React.CSSProperties = {
253291
color: '#94a3b8',
254292
margin: 0,
255293
};
294+
295+
const footerLink: React.CSSProperties = {
296+
color: '#94a3b8',
297+
textDecoration: 'underline',
298+
};

packages/backend/src/notifications/services/limit-check.service.ts

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@ import { EmailProviderConfigService } from './email-provider-config.service';
88
import { IngestEventBusService } from '../../common/services/ingest-event-bus.service';
99
import { computePeriodBoundaries } from '../../common/utils/period.util';
1010
import { detectDialect, portableSql, type DbDialect } from '../../common/utils/sql-dialect';
11-
import { LOCAL_EMAIL, readLocalNotificationEmail } from '../../common/constants/local-mode.constants';
11+
import {
12+
LOCAL_EMAIL,
13+
readLocalNotificationEmail,
14+
} from '../../common/constants/local-mode.constants';
1215

1316
interface BlockRule {
1417
id: string;
@@ -74,7 +77,11 @@ export class LimitCheckService implements OnModuleInit, OnModuleDestroy {
7477
for (const rule of rules) {
7578
const { periodStart, periodEnd } = computePeriodBoundaries(rule.period);
7679
const actual = await this.getCachedConsumption(
77-
tenantId, agentName, rule.metric_type, periodStart, periodEnd,
80+
tenantId,
81+
agentName,
82+
rule.metric_type,
83+
periodStart,
84+
periodEnd,
7885
);
7986

8087
if (actual >= rule.threshold) {
@@ -107,8 +114,10 @@ export class LimitCheckService implements OnModuleInit, OnModuleDestroy {
107114

108115
/** Send email + log (once per rule per period, fire-and-forget). */
109116
private async notifyLimitExceeded(
110-
rule: BlockRule, actual: number,
111-
periodStart: string, periodEnd: string,
117+
rule: BlockRule,
118+
actual: number,
119+
periodStart: string,
120+
periodEnd: string,
112121
): Promise<void> {
113122
const alreadySent = await this.ds.query(
114123
this.sql(`SELECT 1 FROM notification_logs WHERE rule_id = $1 AND period_start = $2`),
@@ -122,6 +131,8 @@ export class LimitCheckService implements OnModuleInit, OnModuleDestroy {
122131
let emailSent = false;
123132

124133
if (email) {
134+
const baseUrl =
135+
process.env['BETTER_AUTH_URL'] ?? `http://localhost:${process.env['PORT'] ?? '3001'}`;
125136
emailSent = await this.emailService.sendThresholdAlert(
126137
email,
127138
{
@@ -131,6 +142,7 @@ export class LimitCheckService implements OnModuleInit, OnModuleDestroy {
131142
actualValue: actual,
132143
period: rule.period,
133144
timestamp: now,
145+
agentUrl: `${baseUrl}/agents/${encodeURIComponent(rule.agent_name)}`,
134146
},
135147
providerConfig ?? undefined,
136148
);
@@ -143,8 +155,17 @@ export class LimitCheckService implements OnModuleInit, OnModuleDestroy {
143155
(id, rule_id, period_start, period_end, actual_value, threshold_value, metric_type, agent_name, sent_at)
144156
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
145157
),
146-
[uuid(), rule.id, periodStart, periodEnd, actual, rule.threshold,
147-
rule.metric_type, rule.agent_name, now],
158+
[
159+
uuid(),
160+
rule.id,
161+
periodStart,
162+
periodEnd,
163+
actual,
164+
rule.threshold,
165+
rule.metric_type,
166+
rule.agent_name,
167+
now,
168+
],
148169
);
149170
}
150171
}
@@ -160,10 +181,7 @@ export class LimitCheckService implements OnModuleInit, OnModuleDestroy {
160181
if (configEmail) return configEmail;
161182
}
162183

163-
const rows = await this.ds.query(
164-
this.sql(`SELECT email FROM "user" WHERE id = $1`),
165-
[userId],
166-
);
184+
const rows = await this.ds.query(this.sql(`SELECT email FROM "user" WHERE id = $1`), [userId]);
167185
const email = rows[0]?.email ?? null;
168186
if (email === LOCAL_EMAIL) return null;
169187
return email;
@@ -182,9 +200,11 @@ export class LimitCheckService implements OnModuleInit, OnModuleDestroy {
182200
}
183201

184202
private async getCachedConsumption(
185-
tenantId: string, agentName: string,
203+
tenantId: string,
204+
agentName: string,
186205
metricType: 'tokens' | 'cost',
187-
periodStart: string, periodEnd: string,
206+
periodStart: string,
207+
periodEnd: string,
188208
): Promise<number> {
189209
const key = `${tenantId}:${agentName}:${metricType}:${periodStart}`;
190210
const now = Date.now();
@@ -193,7 +213,11 @@ export class LimitCheckService implements OnModuleInit, OnModuleDestroy {
193213

194214
this.evictExpired(this.consumptionCache, now);
195215
const actual = await this.rulesService.getConsumption(
196-
tenantId, agentName, metricType, periodStart, periodEnd,
216+
tenantId,
217+
agentName,
218+
metricType,
219+
periodStart,
220+
periodEnd,
197221
);
198222
this.consumptionCache.set(key, { data: actual, expiresAt: now + CACHE_TTL_MS });
199223
return actual;

packages/backend/src/notifications/services/notification-cron.service.ts

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@ import { NotificationEmailService } from './notification-email.service';
77
import { EmailProviderConfigService } from './email-provider-config.service';
88
import { detectDialect, portableSql, type DbDialect } from '../../common/utils/sql-dialect';
99
import { computePeriodBoundaries } from '../../common/utils/period.util';
10-
import { LOCAL_EMAIL, readLocalNotificationEmail } from '../../common/constants/local-mode.constants';
10+
import {
11+
LOCAL_EMAIL,
12+
readLocalNotificationEmail,
13+
} from '../../common/constants/local-mode.constants';
1114

1215
interface ActiveRule {
1316
id: string;
@@ -81,7 +84,11 @@ export class NotificationCronService implements OnModuleInit {
8184
if (alreadySent.length > 0) return false;
8285

8386
const actual = await this.rulesService.getConsumption(
84-
rule.tenant_id, rule.agent_name, rule.metric_type, periodStart, periodEnd,
87+
rule.tenant_id,
88+
rule.agent_name,
89+
rule.metric_type,
90+
periodStart,
91+
periodEnd,
8592
);
8693

8794
if (actual < rule.threshold) return false;
@@ -92,6 +99,8 @@ export class NotificationCronService implements OnModuleInit {
9299
let emailSent = false;
93100
if (email) {
94101
const providerConfig = await this.emailProviderConfigService.getFullConfig(rule.user_id);
102+
const baseUrl =
103+
process.env['BETTER_AUTH_URL'] ?? `http://localhost:${process.env['PORT'] ?? '3001'}`;
95104
emailSent = await this.emailService.sendThresholdAlert(
96105
email,
97106
{
@@ -101,11 +110,14 @@ export class NotificationCronService implements OnModuleInit {
101110
actualValue: actual,
102111
period: rule.period,
103112
timestamp: now,
113+
agentUrl: `${baseUrl}/agents/${encodeURIComponent(rule.agent_name)}`,
104114
},
105115
providerConfig ?? undefined,
106116
);
107117
} else {
108-
this.logger.warn(`No email found for user ${rule.user_id}, skipping alert for rule ${rule.id}`);
118+
this.logger.warn(
119+
`No email found for user ${rule.user_id}, skipping alert for rule ${rule.id}`,
120+
);
109121
}
110122

111123
if (emailSent || !email) {
@@ -115,8 +127,17 @@ export class NotificationCronService implements OnModuleInit {
115127
(id, rule_id, period_start, period_end, actual_value, threshold_value, metric_type, agent_name, sent_at)
116128
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
117129
),
118-
[uuid(), rule.id, periodStart, periodEnd, actual, rule.threshold,
119-
rule.metric_type, rule.agent_name, now],
130+
[
131+
uuid(),
132+
rule.id,
133+
periodStart,
134+
periodEnd,
135+
actual,
136+
rule.threshold,
137+
rule.metric_type,
138+
rule.agent_name,
139+
now,
140+
],
120141
);
121142
} else {
122143
this.logger.warn(`Failed to send alert for rule ${rule.id}, will retry next cron run`);
@@ -134,10 +155,7 @@ export class NotificationCronService implements OnModuleInit {
134155
if (configEmail) return configEmail;
135156
}
136157

137-
const rows = await this.ds.query(
138-
this.sql(`SELECT email FROM "user" WHERE id = $1`),
139-
[userId],
140-
);
158+
const rows = await this.ds.query(this.sql(`SELECT email FROM "user" WHERE id = $1`), [userId]);
141159
const email = rows[0]?.email ?? null;
142160
if (email === LOCAL_EMAIL) return null;
143161
return email;

0 commit comments

Comments
 (0)