mirror of
https://github.com/Hackdex-App/hackdex-website.git
synced 2026-08-22 00:24:34 -05:00
Send email to user on hack approval (#61)
* Add MJML and hack-approved email template * Move email sending to util file * Add trigger to send hack approved email * Fix replyTo contact form regression * Add email subject sanitization * Adjust subject of hack approval email * Use singleton for mail transporter * Fix wrong replyTo restoration
This commit is contained in:
2762
package-lock.json
generated
2762
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,7 @@
|
||||
"js-sha1": "^0.7.0",
|
||||
"mdast-util-to-hast": "^13.2.1",
|
||||
"minio": "^8.0.6",
|
||||
"mjml": "^5.3.0",
|
||||
"next": "^15.5.8",
|
||||
"next-turnstile": "^1.0.7",
|
||||
"nodemailer": "^7.0.11",
|
||||
@@ -44,6 +45,8 @@
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/mjml": "^5.0.0",
|
||||
"@types/mjml-core": "^5.0.0",
|
||||
"@types/node": "^20",
|
||||
"@types/nodemailer": "^7.0.4",
|
||||
"@types/react": "^19",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use server";
|
||||
|
||||
import nodemailer from "nodemailer";
|
||||
import { APIEmbed } from "discord-api-types/v10";
|
||||
import { sendDiscordMessageEmbed } from "@/utils/discord";
|
||||
import { createMailTransporter, sendTransactionalEmail } from "@/utils/email";
|
||||
|
||||
export interface ContactActionState {
|
||||
error: string | null;
|
||||
@@ -47,16 +47,6 @@ export async function sendContact(prev: ContactActionState, formData: FormData):
|
||||
return { error: "Email is required." };
|
||||
}
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST!,
|
||||
port: Number(process.env.SMTP_PORT!),
|
||||
requireTLS: true,
|
||||
auth: {
|
||||
user: process.env.SMTP_USER!,
|
||||
pass: process.env.SMTP_PASS!,
|
||||
},
|
||||
});
|
||||
|
||||
const ticketId = generateTicketId();
|
||||
const noreply = process.env.EMAIL_NOREPLY!;
|
||||
|
||||
@@ -77,8 +67,7 @@ export async function sendContact(prev: ContactActionState, formData: FormData):
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
await transporter.sendMail({
|
||||
from: `Hackdex <${noreply}>`,
|
||||
await sendTransactionalEmail({
|
||||
to: process.env.EMAIL_CONTACT!,
|
||||
replyTo: email,
|
||||
subject,
|
||||
@@ -97,8 +86,7 @@ export async function sendContact(prev: ContactActionState, formData: FormData):
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
await transporter.sendMail({
|
||||
from: `Hackdex <${noreply}>`,
|
||||
await sendTransactionalEmail({
|
||||
to: email,
|
||||
subject: `[#${ticketId}] Support request confirmation`,
|
||||
text: confirmationMessage,
|
||||
|
||||
@@ -9,6 +9,8 @@ import { APIEmbed } from "discord-api-types/v10";
|
||||
import { sendDiscordMessageEmbed } from "@/utils/discord";
|
||||
import { checkEditPermission, checkPatchEditPermission } from "@/utils/hack";
|
||||
import { getCachedTagsWithUsage, resolveTagIdsInOrder } from "@/data/tags";
|
||||
import { sendTransactionalEmail } from "@/utils/email";
|
||||
import { renderEmail } from "@/emails/render";
|
||||
|
||||
export async function updateHack(args: {
|
||||
slug: string;
|
||||
@@ -322,6 +324,26 @@ export async function approveHack(slug: string, verified?: boolean) {
|
||||
|
||||
if (updateErr) return { ok: false, error: updateErr.message } as const;
|
||||
|
||||
try {
|
||||
const { data: creatorData, error: creatorError } = await serviceClient.auth.admin.getUserById(hack.created_by);
|
||||
const creatorEmail = creatorData?.user?.email;
|
||||
if (creatorError || !creatorEmail) {
|
||||
console.error("[HackApprove] Failed to get creator email:", creatorError || "No email found");
|
||||
} else {
|
||||
const html = await renderEmail("hack-approved", {
|
||||
title: hack.title,
|
||||
slug,
|
||||
});
|
||||
await sendTransactionalEmail({
|
||||
to: creatorEmail,
|
||||
subject: `"${hack.title}" has been approved`,
|
||||
html,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[HackApprove] Failed to send email to creator:", error);
|
||||
}
|
||||
|
||||
if (process.env.DISCORD_WEBHOOK_HACKDEX_HACKS_URL) {
|
||||
const { data: profile } = await serviceClient.from('profiles').select('*').eq('id', hack.created_by).single();
|
||||
const displayName = profile?.username ? `@${profile.username}` : user.id;
|
||||
|
||||
9
src/emails/partials/footer.mjml
Normal file
9
src/emails/partials/footer.mjml
Normal file
@@ -0,0 +1,9 @@
|
||||
<mj-section background-color="#ffffff" border-top="1px solid rgba(0,0,0,0.12)">
|
||||
<mj-column>
|
||||
|
||||
<mj-text font-size="14px" color="#9ca3af" align="center" padding-top="16px">
|
||||
© 2026 Hackdex. All rights reserved. Please do not reply to this message.
|
||||
</mj-text>
|
||||
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
9
src/emails/partials/head.mjml
Normal file
9
src/emails/partials/head.mjml
Normal file
@@ -0,0 +1,9 @@
|
||||
<mj-attributes>
|
||||
<mj-all font-family="Inter, Arial, sans-serif" />
|
||||
|
||||
<mj-text color="#171717" />
|
||||
|
||||
<mj-button background-color="#f43f5e" color="#ffffff" border-radius="6px" />
|
||||
<mj-section padding="20px 0" />
|
||||
<mj-column padding="0 20px" />
|
||||
</mj-attributes>
|
||||
7
src/emails/partials/header.mjml
Normal file
7
src/emails/partials/header.mjml
Normal file
@@ -0,0 +1,7 @@
|
||||
<mj-section background-color="#fafafa">
|
||||
<mj-column>
|
||||
|
||||
<mj-image width="64px" src="https://www.hackdex.app/logo.png" alt="Hackdex Logo" href="https://hackdex.app" />
|
||||
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
14
src/emails/partials/sign-off.mjml
Normal file
14
src/emails/partials/sign-off.mjml
Normal file
@@ -0,0 +1,14 @@
|
||||
<mj-section background-color="#fafafa">
|
||||
<mj-column>
|
||||
|
||||
<mj-text font-size="16px" line-height="24px" color="#374151" padding-bottom="16px">
|
||||
If you have any questions or need further assistance, feel free to reach out through our <a href="https://hackdex.app/contact">contact form</a>.
|
||||
</mj-text>
|
||||
|
||||
<mj-text font-size="16px" line-height="24px" color="#374151">
|
||||
Best regards,<br />
|
||||
The Hackdex Team
|
||||
</mj-text>
|
||||
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
85
src/emails/render.ts
Normal file
85
src/emails/render.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import mjml from "mjml";
|
||||
import { MJMLParseError } from "mjml-core";
|
||||
|
||||
const EMAILS_DIR = path.join(process.cwd(), "src/emails");
|
||||
const TEMPLATES_DIR = path.join(EMAILS_DIR, "templates");
|
||||
const PARTIALS_DIR = path.join(EMAILS_DIR, "partials");
|
||||
|
||||
export type HackApprovedEmailVars = {
|
||||
title: string;
|
||||
slug: string;
|
||||
};
|
||||
|
||||
export type EmailTemplateVars = {
|
||||
"hack-approved": HackApprovedEmailVars;
|
||||
};
|
||||
|
||||
export type EmailTemplate = keyof EmailTemplateVars;
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function substituteVars(template: string, vars: Record<string, string>): string {
|
||||
return template.replace(/\{\{(\w+)\}\}/g, (match, key: string) => {
|
||||
const value = vars[key];
|
||||
if (value === undefined) {
|
||||
throw new Error(`Missing email template variable: ${key}`);
|
||||
}
|
||||
return value;
|
||||
});
|
||||
}
|
||||
|
||||
const templateNormalizers: {
|
||||
[K in EmailTemplate]: (vars: EmailTemplateVars[K]) => Record<string, string>;
|
||||
} = {
|
||||
"hack-approved": ({ title, slug }) => ({
|
||||
title: escapeHtml(title),
|
||||
slug: encodeURIComponent(slug),
|
||||
}),
|
||||
};
|
||||
|
||||
function normalizeTemplateVars<T extends EmailTemplate>(
|
||||
template: T,
|
||||
vars: EmailTemplateVars[T],
|
||||
): Record<string, string> {
|
||||
return templateNormalizers[template](vars);
|
||||
}
|
||||
|
||||
async function loadTemplate(template: EmailTemplate): Promise<string> {
|
||||
const templatePath = path.join(TEMPLATES_DIR, `${template}.mjml`);
|
||||
return fs.readFile(templatePath, "utf8");
|
||||
}
|
||||
|
||||
export async function renderEmail<T extends EmailTemplate>(
|
||||
template: T,
|
||||
vars: EmailTemplateVars[T],
|
||||
): Promise<string> {
|
||||
const mjmlSource = substituteVars(
|
||||
await loadTemplate(template),
|
||||
normalizeTemplateVars(template, vars),
|
||||
);
|
||||
|
||||
const { html, errors } = await mjml(mjmlSource, {
|
||||
ignoreIncludes: false,
|
||||
filePath: TEMPLATES_DIR,
|
||||
includePath: PARTIALS_DIR,
|
||||
validationLevel: "soft",
|
||||
});
|
||||
|
||||
const fatalErrors = errors.filter((error: MJMLParseError & { level?: "error" }) => error.level === "error");
|
||||
if (fatalErrors.length > 0) {
|
||||
const message = fatalErrors.map((error) => error.formattedMessage || error.message).join("\n");
|
||||
throw new Error(`Failed to render email template "${template}":\n${message}`);
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
35
src/emails/templates/hack-approved.mjml
Normal file
35
src/emails/templates/hack-approved.mjml
Normal file
@@ -0,0 +1,35 @@
|
||||
<mjml>
|
||||
<mj-head>
|
||||
<mj-include path="../partials/head.mjml" />
|
||||
<mj-preview>Your hack is now live on Hackdex!</mj-preview>
|
||||
</mj-head>
|
||||
<mj-body background-color="#f3f4f6">
|
||||
<mj-include path="../partials/header.mjml" />
|
||||
<mj-section background-color="#ffffff">
|
||||
<mj-column>
|
||||
|
||||
<mj-text font-size="24px" font-weight="700" color="#171717" padding-bottom="12px">
|
||||
Congratulations, Your Hack is Live!
|
||||
</mj-text>
|
||||
|
||||
<mj-text font-size="16px" line-height="24px" color="#374151" padding-bottom="16px">
|
||||
Hello,
|
||||
<br /><br />
|
||||
We're excited to let you know that your hack <strong>{{title}}</strong> has been approved and is now live on <strong>Hackdex</strong>.
|
||||
Make sure to share it on social media and with your friends to help increase visibility!
|
||||
</mj-text>
|
||||
|
||||
<mj-button href="https://hackdex.app/hack/{{slug}}" padding="10px 25px">
|
||||
<strong>View on Hackdex</strong>
|
||||
</mj-button>
|
||||
|
||||
<mj-text font-size="16px" line-height="24px" color="#374151" padding-bottom="16px">
|
||||
You can track the downloads of your hack on your <a href="https://hackdex.app/dashboard">dashboard</a>.
|
||||
</mj-text>
|
||||
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-include path="../partials/sign-off.mjml" />
|
||||
<mj-include path="../partials/footer.mjml" />
|
||||
</mj-body>
|
||||
</mjml>
|
||||
7
src/types/mjml.d.ts
vendored
Normal file
7
src/types/mjml.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
import "mjml-core";
|
||||
|
||||
declare module "mjml-core" {
|
||||
interface MJMLParsingOptions {
|
||||
includePath?: string | string[];
|
||||
}
|
||||
}
|
||||
60
src/utils/email.ts
Normal file
60
src/utils/email.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import nodemailer from "nodemailer";
|
||||
import type Transporter from "nodemailer/lib/mailer";
|
||||
|
||||
let transporter: Transporter | null = null;
|
||||
|
||||
const sanitizeEmailSubject = (subject: string) => {
|
||||
return subject
|
||||
.replace(/[\x00-\x1f\x7f]/g, "") // Remove control characters
|
||||
.replace(/\s+/g, " ") // Replace whitespace with spaces
|
||||
.replace(/<[^>]*>/g, "") // Remove HTML tags
|
||||
.trim(); // Remove leading and trailing whitespace
|
||||
};
|
||||
|
||||
export const createMailTransporter = () => {
|
||||
if (!transporter) {
|
||||
transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST!,
|
||||
port: Number(process.env.SMTP_PORT!),
|
||||
requireTLS: true,
|
||||
auth: {
|
||||
user: process.env.SMTP_USER!,
|
||||
pass: process.env.SMTP_PASS!,
|
||||
},
|
||||
});
|
||||
}
|
||||
return transporter;
|
||||
};
|
||||
|
||||
// At least one of html or text must be provided
|
||||
// If html is provided, text can optionally be provided as well
|
||||
|
||||
interface SendTransactionalEmailBase {
|
||||
to: string;
|
||||
subject: string;
|
||||
replyTo?: string;
|
||||
}
|
||||
|
||||
interface SendTransactionalEmailWithHtml extends SendTransactionalEmailBase {
|
||||
html: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
interface SendTransactionalEmailWithText extends SendTransactionalEmailBase {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export const sendTransactionalEmail = async (args: SendTransactionalEmailWithHtml | SendTransactionalEmailWithText) => {
|
||||
const noreply = process.env.EMAIL_NOREPLY!;
|
||||
const transporter = createMailTransporter();
|
||||
const sanitizedSubject = sanitizeEmailSubject(args.subject);
|
||||
|
||||
await transporter.sendMail({
|
||||
from: `Hackdex <${noreply}>`,
|
||||
to: args.to,
|
||||
replyTo: args.replyTo,
|
||||
subject: sanitizedSubject,
|
||||
html: "html" in args ? args.html : undefined,
|
||||
text: "text" in args ? args.text : undefined,
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user