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:
+
+
+ {[
+ "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.
+
+
+
+
+