Skip to content

Commit c1044bc

Browse files
NiallJoeMaherclaude
andcommitted
fix(review): escape email templates, drop invalid JSON-LD, document partial-failure policy
Addresses the code review on PR #1330: - Critical: email templates were interpolating user-submitted strings directly into HTML, leaking an XSS surface to the admin inbox. Added utils/escapeHtml.ts with escapeHtml() + sanitizeHref() (rejects non- http(s)/mailto URIs in href attributes) and wrapped every interpolation in the volunteer, speaker, AND sponsor templates. The sponsor template fix is pre-existing-bug cleanup included in the same pass per review. - Medium: the JobPosting JSON-LD on /volunteer and /speakers violated Google's rich-results guidelines (JobPosting is for paid employment, was missing baseSalary and validThrough, and combined jobLocation with TELECOMMUTE jobLocationType). Risk of "Deceptive structured data" manual penalty. Removed both. Page-level metadata (title, description, OG, Twitter) is kept; the site-wide Organization JSON-LD from the app layout already covers brand-level structured data. - High: documented the Promise.allSettled partial-failure policy in both routers — if one channel fails we still return success to the user and rely on Sentry + console.error to flag the gap; only both failing errors out. Previously implicit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e5b6cac commit c1044bc

8 files changed

Lines changed: 122 additions & 105 deletions

File tree

app/(app)/speakers/page.tsx

Lines changed: 1 addition & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import type { Metadata } from "next";
2-
import { JsonLd } from "@/components/JsonLd";
32
import { SpeakersClient } from "./_client";
43

54
const PAGE_URL = "https://www.codu.co/speakers";
@@ -33,37 +32,6 @@ export const metadata: Metadata = {
3332
},
3433
};
3534

36-
const speakerJsonLd = {
37-
"@context": "https://schema.org",
38-
"@type": "JobPosting",
39-
title: "Speaker — Codú Meetups",
40-
description: PAGE_DESCRIPTION,
41-
employmentType: "VOLUNTEER",
42-
hiringOrganization: {
43-
"@type": "Organization",
44-
name: "Codú",
45-
sameAs: "https://www.codu.co",
46-
logo: "https://www.codu.co/images/codu-logo.png",
47-
},
48-
jobLocation: {
49-
"@type": "Place",
50-
address: {
51-
"@type": "PostalAddress",
52-
addressLocality: "Dublin",
53-
addressCountry: "IE",
54-
},
55-
},
56-
applicantLocationRequirements: { "@type": "Country", name: "Worldwide" },
57-
jobLocationType: "TELECOMMUTE",
58-
datePosted: new Date().toISOString().split("T")[0],
59-
url: PAGE_URL,
60-
};
61-
6235
export default function SpeakersPage() {
63-
return (
64-
<>
65-
<JsonLd data={speakerJsonLd} />
66-
<SpeakersClient />
67-
</>
68-
);
36+
return <SpeakersClient />;
6937
}

app/(app)/volunteer/page.tsx

Lines changed: 1 addition & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import type { Metadata } from "next";
2-
import { JsonLd } from "@/components/JsonLd";
32
import { VolunteerClient } from "./_client";
43

54
const PAGE_URL = "https://www.codu.co/volunteer";
@@ -34,36 +33,6 @@ export const metadata: Metadata = {
3433
},
3534
};
3635

37-
const volunteerJsonLd = {
38-
"@context": "https://schema.org",
39-
"@type": "JobPosting",
40-
title: "Volunteer — Marketing & Events",
41-
description: PAGE_DESCRIPTION,
42-
employmentType: "VOLUNTEER",
43-
hiringOrganization: {
44-
"@type": "Organization",
45-
name: "Codú",
46-
sameAs: "https://www.codu.co",
47-
logo: "https://www.codu.co/images/codu-logo.png",
48-
},
49-
jobLocation: {
50-
"@type": "Place",
51-
address: {
52-
"@type": "PostalAddress",
53-
addressCountry: "IE",
54-
},
55-
},
56-
applicantLocationRequirements: { "@type": "Country", name: "Worldwide" },
57-
jobLocationType: "TELECOMMUTE",
58-
datePosted: new Date().toISOString().split("T")[0],
59-
url: PAGE_URL,
60-
};
61-
6236
export default function VolunteerPage() {
63-
return (
64-
<>
65-
<JsonLd data={volunteerJsonLd} />
66-
<VolunteerClient />
67-
</>
68-
);
37+
return <VolunteerClient />;
6938
}

server/api/router/speaker.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ export const speakerRouter = createTRPCRouter({
7474
return [t?.title ?? "", t?.lengthLabel ?? "", t?.abstract ?? ""];
7575
};
7676

77+
// Partial-failure policy: see volunteer.ts for the same pattern. If one
78+
// channel fails we still return success; only both failing errors out.
7779
const [sheetResult, emailResult] = await Promise.allSettled([
7880
appendRowToSubmissionsSheet({
7981
tab: SPEAKER_SHEET_TAB,

server/api/router/volunteer.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,11 @@ export const volunteerRouter = createTRPCRouter({
6161

6262
const adminEmail = process.env.ADMIN_EMAIL || "hi@codu.co";
6363

64+
// Partial-failure policy: run both side effects in parallel. If exactly
65+
// one fails, we still return success to the user (the other channel
66+
// carries the signal) and rely on Sentry + console to flag the gap.
67+
// Only if BOTH fail do we error out so the user can retry. Low-volume
68+
// form — duplicate retries are cheaper than lost submissions.
6469
const [sheetResult, emailResult] = await Promise.allSettled([
6570
appendRowToSubmissionsSheet({
6671
tab: VOLUNTEER_SHEET_TAB,

utils/createSpeakerApplicationEmailTemplate.ts

Lines changed: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { escapeHtml, sanitizeHref } from "./escapeHtml";
2+
13
type TalkDetails = {
24
title: string;
35
lengthLabel: string;
@@ -18,26 +20,40 @@ type SpeakerApplicationDetails = {
1820
};
1921

2022
const sectionBlock = (title: string, body: string) => `
21-
<h2 style="color: #171717; font-size: 16px; margin: 24px 0 12px 0; padding-bottom: 8px; border-bottom: 2px solid #e5e5e5;">${title}</h2>
23+
<h2 style="color: #171717; font-size: 16px; margin: 24px 0 12px 0; padding-bottom: 8px; border-bottom: 2px solid #e5e5e5;">${escapeHtml(title)}</h2>
2224
<div style="margin-bottom: 16px; padding: 16px; background: #fafafa; border-radius: 8px; border-left: 4px solid #db2777;">
23-
<p style="margin: 0; color: #404040; white-space: pre-wrap; line-height: 1.6;">${body}</p>
25+
<p style="margin: 0; color: #404040; white-space: pre-wrap; line-height: 1.6;">${escapeHtml(body)}</p>
2426
</div>
2527
`;
2628

2729
const talkBlock = (talk: TalkDetails, idx: number) => `
2830
<div style="margin-bottom: 20px; padding: 18px; background: #fafafa; border-radius: 10px; border: 1px solid #e5e5e5;">
2931
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
3032
<span style="font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: #737373;">Talk ${idx + 1}</span>
31-
<span style="display: inline-block; background: linear-gradient(to right, #fb923c, #db2777); color: white; padding: 4px 10px; border-radius: 9999px; font-size: 11px;">${talk.lengthLabel}</span>
33+
<span style="display: inline-block; background: linear-gradient(to right, #fb923c, #db2777); color: white; padding: 4px 10px; border-radius: 9999px; font-size: 11px;">${escapeHtml(talk.lengthLabel)}</span>
3234
</div>
33-
<h3 style="margin: 0 0 8px 0; font-size: 16px; color: #171717;">${talk.title}</h3>
34-
<p style="margin: 0; color: #404040; white-space: pre-wrap; line-height: 1.6; font-size: 14px;">${talk.abstract}</p>
35+
<h3 style="margin: 0 0 8px 0; font-size: 16px; color: #171717;">${escapeHtml(talk.title)}</h3>
36+
<p style="margin: 0; color: #404040; white-space: pre-wrap; line-height: 1.6; font-size: 14px;">${escapeHtml(talk.abstract)}</p>
3537
</div>
3638
`;
3739

3840
export const createSpeakerApplicationEmailTemplate = (
3941
details: SpeakerApplicationDetails,
40-
) => `
42+
) => {
43+
const name = escapeHtml(details.name);
44+
const email = escapeHtml(details.email);
45+
const emailHref = sanitizeHref(`mailto:${details.email}`);
46+
const location = escapeHtml(details.location);
47+
const formatLabel = escapeHtml(details.formatLabel);
48+
const experienceLabel = escapeHtml(details.experienceLabel);
49+
const submittedAt = escapeHtml(details.submittedAt);
50+
const linkHref = sanitizeHref(details.link);
51+
const linkText = escapeHtml(details.link);
52+
const replyHref = sanitizeHref(
53+
`mailto:${details.email}?subject=Re: Codú Speaker Pitch`,
54+
);
55+
56+
return `
4157
<!DOCTYPE html>
4258
<html>
4359
<head>
@@ -56,36 +72,36 @@ export const createSpeakerApplicationEmailTemplate = (
5672
<table style="width: 100%; border-collapse: collapse; margin-bottom: 8px;">
5773
<tr>
5874
<td style="padding: 8px 0; color: #525252; width: 120px;">Name</td>
59-
<td style="padding: 8px 0; font-weight: 600;">${details.name}</td>
75+
<td style="padding: 8px 0; font-weight: 600;">${name}</td>
6076
</tr>
6177
<tr>
6278
<td style="padding: 8px 0; color: #525252;">Email</td>
63-
<td style="padding: 8px 0;"><a href="mailto:${details.email}" style="color: #db2777; text-decoration: none; font-weight: 600;">${details.email}</a></td>
79+
<td style="padding: 8px 0;"><a href="${emailHref}" style="color: #db2777; text-decoration: none; font-weight: 600;">${email}</a></td>
6480
</tr>
6581
<tr>
6682
<td style="padding: 8px 0; color: #525252;">Location</td>
67-
<td style="padding: 8px 0;">${details.location}</td>
83+
<td style="padding: 8px 0;">${location}</td>
6884
</tr>
6985
<tr>
7086
<td style="padding: 8px 0; color: #525252;">Format</td>
71-
<td style="padding: 8px 0;">${details.formatLabel}</td>
87+
<td style="padding: 8px 0;">${formatLabel}</td>
7288
</tr>
7389
${
7490
details.experienceLabel
7591
? `
7692
<tr>
7793
<td style="padding: 8px 0; color: #525252;">Experience</td>
78-
<td style="padding: 8px 0;">${details.experienceLabel}</td>
94+
<td style="padding: 8px 0;">${experienceLabel}</td>
7995
</tr>
8096
`
8197
: ""
8298
}
8399
${
84-
details.link
100+
linkHref
85101
? `
86102
<tr>
87103
<td style="padding: 8px 0; color: #525252;">Link</td>
88-
<td style="padding: 8px 0;"><a href="${details.link}" style="color: #db2777; text-decoration: none;">${details.link}</a></td>
104+
<td style="padding: 8px 0;"><a href="${linkHref}" style="color: #db2777; text-decoration: none;">${linkText}</a></td>
89105
</tr>
90106
`
91107
: ""
@@ -99,12 +115,12 @@ export const createSpeakerApplicationEmailTemplate = (
99115
100116
${details.other ? sectionBlock("Anything else", details.other) : ""}
101117
102-
<p style="color: #a3a3a3; font-size: 12px; margin: 24px 0 0 0;">Submitted: ${details.submittedAt}</p>
118+
<p style="color: #a3a3a3; font-size: 12px; margin: 24px 0 0 0;">Submitted: ${submittedAt}</p>
103119
104120
<div style="margin-top: 24px; padding-top: 20px; border-top: 1px solid #e5e5e5; text-align: center;">
105-
<a href="mailto:${details.email}?subject=Re: Codú Speaker Pitch"
121+
<a href="${replyHref}"
106122
style="display: inline-block; background: linear-gradient(to right, #fb923c, #db2777); color: white; padding: 14px 28px; border-radius: 8px; text-decoration: none; font-weight: 600;">
107-
Reply to ${details.name}
123+
Reply to ${name}
108124
</a>
109125
</div>
110126
</div>
@@ -116,3 +132,4 @@ export const createSpeakerApplicationEmailTemplate = (
116132
</body>
117133
</html>
118134
`;
135+
};

utils/createSponsorInquiryEmailTemplate.ts

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { escapeHtml, sanitizeHref } from "./escapeHtml";
2+
13
type SponsorInquiryDetails = {
24
name: string;
35
email: string;
@@ -11,7 +13,20 @@ type SponsorInquiryDetails = {
1113

1214
export const createSponsorInquiryEmailTemplate = (
1315
details: SponsorInquiryDetails,
14-
) => `
16+
) => {
17+
const name = escapeHtml(details.name);
18+
const email = escapeHtml(details.email);
19+
const emailHref = sanitizeHref(`mailto:${details.email}`);
20+
const company = escapeHtml(details.company);
21+
const phone = escapeHtml(details.phone);
22+
const budgetRange = escapeHtml(details.budgetRange);
23+
const goals = escapeHtml(details.goals);
24+
const submittedAt = escapeHtml(details.submittedAt);
25+
const replyHref = sanitizeHref(
26+
`mailto:${details.email}?subject=Re: Codu Advertising Inquiry`,
27+
);
28+
29+
return `
1530
<!DOCTYPE html>
1631
<html>
1732
<head>
@@ -31,18 +46,18 @@ export const createSponsorInquiryEmailTemplate = (
3146
<table style="width: 100%; border-collapse: collapse; margin-bottom: 24px;">
3247
<tr>
3348
<td style="padding: 8px 0; color: #525252; width: 120px;">Name</td>
34-
<td style="padding: 8px 0; font-weight: 600;">${details.name}</td>
49+
<td style="padding: 8px 0; font-weight: 600;">${name}</td>
3550
</tr>
3651
<tr>
3752
<td style="padding: 8px 0; color: #525252;">Email</td>
38-
<td style="padding: 8px 0;"><a href="mailto:${details.email}" style="color: #db2777; text-decoration: none; font-weight: 600;">${details.email}</a></td>
53+
<td style="padding: 8px 0;"><a href="${emailHref}" style="color: #db2777; text-decoration: none; font-weight: 600;">${email}</a></td>
3954
</tr>
4055
${
4156
details.company
4257
? `
4358
<tr>
4459
<td style="padding: 8px 0; color: #525252;">Company</td>
45-
<td style="padding: 8px 0; font-weight: 600;">${details.company}</td>
60+
<td style="padding: 8px 0; font-weight: 600;">${company}</td>
4661
</tr>
4762
`
4863
: ""
@@ -51,7 +66,7 @@ export const createSponsorInquiryEmailTemplate = (
5166
? `
5267
<tr>
5368
<td style="padding: 8px 0; color: #525252;">Phone</td>
54-
<td style="padding: 8px 0;">${details.phone}</td>
69+
<td style="padding: 8px 0;">${phone}</td>
5570
</tr>
5671
`
5772
: ""
@@ -64,35 +79,35 @@ export const createSponsorInquiryEmailTemplate = (
6479
${details.interests
6580
.map(
6681
(interest) =>
67-
`<span style="display: inline-block; background: linear-gradient(to right, #fb923c, #db2777); color: white; padding: 6px 14px; border-radius: 9999px; font-size: 13px; margin: 4px 4px 4px 0;">${interest}</span>`,
82+
`<span style="display: inline-block; background: linear-gradient(to right, #fb923c, #db2777); color: white; padding: 6px 14px; border-radius: 9999px; font-size: 13px; margin: 4px 4px 4px 0;">${escapeHtml(interest)}</span>`,
6883
)
6984
.join("")}
7085
</div>
7186
7287
<!-- Budget -->
7388
<h2 style="color: #171717; font-size: 16px; margin: 0 0 16px 0; padding-bottom: 8px; border-bottom: 2px solid #e5e5e5;">Budget Range</h2>
74-
<p style="margin: 0 0 24px 0; font-size: 18px; font-weight: 600; color: #171717;">${details.budgetRange}</p>
89+
<p style="margin: 0 0 24px 0; font-size: 18px; font-weight: 600; color: #171717;">${budgetRange}</p>
7590
7691
<!-- Goals -->
7792
${
7893
details.goals
7994
? `
8095
<h2 style="color: #171717; font-size: 16px; margin: 0 0 16px 0; padding-bottom: 8px; border-bottom: 2px solid #e5e5e5;">Goals & Requirements</h2>
8196
<div style="margin-bottom: 24px; padding: 16px; background: #fafafa; border-radius: 8px; border-left: 4px solid #db2777;">
82-
<p style="margin: 0; color: #404040; white-space: pre-wrap; line-height: 1.6;">${details.goals}</p>
97+
<p style="margin: 0; color: #404040; white-space: pre-wrap; line-height: 1.6;">${goals}</p>
8398
</div>
8499
`
85100
: ""
86101
}
87102
88103
<!-- Timestamp -->
89-
<p style="color: #a3a3a3; font-size: 12px; margin: 0;">Submitted: ${details.submittedAt}</p>
104+
<p style="color: #a3a3a3; font-size: 12px; margin: 0;">Submitted: ${submittedAt}</p>
90105
91106
<!-- CTA -->
92107
<div style="margin-top: 24px; padding-top: 20px; border-top: 1px solid #e5e5e5; text-align: center;">
93-
<a href="mailto:${details.email}?subject=Re: Codu Advertising Inquiry"
108+
<a href="${replyHref}"
94109
style="display: inline-block; background: linear-gradient(to right, #fb923c, #db2777); color: white; padding: 14px 28px; border-radius: 8px; text-decoration: none; font-weight: 600;">
95-
Reply to ${details.name}
110+
Reply to ${name}
96111
</a>
97112
</div>
98113
</div>
@@ -104,3 +119,4 @@ export const createSponsorInquiryEmailTemplate = (
104119
</body>
105120
</html>
106121
`;
122+
};

0 commit comments

Comments
 (0)