瀏覽代碼

feat: support configurable email providers

chenyu 2 月之前
父節點
當前提交
fb7bbad2ed

+ 11 - 2
backend/.env.example

@@ -13,7 +13,16 @@ GCS_CLIENT_EMAIL=oss-227@app-aitori-jp.iam.gserviceaccount.com
 GCS_PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nreplace-me\n-----END PRIVATE KEY-----\n
 CUSTOMER_DEFAULT_AVATAR_URL=https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png
 
-# 内测申请通知邮件(Resend)
+# 内测申请通知邮件:resend | smtp | disabled
+BETA_APPLICATION_EMAIL_PROVIDER=resend
 RESEND_API_KEY=
-BETA_APPLICATION_EMAIL_FROM=Aitori Stylist <noreply@aitori.jp>
+
+# BETA_APPLICATION_EMAIL_PROVIDER=smtp 时填写
+SMTP_HOST=
+SMTP_PORT=587
+SMTP_SECURE=false
+SMTP_USER=
+SMTP_PASSWORD=
+
+BETA_APPLICATION_EMAIL_FROM=Aitori Stylist <info@aitori.jp>
 BETA_APPLICATION_EMAIL_TO=info@aitori.jp

+ 21 - 0
backend/package-lock.json

@@ -20,6 +20,7 @@
         "class-transformer": "^0.5.1",
         "class-validator": "^0.14.2",
         "ioredis": "^5.6.1",
+        "nodemailer": "^9.0.5",
         "reflect-metadata": "^0.2.2",
         "rxjs": "^7.8.1",
         "yazl": "^3.3.1"
@@ -32,6 +33,7 @@
         "@types/jest": "^30.0.0",
         "@types/multer": "^2.1.0",
         "@types/node": "^22.0.0",
+        "@types/nodemailer": "^8.0.1",
         "@types/supertest": "^6.0.2",
         "@types/yazl": "^3.3.1",
         "eslint": "^9.0.0",
@@ -2968,6 +2970,16 @@
         "undici-types": "~6.21.0"
       }
     },
+    "node_modules/@types/nodemailer": {
+      "version": "8.0.1",
+      "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz",
+      "integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*"
+      }
+    },
     "node_modules/@types/qs": {
       "version": "6.15.1",
       "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
@@ -8484,6 +8496,15 @@
         "node": ">=18"
       }
     },
+    "node_modules/nodemailer": {
+      "version": "9.0.5",
+      "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.5.tgz",
+      "integrity": "sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==",
+      "license": "MIT-0",
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
     "node_modules/normalize-path": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",

+ 2 - 0
backend/package.json

@@ -41,6 +41,7 @@
     "class-transformer": "^0.5.1",
     "class-validator": "^0.14.2",
     "ioredis": "^5.6.1",
+    "nodemailer": "^9.0.5",
     "reflect-metadata": "^0.2.2",
     "rxjs": "^7.8.1",
     "yazl": "^3.3.1"
@@ -53,6 +54,7 @@
     "@types/jest": "^30.0.0",
     "@types/multer": "^2.1.0",
     "@types/node": "^22.0.0",
+    "@types/nodemailer": "^8.0.1",
     "@types/supertest": "^6.0.2",
     "@types/yazl": "^3.3.1",
     "eslint": "^9.0.0",

+ 22 - 0
backend/src/config/app.config.spec.ts

@@ -66,4 +66,26 @@ describe('appConfig storage', () => {
       httpTimeoutMs: 15000,
     });
   });
+
+  it('maps the beta application SMTP provider config', () => {
+    process.env.BETA_APPLICATION_EMAIL_PROVIDER = 'smtp';
+    process.env.SMTP_HOST = 'smtp.example.com';
+    process.env.SMTP_PORT = '465';
+    process.env.SMTP_SECURE = 'true';
+    process.env.SMTP_USER = 'info@aitori.jp';
+    process.env.SMTP_PASSWORD = 'app-password';
+
+    const config = appConfig();
+
+    expect(config.betaApplicationEmail).toMatchObject({
+      provider: 'smtp',
+      smtp: {
+        host: 'smtp.example.com',
+        port: 465,
+        secure: true,
+        user: 'info@aitori.jp',
+        password: 'app-password',
+      },
+    });
+  });
 });

+ 12 - 2
backend/src/config/app.config.ts

@@ -35,10 +35,20 @@ export default () => ({
       'https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png',
   },
   betaApplicationEmail: {
-    apiKey: process.env.RESEND_API_KEY ?? '',
+    provider: process.env.BETA_APPLICATION_EMAIL_PROVIDER ?? 'resend',
+    resend: {
+      apiKey: process.env.RESEND_API_KEY ?? '',
+    },
+    smtp: {
+      host: process.env.SMTP_HOST ?? '',
+      port: Number(process.env.SMTP_PORT ?? 587),
+      secure: process.env.SMTP_SECURE === 'true',
+      user: process.env.SMTP_USER ?? '',
+      password: process.env.SMTP_PASSWORD ?? '',
+    },
     from:
       process.env.BETA_APPLICATION_EMAIL_FROM ??
-      'Aitori Stylist <noreply@aitori.jp>',
+      'Aitori Stylist <info@aitori.jp>',
     to: process.env.BETA_APPLICATION_EMAIL_TO ?? 'info@aitori.jp',
   },
 });

+ 28 - 1
backend/src/config/env.schema.ts

@@ -23,9 +23,36 @@ export const envSchema = z.object({
     .default(
       'https://storage.googleapis.com/aitori-public/style/sample/066c7ba22ea149268444bc2d919d5b97.png',
     ),
+  BETA_APPLICATION_EMAIL_PROVIDER: z
+    .enum(['resend', 'smtp', 'disabled'])
+    .default('resend'),
   RESEND_API_KEY: z.string().optional(),
-  BETA_APPLICATION_EMAIL_FROM: z.string().default('Aitori Stylist <noreply@aitori.jp>'),
+  SMTP_HOST: z.string().optional(),
+  SMTP_PORT: z.coerce.number().int().positive().default(587),
+  SMTP_SECURE: z.enum(['true', 'false']).default('false'),
+  SMTP_USER: z.string().optional(),
+  SMTP_PASSWORD: z.string().optional(),
+  BETA_APPLICATION_EMAIL_FROM: z.string().default('Aitori Stylist <info@aitori.jp>'),
   BETA_APPLICATION_EMAIL_TO: z.string().email().default('info@aitori.jp'),
+}).superRefine((env, context) => {
+  if (env.BETA_APPLICATION_EMAIL_PROVIDER === 'smtp' && !env.SMTP_HOST) {
+    context.addIssue({
+      code: z.ZodIssueCode.custom,
+      path: ['SMTP_HOST'],
+      message: 'SMTP_HOST is required when BETA_APPLICATION_EMAIL_PROVIDER=smtp',
+    });
+  }
+
+  if (
+    env.BETA_APPLICATION_EMAIL_PROVIDER === 'smtp' &&
+    Boolean(env.SMTP_USER) !== Boolean(env.SMTP_PASSWORD)
+  ) {
+    context.addIssue({
+      code: z.ZodIssueCode.custom,
+      path: ['SMTP_PASSWORD'],
+      message: 'SMTP_USER and SMTP_PASSWORD must be configured together',
+    });
+  }
 });
 
 export function validateEnv(config: Record<string, unknown>) {

+ 80 - 13
backend/src/modules/beta-applications/beta-applications.service.ts

@@ -1,8 +1,11 @@
 import { Injectable, Logger } from '@nestjs/common';
 import { ConfigService } from '@nestjs/config';
+import * as nodemailer from 'nodemailer';
 import { PrismaService } from '../../prisma/prisma.service';
 import { CreateBetaApplicationDto } from './dto/create-beta-application.dto';
 
+type EmailProvider = 'resend' | 'smtp' | 'disabled';
+
 @Injectable()
 export class BetaApplicationsService {
   private readonly logger = new Logger(BetaApplicationsService.name);
@@ -21,16 +24,9 @@ export class BetaApplicationsService {
       .replaceAll("'", '&#039;');
   }
 
-  private async sendApplicationEmail(input: CreateBetaApplicationDto) {
-    const apiKey = this.configService.get<string>('betaApplicationEmail.apiKey');
+  private buildApplicationEmail(input: CreateBetaApplicationDto) {
     const to = this.configService.getOrThrow<string>('betaApplicationEmail.to');
     const from = this.configService.getOrThrow<string>('betaApplicationEmail.from');
-
-    if (!apiKey) {
-      this.logger.warn('RESEND_API_KEY is not configured; beta application email was not sent');
-      return false;
-    }
-
     const fields = [
       ['姓名', input.name],
       ['邮箱', input.email],
@@ -48,6 +44,28 @@ export class BetaApplicationsService {
       .map(([label, value]) => `<tr><th align="left">${label}</th><td>${this.escapeHtml(value)}</td></tr>`)
       .join('')}</table>`;
 
+    return {
+      from,
+      to,
+      replyTo: input.email.trim().toLowerCase(),
+      subject: `Aitori Stylist 内测申请:${input.name.trim()}`,
+      html,
+    };
+  }
+
+  private async sendWithResend(
+    message: ReturnType<BetaApplicationsService['buildApplicationEmail']>,
+  ) {
+    const apiKey = this.configService.get<string>(
+      'betaApplicationEmail.resend.apiKey',
+    );
+    if (!apiKey) {
+      this.logger.warn(
+        'RESEND_API_KEY is not configured; beta application email was not sent',
+      );
+      return false;
+    }
+
     const response = await fetch('https://api.resend.com/emails', {
       method: 'POST',
       headers: {
@@ -55,11 +73,11 @@ export class BetaApplicationsService {
         'Content-Type': 'application/json',
       },
       body: JSON.stringify({
-        from,
-        to: [to],
-        reply_to: input.email.trim().toLowerCase(),
-        subject: `Aitori Stylist 内测申请:${input.name.trim()}`,
-        html,
+        from: message.from,
+        to: [message.to],
+        reply_to: message.replyTo,
+        subject: message.subject,
+        html: message.html,
       }),
       signal: AbortSignal.timeout(10000),
     });
@@ -71,6 +89,55 @@ export class BetaApplicationsService {
     return true;
   }
 
+  private async sendWithSmtp(
+    message: ReturnType<BetaApplicationsService['buildApplicationEmail']>,
+  ) {
+    const host = this.configService.getOrThrow<string>(
+      'betaApplicationEmail.smtp.host',
+    );
+    const port = this.configService.getOrThrow<number>(
+      'betaApplicationEmail.smtp.port',
+    );
+    const secure = this.configService.getOrThrow<boolean>(
+      'betaApplicationEmail.smtp.secure',
+    );
+    const user = this.configService.get<string>('betaApplicationEmail.smtp.user');
+    const password = this.configService.get<string>(
+      'betaApplicationEmail.smtp.password',
+    );
+
+    const transporter = nodemailer.createTransport({
+      host,
+      port,
+      secure,
+      ...(user && password ? { auth: { user, pass: password } } : {}),
+      connectionTimeout: 10000,
+      greetingTimeout: 10000,
+      socketTimeout: 15000,
+    });
+
+    await transporter.sendMail(message);
+    return true;
+  }
+
+  private async sendApplicationEmail(input: CreateBetaApplicationDto) {
+    const provider = this.configService.getOrThrow<EmailProvider>(
+      'betaApplicationEmail.provider',
+    );
+
+    if (provider === 'disabled') {
+      this.logger.log(
+        'Beta application email is disabled; application was stored without sending',
+      );
+      return false;
+    }
+
+    const message = this.buildApplicationEmail(input);
+    return provider === 'smtp'
+      ? this.sendWithSmtp(message)
+      : this.sendWithResend(message);
+  }
+
   async create(input: CreateBetaApplicationDto) {
     const application = await this.prisma.betaApplication.create({
       data: {