@company-semantics/contracts 27.13.1 → 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 +281 -221
- package/src/email/render/auth-otp.ts +31 -50
- package/src/email/render/blocks.ts +230 -179
- package/src/email/render/chat-shared.ts +19 -61
- package/src/email/render/company-md-access-approved.ts +16 -40
- package/src/email/render/company-md-access-denied.ts +23 -44
- package/src/email/render/company-md-access-requested.ts +23 -48
- package/src/email/render/constants.ts +0 -6
- package/src/email/render/index.ts +15 -58
- package/src/email/render/org-invite.ts +14 -37
- 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 +27 -58
- package/src/email/render/unit-owner-granted.ts +26 -50
- package/src/email/render/base-text.ts +0 -26
|
@@ -1,39 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Central email dispatcher. Given a kind + payload it returns
|
|
3
3
|
* `{ subject, text, html? }`, pulling the subject from the registry (single
|
|
4
|
-
* source of truth)
|
|
5
|
-
*
|
|
4
|
+
* source of truth) and deriving both surfaces from ONE composed block list
|
|
5
|
+
* (`htmlShell` + `textShell`). This is the one entry point both the backend
|
|
6
|
+
* (real sends) and the app (Ladle preview) call.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
import { EMAIL_KINDS } from "../registry";
|
|
9
10
|
import type { EmailKind, EmailPayloads } from "../types";
|
|
10
11
|
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
} from "./company-md-access-
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
} from "./
|
|
20
|
-
import {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
} from "./company-md-access-requested";
|
|
24
|
-
import { renderChatSharedBody, renderChatSharedHtml } from "./chat-shared";
|
|
25
|
-
import { renderOrgInviteBody, renderOrgInviteHtml } from "./org-invite";
|
|
26
|
-
import { renderOwnershipTransferBody } from "./ownership-transfer";
|
|
27
|
-
import { renderOwnershipTransferCompletedBody } from "./ownership-transfer-completed";
|
|
28
|
-
import { renderSecurityAlertBody } from "./security-alert";
|
|
29
|
-
import {
|
|
30
|
-
renderShareGrantedBody,
|
|
31
|
-
renderShareGrantedHtml,
|
|
32
|
-
} from "./share-granted";
|
|
33
|
-
import {
|
|
34
|
-
renderUnitOwnerGrantedBody,
|
|
35
|
-
renderUnitOwnerGrantedHtml,
|
|
36
|
-
} from "./unit-owner-granted";
|
|
12
|
+
import { renderAuthOtp } from "./auth-otp";
|
|
13
|
+
import { type Block, htmlShell, textShell } from "./blocks";
|
|
14
|
+
import { renderAccessApproved } from "./company-md-access-approved";
|
|
15
|
+
import { renderAccessDenied } from "./company-md-access-denied";
|
|
16
|
+
import { renderAccessRequested } from "./company-md-access-requested";
|
|
17
|
+
import { renderChatShared } from "./chat-shared";
|
|
18
|
+
import { renderOrgInvite } from "./org-invite";
|
|
19
|
+
import { renderOwnershipTransfer } from "./ownership-transfer";
|
|
20
|
+
import { renderOwnershipTransferCompleted } from "./ownership-transfer-completed";
|
|
21
|
+
import { renderSecurityAlert } from "./security-alert";
|
|
22
|
+
import { renderShareGranted } from "./share-granted";
|
|
23
|
+
import { renderUnitOwnerGranted } from "./unit-owner-granted";
|
|
37
24
|
|
|
38
25
|
/** Rendered email output. */
|
|
39
26
|
export interface RenderedEmail {
|
|
@@ -68,16 +55,37 @@ export const IMPLEMENTED_EMAIL_KINDS = [
|
|
|
68
55
|
|
|
69
56
|
export type ImplementedEmailKind = (typeof IMPLEMENTED_EMAIL_KINDS)[number];
|
|
70
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Kinds delivered as plain text only. The unified block model renders HTML for
|
|
60
|
+
* every template, but these keep their historical text-only surface — the
|
|
61
|
+
* dispatcher omits `html` for them. (Enabling HTML later is deleting the entry.)
|
|
62
|
+
*/
|
|
63
|
+
const TEXT_ONLY_KINDS = new Set<EmailKind>([
|
|
64
|
+
"security.alert",
|
|
65
|
+
"org.ownership_transfer",
|
|
66
|
+
"org.ownership_transfer_completed",
|
|
67
|
+
]);
|
|
68
|
+
|
|
71
69
|
/** Payload type for a kind (or `never` for kinds without a payload). */
|
|
72
70
|
type PayloadFor<K extends EmailKind> = K extends keyof EmailPayloads
|
|
73
71
|
? EmailPayloads[K]
|
|
74
72
|
: never;
|
|
75
73
|
|
|
74
|
+
function toEmail(
|
|
75
|
+
subject: string,
|
|
76
|
+
kind: EmailKind,
|
|
77
|
+
blocks: Block[],
|
|
78
|
+
): RenderedEmail {
|
|
79
|
+
const email: RenderedEmail = { subject, text: textShell(blocks) };
|
|
80
|
+
if (!TEXT_ONLY_KINDS.has(kind)) email.html = htmlShell(blocks);
|
|
81
|
+
return email;
|
|
82
|
+
}
|
|
83
|
+
|
|
76
84
|
/**
|
|
77
|
-
* Render an email by kind. Subject comes from `EMAIL_KINDS`;
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
85
|
+
* Render an email by kind. Subject comes from `EMAIL_KINDS`; both surfaces
|
|
86
|
+
* derive from one composed block list. Accepts the full `EmailKind` union so
|
|
87
|
+
* callers with a generic kind (e.g. the backend `EmailService`) type cleanly;
|
|
88
|
+
* unimplemented kinds throw at runtime.
|
|
81
89
|
*
|
|
82
90
|
* @throws if the kind has no implementation.
|
|
83
91
|
*/
|
|
@@ -89,82 +97,84 @@ export function renderEmail<K extends EmailKind>(
|
|
|
89
97
|
const subject = EMAIL_KINDS[kind].subject;
|
|
90
98
|
|
|
91
99
|
switch (kind) {
|
|
92
|
-
case "auth.otp":
|
|
93
|
-
|
|
94
|
-
return {
|
|
100
|
+
case "auth.otp":
|
|
101
|
+
return toEmail(
|
|
95
102
|
subject,
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
const p = payload as EmailPayloads["org.invite"];
|
|
102
|
-
return {
|
|
103
|
+
kind,
|
|
104
|
+
renderAuthOtp(payload as EmailPayloads["auth.otp"], options),
|
|
105
|
+
);
|
|
106
|
+
case "org.invite":
|
|
107
|
+
return toEmail(
|
|
103
108
|
subject,
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
const p = payload as EmailPayloads["org.unit_owner_granted"];
|
|
110
|
-
return {
|
|
109
|
+
kind,
|
|
110
|
+
renderOrgInvite(payload as EmailPayloads["org.invite"]),
|
|
111
|
+
);
|
|
112
|
+
case "org.unit_owner_granted":
|
|
113
|
+
return toEmail(
|
|
111
114
|
subject,
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
return
|
|
119
|
-
}
|
|
120
|
-
case "org.ownership_transfer_completed": {
|
|
121
|
-
const p = payload as EmailPayloads["org.ownership_transfer_completed"];
|
|
122
|
-
return { subject, text: renderOwnershipTransferCompletedBody(p) };
|
|
123
|
-
}
|
|
124
|
-
case "security.alert": {
|
|
125
|
-
const p = payload as EmailPayloads["security.alert"];
|
|
126
|
-
return { subject, text: renderSecurityAlertBody(p) };
|
|
127
|
-
}
|
|
128
|
-
case "chat.shared": {
|
|
129
|
-
const p = payload as EmailPayloads["chat.shared"];
|
|
130
|
-
return {
|
|
115
|
+
kind,
|
|
116
|
+
renderUnitOwnerGranted(
|
|
117
|
+
payload as EmailPayloads["org.unit_owner_granted"],
|
|
118
|
+
),
|
|
119
|
+
);
|
|
120
|
+
case "org.ownership_transfer":
|
|
121
|
+
return toEmail(
|
|
131
122
|
subject,
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
return
|
|
123
|
+
kind,
|
|
124
|
+
renderOwnershipTransfer(
|
|
125
|
+
payload as EmailPayloads["org.ownership_transfer"],
|
|
126
|
+
),
|
|
127
|
+
);
|
|
128
|
+
case "org.ownership_transfer_completed":
|
|
129
|
+
return toEmail(
|
|
139
130
|
subject,
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
return
|
|
131
|
+
kind,
|
|
132
|
+
renderOwnershipTransferCompleted(
|
|
133
|
+
payload as EmailPayloads["org.ownership_transfer_completed"],
|
|
134
|
+
),
|
|
135
|
+
);
|
|
136
|
+
case "security.alert":
|
|
137
|
+
return toEmail(
|
|
147
138
|
subject,
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
const p = payload as EmailPayloads["companyMd.access_request_approved"];
|
|
154
|
-
return {
|
|
139
|
+
kind,
|
|
140
|
+
renderSecurityAlert(payload as EmailPayloads["security.alert"]),
|
|
141
|
+
);
|
|
142
|
+
case "chat.shared":
|
|
143
|
+
return toEmail(
|
|
155
144
|
subject,
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
const p = payload as EmailPayloads["companyMd.access_request_denied"];
|
|
162
|
-
return {
|
|
145
|
+
kind,
|
|
146
|
+
renderChatShared(payload as EmailPayloads["chat.shared"]),
|
|
147
|
+
);
|
|
148
|
+
case "share.granted":
|
|
149
|
+
return toEmail(
|
|
163
150
|
subject,
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
151
|
+
kind,
|
|
152
|
+
renderShareGranted(payload as EmailPayloads["share.granted"]),
|
|
153
|
+
);
|
|
154
|
+
case "companyMd.access_requested":
|
|
155
|
+
return toEmail(
|
|
156
|
+
subject,
|
|
157
|
+
kind,
|
|
158
|
+
renderAccessRequested(
|
|
159
|
+
payload as EmailPayloads["companyMd.access_requested"],
|
|
160
|
+
),
|
|
161
|
+
);
|
|
162
|
+
case "companyMd.access_request_approved":
|
|
163
|
+
return toEmail(
|
|
164
|
+
subject,
|
|
165
|
+
kind,
|
|
166
|
+
renderAccessApproved(
|
|
167
|
+
payload as EmailPayloads["companyMd.access_request_approved"],
|
|
168
|
+
),
|
|
169
|
+
);
|
|
170
|
+
case "companyMd.access_request_denied":
|
|
171
|
+
return toEmail(
|
|
172
|
+
subject,
|
|
173
|
+
kind,
|
|
174
|
+
renderAccessDenied(
|
|
175
|
+
payload as EmailPayloads["companyMd.access_request_denied"],
|
|
176
|
+
),
|
|
177
|
+
);
|
|
168
178
|
default: {
|
|
169
179
|
// Reachable for registered-but-unimplemented kinds (e.g. auth.magic_link).
|
|
170
180
|
const unimplemented: string = kind;
|
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Security alert email (plain text only).
|
|
3
|
-
*
|
|
4
|
-
* Uses a single shared footer via `baseTextLayout` with a "Security" signer —
|
|
5
|
-
* the previous copy hand-rolled a second footer, printing two.
|
|
2
|
+
* Security alert email (plain text only). Signs off with a "Security" signer.
|
|
6
3
|
*/
|
|
7
4
|
|
|
8
5
|
import type { EmailPayloads } from "../types";
|
|
9
6
|
|
|
10
|
-
import {
|
|
7
|
+
import { type Block, keyValue, paragraph, signature } from "./blocks";
|
|
11
8
|
import { COMPANY_NAME } from "./constants";
|
|
12
9
|
|
|
13
10
|
export type SecurityAlertPayload = EmailPayloads["security.alert"];
|
|
@@ -18,34 +15,35 @@ export const SECURITY_ALERT_TYPES = [
|
|
|
18
15
|
"unusual_login_location",
|
|
19
16
|
] as const;
|
|
20
17
|
|
|
21
|
-
export function
|
|
18
|
+
export function renderSecurityAlert(payload: SecurityAlertPayload): Block[] {
|
|
22
19
|
const { alertType, details, timestamp } = payload;
|
|
23
20
|
|
|
24
|
-
const
|
|
21
|
+
const blocks: Block[] = [];
|
|
25
22
|
|
|
26
23
|
switch (alertType) {
|
|
27
24
|
case "excessive_otp_requests":
|
|
28
|
-
|
|
29
|
-
|
|
25
|
+
blocks.push(
|
|
26
|
+
paragraph(
|
|
27
|
+
"We detected an unusual number of login code requests for your account.",
|
|
28
|
+
),
|
|
29
|
+
keyValue("Details", details, "normal"),
|
|
30
|
+
paragraph("If this was you, no action is needed.", "tight"),
|
|
31
|
+
paragraph(
|
|
32
|
+
"If you didn't request these codes, someone may be trying to access your account.",
|
|
33
|
+
"tight",
|
|
34
|
+
),
|
|
35
|
+
paragraph("We recommend reviewing your account security."),
|
|
30
36
|
);
|
|
31
|
-
sections.push("");
|
|
32
|
-
sections.push(`Details: ${details}`);
|
|
33
|
-
sections.push("");
|
|
34
|
-
sections.push("If this was you, no action is needed.");
|
|
35
|
-
sections.push(
|
|
36
|
-
"If you didn't request these codes, someone may be trying to access your account.",
|
|
37
|
-
);
|
|
38
|
-
sections.push("We recommend reviewing your account security.");
|
|
39
37
|
break;
|
|
40
38
|
|
|
41
39
|
case "unusual_login_location":
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
40
|
+
blocks.push(
|
|
41
|
+
paragraph("We detected a login attempt from an unusual location."),
|
|
42
|
+
keyValue("Details", details, "normal"),
|
|
43
|
+
paragraph("If this was you, no action is needed.", "tight"),
|
|
44
|
+
paragraph(
|
|
45
|
+
"If you didn't attempt to log in, please secure your account immediately.",
|
|
46
|
+
),
|
|
49
47
|
);
|
|
50
48
|
break;
|
|
51
49
|
|
|
@@ -55,8 +53,10 @@ export function renderSecurityAlertBody(payload: SecurityAlertPayload): string {
|
|
|
55
53
|
}
|
|
56
54
|
}
|
|
57
55
|
|
|
58
|
-
|
|
59
|
-
|
|
56
|
+
blocks.push(
|
|
57
|
+
keyValue("Time", new Date(timestamp).toUTCString(), "normal"),
|
|
58
|
+
signature(`${COMPANY_NAME} Security`),
|
|
59
|
+
);
|
|
60
60
|
|
|
61
|
-
return
|
|
61
|
+
return blocks;
|
|
62
62
|
}
|
|
@@ -1,62 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Share-granted email (an entity was shared with the recipient).
|
|
2
|
+
* Share-granted email (an entity was shared with the recipient).
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import type { EmailPayloads } from "../types";
|
|
6
6
|
|
|
7
|
-
import { baseTextLayout } from "./base-text";
|
|
8
7
|
import {
|
|
9
8
|
ACCESS_PHRASE,
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
type Block,
|
|
10
|
+
bold,
|
|
11
|
+
chatUser,
|
|
12
12
|
ctaBox,
|
|
13
13
|
footer,
|
|
14
14
|
greeting,
|
|
15
|
-
|
|
15
|
+
type Inline,
|
|
16
16
|
NOTICE,
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
textQuote,
|
|
17
|
+
paragraph,
|
|
18
|
+
signature,
|
|
20
19
|
} from "./blocks";
|
|
21
20
|
import { COMPANY_NAME } from "./constants";
|
|
22
|
-
import { escapeHtml } from "./escape-html";
|
|
23
21
|
|
|
24
22
|
export type ShareGrantedPayload = EmailPayloads["share.granted"];
|
|
25
23
|
|
|
26
|
-
|
|
27
|
-
function describeEntity(payload: ShareGrantedPayload): string {
|
|
28
|
-
return payload.entityTitle
|
|
29
|
-
? `the ${payload.entityLabel} "${payload.entityTitle}"`
|
|
30
|
-
: `a ${payload.entityLabel}`;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export function renderShareGrantedBody(payload: ShareGrantedPayload): string {
|
|
34
|
-
const { granterName, recipientName, accessLevel, ctaUrl, message } = payload;
|
|
35
|
-
|
|
36
|
-
const sections: string[] = [];
|
|
37
|
-
sections.push(recipientName ? `Hi ${recipientName},` : "Hi,");
|
|
38
|
-
sections.push("");
|
|
39
|
-
sections.push(
|
|
40
|
-
`${granterName} shared ${describeEntity(payload)} with you — you ${ACCESS_PHRASE[accessLevel]}.`,
|
|
41
|
-
);
|
|
42
|
-
|
|
43
|
-
if (message) {
|
|
44
|
-
sections.push("");
|
|
45
|
-
sections.push(textQuote(`Message from ${granterName}`, message));
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
sections.push("");
|
|
49
|
-
sections.push(asciiCtaBox("OPEN"));
|
|
50
|
-
sections.push("");
|
|
51
|
-
sections.push(ctaUrl);
|
|
52
|
-
sections.push("");
|
|
53
|
-
sections.push(`This notification was sent via ${COMPANY_NAME}.`);
|
|
54
|
-
sections.push(NOTICE);
|
|
55
|
-
|
|
56
|
-
return baseTextLayout(sections.join("\n"));
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export function renderShareGrantedHtml(payload: ShareGrantedPayload): string {
|
|
24
|
+
export function renderShareGranted(payload: ShareGrantedPayload): Block[] {
|
|
60
25
|
const {
|
|
61
26
|
granterName,
|
|
62
27
|
recipientName,
|
|
@@ -67,21 +32,23 @@ export function renderShareGrantedHtml(payload: ShareGrantedPayload): string {
|
|
|
67
32
|
message,
|
|
68
33
|
} = payload;
|
|
69
34
|
|
|
70
|
-
const
|
|
71
|
-
? `the ${
|
|
72
|
-
: `a ${
|
|
35
|
+
const entity: Array<string | Inline> = entityTitle
|
|
36
|
+
? [`the ${entityLabel} `, bold(`"${entityTitle}"`)]
|
|
37
|
+
: [`a ${entityLabel}`];
|
|
73
38
|
|
|
74
|
-
|
|
39
|
+
const blocks: Block[] = [
|
|
75
40
|
greeting(recipientName),
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
41
|
+
paragraph([
|
|
42
|
+
bold(granterName),
|
|
43
|
+
" shared ",
|
|
44
|
+
...entity,
|
|
45
|
+
` with you — you ${ACCESS_PHRASE[accessLevel]}.`,
|
|
46
|
+
]),
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
if (message) blocks.push(chatUser(message, `Message from ${granterName}`));
|
|
50
|
+
|
|
51
|
+
blocks.push(
|
|
85
52
|
ctaBox({
|
|
86
53
|
label: "OPEN",
|
|
87
54
|
href: ctaUrl,
|
|
@@ -90,6 +57,8 @@ export function renderShareGrantedHtml(payload: ShareGrantedPayload): string {
|
|
|
90
57
|
maxWidth: "220px",
|
|
91
58
|
}),
|
|
92
59
|
footer(`This notification was sent via ${COMPANY_NAME}.`, NOTICE),
|
|
93
|
-
|
|
94
|
-
|
|
60
|
+
signature(),
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
return blocks;
|
|
95
64
|
}
|
|
@@ -1,71 +1,45 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Unit-owner-granted email (added to a team's unit ownership).
|
|
2
|
+
* Unit-owner-granted email (added to a team's unit ownership).
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import type { EmailPayloads } from "../types";
|
|
6
6
|
|
|
7
|
-
import { baseTextLayout } from "./base-text";
|
|
8
7
|
import {
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
type Block,
|
|
9
|
+
bold,
|
|
10
|
+
chatUser,
|
|
11
11
|
ctaBox,
|
|
12
12
|
footer,
|
|
13
13
|
greeting,
|
|
14
|
-
htmlShell,
|
|
15
14
|
NOTICE,
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
textQuote,
|
|
15
|
+
paragraph,
|
|
16
|
+
signature,
|
|
19
17
|
} from "./blocks";
|
|
20
18
|
import { COMPANY_NAME } from "./constants";
|
|
21
|
-
import { escapeHtml } from "./escape-html";
|
|
22
19
|
|
|
23
20
|
export type UnitOwnerGrantedPayload = EmailPayloads["org.unit_owner_granted"];
|
|
24
21
|
|
|
25
|
-
export function
|
|
22
|
+
export function renderUnitOwnerGranted(
|
|
26
23
|
payload: UnitOwnerGrantedPayload,
|
|
27
|
-
):
|
|
24
|
+
): Block[] {
|
|
28
25
|
const { granterName, recipientName, unitName, roleLabel, ctaUrl, message } =
|
|
29
26
|
payload;
|
|
30
27
|
|
|
31
|
-
const
|
|
32
|
-
sections.push(recipientName ? `Hi ${recipientName},` : "Hi,");
|
|
33
|
-
sections.push("");
|
|
34
|
-
sections.push(`${granterName} added you as a ${roleLabel} of "${unitName}".`);
|
|
35
|
-
|
|
36
|
-
if (message) {
|
|
37
|
-
sections.push("");
|
|
38
|
-
sections.push(textQuote(`Message from ${granterName}`, message));
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
sections.push("");
|
|
42
|
-
sections.push(asciiCtaBox("VIEW TEAM"));
|
|
43
|
-
sections.push("");
|
|
44
|
-
sections.push(ctaUrl);
|
|
45
|
-
sections.push("");
|
|
46
|
-
sections.push(`This notification was sent via ${COMPANY_NAME}.`);
|
|
47
|
-
sections.push(NOTICE);
|
|
48
|
-
|
|
49
|
-
return baseTextLayout(sections.join("\n"));
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export function renderUnitOwnerGrantedHtml(
|
|
53
|
-
payload: UnitOwnerGrantedPayload,
|
|
54
|
-
): string {
|
|
55
|
-
const { granterName, recipientName, unitName, roleLabel, ctaUrl, message } =
|
|
56
|
-
payload;
|
|
57
|
-
|
|
58
|
-
return htmlShell([
|
|
28
|
+
const blocks: Block[] = [
|
|
59
29
|
greeting(recipientName),
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
30
|
+
paragraph([
|
|
31
|
+
bold(granterName),
|
|
32
|
+
" added you as a ",
|
|
33
|
+
bold(roleLabel),
|
|
34
|
+
" of ",
|
|
35
|
+
bold(unitName),
|
|
36
|
+
".",
|
|
37
|
+
]),
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
if (message) blocks.push(chatUser(message, `Message from ${granterName}`));
|
|
41
|
+
|
|
42
|
+
blocks.push(
|
|
69
43
|
ctaBox({
|
|
70
44
|
label: "VIEW TEAM",
|
|
71
45
|
href: ctaUrl,
|
|
@@ -74,6 +48,8 @@ export function renderUnitOwnerGrantedHtml(
|
|
|
74
48
|
maxWidth: "220px",
|
|
75
49
|
}),
|
|
76
50
|
footer(`This notification was sent via ${COMPANY_NAME}.`, NOTICE),
|
|
77
|
-
|
|
78
|
-
|
|
51
|
+
signature(),
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
return blocks;
|
|
79
55
|
}
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Plain-text email layout.
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import { COMPANY_NAME, SUPPORT_EMAIL } from "./constants";
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Wrap plain-text email content in the shared footer
|
|
9
|
-
* (`--- / <signer> / Questions? Contact <support>`).
|
|
10
|
-
*
|
|
11
|
-
* @param content - Body content (without footer); trimmed.
|
|
12
|
-
* @param signer - Footer signer name; defaults to the company name.
|
|
13
|
-
* (`security.alert` passes "Company Semantics Security".)
|
|
14
|
-
*/
|
|
15
|
-
export function baseTextLayout(
|
|
16
|
-
content: string,
|
|
17
|
-
signer: string = COMPANY_NAME,
|
|
18
|
-
): string {
|
|
19
|
-
const footer = `
|
|
20
|
-
---
|
|
21
|
-
${signer}
|
|
22
|
-
Questions? Contact ${SUPPORT_EMAIL}
|
|
23
|
-
`;
|
|
24
|
-
|
|
25
|
-
return content.trim() + "\n" + footer.trim() + "\n";
|
|
26
|
-
}
|