@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.
@@ -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). This is the one entry point both the backend (real sends)
5
- * and the app (Ladle preview) call.
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 { 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";
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`; `html` is present
78
- * only when the template emits an HTML variant. Accepts the full `EmailKind`
79
- * union so callers with a generic kind (e.g. the backend `EmailService`) type
80
- * cleanly; unimplemented kinds throw at runtime.
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
- const p = payload as EmailPayloads["auth.otp"];
94
- return {
100
+ case "auth.otp":
101
+ return toEmail(
95
102
  subject,
96
- text: renderAuthOtpBody(p, options),
97
- html: renderAuthOtpHtml(p),
98
- };
99
- }
100
- case "org.invite": {
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
- text: renderOrgInviteBody(p),
105
- html: renderOrgInviteHtml(p),
106
- };
107
- }
108
- case "org.unit_owner_granted": {
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
- text: renderUnitOwnerGrantedBody(p),
113
- html: renderUnitOwnerGrantedHtml(p),
114
- };
115
- }
116
- case "org.ownership_transfer": {
117
- const p = payload as EmailPayloads["org.ownership_transfer"];
118
- return { subject, text: renderOwnershipTransferBody(p) };
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
- text: renderChatSharedBody(p),
133
- html: renderChatSharedHtml(p),
134
- };
135
- }
136
- case "share.granted": {
137
- const p = payload as EmailPayloads["share.granted"];
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
- text: renderShareGrantedBody(p),
141
- html: renderShareGrantedHtml(p),
142
- };
143
- }
144
- case "companyMd.access_requested": {
145
- const p = payload as EmailPayloads["companyMd.access_requested"];
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
- text: renderAccessRequestedBody(p),
149
- html: renderAccessRequestedHtml(p),
150
- };
151
- }
152
- case "companyMd.access_request_approved": {
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
- text: renderAccessApprovedBody(p),
157
- html: renderAccessApprovedHtml(p),
158
- };
159
- }
160
- case "companyMd.access_request_denied": {
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
- text: renderAccessDeniedBody(p),
165
- html: renderAccessDeniedHtml(p),
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 { baseTextLayout } from "./base-text";
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 renderSecurityAlertBody(payload: SecurityAlertPayload): string {
18
+ export function renderSecurityAlert(payload: SecurityAlertPayload): Block[] {
22
19
  const { alertType, details, timestamp } = payload;
23
20
 
24
- const sections: string[] = [];
21
+ const blocks: Block[] = [];
25
22
 
26
23
  switch (alertType) {
27
24
  case "excessive_otp_requests":
28
- sections.push(
29
- "We detected an unusual number of login code requests for your account.",
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
- 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.",
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
- sections.push("");
59
- sections.push(`Time: ${new Date(timestamp).toUTCString()}`);
56
+ blocks.push(
57
+ keyValue("Time", new Date(timestamp).toUTCString(), "normal"),
58
+ signature(`${COMPANY_NAME} Security`),
59
+ );
60
60
 
61
- return baseTextLayout(sections.join("\n"), `${COMPANY_NAME} Security`);
61
+ return blocks;
62
62
  }
@@ -1,62 +1,27 @@
1
1
  /**
2
- * Share-granted email (an entity was shared with the recipient). Text + HTML.
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
- asciiCtaBox,
11
- paragraph,
9
+ type Block,
10
+ bold,
11
+ chatUser,
12
12
  ctaBox,
13
13
  footer,
14
14
  greeting,
15
- htmlShell,
15
+ type Inline,
16
16
  NOTICE,
17
- chatUserBubble,
18
- chatUserBoxText,
17
+ paragraph,
19
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
- /** `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(chatUserBoxText(message, `Message from ${granterName}`));
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,16 +32,23 @@ export function renderShareGrantedHtml(payload: ShareGrantedPayload): string {
67
32
  message,
68
33
  } = payload;
69
34
 
70
- const entityHtml = entityTitle
71
- ? `the ${escapeHtml(entityLabel)} <b>"${escapeHtml(entityTitle)}"</b>`
72
- : `a ${escapeHtml(entityLabel)}`;
35
+ const entity: Array<string | Inline> = entityTitle
36
+ ? [`the ${entityLabel} `, bold(`"${entityTitle}"`)]
37
+ : [`a ${entityLabel}`];
73
38
 
74
- return htmlShell([
39
+ const blocks: Block[] = [
75
40
  greeting(recipientName),
76
- paragraph(
77
- `<b>${escapeHtml(granterName)}</b> shared ${entityHtml} with you &mdash; you ${ACCESS_PHRASE[accessLevel]}.`,
78
- ),
79
- message ? chatUserBubble(message, `Message from ${granterName}`) : "",
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(
80
52
  ctaBox({
81
53
  label: "OPEN",
82
54
  href: ctaUrl,
@@ -86,5 +58,7 @@ export function renderShareGrantedHtml(payload: ShareGrantedPayload): string {
86
58
  }),
87
59
  footer(`This notification was sent via ${COMPANY_NAME}.`, NOTICE),
88
60
  signature(),
89
- ]);
61
+ );
62
+
63
+ return blocks;
90
64
  }
@@ -1,66 +1,45 @@
1
1
  /**
2
- * Unit-owner-granted email (added to a team's unit ownership). Text + HTML.
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
- asciiCtaBox,
10
- paragraph,
8
+ type Block,
9
+ bold,
10
+ chatUser,
11
11
  ctaBox,
12
12
  footer,
13
13
  greeting,
14
- htmlShell,
15
14
  NOTICE,
16
- chatUserBubble,
17
- chatUserBoxText,
15
+ paragraph,
18
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 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(chatUserBoxText(message, `Message from ${granterName}`));
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(
22
+ export function renderUnitOwnerGranted(
53
23
  payload: UnitOwnerGrantedPayload,
54
- ): string {
24
+ ): Block[] {
55
25
  const { granterName, recipientName, unitName, roleLabel, ctaUrl, message } =
56
26
  payload;
57
27
 
58
- return htmlShell([
28
+ const blocks: Block[] = [
59
29
  greeting(recipientName),
60
- paragraph(
61
- `<b>${escapeHtml(granterName)}</b> added you as a <b>${escapeHtml(roleLabel)}</b> of <b>${escapeHtml(unitName)}</b>.`,
62
- ),
63
- message ? chatUserBubble(message, `Message from ${granterName}`) : "",
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(
64
43
  ctaBox({
65
44
  label: "VIEW TEAM",
66
45
  href: ctaUrl,
@@ -70,5 +49,7 @@ export function renderUnitOwnerGrantedHtml(
70
49
  }),
71
50
  footer(`This notification was sent via ${COMPANY_NAME}.`, NOTICE),
72
51
  signature(),
73
- ]);
52
+ );
53
+
54
+ return blocks;
74
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
- }