浏览代码

feat: migrate from Resend to Nodemailer (#60)

Norris Oduro 1 月之前
父节点
当前提交
87dae1d0f0
共有 7 个文件被更改,包括 61 次插入94 次删除
  1. 9 6
      .env.example
  2. 0 38
      app/api/webhooks/resend/route.ts
  3. 2 1
      package.json
  4. 13 14
      pnpm-lock.yaml
  5. 7 2
      src/env.ts
  6. 0 5
      src/shared/lib/mail/resend.ts
  7. 30 28
      src/shared/lib/mail/sendEmail.ts

+ 9 - 6
.env.example

@@ -14,12 +14,6 @@ BETTER_AUTH_SECRET="your-secret-key-here"
 GOOGLE_CLIENT_ID="your-google-client-id.apps.googleusercontent.com"
 GOOGLE_CLIENT_SECRET="your-google-client-secret"
 
-# Email Service (Resend)
-# Get your API key from: https://resend.com
-RESEND_API_KEY="re_123456789"
-# Optional: Create an audience in Resend dashboard
-RESEND_AUDIENCE_ID="aud_123456789"
-
 # OpenPanel Integration
 # Get these from your OpenPanel dashboard
 OPENPANEL_SECRET_KEY="op_sk_123456789"
@@ -32,3 +26,12 @@ NEXT_PUBLIC_FACEBOOK_PIXEL_ID="123456789"
 # Environment
 # Options: development, production, test
 NODE_ENV="development"
+
+#SMTP Configuration 
+# Using MailHog for example. https://github.com/mailhog/MailHog 
+SMTP_HOST=localhost
+SMTP_PORT=1025
+SMTP_USER=
+SMTP_PASS=
+SMTP_FROM="Workout Cool <noreply@workout.cool>"
+SMTP_SECURE=false

+ 0 - 38
app/api/webhooks/resend/route.ts

@@ -1,38 +0,0 @@
-import { z } from "zod";
-import { NextResponse } from "next/server";
-
-import { logger } from "@/shared/lib/logger";
-
-import type { NextRequest } from "next/server";
-
-const StripeWebhookSchema = z.object({
-  type: z.string(),
-  created_at: z.string(),
-  data: z.any(),
-});
-
-/**
- * Resends webhooks
- *
- * @docs How it work https://resend.com/docs/dashboard/webhooks/introduction
- * @docs Event type https://resend.com/docs/dashboard/webhooks/event-types
- */
-export const POST = async (req: NextRequest) => {
-  const body = await req.json();
-
-  const event = StripeWebhookSchema.parse(body);
-
-  switch (event.type) {
-    case "email.complained":
-      logger.warn("Email complained", event.data);
-      break;
-    case "email.bounced":
-      logger.warn("Email bounced", event.data);
-      break;
-  }
-
-  NextResponse.redirect("");
-  return NextResponse.json({
-    ok: true,
-  });
-};

+ 2 - 1
package.json

@@ -52,6 +52,7 @@
     "@radix-ui/react-tooltip": "^1.1.8",
     "@react-email/components": "^0.0.35",
     "@react-email/html": "^0.0.11",
+    "@react-email/render": "^1.1.2",
     "@react-email/tailwind": "^1.0.4",
     "@t3-oss/env-nextjs": "^0.12.0",
     "@tailwindcss/typography": "^0.5.16",
@@ -84,7 +85,6 @@
     "react-dom": "^19.0.0",
     "react-hook-form": "^7.55.0",
     "react-icons": "^5.5.0",
-    "resend": "^4.2.0",
     "sonner": "^2.0.3",
     "usehooks-ts": "^3.1.1",
     "vaul": "^1.1.2",
@@ -99,6 +99,7 @@
     "@next/eslint-plugin-next": "^15.2.4",
     "@types/canvas-confetti": "^1.9.0",
     "@types/node": "^20",
+    "@types/nodemailer": "^6.4.17",
     "@types/nprogress": "^0.2.3",
     "@types/react": "^19",
     "@types/react-dom": "^19",

+ 13 - 14
pnpm-lock.yaml

@@ -107,6 +107,9 @@ importers:
       '@react-email/html':
         specifier: ^0.0.11
         version: 0.0.11(react@19.1.0)
+      '@react-email/render':
+        specifier: ^1.1.2
+        version: 1.1.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
       '@react-email/tailwind':
         specifier: ^1.0.4
         version: 1.0.5(react@19.1.0)
@@ -203,9 +206,6 @@ importers:
       react-icons:
         specifier: ^5.5.0
         version: 5.5.0(react@19.1.0)
-      resend:
-        specifier: ^4.2.0
-        version: 4.6.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
       sonner:
         specifier: ^2.0.3
         version: 2.0.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@@ -243,6 +243,9 @@ importers:
       '@types/node':
         specifier: ^20
         version: 20.19.1
+      '@types/nodemailer':
+        specifier: ^6.4.17
+        version: 6.4.17
       '@types/nprogress':
         specifier: ^0.2.3
         version: 0.2.3
@@ -1741,6 +1744,9 @@ packages:
   '@types/node@20.19.1':
     resolution: {integrity: sha512-jJD50LtlD2dodAEO653i3YF04NWak6jN3ky+Ri3Em3mGR39/glWiboM/IePaRbgwSfqM1TpGXfAg8ohn/4dTgA==}
 
+  '@types/nodemailer@6.4.17':
+    resolution: {integrity: sha512-I9CCaIp6DTldEg7vyUTZi8+9Vo0hi1/T8gv3C89yk1rSAAzoKQ8H8ki/jBYJSFoH/BisgLP8tkZMlQ91CIquww==}
+
   '@types/nprogress@0.2.3':
     resolution: {integrity: sha512-k7kRA033QNtC+gLc4VPlfnue58CM1iQLgn1IMAU8VPHGOj7oIHPp9UlhedEnD/Gl8evoCjwkZjlBORtZ3JByUA==}
 
@@ -3721,10 +3727,6 @@ packages:
   remark-rehype@11.1.2:
     resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==}
 
-  resend@4.6.0:
-    resolution: {integrity: sha512-D5T2I82FvEUYFlrHzaDvVtr5ADHdhuoLaXgLFGABKyNtQgPWIuz0Vp2L2Evx779qjK37aF4kcw1yXJDHhA2JnQ==}
-    engines: {node: '>=18'}
-
   resolve-from@4.0.0:
     resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
     engines: {node: '>=4'}
@@ -5595,6 +5597,10 @@ snapshots:
     dependencies:
       undici-types: 6.21.0
 
+  '@types/nodemailer@6.4.17':
+    dependencies:
+      '@types/node': 20.19.1
+
   '@types/nprogress@0.2.3': {}
 
   '@types/react-dom@19.1.6(@types/react@19.1.8)':
@@ -7897,13 +7903,6 @@ snapshots:
       unified: 11.0.5
       vfile: 6.0.3
 
-  resend@4.6.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
-    dependencies:
-      '@react-email/render': 1.1.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
-    transitivePeerDependencies:
-      - react
-      - react-dom
-
   resolve-from@4.0.0: {}
 
   resolve-pkg-maps@1.0.0: {}

+ 7 - 2
src/env.ts

@@ -12,11 +12,16 @@ export const env = createEnv({
     DATABASE_URL: z.string().url(),
     GOOGLE_CLIENT_ID: z.string().min(1),
     GOOGLE_CLIENT_SECRET: z.string().min(1),
-    RESEND_API_KEY: z.string().min(1),
-    RESEND_AUDIENCE_ID: z.string().optional(),
     NODE_ENV: z.enum(["development", "production", "test"]),
     BETTER_AUTH_SECRET: z.string().min(1),
     OPENPANEL_SECRET_KEY: z.string().min(1),
+    SMTP_HOST: z.string().optional(),
+    SMTP_PORT: z.coerce.number().positive().optional(),
+    SMTP_USER: z.string().optional(),
+    SMTP_PASS: z.string().optional(),
+    SMTP_FROM: z.string().optional(),
+    //issue fixed in zod 4. See https://github.com/colinhacks/zod/issues/3906
+    SMTP_SECURE: z.enum(["true", "false"]).default("false").transform((val) => val === "true"),
   },
   /**
    * If you add `client` environment variables, you need to add them to

+ 0 - 5
src/shared/lib/mail/resend.ts

@@ -1,5 +0,0 @@
-import { Resend } from "resend";
-
-import { env } from "@/env";
-
-export const resend = new Resend(env.RESEND_API_KEY);

+ 30 - 28
src/shared/lib/mail/sendEmail.ts

@@ -1,33 +1,35 @@
-import { SiteConfig } from "@/shared/config/site-config";
-import { env } from "@/env";
-
-import { resend } from "./resend";
+import nodemailer from "nodemailer";
+import { render } from "@react-email/components";
 
-type ResendSendType = typeof resend.emails.send;
-type ResendParamsType = Parameters<ResendSendType>;
-type ResendParamsTypeWithConditionalFrom = [payload: Omit<ResendParamsType[0], "from"> & { from?: string }, options?: ResendParamsType[1]];
+import { env } from "@/env";
 
-/**
- * sendEmail will send an email using resend.
- * To avoid repeating the same "from" email, you can leave it empty and it will use the default one.
- * Also, in development, it will add "[DEV]" to the subject.
- * @param params[0] : payload
- * @param params[1] : options
- * @returns a promise of the email sent
- */
-export const sendEmail = async (...params: ResendParamsTypeWithConditionalFrom) => {
-  if (env.NODE_ENV === "development") {
-    params[0].subject = `[DEV] ${params[0].subject}`;
-  }
+type EmailPayload = {
+  from?: string;
+  to: string;
+  subject: string;
+  text: string;
+  react?: React.ReactElement;
+};
 
-  const resendParams = [
-    {
-      ...params[0],
-      from: params[0].from ?? SiteConfig.email.from,
-      to: env.NODE_ENV === "development" ? "delivered@resend.dev" : params[0].to,
-    } as ResendParamsType[0],
-    params[1],
-  ] satisfies ResendParamsType;
+const transporter = nodemailer.createTransport({
+  host: env.SMTP_HOST,
+  port: env.SMTP_PORT,
+  secure: env.SMTP_SECURE,
+  auth:
+    env.SMTP_USER && env.SMTP_PASS
+      ? {
+          user: env.SMTP_USER,
+          pass: env.SMTP_PASS,
+        }
+      : undefined,
+});
 
-  return resend.emails.send(...resendParams);
+export const sendEmail = async ({ from, to, subject, text, react }: EmailPayload) => {
+  return transporter.sendMail({
+    from: from ?? env.SMTP_FROM,
+    to,
+    subject,
+    text,
+    html: react ? await render(react) : undefined,
+  });
 };