Skip to content

Update emails to profesional templates #7

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Jun 19, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
Binary file added packages/app/public/discord.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added packages/app/public/linkedin.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added packages/app/public/youtube.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
15 changes: 14 additions & 1 deletion packages/core/deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/[email protected]/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"
}
}
4 changes: 2 additions & 2 deletions packages/core/src/api/auth/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ export class AuthService {
}

// Send OTP to user via email
sendOtpEmail(email, otp);
await sendOtpEmail(email, otp);

return {};
}
Expand Down Expand Up @@ -208,7 +208,7 @@ export class AuthService {
.execute();
});

sendWelcomeEmail(email);
await sendWelcomeEmail(email);
}

await db
Expand Down
30 changes: 13 additions & 17 deletions packages/core/src/api/billing/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {};
}
Expand Down Expand Up @@ -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 {};
}
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/api/invitation/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export class InvitationService {
.executeTakeFirstOrThrow();

// Send the invitation email to the specified address
sendInvitationEmail(
await sendInvitationEmail(
email,
workspace.name,
invitation.uuid,
Expand Down
159 changes: 56 additions & 103 deletions packages/core/src/email/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;

const resend = new Resend(settings.EMAIL.RESEND_API_KEY);
Expand All @@ -21,91 +44,64 @@ 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) {
throw new Error(`Failed to send email: ${response.error.message}`);
}
};

// 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,
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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),
});
}
Loading