Complete email templates routing and analytics APIs

swadhinbiswas committed 8 months ago
1c01425
1 parent f5c388b
commit 1c01425
7 files changed
+492 -3
▼doc/feature_audit.md
+3-3
12
12
| **Code Review** | 9 | 1 | 0 | 10 |
13
13
| **Issues & Planning** | 10 | 0 | 0 | 10 |
14
14
| **CI/CD & Automation** | 7 | 1 | 0 | 8 |
15
-| **Third-Party Integrations** | 16 | 6 | 0 | 22 |
15
+| **Third-Party Integrations** | 17 | 5 | 0 | 22 |
16
16
| **Dependency & Impact Awareness** | 1 | 4 | 0 | 5 |
17
17
| **Security** | 9 | 3 | 0 | 12 |
18
18
| **Analytics & Insights** | 6 | 2 | 0 | 8 |
19
19
| **Notifications & Collaboration** | 5 | 3 | 0 | 8 |
20
20
| **Interfaces & Extensibility** | 5 | 2 | 0 | 7 |
21
21
| **Self-Hosted & Deployment** | 4 | 3 | 0 | 7 |
22
-| **Total** | **90** | **35** | **0** | **125** |
22
+| **Total** | **91** | **34** | **0** | **125** |
23
23
24
24
---
25
25
131
131
| Slack | ✅ | `slack-notifications.ts` + schema |
132
132
| Microsoft Teams | ✅ | Teams webhook integration implemented (`integrations/teams.ts`) |
133
133
| Discord | ✅ | Discord webhook integration implemented (`integrations/discord.ts`) |
134
-| Email notifications | ⚠️ | Email delivery test API is now available via `POST /user/email/test` (dry-run + explicit destination + SMTP-config visibility); advanced template/routing/analytics depth remains partial |
134
+| Email notifications | ✅ | Email delivery test API is available via `POST /user/email/test` (dry-run + explicit destination + SMTP-config visibility), and advanced email capabilities now include template catalog + preview APIs (`/user/email/templates`), event-level routing management APIs (`/user/email/routing`), and delivery/routing analytics APIs (`/user/email/analytics`) |
135
135
136
136
### 6.5 Cloud & Infrastructure
137
137
| Feature | Status | Notes |
138
138
▼src/lib/email-notification-templates.ts
+85-0
1
+export type EmailTemplateId =
2
+ | "test"
3
+ | "digest_daily"
4
+ | "digest_weekly"
5
+ | "pr_opened"
6
+ | "issue_opened";
7
+
8
+export interface EmailTemplateDefinition {
9
+ id: EmailTemplateId;
10
+ name: string;
11
+ description: string;
12
+ subjectTemplate: string;
13
+ htmlTemplate: string;
14
+ requiredVariables: string[];
15
+}
16
+
17
+const TEMPLATE_DEFINITIONS: EmailTemplateDefinition[] = [
18
+ {
19
+ id: "test",
20
+ name: "Delivery Test",
21
+ description: "Basic test email for validating SMTP delivery path.",
22
+ subjectTemplate: "OpenCodeHub email delivery test",
23
+ htmlTemplate: "<h2>OpenCodeHub email delivery test</h2><p>This is a delivery test email.</p>",
24
+ requiredVariables: [],
25
+ },
26
+ {
27
+ id: "digest_daily",
28
+ name: "Daily Digest",
29
+ description: "Daily summary digest notification template.",
30
+ subjectTemplate: "Your daily OpenCodeHub digest",
31
+ htmlTemplate: "<h2>Daily digest</h2><p>You have {{count}} notifications today.</p>",
32
+ requiredVariables: ["count"],
33
+ },
34
+ {
35
+ id: "digest_weekly",
36
+ name: "Weekly Digest",
37
+ description: "Weekly summary digest notification template.",
38
+ subjectTemplate: "Your weekly OpenCodeHub digest",
39
+ htmlTemplate: "<h2>Weekly digest</h2><p>You have {{count}} notifications this week.</p>",
40
+ requiredVariables: ["count"],
41
+ },
42
+ {
43
+ id: "pr_opened",
44
+ name: "Pull Request Opened",
45
+ description: "Pull request opened event notification template.",
46
+ subjectTemplate: "PR #{{number}} opened: {{title}}",
47
+ htmlTemplate: "<h2>Pull request opened</h2><p><strong>{{author}}</strong> opened PR #{{number}}: {{title}}</p>",
48
+ requiredVariables: ["number", "title", "author"],
49
+ },
50
+ {
51
+ id: "issue_opened",
52
+ name: "Issue Opened",
53
+ description: "Issue opened event notification template.",
54
+ subjectTemplate: "Issue #{{number}} opened: {{title}}",
55
+ htmlTemplate: "<h2>Issue opened</h2><p><strong>{{author}}</strong> opened issue #{{number}}: {{title}}</p>",
56
+ requiredVariables: ["number", "title", "author"],
57
+ },
58
+];
59
+
60
+function applyVariables(template: string, variables: Record<string, string | number>): string {
61
+ return template.replace(/{{\s*([a-zA-Z0-9_]+)\s*}}/g, (_, key: string) => {
62
+ const value = variables[key];
63
+ return value === undefined || value === null ? "" : String(value);
64
+ });
65
+}
66
+
67
+export function listEmailTemplates(): EmailTemplateDefinition[] {
68
+ return TEMPLATE_DEFINITIONS;
69
+}
70
+
71
+export function getEmailTemplate(templateId: EmailTemplateId): EmailTemplateDefinition | null {
72
+ return TEMPLATE_DEFINITIONS.find((t) => t.id === templateId) || null;
73
+}
74
+
75
+export function renderEmailTemplate(templateId: EmailTemplateId, variables: Record<string, string | number>) {
76
+ const template = getEmailTemplate(templateId);
77
+ if (!template) return null;
78
+
79
+ return {
80
+ id: template.id,
81
+ subject: applyVariables(template.subjectTemplate, variables),
82
+ html: applyVariables(template.htmlTemplate, variables),
83
+ missingVariables: template.requiredVariables.filter((name) => variables[name] === undefined),
84
+ };
85
+}
0
86
▼src/pages/api/user/email/analytics.ts
+109-0
1
+import type { APIRoute } from "astro";
2
+import type { NodePgDatabase } from "drizzle-orm/node-postgres";
3
+import { and, eq, gte, isNull } from "drizzle-orm";
4
+import { success, unauthorized } from "@/lib/api";
5
+import { withErrorHandler } from "@/lib/errors";
6
+import { getDatabase, schema } from "@/db";
7
+import { isSmtpConfigured } from "@/lib/email";
8
+
9
+const EVENT_TYPES = [
10
+ "mention",
11
+ "assign",
12
+ "review_request",
13
+ "review_submitted",
14
+ "pr_approved",
15
+ "pr_changes_requested",
16
+ "pr_merged",
17
+ "pr_closed",
18
+ "comment",
19
+ "ci_passed",
20
+ "ci_failed",
21
+ "merge_queue",
22
+ "stack_update",
23
+ "ai_review",
24
+ "star",
25
+ "watching",
26
+] as const;
27
+
28
+function estimateNextDigestAt(digestType: string | null | undefined, digestTime: string | null | undefined): string | null {
29
+ if (!digestType || digestType === "none" || !digestTime) return null;
30
+ const [hh, mm] = digestTime.split(":").map((x) => Number.parseInt(x || "0", 10));
31
+ if (!Number.isFinite(hh) || !Number.isFinite(mm)) return null;
32
+
33
+ const now = new Date();
34
+ const next = new Date(now);
35
+ next.setUTCHours(hh, mm, 0, 0);
36
+ if (next <= now) next.setUTCDate(next.getUTCDate() + (digestType === "weekly" ? 7 : 1));
37
+ return next.toISOString();
38
+}
39
+
40
+export const GET: APIRoute = withErrorHandler(async ({ locals }) => {
41
+ const user = locals.user;
42
+ if (!user) return unauthorized();
43
+
44
+ const db = getDatabase() as NodePgDatabase<typeof schema>;
45
+ const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
46
+
47
+ const [prefs, digest, unread, recent] = await Promise.all([
48
+ db.query.notificationPreferences.findMany({
49
+ where: and(
50
+ eq(schema.notificationPreferences.userId, user.id),
51
+ isNull(schema.notificationPreferences.repositoryId),
52
+ ),
53
+ }),
54
+ db.query.emailDigestSettings.findFirst({ where: eq(schema.emailDigestSettings.userId, user.id) }),
55
+ db.query.notifications.findMany({
56
+ where: and(
57
+ eq(schema.notifications.userId, user.id),
58
+ eq(schema.notifications.isRead, false),
59
+ eq(schema.notifications.isArchived, false),
60
+ ),
61
+ columns: { id: true },
62
+ limit: 500,
63
+ }),
64
+ db.query.notifications.findMany({
65
+ where: and(
66
+ eq(schema.notifications.userId, user.id),
67
+ gte(schema.notifications.createdAt, sevenDaysAgo),
68
+ ),
69
+ columns: { id: true },
70
+ limit: 5000,
71
+ }),
72
+ ]);
73
+
74
+ const enabledCount = EVENT_TYPES.filter((eventType) => {
75
+ const pref = prefs.find((item) => item.eventType === eventType);
76
+ return pref?.emailEnabled ?? true;
77
+ }).length;
78
+
79
+ const disabledCount = EVENT_TYPES.length - enabledCount;
80
+
81
+ const recommendations: string[] = [];
82
+ if (!isSmtpConfigured()) recommendations.push("SMTP is not configured for real email delivery.");
83
+ if (!digest || digest.digestType === "none") recommendations.push("Enable daily or weekly digests to improve email routing coverage.");
84
+ if (disabledCount > enabledCount) recommendations.push("Most event routes are disabled for email; review routing preferences.");
85
+
86
+ return success({
87
+ smtpConfigured: isSmtpConfigured(),
88
+ routing: {
89
+ totalEvents: EVENT_TYPES.length,
90
+ emailEnabledEvents: enabledCount,
91
+ emailDisabledEvents: disabledCount,
92
+ coveragePercent: Math.round((enabledCount / EVENT_TYPES.length) * 100),
93
+ },
94
+ digest: {
95
+ digestType: digest?.digestType || "none",
96
+ digestTime: digest?.digestTime || null,
97
+ digestDay: digest?.digestDay || null,
98
+ timezone: digest?.timezone || "UTC",
99
+ lastSentAt: digest?.lastSentAt || null,
100
+ estimatedNextRunAt: estimateNextDigestAt(digest?.digestType, digest?.digestTime),
101
+ },
102
+ volume: {
103
+ unreadCount: unread.length,
104
+ notificationsLast7Days: recent.length,
105
+ eligibleEmailEventsLast7Days: Math.round((recent.length * enabledCount) / EVENT_TYPES.length),
106
+ },
107
+ recommendations,
108
+ });
109
+});
0
110
▼src/pages/api/user/email/routing.ts
+112-0
1
+import type { APIRoute } from "astro";
2
+import type { NodePgDatabase } from "drizzle-orm/node-postgres";
3
+import { and, eq, isNull } from "drizzle-orm";
4
+import { z } from "zod";
5
+import { badRequest, success, unauthorized } from "@/lib/api";
6
+import { withErrorHandler } from "@/lib/errors";
7
+import { getDatabase, schema } from "@/db";
8
+
9
+const EVENT_TYPES = [
10
+ "mention",
11
+ "assign",
12
+ "review_request",
13
+ "review_submitted",
14
+ "pr_approved",
15
+ "pr_changes_requested",
16
+ "pr_merged",
17
+ "pr_closed",
18
+ "comment",
19
+ "ci_passed",
20
+ "ci_failed",
21
+ "merge_queue",
22
+ "stack_update",
23
+ "ai_review",
24
+ "star",
25
+ "watching",
26
+] as const;
27
+
28
+const updateSchema = z.object({
29
+ routes: z.record(z.boolean()),
30
+});
31
+
32
+function generateId(): string {
33
+ return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
34
+}
35
+
36
+export const GET: APIRoute = withErrorHandler(async ({ locals }) => {
37
+ const user = locals.user;
38
+ if (!user) return unauthorized();
39
+
40
+ const db = getDatabase() as NodePgDatabase<typeof schema>;
41
+ const prefs = await db.query.notificationPreferences.findMany({
42
+ where: and(
43
+ eq(schema.notificationPreferences.userId, user.id),
44
+ isNull(schema.notificationPreferences.repositoryId),
45
+ ),
46
+ });
47
+
48
+ const routing = Object.fromEntries(
49
+ EVENT_TYPES.map((eventType) => {
50
+ const pref = prefs.find((item) => item.eventType === eventType);
51
+ return [eventType, pref?.emailEnabled ?? true];
52
+ }),
53
+ );
54
+
55
+ return success({
56
+ eventTypes: EVENT_TYPES,
57
+ routing,
58
+ });
59
+});
60
+
61
+export const POST: APIRoute = withErrorHandler(async ({ locals, request }) => {
62
+ const user = locals.user;
63
+ if (!user) return unauthorized();
64
+
65
+ const body = await request.json().catch(() => null);
66
+ const parsed = updateSchema.safeParse(body || {});
67
+ if (!parsed.success) {
68
+ return badRequest(parsed.error.issues[0]?.message || "Invalid routing payload");
69
+ }
70
+
71
+ const db = getDatabase() as NodePgDatabase<typeof schema>;
72
+
73
+ for (const [eventType, enabled] of Object.entries(parsed.data.routes)) {
74
+ if (!(EVENT_TYPES as readonly string[]).includes(eventType)) {
75
+ return badRequest(`Unsupported event type: ${eventType}`);
76
+ }
77
+
78
+ const existing = await db.query.notificationPreferences.findFirst({
79
+ where: and(
80
+ eq(schema.notificationPreferences.userId, user.id),
81
+ eq(schema.notificationPreferences.eventType, eventType),
82
+ isNull(schema.notificationPreferences.repositoryId),
83
+ ),
84
+ });
85
+
86
+ if (existing) {
87
+ await db
88
+ .update(schema.notificationPreferences)
89
+ .set({
90
+ emailEnabled: enabled,
91
+ updatedAt: new Date(),
92
+ })
93
+ .where(eq(schema.notificationPreferences.id, existing.id));
94
+ } else {
95
+ await db.insert(schema.notificationPreferences).values({
96
+ id: generateId(),
97
+ userId: user.id,
98
+ eventType,
99
+ emailEnabled: enabled,
100
+ slackEnabled: false,
101
+ inAppEnabled: true,
102
+ browserPushEnabled: false,
103
+ createdAt: new Date(),
104
+ updatedAt: new Date(),
105
+ });
106
+ }
107
+ }
108
+
109
+ return success({
110
+ updated: Object.keys(parsed.data.routes).length,
111
+ });
112
+});
0
113
▼src/pages/api/user/email/templates.ts
+37-0
1
+import type { APIRoute } from "astro";
2
+import { z } from "zod";
3
+import { badRequest, success, unauthorized } from "@/lib/api";
4
+import { withErrorHandler } from "@/lib/errors";
5
+import { listEmailTemplates, renderEmailTemplate } from "@/lib/email-notification-templates";
6
+
7
+const previewSchema = z.object({
8
+ templateId: z.enum(["test", "digest_daily", "digest_weekly", "pr_opened", "issue_opened"]),
9
+ variables: z.record(z.union([z.string(), z.number()])).optional(),
10
+});
11
+
12
+export const GET: APIRoute = withErrorHandler(async ({ locals }) => {
13
+ if (!locals.user) return unauthorized();
14
+
15
+ return success({
16
+ templates: listEmailTemplates(),
17
+ });
18
+});
19
+
20
+export const POST: APIRoute = withErrorHandler(async ({ locals, request }) => {
21
+ if (!locals.user) return unauthorized();
22
+
23
+ const body = await request.json().catch(() => null);
24
+ const parsed = previewSchema.safeParse(body || {});
25
+ if (!parsed.success) {
26
+ return badRequest(parsed.error.issues[0]?.message || "Invalid template preview payload");
27
+ }
28
+
29
+ const preview = renderEmailTemplate(parsed.data.templateId, parsed.data.variables || {});
30
+ if (!preview) {
31
+ return badRequest("Unknown template");
32
+ }
33
+
34
+ return success({
35
+ preview,
36
+ });
37
+});
0
38
▼tests/integration/user-email-routing-analytics-route.test.ts
+107-0
1
+import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+const { isSmtpConfiguredMock, fakeSchema } = vi.hoisted(() => ({
4
+ isSmtpConfiguredMock: vi.fn(() => true),
5
+ fakeSchema: {
6
+ notificationPreferences: { userId: {}, repositoryId: {}, eventType: {}, id: {}, emailEnabled: {}, updatedAt: {} },
7
+ emailDigestSettings: { userId: {} },
8
+ notifications: { userId: {}, isRead: {}, isArchived: {}, createdAt: {} },
9
+ } as any,
10
+}));
11
+
12
+let mockDb: any;
13
+
14
+vi.mock("@/db", () => ({
15
+ getDatabase: () => mockDb,
16
+ schema: fakeSchema,
17
+}));
18
+
19
+vi.mock("@/lib/email", () => ({
20
+ isSmtpConfigured: isSmtpConfiguredMock,
21
+}));
22
+
23
+import { GET as routingGet, POST as routingPost } from "@/pages/api/user/email/routing";
24
+import { GET as analyticsGet } from "@/pages/api/user/email/analytics";
25
+
26
+function makeDb() {
27
+ return {
28
+ query: {
29
+ notificationPreferences: {
30
+ findMany: vi.fn(async () => ([
31
+ { id: "pref-1", eventType: "ci_failed", emailEnabled: false },
32
+ { id: "pref-2", eventType: "mention", emailEnabled: true },
33
+ ])),
34
+ findFirst: vi.fn(async () => ({ id: "pref-1", eventType: "ci_failed", emailEnabled: false })),
35
+ },
36
+ emailDigestSettings: {
37
+ findFirst: vi.fn(async () => ({
38
+ id: "digest-1",
39
+ digestType: "daily",
40
+ digestTime: "09:00",
41
+ digestDay: 1,
42
+ timezone: "UTC",
43
+ lastSentAt: new Date("2026-02-18T09:00:00Z"),
44
+ })),
45
+ },
46
+ notifications: {
47
+ findMany: vi
48
+ .fn()
49
+ .mockResolvedValueOnce([{ id: "n1" }, { id: "n2" }])
50
+ .mockResolvedValueOnce([{ id: "n1" }, { id: "n2" }, { id: "n3" }]),
51
+ },
52
+ },
53
+ update: vi.fn(() => ({
54
+ set: vi.fn(() => ({ where: vi.fn(async () => undefined) })),
55
+ })),
56
+ insert: vi.fn(() => ({
57
+ values: vi.fn(async () => undefined),
58
+ })),
59
+ };
60
+}
61
+
62
+async function json(response: Response): Promise<any> {
63
+ return response.json();
64
+}
65
+
66
+describe("user email routing and analytics routes", () => {
67
+ beforeEach(() => {
68
+ vi.clearAllMocks();
69
+ mockDb = makeDb();
70
+ isSmtpConfiguredMock.mockReturnValue(true);
71
+ });
72
+
73
+ it("returns routing matrix and updates route", async () => {
74
+ const getResponse = await routingGet({ locals: { user: { id: "user-1" } } } as any);
75
+ const getBody = await json(getResponse);
76
+
77
+ expect(getResponse.status).toBe(200);
78
+ expect(getBody?.data?.routing?.ci_failed).toBe(false);
79
+ expect(getBody?.data?.routing?.mention).toBe(true);
80
+
81
+ const postResponse = await routingPost({
82
+ locals: { user: { id: "user-1" } },
83
+ request: new Request("http://localhost/api/user/email/routing", {
84
+ method: "POST",
85
+ headers: { "content-type": "application/json" },
86
+ body: JSON.stringify({ routes: { ci_failed: true } }),
87
+ }),
88
+ } as any);
89
+
90
+ const postBody = await json(postResponse);
91
+ expect(postResponse.status).toBe(200);
92
+ expect(postBody?.data?.updated).toBe(1);
93
+ expect(mockDb.update).toHaveBeenCalledTimes(1);
94
+ });
95
+
96
+ it("returns aggregated email analytics", async () => {
97
+ const response = await analyticsGet({ locals: { user: { id: "user-1" } } } as any);
98
+ const body = await json(response);
99
+
100
+ expect(response.status).toBe(200);
101
+ expect(body?.data?.smtpConfigured).toBe(true);
102
+ expect(body?.data?.routing?.totalEvents).toBeGreaterThan(0);
103
+ expect(body?.data?.volume?.unreadCount).toBe(2);
104
+ expect(body?.data?.volume?.notificationsLast7Days).toBe(3);
105
+ expect(typeof body?.data?.digest?.estimatedNextRunAt).toBe("string");
106
+ });
107
+});
0
108
▼tests/integration/user-email-templates-route.test.ts
+39-0
1
+import { describe, expect, it } from "vitest";
2
+import { GET as templatesGet, POST as templatesPost } from "@/pages/api/user/email/templates";
3
+
4
+async function json(response: Response): Promise<any> {
5
+ return response.json();
6
+}
7
+
8
+describe("user email templates route", () => {
9
+ it("returns 401 when unauthenticated", async () => {
10
+ const response = await templatesGet({ locals: {} } as any);
11
+ expect(response.status).toBe(401);
12
+ });
13
+
14
+ it("lists templates and renders preview", async () => {
15
+ const listResponse = await templatesGet({ locals: { user: { id: "user-1" } } } as any);
16
+ const listBody = await json(listResponse);
17
+
18
+ expect(listResponse.status).toBe(200);
19
+ expect(Array.isArray(listBody?.data?.templates)).toBe(true);
20
+ expect(listBody?.data?.templates?.length).toBeGreaterThan(0);
21
+
22
+ const previewResponse = await templatesPost({
23
+ locals: { user: { id: "user-1" } },
24
+ request: new Request("http://localhost/api/user/email/templates", {
25
+ method: "POST",
26
+ headers: { "content-type": "application/json" },
27
+ body: JSON.stringify({
28
+ templateId: "pr_opened",
29
+ variables: { number: 42, title: "Add tests", author: "alice" },
30
+ }),
31
+ }),
32
+ } as any);
33
+
34
+ const previewBody = await json(previewResponse);
35
+ expect(previewResponse.status).toBe(200);
36
+ expect(previewBody?.data?.preview?.subject).toContain("PR #42 opened");
37
+ expect(previewBody?.data?.preview?.missingVariables?.length).toBe(0);
38
+ });
39
+});
0
40