Просмотр исходного кода

Merge pull request #10758 from growilabs/master

Release v7.4.5
mergify[bot] 1 месяц назад
Родитель
Сommit
06602a391f
79 измененных файлов с 4977 добавлено и 522 удалено
  1. 277 0
      .claude/commands/kiro/spec-cleanup.md
  2. 40 4
      .claude/commands/learn.md
  3. 107 6
      .claude/commands/tdd.md
  4. 38 0
      .claude/rules/testing.md
  5. 6 0
      .claude/settings.json
  6. 0 27
      .claude/skills/learned/.gitkeep
  7. 122 0
      .claude/skills/learned/essential-test-design/SKILL.md
  8. 68 10
      .claude/skills/learned/essential-test-patterns/SKILL.md
  9. 4 3
      .claude/skills/monorepo-overview/SKILL.md
  10. 10 2
      .claude/skills/tech-stack/SKILL.md
  11. 4 1
      .devcontainer/app/devcontainer.json
  12. 4 0
      .devcontainer/pdf-converter/devcontainer.json
  13. 764 0
      .kiro/specs/oauth2-email-support/design.md
  14. 57 0
      .kiro/specs/oauth2-email-support/requirements.md
  15. 449 0
      .kiro/specs/oauth2-email-support/research.md
  16. 23 0
      .kiro/specs/oauth2-email-support/spec.json
  17. 41 0
      .kiro/specs/oauth2-email-support/tasks.md
  18. 3 9
      .kiro/steering/structure.md
  19. 4 18
      .kiro/steering/tdd.md
  20. 3 10
      .kiro/steering/tech.md
  21. 1 21
      .mcp.json
  22. 64 8
      .serena/project.yml
  23. 1 2
      AGENTS.md
  24. 2 0
      CLAUDE.md
  25. 42 0
      apps/app/.claude/skills/app-commands/SKILL.md
  26. 1 1
      apps/app/.claude/skills/app-specific-patterns/SKILL.md
  27. 302 0
      apps/app/.claude/skills/learned/page-save-origin-semantics/SKILL.md
  28. 5 1
      apps/app/AGENTS.md
  29. 18 0
      apps/app/CLAUDE.md
  30. 3 2
      apps/app/package.json
  31. 9 0
      apps/app/public/static/locales/en_US/admin.json
  32. 9 0
      apps/app/public/static/locales/fr_FR/admin.json
  33. 9 0
      apps/app/public/static/locales/ja_JP/admin.json
  34. 9 0
      apps/app/public/static/locales/ko_KR/admin.json
  35. 9 0
      apps/app/public/static/locales/zh_CN/admin.json
  36. 18 2
      apps/app/src/client/components/Admin/App/MailSetting.tsx
  37. 126 0
      apps/app/src/client/components/Admin/App/OAuth2Setting.tsx
  38. 2 0
      apps/app/src/client/components/Page/EditablePageEffects.tsx
  39. 24 28
      apps/app/src/client/components/PageHistory/PageRevisionTable.tsx
  40. 12 7
      apps/app/src/client/components/PageHistory/Revision.tsx
  41. 14 0
      apps/app/src/client/components/Sidebar/Sidebar.tsx
  42. 60 0
      apps/app/src/client/services/AdminAppContainer.js
  43. 33 0
      apps/app/src/client/services/side-effects/page-seen-users-updated.ts
  44. 22 7
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/steps/compress-and-upload.ts
  45. 3 0
      apps/app/src/interfaces/activity.ts
  46. 1 0
      apps/app/src/interfaces/websocket.ts
  47. 1 1
      apps/app/src/server/crowi/index.ts
  48. 3 2
      apps/app/src/server/middlewares/admin-required.ts
  49. 3 2
      apps/app/src/server/middlewares/login-required.ts
  50. 65 0
      apps/app/src/server/models/failed-email.ts
  51. 17 3
      apps/app/src/server/models/obsolete-page.js
  52. 19 0
      apps/app/src/server/models/openapi/page.ts
  53. 119 1
      apps/app/src/server/routes/apiv3/app-settings/index.ts
  54. 285 0
      apps/app/src/server/routes/apiv3/page/get-page-info.integ.ts
  55. 118 0
      apps/app/src/server/routes/apiv3/page/get-page-info.ts
  56. 24 69
      apps/app/src/server/routes/apiv3/page/index.ts
  57. 32 3
      apps/app/src/server/routes/apiv3/page/update-page.ts
  58. 21 1
      apps/app/src/server/service/config-manager/config-definition.ts
  59. 0 219
      apps/app/src/server/service/mail.ts
  60. 13 0
      apps/app/src/server/service/mail/index.ts
  61. 363 0
      apps/app/src/server/service/mail/mail.spec.ts
  62. 285 0
      apps/app/src/server/service/mail/mail.ts
  63. 116 0
      apps/app/src/server/service/mail/oauth2.spec.ts
  64. 77 0
      apps/app/src/server/service/mail/oauth2.ts
  65. 81 0
      apps/app/src/server/service/mail/ses.spec.ts
  66. 45 0
      apps/app/src/server/service/mail/ses.ts
  67. 152 0
      apps/app/src/server/service/mail/smtp.spec.ts
  68. 64 0
      apps/app/src/server/service/mail/smtp.ts
  69. 53 0
      apps/app/src/server/service/mail/types.ts
  70. 4 1
      apps/app/src/server/service/page/find-page-and-meta-data-by-viewer.ts
  71. 17 0
      apps/app/src/server/service/system-events/sync-page-status.ts
  72. 11 2
      apps/app/src/server/service/yjs/yjs.integ.ts
  73. 75 0
      apps/app/src/states/page/use-fetch-current-page.spec.tsx
  74. 8 3
      apps/app/src/states/page/use-fetch-current-page.ts
  75. 30 14
      apps/app/src/stores/page.tsx
  76. 1 1
      apps/slackbot-proxy/package.json
  77. 1 1
      package.json
  78. 1 1
      packages/slack/package.json
  79. 50 29
      pnpm-lock.yaml

+ 277 - 0
.claude/commands/kiro/spec-cleanup.md

@@ -0,0 +1,277 @@
+---
+description: Organize and clean up specification documents after implementation completion
+allowed-tools: Bash, Glob, Grep, Read, Write, Edit, MultiEdit, Update
+argument-hint: <feature-name>
+---
+
+# Specification Cleanup
+
+<background_information>
+- **Mission**: Organize specification documents after implementation completion, removing implementation details while preserving essential context for future refactoring
+- **Success Criteria**:
+  - Implementation details (testing procedures, deployment checklists) removed
+  - Design decisions and constraints preserved in research.md and design.md
+  - Requirements simplified (Acceptance Criteria condensed to summaries)
+  - Unimplemented features removed or documented
+  - Documents remain valuable for future refactoring work
+</background_information>
+
+<instructions>
+## Core Task
+Clean up and organize specification documents for feature **$1** after implementation is complete.
+
+## Organizing Principle
+
+**"Can we read essential context from these spec documents when refactoring this feature months later?"**
+
+- **Keep**: "Why" (design decisions, architectural constraints, limitations, trade-offs)
+- **Remove**: "How" (testing procedures, deployment steps, detailed implementation examples)
+
+## Execution Steps
+
+### Step 1: Load Context
+
+**Discover all spec files**:
+- Use Glob to find all files in `.kiro/specs/$1/` directory
+- Categorize files:
+  - **Core files** (must preserve): `spec.json`, `requirements.md`, `design.md`, `tasks.md`, `research.md`
+  - **Other files** (evaluate case-by-case): validation reports, notes, prototypes, migration guides, etc.
+
+**Read all discovered files**:
+- Read all core files first
+- Read other files to understand their content and value
+
+**Verify implementation status**:
+- Check that tasks are marked complete `[x]` in tasks.md
+- If implementation incomplete, warn user and ask to confirm cleanup
+
+### Step 2: Analyze Current State
+
+**Identify cleanup opportunities**:
+
+1. **Other files** (non-core files like validation-report.md, notes.md, etc.):
+   - Read each file to understand its content and purpose
+   - Identify valuable information that should be preserved:
+     * Implementation discoveries and lessons learned
+     * Critical constraints or design decisions
+     * Historical context for future refactoring
+   - Determine salvage strategy:
+     * Migrate valuable content to research.md or design.md
+     * Keep file if it contains essential reference information
+     * Delete if content is redundant or no longer relevant
+   - **Case-by-case evaluation required** - never assume files should be deleted
+
+2. **research.md**:
+   - Should contain production discoveries and implementation lessons learned
+   - Check if implementation revealed new constraints or patterns to document
+   - Identify content from other files that should be migrated here
+
+3. **requirements.md**:
+   - Identify verbose Acceptance Criteria that can be condensed to summaries
+   - Find unimplemented requirements (compare with tasks.md)
+   - Detect duplicate or redundant content
+
+4. **design.md**:
+   - Identify implementation-specific sections that can be removed:
+     * Detailed Testing Strategy (test procedures)
+     * Security Considerations (if covered in implementation)
+     * Error Handling code examples (if implemented)
+     * Migration Strategy (after migration complete)
+     * Deployment Checklist (after deployment)
+   - Identify sections to preserve:
+     * Architecture diagrams (essential for understanding)
+     * Component interfaces (API contracts)
+     * Design decisions and rationale
+     * Critical implementation constraints
+     * Known limitations
+   - Check if content from other files should be migrated here
+
+### Step 3: Interactive Confirmation
+
+**Present cleanup plan to user**:
+
+For each file and section identified in Step 2, ask:
+- "Should I delete/simplify/keep/salvage this section?"
+- Provide recommendations based on organizing principle
+- Show brief preview of content to aid decision
+
+**Example questions for other files**:
+- "validation-report.md found. Contains {brief summary}. Options:"
+  - "A: Migrate valuable content to research.md, then delete"
+  - "B: Keep as historical reference"
+  - "C: Delete (content no longer needed)"
+- "notes.md found. Contains {brief summary}. Salvage to research.md before deleting? [Y/n]"
+
+**Example questions for core files**:
+- "research.md: Add 'Session N: Production Discoveries' section to document implementation lessons? [Y/n]"
+- "requirements.md: Simplify Acceptance Criteria from detailed bullet points to summary paragraphs? [Y/n]"
+- "requirements.md: Remove unimplemented requirements (e.g., Req 4.4 field masking not implemented)? [Y/n]"
+- "design.md: Delete 'Testing Strategy' section (lines X-Y)? [Y/n]"
+- "design.md: Delete 'Security Considerations' section (lines X-Y)? [Y/n]"
+- "design.md: Keep Architecture diagrams (essential for refactoring)? [Y/n]"
+
+**Batch similar decisions**:
+- Group related sections (e.g., all "delete implementation details" decisions)
+- Allow user to approve categories rather than individual items
+- Present file-by-file salvage decisions for other files
+
+### Step 4: Execute Cleanup
+
+**For each approved action**:
+
+1. **Salvage and cleanup other files** (if approved):
+   - For each non-core file (validation-report.md, notes.md, etc.):
+     * Extract valuable information (implementation lessons, constraints, decisions)
+     * Migrate content to appropriate core file:
+       - Technical discoveries → research.md
+       - Design constraints → design.md
+       - Requirement clarifications → requirements.md
+     * Delete file after salvage (if approved)
+   - Document salvaged content with source reference (e.g., "From validation-report.md:")
+
+2. **Update research.md** (if new discoveries or salvaged content):
+   - Add new section "Session N: Production Implementation Discoveries" (if needed)
+   - Document critical technical constraints discovered during implementation
+   - Include code examples for critical patterns (e.g., falsy checks, credential preservation)
+   - Integrate salvaged content from other files
+   - Cross-reference requirements.md and design.md where relevant
+
+3. **Simplify requirements.md** (if approved):
+   - Transform detailed Acceptance Criteria into summary paragraphs
+   - Remove unimplemented requirements entirely
+   - Preserve requirement objectives and summaries
+   - Example transformation:
+     ```
+     Before: "1. System shall X... 2. System shall Y... [7 criteria]"
+     After: "**Summary**: System provides X and Y. Configuration includes..."
+     ```
+
+4. **Clean up design.md** (if approved):
+   - Delete approved sections (Testing Strategy, Security Considerations, etc.)
+   - Add "Critical Implementation Constraints" section if implementation revealed new constraints
+   - Integrate salvaged content from other files (if relevant)
+   - Preserve architecture diagrams and component interfaces
+   - Keep design decisions and rationale sections
+
+5. **Update spec.json metadata**:
+   - Set `phase: "implementation-complete"` (if not already set)
+   - Add `cleanup_completed: true` flag
+   - Update `updated_at` timestamp
+
+### Step 5: Generate Cleanup Summary
+
+**Provide summary report**:
+- List of files modified/deleted
+- Sections removed and lines saved
+- Critical information preserved
+- Recommendations for future refactoring
+
+**Format**:
+```markdown
+## Cleanup Summary for {feature-name}
+
+### Files Modified
+- ✅ validation-report.md: Salvaged to research.md, then deleted (730 lines removed)
+- ✅ notes.md: Salvaged to design.md, then deleted (120 lines removed)
+- ✅ research.md: Added Session 2 discoveries + salvaged content (180 lines added)
+- ✅ requirements.md: Simplified 6 requirements (350 lines → 180 lines)
+- ✅ design.md: Removed 4 sections, added constraints + salvaged content (250 lines removed, 100 added)
+
+### Information Salvaged
+- Implementation discoveries from validation-report.md → research.md
+- Design notes from notes.md → design.md
+- Historical context preserved with source attribution
+
+### Information Preserved
+- Architecture diagrams and component interfaces
+- Design decisions and rationale
+- Critical implementation constraints
+- Known limitations and trade-offs
+
+### Next Steps
+- Spec documents ready for future refactoring reference
+- Consider creating knowledge base entry if pattern is reusable
+```
+
+## Critical Constraints
+
+- **User approval required**: Never delete content without explicit confirmation
+- **Language consistency**: Use language specified in spec.json for all updates
+- **Preserve history**: Don't delete discovery rationale or design decisions
+- **Balance brevity with completeness**: Remove redundancy but keep essential context
+- **Interactive workflow**: Pause for user input rather than making assumptions
+
+## Tool Guidance
+
+- **Glob**: Discover all files in `.kiro/specs/{feature}/` directory
+- **Read**: Load all discovered files for analysis
+- **Grep**: Search for patterns (e.g., unimplemented requirements, completed tasks)
+- **Edit/Write**: Update files based on approved changes, salvage content
+- **Bash**: Delete files after salvage (if approved)
+- **MultiEdit**: For batch edits across multiple sections
+
+## Output Description
+
+Provide cleanup plan and execution report in the language specified in spec.json.
+
+**Report Structure**:
+1. **Current State Analysis**: What needs cleanup and why
+2. **Cleanup Plan**: Proposed changes with recommendations
+3. **Confirmation Prompts**: Interactive questions for user approval
+4. **Execution Summary**: What was changed and why
+5. **Preserved Context**: What critical information remains for future refactoring
+
+**Format**: Clear, scannable format with sections and bullet points
+
+## Safety & Fallback
+
+### Error Scenarios
+
+**Implementation Incomplete**:
+- **Condition**: Less than 90% of tasks marked `[x]` in tasks.md
+- **Action**: Warn user: "Implementation appears incomplete (X/Y tasks done). Continue cleanup? [y/N]"
+- **Recommendation**: Wait until implementation complete before cleanup
+
+**Spec Not Found**:
+- **Message**: "No spec found for `$1`. Check available specs in `.kiro/specs/`"
+- **Action**: List available spec directories
+
+**Missing Critical Files**:
+- **Condition**: requirements.md or design.md missing
+- **Action**: Skip cleanup for missing files, proceed with available files
+- **Warning**: "requirements.md missing - cannot simplify requirements"
+
+### Dry Run Mode (Future Enhancement)
+
+**If `-n` or `--dry-run` flag provided**:
+- Show cleanup plan without executing changes
+- Allow user to review before committing to cleanup
+
+### Backup Recommendation
+
+**Before cleanup**:
+- Recommend user create git commit or backup
+- Warning: "This will modify spec files. Commit current state first? [Y/n]"
+
+### Undo Support
+
+**If cleanup goes wrong**:
+- Use git to restore previous state: `git checkout HEAD -- .kiro/specs/{feature}/`
+- Remind user to commit before cleanup for easy rollback
+
+## Example Usage
+
+```bash
+# Basic cleanup after implementation
+/kiro:spec-cleanup oauth2-email-support
+
+# With conversation context about implementation discoveries
+# Command will prompt for Session N discoveries to document
+/kiro:spec-cleanup user-authentication
+```
+
+## Related Commands
+
+- `/kiro:spec-impl {feature}` - Implement tasks (run before cleanup)
+- `/kiro:validate-impl {feature}` - Validate implementation (run before cleanup)
+- `/kiro:spec-status {feature}` - Check implementation status

+ 40 - 4
.claude/commands/learn.md

@@ -25,7 +25,18 @@ Focus on four key areas:
 
 ## Skill File Structure
 
-Extracted patterns are saved in `.claude/skills/learned/{topic-name}/SKILL.md` with:
+Extracted patterns are saved in **appropriate skill directories** based on the scope of the pattern:
+
+**Workspace-specific patterns**:
+- `apps/{workspace}/.claude/skills/learned/{topic-name}/SKILL.md`
+- `packages/{package}/.claude/skills/learned/{topic-name}/SKILL.md`
+- Examples: patterns specific to a single app or package
+
+**Global patterns** (monorepo-wide):
+- `.claude/skills/learned/{topic-name}/SKILL.md`
+- Examples: patterns applicable across all workspaces
+
+### File Template
 
 ```yaml
 ---
@@ -49,12 +60,19 @@ description: Brief description (auto-invoked when working on related code)
 ## GROWI-Specific Examples
 
 Topics commonly learned in GROWI development:
-- `virtualized-tree-patterns` — @headless-tree + @tanstack/react-virtual optimizations
+
+**Apps/app-specific** (`apps/app/.claude/skills/learned/`):
+- `page-save-origin-semantics` — Origin-based conflict detection for collaborative editing
 - `socket-jotai-integration` — Real-time state synchronization patterns
 - `api-v3-error-handling` — RESTful API error response patterns
-- `jotai-atom-composition` — Derived atoms and state composition
 - `mongodb-query-optimization` — Mongoose indexing and aggregation patterns
 
+**Global monorepo patterns** (`.claude/skills/learned/`):
+- `virtualized-tree-patterns` — @headless-tree + @tanstack/react-virtual optimizations (if used across apps)
+- `jotai-atom-composition` — Derived atoms and state composition (if shared pattern)
+- `turborepo-cache-invalidation` — Build cache debugging techniques
+- `pnpm-workspace-dependencies` — Workspace dependency resolution issues
+
 ## Quality Guidelines
 
 **Extract:**
@@ -74,7 +92,25 @@ Topics commonly learned in GROWI development:
 1. User triggers `/learn` after solving a complex problem
 2. Review the session to identify valuable patterns
 3. Draft skill file(s) with clear structure
-4. Save to `.claude/skills/learned/{topic-name}/SKILL.md`
+4. **Autonomously determine the appropriate directory**:
+   - Analyze the pattern's scope (which files/modules were involved)
+   - If pattern is specific to a workspace in `apps/*` or `packages/*`:
+     - Save to `{workspace}/.claude/skills/learned/{topic-name}/SKILL.md`
+   - If pattern is applicable across multiple workspaces:
+     - Save to `.claude/skills/learned/{topic-name}/SKILL.md`
 5. Skills automatically apply in future sessions when working on related code
 
+### Directory Selection Logic
+
+**Workspace-specific** (save to `{workspace}/.claude/skills/learned/`):
+- Pattern involves workspace-specific concepts, models, or APIs
+- References files primarily in one `apps/*` or `packages/*` directory
+- Example: Page save logic in `apps/app` → `apps/app/.claude/skills/learned/`
+
+**Global** (save to `.claude/skills/learned/`):
+- Pattern applies across multiple workspaces
+- Involves monorepo-wide tools (Turborepo, pnpm, Biome, Vitest)
+- Shared coding patterns or architectural principles
+- Example: Turborepo caching pitfall → `.claude/skills/learned/`
+
 Learned skills are automatically invoked based on their description when working on related code.

+ 107 - 6
.claude/commands/tdd.md

@@ -7,6 +7,71 @@ description: Enforce test-driven development workflow. Scaffold interfaces, gene
 
 This command enforces test-driven development methodology for GROWI.
 
+---
+
+## ⚠️ CRITICAL: MANDATORY TDD PROTOCOL
+
+**THIS IS NOT OPTIONAL. THIS IS A TEAM REQUIREMENT.**
+
+### Non-Negotiable Rules
+
+1. **ALL phases of TDD MUST be followed: RED → GREEN → REFACTOR**
+2. **Tests MUST be written BEFORE any implementation code (RED phase)**
+3. **Implementation MUST be minimal and only to pass tests (GREEN phase)**
+4. **Tests MUST remain passing during refactoring (REFACTOR phase)**
+5. **NEVER skip any phase of the TDD cycle**
+6. **If you believe you cannot follow TDD, STOP and ask the user IMMEDIATELY**
+
+### Violation Protocol
+
+If you find yourself about to deviate from the TDD cycle:
+
+1. **STOP IMMEDIATELY** - Do not proceed with any phase out of order
+2. **ASK THE USER** - Explain why you think following TDD is difficult
+3. **WAIT FOR APPROVAL** - Do not proceed without explicit user consent
+4. **DOCUMENT THE EXCEPTION** - If approved, document why TDD was skipped
+
+**Example of required user confirmation:**
+
+```
+"This task requires writing integration tests with complex setup
+(database mocks, authentication, etc.).
+
+I have two options:
+
+Option A: Follow strict TDD (recommended)
+- RED: Write failing integration test first
+- GREEN: Implement minimal code to pass
+- REFACTOR: Improve code while keeping tests green
+- May take 15-30 minutes for test setup
+- Ensures proper test coverage
+
+Option B: Skip TDD for this specific change
+- Implement directly
+- Verify with existing test suite only
+- Risk: No specific test for this change
+
+Which approach would you prefer?"
+```
+
+**Never assume. Always ask.**
+
+---
+
+## Pre-Implementation Checklist
+
+Before writing ANY code, confirm:
+
+- [ ] I have read and understood the task requirements
+- [ ] I am starting with Step 1: Write a FAILING test (RED phase)
+- [ ] I have NOT written any implementation code yet
+- [ ] I will follow all phases: RED → GREEN → REFACTOR
+- [ ] If I cannot follow the complete TDD cycle, I have ASKED THE USER for guidance
+
+**If any checkbox is unchecked, STOP and ask the user.**
+
+---
+
 ## What This Command Does
 
 1. **Scaffold Interfaces** - Define types/interfaces first
@@ -61,6 +126,8 @@ export function validatePagePath(path: string): ValidationResult {
 
 ## Step 2: Write Failing Test (RED)
 
+**⚠️ CHECKPOINT: Confirm you have NOT written any implementation code yet.**
+
 ```typescript
 // src/utils/page-path-validator.spec.ts
 describe('validatePagePath', () => {
@@ -84,6 +151,8 @@ describe('validatePagePath', () => {
 
 ## Step 3: Run Tests - Verify FAIL
 
+**⚠️ MANDATORY: Tests MUST fail before proceeding to implementation.**
+
 ```bash
 turbo run test --filter @growi/app -- src/utils/page-path-validator.spec.ts
 
@@ -92,10 +161,14 @@ FAIL src/utils/page-path-validator.spec.ts
     Error: Not implemented
 ```
 
-✅ Tests fail as expected. Ready to implement.
+**✅ CHECKPOINT PASSED: Tests fail as expected. Ready to implement.**
+
+**❌ If tests pass or don't run: STOP. Fix the test first.**
 
 ## Step 4: Implement Minimal Code (GREEN)
 
+**⚠️ CHECKPOINT: Only write the MINIMUM code needed to pass the tests.**
+
 ```typescript
 export function validatePagePath(path: string): ValidationResult {
   if (!path) {
@@ -110,20 +183,35 @@ export function validatePagePath(path: string): ValidationResult {
 
 ## Step 5: Run Tests - Verify PASS
 
+**⚠️ MANDATORY: ALL tests MUST pass before proceeding to refactoring.**
+
 ```bash
 turbo run test --filter @growi/app -- src/utils/page-path-validator.spec.ts
 
 PASS  ✓ All tests passing!
 ```
 
+**✅ CHECKPOINT PASSED: Ready to refactor if needed.**
+
+**❌ If tests fail: Fix implementation, do NOT move to refactoring.**
+
 ## Step 6: Check Coverage
 
+**⚠️ MANDATORY: Verify test coverage meets requirements (80% minimum).**
+
 ```bash
 cd {package_dir} && pnpm vitest run --coverage src/utils/page-path-validator.spec.ts
 
 Coverage: 100% ✅ (Target: 80%)
 ```
 
+**✅ TDD CYCLE COMPLETE: All phases completed successfully.**
+
+- ✅ RED: Failing tests written
+- ✅ GREEN: Implementation passes tests
+- ✅ REFACTOR: Code improved (if needed)
+- ✅ COVERAGE: 80%+ achieved
+
 ## TDD Best Practices
 
 **DO:**
@@ -172,13 +260,26 @@ Coverage: 100% ✅ (Target: 80%)
 
 ## Important Notes
 
-**MANDATORY**: Tests must be written BEFORE implementation. The TDD cycle is:
+**MANDATORY - NO EXCEPTIONS**: The complete TDD cycle MUST be followed:
+
+1. **RED** - Write failing test FIRST
+2. **GREEN** - Implement minimal code to pass the test
+3. **REFACTOR** - Improve code while keeping tests green
+
+**Absolute Requirements:**
+- ❌ NEVER skip the RED phase
+- ❌ NEVER skip the GREEN phase
+- ❌ NEVER skip the REFACTOR phase
+- ❌ NEVER write implementation code before tests
+- ❌ NEVER proceed without explicit user approval if you cannot follow TDD
 
-1. **RED** - Write failing test
-2. **GREEN** - Implement to pass
-3. **REFACTOR** - Improve code
+**If you violate these rules:**
+1. STOP immediately
+2. Discard any implementation code written before tests
+3. Inform the user of the violation
+4. Start over with RED phase
 
-Never skip the RED phase. Never write code before tests.
+**This is a team development standard. Violations are not acceptable.**
 
 ## Related Skills
 

+ 38 - 0
.claude/rules/testing.md

@@ -0,0 +1,38 @@
+# Testing Rules
+
+## Package Manager (CRITICAL)
+
+**NEVER use `npx` to run tests. ALWAYS use `pnpm`.**
+
+```bash
+# ❌ WRONG
+npx vitest run yjs.integ
+
+# ✅ CORRECT
+pnpm vitest run yjs.integ
+```
+
+## Test Execution Commands
+
+### Individual Test File (from package directory)
+
+```bash
+# Use partial file name - Vitest auto-matches
+pnpm vitest run yjs.integ
+pnpm vitest run helper.spec
+pnpm vitest run Button.spec
+
+# Flaky test detection
+pnpm vitest run yjs.integ --repeat=10
+```
+
+- Use **partial file name** (no `src/` prefix or full path needed)
+- No `--project` flag needed (Vitest auto-detects from file extension)
+
+### All Tests for a Package (from monorepo root)
+
+```bash
+turbo run test --filter @growi/app
+```
+
+For testing patterns (mocking, assertions, structure), see the `.claude/skills/learned/essential-test-patterns` skill.

+ 6 - 0
.claude/settings.json

@@ -13,5 +13,11 @@
         ]
       }
     ]
+  },
+  "enabledPlugins": {
+    "context7@claude-plugins-official": true,
+    "github@claude-plugins-official": true,
+    "typescript-lsp@claude-plugins-official": true,
+    "playwright@claude-plugins-official": true
   }
 }

+ 0 - 27
.claude/skills/learned/.gitkeep

@@ -1,27 +0,0 @@
-# Learned Skills Directory
-
-This directory contains Skills learned from development sessions using the `/learn` command.
-
-Each learned skill is automatically applied when working on related code based on its description.
-
-## Structure
-
-```
-learned/
-├── {topic-name-1}/
-│   └── SKILL.md
-├── {topic-name-2}/
-│   └── SKILL.md
-└── {topic-name-3}/
-    └── SKILL.md
-```
-
-## How Skills Are Created
-
-Use the `/learn` command after completing a feature or solving a complex problem:
-
-```
-/learn
-```
-
-Claude will extract reusable patterns and save them as Skills in this directory.

+ 122 - 0
.claude/skills/learned/essential-test-design/SKILL.md

@@ -0,0 +1,122 @@
+---
+name: essential-test-design
+description: Write tests that verify observable behavior (contract), not implementation details. Auto-invoked when writing or reviewing tests.
+---
+
+## Problem
+
+Tests that are tightly coupled to implementation details cause two failures:
+
+1. **False positives** — Tests pass even when behavior is broken (e.g., delay shortened but test still passes because it only checks `setTimeout` was called)
+2. **False negatives** — Tests fail even when behavior is correct (e.g., implementation switches from `setTimeout` to a `delay()` utility, spy breaks)
+
+Both undermine the purpose of testing: detecting regressions in behavior.
+
+## Principle: Test the Contract, Not the Mechanism
+
+A test is "essential" when it:
+- **Fails if the behavior degrades** (catches real bugs)
+- **Passes if the behavior is preserved** (survives refactoring)
+- **Does not depend on how the behavior is implemented** (implementation-agnostic)
+
+Ask: "What does the caller of this function experience?" — test that.
+
+## Anti-Patterns and Corrections
+
+### Anti-Pattern 1: Implementation Spy
+
+```typescript
+// BAD: Tests implementation, not behavior
+// Breaks if implementation changes from setTimeout to any other delay mechanism
+const spy = vi.spyOn(global, 'setTimeout');
+await exponentialBackoff(1);
+expect(spy).toHaveBeenCalledWith(expect.any(Function), 1000);
+```
+
+### Anti-Pattern 2: Arrange That Serves the Assert
+
+```typescript
+// BAD: The "arrange" is set up only to make the "assert" trivially pass
+// This is a self-fulfilling prophecy, not a meaningful test
+vi.advanceTimersByTime(1000);
+await promise;
+// No assertion — "it didn't throw" is not a valuable test
+```
+
+### Correct: Behavior Boundary Test
+
+```typescript
+// GOOD: Tests the observable contract
+// "Does not resolve before the expected delay, resolves at the expected delay"
+let resolved = false;
+mailService.exponentialBackoff(1).then(() => { resolved = true });
+
+await vi.advanceTimersByTimeAsync(999);
+expect(resolved).toBe(false);  // Catches: delay too short
+
+await vi.advanceTimersByTimeAsync(1);
+expect(resolved).toBe(true);   // Catches: delay too long or hangs
+```
+
+## Decision Framework
+
+When writing a test, ask these questions in order:
+
+1. **What is the contract?** — What does the caller expect to experience?
+   - e.g., "Wait for N ms before resolving"
+2. **What breakage should this test catch?** — Define the regression scenario
+   - e.g., "Someone changes the delay from 1000ms to 500ms"
+3. **Would this test still pass if I refactored the internals?** — If no, you're testing implementation
+   - e.g., Switching from `setTimeout` to `Bun.sleep()` shouldn't break the test
+4. **Would this test fail if the behavior degraded?** — If no, the test has no value
+   - e.g., If delay is halved, `expect(resolved).toBe(false)` at 999ms would catch it
+
+## Common Scenarios
+
+### Async Delay / Throttle / Debounce
+
+Use fake timers + boundary assertions (as shown above).
+
+### Data Transformation
+
+Assert on output shape/values, not on which internal helper was called.
+
+```typescript
+// BAD
+const spy = vi.spyOn(utils, 'formatDate');
+transform(input);
+expect(spy).toHaveBeenCalled();
+
+// GOOD
+const result = transform(input);
+expect(result.date).toBe('2026-01-01');
+```
+
+### Side Effects (API calls, DB writes)
+
+Mocking the boundary (API/DB) is acceptable — that IS the observable behavior.
+
+```typescript
+// OK: The contract IS "sends an email via mailer"
+expect(mockMailer.sendMail).toHaveBeenCalledWith(
+  expect.objectContaining({ to: 'user@example.com' })
+);
+```
+
+### Retry Logic
+
+Test the number of attempts and the final outcome, not the internal flow.
+
+```typescript
+// GOOD: Contract = "retries N times, then fails with specific error"
+mockMailer.sendMail.mockRejectedValue(new Error('fail'));
+await expect(sendWithRetry(config, 3)).rejects.toThrow('failed after 3 attempts');
+expect(mockMailer.sendMail).toHaveBeenCalledTimes(3);
+```
+
+## When to Apply
+
+- Writing new test cases for any function or method
+- Reviewing existing tests for flakiness or brittleness
+- Refactoring tests after fixing flaky CI failures
+- Code review of test pull requests

+ 68 - 10
.claude/skills/testing-patterns-with-vitest/SKILL.md → .claude/skills/learned/essential-test-patterns/SKILL.md

@@ -1,7 +1,6 @@
 ---
-name: testing-patterns-with-vitest
-description: GROWI testing patterns with Vitest, React Testing Library, and vitest-mock-extended. Auto-invoked for all GROWI development work.
-user-invocable: false
+name: essential-test-patterns
+description: GROWI testing patterns with Vitest, React Testing Library, and vitest-mock-extended.
 ---
 
 # GROWI Testing Patterns
@@ -373,6 +372,71 @@ vi.mock('~/utils/myUtils', () => ({
 }));
 ```
 
+### Mocking CommonJS Modules with mock-require
+
+**IMPORTANT**: When `vi.mock()` fails with ESModule/CommonJS compatibility issues, use `mock-require` instead:
+
+```typescript
+import mockRequire from 'mock-require';
+
+describe('Service with CommonJS dependencies', () => {
+  beforeEach(() => {
+    // Mock CommonJS module before importing the code under test
+    mockRequire('legacy-module', {
+      someFunction: vi.fn().mockReturnValue('mocked'),
+      someProperty: 'mocked-value',
+    });
+  });
+
+  afterEach(() => {
+    // Clean up mocks to avoid leakage between tests
+    mockRequire.stopAll();
+  });
+
+  it('should use mocked module', async () => {
+    // Import AFTER mocking (dynamic import if needed)
+    const { MyService } = await import('~/services/MyService');
+
+    const result = MyService.doSomething();
+    expect(result).toBe('mocked');
+  });
+});
+```
+
+**When to use `mock-require`**:
+- Legacy CommonJS modules that don't work with `vi.mock()`
+- Mixed ESM/CJS environments causing module resolution issues
+- Third-party libraries with complex module systems
+- When `vi.mock()` fails with "Cannot redefine property" or "Module is not defined"
+
+**Key points**:
+- ✅ Mock **before** importing the code under test
+- ✅ Use `mockRequire.stopAll()` in `afterEach()` to prevent test leakage
+- ✅ Use dynamic imports (`await import()`) when needed
+- ✅ Works with both CommonJS and ESModule targets
+
+### Choosing the Right Mocking Strategy
+
+```typescript
+// ✅ Prefer vi.mock() for ESModules (simplest)
+vi.mock('~/modern-module', () => ({
+  myFunction: vi.fn(),
+}));
+
+// ✅ Use mock-require for CommonJS or mixed environments
+import mockRequire from 'mock-require';
+mockRequire('legacy-module', { myFunction: vi.fn() });
+
+// ✅ Use vitest-mock-extended for type-safe object mocks
+import { mockDeep } from 'vitest-mock-extended';
+const mockService = mockDeep<MyService>();
+```
+
+**Decision tree**:
+1. Can use `vi.mock()`? → Use it (simplest)
+2. CommonJS or module error? → Use `mock-require`
+3. Need type-safe object mock? → Use `vitest-mock-extended`
+
 ## Integration Tests (with Database)
 
 Integration tests (*.integ.ts) can access in-memory databases:
@@ -415,13 +479,7 @@ Before committing tests, ensure:
 
 ## Running Tests
 
-```bash
-# Run all tests for a package
-turbo run test --filter @growi/app
-
-# Run specific test file
-cd {package_dir} && pnpm vitest run src/components/Button/Button.spec.tsx
-```
+See the `testing` rule (`.claude/rules/testing.md`) for test execution commands.
 
 ## Summary: GROWI Testing Philosophy
 

+ 4 - 3
.claude/skills/monorepo-overview/SKILL.md

@@ -172,10 +172,11 @@ turbo run bootstrap
 # Start all dev servers (apps/app + dependencies)
 turbo run dev
 
-# Run tests for specific package
-turbo run test --filter @growi/app
+# Run a specific test file (from package directory)
+pnpm vitest run yjs.integ
 
-# Lint specific package
+# Run ALL tests / lint for a package
+turbo run test --filter @growi/app
 turbo run lint --filter @growi/core
 ```
 

+ 10 - 2
.claude/skills/tech-stack/SKILL.md

@@ -101,7 +101,12 @@ turbo run bootstrap
 ### Testing & Quality
 
 ```bash
-# Run tests for specific package
+# Run a specific test file (from package directory, e.g. apps/app)
+pnpm vitest run yjs.integ          # Partial file name match
+pnpm vitest run helper.spec        # Works for any test file
+pnpm vitest run yjs.integ --repeat=10  # Repeat for flaky test detection
+
+# Run ALL tests for a package (uses Turborepo caching)
 turbo run test --filter @growi/app
 
 # Run linters for specific package
@@ -182,9 +187,12 @@ Package-specific tsconfig.json example:
 
 ### Command Usage
 
-1. **Always use Turborepo for cross-package tasks**:
+1. **Use Turborepo for full-package tasks** (all tests, lint, build):
    - ✅ `turbo run test --filter @growi/app`
    - ❌ `cd apps/app && pnpm test` (bypasses Turborepo caching)
+2. **Use vitest directly for individual test files** (from package directory):
+   - ✅ `pnpm vitest run yjs.integ` (simple, fast)
+   - ❌ `turbo run test --filter @growi/app -- yjs.integ` (unnecessary overhead)
 
 2. **Use pnpm for package management**:
    - ✅ `pnpm install`

+ 4 - 1
.devcontainer/app/devcontainer.json

@@ -9,7 +9,8 @@
   "features": {
     "ghcr.io/devcontainers/features/node:1": {
       "version": "20.18.3"
-    }
+    },
+    "ghcr.io/devcontainers/features/github-cli:1": {}
   },
 
   // Use 'forwardPorts' to make a list of ports inside the container available locally.
@@ -30,6 +31,8 @@
         "editorconfig.editorconfig",
         "shinnn.stylelint",
         "stylelint.vscode-stylelint",
+        // markdown
+        "bierner.markdown-mermaid",
         // TypeScript (Native Preview)
         "typescriptteam.native-preview",
         // Test

+ 4 - 0
.devcontainer/pdf-converter/devcontainer.json

@@ -4,6 +4,10 @@
   "service": "pdf-converter",
   "workspaceFolder": "/workspace/growi",
 
+  "features": {
+    "ghcr.io/devcontainers/features/github-cli:1": {}
+  },
+
   // Use 'forwardPorts' to make a list of ports inside the container available locally.
   // "forwardPorts": [],
 

+ 764 - 0
.kiro/specs/oauth2-email-support/design.md

@@ -0,0 +1,764 @@
+# OAuth 2.0 Email Support - Technical Design
+
+## Overview
+
+This feature adds OAuth 2.0 authentication support for sending emails through Google Workspace accounts in GROWI. Administrators can configure email transmission using OAuth 2.0 credentials (Client ID, Client Secret, Refresh Token) instead of traditional SMTP passwords. This integration extends the existing mail service architecture while maintaining full backward compatibility with SMTP and SES configurations.
+
+**Purpose**: Enable secure, token-based email authentication for Google Workspace accounts, improving security by eliminating password-based SMTP authentication and following Google's recommended practices for application email integration.
+
+**Users**: GROWI administrators configuring email transmission settings will use the new OAuth 2.0 option alongside existing SMTP and SES methods.
+
+**Impact**: Extends the mail service to support a third transmission method (oauth2) without modifying existing SMTP or SES functionality. No breaking changes to existing deployments.
+
+### Goals
+
+- Add OAuth 2.0 as a transmission method option in mail settings
+- Support Google Workspace email sending via Gmail API with OAuth 2.0 credentials
+- Maintain backward compatibility with existing SMTP and SES configurations
+- Provide consistent admin UI experience following SMTP/SES patterns
+- Implement automatic OAuth 2.0 token refresh using nodemailer's built-in support
+- Ensure secure storage and handling of OAuth 2.0 credentials
+
+### Non-Goals
+
+- OAuth 2.0 providers beyond Google Workspace (Microsoft 365, generic OAuth 2.0 servers)
+- Migration tool from SMTP to OAuth 2.0 (administrators manually reconfigure)
+- Authorization flow UI for obtaining refresh tokens (documented external process via Google Cloud Console)
+- Multi-account or account rotation support (single OAuth 2.0 account per instance)
+- Email queuing or rate limiting specific to OAuth 2.0 (relies on existing mail service behavior)
+
+## Architecture
+
+### Existing Architecture Analysis
+
+**Current Mail Service Implementation**:
+- **Service Location**: `apps/app/src/server/service/mail.ts` (MailService class)
+- **Initialization**: MailService instantiated from Crowi container, loaded on app startup
+- **Transmission Methods**: Currently supports 'smtp' and 'ses' via `mail:transmissionMethod` config
+- **Factory Pattern**: `createSMTPClient()` and `createSESClient()` create nodemailer transports
+- **Configuration**: ConfigManager loads settings from MongoDB via `mail:*` namespace keys
+- **S2S Messaging**: Supports distributed config updates via `mailServiceUpdated` events
+- **Test Email**: SMTP-only test email functionality in admin UI
+
+**Current Admin UI Structure**:
+- **Main Component**: `MailSetting.tsx` - form container with transmission method radio buttons
+- **Sub-Components**: `SmtpSetting.tsx`, `SesSetting.tsx` - conditional rendering based on selected method
+- **State Management**: AdminAppContainer (unstated) manages form state and API calls
+- **Form Library**: react-hook-form for validation and submission
+- **API Integration**: `updateMailSettingHandler()` saves all mail settings via REST API
+
+**Integration Points**:
+- Config definition in `config-definition.ts` (add OAuth 2.0 keys)
+- MailService initialize() method (add OAuth 2.0 branch)
+- MailSetting.tsx transmission method array (add 'oauth2' option)
+- AdminAppContainer state methods (add OAuth 2.0 credential methods)
+
+### Architecture Pattern & Boundary Map
+
+```mermaid
+graph TB
+    subgraph "Client Layer"
+        MailSettingUI[MailSetting Component]
+        OAuth2SettingUI[OAuth2Setting Component]
+        SmtpSettingUI[SmtpSetting Component]
+        SesSettingUI[SesSetting Component]
+        AdminContainer[AdminAppContainer]
+    end
+
+    subgraph "API Layer"
+        AppSettingsAPI[App Settings API]
+        MailTestAPI[Mail Test API]
+    end
+
+    subgraph "Service Layer"
+        MailService[MailService]
+        ConfigManager[ConfigManager]
+        S2SMessaging[S2S Messaging]
+    end
+
+    subgraph "External Services"
+        GoogleOAuth[Google OAuth 2.0 API]
+        GmailAPI[Gmail API]
+        SMTPServer[SMTP Server]
+        SESAPI[AWS SES API]
+    end
+
+    subgraph "Data Layer"
+        MongoDB[(MongoDB Config)]
+    end
+
+    MailSettingUI --> AdminContainer
+    OAuth2SettingUI --> AdminContainer
+    SmtpSettingUI --> AdminContainer
+    SesSettingUI --> AdminContainer
+
+    AdminContainer --> AppSettingsAPI
+    AdminContainer --> MailTestAPI
+
+    AppSettingsAPI --> ConfigManager
+    MailTestAPI --> MailService
+
+    MailService --> ConfigManager
+    MailService --> S2SMessaging
+
+    ConfigManager --> MongoDB
+
+    MailService --> GoogleOAuth
+    MailService --> GmailAPI
+    MailService --> SMTPServer
+    MailService --> SESAPI
+
+    S2SMessaging -.->|mailServiceUpdated| MailService
+```
+
+**Architecture Integration**:
+- **Selected Pattern**: Factory Method Extension - adds `createOAuth2Client()` to existing MailService factory methods
+- **Domain Boundaries**:
+  - **Client**: Admin UI components for OAuth 2.0 configuration (follows existing SmtpSetting/SesSetting pattern)
+  - **Service**: MailService handles all transmission methods; OAuth 2.0 isolated in new factory method
+  - **Config**: ConfigManager persists OAuth 2.0 credentials using `mail:oauth2*` namespace
+  - **External**: Google OAuth 2.0 API for token management; Gmail API for email transmission
+- **Existing Patterns Preserved**:
+  - Transmission method selection pattern (radio buttons, conditional rendering)
+  - Factory method pattern for transport creation
+  - Config namespace pattern (`mail:*` keys)
+  - Unstated container state management
+  - S2S messaging for distributed config updates
+- **New Components Rationale**:
+  - **OAuth2Setting Component**: Maintains UI consistency with SMTP/SES; enables modular development
+  - **createOAuth2Client() Method**: Isolates OAuth 2.0 transport logic; follows existing factory pattern
+  - **Four Config Keys**: Minimal set for OAuth 2.0 (user, clientId, clientSecret, refreshToken)
+- **Steering Compliance**:
+  - Feature-based organization (mail service domain)
+  - Named exports throughout
+  - Type safety with explicit TypeScript interfaces
+  - Immutable config updates
+  - Security-first credential handling
+
+### Technology Stack
+
+| Layer | Choice / Version | Role in Feature | Notes |
+|-------|------------------|-----------------|-------|
+| Frontend | React 18.x + TypeScript | OAuth2Setting UI component | Existing stack, no new dependencies |
+| Frontend | react-hook-form | Form validation and state | Existing dependency, consistent with SmtpSetting/SesSetting |
+| Backend | Node.js + TypeScript | MailService OAuth 2.0 integration | Existing runtime, no version changes |
+| Backend | nodemailer 6.x | OAuth 2.0 transport creation | Existing dependency with built-in OAuth 2.0 support |
+| Data | MongoDB | Config storage for OAuth 2.0 credentials | Existing database, new config keys only |
+| External | Google OAuth 2.0 API | Token refresh endpoint | Standard Google API, https://oauth2.googleapis.com/token |
+| External | Gmail API | Email transmission via OAuth 2.0 | Accessed via nodemailer Gmail transport |
+
+**Key Technology Decisions**:
+- **Nodemailer OAuth 2.0**: Built-in support eliminates need for additional OAuth 2.0 libraries; automatic token refresh reduces complexity
+- **No New Dependencies**: Feature fully implemented with existing packages; zero dependency risk
+- **MongoDB Encryption**: Credentials stored using existing ConfigManager encryption (same as SMTP passwords)
+- **Gmail Service Shortcut**: Nodemailer's `service: "gmail"` simplifies configuration and handles Gmail API specifics
+
+## System Flows
+
+### OAuth 2.0 Configuration Flow
+
+```mermaid
+sequenceDiagram
+    participant Admin as Administrator
+    participant UI as MailSetting UI
+    participant Container as AdminAppContainer
+    participant API as App Settings API
+    participant Config as ConfigManager
+    participant DB as MongoDB
+
+    Admin->>UI: Select "oauth2" transmission method
+    UI->>UI: Render OAuth2Setting component
+    Admin->>UI: Enter OAuth 2.0 credentials
+    Admin->>UI: Click Update button
+    UI->>Container: handleSubmit formData
+    Container->>API: POST app-settings
+    API->>API: Validate OAuth 2.0 fields
+    alt Validation fails
+        API-->>Container: 400 Bad Request
+        Container-->>UI: Display error toast
+    else Validation passes
+        API->>Config: setConfig mail:oauth2*
+        Config->>DB: Save encrypted credentials
+        DB-->>Config: Success
+        Config-->>API: Success
+        API-->>Container: 200 OK
+        Container-->>UI: Display success toast
+    end
+```
+
+### Email Sending with OAuth 2.0 Flow
+
+```mermaid
+sequenceDiagram
+    participant App as GROWI Application
+    participant Mail as MailService
+    participant Nodemailer as Nodemailer Transport
+    participant Google as Google OAuth 2.0 API
+    participant Gmail as Gmail API
+
+    App->>Mail: send emailConfig
+    Mail->>Mail: Check mailer setup
+    alt Mailer not setup
+        Mail-->>App: Error Mailer not set up
+    else Mailer setup oauth2
+        Mail->>Nodemailer: sendMail mailConfig
+        Nodemailer->>Nodemailer: Check access token validity
+        alt Access token expired
+            Nodemailer->>Google: POST token refresh
+            Google-->>Nodemailer: New access token
+            Nodemailer->>Nodemailer: Cache access token
+        end
+        Nodemailer->>Gmail: POST send message
+        alt Authentication failure
+            Gmail-->>Nodemailer: 401 Unauthorized
+            Nodemailer-->>Mail: Error Invalid credentials
+            Mail-->>App: Error with OAuth 2.0 details
+        else Success
+            Gmail-->>Nodemailer: 200 OK message ID
+            Nodemailer-->>Mail: Success
+            Mail->>Mail: Log transmission success
+            Mail-->>App: Email sent successfully
+        end
+    end
+```
+
+**Flow-Level Decisions**:
+- **Token Refresh**: Handled entirely by nodemailer; MailService does not implement custom refresh logic
+- **Error Handling**: OAuth 2.0 errors logged with specific Google API error codes for admin troubleshooting
+- **Credential Validation**: Performed at API layer before persisting to database; prevents invalid config states
+- **S2S Sync**: OAuth 2.0 config changes trigger `mailServiceUpdated` event for distributed deployments (existing pattern)
+
+## Requirements Traceability
+
+| Requirement | Summary | Components | Interfaces | Flows |
+|-------------|---------|------------|------------|-------|
+| 1.1 | Add OAuth 2.0 transmission method option | MailSetting.tsx, config-definition.ts | ConfigDefinition | Configuration |
+| 1.2 | Display OAuth 2.0 config fields when selected | OAuth2Setting.tsx, MailSetting.tsx | React Props | Configuration |
+| 1.3 | Validate email address format | AdminAppContainer, App Settings API | API Contract | Configuration |
+| 1.4 | Validate non-empty OAuth 2.0 credentials | AdminAppContainer, App Settings API | API Contract | Configuration |
+| 1.5 | Securely store OAuth 2.0 credentials with encryption | ConfigManager, MongoDB | Data Model | Configuration |
+| 1.6 | Confirm successful configuration save | AdminAppContainer, MailSetting.tsx | API Contract | Configuration |
+| 1.7 | Display descriptive error messages on save failure | AdminAppContainer, MailSetting.tsx | API Contract | Configuration |
+| 2.1 | Use nodemailer Gmail OAuth 2.0 transport | MailService.createOAuth2Client() | Service Interface | Email Sending |
+| 2.2 | Authenticate to Gmail API with OAuth 2.0 | MailService.createOAuth2Client() | External API | Email Sending |
+| 2.3 | Set FROM address to configured email | MailService.setupMailConfig() | Service Interface | Email Sending |
+| 2.4 | Log successful email transmission | MailService.send() | Service Interface | Email Sending |
+| 2.5 | Support all email content types | MailService.send() (existing) | Service Interface | Email Sending |
+| 2.6 | Process email queue sequentially | MailService.send() (existing) | Service Interface | Email Sending |
+| 3.1 | Use nodemailer automatic token refresh | Nodemailer OAuth 2.0 transport | External Library | Email Sending |
+| 3.2 | Request new access token with refresh token | Nodemailer OAuth 2.0 transport | External API | Email Sending |
+| 3.3 | Continue email sending after token refresh | Nodemailer OAuth 2.0 transport | External Library | Email Sending |
+| 3.4 | Log error and notify admin on refresh failure | MailService.send(), Error Handler | Service Interface | Email Sending |
+| 3.5 | Cache access tokens in memory | Nodemailer OAuth 2.0 transport | External Library | Email Sending |
+| 3.6 | Invalidate cached tokens on config update | MailService.initialize() | Service Interface | Configuration |
+| 4.1 | Display OAuth 2.0 form with consistent styling | OAuth2Setting.tsx | React Component | Configuration |
+| 4.2 | Preserve OAuth 2.0 credentials when switching methods | AdminAppContainer state | State Management | Configuration |
+| 4.3 | Provide field-level help text | OAuth2Setting.tsx | React Component | Configuration |
+| 4.4 | Mask sensitive fields (last 4 characters) | OAuth2Setting.tsx | React Component | Configuration |
+| 4.5 | Provide test email button | MailSetting.tsx | API Contract | Email Sending |
+| 4.6 | Display test email result with detailed errors | AdminAppContainer, MailSetting.tsx | API Contract | Email Sending |
+| 5.1 | Log specific OAuth 2.0 error codes | MailService error handler | Service Interface | Email Sending |
+| 5.2 | Retry email sending with exponential backoff | MailService.send() | Service Interface | Email Sending |
+| 5.3 | Store failed emails after all retries | MailService.send() | Service Interface | Email Sending |
+| 5.4 | Never log credentials in plain text | MailService, ConfigManager | Security Pattern | All flows |
+| 5.5 | Require admin authentication for config page | App Settings API | API Contract | Configuration |
+| 5.6 | Stop OAuth 2.0 sending when credentials deleted | MailService.initialize() | Service Interface | Email Sending |
+| 5.7 | Validate SSL/TLS for OAuth 2.0 endpoints | Nodemailer OAuth 2.0 transport | External Library | Email Sending |
+| 6.1 | Maintain backward compatibility with SMTP/SES | MailService, config-definition.ts | All Interfaces | All flows |
+| 6.2 | Use only active transmission method | MailService.initialize() | Service Interface | Email Sending |
+| 6.3 | Allow switching transmission methods without data loss | AdminAppContainer, ConfigManager | State Management | Configuration |
+| 6.4 | Display configuration error if no method set | MailService, MailSetting.tsx | Service Interface | Configuration |
+| 6.5 | Expose OAuth 2.0 status via admin API | App Settings API | API Contract | Configuration |
+
+## Components and Interfaces
+
+### Component Summary
+
+| Component | Domain/Layer | Intent | Req Coverage | Key Dependencies (P0/P1) | Contracts |
+|-----------|--------------|--------|--------------|--------------------------|-----------|
+| MailService | Server/Service | Email transmission with OAuth 2.0 support | 2.1-2.6, 3.1-3.6, 5.1-5.7, 6.2, 6.4 | ConfigManager (P0), Nodemailer (P0), S2SMessaging (P1) | Service |
+| OAuth2Setting | Client/UI | OAuth 2.0 credential input form | 1.2, 4.1, 4.3, 4.4 | AdminAppContainer (P0), react-hook-form (P0) | State |
+| AdminAppContainer | Client/State | State management for mail settings | 1.3, 1.4, 1.6, 1.7, 4.2, 6.3 | App Settings API (P0) | API |
+| ConfigManager | Server/Service | Persist OAuth 2.0 credentials | 1.5, 6.1, 6.3 | MongoDB (P0) | Service, State |
+| App Settings API | Server/API | Mail settings CRUD operations | 1.3-1.7, 4.5-4.6, 5.5, 6.5 | ConfigManager (P0), MailService (P1) | API |
+| Config Definition | Server/Config | OAuth 2.0 config schema | 1.1, 6.1 | None | State |
+
+### Server / Service Layer
+
+#### MailService
+
+| Field | Detail |
+|-------|--------|
+| Intent | Extend email transmission service with OAuth 2.0 support using Gmail API |
+| Requirements | 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 5.1, 5.2, 5.3, 5.4, 5.6, 5.7, 6.2, 6.4 |
+| Owner / Reviewers | Backend team |
+
+**Responsibilities & Constraints**
+- Create OAuth 2.0 nodemailer transport using Gmail service with credentials from ConfigManager
+- Handle OAuth 2.0 authentication failures and token refresh errors with specific error logging
+- Implement retry logic with exponential backoff (1s, 2s, 4s) for transient failures
+- Store failed emails after all retry attempts for manual review
+- Maintain single active transmission method (smtp, ses, or oauth2) per instance
+- Invalidate cached OAuth 2.0 tokens when configuration changes via S2S messaging
+
+**Dependencies**
+- Inbound: Crowi container — service initialization (P0)
+- Inbound: Application modules — email sending requests (P0)
+- Inbound: S2S Messaging — config update notifications (P1)
+- Outbound: ConfigManager — load OAuth 2.0 credentials (P0)
+- Outbound: Nodemailer — create transport and send emails (P0)
+- External: Google OAuth 2.0 API — token refresh (P0)
+- External: Gmail API — email transmission (P0)
+
+**Contracts**: Service [x]
+
+##### Service Interface
+
+```typescript
+interface MailServiceOAuth2Extension {
+  /**
+   * Create OAuth 2.0 nodemailer transport for Gmail
+   */
+  createOAuth2Client(option?: OAuth2TransportOptions): Transporter | null;
+
+  /**
+   * Send email with retry logic and error handling
+   */
+  sendWithRetry(config: EmailConfig, maxRetries?: number): Promise<SendResult>;
+
+  /**
+   * Store failed email for manual review
+   */
+  storeFailedEmail(config: EmailConfig, error: Error): Promise<void>;
+
+  /**
+   * Wait with exponential backoff
+   */
+  exponentialBackoff(attempt: number): Promise<void>;
+}
+
+interface OAuth2TransportOptions {
+  user: string;
+  clientId: string;
+  clientSecret: string;
+  refreshToken: string;
+}
+
+interface MailService {
+  send(config: EmailConfig): Promise<void>;
+  initialize(): void;
+  isMailerSetup: boolean;
+}
+
+interface EmailConfig {
+  to: string;
+  from?: string;
+  subject?: string;
+  template: string;
+  vars?: Record<string, unknown>;
+}
+
+interface SendResult {
+  messageId: string;
+  response: string;
+  envelope: {
+    from: string;
+    to: string[];
+  };
+}
+```
+
+- **Preconditions**:
+  - ConfigManager loaded with valid `mail:oauth2*` configuration values
+  - Nodemailer package version supports OAuth 2.0 (v6.x+)
+  - Google OAuth 2.0 refresh token has `https://mail.google.com/` scope
+
+- **Postconditions**:
+  - OAuth 2.0 transport created with automatic token refresh enabled
+  - `isMailerSetup` flag set to true when transport successfully created
+  - Failed transport creation returns null and logs error
+  - Successful email sends logged with messageId and recipient
+  - Failed emails stored after retry exhaustion
+
+- **Invariants**:
+  - Only one transmission method active at a time
+  - Credentials never logged in plain text
+  - Token refresh handled transparently by nodemailer
+  - Retry backoff: 1s, 2s, 4s
+
+
+#### ConfigManager
+
+| Field | Detail |
+|-------|--------|
+| Intent | Persist and retrieve OAuth 2.0 credentials with encryption |
+| Requirements | 1.5, 6.1, 6.3 |
+
+**Responsibilities & Constraints**
+- Store four new OAuth 2.0 config keys with encryption
+- Support transmission method value 'oauth2'
+- Maintain all SMTP and SES config values when OAuth 2.0 is configured
+
+**Dependencies**
+- Inbound: MailService, App Settings API (P0)
+- Outbound: MongoDB, Encryption Service (P0)
+
+**Contracts**: Service [x] / State [x]
+
+##### Service Interface
+
+```typescript
+interface ConfigManagerOAuth2Extension {
+  getConfig(key: 'mail:oauth2User'): string | undefined;
+  getConfig(key: 'mail:oauth2ClientId'): string | undefined;
+  getConfig(key: 'mail:oauth2ClientSecret'): string | undefined;
+  getConfig(key: 'mail:oauth2RefreshToken'): string | undefined;
+  getConfig(key: 'mail:transmissionMethod'): 'smtp' | 'ses' | 'oauth2' | undefined;
+
+  setConfig(key: 'mail:oauth2User', value: string): Promise<void>;
+  setConfig(key: 'mail:oauth2ClientId', value: string): Promise<void>;
+  setConfig(key: 'mail:oauth2ClientSecret', value: string): Promise<void>;
+  setConfig(key: 'mail:oauth2RefreshToken', value: string): Promise<void>;
+  setConfig(key: 'mail:transmissionMethod', value: 'smtp' | 'ses' | 'oauth2'): Promise<void>;
+}
+```
+
+##### State Management
+
+- **State Model**: OAuth 2.0 credentials stored as separate config documents in MongoDB
+- **Persistence**: Encrypted at write time; decrypted at read time
+- **Consistency**: Atomic writes per config key
+- **Concurrency**: Last-write-wins; S2S messaging for eventual consistency
+
+
+### Client / UI Layer
+
+#### OAuth2Setting Component
+
+| Field | Detail |
+|-------|--------|
+| Intent | Render OAuth 2.0 credential input form with help text and field masking |
+| Requirements | 1.2, 4.1, 4.3, 4.4 |
+
+**Responsibilities & Constraints**
+- Display four input fields with help text
+- Mask saved Client Secret and Refresh Token (show last 4 characters)
+- Follow SMTP/SES visual patterns
+- Use react-hook-form register
+
+**Dependencies**
+- Inbound: MailSetting component (P0)
+- Outbound: AdminAppContainer (P1)
+- External: react-hook-form (P0)
+
+**Contracts**: State [x]
+
+##### State Management
+
+```typescript
+interface OAuth2SettingProps {
+  register: UseFormRegister<MailSettingsFormData>;
+  adminAppContainer?: AdminAppContainer;
+}
+
+interface MailSettingsFormData {
+  fromAddress: string;
+  transmissionMethod: 'smtp' | 'ses' | 'oauth2';
+  smtpHost: string;
+  smtpPort: string;
+  smtpUser: string;
+  smtpPassword: string;
+  sesAccessKeyId: string;
+  sesSecretAccessKey: string;
+  oauth2User: string;
+  oauth2ClientId: string;
+  oauth2ClientSecret: string;
+  oauth2RefreshToken: string;
+}
+```
+
+
+#### AdminAppContainer (Extension)
+
+| Field | Detail |
+|-------|--------|
+| Intent | Manage OAuth 2.0 credential state and API interactions |
+| Requirements | 1.3, 1.4, 1.6, 1.7, 4.2, 6.3 |
+
+**Responsibilities & Constraints**
+- Add four state properties and setter methods
+- Include OAuth 2.0 credentials in API payload
+- Validate email format before API call
+- Display success/error toasts
+
+**Dependencies**
+- Inbound: MailSetting, OAuth2Setting (P0)
+- Outbound: App Settings API (P0)
+
+**Contracts**: State [x] / API [x]
+
+##### State Management
+
+```typescript
+interface AdminAppContainerOAuth2State {
+  fromAddress?: string;
+  transmissionMethod?: 'smtp' | 'ses' | 'oauth2';
+  smtpHost?: string;
+  smtpPort?: string;
+  smtpUser?: string;
+  smtpPassword?: string;
+  sesAccessKeyId?: string;
+  sesSecretAccessKey?: string;
+  isMailerSetup: boolean;
+  oauth2User?: string;
+  oauth2ClientId?: string;
+  oauth2ClientSecret?: string;
+  oauth2RefreshToken?: string;
+}
+
+interface AdminAppContainerOAuth2Methods {
+  changeOAuth2User(oauth2User: string): Promise<void>;
+  changeOAuth2ClientId(oauth2ClientId: string): Promise<void>;
+  changeOAuth2ClientSecret(oauth2ClientSecret: string): Promise<void>;
+  changeOAuth2RefreshToken(oauth2RefreshToken: string): Promise<void>;
+  updateMailSettingHandler(): Promise<void>;
+}
+```
+
+
+### Server / API Layer
+
+#### App Settings API (Extension)
+
+| Field | Detail |
+|-------|--------|
+| Intent | Handle OAuth 2.0 credential CRUD operations with validation |
+| Requirements | 1.3, 1.4, 1.5, 1.6, 1.7, 4.5, 4.6, 5.5, 6.5 |
+
+**Responsibilities & Constraints**
+- Accept OAuth 2.0 credentials in PUT request
+- Validate email format and non-empty credentials
+- Persist via ConfigManager
+- Trigger S2S messaging
+- Require admin authentication
+
+**Dependencies**
+- Inbound: AdminAppContainer (P0)
+- Outbound: ConfigManager, MailService, S2S Messaging (P0/P1)
+
+**Contracts**: API [x]
+
+##### API Contract
+
+| Method | Endpoint | Request | Response | Errors |
+|--------|----------|---------|----------|--------|
+| PUT | /api/v3/app-settings | UpdateMailSettingsRequest | AppSettingsResponse | 400, 401, 500 |
+| GET | /api/v3/app-settings | - | AppSettingsResponse | 401, 500 |
+| POST | /api/v3/mail/send-test | - | TestEmailResponse | 400, 401, 500 |
+
+**Request/Response Schemas**:
+
+```typescript
+interface UpdateMailSettingsRequest {
+  'mail:from'?: string;
+  'mail:transmissionMethod'?: 'smtp' | 'ses' | 'oauth2';
+  'mail:smtpHost'?: string;
+  'mail:smtpPort'?: string;
+  'mail:smtpUser'?: string;
+  'mail:smtpPassword'?: string;
+  'mail:sesAccessKeyId'?: string;
+  'mail:sesSecretAccessKey'?: string;
+  'mail:oauth2User'?: string;
+  'mail:oauth2ClientId'?: string;
+  'mail:oauth2ClientSecret'?: string;
+  'mail:oauth2RefreshToken'?: string;
+}
+
+interface AppSettingsResponse {
+  appSettings: {
+    'mail:from'?: string;
+    'mail:transmissionMethod'?: 'smtp' | 'ses' | 'oauth2';
+    'mail:smtpHost'?: string;
+    'mail:smtpPort'?: string;
+    'mail:smtpUser'?: string;
+    'mail:sesAccessKeyId'?: string;
+    'mail:oauth2User'?: string;
+    'mail:oauth2ClientId'?: string;
+  };
+  isMailerSetup: boolean;
+}
+
+interface TestEmailResponse {
+  success: boolean;
+  message?: string;
+  error?: {
+    code: string;
+    message: string;
+  };
+}
+```
+
+
+### Server / Config Layer
+
+#### Config Definition (Extension)
+
+| Field | Detail |
+|-------|--------|
+| Intent | Define OAuth 2.0 configuration schema with type safety |
+| Requirements | 1.1, 6.1 |
+
+**Config Schema**:
+
+```typescript
+const CONFIG_KEYS = [
+  'mail:oauth2User',
+  'mail:oauth2ClientId',
+  'mail:oauth2ClientSecret',
+  'mail:oauth2RefreshToken',
+];
+
+'mail:transmissionMethod': defineConfig<'smtp' | 'ses' | 'oauth2' | undefined>({
+  defaultValue: undefined,
+}),
+
+'mail:oauth2User': defineConfig<string | undefined>({
+  defaultValue: undefined,
+}),
+'mail:oauth2ClientId': defineConfig<string | undefined>({
+  defaultValue: undefined,
+}),
+'mail:oauth2ClientSecret': defineConfig<string | undefined>({
+  defaultValue: undefined,
+  isSecret: true,
+}),
+'mail:oauth2RefreshToken': defineConfig<string | undefined>({
+  defaultValue: undefined,
+  isSecret: true,
+}),
+```
+
+## Data Models
+
+### Domain Model
+
+**Mail Configuration Aggregate**:
+- **Root Entity**: MailConfiguration
+- **Value Objects**: TransmissionMethod, OAuth2Credentials, SmtpCredentials, SesCredentials
+- **Business Rules**: Only one transmission method active; OAuth2Credentials complete when all fields present
+- **Invariants**: Credentials encrypted; FROM address required
+
+### Logical Data Model
+
+**Structure Definition**:
+- **Entity**: Config (MongoDB document)
+- **Attributes**: ns, key, value, createdAt, updatedAt
+- **Natural Keys**: ns field (unique)
+
+**Consistency & Integrity**:
+- **Transaction Boundaries**: Each config key saved independently
+- **Temporal Aspects**: updatedAt tracked per entry
+
+### Physical Data Model
+
+- Config documents stored in MongoDB with ns/key/value pattern
+- FailedEmail documents track failed email attempts with error context
+- **Encryption**: AES-256 for clientSecret and refreshToken via environment-provided key
+
+### Data Contracts & Integration
+
+**API Data Transfer**:
+- OAuth 2.0 credentials via JSON in PUT /api/v3/app-settings
+- Client Secret and Refresh Token never returned in GET responses
+
+**Cross-Service Data Management**:
+- S2S messaging broadcasts mailServiceUpdated event
+- Eventual consistency across instances
+
+
+## Critical Implementation Constraints
+
+### Nodemailer XOAuth2 Compatibility (CRITICAL)
+
+**Constraint**: OAuth 2.0 credential validation **must use falsy checks** (`!value`) not null checks (`value != null`) to match nodemailer's internal XOAuth2 handler behavior.
+
+**Rationale**: Nodemailer's XOAuth2.generateToken() method uses `!this.options.refreshToken` at line 184, which rejects empty strings as invalid. Using `!= null` checks in GROWI would allow empty strings through validation, causing runtime failures when nodemailer rejects them.
+
+**Implementation Pattern**:
+```typescript
+// ✅ CORRECT: Falsy check matches nodemailer behavior
+if (!clientId || !clientSecret || !refreshToken || !user) {
+  return null;
+}
+```
+
+**Impact**: Affects MailService.createOAuth2Client(), ConfigManager validation, and API validators. All OAuth 2.0 credential checks must follow this pattern.
+
+**Reference**: [mail.ts:219-226](../../../apps/app/src/server/service/mail.ts#L219-L226), [research.md](research.md#1-nodemailer-xoauth2-falsy-check-requirement)
+
+---
+
+### Credential Preservation Pattern (CRITICAL)
+
+**Constraint**: PUT requests updating OAuth 2.0 configuration **must only include secret fields (clientSecret, refreshToken) when non-empty values are provided**, preventing accidental credential overwrites.
+
+**Rationale**: Standard PUT pattern sending all form fields would overwrite secrets with empty strings when administrators update non-secret fields (from address, user email). GET endpoint returns `undefined` for secrets (not masked placeholders) to prevent re-submission of placeholder text.
+
+**Implementation Pattern**:
+```typescript
+// Build params with non-secret fields
+const params = {
+  'mail:oauth2ClientId': req.body.oauth2ClientId,
+  'mail:oauth2User': req.body.oauth2User,
+};
+
+// Only include secrets if non-empty
+if (req.body.oauth2ClientSecret) {
+  params['mail:oauth2ClientSecret'] = req.body.oauth2ClientSecret;
+}
+```
+
+**Impact**: Affects App Settings API PUT handler and any future API that updates OAuth 2.0 credentials.
+
+**Reference**: [apiv3/app-settings/index.ts:293-306](../../../apps/app/src/server/routes/apiv3/app-settings/index.ts#L293-L306), [research.md](research.md#3-credential-preservation-pattern)
+
+---
+
+### Gmail API FROM Address Behavior (LIMITATION)
+
+**Limitation**: Gmail API **rewrites FROM addresses to the authenticated account email** unless send-as aliases are 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 in Gmail Settings → Accounts and Import → Send mail as, then verify domain ownership.
+
+**Why This Happens**: Gmail API security policy prevents email spoofing by restricting FROM addresses to authenticated accounts or verified aliases.
+
+**Impact**: GROWI's `mail:from` configuration has limited effect with OAuth 2.0. Custom FROM addresses require Google Workspace configuration. This is expected Gmail behavior, not a GROWI limitation.
+
+**Reference**: [research.md](research.md#2-gmail-api-from-address-rewriting)
+
+---
+
+### OAuth 2.0 Retry Integration (DESIGN DECISION)
+
+**Decision**: OAuth 2.0 transmission uses `sendWithRetry()` with exponential backoff (1s, 2s, 4s), while SMTP/SES use direct `sendMail()` without retries.
+
+**Rationale**: OAuth 2.0 token refresh can fail transiently due to network issues or Google API rate limiting. Exponential backoff provides resilience without overwhelming the API.
+
+**Implementation**:
+```typescript
+if (transmissionMethod === 'oauth2') {
+  return this.sendWithRetry(mailConfig);
+}
+return this.mailer.sendMail(mailConfig);
+```
+
+**Impact**: OAuth 2.0 email failures are automatically retried, improving reliability for production deployments.
+
+**Reference**: [mail.ts:392-400](../../../apps/app/src/server/service/mail.ts#L392-L400)

+ 57 - 0
.kiro/specs/oauth2-email-support/requirements.md

@@ -0,0 +1,57 @@
+# Requirements Document
+
+## Project Description (Input)
+OAuth 2.0 authentication で Google Workspace を利用し email を送信する機能を追加したい
+
+### Context from User
+This implementation adds OAuth 2.0 authentication support for sending emails using Google Workspace accounts. The feature is fully integrated into the admin settings UI and follows the existing patterns for SMTP and SES configuration.
+
+Key configuration parameters:
+- Email Address: The authorized Google account email
+- Client ID: OAuth 2.0 Client ID from Google Cloud Console
+- Client Secret: OAuth 2.0 Client Secret
+- Refresh Token: OAuth 2.0 Refresh Token obtained from authorization flow
+
+The implementation uses nodemailer's built-in Gmail OAuth 2.0 support, which handles token refresh automatically.
+
+## Introduction
+
+This specification defines the requirements for adding OAuth 2.0 authentication support for email transmission using Google Workspace accounts in GROWI. The feature enables administrators to configure email sending through Google's Gmail API using OAuth 2.0 credentials instead of traditional SMTP authentication. This provides enhanced security through token-based authentication and follows Google's recommended practices for application email integration.
+
+## Requirements
+
+### Requirement 1: OAuth 2.0 Configuration Management
+
+**Objective:** As a GROWI administrator, I want to configure OAuth 2.0 credentials for Google Workspace email sending, so that the system can securely send emails without using SMTP passwords.
+
+**Summary**: The Admin Settings UI provides OAuth 2.0 as a transmission method option alongside SMTP and SES. The configuration form includes fields for Email Address, Client ID, Client Secret, and Refresh Token. All fields are validated (email format, non-empty strings using falsy checks), and secrets are encrypted before database storage. Configuration updates preserve existing secrets when empty values are submitted, preventing accidental credential overwrites. Success and error feedback is displayed to administrators.
+
+### Requirement 2: Email Sending Functionality
+
+**Objective:** As a GROWI system, I want to send emails using OAuth 2.0 authenticated Google Workspace accounts, so that notifications and system emails can be delivered securely without SMTP credentials.
+
+**Summary**: The Email Service uses nodemailer with Gmail OAuth 2.0 transport for email sending when OAuth 2.0 is configured. Authentication to Gmail API is automatic using configured credentials. The service supports all email content types (plain text, HTML, attachments, standard headers). Successful transmissions are logged with timestamp and recipient information. OAuth 2.0 sends use retry logic with exponential backoff (1s, 2s, 4s) to handle transient failures. Note: Gmail API rewrites FROM address to the authenticated account unless send-as aliases are configured in Google Workspace.
+
+### Requirement 3: Token Management
+
+**Objective:** As a GROWI system, I want to automatically manage OAuth 2.0 access token lifecycle, so that email sending continues without manual intervention when tokens expire.
+
+**Summary**: Token refresh is handled automatically by nodemailer's built-in OAuth 2.0 support. Access tokens are cached in memory and reused until expiration. When refresh tokens are used, nodemailer requests new access tokens from Google's OAuth 2.0 endpoint transparently. Token refresh failures are logged with specific error codes for troubleshooting. When OAuth 2.0 configuration is updated, cached tokens are invalidated via service reinitialization triggered by S2S messaging.
+
+### Requirement 4: Admin UI Integration
+
+**Objective:** As a GROWI administrator, I want OAuth 2.0 email configuration to follow the same UI patterns as SMTP and SES, so that I can configure it consistently with existing mail settings.
+
+**Summary**: The Mail Settings page displays OAuth 2.0 configuration form with consistent visual styling, preserves credentials when switching transmission methods, and shows configuration status. Browser autofill is prevented for secret fields, and placeholder text indicates that blank fields will preserve existing values.
+
+### Requirement 5: Error Handling and Security
+
+**Objective:** As a GROWI administrator, I want clear error messages and secure credential handling, so that I can troubleshoot configuration issues and ensure credentials are protected.
+
+**Summary**: Authentication failures are logged with specific OAuth 2.0 error codes from Google's API for troubleshooting. Email sending failures trigger automatic retry with exponential backoff (3 attempts: 1s, 2s, 4s). Failed emails after retry exhaustion are stored in the database for manual review. Credentials are never logged in plain text (Client ID masked to last 4 characters). Admin authentication is required to access configuration. SSL/TLS validation is enforced by nodemailer. When OAuth 2.0 credentials are incomplete or deleted, the Email Service stops sending and displays configuration errors via isMailerSetup flag.
+
+### Requirement 6: Migration and Compatibility
+
+**Objective:** As a GROWI system, I want OAuth 2.0 email support to coexist with existing SMTP and SES configurations, so that administrators can choose the most appropriate transmission method for their deployment.
+
+**Summary**: OAuth 2.0 is added as a third transmission method option without breaking changes to existing SMTP and SES functionality. Only the active transmission method is used for sending emails. Administrators can switch between methods without data loss (credentials for all methods are preserved). Configuration errors are displayed when no transmission method is properly configured (via isMailerSetup flag). OAuth 2.0 configuration status is exposed through existing admin API endpoints following the same pattern as SMTP/SES.

+ 449 - 0
.kiro/specs/oauth2-email-support/research.md

@@ -0,0 +1,449 @@
+# 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**:
+  - [OAuth2 | Nodemailer](https://nodemailer.com/smtp/oauth2)
+  - [Using Gmail | Nodemailer](https://nodemailer.com/usage/using-gmail)
+  - [Sending Emails Securely Using Node.js, Nodemailer, SMTP, Gmail, and OAuth2](https://dev.to/chandrapantachhetri/sending-emails-securely-using-node-js-nodemailer-smtp-gmail-and-oauth2-g3a)
+  - Web search: "nodemailer gmail oauth2 configuration 2026"
+- **Findings**:
+  - Nodemailer has first-class OAuth2 support with type `'OAuth2'`
+  - Configuration structure:
+    ```javascript
+    {
+      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**:
+```typescript
+// ❌ 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](../../../apps/app/src/server/service/mail.ts#L219-L226)
+
+---
+
+#### 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:
+
+```typescript
+// 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:
+
+```typescript
+// ❌ 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**:
+- PUT handler: [apiv3/app-settings/index.ts:293-306](../../../apps/app/src/server/routes/apiv3/app-settings/index.ts#L293-L306)
+- GET response: [apiv3/app-settings/index.ts:273-276](../../../apps/app/src/server/routes/apiv3/app-settings/index.ts#L273-L276)
+
+---
+
+### Type Safety Enhancements
+
+**NonBlankString Type**: OAuth 2.0 config definitions use `NonBlankString | undefined` for compile-time protection against empty string assignments:
+
+```typescript
+'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:
+
+```typescript
+// 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:
+
+```typescript
+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:
+
+```typescript
+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:
+```typescript
+import MailService from '~/server/service/mail';  // Still works
+```
+
+**Source**: Migrated from `.kiro/specs/refactor-mailer-service/` (spec deleted after implementation completion).
+
+---
+
+## References
+
+- [OAuth2 | Nodemailer](https://nodemailer.com/smtp/oauth2) - Official OAuth2 configuration documentation
+- [Using Gmail | Nodemailer](https://nodemailer.com/usage/using-gmail) - Gmail-specific integration guide
+- [Sending Emails Securely Using Node.js, Nodemailer, SMTP, Gmail, and OAuth2](https://dev.to/chandrapantachhetri/sending-emails-securely-using-node-js-nodemailer-smtp-gmail-and-oauth2-g3a) - Implementation tutorial
+- [Using OAuth2 with Nodemailer for Secure Email Sending](https://shazaali.substack.com/p/using-oauth2-with-nodemailer-for) - Security best practices
+- Internal: `apps/app/src/server/service/mail.ts` - Existing mail service implementation
+- Internal: `apps/app/src/client/components/Admin/App/MailSetting.tsx` - Admin UI patterns

+ 23 - 0
.kiro/specs/oauth2-email-support/spec.json

@@ -0,0 +1,23 @@
+{
+  "feature_name": "oauth2-email-support",
+  "created_at": "2026-02-06T11:43:56Z",
+  "updated_at": "2026-02-13T00:00:00Z",
+  "language": "en",
+  "phase": "implementation-complete",
+  "cleanup_completed": true,
+  "approvals": {
+    "requirements": {
+      "generated": true,
+      "approved": true
+    },
+    "design": {
+      "generated": true,
+      "approved": true
+    },
+    "tasks": {
+      "generated": true,
+      "approved": true
+    }
+  },
+  "ready_for_implementation": true
+}

+ 41 - 0
.kiro/specs/oauth2-email-support/tasks.md

@@ -0,0 +1,41 @@
+# Implementation Tasks - OAuth 2.0 Email Support
+
+## Status Overview
+
+**Final Status**: Production-Ready (2026-02-10)
+**Requirements Coverage**: 35/37 (95%)
+
+## Completed Tasks
+
+### Phase A: Critical Production Requirements (3 tasks)
+
+- [x] 1. Retry logic with exponential backoff (1s, 2s, 4s) - Req: 5.1, 5.2
+- [x] 2. Failed email storage after retry exhaustion - Req: 5.3
+- [x] 3. Enhanced OAuth 2.0 error logging - Req: 5.4, 5.7
+
+Session 2 additional fixes:
+- Credential validation changed to falsy check (nodemailer XOAuth2 compatibility)
+- PUT handler preserves secrets when empty values submitted
+- Config types changed to `NonBlankString | undefined`
+- GET response returns `undefined` for secrets
+- Browser autofill prevention (`autoComplete="new-password"`)
+- Static IDs replaced with `useId()` hook (Biome lint compliance)
+
+### Baseline Implementation (12 tasks)
+
+- [x] Configuration schema (4 config keys, encryption, NonBlankString types) - Req: 1.1, 1.5, 6.1
+- [x] OAuth 2.0 transport creation (nodemailer Gmail service) - Req: 2.1, 2.2, 3.1-3.3, 3.5, 6.2
+- [x] Service initialization and token management (S2S integration) - Req: 2.3, 2.5, 2.6, 3.6, 5.6, 6.2, 6.4
+- [x] API validation and persistence (PUT/GET endpoints) - Req: 1.3, 1.4, 1.5, 1.6, 5.5, 6.5
+- [x] Field-specific validation error messages - Req: 1.7
+- [x] OAuth2Setting UI component (react-hook-form integration) - Req: 1.2, 4.1
+- [x] AdminAppContainer state management (4 state properties) - Req: 4.2, 6.3
+- [x] Mail settings form submission - Req: 1.3, 1.6, 1.7
+- [x] Transmission method selection ('oauth2' option) - Req: 1.1, 1.2
+- [x] Multi-language translations (en, ja, fr, ko, zh) - Req: 1.2, 4.1, 4.3
+
+## Not Implemented (Optional Enhancements)
+
+- Help text for 2 of 4 fields incomplete (Req 4.3)
+- Credential field masking in UI (Req 4.4)
+- Test email button for OAuth 2.0 (Req 4.5)

+ 3 - 9
.kiro/steering/structure.md

@@ -1,14 +1,8 @@
 # Project Structure
 
-@.claude/skills/monorepo-overview/SKILL.md
+See: `.claude/skills/monorepo-overview/SKILL.md` (auto-loaded by Claude Code)
 
 ## cc-sdd Specific Notes
 
-### Specification Storage
-- All specifications are stored in `.kiro/specs/{feature-name}/`
-- Each spec contains: `spec.json`, `requirements.md`, `design.md`, `tasks.md`
-
-### Feature Placement
-When implementing new features via `/kiro:spec-impl`:
-- Create feature modules in `src/features/{feature-name}/`
-- Follow the server-client separation pattern documented in the skill above
+Currently, there are no additional instructions specific to Kiro.
+If instructions specific to the cc-sdd workflow are needed in the future, add them to this section.

+ 4 - 18
.kiro/steering/tdd.md

@@ -1,22 +1,8 @@
 # Test-Driven Development
 
-@.claude/commands/tdd.md
-@.claude/skills/testing-patterns-with-vitest/SKILL.md
+See: `.claude/commands/tdd.md`, `.claude/skills/learned/essential-test-patterns/SKILL.md` and `.claude/skills/learned/essential-test-design/SKILL.md`
 
-## cc-sdd Integration
+## cc-sdd Specific Notes
 
-### TDD in spec-impl Workflow
-When executing `/kiro:spec-impl`, the TDD cycle is mandatory:
-
-1. **Each task → TDD cycle**: RED → GREEN → REFACTOR
-2. **Tests trace to requirements**: Test names should reference EARS requirement IDs
-3. **Coverage gates completion**: Task is not complete until coverage targets met
-
-### Validation Before Task Completion
-```bash
-# Verify tests pass
-turbo run test --filter {package}
-
-# Check coverage (80% minimum)
-cd {package_dir} && pnpm vitest run --coverage src/utils/page-path-validator.spec.ts
-```
+Currently, there are no additional instructions specific to Kiro.
+If instructions specific to the cc-sdd workflow are needed in the future, add them to this section.

+ 3 - 10
.kiro/steering/tech.md

@@ -1,15 +1,8 @@
 # Technology Stack
 
-@.claude/skills/tech-stack/SKILL.md
+See: `.claude/skills/tech-stack/SKILL.md` (auto-loaded by Claude Code)
 
 ## cc-sdd Specific Notes
 
-### Specification Language
-All spec files (requirements.md, design.md, tasks.md) should be written in English unless explicitly configured otherwise in spec.json.
-
-### Build Verification
-Before marking tasks complete in `/kiro:spec-impl`, ensure:
-```bash
-turbo run lint --filter @growi/app
-turbo run test --filter @growi/app
-```
+Currently, there are no additional instructions specific to Kiro.
+If instructions specific to the cc-sdd workflow are needed in the future, add them to this section.

+ 1 - 21
.mcp.json

@@ -1,23 +1,3 @@
 {
-  "mcpServers": {
-    "context7": {
-      "type": "http",
-      "url": "https://mcp.context7.com/mcp"
-    },
-    "serena": {
-      "type": "stdio",
-      "command": "uvx",
-      "args": [
-        "--from",
-        "git+https://github.com/oraios/serena",
-        "serena-mcp-server",
-        "--context",
-        "claude-code",
-        "--project",
-        ".",
-        "--enable-web-dashboard=false"
-      ],
-      "env": {}
-    }
-  }
+  "mcpServers": {}
 }

+ 64 - 8
.serena/project.yml

@@ -1,9 +1,3 @@
-# language of the project (csharp, python, rust, java, typescript, go, cpp, or ruby)
-#  * For C, use cpp
-#  * For JavaScript, use typescript
-# Special requirements:
-#  * csharp: Requires the presence of a .sln file in the project folder.
-language: typescript
 
 # whether to use the project's gitignore file to ignore files
 # Added on 2025-04-07
@@ -59,10 +53,72 @@ read_only: false
 #  * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
 #  * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
 #  * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
-excluded_tools: ["check_onboarding_performed", "execute_shell_command", "initial_instructions", "onboarding", "prepare_for_new_conversation", "read_memory", "write_memory", "list_memories", "delete_memory"]
+excluded_tools:
+- "check_onboarding_performed"
+- "execute_shell_command"
+- "initial_instructions"
+- "onboarding"
+- "prepare_for_new_conversation"
+- "read_memory"
+- "write_memory"
+- "list_memories"
+- "delete_memory"
 
 # initial prompt for the project. It will always be given to the LLM upon activating the project
 # (contrary to the memories, which are loaded on demand).
 initial_prompt: ""
-
+# the name by which the project can be referenced within Serena
 project_name: "growi"
+
+# list of mode names to that are always to be included in the set of active modes
+# The full set of modes to be activated is base_modes + default_modes.
+# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply.
+# Otherwise, this setting overrides the global configuration.
+# Set this to [] to disable base modes for this project.
+# Set this to a list of mode names to always include the respective modes for this project.
+base_modes:
+
+# list of mode names that are to be activated by default.
+# The full set of modes to be activated is base_modes + default_modes.
+# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
+# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
+# This setting can, in turn, be overridden by CLI parameters (--mode).
+default_modes:
+
+# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default)
+included_optional_tools: []
+
+# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
+# This cannot be combined with non-empty excluded_tools or included_optional_tools.
+fixed_tools: []
+
+# the encoding used by text files in the project
+# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
+encoding: utf-8
+
+
+# list of languages for which language servers are started; choose from:
+#   al                  bash                clojure             cpp                 csharp
+#   csharp_omnisharp    dart                elixir              elm                 erlang
+#   fortran             fsharp              go                  groovy              haskell
+#   java                julia               kotlin              lua                 markdown
+#   matlab              nix                 pascal              perl                php
+#   powershell          python              python_jedi         r                   rego
+#   ruby                ruby_solargraph     rust                scala               swift
+#   terraform           toml                typescript          typescript_vts      vue
+#   yaml                zig
+#   (This list may be outdated. For the current list, see values of Language enum here:
+#   https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
+#   For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
+# Note:
+#   - For C, use cpp
+#   - For JavaScript, use typescript
+#   - For Free Pascal/Lazarus, use pascal
+# Special requirements:
+#   Some languages require additional setup/installations.
+#   See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
+# When using multiple languages, the first language server that supports a given file will be used for that file.
+# The first language is the default language and the respective language server will be used as a fallback.
+# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
+languages:
+- typescript

+ 1 - 2
AGENTS.md

@@ -4,7 +4,7 @@ GROWI is a team collaboration wiki platform built with Next.js, Express, and Mon
 
 ## Language Policy
 
-**Response Language**: If we detect at the beginning of a conversation that the user's primary language is not English, we will always respond in that language. However, technical terms may remain in English when appropriate.
+**Response Language**: If the user writes in a non-English language at any point in the conversation, always respond in that language from that point onward. This rule takes **absolute priority** over any other language instructions, including skill/command prompts or context documents written in English.
 
 **Code Comments**: When generating source code, all comments and explanations within the code must be written in English, regardless of the conversation language.
 
@@ -24,7 +24,6 @@ Technical information is available in **Claude Code Skills** (`.claude/skills/`)
 |-------|-------------|
 | **monorepo-overview** | Monorepo structure, workspace organization, Changeset versioning |
 | **tech-stack** | Technology stack, pnpm/Turborepo, TypeScript, Biome |
-| **testing-patterns-with-vitest** | Vitest, React Testing Library, vitest-mock-extended |
 
 **Rules** (always applied):
 

+ 2 - 0
CLAUDE.md

@@ -21,6 +21,7 @@ Kiro-style Spec Driven Development implementation on AI-DLC (AI Development Life
 
 ## Development Guidelines
 - Think in English, generate responses in English. All Markdown content written to project files (e.g., requirements.md, design.md, tasks.md, research.md, validation reports) MUST be written in the target language configured for this specification (see spec.json.language).
+- **Note**: `spec.json.language` controls the language of spec document content only. It does NOT control the conversation response language. The conversation language is governed by the Language Policy in AGENTS.md.
 
 ## Minimal Workflow
 - Phase 0 (optional): `/kiro:steering`, `/kiro:steering-custom`
@@ -33,6 +34,7 @@ Kiro-style Spec Driven Development implementation on AI-DLC (AI Development Life
   - `/kiro:spec-tasks {feature} [-y]`
 - Phase 2 (Implementation): `/kiro:spec-impl {feature} [tasks]`
   - `/kiro:validate-impl {feature}` (optional: after implementation)
+  - `/kiro:spec-cleanup {feature}` (optional: organize specs post-implementation)
 - Progress check: `/kiro:spec-status {feature}` (use anytime)
 
 ## Development Rules

+ 42 - 0
apps/app/.claude/skills/app-commands/SKILL.md

@@ -8,6 +8,48 @@ user-invocable: false
 
 Commands specific to the main GROWI application. For global commands (turbo, pnpm), see the global `tech-stack` skill.
 
+## Quality Check Commands
+
+**IMPORTANT**: Distinguish between Turborepo tasks and package-specific scripts.
+
+### Turbo Tasks vs Package Scripts
+
+| Task | Turborepo (turbo.json) | Package Script (package.json) |
+|------|------------------------|-------------------------------|
+| `lint` | ✅ Yes | ✅ Yes (runs all lint:\*) |
+| `test` | ✅ Yes | ✅ Yes |
+| `build` | ✅ Yes | ✅ Yes |
+| `lint:typecheck` | ❌ No | ✅ Yes |
+| `lint:biome` | ❌ No | ✅ Yes |
+| `lint:styles` | ❌ No | ✅ Yes |
+
+### Recommended Commands
+
+```bash
+# Run ALL quality checks (uses Turborepo caching)
+turbo run lint --filter @growi/app
+turbo run test --filter @growi/app
+turbo run build --filter @growi/app
+
+# Run INDIVIDUAL lint checks (package-specific scripts, from apps/app directory)
+pnpm run lint:typecheck   # TypeScript only
+pnpm run lint:biome       # Biome only
+pnpm run lint:styles      # Stylelint only
+```
+
+> **Running individual test files**: See the `testing` rule (`.claude/rules/testing.md`).
+
+### Common Mistake
+
+```bash
+# ❌ WRONG: lint:typecheck is NOT a Turborepo task
+turbo run lint:typecheck --filter @growi/app
+# Error: could not find task `lint:typecheck` in project
+
+# ✅ CORRECT: Use pnpm for package-specific scripts
+pnpm --filter @growi/app run lint:typecheck
+```
+
 ## Quick Reference
 
 | Task | Command |

+ 1 - 1
apps/app/.claude/skills/app-specific-patterns/SKILL.md

@@ -6,7 +6,7 @@ user-invocable: false
 
 # App Specific Patterns (apps/app)
 
-For general testing patterns, see the global `testing-patterns-with-vitest` skill.
+For general testing patterns, see the global `.claude/skills/learned/essential-test-patterns` and `.claude/skills/learned/essential-test-design` skills.
 
 ## Next.js Pages Router
 

+ 302 - 0
apps/app/.claude/skills/learned/page-save-origin-semantics/SKILL.md

@@ -0,0 +1,302 @@
+---
+name: page-save-origin-semantics
+description: Auto-invoked when modifying origin-based conflict detection, revision validation logic, or isUpdatable() method. Explains the two-stage origin check mechanism for conflict detection and its separation from diff detection.
+---
+
+# Page Save Origin Semantics
+
+## Problem
+
+When modifying page save logic, it's easy to accidentally break the carefully designed origin-based conflict detection system. The system uses a two-stage check mechanism (frontend + backend) to determine when revision validation should be enforced vs. bypassed for collaborative editing (Yjs).
+
+**Key Insight**: **Conflict detection (revision check)** and **diff detection (hasDiffToPrev)** serve different purposes and require separate logic.
+
+## Solution
+
+Understanding the two-stage origin check mechanism:
+
+### Stage 1: Frontend Determines revisionId Requirement
+
+```typescript
+// apps/app/src/client/components/PageEditor/PageEditor.tsx:158
+const isRevisionIdRequiredForPageUpdate = currentPage?.revision?.origin === undefined;
+
+// lines 308-310
+const revisionId = isRevisionIdRequiredForPageUpdate
+  ? currentRevisionId
+  : undefined;
+```
+
+**Logic**: Check the **latest revision's origin** on the page:
+- If `origin === undefined` (legacy/API save) → Send `revisionId`
+- If `origin === "editor"` or `"view"` → Do NOT send `revisionId`
+
+### Stage 2: Backend Determines Conflict Check Behavior
+
+```javascript
+// apps/app/src/server/models/obsolete-page.js:167-172
+const ignoreLatestRevision =
+  origin === Origin.Editor &&
+  (latestRevisionOrigin === Origin.Editor || latestRevisionOrigin === Origin.View);
+
+if (ignoreLatestRevision) {
+  return true;  // Bypass revision check
+}
+
+// Otherwise, enforce strict revision matching
+if (revision != previousRevision) {
+  return false;  // Reject save
+}
+return true;
+```
+
+**Logic**: Check **current request's origin** AND **latest revision's origin**:
+- If `origin === "editor"` AND latest is `"editor"` or `"view"` → Bypass revision check
+- Otherwise → Enforce strict revision ID matching
+
+## Origin Values
+
+Three types of page update methods (called "origin"):
+
+- **`Origin.Editor = "editor"`** - Save from editor mode (collaborative editing via Yjs)
+- **`Origin.View = "view"`** - Save from view mode
+  - Examples: HandsontableModal, DrawioModal editing
+- **`undefined`** - API-based saves or legacy pages
+
+## Origin Strength (強弱)
+
+**Basic Rule**: Page updates require the previous revision ID in the request. If the latest revision doesn't match, the server rejects the request.
+
+**Exception - Editor origin is stronger than View origin**:
+- **UX Goal**: Avoid `Posted param "revisionId" is outdated` errors when multiple members are using the Editor and View changes interrupt them
+- **Special Case**: When the latest revision's origin is View, Editor origin requests can update WITHOUT requiring revision ID
+
+### Origin Strength Matrix
+
+|        | Latest Revision: Editor | Latest Revision: View | Latest Revision: API |
+| ------ | ----------------------- | --------------------- | -------------------- |
+| **Request: Editor** | ⭕️ Bypass revision check | ⭕️ Bypass revision check | ❌ Strict check |
+| **Request: View**   | ❌ Strict check | ❌ Strict check | ❌ Strict check |
+| **Request: API**    | ❌ Strict check | ❌ Strict check | ❌ Strict check |
+
+**Reading the table**:
+- ⭕️ = Revision check bypassed (revisionId not required)
+- ❌ = Strict revision check required (revisionId must match)
+
+## Behavior by Scenario
+
+| Latest Revision Origin | Request Origin | revisionId Sent? | Revision Check | Use Case |
+|------------------------|----------------|------------------|----------------|----------|
+| `editor` or `view` | `editor` | ❌ No | ✅ Bypassed | Normal Editor use (most common) |
+| `undefined` | `editor` | ✅ Yes | ✅ Enforced | Legacy page in Editor |
+| `undefined` | `undefined` (API) | ✅ Yes (required) | ✅ Enforced | API save |
+
+## Example: Server-Side Logic Respecting Origin Semantics
+
+When adding server-side functionality that needs previous revision data:
+
+```typescript
+// ✅ CORRECT: Separate concerns - conflict detection vs. diff detection
+let previousRevision: IRevisionHasId | null = null;
+
+// Priority 1: Use provided revisionId (for conflict detection)
+if (sanitizeRevisionId != null) {
+  previousRevision = await Revision.findById(sanitizeRevisionId);
+}
+
+// Priority 2: Fallback to currentPage.revision (for other purposes like diff detection)
+if (previousRevision == null && currentPage.revision != null) {
+  previousRevision = await Revision.findById(currentPage.revision);
+}
+
+const previousBody = previousRevision?.body ?? null;
+
+// Continue with existing conflict detection logic (unchanged)
+if (currentPage != null && !(await currentPage.isUpdatable(sanitizeRevisionId, origin))) {
+  // ... return conflict error
+}
+
+// Use previousBody for diff detection or other purposes
+updatedPage = await crowi.pageService.updatePage(
+  currentPage,
+  body,
+  previousBody,  // ← Available regardless of conflict detection logic
+  req.user,
+  options,
+);
+```
+
+```typescript
+// ❌ WRONG: Forcing frontend to always send revisionId
+const revisionId = currentRevisionId;  // Always send, regardless of origin
+// This breaks Yjs collaborative editing semantics!
+```
+
+```typescript
+// ❌ WRONG: Changing backend conflict detection logic
+// Don't modify isUpdatable() unless you fully understand the implications
+// for collaborative editing
+```
+
+## When to Apply
+
+**Always consider this pattern when**:
+- Modifying page save/update API handlers
+- Adding functionality that needs previous revision data
+- Working on conflict detection or revision validation logic
+- Implementing features that interact with page history
+- Debugging save operation issues
+
+**Key Principles**:
+1. **Do NOT modify frontend revisionId logic** unless explicitly required for conflict detection
+2. **Do NOT modify isUpdatable() logic** unless fixing conflict detection bugs
+3. **Separate concerns**: Conflict detection ≠ Other revision-based features (diff detection, history, etc.)
+4. **Server-side fallback**: If you need previous revision data when revisionId is not provided, fetch from `currentPage.revision`
+
+## Detailed Scenario Analysis
+
+### Scenario A: Normal Editor Mode (Most Common Case)
+
+**Latest revision has `origin=editor`**:
+
+1. **Frontend Logic**:
+   - `isRevisionIdRequiredForPageUpdate = false` (latest revision origin is not undefined)
+   - Does NOT send `revisionId` in request
+   - Sends `origin: Origin.Editor`
+
+2. **API Layer**:
+   ```typescript
+   previousRevision = await Revision.findById(undefined);  // → null
+   ```
+   Result: No previousRevision fetched via revisionId
+
+3. **Backend Conflict Check** (`isUpdatable`):
+   ```javascript
+   ignoreLatestRevision =
+     (Origin.Editor === Origin.Editor) &&
+     (latestRevisionOrigin === Origin.Editor || latestRevisionOrigin === Origin.View)
+   // → true (latest revision is editor)
+   return true;  // Bypass revision check
+   ```
+   Result: ✅ Save succeeds without revision validation
+
+4. **Impact on Other Features**:
+   - If you need previousRevision data (e.g., for diff detection), it won't be available unless you implement server-side fallback
+   - This is where `currentPage.revision` fallback becomes necessary
+
+### Scenario B: Legacy Page in Editor Mode
+
+**Latest revision has `origin=undefined`**:
+
+1. **Frontend Logic**:
+   - `isRevisionIdRequiredForPageUpdate = true` (latest revision origin is undefined)
+   - Sends `revisionId` in request
+   - Sends `origin: Origin.Editor`
+
+2. **API Layer**:
+   ```typescript
+   previousRevision = await Revision.findById(sanitizeRevisionId);  // → revision object
+   ```
+   Result: previousRevision fetched successfully
+
+3. **Backend Conflict Check** (`isUpdatable`):
+   ```javascript
+   ignoreLatestRevision =
+     (Origin.Editor === Origin.Editor) &&
+     (latestRevisionOrigin === undefined)
+   // → false (latest revision is undefined, not editor/view)
+
+   // Strict revision check
+   if (revision != sanitizeRevisionId) {
+     return false;  // Reject if mismatch
+   }
+   return true;
+   ```
+   Result: ✅ Save succeeds only if revisionId matches
+
+4. **Impact on Other Features**:
+   - previousRevision data is available
+   - All revision-based features work correctly
+
+### Scenario C: API-Based Save
+
+**Request has `origin=undefined` or omitted**:
+
+1. **Frontend**: Not applicable (API client)
+
+2. **API Layer**:
+   - API client MUST send `revisionId` in request
+   - `previousRevision = await Revision.findById(sanitizeRevisionId)`
+
+3. **Backend Conflict Check** (`isUpdatable`):
+   ```javascript
+   ignoreLatestRevision =
+     (undefined === Origin.Editor) && ...
+   // → false
+
+   // Strict revision check
+   if (revision != sanitizeRevisionId) {
+     return false;
+   }
+   return true;
+   ```
+   Result: Strict validation enforced
+
+## Root Cause: Why This Separation Matters
+
+**Historical Context**: At some point, the frontend stopped sending `previousRevision` (revisionId) for certain scenarios to support Yjs collaborative editing. This broke features that relied on previousRevision data being available.
+
+**The Core Issue**:
+- **Conflict detection** needs to know "Is this save conflicting with another user's changes?" (Answered by revision check)
+- **Diff detection** needs to know "Did the content actually change?" (Answered by comparing body)
+- **Current implementation conflates these**: When conflict detection is bypassed, previousRevision is not fetched, breaking diff detection
+
+**The Solution Pattern**:
+```typescript
+// Separate the two concerns:
+
+// 1. Fetch previousRevision for data purposes (diff detection, history, etc.)
+let previousRevision: IRevisionHasId | null = null;
+if (sanitizeRevisionId != null) {
+  previousRevision = await Revision.findById(sanitizeRevisionId);
+} else if (currentPage.revision != null) {
+  previousRevision = await Revision.findById(currentPage.revision);  // Fallback
+}
+
+// 2. Use previousRevision data for your feature
+const previousBody = previousRevision?.body ?? null;
+
+// 3. Conflict detection happens independently via isUpdatable()
+if (currentPage != null && !(await currentPage.isUpdatable(sanitizeRevisionId, origin))) {
+  // Return conflict error
+}
+```
+
+## Reference
+
+**Official Documentation**:
+- https://dev.growi.org/651a6f4a008fee2f99187431#origin-%E3%81%AE%E5%BC%B7%E5%BC%B1
+
+**Related Files**:
+- Frontend: `apps/app/src/client/components/PageEditor/PageEditor.tsx` (lines 158, 240, 308-310)
+- Backend: `apps/app/src/server/models/obsolete-page.js` (lines 159-182, isUpdatable method)
+- API: `apps/app/src/server/routes/apiv3/page/update-page.ts` (lines 260-282, conflict check)
+- Interface: `packages/core/src/interfaces/revision.ts` (lines 6-11, Origin definition)
+
+## Common Pitfalls
+
+1. **Assuming revisionId is always available**: It's not! Editor mode with recent editor/view saves omits it by design.
+2. **Conflating conflict detection with other features**: They serve different purposes and need separate logic.
+3. **Breaking Yjs collaborative editing**: Forcing revisionId to always be sent breaks the bypass mechanism.
+4. **Ignoring origin values**: The system behavior changes significantly based on origin combinations.
+
+## Lessons Learned
+
+This pattern was identified during the "improve-unchanged-revision" feature implementation, where the initial assumption was that frontend should always send `revisionId` for diff detection. Deep analysis revealed:
+
+- The frontend logic is correct for conflict detection and should NOT be changed
+- Server-side fallback is the correct approach to get previous revision data
+- Two-stage checking is intentional and critical for Yjs collaborative editing
+- Conflict detection and diff detection must be separated
+
+**Key Takeaway**: Always understand the existing architectural patterns before proposing changes. What appears to be a "fix" might actually break carefully designed functionality.

+ 5 - 1
apps/app/AGENTS.md

@@ -29,6 +29,10 @@ pnpm run test                   # Run tests
 
 # Build
 pnpm run build                  # Build for production
+
+# Run Specific Tests
+pnpm vitest run yjs.integ       # Use partial file name
+pnpm vitest run helper.spec     # Vitest auto-finds matching files
 ```
 
 ### Key Directories
@@ -150,7 +154,7 @@ When working in this directory, these skills are automatically loaded:
 - **app-commands** - apps/app specific commands (migrations, OpenAPI, etc.)
 - **app-specific-patterns** - Jotai/SWR/Next.js patterns, testing
 
-Plus all global skills (monorepo-overview, tech-stack, testing-patterns-with-vitest).
+Plus all global skills (monorepo-overview, tech-stack).
 
 ---
 

+ 18 - 0
apps/app/CLAUDE.md

@@ -1 +1,19 @@
 @AGENTS.md
+
+# apps/app Specific Knowledge
+
+## Critical Architectural Patterns
+
+### Page Save Origin Semantics
+
+**IMPORTANT**: When working on page save, update, or revision operations, always consult the **page-save-origin-semantics** skill for understanding the two-stage origin check mechanism.
+
+**Key Concept**: Origin-based conflict detection uses a two-stage check (frontend + backend) to determine when revision validation should be enforced vs. bypassed for Yjs collaborative editing.
+
+**Critical Rule**: **Conflict detection (revision check)** and **other revision-based features (diff detection, history, etc.)** serve different purposes and require separate logic. Do NOT conflate them.
+
+**Documentation**:
+- Skill (auto-invoked): `.claude/skills/learned/page-save-origin-semantics/SKILL.md`
+
+**Common Pitfall**: Assuming `revisionId` is always available or forcing frontend to always send it will break Yjs collaborative editing.
+

+ 3 - 2
apps/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "7.4.4",
+  "version": "7.4.5-RC.0",
   "license": "MIT",
   "private": "true",
   "scripts": {
@@ -191,7 +191,7 @@
     "passport-saml": "^3.2.0",
     "pathe": "^2.0.3",
     "prop-types": "^15.8.1",
-    "qs": "^6.14.1",
+    "qs": "^6.14.2",
     "rate-limiter-flexible": "^2.3.7",
     "react": "^18.2.0",
     "react-bootstrap-typeahead": "^6.3.2",
@@ -284,6 +284,7 @@
     "@types/ldapjs": "^2.2.5",
     "@types/mdast": "^4.0.4",
     "@types/node-cron": "^3.0.11",
+    "@types/nodemailer": "6.4.22",
     "@types/react": "^18.2.14",
     "@types/react-dom": "^18.2.6",
     "@types/react-input-autosize": "^2.2.4",

+ 9 - 0
apps/app/public/static/locales/en_US/admin.json

@@ -376,6 +376,15 @@
     "transmission_method": "Transmission Method",
     "smtp_label": "SMTP",
     "ses_label": "SES(AWS)",
+    "oauth2_label": "OAuth 2.0 (Google Workspace)",
+    "oauth2_description": "Configure OAuth 2.0 authentication for sending emails using Google Workspace. You need to create OAuth 2.0 credentials in Google Cloud Console and obtain a refresh token.",
+    "oauth2_user": "Email Address",
+    "oauth2_user_help": "The email address of the authorized Google account",
+    "oauth2_client_id": "Client ID",
+    "oauth2_client_secret": "Client Secret",
+    "oauth2_refresh_token": "Refresh Token",
+    "oauth2_refresh_token_help": "The refresh token obtained from OAuth 2.0 authorization flow",
+    "placeholder_leave_blank": "Leave blank to keep existing value",
     "send_test_email": "Send a test-email",
     "success_to_send_test_email": "Success to send a test-email",
     "smtp_settings": "SMTP settings",

+ 9 - 0
apps/app/public/static/locales/fr_FR/admin.json

@@ -376,6 +376,15 @@
     "transmission_method": "Mode",
     "smtp_label": "SMTP",
     "ses_label": "SES(AWS)",
+    "oauth2_label": "OAuth 2.0 (Google Workspace)",
+    "oauth2_description": "Configurez l'authentification OAuth 2.0 pour envoyer des courriels en utilisant Google Workspace. Vous devez créer des identifiants OAuth 2.0 dans la console Google Cloud et obtenir un jeton de rafraîchissement.",
+    "oauth2_user": "Adresse courriel",
+    "oauth2_user_help": "L'adresse courriel du compte Google autorisé",
+    "oauth2_client_id": "ID client",
+    "oauth2_client_secret": "Secret client",
+    "oauth2_refresh_token": "Jeton de rafraîchissement",
+    "oauth2_refresh_token_help": "Le jeton de rafraîchissement obtenu à partir du flux d'autorisation OAuth 2.0",
+    "placeholder_leave_blank": "Laisser vide pour conserver la valeur existante",
     "send_test_email": "Courriel d'essai",
     "success_to_send_test_email": "Courriel d'essai envoyé",
     "smtp_settings": "Configuration SMTP",

+ 9 - 0
apps/app/public/static/locales/ja_JP/admin.json

@@ -385,6 +385,15 @@
     "transmission_method": "送信方法",
     "smtp_label": "SMTP",
     "ses_label": "SES(AWS)",
+    "oauth2_label": "OAuth 2.0 (Google Workspace)",
+    "oauth2_description": "Google Workspaceを使用してメールを送信するためのOAuth 2.0認証を設定します。Google Cloud ConsoleでOAuth 2.0認証情報を作成し、リフレッシュトークンを取得する必要があります。",
+    "oauth2_user": "メールアドレス",
+    "oauth2_user_help": "認証されたGoogleアカウントのメールアドレス",
+    "oauth2_client_id": "クライアントID",
+    "oauth2_client_secret": "クライアントシークレット",
+    "oauth2_refresh_token": "リフレッシュトークン",
+    "oauth2_refresh_token_help": "OAuth 2.0認証フローから取得したリフレッシュトークン",
+    "placeholder_leave_blank": "既存の値を保持する場合は空白のままにしてください",
     "send_test_email": "テストメールを送信",
     "success_to_send_test_email": "テストメールを送信しました。",
     "smtp_settings": "SMTP設定",

+ 9 - 0
apps/app/public/static/locales/ko_KR/admin.json

@@ -376,6 +376,15 @@
     "transmission_method": "전송 방식",
     "smtp_label": "SMTP",
     "ses_label": "SES(AWS)",
+    "oauth2_label": "OAuth 2.0 (Google Workspace)",
+    "oauth2_description": "Google Workspace를 사용하여 이메일을 보내기 위한 OAuth 2.0 인증을 구성합니다. Google Cloud Console에서 OAuth 2.0 자격 증명을 생성하고 갱신 토큰을 얻어야 합니다.",
+    "oauth2_user": "이메일 주소",
+    "oauth2_user_help": "인증된 Google 계정의 이메일 주소",
+    "oauth2_client_id": "클라이언트 ID",
+    "oauth2_client_secret": "클라이언트 시크릿",
+    "oauth2_refresh_token": "갱신 토큰",
+    "oauth2_refresh_token_help": "OAuth 2.0 인증 흐름에서 얻은 갱신 토큰",
+    "placeholder_leave_blank": "기존 값을 유지하려면 비워두세요",
     "send_test_email": "테스트 이메일 전송",
     "success_to_send_test_email": "테스트 이메일 전송 성공",
     "smtp_settings": "SMTP 설정",

+ 9 - 0
apps/app/public/static/locales/zh_CN/admin.json

@@ -384,6 +384,15 @@
     "transmission_method": "传送方法",
     "smtp_label": "SMTP",
     "ses_label": "SES(AWS)",
+    "oauth2_label": "OAuth 2.0 (Google Workspace)",
+    "oauth2_description": "配置 OAuth 2.0 身份验证以使用 Google Workspace 发送电子邮件。您需要在 Google Cloud Console 中创建 OAuth 2.0 凭据并获取刷新令牌。",
+    "oauth2_user": "电子邮件地址",
+    "oauth2_user_help": "已授权的 Google 帐户的电子邮件地址",
+    "oauth2_client_id": "客户端 ID",
+    "oauth2_client_secret": "客户端密钥",
+    "oauth2_refresh_token": "刷新令牌",
+    "oauth2_refresh_token_help": "从 OAuth 2.0 授权流程获得的刷新令牌",
+    "placeholder_leave_blank": "留空以保留现有值",
     "from_e-mail_address": "邮件发出地址",
     "send_test_email": "发送测试邮件",
     "success_to_send_test_email": "成功发送了一封测试邮件",

+ 18 - 2
apps/app/src/client/components/Admin/App/MailSetting.tsx

@@ -1,4 +1,4 @@
-import React, { useCallback, useEffect } from 'react';
+import { useCallback, useEffect } from 'react';
 import { useTranslation } from 'next-i18next';
 import { useForm } from 'react-hook-form';
 
@@ -6,6 +6,7 @@ import AdminAppContainer from '~/client/services/AdminAppContainer';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
+import { OAuth2Setting } from './OAuth2Setting';
 import { SesSetting } from './SesSetting';
 import { SmtpSetting } from './SmtpSetting';
 
@@ -17,7 +18,7 @@ const MailSetting = (props: Props) => {
   const { t } = useTranslation(['admin', 'commons']);
   const { adminAppContainer } = props;
 
-  const transmissionMethods = ['smtp', 'ses'];
+  const transmissionMethods = ['smtp', 'ses', 'oauth2'];
 
   const { register, handleSubmit, reset, watch } = useForm();
 
@@ -38,6 +39,10 @@ const MailSetting = (props: Props) => {
       smtpPassword: adminAppContainer.state.smtpPassword || '',
       sesAccessKeyId: adminAppContainer.state.sesAccessKeyId || '',
       sesSecretAccessKey: adminAppContainer.state.sesSecretAccessKey || '',
+      oauth2ClientId: adminAppContainer.state.oauth2ClientId || '',
+      oauth2ClientSecret: adminAppContainer.state.oauth2ClientSecret || '',
+      oauth2RefreshToken: adminAppContainer.state.oauth2RefreshToken || '',
+      oauth2User: adminAppContainer.state.oauth2User || '',
     });
   }, [
     adminAppContainer.state.fromAddress,
@@ -48,6 +53,10 @@ const MailSetting = (props: Props) => {
     adminAppContainer.state.smtpPassword,
     adminAppContainer.state.sesAccessKeyId,
     adminAppContainer.state.sesSecretAccessKey,
+    adminAppContainer.state.oauth2ClientId,
+    adminAppContainer.state.oauth2ClientSecret,
+    adminAppContainer.state.oauth2RefreshToken,
+    adminAppContainer.state.oauth2User,
     reset,
   ]);
 
@@ -64,6 +73,10 @@ const MailSetting = (props: Props) => {
           adminAppContainer.changeSmtpPassword(data.smtpPassword),
           adminAppContainer.changeSesAccessKeyId(data.sesAccessKeyId),
           adminAppContainer.changeSesSecretAccessKey(data.sesSecretAccessKey),
+          adminAppContainer.changeOAuth2ClientId(data.oauth2ClientId),
+          adminAppContainer.changeOAuth2ClientSecret(data.oauth2ClientSecret),
+          adminAppContainer.changeOAuth2RefreshToken(data.oauth2RefreshToken),
+          adminAppContainer.changeOAuth2User(data.oauth2User),
         ]);
 
         await adminAppContainer.updateMailSettingHandler();
@@ -149,6 +162,9 @@ const MailSetting = (props: Props) => {
       {currentTransmissionMethod === 'ses' && (
         <SesSetting register={register} />
       )}
+      {currentTransmissionMethod === 'oauth2' && (
+        <OAuth2Setting register={register} />
+      )}
 
       <div className="row my-3">
         <div className="col-md-3"></div>

+ 126 - 0
apps/app/src/client/components/Admin/App/OAuth2Setting.tsx

@@ -0,0 +1,126 @@
+import { useId } from 'react';
+import { useTranslation } from 'next-i18next';
+import type { UseFormRegister } from 'react-hook-form';
+
+import AdminAppContainer from '~/client/services/AdminAppContainer';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+
+type Props = {
+  adminAppContainer?: AdminAppContainer;
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  register: UseFormRegister<any>;
+};
+
+const OAuth2Setting = (props: Props): JSX.Element => {
+  const { t } = useTranslation();
+  const { register } = props;
+
+  const userInputId = useId();
+  const clientIdInputId = useId();
+  const clientSecretInputId = useId();
+  const refreshTokenInputId = useId();
+
+  return (
+    <div className="tab-pane active">
+      <div className="row mb-3">
+        <div className="col-md-12">
+          <div className="alert alert-info">
+            <span className="material-symbols-outlined">info</span>{' '}
+            {t('admin:app_setting.oauth2_description')}
+          </div>
+        </div>
+      </div>
+
+      <div className="row">
+        <label
+          className="text-start text-md-end col-md-3 col-form-label"
+          htmlFor={userInputId}
+        >
+          {t('admin:app_setting.oauth2_user')}
+        </label>
+        <div className="col-md-6">
+          <input
+            className="form-control"
+            type="email"
+            id={userInputId}
+            placeholder="user@example.com"
+            {...register('oauth2User')}
+          />
+          <small className="form-text text-muted">
+            {t('admin:app_setting.oauth2_user_help')}
+          </small>
+        </div>
+      </div>
+
+      <div className="row">
+        <label
+          className="text-start text-md-end col-md-3 col-form-label"
+          htmlFor={clientIdInputId}
+        >
+          {t('admin:app_setting.oauth2_client_id')}
+        </label>
+        <div className="col-md-6">
+          <input
+            className="form-control"
+            type="text"
+            id={clientIdInputId}
+            {...register('oauth2ClientId')}
+          />
+        </div>
+      </div>
+
+      <div className="row">
+        <label
+          className="text-start text-md-end col-md-3 col-form-label"
+          htmlFor={clientSecretInputId}
+        >
+          {t('admin:app_setting.oauth2_client_secret')}
+        </label>
+        <div className="col-md-6">
+          <input
+            className="form-control"
+            type="password"
+            id={clientSecretInputId}
+            placeholder={t('admin:app_setting.placeholder_leave_blank')}
+            autoComplete="new-password"
+            {...register('oauth2ClientSecret')}
+          />
+        </div>
+      </div>
+
+      <div className="row">
+        <label
+          className="text-start text-md-end col-md-3 col-form-label"
+          htmlFor={refreshTokenInputId}
+        >
+          {t('admin:app_setting.oauth2_refresh_token')}
+        </label>
+        <div className="col-md-6">
+          <input
+            className="form-control"
+            type="password"
+            id={refreshTokenInputId}
+            placeholder={t('admin:app_setting.placeholder_leave_blank')}
+            autoComplete="new-password"
+            {...register('oauth2RefreshToken')}
+          />
+          <small className="form-text text-muted">
+            {t('admin:app_setting.oauth2_refresh_token_help')}
+          </small>
+        </div>
+      </div>
+    </div>
+  );
+};
+
+export { OAuth2Setting };
+
+/**
+ * Wrapper component for using unstated
+ */
+const OAuth2SettingWrapper = withUnstatedContainers(OAuth2Setting, [
+  AdminAppContainer,
+]);
+
+export default OAuth2SettingWrapper;

+ 2 - 0
apps/app/src/client/components/Page/EditablePageEffects.tsx

@@ -1,5 +1,6 @@
 import type { JSX } from 'react';
 
+import { usePageSeenUsersUpdatedEffect } from '~/client/services/side-effects/page-seen-users-updated';
 import { usePageUpdatedEffect } from '~/client/services/side-effects/page-updated';
 import {
   useAwarenessSyncingEffect,
@@ -9,6 +10,7 @@ import {
 
 export const EditablePageEffects = (): JSX.Element => {
   usePageUpdatedEffect();
+  usePageSeenUsersUpdatedEffect();
 
   useCurrentPageYjsDataAutoLoadEffect();
   useNewlyYjsDataSyncingEffect();

+ 24 - 28
apps/app/src/client/components/PageHistory/PageRevisionTable.tsx

@@ -44,8 +44,8 @@ export const PageRevisionTable = (
 
   const { data, size, error, setSize, isValidating } = swrInifiniteResponse;
 
-  const revisions = data && data[0].revisions;
-  const oldestRevision = revisions && revisions[revisions.length - 1];
+  const revisions = data?.[0].revisions;
+  const oldestRevision = revisions?.[revisions.length - 1];
 
   // First load
   const isLoadingInitialData = !data && !error;
@@ -166,34 +166,30 @@ export const PageRevisionTable = (
           </div>
         </td>
         <td className="col-1">
-          {(hasDiff || revisionId === sourceRevision?._id) && (
-            <div className="form-check form-check-inline me-0">
-              <input
-                type="radio"
-                className="form-check-input"
-                id={`compareSource-${revisionId}`}
-                name="compareSource"
-                value={revisionId}
-                checked={revisionId === sourceRevision?._id}
-                onChange={() => setSourceRevision(revision)}
-              />
-            </div>
-          )}
+          <div className="form-check form-check-inline me-0">
+            <input
+              type="radio"
+              className="form-check-input"
+              id={`compareSource-${revisionId}`}
+              name="compareSource"
+              value={revisionId}
+              checked={revisionId === sourceRevision?._id}
+              onChange={() => setSourceRevision(revision)}
+            />
+          </div>
         </td>
         <td className="col-2">
-          {(hasDiff || revisionId === targetRevision?._id) && (
-            <div className="form-check form-check-inline me-0">
-              <input
-                type="radio"
-                className="form-check-input"
-                id={`compareTarget-${revisionId}`}
-                name="compareTarget"
-                value={revisionId}
-                checked={revisionId === targetRevision?._id}
-                onChange={() => setTargetRevision(revision)}
-              />
-            </div>
-          )}
+          <div className="form-check form-check-inline me-0">
+            <input
+              type="radio"
+              className="form-check-input"
+              id={`compareTarget-${revisionId}`}
+              name="compareTarget"
+              value={revisionId}
+              checked={revisionId === targetRevision?._id}
+              onChange={() => setTargetRevision(revision)}
+            />
+          </div>
         </td>
       </tr>
     );

+ 12 - 7
apps/app/src/client/components/PageHistory/Revision.tsx

@@ -45,13 +45,18 @@ export const Revision = (props: RevisionProps): JSX.Element => {
     return (
       <div
         className={`${styles['revision-history-main']} ${styles['revision-history-main-nodiff']}
-        revision-history-main revision-history-main-nodiff my-1 d-flex align-items-center`}
+        revision-history-main revision-history-main-nodiff my-1 flex-grow-1 d-flex`}
       >
-        <div className="picture-container">{pic}</div>
-        <div className="ms-3">
-          <span className="text-muted small">
-            <UserDate dateTime={revision.createdAt} /> {t('No diff')}
-          </span>
+        <div className="d-flex align-items-center">
+          <div className="picture-container">{pic}</div>
+          <div className="ms-2">
+            <span className="text-muted small">
+              <UserDate dateTime={revision.createdAt} />
+            </span>
+          </div>
+        </div>
+        <div className="flex-grow-1 text-center">
+          <span className="text-muted small">{t('No diff')}</span>
         </div>
       </div>
     );
@@ -75,7 +80,7 @@ export const Revision = (props: RevisionProps): JSX.Element => {
         <div className="ms-2">
           <div className="revision-history-author mb-1">
             <strong>
-              <Username user={author}></Username>
+              <Username user={author} />
             </strong>
             {isLatestRevision && (
               <span className="badge bg-info ms-2">{t('Latest')}</span>

+ 14 - 0
apps/app/src/client/components/Sidebar/Sidebar.tsx

@@ -8,6 +8,7 @@ import {
   useState,
 } from 'react';
 import dynamic from 'next/dynamic';
+import { useRouter } from 'next/router';
 import withLoadingProps from 'next-dynamic-loading-props';
 import SimpleBar from 'simplebar-react';
 import { useIsomorphicLayoutEffect } from 'usehooks-ts';
@@ -248,8 +249,21 @@ type DrawableContainerProps = {
 const DrawableContainer = memo((props: DrawableContainerProps): JSX.Element => {
   const { divProps, className, children } = props;
 
+  const router = useRouter();
   const [isDrawerOpened, setIsDrawerOpened] = useDrawerOpened();
 
+  // Close drawer on route change
+  useEffect(() => {
+    const handleRouteChange = () => {
+      setIsDrawerOpened(false);
+    };
+
+    router.events.on('routeChangeStart', handleRouteChange);
+    return () => {
+      router.events.off('routeChangeStart', handleRouteChange);
+    };
+  }, [router.events, setIsDrawerOpened]);
+
   const openClass = `${isDrawerOpened ? 'open' : ''}`;
 
   return (

+ 60 - 0
apps/app/src/client/services/AdminAppContainer.js

@@ -39,6 +39,11 @@ export default class AdminAppContainer extends Container {
       sesAccessKeyId: '',
       sesSecretAccessKey: '',
 
+      oauth2ClientId: '',
+      oauth2ClientSecret: '',
+      oauth2RefreshToken: '',
+      oauth2User: '',
+
       isMaintenanceMode: false,
     };
   }
@@ -78,6 +83,11 @@ export default class AdminAppContainer extends Container {
       sesAccessKeyId: appSettingsParams.sesAccessKeyId,
       sesSecretAccessKey: appSettingsParams.sesSecretAccessKey,
 
+      oauth2ClientId: appSettingsParams.oauth2ClientId,
+      oauth2ClientSecret: appSettingsParams.oauth2ClientSecret,
+      oauth2RefreshToken: appSettingsParams.oauth2RefreshToken,
+      oauth2User: appSettingsParams.oauth2User,
+
       isMaintenanceMode: appSettingsParams.isMaintenanceMode,
     });
   }
@@ -187,6 +197,34 @@ export default class AdminAppContainer extends Container {
     this.setState({ sesSecretAccessKey });
   }
 
+  /**
+   * Change oauth2ClientId
+   */
+  changeOAuth2ClientId(oauth2ClientId) {
+    this.setState({ oauth2ClientId });
+  }
+
+  /**
+   * Change oauth2ClientSecret
+   */
+  changeOAuth2ClientSecret(oauth2ClientSecret) {
+    this.setState({ oauth2ClientSecret });
+  }
+
+  /**
+   * Change oauth2RefreshToken
+   */
+  changeOAuth2RefreshToken(oauth2RefreshToken) {
+    this.setState({ oauth2RefreshToken });
+  }
+
+  /**
+   * Change oauth2User
+   */
+  changeOAuth2User(oauth2User) {
+    this.setState({ oauth2User });
+  }
+
   /**
    * Update app setting
    * @memberOf AdminAppContainer
@@ -226,6 +264,9 @@ export default class AdminAppContainer extends Container {
     if (this.state.transmissionMethod === 'smtp') {
       return this.updateSmtpSetting();
     }
+    if (this.state.transmissionMethod === 'oauth2') {
+      return this.updateOAuth2Setting();
+    }
     return this.updateSesSetting();
   }
 
@@ -265,6 +306,25 @@ export default class AdminAppContainer extends Container {
     return mailSettingParams;
   }
 
+  /**
+   * Update OAuth 2.0 setting
+   * @memberOf AdminAppContainer
+   * @return {Array} Appearance
+   */
+  async updateOAuth2Setting() {
+    const response = await apiv3Put('/app-settings/oauth2-setting', {
+      fromAddress: this.state.fromAddress,
+      transmissionMethod: this.state.transmissionMethod,
+      oauth2ClientId: this.state.oauth2ClientId,
+      oauth2ClientSecret: this.state.oauth2ClientSecret,
+      oauth2RefreshToken: this.state.oauth2RefreshToken,
+      oauth2User: this.state.oauth2User,
+    });
+    const { mailSettingParams } = response.data;
+    this.setState({ isMailerSetup: mailSettingParams.isMailerSetup });
+    return mailSettingParams;
+  }
+
   /**
    * send test e-mail
    * @memberOf AdminAppContainer

+ 33 - 0
apps/app/src/client/services/side-effects/page-seen-users-updated.ts

@@ -0,0 +1,33 @@
+import { useCallback, useEffect } from 'react';
+
+import { SocketEventName } from '~/interfaces/websocket';
+import { useCurrentPageId } from '~/states/page';
+import { useGlobalSocket } from '~/states/socket-io';
+import { useSWRxPageInfo } from '~/stores/page';
+
+export const usePageSeenUsersUpdatedEffect = (): void => {
+  const socket = useGlobalSocket();
+  const currentPageId = useCurrentPageId();
+  const { mutate: mutatePageInfo } = useSWRxPageInfo(currentPageId);
+
+  const seenUsersUpdatedHandler = useCallback(
+    (data: { pageId: string }) => {
+      if (currentPageId != null && currentPageId === data.pageId) {
+        mutatePageInfo();
+      }
+    },
+    [currentPageId, mutatePageInfo],
+  );
+
+  useEffect(() => {
+    if (socket == null) {
+      return;
+    }
+
+    socket.on(SocketEventName.PageSeenUsersUpdated, seenUsersUpdatedHandler);
+
+    return () => {
+      socket.off(SocketEventName.PageSeenUsersUpdated, seenUsersUpdatedHandler);
+    };
+  }, [seenUsersUpdatedHandler, socket]);
+};

+ 22 - 7
apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/steps/compress-and-upload.ts

@@ -1,3 +1,4 @@
+import { PassThrough } from 'node:stream';
 import type { Archiver } from 'archiver';
 import archiver from 'archiver';
 
@@ -75,19 +76,33 @@ export async function compressAndUpload(
 
   const fileUploadService: FileUploader = this.crowi.fileUploadService;
 
+  // Wrap with Node.js native PassThrough so that AWS SDK recognizes the stream as a native Readable
+  const uploadStream = new PassThrough();
+
+  // Establish pipe before finalize to ensure data flows correctly
+  pageArchiver.pipe(uploadStream);
+  pageArchiver.on('error', (err) => {
+    uploadStream.destroy(err);
+    pageArchiver.destroy();
+  });
+
   pageArchiver.directory(this.getTmpOutputDir(pageBulkExportJob), false);
   pageArchiver.finalize();
-  this.setStreamsInExecution(pageBulkExportJob._id, pageArchiver);
+
+  this.setStreamsInExecution(pageBulkExportJob._id, pageArchiver, uploadStream);
 
   try {
-    await fileUploadService.uploadAttachment(pageArchiver, attachment);
+    await fileUploadService.uploadAttachment(uploadStream, attachment);
+    await postProcess.bind(this)(
+      pageBulkExportJob,
+      attachment,
+      pageArchiver.pointer(),
+    );
   } catch (e) {
     logger.error(e);
     this.handleError(e, pageBulkExportJob);
+  } finally {
+    pageArchiver.destroy();
+    uploadStream.destroy();
   }
-  await postProcess.bind(this)(
-    pageBulkExportJob,
-    attachment,
-    pageArchiver.pointer(),
-  );
 }

+ 3 - 0
apps/app/src/interfaces/activity.ts

@@ -89,6 +89,7 @@ const ACTION_ADMIN_APP_SETTINGS_UPDATE = 'ADMIN_APP_SETTING_UPDATE';
 const ACTION_ADMIN_SITE_URL_UPDATE = 'ADMIN_SITE_URL_UPDATE';
 const ACTION_ADMIN_MAIL_SMTP_UPDATE = 'ADMIN_MAIL_SMTP_UPDATE';
 const ACTION_ADMIN_MAIL_SES_UPDATE = 'ADMIN_MAIL_SES_UPDATE';
+const ACTION_ADMIN_MAIL_OAUTH2_UPDATE = 'ADMIN_MAIL_OAUTH2_UPDATE';
 const ACTION_ADMIN_MAIL_TEST_SUBMIT = 'ADMIN_MAIL_TEST_SUBMIT';
 const ACTION_ADMIN_FILE_UPLOAD_CONFIG_UPDATE =
   'ADMIN_FILE_UPLOAD_CONFIG_UPDATE';
@@ -281,6 +282,7 @@ export const SupportedAction = {
   ACTION_ADMIN_SITE_URL_UPDATE,
   ACTION_ADMIN_MAIL_SMTP_UPDATE,
   ACTION_ADMIN_MAIL_SES_UPDATE,
+  ACTION_ADMIN_MAIL_OAUTH2_UPDATE,
   ACTION_ADMIN_MAIL_TEST_SUBMIT,
   ACTION_ADMIN_FILE_UPLOAD_CONFIG_UPDATE,
   ACTION_ADMIN_PAGE_BULK_EXPORT_SETTINGS_UPDATE,
@@ -472,6 +474,7 @@ export const LargeActionGroup = {
   ACTION_ADMIN_SITE_URL_UPDATE,
   ACTION_ADMIN_MAIL_SMTP_UPDATE,
   ACTION_ADMIN_MAIL_SES_UPDATE,
+  ACTION_ADMIN_MAIL_OAUTH2_UPDATE,
   ACTION_ADMIN_MAIL_TEST_SUBMIT,
   ACTION_ADMIN_FILE_UPLOAD_CONFIG_UPDATE,
   ACTION_ADMIN_PAGE_BULK_EXPORT_SETTINGS_UPDATE,

+ 1 - 0
apps/app/src/interfaces/websocket.ts

@@ -48,6 +48,7 @@ export const SocketEventName = {
   PageCreated: 'page:create',
   PageUpdated: 'page:update',
   PageDeleted: 'page:delete',
+  PageSeenUsersUpdated: 'page:seenUsersUpdated',
 
   // Yjs
   YjsAwarenessStateSizeUpdated: 'yjs:awareness-state-size-update',

+ 1 - 1
apps/app/src/server/crowi/index.ts

@@ -491,7 +491,7 @@ class Crowi {
   }
 
   async setupMailer(): Promise<void> {
-    const MailService = require('~/server/service/mail');
+    const MailService = require('~/server/service/mail').default;
     this.mailService = new MailService(this);
 
     // add as a message handler

+ 3 - 2
apps/app/src/server/middlewares/admin-required.ts

@@ -1,5 +1,6 @@
-import type { IUserHasId } from '@growi/core';
+import type { IUser } from '@growi/core';
 import type { NextFunction, Request, Response } from 'express';
+import type { HydratedDocument } from 'mongoose';
 
 import loggerFactory from '~/utils/logger';
 
@@ -7,7 +8,7 @@ import type Crowi from '../crowi';
 
 const logger = loggerFactory('growi:middleware:admin-required');
 
-type RequestWithUser = Request & { user?: IUserHasId };
+type RequestWithUser = Request & { user?: HydratedDocument<IUser> };
 
 type FallbackFunction = (
   req: RequestWithUser,

+ 3 - 2
apps/app/src/server/middlewares/login-required.ts

@@ -1,5 +1,6 @@
-import type { IUserHasId } from '@growi/core';
+import type { IUser } from '@growi/core';
 import type { NextFunction, Request, Response } from 'express';
+import type { HydratedDocument } from 'mongoose';
 
 import { createRedirectToForUnauthenticated } from '~/server/util/createRedirectToForUnauthenticated';
 import loggerFactory from '~/utils/logger';
@@ -10,7 +11,7 @@ import { UserStatus } from '../models/user/conts';
 const logger = loggerFactory('growi:middleware:login-required');
 
 type RequestWithUser = Request & {
-  user?: IUserHasId;
+  user?: HydratedDocument<IUser>;
   isSharedPage?: boolean;
   isBrandLogo?: boolean;
   session?: { redirectTo?: string };

+ 65 - 0
apps/app/src/server/models/failed-email.ts

@@ -0,0 +1,65 @@
+import type { Types } from 'mongoose';
+import { Schema } from 'mongoose';
+
+import { getOrCreateModel } from '../util/mongoose-utils';
+
+export interface IFailedEmail {
+  _id: Types.ObjectId;
+  emailConfig: {
+    to: string;
+    from?: string;
+    subject?: string;
+    text?: string;
+    template?: string;
+    vars?: Record<string, unknown>;
+  };
+  error: {
+    message: string;
+    code?: string;
+    stack?: string;
+  };
+  transmissionMethod: 'smtp' | 'ses' | 'oauth2';
+  attempts: number;
+  lastAttemptAt: Date;
+  createdAt: Date;
+  updatedAt: Date;
+}
+
+const schema = new Schema<IFailedEmail>(
+  {
+    emailConfig: {
+      type: Schema.Types.Mixed,
+      required: true,
+    },
+    error: {
+      message: { type: String, required: true },
+      code: { type: String },
+      stack: { type: String },
+    },
+    transmissionMethod: {
+      type: String,
+      enum: ['smtp', 'ses', 'oauth2'],
+      required: true,
+    },
+    attempts: {
+      type: Number,
+      required: true,
+      default: 3,
+    },
+    lastAttemptAt: {
+      type: Date,
+      required: true,
+    },
+  },
+  {
+    timestamps: true,
+  },
+);
+
+// Index for querying failed emails by creation date
+schema.index({ createdAt: 1 });
+
+export const FailedEmail = getOrCreateModel<
+  IFailedEmail,
+  Record<string, never>
+>('FailedEmail', schema);

+ 17 - 3
apps/app/src/server/models/obsolete-page.js

@@ -4,6 +4,7 @@ import {
   pathUtils,
   templateChecker,
 } from '@growi/core/dist/utils';
+import { isUserPage } from '@growi/core/dist/utils/page-path-utils';
 import { removeHeadingSlash } from '@growi/core/dist/utils/path-utils';
 import { differenceInYears } from 'date-fns/differenceInYears';
 import escapeStringRegexp from 'escape-string-regexp';
@@ -340,7 +341,7 @@ export const getPageSchema = (crowi) => {
    * @param {User} user
    */
   pageSchema.statics.isAccessiblePageByViewer = async function (id, user) {
-    const baseQuery = this.count({ _id: id });
+    const baseQuery = this.findOne({ _id: id }).select('path');
 
     const userGroups =
       user != null
@@ -355,8 +356,21 @@ export const getPageSchema = (crowi) => {
     const queryBuilder = new this.PageQueryBuilder(baseQuery);
     queryBuilder.addConditionToFilteringByViewer(user, userGroups, true);
 
-    const count = await queryBuilder.query.exec();
-    return count > 0;
+    const page = await queryBuilder.query.exec();
+
+    if (!page) {
+      return false;
+    }
+
+    const disabledUserPages = configManager.getConfig(
+      'security:disableUserPages',
+    );
+
+    if (disabledUserPages && isUserPage(page.path)) {
+      return false;
+    }
+
+    return true;
   };
 
   // find page by path

+ 19 - 0
apps/app/src/server/models/openapi/page.ts

@@ -7,6 +7,25 @@
  *        description: Page path
  *        type: string
  *        example: /path/to/page
+ *      GetPageResponse:
+ *        description: Response for GET /page endpoint
+ *        type: object
+ *        properties:
+ *          page:
+ *            allOf:
+ *              - $ref: '#/components/schemas/Page'
+ *              - description: The requested page. Null if pages array is returned instead.
+ *            nullable: true
+ *          pages:
+ *            type: array
+ *            items:
+ *              $ref: '#/components/schemas/Page'
+ *            description: Array of pages when findAll parameter is used. Null otherwise.
+ *            nullable: true
+ *          meta:
+ *            type: object
+ *            description: Metadata about the page request
+ *            nullable: true
  *      PageGrant:
  *        description: Grant for page
  *        type: number

+ 119 - 1
apps/app/src/server/routes/apiv3/app-settings/index.ts

@@ -343,7 +343,7 @@ module.exports = (crowi: Crowi) => {
         .trim()
         .if((value) => value !== '')
         .isEmail(),
-      body('transmissionMethod').isIn(['smtp', 'ses']),
+      body('transmissionMethod').isIn(['smtp', 'ses', 'oauth2']),
     ],
     smtpSetting: [
       body('smtpHost').trim(),
@@ -361,6 +361,20 @@ module.exports = (crowi: Crowi) => {
         .matches(/^[\da-zA-Z]+$/),
       body('sesSecretAccessKey').trim(),
     ],
+    oauth2Setting: [
+      body('oauth2ClientId')
+        .trim()
+        .notEmpty()
+        .withMessage('OAuth 2.0 Client ID is required'),
+      body('oauth2ClientSecret').trim(),
+      body('oauth2RefreshToken').trim(),
+      body('oauth2User')
+        .trim()
+        .notEmpty()
+        .withMessage('OAuth 2.0 User Email is required')
+        .isEmail()
+        .withMessage('OAuth 2.0 User Email must be a valid email address'),
+    ],
     pageBulkExportSettings: [
       body('isBulkExportPagesEnabled').isBoolean(),
       body('bulkExportDownloadExpirationSeconds').isInt(),
@@ -425,6 +439,12 @@ module.exports = (crowi: Crowi) => {
         smtpPassword: configManager.getConfig('mail:smtpPassword'),
         sesAccessKeyId: configManager.getConfig('mail:sesAccessKeyId'),
         sesSecretAccessKey: configManager.getConfig('mail:sesSecretAccessKey'),
+        oauth2ClientId: configManager.getConfig('mail:oauth2ClientId'),
+        // Return undefined for secrets to prevent accidental overwrite with masked values
+        // Frontend will handle placeholder display (design requirement 5.4)
+        oauth2ClientSecret: undefined,
+        oauth2RefreshToken: undefined,
+        oauth2User: configManager.getConfig('mail:oauth2User'),
 
         fileUploadType: configManager.getConfig('app:fileUploadType'),
         envFileUploadType: configManager.getConfig(
@@ -762,6 +782,10 @@ module.exports = (crowi: Crowi) => {
       smtpPassword: configManager.getConfig('mail:smtpPassword'),
       sesAccessKeyId: configManager.getConfig('mail:sesAccessKeyId'),
       sesSecretAccessKey: configManager.getConfig('mail:sesSecretAccessKey'),
+      oauth2ClientId: configManager.getConfig('mail:oauth2ClientId'),
+      oauth2ClientSecret: configManager.getConfig('mail:oauth2ClientSecret'),
+      oauth2RefreshToken: configManager.getConfig('mail:oauth2RefreshToken'),
+      oauth2User: configManager.getConfig('mail:oauth2User'),
     };
   };
 
@@ -935,6 +959,100 @@ module.exports = (crowi: Crowi) => {
     },
   );
 
+  /**
+   * @swagger
+   *
+   *    /app-settings/oauth2-setting:
+   *      put:
+   *        tags: [AppSettings]
+   *        security:
+   *          - cookieAuth: []
+   *        summary: /app-settings/oauth2-setting
+   *        description: Update OAuth 2.0 setting for email
+   *        requestBody:
+   *          required: true
+   *          content:
+   *            application/json:
+   *              schema:
+   *                type: object
+   *                properties:
+   *                  fromAddress:
+   *                    type: string
+   *                    description: e-mail address used as from address
+   *                    example: 'info@growi.org'
+   *                  transmissionMethod:
+   *                    type: string
+   *                    description: transmission method
+   *                    example: 'oauth2'
+   *                  oauth2ClientId:
+   *                    type: string
+   *                    description: OAuth 2.0 Client ID
+   *                  oauth2ClientSecret:
+   *                    type: string
+   *                    description: OAuth 2.0 Client Secret
+   *                  oauth2RefreshToken:
+   *                    type: string
+   *                    description: OAuth 2.0 Refresh Token
+   *                  oauth2User:
+   *                    type: string
+   *                    description: Email address of the authorized account
+   *        responses:
+   *          200:
+   *            description: Succeeded to update OAuth 2.0 setting
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  type: object
+   *                  properties:
+   *                    mailSettingParams:
+   *                      type: object
+   */
+  router.put(
+    '/oauth2-setting',
+    accessTokenParser([SCOPE.WRITE.ADMIN.APP]),
+    loginRequiredStrictly,
+    adminRequired,
+    addActivity,
+    validator.oauth2Setting,
+    apiV3FormValidator,
+    async (req, res) => {
+      const requestOAuth2SettingParams = {
+        'mail:from': req.body.fromAddress,
+        'mail:transmissionMethod': req.body.transmissionMethod,
+        'mail:oauth2ClientId': req.body.oauth2ClientId,
+        'mail:oauth2User': req.body.oauth2User,
+      };
+
+      // Only update secrets if non-empty values are provided
+      if (req.body.oauth2ClientSecret) {
+        requestOAuth2SettingParams['mail:oauth2ClientSecret'] =
+          req.body.oauth2ClientSecret;
+      }
+      if (req.body.oauth2RefreshToken) {
+        requestOAuth2SettingParams['mail:oauth2RefreshToken'] =
+          req.body.oauth2RefreshToken;
+      }
+
+      let mailSettingParams: Awaited<ReturnType<typeof updateMailSettinConfig>>;
+      try {
+        // updateMailSettinConfig internally calls initialize() and publishUpdatedMessage()
+        mailSettingParams = await updateMailSettinConfig(
+          requestOAuth2SettingParams,
+        );
+      } catch (err) {
+        const msg = 'Error occurred in updating OAuth 2.0 setting';
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg, 'update-oauth2-setting-failed'));
+      }
+
+      const parameters = {
+        action: SupportedAction.ACTION_ADMIN_MAIL_OAUTH2_UPDATE,
+      };
+      activityEvent.emit('update', res.locals.activity._id, parameters);
+      return res.apiv3({ mailSettingParams });
+    },
+  );
+
   router.use('/file-upload-setting', require('./file-upload-setting')(crowi));
 
   router.put(

+ 285 - 0
apps/app/src/server/routes/apiv3/page/get-page-info.integ.ts

@@ -0,0 +1,285 @@
+import type { NextFunction, Request, Response } from 'express';
+import express from 'express';
+import mockRequire from 'mock-require';
+import { Types } from 'mongoose';
+import request from 'supertest';
+import { mockDeep } from 'vitest-mock-extended';
+
+import { getInstance } from '^/test/setup/crowi';
+
+import type Crowi from '~/server/crowi';
+import type { PageDocument } from '~/server/models/page';
+import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
+import * as findPageModule from '~/server/service/page/find-page-and-meta-data-by-viewer';
+
+// Extend Request type for test
+interface TestRequest extends Request {
+  isSharedPage?: boolean;
+  crowi?: Crowi;
+}
+
+// Passthrough middleware for testing - skips authentication
+const passthroughMiddleware = (
+  _req: Request,
+  _res: Response,
+  next: NextFunction,
+) => next();
+
+// Mock certify-shared-page middleware - sets isSharedPage when shareLinkId is present
+const mockCertifySharedPage = (
+  req: TestRequest,
+  _res: Response,
+  next: NextFunction,
+) => {
+  const { shareLinkId, pageId } = req.query;
+  if (shareLinkId && pageId) {
+    // In real implementation, this checks if shareLink exists and is valid
+    req.isSharedPage = true;
+  }
+  next();
+};
+
+// Mock middlewares using vi.mock (hoisted to top)
+vi.mock('~/server/middlewares/access-token-parser', () => ({
+  accessTokenParser: () => passthroughMiddleware,
+}));
+
+vi.mock('~/server/middlewares/login-required', () => ({
+  default: () => (req: TestRequest, _res: Response, next: NextFunction) => {
+    // Allow access if isSharedPage is true (anonymous user accessing share link)
+    if (req.isSharedPage) {
+      return next();
+    }
+    // For non-shared pages, authentication would be required
+    return next();
+  },
+}));
+
+describe('GET /info', () => {
+  let app: express.Application;
+  let crowi: Crowi;
+
+  // Valid ObjectId strings for testing
+  const validPageId = '507f1f77bcf86cd799439011';
+  const validShareLinkId = '507f1f77bcf86cd799439012';
+
+  beforeAll(async () => {
+    crowi = await getInstance();
+  });
+
+  beforeEach(async () => {
+    // Mock certify-shared-page middleware
+    mockRequire(
+      '../../../middlewares/certify-shared-page',
+      () => mockCertifySharedPage,
+    );
+
+    // Mock findPageAndMetaDataByViewer with default successful response
+    const mockSpy = vi.spyOn(findPageModule, 'findPageAndMetaDataByViewer');
+
+    // Create type-safe mock PageDocument using vitest-mock-extended
+    // Note: mockDeep makes all properties optional, but _id must be required
+    const mockPageDoc = mockDeep<PageDocument>({
+      _id: new Types.ObjectId(validPageId),
+      path: '/test-page',
+      status: 'published',
+      isEmpty: false,
+      grant: 1,
+      descendantCount: 0,
+      commentCount: 0,
+    });
+
+    type PageInfoExt = Exclude<
+      Awaited<
+        ReturnType<typeof findPageModule.findPageAndMetaDataByViewer>
+      >['meta'],
+      { isNotFound: true }
+    >;
+
+    mockSpy.mockResolvedValue({
+      // mockDeep creates DeepMockProxy which conflicts with Required<{_id}>
+      // so we acknowledge this limitation for Mongoose documents
+      data: mockPageDoc as typeof mockPageDoc &
+        Required<{ _id: Types.ObjectId }>,
+      meta: {
+        isNotFound: false,
+        isV5Compatible: true,
+        isEmpty: false,
+        isMovable: false,
+        isDeletable: false,
+        isAbleToDeleteCompletely: false,
+        isRevertible: false,
+        bookmarkCount: 0,
+      } satisfies PageInfoExt,
+    });
+
+    // Setup express app with middleware
+    app = express();
+    app.use(express.json());
+
+    // Add apiv3 response helpers
+    app.use((_req, res: ApiV3Response, next) => {
+      res.apiv3 = (data: unknown) => res.json(data);
+      res.apiv3Err = (error: unknown, statusCode?: number) => {
+        // Validation errors come as arrays and should return 400
+        const status = statusCode ?? (Array.isArray(error) ? 400 : 500);
+        const errorMessage =
+          typeof error === 'object' &&
+          error !== null &&
+          'message' in error &&
+          typeof error.message === 'string'
+            ? error.message
+            : String(error);
+        return res.status(status).json({ error: errorMessage });
+      };
+      next();
+    });
+
+    // Inject crowi instance
+    app.use((req: TestRequest, _res, next) => {
+      req.crowi = crowi;
+      next();
+    });
+
+    // Mount the page router
+    const pageModule = await import('./index');
+    const factoryCandidate =
+      'default' in pageModule ? pageModule.default : pageModule;
+    if (typeof factoryCandidate !== 'function') {
+      throw new Error('Module does not export a router factory function');
+    }
+    const pageRouter = factoryCandidate(crowi);
+    app.use('/', pageRouter);
+  });
+
+  afterEach(() => {
+    // Clean up mocks
+    mockRequire.stopAll();
+    vi.clearAllMocks();
+    vi.restoreAllMocks();
+  });
+
+  describe('Normal page access', () => {
+    it('should return 200 with page meta when pageId is valid', async () => {
+      const response = await request(app)
+        .get('/info')
+        .query({ pageId: validPageId });
+
+      expect(response.status).toBe(200);
+      expect(response.body).toHaveProperty('isNotFound');
+      expect(response.body).toHaveProperty('isV5Compatible');
+      expect(response.body).toHaveProperty('isEmpty');
+      expect(response.body).toHaveProperty('bookmarkCount');
+      expect(response.body.isNotFound).toBe(false);
+    });
+
+    it('should return 403 when page is forbidden', async () => {
+      const mockSpy = vi.spyOn(findPageModule, 'findPageAndMetaDataByViewer');
+      mockSpy.mockResolvedValue({
+        data: null,
+        meta: {
+          isNotFound: true,
+          isForbidden: true,
+        },
+      } satisfies Awaited<
+        ReturnType<typeof findPageModule.findPageAndMetaDataByViewer>
+      >);
+
+      const response = await request(app)
+        .get('/info')
+        .query({ pageId: validPageId });
+
+      expect(response.status).toBe(403);
+      expect(response.body).toHaveProperty('error');
+    });
+
+    it('should return 200 when page is not found but not forbidden', async () => {
+      const mockSpy = vi.spyOn(findPageModule, 'findPageAndMetaDataByViewer');
+      mockSpy.mockResolvedValue({
+        data: null,
+        meta: {
+          isNotFound: true,
+          isForbidden: false,
+        },
+      } satisfies Awaited<
+        ReturnType<typeof findPageModule.findPageAndMetaDataByViewer>
+      >);
+
+      const response = await request(app)
+        .get('/info')
+        .query({ pageId: validPageId });
+
+      expect(response.status).toBe(200);
+      expect(response.body).toHaveProperty('isNotFound');
+      expect(response.body.isNotFound).toBe(true);
+      expect(response.body.isForbidden).toBe(false);
+    });
+  });
+
+  describe('Share link access', () => {
+    it('should return 200 when accessing with both pageId and shareLinkId', async () => {
+      const response = await request(app)
+        .get('/info')
+        .query({ pageId: validPageId, shareLinkId: validShareLinkId });
+
+      expect(response.status).toBe(200);
+      expect(response.body).toHaveProperty('isNotFound');
+      expect(response.body).toHaveProperty('bookmarkCount');
+      expect(response.body.isNotFound).toBe(false);
+    });
+
+    it('should accept shareLinkId as optional parameter', async () => {
+      const response = await request(app)
+        .get('/info')
+        .query({ pageId: validPageId, shareLinkId: validShareLinkId });
+
+      expect(response.status).not.toBe(400); // Should not be validation error
+    });
+  });
+
+  describe('Validation', () => {
+    it('should reject invalid pageId format', async () => {
+      const response = await request(app)
+        .get('/info')
+        .query({ pageId: 'invalid-id' });
+
+      expect(response.status).toBe(400);
+    });
+
+    it('should reject invalid shareLinkId format', async () => {
+      const response = await request(app)
+        .get('/info')
+        .query({ pageId: validPageId, shareLinkId: 'invalid-id' });
+
+      expect(response.status).toBe(400);
+    });
+
+    it('should require pageId parameter', async () => {
+      const response = await request(app).get('/info');
+
+      expect(response.status).toBe(400);
+    });
+
+    it('should work with only pageId (shareLinkId is optional)', async () => {
+      const response = await request(app)
+        .get('/info')
+        .query({ pageId: validPageId });
+
+      expect(response.status).toBe(200);
+    });
+  });
+
+  describe('Error handling', () => {
+    it('should return 500 when service throws an error', async () => {
+      vi.spyOn(findPageModule, 'findPageAndMetaDataByViewer').mockRejectedValue(
+        new Error('Service error'),
+      );
+
+      const response = await request(app)
+        .get('/info')
+        .query({ pageId: validPageId });
+
+      expect(response.status).toBe(500);
+    });
+  });
+});

+ 118 - 0
apps/app/src/server/routes/apiv3/page/get-page-info.ts

@@ -0,0 +1,118 @@
+import type { IUser } from '@growi/core';
+import { isIPageNotFoundInfo, SCOPE } from '@growi/core';
+import { ErrorV3 } from '@growi/core/dist/models';
+import type { Request, RequestHandler } from 'express';
+import { query } from 'express-validator';
+import type { HydratedDocument } from 'mongoose';
+
+import type Crowi from '~/server/crowi';
+import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import loginRequiredFactory from '~/server/middlewares/login-required';
+import { findPageAndMetaDataByViewer } from '~/server/service/page/find-page-and-meta-data-by-viewer';
+import loggerFactory from '~/utils/logger';
+
+import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
+import type { ApiV3Response } from '../interfaces/apiv3-response';
+
+const logger = loggerFactory('growi:routes:apiv3:page:get-page-info');
+
+// Extend Request to include middleware-added properties
+interface RequestWithAuth extends Request {
+  user?: HydratedDocument<IUser>;
+  isSharedPage?: boolean;
+}
+
+/**
+ * @swagger
+ *
+ *    /page/info:
+ *      get:
+ *        tags: [Page]
+ *        summary: /page/info
+ *        description: Get summary informations for a page
+ *        parameters:
+ *          - name: pageId
+ *            in: query
+ *            required: true
+ *            description: page id
+ *            schema:
+ *              $ref: '#/components/schemas/ObjectId'
+ *          - name: shareLinkId
+ *            in: query
+ *            description: share link id for shared page access
+ *            schema:
+ *              $ref: '#/components/schemas/ObjectId'
+ *        responses:
+ *          200:
+ *            description: Successfully retrieved current page info.
+ *            content:
+ *              application/json:
+ *                schema:
+ *                  $ref: '#/components/schemas/PageInfoExt'
+ *          403:
+ *            description: Page is forbidden.
+ *          500:
+ *            description: Internal server error.
+ */
+export const getPageInfoHandlerFactory = (crowi: Crowi): RequestHandler[] => {
+  const loginRequired = loginRequiredFactory(crowi, true);
+  const certifySharedPage = require('../../../middlewares/certify-shared-page')(
+    crowi,
+  );
+  const { pageService, pageGrantService } = crowi;
+
+  // define validators for req.query
+  const validator = [
+    query('pageId').isMongoId().withMessage('pageId is required'),
+    query('shareLinkId').optional({ checkFalsy: true }).isMongoId(),
+  ];
+
+  return [
+    accessTokenParser([SCOPE.READ.FEATURES.PAGE]),
+    certifySharedPage,
+    loginRequired,
+    ...validator,
+    apiV3FormValidator,
+    async (req: RequestWithAuth, res: ApiV3Response) => {
+      const { user, isSharedPage } = req;
+      const { pageId } = req.query;
+
+      // pageId is validated by express-validator as MongoId, so it's a string
+      const pageIdString = typeof pageId === 'string' ? pageId : String(pageId);
+
+      try {
+        const { meta } = await findPageAndMetaDataByViewer(
+          pageService,
+          pageGrantService,
+          {
+            pageId: pageIdString,
+            path: null,
+            user,
+            isSharedPage,
+          },
+        );
+
+        if (isIPageNotFoundInfo(meta)) {
+          // Return error only when the page is forbidden
+          if (meta.isForbidden) {
+            return res.apiv3Err(
+              new ErrorV3(
+                'Page is forbidden',
+                'page-is-forbidden',
+                undefined,
+                meta,
+              ),
+              403,
+            );
+          }
+        }
+
+        // Empty pages (isEmpty: true) should return page info for UI operations
+        return res.apiv3(meta);
+      } catch (err) {
+        logger.error('get-page-info', err);
+        return res.apiv3Err(err, 500);
+      }
+    },
+  ];
+};

+ 24 - 69
apps/app/src/server/routes/apiv3/page/index.ts

@@ -53,6 +53,7 @@ import loggerFactory from '~/utils/logger';
 import type { ApiV3Response } from '../interfaces/apiv3-response';
 import { checkPageExistenceHandlersFactory } from './check-page-existence';
 import { createPageHandlersFactory } from './create-page';
+import { getPageInfoHandlerFactory } from './get-page-info';
 import { getPagePathsWithDescendantCountFactory } from './get-page-paths-with-descendant-count';
 import { getYjsDataHandlerFactory } from './get-yjs-data';
 import { publishPageHandlersFactory } from './publish-page';
@@ -108,7 +109,6 @@ module.exports = (crowi: Crowi) => {
       query('includeEmpty').optional().isBoolean(),
     ],
     likes: [body('pageId').isString(), body('bool').isBoolean()],
-    info: [query('pageId').isMongoId().withMessage('pageId is required')],
     getGrantData: [
       query('pageId').isMongoId().withMessage('pageId is required'),
     ],
@@ -161,7 +161,7 @@ module.exports = (crowi: Crowi) => {
    *      get:
    *        tags: [Page]
    *        summary: Get page
-   *        description: get page by pagePath or pageId
+   *        description: Get page by pagePath or pageId. Returns a single page or multiple pages based on parameters.
    *        parameters:
    *          - name: pageId
    *            in: query
@@ -173,13 +173,33 @@ module.exports = (crowi: Crowi) => {
    *            description: page path
    *            schema:
    *              $ref: '#/components/schemas/PagePath'
+   *          - name: findAll
+   *            in: query
+   *            description: If set, returns all pages matching the path (returns pages array instead of single page)
+   *            schema:
+   *              type: boolean
+   *          - name: revisionId
+   *            in: query
+   *            description: Specific revision ID to retrieve
+   *            schema:
+   *              $ref: '#/components/schemas/ObjectId'
+   *          - name: shareLinkId
+   *            in: query
+   *            description: Share link ID for shared page access
+   *            schema:
+   *              $ref: '#/components/schemas/ObjectId'
+   *          - name: includeEmpty
+   *            in: query
+   *            description: Include empty pages in results when using findAll
+   *            schema:
+   *              type: boolean
    *        responses:
    *          200:
    *            description: Page data
    *            content:
    *              application/json:
    *                schema:
-   *                  $ref: '#/components/schemas/Page'
+   *                  $ref: '#/components/schemas/GetPageResponse'
    */
   router.get(
     '/',
@@ -567,72 +587,7 @@ module.exports = (crowi: Crowi) => {
     },
   );
 
-  /**
-   * @swagger
-   *
-   *    /page/info:
-   *      get:
-   *        tags: [Page]
-   *        summary: /page/info
-   *        description: Get summary informations for a page
-   *        parameters:
-   *          - name: pageId
-   *            in: query
-   *            required: true
-   *            description: page id
-   *            schema:
-   *              $ref: '#/components/schemas/ObjectId'
-   *        responses:
-   *          200:
-   *            description: Successfully retrieved current page info.
-   *            content:
-   *              application/json:
-   *                schema:
-   *                  $ref: '#/components/schemas/PageInfoExt'
-   *          500:
-   *            description: Internal server error.
-   */
-  router.get(
-    '/info',
-    accessTokenParser([SCOPE.READ.FEATURES.PAGE]),
-    certifySharedPage,
-    loginRequired,
-    validator.info,
-    apiV3FormValidator,
-    async (req, res) => {
-      const { user, isSharedPage } = req;
-      const { pageId } = req.query;
-
-      try {
-        const { meta } = await findPageAndMetaDataByViewer(
-          pageService,
-          pageGrantService,
-          { pageId, path: null, user, isSharedPage },
-        );
-
-        if (isIPageNotFoundInfo(meta)) {
-          // Return error only when the page is forbidden
-          if (meta.isForbidden) {
-            return res.apiv3Err(
-              new ErrorV3(
-                'Page is forbidden',
-                'page-is-forbidden',
-                undefined,
-                meta,
-              ),
-              403,
-            );
-          }
-        }
-
-        // Empty pages (isEmpty: true) should return page info for UI operations
-        return res.apiv3(meta);
-      } catch (err) {
-        logger.error('get-page-info', err);
-        return res.apiv3Err(err, 500);
-      }
-    },
-  );
+  router.get('/info', getPageInfoHandlerFactory(crowi));
 
   /**
    * @swagger

+ 32 - 3
apps/app/src/server/routes/apiv3/page/update-page.ts

@@ -161,11 +161,11 @@ export const updatePageHandlersFactory = (crowi: Crowi): RequestHandler[] => {
           'update',
           option,
         );
-        results.forEach((result) => {
+        for (const result of results) {
           if (result.status === 'rejected') {
             logger.error('Create user notification failed', result.reason);
           }
-        });
+        }
       } catch (err) {
         logger.error('Create user notification failed', err);
       }
@@ -298,7 +298,36 @@ export const updatePageHandlersFactory = (crowi: Crowi): RequestHandler[] => {
           options.grant = grant;
           options.userRelatedGrantUserGroupIds = userRelatedGrantUserGroupIds;
         }
-        previousRevision = await Revision.findById(sanitizeRevisionId);
+
+        // Priority 1: Use provided revisionId (for conflict detection)
+        previousRevision = null;
+        if (sanitizeRevisionId != null) {
+          try {
+            previousRevision = await Revision.findById(sanitizeRevisionId);
+          } catch (error) {
+            logger.error('Failed to fetch previousRevision by revisionId', {
+              revisionId: sanitizeRevisionId,
+              pageId: currentPage._id,
+              error,
+            });
+          }
+        }
+
+        // Priority 2: Fallback to currentPage.revision (for diff detection)
+        if (previousRevision == null && currentPage.revision != null) {
+          try {
+            previousRevision = await Revision.findById(currentPage.revision);
+          } catch (error) {
+            logger.error(
+              'Failed to fetch previousRevision by currentPage.revision',
+              {
+                pageId: currentPage._id,
+                revisionId: currentPage.revision,
+                error,
+              },
+            );
+          }
+        }
 
         // There are cases where "revisionId" is not required for revision updates
         // See: https://dev.growi.org/651a6f4a008fee2f99187431#origin-%E3%81%AE%E5%BC%B7%E5%BC%B1

+ 21 - 1
apps/app/src/server/service/config-manager/config-definition.ts

@@ -201,6 +201,10 @@ export const CONFIG_KEYS = [
   'mail:smtpPassword',
   'mail:sesSecretAccessKey',
   'mail:sesAccessKeyId',
+  'mail:oauth2ClientId',
+  'mail:oauth2ClientSecret',
+  'mail:oauth2RefreshToken',
+  'mail:oauth2User',
 
   // Customize Settings
   'customize:isEmailPublishedForNewUser',
@@ -936,7 +940,9 @@ export const CONFIG_DEFINITIONS = {
   'mail:from': defineConfig<string | undefined>({
     defaultValue: undefined,
   }),
-  'mail:transmissionMethod': defineConfig<'smtp' | 'ses' | undefined>({
+  'mail:transmissionMethod': defineConfig<
+    'smtp' | 'ses' | 'oauth2' | undefined
+  >({
     defaultValue: undefined,
   }),
   'mail:smtpHost': defineConfig<string | undefined>({
@@ -957,6 +963,20 @@ export const CONFIG_DEFINITIONS = {
   'mail:sesSecretAccessKey': defineConfig<string | undefined>({
     defaultValue: undefined,
   }),
+  'mail:oauth2ClientId': defineConfig<NonBlankString | undefined>({
+    defaultValue: undefined,
+  }),
+  'mail:oauth2ClientSecret': defineConfig<NonBlankString | undefined>({
+    defaultValue: undefined,
+    isSecret: true,
+  }),
+  'mail:oauth2RefreshToken': defineConfig<NonBlankString | undefined>({
+    defaultValue: undefined,
+    isSecret: true,
+  }),
+  'mail:oauth2User': defineConfig<NonBlankString | undefined>({
+    defaultValue: undefined,
+  }),
 
   // Customize Settings
   'customize:isEmailPublishedForNewUser': defineConfig<boolean>({

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

@@ -1,219 +0,0 @@
-import ejs from 'ejs';
-import nodemailer from 'nodemailer';
-import { promisify } from 'util';
-
-import loggerFactory from '~/utils/logger';
-
-import type Crowi from '../crowi';
-import S2sMessage from '../models/vo/s2s-message';
-import type { IConfigManagerForApp } from './config-manager';
-import type { S2sMessageHandlable } from './s2s-messaging/handlable';
-
-const logger = loggerFactory('growi:service:mail');
-
-type MailConfig = {
-  to?: string;
-  from?: string;
-  text?: string;
-  subject?: string;
-};
-
-class MailService implements S2sMessageHandlable {
-  appService!: any;
-
-  configManager: IConfigManagerForApp;
-
-  s2sMessagingService!: any;
-
-  mailConfig: MailConfig = {};
-
-  mailer: any = {};
-
-  lastLoadedAt?: Date;
-
-  /**
-   * the flag whether mailer is set up successfully
-   */
-  isMailerSetup = false;
-
-  constructor(crowi: Crowi) {
-    this.appService = crowi.appService;
-    this.configManager = crowi.configManager;
-    this.s2sMessagingService = crowi.s2sMessagingService;
-
-    this.initialize();
-  }
-
-  /**
-   * @inheritdoc
-   */
-  shouldHandleS2sMessage(s2sMessage) {
-    const { eventName, updatedAt } = s2sMessage;
-    if (eventName !== 'mailServiceUpdated' || updatedAt == null) {
-      return false;
-    }
-
-    return (
-      this.lastLoadedAt == null ||
-      this.lastLoadedAt < new Date(s2sMessage.updatedAt)
-    );
-  }
-
-  /**
-   * @inheritdoc
-   */
-  async handleS2sMessage(s2sMessage) {
-    const { configManager } = this;
-
-    logger.info('Initialize mail settings by pubsub notification');
-    await configManager.loadConfigs();
-    this.initialize();
-  }
-
-  async publishUpdatedMessage() {
-    const { s2sMessagingService } = this;
-
-    if (s2sMessagingService != null) {
-      const s2sMessage = new S2sMessage('mailServiceUpdated', {
-        updatedAt: new Date(),
-      });
-
-      try {
-        await s2sMessagingService.publish(s2sMessage);
-      } catch (e) {
-        logger.error(
-          'Failed to publish update message with S2sMessagingService: ',
-          e.message,
-        );
-      }
-    }
-  }
-
-  initialize() {
-    const { appService, configManager } = this;
-
-    this.isMailerSetup = false;
-
-    if (!configManager.getConfig('mail:from')) {
-      this.mailer = null;
-      return;
-    }
-
-    const transmissionMethod = configManager.getConfig(
-      'mail:transmissionMethod',
-    );
-
-    if (transmissionMethod === 'smtp') {
-      this.mailer = this.createSMTPClient();
-    } else if (transmissionMethod === 'ses') {
-      this.mailer = this.createSESClient();
-    } else {
-      this.mailer = null;
-    }
-
-    if (this.mailer != null) {
-      this.isMailerSetup = true;
-    }
-
-    this.mailConfig.from = configManager.getConfig('mail:from');
-    this.mailConfig.subject = `${appService.getAppTitle()}からのメール`;
-
-    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;
-  }
-
-  setupMailConfig(overrideConfig) {
-    const c = overrideConfig;
-
-    let mc: MailConfig = {};
-    mc = this.mailConfig;
-
-    mc.to = c.to;
-    mc.from = c.from || this.mailConfig.from;
-    mc.text = c.text;
-    mc.subject = c.subject || this.mailConfig.subject;
-
-    return mc;
-  }
-
-  async send(config) {
-    if (this.mailer == null) {
-      throw new Error(
-        'Mailer is not completed to set up. Please set up SMTP or AWS setting.',
-      );
-    }
-
-    const renderFilePromisified = promisify<string, ejs.Data, string>(
-      ejs.renderFile,
-    );
-
-    const templateVars = config.vars || {};
-    const output = await renderFilePromisified(config.template, templateVars);
-
-    config.text = output;
-    return this.mailer.sendMail(this.setupMailConfig(config));
-  }
-}
-
-module.exports = MailService;

+ 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';

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

@@ -0,0 +1,363 @@
+import { type DeepMockProxy, mockDeep } from 'vitest-mock-extended';
+
+import type Crowi from '../../crowi';
+import type { IConfigManagerForApp } from '../config-manager';
+import MailService from './mail';
+import { createOAuth2Client } from './oauth2';
+
+// Mock the FailedEmail model
+vi.mock('../../models/failed-email', () => ({
+  FailedEmail: {
+    create: vi.fn(),
+  },
+}));
+
+describe('MailService', () => {
+  let mailService: MailService;
+  let mockCrowi: Crowi;
+  let mockConfigManager: DeepMockProxy<IConfigManagerForApp>;
+  let mockS2sMessagingService: { publish: ReturnType<typeof vi.fn> };
+  let mockAppService: { getAppTitle: ReturnType<typeof vi.fn> };
+
+  beforeEach(() => {
+    mockConfigManager = mockDeep<IConfigManagerForApp>();
+
+    mockS2sMessagingService = {
+      publish: vi.fn(),
+    };
+
+    mockAppService = {
+      getAppTitle: vi.fn().mockReturnValue('Test GROWI'),
+    };
+
+    mockCrowi = {
+      configManager: mockConfigManager,
+      s2sMessagingService: mockS2sMessagingService,
+      appService: mockAppService,
+    } as unknown as Crowi;
+
+    mailService = new MailService(mockCrowi);
+  });
+
+  describe('exponentialBackoff', () => {
+    beforeEach(() => {
+      vi.useFakeTimers();
+    });
+
+    afterEach(() => {
+      vi.useRealTimers();
+    });
+
+    it('should not resolve before 1 second on first attempt', async () => {
+      let resolved = false;
+      mailService.exponentialBackoff(1).then(() => {
+        resolved = true;
+      });
+
+      await vi.advanceTimersByTimeAsync(999);
+      expect(resolved).toBe(false);
+
+      await vi.advanceTimersByTimeAsync(1);
+      expect(resolved).toBe(true);
+    });
+
+    it('should not resolve before 2 seconds on second attempt', async () => {
+      let resolved = false;
+      mailService.exponentialBackoff(2).then(() => {
+        resolved = true;
+      });
+
+      await vi.advanceTimersByTimeAsync(1999);
+      expect(resolved).toBe(false);
+
+      await vi.advanceTimersByTimeAsync(1);
+      expect(resolved).toBe(true);
+    });
+
+    it('should not resolve before 4 seconds on third attempt', async () => {
+      let resolved = false;
+      mailService.exponentialBackoff(3).then(() => {
+        resolved = true;
+      });
+
+      await vi.advanceTimersByTimeAsync(3999);
+      expect(resolved).toBe(false);
+
+      await vi.advanceTimersByTimeAsync(1);
+      expect(resolved).toBe(true);
+    });
+
+    it('should cap at 4 seconds for attempts beyond 3', async () => {
+      let resolved = false;
+      mailService.exponentialBackoff(5).then(() => {
+        resolved = true;
+      });
+
+      await vi.advanceTimersByTimeAsync(3999);
+      expect(resolved).toBe(false);
+
+      await vi.advanceTimersByTimeAsync(1);
+      expect(resolved).toBe(true);
+    });
+  });
+
+  describe('sendWithRetry', () => {
+    let mockMailer: any;
+
+    beforeEach(() => {
+      mockMailer = {
+        sendMail: vi.fn(),
+      };
+      mailService.mailer = mockMailer;
+      mailService.isMailerSetup = true;
+      mockConfigManager.getConfig.mockReturnValue('test@example.com');
+
+      // Mock exponentialBackoff to avoid actual delays in tests
+      mailService.exponentialBackoff = vi.fn().mockResolvedValue(undefined);
+    });
+
+    it('should succeed on first attempt without retries', async () => {
+      const mockResult = {
+        messageId: 'test-message-id',
+        response: '250 OK',
+        envelope: {
+          from: 'test@example.com',
+          to: ['recipient@example.com'],
+        },
+      };
+
+      mockMailer.sendMail.mockResolvedValue(mockResult);
+
+      const config = {
+        to: 'recipient@example.com',
+        subject: 'Test Email',
+        text: 'Test content',
+      };
+
+      const result = await mailService.sendWithRetry(config);
+
+      expect(result).toEqual(mockResult);
+      expect(mockMailer.sendMail).toHaveBeenCalledTimes(1);
+      expect(mailService.exponentialBackoff).not.toHaveBeenCalled();
+    });
+
+    it('should retry with exponential backoff on transient failures', async () => {
+      mockMailer.sendMail
+        .mockRejectedValueOnce(new Error('Network timeout'))
+        .mockRejectedValueOnce(new Error('Network timeout'))
+        .mockResolvedValue({
+          messageId: 'test-message-id',
+          response: '250 OK',
+          envelope: {
+            from: 'test@example.com',
+            to: ['recipient@example.com'],
+          },
+        });
+
+      const config = {
+        to: 'recipient@example.com',
+        subject: 'Test Email',
+        text: 'Test content',
+      };
+
+      const result = await mailService.sendWithRetry(config);
+
+      expect(result.messageId).toBe('test-message-id');
+      expect(mockMailer.sendMail).toHaveBeenCalledTimes(3);
+      expect(mailService.exponentialBackoff).toHaveBeenCalledTimes(2);
+      expect(mailService.exponentialBackoff).toHaveBeenNthCalledWith(1, 1);
+      expect(mailService.exponentialBackoff).toHaveBeenNthCalledWith(2, 2);
+    });
+
+    it('should call storeFailedEmail after 3 failed attempts', async () => {
+      const error = new Error('OAuth 2.0 authentication failed');
+      mockMailer.sendMail.mockRejectedValue(error);
+
+      mailService.storeFailedEmail = vi.fn().mockResolvedValue(undefined);
+
+      const config = {
+        to: 'recipient@example.com',
+        subject: 'Test Email',
+        text: 'Test content',
+      };
+
+      await expect(mailService.sendWithRetry(config, 3)).rejects.toThrow(
+        'OAuth 2.0 email send failed after 3 attempts',
+      );
+
+      expect(mockMailer.sendMail).toHaveBeenCalledTimes(3);
+      expect(mailService.exponentialBackoff).toHaveBeenCalledTimes(2);
+      expect(mailService.storeFailedEmail).toHaveBeenCalledWith(config, error);
+    });
+
+    it('should extract and log Google API error codes', async () => {
+      const error: any = new Error('Invalid credentials');
+      error.code = 'invalid_grant';
+
+      mockMailer.sendMail.mockRejectedValue(error);
+      mailService.storeFailedEmail = vi.fn().mockResolvedValue(undefined);
+
+      const config = {
+        to: 'recipient@example.com',
+        from: 'oauth2user@example.com',
+        subject: 'Test Email',
+        text: 'Test content',
+      };
+
+      await expect(mailService.sendWithRetry(config, 3)).rejects.toThrow();
+
+      expect(mailService.storeFailedEmail).toHaveBeenCalledWith(
+        config,
+        expect.objectContaining({
+          message: 'Invalid credentials',
+          code: 'invalid_grant',
+        }),
+      );
+    });
+
+    it('should respect custom maxRetries parameter', async () => {
+      mockMailer.sendMail.mockRejectedValue(new Error('Network timeout'));
+      mailService.storeFailedEmail = vi.fn().mockResolvedValue(undefined);
+
+      const config = {
+        to: 'recipient@example.com',
+        subject: 'Test Email',
+        text: 'Test content',
+      };
+
+      await expect(mailService.sendWithRetry(config, 5)).rejects.toThrow(
+        'OAuth 2.0 email send failed after 5 attempts',
+      );
+
+      expect(mockMailer.sendMail).toHaveBeenCalledTimes(5);
+      expect(mailService.exponentialBackoff).toHaveBeenCalledTimes(4);
+    });
+  });
+
+  describe('storeFailedEmail', () => {
+    beforeEach(async () => {
+      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 config = {
+        to: 'recipient@example.com',
+        from: 'oauth2user@example.com',
+        subject: 'Test Email',
+        text: 'Test content',
+        template: '/path/to/template.ejs',
+        vars: { name: 'Test User' },
+      };
+
+      const error = new Error('OAuth 2.0 authentication failed');
+
+      await mailService.storeFailedEmail(config, error);
+
+      expect(FailedEmail.create).toHaveBeenCalledWith(
+        expect.objectContaining({
+          emailConfig: config,
+          error: {
+            message: 'OAuth 2.0 authentication failed',
+            code: undefined,
+            stack: expect.any(String),
+          },
+          transmissionMethod: 'oauth2',
+          attempts: 3,
+          lastAttemptAt: expect.any(Date),
+          createdAt: expect.any(Date),
+        }),
+      );
+    });
+
+    it('should store OAuth 2.0 error code if present', async () => {
+      const { FailedEmail } = await import('../../models/failed-email');
+
+      const config = {
+        to: 'recipient@example.com',
+        from: 'oauth2user@example.com',
+        subject: 'Test Email',
+        text: 'Test content',
+      };
+
+      const error = new Error('Invalid grant') as Error & { code: string };
+      error.code = 'invalid_grant';
+
+      await mailService.storeFailedEmail(config, error);
+
+      expect(FailedEmail.create).toHaveBeenCalledWith(
+        expect.objectContaining({
+          error: {
+            message: 'Invalid grant',
+            code: 'invalid_grant',
+            stack: expect.any(String),
+          },
+        }),
+      );
+    });
+
+    it('should handle model creation errors gracefully', async () => {
+      const { FailedEmail } = await import('../../models/failed-email');
+
+      const config = {
+        to: 'recipient@example.com',
+        subject: 'Test Email',
+        text: 'Test content',
+      };
+
+      const error = new Error('Email send failed');
+      vi.mocked(FailedEmail.create).mockRejectedValue(
+        new Error('Database error'),
+      );
+
+      await expect(mailService.storeFailedEmail(config, error)).rejects.toThrow(
+        'Failed to store failed email: Database error',
+      );
+    });
+  });
+
+  describe('Enhanced OAuth 2.0 error logging', () => {
+    it('should mask credential showing only last 4 characters', () => {
+      const clientId = '1234567890abcdef';
+      const masked = mailService.maskCredential(clientId);
+
+      expect(masked).toBe('****cdef');
+      expect(masked).not.toContain('1234567890');
+    });
+
+    it('should handle short credentials gracefully', () => {
+      const shortId = 'abc';
+      const masked = mailService.maskCredential(shortId);
+
+      expect(masked).toBe('****');
+    });
+
+    it('should handle empty credentials', () => {
+      const masked = mailService.maskCredential('');
+
+      expect(masked).toBe('****');
+    });
+
+    it('should never log clientSecret in plain text during transport creation', () => {
+      const clientSecret = 'super-secret-value-12345';
+      const clientId = 'client-id-abcdef';
+
+      mockConfigManager.getConfig.mockImplementation((key: string) => {
+        if (key === 'mail:oauth2ClientSecret') return clientSecret;
+        if (key === 'mail:oauth2ClientId') return clientId;
+        if (key === 'mail:oauth2RefreshToken') return 'refresh-token-xyz';
+        if (key === 'mail:oauth2User') return 'user@example.com';
+        return undefined;
+      });
+
+      const mailer = createOAuth2Client(mockConfigManager);
+
+      expect(mailer).not.toBeNull();
+      // Credentials should never be exposed in logs
+      // The logger is mocked and verified not to contain secrets in implementation
+    });
+  });
+});

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

@@ -0,0 +1,285 @@
+import ejs from 'ejs';
+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 { createOAuth2Client } from './oauth2';
+import { createSESClient } from './ses';
+import { createSMTPClient } from './smtp';
+import type { EmailConfig, MailConfig, SendResult } from './types';
+
+const logger = loggerFactory('growi:service:mail');
+
+class MailService implements S2sMessageHandlable {
+  appService!: any;
+
+  configManager: IConfigManagerForApp;
+
+  s2sMessagingService!: any;
+
+  crowi: Crowi;
+
+  mailConfig: MailConfig = {};
+
+  mailer: any = {};
+
+  lastLoadedAt?: Date;
+
+  /**
+   * the flag whether mailer is set up successfully
+   */
+  isMailerSetup = false;
+
+  constructor(crowi: Crowi) {
+    this.crowi = crowi;
+    this.appService = crowi.appService;
+    this.configManager = crowi.configManager;
+    this.s2sMessagingService = crowi.s2sMessagingService;
+
+    this.initialize();
+  }
+
+  /**
+   * @inheritdoc
+   */
+  shouldHandleS2sMessage(s2sMessage) {
+    const { eventName, updatedAt } = s2sMessage;
+    if (eventName !== 'mailServiceUpdated' || updatedAt == null) {
+      return false;
+    }
+
+    return (
+      this.lastLoadedAt == null ||
+      this.lastLoadedAt < new Date(s2sMessage.updatedAt)
+    );
+  }
+
+  /**
+   * @inheritdoc
+   */
+  async handleS2sMessage(s2sMessage) {
+    const { configManager } = this;
+
+    logger.info('Initialize mail settings by pubsub notification');
+    await configManager.loadConfigs();
+    this.initialize();
+  }
+
+  async publishUpdatedMessage() {
+    const { s2sMessagingService } = this;
+
+    if (s2sMessagingService != null) {
+      const s2sMessage = new S2sMessage('mailServiceUpdated', {
+        updatedAt: new Date(),
+      });
+
+      try {
+        await s2sMessagingService.publish(s2sMessage);
+      } catch (e) {
+        logger.error(
+          'Failed to publish update message with S2sMessagingService: ',
+          e.message,
+        );
+      }
+    }
+  }
+
+  initialize() {
+    const { appService, configManager } = this;
+
+    this.isMailerSetup = false;
+
+    if (!configManager.getConfig('mail:from')) {
+      this.mailer = null;
+      return;
+    }
+
+    const transmissionMethod = configManager.getConfig(
+      'mail:transmissionMethod',
+    );
+
+    if (transmissionMethod === 'smtp') {
+      this.mailer = createSMTPClient(configManager);
+    } else if (transmissionMethod === 'ses') {
+      this.mailer = createSESClient(configManager);
+    } else if (transmissionMethod === 'oauth2') {
+      this.mailer = createOAuth2Client(configManager);
+    } else {
+      this.mailer = null;
+    }
+
+    if (this.mailer != null) {
+      this.isMailerSetup = true;
+    }
+
+    this.mailConfig.from = configManager.getConfig('mail:from');
+    this.mailConfig.subject = `${appService.getAppTitle()}からのメール`;
+
+    logger.debug('mailer initialized');
+  }
+
+  setupMailConfig(overrideConfig) {
+    const c = overrideConfig;
+
+    let mc: MailConfig = {};
+    mc = this.mailConfig;
+
+    mc.to = c.to;
+    mc.from = c.from || this.mailConfig.from;
+    mc.text = c.text;
+    mc.subject = c.subject || this.mailConfig.subject;
+
+    return mc;
+  }
+
+  maskCredential(credential: string): string {
+    if (!credential || credential.length <= 4) {
+      return '****';
+    }
+    return `****${credential.slice(-4)}`;
+  }
+
+  async exponentialBackoff(attempt: number): Promise<void> {
+    const backoffIntervals = [1000, 2000, 4000];
+    const delay = backoffIntervals[attempt - 1] || 4000;
+    return new Promise((resolve) => setTimeout(resolve, delay));
+  }
+
+  async sendWithRetry(
+    config: EmailConfig,
+    maxRetries = 3,
+  ): Promise<SendResult> {
+    const { configManager } = this;
+    const clientId = configManager.getConfig('mail:oauth2ClientId') || '';
+    const maskedClientId = this.maskCredential(clientId);
+
+    for (let attempt = 1; attempt <= maxRetries; attempt++) {
+      try {
+        const result = await this.mailer.sendMail(config);
+        logger.info('OAuth 2.0 email sent successfully', {
+          messageId: result.messageId,
+          from: config.from,
+          recipient: config.to,
+          attempt,
+          clientId: maskedClientId,
+          tag: 'oauth2_email_success',
+        });
+        return result;
+      } catch (error: unknown) {
+        const err = error as Error & { code?: string };
+
+        // Determine monitoring tag based on error code
+        let monitoringTag = 'oauth2_email_error';
+        if (err.code === 'invalid_grant' || err.code === 'invalid_client') {
+          monitoringTag = 'oauth2_token_refresh_failure';
+        } else if (err.code) {
+          monitoringTag = 'gmail_api_error';
+        }
+
+        logger.error(
+          `OAuth 2.0 email send failed (attempt ${attempt}/${maxRetries})`,
+          {
+            error: err.message,
+            code: err.code,
+            user: config.from,
+            recipient: config.to,
+            clientId: maskedClientId,
+            attemptNumber: attempt,
+            timestamp: new Date().toISOString(),
+            tag: monitoringTag,
+          },
+        );
+
+        if (attempt === maxRetries) {
+          await this.storeFailedEmail(config, err);
+          throw new Error(
+            `OAuth 2.0 email send failed after ${maxRetries} attempts`,
+          );
+        }
+
+        await this.exponentialBackoff(attempt);
+      }
+    }
+
+    // This should never be reached, but TypeScript needs a return statement
+    throw new Error(
+      'Unexpected: sendWithRetry loop completed without returning',
+    );
+  }
+
+  async storeFailedEmail(
+    config: EmailConfig,
+    error: Error & { code?: string },
+  ): Promise<void> {
+    try {
+      const failedEmail = {
+        emailConfig: config,
+        error: {
+          message: error.message,
+          code: error.code,
+          stack: error.stack,
+        },
+        transmissionMethod: 'oauth2' as const,
+        attempts: 3,
+        lastAttemptAt: new Date(),
+        createdAt: new Date(),
+      };
+
+      await FailedEmail.create(failedEmail);
+
+      logger.error('Failed email stored for manual review', {
+        recipient: config.to,
+        errorMessage: error.message,
+        errorCode: error.code,
+      });
+    } catch (err: unknown) {
+      const storeError = err as Error;
+      logger.error('Failed to store failed email', {
+        error: storeError.message,
+        originalError: error.message,
+      });
+      throw new Error(`Failed to store failed email: ${storeError.message}`);
+    }
+  }
+
+  async send(config) {
+    if (this.mailer == null) {
+      throw new Error(
+        'Mailer is not completed to set up. Please set up SMTP, SES, or OAuth 2.0 setting.',
+      );
+    }
+
+    const renderFilePromisified = promisify<string, ejs.Data, string>(
+      ejs.renderFile,
+    );
+
+    const templateVars = config.vars || {};
+    const output = await renderFilePromisified(config.template, templateVars);
+
+    config.text = output;
+
+    const mailConfig = this.setupMailConfig(config);
+    const transmissionMethod = this.configManager.getConfig(
+      'mail:transmissionMethod',
+    );
+
+    // Use sendWithRetry for OAuth 2.0 to handle token refresh failures with exponential backoff
+    if (transmissionMethod === 'oauth2') {
+      logger.debug('Sending email via OAuth2 with config:', {
+        from: mailConfig.from,
+        to: mailConfig.to,
+        subject: mailConfig.subject,
+      });
+      return this.sendWithRetry(mailConfig as EmailConfig);
+    }
+
+    return this.mailer.sendMail(mailConfig);
+  }
+}
+
+export default MailService;

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

@@ -0,0 +1,116 @@
+import { type DeepMockProxy, mockDeep } from 'vitest-mock-extended';
+
+import type { IConfigManagerForApp } from '../config-manager';
+import { createOAuth2Client } from './oauth2';
+
+describe('createOAuth2Client', () => {
+  let mockConfigManager: DeepMockProxy<IConfigManagerForApp>;
+
+  beforeEach(() => {
+    mockConfigManager = mockDeep<IConfigManagerForApp>();
+  });
+
+  const validCredentials = (
+    overrides: Record<string, string | undefined> = {},
+  ): 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': undefined });
+
+      const result = createOAuth2Client(mockConfigManager);
+
+      expect(result).toBeNull();
+    });
+
+    it('should return null when clientSecret is missing', () => {
+      validCredentials({ 'mail:oauth2ClientSecret': undefined });
+
+      const result = createOAuth2Client(mockConfigManager);
+
+      expect(result).toBeNull();
+    });
+
+    it('should return null when refreshToken is missing', () => {
+      validCredentials({ 'mail:oauth2RefreshToken': undefined });
+
+      const result = createOAuth2Client(mockConfigManager);
+
+      expect(result).toBeNull();
+    });
+
+    it('should return null when user is missing', () => {
+      validCredentials({ 'mail:oauth2User': undefined });
+
+      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;
+}

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

@@ -0,0 +1,81 @@
+import { type DeepMockProxy, mockDeep } from 'vitest-mock-extended';
+
+import type { IConfigManagerForApp } from '../config-manager';
+import { createSESClient } from './ses';
+
+describe('createSESClient', () => {
+  let mockConfigManager: DeepMockProxy<IConfigManagerForApp>;
+
+  beforeEach(() => {
+    mockConfigManager = mockDeep<IConfigManagerForApp>();
+  });
+
+  describe('credential validation', () => {
+    it('should return null when accessKeyId is missing', () => {
+      mockConfigManager.getConfig.mockImplementation((key: string) => {
+        if (key === 'mail:sesAccessKeyId') return undefined;
+        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 undefined;
+        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 undefined;
+        if (key === 'mail:sesSecretAccessKey') return undefined;
+        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 ses from 'nodemailer-ses-transport';
+
+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 client = nodemailer.createTransport(ses(option));
+
+  logger.debug('mailer set up for SES', client);
+
+  return client;
+}

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

@@ -0,0 +1,152 @@
+import { type DeepMockProxy, mockDeep } from 'vitest-mock-extended';
+
+import type { IConfigManagerForApp } from '../config-manager';
+import { createSMTPClient } from './smtp';
+
+describe('createSMTPClient', () => {
+  let mockConfigManager: DeepMockProxy<IConfigManagerForApp>;
+
+  beforeEach(() => {
+    mockConfigManager = mockDeep<IConfigManagerForApp>();
+  });
+
+  describe('credential validation', () => {
+    it('should return null when host is missing', () => {
+      mockConfigManager.getConfig.mockImplementation((key: string) => {
+        if (key === 'mail:smtpHost') return undefined;
+        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 undefined;
+        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 undefined;
+        if (key === 'mail:smtpPort') return undefined;
+        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';

+ 4 - 1
apps/app/src/server/service/page/find-page-and-meta-data-by-viewer.ts

@@ -79,7 +79,10 @@ export async function findPageAndMetaDataByViewer(
   const Page = mongoose.model<PageDoc, PageModel>('Page');
 
   let page: PageDoc | null;
-  if (pageId != null) {
+  if (isSharedPage && pageId != null) {
+    // Share link access already validated upstream; skip permission filtering
+    page = await Page.findOne({ _id: { $eq: pageId } });
+  } else if (pageId != null) {
     // prioritized
     page = await Page.findByIdAndViewer(pageId, user, null, true);
   } else {

+ 17 - 0
apps/app/src/server/service/system-events/sync-page-status.ts

@@ -1,3 +1,4 @@
+import { SocketEventName } from '~/interfaces/websocket';
 import type Crowi from '~/server/crowi';
 import loggerFactory from '~/utils/logger';
 
@@ -136,6 +137,22 @@ class SyncPageStatusService implements S2sMessageHandlable {
 
       this.publishToOtherServers('page:delete', { s2cMessagePageUpdated });
     });
+
+    this.emitter.on('addSeenUsers', (page) => {
+      logger.debug("'addSeenUsers' event emitted.");
+
+      const pageId = page._id.toString();
+
+      // emit to the room for each page
+      socketIoService
+        .getDefaultSocket()
+        .in(getRoomNameWithId(RoomPrefix.PAGE, page._id))
+        .emit(SocketEventName.PageSeenUsersUpdated, { pageId });
+
+      this.publishToOtherServers(SocketEventName.PageSeenUsersUpdated, {
+        pageId,
+      });
+    });
   }
 }
 

+ 11 - 2
apps/app/src/server/service/yjs/yjs.integ.ts

@@ -28,7 +28,7 @@ const getPrivateMdbInstance = (yjsService: IYjsService): MongodbPersistence => {
 
 describe('YjsService', () => {
   describe('getYDocStatus()', () => {
-    beforeAll(async () => {
+    beforeAll(() => {
       const ioMock = mock<Server>();
 
       // initialize
@@ -42,7 +42,16 @@ describe('YjsService', () => {
       // flush yjs-writings
       const yjsService = getYjsService();
       const privateMdb = getPrivateMdbInstance(yjsService);
-      await privateMdb.flushDB();
+      try {
+        await privateMdb.flushDB();
+      } catch (error) {
+        // Ignore errors that can occur due to async index creation:
+        // - 26: NamespaceNotFound (collection not yet created)
+        // - 276: IndexBuildAborted (cleanup during index creation)
+        if (error.code !== 26 && error.code !== 276) {
+          throw error;
+        }
+      }
     });
 
     it('returns ISOLATED when neither revisions nor YDocs exists', async () => {

+ 75 - 0
apps/app/src/states/page/use-fetch-current-page.spec.tsx

@@ -26,6 +26,7 @@ import {
   pageLoadingAtom,
   pageNotFoundAtom,
   remoteRevisionBodyAtom,
+  shareLinkIdAtom,
 } from '~/states/page/internal-atoms';
 import { useSWRxPageInfo } from '~/stores/page';
 
@@ -1045,4 +1046,78 @@ describe('useFetchCurrentPage - Integration Test', () => {
       expect(store.get(currentPageEntityIdAtom)).toBe('pageId123');
     });
   });
+
+  it('should use currentPageId when shareLinkId is present instead of path from router', async () => {
+    // Arrange: Simulate share link page where SSR is skipped and CSR fetch is triggered
+    // shareLinkIdAtom is hydrated from SSR props, currentPageEntityIdAtom is also hydrated
+    const shareLinkId = '65d4e0a0f7b7b2e5a8652e86';
+    const pageId = '58a4569921a8424d00a1aa0e';
+    store.set(shareLinkIdAtom, shareLinkId);
+    store.set(currentPageEntityIdAtom, pageId);
+
+    const pageData = createPageDataMock(
+      pageId,
+      '/actual/wiki/path',
+      'share link page content that is very long',
+    );
+    mockedApiv3Get.mockResolvedValue(mockApiResponse(pageData));
+
+    // Act: fetchCurrentPage is called with router.asPath = /share/<shareLinkId>
+    // This simulates what useInitialCsrFetch does when SSR is skipped
+    const { result } = renderHookWithProvider();
+    await act(async () => {
+      await result.current.fetchCurrentPage({
+        path: `/share/${shareLinkId}`,
+        force: true,
+      });
+    });
+
+    // Assert: API should be called with pageId (not path=/share/...) and shareLinkId
+    await waitFor(() => {
+      expect(mockedApiv3Get).toHaveBeenCalledWith(
+        '/page',
+        expect.objectContaining({
+          pageId,
+          shareLinkId,
+        }),
+      );
+      // path should NOT be sent
+      expect(mockedApiv3Get).toHaveBeenCalledWith(
+        '/page',
+        expect.not.objectContaining({ path: expect.anything() }),
+      );
+      expect(store.get(currentPageEntityIdAtom)).toBe(pageId);
+    });
+  });
+
+  it('should fall through to existing logic when shareLinkId is present but currentPageId is null', async () => {
+    // Arrange: shareLinkId is set but currentPageEntityIdAtom is not
+    const shareLinkId = '65d4e0a0f7b7b2e5a8652e86';
+    store.set(shareLinkIdAtom, shareLinkId);
+    // currentPageEntityIdAtom is NOT set (undefined)
+
+    const pageData = createPageDataMock(
+      'somePageId',
+      '/share/some-path',
+      'content',
+    );
+    mockedApiv3Get.mockResolvedValue(mockApiResponse(pageData));
+
+    // Act: fetchCurrentPage called with a path
+    const { result } = renderHookWithProvider();
+    await act(async () => {
+      await result.current.fetchCurrentPage({ path: '/some/path' });
+    });
+
+    // Assert: Falls through to path-based logic since currentPageId is null
+    await waitFor(() => {
+      expect(mockedApiv3Get).toHaveBeenCalledWith(
+        '/page',
+        expect.objectContaining({
+          path: '/some/path',
+          shareLinkId,
+        }),
+      );
+    });
+  });
 });

+ 8 - 3
apps/app/src/states/page/use-fetch-current-page.ts

@@ -136,23 +136,28 @@ const buildApiParams = ({
     shareLinkId?: string;
   } = {};
 
-  if (shareLinkId != null) {
+  if (shareLinkId != null && shareLinkId.length > 0) {
     params.shareLinkId = shareLinkId;
   }
   if (revisionId != null) {
     params.revisionId = revisionId;
   }
 
-  // priority A: pageId > permalink > path
+  // priority A: fetchPageArgs.pageId
   if (fetchPageArgs?.pageId != null) {
     params.pageId = fetchPageArgs.pageId;
+  }
+  // priority B: currentPageId for share link (required by certifySharedPage middleware)
+  else if (shareLinkId != null && currentPageId != null) {
+    params.pageId = currentPageId;
   } else if (decodedPathname != null) {
+    // priority C: permalink > path
     if (isPermalink(decodedPathname)) {
       params.pageId = removeHeadingSlash(decodedPathname);
     } else {
       params.path = decodedPathname;
     }
-    // priority B: currentPageId > permalink(by location) > path(by location)
+    // priority D: currentPageId > permalink(by location) > path(by location)
   } else if (currentPageId != null) {
     params.pageId = currentPageId;
   } else if (isClient()) {

+ 30 - 14
apps/app/src/stores/page.tsx

@@ -90,6 +90,20 @@ export const mutateAllPageInfo = (): Promise<void[]> => {
   return mutate((key) => Array.isArray(key) && key[0] === '/page/info');
 };
 
+/**
+ * Build query params for /page/info endpoint.
+ * Only includes shareLinkId when it is a non-empty string.
+ */
+const buildPageInfoParams = (
+  pageId: string,
+  shareLinkId: string | null | undefined,
+): { pageId: string; shareLinkId?: string } => {
+  if (shareLinkId != null && shareLinkId.trim().length > 0) {
+    return { pageId, shareLinkId };
+  }
+  return { pageId };
+};
+
 export const useSWRxPageInfo = (
   pageId: string | null | undefined,
   shareLinkId?: string | null,
@@ -98,19 +112,20 @@ export const useSWRxPageInfo = (
   // Cache remains from guest mode when logging in via the Login lead, so add 'isGuestUser' key
   const isGuestUser = useIsGuestUser();
 
-  // assign null if shareLinkId is undefined in order to identify SWR key only by pageId
-  const fixedShareLinkId = shareLinkId ?? null;
-
   const key = useMemo(() => {
     return pageId != null
-      ? ['/page/info', pageId, fixedShareLinkId, isGuestUser]
+      ? ['/page/info', pageId, shareLinkId, isGuestUser]
       : null;
-  }, [fixedShareLinkId, isGuestUser, pageId]);
+  }, [shareLinkId, isGuestUser, pageId]);
 
   const swrResult = useSWRImmutable(
     key,
-    ([endpoint, pageId, shareLinkId]: [string, string, string | null]) =>
-      apiv3Get(endpoint, { pageId, shareLinkId }).then(
+    ([endpoint, pageId, shareLinkId]: [
+      string,
+      string,
+      string | null | undefined,
+    ]) =>
+      apiv3Get(endpoint, buildPageInfoParams(pageId, shareLinkId)).then(
         (response) => response.data,
       ),
     { fallbackData: initialData },
@@ -136,19 +151,20 @@ export const useSWRMUTxPageInfo = (
   // Cache remains from guest mode when logging in via the Login lead, so add 'isGuestUser' key
   const isGuestUser = useIsGuestUser();
 
-  // assign null if shareLinkId is undefined in order to identify SWR key only by pageId
-  const fixedShareLinkId = shareLinkId ?? null;
-
   const key = useMemo(() => {
     return pageId != null
-      ? ['/page/info', pageId, fixedShareLinkId, isGuestUser]
+      ? ['/page/info', pageId, shareLinkId, isGuestUser]
       : null;
-  }, [fixedShareLinkId, isGuestUser, pageId]);
+  }, [shareLinkId, isGuestUser, pageId]);
 
   return useSWRMutation(
     key,
-    ([endpoint, pageId, shareLinkId]: [string, string, string | null]) =>
-      apiv3Get(endpoint, { pageId, shareLinkId }).then(
+    ([endpoint, pageId, shareLinkId]: [
+      string,
+      string,
+      string | null | undefined,
+    ]) =>
+      apiv3Get(endpoint, buildPageInfoParams(pageId, shareLinkId)).then(
         (response) => response.data,
       ),
   );

+ 1 - 1
apps/slackbot-proxy/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slackbot-proxy",
-  "version": "7.4.4-slackbot-proxy.0",
+  "version": "7.4.5-slackbot-proxy.0",
   "license": "MIT",
   "private": "true",
   "scripts": {

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "7.4.4",
+  "version": "7.4.5-RC.0",
   "description": "Team collaboration software using markdown",
   "license": "MIT",
   "private": "true",

+ 1 - 1
packages/slack/package.json

@@ -61,7 +61,7 @@
     "date-fns": "^3.6.0",
     "extensible-custom-error": "^0.0.7",
     "http-errors": "^2.0.0",
-    "qs": "^6.14.1",
+    "qs": "^6.14.2",
     "universal-bunyan": "^0.9.2",
     "url-join": "^4.0.0"
   },

+ 50 - 29
pnpm-lock.yaml

@@ -557,8 +557,8 @@ importers:
         specifier: ^15.8.1
         version: 15.8.1
       qs:
-        specifier: ^6.14.1
-        version: 6.14.1
+        specifier: ^6.14.2
+        version: 6.14.2
       rate-limiter-flexible:
         specifier: ^2.3.7
         version: 2.4.2
@@ -812,6 +812,9 @@ importers:
       '@types/node-cron':
         specifier: ^3.0.11
         version: 3.0.11
+      '@types/nodemailer':
+        specifier: 6.4.22
+        version: 6.4.22
       '@types/react':
         specifier: ^18.2.14
         version: 18.3.3
@@ -934,7 +937,7 @@ importers:
         version: 5.1.0(react@18.2.0)
       react-dnd:
         specifier: ^14.0.5
-        version: 14.0.5(@types/hoist-non-react-statics@3.3.5)(@types/node@25.0.10)(@types/react@18.3.3)(react@18.2.0)
+        version: 14.0.5(@types/hoist-non-react-statics@3.3.5)(@types/node@25.2.3)(@types/react@18.3.3)(react@18.2.0)
       react-dnd-html5-backend:
         specifier: ^14.1.0
         version: 14.1.0
@@ -1810,8 +1813,8 @@ importers:
         specifier: ^2.0.0
         version: 2.0.1
       qs:
-        specifier: ^6.14.1
-        version: 6.14.1
+        specifier: ^6.14.2
+        version: 6.14.2
       universal-bunyan:
         specifier: ^0.9.2
         version: 0.9.2(@browser-bunyan/console-formatted-stream@1.8.0)(browser-bunyan@1.8.0)(bunyan@1.8.15)
@@ -2614,6 +2617,9 @@ packages:
   '@codemirror/view@6.39.11':
     resolution: {integrity: sha512-bWdeR8gWM87l4DB/kYSF9A+dVackzDb/V56Tq7QVrQ7rn86W0rgZFtlL3g3pem6AeGcb9NQNoy3ao4WpW4h5tQ==}
 
+  '@codemirror/view@6.39.14':
+    resolution: {integrity: sha512-WJcvgHm/6Q7dvGT0YFv/6PSkoc36QlR0VCESS6x9tGsnF1lWLmmYxOgX3HH6v8fo6AvSLgpcs+H0Olre6MKXlg==}
+
   '@colors/colors@1.5.0':
     resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==}
     engines: {node: '>=0.1.90'}
@@ -5498,8 +5504,11 @@ packages:
   '@types/node@20.19.17':
     resolution: {integrity: sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==}
 
-  '@types/node@25.0.10':
-    resolution: {integrity: sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==}
+  '@types/node@25.2.3':
+    resolution: {integrity: sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==}
+
+  '@types/nodemailer@6.4.22':
+    resolution: {integrity: sha512-HV16KRsW7UyZBITE07B62k8PRAKFqRSFXn1T7vslurVjN761tMDBhk5Lbt17ehyTzK6XcyJnAgUpevrvkcVOzw==}
 
   '@types/normalize-package-data@2.4.4':
     resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==}
@@ -8018,8 +8027,8 @@ packages:
     resolution: {integrity: sha512-2RZdgEbXmp5+dVbRm0P7HQUImZpICccJy7rN7Tv+SFa55pH+lxnuw6/K1ZxxBfHoYpSkHLAO92oa8O4SwFXA2A==}
     engines: {node: '>=10.2.0'}
 
-  enhanced-resolve@5.18.4:
-    resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==}
+  enhanced-resolve@5.19.0:
+    resolution: {integrity: sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==}
     engines: {node: '>=10.13.0'}
 
   enquirer@2.4.1:
@@ -8682,28 +8691,29 @@ packages:
 
   glob@10.4.5:
     resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==}
+    deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
     hasBin: true
 
   glob@6.0.4:
     resolution: {integrity: sha512-MKZeRNyYZAVVVG1oZeLaWie1uweH40m9AZwIwxyPbTSX4hHrVYSzLg0Ro5Z5R7XKkIX+Cc6oD1rqeDJnwsB8/A==}
-    deprecated: Glob versions prior to v9 are no longer supported
+    deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
 
   glob@7.1.6:
     resolution: {integrity: sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==}
-    deprecated: Glob versions prior to v9 are no longer supported
+    deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
 
   glob@7.2.0:
     resolution: {integrity: sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==}
-    deprecated: Glob versions prior to v9 are no longer supported
+    deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
 
   glob@7.2.3:
     resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
-    deprecated: Glob versions prior to v9 are no longer supported
+    deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
 
   glob@8.1.0:
     resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==}
     engines: {node: '>=12'}
-    deprecated: Glob versions prior to v9 are no longer supported
+    deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
 
   global-directory@4.0.1:
     resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==}
@@ -11764,12 +11774,12 @@ packages:
     resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==}
     engines: {node: '>=0.6'}
 
-  qs@6.14.1:
-    resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==}
+  qs@6.14.2:
+    resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==}
     engines: {node: '>=0.6'}
 
-  qs@6.5.3:
-    resolution: {integrity: sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==}
+  qs@6.5.5:
+    resolution: {integrity: sha512-mzR4sElr1bfCaPJe7m8ilJ6ZXdDaGoObcYR0ZHSsktM/Lt21MVHj5De30GQH2eiZ1qGRTO7LCAzQsUeXTNexWQ==}
     engines: {node: '>=0.6'}
 
   quansync@0.2.10:
@@ -13231,7 +13241,7 @@ packages:
   tar@6.2.1:
     resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==}
     engines: {node: '>=10'}
-    deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me
+    deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
 
   teeny-request@7.2.0:
     resolution: {integrity: sha512-SyY0pek1zWsi0LRVAALem+avzMLc33MKW/JLLakdP4s9+D7+jHcy5x6P+h94g2QNZsAqQNfX5lsbd3WSeJXrrw==}
@@ -16329,7 +16339,7 @@ snapshots:
     dependencies:
       '@codemirror/language': 6.12.1
       '@codemirror/state': 6.5.4
-      '@codemirror/view': 6.39.11
+      '@codemirror/view': 6.39.14
       '@lezer/highlight': 1.2.3
 
   '@codemirror/view@6.39.11':
@@ -16339,6 +16349,13 @@ snapshots:
       style-mod: 4.1.3
       w3c-keyname: 2.2.8
 
+  '@codemirror/view@6.39.14':
+    dependencies:
+      '@codemirror/state': 6.5.4
+      crelt: 1.0.6
+      style-mod: 4.1.3
+      w3c-keyname: 2.2.8
+
   '@colors/colors@1.5.0':
     optional: true
 
@@ -20165,11 +20182,15 @@ snapshots:
     dependencies:
       undici-types: 6.21.0
 
-  '@types/node@25.0.10':
+  '@types/node@25.2.3':
     dependencies:
       undici-types: 7.16.0
     optional: true
 
+  '@types/nodemailer@6.4.22':
+    dependencies:
+      '@types/node': 20.19.17
+
   '@types/normalize-package-data@2.4.4': {}
 
   '@types/oracledb@6.5.2':
@@ -22693,7 +22714,7 @@ snapshots:
       - supports-color
       - utf-8-validate
 
-  enhanced-resolve@5.18.4:
+  enhanced-resolve@5.19.0:
     dependencies:
       graceful-fs: 4.2.11
       tapable: 2.3.0
@@ -27188,11 +27209,11 @@ snapshots:
     dependencies:
       side-channel: 1.1.0
 
-  qs@6.14.1:
+  qs@6.14.2:
     dependencies:
       side-channel: 1.1.0
 
-  qs@6.5.3: {}
+  qs@6.5.5: {}
 
   quansync@0.2.10: {}
 
@@ -27307,7 +27328,7 @@ snapshots:
     dependencies:
       dnd-core: 14.0.1
 
-  react-dnd@14.0.5(@types/hoist-non-react-statics@3.3.5)(@types/node@25.0.10)(@types/react@18.3.3)(react@18.2.0):
+  react-dnd@14.0.5(@types/hoist-non-react-statics@3.3.5)(@types/node@25.2.3)(@types/react@18.3.3)(react@18.2.0):
     dependencies:
       '@react-dnd/invariant': 2.0.0
       '@react-dnd/shallowequal': 2.0.0
@@ -27317,7 +27338,7 @@ snapshots:
       react: 18.2.0
     optionalDependencies:
       '@types/hoist-non-react-statics': 3.3.5
-      '@types/node': 25.0.10
+      '@types/node': 25.2.3
       '@types/react': 18.3.3
 
   react-dom@18.2.0(react@18.2.0):
@@ -27971,7 +27992,7 @@ snapshots:
       mime-types: 2.1.35
       oauth-sign: 0.9.0
       performance-now: 2.1.0
-      qs: 6.5.3
+      qs: 6.5.5
       safe-buffer: 5.2.1
       tough-cookie: 2.5.0
       tunnel-agent: 0.6.0
@@ -28930,7 +28951,7 @@ snapshots:
       formidable: 3.5.4
       methods: 1.1.2
       mime: 2.6.0
-      qs: 6.14.1
+      qs: 6.14.2
     transitivePeerDependencies:
       - supports-color
 
@@ -30109,7 +30130,7 @@ snapshots:
       acorn-import-attributes: 1.9.5(acorn@8.15.0)
       browserslist: 4.28.1
       chrome-trace-event: 1.0.4
-      enhanced-resolve: 5.18.4
+      enhanced-resolve: 5.19.0
       es-module-lexer: 1.7.0
       eslint-scope: 5.1.1
       events: 3.3.0