Purpose: Capture discovery findings, architectural investigations, and rationale that inform the technical design for OAuth 2.0 email support.
Usage:
design.md.oauth2-email-supportmail:* namespace with type-safe definitionsapps/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)createSMTPClient(), createSESClient()mail:transmissionMethod config value ('smtp' | 'ses')initialize() method called on service startup and S2S message updatescreateOAuth2Client() methodmail:transmissionMethod type to 'smtp' | 'ses' | 'oauth2'OAuth2Setting.tsx component following SMTP/SES patternmail:* namespaceFindings:
'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:
apps/app/src/server/service/config-manager/config-definition.tsmail:from, mail:transmissionMethod, mail:smtpHost, etc.mail:*defineConfig<T>()defineConfig<'smtp' | 'ses' | undefined>()mail:oauth2User, mail:oauth2ClientId, mail:oauth2ClientSecret, mail:oauth2RefreshTokenmail:transmissionMethod type to 'smtp' | 'ses' | 'oauth2' | undefinedapps/app/src/client/components/Admin/App/SmtpSetting.tsxapps/app/src/client/components/Admin/App/SesSetting.tsxapps/app/src/client/services/AdminAppContainer.jsregister from react-hook-formchangeSmtpHost(), changeFromAddress(), etc.updateMailSettingHandler() saves all settings via APIOAuth2Setting.tsx component following same structurechangeOAuth2User(), changeOAuth2ClientId(), etc.updateMailSettingHandler() API call.claude/rules/security.md)type="password" for sensitive valuestype="password" for clientSecret and refreshToken fields| 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 |
createOAuth2Client() methodOAuth2Setting.tsx component rendered conditionallyRisk: OAuth2 credentials stored in plain text in database
Risk: Refresh token expiration or revocation not handled
Risk: Google rate limiting or account suspension
Risk: Incomplete credential configuration causing service failure
Risk: Breaking changes to existing SMTP/SES functionality
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
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:
Why This Happens: Gmail API security policy prevents email spoofing by restricting FROM addresses to authenticated account or verified aliases.
Impact:
mail:from configuration has limited effect with OAuth 2.0Documentation Note: This behavior must be documented in admin UI help text and user guides.
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:
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.
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.
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.
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.)
Each transport module exports a factory function with a consistent signature:
export function create[Transport]Client(
configManager: IConfigManagerForApp,
option?: TransportOptions
): Transporter | null;
null if required credentials are missing (logs warning)mail:transmissionMethod configDefined 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.
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).
apps/app/src/server/service/mail.ts - Existing mail service implementationapps/app/src/client/components/Admin/App/MailSetting.tsx - Admin UI patterns