import jwt from 'jsonwebtoken' import { randomUUID } from 'crypto' import { Resend } from 'resend' import Member from '../../../models/member.js' import { connectDB } from '../../../utils/mongoose.js' const resend = new Resend(process.env.RESEND_API_KEY) export default defineEventHandler(async (event) => { await requireAdmin(event) const { memberIds, emailTemplate } = await validateBody(event, memberInviteSchema) await connectDB() const baseUrl = process.env.BASE_URL if (!baseUrl) { throw createError({ statusCode: 500, statusMessage: 'BASE_URL environment variable is not set' }) } const config = useRuntimeConfig(event) const members = await Member.find({ _id: { $in: memberIds } }) if (members.length === 0) { throw createError({ statusCode: 404, statusMessage: 'No members found for the provided IDs' }) } const results = [] for (const member of members) { // Skip suspended/cancelled — do not reactivate silently if (member.status === 'suspended' || member.status === 'cancelled') { results.push({ memberId: member._id, email: member.email, success: false, error: `Skipped: account is ${member.status}`, }) continue } try { // Generate single-use invite token (48h), same jti pattern as login.post.js const jti = randomUUID() const token = jwt.sign( { memberId: member._id, jti }, config.jwtSecret, { expiresIn: '48h' }, ) // Store jti for single-use enforcement in verify.post.js (set after email succeeds below) // Token in fragment — never hits server logs const loginLink = `${baseUrl}/verify#${token}` // Interpolate template variables const emailText = emailTemplate .replace(/\{name\}/g, member.name) .replace(/\{loginLink\}/g, loginLink) .replace(/\{circle\}/g, member.circle) // Build HTML version: escape user content, linkify plain URLs, then insert button (unescaped) last const loginButton = `Sign in to Ghost Guild` const emailHtml = emailTemplate .replace(/&/g, '&') .replace(//g, '>') .replace(/\{name\}/g, member.name) .replace(/\{circle\}/g, member.circle) .replace(/(https?:\/\/[^\s<]+)/g, '$1') .replace(/\n/g, '
') .replace(/\{loginLink\}/g, loginButton) const { error: emailError } = await resend.emails.send({ from: 'Ghost Guild ', to: [member.email], subject: "You're invited to Ghost Guild", text: emailText, html: emailHtml, }) if (emailError) { results.push({ memberId: member._id, email: member.email, success: false, error: emailError.message }) continue } // Mark member as active, record invite sent, store jti for single-use enforcement await Member.findByIdAndUpdate( member._id, { $set: { magicLinkJti: jti, magicLinkJtiUsed: false, status: 'active', inviteEmailSent: true, inviteEmailSentAt: new Date(), }, }, { runValidators: false } ) logActivity(member._id, 'email_sent', { emailType: 'invite', subject: "You're invited to Ghost Guild", body: emailText }) results.push({ memberId: member._id, email: member.email, success: true }) } catch (err) { results.push({ memberId: member._id, email: member.email, success: false, error: err.message }) } } const sent = results.filter(r => r.success).length const failed = results.filter(r => !r.success).length return { sent, failed, results } })