mirror of
https://github.com/Hackdex-App/hackdex-website.git
synced 2026-06-20 14:19:46 -05:00
Add MJML and hack-approved email template
This commit is contained in:
parent
34e86ed323
commit
9233593772
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",
|
||||
|
|
|
|||
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[];
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user