Yuki Takei 1 месяц назад
Родитель
Сommit
378ec3d939

+ 4 - 4
.kiro/specs/refactor-mailer-service/spec.json

@@ -1,9 +1,9 @@
 {
   "feature_name": "refactor-mailer-service",
   "created_at": "2026-02-10T08:15:00Z",
-  "updated_at": "2026-02-10T09:15:00Z",
+  "updated_at": "2026-02-10T10:00:00Z",
   "language": "en",
-  "phase": "tasks-generated",
+  "phase": "implementation-in-progress",
   "approvals": {
     "requirements": {
       "generated": true,
@@ -15,8 +15,8 @@
     },
     "tasks": {
       "generated": true,
-      "approved": false
+      "approved": true
     }
   },
-  "ready_for_implementation": false
+  "ready_for_implementation": true
 }

+ 13 - 13
.kiro/specs/refactor-mailer-service/tasks.md

@@ -8,14 +8,14 @@ This refactoring follows a three-phase approach: establishing type safety founda
 
 ## Phase 1: Type Safety Foundation
 
-- [ ] 1. Establish type infrastructure for mail module
-- [ ] 1.1 Install nodemailer type definitions
+- [x] 1. Establish type infrastructure for mail module
+- [x] 1.1 Install nodemailer type definitions
   - Add @types/nodemailer@6.4.22 as devDependency in package.json
   - Run `pnpm install` to install the package
   - Verify package appears in devDependencies
   - _Requirements: 3.1_
 
-- [ ] 1.2 Create shared type definitions module
+- [x] 1.2 Create shared type definitions module
   - Create src/server/service/mail/ directory
   - Define StrictOAuth2Options type with NonBlankString credential fields (user, clientId, clientSecret, refreshToken)
   - Define MailConfig, EmailConfig, SendResult types
@@ -23,7 +23,7 @@ This refactoring follows a three-phase approach: establishing type safety founda
   - Include type assertion verifying StrictOAuth2Options compatibility with SMTPTransport.Options
   - _Requirements: 3.2, 3.3, 3.9_
 
-- [ ] 1.3 Verify type safety infrastructure
+- [x] 1.3 Verify type safety infrastructure
   - Run `pnpm run lint:typecheck` to verify type compilation
   - Confirm no new type errors introduced
   - Run `pnpm run test` to verify existing tests still pass
@@ -34,8 +34,8 @@ This refactoring follows a three-phase approach: establishing type safety founda
 
 ## Phase 2: Module Extraction
 
-- [ ] 2. Extract transport modules with type-safe implementations
-- [ ] 2.1 (P) Extract SMTP transport module
+- [x] 2. Extract transport modules with type-safe implementations
+- [x] 2.1 (P) Extract SMTP transport module
   - Create smtp.ts with createSMTPClient factory function
   - Accept configManager parameter and optional SMTPTransport.Options
   - Read mail:smtpHost, mail:smtpPort, mail:smtpUser, mail:smtpPassword from config
@@ -45,7 +45,7 @@ This refactoring follows a three-phase approach: establishing type safety founda
   - Use named exports following GROWI conventions
   - _Requirements: 2.1, 2.5, 2.6, 2.7, 5.2, 5.6_
 
-- [ ] 2.2 (P) Extract SES transport module
+- [x] 2.2 (P) Extract SES transport module
   - Create ses.ts with createSESClient factory function
   - Accept configManager parameter and optional SES configuration object
   - Read mail:sesAccessKeyId, mail:sesSecretAccessKey from config
@@ -55,7 +55,7 @@ This refactoring follows a three-phase approach: establishing type safety founda
   - Use named exports following GROWI conventions
   - _Requirements: 2.2, 2.5, 2.6, 2.7, 5.3, 5.6_
 
-- [ ] 2.3 (P) Extract OAuth2 transport module with type-safe credentials
+- [x] 2.3 (P) Extract OAuth2 transport module with type-safe credentials
   - Create oauth2.ts with createOAuth2Client factory function
   - Accept configManager parameter and optional SMTPTransport.Options
   - Read mail:oauth2User, mail:oauth2ClientId, mail:oauth2ClientSecret, mail:oauth2RefreshToken from config
@@ -76,8 +76,8 @@ This refactoring follows a three-phase approach: establishing type safety founda
 
 ## Phase 3: Integration & Barrel Export
 
-- [ ] 3. Integrate transport modules and establish barrel export
-- [ ] 3.1 Refactor MailService to delegate transport creation
+- [x] 3. Integrate transport modules and establish barrel export
+- [x] 3.1 Refactor MailService to delegate transport creation
   - Import createSMTPClient, createSESClient, createOAuth2Client from respective modules
   - Update initialize() method to delegate transport creation based on mail:transmissionMethod config
   - Remove inline createSMTPClient(), createSESClient(), createOAuth2Client() method implementations
@@ -88,7 +88,7 @@ This refactoring follows a three-phase approach: establishing type safety founda
   - Verify MailService behavior identical to pre-refactoring implementation
   - _Requirements: 2.4, 4.1, 4.2, 4.3, 4.4, 4.6_
 
-- [ ] 3.2 Reorganize files into mail/ directory structure
+- [x] 3.2 Reorganize files into mail/ directory structure
   - Move src/server/service/mail.ts to src/server/service/mail/mail.ts
   - Move src/server/service/mail.spec.ts to src/server/service/mail/mail.spec.ts
   - Update import paths in mail.spec.ts to reference local modules (./mail, ./smtp, ./ses, ./oauth2, ./types)
@@ -96,7 +96,7 @@ This refactoring follows a three-phase approach: establishing type safety founda
   - Verify all imports resolve correctly after file moves
   - _Requirements: 1.1, 1.2, 5.1_
 
-- [ ] 3.3 Create barrel export for backward compatibility
+- [x] 3.3 Create barrel export for backward compatibility
   - Create src/server/service/mail/index.ts
   - Export MailService as default export (maintains existing import pattern)
   - Export MailConfig, EmailConfig, SendResult, StrictOAuth2Options as named exports
@@ -104,7 +104,7 @@ This refactoring follows a three-phase approach: establishing type safety founda
   - Verify import path ~/server/service/mail resolves to barrel export
   - _Requirements: 1.3, 1.4, 1.5_
 
-- [ ] 3.4 Verify backward compatibility and test coverage
+- [x] 3.4 Verify backward compatibility and test coverage
   - Run full test suite with `pnpm run test`
   - Verify all existing MailService tests pass without modification
   - Confirm Config API (src/server/routes/apiv3/app-settings/index.ts) imports MailService successfully

+ 13 - 0
apps/app/src/server/service/mail/index.ts

@@ -0,0 +1,13 @@
+/**
+ * Mail service barrel export.
+ *
+ * Maintains backward compatibility with existing import pattern:
+ * `import MailService from '~/server/service/mail'`
+ */
+export { default } from './mail';
+export type {
+  EmailConfig,
+  MailConfig,
+  SendResult,
+  StrictOAuth2Options,
+} from './types';

+ 8 - 7
apps/app/src/server/service/mail.spec.ts → apps/app/src/server/service/mail/mail.spec.ts

@@ -1,10 +1,11 @@
 import { beforeEach, describe, expect, it, vi } from 'vitest';
 
-import type Crowi from '../crowi';
+import type Crowi from '../../crowi';
 import MailService from './mail';
+import { createOAuth2Client } from './oauth2';
 
 // Mock the FailedEmail model
-vi.mock('../models/failed-email', () => ({
+vi.mock('../../models/failed-email', () => ({
   FailedEmail: {
     create: vi.fn(),
   },
@@ -237,13 +238,13 @@ describe('MailService', () => {
 
   describe('storeFailedEmail', () => {
     beforeEach(async () => {
-      const { FailedEmail } = await import('../models/failed-email');
+      const { FailedEmail } = await import('../../models/failed-email');
       vi.mocked(FailedEmail.create).mockClear();
       vi.mocked(FailedEmail.create).mockResolvedValue({} as never);
     });
 
     it('should store failed email with all required fields', async () => {
-      const { FailedEmail } = await import('../models/failed-email');
+      const { FailedEmail } = await import('../../models/failed-email');
 
       const config = {
         to: 'recipient@example.com',
@@ -275,7 +276,7 @@ describe('MailService', () => {
     });
 
     it('should store OAuth 2.0 error code if present', async () => {
-      const { FailedEmail } = await import('../models/failed-email');
+      const { FailedEmail } = await import('../../models/failed-email');
 
       const config = {
         to: 'recipient@example.com',
@@ -301,7 +302,7 @@ describe('MailService', () => {
     });
 
     it('should handle model creation errors gracefully', async () => {
-      const { FailedEmail } = await import('../models/failed-email');
+      const { FailedEmail } = await import('../../models/failed-email');
 
       const config = {
         to: 'recipient@example.com',
@@ -354,7 +355,7 @@ describe('MailService', () => {
         return undefined;
       });
 
-      const mailer = mailService.createOAuth2Client();
+      const mailer = createOAuth2Client(mockConfigManager);
 
       expect(mailer).not.toBeNull();
       // Credentials should never be exposed in logs

+ 12 - 134
apps/app/src/server/service/mail.ts → apps/app/src/server/service/mail/mail.ts

@@ -1,42 +1,20 @@
 import ejs from 'ejs';
-import nodemailer from 'nodemailer';
 import { promisify } from 'util';
 
 import loggerFactory from '~/utils/logger';
 
-import type Crowi from '../crowi';
-import { FailedEmail } from '../models/failed-email';
-import S2sMessage from '../models/vo/s2s-message';
-import type { IConfigManagerForApp } from './config-manager';
-import type { S2sMessageHandlable } from './s2s-messaging/handlable';
+import type Crowi from '../../crowi';
+import { FailedEmail } from '../../models/failed-email';
+import S2sMessage from '../../models/vo/s2s-message';
+import type { IConfigManagerForApp } from '../config-manager';
+import type { S2sMessageHandlable } from '../s2s-messaging/handlable';
+import { createOAuth2Client } from './oauth2';
+import { createSESClient } from './ses';
+import { createSMTPClient } from './smtp';
+import type { EmailConfig, MailConfig, SendResult } from './types';
 
 const logger = loggerFactory('growi:service:mail');
 
-type MailConfig = {
-  to?: string;
-  from?: string;
-  text?: string;
-  subject?: string;
-};
-
-type EmailConfig = {
-  to: string;
-  from?: string;
-  subject?: string;
-  text?: string;
-  template?: string;
-  vars?: Record<string, unknown>;
-};
-
-type SendResult = {
-  messageId: string;
-  response: string;
-  envelope: {
-    from: string;
-    to: string[];
-  };
-};
-
 class MailService implements S2sMessageHandlable {
   appService!: any;
 
@@ -126,11 +104,11 @@ class MailService implements S2sMessageHandlable {
     );
 
     if (transmissionMethod === 'smtp') {
-      this.mailer = this.createSMTPClient();
+      this.mailer = createSMTPClient(configManager);
     } else if (transmissionMethod === 'ses') {
-      this.mailer = this.createSESClient();
+      this.mailer = createSESClient(configManager);
     } else if (transmissionMethod === 'oauth2') {
-      this.mailer = this.createOAuth2Client();
+      this.mailer = createOAuth2Client(configManager);
     } else {
       this.mailer = null;
     }
@@ -145,106 +123,6 @@ class MailService implements S2sMessageHandlable {
     logger.debug('mailer initialized');
   }
 
-  createSMTPClient(option?) {
-    const { configManager } = this;
-
-    logger.debug('createSMTPClient option', option);
-    if (!option) {
-      const host = configManager.getConfig('mail:smtpHost');
-      const port = configManager.getConfig('mail:smtpPort');
-
-      if (host == null || port == null) {
-        return null;
-      }
-
-      // biome-ignore lint/style/noParameterAssign: ignore
-      option = {
-        host,
-        port,
-      };
-
-      if (configManager.getConfig('mail:smtpPassword')) {
-        option.auth = {
-          user: configManager.getConfig('mail:smtpUser'),
-          pass: configManager.getConfig('mail:smtpPassword'),
-        };
-      }
-      if (option.port === 465) {
-        option.secure = true;
-      }
-    }
-    option.tls = { rejectUnauthorized: false };
-
-    const client = nodemailer.createTransport(option);
-
-    logger.debug('mailer set up for SMTP', client);
-
-    return client;
-  }
-
-  createSESClient(option?) {
-    const { configManager } = this;
-
-    if (!option) {
-      const accessKeyId = configManager.getConfig('mail:sesAccessKeyId');
-      const secretAccessKey = configManager.getConfig(
-        'mail:sesSecretAccessKey',
-      );
-      if (accessKeyId == null || secretAccessKey == null) {
-        return null;
-      }
-      option = {
-        accessKeyId,
-        secretAccessKey,
-      };
-    }
-
-    const ses = require('nodemailer-ses-transport');
-    const client = nodemailer.createTransport(ses(option));
-
-    logger.debug('mailer set up for SES', client);
-
-    return client;
-  }
-
-  createOAuth2Client(option?) {
-    const { configManager } = this;
-
-    if (!option) {
-      const clientId = configManager.getConfig('mail:oauth2ClientId');
-      const clientSecret = configManager.getConfig('mail:oauth2ClientSecret');
-      const refreshToken = configManager.getConfig('mail:oauth2RefreshToken');
-      const user = configManager.getConfig('mail:oauth2User');
-
-      // Use falsy check (not == null) to match nodemailer's XOAuth2 check:
-      // XOAuth2.generateToken() uses `!this.options.refreshToken` which rejects empty strings
-      if (!clientId || !clientSecret || !refreshToken || !user) {
-        logger.warn(
-          'OAuth 2.0 credentials incomplete, skipping transport creation',
-        );
-        return null;
-      }
-
-      option = {
-        // eslint-disable-line no-param-reassign
-        service: 'gmail',
-        auth: {
-          type: 'OAuth2',
-          user,
-          clientId,
-          clientSecret,
-          refreshToken,
-        },
-      };
-    }
-
-    const client = nodemailer.createTransport(option);
-
-    logger.debug('mailer set up for OAuth2', client);
-
-    return client;
-  }
-
   setupMailConfig(overrideConfig) {
     const c = overrideConfig;
 

+ 117 - 0
apps/app/src/server/service/mail/oauth2.spec.ts

@@ -0,0 +1,117 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { createOAuth2Client } from './oauth2';
+
+describe('createOAuth2Client', () => {
+  let mockConfigManager: any;
+
+  beforeEach(() => {
+    mockConfigManager = {
+      getConfig: vi.fn(),
+    };
+  });
+
+  const validCredentials = (
+    overrides: Record<string, string | null> = {},
+  ): void => {
+    mockConfigManager.getConfig.mockImplementation((key: string) => {
+      const defaults: Record<string, string> = {
+        'mail:oauth2ClientId': 'client-id.apps.googleusercontent.com',
+        'mail:oauth2ClientSecret': 'client-secret-value',
+        'mail:oauth2RefreshToken': 'refresh-token-value',
+        'mail:oauth2User': 'user@gmail.com',
+      };
+      return key in overrides ? overrides[key] : defaults[key];
+    });
+  };
+
+  describe('credential validation with type guards', () => {
+    it('should return null when clientId is missing', () => {
+      validCredentials({ 'mail:oauth2ClientId': null });
+
+      const result = createOAuth2Client(mockConfigManager);
+
+      expect(result).toBeNull();
+    });
+
+    it('should return null when clientSecret is missing', () => {
+      validCredentials({ 'mail:oauth2ClientSecret': null });
+
+      const result = createOAuth2Client(mockConfigManager);
+
+      expect(result).toBeNull();
+    });
+
+    it('should return null when refreshToken is missing', () => {
+      validCredentials({ 'mail:oauth2RefreshToken': null });
+
+      const result = createOAuth2Client(mockConfigManager);
+
+      expect(result).toBeNull();
+    });
+
+    it('should return null when user is missing', () => {
+      validCredentials({ 'mail:oauth2User': null });
+
+      const result = createOAuth2Client(mockConfigManager);
+
+      expect(result).toBeNull();
+    });
+
+    it('should return null when clientId is empty string', () => {
+      validCredentials({ 'mail:oauth2ClientId': '' });
+
+      const result = createOAuth2Client(mockConfigManager);
+
+      expect(result).toBeNull();
+    });
+
+    it('should return null when clientId is whitespace only', () => {
+      validCredentials({ 'mail:oauth2ClientId': '   ' });
+
+      const result = createOAuth2Client(mockConfigManager);
+
+      expect(result).toBeNull();
+    });
+  });
+
+  describe('transport creation', () => {
+    it('should create transport with valid credentials', () => {
+      validCredentials();
+
+      const result = createOAuth2Client(mockConfigManager);
+
+      expect(result).not.toBeNull();
+      expect(result?.options).toMatchObject({
+        service: 'gmail',
+        auth: {
+          type: 'OAuth2',
+          user: 'user@gmail.com',
+          clientId: 'client-id.apps.googleusercontent.com',
+          clientSecret: 'client-secret-value',
+          refreshToken: 'refresh-token-value',
+        },
+      });
+    });
+  });
+
+  describe('option parameter override', () => {
+    it('should use provided option instead of config when option is passed', () => {
+      const customOption = {
+        service: 'gmail' as const,
+        auth: {
+          type: 'OAuth2' as const,
+          user: 'custom@gmail.com',
+          clientId: 'custom-client-id',
+          clientSecret: 'custom-secret',
+          refreshToken: 'custom-token',
+        },
+      };
+
+      const result = createOAuth2Client(mockConfigManager, customOption);
+
+      expect(result).not.toBeNull();
+      expect(mockConfigManager.getConfig).not.toHaveBeenCalled();
+    });
+  });
+});

+ 77 - 0
apps/app/src/server/service/mail/oauth2.ts

@@ -0,0 +1,77 @@
+import { toNonBlankStringOrUndefined } from '@growi/core/dist/interfaces';
+import type { Transporter } from 'nodemailer';
+import nodemailer from 'nodemailer';
+import type SMTPTransport from 'nodemailer/lib/smtp-transport';
+
+import loggerFactory from '~/utils/logger';
+
+import type { IConfigManagerForApp } from '../config-manager';
+import type { StrictOAuth2Options } from './types';
+
+const logger = loggerFactory('growi:service:mail');
+
+/**
+ * Creates a Gmail OAuth2 transport client with type-safe credentials.
+ *
+ * @param configManager - Configuration manager instance
+ * @param option - Optional OAuth2 configuration (for testing)
+ * @returns nodemailer Transporter instance, or null if credentials incomplete
+ *
+ * @remarks
+ * Config keys required: mail:oauth2User, mail:oauth2ClientId,
+ *                       mail:oauth2ClientSecret, mail:oauth2RefreshToken
+ *
+ * All credentials must be non-blank strings (length > 0 after trim).
+ * Uses NonBlankString branded type to prevent empty string credentials at compile time.
+ */
+export function createOAuth2Client(
+  configManager: IConfigManagerForApp,
+  option?: SMTPTransport.Options,
+): Transporter | null {
+  if (!option) {
+    const clientId = toNonBlankStringOrUndefined(
+      configManager.getConfig('mail:oauth2ClientId'),
+    );
+    const clientSecret = toNonBlankStringOrUndefined(
+      configManager.getConfig('mail:oauth2ClientSecret'),
+    );
+    const refreshToken = toNonBlankStringOrUndefined(
+      configManager.getConfig('mail:oauth2RefreshToken'),
+    );
+    const user = toNonBlankStringOrUndefined(
+      configManager.getConfig('mail:oauth2User'),
+    );
+
+    if (
+      clientId === undefined ||
+      clientSecret === undefined ||
+      refreshToken === undefined ||
+      user === undefined
+    ) {
+      logger.warn(
+        'OAuth 2.0 credentials incomplete, skipping transport creation',
+      );
+      return null;
+    }
+
+    const strictOptions: StrictOAuth2Options = {
+      service: 'gmail',
+      auth: {
+        type: 'OAuth2',
+        user,
+        clientId,
+        clientSecret,
+        refreshToken,
+      },
+    };
+
+    // biome-ignore lint/style/noParameterAssign: constructing option from validated credentials
+    option = strictOptions;
+  }
+
+  const client = nodemailer.createTransport(option);
+
+  logger.debug('mailer set up for OAuth2', client);
+
+  return client;
+}

+ 82 - 0
apps/app/src/server/service/mail/ses.spec.ts

@@ -0,0 +1,82 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { createSESClient } from './ses';
+
+describe('createSESClient', () => {
+  let mockConfigManager: any;
+
+  beforeEach(() => {
+    mockConfigManager = {
+      getConfig: vi.fn(),
+    };
+  });
+
+  describe('credential validation', () => {
+    it('should return null when accessKeyId is missing', () => {
+      mockConfigManager.getConfig.mockImplementation((key: string) => {
+        if (key === 'mail:sesAccessKeyId') return null;
+        if (key === 'mail:sesSecretAccessKey') return 'secretKey123';
+        return undefined;
+      });
+
+      const result = createSESClient(mockConfigManager);
+
+      expect(result).toBeNull();
+    });
+
+    it('should return null when secretAccessKey is missing', () => {
+      mockConfigManager.getConfig.mockImplementation((key: string) => {
+        if (key === 'mail:sesAccessKeyId') return 'AKIAIOSFODNN7EXAMPLE';
+        if (key === 'mail:sesSecretAccessKey') return null;
+        return undefined;
+      });
+
+      const result = createSESClient(mockConfigManager);
+
+      expect(result).toBeNull();
+    });
+
+    it('should return null when both credentials are missing', () => {
+      mockConfigManager.getConfig.mockImplementation((key: string) => {
+        if (key === 'mail:sesAccessKeyId') return null;
+        if (key === 'mail:sesSecretAccessKey') return null;
+        return undefined;
+      });
+
+      const result = createSESClient(mockConfigManager);
+
+      expect(result).toBeNull();
+    });
+  });
+
+  describe('transport creation', () => {
+    it('should create transport with AWS credentials', () => {
+      mockConfigManager.getConfig.mockImplementation((key: string) => {
+        if (key === 'mail:sesAccessKeyId') return 'AKIAIOSFODNN7EXAMPLE';
+        if (key === 'mail:sesSecretAccessKey')
+          return 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY';
+        return undefined;
+      });
+
+      const result = createSESClient(mockConfigManager);
+
+      expect(result).not.toBeNull();
+      // SES transport uses nodemailer-ses-transport wrapper, so we check for transport object
+      expect(result?.transporter).toBeDefined();
+    });
+  });
+
+  describe('option parameter override', () => {
+    it('should use provided option instead of config when option is passed', () => {
+      const customOption = {
+        accessKeyId: 'CUSTOM_ACCESS_KEY',
+        secretAccessKey: 'CUSTOM_SECRET_KEY',
+      };
+
+      const result = createSESClient(mockConfigManager, customOption);
+
+      expect(result).not.toBeNull();
+      expect(mockConfigManager.getConfig).not.toHaveBeenCalled();
+    });
+  });
+});

+ 45 - 0
apps/app/src/server/service/mail/ses.ts

@@ -0,0 +1,45 @@
+import type { Transporter } from 'nodemailer';
+import nodemailer from 'nodemailer';
+
+import loggerFactory from '~/utils/logger';
+
+import type { IConfigManagerForApp } from '../config-manager';
+
+const logger = loggerFactory('growi:service:mail');
+
+/**
+ * Creates an AWS SES transport client for email sending.
+ *
+ * @param configManager - Configuration manager instance
+ * @param option - Optional SES configuration (for testing)
+ * @returns nodemailer Transporter instance, or null if credentials incomplete
+ *
+ * @remarks
+ * Config keys required: mail:sesAccessKeyId, mail:sesSecretAccessKey
+ */
+export function createSESClient(
+  configManager: IConfigManagerForApp,
+  option?: { accessKeyId: string; secretAccessKey: string },
+): Transporter | null {
+  if (!option) {
+    const accessKeyId = configManager.getConfig('mail:sesAccessKeyId');
+    const secretAccessKey = configManager.getConfig('mail:sesSecretAccessKey');
+
+    if (accessKeyId == null || secretAccessKey == null) {
+      return null;
+    }
+
+    // biome-ignore lint/style/noParameterAssign: maintaining existing behavior
+    option = {
+      accessKeyId,
+      secretAccessKey,
+    };
+  }
+
+  const ses = require('nodemailer-ses-transport');
+  const client = nodemailer.createTransport(ses(option));
+
+  logger.debug('mailer set up for SES', client);
+
+  return client;
+}

+ 153 - 0
apps/app/src/server/service/mail/smtp.spec.ts

@@ -0,0 +1,153 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { createSMTPClient } from './smtp';
+
+describe('createSMTPClient', () => {
+  let mockConfigManager: any;
+
+  beforeEach(() => {
+    mockConfigManager = {
+      getConfig: vi.fn(),
+    };
+  });
+
+  describe('credential validation', () => {
+    it('should return null when host is missing', () => {
+      mockConfigManager.getConfig.mockImplementation((key: string) => {
+        if (key === 'mail:smtpHost') return null;
+        if (key === 'mail:smtpPort') return 587;
+        return undefined;
+      });
+
+      const result = createSMTPClient(mockConfigManager);
+
+      expect(result).toBeNull();
+    });
+
+    it('should return null when port is missing', () => {
+      mockConfigManager.getConfig.mockImplementation((key: string) => {
+        if (key === 'mail:smtpHost') return 'smtp.example.com';
+        if (key === 'mail:smtpPort') return null;
+        return undefined;
+      });
+
+      const result = createSMTPClient(mockConfigManager);
+
+      expect(result).toBeNull();
+    });
+
+    it('should return null when both host and port are missing', () => {
+      mockConfigManager.getConfig.mockImplementation((key: string) => {
+        if (key === 'mail:smtpHost') return null;
+        if (key === 'mail:smtpPort') return null;
+        return undefined;
+      });
+
+      const result = createSMTPClient(mockConfigManager);
+
+      expect(result).toBeNull();
+    });
+  });
+
+  describe('transport creation', () => {
+    it('should create transport with host and port only', () => {
+      mockConfigManager.getConfig.mockImplementation((key: string) => {
+        if (key === 'mail:smtpHost') return 'smtp.example.com';
+        if (key === 'mail:smtpPort') return 587;
+        return undefined;
+      });
+
+      const result = createSMTPClient(mockConfigManager);
+
+      expect(result).not.toBeNull();
+      expect(result?.options).toMatchObject({
+        host: 'smtp.example.com',
+        port: 587,
+        tls: { rejectUnauthorized: false },
+      });
+    });
+
+    it('should include auth when user and password are provided', () => {
+      mockConfigManager.getConfig.mockImplementation((key: string) => {
+        if (key === 'mail:smtpHost') return 'smtp.example.com';
+        if (key === 'mail:smtpPort') return 587;
+        if (key === 'mail:smtpUser') return 'testuser';
+        if (key === 'mail:smtpPassword') return 'testpass';
+        return undefined;
+      });
+
+      const result = createSMTPClient(mockConfigManager);
+
+      expect(result).not.toBeNull();
+      expect(result?.options).toMatchObject({
+        host: 'smtp.example.com',
+        port: 587,
+        auth: {
+          user: 'testuser',
+          pass: 'testpass',
+        },
+        tls: { rejectUnauthorized: false },
+      });
+    });
+
+    it('should set secure: true for port 465', () => {
+      mockConfigManager.getConfig.mockImplementation((key: string) => {
+        if (key === 'mail:smtpHost') return 'smtp.example.com';
+        if (key === 'mail:smtpPort') return 465;
+        return undefined;
+      });
+
+      const result = createSMTPClient(mockConfigManager);
+
+      expect(result).not.toBeNull();
+      expect(result?.options).toMatchObject({
+        host: 'smtp.example.com',
+        port: 465,
+        secure: true,
+        tls: { rejectUnauthorized: false },
+      });
+    });
+
+    it('should not set secure: true for port 587', () => {
+      mockConfigManager.getConfig.mockImplementation((key: string) => {
+        if (key === 'mail:smtpHost') return 'smtp.example.com';
+        if (key === 'mail:smtpPort') return 587;
+        return undefined;
+      });
+
+      const result = createSMTPClient(mockConfigManager);
+
+      expect(result).not.toBeNull();
+      expect(
+        (result?.options as Record<string, unknown>).secure,
+      ).toBeUndefined();
+    });
+  });
+
+  describe('option parameter override', () => {
+    it('should use provided option instead of config when option is passed', () => {
+      const customOption = {
+        host: 'custom.smtp.com',
+        port: 2525,
+        auth: {
+          user: 'customuser',
+          pass: 'custompass',
+        },
+      };
+
+      const result = createSMTPClient(mockConfigManager, customOption);
+
+      expect(result).not.toBeNull();
+      expect(result?.options).toMatchObject({
+        host: 'custom.smtp.com',
+        port: 2525,
+        auth: {
+          user: 'customuser',
+          pass: 'custompass',
+        },
+        tls: { rejectUnauthorized: false },
+      });
+      expect(mockConfigManager.getConfig).not.toHaveBeenCalled();
+    });
+  });
+});

+ 64 - 0
apps/app/src/server/service/mail/smtp.ts

@@ -0,0 +1,64 @@
+import type { Transporter } from 'nodemailer';
+import nodemailer from 'nodemailer';
+import type SMTPTransport from 'nodemailer/lib/smtp-transport';
+
+import loggerFactory from '~/utils/logger';
+
+import type { IConfigManagerForApp } from '../config-manager';
+
+const logger = loggerFactory('growi:service:mail');
+
+/**
+ * Creates an SMTP transport client for email sending.
+ *
+ * @param configManager - Configuration manager instance
+ * @param option - Optional SMTP configuration (for testing)
+ * @returns nodemailer Transporter instance, or null if credentials incomplete
+ *
+ * @remarks
+ * Config keys required: mail:smtpHost, mail:smtpPort
+ * Config keys optional: mail:smtpUser, mail:smtpPassword (auth)
+ */
+export function createSMTPClient(
+  configManager: IConfigManagerForApp,
+  option?: SMTPTransport.Options,
+): Transporter | null {
+  logger.debug('createSMTPClient option', option);
+
+  let smtpOption: SMTPTransport.Options;
+
+  if (option) {
+    smtpOption = option;
+  } else {
+    const host = configManager.getConfig('mail:smtpHost');
+    const port = configManager.getConfig('mail:smtpPort');
+
+    if (host == null || port == null) {
+      return null;
+    }
+
+    smtpOption = {
+      host,
+      port: Number(port),
+    };
+
+    if (configManager.getConfig('mail:smtpPassword')) {
+      smtpOption.auth = {
+        user: configManager.getConfig('mail:smtpUser'),
+        pass: configManager.getConfig('mail:smtpPassword'),
+      };
+    }
+
+    if (smtpOption.port === 465) {
+      smtpOption.secure = true;
+    }
+  }
+
+  smtpOption.tls = { rejectUnauthorized: false };
+
+  const client = nodemailer.createTransport(smtpOption);
+
+  logger.debug('mailer set up for SMTP', client);
+
+  return client;
+}

+ 53 - 0
apps/app/src/server/service/mail/types.ts

@@ -0,0 +1,53 @@
+import type { NonBlankString } from '@growi/core/dist/interfaces';
+import type SMTPTransport from 'nodemailer/lib/smtp-transport';
+
+/**
+ * Type-safe OAuth2 configuration with non-blank string validation.
+ *
+ * This type is stricter than nodemailer's default XOAuth2.Options, which allows
+ * empty strings. By using NonBlankString, we prevent empty credentials at compile time,
+ * matching nodemailer's runtime falsy checks (`!this.options.refreshToken`).
+ *
+ * @see https://github.com/nodemailer/nodemailer/blob/master/lib/xoauth2/index.js
+ */
+export type StrictOAuth2Options = {
+  service: 'gmail';
+  auth: {
+    type: 'OAuth2';
+    user: NonBlankString;
+    clientId: NonBlankString;
+    clientSecret: NonBlankString;
+    refreshToken: NonBlankString;
+  };
+};
+
+export type MailConfig = {
+  to?: string;
+  from?: string;
+  text?: string;
+  subject?: string;
+};
+
+export type EmailConfig = {
+  to: string;
+  from?: string;
+  subject?: string;
+  text?: string;
+  template?: string;
+  vars?: Record<string, unknown>;
+};
+
+export type SendResult = {
+  messageId: string;
+  response: string;
+  envelope: {
+    from: string;
+    to: string[];
+  };
+};
+
+// Type assertion: StrictOAuth2Options is compatible with SMTPTransport.Options
+// This ensures our strict type can be passed to nodemailer.createTransport()
+declare const _typeCheck: StrictOAuth2Options extends SMTPTransport.Options
+  ? true
+  : 'Type mismatch';