@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.
- package/package.json +4 -4
- package/src/index.ts +17 -11
- package/src/notifications/README.md +142 -0
- package/src/notifications/__tests__/README.md +49 -0
- package/src/notifications/__tests__/__snapshots__/README.md +39 -0
- package/src/notifications/__tests__/__snapshots__/registry.test.ts.snap +31 -0
- package/src/{email/render → notifications}/__tests__/__snapshots__/render-snapshot.test.ts.snap +2 -2
- 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__/fixtures.ts +226 -0
- package/src/notifications/__tests__/kinds.test.ts +80 -0
- package/src/notifications/__tests__/registry.test.ts +184 -0
- package/src/{email/render → notifications}/__tests__/render-snapshot.test.ts +62 -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 +78 -0
- package/src/notifications/renderers/email/__tests__/README.md +32 -0
- package/src/notifications/renderers/email/__tests__/render.test.ts +188 -0
- package/src/{email/render → notifications/renderers/email}/chat.ts +54 -107
- package/src/notifications/renderers/email/constants.ts +64 -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 +238 -0
- package/src/notifications/renderers/email/shells.ts +61 -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/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
|
-
}
|
|
@@ -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 = `>> ${labelHtml} <<`;
|
|
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";
|