Skip to content

Commit 8d0e728

Browse files
Arthur-Kamauclaude
andcommitted
fix: route email OTP through SendGrid, keep Twilio for SMS only
The shared Twilio Verify Service no longer has an email Mailer configured (removed when aquafier-rs switched to SendGrid). This caused "A Mailer must be associated with the Service" errors for email claim workflows. Email OTP is now generated locally, stored in-memory with 10-min TTL, and delivered via SendGrid — matching the aquafier-rs approach. SMS verification continues to use Twilio Verify unchanged. Requires SENDGRID_API_KEY and SENDGRID_FROM_EMAIL env vars. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1bf84be commit 8d0e728

4 files changed

Lines changed: 174 additions & 38 deletions

File tree

api/.env.sample

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,18 @@ S3_BUCKET=
2222
UPLOAD_DIR=
2323

2424
# ============================================================================
25-
# Twilio for sending sms and email challange for creating claims
25+
# Twilio (SMS OTP for phone claims)
2626
# ============================================================================
2727
TWILIO_ACCOUNT_SID=
2828
TWILIO_AUTH_TOKEN=
2929
TWILIO_VERIFY_SERVICE_SID=
3030

31+
# ============================================================================
32+
# SendGrid (Email OTP for email claims)
33+
# ============================================================================
34+
SENDGRID_API_KEY=
35+
SENDGRID_FROM_EMAIL=noreply@inblock.io
36+
3137
SERVER_MNEMONIC=
3238

3339
DEFAULT_WITNESS_NETWORK=

api/src/controllers/api.ts

Lines changed: 59 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import path from 'path';
99
import { getAquaAssetDirectory } from "../utils/file_utils";
1010
import { checkFolderExists } from "../utils/api_utils";
1111
import { authenticate, AuthenticatedRequest } from '../middleware/auth_middleware';
12+
import { generateOtp, storeOtp, verifyOtp, sendEmailOtp } from "../utils/email_otp";
1213

1314
// Rate-limiting configuration
1415
const RATE_LIMIT_CONFIG = {
@@ -231,26 +232,35 @@ if (hasProtocol && !isAllowed) {
231232
});
232233
}
233234

234-
const twilio = process.env.TWILIO_VERIFY_SERVICE_SID;
235+
if (verificationType === "email") {
236+
const valid = verifyOtp(revisionDataPar.email_or_phone_number, revisionDataPar.code);
237+
if (!valid) {
238+
return reply
239+
.code(400)
240+
.send({ success: false, message: "Invalid or expired verification code" });
241+
}
242+
} else {
243+
const twilio = process.env.TWILIO_VERIFY_SERVICE_SID;
235244

236-
if (!twilio) {
237-
return reply
238-
.code(500)
239-
.send({ success: false, message: "Twilio env variable not set" });
240-
}
245+
if (!twilio) {
246+
return reply
247+
.code(500)
248+
.send({ success: false, message: "Twilio env variable not set" });
249+
}
241250

242-
try {
243-
await twilioClient.verify.v2
244-
.services(twilio)
245-
.verificationChecks.create({
246-
to: revisionDataPar.email_or_phone_number,
247-
code: revisionDataPar.code,
248-
});
249-
} catch (err: any) {
250-
Logger.error("🛑 Twilio Verify initiation failed", err.message);
251-
return reply
252-
.code(500)
253-
.send({ ok: false, error: `Twilio Failed ${err.message}` });
251+
try {
252+
await twilioClient.verify.v2
253+
.services(twilio)
254+
.verificationChecks.create({
255+
to: revisionDataPar.email_or_phone_number,
256+
code: revisionDataPar.code,
257+
});
258+
} catch (err: any) {
259+
Logger.error("Twilio Verify check failed", err.message);
260+
return reply
261+
.code(500)
262+
.send({ ok: false, error: `Twilio Failed ${err.message}` });
263+
}
254264
}
255265

256266
return reply
@@ -337,26 +347,39 @@ if (hasProtocol && !isAllowed) {
337347
});
338348
}
339349

340-
const { TWILIO_VERIFY_SERVICE_SID } = process.env;
350+
if (verificationType === "email") {
351+
try {
352+
const code = generateOtp();
353+
storeOtp(revisionDataPar.email_or_phone_number, code);
354+
await sendEmailOtp(revisionDataPar.email_or_phone_number, code);
355+
} catch (err: any) {
356+
Logger.error("Email OTP send failed", err.message);
357+
return reply
358+
.code(500)
359+
.send({ ok: false, error: `Email send failed: ${err.message}` });
360+
}
361+
} else {
362+
const { TWILIO_VERIFY_SERVICE_SID } = process.env;
341363

342-
if (!TWILIO_VERIFY_SERVICE_SID) {
343-
return reply
344-
.code(500)
345-
.send({ success: false, message: "Twilio env variable not set" });
346-
}
364+
if (!TWILIO_VERIFY_SERVICE_SID) {
365+
return reply
366+
.code(500)
367+
.send({ success: false, message: "Twilio env variable not set" });
368+
}
347369

348-
try {
349-
await twilioClient.verify.v2
350-
.services(TWILIO_VERIFY_SERVICE_SID)
351-
.verifications.create({
352-
to: revisionDataPar.email_or_phone_number,
353-
channel,
354-
});
355-
} catch (err: any) {
356-
Logger.error("🛑 Twilio Verify initiation failed", err.message);
357-
return reply
358-
.code(500)
359-
.send({ ok: false, error: `Twilio Failed ${err.message}` });
370+
try {
371+
await twilioClient.verify.v2
372+
.services(TWILIO_VERIFY_SERVICE_SID)
373+
.verifications.create({
374+
to: revisionDataPar.email_or_phone_number,
375+
channel: "sms",
376+
});
377+
} catch (err: any) {
378+
Logger.error("Twilio Verify initiation failed", err.message);
379+
return reply
380+
.code(500)
381+
.send({ ok: false, error: `Twilio Failed ${err.message}` });
382+
}
360383
}
361384

362385
return reply

api/src/utils/email_otp.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import Logger from "./logger";
2+
3+
interface PendingCode {
4+
code: string;
5+
expiresAt: number;
6+
}
7+
8+
const pendingCodes = new Map<string, PendingCode>();
9+
const OTP_TTL_MS = 10 * 60 * 1000; // 10 minutes
10+
11+
export function generateOtp(): string {
12+
return Math.floor(100000 + Math.random() * 900000).toString();
13+
}
14+
15+
export function storeOtp(email: string, code: string): void {
16+
pendingCodes.set(email.toLowerCase(), {
17+
code,
18+
expiresAt: Date.now() + OTP_TTL_MS,
19+
});
20+
}
21+
22+
export function verifyOtp(email: string, code: string): boolean {
23+
const entry = pendingCodes.get(email.toLowerCase());
24+
if (!entry) return false;
25+
if (Date.now() > entry.expiresAt) {
26+
pendingCodes.delete(email.toLowerCase());
27+
return false;
28+
}
29+
if (entry.code !== code) return false;
30+
pendingCodes.delete(email.toLowerCase());
31+
return true;
32+
}
33+
34+
export async function sendEmailOtp(toEmail: string, code: string): Promise<void> {
35+
const apiKey = process.env.SENDGRID_API_KEY;
36+
const fromEmail = process.env.SENDGRID_FROM_EMAIL || "noreply@inblock.io";
37+
const appUrl = process.env.FRONTEND_URL || "https://aquafier.inblock.io";
38+
39+
if (!apiKey) {
40+
throw new Error("Email delivery not configured (SENDGRID_API_KEY missing)");
41+
}
42+
43+
const year = new Date().getFullYear();
44+
const appUrlShort = appUrl.replace(/^https?:\/\//, "");
45+
46+
const html = `<!DOCTYPE html>
47+
<html lang="en">
48+
<head><meta charset="UTF-8"><title>Aquafier — Email verification</title></head>
49+
<body style="margin:0;padding:0;background:#F4F4F5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#1F2937">
50+
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background:#F4F4F5;padding:32px 16px">
51+
<tr><td align="center">
52+
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="560" style="max-width:560px;width:100%;background:#FFFFFF;border-radius:12px;overflow:hidden;box-shadow:0 1px 3px rgba(15,23,42,0.08)">
53+
<tr><td style="background:#E55B1F;background-image:linear-gradient(135deg,#E55B1F 0%,#F37C30 100%);padding:36px 24px;text-align:center;color:#FFFFFF">
54+
<div style="font-size:26px;font-weight:700;letter-spacing:0.2px;margin:0">Aquafier</div>
55+
<div style="font-size:14px;font-weight:500;margin-top:6px;opacity:0.95">Verify your email identity claim</div>
56+
</td></tr>
57+
<tr><td style="padding:32px 36px 8px 36px">
58+
<p style="margin:0 0 14px 0;font-size:15px;line-height:1.55;color:#1F2937">Thank you for using <strong>Aquafier</strong>.</p>
59+
<p style="margin:0 0 6px 0;font-size:15px;line-height:1.55;color:#374151">To complete your email verification and create a verified email identity claim, please use the one-time password (OTP) below:</p>
60+
</td></tr>
61+
<tr><td align="center" style="padding:8px 36px 4px 36px">
62+
<div style="display:inline-block;font-family:'SF Mono','Menlo','Consolas',monospace;font-size:38px;font-weight:700;letter-spacing:10px;color:#E55B1F;background:#FFF4EE;border:1px solid #FBD3B9;border-radius:10px;padding:18px 28px;margin:18px 0">${code}</div>
63+
</td></tr>
64+
<tr><td style="padding:8px 36px 28px 36px">
65+
<p style="margin:0 0 10px 0;font-size:13px;line-height:1.55;color:#4B5563">This OTP is valid for the next <strong>10 minutes</strong>. If you didn't request this verification, please ignore this email or contact support.</p>
66+
<p style="margin:14px 0 0 0;font-size:13px;line-height:1.55;color:#9CA3AF">Do not share this code with anyone for security reasons.</p>
67+
</td></tr>
68+
</table>
69+
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="560" style="max-width:560px;width:100%;margin-top:18px">
70+
<tr><td align="center" style="padding:0 24px;font-size:12px;color:#6B7280">
71+
<div>&copy; ${year} Aquafier. All rights reserved.</div>
72+
<div style="margin-top:6px"><a href="${appUrl}" style="color:#E55B1F;text-decoration:none">${appUrlShort}</a></div>
73+
<div style="margin-top:6px;color:#9CA3AF">This is an automated email. Please do not reply.</div>
74+
</td></tr>
75+
</table>
76+
</td></tr>
77+
</table>
78+
</body>
79+
</html>`;
80+
81+
const payload = {
82+
personalizations: [{ to: [{ email: toEmail }], subject: `Your Aquafier verification code: ${code}` }],
83+
from: { email: fromEmail, name: "Aquafier" },
84+
content: [{ type: "text/html", value: html }],
85+
};
86+
87+
const resp = await fetch("https://api.sendgrid.com/v3/mail/send", {
88+
method: "POST",
89+
headers: {
90+
Authorization: `Bearer ${apiKey}`,
91+
"Content-Type": "application/json",
92+
},
93+
body: JSON.stringify(payload),
94+
});
95+
96+
if (!resp.ok) {
97+
const body = await resp.text();
98+
Logger.error(`SendGrid returned ${resp.status}: ${body}`);
99+
throw new Error(`SendGrid returned ${resp.status}`);
100+
}
101+
102+
Logger.info(`Email OTP sent to ${toEmail} via SendGrid`);
103+
}

deployment/.env.sample

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,15 @@ S3_USE_SSL=false
2323
VITE_INFURA_PROJECT_ID=
2424
SERVER_MNEMONIC="test test test test test test test test test test test test"
2525

26-
#TWILIO
26+
#TWILIO (SMS OTP for phone claims)
2727
TWILIO_ACCOUNT_SID=""
2828
TWILIO_AUTH_TOKEN=""
2929
TWILIO_VERIFY_SERVICE_SID=""
3030

31+
#SENDGRID (Email OTP for email claims)
32+
SENDGRID_API_KEY=""
33+
SENDGRID_FROM_EMAIL="noreply@inblock.io"
34+
3135
# psql conn string for prisma
3236
DATABASE_URL="postgresql://aquafier:changeme@postgres:5432/aquafier?schema=public"
3337

0 commit comments

Comments
 (0)