Skip to content

Commit b2eda17

Browse files
committed
feat: implement email notification enhancements with signature context summaries and truncation
1 parent 6386e67 commit b2eda17

9 files changed

Lines changed: 369 additions & 6 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
});

src/lib/notifications/center.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ import {
1010
} from "./events";
1111
import { createNotificationDelivery } from "./outbox";
1212
import { resolveSignatureRecipients } from "./recipients";
13+
import {
14+
summarizeTransactionSignatureContext,
15+
type SignatureContext,
16+
} from "./signatureContext";
1317
import { renderSignatureRequiredEmail } from "./templates/signatureRequired";
1418
import { drainNotificationOutbox } from "./worker";
1519

@@ -29,6 +33,8 @@ export type EnqueueSignatureRequiredInput = {
2933
rejectedAddresses?: string[];
3034
creatorAddress?: string | null;
3135
description?: string | null;
36+
txJson?: unknown;
37+
signatureContext?: SignatureContext | null;
3238
onlyRecipientAddress?: string | null;
3339
eventType?: NotificationEventType;
3440
};
@@ -99,10 +105,16 @@ export async function enqueueSignatureRequiredNotifications(
99105
const siteUrl = getSiteUrl();
100106
const actionUrl = `${siteUrl}${actionPathFor(input.resourceType, input.wallet.id)}`;
101107
const preferencesUrl = `${siteUrl}/wallets/${input.wallet.id}/info`;
108+
const signatureContext =
109+
input.signatureContext ??
110+
(input.resourceType === "transaction"
111+
? summarizeTransactionSignatureContext(input.txJson, input.description)
112+
: null);
102113
const template = renderSignatureRequiredEmail({
103114
walletName: input.wallet.name,
104115
resourceType: input.resourceType,
105116
description: input.description ?? null,
117+
signatureContext,
106118
signedCount: signedAddresses.length,
107119
requiredCount,
108120
totalSigners: input.wallet.signersAddresses.length,
@@ -131,6 +143,7 @@ export async function enqueueSignatureRequiredNotifications(
131143
requiredCount,
132144
totalSigners: input.wallet.signersAddresses.length,
133145
description: input.description ?? null,
146+
signatureContext,
134147
};
135148

136149
const deliveries = [];
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
export type SignatureContextDetail = {
2+
label: string;
3+
value: string;
4+
};
5+
6+
export type SignatureContext = {
7+
summary: string;
8+
details?: SignatureContextDetail[];
9+
};
10+
11+
type TxOutputAmount = {
12+
unit?: unknown;
13+
quantity?: unknown;
14+
};
15+
16+
type TxOutput = {
17+
address?: unknown;
18+
amount?: unknown;
19+
};
20+
21+
type ProxyVote = {
22+
proposalId?: unknown;
23+
voteKind?: unknown;
24+
};
25+
26+
function asRecord(value: unknown): Record<string, unknown> | null {
27+
return typeof value === "object" && value !== null
28+
? (value as Record<string, unknown>)
29+
: null;
30+
}
31+
32+
function parseJsonRecord(value: unknown): Record<string, unknown> | null {
33+
if (typeof value === "string") {
34+
try {
35+
return asRecord(JSON.parse(value));
36+
} catch {
37+
return null;
38+
}
39+
}
40+
41+
return asRecord(value);
42+
}
43+
44+
export function maskAddress(address: string): string {
45+
const trimmed = address.trim();
46+
if (trimmed.length <= 12) return trimmed;
47+
return `${trimmed.slice(0, 8)}...${trimmed.slice(-4)}`;
48+
}
49+
50+
function formatQuantity(unit: string, quantity: string): string {
51+
if (unit === "lovelace") {
52+
try {
53+
const lovelace = BigInt(quantity);
54+
const whole = lovelace / 1_000_000n;
55+
const fraction = lovelace % 1_000_000n;
56+
const fractionText = fraction.toString().padStart(6, "0").replace(/0+$/, "");
57+
return `${fractionText ? `${whole.toString()}.${fractionText}` : whole.toString()} ADA`;
58+
} catch {
59+
return `${quantity} lovelace`;
60+
}
61+
}
62+
63+
return `${quantity} ${maskAssetUnit(unit)}`;
64+
}
65+
66+
function maskAssetUnit(unit: string): string {
67+
return unit.length > 18 ? `${unit.slice(0, 10)}...${unit.slice(-6)}` : unit;
68+
}
69+
70+
function firstAmount(amount: unknown): { unit: string; quantity: string } | null {
71+
if (!Array.isArray(amount)) return null;
72+
73+
for (const item of amount as TxOutputAmount[]) {
74+
if (
75+
typeof item?.unit === "string" &&
76+
typeof item.quantity === "string" &&
77+
item.quantity.trim().length > 0
78+
) {
79+
return { unit: item.unit, quantity: item.quantity };
80+
}
81+
}
82+
83+
return null;
84+
}
85+
86+
function summarizeOutputs(outputs: unknown): SignatureContext | null {
87+
if (!Array.isArray(outputs) || outputs.length === 0) return null;
88+
89+
const output = (outputs as TxOutput[]).find(
90+
(candidate) =>
91+
typeof candidate?.address === "string" && firstAmount(candidate.amount),
92+
);
93+
if (!output || typeof output.address !== "string") return null;
94+
95+
const amount = firstAmount(output.amount);
96+
if (!amount) return null;
97+
98+
const outputCount = outputs.length;
99+
return {
100+
summary: `Send ${formatQuantity(amount.unit, amount.quantity)} to ${maskAddress(output.address)}`,
101+
details:
102+
outputCount > 1
103+
? [{ label: "Outputs", value: `${outputCount} total outputs` }]
104+
: undefined,
105+
};
106+
}
107+
108+
function summarizeProxyBot(proxyBot: Record<string, unknown>): SignatureContext | null {
109+
const kind = proxyBot.kind;
110+
111+
if (kind === "proxyVote" && Array.isArray(proxyBot.votes)) {
112+
const vote = (proxyBot.votes as ProxyVote[]).find(
113+
(candidate) =>
114+
typeof candidate?.proposalId === "string" ||
115+
typeof candidate?.voteKind === "string",
116+
);
117+
const voteCount = proxyBot.votes.length;
118+
const choice = typeof vote?.voteKind === "string" ? vote.voteKind : "vote";
119+
const proposal =
120+
typeof vote?.proposalId === "string" ? maskAddress(vote.proposalId) : null;
121+
122+
return {
123+
summary: `Governance ${choice} vote${proposal ? ` on ${proposal}` : ""}`,
124+
details:
125+
voteCount > 1 ? [{ label: "Votes", value: `${voteCount} proposals` }] : undefined,
126+
};
127+
}
128+
129+
if (kind === "proxyDRepCertificate") {
130+
const action =
131+
typeof proxyBot.action === "string" && proxyBot.action.trim()
132+
? proxyBot.action.trim()
133+
: "update";
134+
const dRepId =
135+
typeof proxyBot.dRepId === "string" && proxyBot.dRepId.trim()
136+
? maskAddress(proxyBot.dRepId)
137+
: null;
138+
139+
return {
140+
summary: `Governance DRep ${action}${dRepId ? ` for ${dRepId}` : ""}`,
141+
};
142+
}
143+
144+
return null;
145+
}
146+
147+
export function summarizeTransactionSignatureContext(
148+
txJson: unknown,
149+
description?: string | null,
150+
): SignatureContext | null {
151+
const parsed = parseJsonRecord(txJson);
152+
if (parsed) {
153+
const proxyBot = asRecord(parsed.proxyBot);
154+
if (proxyBot) {
155+
const proxySummary = summarizeProxyBot(proxyBot);
156+
if (proxySummary) return proxySummary;
157+
}
158+
159+
const outputSummary = summarizeOutputs(parsed.outputs);
160+
if (outputSummary) return outputSummary;
161+
}
162+
163+
return summarizeDescriptionSignatureContext(description);
164+
}
165+
166+
export function summarizeSignableSignatureContext(args: {
167+
method?: string | null;
168+
description?: string | null;
169+
}): SignatureContext | null {
170+
const method = args.method?.trim();
171+
const description = args.description?.trim();
172+
173+
if (method === "ekklesia-vote") {
174+
return { summary: "Governance vote package" };
175+
}
176+
177+
if (method) {
178+
return { summary: `Sign ${method}` };
179+
}
180+
181+
if (description?.toLowerCase().includes("governance")) {
182+
return { summary: "Governance action" };
183+
}
184+
185+
return null;
186+
}
187+
188+
export function summarizeDescriptionSignatureContext(
189+
description?: string | null,
190+
): SignatureContext | null {
191+
const trimmed = description?.trim();
192+
if (!trimmed) return null;
193+
194+
if (!/(governance|vote|drep|delegate|stake|certificate)/i.test(trimmed)) {
195+
return null;
196+
}
197+
198+
return {
199+
summary: `Governance action: ${trimmed.length > 90 ? `${trimmed.slice(0, 87)}...` : trimmed}`,
200+
};
201+
}

0 commit comments

Comments
 (0)