@company-semantics/contracts 34.0.0 → 35.0.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/package.json +4 -4
- package/src/index.ts +17 -11
- package/src/notifications/README.md +140 -0
- package/src/notifications/__tests__/README.md +54 -0
- package/src/notifications/__tests__/__snapshots__/README.md +32 -0
- package/src/notifications/__tests__/content.test.ts +186 -0
- package/src/notifications/__tests__/context.test.ts +72 -0
- package/src/notifications/__tests__/definition.test.ts +222 -0
- package/src/notifications/__tests__/kinds.test.ts +81 -0
- package/src/notifications/__tests__/output-parity.golden.ts +363 -0
- package/src/notifications/__tests__/output-parity.test.ts +122 -0
- package/src/notifications/__tests__/registry.test.ts +184 -0
- package/src/{email/render → notifications}/__tests__/render-snapshot.test.ts +58 -29
- package/src/notifications/__tests__/renderer.test.ts +181 -0
- package/src/notifications/content.ts +249 -0
- package/src/notifications/context.ts +70 -0
- package/src/notifications/definition.ts +82 -0
- package/src/notifications/index.ts +104 -0
- package/src/notifications/kinds/README.md +57 -0
- package/src/notifications/kinds/auth-otp.ts +99 -0
- package/src/notifications/kinds/chat-shared.ts +56 -0
- package/src/notifications/kinds/company-md-access-approved.ts +57 -0
- package/src/notifications/kinds/company-md-access-denied.ts +61 -0
- package/src/notifications/kinds/company-md-access-requested.ts +65 -0
- package/src/notifications/kinds/index.ts +19 -0
- package/src/notifications/kinds/org-invite.ts +62 -0
- package/src/notifications/kinds/ownership-transfer-completed.ts +57 -0
- package/src/notifications/kinds/ownership-transfer.ts +68 -0
- package/src/notifications/kinds/security-alert.ts +78 -0
- package/src/notifications/kinds/share-granted.ts +74 -0
- package/src/notifications/kinds/unit-owner-granted.ts +95 -0
- package/src/notifications/kinds.ts +68 -0
- package/src/{email/types.ts → notifications/payloads.ts} +33 -68
- package/src/notifications/registry.ts +107 -0
- package/src/notifications/render.ts +106 -0
- package/src/notifications/renderer.ts +50 -0
- package/src/notifications/renderers/README.md +50 -0
- package/src/notifications/renderers/email/README.md +74 -0
- package/src/notifications/renderers/email/__tests__/README.md +29 -0
- package/src/notifications/renderers/email/__tests__/render.test.ts +180 -0
- package/src/{email/render → notifications/renderers/email}/chat.ts +53 -107
- package/src/notifications/renderers/email/constants.ts +47 -0
- package/src/notifications/renderers/email/cta.ts +63 -0
- package/src/{email/render → notifications/renderers/email}/escape-html.ts +5 -1
- package/src/notifications/renderers/email/index.ts +73 -0
- package/src/notifications/renderers/email/render.ts +225 -0
- package/src/notifications/renderers/email/shells.ts +60 -0
- package/src/notifications/renderers/slack/README.md +73 -0
- package/src/notifications/renderers/slack/__tests__/README.md +33 -0
- package/src/notifications/renderers/slack/__tests__/index.test.ts +201 -0
- package/src/notifications/renderers/slack/index.ts +261 -0
- package/src/notifications/renderers/sms/README.md +69 -0
- package/src/notifications/renderers/sms/__tests__/README.md +31 -0
- package/src/notifications/renderers/sms/__tests__/index.test.ts +162 -0
- package/src/notifications/renderers/sms/index.ts +131 -0
- package/src/notifications/text.ts +52 -0
- package/src/email/README.md +0 -51
- package/src/email/__tests__/registry.test.ts +0 -161
- package/src/email/index.ts +0 -36
- package/src/email/registry.ts +0 -155
- package/src/email/render/auth-otp.ts +0 -71
- package/src/email/render/blocks.ts +0 -281
- package/src/email/render/chat-shared.ts +0 -35
- package/src/email/render/company-md-access-approved.ts +0 -42
- package/src/email/render/company-md-access-denied.ts +0 -43
- package/src/email/render/company-md-access-requested.ts +0 -46
- package/src/email/render/constants.ts +0 -18
- package/src/email/render/index.ts +0 -58
- package/src/email/render/org-invite.ts +0 -40
- package/src/email/render/ownership-transfer-completed.ts +0 -41
- package/src/email/render/ownership-transfer.ts +0 -42
- package/src/email/render/render-email.ts +0 -194
- package/src/email/render/security-alert.ts +0 -61
- package/src/email/render/share-granted.ts +0 -52
- package/src/email/render/unit-owner-granted.ts +0 -60
- /package/src/{email/render → notifications}/__tests__/__snapshots__/render-snapshot.test.ts.snap +0 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The stock wording and the value formats every kind's copy is built from
|
|
3
|
+
* (ADR-CONTRACTS-086).
|
|
4
|
+
*
|
|
5
|
+
* `compose` needs a few things that are neither the content MODEL nor any
|
|
6
|
+
* channel's markup: a reassurance line every kind ends on, the phrasing for an
|
|
7
|
+
* access level, and the two display formats the kinds agree on. They lived in
|
|
8
|
+
* the email layer's `blocks.ts` alongside the markup because that was the only
|
|
9
|
+
* layer there was. They are here because what a notification SAYS is the same
|
|
10
|
+
* on every channel — an SMS reassures in the same words email does.
|
|
11
|
+
*
|
|
12
|
+
* INVARIANTS:
|
|
13
|
+
* - Channel-agnostic. Nothing here may emit markup. The moment one of these
|
|
14
|
+
* returns a tag, the seam ADR-CONTRACTS-086 draws is back where it started.
|
|
15
|
+
* - Pure. `formatExpiry` builds a `Date` from its ARGUMENT, which is not a clock
|
|
16
|
+
* read; nothing here may add one.
|
|
17
|
+
* - These strings are output. The output-parity golden asserts them
|
|
18
|
+
* byte-for-byte, so a wording change here changes a real sent email.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** The standard "no action required" reassurance line. */
|
|
22
|
+
export const NOTICE = "If you weren't expecting this, no action is required.";
|
|
23
|
+
|
|
24
|
+
/** Human phrasing per access level (share-granted, access-approved). */
|
|
25
|
+
export const ACCESS_PHRASE: Record<"editor" | "commenter" | "viewer", string> =
|
|
26
|
+
{
|
|
27
|
+
editor: "can edit",
|
|
28
|
+
commenter: "can comment",
|
|
29
|
+
viewer: "can view",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Title-case a display value — capitalize the first letter of each word, e.g. a
|
|
33
|
+
* role like "admin" → "Admin". Leaves already-capitalized letters untouched. */
|
|
34
|
+
export function titleCase(s: string): string {
|
|
35
|
+
return s.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Format an ISO timestamp as "Jun 13, 2026". Pinned to en-US + UTC so the
|
|
40
|
+
* output is locale-/timezone-independent and rendered bytes stay deterministic.
|
|
41
|
+
* Returns the raw input unchanged if it is not a parseable date.
|
|
42
|
+
*/
|
|
43
|
+
export function formatExpiry(iso: string): string {
|
|
44
|
+
const d = new Date(iso);
|
|
45
|
+
if (Number.isNaN(d.getTime())) return iso;
|
|
46
|
+
return d.toLocaleDateString("en-US", {
|
|
47
|
+
month: "short",
|
|
48
|
+
day: "numeric",
|
|
49
|
+
year: "numeric",
|
|
50
|
+
timeZone: "UTC",
|
|
51
|
+
});
|
|
52
|
+
}
|
package/src/email/README.md
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
# email/
|
|
2
|
-
|
|
3
|
-
## Purpose
|
|
4
|
-
|
|
5
|
-
Transactional email vocabulary **and** render layer: kind types, the subject
|
|
6
|
-
registry, and the shared component system (`render/`) that both the backend
|
|
7
|
-
(real sends) and the app (Ladle preview) compose. See ADR-CONTRACTS-077.
|
|
8
|
-
|
|
9
|
-
## Invariants
|
|
10
|
-
|
|
11
|
-
- Types + **pure** render functions only — no classes, no side effects, no
|
|
12
|
-
external imports beyond `zod` (vocabulary-guard enforced).
|
|
13
|
-
- `render/blocks.ts` owns all styled markup; templates supply only content text.
|
|
14
|
-
Editing a block restyles every email — HTML and plain text — in both repos.
|
|
15
|
-
- `renderEmail(kind, payload, options?)` is the single dispatcher; subject comes
|
|
16
|
-
from `EMAIL_KINDS`, never hardcoded in a template.
|
|
17
|
-
- `EmailKind` union MUST match keys in `EMAIL_KINDS` registry.
|
|
18
|
-
- OTP values MUST NEVER appear as literals in contracts (security: logged if
|
|
19
|
-
leaked) — they are runtime payload fields only.
|
|
20
|
-
- Every user-controlled field in an HTML variant MUST pass through `escapeHtml`.
|
|
21
|
-
- Rendered output is locked by `render/__tests__/render-snapshot.test.ts`
|
|
22
|
-
(production-parity markup — a snapshot change means a real email changed).
|
|
23
|
-
- Unknown email kinds MUST be rejected at API boundaries via `isValidEmailKind()`.
|
|
24
|
-
|
|
25
|
-
<!-- BEGIN GENERATED: readme-public-api — derived from code by `pnpm readme-api`. Do not edit. -->
|
|
26
|
-
|
|
27
|
-
## Public API
|
|
28
|
-
|
|
29
|
-
- `EMAIL_KINDS` — EMAILKINDS is the authoritative registry.
|
|
30
|
-
- `EmailKind` _(type)_ — EmailKind identifies the type of transactional email.
|
|
31
|
-
- `EmailKindDefinition` _(type)_ — Complete definition for an email kind.
|
|
32
|
-
- `EmailPayloads` _(type)_ — Type-safe payload mapping for each email kind.
|
|
33
|
-
- `SendEmailInput` _(type)_ — Type-safe input for sending emails.
|
|
34
|
-
- `getEmailKindDefinition` — Type-safe registry lookup.
|
|
35
|
-
- `isValidEmailKind` — Check if a string is a valid EmailKind.
|
|
36
|
-
|
|
37
|
-
<!-- END GENERATED: readme-public-api -->
|
|
38
|
-
|
|
39
|
-
<!-- BEGIN GENERATED: readme-dependencies — derived from code by `pnpm readme-api`. Do not edit. -->
|
|
40
|
-
|
|
41
|
-
## Dependencies
|
|
42
|
-
|
|
43
|
-
**Internal domains:**
|
|
44
|
-
|
|
45
|
-
_None._
|
|
46
|
-
|
|
47
|
-
**External packages:**
|
|
48
|
-
|
|
49
|
-
_None._
|
|
50
|
-
|
|
51
|
-
<!-- END GENERATED: readme-dependencies -->
|
|
@@ -1,161 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import {
|
|
3
|
-
EMAIL_KINDS,
|
|
4
|
-
getEmailKindDefinition,
|
|
5
|
-
isValidEmailKind,
|
|
6
|
-
} from "../registry.js";
|
|
7
|
-
|
|
8
|
-
describe("EMAIL_KINDS golden snapshot", () => {
|
|
9
|
-
it("exact values are frozen", () => {
|
|
10
|
-
expect(EMAIL_KINDS).toStrictEqual({
|
|
11
|
-
"auth.otp": {
|
|
12
|
-
kind: "auth.otp",
|
|
13
|
-
subject: "Your login code for Company Semantics",
|
|
14
|
-
plainTextRequired: true,
|
|
15
|
-
htmlSupported: false,
|
|
16
|
-
},
|
|
17
|
-
"auth.magic_link": {
|
|
18
|
-
kind: "auth.magic_link",
|
|
19
|
-
subject: "Your login link",
|
|
20
|
-
plainTextRequired: true,
|
|
21
|
-
htmlSupported: false,
|
|
22
|
-
},
|
|
23
|
-
"org.invite": {
|
|
24
|
-
kind: "org.invite",
|
|
25
|
-
subject: "You've been invited to join {orgName} on Company Semantics",
|
|
26
|
-
plainTextRequired: true,
|
|
27
|
-
htmlSupported: true,
|
|
28
|
-
},
|
|
29
|
-
"org.unit_owner_granted": {
|
|
30
|
-
kind: "org.unit_owner_granted",
|
|
31
|
-
subject: "You've been added as {roleWord} to {unitName} in {orgName}",
|
|
32
|
-
plainTextRequired: true,
|
|
33
|
-
htmlSupported: true,
|
|
34
|
-
},
|
|
35
|
-
"org.ownership_transfer": {
|
|
36
|
-
kind: "org.ownership_transfer",
|
|
37
|
-
subject: "You've been invited to become a workspace owner",
|
|
38
|
-
plainTextRequired: true,
|
|
39
|
-
htmlSupported: false,
|
|
40
|
-
},
|
|
41
|
-
"org.ownership_transfer_completed": {
|
|
42
|
-
kind: "org.ownership_transfer_completed",
|
|
43
|
-
subject: "Workspace ownership has been transferred",
|
|
44
|
-
plainTextRequired: true,
|
|
45
|
-
htmlSupported: false,
|
|
46
|
-
},
|
|
47
|
-
"security.alert": {
|
|
48
|
-
kind: "security.alert",
|
|
49
|
-
subject: "Security alert for your account",
|
|
50
|
-
plainTextRequired: true,
|
|
51
|
-
htmlSupported: false,
|
|
52
|
-
},
|
|
53
|
-
"chat.shared": {
|
|
54
|
-
kind: "chat.shared",
|
|
55
|
-
subject: "A chat has been shared with you",
|
|
56
|
-
plainTextRequired: true,
|
|
57
|
-
htmlSupported: true,
|
|
58
|
-
},
|
|
59
|
-
"share.granted": {
|
|
60
|
-
kind: "share.granted",
|
|
61
|
-
subject: "Something has been shared with you",
|
|
62
|
-
plainTextRequired: true,
|
|
63
|
-
htmlSupported: true,
|
|
64
|
-
},
|
|
65
|
-
"companyMd.access_requested": {
|
|
66
|
-
kind: "companyMd.access_requested",
|
|
67
|
-
subject: "Someone requested access to a document",
|
|
68
|
-
plainTextRequired: true,
|
|
69
|
-
htmlSupported: true,
|
|
70
|
-
},
|
|
71
|
-
"companyMd.access_request_approved": {
|
|
72
|
-
kind: "companyMd.access_request_approved",
|
|
73
|
-
subject: "Your access request was approved",
|
|
74
|
-
plainTextRequired: true,
|
|
75
|
-
htmlSupported: true,
|
|
76
|
-
},
|
|
77
|
-
"companyMd.access_request_denied": {
|
|
78
|
-
kind: "companyMd.access_request_denied",
|
|
79
|
-
subject: "Your access request was reviewed",
|
|
80
|
-
plainTextRequired: true,
|
|
81
|
-
htmlSupported: true,
|
|
82
|
-
},
|
|
83
|
-
});
|
|
84
|
-
});
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
describe("EMAIL_KINDS registry invariants", () => {
|
|
88
|
-
it("every registry key matches its definition.kind field", () => {
|
|
89
|
-
for (const [key, def] of Object.entries(EMAIL_KINDS)) {
|
|
90
|
-
expect(def.kind).toBe(key);
|
|
91
|
-
}
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
it("all subjects are non-empty strings", () => {
|
|
95
|
-
for (const def of Object.values(EMAIL_KINDS)) {
|
|
96
|
-
expect(typeof def.subject).toBe("string");
|
|
97
|
-
expect(def.subject.length).toBeGreaterThan(0);
|
|
98
|
-
}
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
it("all entries have required boolean fields plainTextRequired and htmlSupported", () => {
|
|
102
|
-
for (const def of Object.values(EMAIL_KINDS)) {
|
|
103
|
-
expect(typeof def.plainTextRequired).toBe("boolean");
|
|
104
|
-
expect(typeof def.htmlSupported).toBe("boolean");
|
|
105
|
-
}
|
|
106
|
-
});
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
describe("getEmailKindDefinition", () => {
|
|
110
|
-
it("returns correct definition for auth.otp", () => {
|
|
111
|
-
const def = getEmailKindDefinition("auth.otp");
|
|
112
|
-
expect(def).toStrictEqual(EMAIL_KINDS["auth.otp"]);
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
it("returns correct definition for auth.magic_link", () => {
|
|
116
|
-
const def = getEmailKindDefinition("auth.magic_link");
|
|
117
|
-
expect(def).toStrictEqual(EMAIL_KINDS["auth.magic_link"]);
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
it("returns correct definition for org.invite", () => {
|
|
121
|
-
const def = getEmailKindDefinition("org.invite");
|
|
122
|
-
expect(def).toStrictEqual(EMAIL_KINDS["org.invite"]);
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
it("returns correct definition for security.alert", () => {
|
|
126
|
-
const def = getEmailKindDefinition("security.alert");
|
|
127
|
-
expect(def).toStrictEqual(EMAIL_KINDS["security.alert"]);
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
it("returns correct definition for chat.shared", () => {
|
|
131
|
-
const def = getEmailKindDefinition("chat.shared");
|
|
132
|
-
expect(def).toStrictEqual(EMAIL_KINDS["chat.shared"]);
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
it("return value matches registry entry exactly", () => {
|
|
136
|
-
for (const [key, expected] of Object.entries(EMAIL_KINDS)) {
|
|
137
|
-
const def = getEmailKindDefinition(key as keyof typeof EMAIL_KINDS);
|
|
138
|
-
expect(def).toBe(expected);
|
|
139
|
-
}
|
|
140
|
-
});
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
describe("isValidEmailKind", () => {
|
|
144
|
-
it("returns true for all valid email kinds", () => {
|
|
145
|
-
for (const kind of Object.keys(EMAIL_KINDS)) {
|
|
146
|
-
expect(isValidEmailKind(kind)).toBe(true);
|
|
147
|
-
}
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
it("returns false for unknown string", () => {
|
|
151
|
-
expect(isValidEmailKind("unknown.kind")).toBe(false);
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
it("returns false for empty string", () => {
|
|
155
|
-
expect(isValidEmailKind("")).toBe(false);
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
it("returns false for partial match", () => {
|
|
159
|
-
expect(isValidEmailKind("auth")).toBe(false);
|
|
160
|
-
});
|
|
161
|
-
});
|
package/src/email/index.ts
DELETED
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Email Domain Barrel
|
|
3
|
-
*
|
|
4
|
-
* Re-exports email kind vocabulary types and registry.
|
|
5
|
-
* Import from '@company-semantics/contracts/email'.
|
|
6
|
-
*
|
|
7
|
-
* @see ADR-CONT-034 for design rationale
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
// =============================================================================
|
|
11
|
-
// Kind Types
|
|
12
|
-
// =============================================================================
|
|
13
|
-
|
|
14
|
-
export type { EmailKind, EmailPayloads, SendEmailInput } from "./types";
|
|
15
|
-
|
|
16
|
-
// =============================================================================
|
|
17
|
-
// Definition Types
|
|
18
|
-
// =============================================================================
|
|
19
|
-
|
|
20
|
-
export type { EmailKindDefinition } from "./registry";
|
|
21
|
-
|
|
22
|
-
// =============================================================================
|
|
23
|
-
// Registry
|
|
24
|
-
// =============================================================================
|
|
25
|
-
|
|
26
|
-
export {
|
|
27
|
-
EMAIL_KINDS,
|
|
28
|
-
getEmailKindDefinition,
|
|
29
|
-
isValidEmailKind,
|
|
30
|
-
} from "./registry";
|
|
31
|
-
|
|
32
|
-
// =============================================================================
|
|
33
|
-
// Render Layer
|
|
34
|
-
// =============================================================================
|
|
35
|
-
|
|
36
|
-
export * from "./render";
|
package/src/email/registry.ts
DELETED
|
@@ -1,155 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Email Kind Registry
|
|
3
|
-
*
|
|
4
|
-
* Central registry of all email kinds and their definitions.
|
|
5
|
-
* This is the single source of truth for email metadata.
|
|
6
|
-
*
|
|
7
|
-
* Invariants:
|
|
8
|
-
* - Every EmailKind MUST have an entry in EMAIL_KINDS
|
|
9
|
-
* - Registry keys MUST match definition.kind
|
|
10
|
-
* - Registry is exhaustive (satisfies Record<EmailKind, ...>)
|
|
11
|
-
* - Subjects are owned here, not duplicated in templates
|
|
12
|
-
*
|
|
13
|
-
* @see ADR-CONT-034 for design rationale
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import type { EmailKind } from "./types";
|
|
17
|
-
|
|
18
|
-
// =============================================================================
|
|
19
|
-
// Email Kind Definition
|
|
20
|
-
// =============================================================================
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Complete definition for an email kind.
|
|
24
|
-
*
|
|
25
|
-
* This interface is the schema for entries in EMAIL_KINDS registry.
|
|
26
|
-
* It captures subject, rendering requirements, and domain metadata.
|
|
27
|
-
*
|
|
28
|
-
* Invariants:
|
|
29
|
-
* - kind field MUST match the registry key
|
|
30
|
-
* - subject is the authoritative source (templates import from here)
|
|
31
|
-
*/
|
|
32
|
-
export interface EmailKindDefinition {
|
|
33
|
-
/** The email kind this definition describes */
|
|
34
|
-
kind: EmailKind;
|
|
35
|
-
/** Email subject line (single source of truth). May contain `{field}`
|
|
36
|
-
* placeholders that `renderEmail` fills from the payload (e.g. `{orgName}`);
|
|
37
|
-
* an unmatched placeholder is left verbatim. */
|
|
38
|
-
subject: string;
|
|
39
|
-
/** Whether plain text body is required */
|
|
40
|
-
plainTextRequired: boolean;
|
|
41
|
-
/** Whether HTML body is supported */
|
|
42
|
-
htmlSupported: boolean;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// =============================================================================
|
|
46
|
-
// Registry
|
|
47
|
-
// =============================================================================
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* EMAIL_KINDS is the authoritative registry.
|
|
51
|
-
*
|
|
52
|
-
* All subjects, rendering rules, and domain metadata are derived from here.
|
|
53
|
-
* Backend email templates MUST use this registry for subjects
|
|
54
|
-
* rather than hardcoding values.
|
|
55
|
-
*
|
|
56
|
-
* To add a new kind:
|
|
57
|
-
* 1. Add to EmailKind union in types.ts
|
|
58
|
-
* 2. Add entry to this registry
|
|
59
|
-
* 3. Add payload to EmailPayloads if needed
|
|
60
|
-
* 4. Implement template in backend
|
|
61
|
-
*/
|
|
62
|
-
export const EMAIL_KINDS = {
|
|
63
|
-
"auth.otp": {
|
|
64
|
-
kind: "auth.otp",
|
|
65
|
-
subject: "Your login code for Company Semantics",
|
|
66
|
-
plainTextRequired: true,
|
|
67
|
-
htmlSupported: false,
|
|
68
|
-
},
|
|
69
|
-
"auth.magic_link": {
|
|
70
|
-
kind: "auth.magic_link",
|
|
71
|
-
subject: "Your login link",
|
|
72
|
-
plainTextRequired: true,
|
|
73
|
-
htmlSupported: false,
|
|
74
|
-
},
|
|
75
|
-
"org.invite": {
|
|
76
|
-
kind: "org.invite",
|
|
77
|
-
subject: "You've been invited to join {orgName} on Company Semantics",
|
|
78
|
-
plainTextRequired: true,
|
|
79
|
-
htmlSupported: true,
|
|
80
|
-
},
|
|
81
|
-
"org.unit_owner_granted": {
|
|
82
|
-
kind: "org.unit_owner_granted",
|
|
83
|
-
subject: "You've been added as {roleWord} to {unitName} in {orgName}",
|
|
84
|
-
plainTextRequired: true,
|
|
85
|
-
htmlSupported: true,
|
|
86
|
-
},
|
|
87
|
-
"org.ownership_transfer": {
|
|
88
|
-
kind: "org.ownership_transfer",
|
|
89
|
-
subject: "You've been invited to become a workspace owner",
|
|
90
|
-
plainTextRequired: true,
|
|
91
|
-
htmlSupported: false,
|
|
92
|
-
},
|
|
93
|
-
"org.ownership_transfer_completed": {
|
|
94
|
-
kind: "org.ownership_transfer_completed",
|
|
95
|
-
subject: "Workspace ownership has been transferred",
|
|
96
|
-
plainTextRequired: true,
|
|
97
|
-
htmlSupported: false,
|
|
98
|
-
},
|
|
99
|
-
"security.alert": {
|
|
100
|
-
kind: "security.alert",
|
|
101
|
-
subject: "Security alert for your account",
|
|
102
|
-
plainTextRequired: true,
|
|
103
|
-
htmlSupported: false,
|
|
104
|
-
},
|
|
105
|
-
"chat.shared": {
|
|
106
|
-
kind: "chat.shared",
|
|
107
|
-
subject: "A chat has been shared with you",
|
|
108
|
-
plainTextRequired: true,
|
|
109
|
-
htmlSupported: true,
|
|
110
|
-
},
|
|
111
|
-
"share.granted": {
|
|
112
|
-
kind: "share.granted",
|
|
113
|
-
subject: "Something has been shared with you",
|
|
114
|
-
plainTextRequired: true,
|
|
115
|
-
htmlSupported: true,
|
|
116
|
-
},
|
|
117
|
-
"companyMd.access_requested": {
|
|
118
|
-
kind: "companyMd.access_requested",
|
|
119
|
-
subject: "Someone requested access to a document",
|
|
120
|
-
plainTextRequired: true,
|
|
121
|
-
htmlSupported: true,
|
|
122
|
-
},
|
|
123
|
-
"companyMd.access_request_approved": {
|
|
124
|
-
kind: "companyMd.access_request_approved",
|
|
125
|
-
subject: "Your access request was approved",
|
|
126
|
-
plainTextRequired: true,
|
|
127
|
-
htmlSupported: true,
|
|
128
|
-
},
|
|
129
|
-
"companyMd.access_request_denied": {
|
|
130
|
-
kind: "companyMd.access_request_denied",
|
|
131
|
-
subject: "Your access request was reviewed",
|
|
132
|
-
plainTextRequired: true,
|
|
133
|
-
htmlSupported: true,
|
|
134
|
-
},
|
|
135
|
-
} as const satisfies Record<EmailKind, EmailKindDefinition>;
|
|
136
|
-
|
|
137
|
-
// =============================================================================
|
|
138
|
-
// Registry Helpers
|
|
139
|
-
// =============================================================================
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Type-safe registry lookup.
|
|
143
|
-
* Returns the definition for a given email kind.
|
|
144
|
-
*/
|
|
145
|
-
export function getEmailKindDefinition(kind: EmailKind): EmailKindDefinition {
|
|
146
|
-
return EMAIL_KINDS[kind];
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/**
|
|
150
|
-
* Check if a string is a valid EmailKind.
|
|
151
|
-
* Use at API boundaries to reject unknown kinds.
|
|
152
|
-
*/
|
|
153
|
-
export function isValidEmailKind(kind: string): kind is EmailKind {
|
|
154
|
-
return kind in EMAIL_KINDS;
|
|
155
|
-
}
|
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Auth OTP email (login code).
|
|
3
|
-
*
|
|
4
|
-
* INVARIANT: the OTP value is a runtime payload field — never a literal in this
|
|
5
|
-
* package. Request metadata (IP / user agent) is gated by the caller-supplied
|
|
6
|
-
* `includeRequestMetadata` option (backend reads its env flag and passes it).
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import type { EmailPayloads } from "../types";
|
|
10
|
-
|
|
11
|
-
import {
|
|
12
|
-
type Block,
|
|
13
|
-
footer,
|
|
14
|
-
greeting,
|
|
15
|
-
keyValue,
|
|
16
|
-
paragraph,
|
|
17
|
-
signature,
|
|
18
|
-
} from "./blocks";
|
|
19
|
-
import { chatAssistant, chatCta, chatUnit } from "./chat";
|
|
20
|
-
import { COMPANY_NAME } from "./constants";
|
|
21
|
-
|
|
22
|
-
export type AuthOtpPayload = EmailPayloads["auth.otp"];
|
|
23
|
-
|
|
24
|
-
export interface RenderOptions {
|
|
25
|
-
/** Include request IP / device details (PII; opt-in). */
|
|
26
|
-
includeRequestMetadata?: boolean;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export function renderAuthOtp(
|
|
30
|
-
payload: AuthOtpPayload,
|
|
31
|
-
options?: RenderOptions,
|
|
32
|
-
): Block[] {
|
|
33
|
-
const { otp, expiresInMinutes, requestIp, userAgent } = payload;
|
|
34
|
-
|
|
35
|
-
const blocks: Block[] = [
|
|
36
|
-
greeting(),
|
|
37
|
-
paragraph("A login code was requested."),
|
|
38
|
-
chatUnit(
|
|
39
|
-
chatAssistant("Copy + paste this code in the login form."),
|
|
40
|
-
chatCta({ label: otp }),
|
|
41
|
-
),
|
|
42
|
-
keyValue("Status", "Valid"),
|
|
43
|
-
keyValue(
|
|
44
|
-
"Expires in",
|
|
45
|
-
`${expiresInMinutes} ${expiresInMinutes === 1 ? "minute" : "minutes"}`,
|
|
46
|
-
"normal",
|
|
47
|
-
),
|
|
48
|
-
];
|
|
49
|
-
|
|
50
|
-
// PRIVACY: IP address is PII under GDPR, so this is opt-in.
|
|
51
|
-
if (options?.includeRequestMetadata && (requestIp || userAgent)) {
|
|
52
|
-
blocks.push(paragraph("Request details:", "tight"));
|
|
53
|
-
if (requestIp) blocks.push(keyValue("IP address", requestIp));
|
|
54
|
-
if (userAgent) {
|
|
55
|
-
const truncated =
|
|
56
|
-
userAgent.length > 80 ? userAgent.slice(0, 77) + "..." : userAgent;
|
|
57
|
-
blocks.push(keyValue("Device", truncated, "normal"));
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
blocks.push(
|
|
62
|
-
footer(
|
|
63
|
-
`This login code was sent via ${COMPANY_NAME}.`,
|
|
64
|
-
"If this wasn't you, no action is required.",
|
|
65
|
-
"none",
|
|
66
|
-
),
|
|
67
|
-
signature(),
|
|
68
|
-
);
|
|
69
|
-
|
|
70
|
-
return blocks;
|
|
71
|
-
}
|