use maizzle for email templating, starting with reset password email
This commit is contained in:
parent
3f279634b6
commit
514dae3ebb
@ -84,7 +84,7 @@ export function AuthForm<S extends z.ZodType<any, any>>({
|
|||||||
type="submit"
|
type="submit"
|
||||||
disabled={ctx.formState.isSubmitting}
|
disabled={ctx.formState.isSubmitting}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white focus:outline-none focus:border-primary-700 focus:shadow-outline-primary transition duration-150 ease-in-out",
|
"w-full flex justify-center py-2 px-4 border border-transparent text-base font-medium rounded-md text-white focus:outline-none focus:border-primary-700 focus:shadow-outline-primary transition duration-150 ease-in-out",
|
||||||
{
|
{
|
||||||
"bg-primary-400 cursor-not-allowed": ctx.formState.isSubmitting,
|
"bg-primary-400 cursor-not-allowed": ctx.formState.isSubmitting,
|
||||||
"bg-primary-600 hover:bg-primary-700": !ctx.formState.isSubmitting,
|
"bg-primary-600 hover:bg-primary-700": !ctx.formState.isSubmitting,
|
||||||
|
@ -4,7 +4,7 @@ import db, { User } from "../../../db";
|
|||||||
import { forgotPasswordMailer } from "../../../mailers/forgot-password-mailer";
|
import { forgotPasswordMailer } from "../../../mailers/forgot-password-mailer";
|
||||||
import { ForgotPassword } from "../validations";
|
import { ForgotPassword } from "../validations";
|
||||||
|
|
||||||
const RESET_PASSWORD_TOKEN_EXPIRATION_IN_HOURS = 4;
|
const RESET_PASSWORD_TOKEN_EXPIRATION_IN_HOURS = 24;
|
||||||
|
|
||||||
export default resolver.pipe(resolver.zod(ForgotPassword), async ({ email }) => {
|
export default resolver.pipe(resolver.zod(ForgotPassword), async ({ email }) => {
|
||||||
const user = await db.user.findFirst({ where: { email: email.toLowerCase() } });
|
const user = await db.user.findFirst({ where: { email: email.toLowerCase() } });
|
||||||
@ -36,5 +36,11 @@ async function updatePassword(user: User | null) {
|
|||||||
sentTo: user.email,
|
sentTo: user.email,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await forgotPasswordMailer({ to: user.email, token }).send();
|
await (
|
||||||
|
await forgotPasswordMailer({
|
||||||
|
to: user.email,
|
||||||
|
token,
|
||||||
|
userName: user.fullName,
|
||||||
|
})
|
||||||
|
).send();
|
||||||
}
|
}
|
||||||
|
@ -1,26 +1,27 @@
|
|||||||
import type { BlitzPage } from "blitz";
|
import type { BlitzPage } from "blitz";
|
||||||
import { Routes, useMutation } from "blitz";
|
import { Routes, useMutation } from "blitz";
|
||||||
|
|
||||||
import BaseLayout from "../../core/layouts/base-layout";
|
import BaseLayout from "app/core/layouts/base-layout";
|
||||||
import { AuthForm as Form, FORM_ERROR } from "../components/auth-form";
|
import { AuthForm as Form, FORM_ERROR } from "../components/auth-form";
|
||||||
import { LabeledTextField } from "../components/labeled-text-field";
|
import { LabeledTextField } from "../components/labeled-text-field";
|
||||||
import { ForgotPassword } from "../validations";
|
import { ForgotPassword } from "../validations";
|
||||||
import forgotPassword from "../../auth/mutations/forgot-password";
|
import forgotPassword from "app/auth/mutations/forgot-password";
|
||||||
|
|
||||||
const ForgotPasswordPage: BlitzPage = () => {
|
const ForgotPasswordPage: BlitzPage = () => {
|
||||||
const [forgotPasswordMutation, { isSuccess }] = useMutation(forgotPassword);
|
const [forgotPasswordMutation, { isSuccess, reset }] = useMutation(forgotPassword);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form
|
<Form
|
||||||
texts={{
|
texts={{
|
||||||
title: isSuccess ? "Request submitted" : "Forgot your password?",
|
title: isSuccess ? "Request submitted" : "Forgot your password?",
|
||||||
subtitle: "",
|
subtitle: "",
|
||||||
submit: isSuccess ? "" : "Send reset password instructions",
|
submit: isSuccess ? "" : "Send reset password link",
|
||||||
}}
|
}}
|
||||||
schema={ForgotPassword}
|
schema={ForgotPassword}
|
||||||
initialValues={{ email: "" }}
|
initialValues={{ email: "" }}
|
||||||
onSubmit={async (values) => {
|
onSubmit={async (values) => {
|
||||||
try {
|
try {
|
||||||
|
reset();
|
||||||
await forgotPasswordMutation(values);
|
await forgotPasswordMutation(values);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
return {
|
return {
|
||||||
@ -30,7 +31,9 @@ const ForgotPasswordPage: BlitzPage = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isSuccess ? (
|
{isSuccess ? (
|
||||||
<p>If your email is in our system, you will receive instructions to reset your password shortly.</p>
|
<p className="text-center">
|
||||||
|
If your email is in our system, you will receive instructions to reset your password shortly.
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<LabeledTextField name="email" label="Email" />
|
<LabeledTextField name="email" label="Email" />
|
||||||
)}
|
)}
|
||||||
@ -40,6 +43,6 @@ const ForgotPasswordPage: BlitzPage = () => {
|
|||||||
|
|
||||||
ForgotPasswordPage.redirectAuthenticatedTo = Routes.Messages();
|
ForgotPasswordPage.redirectAuthenticatedTo = Routes.Messages();
|
||||||
|
|
||||||
ForgotPasswordPage.getLayout = (page) => <BaseLayout title="Forgot Your Password?">{page}</BaseLayout>;
|
ForgotPasswordPage.getLayout = (page) => <BaseLayout title="Reset password">{page}</BaseLayout>;
|
||||||
|
|
||||||
export default ForgotPasswordPage;
|
export default ForgotPasswordPage;
|
||||||
|
@ -56,7 +56,7 @@ const ResetPasswordPage: BlitzPage = () => {
|
|||||||
|
|
||||||
ResetPasswordPage.redirectAuthenticatedTo = Routes.Messages();
|
ResetPasswordPage.redirectAuthenticatedTo = Routes.Messages();
|
||||||
|
|
||||||
ResetPasswordPage.getLayout = (page) => <BaseLayout title="Reset Your Password">{page}</BaseLayout>;
|
ResetPasswordPage.getLayout = (page) => <BaseLayout title="Reset password">{page}</BaseLayout>;
|
||||||
|
|
||||||
export const getServerSideProps: GetServerSideProps = async (context) => {
|
export const getServerSideProps: GetServerSideProps = async (context) => {
|
||||||
if (!context.query.token) {
|
if (!context.query.token) {
|
||||||
|
@ -55,6 +55,6 @@ const SignIn: BlitzPage = () => {
|
|||||||
|
|
||||||
SignIn.redirectAuthenticatedTo = Routes.Messages();
|
SignIn.redirectAuthenticatedTo = Routes.Messages();
|
||||||
|
|
||||||
SignIn.getLayout = (page) => <BaseLayout title="Sign In">{page}</BaseLayout>;
|
SignIn.getLayout = (page) => <BaseLayout title="Sign in">{page}</BaseLayout>;
|
||||||
|
|
||||||
export default SignIn;
|
export default SignIn;
|
||||||
|
@ -55,6 +55,6 @@ SignUp.redirectAuthenticatedTo = ({ session }) => {
|
|||||||
return Routes.Messages();
|
return Routes.Messages();
|
||||||
};
|
};
|
||||||
|
|
||||||
SignUp.getLayout = (page) => <BaseLayout title="Sign Up">{page}</BaseLayout>;
|
SignUp.getLayout = (page) => <BaseLayout title="Sign up">{page}</BaseLayout>;
|
||||||
|
|
||||||
export default SignUp;
|
export default SignUp;
|
||||||
|
@ -10,7 +10,7 @@ const BaseLayout = ({ title, children }: LayoutProps) => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>{title || "shellphone.app"}</title>
|
<title>{title ? `${title} | Shellphone` : "Shellphone"}</title>
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
</Head>
|
</Head>
|
||||||
|
|
||||||
|
@ -15,12 +15,14 @@ const notifyEmailChangeQueue = Queue<Payload>("api/queue/notify-email-change", a
|
|||||||
sendEmail({
|
sendEmail({
|
||||||
recipients: [oldEmail],
|
recipients: [oldEmail],
|
||||||
subject: "",
|
subject: "",
|
||||||
body: "",
|
text: "",
|
||||||
|
html: "",
|
||||||
}),
|
}),
|
||||||
sendEmail({
|
sendEmail({
|
||||||
recipients: [newEmail],
|
recipients: [newEmail],
|
||||||
subject: "",
|
subject: "",
|
||||||
body: "",
|
text: "",
|
||||||
|
html: "",
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
@ -78,7 +78,8 @@ export const subscriptionCreatedQueue = Queue<Payload>("api/queue/subscription-c
|
|||||||
if (isReturningSubscriber) {
|
if (isReturningSubscriber) {
|
||||||
sendEmail({
|
sendEmail({
|
||||||
subject: "Welcome back to Shellphone",
|
subject: "Welcome back to Shellphone",
|
||||||
body: "Welcome back to Shellphone",
|
text: "Welcome back to Shellphone",
|
||||||
|
html: "Welcome back to Shellphone",
|
||||||
recipients: [email],
|
recipients: [email],
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
logger.error(error);
|
logger.error(error);
|
||||||
@ -89,7 +90,8 @@ export const subscriptionCreatedQueue = Queue<Payload>("api/queue/subscription-c
|
|||||||
|
|
||||||
sendEmail({
|
sendEmail({
|
||||||
subject: "Welcome to Shellphone",
|
subject: "Welcome to Shellphone",
|
||||||
body: `Welcome to Shellphone`,
|
text: `Welcome to Shellphone`,
|
||||||
|
html: `Welcome to Shellphone`,
|
||||||
recipients: [email],
|
recipients: [email],
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
logger.error(error);
|
logger.error(error);
|
||||||
|
@ -61,7 +61,8 @@ export const subscriptionUpdatedQueue = Queue<Payload>("api/queue/subscription-u
|
|||||||
|
|
||||||
sendEmail({
|
sendEmail({
|
||||||
subject: "Thanks for your purchase",
|
subject: "Thanks for your purchase",
|
||||||
body: "Thanks for your purchase",
|
text: "Thanks for your purchase",
|
||||||
|
html: "Thanks for your purchase",
|
||||||
recipients: [email],
|
recipients: [email],
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
logger.error(error);
|
logger.error(error);
|
||||||
|
@ -31,7 +31,8 @@ export default async function backup(schedule: "daily" | "weekly" | "monthly") {
|
|||||||
gzipChild.on("exit", (code) => {
|
gzipChild.on("exit", (code) => {
|
||||||
if (code !== 0) {
|
if (code !== 0) {
|
||||||
return sendEmail({
|
return sendEmail({
|
||||||
body: `${schedule} backup failed: gzip: Bad exit code (${code})`,
|
text: `${schedule} backup failed: gzip: Bad exit code (${code})`,
|
||||||
|
html: `${schedule} backup failed: gzip: Bad exit code (${code})`,
|
||||||
subject: `${schedule} backup failed: gzip: Bad exit code (${code})`,
|
subject: `${schedule} backup failed: gzip: Bad exit code (${code})`,
|
||||||
recipients: ["error@shellphone.app"],
|
recipients: ["error@shellphone.app"],
|
||||||
});
|
});
|
||||||
@ -44,7 +45,8 @@ export default async function backup(schedule: "daily" | "weekly" | "monthly") {
|
|||||||
if (code !== 0) {
|
if (code !== 0) {
|
||||||
console.log("pg_dump failed, upload aborted");
|
console.log("pg_dump failed, upload aborted");
|
||||||
return sendEmail({
|
return sendEmail({
|
||||||
body: `${schedule} backup failed: pg_dump: Bad exit code (${code})`,
|
text: `${schedule} backup failed: pg_dump: Bad exit code (${code})`,
|
||||||
|
html: `${schedule} backup failed: pg_dump: Bad exit code (${code})`,
|
||||||
subject: `${schedule} backup failed: pg_dump: Bad exit code (${code})`,
|
subject: `${schedule} backup failed: pg_dump: Bad exit code (${code})`,
|
||||||
recipients: ["error@shellphone.app"],
|
recipients: ["error@shellphone.app"],
|
||||||
});
|
});
|
||||||
@ -67,7 +69,8 @@ export default async function backup(schedule: "daily" | "weekly" | "monthly") {
|
|||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
logger.error(error);
|
logger.error(error);
|
||||||
return sendEmail({
|
return sendEmail({
|
||||||
body: `${schedule} backup failed: ${error}`,
|
text: `${schedule} backup failed: ${error}`,
|
||||||
|
html: `${schedule} backup failed: ${error}`,
|
||||||
subject: `${schedule} backup failed: ${error}`,
|
subject: `${schedule} backup failed: ${error}`,
|
||||||
recipients: ["error@shellphone.app"],
|
recipients: ["error@shellphone.app"],
|
||||||
});
|
});
|
||||||
|
@ -11,19 +11,24 @@ const credentials = new Credentials({
|
|||||||
const ses = new SES({ region: serverRuntimeConfig.awsSes.awsRegion, credentials });
|
const ses = new SES({ region: serverRuntimeConfig.awsSes.awsRegion, credentials });
|
||||||
|
|
||||||
type SendEmailParams = {
|
type SendEmailParams = {
|
||||||
body: string;
|
text: string;
|
||||||
|
html: string;
|
||||||
subject: string;
|
subject: string;
|
||||||
recipients: string[];
|
recipients: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function sendEmail({ body, subject, recipients }: SendEmailParams) {
|
export async function sendEmail({ text, html, subject, recipients }: SendEmailParams) {
|
||||||
const request: SendEmailRequest = {
|
const request: SendEmailRequest = {
|
||||||
Destination: { ToAddresses: recipients },
|
Destination: { ToAddresses: recipients },
|
||||||
Message: {
|
Message: {
|
||||||
Body: {
|
Body: {
|
||||||
Text: {
|
Text: {
|
||||||
Charset: "UTF-8",
|
Charset: "UTF-8",
|
||||||
Data: body,
|
Data: text,
|
||||||
|
},
|
||||||
|
Html: {
|
||||||
|
Charset: "UTF-8",
|
||||||
|
Data: html,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Subject: {
|
Subject: {
|
||||||
|
16
mailers/components/footer.html
Normal file
16
mailers/components/footer.html
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<table align="center" class="email-footer w-570 mx-auto text-center sm:w-full">
|
||||||
|
<tr>
|
||||||
|
<td align="center" class="content-cell p-45 text-base">
|
||||||
|
<p class="mt-6 mb-20 text-xs leading-24 text-center text-gray-postmark-light">
|
||||||
|
© {{ page.year }} {{ page.company.product }}. All rights reserved.
|
||||||
|
</p>
|
||||||
|
<p class="mt-6 mb-20 text-xs leading-24 text-center text-gray-postmark-light">
|
||||||
|
{{ page.company.name }} {{{ page.company.address }}}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
11
mailers/components/header.html
Normal file
11
mailers/components/header.html
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<tr>
|
||||||
|
<td align="center" class="email-masthead">
|
||||||
|
<a
|
||||||
|
href="https://www.shellphone.app"
|
||||||
|
class="email-masthead_name text-base font-bold no-underline text-gray-postmark-light"
|
||||||
|
style="text-shadow: 0 1px 0 #ffffff"
|
||||||
|
>
|
||||||
|
<img width="64px" src="https://www.shellphone.app/shellphone.png" alt="Shellphone logo" />
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
32
mailers/custom/postmark/buttons.css
Normal file
32
mailers/custom/postmark/buttons.css
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
.button {
|
||||||
|
@apply inline-block text-white no-underline;
|
||||||
|
background-color: #3869d4;
|
||||||
|
border-top: 10px solid #3869d4;
|
||||||
|
border-right: 18px solid #3869d4;
|
||||||
|
border-bottom: 10px solid #3869d4;
|
||||||
|
border-left: 18px solid #3869d4;
|
||||||
|
border-radius: 3px;
|
||||||
|
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button--green {
|
||||||
|
background-color: #22bc66;
|
||||||
|
border-top: 10px solid #22bc66;
|
||||||
|
border-right: 18px solid #22bc66;
|
||||||
|
border-bottom: 10px solid #22bc66;
|
||||||
|
border-left: 18px solid #22bc66;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button--red {
|
||||||
|
background-color: #ff6136;
|
||||||
|
border-top: 10px solid #ff6136;
|
||||||
|
border-right: 18px solid #ff6136;
|
||||||
|
border-bottom: 10px solid #ff6136;
|
||||||
|
border-left: 18px solid #ff6136;
|
||||||
|
}
|
||||||
|
|
||||||
|
@screen sm {
|
||||||
|
.button {
|
||||||
|
@apply w-full text-center !important;
|
||||||
|
}
|
||||||
|
}
|
65
mailers/custom/postmark/index.css
Normal file
65
mailers/custom/postmark/index.css
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
@import "buttons";
|
||||||
|
|
||||||
|
.purchase_heading {
|
||||||
|
border-bottom-width: 1px;
|
||||||
|
border-bottom-color: #eaeaec;
|
||||||
|
border-bottom-style: solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.purchase_heading p {
|
||||||
|
@apply text-xxs leading-24 m-0;
|
||||||
|
color: #85878e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.purchase_footer {
|
||||||
|
@apply pt-16 text-base align-middle;
|
||||||
|
border-top-width: 1px;
|
||||||
|
border-top-color: #eaeaec;
|
||||||
|
border-top-style: solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body-sub {
|
||||||
|
@apply mt-25 pt-25 border-t;
|
||||||
|
border-top-color: #eaeaec;
|
||||||
|
border-top-style: solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discount {
|
||||||
|
@apply w-full p-24 bg-gray-postmark-lightest;
|
||||||
|
border: 2px dashed #cbcccf;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-masthead {
|
||||||
|
@apply py-24 text-base text-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@screen dark {
|
||||||
|
body,
|
||||||
|
.email-body,
|
||||||
|
.email-body_inner,
|
||||||
|
.email-content,
|
||||||
|
.email-wrapper,
|
||||||
|
.email-masthead,
|
||||||
|
.email-footer {
|
||||||
|
@apply bg-gray-postmark-darker text-white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
p,
|
||||||
|
ul,
|
||||||
|
ol,
|
||||||
|
blockquote,
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3 {
|
||||||
|
@apply text-white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attributes_content,
|
||||||
|
.discount {
|
||||||
|
@apply bg-gray-postmark-darkest !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-masthead_name {
|
||||||
|
text-shadow: none !important;
|
||||||
|
}
|
||||||
|
}
|
10
mailers/custom/reset.css
Normal file
10
mailers/custom/reset.css
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
body {
|
||||||
|
@apply m-0 p-0 w-full;
|
||||||
|
word-break: break-word;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
border: 0;
|
||||||
|
@apply max-w-full leading-full align-middle;
|
||||||
|
}
|
3
mailers/custom/utilities.css
Normal file
3
mailers/custom/utilities.css
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
.mso-leading-exactly {
|
||||||
|
mso-line-height-rule: exactly;
|
||||||
|
}
|
@ -1,41 +1,39 @@
|
|||||||
/* TODO - You need to add a mailer integration in `integrations/` and import here.
|
|
||||||
*
|
|
||||||
* The integration file can be very simple. Instantiate the email client
|
|
||||||
* and then export it. That way you can import here and anywhere else
|
|
||||||
* and use it straight away.
|
|
||||||
*/
|
|
||||||
import previewEmail from "preview-email";
|
import previewEmail from "preview-email";
|
||||||
|
|
||||||
|
import { sendEmail } from "integrations/aws-ses";
|
||||||
|
import { render, plaintext } from "./renderer";
|
||||||
|
|
||||||
type ResetPasswordMailer = {
|
type ResetPasswordMailer = {
|
||||||
to: string;
|
to: string;
|
||||||
token: string;
|
token: string;
|
||||||
|
userName: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function forgotPasswordMailer({ to, token }: ResetPasswordMailer) {
|
export async function forgotPasswordMailer({ to, token, userName }: ResetPasswordMailer) {
|
||||||
// In production, set APP_ORIGIN to your production server origin
|
// In production, set APP_ORIGIN to your production server origin
|
||||||
const origin = process.env.APP_ORIGIN || process.env.BLITZ_DEV_SERVER_ORIGIN;
|
const origin = process.env.APP_ORIGIN || process.env.BLITZ_DEV_SERVER_ORIGIN;
|
||||||
const resetUrl = `${origin}/reset-password?token=${token}`;
|
const resetUrl = `${origin}/reset-password?token=${token}`;
|
||||||
|
const [html, text] = await Promise.all([
|
||||||
|
render("forgot-password", { action_url: resetUrl, name: userName }),
|
||||||
|
plaintext("forgot-password", { action_url: resetUrl, name: userName }),
|
||||||
|
]);
|
||||||
const msg = {
|
const msg = {
|
||||||
from: "TODO@example.com",
|
from: "mokhtar@shellphone.app",
|
||||||
to,
|
to,
|
||||||
subject: "Your Password Reset Instructions",
|
subject: "Reset your password",
|
||||||
html: `
|
html,
|
||||||
<h1>Reset Your Password</h1>
|
text,
|
||||||
<h3>NOTE: You must set up a production email integration in mailers/forgotPasswordMailer.ts</h3>
|
|
||||||
|
|
||||||
<a href="${resetUrl}">
|
|
||||||
Click here to set a new password
|
|
||||||
</a>
|
|
||||||
`,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
async send() {
|
async send() {
|
||||||
if (process.env.NODE_ENV === "production") {
|
if (process.env.NODE_ENV === "production") {
|
||||||
// TODO - send the production email, like this:
|
await sendEmail({
|
||||||
// await postmark.sendEmail(msg)
|
recipients: [msg.to],
|
||||||
throw new Error("No production email implementation in mailers/forgotPasswordMailer");
|
subject: msg.subject,
|
||||||
|
html: msg.html,
|
||||||
|
text: msg.text,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
// Preview email in the browser
|
// Preview email in the browser
|
||||||
await previewEmail(msg);
|
await previewEmail(msg);
|
||||||
|
75
mailers/layouts/main.html
Normal file
75
mailers/layouts/main.html
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
<!DOCTYPE {{{ page.doctype || 'html' }}}>
|
||||||
|
<html lang="{{ page.language || 'en' }}" xmlns:v="urn:schemas-microsoft-com:vml">
|
||||||
|
<head>
|
||||||
|
<meta charset="{{ page.charset || 'utf-8' }}" />
|
||||||
|
<meta name="x-apple-disable-message-reformatting" />
|
||||||
|
<meta http-equiv="x-ua-compatible" content="ie=edge" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no" />
|
||||||
|
<!--[if mso]>
|
||||||
|
<noscript>
|
||||||
|
<xml>
|
||||||
|
<o:OfficeDocumentSettings xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||||
|
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||||
|
</o:OfficeDocumentSettings>
|
||||||
|
</xml>
|
||||||
|
</noscript>
|
||||||
|
<style>
|
||||||
|
td,
|
||||||
|
th,
|
||||||
|
div,
|
||||||
|
p,
|
||||||
|
a,
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
font-family: "Segoe UI", sans-serif;
|
||||||
|
mso-line-height-rule: exactly;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<![endif]-->
|
||||||
|
<if condition="page.title">
|
||||||
|
<title>{{{ page.title }}}</title>
|
||||||
|
</if>
|
||||||
|
<if condition="page.googleFonts">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?{{{ page.googleFonts }}}&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
media="screen"
|
||||||
|
/>
|
||||||
|
</if>
|
||||||
|
<if condition="page.css">
|
||||||
|
<style>
|
||||||
|
{{{ page.css }}}
|
||||||
|
</style>
|
||||||
|
</if>
|
||||||
|
<block name="head"></block>
|
||||||
|
</head>
|
||||||
|
<body class="{{ page.bodyClass }}">
|
||||||
|
<if condition="page.preheader">
|
||||||
|
<div class="hidden">
|
||||||
|
{{{ page.preheader }}}͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
||||||
|
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
||||||
|
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
||||||
|
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
||||||
|
‌  ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
||||||
|
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
||||||
|
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
||||||
|
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ‌
|
||||||
|
 ͏ ͏ ͏ ͏ ͏
|
||||||
|
</div>
|
||||||
|
</if>
|
||||||
|
<div
|
||||||
|
role="article"
|
||||||
|
aria-roledescription="email"
|
||||||
|
aria-label="{{{ page.title || '' }}}"
|
||||||
|
lang="{{ page.language || 'en' }}"
|
||||||
|
>
|
||||||
|
<block name="template"></block>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
225
mailers/renderer.ts
Normal file
225
mailers/renderer.ts
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
// @ts-ignore
|
||||||
|
import Maizzle from "@maizzle/framework";
|
||||||
|
|
||||||
|
export async function render(templateName: string, locals: Record<string, string> = {}) {
|
||||||
|
const { template, options } = getMaizzleParams(templateName, locals);
|
||||||
|
const { html } = await Maizzle.render(template, options);
|
||||||
|
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function plaintext(templateName: string, locals: Record<string, string> = {}) {
|
||||||
|
const { template, options } = getMaizzleParams(templateName, locals);
|
||||||
|
const { plaintext } = await Maizzle.plaintext(template, options);
|
||||||
|
|
||||||
|
return plaintext;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMaizzleParams(templateName: string, locals: Record<string, string>) {
|
||||||
|
const template = fs
|
||||||
|
.readFileSync(path.resolve(process.cwd(), "./mailers/templates", `${templateName}.html`))
|
||||||
|
.toString();
|
||||||
|
const tailwindCss = fs.readFileSync(path.resolve(process.cwd(), "./mailers/tailwind.css")).toString();
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
tailwind: {
|
||||||
|
css: tailwindCss,
|
||||||
|
config: {
|
||||||
|
mode: "jit",
|
||||||
|
theme: {
|
||||||
|
screens: {
|
||||||
|
sm: { max: "600px" },
|
||||||
|
dark: { raw: "(prefers-color-scheme: dark)" },
|
||||||
|
},
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
gray: {
|
||||||
|
"postmark-lightest": "#F4F4F7",
|
||||||
|
"postmark-lighter": "#F2F4F6",
|
||||||
|
"postmark-light": "#A8AAAF",
|
||||||
|
"postmark-dark": "#51545E",
|
||||||
|
"postmark-darker": "#333333",
|
||||||
|
"postmark-darkest": "#222222",
|
||||||
|
"postmark-meta": "#85878E",
|
||||||
|
},
|
||||||
|
blue: {
|
||||||
|
postmark: "#3869D4",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
spacing: {
|
||||||
|
screen: "100vw",
|
||||||
|
full: "100%",
|
||||||
|
px: "1px",
|
||||||
|
0: "0",
|
||||||
|
2: "2px",
|
||||||
|
3: "3px",
|
||||||
|
4: "4px",
|
||||||
|
5: "5px",
|
||||||
|
6: "6px",
|
||||||
|
7: "7px",
|
||||||
|
8: "8px",
|
||||||
|
9: "9px",
|
||||||
|
10: "10px",
|
||||||
|
11: "11px",
|
||||||
|
12: "12px",
|
||||||
|
14: "14px",
|
||||||
|
16: "16px",
|
||||||
|
20: "20px",
|
||||||
|
21: "21px",
|
||||||
|
24: "24px",
|
||||||
|
25: "25px",
|
||||||
|
28: "28px",
|
||||||
|
30: "30px",
|
||||||
|
32: "32px",
|
||||||
|
35: "35px",
|
||||||
|
36: "36px",
|
||||||
|
40: "40px",
|
||||||
|
44: "44px",
|
||||||
|
45: "45px",
|
||||||
|
48: "48px",
|
||||||
|
52: "52px",
|
||||||
|
56: "56px",
|
||||||
|
60: "60px",
|
||||||
|
64: "64px",
|
||||||
|
72: "72px",
|
||||||
|
80: "80px",
|
||||||
|
96: "96px",
|
||||||
|
570: "570px",
|
||||||
|
600: "600px",
|
||||||
|
"1/2": "50%",
|
||||||
|
"1/3": "33.333333%",
|
||||||
|
"2/3": "66.666667%",
|
||||||
|
"1/4": "25%",
|
||||||
|
"2/4": "50%",
|
||||||
|
"3/4": "75%",
|
||||||
|
"1/5": "20%",
|
||||||
|
"2/5": "40%",
|
||||||
|
"3/5": "60%",
|
||||||
|
"4/5": "80%",
|
||||||
|
"1/6": "16.666667%",
|
||||||
|
"2/6": "33.333333%",
|
||||||
|
"3/6": "50%",
|
||||||
|
"4/6": "66.666667%",
|
||||||
|
"5/6": "83.333333%",
|
||||||
|
"1/12": "8.333333%",
|
||||||
|
"2/12": "16.666667%",
|
||||||
|
"3/12": "25%",
|
||||||
|
"4/12": "33.333333%",
|
||||||
|
"5/12": "41.666667%",
|
||||||
|
"6/12": "50%",
|
||||||
|
"7/12": "58.333333%",
|
||||||
|
"8/12": "66.666667%",
|
||||||
|
"9/12": "75%",
|
||||||
|
"10/12": "83.333333%",
|
||||||
|
"11/12": "91.666667%",
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
none: "0px",
|
||||||
|
sm: "2px",
|
||||||
|
DEFAULT: "4px",
|
||||||
|
md: "6px",
|
||||||
|
lg: "8px",
|
||||||
|
xl: "12px",
|
||||||
|
"2xl": "16px",
|
||||||
|
"3xl": "24px",
|
||||||
|
full: "9999px",
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['"Nunito Sans"', "-apple-system", '"Segoe UI"', "sans-serif"],
|
||||||
|
serif: ["Constantia", "Georgia", "serif"],
|
||||||
|
mono: ["Menlo", "Consolas", "monospace"],
|
||||||
|
},
|
||||||
|
fontSize: {
|
||||||
|
0: "0",
|
||||||
|
xxs: "12px",
|
||||||
|
xs: "13px",
|
||||||
|
sm: "14px",
|
||||||
|
base: "16px",
|
||||||
|
lg: "18px",
|
||||||
|
xl: "20px",
|
||||||
|
"2xl": "24px",
|
||||||
|
"3xl": "30px",
|
||||||
|
"4xl": "36px",
|
||||||
|
"5xl": "48px",
|
||||||
|
"6xl": "60px",
|
||||||
|
"7xl": "72px",
|
||||||
|
"8xl": "96px",
|
||||||
|
"9xl": "128px",
|
||||||
|
},
|
||||||
|
inset: (theme: TailwindThemeHelper) => ({
|
||||||
|
...theme("spacing"),
|
||||||
|
}),
|
||||||
|
letterSpacing: (theme: TailwindThemeHelper) => ({
|
||||||
|
...theme("spacing"),
|
||||||
|
}),
|
||||||
|
lineHeight: (theme: TailwindThemeHelper) => ({
|
||||||
|
...theme("spacing"),
|
||||||
|
}),
|
||||||
|
maxHeight: (theme: TailwindThemeHelper) => ({
|
||||||
|
...theme("spacing"),
|
||||||
|
}),
|
||||||
|
maxWidth: (theme: TailwindThemeHelper) => ({
|
||||||
|
...theme("spacing"),
|
||||||
|
xs: "160px",
|
||||||
|
sm: "192px",
|
||||||
|
md: "224px",
|
||||||
|
lg: "256px",
|
||||||
|
xl: "288px",
|
||||||
|
"2xl": "336px",
|
||||||
|
"3xl": "384px",
|
||||||
|
"4xl": "448px",
|
||||||
|
"5xl": "512px",
|
||||||
|
"6xl": "576px",
|
||||||
|
"7xl": "640px",
|
||||||
|
}),
|
||||||
|
minHeight: (theme: TailwindThemeHelper) => ({
|
||||||
|
...theme("spacing"),
|
||||||
|
}),
|
||||||
|
minWidth: (theme: TailwindThemeHelper) => ({
|
||||||
|
...theme("spacing"),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
corePlugins: {
|
||||||
|
animation: false,
|
||||||
|
backgroundOpacity: false,
|
||||||
|
borderOpacity: false,
|
||||||
|
divideOpacity: false,
|
||||||
|
placeholderOpacity: false,
|
||||||
|
textOpacity: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
maizzle: {
|
||||||
|
build: {
|
||||||
|
posthtml: {
|
||||||
|
expressions: {
|
||||||
|
locals,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
company: {
|
||||||
|
name: "Capsule Corp. Dev Pte. Ltd.",
|
||||||
|
address: `
|
||||||
|
<br>39 Robinson Rd, #11-01
|
||||||
|
<br>Singapore 068911
|
||||||
|
`,
|
||||||
|
product: "Shellphone",
|
||||||
|
},
|
||||||
|
googleFonts: "family=Nunito+Sans:wght@400;700",
|
||||||
|
year: () => new Date().getFullYear(),
|
||||||
|
inlineCSS: true,
|
||||||
|
prettify: true,
|
||||||
|
removeUnusedCSS: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
template,
|
||||||
|
options,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type TailwindThemeHelper = (str: string) => {};
|
18
mailers/tailwind.css
Normal file
18
mailers/tailwind.css
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
/* Your custom CSS resets for email */
|
||||||
|
@import "mailers/custom/reset";
|
||||||
|
|
||||||
|
/* Tailwind components that are generated by plugins */
|
||||||
|
@import "tailwindcss/components";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @import here any custom components - classes that you'd want loaded
|
||||||
|
* before the Tailwind utilities, so that the utilities could still
|
||||||
|
* override them.
|
||||||
|
*/
|
||||||
|
@import "mailers/custom/postmark";
|
||||||
|
|
||||||
|
/* Tailwind utility classes */
|
||||||
|
@import "tailwindcss/utilities";
|
||||||
|
|
||||||
|
/* Your custom utility classes */
|
||||||
|
@import "mailers/custom/utilities";
|
92
mailers/templates/forgot-password.html
Normal file
92
mailers/templates/forgot-password.html
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
---
|
||||||
|
bodyClass: bg-gray-postmark-lighter
|
||||||
|
---
|
||||||
|
|
||||||
|
<extends src="mailers/layouts/main.html">
|
||||||
|
<block name="template">
|
||||||
|
<table class="email-wrapper w-full bg-gray-postmark-lighter font-sans">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table class="email-content w-full">
|
||||||
|
<component src="mailers/components/header.html"></component>
|
||||||
|
<tr>
|
||||||
|
<td class="email-body w-full">
|
||||||
|
<table align="center" class="email-body_inner w-570 bg-white mx-auto sm:w-full">
|
||||||
|
<tr>
|
||||||
|
<td class="px-45 py-24">
|
||||||
|
<div class="text-base">
|
||||||
|
<h1 class="mt-0 text-2xl font-bold text-left text-gray-postmark-darker">
|
||||||
|
Hi {{name}},
|
||||||
|
</h1>
|
||||||
|
<p class="mt-6 mb-20 text-base leading-24 text-gray-postmark-dark">
|
||||||
|
You recently requested to reset your password for your
|
||||||
|
{{page.company.product}} account. Use the button below to reset it.
|
||||||
|
<strong
|
||||||
|
>This password reset is only valid for the next 24
|
||||||
|
hours.</strong
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
<table align="center" class="w-full text-center my-30 mx-auto">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table class="w-full">
|
||||||
|
<tr>
|
||||||
|
<td align="center" class="text-base">
|
||||||
|
<a
|
||||||
|
href="{{action_url}}"
|
||||||
|
class="button button--green"
|
||||||
|
target="_blank"
|
||||||
|
>Reset your password</a
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<p class="mt-6 mb-20 text-base leading-24 text-gray-postmark-dark">
|
||||||
|
If you did not request a password reset, you can safely ignore this
|
||||||
|
email.
|
||||||
|
</p>
|
||||||
|
<table class="body-sub">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<p
|
||||||
|
class="
|
||||||
|
mt-6
|
||||||
|
mb-20
|
||||||
|
text-xs
|
||||||
|
leading-24
|
||||||
|
text-gray-postmark-dark
|
||||||
|
"
|
||||||
|
>
|
||||||
|
If you're having trouble with the button above, copy and
|
||||||
|
paste the URL below into your web browser.
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
class="
|
||||||
|
mt-6
|
||||||
|
mb-20
|
||||||
|
text-xs
|
||||||
|
leading-24
|
||||||
|
text-gray-postmark-dark
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{action_url}}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<!--<component src="mailers/components/footer.html"></component>-->
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</block>
|
||||||
|
</extends>
|
5919
package-lock.json
generated
5919
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -36,6 +36,7 @@
|
|||||||
"@devoxa/paddle-sdk": "0.2.1",
|
"@devoxa/paddle-sdk": "0.2.1",
|
||||||
"@headlessui/react": "1.4.1",
|
"@headlessui/react": "1.4.1",
|
||||||
"@hookform/resolvers": "2.8.2",
|
"@hookform/resolvers": "2.8.2",
|
||||||
|
"@maizzle/framework": "3.7.2",
|
||||||
"@panelbear/panelbear-js": "1.3.2",
|
"@panelbear/panelbear-js": "1.3.2",
|
||||||
"@prisma/client": "3.2.1",
|
"@prisma/client": "3.2.1",
|
||||||
"@react-aria/interactions": "3.6.0",
|
"@react-aria/interactions": "3.6.0",
|
||||||
|
Loading…
Reference in New Issue
Block a user