From 2b4d96690839710edf68e05665af63a87e7802aa Mon Sep 17 00:00:00 2001 From: joeldevelops Date: Wed, 18 Jun 2025 16:56:50 +0200 Subject: [PATCH 1/5] Update emails to profesional templates --- packages/core/deno.json | 8 +- packages/core/src/email/index.ts | 149 +++++++--------- .../src/email/templates/DowngradeEmail.tsx | 84 +++++++++ .../src/email/templates/InvitationEmail.tsx | 64 +++++++ .../core/src/email/templates/OtpEmail.tsx | 59 +++++++ .../core/src/email/templates/UpgradeEmail.tsx | 78 +++++++++ .../core/src/email/templates/WelcomeEmail.tsx | 119 +++++++++++++ packages/core/src/email/templates/base.tsx | 161 ++++++++++++++++++ packages/core/src/email/templates/styles.tsx | 6 + 9 files changed, 640 insertions(+), 88 deletions(-) create mode 100644 packages/core/src/email/templates/DowngradeEmail.tsx create mode 100644 packages/core/src/email/templates/InvitationEmail.tsx create mode 100644 packages/core/src/email/templates/OtpEmail.tsx create mode 100644 packages/core/src/email/templates/UpgradeEmail.tsx create mode 100644 packages/core/src/email/templates/WelcomeEmail.tsx create mode 100644 packages/core/src/email/templates/base.tsx create mode 100644 packages/core/src/email/templates/styles.tsx diff --git a/packages/core/deno.json b/packages/core/deno.json index ee9db95..ae07e1d 100644 --- a/packages/core/deno.json +++ b/packages/core/deno.json @@ -4,10 +4,13 @@ "tasks": { "dev": "deno run --env-file=../../.env -A --watch src/index.ts", "migrate": "deno run -A src/db/scripts/migrate.ts", - "build": "deno compile --allow-all --include src/db/migrations --output ./dist/core ./src/index.ts" + "build": "deno compile --allow-all --include src/db/migrations --output ./dist/core ./src/index.ts", + "email": "email dev -d src/email/templates" }, "imports": { "@oak/oak": "jsr:@oak/oak@^17.1.4", + "@react-email/components": "npm:@react-email/components@^0.1.0", + "@react-email/render": "npm:@react-email/render@^1.1.2", "@sentry/deno": "npm:@sentry/deno@^9.28.1", "@std/assert": "jsr:@std/assert@1", "@std/expect": "jsr:@std/expect@^1.0.16", @@ -18,6 +21,9 @@ "pg": "npm:pg@^8.16.0", "pg-pool": "npm:pg-pool@^3.10.0", "djwt": "https://deno.land/x/djwt@v3.0.2/mod.ts", + "react": "npm:react@^19.1.0", + "react-dom": "npm:react-dom@^19.1.0", + "react-email": "npm:react-email@^4.0.16", "resend": "npm:resend@^4.5.2", "stripe": "npm:stripe@^18.2.1", "zod": "npm:zod@^3.24.4" diff --git a/packages/core/src/email/index.ts b/packages/core/src/email/index.ts index b32753b..a4888b3 100644 --- a/packages/core/src/email/index.ts +++ b/packages/core/src/email/index.ts @@ -1,17 +1,25 @@ +import type { ReactNode } from "react"; import type { StripeBillingCycle, StripeProduct, } from "../db/models/workspace.ts"; import { Resend } from "resend"; +import { render } from "@react-email/render"; import settings from "../settings.ts"; +import OtpEmail from "./templates/OtpEmail.tsx"; +import WelcomeEmail from "./templates/WelcomeEmail.tsx"; +import InvitationEmail from "./templates/InvitationEmail.tsx"; +import UpgradeEmail from "./templates/UpgradeEmail.tsx"; +import DowngradeEmail from "./templates/DowngradeEmail.tsx"; + export type SendEmail = (options: { to: string[]; cc?: string[]; bcc?: string[]; subject: string; text: string; - html?: string; + react?: ReactNode; }) => Promise; const resend = new Resend(settings.EMAIL.RESEND_API_KEY); @@ -22,7 +30,7 @@ const sendEmailWithResend: SendEmail = async (options: { bcc?: string[]; subject: string; text: string; - html?: string; + react?: ReactNode; }) => { const response = await resend.emails.send({ from: `${settings.EMAIL.FROM_EMAIL} <${settings.EMAIL.FROM_EMAIL}>`, @@ -31,7 +39,7 @@ const sendEmailWithResend: SendEmail = async (options: { bcc: options.bcc, subject: options.subject, text: options.text, - html: options.html, + react: options.react, }); if (response.error) { @@ -46,7 +54,7 @@ const sendEmailWithConsole: SendEmail = async (options: { bcc?: string[]; subject: string; text: string; - html?: string; + react?: ReactNode; }) => { console.info(`Sending email to ${options.to}: ${options.subject}`); console.info(options.text); @@ -60,52 +68,35 @@ function getSendEmail() { return sendEmailWithResend; } -export function sendOtpEmail(email: string, otp: string) { +export async function sendOtpEmail(email: string, otp: string) { + const emailPlainText = await render(OtpEmail({ otp }), { + plainText: true, + }); + const sendEmail = getSendEmail(); sendEmail({ to: [email], subject: "Your One-Time Password (OTP)", - text: `Hi there, - -You've requested a one-time password to access your account. Please use the code below to complete your authentication: - -**${otp}** - -This code is valid for a limited time and can only be used once. For your security, please do not share this code with anyone. - -If you didn't request this code, please ignore this email or contact our support team if you have concerns about your account security. - -Best regards, -The Team`, + text: emailPlainText, + react: OtpEmail({ otp }), }); } -export function sendWelcomeEmail(email: string) { +export async function sendWelcomeEmail(email: string) { + const emailPlainText = await render(WelcomeEmail(), { + plainText: true, + }); + const sendEmail = getSendEmail(); sendEmail({ to: [email], subject: "Welcome to our platform!", - text: `Hi there, - -Welcome to our platform! We're thrilled to have you join our community. - -Your account has been successfully created and you're all set to get started. Here's what you can do next: - -• Explore the dashboard and familiarize yourself with the interface -• Set up your profile and preferences -• Create your first workspace or join an existing one -• Invite team members to collaborate with you - -If you have any questions or need assistance getting started, our support team is here to help. Don't hesitate to reach out! - -We're excited to see what you'll accomplish with our platform. - -Best regards, -The Team`, + text: emailPlainText, + react: WelcomeEmail(), }); } -export function sendInvitationEmail( +export async function sendInvitationEmail( email: string, workspaceName: string, invitationUuid: string, @@ -118,27 +109,28 @@ export function sendInvitationEmail( const invitationLink = `${returnUrl}?${searchParams.toString()}`; + const emailPlainText = await render( + InvitationEmail({ + workspaceName, + invitationLink, + }), + { + plainText: true, + }, + ); + sendEmail({ to: [email], subject: `Invitation to join ${workspaceName}`, - text: `Hi there, - -You've been invited to join "${workspaceName}"! - -We're excited to have you as part of our team. To get started, simply click the link below to accept your invitation and set up your account: - -${invitationLink} - -This invitation link is unique to you and will expire after a certain period for security reasons. If you have any questions or need assistance, please don't hesitate to reach out to your team administrator. - -We look forward to collaborating with you! - -Best regards, -The Team`, + text: emailPlainText, + react: InvitationEmail({ + workspaceName, + invitationLink, + }), }); } -export function sendSubscriptionUpgradedEmail( +export async function sendSubscriptionUpgradedEmail( payload: { email: string; workspaceName: string; @@ -152,31 +144,23 @@ export function sendSubscriptionUpgradedEmail( }; }, ) { + const emailPlainText = await render( + UpgradeEmail(payload), + { + plainText: true, + }, + ); + const sendEmail = getSendEmail(); sendEmail({ to: [payload.email], subject: "Subscription upgraded", - text: `Hi there, - -Your subscription for workspace "${payload.workspaceName}" has been successfully upgraded! - -Previous subscription: ${payload.oldSubscription.product} (${ - payload.oldSubscription.billingCycle ?? "Custom billing cycle" - }) -New subscription: ${payload.newSubscription.product} (${ - payload.newSubscription.billingCycle ?? "Custom billing cycle" - }) - -This change is effective immediately, and you now have access to all the features included in your new subscription. - -If you have any questions about your upgraded subscription, please don't hesitate to contact our support team. - -Best regards, -The Team`, + text: emailPlainText, + react: UpgradeEmail(payload), }); } -export function sendSubscriptionDowngradedEmail( +export async function sendSubscriptionDowngradedEmail( payload: { email: string; workspaceName: string; @@ -191,27 +175,18 @@ export function sendSubscriptionDowngradedEmail( newSubscriptionDate: string; }, ) { + const emailPlainText = await render( + DowngradeEmail(payload), + { + plainText: true, + }, + ); + const sendEmail = getSendEmail(); sendEmail({ to: [payload.email], subject: "Subscription downgraded", - text: `Hi there, - -Your subscription for workspace "${payload.workspaceName}" has been scheduled for downgrade. - -Current subscription: ${payload.oldSubscription.product} (${ - payload.oldSubscription.billingCycle ?? "Custom billing cycle" - }) -New subscription: ${payload.newSubscription.product} (${ - payload.newSubscription.billingCycle ?? "Custom billing cycle" - }) -Effective date: ${payload.newSubscriptionDate} - -Your current subscription will remain active until the end of your billing period, and the new subscription will take effect on ${payload.newSubscriptionDate}. - -If you have any questions, please don't hesitate to contact our support team. - -Best regards, -The Team`, + text: emailPlainText, + react: DowngradeEmail(payload), }); } diff --git a/packages/core/src/email/templates/DowngradeEmail.tsx b/packages/core/src/email/templates/DowngradeEmail.tsx new file mode 100644 index 0000000..0105a4b --- /dev/null +++ b/packages/core/src/email/templates/DowngradeEmail.tsx @@ -0,0 +1,84 @@ +import { Heading, Section, Text } from "@react-email/components"; +import { baseTemplate } from "./base.tsx"; +import { headingStyle } from "./styles.tsx"; +import type { + StripeBillingCycle, + StripeProduct, +} from "../../db/models/workspace.ts"; + +type DowngradeEmailProps = { + email: string; + workspaceName: string; + oldSubscription: { + product: StripeProduct; + billingCycle: StripeBillingCycle | null; + }; + newSubscription: { + product: StripeProduct; + billingCycle: StripeBillingCycle | null; + }; + newSubscriptionDate: string; +}; + +const DowngradeEmail = (props: DowngradeEmailProps) => { + const previewText = "Confirmation of your subscription downgrade | NanoAPI"; + return baseTemplate( + previewText, + <> +
+ + We are sorry to see you go + + + Your subscription for workspace {props.workspaceName}{" "} + has been scheduled for downgrade. + +
+
+ + Previous subscription: {props.oldSubscription.product}{" "} + ({props.oldSubscription.billingCycle ?? "Custom billing cycle"}) + + + New subscription: {props.newSubscription.product}{" "} + ({props.newSubscription.billingCycle ?? "Custom billing cycle"}) + + + Effective date: {props.newSubscriptionDate} + +
+
+ + Your current subscription will remain active until the end of your + billing period, and the new subscription will take effect on{" "} + {props.newSubscriptionDate}. + + + If you have any questions, please don't hesitate to contact our + support team. + + Best regards - Team Nano +
+ , + ); +}; + +DowngradeEmail.PreviewProps = { + email: "test@nanoapi.io", + workspaceName: "My Workspace", + oldSubscription: { + product: "Pro Plan", + billingCycle: "Yearly", + }, + newSubscription: { + product: "Basic Plan", + billingCycle: "Monthly", + }, + newSubscriptionDate: "2023-10-01", +}; + +export default DowngradeEmail; diff --git a/packages/core/src/email/templates/InvitationEmail.tsx b/packages/core/src/email/templates/InvitationEmail.tsx new file mode 100644 index 0000000..52ce4c2 --- /dev/null +++ b/packages/core/src/email/templates/InvitationEmail.tsx @@ -0,0 +1,64 @@ +import { Button, Heading, Section, Text } from "@react-email/components"; +import { baseTemplate } from "./base.tsx"; +import { headingStyle } from "./styles.tsx"; + +type InvitationEmailProps = { + workspaceName: string; + invitationLink: string; +}; + +const InvitationEmail = ({ + workspaceName, + invitationLink, +}: InvitationEmailProps) => { + const previewText = `Invitation to join "${workspaceName}" on NanoAPI`; + return baseTemplate( + previewText, + <> +
+ + You have been invited to join "{workspaceName}" + + + We're excited to have you as part of our team. To get started, simply + click the link below to accept your invitation and set up your + account: + +
+
+ +
+
+ + This invitation link is unique to you and will expire after a period + for security reasons. If you have any questions or need assistance, + please don't hesitate to reach out to your team administrator. + + We look forward to collaborating with you! + Best regards - Team Nano +
+ , + ); +}; + +InvitationEmail.PreviewProps = { + workspaceName: "My Workspace", + invitationLink: "https://nanoapi.io/invitation?token=exampleToken", +}; + +export default InvitationEmail; diff --git a/packages/core/src/email/templates/OtpEmail.tsx b/packages/core/src/email/templates/OtpEmail.tsx new file mode 100644 index 0000000..c6ff69e --- /dev/null +++ b/packages/core/src/email/templates/OtpEmail.tsx @@ -0,0 +1,59 @@ +import { Heading, Section, Text } from "@react-email/components"; +import { baseTemplate } from "./base.tsx"; +import { headingStyle } from "./styles.tsx"; + +const codeStyle = { + display: "inline-block", + padding: "16px 4.5%", + width: "90.5%", + backgroundColor: "#f4f4f4", + borderRadius: "5px", + border: "1px solid #eee", + color: "#222", + fontFamily: "roboto mono, monospace", + fontSize: "28px", + textAlign: "center", + letterSpacing: "5px", +}; + +type OtpEmailProps = { + otp: string; +}; + +const OtpEmail = ({ otp }: OtpEmailProps) => { + const previewText = `Your one-time password (OTP) code is ${otp}`; + return baseTemplate( + previewText, +
+ + One-Time Password (OTP) Code + + Hi there, + + + You've requested a one-time password to access your account. Please use + the code below to complete your authentication: + + + {otp} + + + This code is valid for a limited time and can only be used once. For + your security, please do not share this code with anyone. + + + + If you didn't request this code, please ignore this email or contact our + support team if you have concerns about your account security. + + + Best regards - Team Nano +
, + ); +}; + +OtpEmail.PreviewProps = { + otp: "123456", +}; + +export default OtpEmail; diff --git a/packages/core/src/email/templates/UpgradeEmail.tsx b/packages/core/src/email/templates/UpgradeEmail.tsx new file mode 100644 index 0000000..7cb7c82 --- /dev/null +++ b/packages/core/src/email/templates/UpgradeEmail.tsx @@ -0,0 +1,78 @@ +import { Heading, Section, Text } from "@react-email/components"; +import { baseTemplate } from "./base.tsx"; +import { headingStyle } from "./styles.tsx"; +import type { + StripeBillingCycle, + StripeProduct, +} from "../../db/models/workspace.ts"; + +type UpgradeEmailProps = { + email: string; + workspaceName: string; + oldSubscription: { + product: StripeProduct; + billingCycle: StripeBillingCycle | null; + }; + newSubscription: { + product: StripeProduct; + billingCycle: StripeBillingCycle | null; + }; +}; + +const UpgradeEmail = (props: UpgradeEmailProps) => { + const previewText = "Confirmation of your subscription upgrade | NanoAPI"; + return baseTemplate( + previewText, + <> +
+ + Congratulations on your upgrade! 🎉 + + + Your subscription for workspace {props.workspaceName}{" "} + has been successfully upgraded! + +
+
+ + ❌ Previous subscription: {props.oldSubscription.product}{" "} + ({props.oldSubscription.billingCycle ?? "Custom billing cycle"}) + + + ✅ New subscription: {props.newSubscription.product}{" "} + ({props.newSubscription.billingCycle ?? "Custom billing cycle"}) + +
+
+ + This change is effective immediately, and you now have access to all + the features included in your new subscription. + + + If you have any questions about your upgraded subscription, please + don't hesitate to contact our support team. + + Best regards - Team Nano +
+ , + ); +}; + +UpgradeEmail.PreviewProps = { + email: "test@nanoapi.io", + workspaceName: "My Workspace", + oldSubscription: { + product: "Basic Plan", + billingCycle: "Monthly", + }, + newSubscription: { + product: "Pro Plan", + billingCycle: "Yearly", + }, +}; + +export default UpgradeEmail; diff --git a/packages/core/src/email/templates/WelcomeEmail.tsx b/packages/core/src/email/templates/WelcomeEmail.tsx new file mode 100644 index 0000000..666a0f7 --- /dev/null +++ b/packages/core/src/email/templates/WelcomeEmail.tsx @@ -0,0 +1,119 @@ +import { + Button, + Column, + Heading, + Link, + Row, + Section, + Text, +} from "@react-email/components"; +import { baseTemplate } from "./base.tsx"; +import { headingStyle } from "./styles.tsx"; + +const WelcomeEmail = () => { + const previewText = "Welcome to NanoAPI - Your account has been created!"; + return baseTemplate( + previewText, + <> +
+ + Welcome to our platform! We're thrilled to have you join our + community. + + + Your account has been successfully created and you're all set to get + started. Here's what you can do next: + +
    + {[ + "Explore the dashboard and familiarize yourself with the interface", + "Set up your profile and preferences", + "Create your first workspace or join an existing one", + "Invite team members to collaborate with you", + ].map((item, index) => ( +
  • +

    {item}

    +
  • + ))} +
+ + We also recommend checking out our{" "} + + documentation + {" "} + as well as{" "} + + downloading the CLI to get started + . + + + If you have any questions or need assistance getting started, our + support team is here to help. Don't hesitate to reach out! + + + We're excited to see what you'll accomplish with our platform. + + + + + + + + + + + + + + Best regards - Team Nano +
+ , + ); +}; + +export default WelcomeEmail; diff --git a/packages/core/src/email/templates/base.tsx b/packages/core/src/email/templates/base.tsx new file mode 100644 index 0000000..6c44bc3 --- /dev/null +++ b/packages/core/src/email/templates/base.tsx @@ -0,0 +1,161 @@ +import type { ReactNode } from "react"; +import { + Body, + Column, + Container, + Head, + Heading, + Hr, + Html, + Img, + Link, + Preview, + Row, + Section, + Text, +} from "@react-email/components"; + +const bodyStyle = { + fontFamily: "Arial, sans-serif", +}; + +const sectionStyle = { + textAlign: "center", +}; + +const tableStyle = { + width: "100%", +}; + +const containerStyle = { + backgroundColor: "#ffffff", + margin: "0 auto", + padding: "20px 0 48px", + marginBottom: "64px", +}; + +export const baseTemplate = (previewText: string, children: ReactNode) => { + return ( + + + + {previewText} + +
+ NanoAPI logo + + NanoAPI + +
+
+ {children} +
+
+ + + + + + + + + + +
+ React Email logo +
+ + NanoAPI + + + Software Architecture for the AI Age + +
+ + + + Discord Server + + + + + YouTube + + + + + Linkedin + + + +
+
+
+ + + ); +}; diff --git a/packages/core/src/email/templates/styles.tsx b/packages/core/src/email/templates/styles.tsx new file mode 100644 index 0000000..6115ed3 --- /dev/null +++ b/packages/core/src/email/templates/styles.tsx @@ -0,0 +1,6 @@ +export const headingStyle = { + textAlign: "center", + fontFamily: "Arial, sans-serif", + marginTop: "30px", + marginBottom: "40px", +}; From 8075fa114023439f1254b3a308d68451fdec5b22 Mon Sep 17 00:00:00 2001 From: joeldevelops Date: Wed, 18 Jun 2025 17:31:49 +0200 Subject: [PATCH 2/5] Fixes for react email to run in the deno env --- packages/app/public/discord.png | Bin 0 -> 11908 bytes packages/app/public/linkedin.png | Bin 0 -> 8268 bytes packages/app/public/youtube.png | Bin 0 -> 8917 bytes packages/core/deno.json | 3 +++ .../core/src/email/templates/DowngradeEmail.tsx | 3 +++ .../src/email/templates/InvitationEmail.tsx | 3 +++ packages/core/src/email/templates/OtpEmail.tsx | 6 +++++- .../core/src/email/templates/UpgradeEmail.tsx | 3 +++ .../core/src/email/templates/WelcomeEmail.tsx | 4 ++++ packages/core/src/email/templates/base.tsx | 7 ++++++- packages/core/src/email/templates/styles.tsx | 3 ++- 11 files changed, 29 insertions(+), 3 deletions(-) create mode 100644 packages/app/public/discord.png create mode 100644 packages/app/public/linkedin.png create mode 100644 packages/app/public/youtube.png diff --git a/packages/app/public/discord.png b/packages/app/public/discord.png new file mode 100644 index 0000000000000000000000000000000000000000..bc728dd622163cbf1df187be6cc673bbfa3e05f9 GIT binary patch literal 11908 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 X9e{;BN(A6=2qKstGb=T5z43nl%(^Nc literal 0 HcmV?d00001 diff --git a/packages/app/public/linkedin.png b/packages/app/public/linkedin.png new file mode 100644 index 0000000000000000000000000000000000000000..52499201e4a1dd63e88edc09e92b12be6ed9dc3a GIT binary patch literal 8268 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⁣PciH5G4FS0Iv9%e%4Hzx+W8J_pd8;HLz zzcp~E!Gm}*#DY|L9<#R6+_WfO(!}}~vgmn3tp(hAGIMbCU@~N6Pjn zVRyTyd!IshG51op&e}1eJukgzG@MB(eEc3!;`wiJP9U&P%r~yXLwla*L)XJAT7FWL|1}UVk?QTyicME!a##vNFxOY_!3p<_-j8 z^pSzcit(-F*{yROXe~GHszuaZ2#UtdCCYAcW~qe?VN&~bas_TSD#xmNB64&Y%x%P+ zy~b<*2Pyr(0*j4lhagq~_^&QEBId)e7~sDeF`?1gw9xeZZEz&fj<)_M0$sW{AnojX zU_0$?4A%cIH2&f>`sUwJImG=GHFNI)^u8m2^Sc&6Kj@~b%kd0&*>r^C9bMqC9F%>{rH``|VMxWK@6R;xsGiKOEp3&l?g7&Wa2 zfcSWzm%KvNRW5SsU$Kuot20*;`l+_T@|{)qNU%)-?w;@%rbPMgyDn%2GEcTfMXg)? z>!$udd(iS@2Cy5{8Q1gXw8*~=Q1boi?P5xyn_M}m+(%gGox4tHlDun zE$nx_T@1rywMmZ|#yCr61^OXE>T8U^ah@dyVz5W2mY3JB;c)$Q1}|a|GSaO&Jfd0^v)_B>_EOOlbPwclB$^xsMXK$9$h{O|JFdG7 zI2}o?Ob)}!sAV&Pq4jvDj>|=xru-4RoPsThs9}`}#1M@T0d*iq?JH|IyLou8J=;z8 zMY99XUwTakP$#(P08PqsaX!RhI_;c%_r4jNPlz*v08utY6r?_mz1Wk%cf~oOG1znH zNj3G(as5u9%oKukui|gl(Y!m`EW#hS>Cr13a_!lPU&`#hmlil0gYiEg(!;w-+a8hg zHh3HCQtu|bihnYwhkQqNx270Cypu+WW;kD-d_;Df^zB}rmJ$n_4by!M!f2U%*<{5| zhJU`dyy*0@*RANq8azJcIP4F~7QD&Qx|#>7c$sX@o+0!fc!m@Y8&=aaB$8sT!A`P2 SAN~^n969KAp!7@nx&H;ZIs_R2 literal 0 HcmV?d00001 diff --git a/packages/app/public/youtube.png b/packages/app/public/youtube.png new file mode 100644 index 0000000000000000000000000000000000000000..a2febaa453fff6c86d9a08e52cdbe3b65bdd417f GIT binary patch literal 8917 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 Date: Wed, 18 Jun 2025 17:37:23 +0200 Subject: [PATCH 3/5] Fix react linting for types --- packages/core/src/email/templates/DowngradeEmail.tsx | 1 + packages/core/src/email/templates/InvitationEmail.tsx | 1 + packages/core/src/email/templates/OtpEmail.tsx | 1 + packages/core/src/email/templates/UpgradeEmail.tsx | 1 + packages/core/src/email/templates/WelcomeEmail.tsx | 1 + packages/core/src/email/templates/base.tsx | 3 ++- 6 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/core/src/email/templates/DowngradeEmail.tsx b/packages/core/src/email/templates/DowngradeEmail.tsx index 573ddd9..8f7ad31 100644 --- a/packages/core/src/email/templates/DowngradeEmail.tsx +++ b/packages/core/src/email/templates/DowngradeEmail.tsx @@ -1,5 +1,6 @@ /** @jsx React.createElement */ /** @jsxFrag React.Fragment */ +// deno-lint-ignore verbatim-module-syntax import React from "react"; import { Heading, Section, Text } from "@react-email/components"; import { baseTemplate } from "./base.tsx"; diff --git a/packages/core/src/email/templates/InvitationEmail.tsx b/packages/core/src/email/templates/InvitationEmail.tsx index 7e47e44..c652e74 100644 --- a/packages/core/src/email/templates/InvitationEmail.tsx +++ b/packages/core/src/email/templates/InvitationEmail.tsx @@ -1,5 +1,6 @@ /** @jsx React.createElement */ /** @jsxFrag React.Fragment */ +// deno-lint-ignore verbatim-module-syntax import React from "react"; import { Button, Heading, Section, Text } from "@react-email/components"; import { baseTemplate } from "./base.tsx"; diff --git a/packages/core/src/email/templates/OtpEmail.tsx b/packages/core/src/email/templates/OtpEmail.tsx index 2ef0336..04c60ed 100644 --- a/packages/core/src/email/templates/OtpEmail.tsx +++ b/packages/core/src/email/templates/OtpEmail.tsx @@ -1,5 +1,6 @@ /** @jsx React.createElement */ /** @jsxFrag React.Fragment */ +// deno-lint-ignore verbatim-module-syntax import React from "react"; import { Heading, Section, Text } from "@react-email/components"; import { baseTemplate } from "./base.tsx"; diff --git a/packages/core/src/email/templates/UpgradeEmail.tsx b/packages/core/src/email/templates/UpgradeEmail.tsx index fb7707a..92cadbc 100644 --- a/packages/core/src/email/templates/UpgradeEmail.tsx +++ b/packages/core/src/email/templates/UpgradeEmail.tsx @@ -1,5 +1,6 @@ /** @jsx React.createElement */ /** @jsxFrag React.Fragment */ +// deno-lint-ignore verbatim-module-syntax import React from "react"; import { Heading, Section, Text } from "@react-email/components"; import { baseTemplate } from "./base.tsx"; diff --git a/packages/core/src/email/templates/WelcomeEmail.tsx b/packages/core/src/email/templates/WelcomeEmail.tsx index e9a0227..8fbe95e 100644 --- a/packages/core/src/email/templates/WelcomeEmail.tsx +++ b/packages/core/src/email/templates/WelcomeEmail.tsx @@ -1,6 +1,7 @@ /** @jsx React.createElement */ /** @jsxFrag React.Fragment */ // deno-lint-ignore no-undef +// deno-lint-ignore verbatim-module-syntax import React from "react"; import { Button, diff --git a/packages/core/src/email/templates/base.tsx b/packages/core/src/email/templates/base.tsx index fd3e1d7..1e63907 100644 --- a/packages/core/src/email/templates/base.tsx +++ b/packages/core/src/email/templates/base.tsx @@ -1,7 +1,8 @@ /** @jsx React.createElement */ /** @jsxFrag React.Fragment */ + +// deno-lint-ignore verbatim-module-syntax import React from "react"; -import ReactDOM from "react-dom/server"; import type { ReactNode } from "react"; import { Body, From 02ddd4bfa0677e125c02a2d009fef3f20003a067 Mon Sep 17 00:00:00 2001 From: joeldevelops Date: Wed, 18 Jun 2025 17:41:22 +0200 Subject: [PATCH 4/5] Fully await all email sends --- packages/core/src/api/auth/service.ts | 4 ++-- packages/core/src/api/billing/service.ts | 4 ++-- packages/core/src/api/invitation/service.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/core/src/api/auth/service.ts b/packages/core/src/api/auth/service.ts index 617cd52..fa99816 100644 --- a/packages/core/src/api/auth/service.ts +++ b/packages/core/src/api/auth/service.ts @@ -91,7 +91,7 @@ export class AuthService { } // Send OTP to user via email - sendOtpEmail(email, otp); + await sendOtpEmail(email, otp); return {}; } @@ -208,7 +208,7 @@ export class AuthService { .execute(); }); - sendWelcomeEmail(email); + await sendWelcomeEmail(email); } await db diff --git a/packages/core/src/api/billing/service.ts b/packages/core/src/api/billing/service.ts index bf9d98b..1a0c0cf 100644 --- a/packages/core/src/api/billing/service.ts +++ b/packages/core/src/api/billing/service.ts @@ -356,7 +356,7 @@ export class BillingService { .execute(); for (const email of emails) { - sendSubscriptionUpgradedEmail({ + await sendSubscriptionUpgradedEmail({ email: email.email, workspaceName: workspace.name, oldSubscription, @@ -495,7 +495,7 @@ export class BillingService { : "unknown"; for (const email of emails) { - sendSubscriptionDowngradedEmail({ + await sendSubscriptionDowngradedEmail({ email: email.email, workspaceName: workspace.name, oldSubscription, diff --git a/packages/core/src/api/invitation/service.ts b/packages/core/src/api/invitation/service.ts index e095f51..7b1e9a4 100644 --- a/packages/core/src/api/invitation/service.ts +++ b/packages/core/src/api/invitation/service.ts @@ -67,7 +67,7 @@ export class InvitationService { .executeTakeFirstOrThrow(); // Send the invitation email to the specified address - sendInvitationEmail( + await sendInvitationEmail( email, workspace.name, invitation.uuid, From a3722ed14ec58c14d5b1a9377928c38e881e0246 Mon Sep 17 00:00:00 2001 From: florianbgt Date: Thu, 19 Jun 2025 11:17:43 +0200 Subject: [PATCH 5/5] fix convertion email to text + some improvements --- deno.json | 3 +- packages/core/deno.json | 8 +- packages/core/src/api/billing/service.ts | 30 +++--- packages/core/src/email/index.ts | 94 +++++++------------ .../src/email/templates/DowngradeEmail.tsx | 14 +-- .../src/email/templates/InvitationEmail.tsx | 19 +--- .../core/src/email/templates/OtpEmail.tsx | 44 ++++----- .../core/src/email/templates/UpgradeEmail.tsx | 14 +-- .../core/src/email/templates/WelcomeEmail.tsx | 9 +- packages/core/src/email/templates/base.tsx | 64 +++++++------ 10 files changed, 125 insertions(+), 174 deletions(-) diff --git a/deno.json b/deno.json index c82e4b8..3db69e5 100644 --- a/deno.json +++ b/deno.json @@ -18,7 +18,8 @@ "stripe:cli": "docker run --rm -it --env-file=.env stripe/stripe-cli:latest", "stripe:forward": "deno task stripe:cli listen --forward-to http://host.docker.internal:4000/billing/webhook", "stripe:mock": "docker run --rm -it -p 12111-12112:12111-12112 stripe/stripe-mock:latest", - "bucket:mock": "docker run --rm -it -p 4443:4443 fsouza/fake-gcs-server:latest -scheme http -public-host localhost:4443" + "bucket:mock": "docker run --rm -it -p 4443:4443 fsouza/fake-gcs-server:latest -scheme http -public-host localhost:4443", + "email": "deno task --config ./packages/core/deno.json email" }, "lint": { "exclude": [ diff --git a/packages/core/deno.json b/packages/core/deno.json index bbd838e..47d7eca 100644 --- a/packages/core/deno.json +++ b/packages/core/deno.json @@ -11,13 +11,15 @@ "@google-cloud/storage": "npm:@google-cloud/storage@^7.16.0", "@oak/oak": "jsr:@oak/oak@^17.1.4", "@react-email/components": "npm:@react-email/components@^0.1.0", - "@react-email/render": "npm:@react-email/render@^1.1.2", "@sentry/deno": "npm:@sentry/deno@^9.28.1", "@std/assert": "jsr:@std/assert@1", "@std/expect": "jsr:@std/expect@^1.0.16", "@std/path": "jsr:@std/path@^1.0.9", + "@types/html-to-text": "npm:@types/html-to-text@^9.0.4", "@types/pg": "npm:@types/pg@^8.15.2", "@types/pg-pool": "npm:@types/pg-pool@^2.0.6", + "@types/react": "npm:@types/react@^19.1.8", + "html-to-text": "npm:html-to-text@^9.0.5", "kysely": "npm:kysely@^0.28.2", "pg": "npm:pg@^8.16.0", "pg-pool": "npm:pg-pool@^3.10.0", @@ -30,6 +32,8 @@ "zod": "npm:zod@^3.24.4" }, "compilerOptions": { - "jsx": "react" + "jsx": "react-jsx", + "jsxImportSource": "react", + "jsxImportSourceTypes": "@types/react" } } diff --git a/packages/core/src/api/billing/service.ts b/packages/core/src/api/billing/service.ts index 1a0c0cf..67f6f09 100644 --- a/packages/core/src/api/billing/service.ts +++ b/packages/core/src/api/billing/service.ts @@ -355,14 +355,12 @@ export class BillingService { .where("member.role", "=", ADMIN_ROLE) .execute(); - for (const email of emails) { - await sendSubscriptionUpgradedEmail({ - email: email.email, - workspaceName: workspace.name, - oldSubscription, - newSubscription, - }); - } + await sendSubscriptionUpgradedEmail({ + emails: emails.map((e) => e.email), + workspaceName: workspace.name, + oldSubscription, + newSubscription, + }); return {}; } @@ -494,15 +492,13 @@ export class BillingService { ? currentSubscription.cancelAt.toISOString() : "unknown"; - for (const email of emails) { - await sendSubscriptionDowngradedEmail({ - email: email.email, - workspaceName: workspace.name, - oldSubscription, - newSubscription, - newSubscriptionDate, - }); - } + await sendSubscriptionDowngradedEmail({ + emails: emails.map((e) => e.email), + workspaceName: workspace.name, + oldSubscription, + newSubscription, + newSubscriptionDate, + }); return {}; } diff --git a/packages/core/src/email/index.ts b/packages/core/src/email/index.ts index a4888b3..4944e25 100644 --- a/packages/core/src/email/index.ts +++ b/packages/core/src/email/index.ts @@ -1,10 +1,8 @@ -import type { ReactNode } from "react"; import type { StripeBillingCycle, StripeProduct, } from "../db/models/workspace.ts"; import { Resend } from "resend"; -import { render } from "@react-email/render"; import settings from "../settings.ts"; import OtpEmail from "./templates/OtpEmail.tsx"; @@ -12,14 +10,31 @@ import WelcomeEmail from "./templates/WelcomeEmail.tsx"; import InvitationEmail from "./templates/InvitationEmail.tsx"; import UpgradeEmail from "./templates/UpgradeEmail.tsx"; import DowngradeEmail from "./templates/DowngradeEmail.tsx"; +import type { ReactNode } from "react"; +import { renderToString } from "react-dom/server"; +import { convert } from "html-to-text"; + +async function getPlainText(react: ReactNode) { + const emailText = await renderToString(react); + const plainText = convert(emailText, { + selectors: [ + { selector: "img", format: "skip" }, + { selector: "[data-skip-in-text=true]", format: "skip" }, + { + selector: "a", + options: { linkBrackets: false }, + }, + ], + }); + return plainText; +} export type SendEmail = (options: { to: string[]; cc?: string[]; bcc?: string[]; subject: string; - text: string; - react?: ReactNode; + react: ReactNode; }) => Promise; const resend = new Resend(settings.EMAIL.RESEND_API_KEY); @@ -29,16 +44,17 @@ const sendEmailWithResend: SendEmail = async (options: { cc?: string[]; bcc?: string[]; subject: string; - text: string; - react?: ReactNode; + react: ReactNode; }) => { + const emailPlainText = await getPlainText(options.react); + const response = await resend.emails.send({ from: `${settings.EMAIL.FROM_EMAIL} <${settings.EMAIL.FROM_EMAIL}>`, to: options.to, cc: options.cc, bcc: options.bcc, subject: options.subject, - text: options.text, + text: emailPlainText, react: options.react, }); @@ -47,51 +63,40 @@ const sendEmailWithResend: SendEmail = async (options: { } }; -// deno-lint-ignore require-await const sendEmailWithConsole: SendEmail = async (options: { to: string[]; cc?: string[]; bcc?: string[]; subject: string; - text: string; - react?: ReactNode; + react: ReactNode; }) => { + const emailPlainText = await getPlainText(options.react); + console.info(`Sending email to ${options.to}: ${options.subject}`); - console.info(options.text); + console.info(emailPlainText); }; function getSendEmail() { if (settings.EMAIL.USE_CONSOLE) { return sendEmailWithConsole; } - return sendEmailWithResend; } export async function sendOtpEmail(email: string, otp: string) { - const emailPlainText = await render(OtpEmail({ otp }), { - plainText: true, - }); - const sendEmail = getSendEmail(); - sendEmail({ + await sendEmail({ to: [email], subject: "Your One-Time Password (OTP)", - text: emailPlainText, react: OtpEmail({ otp }), }); } export async function sendWelcomeEmail(email: string) { - const emailPlainText = await render(WelcomeEmail(), { - plainText: true, - }); - const sendEmail = getSendEmail(); - sendEmail({ + await sendEmail({ to: [email], subject: "Welcome to our platform!", - text: emailPlainText, react: WelcomeEmail(), }); } @@ -109,20 +114,9 @@ export async function sendInvitationEmail( const invitationLink = `${returnUrl}?${searchParams.toString()}`; - const emailPlainText = await render( - InvitationEmail({ - workspaceName, - invitationLink, - }), - { - plainText: true, - }, - ); - - sendEmail({ + await sendEmail({ to: [email], subject: `Invitation to join ${workspaceName}`, - text: emailPlainText, react: InvitationEmail({ workspaceName, invitationLink, @@ -132,7 +126,7 @@ export async function sendInvitationEmail( export async function sendSubscriptionUpgradedEmail( payload: { - email: string; + emails: string[]; workspaceName: string; oldSubscription: { product: StripeProduct; @@ -144,25 +138,17 @@ export async function sendSubscriptionUpgradedEmail( }; }, ) { - const emailPlainText = await render( - UpgradeEmail(payload), - { - plainText: true, - }, - ); - const sendEmail = getSendEmail(); - sendEmail({ - to: [payload.email], + await sendEmail({ + to: payload.emails, subject: "Subscription upgraded", - text: emailPlainText, react: UpgradeEmail(payload), }); } export async function sendSubscriptionDowngradedEmail( payload: { - email: string; + emails: string[]; workspaceName: string; oldSubscription: { product: StripeProduct; @@ -175,18 +161,10 @@ export async function sendSubscriptionDowngradedEmail( newSubscriptionDate: string; }, ) { - const emailPlainText = await render( - DowngradeEmail(payload), - { - plainText: true, - }, - ); - const sendEmail = getSendEmail(); - sendEmail({ - to: [payload.email], + await sendEmail({ + to: payload.emails, subject: "Subscription downgraded", - text: emailPlainText, react: DowngradeEmail(payload), }); } diff --git a/packages/core/src/email/templates/DowngradeEmail.tsx b/packages/core/src/email/templates/DowngradeEmail.tsx index 8f7ad31..0b0fc3b 100644 --- a/packages/core/src/email/templates/DowngradeEmail.tsx +++ b/packages/core/src/email/templates/DowngradeEmail.tsx @@ -1,7 +1,3 @@ -/** @jsx React.createElement */ -/** @jsxFrag React.Fragment */ -// deno-lint-ignore verbatim-module-syntax -import React from "react"; import { Heading, Section, Text } from "@react-email/components"; import { baseTemplate } from "./base.tsx"; import { headingStyle } from "./styles.tsx"; @@ -10,8 +6,8 @@ import type { StripeProduct, } from "../../db/models/workspace.ts"; -type DowngradeEmailProps = { - email: string; +const DowngradeEmail = (props: { + emails: string[]; workspaceName: string; oldSubscription: { product: StripeProduct; @@ -22,9 +18,7 @@ type DowngradeEmailProps = { billingCycle: StripeBillingCycle | null; }; newSubscriptionDate: string; -}; - -const DowngradeEmail = (props: DowngradeEmailProps) => { +}) => { const previewText = "Confirmation of your subscription downgrade | NanoAPI"; return baseTemplate( previewText, @@ -72,7 +66,7 @@ const DowngradeEmail = (props: DowngradeEmailProps) => { }; DowngradeEmail.PreviewProps = { - email: "test@nanoapi.io", + emails: ["test@nanoapi.io"], workspaceName: "My Workspace", oldSubscription: { product: "Pro Plan", diff --git a/packages/core/src/email/templates/InvitationEmail.tsx b/packages/core/src/email/templates/InvitationEmail.tsx index c652e74..4f11aa8 100644 --- a/packages/core/src/email/templates/InvitationEmail.tsx +++ b/packages/core/src/email/templates/InvitationEmail.tsx @@ -1,27 +1,18 @@ -/** @jsx React.createElement */ -/** @jsxFrag React.Fragment */ -// deno-lint-ignore verbatim-module-syntax -import React from "react"; import { Button, Heading, Section, Text } from "@react-email/components"; import { baseTemplate } from "./base.tsx"; import { headingStyle } from "./styles.tsx"; -type InvitationEmailProps = { +const InvitationEmail = (props: { workspaceName: string; invitationLink: string; -}; - -const InvitationEmail = ({ - workspaceName, - invitationLink, -}: InvitationEmailProps) => { - const previewText = `Invitation to join "${workspaceName}" on NanoAPI`; +}) => { + const previewText = `Invitation to join "${props.workspaceName}" on NanoAPI`; return baseTemplate( previewText, <>
- You have been invited to join "{workspaceName}" + You have been invited to join "{props.workspaceName}" We're excited to have you as part of our team. To get started, simply @@ -33,7 +24,7 @@ const InvitationEmail = ({ style={{ display: "flex", justifyContent: "center", padding: 20 }} >