@company-semantics/contracts 27.14.0 → 28.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 +1 -1
- package/src/email/render/__tests__/__snapshots__/render-snapshot.test.ts.snap +134 -83
- package/src/email/render/auth-otp.ts +25 -43
- package/src/email/render/blocks.ts +183 -150
- package/src/email/render/chat-shared.ts +15 -42
- package/src/email/render/company-md-access-approved.ts +16 -40
- package/src/email/render/company-md-access-denied.ts +23 -39
- package/src/email/render/company-md-access-requested.ts +21 -41
- package/src/email/render/index.ts +12 -47
- package/src/email/render/org-invite.ts +9 -32
- package/src/email/render/ownership-transfer-completed.ts +19 -17
- package/src/email/render/ownership-transfer.ts +36 -21
- package/src/email/render/render-email.ts +110 -100
- package/src/email/render/security-alert.ts +27 -27
- package/src/email/render/share-granted.ts +25 -51
- package/src/email/render/unit-owner-granted.ts +24 -43
- package/src/email/render/base-text.ts +0 -26
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Auth OTP email (login code).
|
|
2
|
+
* Auth OTP email (login code).
|
|
3
3
|
*
|
|
4
4
|
* INVARIANT: the OTP value is a runtime payload field — never a literal in this
|
|
5
5
|
* package. Request metadata (IP / user agent) is gated by the caller-supplied
|
|
@@ -8,14 +8,12 @@
|
|
|
8
8
|
|
|
9
9
|
import type { EmailPayloads } from "../types";
|
|
10
10
|
|
|
11
|
-
import { baseTextLayout } from "./base-text";
|
|
12
11
|
import {
|
|
13
|
-
|
|
14
|
-
paragraph,
|
|
12
|
+
type Block,
|
|
15
13
|
ctaBox,
|
|
16
14
|
footer,
|
|
17
|
-
htmlShell,
|
|
18
15
|
keyValue,
|
|
16
|
+
paragraph,
|
|
19
17
|
signature,
|
|
20
18
|
} from "./blocks";
|
|
21
19
|
import { COMPANY_NAME } from "./constants";
|
|
@@ -27,45 +25,13 @@ export interface RenderOptions {
|
|
|
27
25
|
includeRequestMetadata?: boolean;
|
|
28
26
|
}
|
|
29
27
|
|
|
30
|
-
export function
|
|
28
|
+
export function renderAuthOtp(
|
|
31
29
|
payload: AuthOtpPayload,
|
|
32
30
|
options?: RenderOptions,
|
|
33
|
-
):
|
|
31
|
+
): Block[] {
|
|
34
32
|
const { otp, expiresInMinutes, requestIp, userAgent } = payload;
|
|
35
33
|
|
|
36
|
-
const
|
|
37
|
-
sections.push("Your login code is below.");
|
|
38
|
-
sections.push(asciiCodeBox(otp));
|
|
39
|
-
sections.push(
|
|
40
|
-
`This code expires in ${expiresInMinutes} ${expiresInMinutes === 1 ? "minute" : "minutes"}.`,
|
|
41
|
-
);
|
|
42
|
-
|
|
43
|
-
// PRIVACY: IP address is PII under GDPR, so this feature is opt-in.
|
|
44
|
-
if (options?.includeRequestMetadata && (requestIp || userAgent)) {
|
|
45
|
-
sections.push("");
|
|
46
|
-
sections.push("Request details:");
|
|
47
|
-
if (requestIp) {
|
|
48
|
-
sections.push(` IP address: ${requestIp}`);
|
|
49
|
-
}
|
|
50
|
-
if (userAgent) {
|
|
51
|
-
const truncated =
|
|
52
|
-
userAgent.length > 80 ? userAgent.substring(0, 77) + "..." : userAgent;
|
|
53
|
-
sections.push(` Device: ${truncated}`);
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
sections.push("");
|
|
58
|
-
sections.push(
|
|
59
|
-
"If you didn't request this code, you can safely ignore this email.",
|
|
60
|
-
);
|
|
61
|
-
|
|
62
|
-
return baseTextLayout(sections.join("\n"));
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export function renderAuthOtpHtml(payload: AuthOtpPayload): string {
|
|
66
|
-
const { otp, expiresInMinutes } = payload;
|
|
67
|
-
|
|
68
|
-
return htmlShell([
|
|
34
|
+
const blocks: Block[] = [
|
|
69
35
|
paragraph("Copy/paste code in login form:"),
|
|
70
36
|
ctaBox({
|
|
71
37
|
label: otp,
|
|
@@ -74,17 +40,33 @@ export function renderAuthOtpHtml(payload: AuthOtpPayload): string {
|
|
|
74
40
|
borderRadiusZero: true,
|
|
75
41
|
letterSpacing: "4px",
|
|
76
42
|
}),
|
|
77
|
-
keyValue("Status", "VALID"
|
|
43
|
+
keyValue("Status", "VALID"),
|
|
78
44
|
keyValue(
|
|
79
45
|
"Expires in",
|
|
80
|
-
`${expiresInMinutes} ${expiresInMinutes === 1 ? "
|
|
46
|
+
`${expiresInMinutes} ${expiresInMinutes === 1 ? "minute" : "minutes"}`,
|
|
81
47
|
"normal",
|
|
82
48
|
),
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
// PRIVACY: IP address is PII under GDPR, so this is opt-in.
|
|
52
|
+
if (options?.includeRequestMetadata && (requestIp || userAgent)) {
|
|
53
|
+
blocks.push(paragraph("Request details:", "tight"));
|
|
54
|
+
if (requestIp) blocks.push(keyValue("IP address", requestIp));
|
|
55
|
+
if (userAgent) {
|
|
56
|
+
const truncated =
|
|
57
|
+
userAgent.length > 80 ? userAgent.slice(0, 77) + "..." : userAgent;
|
|
58
|
+
blocks.push(keyValue("Device", truncated, "normal"));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
blocks.push(
|
|
83
63
|
footer(
|
|
84
64
|
`This code was generated to authorize a login to ${COMPANY_NAME}.`,
|
|
85
65
|
"It expires automatically and cannot be reused.",
|
|
86
66
|
),
|
|
87
67
|
paragraph("If this wasn't you, no action is required."),
|
|
88
68
|
signature(),
|
|
89
|
-
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
return blocks;
|
|
90
72
|
}
|
|
@@ -1,30 +1,80 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Shared email building blocks.
|
|
2
|
+
* Shared email building blocks — dual-output components.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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).
|
|
8
9
|
*
|
|
9
10
|
* INVARIANTS:
|
|
10
|
-
* - Pure
|
|
11
|
-
* -
|
|
11
|
+
* - Pure functions, no side effects.
|
|
12
|
+
* - Components escape their own content; templates pass raw text (+ `bold(...)`
|
|
13
|
+
* for inline emphasis). No template hand-writes markup or raw strings.
|
|
12
14
|
*/
|
|
13
15
|
|
|
14
|
-
import { COMPANY_NAME, MONO_FONT_STACK } from "./constants";
|
|
16
|
+
import { COMPANY_NAME, MONO_FONT_STACK, SUPPORT_EMAIL } from "./constants";
|
|
15
17
|
import { escapeHtml } from "./escape-html";
|
|
16
18
|
|
|
17
19
|
const MONO = `font-family: ${MONO_FONT_STACK};`;
|
|
18
20
|
|
|
19
21
|
// =============================================================================
|
|
20
|
-
//
|
|
22
|
+
// Core types
|
|
23
|
+
// =============================================================================
|
|
24
|
+
|
|
25
|
+
/** A rendered block — both presentations of one component. `spacing` controls
|
|
26
|
+
* the plain-text gap AFTER this block ("normal" = blank line, else none). */
|
|
27
|
+
export interface Block {
|
|
28
|
+
html: string;
|
|
29
|
+
text: string;
|
|
30
|
+
spacing: Spacing;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** An inline segment — for in-line emphasis inside a paragraph. */
|
|
34
|
+
export interface Inline {
|
|
35
|
+
html: string;
|
|
36
|
+
text: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Paragraph content: raw string(s) (auto-escaped for HTML) and/or `bold(...)`. */
|
|
40
|
+
export type InlineContent = string | Inline | Array<string | Inline>;
|
|
41
|
+
|
|
42
|
+
/** Bold inline emphasis (plain in text). */
|
|
43
|
+
export function bold(s: string): Inline {
|
|
44
|
+
return { html: `<b>${escapeHtml(s)}</b>`, text: s };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Normalize a segment: a raw string is escaped for HTML, passed through for text. */
|
|
48
|
+
function toInline(seg: string | Inline): Inline {
|
|
49
|
+
return typeof seg === "string" ? { html: escapeHtml(seg), text: seg } : seg;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function renderInline(content: InlineContent): { html: string; text: string } {
|
|
53
|
+
const segs = (Array.isArray(content) ? content : [content]).map(toInline);
|
|
54
|
+
return {
|
|
55
|
+
html: segs.map((s) => s.html).join(""),
|
|
56
|
+
text: segs.map((s) => s.text).join(""),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Vertical spacing options for a paragraph. */
|
|
61
|
+
export type Spacing = "normal" | "tight" | "none";
|
|
62
|
+
const SPACING: Record<Spacing, string> = {
|
|
63
|
+
normal: "0 0 20px 0",
|
|
64
|
+
tight: "0 0 4px 0",
|
|
65
|
+
none: "0",
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// =============================================================================
|
|
69
|
+
// Shells
|
|
21
70
|
// =============================================================================
|
|
22
71
|
|
|
23
72
|
/** Wrap body blocks in the shared `<!DOCTYPE>` monospace shell. */
|
|
24
|
-
export function htmlShell(blocks:
|
|
25
|
-
const inner =
|
|
26
|
-
|
|
27
|
-
|
|
73
|
+
export function htmlShell(blocks: Block[]): string {
|
|
74
|
+
const inner = blocks
|
|
75
|
+
.map((b) => b.html)
|
|
76
|
+
.filter(Boolean)
|
|
77
|
+
.join("\n");
|
|
28
78
|
return `<!DOCTYPE html>
|
|
29
79
|
<html lang="en">
|
|
30
80
|
<head><meta charset="UTF-8"></head>
|
|
@@ -34,56 +84,79 @@ ${inner}
|
|
|
34
84
|
</html>`;
|
|
35
85
|
}
|
|
36
86
|
|
|
87
|
+
/** Join body blocks into the plain-text email (blank-line separated per each
|
|
88
|
+
* block's spacing, trailing newline). */
|
|
89
|
+
export function textShell(blocks: Block[]): string {
|
|
90
|
+
let out = "";
|
|
91
|
+
blocks.forEach((b, i) => {
|
|
92
|
+
out += b.text;
|
|
93
|
+
if (i < blocks.length - 1) out += b.spacing === "normal" ? "\n\n" : "\n";
|
|
94
|
+
});
|
|
95
|
+
return out.trim() + "\n";
|
|
96
|
+
}
|
|
97
|
+
|
|
37
98
|
// =============================================================================
|
|
38
|
-
//
|
|
99
|
+
// Paragraphs
|
|
39
100
|
// =============================================================================
|
|
40
101
|
|
|
41
|
-
/** Vertical spacing options for a mono paragraph. */
|
|
42
|
-
export type Spacing = "normal" | "tight" | "none";
|
|
43
|
-
const SPACING: Record<Spacing, string> = {
|
|
44
|
-
normal: "0 0 20px 0",
|
|
45
|
-
tight: "0 0 4px 0",
|
|
46
|
-
none: "0",
|
|
47
|
-
};
|
|
48
|
-
|
|
49
102
|
/**
|
|
50
|
-
* Email paragraph — mono (matching the body shell), 14px,
|
|
51
|
-
*
|
|
52
|
-
*
|
|
103
|
+
* Email paragraph — mono (matching the body shell), 14px, configurable spacing.
|
|
104
|
+
* The single paragraph primitive: greeting / keyValue / footer / signature all
|
|
105
|
+
* build on it, and templates use it directly for body lines.
|
|
53
106
|
*/
|
|
54
|
-
export function paragraph(
|
|
55
|
-
|
|
107
|
+
export function paragraph(
|
|
108
|
+
content: InlineContent,
|
|
109
|
+
spacing: Spacing = "normal",
|
|
110
|
+
): Block {
|
|
111
|
+
const { html, text } = renderInline(content);
|
|
112
|
+
return {
|
|
113
|
+
html: `<p style="${MONO} font-size: 14px; margin: ${SPACING[spacing]};">${html}</p>`,
|
|
114
|
+
text,
|
|
115
|
+
spacing,
|
|
116
|
+
};
|
|
56
117
|
}
|
|
57
118
|
|
|
58
|
-
/** "Hi Name," / "Hi," greeting.
|
|
59
|
-
export function greeting(recipientName?: string):
|
|
60
|
-
return paragraph(recipientName ? `Hi ${
|
|
119
|
+
/** "Hi Name," / "Hi," greeting. */
|
|
120
|
+
export function greeting(recipientName?: string): Block {
|
|
121
|
+
return paragraph(recipientName ? `Hi ${recipientName},` : "Hi,");
|
|
61
122
|
}
|
|
62
123
|
|
|
63
|
-
/** "Label:
|
|
124
|
+
/** "Label: **value**" key/value row (value bold in HTML; tight by default). */
|
|
64
125
|
export function keyValue(
|
|
65
126
|
label: string,
|
|
66
|
-
|
|
127
|
+
value: string,
|
|
67
128
|
spacing: Spacing = "tight",
|
|
68
|
-
):
|
|
69
|
-
return paragraph(`${label}:
|
|
129
|
+
): Block {
|
|
130
|
+
return paragraph([`${label}: `, bold(value)], spacing);
|
|
70
131
|
}
|
|
71
132
|
|
|
72
|
-
/** Footer paragraph: first line + optional second line after a
|
|
133
|
+
/** Footer paragraph: first line + optional second line after a line break. */
|
|
73
134
|
export function footer(
|
|
74
135
|
firstLine: string,
|
|
75
136
|
secondLine?: string,
|
|
76
137
|
spacing: Spacing = "normal",
|
|
77
|
-
):
|
|
78
|
-
|
|
79
|
-
|
|
138
|
+
): Block {
|
|
139
|
+
const html = secondLine
|
|
140
|
+
? `${escapeHtml(firstLine)}<br>${escapeHtml(secondLine)}`
|
|
141
|
+
: escapeHtml(firstLine);
|
|
142
|
+
const text = secondLine ? `${firstLine}\n${secondLine}` : firstLine;
|
|
143
|
+
return {
|
|
144
|
+
html: `<p style="${MONO} font-size: 14px; margin: ${SPACING[spacing]};">${html}</p>`,
|
|
145
|
+
text,
|
|
80
146
|
spacing,
|
|
81
|
-
|
|
147
|
+
};
|
|
82
148
|
}
|
|
83
149
|
|
|
84
|
-
/**
|
|
85
|
-
|
|
86
|
-
|
|
150
|
+
/**
|
|
151
|
+
* Trailing sign-off. Owns the full company sign-off in both surfaces: the
|
|
152
|
+
* company (or custom `signer`) name and a "Questions? Contact <support>" line.
|
|
153
|
+
*/
|
|
154
|
+
export function signature(signer: string = COMPANY_NAME): Block {
|
|
155
|
+
return {
|
|
156
|
+
html: `<p style="${MONO} font-size: 14px; margin: ${SPACING.none};">${escapeHtml(signer)}<br><span style="color: #888;">Questions? Contact <a href="mailto:${SUPPORT_EMAIL}" style="color: #888;">${SUPPORT_EMAIL}</a></span></p>`,
|
|
157
|
+
text: `---\n${signer}\nQuestions? Contact ${SUPPORT_EMAIL}`,
|
|
158
|
+
spacing: "none",
|
|
159
|
+
};
|
|
87
160
|
}
|
|
88
161
|
|
|
89
162
|
/** The standard "no action required" reassurance line (content constant). */
|
|
@@ -98,19 +171,24 @@ export const ACCESS_PHRASE: Record<"editor" | "commenter" | "viewer", string> =
|
|
|
98
171
|
};
|
|
99
172
|
|
|
100
173
|
/** Centered continuation dots separator (chat-shared). */
|
|
101
|
-
export function chatDots():
|
|
102
|
-
return
|
|
174
|
+
export function chatDots(): Block {
|
|
175
|
+
return {
|
|
176
|
+
html: `<p style="${MONO} font-size: 20px; margin: 0 0 16px 0; max-width: 320px; text-align: center;">⋮</p>`,
|
|
177
|
+
text: " ⋮",
|
|
178
|
+
spacing: "normal",
|
|
179
|
+
};
|
|
103
180
|
}
|
|
104
181
|
|
|
105
182
|
// =============================================================================
|
|
106
|
-
//
|
|
183
|
+
// CTA box
|
|
107
184
|
// =============================================================================
|
|
108
185
|
|
|
109
186
|
/** Options for the `>> LABEL <<` call-to-action box. */
|
|
110
187
|
export interface CtaBoxOptions {
|
|
111
188
|
/** Text between the chevrons (already display-safe). */
|
|
112
189
|
label: string;
|
|
113
|
-
/** When present, the label links to this URL
|
|
190
|
+
/** When present, the label links to this URL (and the URL rides under the
|
|
191
|
+
* plain-text box). */
|
|
114
192
|
href?: string;
|
|
115
193
|
/** Inner-cell padding, e.g. `"16px 24px"`. */
|
|
116
194
|
padding: string;
|
|
@@ -124,8 +202,20 @@ export interface CtaBoxOptions {
|
|
|
124
202
|
letterSpacing?: string;
|
|
125
203
|
}
|
|
126
204
|
|
|
127
|
-
/**
|
|
128
|
-
|
|
205
|
+
/** Padding columns on each side of the `>> LABEL <<` line in the ASCII box. */
|
|
206
|
+
const CTA_BOX_PAD = 3;
|
|
207
|
+
|
|
208
|
+
/** `>> LABEL <<` ASCII box (`*` corners): 3 lines, sized to the label. */
|
|
209
|
+
function asciiCtaBox(label: string): string {
|
|
210
|
+
const pad = " ".repeat(CTA_BOX_PAD);
|
|
211
|
+
const inner = `${pad}>> ${label} <<${pad}`;
|
|
212
|
+
const border = `*${"-".repeat(inner.length)}*`;
|
|
213
|
+
return [border, `|${inner}|`, border].join("\n");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** The bordered `>> LABEL <<` CTA box (OTP / JOIN / OPEN / VIEW TEAM / …). When
|
|
217
|
+
* `href` is given, HTML links the label and plain text prints the URL below. */
|
|
218
|
+
export function ctaBox(opts: CtaBoxOptions): Block {
|
|
129
219
|
const {
|
|
130
220
|
label,
|
|
131
221
|
href,
|
|
@@ -146,98 +236,24 @@ export function ctaBox(opts: CtaBoxOptions): string {
|
|
|
146
236
|
`padding: ${padding}; text-align: center; ${MONO} font-size: ${fontSize};` +
|
|
147
237
|
(letterSpacing ? ` letter-spacing: ${letterSpacing};` : ``);
|
|
148
238
|
|
|
149
|
-
const chevrons = `<b>>> ${label} <<</b>`;
|
|
239
|
+
const chevrons = `<b>>> ${escapeHtml(label)} <<</b>`;
|
|
150
240
|
const inner = href
|
|
151
241
|
? `<a href="${href}" style="color: #0047FF; text-decoration: underline;">${chevrons}</a>`
|
|
152
242
|
: chevrons;
|
|
153
243
|
|
|
154
|
-
|
|
244
|
+
const html = `<table cellpadding="0" cellspacing="0" border="0" style="${tableStyle}">
|
|
155
245
|
<tr><td style="${tdStyle}">
|
|
156
246
|
${inner}
|
|
157
247
|
</td></tr>
|
|
158
248
|
</table>`;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// =============================================================================
|
|
162
|
-
// HTML — chat bubbles
|
|
163
|
-
// =============================================================================
|
|
164
|
-
|
|
165
|
-
/**
|
|
166
|
-
* Right-aligned user chat bubble. Pass raw text — it is truncated by
|
|
167
|
-
* clampMessage and escaped here. When `from` is given (raw) it renders as an
|
|
168
|
-
* attribution line below the bubble, right-aligned to the bubble's right edge
|
|
169
|
-
* (clear of the face). Doubles as the "message from …" quote.
|
|
170
|
-
*/
|
|
171
|
-
export function chatUserBubble(text: string, from?: string): string {
|
|
172
|
-
const contentHtml = escapeHtml(clampMessage(text));
|
|
173
|
-
const attributionRow = from
|
|
174
|
-
? `
|
|
175
|
-
<tr>
|
|
176
|
-
<td style="${MONO} font-size: 11px; color: #888; text-align: right; padding-top: 4px;">${escapeHtml(from)}</td>
|
|
177
|
-
<td></td>
|
|
178
|
-
</tr>`
|
|
179
|
-
: "";
|
|
180
|
-
return `<table cellpadding="0" cellspacing="0" border="0" style="margin: 0 0 16px 0;">
|
|
181
|
-
<tr>
|
|
182
|
-
<td style="border: 1px solid #1a1a1a; border-radius: 12px 12px 0 12px; padding: 10px 14px; min-width: 280px; text-align: right; ${MONO} font-size: 13px; background: #f5f5f5;">${contentHtml}</td>
|
|
183
|
-
<td style="${MONO} font-size: 14px; padding-left: 8px; vertical-align: bottom;">(•̀_ರ╮)</td>
|
|
184
|
-
</tr>${attributionRow}
|
|
185
|
-
</table>`;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/** Left-aligned assistant chat bubble. Pass raw text — truncated + escaped here. */
|
|
189
|
-
export function chatAssistantBubble(text: string): string {
|
|
190
|
-
const contentHtml = escapeHtml(clampMessage(text));
|
|
191
|
-
return `<table cellpadding="0" cellspacing="0" border="0" style="margin: 16px 0;">
|
|
192
|
-
<tr>
|
|
193
|
-
<td style="${MONO} font-size: 14px; padding-right: 8px; vertical-align: bottom;">[c_S]</td>
|
|
194
|
-
<td style="border: 1px solid #1a1a1a; border-radius: 12px 12px 12px 0; padding: 10px 14px; min-width: 280px; ${MONO} font-size: 13px; background: #f5f5f5;">${contentHtml}</td>
|
|
195
|
-
</tr>
|
|
196
|
-
</table>`;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
// =============================================================================
|
|
200
|
-
// Plain text — CTA / code boxes
|
|
201
|
-
// =============================================================================
|
|
202
|
-
|
|
203
|
-
const CODE_BOX_WIDTH = 18;
|
|
204
249
|
|
|
205
|
-
|
|
206
|
-
const CTA_BOX_PAD = 3;
|
|
250
|
+
const text = href ? `${asciiCtaBox(label)}\n\n${href}` : asciiCtaBox(label);
|
|
207
251
|
|
|
208
|
-
|
|
209
|
-
const pad = Math.max(0, width - value.length);
|
|
210
|
-
const left = Math.floor(pad / 2);
|
|
211
|
-
const right = pad - left;
|
|
212
|
-
return `${" ".repeat(left)}${value}${" ".repeat(right)}`;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/**
|
|
216
|
-
* `>> LABEL <<` ASCII box (`*` corners): 3 lines, sized to the label with
|
|
217
|
-
* `CTA_BOX_PAD` padding columns on each side.
|
|
218
|
-
*/
|
|
219
|
-
export function asciiCtaBox(label: string): string {
|
|
220
|
-
const pad = " ".repeat(CTA_BOX_PAD);
|
|
221
|
-
const inner = `${pad}>> ${label} <<${pad}`;
|
|
222
|
-
const border = `*${"-".repeat(inner.length)}*`;
|
|
223
|
-
return [border, `|${inner}|`, border].join("\n");
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
/** ASCII box that displays a code value (OTP; `+` corners, centered). */
|
|
227
|
-
export function asciiCodeBox(value: string): string {
|
|
228
|
-
const border = `+${"-".repeat(CODE_BOX_WIDTH)}+`;
|
|
229
|
-
const blank = `|${" ".repeat(CODE_BOX_WIDTH)}|`;
|
|
230
|
-
return [
|
|
231
|
-
border,
|
|
232
|
-
blank,
|
|
233
|
-
`|${centered(value, CODE_BOX_WIDTH)}|`,
|
|
234
|
-
blank,
|
|
235
|
-
border,
|
|
236
|
-
].join("\n");
|
|
252
|
+
return { html, text, spacing: "normal" };
|
|
237
253
|
}
|
|
238
254
|
|
|
239
255
|
// =============================================================================
|
|
240
|
-
//
|
|
256
|
+
// Chat bubbles — one dual-output block per role
|
|
241
257
|
// =============================================================================
|
|
242
258
|
|
|
243
259
|
/** Greedy word-wrap into lines of at most `width` chars (hard-breaks long words). */
|
|
@@ -281,46 +297,63 @@ function wrapClamped(text: string, width: number, maxLines: number): string[] {
|
|
|
281
297
|
return kept;
|
|
282
298
|
}
|
|
283
299
|
|
|
284
|
-
/** Plain-text continuation dots, centered under the 38-char chat box. */
|
|
285
|
-
export function chatDotsText(): string {
|
|
286
|
-
return " ⋮";
|
|
287
|
-
}
|
|
288
|
-
|
|
289
300
|
/**
|
|
290
301
|
* The one truncation authority: clamp a raw message to MAX_MESSAGE_LINES ×
|
|
291
|
-
* MESSAGE_WIDTH, ellipsized.
|
|
292
|
-
* this, so HTML and plain text truncate at exactly the same point.
|
|
293
|
-
* (unescaped) text.
|
|
302
|
+
* MESSAGE_WIDTH, ellipsized. Both surfaces of a chat block run content through
|
|
303
|
+
* this, so HTML and plain text truncate at exactly the same point.
|
|
294
304
|
*/
|
|
295
|
-
|
|
305
|
+
function clampMessage(text: string): string {
|
|
296
306
|
return wrapClamped(text, MESSAGE_WIDTH, MAX_MESSAGE_LINES).join(" ");
|
|
297
307
|
}
|
|
298
308
|
|
|
299
309
|
/**
|
|
300
|
-
* Right-aligned user chat
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
304
|
-
* Doubles as the "message from …" quote.
|
|
310
|
+
* Right-aligned user chat message. Truncated by clampMessage. The avatar rides
|
|
311
|
+
* the bottom (bottom-aligned like a real chat). When `from` is given it renders
|
|
312
|
+
* as an attribution below, right-aligned to the message's right edge (clear of
|
|
313
|
+
* the face). Doubles as the "message from …" quote.
|
|
305
314
|
*/
|
|
306
|
-
export function
|
|
315
|
+
export function chatUser(content: string, from?: string): Block {
|
|
316
|
+
const clamped = clampMessage(content);
|
|
317
|
+
|
|
318
|
+
const attributionRow = from
|
|
319
|
+
? `
|
|
320
|
+
<tr>
|
|
321
|
+
<td style="${MONO} font-size: 11px; color: #888; text-align: right; padding-top: 4px;">${escapeHtml(from)}</td>
|
|
322
|
+
<td></td>
|
|
323
|
+
</tr>`
|
|
324
|
+
: "";
|
|
325
|
+
const html = `<table cellpadding="0" cellspacing="0" border="0" style="margin: 0 0 16px 0;">
|
|
326
|
+
<tr>
|
|
327
|
+
<td style="border: 1px solid #1a1a1a; border-radius: 12px 12px 0 12px; padding: 10px 14px; min-width: 280px; text-align: right; ${MONO} font-size: 13px; background: #f5f5f5;">${escapeHtml(clamped)}</td>
|
|
328
|
+
<td style="${MONO} font-size: 14px; padding-left: 8px; vertical-align: bottom;">(•̀_ರ╮)</td>
|
|
329
|
+
</tr>${attributionRow}
|
|
330
|
+
</table>`;
|
|
331
|
+
|
|
307
332
|
const border = "─".repeat(MESSAGE_WIDTH + 1);
|
|
308
|
-
const lines = wrapText(
|
|
333
|
+
const lines = wrapText(clamped, MESSAGE_WIDTH);
|
|
309
334
|
const body = lines.map((line) => `│${line.padStart(MESSAGE_WIDTH)} │`);
|
|
310
335
|
const box = [`┌${border}┐`, ...body, `└${border}┘ (•̀_ರ╮)`];
|
|
311
336
|
if (from) box.push(from.padStart(MESSAGE_WIDTH + 3));
|
|
312
|
-
|
|
337
|
+
|
|
338
|
+
return { html, text: box.join("\n"), spacing: "normal" };
|
|
313
339
|
}
|
|
314
340
|
|
|
315
|
-
/** Left-aligned assistant chat
|
|
316
|
-
export function
|
|
317
|
-
const
|
|
318
|
-
const lines = wrapText(clampMessage(text), lineWidth);
|
|
341
|
+
/** Left-aligned assistant chat message with `[c_S]` avatar (bottom-aligned). */
|
|
342
|
+
export function chatAssistant(content: string): Block {
|
|
343
|
+
const clamped = clampMessage(content);
|
|
319
344
|
|
|
345
|
+
const html = `<table cellpadding="0" cellspacing="0" border="0" style="margin: 16px 0;">
|
|
346
|
+
<tr>
|
|
347
|
+
<td style="${MONO} font-size: 14px; padding-right: 8px; vertical-align: bottom;">[c_S]</td>
|
|
348
|
+
<td style="border: 1px solid #1a1a1a; border-radius: 12px 12px 12px 0; padding: 10px 14px; min-width: 280px; ${MONO} font-size: 13px; background: #f5f5f5;">${escapeHtml(clamped)}</td>
|
|
349
|
+
</tr>
|
|
350
|
+
</table>`;
|
|
351
|
+
|
|
352
|
+
const lineWidth = 36;
|
|
353
|
+
const lines = wrapText(clamped, lineWidth);
|
|
320
354
|
const top = " ┌──────────────────────────────────────┐";
|
|
321
|
-
// [c_S] rides the bottom border — bottom-aligned like a chat avatar.
|
|
322
355
|
const bot = "[c_S] └──────────────────────────────────────┘";
|
|
323
356
|
const boxLines = lines.map((line) => ` │ ${line.padEnd(lineWidth)} │`);
|
|
324
357
|
|
|
325
|
-
return [top, ...boxLines, bot].join("\n");
|
|
358
|
+
return { html, text: [top, ...boxLines, bot].join("\n"), spacing: "normal" };
|
|
326
359
|
}
|
|
@@ -1,64 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Chat-shared email (someone shared a chat).
|
|
2
|
+
* Chat-shared email (someone shared a chat).
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import type { EmailPayloads } from "../types";
|
|
6
6
|
|
|
7
|
-
import { baseTextLayout } from "./base-text";
|
|
8
7
|
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
chatAssistantBoxText,
|
|
12
|
-
chatAssistantBubble,
|
|
8
|
+
type Block,
|
|
9
|
+
chatAssistant,
|
|
13
10
|
chatDots,
|
|
14
|
-
|
|
15
|
-
chatUserBoxText,
|
|
16
|
-
chatUserBubble,
|
|
11
|
+
chatUser,
|
|
17
12
|
ctaBox,
|
|
18
13
|
footer,
|
|
19
|
-
htmlShell,
|
|
20
14
|
NOTICE,
|
|
15
|
+
paragraph,
|
|
21
16
|
signature,
|
|
22
17
|
} from "./blocks";
|
|
23
18
|
import { COMPANY_NAME } from "./constants";
|
|
24
19
|
|
|
25
20
|
export type ChatSharedPayload = EmailPayloads["chat.shared"];
|
|
26
21
|
|
|
27
|
-
export function
|
|
22
|
+
export function renderChatShared(payload: ChatSharedPayload): Block[] {
|
|
28
23
|
const { chatTitle, shareUrl, previewText } = payload;
|
|
29
24
|
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
if (previewText) {
|
|
36
|
-
sections.push("");
|
|
37
|
-
sections.push(chatAssistantBoxText(previewText));
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
sections.push("");
|
|
41
|
-
sections.push(chatDotsText());
|
|
42
|
-
sections.push("");
|
|
43
|
-
sections.push(asciiCtaBox("SEE MORE"));
|
|
44
|
-
sections.push("");
|
|
45
|
-
sections.push(shareUrl);
|
|
46
|
-
sections.push("");
|
|
47
|
-
sections.push(`This share was sent via ${COMPANY_NAME}.`);
|
|
48
|
-
sections.push("");
|
|
49
|
-
sections.push(NOTICE);
|
|
50
|
-
|
|
51
|
-
return baseTextLayout(sections.join("\n"));
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export function renderChatSharedHtml(payload: ChatSharedPayload): string {
|
|
55
|
-
const { chatTitle, shareUrl, previewText } = payload;
|
|
25
|
+
const blocks: Block[] = [
|
|
26
|
+
paragraph("A chat has been shared with you."),
|
|
27
|
+
chatUser(chatTitle),
|
|
28
|
+
];
|
|
56
29
|
|
|
57
|
-
|
|
30
|
+
if (previewText) blocks.push(chatAssistant(previewText));
|
|
58
31
|
|
|
59
|
-
|
|
60
|
-
paragraph("A chat has been shared with you."),
|
|
61
|
-
chatUserBubble(chatTitle) + previewHtml,
|
|
32
|
+
blocks.push(
|
|
62
33
|
chatDots(),
|
|
63
34
|
ctaBox({
|
|
64
35
|
label: "SEE MORE",
|
|
@@ -70,5 +41,7 @@ export function renderChatSharedHtml(payload: ChatSharedPayload): string {
|
|
|
70
41
|
footer(`This share was sent via ${COMPANY_NAME}.`),
|
|
71
42
|
paragraph(NOTICE, "tight"),
|
|
72
43
|
signature(),
|
|
73
|
-
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
return blocks;
|
|
74
47
|
}
|