forked from Goutam/lynkeduppro-crm
cc71516278
- POST /api/email/invite: sends the invite email via the Twilio Emails API using server-only TWILIO_ACCOUNT_SID/TWILIO_AUTH_TOKEN (Vercel env), from EMAIL_FROM_ADDRESS (default support@lynkedup.dev). Fixed invite template; gated on a session cookie so it isn't an open relay. - Team Management emails the invite on create and resend (token now returned by both), with the Copy-link as a fallback. Mock mode skips email.
75 lines
3.2 KiB
TypeScript
75 lines
3.2 KiB
TypeScript
import { NextResponse } from "next/server";
|
|
import { buildInviteEmail } from "@/lib/invite-email";
|
|
|
|
// Sends a team-invitation email via the Twilio Emails API. Server-only: the Twilio
|
|
// Account SID + Auth Token come from Vercel env (TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN)
|
|
// and never reach the browser. From EMAIL_FROM_ADDRESS (default support@lynkedup.dev).
|
|
//
|
|
// POST /api/email/invite { to, token, roleNames?[] } → { ok }
|
|
// The email body is a fixed invite template (only `to` + `token` + role names are
|
|
// caller-supplied), so this can't be used to send arbitrary content.
|
|
|
|
export const runtime = "nodejs";
|
|
|
|
const TWILIO_EMAILS_URL = "https://comms.twilio.com/v1/Emails";
|
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
const SESSION_COOKIE = "__Host-insignia_session";
|
|
|
|
export async function POST(request: Request) {
|
|
// Light guard: only a signed-in session (which the invite UI has) may trigger sends,
|
|
// so this isn't an open email relay. Presence check — the cookie is HttpOnly + __Host-.
|
|
const cookies = request.headers.get("cookie") ?? "";
|
|
if (!cookies.includes(`${SESSION_COOKIE}=`)) {
|
|
return NextResponse.json({ ok: false, error: "unauthorized" }, { status: 401 });
|
|
}
|
|
|
|
const sid = process.env.TWILIO_ACCOUNT_SID ?? "";
|
|
const authToken = process.env.TWILIO_AUTH_TOKEN ?? "";
|
|
const from = process.env.EMAIL_FROM_ADDRESS ?? "support@lynkedup.dev";
|
|
const fromName = process.env.EMAIL_FROM_NAME ?? "LynkedUp Pro";
|
|
if (!sid || !authToken) {
|
|
return NextResponse.json({ ok: false, error: "email_not_configured" }, { status: 503 });
|
|
}
|
|
|
|
let body: { to?: string; token?: string; roleNames?: string[] };
|
|
try {
|
|
body = await request.json();
|
|
} catch {
|
|
return NextResponse.json({ ok: false, error: "invalid_json" }, { status: 400 });
|
|
}
|
|
|
|
const to = (body.to ?? "").trim().toLowerCase();
|
|
const token = (body.token ?? "").trim();
|
|
const roleNames = Array.isArray(body.roleNames) ? body.roleNames.filter((r) => typeof r === "string").slice(0, 10) : [];
|
|
if (!EMAIL_RE.test(to)) return NextResponse.json({ ok: false, error: "invalid_email" }, { status: 400 });
|
|
if (token.length < 16 || token.length > 256) return NextResponse.json({ ok: false, error: "invalid_token" }, { status: 400 });
|
|
|
|
const origin = (process.env.PORTAL_BASE_URL || new URL(request.url).origin).replace(/\/$/, "");
|
|
const inviteUrl = `${origin}/portal/invite?token=${encodeURIComponent(token)}`;
|
|
const { subject, html, text } = buildInviteEmail({ inviteUrl, roleNames });
|
|
|
|
const payload = {
|
|
from: { address: from, name: fromName },
|
|
to: [{ address: to }],
|
|
content: { subject, html, text },
|
|
};
|
|
|
|
try {
|
|
const res = await fetch(TWILIO_EMAILS_URL, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Basic ${Buffer.from(`${sid}:${authToken}`).toString("base64")}`,
|
|
},
|
|
body: JSON.stringify(payload),
|
|
});
|
|
if (!res.ok) {
|
|
const detail = await res.text().catch(() => "");
|
|
return NextResponse.json({ ok: false, error: "send_failed", status: res.status, detail: detail.slice(0, 300) }, { status: 502 });
|
|
}
|
|
return NextResponse.json({ ok: true });
|
|
} catch (e) {
|
|
return NextResponse.json({ ok: false, error: "send_error", detail: (e as Error).message }, { status: 502 });
|
|
}
|
|
}
|