@checkstack/backend-api 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +142 -0
- package/package.json +6 -6
- package/src/email-layout.ts +152 -76
- package/src/notification-strategy.ts +25 -0
- package/src/plugin-system.ts +19 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,147 @@
|
|
|
1
1
|
# @checkstack/backend-api
|
|
2
2
|
|
|
3
|
+
## 0.14.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 32d52c6: Bulk notifications affecting multiple systems and collapse lifecycle events into a single card.
|
|
8
|
+
|
|
9
|
+
Notifications now carry an optional `subjects` array (the entities they affect) and an optional `collapseKey` (so related notifications collapse into one row per recipient). Incidents, maintenances, anomalies, healthchecks, and dependency-impact events route through these new fields, so an incident affecting three systems produces one in-app notification + one external send per subscriber instead of three. Lifecycle updates for the same entity (created → updated → resolved) also collapse, with an expandable "+N updates" timeline.
|
|
10
|
+
|
|
11
|
+
Subject kinds are namespaced as `<pluginId>.<localKind>` and built via type-safe helpers exported from each domain's common package (`createSystemSubject`, `incidentCollapseKey`, etc.). The frontend kind registry (`registerSubjectKind`) lets plugins bind icon + label for their kinds; unknown kinds fall back to a generic chip.
|
|
12
|
+
|
|
13
|
+
All notification strategies (SMTP, Slack, Discord, Teams, Telegram, Pushover, Gotify, Webex, Backstage) render the affected subjects natively in their format (HTML cards, Slack blocks, Discord embed fields, adaptive cards, markdown lists, etc.).
|
|
14
|
+
|
|
15
|
+
- 32d52c6: feat: notification target pattern + per-spec subscriptions
|
|
16
|
+
|
|
17
|
+
Replaces the all-or-nothing catalog system/group notification model with a
|
|
18
|
+
platform-level target pattern. Each notification-emitting plugin declares
|
|
19
|
+
_subscription specs_ against typed _target_ objects exported from the
|
|
20
|
+
target's owning plugin (catalog ships `catalogSystemTarget` and
|
|
21
|
+
`catalogGroupTarget`). Notification-backend handles every per-resource
|
|
22
|
+
group lifecycle, parent-edge inheritance, and legacy-subscription seeding
|
|
23
|
+
— plugins never author groupId helpers, lifecycle hooks, or migration
|
|
24
|
+
code again.
|
|
25
|
+
|
|
26
|
+
**Plugin-author surface area is now ~12 lines per emitter:**
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
// <plugin>-common
|
|
30
|
+
const { defineSubscription } = createSubscriptionFactory(pluginMetadata);
|
|
31
|
+
export const fooSystemSubscription = defineSubscription({
|
|
32
|
+
localId: "system",
|
|
33
|
+
target: catalogSystemTarget,
|
|
34
|
+
display: { title: "Foo Alerts", description: "...", iconName: "Bell" },
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// <plugin>-backend register()
|
|
38
|
+
env.registerSubscriptionSpecs([fooSystemSubscription]);
|
|
39
|
+
// ^ feeds the plugin loader's dependency sorter — each spec's
|
|
40
|
+
// target.ownerPlugin becomes an implicit init-order dep, so this
|
|
41
|
+
// plugin automatically waits for catalog (the target owner) to
|
|
42
|
+
// finish init + afterPluginsReady before its own runs.
|
|
43
|
+
|
|
44
|
+
// <plugin>-backend afterPluginsReady
|
|
45
|
+
await notificationClient.registerSubscriptionSpec(
|
|
46
|
+
specToRegistration(fooSystemSubscription)
|
|
47
|
+
);
|
|
48
|
+
// dispatch
|
|
49
|
+
await notificationClient.notifyForSubscription({
|
|
50
|
+
specId: fooSystemSubscription.specId,
|
|
51
|
+
resourceKeys: [systemId],
|
|
52
|
+
title,
|
|
53
|
+
body,
|
|
54
|
+
importance,
|
|
55
|
+
action,
|
|
56
|
+
collapseKey,
|
|
57
|
+
subjects,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// <plugin>-frontend
|
|
61
|
+
createNotificationSubscriptionExtension({ spec: fooSystemSubscription });
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
**Migrated plugins**: anomaly, incident, maintenance, healthcheck,
|
|
65
|
+
dependency. Each lost its bespoke `notification-groups.ts`,
|
|
66
|
+
`bootstrap*NotificationGroups`, `ensure*Group`, and inheritance walk —
|
|
67
|
+
all of that is now centralized in notification-backend's
|
|
68
|
+
`subscription-engine`.
|
|
69
|
+
|
|
70
|
+
**Plugin loader change** (`@checkstack/backend-api`,
|
|
71
|
+
`@checkstack/backend`): the register-time API gains
|
|
72
|
+
`env.registerSubscriptionSpecs([...specs])`. The dependency sorter
|
|
73
|
+
walks `spec.target.ownerPlugin` for every declared spec and adds the
|
|
74
|
+
target owner as an init-order dependency of the emitting plugin. This
|
|
75
|
+
guarantees that catalog (the owner of the platform's `system` and
|
|
76
|
+
`group` targets) completes init + afterPluginsReady before any
|
|
77
|
+
emitting plugin tries to register its specs against the notification
|
|
78
|
+
service — no string-prefix heuristics, no manual `dependsOnPlugins`
|
|
79
|
+
list, no stub rows. Plugins that fail to declare their specs at
|
|
80
|
+
register time get a clear `Target type X is not registered. Did the
|
|
81
|
+
emitting plugin declare this spec via env.registerSubscriptionSpecs?`
|
|
82
|
+
error from the dispatcher.
|
|
83
|
+
|
|
84
|
+
**Removed** (no backwards compat):
|
|
85
|
+
|
|
86
|
+
- `catalogClient.notifySystemSubscribers` and
|
|
87
|
+
`catalogClient.notifyManySystemSubscribers`
|
|
88
|
+
- `notificationClient.notifyUsers` and `notificationClient.notifyGroups`
|
|
89
|
+
as direct dispatch primitives — replaced by spec-bound
|
|
90
|
+
`notifyForSubscription`
|
|
91
|
+
- catalog's `bootstrapNotificationGroups` (replaced by
|
|
92
|
+
`bootstrapNotificationTargets`)
|
|
93
|
+
|
|
94
|
+
**Enforcement**: the dispatcher rejects calls referencing unregistered
|
|
95
|
+
specIds, specs owned by other plugins, or resourceKeys that haven't been
|
|
96
|
+
pushed via `upsertNotificationResource`. Display metadata for any
|
|
97
|
+
groupId is recoverable via the spec registry, so audit lists render
|
|
98
|
+
correct labels even when an emitter's frontend isn't loaded.
|
|
99
|
+
|
|
100
|
+
**Per-field anomaly mute** keeps working — it now lives inside the
|
|
101
|
+
generic SubscriptionRow's optional `SubControls` panel
|
|
102
|
+
(`AnomalyFieldMuteList`), exposed through the catalog system detail
|
|
103
|
+
page's notifications card.
|
|
104
|
+
|
|
105
|
+
The catalog system detail page renders a "Notifications" card hosting
|
|
106
|
+
`SystemNotificationSubscriptionsSlot`. The matching group surface is
|
|
107
|
+
not yet rendered — group-level subscriptions are wired end-to-end on
|
|
108
|
+
the backend; a follow-up will add the host UI.
|
|
109
|
+
|
|
110
|
+
**Migration of existing subscribers**: target types declare a
|
|
111
|
+
`legacyGroupIdTemplate`; on first registration of each spec,
|
|
112
|
+
notification-backend reads subscribers from the legacy
|
|
113
|
+
`catalog.system.<id>` / `catalog.group.<id>` groups and seeds the new
|
|
114
|
+
spec groups exactly once per (spec × resource) pair, tracked in
|
|
115
|
+
`subscription_migrations`. Anomaly stays opt-in (its target also
|
|
116
|
+
declares the template, but the user-explicit nature of the original
|
|
117
|
+
opt-in flow means the seeding produces the same set of subscribers
|
|
118
|
+
they already had).
|
|
119
|
+
|
|
120
|
+
### Patch Changes
|
|
121
|
+
|
|
122
|
+
- 32d52c6: Fix and improve password reset flow + email branding:
|
|
123
|
+
|
|
124
|
+
- **Fix**: password reset emails were failing with "Malformed password reset URL: missing token parameter". Better-auth puts the reset token in the URL path (`/reset-password/{token}`), not as a `?token=` query param, so the previous URL-parsing logic always failed. Now uses the `token` argument better-auth passes to `sendResetPassword` directly.
|
|
125
|
+
- **UX**: the reset password page now validates the token on load via a new anonymous `validateResetToken` endpoint, so users see "Invalid Link" / "Link Expired" before typing a password rather than after submitting. Tokens are 24-char nanoid-style values (~143 bits of entropy), so exposing validity does not enable enumeration.
|
|
126
|
+
- **Fix**: transactional notifications were hardcoded to `importance: "critical"`, causing password reset emails to display a misleading "CRITICAL" badge. The `sendTransactional` contract now accepts an optional `importance` field that defaults to `"info"`.
|
|
127
|
+
- **Branding**: redesigned the email layout (`wrapInEmailLayout`) with a Checkstack-style engineering aesthetic — dark header with grid pattern, monospace importance badge, hardened CTA button (Outlook VML fallback + explicit text color), and force-light color scheme to prevent client auto-inversion from breaking text legibility.
|
|
128
|
+
|
|
129
|
+
- Updated dependencies [32d52c6]
|
|
130
|
+
- Updated dependencies [32d52c6]
|
|
131
|
+
- @checkstack/healthcheck-common@1.0.0
|
|
132
|
+
- @checkstack/cache-api@0.2.2
|
|
133
|
+
- @checkstack/queue-api@0.2.16
|
|
134
|
+
|
|
135
|
+
## 0.13.1
|
|
136
|
+
|
|
137
|
+
### Patch Changes
|
|
138
|
+
|
|
139
|
+
- Updated dependencies [208ad71]
|
|
140
|
+
- @checkstack/signal-common@0.2.0
|
|
141
|
+
- @checkstack/healthcheck-common@0.13.0
|
|
142
|
+
- @checkstack/cache-api@0.2.1
|
|
143
|
+
- @checkstack/queue-api@0.2.15
|
|
144
|
+
|
|
3
145
|
## 0.13.0
|
|
4
146
|
|
|
5
147
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/backend-api",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"scripts": {
|
|
@@ -9,11 +9,11 @@
|
|
|
9
9
|
"lint:code": "eslint . --max-warnings 0"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@checkstack/common": "0.
|
|
13
|
-
"@checkstack/healthcheck-common": "0.
|
|
14
|
-
"@checkstack/cache-api": "0.1
|
|
15
|
-
"@checkstack/queue-api": "0.2.
|
|
16
|
-
"@checkstack/signal-common": "0.
|
|
12
|
+
"@checkstack/common": "0.7.0",
|
|
13
|
+
"@checkstack/healthcheck-common": "0.13.0",
|
|
14
|
+
"@checkstack/cache-api": "0.2.1",
|
|
15
|
+
"@checkstack/queue-api": "0.2.15",
|
|
16
|
+
"@checkstack/signal-common": "0.2.0",
|
|
17
17
|
"@orpc/client": "^1.13.14",
|
|
18
18
|
"@orpc/contract": "^1.13.14",
|
|
19
19
|
"@orpc/openapi": "^1.13.2",
|
package/src/email-layout.ts
CHANGED
|
@@ -7,6 +7,18 @@
|
|
|
7
7
|
* @module
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* A notification subject as rendered by the email layout. Mirrors
|
|
12
|
+
* `NotificationSubject` from `@checkstack/notification-common`.
|
|
13
|
+
*/
|
|
14
|
+
export interface EmailLayoutSubject {
|
|
15
|
+
kind: string;
|
|
16
|
+
id: string;
|
|
17
|
+
name: string;
|
|
18
|
+
url?: string;
|
|
19
|
+
status?: "healthy" | "unhealthy" | "degraded" | "unknown";
|
|
20
|
+
}
|
|
21
|
+
|
|
10
22
|
/**
|
|
11
23
|
* Options for the email layout wrapper.
|
|
12
24
|
*/
|
|
@@ -15,20 +27,26 @@ export interface EmailLayoutOptions {
|
|
|
15
27
|
title: string;
|
|
16
28
|
/** Already-converted HTML body content */
|
|
17
29
|
bodyHtml: string;
|
|
18
|
-
/** Importance level affects
|
|
30
|
+
/** Importance level affects the category badge color */
|
|
19
31
|
importance: "info" | "warning" | "critical";
|
|
20
32
|
/** Optional call-to-action button */
|
|
21
33
|
action?: {
|
|
22
34
|
label: string;
|
|
23
35
|
url: string;
|
|
24
36
|
};
|
|
37
|
+
/**
|
|
38
|
+
* Affected entities. Rendered as a card under the body, with each subject
|
|
39
|
+
* as a clickable row when `url` is present. When `action` is omitted, the
|
|
40
|
+
* subjects are the recipient's only navigation.
|
|
41
|
+
*/
|
|
42
|
+
subjects?: EmailLayoutSubject[];
|
|
25
43
|
|
|
26
44
|
// Admin-customizable options (via layoutConfig)
|
|
27
|
-
/** Logo URL (max ~200px wide recommended) */
|
|
45
|
+
/** Logo URL (max ~200px wide recommended). Rendered in the dark header. */
|
|
28
46
|
logoUrl?: string;
|
|
29
|
-
/** Primary brand color (hex, e.g., "#
|
|
47
|
+
/** Primary brand color (hex, e.g., "#7c3aed"). Used for the CTA button. */
|
|
30
48
|
primaryColor?: string;
|
|
31
|
-
/** Accent color for
|
|
49
|
+
/** Accent color (overrides primary for the CTA button if provided). */
|
|
32
50
|
accentColor?: string;
|
|
33
51
|
/** Footer text */
|
|
34
52
|
footerText?: string;
|
|
@@ -36,15 +54,34 @@ export interface EmailLayoutOptions {
|
|
|
36
54
|
footerLinks?: Array<{ label: string; url: string }>;
|
|
37
55
|
}
|
|
38
56
|
|
|
39
|
-
//
|
|
40
|
-
const
|
|
41
|
-
info: "#
|
|
42
|
-
warning: "#
|
|
43
|
-
critical: "#
|
|
57
|
+
// Importance-based badge colors (rendered as monospace pills in the header)
|
|
58
|
+
const IMPORTANCE_BADGES = {
|
|
59
|
+
info: { label: "INFO", bg: "#1e293b", fg: "#93c5fd" },
|
|
60
|
+
warning: { label: "WARNING", bg: "#3a2a0e", fg: "#fbbf24" },
|
|
61
|
+
critical: { label: "CRITICAL", bg: "#3a0e14", fg: "#fca5a5" },
|
|
44
62
|
} as const;
|
|
45
63
|
|
|
64
|
+
// Subject status dot colors. Status is optional on subjects; when absent,
|
|
65
|
+
// no dot is rendered.
|
|
66
|
+
const SUBJECT_STATUS_COLORS: Record<
|
|
67
|
+
NonNullable<EmailLayoutSubject["status"]>,
|
|
68
|
+
string
|
|
69
|
+
> = {
|
|
70
|
+
healthy: "#22c55e",
|
|
71
|
+
degraded: "#f59e0b",
|
|
72
|
+
unhealthy: "#ef4444",
|
|
73
|
+
unknown: "#a1a1aa",
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// Checkstack brand defaults — purple primary on near-black surfaces.
|
|
77
|
+
const BRAND_PRIMARY = "#8b5cf6"; // matches --primary in dark theme
|
|
78
|
+
const HEADER_BG = "#0a0a0c"; // matches --background in dark theme
|
|
79
|
+
const HEADER_BORDER = "#27272a"; // matches --border in dark theme
|
|
80
|
+
const HEADER_FG = "#fafafa";
|
|
81
|
+
const HEADER_MUTED = "#a1a1aa";
|
|
82
|
+
|
|
46
83
|
/**
|
|
47
|
-
*
|
|
84
|
+
* HTML escaping for security.
|
|
48
85
|
*/
|
|
49
86
|
function escapeHtml(text: string): string {
|
|
50
87
|
return text
|
|
@@ -56,20 +93,29 @@ function escapeHtml(text: string): string {
|
|
|
56
93
|
}
|
|
57
94
|
|
|
58
95
|
/**
|
|
59
|
-
*
|
|
96
|
+
* Subtle grid pattern (matches the `ambient-grid` aesthetic in the app UI).
|
|
97
|
+
* Embedded as an inline SVG data URI — supported in Apple Mail and most modern clients.
|
|
98
|
+
* Email clients that strip backgrounds (Gmail web) gracefully fall back to the solid header color.
|
|
99
|
+
*/
|
|
100
|
+
const GRID_PATTERN_SVG =
|
|
101
|
+
"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0OCIgaGVpZ2h0PSI0OCI+PHBhdGggZD0iTSA0OCAwIEwgMCAwIDAgNDgiIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzNmM2Y0NiIgc3Ryb2tlLXdpZHRoPSIxIiBvcGFjaXR5PSIwLjQiLz48L3N2Zz4=";
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Wrap HTML content in a Checkstack-branded responsive email template.
|
|
60
105
|
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
106
|
+
* The design pairs a dark "engineering" header (with optional grid pattern
|
|
107
|
+
* and monospace importance badge) with a clean light body for readability,
|
|
108
|
+
* and a purple-accented CTA button.
|
|
63
109
|
*
|
|
64
110
|
* @example
|
|
65
111
|
* ```typescript
|
|
66
112
|
* const html = wrapInEmailLayout({
|
|
67
113
|
* title: "Password Reset",
|
|
68
114
|
* bodyHtml: "<p>Click the button below to reset your password.</p>",
|
|
69
|
-
* importance: "
|
|
115
|
+
* importance: "info",
|
|
70
116
|
* action: { label: "Reset Password", url: "https://..." },
|
|
71
|
-
* primaryColor: "#
|
|
72
|
-
* footerText: "Sent by
|
|
117
|
+
* primaryColor: "#7c3aed",
|
|
118
|
+
* footerText: "Sent by Checkstack",
|
|
73
119
|
* });
|
|
74
120
|
* ```
|
|
75
121
|
*/
|
|
@@ -79,27 +125,49 @@ export function wrapInEmailLayout(options: EmailLayoutOptions): string {
|
|
|
79
125
|
bodyHtml,
|
|
80
126
|
importance,
|
|
81
127
|
action,
|
|
128
|
+
subjects,
|
|
82
129
|
logoUrl,
|
|
83
130
|
primaryColor,
|
|
84
|
-
footerText = "This is an automated notification.",
|
|
131
|
+
footerText = "This is an automated notification from Checkstack.",
|
|
85
132
|
footerLinks = [],
|
|
86
133
|
} = options;
|
|
87
134
|
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
|
|
135
|
+
const buttonColor = options.accentColor ?? primaryColor ?? BRAND_PRIMARY;
|
|
136
|
+
const badge = IMPORTANCE_BADGES[importance];
|
|
137
|
+
|
|
138
|
+
const subjectsHtml =
|
|
139
|
+
subjects && subjects.length > 0
|
|
140
|
+
? `
|
|
141
|
+
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="margin-top: 20px; background-color: #fafafa; border: 1px solid #e4e4e7; border-radius: 8px;">
|
|
142
|
+
<tr>
|
|
143
|
+
<td style="padding: 12px 16px;">
|
|
144
|
+
<p class="mono" style="margin: 0 0 8px 0; color: #71717a; font-size: 10px; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase;">
|
|
145
|
+
Affected
|
|
146
|
+
</p>
|
|
147
|
+
${subjects
|
|
148
|
+
.map((subject) => {
|
|
149
|
+
const statusDot = subject.status
|
|
150
|
+
? `<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background-color:${SUBJECT_STATUS_COLORS[subject.status]};margin-right:8px;vertical-align:middle;"></span>`
|
|
151
|
+
: "";
|
|
152
|
+
const namePart = `<span style="color:#18181b;font-size:14px;font-weight:500;vertical-align:middle;">${escapeHtml(subject.name)}</span>`;
|
|
153
|
+
const inner = subject.url
|
|
154
|
+
? `<a href="${escapeHtml(subject.url)}" style="color:#18181b;text-decoration:none;">${statusDot}${namePart}</a>`
|
|
155
|
+
: `${statusDot}${namePart}`;
|
|
156
|
+
return `<div style="padding:6px 0;border-bottom:1px solid #e4e4e7;">${inner}</div>`;
|
|
157
|
+
})
|
|
158
|
+
.join("")}
|
|
159
|
+
</td>
|
|
160
|
+
</tr>
|
|
161
|
+
</table>
|
|
162
|
+
`
|
|
163
|
+
: "";
|
|
91
164
|
|
|
92
|
-
// Build footer links HTML
|
|
93
165
|
const footerLinksHtml =
|
|
94
166
|
footerLinks.length > 0
|
|
95
167
|
? footerLinks
|
|
96
168
|
.map(
|
|
97
169
|
(link) =>
|
|
98
|
-
`<a href="${escapeHtml(
|
|
99
|
-
link.url
|
|
100
|
-
)}" style="color: #6b7280; text-decoration: underline;">${escapeHtml(
|
|
101
|
-
link.label
|
|
102
|
-
)}</a>`
|
|
170
|
+
`<a href="${escapeHtml(link.url)}" style="color: #71717a; text-decoration: underline;">${escapeHtml(link.label)}</a>`,
|
|
103
171
|
)
|
|
104
172
|
.join(" · ")
|
|
105
173
|
: "";
|
|
@@ -110,7 +178,8 @@ export function wrapInEmailLayout(options: EmailLayoutOptions): string {
|
|
|
110
178
|
<head>
|
|
111
179
|
<meta charset="utf-8">
|
|
112
180
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
113
|
-
|
|
181
|
+
<!-- Force light scheme so clients don't auto-invert our intentionally dark header -->
|
|
182
|
+
<meta name="color-scheme" content="only light">
|
|
114
183
|
<meta name="supported-color-schemes" content="light">
|
|
115
184
|
<title>${escapeHtml(title)}</title>
|
|
116
185
|
<!--[if mso]>
|
|
@@ -123,7 +192,6 @@ export function wrapInEmailLayout(options: EmailLayoutOptions): string {
|
|
|
123
192
|
</noscript>
|
|
124
193
|
<![endif]-->
|
|
125
194
|
<style>
|
|
126
|
-
/* Reset styles */
|
|
127
195
|
body, table, td, p, a, li, blockquote {
|
|
128
196
|
-webkit-text-size-adjust: 100%;
|
|
129
197
|
-ms-text-size-adjust: 100%;
|
|
@@ -144,76 +212,84 @@ export function wrapInEmailLayout(options: EmailLayoutOptions): string {
|
|
|
144
212
|
margin: 0 !important;
|
|
145
213
|
padding: 0 !important;
|
|
146
214
|
width: 100% !important;
|
|
147
|
-
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
215
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
148
216
|
background-color: #f4f4f5;
|
|
149
217
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
color: ${buttonColor};
|
|
153
|
-
}
|
|
154
|
-
/* Button styling */
|
|
155
|
-
.button {
|
|
156
|
-
display: inline-block;
|
|
157
|
-
padding: 12px 24px;
|
|
158
|
-
background-color: ${buttonColor};
|
|
159
|
-
color: #ffffff !important;
|
|
160
|
-
text-decoration: none;
|
|
161
|
-
border-radius: 6px;
|
|
162
|
-
font-weight: 600;
|
|
163
|
-
font-size: 14px;
|
|
164
|
-
line-height: 1.5;
|
|
218
|
+
.mono {
|
|
219
|
+
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace;
|
|
165
220
|
}
|
|
221
|
+
a { color: ${buttonColor}; }
|
|
222
|
+
/* Force-light-mode hardening: prevent Outlook/Apple Mail dark-mode auto-inversion. */
|
|
223
|
+
[data-ogsc] .email-body, [data-ogsb] .email-body { color: #27272a !important; background-color: #ffffff !important; }
|
|
224
|
+
[data-ogsc] .email-card, [data-ogsb] .email-card { background-color: #ffffff !important; }
|
|
225
|
+
[data-ogsc] .email-footer, [data-ogsb] .email-footer { background-color: #fafafa !important; }
|
|
226
|
+
[data-ogsc] .button-label, [data-ogsb] .button-label { color: #ffffff !important; }
|
|
166
227
|
</style>
|
|
167
228
|
</head>
|
|
168
229
|
<body style="margin: 0; padding: 0; background-color: #f4f4f5;">
|
|
230
|
+
<span style="display:none !important; visibility:hidden; mso-hide:all; font-size:1px; color:#f4f4f5; line-height:1px; max-height:0; max-width:0; opacity:0; overflow:hidden;">${escapeHtml(title)}</span>
|
|
169
231
|
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="background-color: #f4f4f5;">
|
|
170
232
|
<tr>
|
|
171
|
-
<td align="center" style="padding:
|
|
233
|
+
<td align="center" style="padding: 32px 16px;">
|
|
172
234
|
<!-- Main container -->
|
|
173
|
-
<table role="presentation" cellpadding="0" cellspacing="0" width="600" style="max-width: 600px; background-color: #ffffff; border-radius:
|
|
174
|
-
|
|
175
|
-
logoUrl
|
|
176
|
-
? `
|
|
177
|
-
<!-- Logo -->
|
|
178
|
-
<tr>
|
|
179
|
-
<td align="center" style="padding: 24px 24px 0 24px;">
|
|
180
|
-
<img src="${escapeHtml(
|
|
181
|
-
logoUrl
|
|
182
|
-
)}" alt="Logo" style="max-width: 200px; height: auto;">
|
|
183
|
-
</td>
|
|
184
|
-
</tr>
|
|
185
|
-
`
|
|
186
|
-
: ""
|
|
187
|
-
}
|
|
188
|
-
<!-- Header -->
|
|
235
|
+
<table role="presentation" cellpadding="0" cellspacing="0" width="600" class="email-card" style="max-width: 600px; background-color: #ffffff; border-radius: 12px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.06), 0 8px 24px rgba(0,0,0,0.04);">
|
|
236
|
+
<!-- Header (dark, engineering aesthetic) -->
|
|
189
237
|
<tr>
|
|
190
|
-
<td style="background-color: ${
|
|
191
|
-
<
|
|
192
|
-
|
|
193
|
-
|
|
238
|
+
<td style="background-color: ${HEADER_BG}; background-image: url('${GRID_PATTERN_SVG}'); background-repeat: repeat; background-size: 48px 48px; padding: 28px 28px 24px 28px; border-bottom: 1px solid ${HEADER_BORDER};">
|
|
239
|
+
<table role="presentation" cellpadding="0" cellspacing="0" width="100%">
|
|
240
|
+
<tr>
|
|
241
|
+
<td>
|
|
242
|
+
${
|
|
243
|
+
logoUrl
|
|
244
|
+
? `<img src="${escapeHtml(logoUrl)}" alt="Logo" style="max-width: 160px; height: auto; display: block; margin-bottom: 14px;">`
|
|
245
|
+
: `<div class="mono" style="color: ${HEADER_MUTED}; font-size: 11px; font-weight: 600; letter-spacing: 0.18em; text-transform: uppercase; margin-bottom: 14px;">— checkstack</div>`
|
|
246
|
+
}
|
|
247
|
+
<table role="presentation" cellpadding="0" cellspacing="0" style="margin-bottom: 10px;">
|
|
248
|
+
<tr>
|
|
249
|
+
<td class="mono" style="background-color: ${badge.bg}; color: ${badge.fg}; padding: 3px 8px; border-radius: 4px; font-size: 10px; font-weight: 700; letter-spacing: 0.12em;">
|
|
250
|
+
${badge.label}
|
|
251
|
+
</td>
|
|
252
|
+
</tr>
|
|
253
|
+
</table>
|
|
254
|
+
<h1 style="margin: 0; color: ${HEADER_FG}; font-size: 22px; font-weight: 600; line-height: 1.35; letter-spacing: -0.01em;">
|
|
255
|
+
${escapeHtml(title)}
|
|
256
|
+
</h1>
|
|
257
|
+
</td>
|
|
258
|
+
</tr>
|
|
259
|
+
</table>
|
|
194
260
|
</td>
|
|
195
261
|
</tr>
|
|
196
262
|
<!-- Body -->
|
|
197
263
|
<tr>
|
|
198
|
-
<td style="padding: 24px;">
|
|
199
|
-
<div style="color: #
|
|
264
|
+
<td class="email-body" bgcolor="#ffffff" style="padding: 28px 28px 24px 28px; background-color: #ffffff; color: #18181b;">
|
|
265
|
+
<div style="color: #18181b; font-size: 15px; line-height: 1.65;">
|
|
200
266
|
${bodyHtml}
|
|
201
267
|
</div>
|
|
268
|
+
${subjectsHtml}
|
|
202
269
|
${
|
|
203
270
|
action
|
|
204
271
|
? `
|
|
205
|
-
<!-- CTA
|
|
272
|
+
<!-- CTA button: bulletproof Outlook VML fallback + standard anchor for everyone else -->
|
|
206
273
|
<table role="presentation" cellpadding="0" cellspacing="0" style="margin-top: 24px;">
|
|
207
274
|
<tr>
|
|
208
|
-
<td>
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
275
|
+
<td bgcolor="${buttonColor}" style="background-color: ${buttonColor}; border-radius: 6px;">
|
|
276
|
+
<!--[if mso]>
|
|
277
|
+
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="${escapeHtml(action.url)}" style="height:44px;v-text-anchor:middle;width:220px;" arcsize="14%" stroke="f" fillcolor="${buttonColor}">
|
|
278
|
+
<w:anchorlock/>
|
|
279
|
+
<center style="color:#ffffff;font-family:'Segoe UI',Arial,sans-serif;font-size:14px;font-weight:600;">${escapeHtml(action.label)}</center>
|
|
280
|
+
</v:roundrect>
|
|
281
|
+
<![endif]-->
|
|
282
|
+
<!--[if !mso]><!-- -->
|
|
283
|
+
<a href="${escapeHtml(action.url)}" class="button" style="display: inline-block; padding: 12px 22px; background-color: ${buttonColor}; border-radius: 6px; text-decoration: none; mso-hide: all;">
|
|
284
|
+
<span class="button-label" style="color: #ffffff; font-family: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', Roboto, Arial, sans-serif; font-size: 14px; font-weight: 600; line-height: 1.5; letter-spacing: 0.01em;">${escapeHtml(action.label)}</span>
|
|
213
285
|
</a>
|
|
286
|
+
<!--<![endif]-->
|
|
214
287
|
</td>
|
|
215
288
|
</tr>
|
|
216
289
|
</table>
|
|
290
|
+
<p class="mono" style="margin: 16px 0 0 0; color: #a1a1aa; font-size: 11px; line-height: 1.5; word-break: break-all;">
|
|
291
|
+
${escapeHtml(action.url)}
|
|
292
|
+
</p>
|
|
217
293
|
`
|
|
218
294
|
: ""
|
|
219
295
|
}
|
|
@@ -221,14 +297,14 @@ export function wrapInEmailLayout(options: EmailLayoutOptions): string {
|
|
|
221
297
|
</tr>
|
|
222
298
|
<!-- Footer -->
|
|
223
299
|
<tr>
|
|
224
|
-
<td style="background-color: #
|
|
225
|
-
<p style="margin: 0; color: #
|
|
300
|
+
<td class="email-footer" style="background-color: #fafafa; padding: 16px 28px; border-top: 1px solid #e4e4e7;">
|
|
301
|
+
<p class="mono" style="margin: 0; color: #71717a; font-size: 11px; line-height: 1.5; text-align: center; letter-spacing: 0.02em;">
|
|
226
302
|
${escapeHtml(footerText)}
|
|
227
303
|
</p>
|
|
228
304
|
${
|
|
229
305
|
footerLinksHtml
|
|
230
306
|
? `
|
|
231
|
-
<p style="margin: 8px 0 0 0; color: #
|
|
307
|
+
<p style="margin: 8px 0 0 0; color: #71717a; font-size: 11px; line-height: 1.5; text-align: center;">
|
|
232
308
|
${footerLinksHtml}
|
|
233
309
|
</p>
|
|
234
310
|
`
|
|
@@ -19,6 +19,22 @@ export type NotificationContactResolution =
|
|
|
19
19
|
// Payload and Result Types
|
|
20
20
|
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* An entity affected by a notification, surfaced to the recipient as a
|
|
24
|
+
* chip/link/native element. Mirrors `NotificationSubject` from
|
|
25
|
+
* `@checkstack/notification-common` — duplicated here to avoid a runtime
|
|
26
|
+
* dependency from backend-api on notification-common.
|
|
27
|
+
*
|
|
28
|
+
* `kind` is namespaced as `<pluginId>.<localKind>` (e.g., `catalog.system`).
|
|
29
|
+
*/
|
|
30
|
+
export interface NotificationSubject {
|
|
31
|
+
kind: string;
|
|
32
|
+
id: string;
|
|
33
|
+
name: string;
|
|
34
|
+
url?: string;
|
|
35
|
+
status?: "healthy" | "unhealthy" | "degraded" | "unknown";
|
|
36
|
+
}
|
|
37
|
+
|
|
22
38
|
/**
|
|
23
39
|
* The notification content to send via external channel.
|
|
24
40
|
*/
|
|
@@ -41,6 +57,15 @@ export interface NotificationPayload {
|
|
|
41
57
|
label: string;
|
|
42
58
|
url: string;
|
|
43
59
|
};
|
|
60
|
+
/**
|
|
61
|
+
* Affected entities. Each strategy renders these in its native format
|
|
62
|
+
* (Slack section, Discord embed field, SMTP card, etc.). For text-only
|
|
63
|
+
* channels, render as a bulleted list with optional URLs.
|
|
64
|
+
*
|
|
65
|
+
* When `action` is null and `subjects` are present, the subject URLs are
|
|
66
|
+
* the recipient's only navigation paths.
|
|
67
|
+
*/
|
|
68
|
+
subjects?: NotificationSubject[];
|
|
44
69
|
/**
|
|
45
70
|
* Source type identifier for filtering and templates.
|
|
46
71
|
* Examples: "password-reset", "healthcheck.alert", "maintenance.reminder"
|
package/src/plugin-system.ts
CHANGED
|
@@ -99,6 +99,25 @@ export type BackendPluginRegistry = {
|
|
|
99
99
|
* Multiple cleanup handlers can be registered; they run in LIFO order.
|
|
100
100
|
*/
|
|
101
101
|
registerCleanup: (cleanup: () => Promise<void>) => void;
|
|
102
|
+
/**
|
|
103
|
+
* Declare notification subscription specs this plugin owns. Called
|
|
104
|
+
* synchronously during `register()` so the plugin loader can derive
|
|
105
|
+
* init-order dependencies from each spec's target: an emitter
|
|
106
|
+
* targeting `catalogSystemTarget` automatically waits for catalog's
|
|
107
|
+
* init + afterPluginsReady before its own. Plugins still call the
|
|
108
|
+
* notification-backend RPC `registerSubscriptionSpec` from
|
|
109
|
+
* afterPluginsReady — this declaration is purely for ordering.
|
|
110
|
+
*
|
|
111
|
+
* Each entry must carry an `ownerPlugin` that matches the calling
|
|
112
|
+
* plugin id, and a `target.ownerPlugin` describing the plugin that
|
|
113
|
+
* owns the target type the spec is bound to.
|
|
114
|
+
*/
|
|
115
|
+
registerSubscriptionSpecs: (
|
|
116
|
+
specs: ReadonlyArray<{
|
|
117
|
+
readonly ownerPlugin: string;
|
|
118
|
+
readonly target: { readonly ownerPlugin: string };
|
|
119
|
+
}>,
|
|
120
|
+
) => void;
|
|
102
121
|
pluginManager: {
|
|
103
122
|
getAllAccessRules: () => { id: string; description?: string }[];
|
|
104
123
|
};
|