@company-semantics/contracts 34.0.0 → 35.1.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.
Files changed (76) hide show
  1. package/package.json +4 -4
  2. package/src/index.ts +17 -11
  3. package/src/notifications/README.md +142 -0
  4. package/src/notifications/__tests__/README.md +49 -0
  5. package/src/notifications/__tests__/__snapshots__/README.md +39 -0
  6. package/src/notifications/__tests__/__snapshots__/registry.test.ts.snap +31 -0
  7. package/src/{email/render → notifications}/__tests__/__snapshots__/render-snapshot.test.ts.snap +2 -2
  8. package/src/notifications/__tests__/content.test.ts +186 -0
  9. package/src/notifications/__tests__/context.test.ts +72 -0
  10. package/src/notifications/__tests__/definition.test.ts +222 -0
  11. package/src/notifications/__tests__/fixtures.ts +226 -0
  12. package/src/notifications/__tests__/kinds.test.ts +80 -0
  13. package/src/notifications/__tests__/registry.test.ts +184 -0
  14. package/src/{email/render → notifications}/__tests__/render-snapshot.test.ts +62 -29
  15. package/src/notifications/__tests__/renderer.test.ts +181 -0
  16. package/src/notifications/content.ts +249 -0
  17. package/src/notifications/context.ts +70 -0
  18. package/src/notifications/definition.ts +82 -0
  19. package/src/notifications/index.ts +104 -0
  20. package/src/notifications/kinds/README.md +57 -0
  21. package/src/notifications/kinds/auth-otp.ts +99 -0
  22. package/src/notifications/kinds/chat-shared.ts +56 -0
  23. package/src/notifications/kinds/company-md-access-approved.ts +57 -0
  24. package/src/notifications/kinds/company-md-access-denied.ts +61 -0
  25. package/src/notifications/kinds/company-md-access-requested.ts +65 -0
  26. package/src/notifications/kinds/index.ts +19 -0
  27. package/src/notifications/kinds/org-invite.ts +62 -0
  28. package/src/notifications/kinds/ownership-transfer-completed.ts +57 -0
  29. package/src/notifications/kinds/ownership-transfer.ts +68 -0
  30. package/src/notifications/kinds/security-alert.ts +78 -0
  31. package/src/notifications/kinds/share-granted.ts +74 -0
  32. package/src/notifications/kinds/unit-owner-granted.ts +95 -0
  33. package/src/notifications/kinds.ts +68 -0
  34. package/src/{email/types.ts → notifications/payloads.ts} +33 -68
  35. package/src/notifications/registry.ts +107 -0
  36. package/src/notifications/render.ts +106 -0
  37. package/src/notifications/renderer.ts +50 -0
  38. package/src/notifications/renderers/README.md +50 -0
  39. package/src/notifications/renderers/email/README.md +78 -0
  40. package/src/notifications/renderers/email/__tests__/README.md +32 -0
  41. package/src/notifications/renderers/email/__tests__/render.test.ts +188 -0
  42. package/src/{email/render → notifications/renderers/email}/chat.ts +54 -107
  43. package/src/notifications/renderers/email/constants.ts +64 -0
  44. package/src/notifications/renderers/email/cta.ts +63 -0
  45. package/src/{email/render → notifications/renderers/email}/escape-html.ts +5 -1
  46. package/src/notifications/renderers/email/index.ts +73 -0
  47. package/src/notifications/renderers/email/render.ts +238 -0
  48. package/src/notifications/renderers/email/shells.ts +61 -0
  49. package/src/notifications/renderers/slack/README.md +73 -0
  50. package/src/notifications/renderers/slack/__tests__/README.md +33 -0
  51. package/src/notifications/renderers/slack/__tests__/index.test.ts +201 -0
  52. package/src/notifications/renderers/slack/index.ts +261 -0
  53. package/src/notifications/renderers/sms/README.md +69 -0
  54. package/src/notifications/renderers/sms/__tests__/README.md +31 -0
  55. package/src/notifications/renderers/sms/__tests__/index.test.ts +162 -0
  56. package/src/notifications/renderers/sms/index.ts +131 -0
  57. package/src/notifications/text.ts +52 -0
  58. package/src/email/README.md +0 -51
  59. package/src/email/__tests__/registry.test.ts +0 -161
  60. package/src/email/index.ts +0 -36
  61. package/src/email/registry.ts +0 -155
  62. package/src/email/render/auth-otp.ts +0 -71
  63. package/src/email/render/blocks.ts +0 -281
  64. package/src/email/render/chat-shared.ts +0 -35
  65. package/src/email/render/company-md-access-approved.ts +0 -42
  66. package/src/email/render/company-md-access-denied.ts +0 -43
  67. package/src/email/render/company-md-access-requested.ts +0 -46
  68. package/src/email/render/constants.ts +0 -18
  69. package/src/email/render/index.ts +0 -58
  70. package/src/email/render/org-invite.ts +0 -40
  71. package/src/email/render/ownership-transfer-completed.ts +0 -41
  72. package/src/email/render/ownership-transfer.ts +0 -42
  73. package/src/email/render/render-email.ts +0 -194
  74. package/src/email/render/security-alert.ts +0 -61
  75. package/src/email/render/share-granted.ts +0 -52
  76. package/src/email/render/unit-owner-granted.ts +0 -60
@@ -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
- }
@@ -1,281 +0,0 @@
1
- /**
2
- * Shared email building blocks — dual-output components.
3
- *
4
- * Every component returns a `Block` (`{ html, text }`), so a template composes
5
- * ONE list of blocks and both surfaces derive from the same source. Principle:
6
- * **blocks own the styling; templates supply only content — the only allowable
7
- * UI is the components.** Editing a block restyles every email, HTML and plain
8
- * text, across the backend (real sends) and the app (Ladle preview).
9
- *
10
- * This module holds the primitives — shells, paragraphs, and the CTA box. The
11
- * chat unit builds on them from `./chat`; both are re-exported together from
12
- * `./index`, which is the surface templates and consumers import.
13
- *
14
- * INVARIANTS:
15
- * - Pure functions, no side effects — except `signature()`, which reads the
16
- * current year (`new Date().getFullYear()`) for the copyright line.
17
- * - Components escape their own content; templates pass raw text (+ `bold(...)`
18
- * for inline emphasis). No template hand-writes markup or raw strings.
19
- */
20
-
21
- import { COMPANY_NAME, MONO_FONT_STACK } from "./constants";
22
- import { escapeHtml } from "./escape-html";
23
-
24
- /** Shared type styling. Exported for `./chat` only — not part of the package
25
- * surface (`./index` does not re-export these), so components stay the one
26
- * place styling is decided. */
27
- export const MONO = `font-family: ${MONO_FONT_STACK};`;
28
-
29
- /** The single font size for every email element (HTML). Plain text is monospace
30
- * so it carries no size — this keeps one visual size across both surfaces. */
31
- export const FONT_SIZE = "13px";
32
-
33
- // =============================================================================
34
- // Core types
35
- // =============================================================================
36
-
37
- /** A rendered block — both presentations of one component. `spacing` controls
38
- * the plain-text gap AFTER this block ("normal" = blank line, else none). */
39
- export interface Block {
40
- html: string;
41
- text: string;
42
- spacing: Spacing;
43
- }
44
-
45
- /** An inline segment — for in-line emphasis inside a paragraph. */
46
- export interface Inline {
47
- html: string;
48
- text: string;
49
- }
50
-
51
- /** Paragraph content: raw string(s) (auto-escaped for HTML) and/or `bold(...)`. */
52
- export type InlineContent = string | Inline | Array<string | Inline>;
53
-
54
- /** Inline emphasis segment. Emphasis is currently visually flat — it renders
55
- * plain (no bold) in both surfaces; the seam is kept so all inline emphasis can
56
- * be restyled in one place. */
57
- export function bold(s: string): Inline {
58
- return { html: escapeHtml(s), text: s };
59
- }
60
-
61
- /** Title-case a display value — capitalize the first letter of each word, e.g. a
62
- * role like "admin" → "Admin". Leaves already-capitalized letters untouched. */
63
- export function titleCase(s: string): string {
64
- return s.replace(/\b\w/g, (c) => c.toUpperCase());
65
- }
66
-
67
- /** Normalize a segment: a raw string is escaped for HTML, passed through for text. */
68
- function toInline(seg: string | Inline): Inline {
69
- return typeof seg === "string" ? { html: escapeHtml(seg), text: seg } : seg;
70
- }
71
-
72
- function renderInline(content: InlineContent): { html: string; text: string } {
73
- const segs = (Array.isArray(content) ? content : [content]).map(toInline);
74
- return {
75
- html: segs.map((s) => s.html).join(""),
76
- text: segs.map((s) => s.text).join(""),
77
- };
78
- }
79
-
80
- /** Vertical spacing options for a paragraph. */
81
- export type Spacing = "normal" | "tight" | "none";
82
- const SPACING: Record<Spacing, string> = {
83
- normal: "0 0 20px 0",
84
- tight: "0 0 4px 0",
85
- none: "0",
86
- };
87
-
88
- // =============================================================================
89
- // Shells
90
- // =============================================================================
91
-
92
- /** Wrap body blocks in the shared `<!DOCTYPE>` monospace shell. */
93
- export function htmlShell(blocks: Block[]): string {
94
- const inner = blocks
95
- .map((b) => b.html)
96
- .filter(Boolean)
97
- .join("\n");
98
- return `<!DOCTYPE html>
99
- <html lang="en">
100
- <head><meta charset="UTF-8"></head>
101
- <body style="${MONO} color: #1a1a1a; margin: 0; padding: 0;">
102
- <div style="max-width: 520px; margin: 0 auto;">
103
- ${inner}
104
- </div>
105
- </body>
106
- </html>`;
107
- }
108
-
109
- /** Join body blocks into the plain-text email (blank-line separated per each
110
- * block's spacing, trailing newline). */
111
- export function textShell(blocks: Block[]): string {
112
- let out = "";
113
- blocks.forEach((b, i) => {
114
- out += b.text;
115
- if (i < blocks.length - 1) out += b.spacing === "normal" ? "\n\n" : "\n";
116
- });
117
- return out.trim() + "\n";
118
- }
119
-
120
- // =============================================================================
121
- // Paragraphs
122
- // =============================================================================
123
-
124
- /**
125
- * Email paragraph — mono (matching the body shell), 13px, configurable spacing.
126
- * The single paragraph primitive: greeting / keyValue / footer / signature all
127
- * build on it, and templates use it directly for body lines.
128
- */
129
- export function paragraph(
130
- content: InlineContent,
131
- spacing: Spacing = "normal",
132
- ): Block {
133
- const { html, text } = renderInline(content);
134
- return {
135
- html: `<p style="${MONO} font-size: ${FONT_SIZE}; margin: ${SPACING[spacing]};">${html}</p>`,
136
- text,
137
- spacing,
138
- };
139
- }
140
-
141
- /** "Hi Name," greeting; falls back to "Hi there," when the name is unknown. */
142
- export function greeting(recipientName?: string): Block {
143
- return paragraph(recipientName ? `Hi ${recipientName},` : "Hi there,");
144
- }
145
-
146
- /** Security banner — the "WARNING" wordmark, sits atop security emails. */
147
- export function security(): Block {
148
- return paragraph("🆆🅰🆁🅽🅸🅽🅶");
149
- }
150
-
151
- /**
152
- * Format an ISO timestamp as "Jun 13, 2026". Pinned to en-US + UTC so the
153
- * output is locale-/timezone-independent and render snapshots stay
154
- * deterministic. Returns the raw input unchanged if it is not a parseable date.
155
- */
156
- export function formatExpiry(iso: string): string {
157
- const d = new Date(iso);
158
- if (Number.isNaN(d.getTime())) return iso;
159
- return d.toLocaleDateString("en-US", {
160
- month: "short",
161
- day: "numeric",
162
- year: "numeric",
163
- timeZone: "UTC",
164
- });
165
- }
166
-
167
- /** "Label: **value**" key/value row (value bold in HTML; tight by default). */
168
- export function keyValue(
169
- label: string,
170
- value: string,
171
- spacing: Spacing = "tight",
172
- ): Block {
173
- return paragraph([`${label}: `, bold(value)], spacing);
174
- }
175
-
176
- /** Footer paragraph: first line + optional second line after a line break. */
177
- export function footer(
178
- firstLine: string,
179
- secondLine?: string,
180
- spacing: Spacing = "normal",
181
- ): Block {
182
- const html = secondLine
183
- ? `${escapeHtml(firstLine)}<br>${escapeHtml(secondLine)}`
184
- : escapeHtml(firstLine);
185
- const text = secondLine ? `${firstLine}\n${secondLine}` : firstLine;
186
- return {
187
- html: `<p style="${MONO} font-size: ${FONT_SIZE}; margin: ${SPACING[spacing]};">${html}</p>`,
188
- text,
189
- spacing,
190
- };
191
- }
192
-
193
- /**
194
- * Trailing sign-off — a leading blank line, the `---` rule, then the company
195
- * (or custom `signer`) name. Owns the blank above the rule so every sign-off is
196
- * spaced identically; the block before it carries no trailing gap.
197
- */
198
- export function signature(signer: string = COMPANY_NAME): Block {
199
- const line = `ⓒ ${new Date().getFullYear()} • ${signer}`;
200
- const url = "https://companysemantics.ai";
201
- return {
202
- html: `<p style="${MONO} font-size: ${FONT_SIZE}; margin: ${SPACING.none};"><br><br><span style="color: #bbb;">/* EOM */</span><br>${escapeHtml(line)}<br><a href="${url}" target="_blank" rel="noopener noreferrer" style="color: #0047FF; text-decoration: none;">${escapeHtml(url)}</a></p>`,
203
- text: `\n\n/* EOM */\n${line}\n${url}`,
204
- spacing: "none",
205
- };
206
- }
207
-
208
- /** The standard "no action required" reassurance line (content constant). */
209
- export const NOTICE = "If you weren't expecting this, no action is required.";
210
-
211
- /** Human phrasing per access level (share-granted, access-approved). */
212
- export const ACCESS_PHRASE: Record<"editor" | "commenter" | "viewer", string> =
213
- {
214
- editor: "can edit",
215
- commenter: "can comment",
216
- viewer: "can view",
217
- };
218
-
219
- // =============================================================================
220
- // CTA box
221
- // =============================================================================
222
-
223
- /** Options for the `>> LABEL <<` call-to-action box. Styling is component-owned
224
- * (the only allowable UI is the component) — callers supply only content. */
225
- export interface CtaBoxOptions {
226
- /** Text between the chevrons (already display-safe). */
227
- label: string;
228
- /** When present, the label links to this URL (and the URL rides under the
229
- * plain-text box). */
230
- href?: string;
231
- }
232
-
233
- /** Padding columns on each side of the `>> LABEL <<` line in the ASCII box. */
234
- const CTA_BOX_PAD = 3;
235
-
236
- /** `>> LABEL <<` ASCII box (`*` corners): 3 lines, sized to the label. */
237
- function asciiCtaBox(label: string): string {
238
- const pad = " ".repeat(CTA_BOX_PAD);
239
- const inner = `${pad}>> ${label} <<${pad}`;
240
- const border = `*${"-".repeat(inner.length)}*`;
241
- return [border, `|${inner}|`, border].join("\n");
242
- }
243
-
244
- /** The bordered `>> LABEL <<` button (HTML table + ascii text), with `margin` on
245
- * the table. `ctaBox` wraps it as a standalone block; a chat unit embeds it
246
- * (hence the export — internal to the render layer, not re-exported by `./index`). */
247
- export function ctaButton(
248
- opts: CtaBoxOptions,
249
- margin: string,
250
- ): { html: string; text: string } {
251
- const { label, href } = opts;
252
-
253
- const tableStyle = `display: inline-block; border: 1px solid #666; border-radius: 2px; margin: ${margin}; max-width: 220px;`;
254
- const tdStyle = `padding: 16px 24px; text-align: center; ${MONO} font-size: ${FONT_SIZE};`;
255
-
256
- // Underline only the label text (not the chevrons/spaces), and only when linked.
257
- const labelHtml = href
258
- ? `<span style="text-decoration: underline;">${escapeHtml(label)}</span>`
259
- : escapeHtml(label);
260
- const chevrons = `&gt;&gt; ${labelHtml} &lt;&lt;`;
261
- const inner = href
262
- ? `<a href="${href}" style="color: #0047FF; text-decoration: none;">${chevrons}</a>`
263
- : chevrons;
264
-
265
- const html = `<table cellpadding="0" cellspacing="0" border="0" style="${tableStyle}">
266
- <tr><td style="${tdStyle}">
267
- ${inner}
268
- </td></tr>
269
- </table>`;
270
-
271
- const text = href ? `${asciiCtaBox(label)}\n\n${href}` : asciiCtaBox(label);
272
-
273
- return { html, text };
274
- }
275
-
276
- /** The bordered `>> LABEL <<` CTA box (OTP / JOIN / OPEN / VIEW TEAM / …). When
277
- * `href` is given, HTML links the label and plain text prints the URL below. */
278
- export function ctaBox(opts: CtaBoxOptions): Block {
279
- const { html, text } = ctaButton(opts, "0 0 20px 0");
280
- return { html, text, spacing: "normal" };
281
- }
@@ -1,35 +0,0 @@
1
- /**
2
- * Chat-shared email (someone shared a chat).
3
- */
4
-
5
- import type { EmailPayloads } from "../types";
6
-
7
- import {
8
- type Block,
9
- footer,
10
- greeting,
11
- NOTICE,
12
- paragraph,
13
- signature,
14
- } from "./blocks";
15
- import { chatAssistant, chatCta, chatDots, chatUnit, chatUser } from "./chat";
16
- import { COMPANY_NAME } from "./constants";
17
-
18
- export type ChatSharedPayload = EmailPayloads["chat.shared"];
19
-
20
- export function renderChatShared(payload: ChatSharedPayload): Block[] {
21
- const { chatTitle, shareUrl, previewText, sharedByName } = payload;
22
-
23
- return [
24
- greeting(),
25
- paragraph("A chat was shared with you."),
26
- chatUnit(
27
- chatUser(chatTitle, sharedByName),
28
- ...(previewText ? [chatAssistant(previewText)] : []),
29
- chatDots(),
30
- chatCta({ label: "SEE MORE", href: shareUrl }),
31
- ),
32
- footer(`This share was sent via ${COMPANY_NAME}.`, NOTICE, "none"),
33
- signature(),
34
- ];
35
- }
@@ -1,42 +0,0 @@
1
- /**
2
- * Company.md access-approved email (sent to the requester).
3
- */
4
-
5
- import type { EmailPayloads } from "../types";
6
-
7
- import {
8
- ACCESS_PHRASE,
9
- type Block,
10
- footer,
11
- greeting,
12
- keyValue,
13
- paragraph,
14
- signature,
15
- } from "./blocks";
16
- import { chatAssistant, chatCta, chatUnit } from "./chat";
17
- import { COMPANY_NAME } from "./constants";
18
-
19
- export type AccessApprovedPayload =
20
- EmailPayloads["companyMd.access_request_approved"];
21
-
22
- export function renderAccessApproved(payload: AccessApprovedPayload): Block[] {
23
- const { approverName, docTitle, accessLevel, docUrl } = payload;
24
-
25
- return [
26
- greeting(),
27
- paragraph("Your access request was approved."),
28
- chatUnit(
29
- chatAssistant("Open to view."),
30
- chatCta({ label: "OPEN", href: docUrl }),
31
- ),
32
- keyValue("Document", `"${docTitle}"`),
33
- keyValue("Access", ACCESS_PHRASE[accessLevel]),
34
- keyValue("Approved by", approverName, "normal"),
35
- footer(
36
- `This notification was sent via ${COMPANY_NAME}.`,
37
- "Access is now active.",
38
- "none",
39
- ),
40
- signature(),
41
- ];
42
- }
@@ -1,43 +0,0 @@
1
- /**
2
- * Company.md access-denied email (sent to the requester). No CTA.
3
- */
4
-
5
- import type { EmailPayloads } from "../types";
6
-
7
- import {
8
- type Block,
9
- footer,
10
- greeting,
11
- keyValue,
12
- paragraph,
13
- signature,
14
- } from "./blocks";
15
- import { chatUnit, chatUser } from "./chat";
16
- import { COMPANY_NAME } from "./constants";
17
-
18
- export type AccessDeniedPayload =
19
- EmailPayloads["companyMd.access_request_denied"];
20
-
21
- export function renderAccessDenied(payload: AccessDeniedPayload): Block[] {
22
- const { approverName, docTitle, reason } = payload;
23
-
24
- const blocks: Block[] = [
25
- greeting(),
26
- paragraph("Your access request was declined."),
27
- ];
28
-
29
- if (reason) blocks.push(chatUnit(chatUser(reason, approverName)));
30
-
31
- blocks.push(
32
- keyValue("Document", `"${docTitle}"`),
33
- keyValue("Declined by", approverName, "normal"),
34
- footer(
35
- `This notification was sent via ${COMPANY_NAME}.`,
36
- "This request is now closed.",
37
- "none",
38
- ),
39
- signature(),
40
- );
41
-
42
- return blocks;
43
- }
@@ -1,46 +0,0 @@
1
- /**
2
- * Company.md access-requested email (sent to the doc owner).
3
- */
4
-
5
- import type { EmailPayloads } from "../types";
6
-
7
- import {
8
- type Block,
9
- footer,
10
- greeting,
11
- keyValue,
12
- paragraph,
13
- signature,
14
- } from "./blocks";
15
- import { chatAssistant, chatCta, chatUnit, chatUser } from "./chat";
16
- import { COMPANY_NAME } from "./constants";
17
-
18
- export type AccessRequestedPayload =
19
- EmailPayloads["companyMd.access_requested"];
20
-
21
- const OWNER_NOTE = "You own this document.";
22
-
23
- export function renderAccessRequested(
24
- payload: AccessRequestedPayload,
25
- ): Block[] {
26
- const { requesterName, docTitle, message, reviewUrl } = payload;
27
-
28
- return [
29
- greeting(),
30
- paragraph("Someone requested document access."),
31
- chatUnit(
32
- message
33
- ? chatUser(message, requesterName)
34
- : chatAssistant("Review the request."),
35
- chatCta({ label: "REVIEW", href: reviewUrl }),
36
- ),
37
- keyValue("From", requesterName),
38
- keyValue("Document", `"${docTitle}"`, "normal"),
39
- footer(
40
- `This notification was sent via ${COMPANY_NAME}.`,
41
- OWNER_NOTE,
42
- "none",
43
- ),
44
- signature(),
45
- ];
46
- }
@@ -1,18 +0,0 @@
1
- /**
2
- * Email render constants.
3
- *
4
- * Branding + layout constants shared by every email template. These are the
5
- * single source of truth for both the backend (real sent emails) and the app
6
- * (Ladle preview). `EMAIL_FROM` (SES envelope) is infra and stays in the
7
- * backend, not here.
8
- */
9
-
10
- /** Product name shown in footers and signatures. */
11
- export const COMPANY_NAME = "Company Semantics";
12
-
13
- /** Support address shown in the plain-text footer. */
14
- export const SUPPORT_EMAIL = "support@companysemantics.ai";
15
-
16
- /** Monospace font stack for HTML emails. */
17
- export const MONO_FONT_STACK =
18
- "'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace";