浏览代码

feat: improve beta application emails

chenyu 2 月之前
父节点
当前提交
ef194258b2

+ 50 - 0
backend/src/modules/beta-applications/beta-applications.service.spec.ts

@@ -0,0 +1,50 @@
+import { ConfigService } from '@nestjs/config';
+import { BetaApplicationsService } from './beta-applications.service';
+
+describe('BetaApplicationsService email', () => {
+  it('uses form-aligned Chinese labels and maps enum values', () => {
+    const config = {
+      getOrThrow: jest.fn((key: string) => {
+        if (key === 'betaApplicationEmail.to') return 'info@aitori.jp';
+        if (key === 'betaApplicationEmail.from') {
+          return 'Aitori Stylist <info@aitori.jp>';
+        }
+        throw new Error(`Unexpected config key: ${key}`);
+      }),
+    } as unknown as ConfigService;
+    const service = new BetaApplicationsService(config as never, config);
+
+    const message = (
+      service as unknown as {
+        buildApplicationEmail(input: Record<string, unknown>): {
+          html: string;
+          text: string;
+        };
+      }
+    ).buildApplicationEmail({
+      name: 'Yu <Chen>',
+      email: 'USER@EXAMPLE.COM',
+      salonName: 'Demo Salon',
+      area: 'Shanghai',
+      role: 'stylist',
+      contact: '13800000000',
+      portfolioUrl: 'https://example.com',
+      monthlyCustomers: '1-30',
+      language: 'zh',
+      features: ['customer-try-on', 'templates'],
+      painPoint: '',
+    });
+
+    expect(message.text).toContain('沙龙名 / 店铺名:Demo Salon');
+    expect(message.text).toContain('你的角色:个人发型设计师');
+    expect(message.text).toContain('联系语言:中文');
+    expect(message.text).toContain(
+      '最想测试的功能:顾客预约前 AI 试发型、自己的发型模板',
+    );
+    expect(message.html).toContain('现在最想解决的问题');
+    expect(message.html).toContain('href="https://example.com"');
+    expect(message.html).toContain('href="mailto:user@example.com"');
+    expect(message.html).toContain('Yu &lt;Chen&gt;');
+    expect(message.html).not.toContain('Yu <Chen>');
+  });
+});

+ 83 - 14
backend/src/modules/beta-applications/beta-applications.service.ts

@@ -24,25 +24,92 @@ export class BetaApplicationsService {
       .replaceAll("'", '&#039;');
   }
 
+  private mapRole(value: string): string {
+    return {
+      stylist: '个人发型设计师',
+      'salon-owner': '沙龙负责人 / 主理人',
+      staff: '沙龙员工',
+      other: '其他',
+    }[value] ?? value;
+  }
+
+  private mapLanguage(value?: string): string {
+    if (!value) return '未填写';
+    return { zh: '中文', ja: '日文', en: '英文' }[value] ?? value;
+  }
+
+  private mapFeature(value: string): string {
+    return {
+      'customer-try-on': '顾客预约前 AI 试发型',
+      profile: '美发师公开主页',
+      templates: '自己的发型模板',
+      follow: '顾客管理 / 跟进提醒',
+    }[value] ?? value;
+  }
+
   private buildApplicationEmail(input: CreateBetaApplicationDto) {
     const to = this.configService.getOrThrow<string>('betaApplicationEmail.to');
     const from = this.configService.getOrThrow<string>('betaApplicationEmail.from');
     const fields = [
-      ['姓名', input.name],
-      ['邮箱', input.email],
-      ['沙龙', input.salonName],
-      ['地区', input.area],
-      ['身份', input.role],
-      ['联系方式', input.contact],
-      ['作品链接', input.portfolioUrl || '未填写'],
-      ['月顾客数', input.monthlyCustomers || '未填写'],
-      ['语言', input.language || '未填写'],
-      ['关注功能', input.features?.join('、') || '未填写'],
-      ['当前痛点', input.painPoint || '未填写'],
+      ['姓名', input.name.trim()],
+      ['邮箱', input.email.trim().toLowerCase()],
+      ['沙龙名 / 店铺名', input.salonName.trim()],
+      ['所在地区', input.area.trim()],
+      ['你的角色', this.mapRole(input.role)],
+      ['常用联系方式', input.contact.trim()],
+      ['作品或预约页面 URL', input.portfolioUrl?.trim() || '未填写'],
+      ['每月接待顾客数', input.monthlyCustomers || '未填写'],
+      ['联系语言', this.mapLanguage(input.language)],
+      [
+        '最想测试的功能',
+        input.features?.length
+          ? input.features.map((feature) => this.mapFeature(feature)).join('、')
+          : '未填写',
+      ],
+      ['现在最想解决的问题', input.painPoint?.trim() || '未填写'],
     ];
-    const html = `<h2>Aitori Stylist 新的内测申请</h2><table cellpadding="8" cellspacing="0" border="1" style="border-collapse:collapse">${fields
-      .map(([label, value]) => `<tr><th align="left">${label}</th><td>${this.escapeHtml(value)}</td></tr>`)
-      .join('')}</table>`;
+    const rows = fields
+      .map(([label, value], index) => {
+        const safeValue = this.escapeHtml(value);
+        const renderedValue =
+          label === '邮箱'
+            ? `<a href="mailto:${safeValue}" style="color:#8056d6;text-decoration:none;">${safeValue}</a>`
+            : label === '作品或预约页面 URL' && value !== '未填写'
+              ? `<a href="${safeValue}" style="color:#8056d6;text-decoration:underline;word-break:break-all;">${safeValue}</a>`
+              : safeValue;
+
+        return `<tr>
+          <td style="width:180px;padding:16px 18px;border-top:${index === 0 ? '0' : '1px solid #eeeaf5'};background:#faf8fd;color:#6f6a7b;font-size:13px;font-weight:700;line-height:1.6;vertical-align:top;">${this.escapeHtml(label)}</td>
+          <td style="padding:16px 18px;border-top:${index === 0 ? '0' : '1px solid #eeeaf5'};color:#292735;font-size:15px;font-weight:600;line-height:1.7;vertical-align:top;word-break:break-word;">${renderedValue}</td>
+        </tr>`;
+      })
+      .join('');
+    const html = `<!doctype html>
+<html lang="zh-CN">
+  <body style="margin:0;padding:0;background:#f5f2f7;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Hiragino Sans GB','Microsoft YaHei',Arial,sans-serif;">
+    <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f5f2f7;">
+      <tr><td align="center" style="padding:32px 16px;">
+        <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:680px;background:#ffffff;border-radius:16px;overflow:hidden;box-shadow:0 12px 36px rgba(63,45,86,0.10);">
+          <tr><td style="padding:30px 34px;background:#9c70ed;color:#ffffff;">
+            <div style="font-size:12px;font-weight:800;letter-spacing:2px;text-transform:uppercase;opacity:0.86;">AITORI STYLIST BETA</div>
+            <h1 style="margin:10px 0 0;font-size:25px;line-height:1.35;">收到新的内测申请</h1>
+          </td></tr>
+          <tr><td style="padding:30px 34px 34px;">
+            <p style="margin:0 0 22px;color:#6f6a7b;font-size:14px;line-height:1.8;">申请人 <strong style="color:#292735;">${this.escapeHtml(input.name.trim())}</strong> 提交了 Aitori Stylist 内测申请。直接回复此邮件即可联系申请人。</p>
+            <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #eeeaf5;border-radius:12px;border-collapse:separate;border-spacing:0;overflow:hidden;">${rows}</table>
+            <p style="margin:24px 0 0;padding:16px 18px;border-radius:10px;background:#f6f1ff;color:#655b73;font-size:13px;line-height:1.7;">此邮件由 Aitori Stylist 内测申请页面自动发送,请勿转发包含申请人联系方式的内容。</p>
+          </td></tr>
+        </table>
+        <p style="margin:18px 0 0;color:#a29cab;font-size:12px;">Aitori Stylist · Beta Application</p>
+      </td></tr>
+    </table>
+  </body>
+</html>`;
+    const text = [
+      'Aitori Stylist 新的内测申请',
+      '',
+      ...fields.map(([label, value]) => `${label}:${value}`),
+    ].join('\n');
 
     return {
       from,
@@ -50,6 +117,7 @@ export class BetaApplicationsService {
       replyTo: input.email.trim().toLowerCase(),
       subject: `Aitori Stylist 内测申请:${input.name.trim()}`,
       html,
+      text,
     };
   }
 
@@ -78,6 +146,7 @@ export class BetaApplicationsService {
         reply_to: message.replyTo,
         subject: message.subject,
         html: message.html,
+        text: message.text,
       }),
       signal: AbortSignal.timeout(10000),
     });