research.md 20 KB

Research & Design Decisions


Purpose: Capture discovery findings, architectural investigations, and rationale that inform the technical design for OAuth 2.0 email support.

Usage:

  • Log research activities and outcomes during the discovery phase.
  • Document design decision trade-offs that are too detailed for design.md.
  • Provide references and evidence for future audits or reuse. ---

Summary

  • Feature: oauth2-email-support
  • Discovery Scope: Extension (integrating OAuth2 into existing mail service architecture)
  • Key Findings:
    • Existing mail service supports SMTP and SES via transmission method pattern
    • Nodemailer has built-in OAuth2 support for Gmail with automatic token refresh
    • Admin UI follows modular pattern with separate setting components per transmission method
    • Config management uses mail:* namespace with type-safe definitions

Research Log

Existing Mail Service Architecture

  • Context: Need to understand integration points for OAuth2 support
  • Sources Consulted:
    • apps/app/src/server/service/mail.ts (MailService implementation)
    • apps/app/src/client/components/Admin/App/MailSetting.tsx (Admin UI)
    • apps/app/src/server/service/config-manager/config-definition.ts (Config schema)
  • Findings:
    • MailService uses factory pattern: createSMTPClient(), createSESClient()
    • Transmission method determined by mail:transmissionMethod config value ('smtp' | 'ses')
    • initialize() method called on service startup and S2S message updates
    • Nodemailer transporter created based on transmission method
    • Admin UI uses conditional rendering for SMTP vs SES settings
    • State management via AdminAppContainer (unstated pattern)
    • Test email functionality exists for SMTP only
  • Implications:
    • OAuth2 follows same pattern: add createOAuth2Client() method
    • Extend mail:transmissionMethod type to 'smtp' | 'ses' | 'oauth2'
    • Create new OAuth2Setting.tsx component following SMTP/SES pattern
    • Add OAuth2-specific config keys following mail:* namespace

Nodemailer OAuth2 Integration

  • Context: Verify OAuth2 support in nodemailer and configuration requirements
  • Sources Consulted:
  • Findings:

    • Nodemailer has first-class OAuth2 support with type 'OAuth2'
    • Configuration structure:

      {
      service: "gmail",
      auth: {
      type: "OAuth2",
      user: "user@gmail.com",
      clientId: process.env.GOOGLE_CLIENT_ID,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET,
      refreshToken: process.env.GOOGLE_REFRESH_TOKEN
      }
      }
      
    • Automatic access token refresh handled by nodemailer

    • Requires https://mail.google.com/ OAuth scope

    • Gmail service shortcut available (simplifies configuration)

    • Production consideration: Gmail designed for individual users, not automated services

  • Implications:

    • No additional dependencies needed (nodemailer already installed)
    • Four config values required: user email, clientId, clientSecret, refreshToken
    • Token refresh is automatic - no manual refresh logic needed
    • Should validate credentials before saving to config
    • Security: clientSecret and refreshToken must be encrypted in database

Config Manager Pattern Analysis

  • Context: Understand how to add new config keys for OAuth2 credentials
  • Sources Consulted:
    • apps/app/src/server/service/config-manager/config-definition.ts
    • Existing mail config keys: mail:from, mail:transmissionMethod, mail:smtpHost, etc.
  • Findings:
    • Config keys use namespace pattern: mail:*
    • Type-safe definitions using defineConfig<T>()
    • Existing transmission method: defineConfig<'smtp' | 'ses' | undefined>()
    • Config values stored in database via ConfigManager
    • No explicit encryption layer visible in config definition (handled elsewhere)
  • Implications:
    • Add four new keys: mail:oauth2User, mail:oauth2ClientId, mail:oauth2ClientSecret, mail:oauth2RefreshToken
    • Update mail:transmissionMethod type to 'smtp' | 'ses' | 'oauth2' | undefined
    • Encryption should be handled at persistence layer (ConfigManager or database model)
    • Follow same pattern as SMTP/SES for consistency

Admin UI State Management Pattern

  • Context: Understand how to integrate OAuth2 settings into admin UI
  • Sources Consulted:
    • apps/app/src/client/components/Admin/App/SmtpSetting.tsx
    • apps/app/src/client/components/Admin/App/SesSetting.tsx
    • apps/app/src/client/services/AdminAppContainer.js
  • Findings:
    • Separate component per transmission method (SmtpSetting, SesSetting)
    • Components receive register from react-hook-form
    • Unstated container pattern for state management
    • Container methods: changeSmtpHost(), changeFromAddress(), etc.
    • updateMailSettingHandler() saves all settings via API
    • Test email button only shown for SMTP
  • Implications:
    • Create OAuth2Setting.tsx component following same structure
    • Add four state methods to AdminAppContainer: changeOAuth2User(), changeOAuth2ClientId(), etc.
    • Include OAuth2 credentials in updateMailSettingHandler() API call
    • Test email functionality should work for OAuth2 (same as SMTP)
    • Field masking needed for clientSecret and refreshToken

Security Considerations

  • Context: Ensure secure handling of OAuth2 credentials
  • Sources Consulted:
    • GROWI security guidelines (.claude/rules/security.md)
    • Existing SMTP/SES credential handling
  • Findings:
    • Credentials stored in MongoDB via ConfigManager
    • Input fields use type="password" for sensitive values
    • No explicit encryption visible in UI layer
    • Logging should not expose credentials
  • Implications:
    • Use type="password" for clientSecret and refreshToken fields
    • Mask values when displaying saved configuration (show last 4 characters)
    • Never log credentials in plain text
    • Validate SSL/TLS when connecting to Google OAuth endpoints
    • Ensure admin authentication required before accessing config page

Architecture Pattern Evaluation

Option Description Strengths Risks / Limitations Notes
Factory Method Extension Add createOAuth2Client() to existing MailService Follows existing pattern, minimal changes, consistent with SMTP/SES None significant Recommended - aligns with current architecture
Separate OAuth2Service Create dedicated service for OAuth2 mail Better separation of concerns Over-engineering for simple extension, breaks existing pattern Not recommended - unnecessary complexity
Adapter Pattern Wrap OAuth2 in adapter implementing mail interface More flexible for future auth methods Premature abstraction, more code to maintain Not needed for single OAuth2 implementation

Design Decisions

Decision: Extend Existing MailService with OAuth2 Support

  • Context: Need to add OAuth2 email sending without breaking existing SMTP/SES functionality
  • Alternatives Considered:
    1. Create separate OAuth2MailService - more modular but introduces service management complexity
    2. Refactor to plugin architecture - future-proof but over-engineered for current needs
    3. Extend existing MailService with factory method - follows current pattern
  • Selected Approach: Extend existing MailService with createOAuth2Client() method
  • Rationale:
    • Maintains consistency with existing architecture
    • Minimal code changes reduce risk
    • Clear migration path (no breaking changes)
    • GROWI already uses this pattern successfully for SMTP/SES
  • Trade-offs:
    • Benefits: Low risk, fast implementation, familiar pattern
    • Compromises: All transmission methods in single service (acceptable given simplicity)
  • Follow-up: Ensure test coverage for OAuth2 path alongside existing SMTP/SES tests

Decision: Use Nodemailer's Built-in OAuth2 Support

  • Context: Need reliable OAuth2 implementation with automatic token refresh
  • Alternatives Considered:
    1. Manual OAuth2 implementation with googleapis library - more control but complex
    2. Third-party OAuth2 wrapper - additional dependency
    3. Nodemailer built-in OAuth2 - zero additional dependencies
  • Selected Approach: Use nodemailer's native OAuth2 support with Gmail service
  • Rationale:
    • No additional dependencies (nodemailer already installed)
    • Automatic token refresh reduces complexity
    • Well-documented and actively maintained
    • Matches user's original plan (stated in requirements)
  • Trade-offs:
    • Benefits: Simple, reliable, no new dependencies
    • Compromises: Limited to Gmail/Google Workspace (acceptable per requirements)
  • Follow-up: Document Google Cloud Console setup steps for administrators

Decision: Preserve Existing Transmission Method Pattern

  • Context: Maintain backward compatibility while adding OAuth2 option
  • Alternatives Considered:
    1. Deprecate transmission method concept - breaking change
    2. Add OAuth2 as transmission method option - extends existing pattern
    3. Support multiple simultaneous methods - unnecessary complexity
  • Selected Approach: Add 'oauth2' as third transmission method option
  • Rationale:
    • Zero breaking changes for existing users
    • Consistent admin UI experience
    • Clear mutual exclusivity (one method active at a time)
    • Easy to test and validate
  • Trade-offs:
    • Benefits: Backward compatible, simple mental model
    • Compromises: Only one transmission method active (acceptable per requirements)
  • Follow-up: Ensure switching between methods preserves all config values

Decision: Component-Based UI Following SMTP/SES Pattern

  • Context: Need consistent admin UI for OAuth2 configuration
  • Alternatives Considered:
    1. Inline OAuth2 fields in main form - cluttered UI
    2. Modal dialog for OAuth2 setup - breaks existing pattern
    3. Separate OAuth2Setting component - matches SMTP/SES pattern
  • Selected Approach: Create OAuth2Setting.tsx component rendered conditionally
  • Rationale:
    • Maintains visual consistency across transmission methods
    • Reuses existing form patterns (react-hook-form, unstated)
    • Easy for admins familiar with SMTP/SES setup
    • Supports incremental development (component isolation)
  • Trade-offs:
    • Benefits: Consistent UX, modular code, easy testing
    • Compromises: Minor code duplication in form field rendering (acceptable)
  • Follow-up: Add help text for each OAuth2 field explaining Google Cloud Console setup

Risks & Mitigations

  • Risk: OAuth2 credentials stored in plain text in database

    • Mitigation: Implement encryption at ConfigManager persistence layer; use same encryption as SMTP passwords
  • Risk: Refresh token expiration or revocation not handled

    • Mitigation: Nodemailer handles refresh automatically; log specific error codes for troubleshooting; document token refresh in admin help text
  • Risk: Google rate limiting or account suspension

    • Mitigation: Document production usage considerations; implement exponential backoff retry logic; log detailed error responses from Gmail API
  • Risk: Incomplete credential configuration causing service failure

    • Mitigation: Validate all four required fields before saving; display clear error messages; maintain isMailerSetup flag for health checks
  • Risk: Breaking changes to existing SMTP/SES functionality

    • Mitigation: Preserve all existing code paths; add OAuth2 as isolated branch; comprehensive integration tests for all three methods

Session 2: Production Implementation Discoveries (2026-02-10)

Critical Technical Constraints Identified

1. Nodemailer XOAuth2 Falsy Check Requirement

Discovery: Production testing revealed "Can't create new access token for user" errors from nodemailer's XOAuth2 handler.

Root Cause: Nodemailer's XOAuth2 implementation uses falsy checks (!this.options.refreshToken) at line 184, not null checks, rejecting empty strings as invalid credentials.

Implementation Requirement:

// ❌ WRONG: Allows empty strings to pass validation
if (clientId != null && clientSecret != null && refreshToken != null) {
  // This passes validation but nodemailer will reject it
}

// ✅ CORRECT: Matches nodemailer's falsy check behavior
if (!clientId || !clientSecret || !refreshToken || !user) {
  logger.warn('OAuth 2.0 credentials incomplete, skipping transport creation');
  return null;
}

Why This Matters: Empty strings ("") are falsy in JavaScript. Using != null in GROWI would allow empty strings through validation, but nodemailer's falsy check would then reject them, causing runtime failures.

Impact: All credential validation logic in MailService and ConfigManager must use falsy checks for OAuth 2.0 credentials to maintain compatibility with nodemailer.

Reference: mail.ts:219-226


2. Gmail API FROM Address Rewriting

Discovery: Gmail API rewrites the FROM address to the authenticated account email, ignoring GROWI's configured mail:from address.

Gmail API Behavior: Gmail API enforces that emails are sent FROM the authenticated account unless send-as aliases are explicitly configured in Google Workspace.

Example:

Configured: mail:from = "notifications@example.com"
Authenticated: oauth2User = "admin@company.com"
Actual sent FROM: "admin@company.com"

Workaround: Google Workspace administrators must configure send-as aliases:

  1. Gmail Settings → Accounts and Import → Send mail as
  2. Add desired FROM address as an alias
  3. Verify domain ownership

Why This Happens: Gmail API security policy prevents email spoofing by restricting FROM addresses to authenticated account or verified aliases.

Impact:

  • GROWI's mail:from configuration has limited effect with OAuth 2.0
  • Custom FROM addresses require Google Workspace send-as alias configuration
  • This is expected Gmail behavior, not a GROWI limitation

Documentation Note: This behavior must be documented in admin UI help text and user guides.


3. Credential Preservation Pattern

Discovery: Initial implementation allowed secret credentials to be accidentally overwritten with empty strings or masked placeholder values when updating non-secret fields.

Problem: Standard PUT request pattern sending all form fields would overwrite secrets with empty values when administrators only wanted to update non-secret fields like from address or oauth2User.

Solution: Conditional secret inclusion pattern:

// Build request params with non-secret fields
const requestOAuth2SettingParams: Record<string, any> = {
  'mail:from': req.body.fromAddress,
  'mail:transmissionMethod': req.body.transmissionMethod,
  'mail:oauth2ClientId': req.body.oauth2ClientId,
  'mail:oauth2User': req.body.oauth2User,
};

// Only include secrets if non-empty values provided
if (req.body.oauth2ClientSecret) {
  requestOAuth2SettingParams['mail:oauth2ClientSecret'] = req.body.oauth2ClientSecret;
}
if (req.body.oauth2RefreshToken) {
  requestOAuth2SettingParams['mail:oauth2RefreshToken'] = req.body.oauth2RefreshToken;
}

Frontend Consideration: GET endpoint returns undefined for secrets (not masked values) to prevent accidental re-submission:

// ❌ WRONG: Returns masked value that could be saved back
oauth2ClientSecret: '(set)',

// ✅ CORRECT: Returns undefined, frontend shows placeholder
oauth2ClientSecret: undefined,

Why This Pattern: Allows administrators to update non-secret OAuth 2.0 settings without re-entering sensitive credentials every time.

Impact: This pattern must be followed for any API that updates OAuth 2.0 credentials to prevent accidental secret overwrites.

Reference:


Type Safety Enhancements

NonBlankString Type: OAuth 2.0 config definitions use NonBlankString | undefined for compile-time protection against empty string assignments:

'mail:oauth2ClientSecret': defineConfig<NonBlankString | undefined>({
  defaultValue: undefined,
  isSecret: true,
}),

This provides compile-time protection complementing runtime falsy checks.


Integration Pattern Discovered

OAuth 2.0 Retry Logic: OAuth 2.0 requires retry logic with exponential backoff due to potential token refresh failures:

// OAuth 2.0 uses sendWithRetry() for automatic retry
if (transmissionMethod === 'oauth2') {
  return this.sendWithRetry(mailConfig as EmailConfig);
}

// SMTP/SES use direct sendMail()
return this.mailer.sendMail(mailConfig);

Rationale: OAuth 2.0 token refresh can fail transiently due to network issues or Google API rate limiting. Exponential backoff (1s, 2s, 4s) provides resilience.


Session 3: Post-Refactoring Architecture (2026-02-10)

MailService Modular Structure

The MailService was refactored from a single monolithic file (mail.ts, ~408 lines) into a feature-based directory structure with separate transport modules. This is the current production architecture.

Directory Structure

src/server/service/mail/
├── index.ts              # Barrel export (default: MailService, backward-compatible)
├── mail.ts               # MailService class (orchestration, S2S, retry logic)
├── mail.spec.ts          # MailService tests
├── smtp.ts               # SMTP transport factory: createSMTPClient()
├── smtp.spec.ts          # SMTP transport tests
├── ses.ts                # SES transport factory: createSESClient()
├── ses.spec.ts           # SES transport tests
├── oauth2.ts             # OAuth2 transport factory: createOAuth2Client()
├── oauth2.spec.ts        # OAuth2 transport tests
└── types.ts              # Shared types (StrictOAuth2Options, MailConfig, etc.)

Transport Factory Pattern

Each transport module exports a factory function with a consistent signature:

export function create[Transport]Client(
  configManager: IConfigManagerForApp,
  option?: TransportOptions
): Transporter | null;
  • Returns null if required credentials are missing (logs warning)
  • MailService delegates transport creation based on mail:transmissionMethod config

StrictOAuth2Options Type

Defined in types.ts, this branded type prevents empty string credentials at compile time:

import type { NonBlankString } from '@growi/core/dist/interfaces';

export type StrictOAuth2Options = {
  service: 'gmail';
  auth: {
    type: 'OAuth2';
    user: NonBlankString;
    clientId: NonBlankString;
    clientSecret: NonBlankString;
    refreshToken: NonBlankString;
  };
};

This is stricter than nodemailer's default XOAuth2.Options which allows string | undefined. The branded type ensures compile-time validation complementing runtime falsy checks.

Backward Compatibility

The barrel export at mail/index.ts maintains the existing import pattern:

import MailService from '~/server/service/mail';  // Still works

Source: Migrated from .kiro/specs/refactor-mailer-service/ (spec deleted after implementation completion).


References