|
|
@@ -0,0 +1,759 @@
|
|
|
+# Technical Design: MailService Refactoring
|
|
|
+
|
|
|
+## Overview
|
|
|
+
|
|
|
+This refactoring modernizes the MailService implementation to improve code organization, maintainability, and type safety through modular architecture and compile-time validation.
|
|
|
+
|
|
|
+**Purpose**: This feature delivers improved code maintainability and compile-time type safety to developers working with email functionality. The refactoring separates transmission methods (SMTP, SES, OAuth2) into independent modules and leverages TypeScript's type system to prevent credential-related runtime errors.
|
|
|
+
|
|
|
+**Users**: Developers maintaining or extending GROWI's email functionality will utilize this for easier testing, debugging, and feature additions.
|
|
|
+
|
|
|
+**Impact**: Changes the current monolithic MailService implementation (~408 lines) by restructuring into a modular architecture with separate transport modules, while maintaining full backward compatibility with existing code.
|
|
|
+
|
|
|
+### Goals
|
|
|
+
|
|
|
+- Organize mail-related files into feature-based directory structure (`mail/`)
|
|
|
+- Separate email transmission methods (SMTP, SES, OAuth2) into independent, testable modules
|
|
|
+- Replace runtime falsy checks with TypeScript type guards for OAuth2 credentials
|
|
|
+- Maintain 100% backward compatibility with existing MailService public API
|
|
|
+- Improve type safety using @growi/core's NonBlankString branded types
|
|
|
+
|
|
|
+### Non-Goals
|
|
|
+
|
|
|
+- Changing MailService public API or behavior (beyond internal organization)
|
|
|
+- Adding new transmission methods or email features
|
|
|
+- Modifying retry logic, error handling, or failed email storage mechanisms
|
|
|
+- Migrating away from nodemailer library
|
|
|
+- Implementing OAuth2 token refresh logic improvements (deferred to future iteration)
|
|
|
+
|
|
|
+## Architecture
|
|
|
+
|
|
|
+### Existing Architecture Analysis
|
|
|
+
|
|
|
+**Current Implementation**:
|
|
|
+- Single file: `src/server/service/mail.ts` (~408 lines)
|
|
|
+- MailService class with three inline transport creation methods:
|
|
|
+ - `createSMTPClient()` - SMTP transport with username/password auth
|
|
|
+ - `createSESClient()` - AWS SES transport with IAM credentials
|
|
|
+ - `createOAuth2Client()` - Gmail OAuth2 transport with refresh tokens
|
|
|
+- Implements `S2sMessageHandlable` interface for cross-service configuration synchronization
|
|
|
+- Uses runtime falsy checks for credential validation: `!clientId || !clientSecret || !refreshToken || !user`
|
|
|
+- Single test file: `src/server/service/mail.spec.ts`
|
|
|
+
|
|
|
+**Current Constraints**:
|
|
|
+- Must maintain `S2sMessageHandlable` interface implementation
|
|
|
+- Must preserve `constructor(crowi: Crowi)` signature
|
|
|
+- Must continue to work with existing config keys (`mail:transmissionMethod`, `mail:oauth2ClientId`, etc.)
|
|
|
+- Import path `~/server/service/mail` must remain valid
|
|
|
+
|
|
|
+**Integration Points**:
|
|
|
+- Config API (`src/server/routes/apiv3/app-settings/index.ts`) - reads/updates mail settings
|
|
|
+- Global Notification Service (`src/server/service/global-notification/index.ts`) - sends notification emails
|
|
|
+- Crowi initialization - instantiates MailService during server startup
|
|
|
+
|
|
|
+### Architecture Pattern & Boundary Map
|
|
|
+
|
|
|
+```mermaid
|
|
|
+graph TB
|
|
|
+ subgraph "External Dependencies"
|
|
|
+ Config[ConfigManager]
|
|
|
+ NM[nodemailer]
|
|
|
+ Core[@growi/core]
|
|
|
+ end
|
|
|
+
|
|
|
+ subgraph "mail/ Module"
|
|
|
+ MS[MailService]
|
|
|
+ SMTP[smtp.ts]
|
|
|
+ SES[ses.ts]
|
|
|
+ OAuth2[oauth2.ts]
|
|
|
+ Types[types.ts]
|
|
|
+ Index[index.ts]
|
|
|
+ end
|
|
|
+
|
|
|
+ subgraph "Consumers"
|
|
|
+ API[Config API]
|
|
|
+ Notif[Global Notification]
|
|
|
+ end
|
|
|
+
|
|
|
+ Config --> MS
|
|
|
+ MS --> SMTP
|
|
|
+ MS --> SES
|
|
|
+ MS --> OAuth2
|
|
|
+ SMTP --> NM
|
|
|
+ SES --> NM
|
|
|
+ OAuth2 --> NM
|
|
|
+ OAuth2 --> Core
|
|
|
+ Index --> MS
|
|
|
+ API --> Index
|
|
|
+ Notif --> Index
|
|
|
+```
|
|
|
+
|
|
|
+**Architecture Integration**:
|
|
|
+- **Selected pattern**: Factory Functions - each transport module exports a pure function that creates transport instances
|
|
|
+- **Domain boundaries**:
|
|
|
+ - `MailService` - Orchestration layer, S2S messaging, initialization coordination
|
|
|
+ - `smtp.ts`, `ses.ts`, `oauth2.ts` - Transport-specific credential handling and client creation
|
|
|
+ - `types.ts` - Shared type definitions (MailConfig, EmailConfig, SendResult, StrictOAuth2Options)
|
|
|
+- **Existing patterns preserved**:
|
|
|
+ - Service-based architecture (MailService as singleton in Crowi context)
|
|
|
+ - ConfigManager-based configuration retrieval
|
|
|
+ - S2sMessageHandlable interface for distributed systems
|
|
|
+ - Named exports (except barrel file default export for compatibility)
|
|
|
+- **New components rationale**:
|
|
|
+ - Transport modules: Single responsibility, independently testable, clear failure modes
|
|
|
+ - Barrel export: Maintains backward compatibility while enabling internal modularity
|
|
|
+- **Steering compliance**:
|
|
|
+ - Feature-based directory structure (GROWI monorepo standard)
|
|
|
+ - Immutability (no object mutation in transport creation)
|
|
|
+ - Co-located tests (each module has adjacent .spec.ts file)
|
|
|
+
|
|
|
+### Technology Stack
|
|
|
+
|
|
|
+| Layer | Choice / Version | Role in Feature | Notes |
|
|
|
+|-------|------------------|-----------------|-------|
|
|
|
+| Backend / Services | Node.js 18+ | Runtime environment | No change from existing |
|
|
|
+| Type System | TypeScript 5.x | Compile-time validation | Leverage branded types (NonBlankString) |
|
|
|
+| Email Library | nodemailer 6.9.15 | SMTP/OAuth2 transport | Existing dependency, no upgrade |
|
|
|
+| Type Definitions | @types/nodemailer@6.4.22 | nodemailer TypeScript types | **New dependency** - provides base types for extension |
|
|
|
+| Core Types | @growi/core (NonBlankString) | Branded type validation | Existing infrastructure, used for strict credential types |
|
|
|
+| Testing | Vitest | Unit testing framework | No change from existing test infrastructure |
|
|
|
+
|
|
|
+**Rationale**: @types/nodemailer provides foundational type safety for nodemailer API, while StrictOAuth2Options extends these with NonBlankString for compile-time empty string prevention (see research.md for type compatibility analysis).
|
|
|
+
|
|
|
+## Requirements Traceability
|
|
|
+
|
|
|
+| Requirement | Summary | Components | Interfaces | Flows |
|
|
|
+|-------------|---------|------------|------------|-------|
|
|
|
+| 1.1 | MailService at mail/mail.ts | MailService | - | - |
|
|
|
+| 1.2 | Test file at mail/mail.spec.ts | MailService | - | - |
|
|
|
+| 1.3 | Barrel file at mail/index.ts | index.ts | Exports MailService | - |
|
|
|
+| 1.4 | Import from ~/server/service/mail | index.ts | Default export | - |
|
|
|
+| 1.5 | Legacy import path works | index.ts | Alias resolution | - |
|
|
|
+| 2.1 | SMTP module at mail/smtp.ts | SmtpTransport | createSMTPClient() | - |
|
|
|
+| 2.2 | SES module at mail/ses.ts | SesTransport | createSESClient() | - |
|
|
|
+| 2.3 | OAuth2 module at mail/oauth2.ts | OAuth2Transport | createOAuth2Client() | - |
|
|
|
+| 2.4 | Delegate transport creation | MailService.initialize() | Transport factories | Initialization |
|
|
|
+| 2.5 | Transport function signature | All transport modules | create[Transport]Client | - |
|
|
|
+| 2.6 | Return null for incomplete credentials | All transport modules | - | Error path |
|
|
|
+| 2.7 | Follow immutability conventions | All modules | - | - |
|
|
|
+| 3.1-3.3 | Type infrastructure (@types/nodemailer, StrictOAuth2Options) | types.ts, package.json | Type definitions | - |
|
|
|
+| 3.4-3.7 | Runtime validation (toNonBlankStringOrUndefined, type guards) | oauth2.ts | - | Credential validation |
|
|
|
+| 3.8-3.12 | Compile-time safety (type errors, no any, strict mode) | StrictOAuth2Options, oauth2.ts | Type constraints | Build-time checks |
|
|
|
+| 4.1-4.6 | Backward compatibility | MailService | All existing methods/properties | All existing flows |
|
|
|
+| 5.1-5.7 | Co-located testing | All spec files | Test interfaces | Test execution |
|
|
|
+
|
|
|
+## Implementation Order
|
|
|
+
|
|
|
+The refactoring follows a three-phase approach that ensures type safety from the beginning and maintains a working codebase at each step:
|
|
|
+
|
|
|
+### Phase 1: Type Safety Foundation
|
|
|
+**Requirements**: 3.1-3.3, 3.8-3.12
|
|
|
+
|
|
|
+**Actions**:
|
|
|
+1. Install `@types/nodemailer@6.4.22` as devDependency
|
|
|
+2. Create `src/server/service/mail/types.ts` with:
|
|
|
+ - `StrictOAuth2Options` type definition (with NonBlankString)
|
|
|
+ - Shared types: `MailConfig`, `EmailConfig`, `SendResult`
|
|
|
+3. Verify type compilation with `pnpm run lint:typecheck`
|
|
|
+4. Verify existing tests still pass with `pnpm run test`
|
|
|
+
|
|
|
+**Checkpoint**: ✅ Type definitions exist, existing code unchanged and functional
|
|
|
+
|
|
|
+### Phase 2: Module Extraction
|
|
|
+**Requirements**: 2.1-2.7, 3.4-3.7
|
|
|
+
|
|
|
+**Actions**:
|
|
|
+1. Extract SMTP logic to `smtp.ts` + create `smtp.spec.ts`
|
|
|
+2. Extract SES logic to `ses.ts` + create `ses.spec.ts`
|
|
|
+3. Extract OAuth2 logic to `oauth2.ts` (using NonBlankString) + create `oauth2.spec.ts`
|
|
|
+4. Each module implements `create[Transport]Client(configManager): Transporter | null`
|
|
|
+5. Tests verify null return for incomplete credentials
|
|
|
+
|
|
|
+**Checkpoint**: ✅ Transport modules exist with type-safe implementations, tested in isolation
|
|
|
+
|
|
|
+### Phase 3: Integration & Barrel Export
|
|
|
+**Requirements**: 1.1-1.5, 2.4, 4.1-4.6, 5.1-5.7
|
|
|
+
|
|
|
+**Actions**:
|
|
|
+1. Move `mail.ts` to `mail/mail.ts`
|
|
|
+2. Update MailService to import and delegate to transport modules
|
|
|
+3. Remove inline `createSMTPClient()`, `createSESClient()`, `createOAuth2Client()` methods
|
|
|
+4. Move `mail.spec.ts` to `mail/mail.spec.ts` and update imports
|
|
|
+5. Create `mail/index.ts` barrel export (default export for backward compatibility)
|
|
|
+6. Run full test suite to verify backward compatibility
|
|
|
+
|
|
|
+**Checkpoint**: ✅ Refactoring complete, all tests pass, backward compatibility verified
|
|
|
+
|
|
|
+**Rationale**: This order ensures:
|
|
|
+- Type definitions are available before writing any transport code
|
|
|
+- Each transport module is type-safe from the moment it's created
|
|
|
+- No "add types later" technical debt
|
|
|
+- Continuous verification at each checkpoint
|
|
|
+
|
|
|
+## Components and Interfaces
|
|
|
+
|
|
|
+### Summary Table
|
|
|
+
|
|
|
+| Component | Domain/Layer | Intent | Req Coverage | Key Dependencies (P0/P1) | Contracts |
|
|
|
+|-----------|--------------|--------|--------------|--------------------------|-----------|
|
|
|
+| MailService | Service | Email orchestration and S2S coordination | 1.1-1.5, 2.4, 4.1-4.6 | ConfigManager (P0), Transport modules (P0) | Service, State |
|
|
|
+| SmtpTransport | Transport | SMTP client creation with auth | 2.1, 2.5-2.7 | nodemailer (P0), ConfigManager (P0) | Service |
|
|
|
+| SesTransport | Transport | AWS SES client creation | 2.2, 2.5-2.7 | nodemailer (P0), ConfigManager (P0) | Service |
|
|
|
+| OAuth2Transport | Transport | OAuth2 client with type-safe credentials | 2.3, 2.5-2.7, 3.1-3.12 | nodemailer (P0), @growi/core (P0), ConfigManager (P0) | Service |
|
|
|
+| types.ts | Types | Shared type definitions | 3.1-3.3 | @growi/core (P0), @types/nodemailer (P1) | Type definitions |
|
|
|
+| index.ts | Barrel | Public API export | 1.3-1.5 | MailService (P0) | Export contract |
|
|
|
+
|
|
|
+### Service Layer
|
|
|
+
|
|
|
+#### MailService
|
|
|
+
|
|
|
+| Field | Detail |
|
|
|
+|-------|--------|
|
|
|
+| Intent | Orchestrate email sending, coordinate transport initialization, handle S2S configuration updates |
|
|
|
+| Requirements | 1.1-1.5, 2.4, 4.1-4.6 |
|
|
|
+
|
|
|
+**Responsibilities & Constraints**
|
|
|
+- Orchestrate transport selection based on `mail:transmissionMethod` config value
|
|
|
+- Maintain existing public API: `send()`, `initialize()`, `publishUpdatedMessage()`
|
|
|
+- Implement `S2sMessageHandlable` interface for distributed configuration synchronization
|
|
|
+- Delegate transport creation to specialized modules
|
|
|
+- Preserve retry logic, error handling, and failed email storage behavior
|
|
|
+
|
|
|
+**Dependencies**
|
|
|
+- Inbound: Config API, Global Notification Service — email sending requests (P0)
|
|
|
+- Outbound: SmtpTransport, SesTransport, OAuth2Transport — transport creation (P0)
|
|
|
+- Outbound: ConfigManager — configuration retrieval (P0)
|
|
|
+- External: nodemailer — email transmission (P0)
|
|
|
+
|
|
|
+**Contracts**: Service [x] / State [x]
|
|
|
+
|
|
|
+##### Service Interface
|
|
|
+
|
|
|
+```typescript
|
|
|
+class MailService implements S2sMessageHandlable {
|
|
|
+ // Public properties (existing)
|
|
|
+ isMailerSetup: boolean;
|
|
|
+ mailer: any; // nodemailer.Transporter
|
|
|
+ mailConfig: MailConfig;
|
|
|
+
|
|
|
+ // Constructor (existing signature)
|
|
|
+ constructor(crowi: Crowi);
|
|
|
+
|
|
|
+ // Public methods (existing)
|
|
|
+ initialize(): void;
|
|
|
+ send(config: EmailConfig): Promise<SendResult>;
|
|
|
+ publishUpdatedMessage(): Promise<void>;
|
|
|
+
|
|
|
+ // S2sMessageHandlable interface (existing)
|
|
|
+ shouldHandleS2sMessage(s2sMessage: S2sMessage): boolean;
|
|
|
+ handleS2sMessage(s2sMessage: S2sMessage): Promise<void>;
|
|
|
+
|
|
|
+ // Existing helper methods (unchanged)
|
|
|
+ sendWithRetry(config: EmailConfig, maxRetries?: number): Promise<SendResult>;
|
|
|
+ storeFailedEmail(config: EmailConfig, error: Error & { code?: string }): Promise<void>;
|
|
|
+ maskCredential(credential: string): string;
|
|
|
+ exponentialBackoff(attempt: number): Promise<void>;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+**Preconditions**:
|
|
|
+- Crowi instance must be initialized with ConfigManager, appService, s2sMessagingService
|
|
|
+- Config must include `mail:from` and valid `mail:transmissionMethod`
|
|
|
+
|
|
|
+**Postconditions**:
|
|
|
+- `isMailerSetup` reflects successful transport initialization
|
|
|
+- `mailer` contains nodemailer transport instance or null
|
|
|
+
|
|
|
+**Invariants**:
|
|
|
+- If `isMailerSetup === true`, then `mailer !== null`
|
|
|
+- `send()` method preserves existing behavior (retry logic, error handling, failed email storage)
|
|
|
+
|
|
|
+##### State Management
|
|
|
+
|
|
|
+**State model**:
|
|
|
+- `isMailerSetup: boolean` - Flag indicating successful transport initialization
|
|
|
+- `mailer: any` - Nodemailer transport instance (SMTP/SES/OAuth2)
|
|
|
+- `mailConfig: MailConfig` - Current mail configuration (from, subject)
|
|
|
+- `lastLoadedAt: Date` - Timestamp of last configuration load (for S2S sync)
|
|
|
+
|
|
|
+**Persistence & consistency**:
|
|
|
+- No direct persistence (reads from ConfigManager)
|
|
|
+- S2S messaging ensures configuration consistency across distributed instances
|
|
|
+
|
|
|
+**Concurrency strategy**:
|
|
|
+- `initialize()` called synchronously during construction and S2S message handling
|
|
|
+- No locking required (single-threaded Node.js event loop)
|
|
|
+
|
|
|
+**Implementation Notes**
|
|
|
+- **Integration**: Delegates transport creation to `createSMTPClient()`, `createSESClient()`, `createOAuth2Client()` factory functions
|
|
|
+- **Validation**: Transport modules handle credential validation; MailService handles null return (transport creation failure)
|
|
|
+- **Risks**: Existing consumers must not be affected by internal restructuring; integration tests verify import paths and method signatures
|
|
|
+
|
|
|
+### Transport Layer
|
|
|
+
|
|
|
+#### SmtpTransport
|
|
|
+
|
|
|
+| Field | Detail |
|
|
|
+|-------|--------|
|
|
|
+| Intent | Create nodemailer SMTP transport with username/password authentication |
|
|
|
+| Requirements | 2.1, 2.5-2.7 |
|
|
|
+
|
|
|
+**Responsibilities & Constraints**
|
|
|
+- Read SMTP configuration from ConfigManager (`mail:smtpHost`, `mail:smtpPort`, `mail:smtpUser`, `mail:smtpPassword`)
|
|
|
+- Return `null` if required credentials are missing
|
|
|
+- Configure TLS settings (`rejectUnauthorized: false` for self-signed certificates)
|
|
|
+
|
|
|
+**Dependencies**
|
|
|
+- Inbound: MailService.initialize() — transport creation request (P0)
|
|
|
+- Outbound: ConfigManager — config retrieval (P0)
|
|
|
+- External: nodemailer — transport instance creation (P0)
|
|
|
+
|
|
|
+**Contracts**: Service [x]
|
|
|
+
|
|
|
+##### Service Interface
|
|
|
+
|
|
|
+```typescript
|
|
|
+/**
|
|
|
+ * 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;
|
|
|
+```
|
|
|
+
|
|
|
+**Preconditions**:
|
|
|
+- ConfigManager contains `mail:smtpHost` and `mail:smtpPort`
|
|
|
+
|
|
|
+**Postconditions**:
|
|
|
+- Returns nodemailer SMTP transport instance with configured auth (if credentials present)
|
|
|
+- Returns `null` if host or port missing
|
|
|
+
|
|
|
+**Implementation Notes**
|
|
|
+- **Integration**: Called by MailService.initialize() when `mail:transmissionMethod === 'smtp'`
|
|
|
+- **Validation**: Checks `host == null || port == null` for null return
|
|
|
+- **Risks**: TLS configuration (`rejectUnauthorized: false`) may pose security risk in production
|
|
|
+
|
|
|
+#### SesTransport
|
|
|
+
|
|
|
+| Field | Detail |
|
|
|
+|-------|--------|
|
|
|
+| Intent | Create nodemailer SES transport with AWS IAM credentials |
|
|
|
+| Requirements | 2.2, 2.5-2.7 |
|
|
|
+
|
|
|
+**Responsibilities & Constraints**
|
|
|
+- Read SES configuration from ConfigManager (`mail:sesAccessKeyId`, `mail:sesSecretAccessKey`)
|
|
|
+- Return `null` if required credentials are missing
|
|
|
+- Use nodemailer-ses-transport adapter
|
|
|
+
|
|
|
+**Dependencies**
|
|
|
+- Inbound: MailService.initialize() — transport creation request (P0)
|
|
|
+- Outbound: ConfigManager — config retrieval (P0)
|
|
|
+- External: nodemailer, nodemailer-ses-transport — transport instance creation (P0)
|
|
|
+
|
|
|
+**Contracts**: Service [x]
|
|
|
+
|
|
|
+##### Service Interface
|
|
|
+
|
|
|
+```typescript
|
|
|
+/**
|
|
|
+ * 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;
|
|
|
+```
|
|
|
+
|
|
|
+**Preconditions**:
|
|
|
+- ConfigManager contains `mail:sesAccessKeyId` and `mail:sesSecretAccessKey`
|
|
|
+
|
|
|
+**Postconditions**:
|
|
|
+- Returns nodemailer SES transport instance with AWS credentials
|
|
|
+- Returns `null` if access key or secret key missing
|
|
|
+
|
|
|
+**Implementation Notes**
|
|
|
+- **Integration**: Called by MailService.initialize() when `mail:transmissionMethod === 'ses'`
|
|
|
+- **Validation**: Checks `accessKeyId == null || secretAccessKey == null` for null return
|
|
|
+- **Risks**: Requires nodemailer-ses-transport dependency (existing)
|
|
|
+
|
|
|
+#### OAuth2Transport
|
|
|
+
|
|
|
+| Field | Detail |
|
|
|
+|-------|--------|
|
|
|
+| Intent | Create nodemailer OAuth2 transport with type-safe, non-blank credentials |
|
|
|
+| Requirements | 2.3, 2.5-2.7, 3.1-3.12 |
|
|
|
+
|
|
|
+**Responsibilities & Constraints**
|
|
|
+- Read OAuth2 configuration from ConfigManager (`mail:oauth2User`, `mail:oauth2ClientId`, `mail:oauth2ClientSecret`, `mail:oauth2RefreshToken`)
|
|
|
+- Convert config values to `NonBlankString` using `toNonBlankStringOrUndefined()`
|
|
|
+- Return `null` if any credential is `undefined` after conversion
|
|
|
+- Construct `StrictOAuth2Options` with type-safe credentials
|
|
|
+- Log warning for incomplete credentials
|
|
|
+
|
|
|
+**Dependencies**
|
|
|
+- Inbound: MailService.initialize() — transport creation request (P0)
|
|
|
+- Outbound: ConfigManager — config retrieval (P0)
|
|
|
+- Outbound: @growi/core — NonBlankString helpers (P0)
|
|
|
+- External: nodemailer — transport instance creation (P0)
|
|
|
+
|
|
|
+**Contracts**: Service [x]
|
|
|
+
|
|
|
+##### Service Interface
|
|
|
+
|
|
|
+```typescript
|
|
|
+/**
|
|
|
+ * 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.
|
|
|
+ *
|
|
|
+ * @example
|
|
|
+ * ```typescript
|
|
|
+ * const transport = createOAuth2Client(configManager);
|
|
|
+ * if (transport === null) {
|
|
|
+ * logger.warn('OAuth2 credentials incomplete');
|
|
|
+ * }
|
|
|
+ * ```
|
|
|
+ */
|
|
|
+export function createOAuth2Client(
|
|
|
+ configManager: IConfigManagerForApp,
|
|
|
+ option?: SMTPTransport.Options
|
|
|
+): Transporter | null;
|
|
|
+```
|
|
|
+
|
|
|
+**Preconditions**:
|
|
|
+- ConfigManager contains all four OAuth2 credential keys
|
|
|
+- Credentials are non-blank strings (validated at compile time via NonBlankString type)
|
|
|
+
|
|
|
+**Postconditions**:
|
|
|
+- Returns nodemailer OAuth2 transport instance with Gmail service configuration
|
|
|
+- Returns `null` if any credential is blank or missing
|
|
|
+- Logs warning when returning null
|
|
|
+
|
|
|
+**Invariants**:
|
|
|
+- If function returns non-null transport, all credentials are NonBlankString (guaranteed by type system)
|
|
|
+
|
|
|
+**Implementation Notes**
|
|
|
+- **Integration**: Called by MailService.initialize() when `mail:transmissionMethod === 'oauth2'`
|
|
|
+- **Validation**: Uses `toNonBlankStringOrUndefined()` for conversion, then type guards (`=== undefined`) for null check
|
|
|
+- **Risks**: Type safety depends on correct usage of NonBlankString helpers; runtime validation removed in favor of type guards
|
|
|
+
|
|
|
+### Type Layer
|
|
|
+
|
|
|
+#### types.ts
|
|
|
+
|
|
|
+| Field | Detail |
|
|
|
+|-------|--------|
|
|
|
+| Intent | Provide shared type definitions for mail module |
|
|
|
+| Requirements | 3.1-3.3 |
|
|
|
+
|
|
|
+**Responsibilities & Constraints**
|
|
|
+- Define `StrictOAuth2Options` type with NonBlankString credential fields
|
|
|
+- Export shared types: MailConfig, EmailConfig, SendResult
|
|
|
+- Maintain compatibility with nodemailer's SMTPTransport.Options
|
|
|
+
|
|
|
+**Dependencies**
|
|
|
+- Outbound: @growi/core — NonBlankString type (P0)
|
|
|
+- External: @types/nodemailer — SMTPTransport types (P1)
|
|
|
+
|
|
|
+**Contracts**: Type definitions
|
|
|
+
|
|
|
+##### Type Definitions
|
|
|
+
|
|
|
+```typescript
|
|
|
+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: SMTPTransport.Options extends StrictOAuth2Options ? never : 'Type mismatch';
|
|
|
+```
|
|
|
+
|
|
|
+**Implementation Notes**
|
|
|
+- **Integration**: Imported by oauth2.ts and mail.ts for type-safe credential handling
|
|
|
+- **Validation**: Type compatibility verified via `declare const _typeCheck` assertion
|
|
|
+- **Risks**: Future nodemailer type changes may require updates to StrictOAuth2Options
|
|
|
+
|
|
|
+### Export Layer
|
|
|
+
|
|
|
+#### index.ts
|
|
|
+
|
|
|
+| Field | Detail |
|
|
|
+|-------|--------|
|
|
|
+| Intent | Provide backward-compatible barrel export for MailService |
|
|
|
+| Requirements | 1.3-1.5 |
|
|
|
+
|
|
|
+**Responsibilities & Constraints**
|
|
|
+- Export MailService as default export (backward compatibility)
|
|
|
+- Export types as named exports (future extensibility)
|
|
|
+
|
|
|
+**Contracts**: Export contract
|
|
|
+
|
|
|
+```typescript
|
|
|
+/**
|
|
|
+ * Mail service barrel export.
|
|
|
+ *
|
|
|
+ * Maintains backward compatibility with existing import pattern:
|
|
|
+ * `import MailService from '~/server/service/mail'`
|
|
|
+ */
|
|
|
+export { default } from './mail';
|
|
|
+export type { MailConfig, EmailConfig, SendResult, StrictOAuth2Options } from './types';
|
|
|
+```
|
|
|
+
|
|
|
+## Error Handling
|
|
|
+
|
|
|
+### Error Strategy
|
|
|
+
|
|
|
+The refactored MailService maintains existing error handling behavior:
|
|
|
+- Transport creation failures return `null` (logged as warnings)
|
|
|
+- Email send failures trigger retry logic with exponential backoff
|
|
|
+- Final failures stored in FailedEmail collection for manual review
|
|
|
+
|
|
|
+### Error Categories and Responses
|
|
|
+
|
|
|
+**Transport Initialization Errors** (Credentials):
|
|
|
+- Incomplete credentials → Return `null`, log warning, set `isMailerSetup = false`
|
|
|
+- Type guard validation: `credential === undefined` (OAuth2 module)
|
|
|
+- Null check validation: `host == null` (SMTP/SES modules)
|
|
|
+
|
|
|
+**Email Send Errors** (Runtime):
|
|
|
+- OAuth2 token refresh failure (invalid_grant) → Retry with backoff, store failed email after max retries
|
|
|
+- SMTP/SES connection errors → Retry with backoff
|
|
|
+- Validation errors (missing recipient) → Immediate failure (no retry)
|
|
|
+
|
|
|
+**Type Errors** (Compile-time):
|
|
|
+- Empty string assigned to OAuth2 credential → TypeScript compile error
|
|
|
+- Invalid type passed to transport factory → TypeScript compile error
|
|
|
+
|
|
|
+### Monitoring
|
|
|
+
|
|
|
+No changes to existing monitoring approach:
|
|
|
+- Structured logging with tags: `oauth2_email_success`, `oauth2_token_refresh_failure`, `gmail_api_error`
|
|
|
+- Failed emails stored in MongoDB (FailedEmail collection)
|
|
|
+- S2S messaging for configuration change propagation
|
|
|
+
|
|
|
+## Testing Strategy
|
|
|
+
|
|
|
+### Unit Tests: Transport Modules
|
|
|
+
|
|
|
+1. **SMTP Transport**: Verify null return for missing host/port, successful transport creation with valid credentials
|
|
|
+2. **SES Transport**: Verify null return for missing AWS credentials, successful transport creation with access key/secret
|
|
|
+3. **OAuth2 Transport**: Verify null return for incomplete/blank credentials, successful transport creation with NonBlankString credentials, type guard behavior (`=== undefined`)
|
|
|
+4. **Types Module**: Verify StrictOAuth2Options compatibility with SMTPTransport.Options (type-level test)
|
|
|
+
|
|
|
+### Integration Tests: MailService
|
|
|
+
|
|
|
+1. **Initialization Flow**: Verify MailService delegates to correct transport module based on `mail:transmissionMethod` config
|
|
|
+2. **S2S Messaging**: Verify configuration updates propagate via S2sMessageHandlable interface
|
|
|
+3. **Backward Compatibility**: Verify existing import paths resolve correctly (`~/server/service/mail`)
|
|
|
+4. **Error Handling**: Verify `send()` method preserves retry logic, exponential backoff, and failed email storage
|
|
|
+5. **Public API**: Verify all existing methods/properties remain accessible and functional
|
|
|
+
|
|
|
+### E2E Tests (Minimal)
|
|
|
+
|
|
|
+1. **Email Sending**: Verify end-to-end email send with OAuth2 transport (using test credentials)
|
|
|
+2. **Config API Integration**: Verify mail settings can be updated via API and MailService re-initializes
|
|
|
+
|
|
|
+### Type Safety Tests
|
|
|
+
|
|
|
+1. **Compile-time Validation**: Verify empty string assignment to OAuth2 credential produces TypeScript error
|
|
|
+2. **Type Guard Behavior**: Verify `toNonBlankStringOrUndefined()` return type correctly narrows to `NonBlankString | undefined`
|
|
|
+3. **Compatibility**: Verify StrictOAuth2Options can be passed to `nodemailer.createTransport()` without type errors
|
|
|
+
|
|
|
+### Test Coverage Targets
|
|
|
+
|
|
|
+- **Transport modules**: 100% (simple factory functions, easy to test in isolation)
|
|
|
+- **MailService**: Maintain existing coverage (~80%+)
|
|
|
+- **Types**: Type-level tests only (no runtime testing needed)
|
|
|
+
|
|
|
+## Security Considerations
|
|
|
+
|
|
|
+### Credential Handling
|
|
|
+
|
|
|
+**Existing security measures maintained**:
|
|
|
+- Credentials retrieved from ConfigManager (never hardcoded)
|
|
|
+- `maskCredential()` helper for logging (shows only last 4 characters)
|
|
|
+- Failed email storage excludes sensitive credential details
|
|
|
+
|
|
|
+**Improvements**:
|
|
|
+- Type-safe credential validation prevents accidental empty string credentials
|
|
|
+- Compile-time checks reduce risk of runtime credential errors
|
|
|
+
|
|
|
+### TLS/SSL Configuration
|
|
|
+
|
|
|
+**Existing behavior maintained**:
|
|
|
+- SMTP transport uses `rejectUnauthorized: false` for self-signed certificates
|
|
|
+- OAuth2 transport uses Gmail's secure API endpoints
|
|
|
+- SES transport uses AWS SDK defaults (TLS enabled)
|
|
|
+
|
|
|
+**Recommendation**: Document requirement for valid TLS certificates in production (out of scope for this refactoring)
|
|
|
+
|
|
|
+## Migration Strategy
|
|
|
+
|
|
|
+### Phase 1: Preparation
|
|
|
+
|
|
|
+1. Install `@types/nodemailer@6.4.22` as devDependency
|
|
|
+2. Create `src/server/service/mail/` directory
|
|
|
+3. Verify all existing tests pass before refactoring
|
|
|
+
|
|
|
+### Phase 2: Module Extraction
|
|
|
+
|
|
|
+1. Create `types.ts` with shared type definitions (StrictOAuth2Options, MailConfig, EmailConfig, SendResult)
|
|
|
+2. Extract SMTP logic to `smtp.ts` with `createSMTPClient()` function
|
|
|
+3. Extract SES logic to `ses.ts` with `createSESClient()` function
|
|
|
+4. Extract OAuth2 logic to `oauth2.ts` with `createOAuth2Client()` function (using NonBlankString)
|
|
|
+5. Create co-located test files for each transport module
|
|
|
+
|
|
|
+### Phase 3: MailService Refactoring
|
|
|
+
|
|
|
+1. Move `src/server/service/mail.ts` to `src/server/service/mail/mail.ts`
|
|
|
+2. Update MailService to import and delegate to transport modules
|
|
|
+3. Remove inline `createSMTPClient()`, `createSESClient()`, `createOAuth2Client()` methods
|
|
|
+4. Move `src/server/service/mail.spec.ts` to `src/server/service/mail/mail.spec.ts`
|
|
|
+5. Update test file imports
|
|
|
+
|
|
|
+### Phase 4: Barrel Export
|
|
|
+
|
|
|
+1. Create `src/server/service/mail/index.ts` with default export
|
|
|
+2. Verify all existing imports resolve correctly (no changes needed in consuming code)
|
|
|
+3. Run full test suite to verify backward compatibility
|
|
|
+
|
|
|
+### Phase 5: Validation
|
|
|
+
|
|
|
+1. Run type checker (`pnpm run lint:typecheck`)
|
|
|
+2. Run linter (`pnpm run lint:biome`)
|
|
|
+3. Run full test suite (`pnpm run test`)
|
|
|
+4. Run integration tests with real SMTP/OAuth2 credentials
|
|
|
+5. Verify email sending works in development environment
|
|
|
+
|
|
|
+### Rollback Plan
|
|
|
+
|
|
|
+If issues arise after deployment:
|
|
|
+1. Revert commit to restore single-file MailService implementation
|
|
|
+2. Remove `@types/nodemailer` dependency if causing conflicts
|
|
|
+3. No database migrations or data changes required (pure code refactoring)
|
|
|
+
|
|
|
+## Supporting References
|
|
|
+
|
|
|
+### Package Dependencies
|
|
|
+
|
|
|
+```json
|
|
|
+{
|
|
|
+ "dependencies": {
|
|
|
+ "nodemailer": "6.9.15",
|
|
|
+ "nodemailer-ses-transport": "^1.5.1",
|
|
|
+ "ejs": "^3.1.9",
|
|
|
+ "@growi/core": "workspace:*"
|
|
|
+ },
|
|
|
+ "devDependencies": {
|
|
|
+ "@types/nodemailer": "6.4.22" // NEW
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### Directory Structure (After Refactoring)
|
|
|
+
|
|
|
+```
|
|
|
+src/server/service/mail/
|
|
|
+├── index.ts # Barrel export (default: MailService)
|
|
|
+├── mail.ts # MailService class (orchestration)
|
|
|
+├── mail.spec.ts # MailService tests
|
|
|
+├── smtp.ts # SMTP transport factory
|
|
|
+├── smtp.spec.ts # SMTP transport tests
|
|
|
+├── ses.ts # SES transport factory
|
|
|
+├── ses.spec.ts # SES transport tests
|
|
|
+├── oauth2.ts # OAuth2 transport factory (with NonBlankString)
|
|
|
+├── oauth2.spec.ts # OAuth2 transport tests
|
|
|
+└── types.ts # Shared type definitions (StrictOAuth2Options, etc.)
|
|
|
+```
|
|
|
+
|
|
|
+### Config Key Dependencies
|
|
|
+
|
|
|
+**SMTP** (`smtp.ts`):
|
|
|
+- `mail:smtpHost` (required)
|
|
|
+- `mail:smtpPort` (required)
|
|
|
+- `mail:smtpUser` (optional, for auth)
|
|
|
+- `mail:smtpPassword` (optional, for auth)
|
|
|
+
|
|
|
+**SES** (`ses.ts`):
|
|
|
+- `mail:sesAccessKeyId` (required)
|
|
|
+- `mail:sesSecretAccessKey` (required)
|
|
|
+
|
|
|
+**OAuth2** (`oauth2.ts`):
|
|
|
+- `mail:oauth2User` (required, NonBlankString)
|
|
|
+- `mail:oauth2ClientId` (required, NonBlankString)
|
|
|
+- `mail:oauth2ClientSecret` (required, NonBlankString)
|
|
|
+- `mail:oauth2RefreshToken` (required, NonBlankString)
|
|
|
+
|
|
|
+**Common** (all transports):
|
|
|
+- `mail:from` (required, sender address)
|
|
|
+- `mail:transmissionMethod` (required, one of: 'smtp', 'ses', 'oauth2')
|