Skip to content

Commit cc9f366

Browse files
committed
feat: integrate Plunk email service, add email utility functions, and update package dependencies
1 parent 834ba74 commit cc9f366

File tree

7 files changed

+136
-1
lines changed

7 files changed

+136
-1
lines changed

apps/web/.env.example

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@ NEXT_PUBLIC_SUPABASE_URL=
55
# Make sure to enable RLS and policies to use this key in client
66
NEXT_PUBLIC_SUPABASE_ANON_KEY=
77
SUPABASE_SERVICE_ROLE_KEY=
8-
SUPABASE_URL=
8+
SUPABASE_URL=
9+
PLUNK_API_KEY=

apps/web/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
},
1919
"dependencies": {
2020
"@hookform/resolvers": "5.2.1",
21+
"@plunk/node": "3.0.3",
2122
"@radix-ui/react-alert-dialog": "1.1.15",
2223
"@radix-ui/react-avatar": "1.1.10",
2324
"@radix-ui/react-collapsible": "1.1.11",
@@ -28,6 +29,7 @@
2829
"@radix-ui/react-slot": "1.2.3",
2930
"@radix-ui/react-tabs": "1.1.12",
3031
"@react-email/components": "0.5.1",
32+
"@react-email/render": "1.3.1",
3133
"@supabase/ssr": "0.6.1",
3234
"@supabase/supabase-js": "2.52.0",
3335
"@tanstack/react-query": "5.84.1",

apps/web/src/lib/email/client.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import Plunk from "@plunk/node";
2+
3+
let client: Plunk | undefined;
4+
5+
const createClient = () => {
6+
const apiKey = process.env.PLUNK_API_KEY;
7+
8+
if (!apiKey) {
9+
throw new Error(
10+
"Missing PLUNK_API_KEY environment variable. Set it to use the email service.",
11+
);
12+
}
13+
14+
return new Plunk(apiKey);
15+
};
16+
17+
export const getPlunkClient = () => {
18+
if (!client) {
19+
client = createClient();
20+
}
21+
22+
return client;
23+
};

apps/web/src/lib/email/index.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { getPlunkClient } from "./client";
2+
import type { EmailService } from "./types";
3+
import { DEFAULT_EMAIL_FROM, normalizeRecipients } from "./utils";
4+
5+
const sendEmail: EmailService["sendEmail"] = async (payload) => {
6+
const {
7+
body,
8+
from = DEFAULT_EMAIL_FROM,
9+
name,
10+
subscribed,
11+
subject,
12+
to,
13+
} = payload;
14+
15+
const recipients = normalizeRecipients(to);
16+
17+
try {
18+
await getPlunkClient().emails.send({
19+
body,
20+
from,
21+
name,
22+
subscribed,
23+
subject,
24+
to: recipients,
25+
});
26+
} catch (error) {
27+
console.error("Failed to send email", error);
28+
throw error instanceof Error ? error : new Error("Unknown email error");
29+
}
30+
};
31+
32+
export const emailService: EmailService = {
33+
sendEmail,
34+
};

apps/web/src/lib/email/types.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
export type EmailServiceParams = {
2+
to: string | string[];
3+
subject: string;
4+
body: string;
5+
type?: "html" | "markdown";
6+
from?: string;
7+
name?: string;
8+
subscribed?: boolean;
9+
};
10+
11+
export type EmailService = {
12+
sendEmail: (payload: EmailServiceParams) => Promise<void>;
13+
};

apps/web/src/lib/email/utils.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type { EmailServiceParams } from "./types";
2+
3+
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
4+
const DEFAULT_EMAIL_FROM =
5+
process.env.PLUNK_FROM_EMAIL ??
6+
"Tech Companies Portugal <[email protected]>";
7+
const DEFAULT_EMAIL_TYPE: EmailServiceParams["type"] = "html";
8+
9+
const normalizeRecipients = (to: EmailServiceParams["to"]): string[] => {
10+
const recipients = Array.isArray(to) ? to : [to];
11+
12+
if (!recipients.length) {
13+
throw new Error("Invalid 'to' field: provide at least one email address.");
14+
}
15+
16+
return recipients.map((recipient) => {
17+
const sanitized = recipient.trim();
18+
19+
if (!sanitized) {
20+
throw new Error(
21+
"Invalid 'to' field: recipients must be non-empty strings.",
22+
);
23+
}
24+
25+
if (!EMAIL_PATTERN.test(sanitized)) {
26+
throw new Error(`Invalid email address: ${sanitized}`);
27+
}
28+
29+
return sanitized;
30+
});
31+
};
32+
33+
export { normalizeRecipients, DEFAULT_EMAIL_FROM, DEFAULT_EMAIL_TYPE };

package-lock.json

Lines changed: 29 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)