@hogsend/email 0.13.2 → 0.14.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hogsend/email",
3
- "version": "0.13.2",
3
+ "version": "0.14.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -0,0 +1,89 @@
1
+ import { createElement } from "react";
2
+ import { Body, Html } from "react-email";
3
+ import { describe, expect, it } from "vitest";
4
+ import { EmailAction } from "../email-action.js";
5
+ import { renderToHtml, renderToPlainText } from "../render.js";
6
+
7
+ // EmailAction is the template side of the semantic-link wire: it must render a
8
+ // plain anchor carrying data-hs-event / data-hs-props through renderToHtml so
9
+ // the engine's rewriteLinks can lift + strip them at send time.
10
+ function fixtureElement() {
11
+ return createElement(
12
+ Html,
13
+ null,
14
+ createElement(
15
+ Body,
16
+ null,
17
+ createElement(
18
+ EmailAction,
19
+ {
20
+ href: "https://example.com/thanks?score=9",
21
+ event: "nps.submitted",
22
+ properties: { score: 9, source: "email" },
23
+ },
24
+ "9",
25
+ ),
26
+ ),
27
+ );
28
+ }
29
+
30
+ describe("EmailAction", () => {
31
+ it("renders an anchor with the semantic data attributes", async () => {
32
+ const html = await renderToHtml(fixtureElement());
33
+ expect(html).toContain('href="https://example.com/thanks?score=9"');
34
+ expect(html).toContain('data-hs-event="nps.submitted"');
35
+ // React entity-escapes the JSON quotes — the engine lifter decodes them.
36
+ expect(html).toContain(
37
+ 'data-hs-props="{"score":9,"source":"email"}"',
38
+ );
39
+ });
40
+
41
+ it("omits data-hs-props when no properties are given", async () => {
42
+ const html = await renderToHtml(
43
+ createElement(
44
+ Html,
45
+ null,
46
+ createElement(
47
+ Body,
48
+ null,
49
+ createElement(
50
+ EmailAction,
51
+ { href: "https://example.com/yes", event: "checkin.answered" },
52
+ "Yes",
53
+ ),
54
+ ),
55
+ ),
56
+ );
57
+ expect(html).toContain('data-hs-event="checkin.answered"');
58
+ expect(html).not.toContain("data-hs-props");
59
+ });
60
+
61
+ it("passes ordinary anchor props through", async () => {
62
+ const html = await renderToHtml(
63
+ createElement(
64
+ Html,
65
+ null,
66
+ createElement(
67
+ Body,
68
+ null,
69
+ createElement(
70
+ EmailAction,
71
+ {
72
+ href: "https://example.com/yes",
73
+ event: "checkin.answered",
74
+ style: { color: "rgb(1, 2, 3)" },
75
+ },
76
+ "Yes",
77
+ ),
78
+ ),
79
+ ),
80
+ );
81
+ expect(html).toContain("color:rgb(1, 2, 3)");
82
+ });
83
+
84
+ it("leaves plain-text rendering untouched", async () => {
85
+ const text = await renderToPlainText(fixtureElement());
86
+ expect(text).not.toContain("data-hs-event");
87
+ expect(text).not.toContain("data-hs-props");
88
+ });
89
+ });
@@ -0,0 +1,64 @@
1
+ import {
2
+ type ComponentPropsWithoutRef,
3
+ createElement,
4
+ type ReactElement,
5
+ } from "react";
6
+
7
+ /**
8
+ * Attribute names used to carry semantic-link metadata from the rendered
9
+ * template to the engine's link rewriter. INTERNAL wire format: the engine
10
+ * strips both attributes before the HTML reaches the email provider — the
11
+ * persisted `tracked_links` row is the contract, not this encoding.
12
+ */
13
+ export const EMAIL_ACTION_EVENT_ATTR = "data-hs-event";
14
+ export const EMAIL_ACTION_PROPS_ATTR = "data-hs-props";
15
+
16
+ /** Scalar-only payload — non-scalar values don't survive the Hatchet wire. */
17
+ export type EmailActionProperties = Record<
18
+ string,
19
+ string | number | boolean | null
20
+ >;
21
+
22
+ export interface EmailActionProps extends ComponentPropsWithoutRef<"a"> {
23
+ /** Where the recipient lands after the click is recorded. */
24
+ href: string;
25
+ /**
26
+ * Consumer event name emitted through the full ingest pipeline when this
27
+ * link is clicked (e.g. "nps.submitted"). Engine-reserved namespaces
28
+ * (`email.*`, `journey.*`, `bucket.*`, `contact.*`) are rejected at send
29
+ * time.
30
+ */
31
+ event: string;
32
+ /** Event payload, recorded at send time and emitted with every answer. */
33
+ properties?: EmailActionProperties;
34
+ }
35
+
36
+ /**
37
+ * A semantic link — an `<a>` whose click MEANS something. Renders a plain
38
+ * anchor (react-email's Tailwind transform still applies to `className`),
39
+ * tagged with the event metadata for the engine to lift at send time.
40
+ *
41
+ * Every answer in an email is a link: a yes/no question is two EmailActions,
42
+ * an NPS survey is eleven. The first click per (send, event name) wins.
43
+ *
44
+ * Plain `.ts` + `createElement` (no JSX) so consumers type-checking this
45
+ * package's raw source need no `jsx` compiler setting.
46
+ */
47
+ export function EmailAction({
48
+ event,
49
+ properties,
50
+ children,
51
+ ...anchor
52
+ }: EmailActionProps): ReactElement {
53
+ return createElement(
54
+ "a",
55
+ {
56
+ ...anchor,
57
+ [EMAIL_ACTION_EVENT_ATTR]: event,
58
+ [EMAIL_ACTION_PROPS_ATTR]: properties
59
+ ? JSON.stringify(properties)
60
+ : undefined,
61
+ },
62
+ children,
63
+ );
64
+ }
package/src/index.ts CHANGED
@@ -2,6 +2,16 @@
2
2
  // baked in here; clients own their `.tsx` templates + registry and augment the
3
3
  // open `TemplateRegistryMap` interface (Option B).
4
4
 
5
+ // Semantic links (in-email actions)
6
+ export type {
7
+ EmailActionProperties,
8
+ EmailActionProps,
9
+ } from "./email-action.js";
10
+ export {
11
+ EMAIL_ACTION_EVENT_ATTR,
12
+ EMAIL_ACTION_PROPS_ATTR,
13
+ EmailAction,
14
+ } from "./email-action.js";
5
15
  // Template registry
6
16
  export {
7
17
  createRegistry,
@@ -10,7 +20,6 @@ export {
10
20
  getTemplateDefinition,
11
21
  getTemplateNames,
12
22
  } from "./registry.js";
13
-
14
23
  // Rendering
15
24
  export { renderToHtml, renderToPlainText } from "./render.js";
16
25