From 27c07cd3e92988fc1cbd3a6ad16931fb5b4f9a51 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Thu, 19 Mar 2026 10:41:21 +0000 Subject: [PATCH] Send invite emails as HTML with clickable button, redirect login to wiki Invite emails now include both plain text and HTML versions. The {loginLink} placeholder renders as a styled button in HTML email clients. Other URLs in the template are auto-linked. The auth verify endpoint redirects to wiki.ghostguild.org instead of /members. --- server/api/admin/members/invite.post.js | 88 +++++++++++++++++++++++++ server/api/auth/verify.get.js | 4 +- 2 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 server/api/admin/members/invite.post.js diff --git a/server/api/admin/members/invite.post.js b/server/api/admin/members/invite.post.js new file mode 100644 index 0000000..e4685bf --- /dev/null +++ b/server/api/admin/members/invite.post.js @@ -0,0 +1,88 @@ +import jwt from 'jsonwebtoken' +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 config = useRuntimeConfig(event) + const headers = getHeaders(event) + const baseUrl = + process.env.BASE_URL || + `${headers.host?.includes('localhost') ? 'http' : 'https'}://${headers.host}` + + 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) { + try { + // Generate 48-hour magic login token (same format as login.post.js) + const token = jwt.sign( + { memberId: member._id }, + config.jwtSecret, + { expiresIn: '48h' } + ) + + const loginLink = `${baseUrl}/api/auth/verify?token=${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: sendError } = await resend.emails.send({ + from: 'Ghost Guild ', + to: [member.email], + subject: 'You\'re invited to Ghost Guild', + text: emailText, + html: emailHtml + }) + + if (sendError) { + results.push({ memberId: member._id, email: member.email, success: false, error: sendError.message }) + continue + } + + // Mark member as active and record invite sent + member.status = 'active' + member.inviteEmailSent = true + member.inviteEmailSentAt = new Date() + await member.save() + + 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 } +}) diff --git a/server/api/auth/verify.get.js b/server/api/auth/verify.get.js index 16655e2..57dda9a 100644 --- a/server/api/auth/verify.get.js +++ b/server/api/auth/verify.get.js @@ -52,8 +52,8 @@ export default defineEventHandler(async (event) => { maxAge: 60 * 60 * 24 * 7 // 7 days }) - // Redirect to the members dashboard or home page - await sendRedirect(event, '/members', 302) + // Redirect to the wiki + await sendRedirect(event, 'https://wiki.ghostguild.org', 302) } catch (err) { if (err.statusCode && err.statusCode !== 401) {