Skip to content

Commit 0992910

Browse files
authored
Merge pull request #7 from nanoapi-io/chore/email-templates
Update emails to profesional templates
2 parents 7e9bd24 + a3722ed commit 0992910

File tree

16 files changed

+660
-125
lines changed

16 files changed

+660
-125
lines changed

deno.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@
1818
"stripe:cli": "docker run --rm -it --env-file=.env stripe/stripe-cli:latest",
1919
"stripe:forward": "deno task stripe:cli listen --forward-to http://host.docker.internal:4000/billing/webhook",
2020
"stripe:mock": "docker run --rm -it -p 12111-12112:12111-12112 stripe/stripe-mock:latest",
21-
"bucket:mock": "docker run --rm -it -p 4443:4443 fsouza/fake-gcs-server:latest -scheme http -public-host localhost:4443"
21+
"bucket:mock": "docker run --rm -it -p 4443:4443 fsouza/fake-gcs-server:latest -scheme http -public-host localhost:4443",
22+
"email": "deno task --config ./packages/core/deno.json email"
2223
},
2324
"lint": {
2425
"exclude": [

packages/app/public/discord.png

11.6 KB
Loading

packages/app/public/linkedin.png

8.07 KB
Loading

packages/app/public/youtube.png

8.71 KB
Loading

packages/core/deno.json

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,23 +4,36 @@
44
"tasks": {
55
"dev": "deno run --env-file=../../.env -A --watch src/index.ts",
66
"migrate": "deno run -A src/db/scripts/migrate.ts",
7-
"build": "deno compile --allow-all --include src/db/migrations --output ./dist/core ./src/index.ts"
7+
"build": "deno compile --allow-all --include src/db/migrations --output ./dist/core ./src/index.ts",
8+
"email": "email dev -d src/email/templates"
89
},
910
"imports": {
1011
"@google-cloud/storage": "npm:@google-cloud/storage@^7.16.0",
1112
"@oak/oak": "jsr:@oak/oak@^17.1.4",
13+
"@react-email/components": "npm:@react-email/components@^0.1.0",
1214
"@sentry/deno": "npm:@sentry/deno@^9.28.1",
1315
"@std/assert": "jsr:@std/assert@1",
1416
"@std/expect": "jsr:@std/expect@^1.0.16",
1517
"@std/path": "jsr:@std/path@^1.0.9",
18+
"@types/html-to-text": "npm:@types/html-to-text@^9.0.4",
1619
"@types/pg": "npm:@types/pg@^8.15.2",
1720
"@types/pg-pool": "npm:@types/pg-pool@^2.0.6",
21+
"@types/react": "npm:@types/react@^19.1.8",
22+
"html-to-text": "npm:html-to-text@^9.0.5",
1823
"kysely": "npm:kysely@^0.28.2",
1924
"pg": "npm:pg@^8.16.0",
2025
"pg-pool": "npm:pg-pool@^3.10.0",
2126
"djwt": "https://deno.land/x/[email protected]/mod.ts",
27+
"react": "npm:react@^19.1.0",
28+
"react-dom": "npm:react-dom@^19.1.0",
29+
"react-email": "npm:react-email@^4.0.16",
2230
"resend": "npm:resend@^4.5.2",
2331
"stripe": "npm:stripe@^18.2.1",
2432
"zod": "npm:zod@^3.24.4"
33+
},
34+
"compilerOptions": {
35+
"jsx": "react-jsx",
36+
"jsxImportSource": "react",
37+
"jsxImportSourceTypes": "@types/react"
2538
}
2639
}

packages/core/src/api/auth/service.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ export class AuthService {
9191
}
9292

9393
// Send OTP to user via email
94-
sendOtpEmail(email, otp);
94+
await sendOtpEmail(email, otp);
9595

9696
return {};
9797
}
@@ -208,7 +208,7 @@ export class AuthService {
208208
.execute();
209209
});
210210

211-
sendWelcomeEmail(email);
211+
await sendWelcomeEmail(email);
212212
}
213213

214214
await db

packages/core/src/api/billing/service.ts

Lines changed: 13 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -355,14 +355,12 @@ export class BillingService {
355355
.where("member.role", "=", ADMIN_ROLE)
356356
.execute();
357357

358-
for (const email of emails) {
359-
sendSubscriptionUpgradedEmail({
360-
email: email.email,
361-
workspaceName: workspace.name,
362-
oldSubscription,
363-
newSubscription,
364-
});
365-
}
358+
await sendSubscriptionUpgradedEmail({
359+
emails: emails.map((e) => e.email),
360+
workspaceName: workspace.name,
361+
oldSubscription,
362+
newSubscription,
363+
});
366364

367365
return {};
368366
}
@@ -494,15 +492,13 @@ export class BillingService {
494492
? currentSubscription.cancelAt.toISOString()
495493
: "unknown";
496494

497-
for (const email of emails) {
498-
sendSubscriptionDowngradedEmail({
499-
email: email.email,
500-
workspaceName: workspace.name,
501-
oldSubscription,
502-
newSubscription,
503-
newSubscriptionDate,
504-
});
505-
}
495+
await sendSubscriptionDowngradedEmail({
496+
emails: emails.map((e) => e.email),
497+
workspaceName: workspace.name,
498+
oldSubscription,
499+
newSubscription,
500+
newSubscriptionDate,
501+
});
506502

507503
return {};
508504
}

packages/core/src/api/invitation/service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ export class InvitationService {
6767
.executeTakeFirstOrThrow();
6868

6969
// Send the invitation email to the specified address
70-
sendInvitationEmail(
70+
await sendInvitationEmail(
7171
email,
7272
workspace.name,
7373
invitation.uuid,

packages/core/src/email/index.ts

Lines changed: 56 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,36 @@ import type {
55
import { Resend } from "resend";
66
import settings from "../settings.ts";
77

8+
import OtpEmail from "./templates/OtpEmail.tsx";
9+
import WelcomeEmail from "./templates/WelcomeEmail.tsx";
10+
import InvitationEmail from "./templates/InvitationEmail.tsx";
11+
import UpgradeEmail from "./templates/UpgradeEmail.tsx";
12+
import DowngradeEmail from "./templates/DowngradeEmail.tsx";
13+
import type { ReactNode } from "react";
14+
import { renderToString } from "react-dom/server";
15+
import { convert } from "html-to-text";
16+
17+
async function getPlainText(react: ReactNode) {
18+
const emailText = await renderToString(react);
19+
const plainText = convert(emailText, {
20+
selectors: [
21+
{ selector: "img", format: "skip" },
22+
{ selector: "[data-skip-in-text=true]", format: "skip" },
23+
{
24+
selector: "a",
25+
options: { linkBrackets: false },
26+
},
27+
],
28+
});
29+
return plainText;
30+
}
31+
832
export type SendEmail = (options: {
933
to: string[];
1034
cc?: string[];
1135
bcc?: string[];
1236
subject: string;
13-
text: string;
14-
html?: string;
37+
react: ReactNode;
1538
}) => Promise<void>;
1639

1740
const resend = new Resend(settings.EMAIL.RESEND_API_KEY);
@@ -21,91 +44,64 @@ const sendEmailWithResend: SendEmail = async (options: {
2144
cc?: string[];
2245
bcc?: string[];
2346
subject: string;
24-
text: string;
25-
html?: string;
47+
react: ReactNode;
2648
}) => {
49+
const emailPlainText = await getPlainText(options.react);
50+
2751
const response = await resend.emails.send({
2852
from: `${settings.EMAIL.FROM_EMAIL} <${settings.EMAIL.FROM_EMAIL}>`,
2953
to: options.to,
3054
cc: options.cc,
3155
bcc: options.bcc,
3256
subject: options.subject,
33-
text: options.text,
34-
html: options.html,
57+
text: emailPlainText,
58+
react: options.react,
3559
});
3660

3761
if (response.error) {
3862
throw new Error(`Failed to send email: ${response.error.message}`);
3963
}
4064
};
4165

42-
// deno-lint-ignore require-await
4366
const sendEmailWithConsole: SendEmail = async (options: {
4467
to: string[];
4568
cc?: string[];
4669
bcc?: string[];
4770
subject: string;
48-
text: string;
49-
html?: string;
71+
react: ReactNode;
5072
}) => {
73+
const emailPlainText = await getPlainText(options.react);
74+
5175
console.info(`Sending email to ${options.to}: ${options.subject}`);
52-
console.info(options.text);
76+
console.info(emailPlainText);
5377
};
5478

5579
function getSendEmail() {
5680
if (settings.EMAIL.USE_CONSOLE) {
5781
return sendEmailWithConsole;
5882
}
59-
6083
return sendEmailWithResend;
6184
}
6285

63-
export function sendOtpEmail(email: string, otp: string) {
86+
export async function sendOtpEmail(email: string, otp: string) {
6487
const sendEmail = getSendEmail();
65-
sendEmail({
88+
await sendEmail({
6689
to: [email],
6790
subject: "Your One-Time Password (OTP)",
68-
text: `Hi there,
69-
70-
You've requested a one-time password to access your account. Please use the code below to complete your authentication:
71-
72-
**${otp}**
73-
74-
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.
75-
76-
If you didn't request this code, please ignore this email or contact our support team if you have concerns about your account security.
77-
78-
Best regards,
79-
The Team`,
91+
react: OtpEmail({ otp }),
8092
});
8193
}
8294

83-
export function sendWelcomeEmail(email: string) {
95+
export async function sendWelcomeEmail(email: string) {
8496
const sendEmail = getSendEmail();
85-
sendEmail({
97+
await sendEmail({
8698
to: [email],
8799
subject: "Welcome to our platform!",
88-
text: `Hi there,
89-
90-
Welcome to our platform! We're thrilled to have you join our community.
91-
92-
Your account has been successfully created and you're all set to get started. Here's what you can do next:
93-
94-
• Explore the dashboard and familiarize yourself with the interface
95-
• Set up your profile and preferences
96-
• Create your first workspace or join an existing one
97-
• Invite team members to collaborate with you
98-
99-
If you have any questions or need assistance getting started, our support team is here to help. Don't hesitate to reach out!
100-
101-
We're excited to see what you'll accomplish with our platform.
102-
103-
Best regards,
104-
The Team`,
100+
react: WelcomeEmail(),
105101
});
106102
}
107103

108-
export function sendInvitationEmail(
104+
export async function sendInvitationEmail(
109105
email: string,
110106
workspaceName: string,
111107
invitationUuid: string,
@@ -118,29 +114,19 @@ export function sendInvitationEmail(
118114

119115
const invitationLink = `${returnUrl}?${searchParams.toString()}`;
120116

121-
sendEmail({
117+
await sendEmail({
122118
to: [email],
123119
subject: `Invitation to join ${workspaceName}`,
124-
text: `Hi there,
125-
126-
You've been invited to join "${workspaceName}"!
127-
128-
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:
129-
130-
${invitationLink}
131-
132-
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.
133-
134-
We look forward to collaborating with you!
135-
136-
Best regards,
137-
The Team`,
120+
react: InvitationEmail({
121+
workspaceName,
122+
invitationLink,
123+
}),
138124
});
139125
}
140126

141-
export function sendSubscriptionUpgradedEmail(
127+
export async function sendSubscriptionUpgradedEmail(
142128
payload: {
143-
email: string;
129+
emails: string[];
144130
workspaceName: string;
145131
oldSubscription: {
146132
product: StripeProduct;
@@ -153,32 +139,16 @@ export function sendSubscriptionUpgradedEmail(
153139
},
154140
) {
155141
const sendEmail = getSendEmail();
156-
sendEmail({
157-
to: [payload.email],
142+
await sendEmail({
143+
to: payload.emails,
158144
subject: "Subscription upgraded",
159-
text: `Hi there,
160-
161-
Your subscription for workspace "${payload.workspaceName}" has been successfully upgraded!
162-
163-
Previous subscription: ${payload.oldSubscription.product} (${
164-
payload.oldSubscription.billingCycle ?? "Custom billing cycle"
165-
})
166-
New subscription: ${payload.newSubscription.product} (${
167-
payload.newSubscription.billingCycle ?? "Custom billing cycle"
168-
})
169-
170-
This change is effective immediately, and you now have access to all the features included in your new subscription.
171-
172-
If you have any questions about your upgraded subscription, please don't hesitate to contact our support team.
173-
174-
Best regards,
175-
The Team`,
145+
react: UpgradeEmail(payload),
176146
});
177147
}
178148

179-
export function sendSubscriptionDowngradedEmail(
149+
export async function sendSubscriptionDowngradedEmail(
180150
payload: {
181-
email: string;
151+
emails: string[];
182152
workspaceName: string;
183153
oldSubscription: {
184154
product: StripeProduct;
@@ -192,26 +162,9 @@ export function sendSubscriptionDowngradedEmail(
192162
},
193163
) {
194164
const sendEmail = getSendEmail();
195-
sendEmail({
196-
to: [payload.email],
165+
await sendEmail({
166+
to: payload.emails,
197167
subject: "Subscription downgraded",
198-
text: `Hi there,
199-
200-
Your subscription for workspace "${payload.workspaceName}" has been scheduled for downgrade.
201-
202-
Current subscription: ${payload.oldSubscription.product} (${
203-
payload.oldSubscription.billingCycle ?? "Custom billing cycle"
204-
})
205-
New subscription: ${payload.newSubscription.product} (${
206-
payload.newSubscription.billingCycle ?? "Custom billing cycle"
207-
})
208-
Effective date: ${payload.newSubscriptionDate}
209-
210-
Your current subscription will remain active until the end of your billing period, and the new subscription will take effect on ${payload.newSubscriptionDate}.
211-
212-
If you have any questions, please don't hesitate to contact our support team.
213-
214-
Best regards,
215-
The Team`,
168+
react: DowngradeEmail(payload),
216169
});
217170
}

0 commit comments

Comments
 (0)