@company-semantics/contracts 27.12.0 → 27.13.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/README.md +16 -8
- package/src/email/index.ts +6 -0
- package/src/email/render/__tests__/__snapshots__/render-snapshot.test.ts.snap +973 -0
- package/src/email/render/__tests__/render-snapshot.test.ts +214 -0
- package/src/email/render/auth-otp.ts +91 -0
- package/src/email/render/base-text.ts +26 -0
- package/src/email/render/blocks.ts +308 -0
- package/src/email/render/chat-shared.ts +89 -0
- package/src/email/render/company-md-access-approved.ts +67 -0
- package/src/email/render/company-md-access-denied.ts +63 -0
- package/src/email/render/company-md-access-requested.ts +79 -0
- package/src/email/render/constants.ts +24 -0
- package/src/email/render/escape-html.ts +16 -0
- package/src/email/render/index.ts +90 -0
- package/src/email/render/org-invite.ts +61 -0
- package/src/email/render/ownership-transfer-completed.ts +31 -0
- package/src/email/render/ownership-transfer.ts +36 -0
- package/src/email/render/render-email.ts +166 -0
- package/src/email/render/security-alert.ts +62 -0
- package/src/email/render/share-granted.ts +95 -0
- package/src/email/render/unit-owner-granted.ts +79 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Central email dispatcher. Given a kind + payload it returns
|
|
3
|
+
* `{ subject, text, html? }`, pulling the subject from the registry (single
|
|
4
|
+
* source of truth). This is the one entry point both the backend (real sends)
|
|
5
|
+
* and the app (Ladle preview) call.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { EMAIL_KINDS } from "../registry";
|
|
9
|
+
import type { EmailKind, EmailPayloads } from "../types";
|
|
10
|
+
|
|
11
|
+
import { renderAuthOtpBody, renderAuthOtpHtml } from "./auth-otp";
|
|
12
|
+
import {
|
|
13
|
+
renderAccessApprovedBody,
|
|
14
|
+
renderAccessApprovedHtml,
|
|
15
|
+
} from "./company-md-access-approved";
|
|
16
|
+
import {
|
|
17
|
+
renderAccessDeniedBody,
|
|
18
|
+
renderAccessDeniedHtml,
|
|
19
|
+
} from "./company-md-access-denied";
|
|
20
|
+
import {
|
|
21
|
+
renderAccessRequestedBody,
|
|
22
|
+
renderAccessRequestedHtml,
|
|
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";
|
|
37
|
+
|
|
38
|
+
/** Rendered email output. */
|
|
39
|
+
export interface RenderedEmail {
|
|
40
|
+
/** Subject line (from the registry). */
|
|
41
|
+
subject: string;
|
|
42
|
+
/** Plain-text body. */
|
|
43
|
+
text: string;
|
|
44
|
+
/** HTML body (present only for html-supported kinds). */
|
|
45
|
+
html?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Caller-supplied render options. */
|
|
49
|
+
export interface RenderEmailOptions {
|
|
50
|
+
/** Include OTP request IP / device details (PII; backend gates via env). */
|
|
51
|
+
includeRequestMetadata?: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The email kinds with a render implementation. */
|
|
55
|
+
export const IMPLEMENTED_EMAIL_KINDS = [
|
|
56
|
+
"auth.otp",
|
|
57
|
+
"org.invite",
|
|
58
|
+
"org.unit_owner_granted",
|
|
59
|
+
"org.ownership_transfer",
|
|
60
|
+
"org.ownership_transfer_completed",
|
|
61
|
+
"security.alert",
|
|
62
|
+
"chat.shared",
|
|
63
|
+
"share.granted",
|
|
64
|
+
"companyMd.access_requested",
|
|
65
|
+
"companyMd.access_request_approved",
|
|
66
|
+
"companyMd.access_request_denied",
|
|
67
|
+
] as const satisfies readonly EmailKind[];
|
|
68
|
+
|
|
69
|
+
export type ImplementedEmailKind = (typeof IMPLEMENTED_EMAIL_KINDS)[number];
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Render an email by kind. Subject comes from `EMAIL_KINDS`; `html` is present
|
|
73
|
+
* only when the template emits an HTML variant.
|
|
74
|
+
*
|
|
75
|
+
* @throws if the kind has no implementation.
|
|
76
|
+
*/
|
|
77
|
+
export function renderEmail<K extends ImplementedEmailKind>(
|
|
78
|
+
kind: K,
|
|
79
|
+
payload: EmailPayloads[K],
|
|
80
|
+
options?: RenderEmailOptions,
|
|
81
|
+
): RenderedEmail {
|
|
82
|
+
const subject = EMAIL_KINDS[kind].subject;
|
|
83
|
+
|
|
84
|
+
switch (kind) {
|
|
85
|
+
case "auth.otp": {
|
|
86
|
+
const p = payload as EmailPayloads["auth.otp"];
|
|
87
|
+
return {
|
|
88
|
+
subject,
|
|
89
|
+
text: renderAuthOtpBody(p, options),
|
|
90
|
+
html: renderAuthOtpHtml(p),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
case "org.invite": {
|
|
94
|
+
const p = payload as EmailPayloads["org.invite"];
|
|
95
|
+
return {
|
|
96
|
+
subject,
|
|
97
|
+
text: renderOrgInviteBody(p),
|
|
98
|
+
html: renderOrgInviteHtml(p),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
case "org.unit_owner_granted": {
|
|
102
|
+
const p = payload as EmailPayloads["org.unit_owner_granted"];
|
|
103
|
+
return {
|
|
104
|
+
subject,
|
|
105
|
+
text: renderUnitOwnerGrantedBody(p),
|
|
106
|
+
html: renderUnitOwnerGrantedHtml(p),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
case "org.ownership_transfer": {
|
|
110
|
+
const p = payload as EmailPayloads["org.ownership_transfer"];
|
|
111
|
+
return { subject, text: renderOwnershipTransferBody(p) };
|
|
112
|
+
}
|
|
113
|
+
case "org.ownership_transfer_completed": {
|
|
114
|
+
const p = payload as EmailPayloads["org.ownership_transfer_completed"];
|
|
115
|
+
return { subject, text: renderOwnershipTransferCompletedBody(p) };
|
|
116
|
+
}
|
|
117
|
+
case "security.alert": {
|
|
118
|
+
const p = payload as EmailPayloads["security.alert"];
|
|
119
|
+
return { subject, text: renderSecurityAlertBody(p) };
|
|
120
|
+
}
|
|
121
|
+
case "chat.shared": {
|
|
122
|
+
const p = payload as EmailPayloads["chat.shared"];
|
|
123
|
+
return {
|
|
124
|
+
subject,
|
|
125
|
+
text: renderChatSharedBody(p),
|
|
126
|
+
html: renderChatSharedHtml(p),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
case "share.granted": {
|
|
130
|
+
const p = payload as EmailPayloads["share.granted"];
|
|
131
|
+
return {
|
|
132
|
+
subject,
|
|
133
|
+
text: renderShareGrantedBody(p),
|
|
134
|
+
html: renderShareGrantedHtml(p),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
case "companyMd.access_requested": {
|
|
138
|
+
const p = payload as EmailPayloads["companyMd.access_requested"];
|
|
139
|
+
return {
|
|
140
|
+
subject,
|
|
141
|
+
text: renderAccessRequestedBody(p),
|
|
142
|
+
html: renderAccessRequestedHtml(p),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
case "companyMd.access_request_approved": {
|
|
146
|
+
const p = payload as EmailPayloads["companyMd.access_request_approved"];
|
|
147
|
+
return {
|
|
148
|
+
subject,
|
|
149
|
+
text: renderAccessApprovedBody(p),
|
|
150
|
+
html: renderAccessApprovedHtml(p),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
case "companyMd.access_request_denied": {
|
|
154
|
+
const p = payload as EmailPayloads["companyMd.access_request_denied"];
|
|
155
|
+
return {
|
|
156
|
+
subject,
|
|
157
|
+
text: renderAccessDeniedBody(p),
|
|
158
|
+
html: renderAccessDeniedHtml(p),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
default: {
|
|
162
|
+
const unimplemented: never = kind;
|
|
163
|
+
throw new Error(`Email kind not implemented: ${String(unimplemented)}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
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.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { EmailPayloads } from "../types";
|
|
9
|
+
|
|
10
|
+
import { baseTextLayout } from "./base-text";
|
|
11
|
+
import { COMPANY_NAME } from "./constants";
|
|
12
|
+
|
|
13
|
+
export type SecurityAlertPayload = EmailPayloads["security.alert"];
|
|
14
|
+
export type SecurityAlertType = SecurityAlertPayload["alertType"];
|
|
15
|
+
|
|
16
|
+
export const SECURITY_ALERT_TYPES = [
|
|
17
|
+
"excessive_otp_requests",
|
|
18
|
+
"unusual_login_location",
|
|
19
|
+
] as const;
|
|
20
|
+
|
|
21
|
+
export function renderSecurityAlertBody(payload: SecurityAlertPayload): string {
|
|
22
|
+
const { alertType, details, timestamp } = payload;
|
|
23
|
+
|
|
24
|
+
const sections: string[] = [];
|
|
25
|
+
|
|
26
|
+
switch (alertType) {
|
|
27
|
+
case "excessive_otp_requests":
|
|
28
|
+
sections.push(
|
|
29
|
+
"We detected an unusual number of login code requests for your account.",
|
|
30
|
+
);
|
|
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
|
+
break;
|
|
40
|
+
|
|
41
|
+
case "unusual_login_location":
|
|
42
|
+
sections.push("We detected a login attempt from an unusual location.");
|
|
43
|
+
sections.push("");
|
|
44
|
+
sections.push(`Details: ${details}`);
|
|
45
|
+
sections.push("");
|
|
46
|
+
sections.push("If this was you, no action is needed.");
|
|
47
|
+
sections.push(
|
|
48
|
+
"If you didn't attempt to log in, please secure your account immediately.",
|
|
49
|
+
);
|
|
50
|
+
break;
|
|
51
|
+
|
|
52
|
+
default: {
|
|
53
|
+
const _exhaustive: never = alertType;
|
|
54
|
+
throw new Error(`Unknown alert type: ${_exhaustive}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
sections.push("");
|
|
59
|
+
sections.push(`Time: ${new Date(timestamp).toUTCString()}`);
|
|
60
|
+
|
|
61
|
+
return baseTextLayout(sections.join("\n"), `${COMPANY_NAME} Security`);
|
|
62
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Share-granted email (an entity was shared with the recipient). Text + HTML.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { EmailPayloads } from "../types";
|
|
6
|
+
|
|
7
|
+
import { baseTextLayout } from "./base-text";
|
|
8
|
+
import {
|
|
9
|
+
ACCESS_PHRASE,
|
|
10
|
+
asciiCtaBox,
|
|
11
|
+
bodyParagraph,
|
|
12
|
+
ctaBox,
|
|
13
|
+
footer,
|
|
14
|
+
greeting,
|
|
15
|
+
htmlShell,
|
|
16
|
+
NOTICE,
|
|
17
|
+
quoteBlock,
|
|
18
|
+
signatureLine,
|
|
19
|
+
textQuote,
|
|
20
|
+
} from "./blocks";
|
|
21
|
+
import { COMPANY_NAME } from "./constants";
|
|
22
|
+
import { escapeHtml } from "./escape-html";
|
|
23
|
+
|
|
24
|
+
export type ShareGrantedPayload = EmailPayloads["share.granted"];
|
|
25
|
+
|
|
26
|
+
/** `the document "Roadmap"` — or `a document` when there is no title. */
|
|
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 {
|
|
60
|
+
const {
|
|
61
|
+
granterName,
|
|
62
|
+
recipientName,
|
|
63
|
+
entityLabel,
|
|
64
|
+
entityTitle,
|
|
65
|
+
accessLevel,
|
|
66
|
+
ctaUrl,
|
|
67
|
+
message,
|
|
68
|
+
} = payload;
|
|
69
|
+
|
|
70
|
+
const entityHtml = entityTitle
|
|
71
|
+
? `the ${escapeHtml(entityLabel)} <b>"${escapeHtml(entityTitle)}"</b>`
|
|
72
|
+
: `a ${escapeHtml(entityLabel)}`;
|
|
73
|
+
|
|
74
|
+
return htmlShell([
|
|
75
|
+
greeting(recipientName),
|
|
76
|
+
bodyParagraph(
|
|
77
|
+
`<b>${escapeHtml(granterName)}</b> shared ${entityHtml} with you — you ${ACCESS_PHRASE[accessLevel]}.`,
|
|
78
|
+
),
|
|
79
|
+
message
|
|
80
|
+
? quoteBlock(
|
|
81
|
+
`Message from ${escapeHtml(granterName)}:`,
|
|
82
|
+
escapeHtml(message),
|
|
83
|
+
)
|
|
84
|
+
: "",
|
|
85
|
+
ctaBox({
|
|
86
|
+
label: "OPEN",
|
|
87
|
+
href: ctaUrl,
|
|
88
|
+
padding: "16px 24px",
|
|
89
|
+
fontSize: "16px",
|
|
90
|
+
maxWidth: "220px",
|
|
91
|
+
}),
|
|
92
|
+
footer(`This notification was sent via ${COMPANY_NAME}.`, NOTICE),
|
|
93
|
+
signatureLine(),
|
|
94
|
+
]);
|
|
95
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit-owner-granted email (added to a team's unit ownership). Text + HTML.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { EmailPayloads } from "../types";
|
|
6
|
+
|
|
7
|
+
import { baseTextLayout } from "./base-text";
|
|
8
|
+
import {
|
|
9
|
+
asciiCtaBox,
|
|
10
|
+
bodyParagraph,
|
|
11
|
+
ctaBox,
|
|
12
|
+
footer,
|
|
13
|
+
greeting,
|
|
14
|
+
htmlShell,
|
|
15
|
+
NOTICE,
|
|
16
|
+
quoteBlock,
|
|
17
|
+
signatureLine,
|
|
18
|
+
textQuote,
|
|
19
|
+
} from "./blocks";
|
|
20
|
+
import { COMPANY_NAME } from "./constants";
|
|
21
|
+
import { escapeHtml } from "./escape-html";
|
|
22
|
+
|
|
23
|
+
export type UnitOwnerGrantedPayload = EmailPayloads["org.unit_owner_granted"];
|
|
24
|
+
|
|
25
|
+
export function renderUnitOwnerGrantedBody(
|
|
26
|
+
payload: UnitOwnerGrantedPayload,
|
|
27
|
+
): string {
|
|
28
|
+
const { granterName, recipientName, unitName, roleLabel, ctaUrl, message } =
|
|
29
|
+
payload;
|
|
30
|
+
|
|
31
|
+
const sections: string[] = [];
|
|
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([
|
|
59
|
+
greeting(recipientName),
|
|
60
|
+
bodyParagraph(
|
|
61
|
+
`<b>${escapeHtml(granterName)}</b> added you as a <b>${escapeHtml(roleLabel)}</b> of <b>${escapeHtml(unitName)}</b>.`,
|
|
62
|
+
),
|
|
63
|
+
message
|
|
64
|
+
? quoteBlock(
|
|
65
|
+
`Message from ${escapeHtml(granterName)}:`,
|
|
66
|
+
escapeHtml(message),
|
|
67
|
+
)
|
|
68
|
+
: "",
|
|
69
|
+
ctaBox({
|
|
70
|
+
label: "VIEW TEAM",
|
|
71
|
+
href: ctaUrl,
|
|
72
|
+
padding: "16px 24px",
|
|
73
|
+
fontSize: "16px",
|
|
74
|
+
maxWidth: "220px",
|
|
75
|
+
}),
|
|
76
|
+
footer(`This notification was sent via ${COMPANY_NAME}.`, NOTICE),
|
|
77
|
+
signatureLine(),
|
|
78
|
+
]);
|
|
79
|
+
}
|