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/app/public/discord.png b/packages/app/public/discord.png new file mode 100644 index 0000000..bc728dd Binary files /dev/null and b/packages/app/public/discord.png differ diff --git a/packages/app/public/linkedin.png b/packages/app/public/linkedin.png new file mode 100644 index 0000000..5249920 Binary files /dev/null and b/packages/app/public/linkedin.png differ diff --git a/packages/app/public/youtube.png b/packages/app/public/youtube.png new file mode 100644 index 0000000..a2febaa Binary files /dev/null and b/packages/app/public/youtube.png differ diff --git a/packages/core/deno.json b/packages/core/deno.json index 9136684..47d7eca 100644 --- a/packages/core/deno.json +++ b/packages/core/deno.json @@ -4,23 +4,36 @@ "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": { "@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", "@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", "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" + }, + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "react", + "jsxImportSourceTypes": "@types/react" } } 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..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) { - 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) { - 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/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, diff --git a/packages/core/src/email/index.ts b/packages/core/src/email/index.ts index b32753b..4944e25 100644 --- a/packages/core/src/email/index.ts +++ b/packages/core/src/email/index.ts @@ -5,13 +5,36 @@ import type { import { Resend } from "resend"; 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"; +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; - html?: string; + react: ReactNode; }) => Promise; const resend = new Resend(settings.EMAIL.RESEND_API_KEY); @@ -21,17 +44,18 @@ const sendEmailWithResend: SendEmail = async (options: { cc?: string[]; bcc?: string[]; subject: string; - text: string; - html?: string; + 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, - html: options.html, + text: emailPlainText, + react: options.react, }); if (response.error) { @@ -39,73 +63,45 @@ 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; - html?: string; + 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 function sendOtpEmail(email: string, otp: string) { +export async function sendOtpEmail(email: string, otp: string) { const sendEmail = getSendEmail(); - sendEmail({ + await 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`, + react: OtpEmail({ otp }), }); } -export function sendWelcomeEmail(email: string) { +export async function sendWelcomeEmail(email: string) { const sendEmail = getSendEmail(); - sendEmail({ + await 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`, + react: WelcomeEmail(), }); } -export function sendInvitationEmail( +export async function sendInvitationEmail( email: string, workspaceName: string, invitationUuid: string, @@ -118,29 +114,19 @@ export function sendInvitationEmail( const invitationLink = `${returnUrl}?${searchParams.toString()}`; - sendEmail({ + await 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`, + react: InvitationEmail({ + workspaceName, + invitationLink, + }), }); } -export function sendSubscriptionUpgradedEmail( +export async function sendSubscriptionUpgradedEmail( payload: { - email: string; + emails: string[]; workspaceName: string; oldSubscription: { product: StripeProduct; @@ -153,32 +139,16 @@ export function sendSubscriptionUpgradedEmail( }, ) { const sendEmail = getSendEmail(); - sendEmail({ - to: [payload.email], + await sendEmail({ + to: payload.emails, 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`, + react: UpgradeEmail(payload), }); } -export function sendSubscriptionDowngradedEmail( +export async function sendSubscriptionDowngradedEmail( payload: { - email: string; + emails: string[]; workspaceName: string; oldSubscription: { product: StripeProduct; @@ -192,26 +162,9 @@ export function sendSubscriptionDowngradedEmail( }, ) { const sendEmail = getSendEmail(); - sendEmail({ - to: [payload.email], + await sendEmail({ + to: payload.emails, 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`, + 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..0b0fc3b --- /dev/null +++ b/packages/core/src/email/templates/DowngradeEmail.tsx @@ -0,0 +1,82 @@ +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"; + +const DowngradeEmail = (props: { + emails: string[]; + workspaceName: string; + oldSubscription: { + product: StripeProduct; + billingCycle: StripeBillingCycle | null; + }; + newSubscription: { + product: StripeProduct; + billingCycle: StripeBillingCycle | null; + }; + newSubscriptionDate: string; +}) => { + 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 = { + emails: ["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..4f11aa8 --- /dev/null +++ b/packages/core/src/email/templates/InvitationEmail.tsx @@ -0,0 +1,59 @@ +import { Button, Heading, Section, Text } from "@react-email/components"; +import { baseTemplate } from "./base.tsx"; +import { headingStyle } from "./styles.tsx"; + +const InvitationEmail = (props: { + workspaceName: string; + invitationLink: string; +}) => { + const previewText = `Invitation to join "${props.workspaceName}" on NanoAPI`; + return baseTemplate( + previewText, + <> +
+ + You have been invited to join "{props.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..ec97552 --- /dev/null +++ b/packages/core/src/email/templates/OtpEmail.tsx @@ -0,0 +1,60 @@ +import { Heading, Section, Text } from "@react-email/components"; +import { baseTemplate } from "./base.tsx"; +import { headingStyle } from "./styles.tsx"; + +const OtpEmail = ({ otp }: { + otp: string; +}) => { + 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..22fc57a --- /dev/null +++ b/packages/core/src/email/templates/UpgradeEmail.tsx @@ -0,0 +1,76 @@ +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"; + +const UpgradeEmail = (props: { + emails: string[]; + workspaceName: string; + oldSubscription: { + product: StripeProduct; + billingCycle: StripeBillingCycle | null; + }; + newSubscription: { + product: StripeProduct; + billingCycle: StripeBillingCycle | null; + }; +}) => { + 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 = { + emails: ["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..8487d0c --- /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: + + + + 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..e74e0df --- /dev/null +++ b/packages/core/src/email/templates/base.tsx @@ -0,0 +1,169 @@ +import type { ReactNode } from "react"; +import { + Body, + Column, + Container, + Head, + Heading, + Hr, + Html, + Img, + Link, + Preview, + Row, + Section, + Text, +} from "@react-email/components"; + +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..d9159ce --- /dev/null +++ b/packages/core/src/email/templates/styles.tsx @@ -0,0 +1,7 @@ +export const headingStyle = { + // deno-lint-ignore no-explicit-any + textAlign: "center" as any, + fontFamily: "Arial, sans-serif", + marginTop: "30px", + marginBottom: "40px", +};