@company-semantics/contracts 31.0.0 → 33.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/content/schemas.ts +18 -1
- package/src/email/__tests__/registry.test.ts +3 -3
- package/src/email/registry.ts +6 -4
- package/src/email/render/__tests__/__snapshots__/render-snapshot.test.ts.snap +1141 -454
- package/src/email/render/__tests__/render-snapshot.test.ts +11 -7
- package/src/email/render/auth-otp.ts +12 -7
- package/src/email/render/blocks.ts +79 -178
- package/src/email/render/chat-shared.ts +15 -21
- package/src/email/render/chat.ts +331 -0
- package/src/email/render/company-md-access-approved.ts +15 -10
- package/src/email/render/company-md-access-denied.ts +11 -10
- package/src/email/render/company-md-access-requested.ts +19 -21
- package/src/email/render/index.ts +13 -3
- package/src/email/render/org-invite.ts +14 -7
- package/src/email/render/ownership-transfer-completed.ts +19 -11
- package/src/email/render/ownership-transfer.ts +20 -23
- package/src/email/render/render-email.ts +39 -1
- package/src/email/render/security-alert.ts +11 -12
- package/src/email/render/share-granted.ts +11 -17
- package/src/email/render/unit-owner-granted.ts +36 -25
- package/src/email/types.ts +10 -3
- package/src/org/company-md.ts +15 -1
- package/src/permissions/share-api.ts +22 -1
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat unit — the dual-output chat transcript block.
|
|
3
|
+
*
|
|
4
|
+
* The one component with real layout of its own: message bubbles, an in-stream
|
|
5
|
+
* CTA, and continuation dots, laid out as a bracketed "unit". Same contract as
|
|
6
|
+
* every other component in `blocks.ts` (returns a `Block` = `{ html, text }`, so
|
|
7
|
+
* both surfaces derive from one source); it lives apart because bubble geometry
|
|
8
|
+
* — wrapping, truncation, avatar gutters, alignment — is a self-contained
|
|
9
|
+
* concern that the paragraph/CTA primitives next door do not share.
|
|
10
|
+
*
|
|
11
|
+
* INVARIANTS:
|
|
12
|
+
* - Pure functions, no side effects.
|
|
13
|
+
* - Components escape their own content; templates pass raw text.
|
|
14
|
+
* - HTML and plain text truncate at exactly the same point (`clampMessage` is
|
|
15
|
+
* the single truncation authority).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
type Block,
|
|
20
|
+
type CtaBoxOptions,
|
|
21
|
+
ctaButton,
|
|
22
|
+
FONT_SIZE,
|
|
23
|
+
MONO,
|
|
24
|
+
} from "./blocks";
|
|
25
|
+
import { escapeHtml } from "./escape-html";
|
|
26
|
+
|
|
27
|
+
// =============================================================================
|
|
28
|
+
// Text geometry
|
|
29
|
+
// =============================================================================
|
|
30
|
+
|
|
31
|
+
/** Greedy word-wrap into lines of at most `width` chars (hard-breaks long words). */
|
|
32
|
+
function wrapText(text: string, width: number): string[] {
|
|
33
|
+
const lines: string[] = [];
|
|
34
|
+
let cur = "";
|
|
35
|
+
for (const word of text.split(/\s+/).filter(Boolean)) {
|
|
36
|
+
let w = word;
|
|
37
|
+
while (w.length > width) {
|
|
38
|
+
if (cur) {
|
|
39
|
+
lines.push(cur);
|
|
40
|
+
cur = "";
|
|
41
|
+
}
|
|
42
|
+
lines.push(w.slice(0, width));
|
|
43
|
+
w = w.slice(width);
|
|
44
|
+
}
|
|
45
|
+
if (!cur) cur = w;
|
|
46
|
+
else if (cur.length + 1 + w.length <= width) cur += ` ${w}`;
|
|
47
|
+
else {
|
|
48
|
+
lines.push(cur);
|
|
49
|
+
cur = w;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (cur) lines.push(cur);
|
|
53
|
+
return lines.length ? lines : [""];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Chat message truncation budget: at most MAX_MESSAGE_LINES lines of MESSAGE_WIDTH chars. */
|
|
57
|
+
const MAX_MESSAGE_LINES = 3;
|
|
58
|
+
const MESSAGE_WIDTH = 36;
|
|
59
|
+
/** Plain-text left gutter (7 cols) reserved for the avatar on both sides, so the
|
|
60
|
+
* user box aligns with the assistant box. */
|
|
61
|
+
const CHAT_INDENT = " ";
|
|
62
|
+
|
|
63
|
+
/** Word-wrap `text`, then clamp to `maxLines`, ellipsizing the last line on overflow. */
|
|
64
|
+
function wrapClamped(text: string, width: number, maxLines: number): string[] {
|
|
65
|
+
const lines = wrapText(text, width);
|
|
66
|
+
if (lines.length <= maxLines) return lines;
|
|
67
|
+
const kept = lines.slice(0, maxLines);
|
|
68
|
+
const last = kept[maxLines - 1];
|
|
69
|
+
kept[maxLines - 1] =
|
|
70
|
+
(last.length > width - 3 ? last.slice(0, width - 3).trimEnd() : last) +
|
|
71
|
+
"...";
|
|
72
|
+
return kept;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The one truncation authority: clamp a raw message to MAX_MESSAGE_LINES ×
|
|
77
|
+
* MESSAGE_WIDTH, ellipsized. Both surfaces of a chat block run content through
|
|
78
|
+
* this, so HTML and plain text truncate at exactly the same point.
|
|
79
|
+
*/
|
|
80
|
+
function clampMessage(text: string): string {
|
|
81
|
+
return wrapClamped(text, MESSAGE_WIDTH, MAX_MESSAGE_LINES).join(" ");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// =============================================================================
|
|
85
|
+
// Chat items
|
|
86
|
+
// =============================================================================
|
|
87
|
+
|
|
88
|
+
/** One message in a chat unit. `chatUser`/`chatAssistant` build these; `chatUnit`
|
|
89
|
+
* lays them out together. `from` is the user attribution (sender name). */
|
|
90
|
+
export interface ChatMessage {
|
|
91
|
+
role: "user" | "assistant";
|
|
92
|
+
text: string;
|
|
93
|
+
from?: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A user (right-aligned) chat message with an optional `from` attribution. */
|
|
97
|
+
export function chatUser(text: string, from?: string): ChatMessage {
|
|
98
|
+
return { role: "user", text, from };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** An assistant (left-aligned) chat message. */
|
|
102
|
+
export function chatAssistant(text: string): ChatMessage {
|
|
103
|
+
return { role: "assistant", text };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** A CTA button placed inside a chat unit (below a message). */
|
|
107
|
+
export interface ChatCta {
|
|
108
|
+
role: "cta";
|
|
109
|
+
cta: CtaBoxOptions;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Centered continuation dots inside a chat unit — a "conversation continues"
|
|
113
|
+
* separator placed between a message bubble and a following CTA. */
|
|
114
|
+
export interface ChatDots {
|
|
115
|
+
role: "dots";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** An item in a chat unit: a message bubble, a CTA button, or continuation dots. */
|
|
119
|
+
export type ChatItem = ChatMessage | ChatCta | ChatDots;
|
|
120
|
+
|
|
121
|
+
/** A CTA button for a chat unit — pass it to `chatUnit` alongside messages. */
|
|
122
|
+
export function chatCta(cta: CtaBoxOptions): ChatCta {
|
|
123
|
+
return { role: "cta", cta };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Continuation dots for a chat unit — pass it to `chatUnit` where the
|
|
127
|
+
* conversation should read as continuing (e.g. between the preview and CTA). */
|
|
128
|
+
export function chatDots(): ChatDots {
|
|
129
|
+
return { role: "dots" };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// =============================================================================
|
|
133
|
+
// Item rendering
|
|
134
|
+
// =============================================================================
|
|
135
|
+
|
|
136
|
+
/** The `<hr>` bracketing a chat unit — 24px toward the bubbles, 12px on the
|
|
137
|
+
* outer side. */
|
|
138
|
+
function chatRuleHtml(position: "top" | "bottom"): string {
|
|
139
|
+
const margin = position === "top" ? "12px 0 24px 0" : "24px 0 12px 0";
|
|
140
|
+
return `<hr style="border: none; border-top: 1px solid #bbb; margin: ${margin};">`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Render one message to its HTML `<table>` and plain-text box lines. Both roles
|
|
145
|
+
* share the 3-column skeleton: a fixed avatar column on each side (the visible
|
|
146
|
+
* avatar plus the opposite avatar rendered `visibility: hidden` to reserve its
|
|
147
|
+
* width, so bubbles stay bounded and aligned), a middle cell that right/left-
|
|
148
|
+
* aligns the bubble, and — for a user `from` — an attribution row below.
|
|
149
|
+
*/
|
|
150
|
+
function renderBubble(
|
|
151
|
+
msg: ChatMessage,
|
|
152
|
+
margin: string,
|
|
153
|
+
): { html: string; text: string[] } {
|
|
154
|
+
const clamped = clampMessage(msg.text);
|
|
155
|
+
const isUser = msg.role === "user";
|
|
156
|
+
|
|
157
|
+
const radius = isUser ? "8px 8px 0 8px" : "8px 8px 8px 0";
|
|
158
|
+
const bubbleAlign = isUser ? " text-align: right;" : "";
|
|
159
|
+
const cellAlign = isUser ? "right" : "left";
|
|
160
|
+
const csHidden = isUser ? "visibility: hidden; " : "";
|
|
161
|
+
const kaomojiHidden = isUser ? "" : "visibility: hidden; ";
|
|
162
|
+
|
|
163
|
+
const attributionRow =
|
|
164
|
+
isUser && msg.from
|
|
165
|
+
? `
|
|
166
|
+
<tr>
|
|
167
|
+
<td></td>
|
|
168
|
+
<td style="${MONO} font-size: ${FONT_SIZE}; color: #666; text-align: right; padding-top: 6px; padding-right: 1ch;">${escapeHtml(msg.from)}</td>
|
|
169
|
+
<td></td>
|
|
170
|
+
</tr>`
|
|
171
|
+
: "";
|
|
172
|
+
|
|
173
|
+
const html = `<table cellpadding="0" cellspacing="0" border="0" width="100%" style="margin: ${margin};">
|
|
174
|
+
<tr>
|
|
175
|
+
<td style="${MONO} font-size: ${FONT_SIZE}; padding-right: 8px; vertical-align: bottom; ${csHidden}white-space: nowrap;">[c_S]</td>
|
|
176
|
+
<td style="width: 100%; text-align: ${cellAlign};"><table cellpadding="0" cellspacing="0" border="0" style="display: inline-block; max-width: 100%; vertical-align: bottom;">
|
|
177
|
+
<tr><td style="border-radius: ${radius}; padding: 10px 14px;${bubbleAlign} ${MONO} font-size: ${FONT_SIZE}; color: #ffffff; background: #666;">${escapeHtml(clamped)}</td></tr>
|
|
178
|
+
</table></td>
|
|
179
|
+
<td style="${MONO} font-size: ${FONT_SIZE}; padding-left: 8px; vertical-align: bottom; ${kaomojiHidden}white-space: nowrap;">(•̀_ರ╮)</td>
|
|
180
|
+
</tr>${attributionRow}
|
|
181
|
+
</table>`;
|
|
182
|
+
|
|
183
|
+
const border = "─".repeat(MESSAGE_WIDTH + 2);
|
|
184
|
+
const lines = wrapText(clamped, MESSAGE_WIDTH);
|
|
185
|
+
const body = lines.map(
|
|
186
|
+
(line) =>
|
|
187
|
+
`${CHAT_INDENT}│ ${isUser ? line.padStart(MESSAGE_WIDTH) : line.padEnd(MESSAGE_WIDTH)} │`,
|
|
188
|
+
);
|
|
189
|
+
// Avatar beside the last message line (one row up from the bottom border).
|
|
190
|
+
const last = body.length - 1;
|
|
191
|
+
if (isUser) body[last] += " (•̀_ರ╮)";
|
|
192
|
+
else body[last] = `[c_S] ${body[last].slice(CHAT_INDENT.length)}`;
|
|
193
|
+
|
|
194
|
+
const box = [
|
|
195
|
+
`${CHAT_INDENT}┌${border}┐`,
|
|
196
|
+
...body,
|
|
197
|
+
`${CHAT_INDENT}└${border}┘`,
|
|
198
|
+
];
|
|
199
|
+
if (isUser && msg.from) {
|
|
200
|
+
box.push(msg.from.padStart(CHAT_INDENT.length + MESSAGE_WIDTH + 3));
|
|
201
|
+
}
|
|
202
|
+
return { html, text: box };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** The right-edge column the plain-text CTA/dots align to under a user bubble. */
|
|
206
|
+
const CHAT_RIGHT_EDGE = CHAT_INDENT.length + MESSAGE_WIDTH + 4;
|
|
207
|
+
|
|
208
|
+
/** Centered "⋮" HTML, sized to sit above and centered over a CTA box (they share
|
|
209
|
+
* the same inline-block, so the dots span exactly the button's width). */
|
|
210
|
+
function dotsOverCtaHtml(): string {
|
|
211
|
+
return `<div style="${MONO} font-size: 20px; font-weight: bold; color: #666; text-align: center; margin: 0 0 16px 0;">⋮</div>`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* A CTA button inside the chat stream — aligned to the side of the message it
|
|
216
|
+
* follows (`right` under a user bubble, `left` otherwise). Hidden avatar mirrors
|
|
217
|
+
* reserve both columns, so it sits in the message channel and never enters the
|
|
218
|
+
* kaomoji column. The button carries no margin; the row's 24px matches the bubbles.
|
|
219
|
+
* When `withDots`, continuation "⋮" render just above the button, centered over it.
|
|
220
|
+
*/
|
|
221
|
+
function renderChatCta(
|
|
222
|
+
cta: CtaBoxOptions,
|
|
223
|
+
align: "left" | "right",
|
|
224
|
+
withDots: boolean,
|
|
225
|
+
): { html: string; text: string[] } {
|
|
226
|
+
const { html: btnHtml, text: btnText } = ctaButton(cta, "0");
|
|
227
|
+
// Dots + button share one inline-block so the dots center over the button's
|
|
228
|
+
// exact width regardless of label length.
|
|
229
|
+
const stack = `<div style="display: inline-block; text-align: left;">${withDots ? dotsOverCtaHtml() : ""}${btnHtml}</div>`;
|
|
230
|
+
const html = `<table cellpadding="0" cellspacing="0" border="0" width="100%" style="margin: 0 0 24px 0;">
|
|
231
|
+
<tr>
|
|
232
|
+
<td style="${MONO} font-size: ${FONT_SIZE}; padding-right: 8px; vertical-align: bottom; visibility: hidden; white-space: nowrap;">[c_S]</td>
|
|
233
|
+
<td style="width: 100%; text-align: ${align};">${stack}</td>
|
|
234
|
+
<td style="${MONO} font-size: ${FONT_SIZE}; padding-left: 8px; vertical-align: bottom; visibility: hidden; white-space: nowrap;">(•̀_ರ╮)</td>
|
|
235
|
+
</tr>
|
|
236
|
+
</table>`;
|
|
237
|
+
|
|
238
|
+
// Right-align to the message box's right edge (under a user bubble); else the
|
|
239
|
+
// left avatar gutter.
|
|
240
|
+
const btnLines = btnText
|
|
241
|
+
.split("\n")
|
|
242
|
+
.map((l) =>
|
|
243
|
+
!l
|
|
244
|
+
? l
|
|
245
|
+
: align === "right"
|
|
246
|
+
? l.padStart(CHAT_RIGHT_EDGE)
|
|
247
|
+
: `${CHAT_INDENT}${l}`,
|
|
248
|
+
);
|
|
249
|
+
if (!withDots) return { html, text: btnLines };
|
|
250
|
+
|
|
251
|
+
// Center "⋮" over the ascii box (its first line spans the full box width).
|
|
252
|
+
const boxWidth = btnText.split("\n")[0].length;
|
|
253
|
+
const dotsCol =
|
|
254
|
+
align === "right"
|
|
255
|
+
? CHAT_RIGHT_EDGE - Math.floor(boxWidth / 2)
|
|
256
|
+
: CHAT_INDENT.length + Math.ceil(boxWidth / 2);
|
|
257
|
+
return { html, text: ["⋮".padStart(dotsCol), "", ...btnLines] };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Standalone continuation dots — centered in the message channel. Used only when
|
|
262
|
+
* `chatDots()` is NOT immediately followed by a CTA (the common case folds the
|
|
263
|
+
* dots into the CTA via `renderChatCta`, centered over the box).
|
|
264
|
+
*/
|
|
265
|
+
function renderChatDots(): { html: string; text: string[] } {
|
|
266
|
+
const html = `<table cellpadding="0" cellspacing="0" border="0" width="100%" style="margin: 0 0 16px 0;">
|
|
267
|
+
<tr>
|
|
268
|
+
<td style="${MONO} font-size: ${FONT_SIZE}; padding-right: 8px; vertical-align: bottom; visibility: hidden; white-space: nowrap;">[c_S]</td>
|
|
269
|
+
<td style="width: 100%; text-align: center; ${MONO} font-size: 20px; font-weight: bold; color: #666;">⋮</td>
|
|
270
|
+
<td style="${MONO} font-size: ${FONT_SIZE}; padding-left: 8px; vertical-align: bottom; visibility: hidden; white-space: nowrap;">(•̀_ರ╮)</td>
|
|
271
|
+
</tr>
|
|
272
|
+
</table>`;
|
|
273
|
+
// Center the "⋮" over the message box (avatar gutter + box width + borders).
|
|
274
|
+
const center = Math.round(CHAT_RIGHT_EDGE / 2);
|
|
275
|
+
return { html, text: ["⋮".padStart(center)] };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// =============================================================================
|
|
279
|
+
// Chat unit
|
|
280
|
+
// =============================================================================
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Lay out one or more chat items — message bubbles and/or CTA buttons — as a
|
|
284
|
+
* single block: a rule above and below (the real-chat "unit"), the items
|
|
285
|
+
* between. The plain-text rule spans the widest line; a blank line follows the
|
|
286
|
+
* top rule and the last item hugs the bottom rule. Continuation dots
|
|
287
|
+
* (`chatDots`) sit above the CTA they precede, centered over the box.
|
|
288
|
+
*/
|
|
289
|
+
export function chatUnit(...items: ChatItem[]): Block {
|
|
290
|
+
const parts: { html: string; text: string[] }[] = [];
|
|
291
|
+
items.forEach((item, i) => {
|
|
292
|
+
if (item.role === "dots") {
|
|
293
|
+
// Dots immediately before a CTA render with it (centered over the box);
|
|
294
|
+
// otherwise fall back to channel-centered standalone dots.
|
|
295
|
+
if (items[i + 1]?.role !== "cta") parts.push(renderChatDots());
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
if (item.role === "cta") {
|
|
299
|
+
// Mirror the side of the nearest preceding message (skip any dots between),
|
|
300
|
+
// so the CTA sits under the bubble it belongs to — right under a user.
|
|
301
|
+
let j = i - 1;
|
|
302
|
+
while (j >= 0 && items[j].role === "dots") j--;
|
|
303
|
+
const align = items[j]?.role === "user" ? "right" : "left";
|
|
304
|
+
parts.push(renderChatCta(item.cta, align, items[i - 1]?.role === "dots"));
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
// A bubble directly above a CTA or continuation dots gets a tighter 16px
|
|
308
|
+
// gap; else 24px.
|
|
309
|
+
const next = items[i + 1]?.role;
|
|
310
|
+
const margin =
|
|
311
|
+
next === "cta" || next === "dots" ? "0 0 16px 0" : "0 0 24px 0";
|
|
312
|
+
parts.push(renderBubble(item, margin));
|
|
313
|
+
});
|
|
314
|
+
const width = parts
|
|
315
|
+
.flatMap((b) => b.text)
|
|
316
|
+
.reduce((w, l) => Math.max(w, l.length), 0);
|
|
317
|
+
const rule = "_".repeat(width);
|
|
318
|
+
|
|
319
|
+
const html = [
|
|
320
|
+
chatRuleHtml("top"),
|
|
321
|
+
...parts.map((b) => b.html),
|
|
322
|
+
chatRuleHtml("bottom"),
|
|
323
|
+
].join("\n");
|
|
324
|
+
|
|
325
|
+
const text =
|
|
326
|
+
`${rule}\n\n` +
|
|
327
|
+
parts.map((b) => b.text.join("\n")).join("\n\n") +
|
|
328
|
+
`\n${rule}`;
|
|
329
|
+
|
|
330
|
+
return { html, text, spacing: "normal" };
|
|
331
|
+
}
|
|
@@ -7,13 +7,13 @@ import type { EmailPayloads } from "../types";
|
|
|
7
7
|
import {
|
|
8
8
|
ACCESS_PHRASE,
|
|
9
9
|
type Block,
|
|
10
|
-
bold,
|
|
11
|
-
ctaBox,
|
|
12
10
|
footer,
|
|
13
11
|
greeting,
|
|
12
|
+
keyValue,
|
|
14
13
|
paragraph,
|
|
15
14
|
signature,
|
|
16
15
|
} from "./blocks";
|
|
16
|
+
import { chatAssistant, chatCta, chatUnit } from "./chat";
|
|
17
17
|
import { COMPANY_NAME } from "./constants";
|
|
18
18
|
|
|
19
19
|
export type AccessApprovedPayload =
|
|
@@ -24,14 +24,19 @@ export function renderAccessApproved(payload: AccessApprovedPayload): Block[] {
|
|
|
24
24
|
|
|
25
25
|
return [
|
|
26
26
|
greeting(),
|
|
27
|
-
paragraph(
|
|
28
|
-
|
|
29
|
-
"
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
+
),
|
|
35
40
|
signature(),
|
|
36
41
|
];
|
|
37
42
|
}
|
|
@@ -6,13 +6,13 @@ import type { EmailPayloads } from "../types";
|
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
8
|
type Block,
|
|
9
|
-
bold,
|
|
10
|
-
chatUser,
|
|
11
9
|
footer,
|
|
12
10
|
greeting,
|
|
11
|
+
keyValue,
|
|
13
12
|
paragraph,
|
|
14
13
|
signature,
|
|
15
14
|
} from "./blocks";
|
|
15
|
+
import { chatUnit, chatUser } from "./chat";
|
|
16
16
|
import { COMPANY_NAME } from "./constants";
|
|
17
17
|
|
|
18
18
|
export type AccessDeniedPayload =
|
|
@@ -23,18 +23,19 @@ export function renderAccessDenied(payload: AccessDeniedPayload): Block[] {
|
|
|
23
23
|
|
|
24
24
|
const blocks: Block[] = [
|
|
25
25
|
greeting(),
|
|
26
|
-
paragraph(
|
|
27
|
-
bold(approverName),
|
|
28
|
-
" declined your request to access ",
|
|
29
|
-
bold(`"${docTitle}"`),
|
|
30
|
-
".",
|
|
31
|
-
]),
|
|
26
|
+
paragraph("Your access request was declined."),
|
|
32
27
|
];
|
|
33
28
|
|
|
34
|
-
if (reason) blocks.push(chatUser(reason,
|
|
29
|
+
if (reason) blocks.push(chatUnit(chatUser(reason, approverName)));
|
|
35
30
|
|
|
36
31
|
blocks.push(
|
|
37
|
-
|
|
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
|
+
),
|
|
38
39
|
signature(),
|
|
39
40
|
);
|
|
40
41
|
|
|
@@ -6,43 +6,41 @@ import type { EmailPayloads } from "../types";
|
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
8
|
type Block,
|
|
9
|
-
bold,
|
|
10
|
-
chatUser,
|
|
11
|
-
ctaBox,
|
|
12
9
|
footer,
|
|
13
10
|
greeting,
|
|
11
|
+
keyValue,
|
|
14
12
|
paragraph,
|
|
15
13
|
signature,
|
|
16
14
|
} from "./blocks";
|
|
15
|
+
import { chatAssistant, chatCta, chatUnit, chatUser } from "./chat";
|
|
17
16
|
import { COMPANY_NAME } from "./constants";
|
|
18
17
|
|
|
19
18
|
export type AccessRequestedPayload =
|
|
20
19
|
EmailPayloads["companyMd.access_requested"];
|
|
21
20
|
|
|
22
|
-
const OWNER_NOTE = "You
|
|
21
|
+
const OWNER_NOTE = "You own this document.";
|
|
23
22
|
|
|
24
23
|
export function renderAccessRequested(
|
|
25
24
|
payload: AccessRequestedPayload,
|
|
26
25
|
): Block[] {
|
|
27
26
|
const { requesterName, docTitle, message, reviewUrl } = payload;
|
|
28
27
|
|
|
29
|
-
|
|
28
|
+
return [
|
|
30
29
|
greeting(),
|
|
31
|
-
paragraph(
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
44
|
signature(),
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
return blocks;
|
|
45
|
+
];
|
|
48
46
|
}
|
|
@@ -17,15 +17,13 @@ export {
|
|
|
17
17
|
textShell,
|
|
18
18
|
paragraph,
|
|
19
19
|
greeting,
|
|
20
|
+
security,
|
|
20
21
|
keyValue,
|
|
21
22
|
footer,
|
|
22
23
|
signature,
|
|
23
24
|
NOTICE,
|
|
24
25
|
ACCESS_PHRASE,
|
|
25
|
-
chatDots,
|
|
26
26
|
ctaBox,
|
|
27
|
-
chatUser,
|
|
28
|
-
chatAssistant,
|
|
29
27
|
bold,
|
|
30
28
|
type Block,
|
|
31
29
|
type Inline,
|
|
@@ -34,6 +32,18 @@ export {
|
|
|
34
32
|
type CtaBoxOptions,
|
|
35
33
|
} from "./blocks";
|
|
36
34
|
|
|
35
|
+
// The chat unit — same `Block` contract, its own module (bubble geometry)
|
|
36
|
+
export {
|
|
37
|
+
chatDots,
|
|
38
|
+
chatUser,
|
|
39
|
+
chatAssistant,
|
|
40
|
+
chatCta,
|
|
41
|
+
chatUnit,
|
|
42
|
+
type ChatMessage,
|
|
43
|
+
type ChatCta,
|
|
44
|
+
type ChatItem,
|
|
45
|
+
} from "./chat";
|
|
46
|
+
|
|
37
47
|
// Dispatcher
|
|
38
48
|
export {
|
|
39
49
|
renderEmail,
|
|
@@ -6,28 +6,35 @@ import type { EmailPayloads } from "../types";
|
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
8
|
type Block,
|
|
9
|
-
ctaBox,
|
|
10
9
|
footer,
|
|
10
|
+
formatExpiry,
|
|
11
|
+
greeting,
|
|
11
12
|
keyValue,
|
|
12
13
|
NOTICE,
|
|
13
14
|
paragraph,
|
|
14
15
|
signature,
|
|
16
|
+
titleCase,
|
|
15
17
|
} from "./blocks";
|
|
18
|
+
import { chatAssistant, chatCta, chatUnit } from "./chat";
|
|
16
19
|
import { COMPANY_NAME } from "./constants";
|
|
17
20
|
|
|
18
21
|
export type OrgInvitePayload = EmailPayloads["org.invite"];
|
|
19
22
|
|
|
20
23
|
export function renderOrgInvite(payload: OrgInvitePayload): Block[] {
|
|
21
|
-
const { inviterName, orgName, role, acceptUrl,
|
|
24
|
+
const { inviterName, orgName, role, acceptUrl, expiresAt } = payload;
|
|
22
25
|
|
|
23
26
|
return [
|
|
24
|
-
|
|
25
|
-
|
|
27
|
+
greeting(),
|
|
28
|
+
paragraph(`${orgName} uses Company Semantics.`),
|
|
29
|
+
chatUnit(
|
|
30
|
+
chatAssistant("Join to accept the invitation."),
|
|
31
|
+
chatCta({ label: "JOIN", href: acceptUrl }),
|
|
32
|
+
),
|
|
26
33
|
keyValue("From", inviterName),
|
|
27
34
|
keyValue("Workspace", orgName),
|
|
28
|
-
keyValue("Role", role),
|
|
29
|
-
keyValue("Expires
|
|
30
|
-
footer(`This invitation was sent via ${COMPANY_NAME}.`, NOTICE),
|
|
35
|
+
keyValue("Role", titleCase(role)),
|
|
36
|
+
keyValue("Expires", formatExpiry(expiresAt), "normal"),
|
|
37
|
+
footer(`This invitation was sent via ${COMPANY_NAME}.`, NOTICE, "none"),
|
|
31
38
|
signature(),
|
|
32
39
|
];
|
|
33
40
|
}
|
|
@@ -4,7 +4,16 @@
|
|
|
4
4
|
|
|
5
5
|
import type { EmailPayloads } from "../types";
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
type Block,
|
|
9
|
+
footer,
|
|
10
|
+
greeting,
|
|
11
|
+
keyValue,
|
|
12
|
+
paragraph,
|
|
13
|
+
signature,
|
|
14
|
+
} from "./blocks";
|
|
15
|
+
import { chatAssistant, chatUnit } from "./chat";
|
|
16
|
+
import { COMPANY_NAME } from "./constants";
|
|
8
17
|
|
|
9
18
|
export type OwnershipTransferCompletedPayload =
|
|
10
19
|
EmailPayloads["org.ownership_transfer_completed"];
|
|
@@ -15,18 +24,17 @@ export function renderOwnershipTransferCompleted(
|
|
|
15
24
|
const { orgName, newOwnerEmail } = payload;
|
|
16
25
|
|
|
17
26
|
return [
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
"
|
|
22
|
-
bold(newOwnerEmail),
|
|
23
|
-
".",
|
|
24
|
-
]),
|
|
25
|
-
paragraph(
|
|
26
|
-
"You are no longer the owner of this workspace, but you remain an admin.",
|
|
27
|
+
greeting(),
|
|
28
|
+
paragraph("Ownership was transferred."),
|
|
29
|
+
chatUnit(
|
|
30
|
+
chatAssistant("You're no longer the owner, but you remain an admin."),
|
|
27
31
|
),
|
|
32
|
+
keyValue("Workspace", orgName),
|
|
33
|
+
keyValue("New owner", newOwnerEmail, "normal"),
|
|
28
34
|
footer(
|
|
29
|
-
|
|
35
|
+
`This confirmation was sent via ${COMPANY_NAME}.`,
|
|
36
|
+
"If you did not authorize this, contact support immediately.",
|
|
37
|
+
"none",
|
|
30
38
|
),
|
|
31
39
|
signature(),
|
|
32
40
|
];
|
|
@@ -6,40 +6,37 @@ import type { EmailPayloads } from "../types";
|
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
8
|
type Block,
|
|
9
|
-
bold,
|
|
10
|
-
chatUser,
|
|
11
|
-
ctaBox,
|
|
12
9
|
footer,
|
|
10
|
+
formatExpiry,
|
|
11
|
+
greeting,
|
|
13
12
|
keyValue,
|
|
13
|
+
NOTICE,
|
|
14
14
|
paragraph,
|
|
15
15
|
signature,
|
|
16
16
|
} from "./blocks";
|
|
17
|
+
import { chatAssistant, chatCta, chatUnit, chatUser } from "./chat";
|
|
18
|
+
import { COMPANY_NAME } from "./constants";
|
|
17
19
|
|
|
18
20
|
export type OwnershipTransferPayload = EmailPayloads["org.ownership_transfer"];
|
|
19
21
|
|
|
20
22
|
export function renderOwnershipTransfer(
|
|
21
23
|
payload: OwnershipTransferPayload,
|
|
22
24
|
): Block[] {
|
|
23
|
-
const { orgName, acceptUrl,
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
if (note) blocks.push(chatUser(note, "the current owner"));
|
|
34
|
-
|
|
35
|
-
blocks.push(
|
|
36
|
-
ctaBox({ label: "ACCEPT", href: acceptUrl }),
|
|
37
|
-
keyValue("Expires in", `${expiresInDays} days`, "normal"),
|
|
38
|
-
footer(
|
|
39
|
-
"If you did not expect this invitation, you can safely ignore this email.",
|
|
25
|
+
const { orgName, acceptUrl, expiresAt, note, fromName } = payload;
|
|
26
|
+
|
|
27
|
+
return [
|
|
28
|
+
greeting(),
|
|
29
|
+
paragraph("You've been invited to take ownership."),
|
|
30
|
+
chatUnit(
|
|
31
|
+
note
|
|
32
|
+
? chatUser(note, fromName)
|
|
33
|
+
: chatAssistant("Accept to take ownership."),
|
|
34
|
+
chatCta({ label: "ACCEPT", href: acceptUrl }),
|
|
40
35
|
),
|
|
36
|
+
...(fromName ? [keyValue("From", fromName)] : []),
|
|
37
|
+
keyValue("Workspace", orgName),
|
|
38
|
+
keyValue("Expires", formatExpiry(expiresAt), "normal"),
|
|
39
|
+
footer(`This transfer was sent via ${COMPANY_NAME}.`, NOTICE, "none"),
|
|
41
40
|
signature(),
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
return blocks;
|
|
41
|
+
];
|
|
45
42
|
}
|