adjust email template for alerts

This commit is contained in:
miloschwartz
2026-04-21 18:19:27 -07:00
parent b3aafa5fe6
commit 6f07156075
7 changed files with 69 additions and 34 deletions

View File

@@ -8,9 +8,11 @@ import {
EmailHeading, EmailHeading,
EmailInfoSection, EmailInfoSection,
EmailLetterHead, EmailLetterHead,
EmailSection,
EmailSignature, EmailSignature,
EmailText EmailText
} from "./components/Email"; } from "./components/Email";
import ButtonLink from "./components/ButtonLink";
export type AlertEventType = export type AlertEventType =
| "site_online" | "site_online"
@@ -23,11 +25,38 @@ export type AlertEventType =
| "resource_unhealthy" | "resource_unhealthy"
| "resource_toggle"; | "resource_toggle";
interface Props { // ---------------------------------------------------------------------------
// Local preview / layout testing
// ---------------------------------------------------------------------------
//
// Set to `true` while running `npm run email` or otherwise rendering this
// template without real alert context. Uses `alertNotificationFixture` below
// and ignores props passed by callers (including real alert sends). Must be
// `false` before shipping or triggering real alert emails.
export const USE_FAKE_ALERT_NOTIFICATION_DATA = true;
export type AlertNotificationProps = {
eventType: AlertEventType; eventType: AlertEventType;
orgId: string; orgId: string;
data: Record<string, unknown>; data: Record<string, unknown>;
dashboardLink: string;
};
/** Sample props for previews; also used when `USE_FAKE_ALERT_NOTIFICATION_DATA` is true. */
export const alertNotificationFixture: AlertNotificationProps = {
eventType: "site_online",
orgId: "org_preview_7a3c2f91",
dashboardLink:
"https://app.pangolin.net/org_preview_7a3c2f91/settings/alerting/rules",
data: {
siteId: 42,
healthCheckName: "Edge API readiness probe",
targetUrl: "https://api.example.com/internal/health",
lastFailureMessage: "Connection timed out after 5000ms",
consecutiveFailures: 3
} }
};
function getEventMeta(eventType: AlertEventType): { function getEventMeta(eventType: AlertEventType): {
heading: string; heading: string;
@@ -51,7 +80,7 @@ function getEventMeta(eventType: AlertEventType): {
heading: "Site Offline", heading: "Site Offline",
previewText: "A site in your organization has gone offline.", previewText: "A site in your organization has gone offline.",
summary: summary:
"A site in your organization has gone offline and is no longer reachable. Please investigate as soon as possible.", "A site in your organization has gone offline and is no longer reachable.",
statusLabel: "Offline", statusLabel: "Offline",
statusColor: "#dc2626" statusColor: "#dc2626"
}; };
@@ -59,8 +88,7 @@ function getEventMeta(eventType: AlertEventType): {
return { return {
heading: "Site Status Changed", heading: "Site Status Changed",
previewText: "A site in your organization has changed status.", previewText: "A site in your organization has changed status.",
summary: summary: "A site in your organization has changed status.",
"A site in your organization has changed status. Please review the details below and take action if needed.",
statusLabel: "Status Changed", statusLabel: "Status Changed",
statusColor: "#f59e0b" statusColor: "#f59e0b"
}; };
@@ -80,7 +108,7 @@ function getEventMeta(eventType: AlertEventType): {
previewText: previewText:
"A health check in your organization is not healthy.", "A health check in your organization is not healthy.",
summary: summary:
"A health check in your organization is currently failing. Please review the details below and take action if needed.", "A health check in your organization is currently failing.",
statusLabel: "Not Healthy", statusLabel: "Not Healthy",
statusColor: "#dc2626" statusColor: "#dc2626"
}; };
@@ -90,7 +118,7 @@ function getEventMeta(eventType: AlertEventType): {
previewText: previewText:
"A health check in your organization has changed status.", "A health check in your organization has changed status.",
summary: summary:
"A health check in your organization has changed status. Please review the details below and take action if needed.", "A health check in your organization has changed status.",
statusLabel: "Status Changed", statusLabel: "Status Changed",
statusColor: "#f59e0b" statusColor: "#f59e0b"
}; };
@@ -108,7 +136,7 @@ function getEventMeta(eventType: AlertEventType): {
heading: "Resource Unhealthy", heading: "Resource Unhealthy",
previewText: "A resource in your organization is not healthy.", previewText: "A resource in your organization is not healthy.",
summary: summary:
"A resource in your organization is currently unhealthy. Please review the details below and take action if needed.", "A resource in your organization is currently unhealthy.",
statusLabel: "Unhealthy", statusLabel: "Unhealthy",
statusColor: "#dc2626" statusColor: "#dc2626"
}; };
@@ -117,17 +145,16 @@ function getEventMeta(eventType: AlertEventType): {
heading: "Resource Status Changed", heading: "Resource Status Changed",
previewText: previewText:
"A resource in your organization has changed status.", "A resource in your organization has changed status.",
summary: summary: "A resource in your organization has changed status.",
"A resource in your organization has changed status. Please review the details below and take action if needed.",
statusLabel: "Status Changed", statusLabel: "Status Changed",
statusColor: "#f59e0b" statusColor: "#f59e0b"
}; };
default: default:
return { return {
heading: "Alert Notification", heading: "Alert Notification",
previewText: "An alert event has occurred in your organization.", previewText:
summary: "An alert event has occurred in your organization.",
"An alert event has occurred in your organization. Please review the details below and take action if needed.", summary: "An alert event has occurred in your organization.",
statusLabel: "Alert", statusLabel: "Alert",
statusColor: "#f59e0b" statusColor: "#f59e0b"
}; };
@@ -148,17 +175,22 @@ function formatDataItems(
})); }));
} }
export const AlertNotification = ({ eventType, orgId, data }: Props) => { export const AlertNotification = (props: AlertNotificationProps) => {
const { eventType, orgId, data, dashboardLink } =
USE_FAKE_ALERT_NOTIFICATION_DATA ? alertNotificationFixture : props;
const meta = getEventMeta(eventType); const meta = getEventMeta(eventType);
const dataItems = formatDataItems(data); const dataItems = formatDataItems(data);
const allItems: { label: string; value: React.ReactNode }[] = [ const allItems: { label: string; value: React.ReactNode }[] = [
{ label: "Organization", value: orgId }, { label: "Organization", value: orgId },
{ label: "Status", value: ( {
label: "Status",
value: (
<span style={{ color: meta.statusColor, fontWeight: 600 }}> <span style={{ color: meta.statusColor, fontWeight: 600 }}>
{meta.statusLabel} {meta.statusLabel}
</span> </span>
)}, )
},
{ label: "Time", value: new Date().toUTCString() }, { label: "Time", value: new Date().toUTCString() },
...dataItems ...dataItems
]; ];
@@ -184,10 +216,16 @@ export const AlertNotification = ({ eventType, orgId, data }: Props) => {
/> />
<EmailText> <EmailText>
Log in to your dashboard to view more details and Open your dashboard to view more details and manage
manage your alert rules. your alert rules.
</EmailText> </EmailText>
<EmailSection>
<ButtonLink href={dashboardLink}>
Open Dashboard
</ButtonLink>
</EmailSection>
<EmailFooter> <EmailFooter>
<EmailSignature /> <EmailSignature />
</EmailFooter> </EmailFooter>

View File

@@ -36,8 +36,8 @@ export const NotifyTrialExpiring = ({
: `Your trial for ${orgName} ends in ${daysRemaining} days.`; : `Your trial for ${orgName} ends in ${daysRemaining} days.`;
const heading = hasEnded const heading = hasEnded
? "Your Trial Has Ended" ? "Your Trial Ended"
: "Your Trial Is Ending Soon"; : "Your Trial is Ending Soon";
return ( return (
<Html> <Html>

View File

@@ -5,7 +5,7 @@ import { build } from "@server/build";
// EmailContainer: Wraps the entire email layout // EmailContainer: Wraps the entire email layout
export function EmailContainer({ children }: { children: React.ReactNode }) { export function EmailContainer({ children }: { children: React.ReactNode }) {
return ( return (
<Container className="bg-white border border-solid border-gray-200 max-w-lg mx-auto my-8 rounded-lg overflow-hidden shadow-sm"> <Container className="bg-white border border-solid border-gray-200 max-w-lg mx-auto my-8 rounded-xl overflow-hidden">
{children} {children}
</Container> </Container>
); );
@@ -18,7 +18,7 @@ export function EmailLetterHead() {
<Img <Img
src="https://fossorial-public-assets.s3.us-east-1.amazonaws.com/word_mark_black.png" src="https://fossorial-public-assets.s3.us-east-1.amazonaws.com/word_mark_black.png"
alt="Pangolin Logo" alt="Pangolin Logo"
width="120" width="180"
height="auto" height="auto"
className="mx-auto" className="mx-auto"
/> />

View File

@@ -41,7 +41,6 @@ export async function fireHealthCheckHealthyAlert(
orgId, orgId,
healthCheckId, healthCheckId,
data: { data: {
healthCheckId,
...(healthCheckName != null ? { healthCheckName } : {}), ...(healthCheckName != null ? { healthCheckName } : {}),
...extra ...extra
} }
@@ -87,7 +86,6 @@ export async function fireHealthCheckNotHealthyAlert(
orgId, orgId,
healthCheckId, healthCheckId,
data: { data: {
healthCheckId,
...(healthCheckName != null ? { healthCheckName } : {}), ...(healthCheckName != null ? { healthCheckName } : {}),
...extra ...extra
} }

View File

@@ -41,7 +41,6 @@ export async function fireResourceHealthyAlert(
orgId, orgId,
resourceId, resourceId,
data: { data: {
resourceId,
...(resourceName != null ? { resourceName } : {}), ...(resourceName != null ? { resourceName } : {}),
...extra ...extra
} }
@@ -87,7 +86,6 @@ export async function fireResourceUnhealthyAlert(
orgId, orgId,
resourceId, resourceId,
data: { data: {
resourceId,
...(resourceName != null ? { resourceName } : {}), ...(resourceName != null ? { resourceName } : {}),
...extra ...extra
} }
@@ -133,7 +131,6 @@ export async function fireResourceToggleAlert(
orgId, orgId,
resourceId, resourceId,
data: { data: {
resourceId,
...(resourceName != null ? { resourceName } : {}), ...(resourceName != null ? { resourceName } : {}),
...extra ...extra
} }

View File

@@ -41,7 +41,6 @@ export async function fireSiteOnlineAlert(
orgId, orgId,
siteId, siteId,
data: { data: {
siteId,
...(siteName != null ? { siteName } : {}), ...(siteName != null ? { siteName } : {}),
...extra ...extra
} }
@@ -87,7 +86,6 @@ export async function fireSiteOfflineAlert(
orgId, orgId,
siteId, siteId,
data: { data: {
siteId,
...(siteName != null ? { siteName } : {}), ...(siteName != null ? { siteName } : {}),
...extra ...extra
} }

View File

@@ -36,13 +36,17 @@ export async function sendAlertEmail(
const from = config.getNoReplyEmail(); const from = config.getNoReplyEmail();
const subject = buildSubject(context); const subject = buildSubject(context);
const baseUrl = config.getRawConfig().app.dashboard_url!.replace(/\/$/, "");
const dashboardLink = `${baseUrl}/${context.orgId}/settings`;
for (const to of recipients) { for (const to of recipients) {
try { try {
await sendEmail( await sendEmail(
AlertNotification({ AlertNotification({
eventType: context.eventType, eventType: context.eventType,
orgId: context.orgId, orgId: context.orgId,
data: context.data data: context.data,
dashboardLink
}), }),
{ {
from, from,