@checkstack/backend-api 0.0.2
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 +228 -0
- package/package.json +33 -0
- package/src/assertions.test.ts +345 -0
- package/src/assertions.ts +371 -0
- package/src/auth-strategy.ts +58 -0
- package/src/chart-metadata.ts +77 -0
- package/src/config-service.ts +71 -0
- package/src/config-versioning.ts +310 -0
- package/src/contract.ts +8 -0
- package/src/core-services.ts +45 -0
- package/src/email-layout.ts +246 -0
- package/src/encryption.ts +95 -0
- package/src/event-bus-types.ts +28 -0
- package/src/extension-point.ts +11 -0
- package/src/health-check.ts +68 -0
- package/src/hooks.ts +182 -0
- package/src/index.ts +23 -0
- package/src/markdown.test.ts +106 -0
- package/src/markdown.ts +104 -0
- package/src/notification-strategy.ts +436 -0
- package/src/oauth-handler.ts +442 -0
- package/src/plugin-admin-contract.ts +64 -0
- package/src/plugin-system.ts +103 -0
- package/src/rpc.ts +284 -0
- package/src/schema-utils.ts +79 -0
- package/src/service-ref.ts +15 -0
- package/src/test-utils.ts +65 -0
- package/src/types.ts +111 -0
- package/src/zod-config.ts +149 -0
- package/tsconfig.json +6 -0
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
4
|
+
// Storage Interfaces (simple data shapes for DB/API)
|
|
5
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Base interface for versioned data stored in database.
|
|
9
|
+
* Simple JSON-serializable shape.
|
|
10
|
+
*/
|
|
11
|
+
export interface VersionedRecord<T = unknown> {
|
|
12
|
+
/** Schema version of this record */
|
|
13
|
+
version: number;
|
|
14
|
+
/** The actual data payload */
|
|
15
|
+
data: T;
|
|
16
|
+
/** When the last migration was applied (if any) */
|
|
17
|
+
migratedAt?: Date;
|
|
18
|
+
/** Original version before any migrations were applied */
|
|
19
|
+
originalVersion?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Versioned record with plugin context.
|
|
24
|
+
* Used for plugin-wide configuration storage.
|
|
25
|
+
*/
|
|
26
|
+
export interface VersionedPluginRecord<T = unknown> extends VersionedRecord<T> {
|
|
27
|
+
/** Plugin ID that owns this configuration */
|
|
28
|
+
pluginId: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
32
|
+
// Migration Types
|
|
33
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Type-safe migration from one data version to another.
|
|
37
|
+
* Used for backward-compatible schema evolution.
|
|
38
|
+
*/
|
|
39
|
+
export interface Migration<TFrom = unknown, TTo = unknown> {
|
|
40
|
+
/** Version number migrating from */
|
|
41
|
+
fromVersion: number;
|
|
42
|
+
/** Version number migrating to (must be fromVersion + 1) */
|
|
43
|
+
toVersion: number;
|
|
44
|
+
/** Human-readable description of what this migration does */
|
|
45
|
+
description: string;
|
|
46
|
+
/** Migration function that transforms old data to new format */
|
|
47
|
+
migrate(data: TFrom): TTo | Promise<TTo>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
51
|
+
// Migration Builder
|
|
52
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Builder for creating type-safe migration chains.
|
|
56
|
+
* Provides better type inference for each migration step.
|
|
57
|
+
*/
|
|
58
|
+
export class MigrationBuilder<TCurrent> {
|
|
59
|
+
private migrations: Migration<unknown, unknown>[] = [];
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Add a migration to the chain.
|
|
63
|
+
* Returns a new builder with updated type for the next migration.
|
|
64
|
+
*/
|
|
65
|
+
addMigration<TNext>(
|
|
66
|
+
migration: Migration<TCurrent, TNext>
|
|
67
|
+
): MigrationBuilder<TNext> {
|
|
68
|
+
this.migrations.push(migration);
|
|
69
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
70
|
+
return this as any as MigrationBuilder<TNext>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Build the final migration chain.
|
|
75
|
+
*/
|
|
76
|
+
build(): Migration<unknown, unknown>[] {
|
|
77
|
+
return this.migrations;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
82
|
+
// Parse Result Types
|
|
83
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
84
|
+
|
|
85
|
+
export type ParseSuccess<T> = { success: true; data: T };
|
|
86
|
+
export type ParseError = { success: false; error: Error };
|
|
87
|
+
export type ParseResult<T> = ParseSuccess<T> | ParseError;
|
|
88
|
+
|
|
89
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
90
|
+
// Versioned<T> Class
|
|
91
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Options for creating a Versioned instance.
|
|
95
|
+
*/
|
|
96
|
+
export interface VersionedOptions<T> {
|
|
97
|
+
/** Current schema version */
|
|
98
|
+
version: number;
|
|
99
|
+
/** Zod schema for validation */
|
|
100
|
+
schema: z.ZodType<T>;
|
|
101
|
+
/** Optional migrations for backward compatibility */
|
|
102
|
+
migrations?: Migration<unknown, unknown>[];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Unified versioned schema handler.
|
|
107
|
+
* Combines type definition, validation, and migration in one API.
|
|
108
|
+
*
|
|
109
|
+
* @example
|
|
110
|
+
* ```typescript
|
|
111
|
+
* const configType = new Versioned({
|
|
112
|
+
* version: 2,
|
|
113
|
+
* schema: configSchemaV2,
|
|
114
|
+
* migrations: [v1ToV2Migration],
|
|
115
|
+
* });
|
|
116
|
+
*
|
|
117
|
+
* // Parse stored data (auto-migrates and validates)
|
|
118
|
+
* const config = await configType.parse(storedRecord);
|
|
119
|
+
*
|
|
120
|
+
* // Create new versioned data
|
|
121
|
+
* const record = configType.create({ url: "...", method: "GET" });
|
|
122
|
+
* ```
|
|
123
|
+
*/
|
|
124
|
+
export class Versioned<T> {
|
|
125
|
+
readonly version: number;
|
|
126
|
+
readonly schema: z.ZodType<T>;
|
|
127
|
+
private readonly _migrations: Migration<unknown, unknown>[];
|
|
128
|
+
|
|
129
|
+
constructor(options: VersionedOptions<T>) {
|
|
130
|
+
this.version = options.version;
|
|
131
|
+
this.schema = options.schema;
|
|
132
|
+
this._migrations = options.migrations ?? [];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Get the migrations chain for this versioned schema.
|
|
137
|
+
* Useful for ConfigService integration.
|
|
138
|
+
*/
|
|
139
|
+
get migrations(): Migration<unknown, unknown>[] {
|
|
140
|
+
return this._migrations;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
144
|
+
// Data Parsing (load from storage)
|
|
145
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Parse and migrate stored data to current version.
|
|
149
|
+
* Returns just the data payload.
|
|
150
|
+
* @throws ZodError on validation failure
|
|
151
|
+
* @throws Error on migration failure
|
|
152
|
+
*/
|
|
153
|
+
async parse(input: VersionedRecord<unknown>): Promise<T> {
|
|
154
|
+
const migrated = await this.migrateToVersion(input);
|
|
155
|
+
return this.schema.parse(migrated.data);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Safe parse - returns result object instead of throwing.
|
|
160
|
+
*/
|
|
161
|
+
async safeParse(input: VersionedRecord<unknown>): Promise<ParseResult<T>> {
|
|
162
|
+
try {
|
|
163
|
+
const data = await this.parse(input);
|
|
164
|
+
return { success: true, data };
|
|
165
|
+
} catch (error) {
|
|
166
|
+
return { success: false, error: error as Error };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Parse and return full VersionedRecord wrapper (preserves metadata).
|
|
172
|
+
*/
|
|
173
|
+
async parseRecord(
|
|
174
|
+
input: VersionedRecord<unknown>
|
|
175
|
+
): Promise<VersionedRecord<T>> {
|
|
176
|
+
const migrated = await this.migrateToVersion(input);
|
|
177
|
+
const validated = this.schema.parse(migrated.data);
|
|
178
|
+
return { ...migrated, data: validated };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
182
|
+
// Data Creation (wrap new data)
|
|
183
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Create a new VersionedRecord wrapper for fresh data.
|
|
187
|
+
* Validates data against schema.
|
|
188
|
+
*/
|
|
189
|
+
create(data: T): VersionedRecord<T> {
|
|
190
|
+
return {
|
|
191
|
+
version: this.version,
|
|
192
|
+
data: this.schema.parse(data),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Create with plugin context for plugin-wide configs.
|
|
198
|
+
*/
|
|
199
|
+
createForPlugin(data: T, pluginId: string): VersionedPluginRecord<T> {
|
|
200
|
+
return {
|
|
201
|
+
version: this.version,
|
|
202
|
+
data: this.schema.parse(data),
|
|
203
|
+
pluginId,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
208
|
+
// Utilities
|
|
209
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Check if data needs migration to reach current version.
|
|
213
|
+
*/
|
|
214
|
+
needsMigration(input: VersionedRecord<unknown>): boolean {
|
|
215
|
+
return input.version !== this.version;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Validate data without migration (schema.parse wrapper).
|
|
220
|
+
* For data already at current version.
|
|
221
|
+
*/
|
|
222
|
+
validate(data: unknown): T {
|
|
223
|
+
return this.schema.parse(data);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Safe validate - returns result object instead of throwing.
|
|
228
|
+
*/
|
|
229
|
+
safeValidate(data: unknown): ReturnType<z.ZodType<T>["safeParse"]> {
|
|
230
|
+
return this.schema.safeParse(data);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
234
|
+
// Internal migration logic
|
|
235
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
236
|
+
|
|
237
|
+
private async migrateToVersion(
|
|
238
|
+
input: VersionedRecord<unknown>
|
|
239
|
+
): Promise<VersionedRecord<unknown>> {
|
|
240
|
+
if (input.version === this.version) {
|
|
241
|
+
return input;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Validate migration chain
|
|
245
|
+
this.validateMigrationChain(input.version);
|
|
246
|
+
|
|
247
|
+
// Sort and filter applicable migrations
|
|
248
|
+
const applicable = this.migrations
|
|
249
|
+
.filter(
|
|
250
|
+
(m) => m.fromVersion >= input.version && m.toVersion <= this.version
|
|
251
|
+
)
|
|
252
|
+
.toSorted((a, b) => a.fromVersion - b.fromVersion);
|
|
253
|
+
|
|
254
|
+
// Run migrations sequentially
|
|
255
|
+
let currentData = input.data;
|
|
256
|
+
let currentVersion = input.version;
|
|
257
|
+
const originalVersion = input.originalVersion ?? input.version;
|
|
258
|
+
|
|
259
|
+
for (const migration of applicable) {
|
|
260
|
+
try {
|
|
261
|
+
currentData = await migration.migrate(currentData);
|
|
262
|
+
currentVersion = migration.toVersion;
|
|
263
|
+
} catch (error) {
|
|
264
|
+
throw new Error(
|
|
265
|
+
`Migration from v${migration.fromVersion} to v${migration.toVersion} failed: ${error}`
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
version: currentVersion,
|
|
272
|
+
data: currentData,
|
|
273
|
+
migratedAt: new Date(),
|
|
274
|
+
originalVersion,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private validateMigrationChain(fromVersion: number): void {
|
|
279
|
+
const sorted = this.migrations.toSorted(
|
|
280
|
+
(a, b) => a.fromVersion - b.fromVersion
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
let expectedVersion = fromVersion;
|
|
284
|
+
for (const migration of sorted) {
|
|
285
|
+
if (migration.fromVersion < fromVersion) continue;
|
|
286
|
+
if (migration.toVersion > this.version) break;
|
|
287
|
+
|
|
288
|
+
if (migration.fromVersion !== expectedVersion) {
|
|
289
|
+
throw new Error(
|
|
290
|
+
`Migration chain broken: expected migration from version ${expectedVersion}, ` +
|
|
291
|
+
`but found migration from version ${migration.fromVersion}`
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
if (migration.toVersion !== migration.fromVersion + 1) {
|
|
295
|
+
throw new Error(
|
|
296
|
+
`Migration must increment version by 1: migration from ${migration.fromVersion} ` +
|
|
297
|
+
`to ${migration.toVersion} is invalid`
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
expectedVersion = migration.toVersion;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (expectedVersion !== this.version) {
|
|
304
|
+
throw new Error(
|
|
305
|
+
`Migration chain incomplete: reaches version ${expectedVersion}, ` +
|
|
306
|
+
`but target version is ${this.version}`
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
package/src/contract.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Helper to ensure backend routers implement their RPC contracts
|
|
2
|
+
// This provides compile-time type checking to prevent contract drift
|
|
3
|
+
|
|
4
|
+
export function validateRouter<TContract>() {
|
|
5
|
+
return <TRouter extends TContract>(router: TRouter): TRouter => {
|
|
6
|
+
return router;
|
|
7
|
+
};
|
|
8
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { createServiceRef } from "./service-ref";
|
|
2
|
+
import type { RpcService } from "./rpc";
|
|
3
|
+
import type { HealthCheckRegistry } from "./health-check";
|
|
4
|
+
import type {
|
|
5
|
+
QueuePluginRegistry,
|
|
6
|
+
QueueManager,
|
|
7
|
+
} from "@checkstack/queue-api";
|
|
8
|
+
import type { ConfigService } from "./config-service";
|
|
9
|
+
import type { SignalService } from "@checkstack/signal-common";
|
|
10
|
+
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
11
|
+
import {
|
|
12
|
+
Logger,
|
|
13
|
+
Fetch,
|
|
14
|
+
AuthService,
|
|
15
|
+
PluginInstaller,
|
|
16
|
+
RpcClient,
|
|
17
|
+
} from "./types";
|
|
18
|
+
import type { EventBus } from "./event-bus-types";
|
|
19
|
+
|
|
20
|
+
export * from "./types";
|
|
21
|
+
|
|
22
|
+
export const authenticationStrategyServiceRef = createServiceRef<unknown>(
|
|
23
|
+
"internal.authenticationStrategy"
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
export const coreServices = {
|
|
27
|
+
database:
|
|
28
|
+
createServiceRef<NodePgDatabase<Record<string, unknown>>>("core.database"),
|
|
29
|
+
logger: createServiceRef<Logger>("core.logger"),
|
|
30
|
+
fetch: createServiceRef<Fetch>("core.fetch"),
|
|
31
|
+
auth: createServiceRef<AuthService>("core.auth"),
|
|
32
|
+
healthCheckRegistry: createServiceRef<HealthCheckRegistry>(
|
|
33
|
+
"core.healthCheckRegistry"
|
|
34
|
+
),
|
|
35
|
+
pluginInstaller: createServiceRef<PluginInstaller>("core.pluginInstaller"),
|
|
36
|
+
rpc: createServiceRef<RpcService>("core.rpc"),
|
|
37
|
+
rpcClient: createServiceRef<RpcClient>("core.rpcClient"),
|
|
38
|
+
queuePluginRegistry: createServiceRef<QueuePluginRegistry>(
|
|
39
|
+
"core.queuePluginRegistry"
|
|
40
|
+
),
|
|
41
|
+
queueManager: createServiceRef<QueueManager>("core.queueManager"),
|
|
42
|
+
config: createServiceRef<ConfigService>("core.config"),
|
|
43
|
+
eventBus: createServiceRef<EventBus>("core.eventBus"),
|
|
44
|
+
signalService: createServiceRef<SignalService>("core.signalService"),
|
|
45
|
+
};
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Email layout utilities for notification strategies.
|
|
3
|
+
*
|
|
4
|
+
* Provides a responsive HTML email template that strategies can use
|
|
5
|
+
* to wrap their content with consistent branding.
|
|
6
|
+
*
|
|
7
|
+
* @module
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Options for the email layout wrapper.
|
|
12
|
+
*/
|
|
13
|
+
export interface EmailLayoutOptions {
|
|
14
|
+
/** Email subject/title */
|
|
15
|
+
title: string;
|
|
16
|
+
/** Already-converted HTML body content */
|
|
17
|
+
bodyHtml: string;
|
|
18
|
+
/** Importance level affects header/button colors */
|
|
19
|
+
importance: "info" | "warning" | "critical";
|
|
20
|
+
/** Optional call-to-action button */
|
|
21
|
+
action?: {
|
|
22
|
+
label: string;
|
|
23
|
+
url: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Admin-customizable options (via layoutConfig)
|
|
27
|
+
/** Logo URL (max ~200px wide recommended) */
|
|
28
|
+
logoUrl?: string;
|
|
29
|
+
/** Primary brand color (hex, e.g., "#3b82f6") */
|
|
30
|
+
primaryColor?: string;
|
|
31
|
+
/** Accent color for secondary elements */
|
|
32
|
+
accentColor?: string;
|
|
33
|
+
/** Footer text */
|
|
34
|
+
footerText?: string;
|
|
35
|
+
/** Footer links (e.g., Privacy Policy, Unsubscribe) */
|
|
36
|
+
footerLinks?: Array<{ label: string; url: string }>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Default importance-based colors
|
|
40
|
+
const IMPORTANCE_COLORS = {
|
|
41
|
+
info: "#3b82f6", // blue
|
|
42
|
+
warning: "#f59e0b", // amber
|
|
43
|
+
critical: "#ef4444", // red
|
|
44
|
+
} as const;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Simple HTML escaping for security.
|
|
48
|
+
*/
|
|
49
|
+
function escapeHtml(text: string): string {
|
|
50
|
+
return text
|
|
51
|
+
.replaceAll("&", "&")
|
|
52
|
+
.replaceAll("<", "<")
|
|
53
|
+
.replaceAll(">", ">")
|
|
54
|
+
.replaceAll('"', """)
|
|
55
|
+
.replaceAll("'", "'");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Wrap HTML content in a responsive email template.
|
|
60
|
+
*
|
|
61
|
+
* This template is designed to render consistently across major
|
|
62
|
+
* email clients (Gmail, Outlook, Apple Mail, etc.).
|
|
63
|
+
*
|
|
64
|
+
* @example
|
|
65
|
+
* ```typescript
|
|
66
|
+
* const html = wrapInEmailLayout({
|
|
67
|
+
* title: "Password Reset",
|
|
68
|
+
* bodyHtml: "<p>Click the button below to reset your password.</p>",
|
|
69
|
+
* importance: "warning",
|
|
70
|
+
* action: { label: "Reset Password", url: "https://..." },
|
|
71
|
+
* primaryColor: "#10b981",
|
|
72
|
+
* footerText: "Sent by Acme Corp",
|
|
73
|
+
* });
|
|
74
|
+
* ```
|
|
75
|
+
*/
|
|
76
|
+
export function wrapInEmailLayout(options: EmailLayoutOptions): string {
|
|
77
|
+
const {
|
|
78
|
+
title,
|
|
79
|
+
bodyHtml,
|
|
80
|
+
importance,
|
|
81
|
+
action,
|
|
82
|
+
logoUrl,
|
|
83
|
+
primaryColor,
|
|
84
|
+
footerText = "This is an automated notification.",
|
|
85
|
+
footerLinks = [],
|
|
86
|
+
} = options;
|
|
87
|
+
|
|
88
|
+
// Use custom color or importance-based default
|
|
89
|
+
const headerColor = primaryColor ?? IMPORTANCE_COLORS[importance];
|
|
90
|
+
const buttonColor = options.accentColor ?? headerColor;
|
|
91
|
+
|
|
92
|
+
// Build footer links HTML
|
|
93
|
+
const footerLinksHtml =
|
|
94
|
+
footerLinks.length > 0
|
|
95
|
+
? footerLinks
|
|
96
|
+
.map(
|
|
97
|
+
(link) =>
|
|
98
|
+
`<a href="${escapeHtml(
|
|
99
|
+
link.url
|
|
100
|
+
)}" style="color: #6b7280; text-decoration: underline;">${escapeHtml(
|
|
101
|
+
link.label
|
|
102
|
+
)}</a>`
|
|
103
|
+
)
|
|
104
|
+
.join(" · ")
|
|
105
|
+
: "";
|
|
106
|
+
|
|
107
|
+
return `
|
|
108
|
+
<!DOCTYPE html>
|
|
109
|
+
<html lang="en">
|
|
110
|
+
<head>
|
|
111
|
+
<meta charset="utf-8">
|
|
112
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
113
|
+
<meta name="color-scheme" content="light">
|
|
114
|
+
<meta name="supported-color-schemes" content="light">
|
|
115
|
+
<title>${escapeHtml(title)}</title>
|
|
116
|
+
<!--[if mso]>
|
|
117
|
+
<noscript>
|
|
118
|
+
<xml>
|
|
119
|
+
<o:OfficeDocumentSettings>
|
|
120
|
+
<o:PixelsPerInch>96</o:PixelsPerInch>
|
|
121
|
+
</o:OfficeDocumentSettings>
|
|
122
|
+
</xml>
|
|
123
|
+
</noscript>
|
|
124
|
+
<![endif]-->
|
|
125
|
+
<style>
|
|
126
|
+
/* Reset styles */
|
|
127
|
+
body, table, td, p, a, li, blockquote {
|
|
128
|
+
-webkit-text-size-adjust: 100%;
|
|
129
|
+
-ms-text-size-adjust: 100%;
|
|
130
|
+
}
|
|
131
|
+
table, td {
|
|
132
|
+
mso-table-lspace: 0pt;
|
|
133
|
+
mso-table-rspace: 0pt;
|
|
134
|
+
}
|
|
135
|
+
img {
|
|
136
|
+
-ms-interpolation-mode: bicubic;
|
|
137
|
+
border: 0;
|
|
138
|
+
height: auto;
|
|
139
|
+
line-height: 100%;
|
|
140
|
+
outline: none;
|
|
141
|
+
text-decoration: none;
|
|
142
|
+
}
|
|
143
|
+
body {
|
|
144
|
+
margin: 0 !important;
|
|
145
|
+
padding: 0 !important;
|
|
146
|
+
width: 100% !important;
|
|
147
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
148
|
+
background-color: #f4f4f5;
|
|
149
|
+
}
|
|
150
|
+
/* Link styling */
|
|
151
|
+
a {
|
|
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;
|
|
165
|
+
}
|
|
166
|
+
</style>
|
|
167
|
+
</head>
|
|
168
|
+
<body style="margin: 0; padding: 0; background-color: #f4f4f5;">
|
|
169
|
+
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="background-color: #f4f4f5;">
|
|
170
|
+
<tr>
|
|
171
|
+
<td align="center" style="padding: 24px 16px;">
|
|
172
|
+
<!-- Main container -->
|
|
173
|
+
<table role="presentation" cellpadding="0" cellspacing="0" width="600" style="max-width: 600px; background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
|
|
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 -->
|
|
189
|
+
<tr>
|
|
190
|
+
<td style="background-color: ${headerColor}; padding: 20px 24px;">
|
|
191
|
+
<h1 style="margin: 0; color: #ffffff; font-size: 20px; font-weight: 600; line-height: 1.4;">
|
|
192
|
+
${escapeHtml(title)}
|
|
193
|
+
</h1>
|
|
194
|
+
</td>
|
|
195
|
+
</tr>
|
|
196
|
+
<!-- Body -->
|
|
197
|
+
<tr>
|
|
198
|
+
<td style="padding: 24px;">
|
|
199
|
+
<div style="color: #374151; font-size: 16px; line-height: 1.6;">
|
|
200
|
+
${bodyHtml}
|
|
201
|
+
</div>
|
|
202
|
+
${
|
|
203
|
+
action
|
|
204
|
+
? `
|
|
205
|
+
<!-- CTA Button -->
|
|
206
|
+
<table role="presentation" cellpadding="0" cellspacing="0" style="margin-top: 24px;">
|
|
207
|
+
<tr>
|
|
208
|
+
<td>
|
|
209
|
+
<a href="${escapeHtml(
|
|
210
|
+
action.url
|
|
211
|
+
)}" class="button" style="display: inline-block; padding: 12px 24px; background-color: ${buttonColor}; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 14px;">
|
|
212
|
+
${escapeHtml(action.label)}
|
|
213
|
+
</a>
|
|
214
|
+
</td>
|
|
215
|
+
</tr>
|
|
216
|
+
</table>
|
|
217
|
+
`
|
|
218
|
+
: ""
|
|
219
|
+
}
|
|
220
|
+
</td>
|
|
221
|
+
</tr>
|
|
222
|
+
<!-- Footer -->
|
|
223
|
+
<tr>
|
|
224
|
+
<td style="background-color: #f9fafb; padding: 16px 24px; border-top: 1px solid #e5e7eb;">
|
|
225
|
+
<p style="margin: 0; color: #6b7280; font-size: 12px; line-height: 1.5; text-align: center;">
|
|
226
|
+
${escapeHtml(footerText)}
|
|
227
|
+
</p>
|
|
228
|
+
${
|
|
229
|
+
footerLinksHtml
|
|
230
|
+
? `
|
|
231
|
+
<p style="margin: 8px 0 0 0; color: #6b7280; font-size: 12px; line-height: 1.5; text-align: center;">
|
|
232
|
+
${footerLinksHtml}
|
|
233
|
+
</p>
|
|
234
|
+
`
|
|
235
|
+
: ""
|
|
236
|
+
}
|
|
237
|
+
</td>
|
|
238
|
+
</tr>
|
|
239
|
+
</table>
|
|
240
|
+
</td>
|
|
241
|
+
</tr>
|
|
242
|
+
</table>
|
|
243
|
+
</body>
|
|
244
|
+
</html>
|
|
245
|
+
`.trim();
|
|
246
|
+
}
|