Skip to content

Commit b1923b7

Browse files
Merge pull request #326 from MeshJS/feature/email-notification-center
Feature/email notification center
2 parents 396232b + fd66765 commit b1923b7

14 files changed

Lines changed: 954 additions & 621 deletions

File tree

src/__tests__/notifications.test.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ import {
77
NOTIFICATION_STATUS_SKIPPED_OPTED_OUT,
88
} from "@/lib/notifications/events";
99
import { resolveSignatureRecipients } from "@/lib/notifications/recipients";
10+
import {
11+
maskAddress,
12+
summarizeSignableSignatureContext,
13+
summarizeTransactionSignatureContext,
14+
} from "@/lib/notifications/signatureContext";
1015
import { renderSignatureRequiredEmail } from "@/lib/notifications/templates/signatureRequired";
1116

1217
describe("notification recipient resolution", () => {
@@ -113,13 +118,97 @@ describe("notification email templates", () => {
113118
totalSigners: 3,
114119
actionUrl: "https://example.com/sign?x=<bad>",
115120
preferencesUrl: "https://example.com/preferences",
121+
signatureContext: {
122+
summary: "Send 50 ADA to addr_tes...3te2",
123+
details: [{ label: "Outputs", value: "2 total outputs" }],
124+
},
116125
});
117126

118127
expect(email.subject).toBe("Signature required: <Vault>");
119128
expect(email.html).toContain("&lt;Vault&gt;");
120129
expect(email.html).toContain("&lt;script&gt;alert(&#39;x&#39;)&lt;/script&gt;");
121130
expect(email.html).not.toContain("<script>alert");
122131
expect(email.text).toContain("A transaction in <Vault> is waiting");
132+
expect(email.text).toContain("What needs signing: Send 50 ADA");
133+
expect(email.html).toContain("What needs signing");
134+
expect(email.html.indexOf("Description")).toBeLessThan(
135+
email.html.indexOf("What needs signing"),
136+
);
137+
expect(email.text.indexOf("Description:")).toBeLessThan(
138+
email.text.indexOf("What needs signing:"),
139+
);
123140
expect(email.text).toContain("Review and sign:");
124141
});
142+
143+
it("truncates long descriptions in html and text bodies", () => {
144+
const longDescription = "Governance ".repeat(40).trim();
145+
const email = renderSignatureRequiredEmail({
146+
walletName: "Vault",
147+
resourceType: "transaction",
148+
description: longDescription,
149+
signedCount: 0,
150+
requiredCount: 2,
151+
totalSigners: 3,
152+
actionUrl: "https://example.com/sign",
153+
preferencesUrl: "https://example.com/preferences",
154+
});
155+
156+
expect(email.html).toContain("Description");
157+
expect(email.html).toContain("...");
158+
expect(email.html).not.toContain(longDescription);
159+
expect(email.text).toContain("Description:");
160+
expect(email.text).toContain("...");
161+
expect(email.text).not.toContain(longDescription);
162+
});
163+
});
164+
165+
describe("notification signature context summaries", () => {
166+
it("summarizes transfer outputs without revealing full recipient addresses", () => {
167+
const fullAddress = "addr_test1qpy7y6u20jv7ky7p4vte2";
168+
const summary = summarizeTransactionSignatureContext({
169+
outputs: [
170+
{
171+
address: fullAddress,
172+
amount: [{ unit: "lovelace", quantity: "50000000" }],
173+
},
174+
],
175+
});
176+
177+
expect(summary?.summary).toBe(`Send 50 ADA to ${maskAddress(fullAddress)}`);
178+
expect(summary?.summary).not.toContain(fullAddress);
179+
});
180+
181+
it("summarizes governance proxy vote metadata", () => {
182+
const summary = summarizeTransactionSignatureContext({
183+
proxyBot: {
184+
kind: "proxyVote",
185+
votes: [
186+
{
187+
proposalId:
188+
"0123456789abcdef0123456789abcdef0123456789abcdef01234567#0",
189+
voteKind: "Yes",
190+
},
191+
],
192+
},
193+
});
194+
195+
expect(summary?.summary).toContain("Governance Yes vote");
196+
expect(summary?.summary).toContain("01234567");
197+
expect(summary?.summary).toContain("...67#0");
198+
});
199+
200+
it("summarizes signable governance payloads by method", () => {
201+
expect(
202+
summarizeSignableSignatureContext({
203+
method: "ekklesia-vote",
204+
description: "Hydra Budget Vote",
205+
}),
206+
).toEqual({ summary: "Governance vote package" });
207+
});
208+
209+
it("falls back to governance descriptions when transaction metadata is generic", () => {
210+
expect(summarizeTransactionSignatureContext("{}", "Vote: Yes - Treasury")).toEqual({
211+
summary: "Governance action: Vote: Yes - Treasury",
212+
});
213+
});
125214
});
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import { afterEach, describe, expect, it, jest } from "@jest/globals";
2+
import {
3+
createProposalMetadataFallback,
4+
fetchProposalMetadataWithFallback,
5+
getAnchorUrls,
6+
normalizeProposalMetadata,
7+
} from "@/lib/governance/proposalMetadata";
8+
9+
const proposal = {
10+
tx_hash: "tx-proposal",
11+
cert_index: 0,
12+
governance_type: "info_action",
13+
};
14+
15+
afterEach(() => {
16+
jest.restoreAllMocks();
17+
});
18+
19+
describe("proposal metadata helpers", () => {
20+
it("normalizes usable Blockfrost metadata without extra fetching", async () => {
21+
const provider = {
22+
get: jest.fn(async () => ({
23+
tx_hash: "tx-proposal",
24+
cert_index: 0,
25+
hash: "hash",
26+
url: "https://example.com/metadata.json",
27+
bytes: "123",
28+
json_metadata: {
29+
body: {
30+
title: "Proposal title",
31+
abstract: "Proposal abstract",
32+
motivation: "Motivation",
33+
rationale: "Rationale",
34+
references: [{ "@type": "Other", label: "Spec", uri: "https://example.com" }],
35+
},
36+
authors: [{ name: "Ada" }],
37+
},
38+
})),
39+
};
40+
41+
const metadata = await fetchProposalMetadataWithFallback({ provider, proposal });
42+
43+
expect(provider.get).toHaveBeenCalledTimes(1);
44+
expect(metadata).toMatchObject({
45+
tx_hash: "tx-proposal",
46+
cert_index: 0,
47+
governance_type: "info_action",
48+
hash: "hash",
49+
json_metadata: {
50+
body: {
51+
title: "Proposal title",
52+
abstract: "Proposal abstract",
53+
motivation: "Motivation",
54+
rationale: "Rationale",
55+
},
56+
authors: [{ name: "Ada" }],
57+
},
58+
});
59+
});
60+
61+
it("hydrates metadata from a regular anchor URL", async () => {
62+
const provider = {
63+
get: jest.fn(async () => ({
64+
tx_hash: "tx-proposal",
65+
cert_index: 0,
66+
url: "https://example.com/anchor.json",
67+
})),
68+
};
69+
const fetchSpy = jest.spyOn(globalThis, "fetch").mockResolvedValue(
70+
new Response(
71+
JSON.stringify({
72+
body: {
73+
title: "Anchor title",
74+
abstract: "Anchor abstract",
75+
},
76+
authors: [{ name: "Anchor author" }],
77+
}),
78+
{ status: 200 },
79+
),
80+
);
81+
82+
const metadata = await fetchProposalMetadataWithFallback({ provider, proposal });
83+
84+
expect(fetchSpy).toHaveBeenCalledWith(
85+
"https://example.com/anchor.json",
86+
expect.objectContaining({ method: "GET" }),
87+
);
88+
expect(metadata?.json_metadata.body.title).toBe("Anchor title");
89+
expect(metadata?.json_metadata.authors).toEqual([{ name: "Anchor author" }]);
90+
});
91+
92+
it("tries IPFS gateway fallbacks until one returns usable JSON", async () => {
93+
expect(getAnchorUrls("ipfs://cid/path.json")).toEqual([
94+
"https://ipfs.io/ipfs/cid/path.json",
95+
"https://cloudflare-ipfs.com/ipfs/cid/path.json",
96+
"https://dweb.link/ipfs/cid/path.json",
97+
]);
98+
99+
const provider = {
100+
get: jest.fn(async () => ({
101+
tx_hash: "tx-proposal",
102+
cert_index: 0,
103+
url: "ipfs://cid/path.json",
104+
})),
105+
};
106+
const fetchSpy = jest
107+
.spyOn(globalThis, "fetch")
108+
.mockResolvedValueOnce(new Response("not found", { status: 504 }))
109+
.mockResolvedValueOnce(
110+
new Response(JSON.stringify({ title: "Wrapped anchor title" }), {
111+
status: 200,
112+
}),
113+
);
114+
115+
const metadata = await fetchProposalMetadataWithFallback({ provider, proposal });
116+
117+
expect(fetchSpy).toHaveBeenCalledTimes(2);
118+
expect(metadata?.json_metadata.body.title).toBe("Wrapped anchor title");
119+
});
120+
121+
it("returns fallback metadata without changing the ProposalMetadata shape", () => {
122+
const metadata = createProposalMetadataFallback(proposal);
123+
124+
expect(metadata).toEqual({
125+
tx_hash: "tx-proposal",
126+
cert_index: 0,
127+
governance_type: "info_action",
128+
hash: "",
129+
url: "",
130+
bytes: "",
131+
json_metadata: {
132+
body: {
133+
title: "Metadata could not be loaded.",
134+
abstract: "tx-proposal#0",
135+
motivation: "",
136+
rationale: "",
137+
references: [],
138+
},
139+
authors: [],
140+
},
141+
});
142+
});
143+
144+
it("normalizes missing or unusable metadata fields into safe defaults", () => {
145+
const metadata = normalizeProposalMetadata({ json_metadata: { body: null } }, proposal);
146+
147+
expect(metadata).toMatchObject({
148+
tx_hash: "tx-proposal",
149+
cert_index: 0,
150+
governance_type: "info_action",
151+
json_metadata: {
152+
body: {
153+
title: "Metadata could not be loaded.",
154+
abstract: "tx-proposal#0",
155+
motivation: "",
156+
rationale: "",
157+
references: [],
158+
},
159+
authors: [],
160+
},
161+
});
162+
});
163+
});

0 commit comments

Comments
 (0)