Skip to content

Commit 5ad9ebd

Browse files
committed
feat: implement email service and testimonial approval workflow with secure magic links
1 parent 5dbff8a commit 5ad9ebd

4 files changed

Lines changed: 131 additions & 92 deletions

File tree

src/app/(dashboard)/testimonials/page.tsx

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -350,7 +350,15 @@ export default function TestimonialsModerationPage() {
350350
}));
351351

352352
if (res.ok && data.success) {
353-
const linkToCopy = data.approvalUrl || data.devApprovalUrl;
353+
let linkToCopy = data.approvalUrl || data.devApprovalUrl;
354+
if (linkToCopy && typeof window !== "undefined") {
355+
try {
356+
const parsed = new URL(linkToCopy);
357+
if (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1") {
358+
linkToCopy = `${window.location.origin}${parsed.pathname}${parsed.search}`;
359+
}
360+
} catch (_) {}
361+
}
354362
if (linkToCopy) {
355363
try {
356364
await navigator.clipboard.writeText(linkToCopy);
@@ -394,7 +402,16 @@ export default function TestimonialsModerationPage() {
394402
error: `Server responded with HTTP ${res.status}: ${res.statusText || "Unexpected error"}`,
395403
}));
396404
if (res.ok && data.success && data.approvalUrl) {
397-
await navigator.clipboard.writeText(data.approvalUrl);
405+
let linkToCopy = data.approvalUrl;
406+
if (typeof window !== "undefined") {
407+
try {
408+
const parsed = new URL(linkToCopy);
409+
if (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1") {
410+
linkToCopy = `${window.location.origin}${parsed.pathname}${parsed.search}`;
411+
}
412+
} catch (_) {}
413+
}
414+
await navigator.clipboard.writeText(linkToCopy);
398415
setCopiedMagicLinkId(testimonialId);
399416
showToast("Magic approval link copied to clipboard!", "success");
400417
setTimeout(() => setCopiedMagicLinkId(null), 2500);

src/app/api/testimonials/approve-token/route.ts

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -31,15 +31,7 @@ export async function GET(req: Request) {
3131
return NextResponse.json({ valid: false, reason: "not_found" }, { status: 404 });
3232
}
3333

34-
if (tokenRecord.usedAt) {
35-
return NextResponse.json({ valid: false, reason: "already_approved" });
36-
}
37-
38-
if (new Date(tokenRecord.expiresAt) < new Date()) {
39-
return NextResponse.json({ valid: false, reason: "expired" });
40-
}
41-
42-
// Fetch associated draft testimonial
34+
// Fetch associated testimonial
4335
const [testimonial] = await db
4436
.select()
4537
.from(testimonials)
@@ -49,8 +41,29 @@ export async function GET(req: Request) {
4941
return NextResponse.json({ valid: false, reason: "testimonial_not_found" }, { status: 404 });
5042
}
5143

52-
if (testimonial.status === "approved") {
53-
return NextResponse.json({ valid: false, reason: "already_approved" });
44+
if (tokenRecord.usedAt || testimonial.status === "approved") {
45+
return NextResponse.json({
46+
valid: false,
47+
reason: "already_approved",
48+
testimonial: {
49+
id: testimonial.id,
50+
authorName: testimonial.authorName,
51+
authorTitle: testimonial.authorTitle,
52+
content: testimonial.content,
53+
rating: testimonial.rating,
54+
},
55+
});
56+
}
57+
58+
if (new Date(tokenRecord.expiresAt) < new Date()) {
59+
return NextResponse.json({
60+
valid: false,
61+
reason: "expired",
62+
testimonial: {
63+
id: testimonial.id,
64+
authorName: testimonial.authorName,
65+
},
66+
});
5467
}
5568

5669
// Non-blocking telemetry: Record openedAt timestamp in testimonial metadata if first time viewing
@@ -186,7 +199,11 @@ export async function POST(req: Request) {
186199
}
187200
}
188201

189-
return NextResponse.json({ success: true, message: "Testimonial approved successfully!" });
202+
return NextResponse.json({
203+
success: true,
204+
testimonialId: updatedTestimonial.id,
205+
message: "Testimonial approved successfully!",
206+
});
190207

191208
} catch (error) {
192209
console.error("Token approval error:", error);

src/app/approve-testimonial/page.tsx

Lines changed: 71 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -214,24 +214,62 @@ function ApproveTestimonialContent() {
214214

215215
// ─── Already approved ────────────────────────────────────────────────────
216216
if (!validState.valid && validState.reason === "already_approved") {
217+
const t = validState.testimonial;
217218
return (
218219
<Shell>
219-
<Card className="p-8 sm:p-10 text-center space-y-6">
220-
<div className="w-16 h-16 bg-emerald-500/10 rounded-full flex items-center justify-center mx-auto border border-emerald-500/20">
221-
<CheckCircle className="w-8 h-8 text-emerald-600" />
220+
<Card className="p-7 sm:p-9 text-center space-y-5">
221+
<div className="w-14 h-14 bg-emerald-500/10 rounded-full flex items-center justify-center mx-auto border border-emerald-500/20 shadow-xs">
222+
<CheckCircle className="w-7 h-7 text-emerald-600" />
222223
</div>
223-
<div className="space-y-2">
224-
<h1 className="font-display text-2xl font-bold text-neutral-900">Already Confirmed!</h1>
225-
<p className="text-neutral-500 text-sm leading-relaxed">
226-
This testimonial has already been verified and published. No further action needed.
224+
<div className="space-y-1.5">
225+
<div className="inline-flex items-center gap-1.5 px-3 py-0.5 rounded-full bg-emerald-50 border border-emerald-200/80 text-[10px] font-mono uppercase tracking-wider text-emerald-800 font-bold">
226+
<ShieldCheck className="w-3 h-3 text-emerald-600" />
227+
<span>Published & Cryptographically Signed</span>
228+
</div>
229+
<h1 className="font-display text-xl sm:text-2xl font-bold text-neutral-900">
230+
Already Confirmed!
231+
</h1>
232+
<p className="text-neutral-500 text-xs sm:text-sm leading-relaxed max-w-sm mx-auto">
233+
This testimonial has already been verified and published to the creator's live portfolio.
227234
</p>
228235
</div>
229-
<Link
230-
href="/"
231-
className="inline-flex items-center justify-center gap-2 w-full py-3.5 bg-neutral-900 hover:bg-neutral-800 text-white font-semibold text-sm rounded-2xl transition shadow-sm"
232-
>
233-
Visit ClientEcho Home
234-
</Link>
236+
237+
{t && t.content && (
238+
<div className="bg-neutral-50/90 rounded-2xl border border-neutral-200/80 p-4 text-left space-y-2">
239+
<div className="flex items-center gap-1">
240+
{Array.from({ length: t.rating || 5 }).map((_, i) => (
241+
<Star key={i} className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
242+
))}
243+
</div>
244+
<p className="text-xs sm:text-sm text-neutral-700 italic leading-relaxed">
245+
"{t.content}"
246+
</p>
247+
<div className="text-xs font-bold text-neutral-900 pt-1 border-t border-neutral-200/60">
248+
{t.authorName || "Verified Client"}
249+
{t.authorTitle && (
250+
<span className="font-normal text-neutral-500 ml-1">({t.authorTitle})</span>
251+
)}
252+
</div>
253+
</div>
254+
)}
255+
256+
<div className="space-y-2.5 pt-1">
257+
{t?.id && (
258+
<Link
259+
href={`/verify/${t.id}`}
260+
className="inline-flex items-center justify-center gap-2 w-full py-3.5 bg-neutral-900 hover:bg-neutral-800 text-white font-semibold text-sm rounded-2xl transition shadow-sm"
261+
>
262+
<ShieldCheck className="w-4 h-4 text-emerald-400" />
263+
<span>View Verification Certificate</span>
264+
</Link>
265+
)}
266+
<Link
267+
href="/"
268+
className="inline-flex items-center justify-center gap-2 w-full py-3 bg-neutral-100 hover:bg-neutral-200 text-neutral-800 font-semibold text-xs sm:text-sm rounded-2xl transition border border-neutral-200"
269+
>
270+
Visit ClientEcho Home
271+
</Link>
272+
</div>
235273
</Card>
236274
</Shell>
237275
);
@@ -248,7 +286,7 @@ function ApproveTestimonialContent() {
248286
<div className="space-y-2">
249287
<h1 className="font-display text-2xl font-bold text-neutral-900">Link Expired</h1>
250288
<p className="text-neutral-500 text-sm leading-relaxed">
251-
This magic approval link has expired for security reasons. Please contact your service provider for a fresh link.
289+
This magic approval link has expired for security reasons (72-hour validity). Please contact your creator or service provider to generate a fresh link.
252290
</p>
253291
</div>
254292
<Link
@@ -279,7 +317,7 @@ function ApproveTestimonialContent() {
279317
<p className="text-neutral-500 text-sm leading-relaxed">
280318
{isNetworkError
281319
? "We couldn't connect to verify this magic link. Please check your internet connection and try again."
282-
: "We couldn't verify this magic link. Please check the URL from your invitation email or request a new invite from your creator."}
320+
: "We couldn't verify this magic link token. Please check that the entire link was copied properly from your invitation or request a new invite."}
283321
</p>
284322
</div>
285323

@@ -307,6 +345,7 @@ function ApproveTestimonialContent() {
307345

308346
// ─── Success ─────────────────────────────────────────────────────────────
309347
if (success) {
348+
const publishedId = validState.testimonial?.id;
310349
return (
311350
<Shell>
312351
<Card className="p-8 sm:p-10 text-center space-y-6">
@@ -323,12 +362,23 @@ function ApproveTestimonialContent() {
323362
<ShieldCheck className="w-4 h-4 text-emerald-600" />
324363
<span>Cryptographically Verified & Published</span>
325364
</div>
326-
<Link
327-
href="/"
328-
className="inline-flex items-center justify-center gap-2 w-full py-3.5 bg-neutral-100 hover:bg-neutral-200 text-neutral-800 font-semibold text-sm rounded-2xl transition border border-neutral-200"
329-
>
330-
Learn More About ClientEcho
331-
</Link>
365+
<div className="space-y-2.5 pt-2">
366+
{publishedId && (
367+
<Link
368+
href={`/verify/${publishedId}`}
369+
className="inline-flex items-center justify-center gap-2 w-full py-3.5 bg-neutral-900 hover:bg-neutral-800 text-white font-semibold text-sm rounded-2xl transition shadow-sm"
370+
>
371+
<ShieldCheck className="w-4 h-4 text-emerald-400" />
372+
<span>View Live Verification Certificate</span>
373+
</Link>
374+
)}
375+
<Link
376+
href="/"
377+
className="inline-flex items-center justify-center gap-2 w-full py-3.5 bg-neutral-100 hover:bg-neutral-200 text-neutral-800 font-semibold text-sm rounded-2xl transition border border-neutral-200"
378+
>
379+
Learn More About ClientEcho
380+
</Link>
381+
</div>
332382
</Card>
333383
</Shell>
334384
);

src/lib/email/index.ts

Lines changed: 12 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,6 @@
1-
import { Resend } from "resend";
21
import nodemailer from "nodemailer";
32
import { logger } from "@/lib/logger";
43

5-
const resendApiKey = process.env.RESEND_API_KEY;
6-
export const resend = resendApiKey ? new Resend(resendApiKey) : null;
7-
84
function getSmtpTransporter() {
95
const rawUser = process.env.GMAIL_USER || process.env.SMTP_USER;
106
const rawPass = process.env.GMAIL_APP_PASSWORD || process.env.SMTP_PASS || process.env.SMTP_PASSWORD;
@@ -19,9 +15,9 @@ function getSmtpTransporter() {
1915
tls: {
2016
rejectUnauthorized: false,
2117
},
22-
connectionTimeout: 5000, // 5s connection timeout to avoid hanging
23-
greetingTimeout: 4000, // 4s greeting timeout
24-
socketTimeout: 6000, // 6s socket timeout
18+
connectionTimeout: 8000, // 8s connection timeout to avoid hanging
19+
greetingTimeout: 5000, // 5s greeting timeout
20+
socketTimeout: 8000, // 8s socket timeout
2521
});
2622
}
2723
return null;
@@ -64,31 +60,19 @@ export function getBaseUrl(req?: Request): string {
6460
}
6561

6662
// 5. Default Canonical Production Domain Fallback
67-
// Transactional links (Magic Links, Approval Links, Password Resets) sent to external
68-
// clients/users must ALWAYS use a publicly reachable domain rather than localhost.
6963
return CANONICAL_APP_URL;
7064
}
7165

7266
export function getFromAddress(senderName?: string): string {
7367
const rawUser = process.env.GMAIL_USER || process.env.SMTP_USER;
74-
const gmailUser = rawUser ? rawUser.replace(/['"]/g, "").trim() : null;
68+
const gmailUser = rawUser ? rawUser.replace(/['"]/g, "").trim() : "ClientEcho.web@gmail.com";
7569
const displayName = senderName ? `${senderName} via ClientEcho` : "ClientEcho";
76-
if (gmailUser) {
77-
return `${displayName} <${gmailUser}>`;
78-
}
79-
const envFrom = (process.env.EMAIL_FROM || process.env.RESEND_FROM_ADDRESS || "").replace(/['"]/g, "").trim();
80-
if (envFrom && !envFrom.includes("@clientecho.com")) {
81-
return envFrom;
82-
}
83-
// Default to Resend testing domain if no Gmail SMTP is configured
84-
return `${displayName} <onboarding@resend.dev>`;
70+
return `${displayName} <${gmailUser}>`;
8571
}
8672

8773
/**
88-
* Universal email dispatcher: routes through Gmail SMTP if configured,
89-
* otherwise falls back to Resend API.
74+
* Universal email dispatcher: routes through Gmail SMTP with verified credentials.
9075
* Uses strict timeouts to guarantee non-blocking execution.
91-
* Avoids artificial spammy priority headers to ensure natural Primary Inbox delivery.
9276
*/
9377
async function sendEmailMessage(options: {
9478
to: string;
@@ -102,7 +86,6 @@ async function sendEmailMessage(options: {
10286
const fromAddress = options.from || getFromAddress();
10387
const transporter = getSmtpTransporter();
10488

105-
// 1. Send via Gmail SMTP if configured (Primary Inbox Delivery)
10689
if (transporter) {
10790
try {
10891
const sendPromise = transporter.sendMail({
@@ -112,11 +95,14 @@ async function sendEmailMessage(options: {
11295
text: options.text,
11396
html: options.html,
11497
replyTo: options.replyTo,
115-
headers: options.headers,
98+
headers: {
99+
"X-Auto-Response-Suppress": "OOF, AutoReply",
100+
...options.headers,
101+
},
116102
});
117103

118104
const timeoutPromise = new Promise<{ timeout: true }>((resolve) =>
119-
setTimeout(() => resolve({ timeout: true }), 6000)
105+
setTimeout(() => resolve({ timeout: true }), 8000)
120106
);
121107

122108
const result = await Promise.race([sendPromise, timeoutPromise]);
@@ -133,38 +119,7 @@ async function sendEmailMessage(options: {
133119
}
134120
}
135121

136-
// 2. Send via Resend API
137-
if (resend) {
138-
try {
139-
const sendPromise = resend.emails.send({
140-
from: fromAddress,
141-
to: options.to,
142-
subject: options.subject,
143-
text: options.text,
144-
html: options.html,
145-
replyTo: options.replyTo,
146-
headers: options.headers,
147-
});
148-
149-
const timeoutPromise = new Promise<{ timeout: true }>((resolve) =>
150-
setTimeout(() => resolve({ timeout: true }), 6000)
151-
);
152-
153-
const result = await Promise.race([sendPromise, timeoutPromise]);
154-
if (result && "timeout" in result) {
155-
logger.error(`[RESEND_TIMEOUT] Timeout while sending email via Resend to ${options.to}`);
156-
return { success: false, error: "Resend API request timed out" };
157-
}
158-
159-
return { success: true };
160-
} catch (err: any) {
161-
const errMsg = err?.message || String(err);
162-
logger.error(`Failed to send email via Resend`, err, { recipient: options.to });
163-
return { success: false, error: errMsg || "Failed to send email" };
164-
}
165-
}
166-
167-
// 3. Local / Dev fallback logger
122+
// Local / Dev fallback logger if SMTP credentials not provided
168123
logger.info(`[DEV / TEST] Email logged for ${options.to}: [${options.subject}]`);
169124
return { success: true };
170125
}

0 commit comments

Comments
 (0)