@company-semantics/contracts 30.0.0 → 32.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/api/generated-spec-hash.ts +2 -2
- package/src/api/generated.ts +5 -11
- package/src/content/schemas.ts +9 -20
- 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 +14 -7
- package/src/email/render/blocks.ts +291 -104
- package/src/email/render/chat-shared.ts +16 -18
- package/src/email/render/company-md-access-approved.ts +17 -10
- package/src/email/render/company-md-access-denied.ts +11 -9
- package/src/email/render/company-md-access-requested.ts +21 -20
- package/src/email/render/index.ts +6 -0
- package/src/email/render/org-invite.ts +16 -7
- package/src/email/render/ownership-transfer-completed.ts +20 -11
- package/src/email/render/ownership-transfer.ts +22 -22
- 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 +13 -16
- package/src/email/render/unit-owner-granted.ts +38 -24
- package/src/email/types.ts +10 -3
|
@@ -7,10 +7,12 @@ import type { EmailPayloads } from "../types";
|
|
|
7
7
|
import {
|
|
8
8
|
ACCESS_PHRASE,
|
|
9
9
|
type Block,
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
chatAssistant,
|
|
11
|
+
chatCta,
|
|
12
|
+
chatUnit,
|
|
12
13
|
footer,
|
|
13
14
|
greeting,
|
|
15
|
+
keyValue,
|
|
14
16
|
paragraph,
|
|
15
17
|
signature,
|
|
16
18
|
} from "./blocks";
|
|
@@ -24,14 +26,19 @@ export function renderAccessApproved(payload: AccessApprovedPayload): Block[] {
|
|
|
24
26
|
|
|
25
27
|
return [
|
|
26
28
|
greeting(),
|
|
27
|
-
paragraph(
|
|
28
|
-
|
|
29
|
-
"
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
29
|
+
paragraph("Your access request was approved."),
|
|
30
|
+
chatUnit(
|
|
31
|
+
chatAssistant("Open to view."),
|
|
32
|
+
chatCta({ label: "OPEN", href: docUrl }),
|
|
33
|
+
),
|
|
34
|
+
keyValue("Document", `"${docTitle}"`),
|
|
35
|
+
keyValue("Access", ACCESS_PHRASE[accessLevel]),
|
|
36
|
+
keyValue("Approved by", approverName, "normal"),
|
|
37
|
+
footer(
|
|
38
|
+
`This notification was sent via ${COMPANY_NAME}.`,
|
|
39
|
+
"Access is now active.",
|
|
40
|
+
"none",
|
|
41
|
+
),
|
|
35
42
|
signature(),
|
|
36
43
|
];
|
|
37
44
|
}
|
|
@@ -6,10 +6,11 @@ import type { EmailPayloads } from "../types";
|
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
8
|
type Block,
|
|
9
|
-
|
|
9
|
+
chatUnit,
|
|
10
10
|
chatUser,
|
|
11
11
|
footer,
|
|
12
12
|
greeting,
|
|
13
|
+
keyValue,
|
|
13
14
|
paragraph,
|
|
14
15
|
signature,
|
|
15
16
|
} from "./blocks";
|
|
@@ -23,18 +24,19 @@ export function renderAccessDenied(payload: AccessDeniedPayload): Block[] {
|
|
|
23
24
|
|
|
24
25
|
const blocks: Block[] = [
|
|
25
26
|
greeting(),
|
|
26
|
-
paragraph(
|
|
27
|
-
bold(approverName),
|
|
28
|
-
" declined your request to access ",
|
|
29
|
-
bold(`"${docTitle}"`),
|
|
30
|
-
".",
|
|
31
|
-
]),
|
|
27
|
+
paragraph("Your access request was declined."),
|
|
32
28
|
];
|
|
33
29
|
|
|
34
|
-
if (reason) blocks.push(chatUser(reason,
|
|
30
|
+
if (reason) blocks.push(chatUnit(chatUser(reason, approverName)));
|
|
35
31
|
|
|
36
32
|
blocks.push(
|
|
37
|
-
|
|
33
|
+
keyValue("Document", `"${docTitle}"`),
|
|
34
|
+
keyValue("Declined by", approverName, "normal"),
|
|
35
|
+
footer(
|
|
36
|
+
`This notification was sent via ${COMPANY_NAME}.`,
|
|
37
|
+
"This request is now closed.",
|
|
38
|
+
"none",
|
|
39
|
+
),
|
|
38
40
|
signature(),
|
|
39
41
|
);
|
|
40
42
|
|
|
@@ -6,11 +6,13 @@ import type { EmailPayloads } from "../types";
|
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
8
|
type Block,
|
|
9
|
-
|
|
9
|
+
chatAssistant,
|
|
10
|
+
chatCta,
|
|
11
|
+
chatUnit,
|
|
10
12
|
chatUser,
|
|
11
|
-
ctaBox,
|
|
12
13
|
footer,
|
|
13
14
|
greeting,
|
|
15
|
+
keyValue,
|
|
14
16
|
paragraph,
|
|
15
17
|
signature,
|
|
16
18
|
} from "./blocks";
|
|
@@ -19,30 +21,29 @@ import { COMPANY_NAME } from "./constants";
|
|
|
19
21
|
export type AccessRequestedPayload =
|
|
20
22
|
EmailPayloads["companyMd.access_requested"];
|
|
21
23
|
|
|
22
|
-
const OWNER_NOTE = "You
|
|
24
|
+
const OWNER_NOTE = "You own this document.";
|
|
23
25
|
|
|
24
26
|
export function renderAccessRequested(
|
|
25
27
|
payload: AccessRequestedPayload,
|
|
26
28
|
): Block[] {
|
|
27
29
|
const { requesterName, docTitle, message, reviewUrl } = payload;
|
|
28
30
|
|
|
29
|
-
|
|
31
|
+
return [
|
|
30
32
|
greeting(),
|
|
31
|
-
paragraph(
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
33
|
+
paragraph("Someone requested document access."),
|
|
34
|
+
chatUnit(
|
|
35
|
+
message
|
|
36
|
+
? chatUser(message, requesterName)
|
|
37
|
+
: chatAssistant("Review the request."),
|
|
38
|
+
chatCta({ label: "REVIEW", href: reviewUrl }),
|
|
39
|
+
),
|
|
40
|
+
keyValue("From", requesterName),
|
|
41
|
+
keyValue("Document", `"${docTitle}"`, "normal"),
|
|
42
|
+
footer(
|
|
43
|
+
`This notification was sent via ${COMPANY_NAME}.`,
|
|
44
|
+
OWNER_NOTE,
|
|
45
|
+
"none",
|
|
46
|
+
),
|
|
44
47
|
signature(),
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
return blocks;
|
|
48
|
+
];
|
|
48
49
|
}
|
|
@@ -17,6 +17,7 @@ export {
|
|
|
17
17
|
textShell,
|
|
18
18
|
paragraph,
|
|
19
19
|
greeting,
|
|
20
|
+
security,
|
|
20
21
|
keyValue,
|
|
21
22
|
footer,
|
|
22
23
|
signature,
|
|
@@ -26,8 +27,13 @@ export {
|
|
|
26
27
|
ctaBox,
|
|
27
28
|
chatUser,
|
|
28
29
|
chatAssistant,
|
|
30
|
+
chatCta,
|
|
31
|
+
chatUnit,
|
|
29
32
|
bold,
|
|
30
33
|
type Block,
|
|
34
|
+
type ChatMessage,
|
|
35
|
+
type ChatCta,
|
|
36
|
+
type ChatItem,
|
|
31
37
|
type Inline,
|
|
32
38
|
type InlineContent,
|
|
33
39
|
type Spacing,
|
|
@@ -6,28 +6,37 @@ import type { EmailPayloads } from "../types";
|
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
8
|
type Block,
|
|
9
|
-
|
|
9
|
+
chatAssistant,
|
|
10
|
+
chatCta,
|
|
11
|
+
chatUnit,
|
|
10
12
|
footer,
|
|
13
|
+
formatExpiry,
|
|
14
|
+
greeting,
|
|
11
15
|
keyValue,
|
|
12
16
|
NOTICE,
|
|
13
17
|
paragraph,
|
|
14
18
|
signature,
|
|
19
|
+
titleCase,
|
|
15
20
|
} from "./blocks";
|
|
16
21
|
import { COMPANY_NAME } from "./constants";
|
|
17
22
|
|
|
18
23
|
export type OrgInvitePayload = EmailPayloads["org.invite"];
|
|
19
24
|
|
|
20
25
|
export function renderOrgInvite(payload: OrgInvitePayload): Block[] {
|
|
21
|
-
const { inviterName, orgName, role, acceptUrl,
|
|
26
|
+
const { inviterName, orgName, role, acceptUrl, expiresAt } = payload;
|
|
22
27
|
|
|
23
28
|
return [
|
|
24
|
-
|
|
25
|
-
|
|
29
|
+
greeting(),
|
|
30
|
+
paragraph(`${orgName} uses Company Semantics.`),
|
|
31
|
+
chatUnit(
|
|
32
|
+
chatAssistant("Join to accept the invitation."),
|
|
33
|
+
chatCta({ label: "JOIN", href: acceptUrl }),
|
|
34
|
+
),
|
|
26
35
|
keyValue("From", inviterName),
|
|
27
36
|
keyValue("Workspace", orgName),
|
|
28
|
-
keyValue("Role", role),
|
|
29
|
-
keyValue("Expires
|
|
30
|
-
footer(`This invitation was sent via ${COMPANY_NAME}.`, NOTICE),
|
|
37
|
+
keyValue("Role", titleCase(role)),
|
|
38
|
+
keyValue("Expires", formatExpiry(expiresAt), "normal"),
|
|
39
|
+
footer(`This invitation was sent via ${COMPANY_NAME}.`, NOTICE, "none"),
|
|
31
40
|
signature(),
|
|
32
41
|
];
|
|
33
42
|
}
|
|
@@ -4,7 +4,17 @@
|
|
|
4
4
|
|
|
5
5
|
import type { EmailPayloads } from "../types";
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
type Block,
|
|
9
|
+
chatAssistant,
|
|
10
|
+
chatUnit,
|
|
11
|
+
footer,
|
|
12
|
+
greeting,
|
|
13
|
+
keyValue,
|
|
14
|
+
paragraph,
|
|
15
|
+
signature,
|
|
16
|
+
} from "./blocks";
|
|
17
|
+
import { COMPANY_NAME } from "./constants";
|
|
8
18
|
|
|
9
19
|
export type OwnershipTransferCompletedPayload =
|
|
10
20
|
EmailPayloads["org.ownership_transfer_completed"];
|
|
@@ -15,18 +25,17 @@ export function renderOwnershipTransferCompleted(
|
|
|
15
25
|
const { orgName, newOwnerEmail } = payload;
|
|
16
26
|
|
|
17
27
|
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.",
|
|
28
|
+
greeting(),
|
|
29
|
+
paragraph("Ownership was transferred."),
|
|
30
|
+
chatUnit(
|
|
31
|
+
chatAssistant("You're no longer the owner, but you remain an admin."),
|
|
27
32
|
),
|
|
33
|
+
keyValue("Workspace", orgName),
|
|
34
|
+
keyValue("New owner", newOwnerEmail, "normal"),
|
|
28
35
|
footer(
|
|
29
|
-
|
|
36
|
+
`This confirmation was sent via ${COMPANY_NAME}.`,
|
|
37
|
+
"If you did not authorize this, contact support immediately.",
|
|
38
|
+
"none",
|
|
30
39
|
),
|
|
31
40
|
signature(),
|
|
32
41
|
];
|
|
@@ -6,40 +6,40 @@ import type { EmailPayloads } from "../types";
|
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
8
|
type Block,
|
|
9
|
-
|
|
9
|
+
chatAssistant,
|
|
10
|
+
chatCta,
|
|
11
|
+
chatUnit,
|
|
10
12
|
chatUser,
|
|
11
|
-
ctaBox,
|
|
12
13
|
footer,
|
|
14
|
+
formatExpiry,
|
|
15
|
+
greeting,
|
|
13
16
|
keyValue,
|
|
17
|
+
NOTICE,
|
|
14
18
|
paragraph,
|
|
15
19
|
signature,
|
|
16
20
|
} from "./blocks";
|
|
21
|
+
import { COMPANY_NAME } from "./constants";
|
|
17
22
|
|
|
18
23
|
export type OwnershipTransferPayload = EmailPayloads["org.ownership_transfer"];
|
|
19
24
|
|
|
20
25
|
export function renderOwnershipTransfer(
|
|
21
26
|
payload: OwnershipTransferPayload,
|
|
22
27
|
): 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.",
|
|
28
|
+
const { orgName, acceptUrl, expiresAt, note, fromName } = payload;
|
|
29
|
+
|
|
30
|
+
return [
|
|
31
|
+
greeting(),
|
|
32
|
+
paragraph("You've been invited to take ownership."),
|
|
33
|
+
chatUnit(
|
|
34
|
+
note
|
|
35
|
+
? chatUser(note, fromName)
|
|
36
|
+
: chatAssistant("Accept to take ownership."),
|
|
37
|
+
chatCta({ label: "ACCEPT", href: acceptUrl }),
|
|
40
38
|
),
|
|
39
|
+
...(fromName ? [keyValue("From", fromName)] : []),
|
|
40
|
+
keyValue("Workspace", orgName),
|
|
41
|
+
keyValue("Expires", formatExpiry(expiresAt), "normal"),
|
|
42
|
+
footer(`This transfer was sent via ${COMPANY_NAME}.`, NOTICE, "none"),
|
|
41
43
|
signature(),
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
return blocks;
|
|
44
|
+
];
|
|
45
45
|
}
|
|
@@ -64,6 +64,41 @@ function toEmail(subject: string, blocks: Block[]): RenderedEmail {
|
|
|
64
64
|
return { subject, text: textShell(blocks), html: htmlShell(blocks) };
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Fill `{field}` placeholders in a registry subject from the payload (e.g.
|
|
69
|
+
* `{orgName}` → the payload's orgName). Unmatched/absent placeholders are left
|
|
70
|
+
* verbatim, and a placeholder-free subject passes through untouched.
|
|
71
|
+
*/
|
|
72
|
+
function resolveSubject(template: string, payload: unknown): string {
|
|
73
|
+
if (!payload || typeof payload !== "object") return template;
|
|
74
|
+
const data = payload as Record<string, unknown>;
|
|
75
|
+
return template.replace(/\{(\w+)\}/g, (whole, key) =>
|
|
76
|
+
data[key] != null ? String(data[key]) : whole,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Per-kind subject data. Most kinds resolve directly against the payload; a few
|
|
82
|
+
* expose a derived token the payload doesn't carry. `org.unit_owner_granted`
|
|
83
|
+
* needs a short role word ("owner"/"delegate") for the subject, whereas the
|
|
84
|
+
* payload's `roleLabel` is the display form ("Unit owner"/"Delegate") used in
|
|
85
|
+
* the body's Role row — so it's derived here rather than mutating roleLabel.
|
|
86
|
+
*/
|
|
87
|
+
function subjectData(kind: EmailKind, payload: unknown): unknown {
|
|
88
|
+
if (
|
|
89
|
+
kind === "org.unit_owner_granted" &&
|
|
90
|
+
payload &&
|
|
91
|
+
typeof payload === "object"
|
|
92
|
+
) {
|
|
93
|
+
const roleLabel = (payload as { roleLabel?: string }).roleLabel;
|
|
94
|
+
return {
|
|
95
|
+
...payload,
|
|
96
|
+
roleWord: roleLabel === "Delegate" ? "delegate owner" : "owner",
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
return payload;
|
|
100
|
+
}
|
|
101
|
+
|
|
67
102
|
/**
|
|
68
103
|
* Render an email by kind. Subject comes from `EMAIL_KINDS`; both surfaces
|
|
69
104
|
* derive from one composed block list. Accepts the full `EmailKind` union so
|
|
@@ -77,7 +112,10 @@ export function renderEmail<K extends EmailKind>(
|
|
|
77
112
|
payload: PayloadFor<K>,
|
|
78
113
|
options?: RenderEmailOptions,
|
|
79
114
|
): RenderedEmail {
|
|
80
|
-
const subject =
|
|
115
|
+
const subject = resolveSubject(
|
|
116
|
+
EMAIL_KINDS[kind].subject,
|
|
117
|
+
subjectData(kind, payload),
|
|
118
|
+
);
|
|
81
119
|
|
|
82
120
|
switch (kind) {
|
|
83
121
|
case "auth.otp":
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Security alert email (plain text only).
|
|
2
|
+
* Security alert email (plain text only).
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import type { EmailPayloads } from "../types";
|
|
6
6
|
|
|
7
|
-
import { type Block, keyValue, paragraph, signature } from "./blocks";
|
|
8
|
-
import { COMPANY_NAME } from "./constants";
|
|
7
|
+
import { type Block, keyValue, paragraph, security, signature } from "./blocks";
|
|
9
8
|
|
|
10
9
|
export type SecurityAlertPayload = EmailPayloads["security.alert"];
|
|
11
10
|
export type SecurityAlertType = SecurityAlertPayload["alertType"];
|
|
@@ -18,19 +17,17 @@ export const SECURITY_ALERT_TYPES = [
|
|
|
18
17
|
export function renderSecurityAlert(payload: SecurityAlertPayload): Block[] {
|
|
19
18
|
const { alertType, details, timestamp } = payload;
|
|
20
19
|
|
|
21
|
-
const blocks: Block[] = [];
|
|
20
|
+
const blocks: Block[] = [security()];
|
|
22
21
|
|
|
23
22
|
switch (alertType) {
|
|
24
23
|
case "excessive_otp_requests":
|
|
25
24
|
blocks.push(
|
|
26
|
-
paragraph(
|
|
27
|
-
"We detected an unusual number of login code requests for your account.",
|
|
28
|
-
),
|
|
25
|
+
paragraph("Unusual login-code activity detected."),
|
|
29
26
|
keyValue("Details", details, "normal"),
|
|
30
27
|
paragraph("If this was you, no action is needed.", "tight"),
|
|
31
28
|
paragraph(
|
|
32
29
|
"If you didn't request these codes, someone may be trying to access your account.",
|
|
33
|
-
"
|
|
30
|
+
"normal",
|
|
34
31
|
),
|
|
35
32
|
paragraph("We recommend reviewing your account security."),
|
|
36
33
|
);
|
|
@@ -38,12 +35,14 @@ export function renderSecurityAlert(payload: SecurityAlertPayload): Block[] {
|
|
|
38
35
|
|
|
39
36
|
case "unusual_login_location":
|
|
40
37
|
blocks.push(
|
|
41
|
-
paragraph("
|
|
38
|
+
paragraph("A login from an unusual location was detected."),
|
|
42
39
|
keyValue("Details", details, "normal"),
|
|
43
40
|
paragraph("If this was you, no action is needed.", "tight"),
|
|
44
41
|
paragraph(
|
|
45
|
-
"If you didn't attempt to log in,
|
|
42
|
+
"If you didn't attempt to log in, someone may be trying to access your account.",
|
|
43
|
+
"normal",
|
|
46
44
|
),
|
|
45
|
+
paragraph("We recommend reviewing your account security."),
|
|
47
46
|
);
|
|
48
47
|
break;
|
|
49
48
|
|
|
@@ -54,8 +53,8 @@ export function renderSecurityAlert(payload: SecurityAlertPayload): Block[] {
|
|
|
54
53
|
}
|
|
55
54
|
|
|
56
55
|
blocks.push(
|
|
57
|
-
keyValue("Time", new Date(timestamp).toUTCString(), "
|
|
58
|
-
signature(
|
|
56
|
+
keyValue("Time", new Date(timestamp).toUTCString(), "none"),
|
|
57
|
+
signature(),
|
|
59
58
|
);
|
|
60
59
|
|
|
61
60
|
return blocks;
|
|
@@ -7,12 +7,13 @@ import type { EmailPayloads } from "../types";
|
|
|
7
7
|
import {
|
|
8
8
|
ACCESS_PHRASE,
|
|
9
9
|
type Block,
|
|
10
|
-
|
|
10
|
+
chatAssistant,
|
|
11
|
+
chatCta,
|
|
12
|
+
chatUnit,
|
|
11
13
|
chatUser,
|
|
12
|
-
ctaBox,
|
|
13
14
|
footer,
|
|
14
15
|
greeting,
|
|
15
|
-
|
|
16
|
+
keyValue,
|
|
16
17
|
NOTICE,
|
|
17
18
|
paragraph,
|
|
18
19
|
signature,
|
|
@@ -32,25 +33,21 @@ export function renderShareGranted(payload: ShareGrantedPayload): Block[] {
|
|
|
32
33
|
message,
|
|
33
34
|
} = payload;
|
|
34
35
|
|
|
35
|
-
const entity: Array<string | Inline> = entityTitle
|
|
36
|
-
? [`the ${entityLabel} `, bold(`"${entityTitle}"`)]
|
|
37
|
-
: [`a ${entityLabel}`];
|
|
38
|
-
|
|
39
36
|
const blocks: Block[] = [
|
|
40
37
|
greeting(recipientName),
|
|
41
|
-
paragraph(
|
|
42
|
-
|
|
43
|
-
"
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
38
|
+
paragraph(`A ${entityLabel} was shared with you.`),
|
|
39
|
+
chatUnit(
|
|
40
|
+
message ? chatUser(message, granterName) : chatAssistant("Open to view."),
|
|
41
|
+
chatCta({ label: "OPEN", href: ctaUrl }),
|
|
42
|
+
),
|
|
43
|
+
keyValue("From", granterName),
|
|
47
44
|
];
|
|
48
45
|
|
|
49
|
-
if (
|
|
46
|
+
if (entityTitle) blocks.push(keyValue("Item", `"${entityTitle}"`));
|
|
50
47
|
|
|
51
48
|
blocks.push(
|
|
52
|
-
|
|
53
|
-
footer(`This notification was sent via ${COMPANY_NAME}.`, NOTICE),
|
|
49
|
+
keyValue("Access", ACCESS_PHRASE[accessLevel], "normal"),
|
|
50
|
+
footer(`This notification was sent via ${COMPANY_NAME}.`, NOTICE, "none"),
|
|
54
51
|
signature(),
|
|
55
52
|
);
|
|
56
53
|
|
|
@@ -6,14 +6,18 @@ import type { EmailPayloads } from "../types";
|
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
8
|
type Block,
|
|
9
|
-
|
|
9
|
+
chatAssistant,
|
|
10
|
+
chatCta,
|
|
11
|
+
chatUnit,
|
|
10
12
|
chatUser,
|
|
11
|
-
ctaBox,
|
|
12
13
|
footer,
|
|
14
|
+
formatExpiry,
|
|
13
15
|
greeting,
|
|
16
|
+
keyValue,
|
|
14
17
|
NOTICE,
|
|
15
18
|
paragraph,
|
|
16
19
|
signature,
|
|
20
|
+
titleCase,
|
|
17
21
|
} from "./blocks";
|
|
18
22
|
import { COMPANY_NAME } from "./constants";
|
|
19
23
|
|
|
@@ -22,28 +26,38 @@ export type UnitOwnerGrantedPayload = EmailPayloads["org.unit_owner_granted"];
|
|
|
22
26
|
export function renderUnitOwnerGranted(
|
|
23
27
|
payload: UnitOwnerGrantedPayload,
|
|
24
28
|
): Block[] {
|
|
25
|
-
const {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
+
const {
|
|
30
|
+
granterName,
|
|
31
|
+
recipientName,
|
|
32
|
+
unitName,
|
|
33
|
+
roleLabel,
|
|
34
|
+
ctaUrl,
|
|
35
|
+
message,
|
|
36
|
+
expiresAt,
|
|
37
|
+
} = payload;
|
|
38
|
+
const roleNoun = roleLabel === "Delegate" ? "delegate owner" : "unit owner";
|
|
39
|
+
|
|
40
|
+
return [
|
|
29
41
|
greeting(recipientName),
|
|
30
|
-
paragraph(
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
"
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
42
|
+
paragraph(
|
|
43
|
+
`You are now a ${roleNoun} of ${unitName}. ${roleNoun[0].toUpperCase()}${roleNoun.slice(1)}s hold authority over the team: managing team membership, doc access, strategy, and execution against goals.`,
|
|
44
|
+
),
|
|
45
|
+
chatUnit(
|
|
46
|
+
...(message ? [chatUser(message, granterName)] : []),
|
|
47
|
+
chatAssistant("Open the team to get started."),
|
|
48
|
+
chatCta({ label: "MANAGE TEAM", href: ctaUrl }),
|
|
49
|
+
),
|
|
50
|
+
keyValue("From", granterName),
|
|
51
|
+
keyValue("Team", unitName),
|
|
52
|
+
keyValue(
|
|
53
|
+
"Role",
|
|
54
|
+
titleCase(roleLabel),
|
|
55
|
+
expiresAt != null ? "tight" : "normal",
|
|
56
|
+
),
|
|
57
|
+
...(expiresAt != null
|
|
58
|
+
? [keyValue("Expires", formatExpiry(expiresAt), "normal")]
|
|
59
|
+
: []),
|
|
60
|
+
footer(`This notification was sent via ${COMPANY_NAME}.`, NOTICE, "none"),
|
|
45
61
|
signature(),
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
return blocks;
|
|
62
|
+
];
|
|
49
63
|
}
|
package/src/email/types.ts
CHANGED
|
@@ -63,13 +63,16 @@ export interface EmailPayloads {
|
|
|
63
63
|
orgName: string;
|
|
64
64
|
role: "admin" | "member";
|
|
65
65
|
acceptUrl: string;
|
|
66
|
-
|
|
66
|
+
/** ISO timestamp when the invitation expires. Rendered as "Expires: Jun 13, 2026". */
|
|
67
|
+
expiresAt: string;
|
|
67
68
|
};
|
|
68
69
|
"org.unit_owner_granted": {
|
|
69
70
|
/** Display name of the person who granted access */
|
|
70
71
|
granterName: string;
|
|
71
72
|
/** Display name of the recipient (optional; falls back to a neutral greeting) */
|
|
72
73
|
recipientName?: string;
|
|
74
|
+
/** Name of the organization / workspace (rendered in the subject) */
|
|
75
|
+
orgName: string;
|
|
73
76
|
/** Name of the org unit / team the access applies to */
|
|
74
77
|
unitName: string;
|
|
75
78
|
/** Human-readable role label the recipient was given */
|
|
@@ -78,16 +81,20 @@ export interface EmailPayloads {
|
|
|
78
81
|
ctaUrl: string;
|
|
79
82
|
/** Optional message from the granter, shown in the email and recorded with the grant */
|
|
80
83
|
message?: string;
|
|
84
|
+
/** ISO timestamp when the grant expires. Absent = permanent (formal unit owners; delegations with no expiry). Rendered as the last key/value row ("Expires: Jun 13, 2026") when present. */
|
|
85
|
+
expiresAt?: string;
|
|
81
86
|
};
|
|
82
87
|
"org.ownership_transfer": {
|
|
83
88
|
/** Name of the organization being transferred (subject is static; org name renders in the body) */
|
|
84
89
|
orgName: string;
|
|
85
90
|
/** Full URL to accept the transfer (token-bearing) */
|
|
86
91
|
acceptUrl: string;
|
|
87
|
-
/**
|
|
88
|
-
|
|
92
|
+
/** ISO timestamp when the invitation expires. Rendered as "Expires: Jun 13, 2026". */
|
|
93
|
+
expiresAt: string;
|
|
89
94
|
/** Optional message from the current owner */
|
|
90
95
|
note?: string;
|
|
96
|
+
/** Display name of the workspace owner who initiated the transfer; attributes the note bubble. Omitted for admin-initiated transfers (no workspace-owner author). */
|
|
97
|
+
fromName?: string;
|
|
91
98
|
};
|
|
92
99
|
"org.ownership_transfer_completed": {
|
|
93
100
|
/** Name of the organization that was transferred */
|