Add MJML and hack-approved email template

This commit is contained in:
Jared Schoeny 2026-06-12 23:12:19 -06:00
parent 34e86ed323
commit 9233593772
9 changed files with 2856 additions and 75 deletions

2762
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -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",

View 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>

View 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>

View 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>

View 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
View 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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;
}

View 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
View File

@ -0,0 +1,7 @@
import "mjml-core";
declare module "mjml-core" {
interface MJMLParsingOptions {
includePath?: string | string[];
}
}