diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..61b594b --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +# Outlook CLI Configuration +# Copy this to .env and fill in your values + +# Azure App Registration client ID (required) +OUTLOOK_CLI_CLIENT_ID=your-client-id-here + +# Azure AD tenant ID (default: "common" for multi-tenant) +# Use "consumers" for personal Microsoft accounts only +# Use "organizations" for work/school accounts only +# Use a specific tenant GUID for single-tenant apps +OUTLOOK_CLI_TENANT_ID=common + +# Passphrase for encrypting token cache (optional) +# If not set, a machine-derived passphrase is used automatically +# Set this if you want to share token caches across machines +# OUTLOOK_CLI_PASSPHRASE=your-secure-passphrase + +# Log level: error, warn, info, debug (default: warn) +OUTLOOK_CLI_LOG_LEVEL=warn diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..1b2c643 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,120 @@ +# Copilot Instructions — outlook-cli + +> **For comprehensive agent documentation**, see the `agents/` directory: +> `agents/ARCHITECTURE.md`, `agents/BUILDING.md`, `agents/MAKING-CHANGES.md`, +> `agents/TESTING.md`, `agents/REAL-WORLD-VALIDATION.md`, `agents/COMMON-PITFALLS.md` + +## Build, Test, Lint + +```bash +npm install # Install dependencies +npm test # Run all tests (vitest) +npm run test:verbose # Detailed test output +npx vitest run test/node/security/crypto.test.js # Run a single test file +npx vitest run -t "should encrypt" # Run tests matching a name +npm run test:watch # Watch mode (re-runs on save) +npm run test:coverage # Coverage report +``` + +The C# implementation builds separately: + +```bash +dotnet build src/dotnet # Build C# implementation +``` + +CLI integration tests can run against either implementation: + +```bash +OUTLOOK_CLI_BIN="dotnet run --project src/dotnet" npm test +``` + +No linter is configured. No build step is needed for Node.js — it runs directly as ES modules. + +## Architecture + +This is a CLI for Microsoft Outlook (email + calendar) via the Microsoft Graph API. It has two implementations — a **Node.js reference implementation** (`src/node/`) and a **C# optimized implementation** (`src/dotnet/`) — that share the same command structure, file formats, and CLI integration test suite. + +### Request flow + +``` +bin/outlook-cli.js → src/node/cli/index.js (Commander.js program) + → src/node/cli/{mail,calendar,contacts,auth,account,watch}.js (command handlers) + → src/node/graph/client.js (GraphClient: auth, retry, rate-limit, pagination) + → src/node/auth/msal-client.js → src/node/auth/token-cache.js → src/node/security/crypto.js + → src/node/security/token-validator.js (defense-in-depth scope check on EVERY request) + → src/node/graph/{mail,calendar,contacts,delta}.js (Graph API endpoints) + → src/node/output/render.js → src/node/output/{formatter,markdown,html}.js +``` + +### Output dispatch (Strategy pattern) + +`render.js` dispatches to format-specific modules (`formatter.js`, `markdown.js`, `html.js`). Each module exports functions named after entity types (`mailList`, `mailDetail`, `eventList`, etc.). The `output()` helper resolves the format, renders, and writes to stdout or file. + +### Delegate access + +`GraphClient.userPath` returns `/me` for own account or `/users/{email}` for delegate access (`--as` flag). All Graph API modules prepend `userPath` to their endpoint paths. + +### Watch mode + +Uses Microsoft Graph delta queries (not webhooks). Single-account mode stores delta tokens in `~/.outlook-cli/delta-{alias}.json`. Multi-job mode (`--config watches.json`) runs multiple watches with independent schedules, rule-based condition matching, and action execution (move, mark-read, flag, run commands, channel delivery). + +### Configurable permissions + +Accounts.json supports a `defaults` block inherited by all accounts, `parent` chains for hierarchical config, and `+`/`-` modifiers on allowed/forbidden scope lists. `send_to` whitelists restrict recipients per-account. + +### Operation logging (SQLite) + +`src/node/db/database.js` manages `~/.outlook-cli/outlook-cli.db` (WAL mode). `src/node/db/logger.js` provides correlation-ID-based operation logging (start/complete/fail). + +### Telemetry + +`src/node/telemetry/collector.js` provides an in-memory ring buffer (1000 events), optional SQLite persistence, and process snapshots. Disabled by default; enable via `OUTLOOK_CLI_TELEMETRY=1`. + +### Node.js ↔ C# interoperability + +Both implementations use identical file formats in `~/.outlook-cli/`: +- `cache-{alias}.enc` — AES-256-GCM encrypted MSAL token cache (binary: salt(32) + iv(16) + authTag(16) + ciphertext) +- `accounts.json`, `aliases.json`, `config.json`, `delta-{alias}.json` + +The encryption uses PBKDF2-SHA512 with 310,000 iterations. A cache encrypted by Node.js decrypts in C# and vice versa. + +## Key Conventions + +### Security — CRITICAL + +- **`Mail.ReadWrite.All` is a FORBIDDEN scope** — application-level access to all mailboxes is never allowed. `Mail.Send` and `Mail.Send.Shared` are both allowed. The permissions system (configurable via accounts.json) can restrict per-account. +- `validateTokenScopes()` in `src/node/security/token-validator.js` runs on **every token acquisition** as defense-in-depth. It checks both the MSAL result's scopes array and the decoded JWT `scp` claim. +- Personal Microsoft accounts return opaque (non-JWT) tokens. Token validators must handle this gracefully (return valid, validate via MSAL scopes array instead). + +### Module patterns + +- **ESM only** (`"type": "module"`). All imports must use `.js` extensions. +- **Named exports only** — no default exports. Use `export function` or `export { name }`. +- **Factory functions** for stateful objects: `createGraphClient()`, `createMsalClient()`, `createCachePlugin()`. +- **Singleton with reset**: `AccountManager` uses `getAccountManager()` / `resetAccountManager()` (reset is for test isolation). + +### Error handling + +- Throw immediately for validation errors with actionable context (e.g., "Run `outlook-cli auth login` to authenticate"). +- Network retry: 401 → clear token cache + retry; 429 → wait `Retry-After` seconds + retry; other errors → throw. +- Decryption failures degrade gracefully (log warning, start with empty cache, user re-authenticates). + +### Config resolution (last wins) + +1. Hardcoded defaults → 2. Environment variables → 3. `~/.outlook-cli/config.json` → 4. Per-account settings in `accounts.json` → 5. CLI flags. + +Config file intentionally overrides env vars (see `Object.assign` in `src/node/config.js`). + +### Testing conventions + +- **Vitest** with Jest-compatible API (`describe`/`it`/`expect`/`vi.mock()`). +- Test files organized by category: `test/node//`, `test/dotnet/`, `test/shared/`, `test/stress/`. +- Stress tiers: `test/stress/scale/` (@stress), `test/stress/integrity/` (@stress), `test/stress/performance/` (@perf), `test/stress/soak/` (@soak). +- Tests are fully offline — no network calls, no real Microsoft auth. Mock external dependencies with `vi.mock()`. +- Crypto tests use **real encryption** (not mocks) — expect ~300ms overhead per test. +- Tests that write to disk create unique temp directories and clean up in `afterEach`. +- Test names use `should ` format. + +### Input merging + +Commands support `--input ` for JSON parameter files. **CLI flags always override JSON values.** `bodyFile` takes priority over inline `body`. HTML content type is auto-detected from `.html`/`.htm` file extensions. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..32fc1f9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,130 @@ +# CI — Unit Tests + NativeAOT Build +# +# Runs on every push and PR. Fast feedback loop (~2-3 min). +# - Node.js unit tests on Ubuntu + Windows +# - C# build + NativeAOT publish on Windows +# - Test report generation and artifact upload +# +# Integration tests (requiring M365 credentials) run separately +# via integration.yml on manual dispatch or nightly schedule. + +name: CI + +on: + push: + branches: [main, 'feature/**'] + pull_request: + branches: [main] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + # ── Node.js Unit Tests ──────────────────────────────────────────────── + unit-tests: + name: Unit Tests (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + node-version: [22] + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run unit tests + run: npm run test:ci + + - name: Generate test report + if: always() + run: npm run test:report:markdown > test-results/report.md 2>&1 || true + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results-${{ matrix.os }} + path: test-results/ + retention-days: 30 + + - name: Post test summary to PR + if: always() && github.event_name == 'pull_request' + run: | + if [ -f test-results/report.md ]; then + echo "## 🧪 Test Results (${{ matrix.os }})" >> $GITHUB_STEP_SUMMARY + cat test-results/report.md >> $GITHUB_STEP_SUMMARY + fi + shell: bash + + # ── C# Build ────────────────────────────────────────────────────────── + dotnet-build: + name: C# Build + runs-on: windows-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Restore dependencies + run: dotnet restore src/dotnet/OutlookCli.csproj + + - name: Build (Debug) + run: dotnet build src/dotnet/OutlookCli.csproj --no-restore -warnaserror- + + - name: Verify CLI help + run: dotnet run --project src/dotnet -- --help + + # ── NativeAOT Publish ───────────────────────────────────────────────── + nativeaot: + name: NativeAOT (${{ matrix.rid }}) + runs-on: ${{ matrix.os }} + needs: dotnet-build + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + rid: win-x64 + ext: .exe + - os: ubuntu-latest + rid: linux-x64 + ext: "" + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Publish NativeAOT + run: > + dotnet publish src/dotnet/OutlookCli.csproj + -r ${{ matrix.rid }} + --self-contained + /p:PublishAot=true + -o publish/${{ matrix.rid }} + + - name: Verify binary runs + run: ./publish/${{ matrix.rid }}/outlook-cli${{ matrix.ext }} --help + shell: bash + + - name: Upload binary + uses: actions/upload-artifact@v4 + with: + name: outlook-cli-${{ matrix.rid }} + path: publish/${{ matrix.rid }}/ + retention-days: 90 diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml new file mode 100644 index 0000000..ee333de --- /dev/null +++ b/.github/workflows/integration.yml @@ -0,0 +1,140 @@ +# Integration Tests — Real Microsoft Graph API +# +# Runs integration tests against a live M365 account. +# Triggered manually or on a nightly schedule. +# +# SECRETS REQUIRED: +# OUTLOOK_CLI_TEST_ACCOUNT — Account alias for test account +# OUTLOOK_CLI_PASSPHRASE — Encryption passphrase for token cache +# OUTLOOK_CLI_ACCOUNTS_JSON — Base64-encoded accounts.json +# OUTLOOK_CLI_CACHE_ENC — Base64-encoded encrypted token cache +# +# These secrets contain OAuth tokens — treat with care. +# The test account should be a dedicated test mailbox, not a production account. + +name: Integration Tests + +on: + workflow_dispatch: + inputs: + test_filter: + description: 'Test file filter (e.g., mail-read)' + required: false + default: '' + runtime: + description: 'Runtime to test' + required: false + default: 'node' + type: choice + options: + - node + - dotnet + - both + schedule: + # Run nightly at 2 AM UTC + - cron: '0 2 * * *' + +concurrency: + group: integration-${{ github.ref }} + cancel-in-progress: true + +jobs: + integration: + name: Integration (${{ matrix.runtime }}) + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + runtime: ${{ github.event.inputs.runtime == 'both' && fromJSON('["node", "dotnet"]') || fromJSON(format('["{0}"]', github.event.inputs.runtime || 'node')) }} + + env: + OUTLOOK_CLI_E2E: "1" + OUTLOOK_CLI_TEST_ACCOUNT: ${{ secrets.OUTLOOK_CLI_TEST_ACCOUNT }} + OUTLOOK_CLI_PASSPHRASE: ${{ secrets.OUTLOOK_CLI_PASSPHRASE }} + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - uses: actions/setup-dotnet@v4 + if: matrix.runtime == 'dotnet' + with: + dotnet-version: 8.0.x + + - name: Install dependencies + run: npm ci + + - name: Restore credentials + shell: bash + run: | + mkdir -p ~/.outlook-cli + echo "${{ secrets.OUTLOOK_CLI_ACCOUNTS_JSON }}" | base64 -d > ~/.outlook-cli/accounts.json + echo "${{ secrets.OUTLOOK_CLI_CACHE_ENC }}" | base64 -d > ~/.outlook-cli/cache-${{ secrets.OUTLOOK_CLI_TEST_ACCOUNT }}.enc + + - name: Build C# (dotnet runtime only) + if: matrix.runtime == 'dotnet' + run: > + dotnet publish src/dotnet/OutlookCli.csproj + -r win-x64 --self-contained /p:PublishAot=true + -o publish/win-x64 + + - name: Set runtime binary + if: matrix.runtime == 'dotnet' + shell: bash + run: echo "OUTLOOK_CLI_BIN=publish/win-x64/outlook-cli.exe" >> $GITHUB_ENV + + - name: Run integration tests + shell: bash + run: | + FILTER="${{ github.event.inputs.test_filter }}" + if [ -n "$FILTER" ]; then + npx vitest run "test/integration/*${FILTER}*" + else + # Run one file at a time to avoid rate limits + for f in test/integration/*.test.js; do + echo "=== Running: $f ===" + npx vitest run "$f" || true + done + fi + + - name: Generate test report + if: always() + run: npm run test:report:json > test-results/integration-report.json 2>&1 || true + + - name: Upload results + if: always() + uses: actions/upload-artifact@v4 + with: + name: integration-results-${{ matrix.runtime }} + path: test-results/ + retention-days: 30 + + - name: Post summary + if: always() + shell: bash + run: | + echo "## 🔗 Integration Test Results (${{ matrix.runtime }})" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + if [ -f test-results/vitest-report.json ]; then + node -e " + const r = JSON.parse(require('fs').readFileSync('test-results/vitest-report.json','utf8')); + const passed = r.testResults?.reduce((s,f) => s + f.assertionResults?.filter(t => t.status === 'passed').length, 0) || 0; + const failed = r.testResults?.reduce((s,f) => s + f.assertionResults?.filter(t => t.status === 'failed').length, 0) || 0; + console.log('| Metric | Value |'); + console.log('|--------|-------|'); + console.log('| Passed | ' + passed + ' |'); + console.log('| Failed | ' + failed + ' |'); + console.log('| Total | ' + (passed + failed) + ' |'); + " >> $GITHUB_STEP_SUMMARY + else + echo "No test results found." >> $GITHUB_STEP_SUMMARY + fi + + - name: Cleanup credentials + if: always() + shell: bash + run: rm -rf ~/.outlook-cli/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bbb907b --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +node_modules/ +test-results/ +.env +.env.* +!.env.example +*.enc +dist/ +coverage/ +.outlook-cli/ +*.log +.DS_Store +Thumbs.db +.vscode/ +.idea/ +*.swp +*.swo +*~ +package-lock.json + +# .NET / C# build artifacts +src/dotnet/**/bin/ +src/dotnet/**/obj/ +publish/ +*.user +*.suo diff --git a/README.md b/README.md new file mode 100644 index 0000000..59acf99 --- /dev/null +++ b/README.md @@ -0,0 +1,177 @@ +# outlook-cli + +Cross-platform CLI for Microsoft Outlook via the Microsoft Graph API, with dual implementations in **Node.js** and **.NET NativeAOT**. + +## Features + +- 📧 **Email** — inbox, read, search, draft, reply, forward, send, move, flag, mark-read +- 📅 **Calendar** — today, week, date range, view, create events, list calendars +- 👥 **Contacts** — search contacts and GAL, manage aliases (`fred` → `freddie@outlook.com`) +- 👤 **Multi-account** — personal + work accounts, parent chains, per-account permissions +- 👥 **Delegate access** — read and send on behalf of other users (`--as fred`) +- 👀 **Watch mode** — real-time change tracking via delta queries, rules engine for channel delivery +- 🔐 **Security** — AES-256-GCM encrypted token cache, configurable permissions, forbidden scopes, send_to whitelist, defense-in-depth token validation +- 🖥️ **Cross-platform** — Mac, Linux, Windows; device code flow for headless VMs +- 📊 **Multiple output formats** — text, json, markdown, html +- 🤖 **AI agent integration** — OpenClaw / NanoClaw dual-mode (Skill + Channel Factory) + +## Quick Start + +### Node.js + +```bash +git clone https://github.com/jeffstall/outlook-cli.git +cd outlook-cli +npm install +node bin/outlook-cli.js auth login --client-id YOUR_CLIENT_ID +node bin/outlook-cli.js mail inbox +``` + +### .NET (NativeAOT) + +```bash +dotnet publish src/dotnet/OutlookCli.csproj -c Release -r win-x64 --self-contained /p:PublishAot=true -o publish/win-x64 +./publish/win-x64/outlook-cli auth login --client-id YOUR_CLIENT_ID +./publish/win-x64/outlook-cli mail inbox +``` + +> Both implementations share the same config directory (`~/.outlook-cli/`), accounts, aliases, and encrypted token cache. See [docs/architecture.md](docs/architecture.md) for details. + +## Documentation + +| Document | Description | +|---|---| +| [docs/usage.md](docs/usage.md) | Complete command-line reference with examples | +| [docs/architecture.md](docs/architecture.md) | Architecture, config formats, security model, integration | +| [docs/src-node.md](docs/src-node.md) | Building and testing the Node.js implementation | +| [docs/src-dotnet.md](docs/src-dotnet.md) | Building and testing the .NET implementation | +| [docs/src-tests.md](docs/src-tests.md) | Test architecture and conventions | +| [docs/AZURE-SETUP.md](docs/AZURE-SETUP.md) | Azure App Registration setup guide | +| [docs/SECURITY.md](docs/SECURITY.md) | Security model and threat analysis | +| [docs/MULTI-ACCOUNT.md](docs/MULTI-ACCOUNT.md) | Multi-account configuration guide | +| [docs/DELEGATE-ACCESS.md](docs/DELEGATE-ACCESS.md) | Delegate access setup and usage | +| [docs/WATCH-MODE.md](docs/WATCH-MODE.md) | Watch mode and rules engine | +| [docs/JSON-INPUT.md](docs/JSON-INPUT.md) | JSON input file schemas for all commands | +| [docs/DIRECTORY-STRUCTURE.md](docs/DIRECTORY-STRUCTURE.md) | Project directory layout | + +## Architecture Overview + +``` +┌──────────────────────────────────────────────────┐ +│ outlook-cli │ +├────────────────────┬─────────────────────────────┤ +│ Node.js (ESM) │ .NET 8 (NativeAOT) │ +│ Commander.js CLI │ System.CommandLine CLI │ +│ @azure/msal-node │ MSAL.NET │ +│ Direct HTTP Graph │ Direct HTTP Graph │ +├────────────────────┴─────────────────────────────┤ +│ Shared Configuration │ +│ ~/.outlook-cli/accounts.json │ +│ ~/.outlook-cli/aliases.json │ +│ ~/.outlook-cli/token-cache-*.bin (AES-256-GCM) │ +│ ~/.outlook-cli/delta-*.json │ +└──────────────────────────────────────────────────┘ +``` + +Both implementations produce identical CLI behavior and share all configuration files. You can switch between them freely. + +## Azure App Registration + +You need a free App Registration in [Microsoft Entra ID](https://entra.microsoft.com). See [docs/AZURE-SETUP.md](docs/AZURE-SETUP.md) for step-by-step instructions. + +**Required permissions:** + +| Permission | Purpose | +|---|---| +| `User.Read` | Read user profile | +| `Mail.Read` | Read email messages | +| `Mail.ReadWrite` | Create drafts, move, flag, mark-read | +| `Calendars.Read` | Read calendar events | +| `Calendars.ReadWrite` | Create calendar events | +| `offline_access` | Refresh tokens for persistent sessions | + +**Optional permissions:** + +| Permission | Purpose | +|---|---| +| `Mail.Send` | Send email from own account | +| `Mail.Send.Shared` | Send on behalf of delegate mailbox owner | +| `Mail.Read.Shared` | Read delegate mailbox | +| `Mail.ReadWrite.Shared` | Write to delegate mailbox | +| `Calendars.Read.Shared` | Read delegate calendar | + +> **Security note:** Permissions are configurable per account via `accounts.json`. The security model supports forbidden scopes and send_to recipient whitelists. See [docs/SECURITY.md](docs/SECURITY.md). + +## Security Model + +- **Configurable permissions** — Each account defines allowed/forbidden scopes in `accounts.json` +- **Forbidden scopes** — Block specific permissions (e.g., `Mail.Send`) at the config level +- **send_to whitelist** — Restrict which recipients can receive mail from a given account +- **Defense-in-depth token validation** — Tokens are inspected at runtime to enforce permission policy +- **Encrypted token cache** — AES-256-GCM encryption, PBKDF2 SHA-512 with 310,000 iterations +- **Confirmation prompts** — Write operations require confirmation (bypass with `--yes` for scripting) + +See [docs/SECURITY.md](docs/SECURITY.md) for the full threat model. + +## AI Agent Integration (OpenClaw / NanoClaw) + +outlook-cli operates in two modes for AI agent integration: + +- **Skill mode** — Agents invoke CLI commands and parse JSON output +- **Channel Factory mode** — Watch rules engine normalizes incoming emails to channel messages, delivered via stdout / webhook / exec / file + +```bash +# OpenClaw — symlink as a skill +ln -s /path/to/outlook-cli/skill ~/.openclaw/skills/outlook + +# NanoClaw — install in container +git clone https://github.com/jeffstall/outlook-cli.git /opt/outlook-cli +cd /opt/outlook-cli && npm install && npm link +``` + +See [skill/SKILL.md](skill/SKILL.md) and [skill/NANOCLAW.md](skill/NANOCLAW.md). + +## Configuration + +### Environment Variables + +| Variable | Description | Default | +|---|---|---| +| `OUTLOOK_CLI_CLIENT_ID` | Azure app client ID | (none) | +| `OUTLOOK_CLI_TENANT_ID` | Azure AD tenant ID | `common` | +| `OUTLOOK_CLI_PASSPHRASE` | Token cache encryption passphrase | Machine-derived | +| `OUTLOOK_CLI_LOG_LEVEL` | Logging level | `warn` | + +### Config Directory + +All configuration is stored in `~/.outlook-cli/`: + +| File | Purpose | +|---|---| +| `accounts.json` | Account definitions, permissions, defaults | +| `aliases.json` | Contact aliases | +| `token-cache-*.bin` | Encrypted MSAL token cache | +| `delta-*.json` | Watch mode delta tokens | +| `config.json` | Optional global configuration | + +## Development + +```bash +# Node.js +npm install +npm test # 160+ tests via vitest +node bin/outlook-cli.js --help + +# .NET +dotnet build src/dotnet/OutlookCli.csproj +dotnet run --project src/dotnet -- --help + +# Both (unified build script) +.\build.ps1 all +``` + +See [docs/src-node.md](docs/src-node.md), [docs/src-dotnet.md](docs/src-dotnet.md), and [docs/src-tests.md](docs/src-tests.md). + +## License + +MIT — see [LICENSE](LICENSE) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..c6ac0fe --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,42 @@ +# Security Policy + +## Reporting a Vulnerability + +If you discover a security vulnerability in outlook-cli, please report it responsibly: + +1. **Do not** open a public GitHub issue for security vulnerabilities. +2. **Email**: Report the vulnerability via [GitHub Security Advisories](https://github.com/jeffstall/outlook-cli/security/advisories/new) (preferred) or contact the maintainers directly. +3. **Include**: A description of the vulnerability, steps to reproduce, and the potential impact. + +We will acknowledge receipt within 48 hours and provide a timeline for a fix. + +## Supported Versions + +| Version | Supported | +|---|---| +| 2.x (current) | ✅ Security updates | +| 1.x | ❌ No longer supported | + +## Scope + +The following are in scope for security reports: + +- Token theft or credential exposure +- Scope escalation (bypassing forbidden scope enforcement) +- Encryption weaknesses (AES-256-GCM implementation, key derivation) +- Authentication bypass (PKCE, token validation) +- Path injection in Graph API calls +- Sensitive data exposure in logs, telemetry, or error messages + +The following are **not** in scope: + +- Issues in Microsoft Graph API itself (report to [Microsoft Security Response Center](https://msrc.microsoft.com/)) +- Issues in MSAL libraries (report to [Microsoft Identity team](https://github.com/AzureAD/microsoft-authentication-library-for-js/security)) +- Denial of service via normal CLI usage (rate limiting is handled by Graph API) +- Social engineering attacks + +## Security Design + +For a comprehensive overview of the security architecture, see: +- [`docs/SECURITY.md`](docs/SECURITY.md) — Security model summary +- [`docs/SECURITY-DESIGN.md`](docs/SECURITY-DESIGN.md) — Full enterprise security design document with threat model, STRIDE analysis, and compliance mappings diff --git a/agents/ARCHITECTURE.md b/agents/ARCHITECTURE.md new file mode 100644 index 0000000..04a88cc --- /dev/null +++ b/agents/ARCHITECTURE.md @@ -0,0 +1,569 @@ +# Architecture — outlook-cli + +## Overview + +outlook-cli is a CLI for Microsoft Outlook (email + calendar) via the Microsoft Graph API. It has two complete implementations that share identical behavior, file formats, and configuration: + +| Implementation | Location | Framework | Auth Library | CLI Parser | +|---|---|---|---|---| +| **Node.js** (reference) | `src/node/` | ESM, no build step | @azure/msal-node | Commander.js | +| **C# NativeAOT** (optimized) | `src/dotnet/` | .NET 8, AOT compiled | MSAL.NET | System.CommandLine | + +Both produce the same CLI interface, same output formats, same error messages, and read/write the same encrypted token caches interchangeably. + +## Request Flow — Node.js + +``` +bin/outlook-cli.js ← Entry point (shebang, parseAsync) + └─ src/node/cli/index.js ← Commander.js program, global options + ├─ src/node/cli/mail.js ← mail inbox/read/search/draft/send/reply/... + ├─ src/node/cli/calendar.js ← calendar today/week/range/view/create + ├─ src/node/cli/contacts.js ← contacts search, alias management + ├─ src/node/cli/auth.js ← auth login/logout/status + ├─ src/node/cli/account.js ← account add/remove/list/set-default + ├─ src/node/cli/watch.js ← watch --mode poll/fast-poll/webhook + ├─ src/node/cli/doctor.js ← doctor (diagnostics) + └─ src/node/cli/log.js ← log (operation history) + │ + ▼ + src/node/graph/client.js ← GraphClient: token management, HTTP, retry + ├─ src/node/auth/msal-client.js ← MSAL PublicClientApplication factory + │ └─ src/node/auth/token-cache.js ← Encrypted ICachePlugin + │ └─ src/node/security/crypto.js ← AES-256-GCM (PBKDF2-SHA512) + ├─ src/node/security/token-validator.js ← Defense-in-depth scope check + ├─ src/node/graph/mail.js ← Graph /messages endpoints + ├─ src/node/graph/calendar.js ← Graph /calendarView endpoints + ├─ src/node/graph/contacts.js ← Graph /people, /contacts endpoints + └─ src/node/graph/delta.js ← Delta query engine + │ + ▼ + src/node/output/render.js ← Format dispatcher (Strategy pattern) + ├─ src/node/output/formatter.js ← Text tables + ├─ src/node/output/markdown.js ← Markdown tables + └─ src/node/output/html.js ← HTML output +``` + +## Request Flow — C# + +``` +src/dotnet/Program.cs ← Entry point + ALL command handlers + ├─ src/dotnet/Graph/GraphClient.cs ← HTTP client, token, retry, pagination + │ ├─ src/dotnet/Auth/MsalClientFactory.cs ← MSAL.NET PublicClientApplication + │ │ └─ src/dotnet/Auth/TokenCacheHelper.cs ← Encrypted cache plugin + │ │ └─ src/dotnet/Security/CryptoService.cs ← AES-256-GCM + │ └─ src/dotnet/Security/TokenValidator.cs ← Scope validation + ├─ src/dotnet/Graph/MailService.cs ← Mail endpoints + ├─ src/dotnet/Graph/CalendarService.cs ← Calendar endpoints + ├─ src/dotnet/Graph/ContactsService.cs ← Contacts endpoints + ├─ src/dotnet/Accounts/AccountManager.cs ← Account registry + ├─ src/dotnet/Contacts/AliasManager.cs ← Alias management + └─ src/dotnet/Output/OutputFormatter.cs ← All format rendering +``` + +## Detailed Flow Diagrams + +### Inbox Flow (`outlook-cli mail inbox`) + +``` + User bin/outlook-cli.js cli/index.js cli/mail.js graph/client.js graph/mail.js output/render.js + │ │ │ │ │ │ │ + │ $ outlook-cli │ │ │ │ │ │ + │ mail inbox --top 5 │ │ │ │ │ │ + │──────────────────────>│ │ │ │ │ │ + │ │ createProgram() │ │ │ │ │ + │ │ program.parseAsync() │ │ │ │ + │ │─────────────────────> │ │ │ │ + │ │ │ Commander parses │ │ │ │ + │ │ │ global opts: │ │ │ │ + │ │ │ --account,--format│ │ │ │ + │ │ │ Routes to mail │ │ │ │ + │ │ │ inbox action │ │ │ │ + │ │ │──────────────────->│ │ │ │ + │ │ │ │ resolveAccount() │ │ │ + │ │ │ │ → alias + config │ │ │ + │ │ │ │ │ │ │ + │ │ │ │ createGraphClient() │ │ │ + │ │ │ │─────────────────────>│ │ │ + │ │ │ │ │ acquireTokenSilently │ │ + │ │ │ │ │ → MSAL refresh token │ │ + │ │ │ │ │ validateTokenScopes()│ │ + │ │ │ │ │ → reject forbidden │ │ + │ │ │ │ │ │ │ + │ │ │ │ listMessages(client,│ │ │ + │ │ │ │ {top:5,folder}) │ │ │ + │ │ │ │─────────────────────────────────────────────>│ │ + │ │ │ │ │ │ │ + │ │ │ │ │ client.userPath → │ │ + │ │ │ │ │ "/me" or │ │ + │ │ │ │ │ "/users/{email}" │ │ + │ │ │ │ │ │ │ + │ │ │ │ │ GET {userPath}/ │ │ + │ │ │ │ │ mailFolders/Inbox/ │ │ + │ │ │ │ │ messages?$top=5 │ │ + │ │ │ │ │ &$select=id,subject,│ │ + │ │ │ │ │ from,receivedDate...│ │ + │ │ │ │ │<── Graph API response│ │ + │ │ │ │ │ │ │ + │ │ │ │<─────────────────────────────────────────────│ │ + │ │ │ │ messages[] array │ │ │ + │ │ │ │ │ │ │ + │ │ │ │ output(messages, │ │ │ + │ │ │ │ 'mailList',options)│ │ │ + │ │ │ │─────────────────────────────────────────────────────────────────────>│ + │ │ │ │ │ │ resolveFormat() │ + │ │ │ │ │ │ → text|json|md|html │ + │ │ │ │ │ │ formatter.mailList()│ + │ │ │ │ │ │ → ASCII table │ + │ │ │ │ │ │ writeOutput() │ + │<──────────────────────────────────────────────────────────────────────────────────────────────────────────────────stdout or --output──│ + │ Formatted mail table │ │ │ │ │ │ +``` + +### Auth Login Flow (`outlook-cli auth login`) + +``` + User cli/auth.js msal-client.js auth-flows.js Browser token-validator.js token-cache.js + │ │ │ │ │ │ │ + │ auth login │ │ │ │ │ │ + │───────────────>│ │ │ │ │ │ + │ │ resolveAccount() │ │ │ │ │ + │ │ → alias + config │ │ │ │ │ + │ │ │ │ │ │ │ + │ │ createMsalClient() │ │ │ │ │ + │ │─────────────────────>│ │ │ │ │ + │ │ │ createCachePlugin()│ │ │ │ + │ │ │────────────────────────────────────────────────────────────────────────────────────────>│ + │ │ │ │ │ │ │ + │ │ │ new PublicClient │ │ │ │ + │ │ │ Application(config)│ │ │ │ + │ │<─────────────────────│ │ │ │ │ + │ │ msalClient │ │ │ │ │ + │ │ │ │ │ │ │ + │ │ interactiveLogin(msalClient) │ │ │ │ + │ │───────────────────────────────────────────>│ │ │ │ + │ │ │ │ Generate PKCE │ │ │ + │ │ │ │ verifier + challenge│ │ │ + │ │ │ │ │ │ │ + │ │ │ │ Start HTTP server │ │ │ + │ │ │ │ 127.0.0.1:53847 │ │ │ + │ │ │ │ │ │ │ + │ │ │ │ openBrowser(authUrl) │ │ │ + │ │ │ │─────────────────────>│ │ │ + │ │ │ │ │ │ │ + │ User logs in via Microsoft ──────────────────────────────────────────────────────>│ │ │ + │ (credentials + MFA) │ │ │ │ │ + │ │ │ │ │ │ │ + │ │ │ │ GET /callback?code= │ │ │ + │ │ │ │<─────────────────────│ │ │ + │ │ │ │ Microsoft redirects │ │ │ + │ │ │ │ to localhost with │ │ │ + │ │ │ │ authorization code │ │ │ + │ │ │ │ │ │ │ + │ │ │ │ acquireTokenByCode()│ │ │ + │ │ │ │ exchange code + │ │ │ + │ │ │ │ PKCE verifier for │ │ │ + │ │ │ │ access + refresh │ │ │ + │ │ │ │ tokens │ │ │ + │ │ │ │ │ │ │ + │ │ │ │ closeServer() │ │ │ + │ │ │ │ destroy keep-alive │ │ │ + │ │ │ │ connections │ │ │ + │ │<──────────────────────────────────────────│ authResult │ │ │ + │ │ │ │ │ │ │ + │ │ validateTokenScopes(authResult) │ │ │ │ + │ │──────────────────────────────────────────────────────────────────────────────────────────>│ │ + │ │ │ │ │ │ │ + │ │ │ │ │ checkScopes(): │ │ + │ │ │ │ │ 1. MSAL scopes array│ │ + │ │ │ │ │ 2. decodeJwtPayload │ │ + │ │ │ │ │ → check scp claim│ │ + │ │ │ │ │ 3. Reject forbidden │ │ + │ │ │ │ │ Mail.ReadWrite. │ │ + │ │ │ │ │ All │ │ + │ │<─────────────────────────────────────────────────────────────────────────────────────────│ │ + │ │ │ │ │ │ │ + │ │ │ │ │ │ │ + │ │ MSAL triggers afterCacheAccess hook │ │ │ │ + │ │────────────────────────────────────────────────────────────────────────────────────────────────────────────────>│ + │ │ │ │ │ │ encrypt(cache) │ + │ │ │ │ │ │ AES-256-GCM │ + │ │ │ │ │ │ → cache-{alias} │ + │ │ │ │ │ │ .enc │ + │<───────────────│ "Login successful" │ │ │ │ │ +``` + +### Watch Cycle Flow (`outlook-cli watch`) + +``` + cli/watch.js watcher.js delta.js DeltaStore graph/client.js stdout/jsonl + │ │ │ │ │ │ + │ startWatcher() │ │ │ │ │ + │───────────────────>│ │ │ │ │ + │ │ createDeltaStore() │ │ │ │ + │ │───────────────────────>│ │ │ │ + │ │ │ new DeltaStore() │ │ │ + │ │ │─────────────────────>│ │ │ + │ │ │ │ Load delta-{alias} │ │ + │ │ │ │ .json from disk │ │ + │ │ │ │ (or empty on first │ │ + │ │ │ │ run) │ │ + │ │<───────────────────────│<─────────────────────│ │ │ + │ │ │ │ │ │ + │ │ ── INITIAL SYNC (suppressEvents=true) ── │ │ │ + │ │ │ │ │ │ + │ │ doMailSync() │ │ │ │ + │ │ executeDeltaQuery( │ │ │ │ + │ │ client,"mail:Inbox", │ │ │ │ + │ │ initialDeltaUrl, │ │ │ │ + │ │ store) │ │ │ │ + │ │───────────────────────>│ │ │ │ + │ │ │ store.getDeltaLink()│ │ │ + │ │ │─────────────────────>│ → null (first run) │ │ + │ │ │ │ │ │ + │ │ │ GET {userPath}/mailFolders/Inbox/ │ │ + │ │ │ messages/delta?$select=... │ │ + │ │ │────────────────────────────────────────────>│ │ + │ │ │ │ │ ── Graph API ── │ + │ │ │ │ │ returns all items │ + │ │ │<────────────────────────────────────────────│ │ + │ │ │ │ │ │ + │ │ │ Follow @odata.nextLink pages (max 50) │ │ + │ │ │ ... │ │ │ + │ │ │ │ │ │ + │ │ │ Store @odata.deltaLink │ │ + │ │ │─────────────────────>│ Persist to │ │ + │ │ │ │ delta-{alias}.json │ │ + │ │ │ │ │ │ + │ │<───────────────────────│ {changed,removed, │ │ │ + │ │ │ isInitialSync:true}│ │ │ + │ │ │ │ │ │ + │ │ (events suppressed — initial baseline) │ │ │ + │ │ │ │ │ │ + │ │ ══ POLL LOOP START (interval: 30s/5s) ═════════════════════════════════════════════════ │ + │ │ │ │ │ │ + │ │ interruptibleSleep() │ │ │ │ + │ │ (checks abort every 1s │ │ │ │ + │ │ for responsive Ctrl+C)│ │ │ │ + │ │ │ │ │ │ + │ │ doMailSync() │ │ │ │ + │ │ executeDeltaQuery() │ │ │ │ + │ │───────────────────────>│ │ │ │ + │ │ │ store.getDeltaLink()│ │ │ + │ │ │─────────────────────>│ → stored deltaLink │ │ + │ │ │ │ │ │ + │ │ │ GET deltaLink URL │ │ │ + │ │ │ (returns ONLY │ │ │ + │ │ │ changes since last)│ │ │ + │ │ │────────────────────────────────────────────>│ │ + │ │ │<────────────────────────────────────────────│ │ + │ │ │ │ │ │ + │ │ │ Store new deltaLink │ │ │ + │ │ │─────────────────────>│ Update delta- │ │ + │ │ │ │ {alias}.json │ │ + │ │<───────────────────────│ {changed:[msg1], │ │ │ + │ │ │ removed:[], │ │ │ + │ │ │ isInitialSync: │ │ │ + │ │ │ false} │ │ │ + │ │ │ │ │ │ + │ │ For each changed item:│ │ │ │ + │ │ emitEvent('mail.changed', msg1) │ │ │ + │ │──────────────────────────────────────────────────────────────────────────────────────────>│ + │ │ │ │ │ │ + │ │ │ │ │ text: "📧 New: │ + │ │ │ │ │ " │ + │ │ │ │ │ jsonl: {"type": │ + │ │ │ │ │ "mail.changed", │ + │ │ │ │ │ "data":{...}} │ + │ │ │ │ │ │ + │ │ (fast-poll: activity → reset to 5s interval) │ │ │ + │ │ (fast-poll: quiet → backoff ×1.5 to 60s) │ │ │ + │ │ │ │ │ │ + │ │ ── REPEAT POLL LOOP ── (until SIGINT/SIGTERM/AbortSignal) ───────────────────────────── │ + │ │ │ │ │ │ + │ │ On delta token expired (410 Gone): │ │ │ + │ │ store.clearDeltaLink() → full re-sync │ │ │ +``` + +## Command Tree + +Both implementations expose identical commands: + +``` +outlook-cli +├─ auth +│ ├─ login [--device-code] [--tenant] [--client-id] +│ ├─ logout +│ └─ status +├─ account +│ ├─ add --client-id [--tenant] +│ ├─ remove +│ ├─ list +│ └─ set-default +├─ mail +│ ├─ inbox [--top N] [--unread] [--folder] +│ ├─ read [--plain] [--html] [--raw] +│ ├─ search [--top N] +│ ├─ folders +│ ├─ draft --to --subject --body [--send] +│ ├─ send +│ ├─ reply --body [--all] +│ ├─ forward --to [--body] +│ ├─ move --folder +│ ├─ flag [--unflag] +│ └─ mark-read [--unread] +├─ calendar +│ ├─ today +│ ├─ week +│ ├─ range --start --end +│ ├─ view +│ ├─ list-calendars +│ └─ create --subject --start
--end
+├─ contacts +│ ├─ search +│ └─ alias set|remove|list +├─ watch +│ ├─ [--mode poll|fast-poll|webhook] +│ ├─ [--interval N] [--folder] [--no-mail] [--no-calendar] +│ ├─ [--jsonl] [--heartbeat] [--heartbeat-interval N] +│ ├─ [--tunnel cloudflared|ngrok|localtunnel] [--webhook-port N] +│ └─ [--config watches.json] [--validate] +├─ doctor +├─ log [--last N] [--clear] +└─ upgrade +``` + +Global options on every command: `--account`, `--as`, `--format`, `--json`, `--output`, `--verbose`, `--yes`. + +## Key Subsystems + +### Authentication + +- **MSAL** (Microsoft Authentication Library) handles OAuth2 PKCE flows +- Two auth methods: interactive browser (default) and device code (headless) +- Tokens are cached in encrypted files: `~/.outlook-cli/cache-{alias}.enc` +- Token refresh is automatic via MSAL's built-in refresh token handling +- On 401 from Graph API, the client clears the token cache and retries once + +### Encryption (Interoperable) + +Both runtimes use identical encryption: +- **Algorithm**: AES-256-GCM +- **Key derivation**: PBKDF2-SHA512, 310,000 iterations +- **Binary format**: `[salt:32][iv:12][authTag:16][ciphertext:*]` +- **Passphrase**: `OUTLOOK_CLI_PASSPHRASE` env var, or auto-derived from `outlook-cli:{user}@{hostname}:{alias}` + +A cache file encrypted by Node.js decrypts correctly in C# and vice versa. + +### Token Validation (Defense-in-Depth) + +`validateTokenScopes()` runs on **every token acquisition**: +1. Checks MSAL result's scopes array +2. Decodes the JWT `scp` claim (work/school accounts) +3. Rejects forbidden scopes (e.g., `Mail.ReadWrite.All`) +4. Personal Microsoft accounts return opaque (non-JWT) tokens — validator handles this gracefully + +### Watch Mode (Three Tiers) + +| Mode | Mechanism | Latency | Setup | +|---|---|---|---| +| `poll` | Delta queries at 30–60s interval | 30–60s | None | +| `fast-poll` | Adaptive delta queries (5s → 60s backoff) | 5–60s | None | +| `webhook` | Graph push notifications via tunnel | ~1–5s | cloudflared | + +Webhook mode architecture: +``` +Microsoft Graph ──POST──→ Tunnel (cloudflared) ──→ localhost:PORT ──→ outlook-cli +``` + +### Output Dispatch (Strategy Pattern) + +`render.js` dispatches to format-specific modules. Each module exports functions named after entity types (`mailList`, `mailDetail`, `eventList`, etc.): + +``` +output(data, 'mailList', options) + → resolves format (text/json/markdown/html) + → calls formatter.mailList(data) or markdown.mailList(data), etc. + → writes to stdout or --output file +``` + +### Config Resolution (Last Wins) + +1. Hardcoded defaults +2. Environment variables (`OUTLOOK_CLI_CLIENT_ID`, etc.) +3. `~/.outlook-cli/config.json` (intentionally overrides env vars) +4. Per-account settings in `accounts.json` +5. CLI flags (always win) + +### Delegate Access + +`GraphClient.userPath` returns `/me` for the authenticated user or `/users/{email}` when using the `--as` flag. All Graph API modules prepend `userPath` to endpoint paths, enabling shared/delegate mailbox access transparently. + +### Shared File Formats + +Both implementations read/write identical files in `~/.outlook-cli/`: + +| File | Format | Purpose | +|---|---|---| +| `accounts.json` | JSON | Account registry (alias → client ID, tenant, email) | +| `aliases.json` | JSON | Contact aliases (name → email) | +| `config.json` | JSON | Global configuration overrides | +| `cache-{alias}.enc` | Binary | AES-256-GCM encrypted MSAL token cache | +| `delta-{alias}.json` | JSON | Delta query sync tokens for watch mode | +| `outlook-cli.db` | SQLite | Operation logs (Node.js creates, C# reads) | + +## Module Reference — Node.js + +### `bin/outlook-cli.js` — Entry Point + +The CLI entry point. It imports `createProgram` from `src/node/cli/index.js` and `stampVersion` from `src/node/version.js`, then calls `program.parseAsync(process.argv)` to hand off to Commander.js. A top-level catch wraps the entire invocation, using `formatError()` from `src/node/errors.js` to produce user-friendly messages for any uncaught exception. This file has no dependents — it is invoked directly by Node.js via the shebang line (`#!/usr/bin/env node`). Nothing else imports it. + +### `src/node/cli/index.js` — Commander Program Setup + +Exports `createProgram()`, which instantiates the Commander.js `Command` object, attaches global options (`--account`, `--as`, `--format`, `--json`, `--output`, `--verbose`, `--yes`), and calls each `register*Commands(program)` function to wire subcommands: `registerMailCommands`, `registerCalendarCommands`, `registerAuthCommands`, `registerWatchCommands`, `registerAccountCommands`, `registerContactsCommands`, `registerDoctorCommands`, `registerLogCommands`, and `registerUpgradeCommands`. It also installs a global pre-action hook that runs startup migrations (e.g., schema upgrades). Depends on all `cli/*.js` subcommand modules. Depended on by `bin/outlook-cli.js`. + +### `src/node/cli/mail.js` — Mail Command Handlers + +Exports `registerMailCommands(program)`. Registers all mail subcommands: `inbox`, `read`, `search`, `draft`, `send`, `reply`, `forward`, `move`, `flag`, `mark-read`, and `folders`. Each action handler resolves the account via `resolveAccount()`, creates a `GraphClient` via `createGraphClient()`, calls the appropriate function from `graph/mail.js` (e.g., `listMessages`, `getMessage`, `searchMessages`), and passes the result to `output()` from `output/render.js`. Write operations (`draft --send`, `send`, `reply`, `forward`, `move`) require confirmation prompts unless `--yes` is set. Recipient addresses are resolved through `resolveAlias()` from `contacts/aliases.js`. The `--input` flag enables JSON parameter files, with CLI flags always overriding JSON values. + +### `src/node/cli/calendar.js` — Calendar Command Handlers + +Exports `registerCalendarCommands(program)`. Registers `today`, `week`, `range`, `view`, `list-calendars`, and `create` subcommands. Date ranges are computed relative to the user's local timezone. The `create` subcommand resolves attendee email addresses via alias lookup, requires confirmation, and supports `--input` for JSON parameter files. All commands use `createGraphClient()` for Graph API access and `output()` for rendering. Depends on `graph/calendar.js` for `listEvents`, `getEvent`, `listCalendars`, and `createEvent`. + +### `src/node/cli/auth.js` — Auth Command Handlers + +Exports `registerAuthCommands(program)`. Registers `login`, `logout`, and `status` subcommands. The `login` handler selects between `interactiveLogin()` and `deviceCodeLogin()` from `auth/auth-flows.js` based on the `--device-code` flag. After login, it calls `validateTokenScopes()` to enforce the forbidden-scope policy. Implements authority auto-retry: if MSAL returns an AADSTS tenant-mismatch error, it automatically retries with the corrected authority (`/consumers` or `/organizations`). The `status` command displays cached account info and token expiry without hitting the network. Depends on `auth/msal-client.js`, `auth/auth-flows.js`, `security/token-validator.js`, and `accounts/manager.js`. + +### `src/node/cli/watch.js` — Watch Command Handler + +Exports `registerWatchCommands(program)`. Registers the `watch` command with mode (`poll`, `fast-poll`, `webhook`), interval, folder, JSONL, heartbeat, tunnel, and config options. Routes to different watch implementations: `startWatcher()` from `watch/watcher.js` for poll/fast-poll, `startWebhookWatcher()` from `watch/webhook-watcher.js` for webhook mode, or `startWatchManager()` from `watch/manager.js` for config-driven multi-job mode. Validates minimum intervals (30s for poll, 5s for fast-poll) before starting. The `--validate` flag parses and validates a watch config file without starting a watch. Depends on `graph/client.js`, `watch/watcher.js`, `watch/webhook-watcher.js`, `watch/manager.js`, and `watch/config.js`. + +### `src/node/graph/client.js` — GraphClient + +Exports `createGraphClient(accountAlias, accountConfig, options)` factory function and the `GraphClient` class. The client manages the full HTTP lifecycle for Microsoft Graph API calls: token acquisition via `acquireTokenSilently()` from `msal-client.js`, automatic token refresh with a 60-second safety buffer before expiry, and defense-in-depth scope validation on every token via `validateTokenScopes()`. Provides `get()`, `post()`, `patch()`, and `delete()` methods. Implements retry logic: 401 responses trigger token cache clear + single retry; 429 responses wait for the `Retry-After` header then retry. Supports pagination via `getAll()` which follows `@odata.nextLink`. The `userPath` property returns `/me` or `/users/{email}` for delegate access. Depended on by every command handler and all `graph/*.js` API modules. + +### `src/node/graph/mail.js` — Mail Graph API Calls + +Exports `listMessages()`, `getMessage()`, `searchMessages()`, `listFolders()`, `createDraft()`, `createReplyDraft()`, `createForwardDraft()`, `moveMessage()`, `flagMessage()`, `markRead()`, and `sendDraft()`. Each function is a thin wrapper that constructs the Microsoft Graph URL with OData query parameters (`$top`, `$select`, `$filter`, `$search`, `$orderby`) and calls `client.get()`, `client.post()`, or `client.patch()`. Draft creation and send are deliberately separate API calls to use minimal permissions — `Mail.ReadWrite` for drafts, `Mail.Send` for sending. All endpoints prepend `client.userPath` for delegate mailbox support. Has no internal dependencies beyond the `GraphClient` instance passed as the first argument. + +### `src/node/graph/delta.js` — Delta Query Engine + +Exports `executeDeltaQuery()`, `buildMailDeltaUrl()`, `buildCalendarDeltaUrl()`, and `createDeltaStore()`. The `executeDeltaQuery(client, resourceKey, initialDeltaUrl, store)` function drives the Microsoft Graph delta query protocol: it checks the `DeltaStore` for a saved `@odata.deltaLink`, pages through results via `@odata.nextLink` (max 50 pages), stores the final `@odata.deltaLink`, and classifies items into `changed` and `removed` arrays based on the `@removed` property. Delta token expiry (HTTP 410 Gone, 404, or `resyncRequired` error code) triggers automatic fallback to a full sync. The `DeltaStore` class (internal) persists delta tokens to `~/.outlook-cli/delta-{alias}.json` and lazy-loads on first access. `buildMailDeltaUrl()` selects key fields (id, subject, from, receivedDateTime, etc.) and `buildCalendarDeltaUrl()` uses a rolling 30-day window. Depended on by `watch/watcher.js`. + +### `src/node/auth/msal-client.js` — MSAL Factory + +Exports `createMsalClient()`, `getScopes()`, `getRedirectUri()`, `getRedirectPort()`, and `acquireTokenSilently()`. The `createMsalClient(accountAlias, accountConfig)` factory constructs an MSAL `PublicClientApplication` with the resolved client ID, authority, and an encrypted cache plugin from `token-cache.js`. `acquireTokenSilently()` attempts silent token renewal via refresh tokens, with error classification for re-auth scenarios (interaction required, MFA changed, password expired). `getScopes()` returns the configured permission scopes. `getRedirectUri()` returns `http://127.0.0.1:53847/callback` for the PKCE loopback flow. Depended on by `graph/client.js` and `cli/auth.js`. + +### `src/node/auth/token-cache.js` — Encrypted Cache Plugin + +Exports `createCachePlugin(accountAlias)`. Returns an MSAL `ICachePlugin` implementation with `beforeCacheAccess` and `afterCacheAccess` hooks. Before access, it reads `~/.outlook-cli/cache-{alias}.enc`, calls `decrypt()` from `security/crypto.js`, and deserializes the token cache into MSAL's in-memory store. After access, it serializes the updated cache, calls `encrypt()`, and writes the binary result back to disk. Decryption failures (wrong passphrase, corrupt file) degrade gracefully: a warning is logged and the cache starts empty, requiring the user to re-authenticate. Depends on `security/crypto.js` and `accounts/manager.js`. Depended on by `auth/msal-client.js`. + +### `src/node/security/crypto.js` — AES-256-GCM Encryption + +Exports `encrypt()`, `decrypt()`, and `getPassphrase()`. Implements AES-256-GCM authenticated encryption with PBKDF2-SHA512 key derivation (310,000 iterations per OWASP guidelines). Each `encrypt()` call generates a fresh random 32-byte salt and 12-byte IV. The binary format is `[salt:32][iv:12][authTag:16][ciphertext:*]`. `getPassphrase()` reads `OUTLOOK_CLI_PASSPHRASE` from the environment for CI/automation, or auto-derives a machine-specific passphrase from `outlook-cli:{username}@{hostname}:{alias}` for zero-config local use. Uses only the Node.js built-in `crypto` module — no external dependencies. Depended on by `auth/token-cache.js`. + +### `src/node/security/token-validator.js` — Scope Checking + +Exports `validateTokenScopes()`, `checkScopes()`, and `decodeJwtPayload()`. `validateTokenScopes(authResult)` is called on every token acquisition as defense-in-depth. It performs a two-layer check: (1) inspects the MSAL result's `scopes` array for forbidden scopes, and (2) decodes the JWT `scp` claim from the access token to cross-check granted permissions. `Mail.ReadWrite.All` (application-level access to all mailboxes) is always rejected. `Mail.Send` and `Mail.Send.Shared` are explicitly allowed. Personal Microsoft accounts return opaque (non-JWT) tokens — the validator detects this and falls back to MSAL scopes-array validation only. Has no external dependencies — pure validation logic. Depended on by `graph/client.js` and `cli/auth.js`. + +### `src/node/output/render.js` — Output Strategy Dispatcher + +Exports `output()`, `render()`, `writeOutput()`, and `resolveFormat()`. The `output(data, entityType, options)` function is the main entry point for all CLI output. It resolves the format from `--format`, `--json`, or defaults to `text`, then dispatches to the appropriate renderer module: `formatter.js` (text), `markdown.js` (markdown), or `html.js` (HTML). JSON format bypasses renderers entirely and serializes the raw data. Each renderer module exports functions named after entity types (`mailList`, `mailDetail`, `eventList`, `eventDetail`, `folderList`, `contactList`, etc.). `writeOutput()` writes to stdout by default or to the file specified by `--output`. Depended on by all `cli/*.js` command handlers. + +### `src/node/output/formatter.js` — Text Table Formatter + +Exports `mailList()`, `mailDetail()`, `folderList()`, `eventList()`, `eventDetail()`, `calendarList()`, `contactList()`, and `generic()`, plus backward-compatible `format*` aliases. Each function takes a data array or object and returns a formatted ASCII table string. Implements smart date formatting (time-only for today's messages, full date for older ones), column truncation with ellipsis for terminal-width friendliness, and human-readable field summaries (e.g., flag status, read/unread indicators). All functions return strings for compatibility with the render.js dispatcher pattern. Has no external dependencies. + +### `src/node/db/database.js` — SQLite Operations Database + +Exports `getDatabase()`, `closeDatabase()`, `getDatabaseAt()`, and `CURRENT_SCHEMA_VERSION`. Manages the SQLite database at `~/.outlook-cli/outlook-cli.db` using the `better-sqlite3` driver in WAL mode for concurrent read/write access. Implements schema versioning with migration support — checks the stored version and applies incremental migrations to reach `CURRENT_SCHEMA_VERSION`. The database stores operation logs (via `db/logger.js`), delta state tracking, and cache metadata. Forward-compatibility is protected: if the on-disk schema version exceeds the running code's version, the database refuses to open. Depended on by `db/logger.js` and `cli/log.js`. + +### `src/node/config.js` — Config Resolution + +Exports `loadConfig()`. Implements the three-layer config resolution strategy: (1) hardcoded defaults for all settings, (2) environment variables (`OUTLOOK_CLI_CLIENT_ID`, `OUTLOOK_CLI_TENANT`, etc.), (3) `~/.outlook-cli/config.json` file. Config file values intentionally override environment variables via `Object.assign` ordering — this is by design, so persistent user preferences in the file take precedence over transient env vars. Per-account settings in `accounts.json` and CLI flags are applied at higher layers by the command handlers. Depends on `fs` and `path` only. Depended on by `accounts/manager.js` and `graph/client.js`. + +## Module Patterns — Node.js + +- **ESM only** (`"type": "module"`). All imports use `.js` extensions. +- **Named exports only** — no `export default`. +- **Factory functions** for stateful objects: `createGraphClient()`, `createMsalClient()`. +- **Singleton with reset**: `AccountManager` uses `getAccountManager()` / `resetAccountManager()`. + +## Module Patterns — C# + +- **NativeAOT compatible** — no reflection-based serialization. +- **Source-generated JSON**: `OutlookCliJsonContext` registers all serializable types. +- **All commands in Program.cs** — single-file command registration using System.CommandLine 2.0.5 (stable). +- **Service classes** (MailService, CalendarService) are static method collections. +- **GraphClient** accepts pre-serialized `string? bodyJson` (avoids reflection). + +## How to Trace a Bug + +### "mail inbox returns no results" + +1. **Check `cli/mail.js` option parsing.** Is `--folder` set to a non-default folder? Commander.js may silently accept a misspelled folder name like `--folder "Inbx"` without error. Add `--verbose` to see the resolved options object. Verify `--top` is not set to `0`. + +2. **Check `graph/client.js` token acquisition.** Run with `--verbose` to see whether `acquireTokenSilently()` succeeded. If the token is expired and refresh fails, the client clears the cache and retries once — but if re-auth is required (interaction_required), it throws. Check stderr for auth errors. Verify the account alias resolves correctly via `resolveAccount()`. + +3. **Check `graph/mail.js` URL construction.** With `--verbose`, inspect the actual Graph API URL being requested. Key things to verify: (a) `client.userPath` is `/me` for own account or `/users/{email}` for delegate, (b) the folder name is URL-encoded correctly, (c) `$top`, `$select`, and `$filter` parameters are present. A common issue: the `--unread` flag adds `$filter=isRead eq false` which returns empty if all messages are read. + +4. **Check `output/render.js` rendering.** If the Graph API returned data but the screen is blank, the issue is in the output layer. Verify by adding `--json` to see raw data. If JSON shows messages but text format is empty, check that `formatter.mailList()` handles the response shape (it expects `value` array from Graph API). Also check `--output` — if set, output goes to a file, not stdout. + +### "auth login hangs" + +1. **Check `cli/auth.js` flow selection.** Which flow was selected? Without `--device-code`, it defaults to `interactiveLogin()`. With `--device-code`, it uses `deviceCodeLogin()`. Add `--verbose` to confirm. If using device code, check the pre-flight diagnostic output — it hits the `/devicecode` endpoint directly to catch misconfiguration before MSAL's own flow starts. + +2. **Check `msal-client.js` initialization.** Did `createMsalClient()` complete? If the account alias doesn't exist in `accounts.json`, or if the client ID is invalid, MSAL construction itself can fail. Check that `createCachePlugin()` in `token-cache.js` didn't hang on disk I/O (e.g., locked `cache-{alias}.enc` file). + +3. **Check `auth-flows.js` server startup.** For interactive login, `interactiveLogin()` starts an HTTP server on `127.0.0.1:53847`. Common hang causes: (a) port 53847 is already in use by another process or a previous interrupted login, (b) the browser failed to open — `openBrowser()` uses platform-specific commands (`start` on Windows, `open` on macOS, `xdg-open` on Linux) and fails silently if none work, (c) the Microsoft login page redirects to the wrong URL. Check if the server is listening: `curl http://127.0.0.1:53847/` should respond. + +4. **Check server cleanup.** After the auth code is received, `closeServer(server)` must destroy keep-alive connections. On Node.js 18.2+, it uses `server.closeAllConnections()`. On older versions, lingering keep-alive connections prevent the server from closing, which keeps the process alive indefinitely. The 5-minute safety timeout in `interactiveLogin()` will eventually fire, but the process appears hung until then. + +### "watch mode misses events" + +1. **Check `watcher.js` poll interval.** Is the interval too long for the expected responsiveness? Default poll mode uses 30–60 second intervals. If you need sub-30-second detection, use `--mode fast-poll` which starts at 5 seconds and backs off to 60 seconds when quiet. Verify the actual interval with `--verbose` output. + +2. **Check `delta.js` delta token state.** Is the delta token stale or missing? If `executeDeltaQuery()` receives a 410 Gone or `resyncRequired` error, it clears the token and does a full re-sync. The first sync after token expiry is `isInitialSync=true`, which means events are suppressed (this is by design — to avoid flooding). If events were created and deleted between polls, they won't appear in the delta response at all. + +3. **Check `delta-{alias}.json` persistence.** Open `~/.outlook-cli/delta-{alias}.json` and verify the resource key exists (e.g., `"mail:Inbox"`). If the file is empty `{}` or missing, every poll cycle starts a full sync and suppresses events. Check file permissions — if the file is read-only or the directory is not writable, `DeltaStore.setDeltaLink()` will silently fail to persist, causing repeated full syncs. + +4. **Check event emission vs. rendering.** Events may be emitted by `emitEvent()` in `watcher.js` but not visible. In text mode, events print to stdout with emoji prefixes (📧). In JSONL mode, each event is a single JSON line. If stdout is being piped or buffered, events may not appear immediately. Try `--heartbeat` to verify the output stream is flowing — heartbeat events (`sync.status` type) print at regular intervals regardless of mail activity. Also verify `--no-mail` or `--no-calendar` flags aren't accidentally suppressing the events you expect. + +### "C# and Node.js produce different output" + +1. **Compare `--json` output from both implementations.** Run the same command with `--json` flag using both the Node.js and C# binaries. The JSON output should be identical since both forward the raw Microsoft Graph API response. If JSON differs, the issue is in Graph API request construction — compare the URL, `$select` fields, and `$orderby` parameters between `graph/mail.js` (Node.js) and `Graph/MailService.cs` (C#). + +2. **Check `OutputFormatter.cs` vs `formatter.js` for the entity type.** Each entity type has a dedicated rendering function in both implementations. Compare `OutputFormatter.FormatMailList()` in C# with `mailList()` in `formatter.js`. Common divergences: column widths, truncation thresholds, and the set of fields displayed. The Node.js formatter uses dynamic terminal width; the C# formatter may use a fixed width. + +3. **Check date formatting.** Date rendering is a frequent source of divergence. Node.js uses JavaScript's `Date` object and Intl formatters. C# uses `DateTime` and `DateTimeOffset` with format strings. Timezone handling differs: Node.js may show local timezone by default while C# may show UTC unless explicitly converted. Compare the raw `receivedDateTime` from `--json` output with what each formatter displays. + +4. **Check field name mapping in Graph response parsing.** Microsoft Graph returns camelCase JSON properties (`receivedDateTime`, `bodyPreview`, `isRead`). Node.js accesses these directly as JavaScript object properties. C# deserializes via source-generated JSON in `OutlookCliJsonContext`, which maps to PascalCase C# properties. If a new Graph API field was added to the Node.js implementation but not to the C# `JsonSerializable` types, it will be silently dropped during deserialization. Check that both implementations select the same `$select` fields in their Graph API requests. + +### Quick Reference: Symptom → Layer + +| Symptom | Likely Layer | Start Here | +|---|---|---| +| "Error: messageId is required" | CLI parsing | `src/node/cli/mail.js` or `Program.cs` — option/argument wiring | +| "Network error: ..." | HTTP client | `src/node/graph/client.js` or `GraphClient.cs` — fetch/HttpClient error handling | +| "No valid token for account X" | Authentication | `src/node/auth/msal-client.js` — `acquireTokenSilently()` returns null | +| "Forbidden scope detected" | Token validation | `src/node/security/token-validator.js` — scope whitelist | +| Wrong data in output | Graph API or formatter | Compare raw `--json` output vs formatted output | +| Watch mode misses events | Delta queries | `src/node/graph/delta.js` — delta token handling | +| C# works, Node.js doesn't (or vice versa) | Implementation gap | Compare the specific command handler in both | + +### General Debugging Tools + +```bash +# See what the CLI is doing internally +outlook-cli mail inbox --account test --verbose + +# See raw Graph API response (bypass formatter) +outlook-cli mail inbox --account test --json + +# Recent operations +outlook-cli log --last 20 + +# Correlation trace (if an operation ID is shown in error) +outlook-cli log search --correlation-id abc123 +``` + +If a bug appears in one runtime but not the other, the issue is in the implementation layer (not the architecture). Check the mapping table in `agents/MAKING-CHANGES.md` to find the corresponding files. diff --git a/agents/BUILDING.md b/agents/BUILDING.md new file mode 100644 index 0000000..a004710 --- /dev/null +++ b/agents/BUILDING.md @@ -0,0 +1,517 @@ +# Building & Testing — outlook-cli + +## Quick Reference + +```bash +# ── Node.js ───────────────────────────────────────────────────────────── +npm install # Install dependencies (first time) +npm test # Run all vitest tests (~750 tests) +npm run test:verbose # Same, with detailed output +npm run test:coverage # With coverage report + +# Run specific tests +npx vitest run test/node/security/crypto.test.js # Single file +npx vitest run -t "should encrypt" # By test name pattern +npx vitest run test/node/graph/ # All tests in a directory + +# ── C# (.NET 8) ───────────────────────────────────────────────────────── +dotnet build src\dotnet\OutlookCli.csproj # Build +dotnet run --project src\dotnet\OutlookCli.csproj -- --help # Run from source + +# ── NativeAOT Binary ──────────────────────────────────────────────────── +dotnet publish src\dotnet\OutlookCli.csproj -r win-x64 --self-contained /p:PublishAot=true -o publish\win-x64 +publish\win-x64\outlook-cli.exe --version # Verify + +# ── Unified Build Script ──────────────────────────────────────────────── +.\build.ps1 all # Build + test both +.\build.ps1 all -Publish # Build + test + publish NativeAOT +.\build.ps1 node -TestOnly # Just run Node.js tests +.\build.ps1 dotnet -Publish -Runtime all # Publish all 4 platforms +``` + +## Expected Output and Timing + +This section describes what success looks like for each build command so you can quickly identify if something went wrong. + +### `npm install` + +**Typical time**: ~10–15 seconds (faster with warm cache via `--prefer-offline`). + +``` +added 47 packages, and audited 48 packages in 12s +``` + +If you see `npm warn` about peer dependencies, those are generally safe to ignore. A non-zero exit code or `npm ERR!` lines indicate a real failure. + +### `npm test` + +**Typical time**: ~26 seconds for all ~750 tests across all suites. + +Vitest runs all test files under `test/` in parallel. Successful output ends with a summary block like this: + +``` + ✓ test/node/security/crypto.test.js (18 tests) 312ms + ✓ test/node/graph/client.test.js (45 tests) 198ms + ✓ test/node/graph/mail.test.js (67 tests) 156ms + ✓ test/dotnet/native-binary.test.js (42 tests) 1204ms + ✓ test/shared/cli.test.js (38 tests) 890ms + ... (many more suites) + + Test Files 28 passed (28) + Tests 753 passed (753) + Start at 10:15:32 + Duration 25.84s (transform 312ms, setup 45ms, collect 1.2s, tests 22.1s, environment 0ms, prepare 890ms) +``` + +Key things to check: +- **"0 failed"** — any failed test means something is broken +- **All test files listed** — if a file is missing, vitest may have crashed during collection +- **NativeAOT binary tests** (`test/dotnet/native-binary.test.js`) — these require the published binary to exist in `publish\win-x64\`; if the binary is missing, these tests fail with `ENOENT` + +### `dotnet build src\dotnet` + +**Typical time**: ~5 seconds (incremental), ~15 seconds (clean build). + +``` + OutlookCli -> O:\Sources\GitHub\jeffstall\outlook-cli\src\dotnet\bin\Release\net8.0\OutlookCli.dll + +Build succeeded. + 0 Warning(s) + 0 Error(s) +``` + +Any trim or AOT analysis warnings during build should be investigated — the project treats these seriously because they indicate potential NativeAOT runtime failures. + +### `dotnet publish` (NativeAOT) + +**Typical time**: ~30 seconds. The ILC (IL Compiler) phase takes the longest (~20 seconds) as it performs whole-program analysis and native code generation. + +``` + OutlookCli -> O:\Sources\GitHub\jeffstall\outlook-cli\src\dotnet\bin\Release\net8.0\win-x64\publish\ + Generating native code + OutlookCli -> O:\Sources\GitHub\jeffstall\outlook-cli\publish\win-x64\outlook-cli.exe +``` + +**Final binary size**: ~10 MB. This is a fully self-contained executable with no .NET runtime dependency. If the binary is significantly larger (>15 MB) or smaller (<5 MB), something has likely changed in the dependency tree. + +The ILC phase output looks like: + +``` + Compiling dependency graph + Generating code for 2,847 methods + Emitting R2R methods + Generating native code +``` + +If ILC fails, it usually means a NativeAOT-incompatible pattern was introduced (reflection, dynamic code generation, etc.). + +### `.\build.ps1 all` + +Runs the full build and test pipeline for both implementations. Steps executed in order: + +1. **Node.js: Install dependencies** — runs `npm install --prefer-offline --no-audit` +2. **Node.js: Run tests** — runs `npx vitest run` (all ~750 tests) +3. **.NET: Build (Release)** — runs `dotnet build` in Release configuration +4. **.NET: Run tests** — runs `dotnet test` if a .NET test project exists + +**Typical time**: ~45 seconds total. Ends with a summary table: + +``` +═══════════════════════════════════════════════════ + Build Summary +═══════════════════════════════════════════════════ + [PASS] Node.js install + [PASS] Node.js tests + [PASS] .NET build + [PASS] .NET tests + + ✅ All steps passed +``` + +### `.\build.ps1 all -Publish` + +Same as above, plus NativeAOT publishing. Steps executed in order: + +1. **Node.js: Install dependencies** +2. **Node.js: Run tests** +3. **.NET: Build (Release)** +4. **.NET: Run tests** +5. **.NET: Publish NativeAOT** — runs `dotnet publish` with NativeAOT for the current platform (or all platforms with `-Runtime all`) + +**Typical time**: ~60–90 seconds total (the NativeAOT publish step adds ~30s per target platform). + +The summary table includes binary sizes: + +``` +═══════════════════════════════════════════════════ + Build Summary +═══════════════════════════════════════════════════ + [PASS] Node.js install + [PASS] Node.js tests + [PASS] .NET build + [PASS] .NET tests + [PASS] Publish win-x64 — 10.2 MB + + ✅ All steps passed +``` + +With `-Runtime all`, you'll see four publish lines (one per platform). + +## What Gets Built + +### Node.js + +No build step. Node.js runs ESM modules directly from `src/node/`. The entry point is `bin/outlook-cli.js`, which imports `src/node/cli/index.js`. + +### C# NativeAOT + +The C# project compiles to a self-contained native binary via .NET 8 NativeAOT: + +| Target | Binary | Size | +|---|---|---| +| `win-x64` | `publish/win-x64/outlook-cli.exe` | ~10 MB | +| `win-arm64` | `publish/win-arm64/outlook-cli.exe` | ~10 MB | +| `osx-arm64` | `publish/osx-arm64/outlook-cli` | ~10 MB | +| `linux-x64` | `publish/linux-x64/outlook-cli` | ~10 MB | + +**Critical**: Always republish NativeAOT binaries after any C# code changes. The published binary is a standalone executable — it does not use `dotnet run`. If you change C# source and forget to republish, the binary test suite runs against stale code. + +```powershell +# After ANY change to src/dotnet/**: +dotnet publish src\dotnet\OutlookCli.csproj -r win-x64 --self-contained /p:PublishAot=true -o publish\win-x64 +``` + +## Test Suites + +### Test Directory Structure + +``` +test/ +├── node/ # Unit tests for Node.js modules +│ ├── accounts/ # AccountManager tests +│ ├── auth/ # Token cache tests +│ ├── db/ # SQLite logger tests +│ ├── graph/ # GraphClient, mail, calendar, contacts, delta +│ ├── output/ # Formatter, render, last-results +│ ├── security/ # Crypto, permissions, token-validator +│ ├── telemetry/ # Collector tests +│ └── watch/ # Watcher, webhook, subscription, rules, fast-poll +├── dotnet/ +│ └── native-binary.test.js # NativeAOT binary feature parity tests +├── shared/ +│ └── cli.test.js # CLI integration tests (both runtimes) +├── e2e/ # End-to-end tests (require real auth) +│ ├── helpers.js +│ ├── mail-lifecycle.test.js +│ ├── mail-send.test.js +│ └── soak/ # Long-running real API tests +└── stress/ # Scale, performance, integrity tests + ├── scale/ # Large data sets + ├── performance/ # Memory, throughput + ├── integrity/ # Data fidelity, SQLite + └── soak/ # Long-running stability +``` + +### What Tests Run by Default + +`npm test` runs ALL test files in `test/` via vitest. This includes: +- ~700+ Node.js unit tests (fast, fully offline, mocked dependencies) +- NativeAOT binary tests (requires `publish/win-x64/outlook-cli.exe`) +- CLI integration tests +- Stress/scale tests (run against mocks, not real APIs) + +E2E tests that hit real Microsoft Graph APIs are gated behind `OUTLOOK_CLI_E2E=1`. + +### Test Categories and When to Run + +| Category | Command | When | +|---|---|---| +| All tests | `npm test` | Before every commit | +| Node.js unit | `npx vitest run test/node/` | During Node.js development | +| C# binary | `npx vitest run test/dotnet/` | After C# changes + republish | +| CLI integration | `npx vitest run test/shared/` | After command interface changes | +| Single module | `npx vitest run test/node/graph/client.test.js` | While working on a specific module | +| By name | `npx vitest run -t "should handle 429"` | While debugging a specific behavior | +| E2E (real API) | `OUTLOOK_CLI_E2E=1 npm test` | Final validation with real account | + +## NativeAOT Considerations + +NativeAOT compiles C# code ahead-of-time to a native executable. This eliminates the .NET runtime dependency but imposes several constraints that regularly catch developers off guard. + +### SQLite Provider + +NativeAOT requires explicit SQLite provider setup because the automatic provider discovery that works in normal .NET relies on reflection, which is stripped by the AOT compiler. The project uses: +- `SQLitePCLRaw.provider.e_sqlite3` (NOT `bundle_e_sqlite3` — the bundle uses reflection internally) +- `SQLitePCLRaw.lib.e_sqlite3` (the native SQLite library) +- `SQLitePCLRaw.core` + +Code must call `raw.SetProvider(new SQLite3Provider_e_sqlite3())` before any database access (see `OperationsDb.InitProvider()`). Without this call, you'll get a runtime exception: "You need to call SQLitePCL.raw.SetProvider()." + +### JSON Serialization + +NativeAOT forbids reflection-based JSON serialization (`JsonSerializerIsReflectionEnabledByDefault=false` in `.csproj`). All types must be registered in `OutlookCliJsonContext.cs`: + +```csharp +[JsonSerializable(typeof(MyNewType))] +[JsonSerializable(typeof(List))] // Don't forget collection types! +internal partial class OutlookCliJsonContext : JsonSerializerContext { } +``` + +**Common mistake**: Forgetting to register a type. The code compiles fine, `dotnet run` works fine (it uses reflection as fallback), but the published NativeAOT binary crashes at runtime with `InvalidOperationException: Metadata for type 'MyNewType' was not provided by JsonSerializerContext`. Always test with the published binary, not `dotnet run`. + +### NativeAOT Troubleshooting Quick Reference + +| Symptom | Cause | Fix | +|---|---|---| +| `Missing metadata for type X` | Type not in JsonContext | Add `[JsonSerializable(typeof(X))]` to `OutlookCliJsonContext.cs` | +| `You need to call SetProvider()` | SQLite provider not initialized | Ensure `raw.SetProvider()` runs before any DB access | +| Trim warnings during publish | Assembly uses reflection | Add `` in `.csproj` | +| Binary works on dev machine, crashes elsewhere | Missing native libs | Ensure `win-x64` matches target; check VC++ runtime | +| `dotnet run` works but binary doesn't | Reflection-dependent code | NativeAOT strips reflection — use source generators | + +## NativeAOT Troubleshooting — Detailed + +This section expands on the quick reference table above with full symptoms, root causes, and step-by-step fixes for common NativeAOT issues in outlook-cli. + +### "Missing metadata" runtime error + +**Symptom**: +``` +System.InvalidOperationException: Metadata for type 'OutlookCli.Models.CalendarEventResponse' was not found + in the source-generated JsonSerializerContext. Ensure that the type is registered via + [JsonSerializable(typeof(CalendarEventResponse))] on OutlookCliJsonContext. +``` + +This crash happens **only in the published NativeAOT binary** — `dotnet run` works fine because it falls back to reflection-based serialization. + +**Cause**: A type is being serialized or deserialized by `System.Text.Json` without being registered in the source-generated `OutlookCliJsonContext.cs`. NativeAOT disables reflection-based serialization entirely (`JsonSerializerIsReflectionEnabledByDefault=false` in the `.csproj`), so every type that passes through `JsonSerializer` must have a source-generated serializer. + +**Fix**: Add `[JsonSerializable]` attributes to `OutlookCliJsonContext.cs`: + +```csharp +// In src/dotnet/Json/OutlookCliJsonContext.cs +[JsonSerializable(typeof(CalendarEventResponse))] +[JsonSerializable(typeof(List))] // Collection types too! +[JsonSerializable(typeof(GraphApiResponse))] // Generic wrappers! +internal partial class OutlookCliJsonContext : JsonSerializerContext { } +``` + +**Prevention**: After adding any new DTO or model class, always: +1. Add the `[JsonSerializable]` attribute for the type and any collection/wrapper types +2. Publish the NativeAOT binary: `dotnet publish src\dotnet\OutlookCli.csproj -r win-x64 --self-contained /p:PublishAot=true -o publish\win-x64` +3. Run the binary tests: `npx vitest run test/dotnet/native-binary.test.js` + +### "SQLite provider not found" + +**Symptom**: +``` +System.TypeInitializationException: The type initializer for 'SQLitePCL.Batteries_V2' threw an exception. + ---> System.InvalidOperationException: This is the 'bait' assembly... You need to call + SQLitePCL.raw.SetProvider(). +``` + +This crash occurs when the NativeAOT binary tries to open a database connection without explicit provider initialization. + +**Cause**: In standard .NET, `SQLitePCL.Batteries_V2.Init()` uses reflection to discover and load the appropriate SQLite provider. NativeAOT strips this reflection-based discovery, so the provider must be set explicitly. + +**Fix**: Ensure `raw.SetProvider(new SQLite3Provider_e_sqlite3())` is called before any database access. In outlook-cli, this is handled in `OperationsDb.InitProvider()`: + +```csharp +// Must be called at program startup, before any DbConnection is opened +SQLitePCL.raw.SetProvider(new SQLitePCL.SQLite3Provider_e_sqlite3()); +``` + +**Important**: This call must happen at program startup, before any `DbConnection` is opened. If it runs too late (e.g., lazily when the first database operation occurs), other code paths may have already triggered the type initializer and cached the failure. + +### "Trim warnings" during publish + +**Symptom**: Warnings during `dotnet publish` like: +``` +warning IL2026: Member 'System.Text.Json.JsonSerializer.Serialize(T)' requires unreferenced code. + Using member 'MyType.GetProperties()' which has 'RequiresUnreferencedCodeAttribute'. +warning IL2072: 'type' argument does not satisfy 'DynamicallyAccessedMemberTypes.PublicConstructors' +``` + +**Cause**: NativeAOT's tree-shaking (trimming) analyzes code paths and removes code it determines is unused. When it encounters reflection-based patterns, it emits warnings because it cannot guarantee the reflected-over types will be preserved. + +**Fix**: Depending on the warning: +- For JSON serialization warnings: Use the source-generated `OutlookCliJsonContext` (see above) instead of calling `JsonSerializer.Serialize()` directly +- For reflection on specific types: Add `[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)]` to the relevant parameter or property +- For entire assemblies that must be preserved: Add to the `.csproj`: + +```xml + + + +``` + +**Note**: The outlook-cli project pins trim warnings as errors (`false`) to catch these at build time rather than discovering them as runtime crashes. If you see trim warnings, they **must** be resolved before the code can be published. + +### "JsonSerializer returns empty object" + +**Symptom**: JSON output is `{}` or `[]` instead of the expected data. No exception is thrown — the serialization silently produces empty output. + +**Cause**: Calling `JsonSerializer.Serialize(obj)` (without specifying the source-generated context) in a NativeAOT binary. Because reflection is disabled, the serializer has no metadata about the object's properties and produces an empty result. + +**Fix**: Always use the source-generated context overload: + +```csharp +// ❌ WRONG — produces {} in NativeAOT +var json = JsonSerializer.Serialize(mailMessage); + +// ✅ CORRECT — uses source-generated serializer +var json = JsonSerializer.Serialize(mailMessage, OutlookCliJsonContext.Default.MailMessage); + +// ✅ CORRECT — for generic/collection types +var json = JsonSerializer.Serialize(messages, OutlookCliJsonContext.Default.ListMailMessage); +``` + +This issue is particularly insidious because: +- No exception is thrown — the output just silently loses all data +- `dotnet run` works correctly (reflection fills in the metadata) +- Only the published NativeAOT binary is affected +- Tests using `dotnet run` will pass even when the code is wrong + +Always verify serialization output with the published binary, not `dotnet run`. + +## build.ps1 Walkthrough + +The unified build script (`build.ps1`) orchestrates the complete build, test, and publish pipeline for outlook-cli. It requires PowerShell 7+ (`#Requires -Version 7.0`). + +### Parameters + +| Parameter | Values | Default | Description | +|---|---|---|---| +| `Target` | `node`, `dotnet`, `all` | `all` | Which implementation to build/test | +| `-Publish` | switch | off | Publish NativeAOT binaries after build+test | +| `-Runtime` | `win-x64`, `win-arm64`, `osx-arm64`, `linux-x64`, `all` | current platform | Target platform(s) for publish | +| `-TestOnly` | switch | off | Skip build steps, run tests only | +| `-NoBuild` | switch | off | Skip build step (useful with `-Publish` to skip rebuild) | +| `-Configuration` | `Debug`, `Release` | `Release` | Build configuration | + +### Execution Flow + +The script follows this execution order: + +``` +1. Parse parameters, determine current platform RID +2. If NOT -TestOnly: + a. Node target? → npm install --prefer-offline --no-audit + b. Node target? → npx vitest run + c. Dotnet target? → dotnet build (Release) + d. Dotnet target? → dotnet test (if test project exists) +3. If -TestOnly: + a. Node target? → npx vitest run (skip install) + b. Dotnet target? → dotnet test +4. If -Publish AND dotnet target: + a. Resolve target RIDs (current platform, specified list, or all 4) + b. For each RID: dotnet publish with NativeAOT + c. Report binary size for each published binary +5. Print summary table (PASS/FAIL for each step) +6. Exit with code 1 if any step failed +``` + +### Example Invocations + +**Run just the Node.js tests** (fastest feedback loop during development): +```powershell +.\build.ps1 node -TestOnly +# Output: +# ═══════════════════════════════════════════════════ +# Node.js: Run tests +# ═══════════════════════════════════════════════════ +# ... (vitest output) ... +# [PASS] Node.js tests +``` + +**Full build and test, both implementations**: +```powershell +.\build.ps1 all +# Runs: npm install → npm test → dotnet build → dotnet test +# Time: ~45 seconds +``` + +**Build, test, and publish for current platform**: +```powershell +.\build.ps1 dotnet -Publish +# Runs: dotnet build → dotnet test → dotnet publish (NativeAOT, current RID) +# Time: ~45 seconds (build+test ~15s, publish ~30s) +``` + +**Publish for all platforms** (e.g., for a release): +```powershell +.\build.ps1 dotnet -Publish -Runtime all +# Publishes: win-x64, win-arm64, osx-arm64, linux-x64 +# Time: ~2 minutes (4 × ~30s publish) +# Note: Cross-compilation works from any host OS +``` + +**Publish without rebuilding** (reuse existing build output): +```powershell +.\build.ps1 dotnet -Publish -NoBuild -Runtime win-x64 +# Skips: dotnet build +# Runs: dotnet test → dotnet publish +``` + +### Error Handling + +The script uses `$ErrorActionPreference = 'Stop'` and wraps each step in try/catch. If any step fails: +- The failure is recorded in the results table +- Subsequent steps are skipped (the exception propagates) +- The summary table still prints, showing which step failed +- The script exits with code 1 + +This means you can safely run `.\build.ps1 all -Publish` and trust that a zero exit code means everything passed. + +### System.CommandLine + +Uses `System.CommandLine` stable **2.0.5**. The C# project was upgraded from `System.CommandLine` 2.0.0-beta4 to the stable 2.0.5 release. If you're reading old Stack Overflow answers, blog posts, or documentation about System.CommandLine, be aware they almost certainly reference the beta API — the stable release has significant breaking changes. + +Key API changes from beta4 → 2.0.5: +- **`SetHandler` → `SetAction`**: Beta4's `command.SetHandler((ctx) => { ... })` is replaced with `command.SetAction(parseResult => { ... })`. The handler receives a `ParseResult` instead of an `InvocationContext`. +- **`InvocationContext` → `ParseResult`**: All option/argument value access goes through `parseResult.GetValue(option)` instead of `ctx.ParseResult.GetValueForOption(option)`. +- **`IConsole` removed**: The beta `IConsole` abstraction is gone. Use `Console.Out` / `Console.Error` directly. +- **`command.AddOption()` → `command.Options.Add()`**: Registration uses collection properties instead of methods. Similarly, `command.AddCommand()` → `command.Subcommands.Add()` and `command.AddArgument()` → `command.Arguments.Add()`. +- **`AddGlobalOption` → `Recursive = true`**: Global options use `opt.Recursive = true` on the option itself instead of `command.AddGlobalOption(opt)`. +- **`Option` constructor**: Description is now a property, not a constructor parameter. Use `new Option("--name") { Description = "desc" }`. + +See `agents/COMMON-PITFALLS.md` §19 for the full migration table from beta4 to stable. + +## Verifying a Complete Change + +After making changes to both implementations, follow this full validation sequence. Skipping steps (especially the NativeAOT republish) is one of the most common sources of broken commits. + +```powershell +# 1. Run all Node.js tests — establishes baseline +npm test +# Expected: ~750 tests pass. Any failures here are pre-existing or in your Node.js changes. + +# 2. Build C# implementation — catches compile errors +dotnet build src\dotnet\OutlookCli.csproj +# Expected: Build succeeded. 0 Warning(s). 0 Error(s). + +# 3. Publish NativeAOT binary — CRITICAL: must be done after EVERY C# change +dotnet publish src\dotnet\OutlookCli.csproj -r win-x64 --self-contained /p:PublishAot=true -o publish\win-x64 +# Expected: ~10 MB binary at publish\win-x64\outlook-cli.exe +# This step takes 30-60 seconds. Do not skip it. + +# 4. Run all tests AGAIN — the dotnet/ tests now run against your freshly published binary +npm test +# Expected: All tests pass, including test/dotnet/native-binary.test.js + +# 5. Verify both runtimes produce the same output for key commands +node bin\outlook-cli.js --version +publish\win-x64\outlook-cli.exe --version +# Both should print the same version string + +# Or use the unified build script which does all of the above: +.\build.ps1 all -Publish +``` + +### Why Run Tests Twice? + +The first `npm test` (step 1) validates your Node.js changes. The second `npm test` (step 4) validates the published NativeAOT binary. The binary test suite in `test/dotnet/native-binary.test.js` spawns the actual compiled binary and checks its output — so it must be freshly published to reflect your latest changes. + +### Real-World Validation + +After tests pass, test with a real Microsoft account if your changes touch authentication, Graph API calls, or output formatting. See `agents/REAL-WORLD-VALIDATION.md` for scenarios and expected output. diff --git a/agents/COMMON-PITFALLS.md b/agents/COMMON-PITFALLS.md new file mode 100644 index 0000000..b07ed86 --- /dev/null +++ b/agents/COMMON-PITFALLS.md @@ -0,0 +1,602 @@ +# Common Pitfalls — outlook-cli + +**Read this before making changes.** These are mistakes that have caused real bugs, wasted hours of debugging, and required rollbacks. Every item here comes from actual experience. + +--- + +## 1. Forgetting to Republish the NativeAOT Binary + +**The #1 most common mistake.** + +After changing any file in `src/dotnet/`, you must republish: + +```powershell +dotnet publish src\dotnet\OutlookCli.csproj -r win-x64 --self-contained /p:PublishAot=true -o publish\win-x64 +``` + +The test suite at `test/dotnet/native-binary.test.js` runs against `publish/win-x64/outlook-cli.exe`. If you forget to republish, you're testing old code and your changes appear to work — but they don't ship. + +**Rule**: After any C# change, always: build → publish → test. + +--- + +## 2. Using 16-byte IV Instead of 12-byte for AES-GCM + +.NET's `AesGcm` requires **exactly 12-byte** IVs. Node.js `crypto.createCipheriv` accepts 16 bytes but .NET will throw `ArgumentException`. The encrypted binary format is: + +``` +[salt:32 bytes] [iv:12 bytes] [authTag:16 bytes] [ciphertext:variable] +``` + +If you change anything in crypto, verify cross-runtime decryption works. + +--- + +## 3. Using `Environment.MachineName` in C# + +`Environment.MachineName` uppercases the hostname on Windows. Node.js `os.hostname()` preserves case. Since the hostname is part of the encryption passphrase, this breaks cross-runtime cache decryption. + +**Correct**: `System.Net.Dns.GetHostName()` +**Wrong**: `Environment.MachineName` + +--- + +## 4. Missing `.js` Extension in ESM Imports + +Node.js ESM requires file extensions. This will fail at runtime: + +```javascript +// WRONG — will throw ERR_MODULE_NOT_FOUND +import { encrypt } from '../security/crypto'; + +// CORRECT +import { encrypt } from '../security/crypto.js'; +``` + +--- + +## 5. Using `export default` in Node.js + +The codebase uses **named exports only**. `export default` breaks the import pattern: + +```javascript +// WRONG +export default function createClient() { ... } + +// CORRECT +export function createClient() { ... } +``` + +--- + +## 6. Forgetting to Register Types in OutlookCliJsonContext + +NativeAOT does not support reflection-based JSON serialization. If you add a new type that gets serialized, you must register it: + +```csharp +// In OutlookCliJsonContext.cs +[JsonSerializable(typeof(YourNewType))] +internal partial class OutlookCliJsonContext : JsonSerializerContext { } +``` + +Without this, the code compiles fine in Debug mode but **crashes at runtime** in the published NativeAOT binary with a missing metadata exception. This is especially insidious because `dotnet run` works but the published binary doesn't. + +--- + +## 7. Using `program.parse()` Instead of `program.parseAsync()` + +Commander.js `parse()` does not await async action handlers. If an async handler throws, it becomes an unhandled promise rejection and the process may exit with code 0 (success!) or produce no error output. + +```javascript +// WRONG — async errors become unhandled rejections +program.parse(process.argv); + +// CORRECT — properly awaits and propagates errors +await program.parseAsync(process.argv); +``` + +--- + +## 8. Not Clearing setTimeout After Login + +The interactive login flow sets a safety timeout (e.g., 5 minutes). If you don't clear it on success, the process hangs for the full timeout duration after the user completes login. + +```javascript +// Pattern: always clear on all exit paths +const timeout = setTimeout(() => { /* safety shutdown */ }, 5 * 60 * 1000); + +try { + const result = await doLogin(); + clearTimeout(timeout); // <-- Don't forget this + return result; +} catch (err) { + clearTimeout(timeout); // <-- Or this + throw err; +} +``` + +Also: `server.close()` alone doesn't kill keep-alive connections. Use `server.closeAllConnections()` (Node 18.2+). + +--- + +## 9. Swallowing Network Errors in acquireTokenSilently + +MSAL's `acquireTokenSilent` can fail for two very different reasons: +- **Auth expired** (user needs to re-login) → return null with a reason code +- **Network failure** (DNS, timeout, connection refused) → **must rethrow** + +If you catch all errors and return null, network failures look like auth expiry and the user gets "please re-login" when they actually have a network problem. + +```javascript +// WRONG — treats network errors as auth errors +try { + return await msalClient.acquireTokenSilent(request); +} catch (err) { + return null; // User sees "re-login" for network failures +} + +// CORRECT — only return null for known reauth cases +try { + return await msalClient.acquireTokenSilent(request); +} catch (err) { + if (err instanceof InteractionRequiredAuthError) { + return { result: null, reason: 'interaction_required' }; + } + throw err; // Network errors propagate up +} +``` + +--- + +## 10. Testing Against Mocks Only + +Mocks test your logic but miss integration bugs: + +- Mocked `fetch` always returns clean JSON. Real Graph API returns HTML error pages for some 5xx errors. +- Mocked MSAL always succeeds or fails cleanly. Real MSAL has race conditions with cache files. +- Mocked encryption is instant. Real PBKDF2 takes 300ms and reveals timing issues. +- Mocked servers close instantly. Real servers with keep-alive connections hang. + +**After making changes to auth, network, encryption, or error handling, always do a real-world smoke test.** See [REAL-WORLD-VALIDATION.md](REAL-WORLD-VALIDATION.md). + +--- + +## 11. Assuming Personal Microsoft Accounts Return JWTs + +Microsoft Graph returns **opaque (non-JWT) access tokens** for personal Microsoft accounts (@outlook.com, @hotmail.com, @live.com). These are encrypted blobs, not three-part JWTs. + +Token validators must handle this: + +```javascript +function decodeJwtPayload(token) { + const parts = token.split('.'); + if (parts.length !== 3) return null; // Opaque token — not a JWT + // ... decode parts[1] +} +``` + +If your validation code assumes all tokens are JWTs, it will crash for personal accounts. + +--- + +## 12. PowerShell `#` Treats CLI Arguments as Comments + +PowerShell interprets `#` as the start of a comment. If your CLI displays message numbers as `#1`, `#2`, `#3` and the user types `outlook-cli mail read #2`, PowerShell silently drops everything from `#2` onward. The command receives no message ID argument. + +**Symptoms**: "messageId is required" error when the user clearly typed an ID. + +**Prevention**: Never use `#` prefix in displayed IDs. Use bare numbers (`1`, `2`, `3`). The `resolveId()` function in both Node.js (`src/node/output/last-results.js`) and C# (`src/dotnet/Output/LastResults.cs`) strips `#` if present for backward compatibility, but the display should never suggest `#N` syntax. + +```powershell +# These all work: +outlook-cli mail read 2 +outlook-cli mail read "2" + +# This FAILS silently — #2 is eaten by PowerShell's comment parser: +outlook-cli mail read #2 +``` + +--- + +## 13. Not Handling `Retry-After` on 429 Responses + +Microsoft Graph rate-limits aggressively. When you get a 429, you **must** wait for the duration specified in the `Retry-After` header. Immediately retrying will get you throttled harder. + +```javascript +if (response.status === 429) { + const retryAfter = parseInt(response.headers.get('Retry-After') || '5', 10); + await new Promise(r => setTimeout(r, retryAfter * 1000)); + // Then retry +} +``` + +--- + +## 14. Making Graph API Calls Without `userPath` + +All Graph API calls must use `client.userPath` (Node.js) or `client.UserPath` (C#) as the base path. This returns `/me` for the user's own account or `/users/{email}` for delegate access. + +```javascript +// WRONG — only works for own account +const url = `/me/messages/${id}`; + +// CORRECT — works for both own and delegate +const url = `${client.userPath}/messages/${id}`; +``` + +--- + +## 15. Adding `Mail.ReadWrite.All` to Scopes + +**`Mail.ReadWrite.All` is a FORBIDDEN scope.** It grants application-level access to every mailbox in an organization. The token validator will reject tokens with this scope, and any code that requests it will fail. + +`Mail.Send` and `Mail.Send.Shared` are both allowed. Check `src/node/security/token-validator.js` and `src/dotnet/Security/TokenValidator.cs` for the full list. + +--- + +## 16. Forgetting Config File Overrides Env Vars + +Config resolution order: defaults → env vars → **config file** → account settings → CLI flags. + +The config file (`~/.outlook-cli/config.json`) **intentionally overrides** environment variables. This is by design (see `Object.assign` in `src/node/config.js`). Don't assume `OUTLOOK_CLI_CLIENT_ID` always wins — a config file setting overrides it. + +If you see a value being used that doesn't match your environment variable, check `~/.outlook-cli/config.json` first. This is counterintuitive since most tools use env vars to override config files, but outlook-cli inverts this so that explicit local configuration wins. + +--- + +## 17. Short IDs Are Ephemeral and Global + +The short ID system (`mail read 1` instead of `mail read AAMkAGI2...`) uses `~/.outlook-cli/last-results.json` as a mapping file. Key behaviors that cause confusion: + +- **Overwritten on every list command**: `mail inbox` overwrites the mapping, then `mail search "foo"` overwrites it again. ID `1` now refers to the first search result, not the first inbox message. +- **Single global file**: If two terminal windows run inbox queries simultaneously, they clobber each other's mapping. There's no per-session or per-process isolation. +- **When to use full IDs**: For scripts, automation, or multi-window workflows, always use the full Graph message ID. Short IDs are a convenience for interactive single-window use only. + +```bash +# This works if you just ran inbox: +outlook-cli mail read 1 + +# But if you ran search after inbox, '1' now refers to the first SEARCH result +outlook-cli mail search "meeting" # Overwrites last-results.json +outlook-cli mail read 1 # Reads first search result, not first inbox message +``` + +--- + +## 18. Not Testing Both Node.js AND C# Implementations + +The most insidious bugs are commands that exist in Node.js but were never ported to C#. The `log` command was missing from C# for an extended period and nobody noticed because: + +1. The parity test only checked `auth, account, mail, calendar, contacts` — it didn't check `log` or `watch` +2. Developers tested with Node.js and assumed C# "just works" +3. The C# binary compiled fine — a missing command doesn't cause a build error + +**Rules**: +- After adding any command to Node.js, always add the equivalent to C#'s `Program.cs` +- After adding any command to C#, always add it to the parity check in `test/dotnet/native-binary.test.js` +- When making changes, build and run both: `npm test` AND `dotnet publish ... && publish\win-x64\outlook-cli.exe --help` + +--- + +## 19. System.CommandLine API Differences (Beta vs Stable) + +The C# project was upgraded from System.CommandLine `2.0.0-beta4` to stable `2.0.5`. The API changed significantly. If you see old code examples or documentation referencing the beta patterns, use the stable equivalents: + +| Beta4 Pattern | Stable 2.0.5 Pattern | +|---|---| +| `command.SetHandler(async (context) => { ... })` | `command.SetAction(async (parseResult) => { ... })` | +| `context.ParseResult.GetValueForOption(opt)` | `parseResult.GetValue(opt)` | +| `context.ParseResult.GetValueForArgument(arg)` | `parseResult.GetValue(arg)` | +| `command.AddOption(opt)` | `command.Options.Add(opt)` | +| `command.AddCommand(sub)` | `command.Subcommands.Add(sub)` | +| `command.AddArgument(arg)` | `command.Arguments.Add(arg)` | +| `rootCommand.AddGlobalOption(opt)` | `opt.Recursive = true; rootCommand.Options.Add(opt)` | +| `new Option(name, description)` | `new Option(name) { Description = description }` | +| `new Option(name, () => default, desc)` | `new Option(name) { Description = desc, DefaultValueFactory = _ => default }` | +| `context.ExitCode = 1` | `Environment.ExitCode = 1` | +| `rootCommand.InvokeAsync(args)` | `rootCommand.Parse(args).InvokeAsync()` | + +--- + +## 20. NativeAOT Binary Works in Debug but Crashes in Release + +A common pattern: code compiles and runs fine with `dotnet run`, but the published NativeAOT binary crashes. This happens because: + +1. **Missing JSON types**: `JsonSerializer.Serialize(obj)` works with reflection in debug but NativeAOT has `JsonSerializerIsReflectionEnabledByDefault=false`. Every serialized type needs an entry in `OutlookCliJsonContext.cs`. +2. **Missing SQLite provider**: NativeAOT trims unused code. Without `raw.SetProvider(new SQLite3Provider_e_sqlite3())` called before any database access, the native binary throws a missing provider exception. +3. **Trimming warnings**: Suppress trimming warnings for SQLitePCLRaw assemblies in the `.csproj` (`TrimmerRootAssembly` entries). + +**Always test with the published binary** (`publish\win-x64\outlook-cli.exe`), not `dotnet run`. + +--- + +## 21. Watch Mode Process Managers Kill "Idle" Processes + +Process managers (systemd, PM2, Docker health checks) may kill the watch process if it appears idle between polling intervals. The 30-60 second delta query interval means the process does nothing for extended periods. + +**Solution**: The watch implementation emits periodic heartbeat events (a JSON `{"type":"heartbeat"}` line in JSONL mode, or a silent timer reset) to signal it's still alive. If running under a process manager, configure a generous timeout (at least 2× the polling interval). + +--- + +## 22. Option Descriptions Passed as Aliases in System.CommandLine + +In System.CommandLine 2.0.5, the `Option` constructor is `new Option(name, params string[] aliases)`. If you accidentally pass a description string as the second argument, it becomes an alias: + +```csharp +// WRONG — "Filter by account" becomes an alias, crashes on whitespace validation +var opt = new Option("--account", "Filter by account"); + +// CORRECT — Description is a property, not a constructor parameter +var opt = new Option("--account") { Description = "Filter by account" }; +``` + +This is especially easy to miss because the beta4 API used `new Option(name, description)` — the exact pattern that now breaks. + +--- + +## 23. Cross-Runtime JSON Shape Differences in Integration Tests + +Node.js and C# return DIFFERENT JSON shapes for some operations. If you write a test that works on Node.js, it may fail on C# (and vice versa). Common differences: + +| Operation | Node.js shape | C# shape | +|-----------|---------------|----------| +| `mail flag` | `{success: true, flagged: true}` | `{flag: {flagStatus: "flagged"}, ...}` | +| `mail mark-read` | `{success: true, isRead: true}` | `{isRead: true, id: "AAMk..."}` | +| `mail move` | `{success: true, newId: "AAMk..."}` | `{id: "AAMk...", subject: "..."}` | +| `mail delete` | `{success: true, deleted: true}` | `{success: true, ...}` | +| `doctor --json` | `{checks: [{status: "pass"}]}` | `{checks: [{status: "ok"}]}` | + +**Write assertions that accept both shapes:** +```javascript +// WRONG — only works for Node.js +expect(result.success).toBe(true); + +// CORRECT — works for both runtimes +expect(result.success === true || result.id).toBeTruthy(); +const id = result.messageId ?? result.id; +``` + +--- + +## 24. Vitest afterAll Hook Timeout (10 Seconds Default) + +Vitest's default `hookTimeout` is **10 seconds**. Integration test cleanup hooks (afterAll) that delete multiple messages via Graph API easily exceed this — each deletion takes 1-2 seconds plus throttle pauses. + +If afterAll is killed mid-cleanup, stale test messages accumulate in the test account. The timeout is silent — no error message, just abandoned cleanup. + +**Always pass E2E_SUITE_TIMEOUT as the second argument:** +```javascript +// WRONG — uses default 10s timeout, cleanup may be killed +afterAll(async () => { ... }); + +// CORRECT — generous timeout for API-based cleanup +afterAll(async () => { ... }, E2E_SUITE_TIMEOUT); // 15 minutes +``` + +--- + +## 25. C# Exit Code Swallowed by InvokeAsync + +Setting `Environment.ExitCode = 1` inside a System.CommandLine `SetAction` handler does NOT guarantee the process exits with code 1. `InvokeAsync()` returns its own result (typically 0 if the handler didn't throw), and that's what `Main` returns. + +**Fix in Program.cs:** +```csharp +var result = await rootCommand.Parse(args).InvokeAsync(); +return result != 0 ? result : Environment.ExitCode; +``` + +Without this, commands that set `Environment.ExitCode = 1` for Graph API errors still exit with code 0, and integration tests that check `result.code !== 0` will fail. + +--- + +## 26. Graph Eventual Consistency in Integration Tests + +After a write operation (send, create event), the item may NOT be immediately visible in queries: + +| Operation | Typical delay | Worst case | +|-----------|--------------|------------| +| Send self-email → inbox search | 3–5 seconds | 2 minutes | +| Create event → calendarView | 5–10 seconds | 60 seconds | +| Move message → folder listing | 1–3 seconds | 10 seconds | + +**Always use `pollUntil()` after writes, never assume immediate visibility.** + +For calendar events, use `calendar week` (broader window) instead of `calendar today` to avoid timezone edge-cases at midnight where the event might technically be "tomorrow" in UTC. + +--- + +## 27. Read-Only Account Write Guard Must Be in Both Runtimes + +When adding or modifying write commands, the read-only write guard must be present in **both** Node.js and C#. The guard must run **before** any Graph API call to provide fast failure with a clear error message. + +**Node.js pattern** (in `src/node/cli/mail.js` and `calendar.js`): +```javascript +import { assertWriteAllowed } from '../security/write-guard.js'; + +// At the top of every write command handler: +assertWriteAllowed(accountAlias, 'send draft'); +``` + +**C# pattern** (in `Program.cs`): +```csharp +// At the top of every write command's try block, before BuildClient: +AssertWriteAllowed(acct, "send draft"); +var client = BuildClient(acct, asEmail); +``` + +Write commands that need guards: `mail send`, `mail draft`, `mail reply`, `mail forward`, `mail move`, `mail flag`, `mail mark-read`, `mail delete`, `calendar create`, `calendar delete`. + +If you add a new write command, add the guard immediately — the integration test at `test/integration/account-readonly.test.js` will catch missing guards. + +--- + +## 28. Per-Account MSAL Scopes Must Flow Through to AcquireTokenSilently + +The `AcquireTokenSilently` call must use per-account scopes, not the global default. This is critical for read-only accounts — if the silent token refresh uses full scopes but the original login used read-only scopes, the refresh will fail. + +**Node.js**: `GraphClient.getToken()` passes `scopeOptions` through to `acquireTokenSilently()`. +**C#**: `GraphClient.GetTokenAsync()` passes `_accountConfig` to `MsalClientFactory.AcquireTokenSilently()`. + +If you change the scope resolution logic, verify that `auth login` and `GraphClient` both use `GetScopesForAccount()` consistently. + +--- + +## 29. listMessages/searchMessages Return Objects, Not Arrays + +As of the pagination upgrade, `listMessages()` and `searchMessages()` return `{ messages, nextLink, count }` objects in Node.js, and `(JsonElement? Messages, bool HasNextLink)` tuples in C#. **Not plain arrays.** + +If you write code that does `const msgs = await listMessages(...); msgs.forEach(...)`, it will crash. Use `result.messages` to get the array. + +```javascript +// WRONG — result is an object, not an array +const result = await listMessages(client); +result.forEach(msg => console.log(msg.subject)); // TypeError + +// CORRECT +const result = await listMessages(client); +result.messages.forEach(msg => console.log(msg.subject)); +``` + +--- + +## 30. Commander.js Default Values Suppress Auto-Detection + +Don't set default values on Commander.js options if the presence/absence of the option drives behavior. The `--body-content-type` option had `'Text'` as a default, which made `options.bodyContentType` always truthy, suppressing auto-detection from file extensions. + +```javascript +// WRONG — default value makes auto-detection unreachable +.option('--body-content-type ', 'Content type', 'Text') + +// CORRECT — no default; resolveBody() handles the fallback +.option('--body-content-type ', 'Content type') +``` + +The auto-detection logic in `resolveBody()` checks `!options.bodyContentType` before inferring HTML from `.html`/`.htm` extensions. + +--- + +## 31. Page State and Short IDs Are Independent Files + +Pagination state (`~/.outlook-cli/page-state.json`) and short ID mapping (`~/.outlook-cli/last-results.json`) are **separate files with different lifecycles**: + +- `last-results.json`: Overwritten by every `inbox` or `search` command. Maps `1 → AAMkAG...` +- `page-state.json`: Tracks cursor position per command. Updated (not overwritten) by inbox/search. + +When implementing `--page next`, the short IDs update to reflect the new page's messages, but the page cursor continues from where it left off. Don't confuse the two systems. + +## 32. Soak Tests Must Use OUTLOOK_CLI_TEST_ACCOUNT + +Integration soak tests (`test/integration/soak/`) use the Node.js modules directly (not CLI spawning). They previously used `loadConfig()` + `createGraphClient('default', ...)`, which fails when no "default" alias exists. **Always use `resolveAccount(process.env.OUTLOOK_CLI_TEST_ACCOUNT || 'default')` to get the correct account config.** This matches how the CLI commands resolve accounts. + +## 33. Telemetry Requires SQLite for Cross-Invocation Persistence + +The telemetry ring buffer is in-memory — it's lost when the process exits. For `telemetry show` to display events from previous commands, events must be flushed to SQLite via `collector.stop()` at command completion. The DB is wired lazily in the action wrapper (async context), not at startup (sync `createProgram()`). Without this, `telemetry show` always reports "No events." + +## 34. Telemetry DB Uses snake_case, JS Objects Use camelCase + +The SQLite telemetry table uses `duration_ms`, `graph_endpoint`, `graph_status_code` (snake_case), but the JavaScript telemetry events use `durationMs`, `graphEndpoint`, `graphStatusCode` (camelCase). The `export()` method in `collector.js` maps between these formats. If you add new telemetry fields, you must update both the `flush()` (JS→DB) and `export()` (DB→JS) methods, or the field will be silently lost. + +## 35. C# Regex Must Use Source Generators for NativeAOT + +In the C# implementation, all regex patterns must use `[GeneratedRegex(...)]` source generators (partial methods). Runtime `new Regex(...)` compiles correctly in debug builds but causes trimming warnings and potential failures in NativeAOT published binaries. See `src/dotnet/Output/HtmlToText.cs` for the correct pattern with 25+ source-generated regexes. + +## 36. HTML-to-Text Conversion Only in Text Format + +The HTML-to-text converter (`html-to-text.js`, `HtmlToText.cs`) only runs when rendering in text format. JSON and HTML output formats preserve the original HTML body unchanged. This is intentional — programmatic consumers (agents, scripts) need the raw HTML. The converter is triggered in `formatter.js`/`OutputFormatter.cs` based on the body's `contentType` field or by detecting HTML tags with `isHtml()`. + +## 37. Attachment Size Limit is 3MB for Inline Uploads + +Microsoft Graph API limits inline attachment uploads (base64 in request body) to 3MB. Files larger than 3MB require upload sessions (`createUploadSession`). The CLI checks file size before uploading and rejects files over 3MB with a clear error. Do not simply increase the limit — Graph will return 413. For large files, implement upload session support. + +See: `src/node/cli/mail.js` (`--attach` handler), `src/node/graph/mail.js` (`addAttachment`, `createUploadSession`) + +## 38. C# OutputFormatter Requires Triple Switch Updates + +When adding a new entity type to output formatting, you must add it to ALL THREE format switches in `OutputFormatter.cs` (text, markdown, html). Missing one causes that format to silently fall through to `Generic()`, which produces unhelpful output. Test all three formats when adding entity types. + +See: `src/dotnet/Output/OutputFormatter.cs` — the three dispatch switch expressions near the end of the file. + +## 39. System.CommandLine DefaultValueFactory Syntax + +In System.CommandLine, `() => "."` lambda syntax does NOT work for setting Option default values. You must use the `DefaultValueFactory = _ => "."` property initializer. This is a subtle API difference that compiles fine but doesn't actually set the default. + +See: `src/dotnet/Program.cs` (e.g., `--output-dir` option on `download-attachment`). + +## 40. Always Update docs/usage/ When Adding Commands + +Every CLI command, flag, and option must be documented in `docs/usage/`. The usage docs are the user-facing reference and are expected to be copy-paste ready. When adding or modifying a command: +1. Add the command section with description, syntax, example, and options table +2. Update the short ID reference if the command produces or consumes message IDs +3. Add a workflow example if the command fits into a multi-step pattern +4. Keep sample output realistic — use friendly IDs (1, 2, 3), real-looking subjects and senders + +See: `docs/usage/mail.md` for the standard format. + +## 41. Always Republish NativeAOT After C# Changes + +After any C# code changes, always republish the NativeAOT binary: +```bash +dotnet publish src\dotnet -r win-x64 --self-contained /p:PublishAot=true -o publish\win-x64 +``` +Then verify the published binary works (not just `dotnet run`). NativeAOT trimming can silently remove code that works fine in debug builds. Always test with the published binary before committing. + +## 42. File Permissions Are Best-Effort on Windows + +The `setRestrictivePermissions()` and `ensureDirectoryPermissions()` functions set Unix file permissions (chmod 600/700) for cache files and the config directory. On Windows, these are no-ops wrapped in try/catch because Windows uses ACLs inherited from the home directory. Do not add Windows-specific ACL code — it would require native dependencies and the encrypted cache already provides adequate protection. + +See: `src/node/auth/token-cache.js`, `src/dotnet/Auth/TokenCacheHelper.cs` + +## 43. Adding MSAL Scopes Breaks Existing Tokens — REQUIRES RE-AUTH + +**This is a breaking change for existing users.** When you add new scopes to the MSAL scope list (e.g., `Files.Read`, `Files.ReadWrite`), every existing cached token becomes invalid because: + +1. User logged in with scopes `[A, B, C]` — token cached with those scopes +2. Code now requests scopes `[A, B, C, D, E]` via `acquireTokenSilent` +3. MSAL compares: cached token has `[A, B, C]` but request asks for `[A, B, C, D, E]` +4. MSAL throws `InteractionRequiredAuthError` (Node.js) or `MsalUiRequiredException` (C#) +5. User sees "Re-authentication required" and must run `auth login` again + +**There is no automatic scope upgrade.** MSAL does not incrementally consent — it's all-or-nothing. + +**When adding scopes, you MUST:** +- Document the re-auth requirement in the commit message and release notes +- Add a migration note to `docs/usage/errors.md` explaining what happened and why +- Consider adding a version check that detects the scope mismatch and shows a targeted message like: "New permissions required (Files.Read). Run `outlook-cli auth login --account ` to grant access." +- Update `docs/self-hosting.md` if the Azure App Registration needs new API permissions configured + +**The error message users see is clear** (GraphClient handles `interaction_required`), but the *reason* is not obvious. Users don't know that a code update changed the scope list. + +See: `src/node/auth/msal-client.js:33-50` (Node.js scopes), `src/dotnet/Auth/MsalClientFactory.cs:37-52` (C# scopes), `src/node/graph/client.js:111-130` (error handling) + +## 44. OneDrive/Files Scopes Are Opt-In — Not in Default Scope List + +Unlike Mail and Calendar scopes, `Files.Read` and `Files.ReadWrite` are **not** included in the default MSAL scope list. This is intentional: + +1. Most users' Azure App Registrations don't have Files permissions configured +2. Requesting unconfigured scopes causes `AADSTS70000` — Azure rejects the *entire* token request +3. This breaks ALL commands, not just drive commands — the user can't even read email + +**When adding optional features that need new scopes:** +- Add the scope constants as a separate exported array (e.g., `DRIVE_SCOPES`) +- Do NOT add them to the default `SCOPES` or `READ_ONLY_SCOPES` arrays +- Document the Azure Portal setup steps in `docs/self-hosting.md` +- The `drive` commands should check for Files scopes and give a helpful error if they're missing + +See: `src/node/auth/msal-client.js` — `DRIVE_SCOPES` array, `src/dotnet/Auth/MsalClientFactory.cs` — `DriveScopes` array + +## 45. Microsoft Rate Limits on Token Requests (AADSTS70000 Abuse Detection) + +Rapid repeated token requests (e.g., calling `acquireTokenSilent` in a tight loop, or many failed auth attempts) can trigger Microsoft's abuse detection: + +- Error: `AADSTS70000: User account is found to be in service abuse mode` +- Error code: `invalid_grant` (same code as expired tokens — must check message text) +- Cooldown: 5–10 minutes before the account can request tokens again + +**This is NOT a token expiry issue.** The fix is to wait, not to re-authenticate. + +**Prevention:** +- Don't call `acquireTokenSilent` on every CLI invocation if you have a cached access token +- GraphClient already caches tokens in-memory with a 60-second buffer (`_cachedToken`, `_tokenExpiry`) +- In scripts, add delays between CLI calls or batch operations into a single invocation + +See: `src/node/auth/msal-client.js` — `rate_limited` reason, `src/node/graph/client.js` — `AUTH_RATE_LIMITED` error code diff --git a/agents/MAKING-CHANGES.md b/agents/MAKING-CHANGES.md new file mode 100644 index 0000000..f930ede --- /dev/null +++ b/agents/MAKING-CHANGES.md @@ -0,0 +1,658 @@ +# Making Changes — outlook-cli + +## The Dual-Implementation Rule + +This project has two complete implementations: Node.js (`src/node/`) and C# (`src/dotnet/`). **They must stay in sync.** Any behavioral change to one must be mirrored in the other. This includes: + +- Command-line options and arguments +- Output format and content +- Error messages and exit codes +- File format changes (accounts.json, aliases.json, delta tokens, encrypted caches) +- New features (commands, flags, modes) + +The only exceptions are implementation-specific internals (e.g., Node.js ESM module patterns vs C# class structure) and features explicitly marked as single-runtime (e.g., webhook mode is Node.js only; the C# version shows a redirect message). + +## Step-by-Step: Adding a Feature + +### 1. Plan the Change + +Before coding, understand what files need to change in both implementations: + +| Node.js | C# Equivalent | +|---|---| +| `src/node/cli/{command}.js` | `src/dotnet/Program.cs` (same file, find the command section) | +| `src/node/graph/mail.js` | `src/dotnet/Graph/MailService.cs` | +| `src/node/graph/calendar.js` | `src/dotnet/Graph/CalendarService.cs` | +| `src/node/graph/client.js` | `src/dotnet/Graph/GraphClient.cs` | +| `src/node/auth/msal-client.js` | `src/dotnet/Auth/MsalClientFactory.cs` | +| `src/node/auth/token-cache.js` | `src/dotnet/Auth/TokenCacheHelper.cs` | +| `src/node/security/crypto.js` | `src/dotnet/Security/CryptoService.cs` | +| `src/node/security/token-validator.js` | `src/dotnet/Security/TokenValidator.cs` | +| `src/node/accounts/manager.js` | `src/dotnet/Accounts/AccountManager.cs` | +| `src/node/output/render.js` + `formatter.js` | `src/dotnet/Output/OutputFormatter.cs` | +| `src/node/errors.js` | `src/dotnet/Errors/OutlookCliException.cs` | +| `src/node/config.js` | `src/dotnet/Config/ConfigManager.cs` | +| `src/node/watch/watcher.js` | `src/dotnet/Program.cs` (watch command section) | + +### 2. Implement in Node.js First + +Node.js is the reference implementation. Implement and test there first: + +```bash +# Edit files in src/node/ +# Write tests in test/node/ + +# Run affected tests during development +npx vitest run test/node/graph/client.test.js + +# Run all tests before moving to C# +npm test +``` + +### 3. Mirror in C# + +Port the logic to C#. Key differences to account for: + +#### Module Patterns + +```javascript +// Node.js — named exports, factory functions +export function createGraphClient(options) { ... } +export async function listMessages(client, folder, options) { ... } +``` + +```csharp +// C# — static service classes, async Task +public static class MailService { + public static async Task ListMessagesAsync(GraphClient client, string folder, ...) { ... } +} +``` + +#### JSON Serialization + +Node.js uses `JSON.stringify()` / `JSON.parse()` freely. C# must use source-generated serialization: + +```csharp +// Always use the context: +JsonSerializer.Serialize(value, OutlookCliJsonContext.Default.MyType); +JsonSerializer.Deserialize(json, OutlookCliJsonContext.Default.MyType); + +// If you add a new type, register it: +[JsonSerializable(typeof(MyNewType))] +internal partial class OutlookCliJsonContext : JsonSerializerContext { } +``` + +**Never** use `JsonSerializer.Serialize(value)` without a context — it silently works in debug builds but crashes in NativeAOT. + +#### Error Handling + +```javascript +// Node.js +throw new GraphApiError(`Not found: ${id}`, { statusCode: 404, graphCode: 'ErrorItemNotFound' }); +``` + +```csharp +// C# +throw new GraphApiException($"Not found: {id}", 404, "ErrorItemNotFound", "Check the message ID and try again"); +``` + +#### HTTP Requests + +```javascript +// Node.js — GraphClient wraps fetch() +const data = await client.get(`${client.userPath}/messages/${id}`); +``` + +```csharp +// C# — GraphClient wraps HttpClient +var data = await client.GetAsync($"{client.UserPath}/messages/{id}"); +``` + +### 4. Add Tests + +- Add unit tests in `test/node/` for the Node.js implementation +- If adding a new command or option, add a binary test case in `test/dotnet/native-binary.test.js` +- If changing CLI interface, update `test/shared/cli.test.js` + +### 5. Rebuild and Verify + +```powershell +# Full verification cycle +npm test # Node.js tests pass +dotnet build src\dotnet # C# builds +dotnet publish src\dotnet\OutlookCli.csproj -r win-x64 --self-contained /p:PublishAot=true -o publish\win-x64 +npm test # Binary tests pass with fresh build +``` + +## Step-by-Step: Adding a CLI Option + +Example: adding `--priority` flag to `mail inbox`. + +### Node.js Side + +1. **Define the option** in `src/node/cli/mail.js`: + ```javascript + .option('--priority ', 'Filter by priority (low, normal, high)') + ``` + +2. **Pass it through** the command handler to the Graph API module: + ```javascript + const messages = await listMessages(client, folder, { ...options, priority: options.priority }); + ``` + +3. **Use it** in `src/node/graph/mail.js`: + ```javascript + if (options.priority) { + params.push(`$filter=importance eq '${options.priority}'`); + } + ``` + +### C# Side + +1. **Define the option** in `src/dotnet/Program.cs` (find the `mail inbox` command section): + ```csharp + var priorityOption = new Option("--priority") { Description = "Filter by priority (low, normal, high)" }; + inboxCommand.Options.Add(priorityOption); + ``` + +2. **Use it** in the command handler and pass to `MailService`. + +3. **If a new DTO is needed**, register it in `OutlookCliJsonContext.cs`. + +### Tests + +1. **Node.js test** in `test/node/graph/mail.test.js`: + ```javascript + it('should filter by priority when --priority is specified', async () => { + const client = createMockClient(); + await listMessages(client, 'Inbox', { priority: 'high' }); + expect(client.get).toHaveBeenCalledWith(expect.stringContaining("importance eq 'high'")); + }); + ``` + +2. **Binary test** in `test/dotnet/native-binary.test.js`: + ```javascript + it('should show --priority option in mail inbox help', async () => { + const { stdout } = await run(['mail', 'inbox', '--help']); + expect(stdout).toContain('--priority'); + }); + ``` + +## Worked Example: Adding a New Command End-to-End + +This walkthrough adds a hypothetical `mail attachments ` command that lists attachments for a given email. It touches every layer: Graph API module, CLI command, output formatting, tests, and the C# mirror. + +### Node.js Side + +#### 1. Create the Graph API module — `src/node/graph/attachments.js` + +```javascript +// src/node/graph/attachments.js +import { buildQueryString } from './query.js'; + +export async function listAttachments(client, messageId, options = {}) { + if (!messageId) { + throw new Error('Message ID is required. Use `outlook-cli mail inbox` to find message IDs.'); + } + + const params = []; + params.push("$select=id,name,contentType,size,isInline,lastModifiedDateTime"); + + if (options.top) { + params.push(`$top=${options.top}`); + } + + const query = buildQueryString(params); + const endpoint = `${client.userPath}/messages/${messageId}/attachments${query}`; + const data = await client.get(endpoint); + return data.value || []; +} +``` + +Key points: +- Named export (`export function`), no default export +- Validate `messageId` immediately with an actionable error message +- Use `client.userPath` so delegate access (`--as`) works automatically +- Limit fields with `$select` to reduce payload size + +#### 2. Add the command handler in `src/node/cli/mail.js` + +```javascript +// In src/node/cli/mail.js — add after existing subcommands + +mail + .command('attachments ') + .description('List attachments for a message') + .option('--top ', 'Maximum number of attachments', '25') + .option('-o, --output ', 'Output format (text, json, markdown, html)', 'text') + .option('--as ', 'Access a shared/delegate mailbox') + .action(async (messageId, options) => { + const client = await createAuthenticatedClient(options); + const attachments = await listAttachments(client, messageId, options); + await output('attachmentList', attachments, options); + }); +``` + +The command is registered as a subcommand of `mail`, so users invoke it as `outlook-cli mail attachments `. No changes to `src/node/cli/index.js` are needed — `mail.js` already registers all mail subcommands. + +#### 3. Add output formatting — `src/node/output/formatter.js` + +```javascript +// In src/node/output/formatter.js — add alongside mailList, eventList, etc. + +export function attachmentList(attachments) { + if (!attachments || attachments.length === 0) { + return 'No attachments found.'; + } + + const lines = []; + const header = 'Name'.padEnd(40) + 'Size'.padStart(12) + ' Type'.padEnd(30) + ' Inline'; + lines.push(header); + lines.push('─'.repeat(header.length)); + + for (const att of attachments) { + const name = (att.name || '(unnamed)').substring(0, 38).padEnd(40); + const size = formatFileSize(att.size).padStart(12); + const type = (' ' + (att.contentType || 'unknown')).padEnd(30); + const inline = att.isInline ? ' Yes' : ' No'; + lines.push(`${name}${size}${type}${inline}`); + } + + return lines.join('\n'); +} + +function formatFileSize(bytes) { + if (bytes == null) return '—'; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} +``` + +Optionally add `attachmentList` to `markdown.js` and `html.js` as well. The `render.js` dispatcher will fall back to `formatter.js` (text format) if a format-specific function is not found. + +#### 4. Registration in `src/node/cli/index.js` + +No changes needed. Mail subcommands are registered inside `src/node/cli/mail.js` itself. The `index.js` file already imports and registers the top-level `mail` command group. + +#### 5. Add tests — `test/node/graph/attachments.test.js` + +```javascript +// test/node/graph/attachments.test.js +import { describe, it, expect, vi } from 'vitest'; +import { listAttachments } from '../../../src/node/graph/attachments.js'; + +function createMockClient(responseData = { value: [] }) { + return { + userPath: '/me', + get: vi.fn().mockResolvedValue(responseData), + }; +} + +describe('listAttachments', () => { + it('should call correct Graph API endpoint', async () => { + const client = createMockClient(); + await listAttachments(client, 'AAMkADEz'); + expect(client.get).toHaveBeenCalledWith( + expect.stringContaining('/me/messages/AAMkADEz/attachments') + ); + }); + + it('should include $select in query', async () => { + const client = createMockClient(); + await listAttachments(client, 'AAMkADEz'); + expect(client.get).toHaveBeenCalledWith( + expect.stringContaining('$select=id,name,contentType,size') + ); + }); + + it('should throw when messageId is missing', async () => { + const client = createMockClient(); + await expect(listAttachments(client, '')).rejects.toThrow('Message ID is required'); + }); + + it('should respect --top option', async () => { + const client = createMockClient(); + await listAttachments(client, 'AAMkADEz', { top: 10 }); + expect(client.get).toHaveBeenCalledWith( + expect.stringContaining('$top=10') + ); + }); + + it('should use delegate path when userPath is set', async () => { + const client = createMockClient(); + client.userPath = '/users/shared@example.com'; + await listAttachments(client, 'AAMkADEz'); + expect(client.get).toHaveBeenCalledWith( + expect.stringContaining('/users/shared@example.com/messages/') + ); + }); + + it('should return empty array when no attachments exist', async () => { + const client = createMockClient({ value: [] }); + const result = await listAttachments(client, 'AAMkADEz'); + expect(result).toEqual([]); + }); +}); +``` + +### C# Side + +#### 1. Add to `src/dotnet/Graph/MailService.cs` + +```csharp +// In MailService.cs — add a new static method + +public static async Task ListAttachmentsAsync( + GraphClient client, string messageId, int top = 25) +{ + if (string.IsNullOrEmpty(messageId)) + throw new ArgumentException("Message ID is required. Use `outlook-cli mail inbox` to find message IDs."); + + var select = "$select=id,name,contentType,size,isInline,lastModifiedDateTime"; + var endpoint = $"{client.UserPath}/messages/{messageId}/attachments?{select}&$top={top}"; + return await client.GetAsync(endpoint); +} +``` + +#### 2. Add command in `src/dotnet/Program.cs` + +```csharp +// In Program.cs — find the mail command group, add: + +var attachmentsCommand = new Command("attachments", "List attachments for a message"); +var msgIdArg = new Argument("message-id", "The message ID"); +var topOption = new Option("--top", () => 25, "Maximum number of attachments"); +attachmentsCommand.AddArgument(msgIdArg); +attachmentsCommand.AddOption(topOption); +attachmentsCommand.AddOption(outputOption); // reuse global output option +attachmentsCommand.AddOption(asOption); // reuse delegate access option + +attachmentsCommand.SetAction(async (parseResult, cancellationToken) => +{ + var messageId = parseResult.GetValue(msgIdArg)!; + var top = parseResult.GetValue(topOption); + var outputFormat = parseResult.GetValue(outputOption) ?? "text"; + var asEmail = parseResult.GetValue(asOption); + + var client = await CreateClient(parseResult, asEmail, cancellationToken); + var result = await MailService.ListAttachmentsAsync(client, messageId, top); + OutputFormatter.PrintAttachmentList(result, outputFormat); +}); + +mailCommand.AddCommand(attachmentsCommand); +``` + +#### 3. Register types in `OutlookCliJsonContext.cs` + +If you define any new DTOs for the attachment response, register them: + +```csharp +// In OutlookCliJsonContext.cs +[JsonSerializable(typeof(AttachmentResponse))] +[JsonSerializable(typeof(List))] // if needed +internal partial class OutlookCliJsonContext : JsonSerializerContext { } +``` + +In practice, `ListAttachmentsAsync` returns `JsonElement` directly (matching the existing pattern in `MailService`), so no new DTOs may be needed unless you add strongly-typed models. + +#### 4. Add to `src/dotnet/Output/OutputFormatter.cs` + +```csharp +// In OutputFormatter.cs + +public static void PrintAttachmentList(JsonElement data, string format) +{ + var attachments = data.GetProperty("value").EnumerateArray().ToList(); + if (attachments.Count == 0) + { + Console.WriteLine("No attachments found."); + return; + } + + if (format == "json") + { + Console.WriteLine(data.GetRawText()); + return; + } + + Console.WriteLine($"{"Name",-40}{"Size",12} {"Type",-28} Inline"); + Console.WriteLine(new string('─', 86)); + + foreach (var att in attachments) + { + var name = Truncate(att.GetPropertyOrDefault("name", "(unnamed)"), 38); + var size = FormatFileSize(att.GetPropertyOrDefault("size", 0)); + var type = att.GetPropertyOrDefault("contentType", "unknown"); + var isInline = att.GetPropertyOrDefault("isInline", false) ? "Yes" : "No"; + Console.WriteLine($"{name,-40}{size,12} {type,-28} {isInline}"); + } +} +``` + +Column widths must match the Node.js `formatter.js` implementation exactly — the binary parity tests compare output structure. + +#### 5. Republish the NativeAOT binary + +```powershell +dotnet publish src\dotnet\OutlookCli.csproj -r win-x64 --self-contained /p:PublishAot=true -o publish\win-x64 +``` + +### Testing the Complete Feature + +#### 1. Unit tests (already shown above) + +Run during development: + +```bash +npx vitest run test/node/graph/attachments.test.js +``` + +#### 2. Binary parity test — `test/dotnet/native-binary.test.js` + +```javascript +it('should show mail attachments help', async () => { + const { stdout, exitCode } = await run(['mail', 'attachments', '--help']); + expect(exitCode).toBe(0); + expect(stdout).toContain('attachments'); + expect(stdout).toContain('message-id'); + expect(stdout).toContain('--top'); +}); +``` + +#### 3. CLI integration test — `test/shared/cli.test.js` + +If the command has user-visible behavior worth testing end-to-end (e.g., output structure), add a test here. For a simple list command, the unit tests and binary parity tests are usually sufficient. + +#### 4. Full verification + +```powershell +npm test # All Node.js tests pass +dotnet build src\dotnet # C# builds with 0 errors +dotnet publish src\dotnet\OutlookCli.csproj -r win-x64 --self-contained /p:PublishAot=true -o publish\win-x64 +npm test # Binary tests pass against fresh publish +``` + +--- + +## Paired Files — Must Stay in Sync + +When you modify behavior in one implementation, you **must** update the corresponding file in the other implementation. This table lists every paired file and what they share: + +| Node.js File | C# File | What They Share | +|---|---|---| +| `src/node/output/formatter.js` | `src/dotnet/Output/OutputFormatter.cs` | Column widths, field selection, date formatting, table layout | +| `src/node/security/crypto.js` | `src/dotnet/Security/CryptoService.cs` | Binary format `[salt:32][iv:12][authTag:16][ciphertext]`, PBKDF2 iteration count (310,000), key derivation params | +| `src/node/security/token-validator.js` | `src/dotnet/Security/TokenValidator.cs` | Forbidden scope list, JWT parsing logic, opaque token handling | +| `src/node/graph/mail.js` | `src/dotnet/Graph/MailService.cs` | API endpoints, query parameters, OData `$select` fields | +| `src/node/graph/calendar.js` | `src/dotnet/Graph/CalendarService.cs` | Calendar view endpoints, date range handling, timezone logic | +| `src/node/graph/contacts.js` | `src/dotnet/Graph/ContactsService.cs` | People API endpoints, search behavior | +| `src/node/graph/client.js` | `src/dotnet/Graph/GraphClient.cs` | Retry logic (401 clear+retry, 429 backoff), pagination, `userPath` pattern | +| `src/node/auth/token-cache.js` | `src/dotnet/Auth/TokenCacheHelper.cs` | Cache file naming (`cache-{alias}.enc`), encryption/decryption calls | +| `src/node/accounts/manager.js` | `src/dotnet/Accounts/AccountManager.cs` | `accounts.json` schema, alias resolution, default account logic | +| `src/node/db/database.js` | `src/dotnet/Database/OperationsDb.cs` | SQLite schema (tables, columns), migration versioning | +| `src/node/config.js` | `src/dotnet/Config/ConfigManager.cs` | Config resolution order, environment variable names, defaults | +| `src/node/errors.js` | `src/dotnet/Errors/OutlookCliException.cs` | Error categories, exit codes, user-facing message format | + +### The Sync Contract + +When you change behavior in any paired file, you **MUST** update the corresponding file in the other implementation. This is the fundamental contract of the dual-implementation architecture. + +The CI test suite catches **some** drift automatically — binary parity tests verify that both implementations accept the same command-line arguments, produce similar `--help` output, and return matching exit codes. However, **logic differences are not caught by automated tests**. For example, if you change the `$select` fields in a Graph API query in Node.js but forget to update C#, both implementations will still pass all tests independently — they'll just return different data from Microsoft Graph. Similarly, if you change column widths in `formatter.js` but not `OutputFormatter.cs`, the text output will differ between runtimes with no test failure. + +The only reliable defense is discipline: every PR that touches a paired file must include changes to both sides. Code reviewers should check the paired files table above and flag any one-sided changes. + +## Step-by-Step: Fixing a Bug + +1. **Reproduce** — Write a failing test that demonstrates the bug. This is the most important step — if you can't reproduce it in a test, you may not understand the root cause. +2. **Fix in Node.js** — Make the test pass. Node.js is the reference implementation; fix it there first. +3. **Check if C# has the same bug** — It almost always does. Both implementations share the same architecture, so architectural bugs (wrong API endpoint, incorrect error handling, missing retry logic) appear in both. +4. **Fix in C#** — Mirror the fix. The C# fix may look different syntactically but should address the same root cause. +5. **Rebuild NativeAOT** — The binary test suite runs against the published binary, not `dotnet run`. If you skip this step, your fix isn't tested in the production binary. +6. **Run all tests** — `npm test` twice (before and after C# rebuild) to catch regressions. + +## Before You Commit — Checklist + +Use this checklist for every commit that changes behavior: + +``` +□ All npm test pass (~750 tests) +□ If C# changed: dotnet build succeeds +□ If C# changed: NativeAOT binary republished +□ If C# changed: npm test passes AGAIN with fresh binary +□ New behavior has tests (both unit and binary if applicable) +□ Error messages are actionable ("Run `outlook-cli X` to fix" not just "Error") +□ No secrets, tokens, or personal data in code or test fixtures +□ If adding new JSON types in C#: registered in OutlookCliJsonContext.cs +□ If changing file formats: both implementations read/write the same format +□ If changing CLI options: both implementations accept the same flags +□ Documentation updated if user-facing behavior changed +``` + +## Error Handling Conventions + +### Node.js + +```javascript +import { NetworkError, GraphApiError, AuthError } from '../errors.js'; + +// Network errors (DNS, connection, timeout) +throw new NetworkError('DNS resolution failed for graph.microsoft.com', { + code: 'ENOTFOUND', + hostname: 'graph.microsoft.com' +}); + +// Graph API errors (4xx, 5xx) +throw GraphApiError.fromResponse(response, body); + +// Auth errors +throw new AuthError('Token expired', { + suggestedAction: 'Run `outlook-cli auth login` to re-authenticate' +}); + +// Validation errors — throw immediately with actionable message +if (!messageId) { + throw new Error('Message ID is required. Use `outlook-cli mail inbox` to find message IDs.'); +} +``` + +### C# + +```csharp +// Network errors +catch (HttpRequestException ex) { + Console.Error.WriteLine($"❌ Network error: {ex.Message}"); + Console.Error.WriteLine(" Check your internet connection and try again."); + return 1; +} + +// Auth errors +catch (MsalUiRequiredException) { + Console.Error.WriteLine("❌ Authentication expired."); + Console.Error.WriteLine($" Fix: Run `outlook-cli auth login --account {account}` to re-authenticate."); + return 1; +} +``` + +### Key Principles + +- **Throw immediately** for validation errors — don't let invalid state propagate +- **Actionable messages** — always tell the user what to do next +- **Network retry**: 401 → clear cache + retry once; 429 → wait Retry-After; 5xx → throw +- **Decryption failures** → warn and start fresh (user re-authenticates) +- **Never swallow errors silently** — if you catch, either handle meaningfully or rethrow + +## Node.js Module Conventions + +- **ESM only** (`"type": "module"` in package.json) +- **File extensions required** in imports: `import { foo } from './bar.js'` (not `'./bar'`) +- **Named exports only** — never use `export default` +- **Factory functions** for stateful objects: `createGraphClient()`, not `new GraphClient()` +- **Singleton with reset** for global state: `getAccountManager()` / `resetAccountManager()` + +## C# Conventions + +- **All JSON types** registered in `OutlookCliJsonContext.cs` +- **GraphClient accepts `string? bodyJson`** (pre-serialized JSON, avoids reflection) +- **Use `JsonObject`/`JsonArray`** from `System.Text.Json.Nodes` for building request bodies +- **System.CommandLine at stable 2.0.5** — see `agents/BUILDING.md` for the API reference and `agents/COMMON-PITFALLS.md` §19 for migration notes +- **`Dns.GetHostName()`** for hostname (not `Environment.MachineName` which uppercases on Windows — breaks crypto interop) + +## Security Constraints — CRITICAL + +- **`Mail.ReadWrite.All` is FORBIDDEN** — application-level access to all mailboxes is never allowed +- **`Mail.Send` and `Mail.Send.Shared` are both ALLOWED** — individual send permissions are fine +- **Token validation runs on every request** — this is defense-in-depth, never bypass it +- **Never log or display tokens** — not even in verbose mode +- **AES-256-GCM IV must be 12 bytes** (not 16) for .NET AesGcm compatibility + +## Before-You-Commit Checklist + +Run through this checklist before pushing any change. Not every item applies to every PR — check the ones relevant to your change. + +``` +- [ ] All `npm test` tests pass (~750 tests) +- [ ] If Node.js code changed: relevant unit tests added/updated in `test/node/` +- [ ] If C# code changed: `dotnet build src\dotnet` succeeds with 0 errors +- [ ] If C# code changed: NativeAOT binary republished (`dotnet publish ... -o publish\win-x64`) +- [ ] If C# code changed: `npm test` passes again (binary tests run against fresh publish) +- [ ] If new type added in C#: registered in `OutlookCliJsonContext.cs` +- [ ] If CLI interface changed: both Node.js and C# implementations updated +- [ ] If CLI interface changed: `test/shared/cli.test.js` updated +- [ ] If CLI interface changed: `test/dotnet/native-binary.test.js` updated +- [ ] If output format changed: `formatter.js` and `OutputFormatter.cs` both updated +- [ ] If error messages changed: same message in both runtimes +- [ ] If file format changed (accounts.json, etc.): both runtimes read/write the new format +- [ ] If crypto or auth changed: real-world validation performed (see REAL-WORLD-VALIDATION.md) +- [ ] If security-sensitive: token-validator.js and TokenValidator.cs both updated +- [ ] No secrets, tokens, or credentials in code or comments +- [ ] Test names start with "should" +- [ ] Temp directories cleaned up in afterEach +- [ ] No `export default` in Node.js modules +- [ ] All imports use `.js` extensions +``` + +## Documentation Update Rule — MANDATORY + +When any command, option, flag, or output format changes, the corresponding documentation in `docs/usage/` **must** be updated in the same commit. The per-command reference files are the user-facing truth: + +| Changed | Update | +|---|---| +| New command or subcommand | Add to the relevant `docs/usage/*.md` file | +| New option on existing command | Add to the option table with description, default, and example | +| Changed option name (e.g., `--tz` → `--timezone`) | Update all references in docs | +| Changed output format | Update example output in docs | +| Changed error messages | Update troubleshooting tables | +| New feature (watch mode, etc.) | Add detailed section with examples | + +**Users copy/paste from these docs.** If the docs say `--reply-all` but the CLI actually uses `--all`, users get errors and lose trust. Every example must actually work when pasted into a terminal. + +The per-command files in `docs/usage/` are: +- `README.md` — Global options, short IDs, output formats +- `mail.md` — All mail subcommands +- `calendar.md` — All calendar subcommands +- `contacts.md` — Contacts search + alias management +- `auth.md` — Login/logout/status +- `account.md` — Account management +- `watch.md` — Watch modes and options +- `diagnostics.md` — Doctor, log, upgrade, telemetry diff --git a/agents/README.md b/agents/README.md new file mode 100644 index 0000000..c4f9682 --- /dev/null +++ b/agents/README.md @@ -0,0 +1,36 @@ +# Agent Guide — outlook-cli + +This directory contains everything an AI agent needs to understand, modify, build, test, and validate the outlook-cli codebase. It is the authoritative reference for agents working on this project. + +## Documents + +| File | Purpose | +|---|---| +| [ARCHITECTURE.md](ARCHITECTURE.md) | System architecture, request flows, module map | +| [BUILDING.md](BUILDING.md) | How to build, test, publish — both runtimes | +| [MAKING-CHANGES.md](MAKING-CHANGES.md) | How to make code changes and keep Node.js + C# in sync | +| [TESTING.md](TESTING.md) | Test infrastructure, patterns, writing new tests | +| [REAL-WORLD-VALIDATION.md](REAL-WORLD-VALIDATION.md) | How to verify changes work beyond unit tests | +| [COMMON-PITFALLS.md](COMMON-PITFALLS.md) | Mistakes agents make repeatedly — read this first | + +## Quick Start for Agents + +```bash +# 1. Install and verify +npm install +npm test # All ~750 tests should pass + +# 2. Build C# implementation +dotnet build src\dotnet\OutlookCli.csproj + +# 3. Publish NativeAOT binary (Windows) +dotnet publish src\dotnet\OutlookCli.csproj -r win-x64 --self-contained /p:PublishAot=true -o publish\win-x64 + +# 4. Verify both runtimes +node bin\outlook-cli.js --version +publish\win-x64\outlook-cli.exe --version +``` + +## The One Rule + +**Every change must work in both Node.js and C#.** If you change behavior in one, you must mirror it in the other. If you add a test for Node.js, the NativeAOT binary tests should also pass. Run `.\build.ps1 all -Publish` to verify everything. diff --git a/agents/REAL-WORLD-VALIDATION.md b/agents/REAL-WORLD-VALIDATION.md new file mode 100644 index 0000000..c6475ef --- /dev/null +++ b/agents/REAL-WORLD-VALIDATION.md @@ -0,0 +1,513 @@ +# Real-World Validation — outlook-cli + +Unit tests with mocks are necessary but not sufficient. This document covers how to verify changes work against real Microsoft Graph APIs, real authentication flows, and real-world network conditions. + +## Why This Matters + +The user has repeatedly encountered problems that only appear in real-world usage: + +- Login process hung after successful authentication (mocks don't test server shutdown) +- Network errors produced stack traces instead of helpful messages (mocks return clean errors) +- Lost authentication showed cryptic errors instead of re-login instructions +- Polling intervals that work fine in tests are unworkable for real agent integration +- Encryption interop bugs between Node.js and C# only appear with real cache files + +**Rule**: If a change touches auth, network handling, encryption, or user-facing errors, validate with real Microsoft accounts — not just mocks. + +## Setting Up for Real-World Testing + +### Prerequisites + +1. **A real Microsoft account** (personal @outlook.com works fine) +2. **An Azure App Registration** with the correct API permissions +3. **Both runtimes ready**: + ```bash + node bin\outlook-cli.js --version # Node.js + publish\win-x64\outlook-cli.exe --version # NativeAOT + ``` + +### Initial Auth Setup + +```bash +# Authenticate with Node.js +node bin\outlook-cli.js auth login --account test + +# Verify the token cache was created +dir %USERPROFILE%\.outlook-cli\cache-test.enc + +# Now verify C# can read the same cache (interop test!) +publish\win-x64\outlook-cli.exe auth status --account test +``` + +If C# can show auth status using a cache encrypted by Node.js, encryption interop is working. + +## Validation Scenarios + +### 1. Authentication Lifecycle + +```bash +# Login with Node.js (should open browser, complete, and exit cleanly) +node bin\outlook-cli.js auth login --account test + +# Verify status with both runtimes +node bin\outlook-cli.js auth status --account test --json +publish\win-x64\outlook-cli.exe auth status --account test --json + +# Test token refresh (tokens expire after 1 hour) +# Wait 1+ hour, then: +node bin\outlook-cli.js mail inbox --account test +# Should silently refresh — no re-login prompt + +# Logout and verify +node bin\outlook-cli.js auth logout --account test +node bin\outlook-cli.js mail inbox --account test +# Should show: "No valid token for account test. Fix: Run outlook-cli auth login..." +``` + +**What to watch for:** +- Login should NOT hang after completing authentication +- Browser should open, user signs in, CLI exits promptly +- Device code flow (`--device-code`) should show clear instructions +- Logout + inbox should give a clear re-login message, not a crash + +### 2. Network Error Handling + +```bash +# Simulate no network (disconnect WiFi/Ethernet, then): +node bin\outlook-cli.js mail inbox --account test +# Should show: "Network error: Could not connect to graph.microsoft.com" +# NOT: "TypeError: fetch failed" or a stack trace + +publish\win-x64\outlook-cli.exe mail inbox --account test +# C# should show similar: "Network error: ..." with action suggestion + +# Simulate DNS failure (point graph.microsoft.com to 0.0.0.0 in hosts, then): +node bin\outlook-cli.js mail inbox --account test +# Should show DNS-specific message + +# Re-enable network and verify recovery: +node bin\outlook-cli.js mail inbox --account test +# Should work normally +``` + +**What to watch for:** +- No raw `Error`, `TypeError`, `FetchError`, or `HttpRequestException` shown to user +- Error messages include what went wrong AND what to do about it +- Recovery after re-enabling network — no stuck state + +### 3. Cross-Runtime Interoperability + +```bash +# Encrypt with Node.js, decrypt with C# +node bin\outlook-cli.js auth login --account interop-test +publish\win-x64\outlook-cli.exe mail inbox --account interop-test +# Should work — C# reads cache encrypted by Node.js + +# Encrypt with C#, decrypt with Node.js +publish\win-x64\outlook-cli.exe auth login --account interop-test2 +node bin\outlook-cli.js mail inbox --account interop-test2 +# Should work — Node.js reads cache encrypted by C# + +# Verify account registry is shared +node bin\outlook-cli.js account list +publish\win-x64\outlook-cli.exe account list +# Both should show the same accounts +``` + +### 4. Command Output Parity + +```bash +# Compare outputs side by side +node bin\outlook-cli.js mail inbox --account test --json > inbox-node.json +publish\win-x64\outlook-cli.exe mail inbox --account test --json > inbox-dotnet.json + +# JSON structure should be identical (field names, nesting, types) +# Values will differ by timing (receivedDateTime changes between calls) + +# Text format comparison +node bin\outlook-cli.js mail inbox --account test > inbox-node.txt +publish\win-x64\outlook-cli.exe mail inbox --account test > inbox-dotnet.txt +``` + +### 5. Watch Mode (All Three Modes) + +```bash +# Poll mode (default) — verify delta queries work +node bin\outlook-cli.js watch --account test --interval 30 --jsonl + +# Fast-poll mode — verify adaptive polling +node bin\outlook-cli.js watch --mode fast-poll --account test --interval 10 --jsonl +# Send yourself an email, watch for the event within ~5-10 seconds + +# Webhook mode (requires cloudflared) +node bin\outlook-cli.js watch --mode webhook --account test --jsonl +# Send yourself an email, watch for the event within ~1-5 seconds + +# C# fast-poll mode +publish\win-x64\outlook-cli.exe watch --mode fast-poll --account test --interval 10 --jsonl +``` + +**What to watch for:** +- Initial sync completes without errors +- Delta tokens persist across restarts (`~/.outlook-cli/delta-test.json`) +- New emails appear in the JSONL output +- Ctrl+C exits cleanly (no stuck process, no orphaned tunnel) +- Adaptive polling speeds up after detecting changes, slows during quiet periods + +### 6. Error Recovery After Auth Expiry + +```bash +# Force a stale token by manually corrupting the cache: +# (Or wait for natural expiry — refresh tokens last 90 days for personal accounts) + +# Delete the cache file to simulate lost auth: +del %USERPROFILE%\.outlook-cli\cache-test.enc + +node bin\outlook-cli.js mail inbox --account test +# Should show clear message about missing/expired auth with re-login instructions + +publish\win-x64\outlook-cli.exe mail inbox --account test +# Same clear message from C# +``` + +### 7. Delegate Access + +```bash +# If you have shared mailbox access: +node bin\outlook-cli.js mail inbox --as shared@example.com --account test +publish\win-x64\outlook-cli.exe mail inbox --as shared@example.com --account test + +# Without access — should show 403 with clear message +node bin\outlook-cli.js mail inbox --as no-access@example.com --account test +``` + +## Detailed Test Scenarios with Expected Output + +These scenarios show exact commands with the output format you should expect. Use these to verify that both the data and the presentation are correct. Run each command with both runtimes (Node.js and C# binary) to verify parity. + +### Auth Commands + +```bash +# Login — expected: browser opens, user authenticates, CLI prints success +node bin\outlook-cli.js auth login --account test +# Expected output: +# ✓ Logged in as user@outlook.com (account: test) +# Token cached at ~/.outlook-cli/cache-test.enc +# Process should exit within 2-3 seconds after browser auth completes + +# Status — expected: shows account info and token expiry +node bin\outlook-cli.js auth status --account test --json +# Expected JSON structure: +# { "account": "test", "email": "user@outlook.com", "tokenExpiry": "2024-...", "scopes": ["Mail.Read", ...] } + +# Status (text format) — expected: human-readable summary +node bin\outlook-cli.js auth status --account test +# Expected output: +# Account: test +# Email: user@outlook.com +# Status: Authenticated +# Expires: 2024-01-15 10:30:00 (in 58 minutes) +# Scopes: Mail.Read, Mail.Send, Calendars.Read, Contacts.Read + +# Device code login — for headless environments (SSH, containers) +node bin\outlook-cli.js auth login --device-code --account test +# Expected: prints a URL and a code, user visits URL and enters code +# "To sign in, use a web browser to open https://microsoft.com/devicelogin and enter the code ABCDEFGH" +# Process polls for completion, then prints the same success message as browser login + +# Logout — expected: removes token cache and confirms +node bin\outlook-cli.js auth logout --account test +# Expected output: +# ✓ Logged out of account: test +# Cache file removed: ~/.outlook-cli/cache-test.enc + +# C# parity — all auth commands should produce identical output +publish\win-x64\outlook-cli.exe auth status --account test --json +# Expected: identical JSON structure to Node.js output above +``` + +### Mail Commands + +```bash +# Inbox — expected: formatted table with subject, from, date +node bin\outlook-cli.js mail inbox --account test --top 3 +# Expected output (text format): +# ID From Subject Date +# AAMk... sender@example.com Meeting notes 2024-01-15 09:30 +# AAMk... boss@company.com Q4 Review 2024-01-15 08:15 +# AAMk... team@company.com Sprint planning 2024-01-14 16:45 + +# Inbox with JSON — expected: raw Graph API response with selected fields +node bin\outlook-cli.js mail inbox --account test --top 3 --json +# Expected: JSON array with id, subject, from, receivedDateTime, isRead, importance fields + +# Inbox with --unread — expected: only unread messages +node bin\outlook-cli.js mail inbox --account test --unread --top 5 +# Expected: same table format, but only messages with isRead=false + +# Inbox from a specific folder +node bin\outlook-cli.js mail inbox --account test --folder "Sent Items" --top 3 +# Expected: messages from Sent Items folder (note: "Sent Items" needs quotes) + +# Read — expected: full message header + body +node bin\outlook-cli.js mail read AAMkAG... --account test +# Expected: +# From: sender@example.com +# To: user@outlook.com +# Subject: Meeting notes +# Date: 2024-01-15 09:30 +# +# Here are the notes from today's meeting... + +# Read with --html — expected: raw HTML body output +node bin\outlook-cli.js mail read AAMkAG... --account test --html +# Expected: raw HTML content (useful for piping to a file or browser) + +# Search — expected: filtered results matching the query +node bin\outlook-cli.js mail search "quarterly report" --account test --top 5 +# Expected: same table format as inbox, filtered to matching messages +# Graph API uses KQL (Keyword Query Language) for search + +# Folders — expected: list of all mail folders +node bin\outlook-cli.js mail folders --account test +# Expected: +# Name Unread Total +# Inbox 12 847 +# Sent Items 0 234 +# Drafts 3 3 +# Deleted Items 0 156 + +# Draft + send — expected: two-step message creation +node bin\outlook-cli.js mail draft --to "colleague@example.com" --subject "Test" --body "Hello" --account test +# Expected: "Draft created: AAMkAG..." +node bin\outlook-cli.js mail send AAMkAG... --account test +# Expected: "Message sent successfully" + +# Draft with --send — expected: create and send in one step +node bin\outlook-cli.js mail draft --to "colleague@example.com" --subject "Test" --body "Hello" --send --account test +# Expected: "Message sent successfully" + +# Move — expected: message moved to target folder +node bin\outlook-cli.js mail move AAMkAG... --folder "Archive" --account test +# Expected: "Message moved to Archive" +``` + +### Calendar Commands + +```bash +# Today — expected: today's events with times and locations +node bin\outlook-cli.js calendar today --account test +# Expected output: +# Time Subject Location +# 09:00-09:30 Stand-up Teams +# 14:00-15:00 Design Review Conference Room B +# (no events message if calendar is empty for today) + +# Week — expected: events grouped by day for the current week +node bin\outlook-cli.js calendar week --account test +# Expected: events grouped under date headers (Mon, Tue, etc.) +# ── Monday, January 15 ── +# 09:00-09:30 Stand-up Teams +# ── Tuesday, January 16 ── +# 10:00-11:00 1:1 with Manager Office + +# Range — expected: events within a custom date range +node bin\outlook-cli.js calendar range --start 2024-01-15 --end 2024-01-20 --account test +# Expected: same grouped format as week, for the specified range + +# View — expected: detailed view of a single event +node bin\outlook-cli.js calendar view AAMkAG... --account test +# Expected: full event details including attendees, body, recurrence + +# List calendars — expected: all calendars in the account +node bin\outlook-cli.js calendar list-calendars --account test +# Expected: +# Name Owner Color +# Calendar user@outlook.com Blue +# Work user@outlook.com Green + +# Create — expected: new event created +node bin\outlook-cli.js calendar create --subject "Team Lunch" --start "2024-01-20 12:00" --end "2024-01-20 13:00" --account test +# Expected: "Event created: AAMkAG..." +``` + +### Contacts Commands + +```bash +# Search — expected: matching contacts with email addresses +node bin\outlook-cli.js contacts search "john" --account test +# Expected output: +# Name Email +# John Smith john.smith@company.com +# John Doe jdoe@example.com +# Note: work accounts search the organization directory; personal accounts search contacts only + +# Alias management — expected: local name-to-email mapping +node bin\outlook-cli.js contacts alias set jsmith john.smith@company.com +# Expected: "Alias set: jsmith → john.smith@company.com" + +node bin\outlook-cli.js contacts alias list +# Expected: +# Alias Email +# jsmith john.smith@company.com +# boss manager@company.com + +# Using aliases in other commands +node bin\outlook-cli.js mail draft --to jsmith --subject "Hello" --body "Hi John" --account test +# Expected: resolves jsmith to john.smith@company.com and creates draft +``` + +### Cross-Runtime Parity Checks + +For every command above, run the same command with the C# binary and verify the output matches: + +```bash +# Side-by-side JSON comparison +node bin\outlook-cli.js mail inbox --account test --top 3 --json > node-output.json +publish\win-x64\outlook-cli.exe mail inbox --account test --top 3 --json > dotnet-output.json +# JSON structure (field names, nesting) should be identical +# Values may differ slightly due to timing (receivedDateTime between requests) + +# Side-by-side text comparison +node bin\outlook-cli.js calendar today --account test > node-calendar.txt +publish\win-x64\outlook-cli.exe calendar today --account test > dotnet-calendar.txt +# Table layout, column widths, and date formatting should match +``` + +## Personal vs Work Account Differences + +Understanding the differences between personal Microsoft accounts and work/school accounts is critical for testing and for interpreting errors correctly. + +### Token Format + +- **Work/school accounts** (Azure AD / Entra ID): Return JWT access tokens with a `scp` claim containing granted scopes. The token validator in `src/node/security/token-validator.js` can decode and verify scopes directly from the JWT payload. +- **Personal Microsoft accounts** (@outlook.com, @hotmail.com): Return opaque access tokens (not JWT format). The token validator falls back to checking scopes from the MSAL result object's `scopes` array only. Attempting to decode the token as JWT will fail — this is expected and handled gracefully by `validateTokenScopes()`. + +### Tenant ID + +- **Work accounts**: Use a specific tenant ID (GUID) like `72f988bf-86f1-41af-91ab-2d7cd011db47`. Set with `--tenant ` during login or account setup. +- **Personal accounts**: Use the special tenant `consumers`. The CLI sets this automatically when no tenant is specified and login redirects to the consumer endpoint. +- **Multi-tenant apps**: Use `common` tenant, which allows both account types. This is the default if no tenant is configured in `accounts.json`. + +### Permission Model + +- **Work accounts**: Scopes are granted by admin consent (organization-wide) or user consent (individual). Admin may restrict which scopes users can consent to. The `accounts.json` permission system (`allowed_scopes`, `forbidden_scopes`, `+`/`-` modifiers) applies on top of Azure AD consent. +- **Personal accounts**: User directly consents to all requested scopes. No admin consent concept. Some Graph API endpoints (e.g., organizational contacts, shared mailboxes) are not available. The `forbidden_scopes` list in `accounts.json` still applies — `Mail.ReadWrite.All` is always forbidden regardless of account type. + +### API Differences + +- **Shared/delegate mailboxes** (`--as` flag): Only available with work accounts. The `GraphClient.userPath` returns `/users/{email}` for delegate access. Personal accounts will return 403 Forbidden from the Graph API. +- **Calendar view**: Works the same for both, but personal accounts may have different timezone handling. Always test with `--json` to verify timezone offsets. +- **People API** (contacts search): Returns different result sets — work accounts include the organization directory via the `/people` endpoint, personal accounts only return the user's `/contacts` folder. +- **Delta queries** (watch mode): Both account types support delta queries for mail, but the delta token format and expiration may differ. Delta tokens for personal accounts tend to expire faster. + +### Implications for Testing + +- Always test with both account types if possible. Many bugs only manifest with one type. +- Token validation code in `src/node/security/token-validator.js` must handle both JWT and opaque tokens — the `isPersonalAccount` check determines the validation path. +- Error messages should be helpful for both account types. Don't assume JWT structure in error output — a message like "Invalid scope in token: undefined" means the code tried to read `scp` from an opaque token. +- The `send_to` whitelist in `accounts.json` works identically for both account types — it restricts recipients at the CLI level before the API call is made. +- Cross-runtime tests (Node.js ↔ C#) should be run with both account types, since the encrypted cache format is the same but token content differs. + +## Quick Smoke Test (2 Minutes) + +If you don't have time for full validation, run this minimum set: + +```bash +# Both runtimes show version +node bin\outlook-cli.js --version +publish\win-x64\outlook-cli.exe --version + +# Auth status (requires prior login) +node bin\outlook-cli.js auth status --account test +publish\win-x64\outlook-cli.exe auth status --account test + +# Read inbox (tests full request path: auth → token → Graph API → output) +node bin\outlook-cli.js mail inbox --account test --top 5 +publish\win-x64\outlook-cli.exe mail inbox --account test --top 5 + +# Error case: invalid account +node bin\outlook-cli.js mail inbox --account nonexistent +publish\win-x64\outlook-cli.exe mail inbox --account nonexistent +# Both should show account-not-found error with suggestion +``` + +## Debugging Real-World Failures + +### Verbose Mode + +Add `--verbose` to any command for detailed output: + +```bash +node bin\outlook-cli.js mail inbox --account test --verbose +``` + +This shows: +- Token acquisition details (cache hit vs refresh) +- HTTP request URLs +- Response status codes +- Retry attempts + +### Environment Variables + +```bash +# Enable MSAL debug logging +AZURE_LOG_LEVEL=verbose node bin\outlook-cli.js auth login + +# Enable telemetry (in-memory + SQLite) +OUTLOOK_CLI_TELEMETRY=1 node bin\outlook-cli.js mail inbox --account test + +# Override token cache passphrase +OUTLOOK_CLI_PASSPHRASE=debug-passphrase node bin\outlook-cli.js auth login +``` + +### Common Real-World Failures and Causes + +| Symptom | Likely Cause | Fix | +|---|---|---| +| Login hangs after auth | Server keep-alive not closed | Use `closeAllConnections()` on server | +| "fetch failed" with no details | Network error not caught | Wrap fetch in try/catch with NetworkError | +| "Cannot read property of undefined" | Token result assumed non-null | Check `result` before accessing `.accessToken` | +| C# can't decrypt Node.js cache | IV size mismatch (16 vs 12) | Use 12-byte IV for AES-GCM | +| C# hostname differs from Node.js | `Environment.MachineName` uppercases | Use `Dns.GetHostName()` instead | +| JSON parse error in C# | Missing type in JsonContext | Register type in `OutlookCliJsonContext.cs` | +| Binary works, published binary crashes | Stale binary — not republished | Run `dotnet publish` after code changes | + +## What Real-World Testing Catches That Mocks Don't + +Mock-based unit tests are essential for fast iteration and CI, but they systematically miss certain classes of bugs. Every example below is drawn from real outlook-cli development experience. + +### Login Flow Hang (setTimeout Not Cleared) + +- **What happened**: After successful browser authentication, the CLI hung indefinitely instead of exiting. The user had to Ctrl+C out of the process. +- **Root cause**: The local HTTP server started by `src/node/auth/msal-client.js` for the OAuth redirect callback had keep-alive connections. Even after receiving the auth code and shutting down the server with `server.close()`, active keep-alive connections prevented the Node.js event loop from exiting. +- **Fix**: Call `server.closeAllConnections()` after `server.close()` to forcibly terminate keep-alive connections, allowing the event loop to drain and the process to exit. +- **Why mocks missed it**: Mock tests in `test/node/auth/` never start a real HTTP server. They stub the MSAL `acquireTokenInteractive` method, which returns a mocked token result immediately. The test completes cleanly because there are no real TCP connections to keep the event loop alive. + +### HTML Error Pages from Graph API + +- **What happened**: When the Graph API returned a 503 Service Unavailable during an outage, the response body was an HTML error page (not JSON). The `response.json()` call in `src/node/graph/client.js` threw an unhandled `SyntaxError`, showing the user a raw stack trace instead of a helpful error message. +- **Root cause**: The `GraphClient` called `response.json()` without first checking the `content-type` header. Microsoft Graph sometimes returns HTML error pages during outages or when intermediate proxies intercept the request. +- **Fix**: Check the `content-type` header before parsing. If not `application/json`, treat the response body as a text error message and throw a descriptive error like "Graph API returned non-JSON response (503): Service Unavailable". +- **Why mocks missed it**: Mock responses in `test/node/graph/` always return well-structured JSON error objects matching the Graph API error schema (`{ error: { code: "ServiceUnavailable", message: "..." } }`). Real API responses during outages are unpredictable — they can be HTML, empty, or truncated. + +### Encrypted Cache Files That Decrypt Differently Cross-Platform + +- **What happened**: A token cache encrypted by Node.js on Windows couldn't be decrypted by the C# implementation on the same machine. The C# side logged "Decryption failed, starting with empty cache" and the user had to re-authenticate. +- **Root cause**: The auto-derived passphrase (used when `OUTLOOK_CLI_PASSPHRASE` is not set) incorporates the machine hostname. Node.js `os.hostname()` returns the hostname in its original case (e.g., `MyWorkstation`), while C# `Environment.MachineName` returns it in UPPERCASE (`MYWORKSTATION`). The different passphrases produced different PBKDF2-derived encryption keys, making cross-runtime decryption impossible. +- **Fix**: The C# implementation switched from `Environment.MachineName` to `Dns.GetHostName()`, which preserves the original case and matches Node.js `os.hostname()` behavior. This is implemented in the crypto module of `src/dotnet/`. +- **Why mocks missed it**: Crypto tests in `test/node/security/crypto.test.js` and the C# test suite both use hardcoded passphrases like `"test-passphrase"`. The hostname derivation logic in the passphrase generation path is never exercised by mocks, so the case mismatch was invisible. + +### Keep-Alive Connections Preventing server.close() + +- **What happened**: In watch mode with webhooks (`--mode webhook`), stopping the watcher with Ctrl+C left the HTTP server running. The process wouldn't exit, requiring a force-kill (`taskkill /F` on Windows, `kill -9` on Linux). +- **Root cause**: Same fundamental issue as the login flow hang. HTTP/1.1 connections default to keep-alive. The `server.close()` call in the shutdown handler stops accepting new connections but waits for existing connections to close naturally, which can take minutes depending on the client's keep-alive timeout. +- **Fix**: Track active connections with `server.on('connection', socket => sockets.add(socket))` and destroy them all in the shutdown handler: `sockets.forEach(s => s.destroy())`. This ensures the server fully shuts down within seconds of Ctrl+C. +- **Why mocks missed it**: Webhook tests in `test/node/` mock the HTTP server entirely. They test the event processing logic (delta query → parse changes → execute actions) but never create real TCP connections. The mock server's `close()` completes instantly because there are no connections to wait for. + +### Rate Limiting (429) with Retry-After Header + +- **What happened**: During heavy inbox pagination (e.g., `mail inbox --top 500` which requires multiple Graph API requests), the Graph API returned 429 Too Many Requests. The initial retry implementation retried immediately, causing more 429 responses in a cascade that eventually exhausted all retry attempts. +- **Root cause**: The `Retry-After` header sent by Microsoft Graph wasn't being read. The retry logic in `src/node/graph/client.js` used a fixed 1-second delay between retries instead of respecting the server's requested wait time (typically 5-30 seconds for Graph API throttling). +- **Fix**: Parse the `Retry-After` header — it can be either a number of seconds (`Retry-After: 10`) or an HTTP-date (`Retry-After: Thu, 01 Jan 2024 00:00:10 GMT`). The `GraphClient` now reads the header and waits the specified duration before retrying. +- **Why mocks missed it**: Mock tests in `test/node/graph/` do test the 429 retry path — they return a 429 with a `Retry-After: 1` header and verify the retry happens. But vitest's fake timers (`vi.useFakeTimers()`) make `setTimeout` resolve instantly. The test passed, but the actual header parsing code had a bug: it read `headers['retry-after']` (lowercase) while the mock set `headers['Retry-After']` (title case). Real Graph API responses use title case, and the `Headers` object in Node.js `fetch` normalizes to lowercase — a mismatch the mock never exposed. diff --git a/agents/TESTING.md b/agents/TESTING.md new file mode 100644 index 0000000..472f394 --- /dev/null +++ b/agents/TESTING.md @@ -0,0 +1,1239 @@ +# Testing — outlook-cli + +## Test Framework + +**Vitest 4.x** with Jest-compatible API. Config in `vitest.config.js` (JSON reporter for test dashboard). The project is ESM (`"type": "module"`). + +```bash +npm test # Run all tests (unit only) +npm run test:verbose # With detailed reporter +npx vitest run test/unit/graph/mail.test.js # Single file +npx vitest run -t "should handle network" # Pattern match +npx vitest run test/unit/graph/ # All in directory +npm run test:watch # Watch mode (re-runs on save) +npm run test:coverage # Coverage report +npm run test:report # Run tests + generate dashboard report +npm run test:report:json # Dashboard report in JSON format +npm run test:report:markdown # Dashboard report in markdown (for PRs) +npm run test:ci # CI mode: tests + JSON report +``` + +## Test Reporting Dashboard + +After running `npm run test:report`, a dashboard is generated showing: +- **Summary**: total/passed/failed/skipped, overall duration +- **Per-file breakdown**: test count, pass/fail, duration per file +- **Top 10 slowest tests**: helps identify Graph API bottlenecks +- **Category counts**: unit vs integration vs stress/soak/performance +- **Failure details**: full error messages for failed tests + +The report generator is at `scripts/test-report.js`. Raw JSON data is written to `test-results/vitest-report.json`. + +## Test Fixtures + +Realistic email bodies for testing are in `test/fixtures/email-bodies/`: +- `newsletter.html` — marketing email with images, tables, CSS +- `corporate-reply.html` — Outlook reply chain with MsoNormal classes +- `invoice.html` — structured HTML with tables, addresses, payment details +- `plain-text.txt` — professional multi-paragraph email +- `unicode-intl.txt` — Japanese, Chinese, French, Arabic, emoji + +## Test Organization + +``` +test/unit/ → Unit tests for Node.js modules (mirrors src/node/ structure) +test/integration/ → Real API tests against a live Microsoft Graph account + Gated behind OUTLOOK_CLI_E2E=1 environment variable +test/shared/ → CLI integration tests (work with either runtime, offline) +test/stress/ → Scale, performance, integrity, soak tests +``` + +### Integration tests (test/integration/) + +Integration tests run against a real Microsoft Graph account and validate +end-to-end behavior that unit tests cannot catch (auth flows, eventual +consistency, JSON shape differences between runtimes). Each test file has a +comprehensive JSDoc header explaining: + +- **WHAT** it tests (commands, API endpoints, scenarios) +- **WHY** it exists (what bugs/regressions it catches) +- **HOW** to debug failures (common causes, manual reproduction steps) +- **CROSS-RUNTIME** notes (JSON shape differences between Node.js and C#) +- **Graph API citations** (links to relevant Microsoft documentation) + +**Running integration tests:** + +```bash +# Prerequisites +export OUTLOOK_CLI_E2E=1 +export OUTLOOK_CLI_TEST_ACCOUNT=ac-jstall-ms + +# Run all integration tests (one file at a time to avoid rate limits) +for f in test/integration/*.test.js; do npx vitest run "$f"; done + +# Run against C# binary instead of Node.js +export OUTLOOK_CLI_BIN="publish/win-x64/outlook-cli.exe" +for f in test/integration/*.test.js; do npx vitest run "$f"; done + +# Run a single test file +npx vitest run test/integration/mail-read.test.js +``` + +**Integration test files and what they cover:** + +| File | Tests | What it covers | +|------|-------|----------------| +| `account-local.test.js` | 7 | Local commands: account list, auth status, log, doctor | +| `mail-read.test.js` | 11 | Inbox listing, message read, search, folder listing | +| `mail-lifecycle.test.js` | 10 | Full lifecycle: draft→send→read→flag→mark-read→move→search→delete | +| `mail-send.test.js` | 6 | Draft creation, send, double-send error | +| `mail-draft-options.test.js` | 8 | CC, BCC, importance, HTML body, body-file, auto-detection | +| `mail-reply-forward.test.js` | 5 | Reply/forward drafts, subject prefixes, "me" resolution | +| `mail-move-delete.test.js` | 5 | Move to folder, soft-delete, ID-change-on-move | +| `mail-errors.test.js` | 6 | Error handling for invalid IDs and missing args | +| `error-messages.test.js` | 5 | Cross-command error quality, stack trace suppression | +| `short-ids.test.js` | 5 | Short ID cache population, overwrite, resolution, range errors | +| `calendar-read.test.js` | 8 | Today/week/range views, timezone, list-calendars | +| `calendar-lifecycle.test.js` | 8 | Create→view→today-check→delete, location, body | +| `contacts.test.js` | 7 | Contact search, alias CRUD lifecycle | +| `output-formats.test.js` | 6 | JSON/markdown/HTML formats, file output | +| `account-readonly.test.js` | 11 | Read-only account: add, list, write guard (all write commands blocked), mode change | + +## Writing Tests + +### Basic Structure + +```javascript +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +describe('ModuleName', () => { + it('should do the expected thing', () => { + // arrange, act, assert + }); + + it('should handle edge case', () => { + // ... + }); +}); +``` + +**Naming convention**: `should ` — always start test names with "should". + +### Mocking External Dependencies + +Tests are **fully offline** — no network calls, no real Microsoft auth. Mock everything external: + +```javascript +// Mock an entire module (call BEFORE importing the module under test) +vi.mock('../../../src/node/auth/msal-client.js', () => ({ + createMsalClient: vi.fn(), + acquireTokenSilently: vi.fn(), +})); + +vi.mock('../../../src/node/security/token-validator.js', () => ({ + validateTokenScopes: vi.fn(), +})); + +// Stub global fetch +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +// Import AFTER mocking +const { createGraphClient } = await import('../../../src/node/graph/client.js'); +``` + +**Important**: `vi.mock()` calls are hoisted by vitest, but the mocked module path must be relative to the test file. Dynamic imports (`await import()`) happen after mocks are set up. + +### Mock Client Pattern + +Many Graph API tests need a mock GraphClient. Use a helper: + +```javascript +function createMockClient(overrides = {}) { + return { + userPath: '/me', + get: vi.fn().mockResolvedValue(overrides.get ?? { value: [] }), + post: vi.fn().mockResolvedValue(overrides.post ?? {}), + patch: vi.fn().mockResolvedValue(overrides.patch ?? {}), + delete: vi.fn().mockResolvedValue(overrides.delete ?? {}), + }; +} +``` + +### Mock HTTP Responses + +```javascript +function makeJsonResponse(data, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + headers: new Map([['content-type', 'application/json']]), + json: async () => data, + text: async () => JSON.stringify(data), + }; +} + +// Usage +mockFetch.mockResolvedValueOnce(makeJsonResponse({ value: [] })); +``` + +### Fake JWT Tokens + +For testing token validation without real Microsoft tokens: + +```javascript +function createFakeJwt(payload) { + const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url'); + const body = Buffer.from(JSON.stringify(payload)).toString('base64url'); + return `${header}.${body}.fake-signature`; +} + +// Work/school account token (JWT with scp claim) +const workToken = createFakeJwt({ sub: '123', scp: 'Mail.Read Mail.Send' }); + +// Personal account — returns opaque token (not JWT) +const personalToken = 'EwB4A8l6BAAURSN...opaque'; +``` + +### MSAL Token Result + +The `acquireTokenSilently()` function returns `{ result, reason }`: + +```javascript +// Successful token acquisition +acquireTokenSilently.mockResolvedValue({ + result: { + accessToken: 'mock-token', + scopes: ['Mail.Read', 'Mail.Send'], + expiresOn: new Date(Date.now() + 3600000), + account: { homeAccountId: 'test-id', username: 'user@example.com' }, + }, + reason: null, +}); + +// Failed — needs re-login +acquireTokenSilently.mockResolvedValue({ + result: null, + reason: 'interaction_required', +}); +``` + +### Temp Directories + +Tests that write to disk must create unique directories and clean up: + +```javascript +import { tmpdir } from 'node:os'; +import { mkdirSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; + +const testDir = join(tmpdir(), `outlook-cli-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + +beforeEach(() => { + mkdirSync(testDir, { recursive: true }); +}); + +afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); +}); +``` + +### Crypto Tests (Real Encryption) + +Crypto tests use **real encryption**, not mocks. This is intentional — it catches interoperability bugs: + +```javascript +import { encrypt, decrypt } from '../../../src/node/security/crypto.js'; + +it('should encrypt and decrypt roundtrip', () => { + const plaintext = 'Hello, secret!'; + const encrypted = encrypt(plaintext, 'test-passphrase'); + const decrypted = decrypt(encrypted, 'test-passphrase'); + expect(decrypted).toBe(plaintext); +}); + +it('should produce different ciphertext each time (random IV/salt)', () => { + const encrypted1 = encrypt('same', 'pass'); + const encrypted2 = encrypt('same', 'pass'); + expect(encrypted1.equals(encrypted2)).toBe(false); +}); +``` + +**Warning**: Each crypto test takes ~300ms due to PBKDF2 with 310K iterations. This is expected. + +## Annotated Test Examples + +Three complete, heavily commented test files that demonstrate outlook-cli testing patterns from simple unit tests to stress tests. + +### Example 1: Unit Test with vi.mock() — Testing `listMessages` + +This tests `listMessages` from `src/node/graph/mail.js`. It mocks the GraphClient and verifies that the function constructs the correct Microsoft Graph API URLs. + +```javascript +// test/node/graph/mail-list.test.js +// +// PURPOSE: Verify that listMessages() builds the correct Graph API URL, +// passes the right OData query parameters, and returns the +// response payload unchanged. + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// ── WHY vi.mock() BEFORE import ───────────────────────────────────── +// vi.mock() is HOISTED to the top of the file by vitest's transformer. +// Even though it appears here in the source, it actually executes before +// any import statement. This lets us replace the real module with a mock +// BEFORE the module-under-test tries to import it. +// +// In this test we don't need to mock the client module because +// listMessages() receives the client as a parameter — but if the +// module imported other dependencies (like token-validator), we would +// mock those here. + +// ── IMPORT AFTER MOCKS ────────────────────────────────────────────── +// Dynamic import ensures the module loads after all vi.mock() calls +// have been registered. For modules that have no hoisted mocks this +// is optional, but it's a good habit to always use dynamic import +// in test files for consistency. +const { listMessages } = await import('../../../src/node/graph/mail.js'); + +// ── MOCK CLIENT FACTORY ───────────────────────────────────────────── +// The GraphClient exposes get/post/patch/delete plus a userPath getter. +// This factory creates a minimal mock that satisfies the interface. +// Pass `overrides` to customize what the mock returns for each method. +function createMockClient(overrides = {}) { + return { + // userPath is '/me' for the authenticated user, or + // '/users/{email}' when using --as for delegate access. + userPath: '/me', + + // Each method is a vi.fn() so we can inspect calls later. + // Default return values match the shape of real Graph responses. + get: vi.fn().mockResolvedValue(overrides.get ?? { value: [] }), + post: vi.fn().mockResolvedValue(overrides.post ?? {}), + patch: vi.fn().mockResolvedValue(overrides.patch ?? {}), + delete: vi.fn().mockResolvedValue(overrides.delete ?? {}), + }; +} + +describe('listMessages', () => { + // ── CLEANUP ───────────────────────────────────────────────────── + // clearAllMocks resets call counts and recorded arguments on every + // vi.fn(). This prevents test A's calls from leaking into test B. + afterEach(() => { + vi.clearAllMocks(); + }); + + // ── SUCCESS PATH: default options ─────────────────────────────── + it('should GET from Inbox with default OData params', async () => { + // ARRANGE: Create a mock client that returns two messages + const mockMessages = [ + { id: '1', subject: 'Hello', isRead: false }, + { id: '2', subject: 'World', isRead: true }, + ]; + const client = createMockClient({ get: { value: mockMessages } }); + + // ACT: Call listMessages with no options (all defaults) + const result = await listMessages(client); + + // ASSERT 1: The return value is the raw response from client.get + expect(result.value).toEqual(mockMessages); + + // ASSERT 2: Verify the exact URL that was called. + // client.get.mock.calls[0][0] is the first argument of the + // first call to client.get(). + const url = client.get.mock.calls[0][0]; + + // Must use the userPath prefix (/me or /users/{email}) + expect(url).toContain('/me/mailFolders/Inbox/messages'); + + // Default $top is 25 + expect(url).toContain('$top=25'); + + // Default $orderby sorts newest first + expect(url).toContain('$orderby=receivedDateTime%20desc'); + + // Default $select includes the standard mail fields + expect(url).toContain('$select='); + expect(url).toContain('subject'); + expect(url).toContain('from'); + }); + + // ── SUCCESS PATH: custom folder and unread filter ─────────────── + it('should apply unreadOnly filter when option is set', async () => { + const client = createMockClient(); + + await listMessages(client, { + folder: 'SentItems', + unreadOnly: true, + top: 10, + }); + + const url = client.get.mock.calls[0][0]; + + // Folder name is URL-encoded in the path + expect(url).toContain('/mailFolders/SentItems/messages'); + + // unreadOnly adds an OData $filter clause + expect(url).toContain('$filter=isRead%20eq%20false'); + + // Custom top overrides the default 25 + expect(url).toContain('$top=10'); + }); + + // ── DELEGATE ACCESS ───────────────────────────────────────────── + it('should use delegate userPath for shared mailbox access', async () => { + const client = createMockClient(); + // Simulate --as flag: userPath points to another user's mailbox + client.userPath = '/users/shared%40example.com'; + + await listMessages(client); + + const url = client.get.mock.calls[0][0]; + + // The URL must start with the delegate path, not /me + expect(url).toStartWith('/users/shared%40example.com/mailFolders/'); + }); + + // ── ERROR PATH ────────────────────────────────────────────────── + it('should propagate errors from the Graph client', async () => { + const client = createMockClient(); + // Simulate a Graph API error (e.g., 403 Forbidden) + client.get.mockRejectedValue(new Error('Forbidden: insufficient permissions')); + + // The function should NOT swallow the error — it must propagate + // so that the CLI layer can display it to the user. + await expect(listMessages(client)).rejects.toThrow('Forbidden'); + }); + + // ── VERIFY CALL COUNT ─────────────────────────────────────────── + it('should make exactly one GET request', async () => { + const client = createMockClient(); + + await listMessages(client); + + // listMessages does a single GET — it does NOT follow pagination. + // (Pagination is handled by client.getAllPages for commands that + // need it, like search or watch.) + expect(client.get).toHaveBeenCalledTimes(1); + expect(client.post).not.toHaveBeenCalled(); + }); +}); +``` + +### Example 2: Test with Real Crypto (No Mocks) + +This tests the encrypt/decrypt roundtrip using real PBKDF2 — no mocks. Real crypto is essential here because the binary format must be identical between the Node.js and C# implementations. + +```javascript +// test/node/security/crypto-format.test.js +// +// PURPOSE: Verify the binary wire format of encrypted data. +// These tests use REAL cryptographic operations (not mocks) because: +// 1. The encrypted format must be byte-compatible with the C# implementation +// 2. A mock would only test our assumptions, not the actual crypto behavior +// 3. We need to verify that random salt/IV produce unique ciphertexts +// +// PERFORMANCE NOTE: +// PBKDF2 with 310,000 iterations of SHA-512 takes ~300ms per call. +// Each test that calls encrypt() or decrypt() pays this cost. +// With ~15 crypto tests, the total is ~4.5 seconds — acceptable for +// the confidence it provides in cross-implementation compatibility. + +import { describe, it, expect } from 'vitest'; +import { encrypt, decrypt } from '../../../src/node/security/crypto.js'; + +// ── BINARY FORMAT CONSTANTS ───────────────────────────────────────── +// The encrypted buffer layout is: +// [salt: 32 bytes] [iv: 12 bytes] [authTag: 16 bytes] [ciphertext: N bytes] +// +// This format is shared between Node.js and C#. If either side changes +// the layout, cross-implementation decryption will fail — which is +// exactly what these tests catch. +const SALT_LENGTH = 32; +const IV_LENGTH = 12; +const AUTH_TAG_LENGTH = 16; +const HEADER_LENGTH = SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH; // 60 bytes + +const TEST_PASSPHRASE = 'test-passphrase-for-unit-tests'; + +describe('Crypto Binary Format', () => { + + // ── ROUNDTRIP: the fundamental correctness test ───────────────── + it('should encrypt and decrypt back to the original plaintext', () => { + const plaintext = 'Hello from outlook-cli! 🎉'; + + // encrypt() returns a Buffer containing the full binary payload + const encrypted = encrypt(plaintext, TEST_PASSPHRASE); + + // decrypt() accepts a Buffer and returns the original UTF-8 string + const decrypted = decrypt(encrypted, TEST_PASSPHRASE); + + expect(decrypted).toBe(plaintext); + }); + + // ── BINARY LAYOUT: verify header structure ────────────────────── + it('should produce a buffer with [salt:32][iv:12][authTag:16][ciphertext]', () => { + const plaintext = 'short message'; + const encrypted = encrypt(plaintext, TEST_PASSPHRASE); + + // The total length must be at least the 60-byte header plus some + // ciphertext. AES-GCM ciphertext is the same length as plaintext. + expect(encrypted.length).toBeGreaterThanOrEqual(HEADER_LENGTH + 1); + + // Extract each component by byte offset + const salt = encrypted.subarray(0, SALT_LENGTH); + const iv = encrypted.subarray(SALT_LENGTH, SALT_LENGTH + IV_LENGTH); + const authTag = encrypted.subarray(SALT_LENGTH + IV_LENGTH, HEADER_LENGTH); + const ciphertext = encrypted.subarray(HEADER_LENGTH); + + // Each component must be the correct length + expect(salt.length).toBe(32); + expect(iv.length).toBe(12); + expect(authTag.length).toBe(16); + + // Ciphertext length equals plaintext length for AES-GCM + // (GCM is a stream cipher mode — no padding) + expect(ciphertext.length).toBe(Buffer.byteLength(plaintext, 'utf-8')); + }); + + // ── RANDOM IV/SALT: same plaintext → different ciphertext ─────── + it('should produce different ciphertext for the same input (random IV and salt)', () => { + const plaintext = 'encrypt me twice'; + + // Two encryptions of the same data with the same passphrase + const enc1 = encrypt(plaintext, TEST_PASSPHRASE); + const enc2 = encrypt(plaintext, TEST_PASSPHRASE); + + // The full buffers must differ (random salt + random IV) + expect(enc1.equals(enc2)).toBe(false); + + // Specifically, the salts must differ + const salt1 = enc1.subarray(0, SALT_LENGTH); + const salt2 = enc2.subarray(0, SALT_LENGTH); + expect(salt1.equals(salt2)).toBe(false); + + // And the IVs must differ + const iv1 = enc1.subarray(SALT_LENGTH, SALT_LENGTH + IV_LENGTH); + const iv2 = enc2.subarray(SALT_LENGTH, SALT_LENGTH + IV_LENGTH); + expect(iv1.equals(iv2)).toBe(false); + + // But both must decrypt to the same plaintext + expect(decrypt(enc1, TEST_PASSPHRASE)).toBe(plaintext); + expect(decrypt(enc2, TEST_PASSPHRASE)).toBe(plaintext); + }); + + // ── CROSS-IMPLEMENTATION COMPATIBILITY ────────────────────────── + // This pattern tests the Node.js → C# decrypt path. + // In a real CI pipeline, the C# test suite would decrypt buffers + // produced by this Node.js code and vice versa. Here, we verify + // the structural prerequisites for that to work. + it('should produce a buffer that a C#-compatible decoder can parse', () => { + const encrypted = encrypt('interop test data', TEST_PASSPHRASE); + + // C# implementation reads salt, then IV, then authTag, then ciphertext + // using BinaryReader with fixed offsets. Verify those offsets are correct. + const salt = encrypted.subarray(0, 32); + const iv = encrypted.subarray(32, 44); // 32 + 12 = 44 + const authTag = encrypted.subarray(44, 60); // 44 + 16 = 60 + const ciphertext = encrypted.subarray(60); + + // None of these should be all-zeros (that would mean randomBytes failed) + const allZero = Buffer.alloc(32); + expect(salt.equals(allZero.subarray(0, 32))).toBe(false); + expect(iv.equals(allZero.subarray(0, 12))).toBe(false); + + // The total must be parseable + expect(salt.length + iv.length + authTag.length + ciphertext.length) + .toBe(encrypted.length); + }); + + // ── ERROR PATH: wrong passphrase ──────────────────────────────── + it('should throw on decryption with wrong passphrase', () => { + const encrypted = encrypt('secret data', TEST_PASSPHRASE); + + // GCM authentication will fail because the derived key is different, + // which means the auth tag won't match. This is the expected behavior. + expect(() => decrypt(encrypted, 'wrong-passphrase')) + .toThrow('Decryption failed'); + }); + + // ── EDGE CASE: Unicode and emoji ──────────────────────────────── + it('should handle Unicode content (emoji, CJK, RTL)', () => { + const unicode = '📧 メール 邮件 بريد 🔐'; + const encrypted = encrypt(unicode, TEST_PASSPHRASE); + const decrypted = decrypt(encrypted, TEST_PASSPHRASE); + + // UTF-8 encoding must survive the roundtrip intact + expect(decrypted).toBe(unicode); + }); +}); +``` + +### Example 3: Stress Test — Pagination at Scale + +This tests that mail listing and formatting can handle 1000+ messages without excessive memory growth or timeouts. Located in `test/stress/scale/`. + +```javascript +// test/stress/scale/mail-pagination.test.js +// +// PURPOSE: Verify that the mail formatting pipeline handles large datasets +// without memory leaks or performance degradation. +// +// TAG: @stress — these tests are heavier than unit tests and may be +// skipped in quick CI runs via SKIP_STRESS=1 environment variable. +// +// PHILOSOPHY: We use MOCKED data (not real Graph API calls) but test +// at realistic scale. This catches O(n²) bugs, memory leaks in +// string concatenation, and pagination boundary errors that only +// appear with large datasets. + +import { describe, it, expect } from 'vitest'; + +// ── SKIP MECHANISM ────────────────────────────────────────────────── +// Stress tests are opt-in in local development. CI runs them on a +// schedule or when the @stress label is applied to a PR. +const SKIP = !!process.env.SKIP_STRESS; + +// ── MOCK DATA GENERATOR ───────────────────────────────────────────── +// Generates an array of N realistic-looking message objects that match +// the shape returned by the Microsoft Graph API. +function generateMessages(count) { + const messages = []; + for (let i = 0; i < count; i++) { + messages.push({ + id: `msg-${i.toString().padStart(6, '0')}`, + subject: `Test message #${i} — ${randomSubject()}`, + from: { + emailAddress: { + name: `Sender ${i}`, + address: `sender${i}@example.com`, + }, + }, + receivedDateTime: new Date( + Date.now() - i * 60_000 // Each message 1 minute apart + ).toISOString(), + bodyPreview: `Preview of message ${i}. `.repeat(3), + isRead: i % 3 !== 0, // 1/3 are unread + importance: i % 10 === 0 ? 'high' : 'normal', + flag: { flagStatus: i % 20 === 0 ? 'flagged' : 'notFlagged' }, + hasAttachments: i % 5 === 0, // 20% have attachments + }); + } + return messages; +} + +// Helper: generate varied subject lines for realistic data +function randomSubject() { + const subjects = [ + 'Q3 Budget Review', + 'Meeting Notes', + 'RE: Project Update', + 'FW: Action Required', + 'Weekly Standup Agenda', + ]; + return subjects[Math.floor(Math.random() * subjects.length)]; +} + +// ── PAGINATION SIMULATION ─────────────────────────────────────────── +// Simulates Microsoft Graph pagination by splitting an array into pages +// of `pageSize` items, each with an @odata.nextLink (except the last). +function simulatePages(allItems, pageSize = 100) { + const pages = []; + for (let offset = 0; offset < allItems.length; offset += pageSize) { + const slice = allItems.slice(offset, offset + pageSize); + const page = { value: slice }; + if (offset + pageSize < allItems.length) { + // Graph API includes @odata.nextLink for all pages except the last + page['@odata.nextLink'] = `https://graph.microsoft.com/v1.0/me/messages?$skip=${offset + pageSize}`; + } + pages.push(page); + } + return pages; +} + +describe.skipIf(SKIP)('@stress Mail Pagination at Scale', () => { + + // ── 1000 MESSAGES: basic throughput test ───────────────────────── + it('should process 1000 messages without timeout', () => { + const messages = generateMessages(1000); + + const start = performance.now(); + + // Simulate what the CLI does: iterate all messages and extract + // display data (subject, sender, date, flags) + const displayRows = messages.map((msg) => ({ + id: msg.id, + subject: msg.subject, + from: msg.from.emailAddress.address, + date: msg.receivedDateTime, + unread: !msg.isRead, + flagged: msg.flag.flagStatus === 'flagged', + })); + + const elapsed = performance.now() - start; + + // All 1000 messages must be processed + expect(displayRows.length).toBe(1000); + + // Must complete in under 2 seconds (typically ~50ms) + expect(elapsed).toBeLessThan(2000); + }); + + // ── PAGINATION BOUNDARY CONDITIONS ────────────────────────────── + it('should handle pagination across 10+ pages correctly', () => { + const totalCount = 1050; // Not a clean multiple of page size + const pageSize = 100; + const allMessages = generateMessages(totalCount); + const pages = simulatePages(allMessages, pageSize); + + // Should produce 11 pages (10 × 100 + 1 × 50) + expect(pages.length).toBe(11); + + // First 10 pages have nextLink, last page does not + for (let i = 0; i < pages.length - 1; i++) { + expect(pages[i]['@odata.nextLink']).toBeDefined(); + } + expect(pages[pages.length - 1]['@odata.nextLink']).toBeUndefined(); + + // Reassemble all pages and verify no messages were lost + const reassembled = pages.flatMap((p) => p.value); + expect(reassembled.length).toBe(totalCount); + + // Verify ordering is preserved (IDs should be sequential) + for (let i = 0; i < reassembled.length; i++) { + expect(reassembled[i].id).toBe(`msg-${i.toString().padStart(6, '0')}`); + } + }); + + // ── MEMORY: verify no unbounded growth ────────────────────────── + it('should not leak memory when processing 5000 messages', () => { + // Force GC if available (node --expose-gc), otherwise skip this + // measurement as it won't be accurate. + if (global.gc) global.gc(); + + const baselineMemory = process.memoryUsage().heapUsed; + + // Process 5000 messages in batches, discarding each batch + // after extracting summary data — simulating streaming output. + let processedCount = 0; + const batchSize = 500; + + for (let batch = 0; batch < 10; batch++) { + const messages = generateMessages(batchSize); + + // Simulate formatting: extract display fields + const summaries = messages.map((m) => m.subject + ' — ' + m.from.emailAddress.address); + processedCount += summaries.length; + + // In a real CLI, each batch is printed and discarded. + // The summaries array goes out of scope here. + } + + if (global.gc) global.gc(); + const finalMemory = process.memoryUsage().heapUsed; + + expect(processedCount).toBe(5000); + + // Memory growth should be modest — not proportional to total + // data processed. Allow 50MB headroom for V8 heap fluctuation. + const growthMB = (finalMemory - baselineMemory) / 1024 / 1024; + expect(growthMB).toBeLessThan(50); + }); + + // ── UNIQUE IDS: data integrity at scale ───────────────────────── + it('should generate messages with unique IDs', () => { + const messages = generateMessages(5000); + const ids = new Set(messages.map((m) => m.id)); + + // Every message must have a unique ID — duplicates would cause + // the CLI to overwrite or skip messages. + expect(ids.size).toBe(5000); + }); +}); +``` + +## When to Use Real Crypto vs Mocks + +Choosing between real crypto and mocked crypto affects both test correctness and performance. Here's when to use each approach. + +### Use real crypto when: + +- **Testing encrypt/decrypt roundtrip** — You need to verify that `encrypt()` produces a buffer that `decrypt()` can reverse. Mocks would only test your assumptions, not the actual algorithm. +- **Testing interoperability** — The Node.js and C# implementations must produce byte-compatible encrypted data. A mock cannot verify that `PBKDF2(passphrase, salt, 310000, 32, 'sha512')` produces the same key in both runtimes. +- **Verifying the binary format** — The wire format `[salt:32][iv:12][authTag:16][ciphertext]` must be exact. Real crypto validates that `Buffer.concat([salt, iv, authTag, ciphertext])` produces a parseable output. +- **Testing edge cases** — Empty plaintext, very large data (1MB+), Unicode content (emoji, CJK), and binary data all exercise different code paths in the crypto module. +- **Verifying randomness** — Each call to `encrypt()` must produce different ciphertext due to random IV and salt. A mock that returns deterministic output cannot test this property. + +### Use mocked crypto when: + +- **Testing `token-cache.js` logic** — The cache plugin calls `encrypt()` on save and `decrypt()` on load. Your test only cares that it calls them with the right arguments, not that AES-GCM works correctly. +- **Testing `GraphClient` token management** — The client uses the cache to store and retrieve tokens. Mock the cache (which internally uses crypto) to test the client's retry and refresh logic. +- **Testing `AccountManager`** — It reads/writes `accounts.json` and may interact with the encrypted cache. Mock crypto to test the account CRUD logic in isolation. +- **Testing any module where crypto is a transitive dependency** — If your module under test doesn't directly call `encrypt()`/`decrypt()`, mock the crypto layer so your test runs in <1ms instead of 300ms. + +### Performance impact + +| Approach | Time per test | Reason | +|---|---|---| +| Real crypto | ~300ms | PBKDF2 with 310,000 iterations of SHA-512 | +| Mocked crypto | <1ms | No key derivation, instant return | + +**Concrete numbers:** +- The `crypto.test.js` suite has ~15 tests × 300ms = **~4.5 seconds total**. This is acceptable — these tests provide high-confidence interoperability guarantees. +- If you use real crypto in 100 tests that only need it as a dependency, you add **~30 seconds** of unnecessary test time. Those tests should mock crypto instead. +- The full test suite (~750 tests) runs in ~20 seconds. Unnecessary real crypto could nearly triple that to ~50 seconds. + +### How to mock crypto + +Use `vi.mock()` to replace the crypto module with instant, deterministic functions: + +```javascript +// Place this BEFORE importing the module under test +vi.mock('../../../src/node/security/crypto.js', () => ({ + // encrypt: return a tagged string so decrypt can reverse it. + // This is NOT real encryption — it's just enough to test that + // the caller passes the right data and uses the result correctly. + encrypt: vi.fn((data, passphrase) => { + return Buffer.from(`encrypted:${passphrase}:${data}`); + }), + + // decrypt: reverse the tagged format from encrypt above. + decrypt: vi.fn((data, passphrase) => { + const str = Buffer.isBuffer(data) ? data.toString() : data; + return str.replace(`encrypted:${passphrase}:`, ''); + }), + + // getPassphrase: return a deterministic passphrase for testing. + getPassphrase: vi.fn((alias) => `mock-passphrase-${alias ?? 'default'}`), +})); +``` + +**Why this mock pattern works:** +1. `encrypt` wraps the input in a tagged format, so you can verify the correct data was encrypted. +2. `decrypt` reverses the tag, so code that does `decrypt(encrypt(data))` still gets back the original data. +3. Both are `vi.fn()` so you can assert call counts, arguments, and mock different return values per test. +4. `getPassphrase` returns a predictable string so tests don't depend on the machine's hostname or username. + +## NativeAOT Binary Tests + +`test/dotnet/native-binary.test.js` tests the published `.exe` by spawning it as a child process: + +```javascript +import { execFile } from 'node:child_process'; + +const BINARY = join(__dirname, '../../publish/win-x64/outlook-cli.exe'); + +function run(args) { + return new Promise((resolve) => { + execFile(BINARY, args, { timeout: 10000 }, (error, stdout, stderr) => { + resolve({ exitCode: error?.code ?? 0, stdout, stderr }); + }); + }); +} + +it('should show version', async () => { + const { stdout } = await run(['--version']); + expect(stdout.trim()).toMatch(/^\d+\.\d+\.\d+$/); +}); + +it('should show all mail subcommands in help', async () => { + const { stdout } = await run(['mail', '--help']); + for (const cmd of ['inbox', 'read', 'search', 'draft', 'send', 'reply', 'forward', 'move', 'flag', 'mark-read', 'watch']) { + expect(stdout).toContain(cmd); + } +}); +``` + +These tests verify **feature parity** between Node.js and C#: +- Same commands exist +- Same options exist +- Same help text structure +- Error handling produces formatted output (not stack traces) +- Exit codes are correct + +**Critical**: These tests run against `publish/win-x64/outlook-cli.exe`. If you change C# code but don't republish, you're testing stale code. + +## E2E Tests (Real Microsoft Graph) + +Located in `test/e2e/`. Gated behind environment variable: + +```bash +# Enable E2E tests +OUTLOOK_CLI_E2E=1 npm test + +# Or run specific E2E file +OUTLOOK_CLI_E2E=1 npx vitest run test/e2e/mail-lifecycle.test.js +``` + +These tests: +- Use a real Microsoft account +- Create real drafts, read real inbox, send real emails +- Require valid authentication (`outlook-cli auth login` first) +- Have rate limiting and soak variants in `test/e2e/soak/` + +Use `describeE2E` instead of `describe`: +```javascript +import { describeE2E } from './helpers.js'; + +describeE2E('Mail Lifecycle', () => { + it('should create and delete a draft', async () => { ... }); +}); +``` + +## Stress Tests + +Located in `test/stress/`. Categories: + +| Directory | Tag | Purpose | +|---|---|---| +| `stress/scale/` | @stress | Large data sets (1000+ messages, deep pagination) | +| `stress/integrity/` | @stress | Data fidelity (message content survives roundtrip, SQLite consistency) | +| `stress/performance/` | @perf | Memory profiles, throughput benchmarks | +| `stress/soak/` | @soak | Long-running stability (hours of continuous sync) | + +These use mocked data (not real APIs) but test at scale. They verify that the CLI handles large volumes without memory leaks, data corruption, or performance degradation. + +## Common Test Patterns + +### Testing Error Handling + +Many tests verify that errors produce helpful messages, not stack traces. The pattern: + +```javascript +it('should show actionable error when not authenticated', async () => { + acquireTokenSilently.mockResolvedValue({ + result: null, + reason: 'interaction_required', + }); + + await expect(inbox(mockClient)).rejects.toThrow(/Run `outlook-cli auth login`/); +}); +``` + +Key principle: every error message should tell the user **what went wrong** and **what to do about it**. Tests enforce this by checking for the suggested fix command in the error message. + +### Testing Retry Logic + +The GraphClient retries on 401 (re-auth) and 429 (rate limit). Tests verify the retry sequence: + +```javascript +it('should retry once on 401 then succeed', async () => { + mockFetch + .mockResolvedValueOnce(makeJsonResponse({ error: { code: 'InvalidAuthenticationToken' } }, 401)) + .mockResolvedValueOnce(makeJsonResponse({ value: [] })); + + const result = await client.get('/me/messages'); + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(result.value).toEqual([]); +}); +``` + +### Testing Output Formatting + +Output tests capture stdout and verify the rendered format: + +```javascript +it('should format mail list with short IDs', () => { + const messages = [ + { id: 'AAMk123', subject: 'Test', from: { emailAddress: { name: 'Alice', address: 'alice@test.com' } } }, + ]; + const output = formatMailList(messages); + expect(output).toContain('#1'); + expect(output).toContain('Test'); + expect(output).toContain('Alice'); +}); +``` + +### Debugging Test Failures + +```bash +# Run a single failing test with verbose output +npx vitest run -t "should handle 429" --reporter=verbose + +# Run with Node.js debugger +node --inspect-brk node_modules/.bin/vitest run -t "should handle 429" + +# Print intermediate values (vitest shows console.log output on failure) +it('should parse delta response', () => { + const result = parseDelta(response); + console.log('Parsed result:', JSON.stringify(result, null, 2)); // Shows on failure + expect(result.changes).toHaveLength(3); +}); +``` + +## Test Checklist for PRs + +Before committing any change: + +- [ ] `npm test` passes (all ~750 tests) +- [ ] If C# changed: `dotnet build src\dotnet` succeeds +- [ ] If C# changed: republish NativeAOT and `npm test` again +- [ ] New behavior has corresponding tests +- [ ] Tests cover the happy path AND at least one error/edge case +- [ ] No tests depend on network access +- [ ] Temp directories cleaned up in afterEach +- [ ] Test names start with "should" + +## Writing Your First Test — Step by Step + +A complete walkthrough for adding a new test to outlook-cli. We'll test a hypothetical `getMessage` function from `src/node/graph/mail.js` as an example. + +### Step 1: Create the test file + +Test file paths mirror the `src/node/` structure: + +| Source file | Test file | +|---|---| +| `src/node/graph/mail.js` | `test/node/graph/mail.test.js` | +| `src/node/auth/msal-client.js` | `test/node/auth/msal-client.test.js` | +| `src/node/security/crypto.js` | `test/node/security/crypto.test.js` | +| `src/node/output/formatter.js` | `test/node/output/formatter.test.js` | + +Create the file in the correct directory. If the directory doesn't exist, create it. + +### Step 2: Import vitest and set up mocks + +Start by importing vitest's test API, then identify and mock every external dependency your module uses. + +```javascript +// test/node/graph/mail-get.test.js + +// ── VITEST IMPORTS ────────────────────────────────────────────────── +// Import everything you need from vitest. The full set is: +// describe — group related tests +// it — define a single test case +// expect — make assertions +// vi — create mocks, spies, stubs, and fake timers +// beforeEach / afterEach — setup and teardown per test +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// ── IDENTIFY DEPENDENCIES ─────────────────────────────────────────── +// Look at the imports in src/node/graph/mail.js. If it imports from +// other src/node/ modules, you may need to mock those. For mail.js, +// the function takes a client parameter so we don't need to mock +// the client module — we'll create a mock object directly. +// +// If your module under test imports the token validator: +// vi.mock('../../../src/node/security/token-validator.js', () => ({ +// validateTokenScopes: vi.fn(), +// })); +// +// If your module under test uses fetch directly: +// const mockFetch = vi.fn(); +// vi.stubGlobal('fetch', mockFetch); +``` + +### Step 3: Import the module under test + +Always use dynamic `await import()` to ensure mocks are registered before the module loads. This matters because vitest hoists `vi.mock()` calls, but dynamic imports happen at runtime after the hoisting. + +```javascript +// ── IMPORT AFTER MOCKS ────────────────────────────────────────────── +// Use dynamic import so the module sees our mocked dependencies. +// Destructure only the functions you're testing. +const { getMessage } = await import('../../../src/node/graph/mail.js'); + +// IMPORTANT: Always use the .js extension in import paths. +// This project is ESM ("type": "module") and Node.js requires +// explicit file extensions for ESM imports. +// +// ✅ await import('../../../src/node/graph/mail.js') +// ❌ await import('../../../src/node/graph/mail') ← will fail +``` + +### Step 4: Write describe/it blocks + +Structure your tests with one `describe` block per module or function, and one `it` block per behavior. Follow the Arrange-Act-Assert pattern. + +```javascript +// ── MOCK CLIENT FACTORY ───────────────────────────────────────────── +// Create this helper before the describe block. It produces a mock +// GraphClient object with vi.fn() methods you can inspect and control. +function createMockClient(overrides = {}) { + return { + userPath: '/me', + get: vi.fn().mockResolvedValue(overrides.get ?? { value: [] }), + post: vi.fn().mockResolvedValue(overrides.post ?? {}), + patch: vi.fn().mockResolvedValue(overrides.patch ?? {}), + delete: vi.fn().mockResolvedValue(overrides.delete ?? {}), + }; +} + +describe('getMessage', () => { + + // ── HAPPY PATH ────────────────────────────────────────────────── + it('should fetch a single message by ID', async () => { + // ARRANGE: Set up a mock client that returns a specific message + const mockMessage = { + id: 'AAMkAGI2', + subject: 'Quarterly Review', + from: { emailAddress: { name: 'Boss', address: 'boss@company.com' } }, + body: { contentType: 'html', content: '

See attached.

' }, + }; + const client = createMockClient({ get: mockMessage }); + + // ACT: Call the function under test + const result = await getMessage(client, 'AAMkAGI2'); + + // ASSERT: Verify the return value + expect(result.subject).toBe('Quarterly Review'); + + // ASSERT: Verify the correct Graph API URL was called + const url = client.get.mock.calls[0][0]; + expect(url).toContain('/me/messages/AAMkAGI2'); + }); + + // ── ERROR PATH ────────────────────────────────────────────────── + it('should throw when message is not found', async () => { + // ARRANGE: Simulate a 404 from the Graph API + const client = createMockClient(); + client.get.mockRejectedValue(new Error('Not Found')); + + // ACT + ASSERT: Verify the error propagates + await expect(getMessage(client, 'nonexistent')) + .rejects.toThrow('Not Found'); + }); + + // ── EDGE CASE ─────────────────────────────────────────────────── + it('should handle messages with no body', async () => { + const client = createMockClient({ + get: { id: 'no-body', subject: 'Empty', body: null }, + }); + + const result = await getMessage(client, 'no-body'); + + // The function should not crash on null body + expect(result.id).toBe('no-body'); + expect(result.body).toBeNull(); + }); +}); +``` + +### Step 5: Handle cleanup + +Add `beforeEach` and `afterEach` hooks to prevent test pollution. Every test must start with a clean slate. + +```javascript +describe('getMessage', () => { + + // ── SETUP ─────────────────────────────────────────────────────── + beforeEach(() => { + // Reset all mock call counts and recorded arguments. + // Without this, test B might see call records from test A. + vi.clearAllMocks(); + + // If your tests write files, create a unique temp directory: + // mkdirSync(testDir, { recursive: true }); + }); + + // ── TEARDOWN ──────────────────────────────────────────────────── + afterEach(() => { + // Restore any stubbed globals (fetch, process.env, etc.) + vi.restoreAllMocks(); + + // If your tests created files, clean up: + // rmSync(testDir, { recursive: true, force: true }); + }); + + // ... it() blocks go here ... +}); +``` + +### Step 6: Run and verify + +Run your test file in isolation first, then run the full suite to check for regressions. + +```bash +# Run just your new test file +npx vitest run test/node/graph/mail-get.test.js + +# Run all tests in the same module directory +npx vitest run test/node/graph/ + +# Run the full test suite — must still pass +npm test + +# If debugging a failure, use verbose mode for more detail +npm run test:verbose +``` + +**Common pitfalls and fixes:** + +| Problem | Cause | Fix | +|---|---|---| +| `ERR_MODULE_NOT_FOUND` | Missing `.js` extension in import | Add `.js` to all import paths | +| `TypeError: x is not a function` | Module wasn't mocked before import | Move `vi.mock()` above the `await import()` | +| Test passes alone, fails in suite | Shared state between tests | Add `vi.clearAllMocks()` in `beforeEach` | +| `fetch is not defined` | Forgot to stub global fetch | Add `vi.stubGlobal('fetch', vi.fn())` | +| Assertion on wrong call | Multiple tests share mock state | Use `vi.clearAllMocks()` or check specific `mock.calls` index | +| Test hangs forever | Forgot to `await` an async function | Add `await` before the function call | +| `Cannot use import statement outside a module` | File not recognized as ESM | Verify `"type": "module"` in package.json | + +### Complete template + +Here's the full file assembled as a copy-paste starting point: + +```javascript +// test/node/graph/mail-get.test.js +// +// Tests for getMessage() from src/node/graph/mail.js +// Run: npx vitest run test/node/graph/mail-get.test.js + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// ── MOCKS (before imports) ────────────────────────────────────────── +// Mock any modules that your subject imports. vi.mock() is hoisted +// by vitest so it runs before any import statement. +// +// Uncomment and adapt if your module imports other src/node/ modules: +// vi.mock('../../../src/node/security/token-validator.js', () => ({ +// validateTokenScopes: vi.fn(), +// })); + +// ── MODULE UNDER TEST ─────────────────────────────────────────────── +const { getMessage } = await import('../../../src/node/graph/mail.js'); + +// ── HELPERS ───────────────────────────────────────────────────────── +function createMockClient(overrides = {}) { + return { + userPath: '/me', + get: vi.fn().mockResolvedValue(overrides.get ?? { value: [] }), + post: vi.fn().mockResolvedValue(overrides.post ?? {}), + patch: vi.fn().mockResolvedValue(overrides.patch ?? {}), + delete: vi.fn().mockResolvedValue(overrides.delete ?? {}), + }; +} + +// ── TESTS ─────────────────────────────────────────────────────────── +describe('getMessage', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should fetch a single message by ID', async () => { + const mockMessage = { + id: 'AAMkAGI2', + subject: 'Quarterly Review', + body: { contentType: 'text', content: 'Hello' }, + }; + const client = createMockClient({ get: mockMessage }); + + const result = await getMessage(client, 'AAMkAGI2'); + + expect(result.subject).toBe('Quarterly Review'); + expect(client.get.mock.calls[0][0]).toContain('/me/messages/AAMkAGI2'); + }); + + it('should throw when the message does not exist', async () => { + const client = createMockClient(); + client.get.mockRejectedValue(new Error('Not Found')); + + await expect(getMessage(client, 'nonexistent')) + .rejects.toThrow('Not Found'); + }); +}); +``` diff --git a/bin/outlook-cli.js b/bin/outlook-cli.js new file mode 100644 index 0000000..d93ce85 --- /dev/null +++ b/bin/outlook-cli.js @@ -0,0 +1,28 @@ +#!/usr/bin/env node + +/** + * Entry point for the outlook-cli tool. + * + * This file is intentionally minimal — all logic lives in src/. + * Commander.js parses process.argv and dispatches to the appropriate + * subcommand handler (auth, account, mail, calendar, contacts). + * + * Uses parseAsync() so async command errors flow through the global error + * handler instead of becoming unhandled promise rejections. + */ + +import { createProgram } from '../src/node/cli/index.js'; +import { stampVersion } from '../src/node/version.js'; +import { formatError } from '../src/node/errors.js'; + +stampVersion(); +const program = createProgram(); + +try { + await program.parseAsync(process.argv); +} catch (err) { + // Last-resort handler for errors that escape the command-level error handler. + // This should rarely fire — the global handler in cli/index.js catches most errors. + console.error(formatError(err)); + process.exitCode = process.exitCode || 2; +} diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..84d8fa8 --- /dev/null +++ b/build.ps1 @@ -0,0 +1,247 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS + Unified build, test, and publish script for outlook-cli. + +.DESCRIPTION + Builds and tests both the Node.js and .NET implementations. + Optionally publishes NativeAOT binaries for specified platforms. + +.PARAMETER Target + What to build: 'node', 'dotnet', or 'all'. Default: 'all'. + +.PARAMETER Publish + After build+test, publish NativeAOT binaries. + +.PARAMETER Runtime + Comma-separated RIDs for publish, or 'all' for all 4 targets. + Default: current platform. Options: win-x64, win-arm64, osx-arm64, linux-x64. + +.PARAMETER TestOnly + Skip build, run tests only. + +.PARAMETER NoBuild + Skip build step (useful with -Publish to skip rebuild). + +.PARAMETER Configuration + Build configuration. Default: Release. + +.EXAMPLE + .\build.ps1 node # Build + test Node.js + .\build.ps1 dotnet # Build + test .NET + .\build.ps1 all # Build + test both + .\build.ps1 dotnet -Publish # Build, test, publish for current platform + .\build.ps1 dotnet -Publish -Runtime all # All 4 targets + .\build.ps1 dotnet -Publish -Runtime win-x64,osx-arm64 + .\build.ps1 node -TestOnly # Run Node.js tests only +#> + +param( + [Parameter(Position = 0)] + [ValidateSet('node', 'dotnet', 'all')] + [string]$Target = 'all', + + [switch]$Publish, + [string]$Runtime, + [switch]$TestOnly, + [switch]$NoBuild, + [ValidateSet('Debug', 'Release')] + [string]$Configuration = 'Release' +) + +$ErrorActionPreference = 'Stop' +$script:ProjectRoot = $PSScriptRoot +$script:DotnetProject = Join-Path $ProjectRoot 'src' 'dotnet' 'OutlookCli.csproj' +$script:PublishDir = Join-Path $ProjectRoot 'publish' + +$AllRuntimes = @('win-x64', 'win-arm64', 'osx-arm64', 'linux-x64') + +# Determine current platform RID +function Get-CurrentRid { + if ($IsWindows -or $env:OS -eq 'Windows_NT') { + $arch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture + return if ($arch -eq 'Arm64') { 'win-arm64' } else { 'win-x64' } + } + elseif ($IsMacOS) { return 'osx-arm64' } + elseif ($IsLinux) { return 'linux-x64' } + else { return 'win-x64' } +} + +# Track results for summary table +$script:Results = [System.Collections.Generic.List[PSCustomObject]]::new() + +function Add-Result([string]$Step, [bool]$Success, [string]$Detail = '') { + $script:Results.Add([PSCustomObject]@{ + Step = $Step + Status = if ($Success) { 'PASS' } else { 'FAIL' } + Detail = $Detail + }) +} + +function Write-StepHeader([string]$Message) { + Write-Host "" + Write-Host "═══════════════════════════════════════════════════" -ForegroundColor Cyan + Write-Host " $Message" -ForegroundColor Cyan + Write-Host "═══════════════════════════════════════════════════" -ForegroundColor Cyan +} + +# ── Node.js ────────────────────────────────────────────── + +function Invoke-NodeBuild { + Write-StepHeader "Node.js: Install dependencies" + try { + Push-Location $ProjectRoot + npm install --prefer-offline --no-audit 2>&1 | Out-Host + if ($LASTEXITCODE -ne 0) { throw "npm install failed" } + Add-Result "Node.js install" $true + } + catch { + Add-Result "Node.js install" $false $_.Exception.Message + throw + } + finally { Pop-Location } +} + +function Invoke-NodeTest { + Write-StepHeader "Node.js: Run tests" + try { + Push-Location $ProjectRoot + npx vitest run 2>&1 | Out-Host + if ($LASTEXITCODE -ne 0) { throw "Node.js tests failed" } + Add-Result "Node.js tests" $true + } + catch { + Add-Result "Node.js tests" $false $_.Exception.Message + throw + } + finally { Pop-Location } +} + +# ── .NET ───────────────────────────────────────────────── + +function Invoke-DotnetBuild { + Write-StepHeader ".NET: Build ($Configuration)" + try { + dotnet build $script:DotnetProject -c $Configuration --nologo 2>&1 | Out-Host + if ($LASTEXITCODE -ne 0) { throw "dotnet build failed" } + Add-Result ".NET build" $true + } + catch { + Add-Result ".NET build" $false $_.Exception.Message + throw + } +} + +function Invoke-DotnetTest { + $testProject = Join-Path $ProjectRoot 'test' 'dotnet' 'OutlookCli.Tests' 'OutlookCli.Tests.csproj' + if (Test-Path $testProject) { + Write-StepHeader ".NET: Run tests" + try { + dotnet test $testProject -c $Configuration --nologo 2>&1 | Out-Host + if ($LASTEXITCODE -ne 0) { throw "dotnet tests failed" } + Add-Result ".NET tests" $true + } + catch { + Add-Result ".NET tests" $false $_.Exception.Message + throw + } + } + else { + Write-Host " (No .NET test project found — skipping)" -ForegroundColor Yellow + Add-Result ".NET tests" $true "(no test project)" + } +} + +function Invoke-DotnetPublish([string[]]$Rids) { + foreach ($rid in $Rids) { + Write-StepHeader ".NET: Publish NativeAOT — $rid" + $outDir = Join-Path $script:PublishDir $rid + try { + dotnet publish $script:DotnetProject ` + -c $Configuration ` + -r $rid ` + --self-contained true ` + /p:PublishAot=true ` + -o $outDir ` + --nologo 2>&1 | Out-Host + if ($LASTEXITCODE -ne 0) { throw "publish failed for $rid" } + + # Report binary size + $ext = if ($rid -like 'win-*') { '.exe' } else { '' } + $binary = Join-Path $outDir "outlook-cli$ext" + if (Test-Path $binary) { + $sizeMB = [math]::Round((Get-Item $binary).Length / 1MB, 1) + Add-Result "Publish $rid" $true "${sizeMB} MB" + } + else { + Add-Result "Publish $rid" $true "(binary location unknown)" + } + } + catch { + Add-Result "Publish $rid" $false $_.Exception.Message + throw + } + } +} + +# ── Orchestration ──────────────────────────────────────── + +function Show-Summary { + Write-Host "" + Write-Host "═══════════════════════════════════════════════════" -ForegroundColor White + Write-Host " Build Summary" -ForegroundColor White + Write-Host "═══════════════════════════════════════════════════" -ForegroundColor White + + $anyFail = $false + foreach ($r in $script:Results) { + $color = if ($r.Status -eq 'PASS') { 'Green' } else { 'Red'; $anyFail = $true } + $detail = if ($r.Detail) { " — $($r.Detail)" } else { '' } + Write-Host (" [{0}] {1}{2}" -f $r.Status, $r.Step, $detail) -ForegroundColor $color + } + + Write-Host "" + if ($anyFail) { + Write-Host " ❌ One or more steps FAILED" -ForegroundColor Red + exit 1 + } + else { + Write-Host " ✅ All steps passed" -ForegroundColor Green + } +} + +# ── Main ───────────────────────────────────────────────── + +$doNode = ($Target -eq 'node') -or ($Target -eq 'all') +$doDotnet = ($Target -eq 'dotnet') -or ($Target -eq 'all') + +try { + if ($TestOnly) { + if ($doNode) { Invoke-NodeTest } + if ($doDotnet) { Invoke-DotnetTest } + } + else { + if ($doNode -and -not $NoBuild) { Invoke-NodeBuild } + if ($doNode) { Invoke-NodeTest } + if ($doDotnet -and -not $NoBuild) { Invoke-DotnetBuild } + if ($doDotnet) { Invoke-DotnetTest } + } + + if ($Publish -and $doDotnet) { + $rids = if ($Runtime -eq 'all') { + $AllRuntimes + } + elseif ($Runtime) { + $Runtime -split ',' + } + else { + @(Get-CurrentRid) + } + Invoke-DotnetPublish $rids + } +} +catch { + # Error already logged via Add-Result +} +finally { + Show-Summary +} diff --git a/docs/AZURE-SETUP.md b/docs/AZURE-SETUP.md new file mode 100644 index 0000000..8a5c490 --- /dev/null +++ b/docs/AZURE-SETUP.md @@ -0,0 +1,156 @@ +# Microsoft Entra ID App Registration — Setup Guide + +This guide walks you through registering an application in **Microsoft Entra ID** (formerly Azure Active Directory) for `outlook-cli`. The app registration is **free** and lets the CLI authenticate with Microsoft accounts. + +## Prerequisites + +- A Microsoft account (personal, work, or school) +- Access to [Microsoft Entra admin center](https://entra.microsoft.com) (free — no subscription required) + +## Step 1: Go to App Registrations + +1. Open [https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade](https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade) +2. Sign in with your Microsoft account +3. You should see **App registrations** under **Microsoft Entra ID** +4. Alternatively: [portal.azure.com](https://portal.azure.com) → search for **"App registrations"** + +## Step 2: Register a New Application + +1. Click **"+ New registration"** at the top +2. Fill in the form: + - **Name:** `outlook-cli` (or any name you like) + - **Supported account types:** Choose based on your needs: + - **Accounts in any organizational directory and personal Microsoft accounts** — supports both work/school and personal Outlook.com accounts **(recommended)** + - **Personal Microsoft accounts only** — for Outlook.com accounts only + - **Single tenant** — if you only need one organization + - **Redirect URI:** + - Platform: **"Mobile and desktop applications"** (NOT Web!) + - URI: leave blank for now (we'll add it next) +3. Click **"Register"** + +> **Note:** The CLI auto-detects the correct authority endpoint (`/common`, `/consumers`, or `/organizations`) based on your App Registration's account type configuration. If you chose "Personal accounts only", the CLI will automatically use the `/consumers` endpoint. + +## Step 3: Note Your Client ID + +After registration, you'll see the **Overview** page. Copy and save: + +- **Application (client) ID** — this is your `OUTLOOK_CLI_CLIENT_ID` +- **Directory (tenant) ID** — this is your `OUTLOOK_CLI_TENANT_ID` + +## Step 4: Add Redirect URI + +1. Go to **"Authentication"** in the left sidebar +2. Click **"+ Add a platform"** +3. Select **"Mobile and desktop applications"** +4. Under **Custom redirect URIs**, add: `http://localhost:53847/callback` +5. Click **"Configure"** +6. Scroll down to **"Advanced settings"** +7. Set **"Allow public client flows"** to **Yes** +8. Click **"Save"** at the top + +## Step 5: Configure API Permissions + +1. Go to **"API permissions"** in the left sidebar +2. Click **"+ Add a permission"** +3. Select **"Microsoft Graph"** +4. Select **"Delegated permissions"** +5. Add these permissions (search for each): + - `User.Read` (usually already added) + - `Mail.Read` + - `Mail.ReadWrite` + - `Mail.Read.Shared` (for reading delegate mailboxes) + - `Mail.ReadWrite.Shared` (for drafts in delegate mailboxes) + - `Mail.Send.Shared` (for sending on behalf of a delegate) + - `Calendars.Read` + - `Calendars.ReadWrite` + - `Calendars.Read.Shared` (for reading delegate calendars) + - `offline_access` +6. Click **"Add permissions"** + +### ⚠️ CRITICAL: Do NOT add these permissions: +- `Mail.Send` — The entire security model depends on this NOT being present. `Mail.Send.Shared` (delegate send-on-behalf-of) is safe and separate. +- `Mail.ReadWrite.All` — Application-level access, not needed +- `Calendars.ReadWrite.All` — Application-level access, not needed + +## Step 6: Configure the CLI + +Set the client ID as an environment variable or pass it to the CLI: + +```bash +# Option A: Environment variable +export OUTLOOK_CLI_CLIENT_ID=your-client-id-here + +# Option B: Pass to CLI directly +outlook-cli auth login --client-id your-client-id-here + +# Option C: Add to account +outlook-cli account add personal --client-id your-client-id-here +``` + +## Step 7: Authenticate + +### On a machine with a browser: +```bash +outlook-cli auth login +``` +This opens your browser for Microsoft login (handles 2FA automatically). + +### On a headless VM (no browser): +```bash +outlook-cli auth login --device-code +``` +This shows a code you enter at [https://microsoft.com/devicelogin](https://microsoft.com/devicelogin) on any device. + +## Step 8: Verify + +```bash +outlook-cli auth status +``` + +Should show your email and `Authenticated: ✓ yes`. + +## Sharing the App Registration + +Multiple users can share the same app registration (client ID). Each user authenticates with their own Microsoft account and gets their own tokens. This is safe because: + +- The app has no client secret (public client) +- Tokens are scoped to each user's account +- No one can access another user's data with the shared client ID + +## Cost + +| Component | Cost | +|---|---| +| Azure App Registration | Free (forever) | +| Graph API calls | Free (included with M365/Outlook license) | +| outlook-cli | Free (MIT license) | + +## Troubleshooting + +### "AADSTS700016: Application not found" +- Verify the client ID matches your app registration +- Check if the app was registered in the correct tenant + +### "redirect_uri does not match" +- Ensure `http://localhost:53847/callback` is added as a redirect URI +- Ensure the platform is "Mobile and desktop applications" (not Web) + +### "AADSTS65001: consent required" +- The first login may prompt for consent — click "Accept" +- Admin consent may be required for organizational accounts + +### "Port 53847 is in use" +- Another application is using the port +- Use `--device-code` as an alternative + +### Device code shows "undefined" or "(unknown)" instead of a code +- The App Registration may not be configured correctly +- Verify **"Allow public client flows"** is set to **Yes** (Step 4, item 7) +- Verify the **Supported account types** includes your account type +- For personal Outlook.com accounts, use "Accounts in any organizational directory and personal Microsoft accounts" +- Re-run with `--verbose` for detailed error output + +### "invalid_grant" during device code login +- If the device code wasn't displayed (showed "undefined"), the code request failed before you could enter it. Fix the App Registration first (see above). +- If you saw a valid code: the code may have expired (15-minute limit). Try again and enter the code promptly. +- Verify all API permissions from Step 5 are added diff --git a/docs/DELEGATE-ACCESS.md b/docs/DELEGATE-ACCESS.md new file mode 100644 index 0000000..d843d90 --- /dev/null +++ b/docs/DELEGATE-ACCESS.md @@ -0,0 +1,148 @@ +# Delegate Access & Contact Aliases + +## Overview + +`outlook-cli` supports two related features for working with multiple users: + +1. **Contact Aliases** — Short names for email addresses (e.g., `fred` → `freddie@outlook.com`) +2. **Delegate Access** — Read, draft, and send on behalf of another user's mailbox + +## Contact Aliases + +Aliases are local shortcuts stored in `~/.outlook-cli/aliases.json`. They work in any command that accepts email addresses. + +### Managing Aliases + +```bash +# Create or update an alias +outlook-cli contacts alias set fred freddie@outlook.com +outlook-cli contacts alias set wilma wilma@outlook.com +outlook-cli contacts alias set boss ceo@company.com + +# List all aliases +outlook-cli contacts alias list + +# Remove an alias +outlook-cli contacts alias remove fred +``` + +### Using Aliases + +Aliases work anywhere an email is expected — `--to`, `--cc`, `--bcc`, `--attendees`, and `--as`: + +```bash +# Draft using aliases instead of full emails +outlook-cli mail draft --to fred --subject "Hey!" --body "How's it going?" + +# Mix aliases and full emails +outlook-cli mail draft --to fred,wilma,barney@outlook.com --subject "Dinner" + +# Calendar events with alias attendees +outlook-cli calendar create --subject "Standup" --start "2025-01-15T09:00" --end "2025-01-15T09:30" --attendees fred,wilma + +# Delegate access with alias +outlook-cli --as fred mail inbox +``` + +## Delegate Access + +Delegate access lets you read another user's mailbox, create drafts, and send on their behalf — if they've granted you permission in Outlook. + +### Prerequisites + +1. **The mailbox owner must grant you delegate access** in Outlook: + - Outlook Desktop: File → Account Settings → Delegate Access → Add + - Outlook Web: Settings → Mail → Shared mailboxes → Add + - Exchange Admin Center: Recipient → Mailboxes → Delegation + +2. **Your App Registration must have shared permissions** (see [AZURE-SETUP.md](./AZURE-SETUP.md)): + - `Mail.Read.Shared` + - `Mail.ReadWrite.Shared` + - `Mail.Send.Shared` + - `Calendars.Read.Shared` + +3. **Re-authenticate** after adding new permissions: + ```bash + outlook-cli auth logout --account myaccount + outlook-cli auth login --account myaccount --device-code + ``` + +### Reading Another User's Mail + +```bash +# Read fred's inbox (using alias) +outlook-cli --as fred mail inbox + +# Read fred's inbox (using full email) +outlook-cli --as freddie@outlook.com mail inbox + +# Read a specific message in fred's mailbox +outlook-cli --as fred mail read + +# Search fred's mail +outlook-cli --as fred mail search "quarterly report" + +# List fred's mail folders +outlook-cli --as fred mail folders +``` + +### Drafts and Sending On Behalf Of + +```bash +# Create a draft in fred's mailbox +outlook-cli --as fred mail draft --to wilma --subject "Meeting notes" --body "..." --yes + +# Send an existing draft on behalf of fred +# (Recipients will see "Sent by you on behalf of fred") +outlook-cli --as fred mail send --yes +``` + +> **Security note:** `mail send` is **only** available with `--as` (delegate mode). Sending from your own account is intentionally disabled. The `Mail.Send.Shared` permission is entirely separate from `Mail.Send` — having one does NOT grant the other. + +### Calendar Delegate Access + +```bash +# View fred's calendar +outlook-cli --as fred calendar today +outlook-cli --as fred calendar week + +# View fred's calendars +outlook-cli --as fred calendar list-calendars +``` + +### Contacts Delegate Access + +```bash +# Search fred's contacts +outlook-cli --as fred contacts search "John" +``` + +## How It Works + +The `--as ` flag changes the Graph API path from `/me/...` to `/users/{email}/...`. Microsoft Graph handles permission enforcement — if you don't have delegate access, you'll get a `403 Forbidden` error. + +## Multiple Accounts + Delegates + +Combine `--account` and `--as` for full control: + +```bash +# Using work account to access fred's mailbox +outlook-cli --account work --as fred mail inbox + +# Using personal account to access boss's calendar +outlook-cli --account personal --as boss calendar today +``` + +## Security Model + +| Capability | Status | Notes | +|---|---|---| +| Read own mail | ✅ Allowed | `Mail.Read` scope | +| Read delegate mail | ✅ Allowed | `Mail.Read.Shared` + owner permission | +| Draft in own mailbox | ✅ Allowed | `Mail.ReadWrite` scope | +| Draft in delegate mailbox | ✅ Allowed | `Mail.ReadWrite.Shared` + owner permission | +| Send from own account | ❌ Blocked | `Mail.Send` is NEVER requested | +| Send on behalf of delegate | ✅ Allowed | `Mail.Send.Shared` + owner permission | +| Read delegate calendar | ✅ Allowed | `Calendars.Read.Shared` + owner permission | + +The key security insight: `Mail.Send` and `Mail.Send.Shared` are **completely separate** Microsoft Graph permissions. `Mail.Send.Shared` can only send on behalf of someone who has explicitly granted you delegate access. It cannot send as yourself. diff --git a/docs/DIAGNOSTICS.md b/docs/DIAGNOSTICS.md new file mode 100644 index 0000000..a5db7e0 --- /dev/null +++ b/docs/DIAGNOSTICS.md @@ -0,0 +1,320 @@ +# Diagnostics — Logs, Telemetry, and Doctor + +outlook-cli includes built-in diagnostics for troubleshooting, auditing, and monitoring. + +## Operations Log (`outlook-cli log`) + +Every CLI command is automatically recorded in a SQLite database at `~/.outlook-cli/outlook-cli.db`. This provides an audit trail of all operations, their status, duration, and any errors. + +### Search the Log + +```bash +# Show recent operations (default: last 50) +outlook-cli log search + +# Filter by account +outlook-cli log search --account work + +# Filter by command +outlook-cli log search --operation "mail inbox" + +# Filter by status +outlook-cli log search --status failed + +# Operations since a specific date +outlook-cli log search --since 2024-01-01 + +# Trace a full request chain by correlation ID +outlook-cli log search --correlation-id abc-123-def + +# Limit results +outlook-cli log search --limit 10 + +# JSON output +outlook-cli log search --json +``` + +### Operations Summary + +```bash +# Summary of the last 7 days +outlook-cli log summary + +# Summary of the last 30 days +outlook-cli log summary --days 30 + +# JSON output +outlook-cli log summary --json +``` + +The summary includes: +- Total operations and error rate +- Average latency +- Throttle events (429 responses from Microsoft Graph) +- Daily activity chart +- Top commands by frequency +- Per-account breakdown + +### Log Options + +| Option | Description | +|---|---| +| `-a, --account ` | Filter by account | +| `-o, --operation ` | Filter by command/operation name | +| `-s, --since ` | Only operations after this date (ISO 8601) | +| `--status ` | Filter by status: `started`, `completed`, `failed` | +| `--correlation-id ` | Trace all operations with this correlation ID | +| `-n, --limit ` | Max results (default: 50) | +| `-d, --days ` | Summary window (default: 7) | + +### Correlation IDs + +Every CLI invocation gets a unique correlation ID. Multi-step operations (e.g., watch mode doing delta queries) share the same correlation ID, so you can trace the full chain: + +```bash +# Find a failed operation +outlook-cli log search --status failed --limit 1 --json + +# Trace the full chain +outlook-cli log search --correlation-id +``` + +### Log Storage + +- **Location**: `~/.outlook-cli/outlook-cli.db` +- **Format**: SQLite (WAL mode for concurrent access) +- **Schema**: `operations` table with columns: `id`, `parent_id`, `account`, `command`, `operation`, `status`, `started_at`, `completed_at`, `duration_ms`, `request_summary`, `response_summary`, `error_message`, `error_stack`, `correlation_id`, `user`, `runtime`, `version` +- **Retention**: No automatic cleanup — the database grows over time. Delete the file to reset. + +Both the Node.js and C# implementations write to the same database file, using the `runtime` column to distinguish entries. The `version` column records which CLI version produced each entry, which helps when diagnosing issues after an upgrade. + +### Advanced Log Queries with SQLite + +For analysis beyond what `log search` and `log summary` provide, you can query the database directly: + +```bash +# Find all operations that took longer than 5 seconds +sqlite3 ~/.outlook-cli/outlook-cli.db \ + "SELECT started_at, command, duration_ms FROM operations WHERE duration_ms > 5000 ORDER BY duration_ms DESC LIMIT 20" + +# Count errors by command type in the last 30 days +sqlite3 ~/.outlook-cli/outlook-cli.db \ + "SELECT command, COUNT(*) as errors FROM operations WHERE status='failed' AND started_at >= date('now','-30 days') GROUP BY command ORDER BY errors DESC" + +# Find throttled requests (429 responses) with timestamps +sqlite3 ~/.outlook-cli/outlook-cli.db \ + "SELECT started_at, account, command, error_message FROM operations WHERE error_message LIKE '%429%' OR error_message LIKE '%throttl%' ORDER BY started_at DESC" + +# Compare Node.js vs C# usage +sqlite3 ~/.outlook-cli/outlook-cli.db \ + "SELECT runtime, COUNT(*) as ops, AVG(duration_ms) as avg_ms FROM operations GROUP BY runtime" + +# Find orphaned operations (started but never completed/failed) +sqlite3 ~/.outlook-cli/outlook-cli.db \ + "SELECT id, started_at, command FROM operations WHERE status='started' AND started_at < datetime('now','-1 hour')" +``` + +### Example Log Output + +When you run `outlook-cli log search`, you see a formatted table like this: + +``` +Started Account Command Status Duration Correlation +──────────────────────────────────────────────────────────────────────────────────────────────────── +2026-04-15 14:23:01 ac-jstall-ms mail inbox completed 342ms a1b2c3d4e5 +2026-04-15 14:22:45 ac-jstall-ms auth status completed 128ms f6g7h8i9j0 +2026-04-15 14:20:12 ac-jstall-ms mail search completed 567ms k1l2m3n4o5 +2026-04-15 14:18:30 ac-jstall-ms mail read completed 234ms p6q7r8s9t0 + +4 operation(s) +``` + +The `log summary` command gives an aggregate view: + +``` + outlook-cli operations summary + Period: last 7 day(s) (since 2026-04-08) + ──────────────────────────────────────────── + + Total operations: 47 + Errors: 2 (4.3%) + Avg latency: 312ms + Throttle events: 0 + + Operations per day: + 2026-04-08 3 ██ + 2026-04-09 8 ████ + 2026-04-10 12 ██████ + 2026-04-11 6 ███ + 2026-04-14 10 █████ + 2026-04-15 8 ████ + + Top commands: + mail inbox 18 + mail read 12 + mail search 8 + auth status 5 + calendar today 4 + + Per-account breakdown: + Account Total Errors Avg ms + ─────────────────────────────────────────────────── + ac-jstall-ms 42 2 298 + work-account 5 0 412 +``` + +--- + +## Telemetry (`OUTLOOK_CLI_TELEMETRY=1`) + +Optional in-memory telemetry with SQLite persistence. **Disabled by default** — no data is collected unless explicitly enabled. All data stays local (nothing is sent externally). + +### Enable Telemetry + +```bash +# Via environment variable +OUTLOOK_CLI_TELEMETRY=1 outlook-cli mail inbox + +# Or set in config +outlook-cli config set telemetry true +``` + +### What Gets Collected + +When enabled, telemetry records: + +- **API call events**: endpoint, status code, response time, response size +- **Auth events**: token refresh, cache hits/misses, re-authentication +- **Process snapshots**: memory usage (RSS, heap), CPU time, event loop latency (sampled every 60 seconds) +- **Error events**: error type, message, stack trace + +### Architecture + +The telemetry subsystem (implemented in `src/node/telemetry/collector.js`) uses a lightweight design optimized for minimal overhead when enabled: + +- **Ring buffer**: In-memory circular buffer of 1,000 events. When the buffer is full, the oldest event is overwritten by the newest. This prevents unbounded memory growth regardless of how long the CLI runs. For watch mode running continuously, this means you always have the last ~1,000 events available for inspection. +- **SQLite persistence**: If the database is available, events are flushed to disk every 5 seconds in a batch insert. This amortizes I/O overhead — individual API calls don't trigger a write. +- **Process snapshots**: Captured every 60 seconds via `process.memoryUsage()` and `process.cpuUsage()`. These snapshots are useful for detecting memory leaks in long-running watch mode, showing RSS growth, heap used/total, and event loop latency. +- **No external transmission**: All data stays in `~/.outlook-cli/outlook-cli.db`. Nothing is sent to Microsoft, the CLI developer, or any third party. + +### Querying Telemetry Data + +Telemetry events are stored alongside operations logs in the same SQLite database. For advanced analysis: + +```bash +# Recent API call performance +sqlite3 ~/.outlook-cli/outlook-cli.db \ + "SELECT timestamp, endpoint, status_code, response_time_ms FROM telemetry WHERE type='api_call' ORDER BY timestamp DESC LIMIT 20" + +# Memory usage over time (for watch mode leak detection) +sqlite3 ~/.outlook-cli/outlook-cli.db \ + "SELECT timestamp, json_extract(data, '$.rss') / 1048576 as rss_mb FROM telemetry WHERE type='process_snapshot' ORDER BY timestamp" + +# Error event details +sqlite3 ~/.outlook-cli/outlook-cli.db \ + "SELECT timestamp, json_extract(data, '$.type') as error_type, json_extract(data, '$.message') as msg FROM telemetry WHERE type='error' ORDER BY timestamp DESC" +``` + +--- + +## Doctor (`outlook-cli doctor`) + +The `doctor` command runs a comprehensive health check of your outlook-cli setup. It validates everything from runtime version to Graph API connectivity, reporting clear pass/fail/warning status for each check. This is the first command to run when something isn't working — it catches 90% of setup issues. + +Both the Node.js and C# implementations have `doctor` commands, though they check slightly different things: +- **Node.js**: Checks Node.js version, config files, token caches, Graph API reachability, scopes +- **C# (NativeAOT)**: Checks CLR version, config directory, database, accounts, token caches + +```bash +# Node.js doctor (full checks including Graph API) +outlook-cli doctor +outlook-cli doctor --json + +# C# doctor (system health overview) +.\outlook-cli.exe doctor +``` + +### Checks Performed + +| Check | What It Validates | +|---|---| +| Node.js version | >= 20.0.0 | +| Config directory | `~/.outlook-cli/` exists and is writable | +| accounts.json | File is valid JSON and parseable | +| Account fields | Each account has required fields (alias, clientId, tenantId) | +| Token cache | Encrypted cache file exists and is decryptable | +| Token validity | Silent token refresh works (not expired) | +| Graph API | Microsoft Graph is reachable (`GET /me`) | +| Scopes | Required permissions are granted | +| Version | CLI version is not stale | + +### Output + +Each check reports ✅ (pass), ❌ (fail), or ⚠️ (warning) with a fix instruction if something is wrong: + +``` +outlook-cli doctor + + ✅ Node.js v20.11.0 (>= 20.0.0) + ✅ Config directory exists and is writable + ✅ accounts.json is valid + ✅ Account "work" has required fields + ✅ Token cache for "work" is decryptable + ✅ Token refresh successful for "work" + ✅ Graph API reachable (200 OK) + ✅ Required scopes granted: Mail.Read, Mail.ReadWrite, ... + ✅ Version 1.0.0 is current + + 9/9 checks passed +``` + +If a check fails: + +``` + ❌ Token cache for "work" is not decryptable + Fix: Run `outlook-cli auth login --account work` to re-authenticate +``` + +--- + +## Troubleshooting Workflow + +When something goes wrong, use these tools in order. Each level provides more detail than the previous: + +1. **`outlook-cli doctor`** — Quick health check (takes ~2 seconds). Catches common setup issues like missing config, expired tokens, or unreachable Graph API. Start here. + +2. **`outlook-cli log search --status failed`** — Find recent errors with full context. Each failed operation includes the error message, stack trace (if available), and the exact command that failed. Look at the most recent failures first: + ```bash + outlook-cli log search --status failed --limit 5 + ``` + +3. **`--verbose` flag** — Add to any command for detailed output. This shows token refresh attempts, HTTP request/response details, retry logic, and timing information. Extremely useful for auth and network issues: + ```bash + outlook-cli mail inbox --verbose + ``` + +4. **`outlook-cli log search --correlation-id `** — Trace a specific failed operation chain. Multi-step operations (e.g., watch mode doing delta queries that trigger message fetches) share a correlation ID, so you can see the full chain of calls that led to a failure: + ```bash + # First find the correlation ID from a failed operation + outlook-cli log search --status failed --limit 1 --json | grep correlationId + # Then trace the full chain + outlook-cli log search --correlation-id a1b2c3d4-e5f6-7890-abcd-ef1234567890 + ``` + +5. **`outlook-cli log summary`** — Step back and look at patterns. A high error rate (>5%) or frequent throttle events suggest systemic issues rather than one-off failures. Throttle events mean Microsoft Graph is rate-limiting your requests — consider reducing polling frequency or staggering multi-account operations: + ```bash + outlook-cli log summary --days 30 + ``` + +6. **Direct SQLite queries** — For the deepest analysis, query the database directly (see "Advanced Log Queries" section above). This is useful for correlating timing patterns, finding slow operations, or building custom reports. + +### Common Diagnostic Scenarios + +| Symptom | First Check | Likely Cause | +|---|---|---| +| "No valid token" error | `doctor` | Token expired or cache corrupted — run `auth login` | +| "Network error" or timeout | `--verbose` | DNS failure, proxy issues, or Graph API outage | +| Commands suddenly slow | `log summary` | Throttling (429s) — reduce polling frequency | +| Watch mode stops updating | `log search --operation delta` | Delta token expired — restart watch | +| "messageId is required" | Check your shell | PowerShell `#` comment issue — use bare numbers | +| Config value not taking effect | Check config resolution | `config.json` overrides env vars (see [self-hosting.md](self-hosting.md)) | diff --git a/docs/DIRECTORY-STRUCTURE.md b/docs/DIRECTORY-STRUCTURE.md new file mode 100644 index 0000000..b90812c --- /dev/null +++ b/docs/DIRECTORY-STRUCTURE.md @@ -0,0 +1,239 @@ +# Project Directory Structure + +Complete directory layout for both the Node.js reference implementation and the C# NativeAOT implementation. + +## Top-Level Layout + +``` +outlook-cli/ +├── agents/ # Agent documentation (AI agent reference) +│ ├── README.md # Quick start for agents +│ ├── ARCHITECTURE.md # System architecture, request flows, module map +│ ├── BUILDING.md # How to build, test, publish — both runtimes +│ ├── MAKING-CHANGES.md # How to make code changes, keep Node.js + C# in sync +│ ├── TESTING.md # Test infrastructure, patterns, writing new tests +│ ├── REAL-WORLD-VALIDATION.md # Verify changes beyond unit tests +│ └── COMMON-PITFALLS.md # Mistakes that cause real bugs — read first +│ +├── bin/ # CLI entry points +│ └── outlook-cli.js # Node.js entry point (#!/usr/bin/env node) +│ +├── src/ +│ ├── node/ # Node.js reference implementation +│ │ ├── cli/ # CLI command definitions (Commander.js) +│ │ │ ├── index.js # Program setup, global options, command registration +│ │ │ ├── auth.js # auth login/logout/status +│ │ │ ├── account.js # account add/remove/list/set-default +│ │ │ ├── mail.js # mail inbox/read/search/draft/reply/forward/move/flag/send +│ │ │ ├── calendar.js # calendar today/week/range/view/create/list-calendars +│ │ │ ├── contacts.js # contacts search, alias set/remove/list +│ │ │ ├── watch.js # watch command (--mode poll/fast-poll/webhook) +│ │ │ ├── doctor.js # doctor (diagnostics) +│ │ │ ├── upgrade.js # upgrade (schema migration) +│ │ │ └── log.js # log (operation history) +│ │ │ +│ │ ├── auth/ # Authentication layer +│ │ │ ├── msal-client.js # MSAL PublicClientApplication factory, scopes +│ │ │ ├── auth-flows.js # Interactive (PKCE) and device code flows +│ │ │ └── token-cache.js # Encrypted MSAL token cache plugin +│ │ │ +│ │ ├── graph/ # Microsoft Graph API wrappers +│ │ │ ├── client.js # HTTP client: auth, retry, rate-limit, pagination +│ │ │ ├── mail.js # Mail endpoints +│ │ │ ├── calendar.js # Calendar endpoints +│ │ │ ├── contacts.js # Contacts search (People API + personal) +│ │ │ └── delta.js # Delta query engine for watch mode +│ │ │ +│ │ ├── watch/ # Watch/event system +│ │ │ ├── watcher.js # Poll/fast-poll orchestrator, event emission +│ │ │ ├── webhook-watcher.js # Webhook mode orchestrator +│ │ │ ├── webhook-server.js # Local HTTP server for Graph notifications +│ │ │ ├── subscription.js # Graph subscription create/renew/delete +│ │ │ ├── tunnel.js # Tunnel abstraction (cloudflared/ngrok/localtunnel) +│ │ │ ├── config.js # Multi-job watch config parser +│ │ │ ├── rules.js # Rule-based condition matching +│ │ │ ├── actions.js # Action executor (move, flag, exec, channel) +│ │ │ ├── schedule.js # Cron/duration/time-of-day parser +│ │ │ └── manager.js # Multi-job watch orchestrator +│ │ │ +│ │ ├── accounts/ # Multi-account management +│ │ │ └── manager.js # Account registry (add/remove/list/default) +│ │ │ +│ │ ├── contacts/ # Contact aliases +│ │ │ └── aliases.js # Alias CRUD + resolution (name → email) +│ │ │ +│ │ ├── security/ # Defense-in-depth security +│ │ │ ├── crypto.js # AES-256-GCM encrypt/decrypt (PBKDF2-SHA512) +│ │ │ ├── token-validator.js # JWT scope validation (reject forbidden scopes) +│ │ │ └── permissions.js # Configurable permission system +│ │ │ +│ │ ├── output/ # Output formatting (Strategy pattern) +│ │ │ ├── render.js # Format dispatcher (text/json/markdown/html) +│ │ │ ├── formatter.js # Text table formatter +│ │ │ ├── markdown.js # Markdown table formatter +│ │ │ ├── html.js # HTML formatter +│ │ │ └── last-results.js # Save/resolve result IDs for piping +│ │ │ +│ │ ├── db/ # Operation logging (SQLite) +│ │ │ ├── database.js # SQLite WAL mode, migration system +│ │ │ └── logger.js # Correlation-ID-based operation logging +│ │ │ +│ │ ├── telemetry/ # Optional telemetry +│ │ │ └── collector.js # Ring buffer + SQLite persistence +│ │ │ +│ │ ├── config.js # Config loading (env → file → defaults) +│ │ ├── config-schema.js # Schema validation + versioning +│ │ ├── errors.js # Typed error classes (NetworkError, GraphApiError, etc.) +│ │ ├── input.js # JSON input file loading + CLI merge +│ │ └── version.js # Version tracking +│ │ +│ └── dotnet/ # C# NativeAOT implementation +│ ├── OutlookCli.csproj # .NET 8 project file (NativeAOT, no reflection) +│ ├── OutlookCliJsonContext.cs # Source-generated JSON serialization +│ ├── Program.cs # Entry point + ALL command handlers +│ ├── Auth/ # Authentication (mirrors src/node/auth/) +│ │ ├── MsalClientFactory.cs +│ │ ├── AuthFlows.cs +│ │ └── TokenCacheHelper.cs +│ ├── Graph/ # Graph API (mirrors src/node/graph/) +│ │ ├── GraphClient.cs +│ │ ├── MailService.cs +│ │ ├── CalendarService.cs +│ │ └── ContactsService.cs +│ ├── Security/ # Security (mirrors src/node/security/) +│ │ ├── CryptoService.cs +│ │ └── TokenValidator.cs +│ ├── Accounts/ # Account management +│ │ └── AccountManager.cs +│ ├── Contacts/ # Alias management +│ │ └── AliasManager.cs +│ ├── Output/ # Output formatting +│ │ └── OutputFormatter.cs +│ ├── Database/ # SQLite operations logging +│ │ └── OperationsDb.cs +│ └── Errors/ # Typed exceptions +│ └── OutlookCliException.cs +│ +├── test/ # All tests (vitest) +│ ├── node/ # Unit tests (mirrors src/node/ structure) +│ │ ├── accounts/ # AccountManager tests +│ │ ├── auth/ # Token cache tests +│ │ ├── db/ # SQLite logger tests +│ │ ├── graph/ # GraphClient, mail, calendar, contacts, delta +│ │ ├── output/ # Formatter, render, last-results +│ │ ├── security/ # Crypto, permissions, token-validator +│ │ ├── telemetry/ # Collector tests +│ │ └── watch/ # Watcher, webhook, subscription, rules, fast-poll +│ ├── dotnet/ +│ │ └── native-binary.test.js # NativeAOT binary feature parity tests +│ ├── shared/ +│ │ └── cli.test.js # CLI integration tests (both runtimes) +│ ├── e2e/ # Real API tests (OUTLOOK_CLI_E2E=1) +│ │ ├── helpers.js +│ │ ├── mail-lifecycle.test.js +│ │ ├── mail-send.test.js +│ │ └── soak/ # Long-running real API tests +│ └── stress/ # Scale, performance, integrity tests +│ ├── scale/ +│ ├── performance/ +│ ├── integrity/ +│ └── soak/ +│ +├── docs/ # Documentation +│ ├── self-hosting.md # Complete setup guide (12 sections) +│ ├── AZURE-SETUP.md # App Registration walkthrough +│ ├── MULTI-ACCOUNT.md # Multi-account usage +│ ├── SECURITY.md # Security model +│ ├── JSON-INPUT.md # JSON input file format +│ ├── DELEGATE-ACCESS.md # Delegate access and aliases +│ ├── WATCH-MODE.md # Watch modes (poll/fast-poll/webhook) +│ ├── DIAGNOSTICS.md # Operations log, telemetry, doctor command +│ ├── TESTING.md # Testing guide +│ └── DIRECTORY-STRUCTURE.md # This file +│ +├── skill/ # AI agent integration +│ ├── openclaw/ # OpenClaw platform +│ │ ├── SKILL.md # Skill definition + command reference +│ │ ├── setup.ps1 # PowerShell setup script +│ │ ├── setup.sh # Bash setup script +│ │ ├── gateway.ps1 # PowerShell gateway (email → JSONL) +│ │ └── gateway.sh # Bash gateway (email → JSONL) +│ └── nanoclaw/ # NanoClaw platform +│ ├── SKILL.md # Skill definition + container config +│ ├── setup.sh # Container setup script +│ └── gateway.sh # Container gateway (email → JSONL) +│ +├── publish/ # NativeAOT binaries (git-ignored) +│ ├── win-x64/outlook-cli.exe +│ ├── win-arm64/outlook-cli.exe +│ ├── osx-arm64/outlook-cli +│ └── linux-x64/outlook-cli +│ +├── scripts/ # Helper scripts +│ ├── self-host-azure.ps1 # Azure App Registration automation +│ ├── self-host-client.ps1 # Client setup automation +│ └── scale-test.js # Scale test runner +│ +├── plan/ # Design documents +├── config/ # Configuration templates +├── build.ps1 # Unified build/test/publish script +├── outlook-cli.sln # Visual Studio solution file +├── package.json # Node.js dependencies and scripts +├── README.md # Getting started guide +├── LICENSE # License +└── .gitignore # Git ignore rules +``` + +## Module Dependency Graph + +``` +CLI Commands (src/node/cli/*.js) + │ + ├── accounts/manager.js ──→ config.js, config-schema.js + ├── contacts/aliases.js + ├── input.js + ├── output/render.js ──→ formatter.js, markdown.js, html.js + │ + └── graph/client.js + │ + ├── auth/msal-client.js ──→ auth/token-cache.js ──→ security/crypto.js + ├── security/token-validator.js + ├── security/permissions.js + │ + ├── graph/mail.js, calendar.js, contacts.js, delta.js + │ + └── watch/watcher.js ──→ watch/webhook-watcher.js + ├── watch/webhook-server.js + ├── watch/tunnel.js + └── watch/subscription.js +``` + +## Shared File Formats + +Both Node.js and C# implementations use identical file formats for interoperability: + +| File | Location | Format | +|---|---|---| +| Account registry | `~/.outlook-cli/accounts.json` | JSON | +| Contact aliases | `~/.outlook-cli/aliases.json` | JSON | +| Token cache | `~/.outlook-cli/cache-{alias}.enc` | Binary (salt:32 + iv:12 + authTag:16 + ciphertext) | +| Delta tokens | `~/.outlook-cli/delta-{alias}.json` | JSON | +| Config | `~/.outlook-cli/config.json` | JSON (optional) | +| Operations log | `~/.outlook-cli/outlook-cli.db` | SQLite (WAL mode) | + +## Running Tests + +```bash +npm test # All tests (~750 tests, vitest) +npm run test:verbose # Detailed output +npm run test:coverage # Coverage report +npx vitest run test/node/graph/ # Single directory +npx vitest run -t "should handle" # Pattern match + +# After C# changes, republish and retest: +dotnet publish src/dotnet/OutlookCli.csproj -r win-x64 --self-contained /p:PublishAot=true -o publish/win-x64 +npm test + +# Cross-runtime CLI tests: +OUTLOOK_CLI_BIN="dotnet run --project src/dotnet" npm test +``` diff --git a/docs/JSON-INPUT.md b/docs/JSON-INPUT.md new file mode 100644 index 0000000..bbc479c --- /dev/null +++ b/docs/JSON-INPUT.md @@ -0,0 +1,292 @@ +# JSON Input Reference + +Every `outlook-cli` command accepts `--input ` to load parameters from a JSON file. Use `-` to read from stdin. + +**CLI flags always take precedence** over JSON values. You can combine both approaches. + +## Mail Commands + +### `mail inbox` + +```json +{ + "top": 25, + "unread": true, + "folder": "Inbox" +} +``` + +| Key | Type | Default | Description | +|---|---|---|---| +| `top` | number | `25` | Number of messages to return | +| `unread` | boolean | `false` | Show only unread messages | +| `folder` | string | `"Inbox"` | Mail folder name | + +### `mail read` + +```json +{ + "messageId": "AAMkAG...", + "plain": true +} +``` + +| Key | Type | Required | Description | +|---|---|---|---| +| `messageId` | string | yes | Message ID (or pass as positional arg) | +| `plain` | boolean | no | Request plain text body | + +### `mail search` + +```json +{ + "query": "from:bob subject:meeting", + "top": 10 +} +``` + +| Key | Type | Required | Description | +|---|---|---|---| +| `query` | string | yes | Search query (or pass as positional arg) | +| `top` | number | no | Number of results (default: 25) | + +### `mail draft` + +```json +{ + "to": ["bob@example.com", "carol@example.com"], + "cc": ["dave@example.com"], + "bcc": ["eve@example.com"], + "subject": "Project Update", + "body": "Inline body text", + "bodyFile": "/path/to/email-body.html", + "bodyContentType": "HTML", + "importance": "high" +} +``` + +| Key | Type | Required | Description | +|---|---|---|---| +| `to` | string or string[] | yes | Recipient email(s) | +| `cc` | string or string[] | no | CC recipients | +| `bcc` | string or string[] | no | BCC recipients | +| `subject` | string | yes | Email subject | +| `body` | string | no | Inline body text | +| `bodyFile` | string | no | Path to body file (overrides `body`) | +| `bodyContentType` | string | no | `"Text"` (default) or `"HTML"`. Auto-detected from `.html`/`.htm` file extension. | +| `importance` | string | no | `"low"`, `"normal"` (default), or `"high"` | + +### `mail reply` + +```json +{ + "messageId": "AAMkAG...", + "body": "Thanks for the update!", + "bodyFile": "/path/to/reply.html", + "bodyContentType": "HTML", + "replyAll": true +} +``` + +| Key | Type | Required | Description | +|---|---|---|---| +| `messageId` | string | yes | ID of message to reply to | +| `body` | string | no | Reply body text | +| `bodyFile` | string | no | Path to reply body file | +| `bodyContentType` | string | no | `"Text"` (default) or `"HTML"` | +| `replyAll` | boolean | no | Reply to all recipients | + +### `mail forward` + +```json +{ + "messageId": "AAMkAG...", + "to": ["recipient@example.com"], + "comment": "FYI — please review", + "bodyFile": "/path/to/comment.txt", + "bodyContentType": "Text" +} +``` + +| Key | Type | Required | Description | +|---|---|---|---| +| `messageId` | string | yes | ID of message to forward | +| `to` | string or string[] | yes | Forward recipients | +| `comment` | string | no | Comment to add (inline) | +| `bodyFile` | string | no | Path to comment file (overrides `comment`) | +| `bodyContentType` | string | no | `"Text"` (default) or `"HTML"` | + +### `mail move` + +```json +{ + "messageId": "AAMkAG...", + "folder": "Archive" +} +``` + +| Key | Type | Required | Description | +|---|---|---|---| +| `messageId` | string | yes | ID of message to move | +| `folder` | string | yes | Destination folder name or ID | + +### `mail flag` + +```json +{ + "messageId": "AAMkAG...", + "unflag": false +} +``` + +| Key | Type | Required | Description | +|---|---|---|---| +| `messageId` | string | yes | ID of message to flag | +| `unflag` | boolean | no | Remove flag instead of adding | + +### `mail mark-read` + +```json +{ + "messageId": "AAMkAG...", + "unread": false +} +``` + +| Key | Type | Required | Description | +|---|---|---|---| +| `messageId` | string | yes | ID of message | +| `unread` | boolean | no | Mark as unread instead of read | + +## Calendar Commands + +### `calendar today` / `calendar week` + +```json +{ + "timezone": "America/Los_Angeles" +} +``` + +| Key | Type | Default | Description | +|---|---|---|---| +| `timezone` | string | System timezone | IANA timezone name | + +### `calendar range` + +```json +{ + "start": "2026-01-15", + "end": "2026-01-22", + "timezone": "America/New_York" +} +``` + +| Key | Type | Required | Description | +|---|---|---|---| +| `start` | string | yes | Start date (ISO 8601) | +| `end` | string | yes | End date (ISO 8601) | +| `timezone` | string | no | IANA timezone name | + +### `calendar view` + +```json +{ + "eventId": "AAMkAG..." +} +``` + +| Key | Type | Required | Description | +|---|---|---|---| +| `eventId` | string | yes | Event ID (or pass as positional arg) | + +### `calendar create` + +```json +{ + "subject": "Team Standup", + "start": "2026-01-15T09:00:00", + "end": "2026-01-15T09:30:00", + "timezone": "America/Los_Angeles", + "location": "Conference Room A", + "attendees": ["alice@example.com", "bob@example.com"], + "body": "Weekly sync meeting agenda", + "bodyFile": "/path/to/agenda.html", + "bodyContentType": "HTML" +} +``` + +| Key | Type | Required | Description | +|---|---|---|---| +| `subject` | string | yes | Event subject/title | +| `start` | string | yes | Start date/time (ISO 8601) | +| `end` | string | yes | End date/time (ISO 8601) | +| `timezone` | string | no | IANA timezone (default: system timezone) | +| `location` | string | no | Event location | +| `attendees` | string or string[] | no | Attendee emails | +| `body` | string | no | Event body/description | +| `bodyFile` | string | no | Path to body file | +| `bodyContentType` | string | no | `"Text"` (default) or `"HTML"` | + +## Contacts Commands + +### `contacts search` + +```json +{ + "query": "Alice Johnson", + "top": 10 +} +``` + +| Key | Type | Required | Description | +|---|---|---|---| +| `query` | string | yes | Search query (or pass as positional arg) | +| `top` | number | no | Number of results (default: 25) | + +## Body Content + +All commands that accept body content support three approaches: + +1. **Inline text:** `"body": "Hello world"` +2. **From file:** `"bodyFile": "/path/to/content.html"` +3. **CLI flag:** `--body "text"` or `--body-file /path/to/file` + +**Precedence:** `bodyFile` > `body` (file always wins) + +**Content type auto-detection:** When using `bodyFile`, if the file extension is `.html` or `.htm`, the content type is automatically set to `HTML`. Override with `"bodyContentType": "Text"`. + +## Piping from Stdin + +Use `--input -` to read JSON from stdin: + +```bash +# From a pipe +echo '{"to":"bob@example.com","subject":"Hello","body":"Hi"}' | outlook-cli mail draft --input - --yes --json + +# From a heredoc +outlook-cli mail draft --input - --yes --json < --input request.json --yes --json` +3. Agent parses the JSON output +4. Agent uses the result (e.g., message IDs) for follow-up commands + +```bash +# Agent creates draft +outlook-cli mail draft --input draft.json --yes --json > result.json + +# Agent reads the result to get the draft ID +# Then could forward, move, or take other actions +``` diff --git a/docs/MULTI-ACCOUNT.md b/docs/MULTI-ACCOUNT.md new file mode 100644 index 0000000..e169b78 --- /dev/null +++ b/docs/MULTI-ACCOUNT.md @@ -0,0 +1,135 @@ +# Multi-Account Guide + +`outlook-cli` supports multiple Microsoft accounts simultaneously. Each account is identified by an alias and maintains its own encrypted token cache. + +## Adding Accounts + +```bash +# Add a personal Outlook account +outlook-cli account add personal --client-id YOUR_CLIENT_ID + +# Add a work/school account (specific tenant) +outlook-cli account add work --client-id YOUR_CLIENT_ID --tenant YOUR_TENANT_ID + +# Add another account with the same client ID (different Microsoft login) +outlook-cli account add shared --client-id YOUR_CLIENT_ID +``` + +Multiple accounts can share the same Azure App Registration (client ID). Each account authenticates separately with its own Microsoft credentials. + +## Authenticating Accounts + +```bash +# Login to the personal account (browser) +outlook-cli auth login --account personal + +# Login to work account on a headless VM +outlook-cli auth login --account work --device-code +``` + +## Setting a Default Account + +```bash +outlook-cli account set-default personal +``` + +The default account is used when `--account` is not specified. + +## Using Specific Accounts + +Add `--account ` to any command: + +```bash +# Check personal inbox +outlook-cli mail inbox --account personal + +# Check work inbox +outlook-cli mail inbox --account work + +# Search across accounts (run separately) +outlook-cli mail search "project update" --account personal +outlook-cli mail search "project update" --account work +``` + +## Listing Accounts + +```bash +outlook-cli account list +``` + +Output: +``` +Accounts: + personal (default) — user@outlook.com [common] + work — user@company.com [tenant-id-here] +``` + +## Removing Accounts + +```bash +outlook-cli account remove work +``` + +This removes the account configuration and its encrypted token cache. + +## How It Works + +### Storage Location + +All account data is stored in `~/.outlook-cli/`: + +``` +~/.outlook-cli/ +├── accounts.json # Account registry (aliases, email, tenant, client ID) +├── cache-personal.enc # Encrypted MSAL token cache (personal account) +├── cache-work.enc # Encrypted MSAL token cache (work account) +└── config.json # Optional global config +``` + +### Token Isolation + +Each account has its own encrypted cache file. Encryption uses AES-256-GCM with PBKDF2 key derivation (310,000 iterations). The encryption key is derived per-account, so decrypting one account's cache doesn't expose another's. + +### Token Lifecycle + +- **Access tokens**: Valid for ~1 hour, automatically refreshed +- **Refresh tokens**: ~90-day rolling lifetime, stored in encrypted cache +- **Re-authentication**: Required if refresh token expires (after ~90 days of inactivity) + +## Work/School Accounts (Azure AD) + +For organizational accounts, you may need: + +1. **Specific tenant ID** — get it from your IT admin or Azure Portal +2. **Admin consent** — some organizations require admin approval for new apps +3. **Conditional access** — the device may need to meet compliance policies + +```bash +# Use a specific tenant +outlook-cli account add work --client-id CLIENT_ID --tenant TENANT_ID + +# Authenticate +outlook-cli auth login --account work +``` + +## Exchange On-Premises + +`outlook-cli` uses Microsoft Graph, which requires cloud-connected Exchange. Pure on-premises Exchange without hybrid connectivity is not supported. + +## JSON Output for Scripting + +All account commands support `--json`: + +```bash +outlook-cli account list --json +``` + +```json +{ + "accounts": [ + { "alias": "personal", "email": "user@outlook.com", "tenantId": "common" }, + { "alias": "work", "email": "user@company.com", "tenantId": "xxx-xxx" } + ], + "defaultAccount": "personal" +} +``` diff --git a/docs/SECURITY-DESIGN.md b/docs/SECURITY-DESIGN.md new file mode 100644 index 0000000..ea0326f --- /dev/null +++ b/docs/SECURITY-DESIGN.md @@ -0,0 +1,972 @@ +# Security Design Document — outlook-cli + +> **Version**: 2.0 | **Date**: April 2026 | **Classification**: Internal — For Security Review +> +> **Target Audience**: Windows OS engineers, Outlook/Exchange system engineers, enterprise +> security reviewers evaluating outlook-cli for production deployment. + +--- + +## Table of Contents + +1. [Executive Summary](#1-executive-summary) +2. [System Overview](#2-system-overview) +3. [Threat Model](#3-threat-model) +4. [Authentication Architecture](#4-authentication-architecture) +5. [Token Storage & Encryption](#5-token-storage--encryption) +6. [Authorization & Scope Management](#6-authorization--scope-management) +7. [Network Security](#7-network-security) +8. [Data at Rest](#8-data-at-rest) +9. [Agent Gateway Security](#9-agent-gateway-security) +10. [Supply Chain & Dependencies](#10-supply-chain--dependencies) +11. [Compliance & Standards Mapping](#11-compliance--standards-mapping) +12. [Known Limitations & Residual Risk](#12-known-limitations--residual-risk) +13. [Recommendations for Enterprise Deployment](#13-recommendations-for-enterprise-deployment) + +--- + +## 1. Executive Summary + +outlook-cli is a cross-platform command-line interface for Microsoft Outlook email, +calendar, and contacts via the Microsoft Graph API. It has two implementations — a +Node.js reference implementation (`src/node/`) and a C# NativeAOT-compiled binary +(`src/dotnet/`) — that share the same security model, file formats, and encrypted +token cache. + +**Deployment contexts:** +- Standalone CLI tool on a developer or administrator workstation +- Gateway process inside OpenClaw/NanoClaw AI agent environments +- Automated CI/CD pipeline for email-driven workflows + +**Security posture: defense-in-depth** with four enforcement layers: +1. **Azure App Registration** — defines the maximum possible permissions +2. **MSAL token acquisition** — requests only the scopes configured per account +3. **JWT scope validation** — inspects every token before use, rejects forbidden scopes +4. **CLI write-guard** — blocks write operations for read-only accounts at the command level + +**Key cryptographic properties:** +- Token cache encrypted with AES-256-GCM (NIST SP 800-38D) +- Key derived via PBKDF2-HMAC-SHA512 with 310,000 iterations (OWASP 2023 minimum) +- Fresh random salt (32 bytes) and IV (12 bytes) per encryption operation +- Authentication tag (16 bytes) provides tamper detection + +**Dependencies**: 3 Node.js production dependencies (all from Microsoft or well-established +maintainers), 6 NuGet packages (all Microsoft-published). No custom cryptographic +implementations — all crypto uses platform built-ins (`node:crypto`, `System.Security.Cryptography`). + +--- + +## 2. System Overview + +### 2.1 Architecture + +``` +User / Agent + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ CLI Layer (Commander.js / System.CommandLine) │ +│ • Parse flags, validate input, resolve short IDs │ +│ • Write-guard: block writes for read-only accounts │ +│ • Confirmation prompts (--yes bypasses for agents) │ +└────────────────┬────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Graph Client (src/node/graph/client.js, lines 21-370) │ +│ • Token acquisition via MSAL (silent → interactive) │ +│ • Token scope validation on every acquisition (line 151) │ +│ • HTTPS-only requests to graph.microsoft.com │ +│ • Retry logic: 401 → refresh token; 429 → Retry-After │ +│ • Timeout: 30s default with AbortController │ +└────────────────┬────────────────────────────────────────────┘ + │ HTTPS (TLS 1.2+) + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Microsoft Graph API (v1.0) │ +│ • Server-side scope enforcement (403 on insufficient perms)│ +│ • Rate limiting (429 with Retry-After header) │ +│ • Delegated permissions model (user context, not app) │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 2.2 Data Flow + +``` +Authentication: + User → Browser/Device Code → Azure AD → Authorization Code → MSAL → Token + Token → AES-256-GCM encrypt → ~/.outlook-cli/cache-.enc + +API Request: + CLI command → GraphClient.getToken() → decrypt cache → MSAL acquireTokenSilent + → validateTokenScopes(result) → fetch(HTTPS) with Bearer token → response +``` + +### 2.3 Trust Boundaries + +| Boundary | Inside (Trusted) | Outside (Untrusted) | +|---|---|---| +| **Local machine** | CLI process, encrypted cache, config files | Network, Graph API responses | +| **MSAL library** | Token lifecycle, PKCE, refresh rotation | Azure AD endpoints | +| **Azure AD** | Scope enforcement, token issuance | App Registration config (admin-controlled) | +| **Graph API** | Permission enforcement, data access | Third-party mailbox data | + +--- + +## 3. Threat Model + +### 3.1 Threat Actors + +| Actor | Capability | Motivation | +|---|---|---| +| **Compromised AI agent** | Runs CLI commands with `--yes --json` | Send unauthorized email, exfiltrate data | +| **Local attacker** | Access to `~/.outlook-cli/` directory | Steal tokens, impersonate user | +| **Network attacker** | Man-in-the-middle position | Intercept tokens in transit | +| **Supply chain attacker** | Compromised npm/NuGet package | Inject malicious code into build | +| **Misconfigured admin** | Grants excessive Azure permissions | Accidentally enable `Mail.ReadWrite.All` | + +### 3.2 STRIDE Analysis + +| Threat | Category | Component | Mitigation | Residual Risk | +|---|---|---|---|---| +| Attacker obtains refresh token from disk | **Spoofing** | Token cache | AES-256-GCM encryption with PBKDF2 key derivation (`src/node/security/crypto.js`, line 18-24) | Low — requires local access + passphrase knowledge | +| Attacker modifies encrypted cache to inject scopes | **Tampering** | Token cache | GCM authentication tag detects any modification (16-byte tag, `crypto.js` line 21) | None — tampered files fail decryption | +| CLI operations not logged | **Repudiation** | Audit trail | SQLite operation logger records every command with correlation ID (`src/node/db/logger.js`) | Low — local DB can be deleted | +| Email data exposed to LLM pipeline | **Information Disclosure** | Agent gateway | PII redaction module (planned); read-only accounts limit data access | Medium — requires redaction deployment | +| Thousands of Graph API calls exhaust quota | **Denial of Service** | Graph client | Pagination limits (`maxPages` default 10), 429 retry with backoff (`client.js`, line 267-280) | Low — Graph API enforces per-user limits | +| Agent requests `Mail.ReadWrite.All` scope | **Elevation of Privilege** | Token validator | Forbidden scope enforcement on every token (`token-validator.js`, line 26) | None — blocked at both Azure AD and client | + +### 3.3 Attack Scenarios + +**Scenario 1: AI Agent Attempts Unauthorized Send** +``` +Agent calls: outlook-cli mail send 1 --yes + → CLI checks write-guard (read-only account? → blocked) + → GraphClient.getToken() validates scopes (Mail.ReadWrite.All? → blocked) + → Graph API enforces delegated permissions (no Mail.Send in token? → 403) +``` +Three independent layers must all be bypassed. Even if the CLI is modified, the +Graph API enforces permissions server-side. + +**Scenario 2: Stolen Cache File** +``` +Attacker copies ~/.outlook-cli/cache-work.enc to another machine + → Decryption requires passphrase + → Default passphrase is username@hostname:alias (machine-bound) + → Different machine = different hostname = wrong passphrase + → PBKDF2(310k iterations) prevents brute-force (~100ms/attempt) + → Even with OUTLOOK_CLI_PASSPHRASE, attacker needs the env var value +``` + +**Scenario 3: Compromised App Registration** +``` +Admin accidentally adds Mail.ReadWrite.All to the app registration + → User authenticates, Azure AD issues token with the scope + → validateTokenScopes() (client.js line 151) decodes JWT + → Finds "Mail.ReadWrite.All" in scp claim + → Throws SECURITY error, refuses to proceed + → Defense-in-depth catches what Azure AD allowed +``` + +--- + +## 4. Authentication Architecture + +### 4.1 OAuth 2.0 Authorization Code with PKCE + +outlook-cli uses the **Authorization Code flow with Proof Key for Code Exchange** +(RFC 7636) as the primary authentication method. This is the recommended flow for +native/CLI applications per RFC 8252 (OAuth 2.0 for Native Apps). + +**Implementation** (`src/node/auth/auth-flows.js`, lines 43-126): + +``` +1. Generate PKCE codes (line 52): + const { verifier, challenge } = await cryptoProvider.generatePkceCodes(); + // verifier: 43-128 char random string + // challenge: SHA-256 hash of verifier, base64url-encoded + +2. Build authorization URL (line 63): + MSAL constructs URL with client_id, scopes, challenge, response_type=code + +3. Start localhost HTTP server (line 69): + Server listens on port 53847 for the callback + +4. Open browser → user authenticates at login.microsoftonline.com + +5. Azure AD redirects to http://localhost:53847/callback?code=AUTH_CODE + +6. Exchange code for tokens (line 87): + msalClient.acquireTokenByCode({ code, codeVerifier: verifier }) + // MSAL sends verifier to Azure AD, which verifies SHA-256(verifier) == challenge +``` + +**Why PKCE and not client secrets**: outlook-cli is a `PublicClientApplication` +(`src/node/auth/msal-client.js`, line 85). CLI tools cannot securely store client +secrets — any embedded secret would be extractable from the binary. PKCE provides +equivalent security without requiring a secret, by cryptographically binding the +token exchange to the original authorization request. + +**MSAL library**: `@azure/msal-node` v5.1.2 (Node.js), `Microsoft.Identity.Client` +v4.67.x (C#). Both are official Microsoft libraries maintained by the Microsoft +Identity team. MSAL handles PKCE code generation, token refresh rotation, and +cache serialization internally. + +### 4.2 Device Code Flow + +For headless environments (containers, SSH sessions, CI runners), outlook-cli supports +the Device Code flow (RFC 8628). The user authenticates on a separate device with +a browser. + +**Implementation** (`src/node/auth/auth-flows.js`, device code section): +``` +1. Request device code from Azure AD +2. Display code + verification URL to user +3. Poll Azure AD until user completes authentication +4. Receive tokens on success +``` + +This flow is recommended for OpenClaw/NanoClaw containers that lack a browser. + +### 4.3 Token Lifecycle + +``` + ┌─────────────────┐ + │ User Login │ + │ (PKCE/Device) │ + └────────┬────────┘ + │ + ┌────────▼────────┐ + │ Azure AD │ + │ Issues tokens: │ + │ • Access (1hr) │ + │ • Refresh(90d) │ + └────────┬────────┘ + │ + ┌──────────────▼──────────────┐ + │ MSAL Token Cache │ + │ (in-memory + encrypted disk)│ + └──────────────┬──────────────┘ + │ + ┌─────────────▼─────────────┐ + │ Each CLI invocation: │ + │ 1. Decrypt cache from disk│ + │ 2. acquireTokenSilent() │ + │ → access token valid? │ + │ Yes → use it │ + │ No → use refresh tok │ + │ 3. validateTokenScopes() │ + │ 4. Make Graph API call │ + │ 5. Re-encrypt if changed │ + └───────────────────────────┘ +``` + +**Token expiration handling**: +- Access token: ~1 hour. MSAL refreshes automatically via refresh token. +- Refresh token: ~90 days (Microsoft default). After expiry, user must re-authenticate. +- `acquireTokenSilent()` failure classification (`src/node/auth/msal-client.js`, lines 155-204): + - `InteractionRequiredAuthError` → user must re-login (MFA change, password change) + - `invalid_grant` → refresh token expired or revoked + - `AADSTS50076/50079` → MFA required (conditional access policy change) + - `AADSTS50173` → password changed since last login + - Network errors → rethrown (not a token issue) + +### 4.4 Multi-Tenant Support + +The MSAL authority URL controls which Microsoft accounts can authenticate: + +```javascript +// src/node/auth/msal-client.js, line 79 +authority: `https://login.microsoftonline.com/${tenantId}`, +``` + +| `tenantId` Value | Account Types Accepted | +|---|---| +| `common` (default) | Personal Microsoft + work/school | +| `consumers` | Personal Microsoft accounts only | +| `organizations` | Work/school accounts only | +| Specific GUID | Single Azure AD tenant only | + +The tenant is configured per account in `accounts.json`, allowing mixed environments +(e.g., personal email + corporate email with different tenants). + +--- + +## 5. Token Storage & Encryption + +### 5.1 Encryption Algorithm + +**Algorithm**: AES-256-GCM (Galois/Counter Mode) +**Standard**: NIST SP 800-38D — Recommendation for Block Cipher Modes of Operation: Galois/Counter Mode + +AES-GCM provides both **confidentiality** (encryption) and **integrity** (authentication). +The 16-byte authentication tag ensures that any modification to the ciphertext — even +a single bit flip — causes decryption to fail rather than produce corrupted plaintext. + +**Implementation** (`src/node/security/crypto.js`, lines 18-24): +```javascript +const ALGORITHM = 'aes-256-gcm'; +const IV_LENGTH = 12; // 96-bit IV — NIST SP 800-38D §5.2.1.1 recommended +const SALT_LENGTH = 32; // 256-bit salt for PBKDF2 +const TAG_LENGTH = 16; // 128-bit GCM authentication tag (full tag) +const KEY_LENGTH = 32; // 256-bit key for AES-256 +const PBKDF2_ITERATIONS = 310_000; // OWASP 2023 recommended minimum for PBKDF2-SHA512 +const PBKDF2_DIGEST = 'sha512'; +``` + +**C# equivalent** (`src/dotnet/Security/CryptoService.cs`, lines 23-40): +Uses `System.Security.Cryptography.AesGcm` with identical parameters. Both +implementations produce interoperable ciphertext — a cache encrypted by Node.js +decrypts correctly in C# and vice versa. + +**Why 12-byte IV (not 16)**: NIST SP 800-38D §5.2.1.1 specifies that 96-bit (12-byte) +IVs are the recommended length for GCM. Other lengths require an additional hashing +step and provide no security benefit. The .NET `AesGcm` class requires exactly 12 bytes, +making this choice essential for cross-implementation interoperability. + +### 5.2 Key Derivation + +**Algorithm**: PBKDF2-HMAC-SHA512 +**Standard**: NIST SP 800-132 — Recommendation for Password-Based Key Derivation + +```javascript +// src/node/security/crypto.js, line 31-33 +function deriveKey(passphrase, salt) { + return pbkdf2Sync(passphrase, salt, PBKDF2_ITERATIONS, KEY_LENGTH, PBKDF2_DIGEST); +} +``` + +**310,000 iterations**: This matches the OWASP 2023 Password Storage Cheat Sheet +recommendation for PBKDF2-HMAC-SHA512. At typical hardware speeds, this produces +~100ms per key derivation attempt, making brute-force attacks impractical. + +**Fresh salt per encryption**: Each call to `encrypt()` generates 32 random bytes +(`crypto.js`, line 40). This means identical plaintext encrypted at different times +produces completely different ciphertext, preventing precomputation attacks. + +### 5.3 Binary Cache Format + +``` +Offset Length Field Purpose +────── ────── ──────────── ────────────────────────────────── +0 32 salt PBKDF2 salt (unique per encryption) +32 12 iv GCM initialization vector (unique per encryption) +44 16 authTag GCM authentication tag (integrity check) +60 var ciphertext MSAL serialized token cache (encrypted) +``` + +The format is intentionally simple — a single binary blob with no headers or metadata. +This avoids format-parsing vulnerabilities and makes implementation straightforward +in both languages. + +### 5.4 Passphrase Sources + +**Priority 1 — Environment variable** (recommended for production): +```bash +export OUTLOOK_CLI_PASSPHRASE="your-strong-passphrase-here" +``` + +**Priority 2 — Machine-derived** (convenient for personal machines): +```javascript +// src/node/security/crypto.js, lines 135-146 +export function getPassphrase(accountAlias = 'default') { + if (process.env.OUTLOOK_CLI_PASSPHRASE) { + return process.env.OUTLOOK_CLI_PASSPHRASE; + } + const host = hostname(); + const user = userInfo().username; + return `outlook-cli:${user}@${host}:${accountAlias}`; +} +``` + +**Machine-derived passphrase security analysis**: +- The passphrase format `outlook-cli:@:` is deterministic and + predictable if the attacker knows the username, hostname, and account alias. +- However, the combination of PBKDF2(310k iterations) + AES-256-GCM means that + even a known passphrase requires ~100ms per decryption attempt, and the attacker + must first obtain the encrypted cache file (requiring local access). +- **Risk**: Low. An attacker with local filesystem access likely has simpler attack + vectors (keylogger, process memory dump). The machine-derived passphrase provides + adequate protection against casual theft (e.g., backup drives, shared filesystems). +- **Mitigation**: Production and shared environments should always set + `OUTLOOK_CLI_PASSPHRASE` to a strong, randomly-generated value stored in a + secrets manager (Azure Key Vault, HashiCorp Vault, etc.). + +**C# interoperability** (`src/dotnet/Security/CryptoService.cs`, line 143): +```csharp +var host = System.Net.Dns.GetHostName(); // Preserves original casing +``` +Note: C# uses `Dns.GetHostName()` (not `Environment.MachineName`) because +`Environment.MachineName` uppercases the hostname on Windows, which would produce +a different passphrase than Node.js's `os.hostname()` and break interoperability. + +### 5.5 Degradation Behavior + +If decryption fails (wrong passphrase, corrupted file, format mismatch): +1. Log a warning to stderr: "Could not decrypt token cache. Starting fresh." +2. Initialize MSAL with an empty cache +3. User must re-authenticate (`outlook-cli auth login`) +4. **No crash, no stack trace, no data loss** — the encrypted file is preserved + +This design prevents a corrupted cache from permanently locking out a user. + +--- + +## 6. Authorization & Scope Management + +### 6.1 Default Scopes + +outlook-cli requests the following Microsoft Graph delegated permissions at login time. +Scopes are fixed per authentication session — changing them requires `auth logout` +followed by `auth login`. + +**Default scopes** (`src/node/auth/msal-client.js`, lines 33-45): + +| Scope | Purpose | Risk | Graph Endpoints Unlocked | +|---|---|---|---| +| `User.Read` | Display logged-in user info | Low | `GET /me` | +| `Mail.Read` | Read own mailbox | Medium | `GET /me/messages`, `GET /me/mailFolders` | +| `Mail.ReadWrite` | Create drafts, move, flag | Medium | `POST /me/messages`, `PATCH /me/messages/{id}` | +| `Mail.Send` | Send email from own account | High | `POST /me/messages/{id}/send`, `POST /me/sendMail` | +| `Mail.Read.Shared` | Read delegate mailboxes | Medium | `GET /users/{id}/messages` (with `--as` flag) | +| `Mail.ReadWrite.Shared` | Create drafts in delegate mailboxes | Medium | `POST /users/{id}/messages` | +| `Mail.Send.Shared` | Send on behalf of delegate | High | `POST /users/{id}/messages/{id}/send` | +| `Calendars.Read` | Read own calendar | Low | `GET /me/calendarView`, `GET /me/events` | +| `Calendars.ReadWrite` | Create/modify own events | Medium | `POST /me/events`, `PATCH /me/events/{id}` | +| `Calendars.Read.Shared` | Read delegate calendars | Low | `GET /users/{id}/calendarView` | +| `offline_access` | Refresh token for silent renewal | Low | (MSAL infrastructure — no API endpoint) | + +### 6.2 Read-Only Account Mode + +Accounts configured with `mode: "read-only"` use a reduced scope set that excludes +all write and send permissions. + +**Read-only scopes** (`src/node/auth/msal-client.js`, lines 93-101): + +| Scope | Purpose | +|---|---| +| `User.Read` | Identity | +| `Mail.Read` | Read own mail | +| `Mail.Read.Shared` | Read delegate mail | +| `Calendars.Read` | Read own calendar | +| `Calendars.Read.Shared` | Read delegate calendars | +| `Contacts.Read` | Read contacts | +| `offline_access` | Token refresh | + +**Enforcement layers for read-only accounts**: + +1. **MSAL scope request** (`msal-client.js`, line 115): Read-only accounts request + only read scopes. Azure AD will not issue a token with write permissions because + they were never requested. + +2. **CLI write-guard** (`src/node/security/write-guard.js`, lines 26-33): Before + executing any write operation (draft, move, flag, send, calendar create), the + CLI checks `isReadOnly(accountAlias)`. If true, it prints an error and exits + with non-zero status — the Graph API call is never made. + +3. **Graph API server-side** (Microsoft infrastructure): Even if layers 1 and 2 + are bypassed, the Graph API will return HTTP 403 because the token lacks the + required write permissions. + +### 6.3 Forbidden Scope: Mail.ReadWrite.All + +`Mail.ReadWrite.All` is an **application-level** permission that grants access to +**every mailbox in the organization** without user consent. This scope is permanently +forbidden in outlook-cli. + +**Enforcement** (`src/node/security/token-validator.js`, line 26): +```javascript +const DEFAULT_FORBIDDEN_SCOPES = ['Mail.ReadWrite.All']; +``` + +**C# equivalent** (`src/dotnet/Security/TokenValidator.cs`, line 49): +```csharp +private static readonly HashSet ForbiddenScopes = new(StringComparer.OrdinalIgnoreCase) +{ + "Mail.ReadWrite.All" +}; +``` + +**Validation runs on every token acquisition** (`src/node/graph/client.js`, line 151): +```javascript +validateTokenScopes(result); +``` + +The validation performs two independent checks: +1. **MSAL result scopes array** — inspects `result.scopes` for forbidden entries +2. **JWT payload decode** — decodes the `scp` claim from the JWT payload and checks + for forbidden entries (belt-and-suspenders approach) + +For personal Microsoft accounts that return opaque (non-JWT) tokens, only check #1 +applies. This is acceptable because the MSAL scopes array accurately reflects the +granted permissions regardless of token format. + +### 6.4 Configurable Permissions + +Beyond the global scope lists, individual accounts can have custom permission +configurations in `accounts.json`: + +```json +{ + "accounts": { + "restricted-agent": { + "permissions": { + "allowed": ["Mail.Read", "Calendars.Read"], + "forbidden": ["Mail.Send"], + "send_to": ["approved-recipient@company.com"] + } + } + } +} +``` + +**Permission resolution** (`src/node/security/permissions.js`, line 145): +- `defaults` block → `parent` account inheritance → account-specific overrides +- `+`/`-` modifiers for additive/subtractive scope changes +- Circular reference detection prevents infinite loops (`resolveRecursive` with + `visited` Set, lines 87-99) +- `send_to` whitelist restricts which email addresses the account can send to + (`validateRecipients`, lines 159-171) + +--- + +## 7. Network Security + +### 7.1 Transport Layer + +All Microsoft Graph API communication uses HTTPS exclusively. + +```javascript +// src/node/graph/client.js, line 21 +const GRAPH_BASE = 'https://graph.microsoft.com/v1.0'; +``` + +The CLI does not support custom Graph API endpoints, HTTP proxies with TLS +termination, or any mechanism to downgrade to plain HTTP. The `node:https` +module (or .NET `HttpClient`) handles TLS negotiation using the platform's +default certificate store, which trusts the Microsoft certificate chain. + +**TLS version**: Determined by the Node.js/CLR runtime. Node.js 20+ defaults to +TLS 1.2+ with modern cipher suites. .NET 8 uses `SslProtocols.None` (OS default), +which on modern Windows/Linux is TLS 1.2 or 1.3. + +### 7.2 Request Authentication + +Every Graph API request includes a Bearer token in the `Authorization` header +(RFC 6750): + +```javascript +// src/node/graph/client.js, lines 210-211 +const headers = { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', +}; +``` + +The token is never logged, written to disk in plaintext, or included in error +messages. Telemetry events record the endpoint and status code but not the token. + +### 7.3 Timeout Protection + +```javascript +// src/node/graph/client.js, lines 222-224 +const controller = new AbortController(); +const timer = setTimeout(() => controller.abort(), timeoutMs); +fetchOptions.signal = controller.signal; +``` + +Default timeout: 30 seconds (`client.js`, line 203). This prevents the CLI from +hanging indefinitely on network issues. Aborted requests emit a `graph.error` +telemetry event with `metadata: { error: 'timeout' }`. + +### 7.4 Retry Logic + +| Status Code | Behavior | Implementation | +|---|---|---| +| **401 Unauthorized** | Clear cached token, retry with fresh token | `client.js`, lines 259-263 | +| **429 Too Many Requests** | Wait `Retry-After` seconds (default 5), retry | `client.js`, lines 267-280 | +| **Other errors** | Throw with status code and response body | `client.js`, lines 302-320 | + +**Maximum retries**: 2 (configurable via `options.maxRetries`). This prevents +infinite retry loops while handling transient failures gracefully. + +**429 handling detail**: The CLI respects the `Retry-After` header from Microsoft +Graph, which indicates server-side throttle duration. The wait is logged to stderr +so the user sees "Rate limited. Waiting 5s..." and a telemetry event is emitted +with the `graph.throttle` type for monitoring. + +### 7.5 Delegate Access Path Safety + +When accessing another user's mailbox via `--as `, the email is encoded +to prevent path injection: + +```javascript +// src/node/graph/client.js, lines 70-73 +get userPath() { + if (this.delegateFor) { + return `/users/${encodeURIComponent(this.delegateFor)}`; + } + return '/me'; +} +``` + +`encodeURIComponent()` prevents path traversal attacks — an email like +`user@company.com/../../admin` would be encoded to +`user%40company.com%2F..%2F..%2Fadmin`, which the Graph API would reject as +an invalid user identifier. + +--- + +## 8. Data at Rest + +### 8.1 Configuration Directory + +All outlook-cli data is stored in `~/.outlook-cli/` on the user's home directory. + +| File | Encrypted | Sensitive | Content Description | +|---|---|---|---| +| `accounts.json` | No | Low | Account aliases, client IDs, tenant IDs, email addresses, mode (read-only/full). Client IDs are public (they identify the app registration, not a secret). | +| `cache-.enc` | **Yes** (AES-256-GCM) | **High** | MSAL serialized token cache containing access tokens (1hr expiry) and refresh tokens (~90 day expiry). One file per account. | +| `aliases.json` | No | Low | Contact name-to-email mappings for convenience addressing. | +| `config.json` | No | Low | Global CLI configuration (format preferences, telemetry opt-in). No secrets. | +| `outlook-cli.db` | No | Low | SQLite database (WAL mode) with operation logs and telemetry. Contains command names, timestamps, Graph API endpoints, and durations. Does not contain email content, tokens, or credentials. | +| `page-state.json` | No | None | Pagination cursor for `--page next/prev` continuation. Contains `@odata.nextLink` URLs (which include access tokens in query parameters — these expire in 1 hour). | +| `last-results.json` | No | Low | Short ID → Graph message ID mapping from the most recent `inbox` or `search` command. Contains Graph API message IDs (opaque strings, not email content). | +| `delta-.json` | No | Low | Microsoft Graph delta sync tokens for watch mode. Opaque strings that track change position. | + +### 8.2 File Permissions + +**Unix systems**: The `~/.outlook-cli/` directory should be restricted to the +owning user: + +```bash +chmod 700 ~/.outlook-cli # Directory: owner-only access +chmod 600 ~/.outlook-cli/*.enc # Cache files: owner read/write only +chmod 600 ~/.outlook-cli/accounts.json # Config: owner only +``` + +**Windows**: The directory inherits ACLs from the user's home directory (`%USERPROFILE%`). +On standard Windows configurations, the home directory is accessible only to the +user and administrators. + +**Note**: The code does not currently enforce file permissions programmatically +after writing cache files — it relies on the system umask (Unix) or inherited +ACLs (Windows). Since the cache files are encrypted, the impact of overly +permissive file modes is mitigated by the AES-256-GCM encryption layer. + +### 8.3 SQLite Database + +The operations database (`outlook-cli.db`) uses WAL (Write-Ahead Logging) mode +(`src/node/db/database.js`, lines 91-92) for safe concurrent access from multiple +CLI instances: + +```javascript +db.pragma('journal_mode = WAL'); +db.pragma('busy_timeout = 5000'); +``` + +WAL mode allows multiple readers to operate simultaneously while a single writer +holds the database. The 5-second busy timeout prevents immediate failures when +two CLI invocations write concurrently. + +**Data stored**: Operation type, timestamp, correlation ID, duration, account alias, +Graph endpoint, status (success/fail). **Not stored**: email content, message bodies, +token values, credentials. + +--- + +## 9. Agent Gateway Security + +### 9.1 Deployment Model + +When used with OpenClaw/NanoClaw AI agent platforms, outlook-cli operates as a +**gateway process** that bridges email channels to the agent's message loop. + +**Recommended two-account configuration**: + +| Account | Mode | Purpose | Scopes | +|---|---|---|---| +| Primary (user's email) | `read-only` | Monitor inbox for inbound messages | `Mail.Read`, `Calendars.Read` | +| Agent (dedicated email) | `full` | Create drafts, respond to requests | `Mail.ReadWrite`, `Mail.Send` | + +This separation ensures the agent cannot modify the user's primary mailbox. The +agent can only create drafts and send from its own dedicated address. + +### 9.2 Gateway Script Security + +The gateway scripts (`skill/openclaw/gateway.ps1`, `skill/nanoclaw/gateway.sh`) +automatically: + +1. Start outlook-cli in webhook or polling mode +2. Pipe JSONL events to the agent's message loop +3. Handle Graph subscription management and renewal + +**Webhook mode** uses Microsoft Graph change notifications (push), which require +an HTTPS endpoint. The gateway uses `cloudflared` (Cloudflare Tunnel) to expose +a local HTTP server without opening inbound ports. The tunnel provides TLS +termination. + +**Polling mode** (fallback) polls the Graph API at configurable intervals. This +does not require any inbound network access. + +### 9.3 Agent Command Restrictions + +When invoked by an agent, outlook-cli commands include `--yes --json`: +- `--yes` bypasses interactive confirmation prompts (appropriate for programmatic use) +- `--json` produces structured output for parsing + +The agent cannot escalate beyond the permissions granted by the account's scope set +and Azure App Registration. Even if the agent constructs arbitrary CLI commands, +the security layers in §6 (scope validation, write-guard, forbidden scopes) enforce +the permission boundaries. + +### 9.4 PII Considerations + +Email content passed to AI agents may contain personally identifiable information. +The planned PII redaction module (§6 of the implementation plan) will: +- Detect and tokenize email addresses, phone numbers, credit card numbers, and API keys +- Use `@redactpii/node` (Node.js) and `Microsoft.Extensions.Compliance.Redaction` + `ZeroRedact` (C#) +- Replace detected PII with tokens (`**REDACTED-1:email**`) before sending to the agent +- Maintain an in-memory mapping for reconstruction (never persisted to disk) +- Support custom recognizer patterns via `--redact-config` + +--- + +## 10. Supply Chain & Dependencies + +### 10.1 Node.js Dependencies + +outlook-cli has **3 production dependencies** (`package.json`, lines 41-45): + +| Package | Version | Publisher | Purpose | Security Notes | +|---|---|---|---|---| +| `@azure/msal-node` | ^5.1.2 | Microsoft | OAuth 2.0 / MSAL authentication | Official Microsoft Identity library. Handles PKCE, token refresh, cache serialization. No known critical CVEs. | +| `better-sqlite3` | ^12.9.0 | Joshua Wise | SQLite database access | Native C++ addon. Used only for operation logs and telemetry — not for token storage. Well-maintained with regular releases. | +| `commander` | ^14.0.3 | TJ Holowaychuk | CLI argument parsing | Standard Node.js CLI framework. No security-critical operations — only parses command-line flags. | + +**Dev dependencies**: `vitest` ^4.1.4 (test runner only — not included in production). + +**Cryptographic operations** use Node.js built-in `node:crypto` module — no +third-party crypto libraries. This module wraps OpenSSL, which is the standard +cryptographic implementation used by the Node.js runtime. + +### 10.2 C# Dependencies + +(`src/dotnet/OutlookCli.csproj`, lines 23-29): + +| Package | Version | Publisher | Purpose | Security Notes | +|---|---|---|---|---| +| `Microsoft.Identity.Client` | 4.67.* | Microsoft | MSAL.NET authentication | Official Microsoft Identity library. Same PKCE/refresh logic as Node.js. | +| `Microsoft.Data.Sqlite.Core` | 8.* | Microsoft | SQLite data access | ADO.NET provider for SQLite. Used for operation logs only. | +| `SQLitePCLRaw.core` | 2.* | Eric Sink | SQLite native interop | Required for NativeAOT compatibility with `e_sqlite3` provider. | +| `SQLitePCLRaw.provider.e_sqlite3` | 2.* | Eric Sink | SQLite native provider | Explicit provider selection for NativeAOT (not bundle pattern). | +| `SQLitePCLRaw.lib.e_sqlite3` | 2.* | Eric Sink | SQLite native library | Contains the compiled SQLite native binary. | +| `System.CommandLine` | 2.0.5 | Microsoft | CLI argument parsing | Microsoft's CLI framework. Stable release. | + +**Cryptographic operations** use `System.Security.Cryptography.AesGcm` and +`System.Security.Cryptography.Rfc2898DeriveBytes` — built-in .NET BCL classes. +No third-party crypto packages. + +### 10.3 Supply Chain Mitigations + +- **Minimal dependency surface**: 3 production packages (Node.js), 6 packages (C#) +- **No vendored code**: All dependencies installed via npm/NuGet from official registries +- **No custom crypto**: All encryption/hashing uses platform built-ins +- **Lock files**: `package-lock.json` pins exact dependency versions +- **Recommended CI practice**: Run `npm audit` and `dotnet list package --vulnerable` + in CI pipelines to detect newly disclosed vulnerabilities + +--- + +## 11. Compliance & Standards Mapping + +### 11.1 Cryptographic Standards + +| Standard | Application | Implementation | +|---|---|---| +| **NIST SP 800-38D** | AES-GCM mode of operation | `crypto.js` line 18: `aes-256-gcm` | +| **NIST SP 800-132** | Password-based key derivation | `crypto.js` line 31: `pbkdf2Sync()` with SHA-512 | +| **NIST SP 800-38D §5.2.1.1** | GCM IV length recommendation | `crypto.js` line 19: 12-byte (96-bit) IV | +| **OWASP 2023 Password Storage** | PBKDF2 iteration count | `crypto.js` line 23: 310,000 iterations | + +### 11.2 OAuth 2.0 Standards + +| Standard | Application | Implementation | +|---|---|---| +| **RFC 7636** | PKCE for public clients | `auth-flows.js` line 52: `generatePkceCodes()` with S256 | +| **RFC 8252** | OAuth 2.0 for Native Apps | Localhost redirect (line 50), system browser | +| **RFC 8252 §7.3** | Loopback redirect URIs | HTTP allowed for `127.0.0.1`/`::1` | +| **RFC 8628** | Device Authorization Grant | Device code flow for headless environments | +| **RFC 6750** | Bearer Token Usage | `client.js` line 211: `Authorization: Bearer ${token}` | + +### 11.3 OWASP Top 10 Mapping (for CLI Tools) + +| # | Category | Status | Evidence | +|---|---|---|---| +| A01 | Broken Access Control | ✅ Mitigated | 4-layer authorization (§6), forbidden scopes, read-only mode | +| A02 | Cryptographic Failures | ✅ Addressed | AES-256-GCM, PBKDF2-SHA512-310k, fresh salt/IV (§5) | +| A03 | Injection | ✅ Addressed | `encodeURIComponent()` for user paths (§7.5), parameterized SQL | +| A04 | Insecure Design | ✅ Addressed | Defense-in-depth architecture, threat model (§3) | +| A05 | Security Misconfiguration | ✅ Mitigated | Sensible defaults, forbidden scope catch (§6.3) | +| A06 | Vulnerable Components | ✅ Addressed | Minimal dependencies, all reputable (§10) | +| A07 | Authentication Failures | ✅ Addressed | MSAL handles auth, proper error classification (§4.3) | +| A08 | Data Integrity Failures | ✅ Addressed | GCM authentication tag, no unsigned updates | +| A09 | Logging & Monitoring | ⚠️ Partial | Operation logging exists; token refresh telemetry could be expanded | +| A10 | Server-Side Request Forgery | ✅ N/A | Fixed Graph API URL, no user-controlled endpoints | + +### 11.4 Data Handling + +outlook-cli processes Microsoft 365 email, calendar, and contact data. This data +resides in Microsoft's cloud infrastructure, which holds SOC 2 Type II, ISO 27001, +and HIPAA BAA certifications. outlook-cli does not independently store email content — +it reads from and writes to the Graph API in real time. The only persistent data +is the token cache (encrypted), operation logs (no email content), and delta sync +tokens (opaque strings). + +--- + +## 12. Known Limitations & Residual Risk + +### 12.1 Machine-Derived Passphrase (Low Risk) + +**What**: The default passphrase (`outlook-cli:@:`) is predictable +if the attacker knows the username, hostname, and account alias. + +**Why accepted**: An attacker with this knowledge already has local access to the +machine, where simpler attack vectors exist (process memory, keylogging). The +PBKDF2 slowdown (~100ms/attempt) prevents rapid brute-force. The env var override +is available for higher-security deployments. + +**Mitigation**: Set `OUTLOOK_CLI_PASSPHRASE` via a secrets manager in production. + +### 12.2 No Programmatic File Permission Enforcement (Low Risk) + +**What**: Cache files are written using the system's default umask. On misconfigured +systems, they could be world-readable. + +**Why accepted**: The files are encrypted with AES-256-GCM. Even if readable, they +are useless without the passphrase. Home directories on properly configured systems +are already restricted to the owning user. + +**Mitigation**: Document recommended `chmod` settings. Consider adding explicit +`chmod 600` in a future release for defense-in-depth. + +### 12.3 Localhost HTTP Redirect (Accepted per Spec) + +**What**: The PKCE authentication flow uses `http://localhost:53847/callback` +(plain HTTP, not HTTPS). + +**Why accepted**: RFC 8252 §7.3 explicitly allows HTTP for loopback redirect URIs +in native applications. The authorization code is single-use, short-lived (~1 minute), +and cryptographically bound to the PKCE verifier. A localhost listener is only +accessible from the local machine. + +### 12.4 No Automatic Token Revocation on Uninstall + +**What**: Deleting the outlook-cli binary does not revoke or delete cached tokens. +The encrypted cache files remain in `~/.outlook-cli/`. + +**Mitigation**: Users should run `outlook-cli auth logout` before uninstalling. +The refresh token also expires naturally after ~90 days of inactivity. + +### 12.5 page-state.json Contains @odata.nextLink URLs + +**What**: `page-state.json` stores Graph API `@odata.nextLink` URLs, which may +contain short-lived access tokens as query parameters. + +**Why accepted**: These tokens expire within 1 hour. The file is stored in the +user's home directory alongside the encrypted token cache. An attacker who can +read this file can likely also read the (more valuable) encrypted cache file. + +--- + +## 13. Recommendations for Enterprise Deployment + +### 13.1 Critical (Must Do) + +1. **Set `OUTLOOK_CLI_PASSPHRASE`** via Azure Key Vault, HashiCorp Vault, or your + organization's secrets management system. Do not rely on machine-derived passphrases + in shared or multi-user environments. + +2. **Use the NativeAOT binary** (`publish/win-x64/outlook-cli.exe`) in production. + It has no runtime dependencies, a smaller attack surface (single file, trimmed), + and ~10MB footprint. + +3. **Configure read-only mode** for monitoring accounts. Set `mode: "read-only"` in + `accounts.json` for any account that should only observe, not modify. + +4. **Review Azure App Registration** at [portal.azure.com](https://portal.azure.com) + quarterly. Verify that `Mail.ReadWrite.All` is not present in API permissions. + +5. **Restrict `send_to` lists** for agent accounts. In `accounts.json`, specify + the exact email addresses the account is allowed to send to. + +### 13.2 Recommended + +6. **Enable telemetry** (`OUTLOOK_CLI_TELEMETRY=1`) for production monitoring. + Telemetry records Graph API latency, error rates, and command durations to the + local SQLite database. Export this data to your monitoring system. + +7. **Run `outlook-cli doctor`** periodically (or in CI) to validate configuration: + token freshness, scope correctness, Graph API connectivity, database integrity. + +8. **Lock down `~/.outlook-cli/` permissions** on Unix: + ```bash + chmod 700 ~/.outlook-cli && chmod 600 ~/.outlook-cli/*.enc + ``` + +9. **Use device code flow** for shared machines or containers where opening a + browser is not possible: `outlook-cli auth login --device-code` + +10. **Rotate tokens** quarterly by running `outlook-cli auth logout && outlook-cli auth login`. + This invalidates the cached refresh token and issues a new one. + +### 13.3 For CI/CD Pipelines + +11. **Store account configuration as encrypted secrets** in your CI system (GitHub + Secrets, Azure DevOps Variable Groups). Inject `OUTLOOK_CLI_PASSPHRASE` and + account configuration at runtime. + +12. **Run `npm audit` / `dotnet list package --vulnerable`** in CI to detect + newly disclosed dependency vulnerabilities. + +13. **Use read-only accounts** for CI monitoring tasks. Only use write-capable + accounts for explicitly approved automation workflows. + +--- + +## Appendix A: References + +| Reference | URL | +|---|---| +| NIST SP 800-38D (AES-GCM) | https://csrc.nist.gov/pubs/sp/800/38/d/final | +| NIST SP 800-132 (PBKDF2) | https://csrc.nist.gov/pubs/sp/800/132/final | +| RFC 7636 (PKCE) | https://datatracker.ietf.org/doc/html/rfc7636 | +| RFC 8252 (OAuth 2.0 for Native Apps) | https://datatracker.ietf.org/doc/html/rfc8252 | +| RFC 8628 (Device Authorization Grant) | https://datatracker.ietf.org/doc/html/rfc8628 | +| RFC 6750 (Bearer Token Usage) | https://datatracker.ietf.org/doc/html/rfc6750 | +| OWASP Password Storage Cheat Sheet | https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html | +| OWASP Top 10 (2021) | https://owasp.org/Top10/ | +| Microsoft Graph API Reference | https://learn.microsoft.com/graph/api/overview | +| MSAL.js Documentation | https://learn.microsoft.com/entra/msal/js/ | +| MSAL.NET Documentation | https://learn.microsoft.com/entra/msal/dotnet/ | + +## Appendix B: Code File Index + +| File | Lines | Security Role | +|---|---|---| +| `src/node/security/crypto.js` | 1-147 | AES-256-GCM encryption/decryption, PBKDF2 key derivation, passphrase resolution | +| `src/node/security/token-validator.js` | 1-144 | JWT decode, forbidden scope check, dual-layer validation | +| `src/node/security/write-guard.js` | 1-33 | Read-only account enforcement at CLI layer | +| `src/node/security/permissions.js` | 1-196 | Configurable permission resolution with inheritance | +| `src/node/auth/msal-client.js` | 1-204 | MSAL configuration, scope lists, token acquisition | +| `src/node/auth/auth-flows.js` | 1-180 | PKCE flow, device code flow, localhost redirect server | +| `src/node/auth/token-cache.js` | 1-79 | MSAL cache plugin, encrypt-on-write, decrypt-on-read | +| `src/node/graph/client.js` | 1-392 | Graph API HTTP client, retry logic, timeout, telemetry | +| `src/node/accounts/manager.js` | 1-222 | Account configuration, read-only mode, aliases | +| `src/node/db/database.js` | 1-100 | SQLite WAL mode, operation logging schema | +| `src/dotnet/Security/CryptoService.cs` | 1-150 | C# AES-256-GCM encryption (interoperable) | +| `src/dotnet/Security/TokenValidator.cs` | 1-197 | C# forbidden scope validation | +| `src/dotnet/Auth/MsalClientFactory.cs` | 1-175 | C# MSAL configuration and scopes | +| `src/dotnet/Graph/GraphClient.cs` | 1-300+ | C# Graph API client with retry logic | +| `src/dotnet/Accounts/AccountManager.cs` | 1-266 | C# account management | diff --git a/docs/SECURITY.md b/docs/SECURITY.md new file mode 100644 index 0000000..dbe6ffe --- /dev/null +++ b/docs/SECURITY.md @@ -0,0 +1,201 @@ +# Security Model + +This document describes the security architecture of `outlook-cli`. For the comprehensive +enterprise security design document with STRIDE analysis, NIST/OWASP mappings, and code +citations, see [`SECURITY-DESIGN.md`](SECURITY-DESIGN.md). + +## Threat Model + +`outlook-cli` is designed to be used by developers, administrators, and AI agent platforms +(OpenClaw, NanoClaw) where the tool has access to real email, calendar, and contact data. +The primary threats are: + +1. **Unauthorized email sending** — An AI agent or compromised system could send emails without user knowledge +2. **Token theft** — Cached tokens could be exfiltrated and used by an attacker +3. **Scope escalation** — The tool could be modified to request more permissions than intended +4. **Data exfiltration** — Email/calendar data could be sent to unauthorized third parties + +## Security Boundaries + +### 1. Forbidden Scope: Mail.ReadWrite.All + +`Mail.ReadWrite.All` is an application-level permission that grants access to **every mailbox +in the organization** without user consent. This scope is permanently forbidden. It is enforced +at two levels: + +**Level 1 — Azure Identity Platform:** The app registration should not include `Mail.ReadWrite.All`. +Even if it does, Level 2 catches it. + +**Level 2 — Token Validation (Defense-in-Depth):** Every access token is validated before use. +The `validateTokenScopes()` function (`src/node/security/token-validator.js`, line 26) checks +both the MSAL result's scopes array and the decoded JWT `scp` claim. If `Mail.ReadWrite.All` +appears, the token is rejected and the operation fails with a `SECURITY` error. + +``` +Forbidden scope: Mail.ReadWrite.All +``` + +**Note:** `Mail.Send` and `Mail.Send.Shared` are **allowed** scopes. The `mail send` command +uses these to send drafts. Per-account restrictions can limit sending via the permissions +system in `accounts.json` (see [Configurable Permissions](#5-configurable-permissions)). + +### 2. Encrypted Token Storage + +Refresh tokens are encrypted at rest using: + +- **Algorithm:** AES-256-GCM (authenticated encryption, NIST SP 800-38D) +- **Key derivation:** PBKDF2 with 310,000 iterations and SHA-512 (OWASP 2023 recommended minimum) +- **Salt:** 32 random bytes per encryption (unique per save) +- **IV:** 12 random bytes per encryption (96-bit, NIST recommended for GCM) +- **Authentication tag:** 16 bytes (full GCM tag — prevents tampering) + +The encryption passphrase can be: +- Set via `OUTLOOK_CLI_PASSPHRASE` environment variable (recommended for production/shared environments) +- Machine-derived from `username@hostname:account-alias` (convenient for personal machines) + +For full cryptographic details, see [SECURITY-DESIGN.md §5](SECURITY-DESIGN.md#5-token-storage--encryption). + +### 3. Read-Only Account Mode + +Accounts configured with `mode: "read-only"` in `accounts.json` are restricted to +read-only Graph API scopes at login time. Write operations are blocked at two levels: + +1. **MSAL scope request** — read-only accounts request only `Mail.Read`, `Calendars.Read`, + `Contacts.Read`, and their `.Shared` equivalents. Azure AD will not issue a token + with write permissions. +2. **CLI write-guard** (`src/node/security/write-guard.js`) — before executing any write + operation, the CLI checks `isReadOnly(accountAlias)` and exits with an error if true. + +```bash +# Configure a read-only account +outlook-cli account add monitoring --client-id YOUR_ID --mode read-only +``` + +### 4. Write Confirmation + +All write operations (draft creation, message move, calendar event creation, email sending) +prompt for user confirmation before executing. This can be bypassed with `--yes` for +scripted/agent use. + +### 5. Configurable Permissions + +Individual accounts can have custom permission configurations in `accounts.json`: + +```json +{ + "accounts": { + "agent-account": { + "permissions": { + "allowed": ["Mail.Read", "Mail.ReadWrite", "Mail.Send"], + "forbidden": [], + "send_to": ["approved@company.com", "team@company.com"] + } + } + } +} +``` + +- **`allowed`/`forbidden`**: Control which Graph API scopes the account can use +- **`send_to`**: Whitelist of email addresses the account can send to (empty = unrestricted) +- **Inheritance**: `defaults` block → `parent` chain → account-specific overrides +- **`+`/`-` modifiers**: Additive/subtractive scope changes from parent + +## Token Lifecycle + +``` +User authenticates (browser PKCE or device code flow) + ↓ +Azure AD issues access token (1hr) + refresh token (~90 days) + ↓ +Tokens encrypted with AES-256-GCM → stored in ~/.outlook-cli/cache-.enc + ↓ +On each CLI invocation: + 1. Decrypt cache (PBKDF2 key derivation + AES-GCM decrypt) + 2. MSAL acquireTokenSilent (uses refresh token if access token expired) + 3. validateTokenScopes() — reject Mail.ReadWrite.All + 4. Make Graph API call over HTTPS + 5. Re-encrypt cache if MSAL updated it (new access token, rotated refresh token) +``` + +## File Permissions + +On Unix systems, the `~/.outlook-cli/` directory should have restrictive permissions: + +```bash +chmod 700 ~/.outlook-cli +chmod 600 ~/.outlook-cli/*.enc +chmod 600 ~/.outlook-cli/accounts.json +``` + +On Windows, the directory inherits user-level ACLs from the home directory. + +## Allowed Scopes + +### Default Scopes (full mode) + +| Scope | Purpose | Risk Level | +|---|---|---| +| `User.Read` | Identify the logged-in user | Low | +| `Mail.Read` | Read own emails | Medium | +| `Mail.ReadWrite` | Create drafts, move, flag, mark-read | Medium | +| `Mail.Send` | Send email from own account | High | +| `Mail.Read.Shared` | Read delegate mailboxes (`--as` flag) | Medium | +| `Mail.ReadWrite.Shared` | Create drafts in delegate mailboxes | Medium | +| `Mail.Send.Shared` | Send on behalf of delegate | High | +| `Calendars.Read` | Read own calendar events | Low | +| `Calendars.ReadWrite` | Create and modify own events | Medium | +| `Calendars.Read.Shared` | Read delegate calendars | Low | +| `offline_access` | Get refresh token for silent renewal | Low | + +### Read-Only Scopes + +| Scope | Purpose | +|---|---| +| `User.Read` | Identity | +| `Mail.Read` | Read own mail | +| `Mail.Read.Shared` | Read delegate mail | +| `Calendars.Read` | Read own calendar | +| `Calendars.Read.Shared` | Read delegate calendars | +| `Contacts.Read` | Read contacts | +| `offline_access` | Token refresh | + +### Optional Scopes (opt-in) + +| Scope | Purpose | Risk Level | Required For | +|---|---|---|---| +| `Files.Read` | Read OneDrive files (list, download) | Medium | `drive list`, `drive download`, `drive search` | +| `Files.ReadWrite` | Upload files, create folders, sharing links | High | `drive upload`, `drive mkdir`, `drive share` | + +> **Note:** Files/OneDrive scopes are not included in the default scope list. They must be added to the Azure App Registration first, then the user must re-authenticate. This prevents token acquisition failures for users who haven't configured OneDrive permissions. + +## What outlook-cli Cannot Do + +- ❌ Access all mailboxes in the organization (`Mail.ReadWrite.All` is forbidden) +- ❌ Delete emails permanently (only move to Deleted Items via `mail delete`) +- ❌ Access admin-level mail data (application permissions not requested — delegated only) +- ❌ Modify mail rules, inbox settings, or transport rules +- ❌ Access Teams, SharePoint, or Planner data (OneDrive files only, with opt-in) +- ❌ Bypass Azure AD conditional access policies or MFA requirements + +## Recommendations + +1. **Review your Azure app permissions** periodically at [portal.azure.com](https://portal.azure.com). + Verify `Mail.ReadWrite.All` is not granted. +2. **Set `OUTLOOK_CLI_PASSPHRASE`** in production/shared environments via a secrets manager + (Azure Key Vault, HashiCorp Vault, environment variable injection). +3. **Restrict file permissions** on `~/.outlook-cli/` (see [File Permissions](#file-permissions)). +4. **Use device code flow** on shared/untrusted machines: `outlook-cli auth login --device-code` +5. **Rotate tokens** by running `outlook-cli auth logout && outlook-cli auth login` quarterly. +6. **Use read-only mode** for monitoring accounts that should only observe, not modify. +7. **Configure `send_to` whitelists** for agent accounts to limit who they can email. +8. **Enable telemetry** (`OUTLOOK_CLI_TELEMETRY=1`) for production monitoring of Graph API + latency, error rates, and throttling events. +9. **Run `outlook-cli doctor`** periodically to validate configuration health. + +## Further Reading + +- [Security Design Document](SECURITY-DESIGN.md) — comprehensive enterprise security review + with STRIDE analysis, NIST/OWASP mappings, attack scenarios, and code citations +- [Azure App Setup](AZURE-SETUP.md) — step-by-step app registration with permission guidance +- [Multi-Account Configuration](MULTI-ACCOUNT.md) — per-account permission scoping +- [Self-Hosting Guide](self-hosting.md) — production deployment recommendations diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 0000000..76754c6 --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,235 @@ +# Testing Guide + +This document explains how to run, write, and debug tests for `outlook-cli`. + +## Quick Start + +```bash +# Install dependencies (if you haven't already) +npm install + +# Run all tests +npm test + +# Run tests with detailed output (shows every test name and result) +npm run test:verbose + +# Run tests in watch mode (re-runs when files change) +npm run test:watch + +# Run a specific test file +npx vitest run test/crypto.test.js + +# Run tests matching a name pattern +npx vitest run -t "should encrypt" +``` + +## Test Framework + +We use [Vitest](https://vitest.dev/) — it's compatible with the Jest API but natively supports ES modules (which this project uses). No Babel or transpilation step is required. + +### Why Vitest? + +- Native ESM support (the project uses `"type": "module"`) +- Fast — uses Vite's module resolution +- Jest-compatible `describe`/`it`/`expect` API +- Built-in mocking with `vi.mock()` and `vi.spyOn()` +- Watch mode with HMR for instant re-runs + +## Test Structure + +``` +test/ +├── aliases.test.js # Contact alias CRUD + resolution +├── account-manager.test.js # Multi-account add/remove/default +├── crypto.test.js # AES-256-GCM encrypt/decrypt roundtrips +├── formatter.test.js # Basic text output formatting +├── formatter-detailed.test.js # Per-type formatting (mail, calendar, etc.) +├── input.test.js # JSON input file loading + merging +├── render.test.js # Markdown + HTML output renderers +├── token-cache.test.js # Encrypted MSAL cache persistence +└── token-validator.test.js # JWT scope validation (Mail.Send rejection) +``` + +### What Each Test File Covers + +| File | Module Under Test | Key Scenarios | +|---|---|---| +| `crypto.test.js` | `src/security/crypto.js` | Roundtrip encrypt/decrypt, wrong passphrase, tampered data, unicode, large payloads, machine passphrase derivation | +| `token-validator.test.js` | `src/security/token-validator.js` | JWT decoding, forbidden scope detection (`Mail.Send`), allowed scopes (`Mail.Send.Shared`), opaque token handling, MSAL result validation | +| `token-cache.test.js` | `src/auth/token-cache.js` | Encrypt-to-file roundtrip, re-encrypt, wrong passphrase, missing file, large payloads | +| `account-manager.test.js` | `src/accounts/manager.js` | Add/remove accounts, set default, list, singleton reset | +| `aliases.test.js` | `src/contacts/aliases.js` | Set/get/remove aliases, case-insensitivity, `resolveAlias`, `resolveRecipients`, delegate userPath | +| `formatter.test.js` | `src/output/formatter.js` | Text/JSON format selection, message list table | +| `formatter-detailed.test.js` | `src/output/formatter.js` | All format types: mailList, mailDetail, eventList, eventDetail, folderList, contactList, calendarList, generic | +| `input.test.js` | `src/input.js` | File loading, stdin (`-`), merge precedence, body resolution, auto-detect HTML | +| `render.test.js` | `src/output/render.js` + `markdown.js` + `html.js` | Markdown tables, HTML tables, escaping, empty data, file output | + +## Diagnosing Failures + +### Step 1: Run with verbose output + +```bash +npm run test:verbose +``` + +This shows each test name with ✓ (pass) or × (fail), along with the full error message and stack trace for failures. + +### Step 2: Run just the failing test file + +```bash +npx vitest run test/token-validator.test.js +``` + +### Step 3: Run a single test by name + +```bash +npx vitest run -t "should reject token with Mail.Send" +``` + +### Step 4: Debug with `console.log` + +Vitest shows `console.log` output inline with test results. Add logging to the test or the source code to trace the issue. + +### Step 5: Use watch mode for rapid iteration + +```bash +npx vitest test/token-validator.test.js +``` + +This re-runs the test every time you save a file. Faster than repeatedly running `npm test`. + +### Common Failure Patterns + +#### "Module not found" errors +- Check that the import path is correct and uses `.js` extension (required for ESM) +- Run `npm install` to ensure dependencies are installed + +#### Crypto tests are slow +- This is expected. PBKDF2 with 310K iterations takes ~300ms per operation. The tests exercise real crypto, not mocks, to catch integration bugs. + +#### Test expects a specific scope to be forbidden/allowed +- Check `src/security/token-validator.js` → `FORBIDDEN_SCOPES` array +- If you add or remove a forbidden scope, update the corresponding test in `test/token-validator.test.js` + +#### Alias tests fail with "vi.mock" warnings +- The tests use `vi.mock('os')` inside `beforeEach` to redirect `homedir()` to a temp directory. Vitest hoists the mock. The warning is informational and does not affect results. + +#### Tests pass locally but fail in CI +- Ensure Node.js 20+ is installed +- Ensure `npm install` has been run +- The tests are self-contained — they create temp directories and clean up after themselves. No network access or real Microsoft auth is required. + +## Writing New Tests + +### Convention + +```javascript +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +describe('Feature Name', () => { + it('should do the expected thing', () => { + const result = myFunction(input); + expect(result).toBe(expectedOutput); + }); + + it('should handle edge case', () => { + expect(() => myFunction(badInput)).toThrow('error message'); + }); +}); +``` + +### Guidelines + +1. **Test file naming:** `test/.test.js` — matches the source module it tests +2. **Test isolation:** Each test should be independent. Use `beforeEach`/`afterEach` to set up and tear down state. Never rely on test execution order. +3. **No network calls:** Unit tests must not call the Graph API or Microsoft auth. Mock external dependencies using `vi.mock()`. +4. **Real crypto:** Crypto tests use actual encryption (not mocks) to catch integration bugs. Accept the ~300ms overhead per crypto test. +5. **Temp directories:** Tests that write to disk should create a unique temp directory and clean up in `afterEach`. +6. **Clear test names:** Use `should ` format. The test name should explain what's being verified without reading the code. + +### Testing a New Module + +1. Create `test/.test.js` +2. Import the module under test +3. Write `describe` blocks for each logical group +4. Write `it` blocks for each scenario (happy path, edge cases, errors) +5. Run: `npx vitest run test/.test.js` +6. Verify: `npm test` (all tests still pass) + +### Example: Adding a Test for a New Graph API Function + +```javascript +import { describe, it, expect, vi } from 'vitest'; + +describe('listMessages', () => { + it('should call the correct Graph API endpoint', async () => { + // Create a mock Graph client + const mockClient = { + userPath: '/me', + get: vi.fn().mockResolvedValue({ + value: [{ id: '1', subject: 'Test' }], + }), + }; + + const { listMessages } = await import('../src/graph/mail.js'); + const messages = await listMessages(mockClient, { folder: 'Inbox', top: 10 }); + + expect(mockClient.get).toHaveBeenCalledOnce(); + expect(mockClient.get.mock.calls[0][0]).toContain('/me/mailFolders/Inbox/messages'); + expect(messages).toHaveLength(1); + expect(messages[0].subject).toBe('Test'); + }); +}); +``` + +## Test Coverage + +To see which lines of code are covered by tests: + +```bash +npm run test:coverage +``` + +This requires `@vitest/coverage-v8` (install with `npm install -D @vitest/coverage-v8` if needed). + +## Live Testing (Manual) + +After making code changes, test against a real Outlook account: + +```bash +# Verify auth still works +node bin/outlook-cli.js auth status + +# Test mail commands +node bin/outlook-cli.js mail inbox --top 5 +node bin/outlook-cli.js mail folders +node bin/outlook-cli.js mail search "test" + +# Test calendar +node bin/outlook-cli.js calendar today + +# Test contacts aliases +node bin/outlook-cli.js contacts alias set testuser test@example.com +node bin/outlook-cli.js contacts alias list +node bin/outlook-cli.js contacts alias remove testuser + +# Test output formats +node bin/outlook-cli.js mail inbox --json +node bin/outlook-cli.js mail inbox --format markdown +node bin/outlook-cli.js mail inbox --format html --output test-output.html +``` + +## CI Integration + +For GitHub Actions or similar CI systems: + +```yaml +- name: Install dependencies + run: npm install + +- name: Run tests + run: npm test +``` + +No secrets or auth tokens are needed for the test suite — all tests are offline and self-contained. diff --git a/docs/WATCH-MODE.md b/docs/WATCH-MODE.md new file mode 100644 index 0000000..10804ae --- /dev/null +++ b/docs/WATCH-MODE.md @@ -0,0 +1,285 @@ +# Watch Mode — Real-Time Event Monitoring + +The `outlook-cli watch` command monitors your mailbox and calendar for changes using [Microsoft Graph](https://learn.microsoft.com/en-us/graph/overview). Three notification modes are available: + +| Mode | Latency | Setup | Best For | +|---|---|---|---| +| `--mode poll` (default) | 30–60s | None | General monitoring | +| `--mode fast-poll` | 5–60s adaptive | None | Quick response without external deps | +| `--mode webhook` | ~1–5s push | Tunnel tool (cloudflared) | Gateway/agent integration | + +## Quick Start + +```bash +# Default: poll mode (delta queries every 60 seconds) +outlook-cli watch --account my-account + +# Fast-poll: adaptive 5–60 second polling (speeds up on activity) +outlook-cli watch --mode fast-poll --interval 10 + +# Webhook: near-instant push notifications via Microsoft Graph subscriptions +outlook-cli watch --mode webhook + +# JSONL mode for piping to an AI agent +outlook-cli watch --mode webhook --jsonl | my-agent-process + +# Watch mail only, specific folder +outlook-cli watch --no-calendar --folder "Important" + +# Include heartbeat signals every 5 minutes +outlook-cli watch --heartbeat --heartbeat-interval 300 +``` + +## Watch Modes + +### Choosing the Right Mode + +| Question | Recommendation | +|---|---| +| Just monitoring for notifications? | `poll` (default) — 30-60s is fine for human attention | +| AI agent needs quick response? | `fast-poll` — no external dependencies, 5-10s response | +| Building a real-time gateway? | `webhook` — ~1-5 second push, but needs a tunnel | +| Running on a server with a public IP? | `webhook` — skip the tunnel, use `--webhook-port` | +| Multiple accounts simultaneously? | `poll` or `fast-poll` with staggered intervals to avoid rate limits | +| Limited API quota / shared tenant? | `poll` at 60s — fewest API calls | + +### Poll Mode (Default) + +Uses Microsoft Graph **delta queries** — an efficient pull-based approach that minimizes API calls. Delta queries only return items that changed since your last call, so even if you have 10,000 messages, each poll transfers only the new/changed ones. + +1. **Initial sync**: Fetches all existing items and establishes a baseline (no events emitted). This can take several seconds for large mailboxes but only happens once. +2. **Incremental sync**: Each subsequent call returns **only what changed** since the last check. Microsoft Graph tracks the changes server-side using a delta token. +3. **Delta tokens**: Sync state is persisted to `~/.outlook-cli/delta-{alias}.json`, surviving restarts. When you restart the watcher, it picks up where it left off — no duplicate events. + +Minimum interval: 30 seconds. Default: 60 seconds. Microsoft Graph rate limits apply — for personal accounts, you get ~10,000 API calls per 10 minutes across all applications. + +### Fast-Poll Mode + +Same delta query mechanism but with **adaptive polling** that automatically adjusts its frequency based on activity. This is the best mode for AI agent integration when you don't want to set up a tunnel: + +- Starts at the configured interval (minimum 5 seconds) +- When changes are detected, **drops to 5-second polling** — catching follow-up messages almost immediately +- When no changes are found, **backs off by 1.5× each cycle** — e.g., 5s → 7.5s → 11s → 17s → 25s → 38s → max +- Caps at the configured `--interval` value (default 60s) +- **Resets to 5s immediately** when any change is detected + +This means: during an active email conversation, polling happens every 5 seconds. After the conversation stops, it gradually relaxes back to the configured maximum. This balances responsiveness with API efficiency. + +```bash +# Adaptive polling: 5–30 second range +outlook-cli watch --mode fast-poll --interval 30 + +# Aggressive: 5–10 second range (for gateway use) +outlook-cli watch --mode fast-poll --interval 10 +``` + +### Webhook Mode + +Uses **Microsoft Graph change notifications** (push-based). Microsoft Graph POSTs to a webhook URL when changes occur, delivering notifications within ~1–5 seconds. + +Since the CLI runs on your local machine (not a public server), a **tunnel** exposes the local webhook server to the internet: + +``` +Microsoft Graph ──POST──→ Tunnel (cloudflared) ──→ localhost:PORT ──→ outlook-cli +``` + +**Supported tunnels:** + +| Tunnel | Install | Notes | +|---|---|---| +| `cloudflared` (default) | `winget install Cloudflare.cloudflared` | Free, no signup for quick tunnels | +| `localtunnel` | `npx localtunnel` (auto-installed) | NPM package, zero config | +| `ngrok` | `winget install Ngrok.Ngrok` | Popular, optional free signup | + +```bash +# Webhook with default tunnel (cloudflared) +outlook-cli watch --mode webhook + +# Webhook with specific tunnel +outlook-cli watch --mode webhook --tunnel ngrok + +# Webhook with custom port +outlook-cli watch --mode webhook --webhook-port 3000 +``` + +**Reliability features:** +- Auto-renews Graph subscriptions before expiry (~3 days for personal accounts) +- Runs delta query reconciliation every 5 minutes as safety net for missed notifications +- Graceful shutdown: deletes subscriptions, stops tunnel, closes server + +### Event Types + +| Event Type | Description | +|---|---| +| `mail.changed` | New or modified email message | +| `mail.removed` | Deleted email message | +| `calendar.changed` | New or modified calendar event | +| `calendar.removed` | Deleted calendar event | +| `sync.status` | Informational (initial sync, errors) | +| `heartbeat` | Periodic liveness signal | + +## Output Modes + +### Text Mode (Default) + +Human-readable notifications with timestamps and icons: + +``` +[14:32:05] ℹ️ Watch started +[14:32:06] ℹ️ Mail baseline: 47 messages in Inbox +[14:32:07] ℹ️ Calendar baseline: 12 events in next 30 days +[14:32:07] ℹ️ Initial sync complete. Watching for changes... +[14:33:07] 📧 Mail from alice@example.com: "Meeting notes" (unread) + Hi everyone, here are the notes from today's... +[14:34:07] 📅 Calendar: "Team Standup" at 1/15/2024, 10:00:00 AM + 📍 Conference Room B +``` + +### JSONL Mode (for Agents) + +One JSON object per line — designed for piping to AI agents: + +```bash +outlook-cli watch --jsonl +``` + +```json +{"type":"sync.status","timestamp":"2024-01-15T14:32:05.123Z","data":{"message":"Watch started","account":"my-account","watching":{"mail":"Inbox","calendar":true},"interval":"60s"}} +{"type":"mail.changed","timestamp":"2024-01-15T14:33:07.456Z","data":{"id":"AAMk...","subject":"Meeting notes","from":"alice@example.com","receivedDateTime":"2024-01-15T14:33:00Z","isRead":false,"bodyPreview":"Hi everyone...","importance":"normal","hasAttachments":false}} +{"type":"calendar.changed","timestamp":"2024-01-15T14:34:07.789Z","data":{"id":"AAMk...","subject":"Team Standup","start":{"dateTime":"2024-01-15T10:00:00","timeZone":"Pacific Standard Time"},"end":{"dateTime":"2024-01-15T10:30:00","timeZone":"Pacific Standard Time"},"location":"Conference Room B","organizer":"bob@example.com"}} +``` + +## OpenClaw / NanoClaw Integration + +An AI agent can consume the JSONL stream to react to real-time changes: + +```bash +# Start watch in background, pipe to agent +outlook-cli watch --jsonl --account work --heartbeat | process-events.sh +``` + +The agent reads lines from stdin, parses each as JSON, and acts accordingly: + +```javascript +// Example agent event processing +process.stdin.on('data', (chunk) => { + for (const line of chunk.toString().split('\n').filter(Boolean)) { + const event = JSON.parse(line); + if (event.type === 'mail.changed' && !event.data.isRead) { + console.log(`New email from ${event.data.from}: ${event.data.subject}`); + // Trigger agent action... + } + } +}); +``` + +## Options + +| Option | Default | Description | +|---|---|---| +| `--mode ` | poll | Watch mode: `poll`, `fast-poll`, or `webhook` | +| `--interval ` | 60 | Poll/fast-poll interval (min: 30 for poll, 5 for fast-poll) | +| `--tunnel ` | cloudflared | Tunnel for webhook mode: `cloudflared`, `ngrok`, `localtunnel` | +| `--webhook-port ` | random | Local port for webhook server | +| `--no-mail` | watch mail | Disable mail watching | +| `--no-calendar` | watch calendar | Disable calendar watching | +| `--folder ` | Inbox | Mail folder to watch | +| `--jsonl` | text | Output as JSON Lines | +| `--heartbeat` | off | Emit periodic heartbeat events | +| `--heartbeat-interval ` | 300 | Heartbeat interval in seconds | +| `--account ` | default | Account to watch | +| `--as ` | own mailbox | Watch a delegate mailbox | + +## Delta Token Management + +Delta tokens are the backbone of the watch system. They allow the CLI to ask Microsoft Graph "what changed since I last checked?" instead of re-fetching everything. Tokens are stored per-account in `~/.outlook-cli/delta-{alias}.json`: + +```json +{ + "mail:Inbox": "https://graph.microsoft.com/v1.0/me/mailFolders/Inbox/messages/delta?$deltatoken=...", + "calendar:rolling30d": "https://graph.microsoft.com/v1.0/me/calendarView/delta?$deltatoken=..." +} +``` + +- **Persistence**: Tokens survive process restarts — only new changes are reported. You can stop the watcher, restart your machine, and when you start watching again, it only reports changes that happened while you were away. +- **Expiry**: Tokens expire after ~30 days of inactivity. When a token expires, the Graph API returns a `410 Gone` response, and the watcher automatically falls back to a full re-sync (establishing a new baseline and getting a fresh token). The initial sync is suppressed so you don't get a flood of old messages. +- **Reset**: Delete the delta file (`~/.outlook-cli/delta-{alias}.json`) to force a full re-sync. This is useful if you suspect the delta state is corrupted or out of sync. +- **Multiple watchers**: Each account has its own delta file. You can watch multiple accounts simultaneously without conflicts. However, running two watchers for the same account simultaneously will cause delta token conflicts — the second watcher may miss events or report duplicates. + +## Graceful Shutdown + +Press `Ctrl+C` to stop the watcher. It will: +1. Complete any in-progress delta query +2. Save delta tokens to disk +3. Delete Graph webhook subscriptions (webhook mode) +4. Stop tunnel process (webhook mode) +5. Exit cleanly + +## Gateway Integration + +For OpenClaw and NanoClaw integration, use the provided scripts in `skill/openclaw/` and `skill/nanoclaw/`. These scripts automatically: +- Detect the best watch mode (webhook if tunnel available, fast-poll otherwise) +- Configure JSONL output for agent consumption +- Handle binary detection and account selection + +See `skill/openclaw/SKILL.md` and `skill/nanoclaw/SKILL.md` for full setup instructions. + +## Multi-Job Watch Configuration + +For advanced scenarios, you can define multiple watch jobs in a JSON configuration file. Each job has its own account, folder, interval, and rules: + +```json +{ + "jobs": [ + { + "name": "personal-inbox", + "account": "personal", + "folder": "Inbox", + "interval": 60, + "mode": "poll", + "rules": [ + { + "condition": { "from": "*@company.com" }, + "actions": ["mark-read", { "move": "Work" }] + } + ] + }, + { + "name": "work-urgent", + "account": "work", + "folder": "Inbox", + "interval": 15, + "mode": "fast-poll", + "rules": [ + { + "condition": { "importance": "high", "isRead": false }, + "actions": ["flag", { "run": "notify-send 'Urgent: ${subject}'" }] + } + ] + } + ] +} +``` + +```bash +# Run multi-job watch +outlook-cli watch --config watches.json + +# Validate config without running +outlook-cli watch --config watches.json --validate +``` + +Each job runs independently with its own delta token and polling interval. Rules are evaluated on each change event, and matching actions are executed in order. The `run` action can execute arbitrary commands with variable substitution (`${subject}`, `${from}`, `${id}`). + +## C# Implementation Notes + +The C# implementation supports `poll` and `fast-poll` modes. Webhook mode is not available in C# — when `--mode webhook` is specified, the C# binary displays a message directing the user to the Node.js implementation: + +``` +⚠️ Webhook mode is only available in the Node.js implementation. + Run: outlook-cli watch --mode webhook + (Requires Node.js runtime) + Falling back to fast-poll mode... +``` + +This is because the webhook infrastructure (tunnel management, HTTP server, subscription lifecycle) is complex and Node.js-specific. For gateway scenarios requiring webhook mode, use the Node.js runtime. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..e7f662e --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,243 @@ +# Architecture + +This document describes the architecture of outlook-cli, covering the shared configuration format, the Node.js and .NET implementations, the security model, and AI agent integration. + +## Overview + +outlook-cli has two independent implementations that produce identical CLI behavior: + +| | Node.js | .NET | +|---|---|---| +| **Language** | JavaScript (ESM) | C# (.NET 8) | +| **CLI framework** | Commander.js | System.CommandLine (beta4) | +| **Auth library** | @azure/msal-node | Microsoft.Identity.Client (MSAL.NET) | +| **Graph calls** | Direct HTTP (fetch) | Direct HTTP (HttpClient) | +| **Test runner** | vitest | Integration tests via vitest | +| **AOT** | N/A (interpreted) | NativeAOT (~10 MB binary) | + +Both implementations share all configuration files and the encrypted token cache. You can authenticate with one and use the other. + +## Shared Configuration + +All configuration is stored in `~/.outlook-cli/`. + +### accounts.json + +Defines accounts, their Azure app registrations, and per-account permissions: + +```json +{ + "defaults": { + "permissions": { + "allowed": ["User.Read", "Mail.Read", "Mail.ReadWrite", "Calendars.Read", "Calendars.ReadWrite", "offline_access"], + "forbidden": ["Mail.Send"] + } + }, + "accounts": { + "personal": { + "clientId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "tenant": "common", + "parent": null + }, + "work": { + "clientId": "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy", + "tenant": "contoso.onmicrosoft.com", + "parent": "personal", + "permissions": { + "send_to": ["team@contoso.com", "boss@contoso.com"] + } + } + }, + "default": "personal" +} +``` + +Key concepts: +- **defaults block** — Shared permission settings inherited by all accounts +- **parent chains** — An account can inherit settings from a parent account +- **per-account permissions** — Override allowed/forbidden scopes and send\_to whitelists + +### aliases.json + +Maps short names to email addresses: + +```json +{ + "fred": "freddie@outlook.com", + "wilma": "wilma@outlook.com", + "boss": "jane.smith@contoso.com" +} +``` + +Aliases can be used anywhere an email address is expected (e.g., `--to fred`, `--as fred`). + +### Token Cache + +- **Format:** AES-256-GCM encrypted binary +- **Key derivation:** PBKDF2 with SHA-512, 310,000 iterations +- **Passphrase source:** `OUTLOOK_CLI_PASSPHRASE` environment variable, or machine-derived default +- **Files:** `token-cache-.bin` +- **Interoperable:** Both Node.js and .NET read/write the same format + +### Delta Tokens + +Watch mode stores delta tokens for change tracking: + +- **Files:** `delta-.json` +- **Content:** Microsoft Graph delta link URLs for resuming change queries + +## Microsoft Graph API + +Both implementations call the [Microsoft Graph REST API v1.0](https://learn.microsoft.com/en-us/graph/api/overview) directly via HTTP — no SDK wrapper. + +### Endpoints Used + +| Feature | Method | Endpoint | +|---|---|---| +| Inbox | GET | `/me/mailFolders/inbox/messages` | +| Read message | GET | `/me/messages/{id}` | +| Search | GET | `/me/messages?$search=...` | +| Create draft | POST | `/me/messages` | +| Reply | POST | `/me/messages/{id}/createReply` | +| Reply all | POST | `/me/messages/{id}/createReplyAll` | +| Forward | POST | `/me/messages/{id}/createForward` | +| Send | POST | `/me/messages/{id}/send` | +| Move | POST | `/me/messages/{id}/move` | +| Flag | PATCH | `/me/messages/{id}` | +| Mark read | PATCH | `/me/messages/{id}` | +| Mail folders | GET | `/me/mailFolders` | +| Calendar view | GET | `/me/calendarView` | +| Event details | GET | `/me/events/{id}` | +| Create event | POST | `/me/events` | +| List calendars | GET | `/me/calendars` | +| Contacts | GET | `/me/people?$search=...` | +| Delta (mail) | GET | `/me/mailFolders/inbox/messages/delta` | +| Delta (calendar) | GET | `/me/calendarView/delta` | + +For delegate access (`--as`), the `/me/` prefix is replaced with `/users/{userId}/`. + +## Output Pipeline + +Both implementations use a render dispatcher pattern: + +``` +Command result → Render dispatcher → Format module → stdout/file + ├── text.js / Text.cs + ├── json.js / Json.cs + ├── markdown.js / Markdown.cs + └── html.js / Html.cs +``` + +The `--format` flag (or `--json` shorthand) selects the output module. The `--output` flag redirects to a file. + +## Security Model + +See [SECURITY.md](SECURITY.md) for the full threat model. Key architectural elements: + +### Configurable Permissions + +Each account in `accounts.json` can define: +- **allowed** — Scopes the CLI will request during authentication +- **forbidden** — Scopes that must NOT appear in the token (enforced at runtime) +- **send\_to** — Whitelist of recipient email addresses for send operations + +### Defense-in-Depth Token Validation + +Even if the Azure app registration is misconfigured, the CLI inspects the acquired token and rejects it if: +1. It contains a forbidden scope +2. A send operation targets a recipient not in the send\_to whitelist (when configured) + +### Confirmation Prompts + +All write operations (draft, reply, forward, send, move, flag, create event) require interactive confirmation. The `--yes` flag bypasses prompts for scripting and agent use. + +## Node.js Specifics + +- **Module system:** ESM (`"type": "module"` in `package.json`) +- **CLI:** Commander.js defines commands and options declaratively +- **Auth:** `@azure/msal-node` handles OAuth 2.0 authorization code flow, device code flow, and silent token refresh +- **HTTP:** Native `fetch()` (Node.js 20+) for Graph API calls +- **Testing:** vitest with mock-based unit tests + +See [src-node.md](src-node.md) for build and development details. + +## .NET Specifics + +- **Framework:** .NET 8 with NativeAOT compilation +- **CLI:** System.CommandLine (2.0.0-beta4) for command parsing +- **Auth:** MSAL.NET (`Microsoft.Identity.Client`) for OAuth 2.0 flows +- **HTTP:** `HttpClient` for Graph API calls +- **Serialization:** All JSON handled via source generators (`OutlookCliJsonContext`) — no reflection at runtime +- **Graph client design:** `GraphClient` accepts pre-serialized `string? bodyJson` parameters rather than generic objects, ensuring AOT compatibility +- **Binary size:** ~10 MB self-contained, no .NET runtime required on target + +See [src-dotnet.md](src-dotnet.md) for build and publish details. + +## Watch Mode + +Watch mode uses Microsoft Graph [delta queries](https://learn.microsoft.com/en-us/graph/delta-query-overview) for efficient change tracking: + +1. First call returns all current items + a delta token +2. Subsequent calls send the delta token and receive only changes since the last call +3. Delta tokens are persisted in `~/.outlook-cli/delta-.json` + +### Rules Engine + +The `--config` flag accepts a JSON file defining rules for processing incoming changes: + +```json +{ + "jobs": [ + { + "account": "work", + "folder": "Inbox", + "rules": [ + { + "match": { "from": "*@contoso.com" }, + "action": { "type": "webhook", "url": "https://..." } + } + ] + } + ] +} +``` + +See [WATCH-MODE.md](WATCH-MODE.md) for full documentation. + +## Dual-Mode Claw Integration + +outlook-cli integrates with OpenClaw and NanoClaw AI agent frameworks in two modes: + +### Skill Mode + +The agent invokes CLI commands and parses structured JSON output: + +``` +Agent → outlook-cli mail inbox --json → JSON response → Agent processes +``` + +The `skill/` directory contains tool definitions that describe available commands, parameters, and response formats for the agent framework. + +### Channel Factory Mode + +Watch mode acts as a channel factory — it normalizes incoming emails into channel messages and delivers them to the agent: + +``` +Graph delta → Watch rules engine → Normalize to channel message + ├── stdout (JSONL) + ├── webhook (HTTP POST) + ├── exec (spawn process) + └── file (append to file) +``` + +This allows the agent to receive a stream of normalized events without polling. + +## Related Documentation + +- [src-node.md](src-node.md) — Node.js build and development +- [src-dotnet.md](src-dotnet.md) — .NET build and publish +- [src-tests.md](src-tests.md) — Test architecture +- [SECURITY.md](SECURITY.md) — Full security model +- [WATCH-MODE.md](WATCH-MODE.md) — Watch mode and rules engine +- [MULTI-ACCOUNT.md](MULTI-ACCOUNT.md) — Multi-account configuration +- [DIRECTORY-STRUCTURE.md](DIRECTORY-STRUCTURE.md) — Project directory layout diff --git a/docs/operations.md b/docs/operations.md new file mode 100644 index 0000000..4b9e131 --- /dev/null +++ b/docs/operations.md @@ -0,0 +1,514 @@ +# Operations Guide + +A guide for team admins managing an outlook-cli deployment — covering binary distribution, monitoring, auditing, permissions, and upgrades. + +--- + +## Table of Contents + +1. [Binary Distribution](#1-binary-distribution) +2. [Monitoring](#2-monitoring) +3. [Auditing](#3-auditing) +4. [Permissions Management](#4-permissions-management) +5. [Upgrading](#5-upgrading) +6. [Common Admin Tasks](#6-common-admin-tasks) + +--- + +## 1. Binary Distribution + +### NativeAOT Publish + +The .NET NativeAOT binary is a single ~10 MB executable with no runtime dependencies. This is the recommended distribution method for teams. + +#### Build for all platforms + +```powershell +# Build all platforms at once +./build.ps1 dotnet -Publish -Runtime all + +# Or build for a specific platform +dotnet publish src/dotnet/OutlookCli.csproj -c Release -r win-x64 --self-contained /p:PublishAot=true -o publish/win-x64 +dotnet publish src/dotnet/OutlookCli.csproj -c Release -r linux-x64 --self-contained /p:PublishAot=true -o publish/linux-x64 +dotnet publish src/dotnet/OutlookCli.csproj -c Release -r osx-arm64 --self-contained /p:PublishAot=true -o publish/osx-arm64 +``` + +#### File share layout + +Set up a central file share for your team: + +``` +\\server\tools\outlook-cli\ +├── publish\ +│ ├── win-x64\ +│ │ └── outlook-cli.exe # Windows x64 binary +│ ├── win-arm64\ +│ │ └── outlook-cli.exe # Windows ARM64 binary +│ ├── linux-x64\ +│ │ └── outlook-cli # Linux x64 binary +│ └── osx-arm64\ +│ └── outlook-cli # macOS Apple Silicon binary +├── config\ +│ └── accounts.json.template # Pre-configured accounts template +├── scripts\ +│ ├── self-host-azure.ps1 # Admin: one-time Azure app setup +│ └── self-host-client.ps1 # User: per-machine setup +└── README.txt # Client ID + quick-start instructions +``` + +#### User onboarding + +Each user runs the client setup script: + +```powershell +# Point at your file share and client ID +.\scripts\self-host-client.ps1 -ClientId YOUR_CLIENT_ID -RepoPath \\server\tools\outlook-cli + +# For work/school accounts with a specific tenant +.\scripts\self-host-client.ps1 -ClientId YOUR_CLIENT_ID -AccountAlias "work" -Tenant "contoso.onmicrosoft.com" +``` + +Or manually: + +```bash +# Copy the binary to user's PATH +copy \\server\tools\outlook-cli\publish\win-x64\outlook-cli.exe C:\Users\jsmith\bin\ + +# Add account and authenticate +outlook-cli account add work --client-id YOUR_CLIENT_ID --tenant contoso.onmicrosoft.com +outlook-cli auth login --account work +``` + +#### Node.js alternative + +For teams that prefer Node.js (or need to modify the source): + +```bash +git clone https://github.com/jeffstall/outlook-cli.git +cd outlook-cli && npm install +node bin/outlook-cli.js --version +``` + +--- + +## 2. Monitoring + +### Operations log + +outlook-cli logs all operations to a SQLite database at `~/.outlook-cli/operations.db`. This provides a complete audit trail of every command executed. + +#### Querying the operations log + +```bash +# View recent operations (using sqlite3 CLI) +sqlite3 ~/.outlook-cli/operations.db "SELECT timestamp, command, account, status FROM operations ORDER BY timestamp DESC LIMIT 20;" + +# View operations for a specific account +sqlite3 ~/.outlook-cli/operations.db "SELECT timestamp, command, status FROM operations WHERE account = 'work' ORDER BY timestamp DESC LIMIT 10;" + +# View failed operations +sqlite3 ~/.outlook-cli/operations.db "SELECT timestamp, command, account, error FROM operations WHERE status = 'error' ORDER BY timestamp DESC;" + +# Count operations by command type (last 24 hours) +sqlite3 ~/.outlook-cli/operations.db "SELECT command, COUNT(*) as count FROM operations WHERE timestamp > datetime('now', '-1 day') GROUP BY command ORDER BY count DESC;" +``` + +#### Operations log schema + +| Column | Type | Description | +|---|---|---| +| `id` | INTEGER | Auto-incrementing primary key | +| `timestamp` | TEXT | ISO 8601 timestamp | +| `command` | TEXT | Command name (e.g., `mail inbox`, `auth login`) | +| `account` | TEXT | Account alias used | +| `delegate` | TEXT | Delegate user (if `--as` was used), null otherwise | +| `status` | TEXT | `success` or `error` | +| `error` | TEXT | Error message (if status is `error`) | +| `duration_ms` | INTEGER | Command execution time in milliseconds | + +#### Watch mode monitoring + +Watch mode logs each poll cycle. To check if watch is running and healthy: + +```bash +# Check recent watch activity +sqlite3 ~/.outlook-cli/operations.db "SELECT timestamp, command, status FROM operations WHERE command LIKE 'watch%' ORDER BY timestamp DESC LIMIT 5;" + +# Check for watch errors in the last hour +sqlite3 ~/.outlook-cli/operations.db "SELECT timestamp, error FROM operations WHERE command LIKE 'watch%' AND status = 'error' AND timestamp > datetime('now', '-1 hour');" +``` + +### Health checks + +Quick checks an admin can run: + +```bash +# Verify authentication is valid for all accounts +outlook-cli auth status --account work +outlook-cli auth status --account personal + +# Verify Graph API connectivity +outlook-cli mail inbox --top 1 --account work + +# Check binary version +outlook-cli --version +``` + +--- + +## 3. Auditing + +### Who sent what, when + +All email operations (drafts, sends, replies, forwards) are logged in the operations database: + +```bash +# All send operations +sqlite3 ~/.outlook-cli/operations.db "SELECT timestamp, account, delegate, status FROM operations WHERE command = 'mail send' ORDER BY timestamp DESC;" + +# All draft creations +sqlite3 ~/.outlook-cli/operations.db "SELECT timestamp, account, delegate, status FROM operations WHERE command = 'mail draft' ORDER BY timestamp DESC;" + +# All write operations in the last 7 days +sqlite3 ~/.outlook-cli/operations.db " + SELECT timestamp, command, account, delegate, status + FROM operations + WHERE command IN ('mail draft', 'mail send', 'mail reply', 'mail forward', 'mail move', 'mail flag', 'calendar create') + AND timestamp > datetime('now', '-7 days') + ORDER BY timestamp DESC; +" +``` + +### Centralized auditing + +For a team deployment, collect operations databases from each user's machine: + +```powershell +# PowerShell — collect operations logs from multiple machines +$machines = @("ws01", "ws02", "ws03") +foreach ($m in $machines) { + $src = "\\$m\C$\Users\*\.outlook-cli\operations.db" + $dest = "\\server\audit\operations-$m.db" + Copy-Item $src $dest -ErrorAction SilentlyContinue +} +``` + +Or point users at a shared operations database (set via environment variable if supported), or aggregate logs via a scheduled task that copies the SQLite file. + +### Microsoft 365 admin audit logs + +For organization-level auditing, Microsoft 365 admin center also logs Graph API activity: + +- **Entra admin center** → Sign-in logs → Filter by application name "outlook-cli" +- **Microsoft Purview** → Audit → Search for Graph API activity + +These logs are independent of outlook-cli and capture all API calls made with your app registration. + +--- + +## 4. Permissions Management + +### Per-account permissions config + +Permissions are defined in `~/.outlook-cli/accounts.json`. For team deployments, distribute a standardized config. + +#### Restrictive baseline (recommended) + +```json +{ + "defaults": { + "permissions": { + "allowed": ["User.Read", "Mail.Read", "Mail.ReadWrite", "Calendars.Read", "Calendars.ReadWrite", "offline_access"], + "forbidden": ["Mail.Send"] + } + }, + "accounts": { + "work": { + "clientId": "YOUR_SHARED_CLIENT_ID", + "tenant": "contoso.onmicrosoft.com" + } + }, + "default": "work" +} +``` + +This config: +- Allows reading and drafting email, reading and creating calendar events +- **Blocks sending** email entirely (Mail.Send is forbidden by default) +- Works for most read/draft workflows + +#### Enabling send for specific accounts + +```json +{ + "defaults": { + "permissions": { + "forbidden": ["Mail.Send"] + } + }, + "accounts": { + "read-only": { + "clientId": "...", + "tenant": "contoso.onmicrosoft.com" + }, + "power-user": { + "clientId": "...", + "tenant": "contoso.onmicrosoft.com", + "permissions": { + "allowed": ["+Mail.Send"], + "send_to": ["team@contoso.com", "reports@contoso.com"] + } + } + } +} +``` + +The `power-user` account can send, but only to whitelisted addresses. The `read-only` account inherits the default and cannot send at all. + +#### Permission inheritance + +Accounts can inherit from a parent account: + +```json +{ + "accounts": { + "base": { + "clientId": "...", + "permissions": { "allowed": ["Mail.Read", "Calendars.Read"] } + }, + "extended": { + "clientId": "...", + "parent": "base", + "permissions": { "allowed": ["+Mail.ReadWrite", "+Calendars.ReadWrite"] } + } + } +} +``` + +`extended` inherits from `base` and adds write permissions on top. + +#### Permission modifiers reference + +| Modifier | Meaning | Example | +|---|---|---| +| (none) | Replace the entire list | `"allowed": ["Mail.Read"]` | +| `+` | Add to inherited list | `"+Mail.Send"` | +| `-` | Remove from inherited list | `"-Calendars.ReadWrite"` | + +### Azure App Registration permissions + +The `accounts.json` permissions control what the CLI will *request*. The Azure App Registration controls what Microsoft will *grant*. Both must align: + +1. **Azure app** must have the permission registered (delegated, not application) +2. **accounts.json** must include it in `allowed` and not in `forbidden` +3. **User** must have consented to the permission (happens during login) + +For work/school accounts, an admin may need to grant tenant-wide consent: + +**Entra admin center → Enterprise applications → outlook-cli → Permissions → Grant admin consent for [org]** + +--- + +## 5. Upgrading + +### Version detection + +Check the currently deployed version: + +```bash +# Check Node.js version +node bin/outlook-cli.js --version + +# Check .NET binary version +outlook-cli.exe --version +``` + +Compare against the latest in the repository: + +```bash +git fetch origin +git log --oneline HEAD..origin/main | head -5 +``` + +### Upgrade process + +#### Node.js + +```bash +cd /path/to/outlook-cli +git pull +npm install +node bin/outlook-cli.js --version # Verify +``` + +#### .NET NativeAOT binary + +```bash +cd /path/to/outlook-cli +git pull + +# Rebuild the binary +dotnet publish src/dotnet/OutlookCli.csproj -c Release -r win-x64 --self-contained /p:PublishAot=true -o publish/win-x64 + +# Or rebuild all platforms +./build.ps1 dotnet -Publish -Runtime all + +# Copy to file share +copy publish\win-x64\outlook-cli.exe \\server\tools\outlook-cli\publish\win-x64\ +``` + +#### Config migration + +After upgrading, check if config files need changes: + +1. **Run a smoke test** — if commands work, the config is compatible: + ```bash + outlook-cli auth status --account work + outlook-cli mail inbox --top 1 --account work + ``` + +2. **Check for deprecation warnings** — run with `--verbose` to see any config migration messages: + ```bash + outlook-cli mail inbox --verbose + ``` + +3. **Review release notes** for any breaking config changes. If `accounts.json` schema changed, the tool will typically show a clear error message indicating which fields need to be updated. + +### Rolling upgrades for teams + +1. **Test on one machine first** — upgrade your own workstation, run smoke tests +2. **Update the file share** — copy new binary to `\\server\tools\outlook-cli\publish\` +3. **Notify the team** — users running the binary from the file share get the new version automatically on next invocation +4. **Users with local copies** must manually update their binary + +--- + +## 6. Common Admin Tasks + +### Onboarding a new team member + +```bash +# 1. Give them the Client ID and tenant (these are not secrets) +# 2. They run: +outlook-cli account add work --client-id YOUR_CLIENT_ID --tenant contoso.onmicrosoft.com +outlook-cli auth login --account work + +# 3. Verify: +outlook-cli auth status --account work +outlook-cli mail inbox --top 1 --account work +``` + +Or use the setup script: + +```powershell +.\scripts\self-host-client.ps1 -ClientId YOUR_CLIENT_ID -AccountAlias "work" -Tenant "contoso.onmicrosoft.com" +``` + +### Offboarding a team member + +On the user's machine (or remotely): + +```bash +# Remove the account and cached tokens +outlook-cli auth logout --account work +outlook-cli account remove work + +# Or delete the entire config directory +rm -rf ~/.outlook-cli/ +``` + +On the Azure side: +- **Entra admin center → Enterprise applications → outlook-cli → Users and groups** — remove the user +- The user's refresh token will stop working within ~1 hour (access token expiry) + +### Revoking a single user's access + +If you need to immediately revoke access without touching their machine: + +1. **Entra admin center → Users → [user] → Revoke sessions** — invalidates all tokens immediately +2. The user's cached refresh token will fail on next use + +### Rotating the Azure App credentials + +If you suspect the app registration is compromised: + +1. **Entra admin center → App registrations → outlook-cli → Certificates & secrets** +2. This is a public client (no secret to rotate). The security boundary is the user's own refresh token. +3. To force all users to re-authenticate, you can delete and re-create the app registration with a new Client ID. All users will need to update their `accounts.json`. + +### Setting up watch mode as a service + +#### Windows (Task Scheduler) + +```powershell +# Create a scheduled task that runs watch mode at startup +$action = New-ScheduledTaskAction -Execute "C:\tools\outlook-cli.exe" -Argument "watch --account work --interval 60" +$trigger = New-ScheduledTaskTrigger -AtStartup +$settings = New-ScheduledTaskSettingsSet -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) +Register-ScheduledTask -TaskName "outlook-cli-watch" -Action $action -Trigger $trigger -Settings $settings -User "DOMAIN\jsmith" +``` + +#### Linux (systemd) + +```ini +# /etc/systemd/system/outlook-cli-watch.service +[Unit] +Description=outlook-cli watch mode +After=network-online.target + +[Service] +Type=simple +User=jsmith +ExecStart=/usr/local/bin/outlook-cli watch --account work --interval 60 +Restart=always +RestartSec=30 +Environment=OUTLOOK_CLI_PASSPHRASE=your-passphrase + +[Install] +WantedBy=multi-user.target +``` + +```bash +sudo systemctl enable outlook-cli-watch +sudo systemctl start outlook-cli-watch +sudo systemctl status outlook-cli-watch +``` + +### Backing up configuration + +```bash +# Back up the entire config directory +cp -r ~/.outlook-cli/ ~/outlook-cli-backup-$(date +%Y%m%d)/ + +# Key files to preserve: +# - accounts.json (account definitions and permissions) +# - aliases.json (contact aliases) +# - token-cache-*.bin (encrypted tokens — machine-specific unless OUTLOOK_CLI_PASSPHRASE is set) +# - delta-*.json (watch mode state — can be regenerated) +# - operations.db (audit log — preserve for compliance) +``` + +### Checking deployment health across machines + +```powershell +# PowerShell — check version and auth status on multiple machines +$machines = @("ws01", "ws02", "ws03") +foreach ($m in $machines) { + Write-Host "=== $m ===" -ForegroundColor Cyan + Invoke-Command -ComputerName $m -ScriptBlock { + & "C:\tools\outlook-cli.exe" --version + & "C:\tools\outlook-cli.exe" auth status --account work + } +} +``` + +--- + +## Related Documentation + +- [self-hosting.md](self-hosting.md) — Initial setup guide +- [troubleshooting.md](troubleshooting.md) — Common issues and fixes +- [architecture.md](architecture.md) — System architecture +- [SECURITY.md](SECURITY.md) — Security model and threat analysis +- [MULTI-ACCOUNT.md](MULTI-ACCOUNT.md) — Multi-account details +- [WATCH-MODE.md](WATCH-MODE.md) — Watch mode and rules engine diff --git a/docs/self-hosting.md b/docs/self-hosting.md new file mode 100644 index 0000000..5a6b5f0 --- /dev/null +++ b/docs/self-hosting.md @@ -0,0 +1,1419 @@ +# Self-Hosting Guide + +A step-by-step guide to setting up outlook-cli for yourself or your team. No prior Azure experience required. + +--- + +## Table of Contents + +1. [Prerequisites](#1-prerequisites) +2. [Register an Azure App (5 minutes)](#2-register-an-azure-app) +3. [Install outlook-cli](#3-install-outlook-cli) +4. [Authenticate Your First Account](#4-authenticate-your-first-account) +5. [Verify It Works](#5-verify-it-works) +6. [Short IDs and Message References](#6-short-ids-and-message-references) +7. [Common Operations](#7-common-operations) +8. [Common Workflows](#8-common-workflows) +9. [Add More Accounts](#9-add-more-accounts) +10. [Configure Permissions](#10-configure-permissions) +11. [Configuration Resolution](#11-configuration-resolution) +12. [Set Up Watch Mode](#12-set-up-watch-mode) +13. [OpenClaw / NanoClaw Integration](#13-openclaw--nanoclaw-integration) +14. [Diagnostics — Logs, Telemetry, Doctor](#14-diagnostics--logs-telemetry-doctor) +15. [Rolling Out to Your Team](#15-rolling-out-to-your-team) +16. [Enterprise Deployment](#16-enterprise-deployment) +17. [Troubleshooting Quick Reference](#17-troubleshooting-quick-reference) + +--- + +## 1. Prerequisites + +You need: + +- **A Microsoft account** — personal (Outlook.com), work (Microsoft 365), or school. If you have an email address ending in `@outlook.com`, `@hotmail.com`, `@live.com`, or your company uses Microsoft 365, you're set. Any account type that can sign in to `outlook.office.com` will work with outlook-cli — the CLI uses the same Microsoft Graph API that the Outlook web app uses. +- **Node.js 18+** (for the Node.js version) or **.NET 8 SDK** (for the .NET version). You only need one. The Node.js implementation runs as ES modules directly — there is no build or transpilation step. The .NET implementation compiles to a NativeAOT binary that needs no runtime on the target machine. +- A computer running **Windows**, **macOS**, or **Linux**. All three platforms are tested in CI. The `~/.outlook-cli/` directory stores config, token caches, and operation logs in the same format across all platforms. + +**Cost:** Everything here is free. The Azure App Registration is free. The Microsoft Graph API calls are free (included with any Microsoft account). outlook-cli is MIT licensed. There are no usage limits beyond Microsoft's standard Graph API throttling (which is generous for individual use — typically 10,000 requests per 10 minutes per user). + +--- + +## 2. Register an Azure App + +This is a one-time setup. Once registered, the same app can be shared by everyone in your organization. + +### What is an Azure App Registration? + +When outlook-cli connects to your email, Microsoft needs to know *which program* is asking for access. An "App Registration" is like a name tag — it says "I'm outlook-cli, and I'd like to read this user's email." It's free and takes about 5 minutes. + +A single App Registration can serve your entire organization — every user authenticates with their own Microsoft credentials, gets their own private token cache, and nobody can access anyone else's data. The Client ID is just an identifier (not a secret), so it's safe to share it in documentation, chat messages, or config files. This is why the multi-account setup (Section [9](#9-add-more-accounts)) reuses the same Client ID across personal, work, and shared accounts. + +### Option A: Use the Azure Portal (visual, point-and-click) + +#### Step 2.1: Open the App Registrations page + +Go to one of these URLs (they both go to the same place): + +- **Recommended:** [https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade](https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade) +- **Alternative:** [https://portal.azure.com](https://portal.azure.com) → type **"App registrations"** in the search bar at the top → click the result + +Sign in with your Microsoft account. If you see a "Welcome" screen or "Get started" page, look for **"App registrations"** in the left sidebar or search bar. + +> **Can't find it?** The Azure portal has a confusing number of menus. The direct link above is the fastest way. If even that doesn't work, try: portal.azure.com → hamburger menu (☰) → "Microsoft Entra ID" → "App registrations" in the left sidebar. + +#### Step 2.2: Create a new registration + +1. Click the **"+ New registration"** button at the top +2. Fill in the form: + + | Field | What to enter | + |---|---| + | **Name** | `outlook-cli` (or any name you want) | + | **Supported account types** | Select **"Accounts in any organizational directory and personal Microsoft accounts"** — this is the most flexible option and works for both personal and work accounts | + | **Redirect URI** | Leave this blank for now (we'll add it next) | + +3. Click **"Register"** + +#### Step 2.3: Copy your Client ID + +After registering, you'll land on the app's **Overview** page. You'll see two important values: + +- **Application (client) ID** — a long string like `a1b2c3d4-e5f6-7890-abcd-ef1234567890`. **Copy this.** You'll need it in every step below. +- **Directory (tenant) ID** — you can ignore this for now (outlook-cli defaults to `common`, which works for most people). + +> 💡 **Tip:** Save the Client ID somewhere handy — a sticky note, a password manager, a text file. You'll use it once per computer you set up. + +#### Step 2.4: Add the redirect URI + +1. In the left sidebar, click **"Authentication"** +2. Click **"+ Add a platform"** +3. Select **"Mobile and desktop applications"** +4. Under **Custom redirect URIs**, type: `http://localhost:53847/callback` +5. Click **"Configure"** +6. Scroll down to **"Advanced settings"** +7. Find **"Allow public client flows"** and set it to **Yes** +8. Click **"Save"** at the top of the page + +#### Step 2.5: Add API permissions + +This tells Microsoft which data outlook-cli is allowed to access. + +1. In the left sidebar, click **"API permissions"** +2. Click **"+ Add a permission"** +3. Select **"Microsoft Graph"** (it's usually the first option) +4. Select **"Delegated permissions"** (NOT "Application permissions") +5. In the search box, search for and check each of these: + +**Required permissions (add all of these):** + +| Permission | What it does | +|---|---| +| `User.Read` | Read your profile (usually already added) | +| `Mail.Read` | Read your email | +| `Mail.ReadWrite` | Create drafts, move messages, flag messages | +| `Calendars.Read` | Read your calendar | +| `Calendars.ReadWrite` | Create calendar events | +| `offline_access` | Stay logged in (refresh tokens) | + +**Optional — for delegate access (shared mailboxes):** + +| Permission | What it does | +|---|---| +| `Mail.Read.Shared` | Read another user's mailbox (if they've granted you access) | +| `Mail.ReadWrite.Shared` | Create drafts in another user's mailbox | +| `Mail.Send.Shared` | Send on behalf of another user | +| `Calendars.Read.Shared` | Read another user's calendar | + +**Optional — for sending email:** + +| Permission | What it does | +|---|---| +| `Mail.Send` | Send email from your own account | + +**Optional — for OneDrive file access:** + +| Permission | What it does | +|---|---| +| `Files.Read` | Browse and download files from OneDrive | +| `Files.ReadWrite` | Upload files, create folders, create sharing links | + +> **Note:** OneDrive scopes are **opt-in** — they are not requested during login unless you add them to your Azure App Registration. Add these only if you plan to use `outlook-cli drive` commands. + +**Optional — for contacts:** + +| Permission | What it does | +|---|---| +| `Contacts.Read` | Read your contacts (used by `contacts list`, `contacts search`) | + +6. Click **"Add permissions"** + +> ⚠️ **Security note:** `Mail.Send` lets the CLI send email. If you don't need to send email from the CLI, leave it off. You can always add it later. The permission system in outlook-cli lets you control which accounts can send, even if the Azure app allows it. + +> 🚫 **Never add these:** `Mail.ReadWrite.All`, `Calendars.ReadWrite.All`, or any permission ending in `.All` — those give application-level access to every user's mailbox, which is dangerous and unnecessary. + +### Option B: Use the PowerShell script (automated) + +Run the provided script from the `scripts/` directory: + +```powershell +# Basic setup (read + write email and calendar): +.\scripts\self-host-azure.ps1 + +# Include shared/delegate mailbox permissions: +.\scripts\self-host-azure.ps1 -IncludeShared + +# Include direct send permission: +.\scripts\self-host-azure.ps1 -IncludeSend + +# All permissions + custom app name: +.\scripts\self-host-azure.ps1 -AppName "my-outlook-tool" -IncludeSend -IncludeShared +``` + +The script: +- Installs the Az PowerShell module if needed +- Opens your browser for Azure login +- Creates the app registration with the correct permissions +- Outputs the Client ID you'll need for the next step + +> Open [`scripts/self-host-azure.ps1`](../scripts/self-host-azure.ps1) to see exactly what it does. Every permission GUID has a comment explaining the permission name and what it grants. + +--- + +## 3. Install outlook-cli + +Choose **one** of the two implementations. They're fully interchangeable — same commands, same config, same token cache. A token cache encrypted by the Node.js implementation decrypts correctly in the .NET implementation and vice versa, because both use the same binary format: AES-256-GCM with PBKDF2-SHA512 key derivation at 310,000 iterations (see [SECURITY.md](SECURITY.md) for details). You can switch between implementations at any time without re-authenticating. + +### Option A: Node.js (easiest to get started) + +```bash +# Clone the repository +git clone https://github.com/jeffstall/outlook-cli.git +cd outlook-cli + +# Install dependencies +npm install + +# Verify it works +node bin/outlook-cli.js --version +``` + +You'll run commands as `node bin/outlook-cli.js `. + +> **Tip:** To use just `outlook-cli` instead of `node bin/outlook-cli.js`, run `npm link` (may need admin/sudo). + +### Option B: .NET Native Binary (fastest, no runtime needed) + +Pre-built binaries are available for: + +| Platform | File | +|---|---| +| Windows x64 | `publish/win-x64/outlook-cli.exe` | +| Windows ARM64 | `publish/win-arm64/outlook-cli.exe` | +| macOS Apple Silicon | `publish/osx-arm64/outlook-cli` | +| Linux x64 | `publish/linux-x64/outlook-cli` | + +To build from source: + +```bash +# Clone the repository +git clone https://github.com/jeffstall/outlook-cli.git +cd outlook-cli + +# Build a native binary for your platform (requires .NET 8 SDK) +# Windows x64: +dotnet publish src/dotnet/OutlookCli.csproj -c Release -r win-x64 --self-contained /p:PublishAot=true -o publish/win-x64 + +# macOS: +dotnet publish src/dotnet/OutlookCli.csproj -c Release -r osx-arm64 --self-contained /p:PublishAot=true -o publish/osx-arm64 + +# Linux: +dotnet publish src/dotnet/OutlookCli.csproj -c Release -r linux-x64 --self-contained /p:PublishAot=true -o publish/linux-x64 + +# Or build all platforms at once: +./build.ps1 dotnet -Publish -Runtime all +``` + +The resulting binary is ~10 MB, fully self-contained — no .NET runtime needed on the target machine. + +You can also run without publishing (requires .NET 8 SDK on the machine): + +```bash +dotnet run --project src/dotnet -- +``` + +> **Note:** When using `dotnet run`, put `--` before your outlook-cli arguments to separate them from dotnet's own flags. + +### Which should I choose? + +| | Node.js | .NET Binary | +|---|---|---| +| **Setup effort** | `npm install` | `dotnet publish` (or download binary) | +| **Runtime needed** | Node.js 18+ | Nothing (self-contained) | +| **Startup time** | ~200ms | ~50ms | +| **Binary size** | ~80 MB (with node_modules) | ~10 MB | +| **Best for** | Development, scripting | Production, distribution | + +--- + +## 4. Authenticate Your First Account + +Now connect outlook-cli to your Microsoft account. + +### Option A: Use the setup script (recommended) + +```powershell +# Basic setup — opens browser for Microsoft login: +.\scripts\self-host-client.ps1 -ClientId YOUR_CLIENT_ID + +# Work/school account with specific tenant: +.\scripts\self-host-client.ps1 -ClientId YOUR_CLIENT_ID -AccountAlias "work" -Tenant "contoso.onmicrosoft.com" + +# Force a specific implementation: +.\scripts\self-host-client.ps1 -ClientId YOUR_CLIENT_ID -Implementation dotnet +``` + +The script auto-detects whether to use Node.js or .NET, handles authentication, and verifies everything works. See [`scripts/self-host-client.ps1`](../scripts/self-host-client.ps1) for the full source. + +### Option B: Manual setup + +#### On a computer with a web browser (most people): + +```bash +# Node.js: +node bin/outlook-cli.js auth login --client-id YOUR_CLIENT_ID + +# .NET: +outlook-cli.exe auth login --client-id YOUR_CLIENT_ID +``` + +This opens your browser. Sign in with your Microsoft account, approve the permissions, and you're done. + +#### On a server or VM without a browser: + +```bash +outlook-cli auth login --client-id YOUR_CLIENT_ID --device-code +``` + +This shows a message like: + +``` +To sign in, use a web browser to open https://microsoft.com/devicelogin +and enter the code ABCD-EFGH to authenticate. +``` + +Open that URL on **any device** (your phone, another computer), enter the code, and sign in. The CLI will automatically detect the login. + +#### Set it permanently (so you don't type the client ID every time): + +```bash +# Add a named account with the client ID baked in +outlook-cli account add personal --client-id YOUR_CLIENT_ID + +# Then authenticate +outlook-cli auth login --account personal +``` + +--- + +## 5. Verify It Works + +Run these commands to confirm everything is connected: + +```bash +# Check authentication status +outlook-cli auth status +# Should show: Authenticated: ✓ yes + +# List your inbox +outlook-cli mail inbox + +# View today's calendar +outlook-cli calendar today +``` + +If you see your emails and calendar events, you're all set! The `auth status` command validates that your token cache can be decrypted and that the cached refresh token is still valid. If it shows "Authenticated: ✗ no", your token has expired — run `outlook-cli auth login` to re-authenticate. + +> **Note:** In the examples below, `outlook-cli` means either `node bin/outlook-cli.js` (Node.js) or `./outlook-cli.exe` (.NET). The commands are identical. + +--- + +## 6. Short IDs and Message References + +### How short IDs work + +When you run `mail inbox`, `mail search`, or any command that lists messages, outlook-cli assigns each result a short numeric ID (#1, #2, #3, etc.) displayed alongside the message. These short IDs are a convenience feature so you don't need to copy and paste the full Microsoft Graph message IDs, which are opaque strings like `AAMkAGI2TG93AAA=`. You can use a short ID anywhere a full message ID is accepted — `mail read 1`, `mail reply 3`, `mail move 2 --folder Archive`, and so on. The `#` prefix is optional: both `1` and `#1` work. + +### Where the mapping is stored + +The mapping from short IDs to full Graph IDs is stored in `~/.outlook-cli/last-results.json`. This file is **overwritten entirely** every time a list command runs — there is no append or merge. The file records a timestamp, the count of results, and a simple `{ "1": "AAMk...", "2": "AAMk..." }` dictionary. Both the Node.js and .NET implementations read and write the same file in the same format. + +### The clobber problem + +Because `last-results.json` is a single global file, **any list command in any terminal window overwrites it**. If you have two terminal windows open and run `inbox` in one, the short IDs from the other window's previous `search` are gone. This is by design — short IDs are meant for quick interactive use in a single terminal session, not for durable references. + +Here's the most common way this bites you: + +```bash +$ outlook-cli mail inbox # Short IDs 1-7 now map to inbox messages +$ outlook-cli mail read 1 # ✓ Reads first inbox message + +$ outlook-cli mail search "meeting" # Short IDs 1-3 now map to search results! +$ outlook-cli mail read 1 # ⚠ Reads first SEARCH result, not inbox message +``` + +The second `mail read 1` resolves to a completely different message because `search` overwrote the mapping that `inbox` created. + +### When to use full Graph IDs + +For any of these scenarios, use the full Graph message ID instead of short IDs: + +- **Scripts and automation** — A cron job or agent should capture the full ID from `--json` output (e.g., `outlook-cli mail inbox --json | jq '.[0].id'`) and pass it to subsequent commands. Short IDs are unreliable when multiple processes may run list commands. +- **Multi-window workflows** — If you frequently switch between terminal windows, copy the full ID from the output or use `--json` to extract it. +- **CI/CD pipelines** — Never rely on `last-results.json` in automated environments. Use `--json` output and parse the `id` field. + +### Checking what the current short IDs point to + +You can inspect the mapping file directly: + +```bash +cat ~/.outlook-cli/last-results.json +``` + +```json +{ + "timestamp": "2026-01-15T14:32:01.000Z", + "count": 7, + "ids": { + "1": "AAMkAGI2TG93AAA=", + "2": "AAMkAGI2TG94BBB=", + "3": "AAMkAGI2TG95CCC=" + } +} +``` + +If the timestamp is from a different terminal session or an unexpected command, the IDs may not be what you expect. When in doubt, re-run `mail inbox` to refresh the mapping. + +--- + +## 7. Common Operations + +### 📬 Reading Email + +The `mail inbox` command fetches messages from the Microsoft Graph API's `/me/mailFolders/inbox/messages` endpoint. Results are sorted by received date (newest first) and each message is assigned a short numeric ID for use in subsequent commands (see [Short IDs](#6-short-ids-and-message-references)). By default, 25 messages are returned — use `--top` to change this limit. + +```bash +# List your inbox (latest 25 messages) +outlook-cli mail inbox + +# Show only unread messages +outlook-cli mail inbox --unread + +# Show the latest 10 +outlook-cli mail inbox --top 10 + +# List messages in a specific folder +outlook-cli mail inbox --folder "Sent Items" +outlook-cli mail inbox --folder "Archive" + +# Read a specific email (use short ID from inbox output, or full Graph ID) +outlook-cli mail read 1 +outlook-cli mail read MESSAGE_ID + +# Read in plain text (strips HTML) +outlook-cli mail read MESSAGE_ID --plain + +# Search for emails (uses Microsoft Graph $search, supports KQL syntax) +outlook-cli mail search "quarterly report" +outlook-cli mail search "from:alice@example.com" +outlook-cli mail search "hasAttachments:true" + +# List all your mail folders +outlook-cli mail folders +``` + +> **Note:** The `search` command also saves short IDs, overwriting the ones from `inbox`. If you need to reference both inbox and search results, capture the full IDs from `--json` output. See [Short IDs](#6-short-ids-and-message-references) for details. + +### ✉️ Composing Email + +Drafting and sending are separate operations in outlook-cli. The `mail draft` command creates a message in your Drafts folder without sending it — this is a safety feature, especially when agents or scripts compose email. The `mail send` command sends a previously-created draft, and requires `Mail.Send` in the account's allowed permissions (see [Configure Permissions](#10-configure-permissions)). The `--yes` flag skips the confirmation prompt, which is required for non-interactive use (scripts, agents). + +```bash +# Create a draft (does NOT send — saves to Drafts folder) +outlook-cli mail draft --to "bob@example.com" --subject "Hello" --body "Hi Bob!" + +# Draft with multiple recipients +outlook-cli mail draft \ + --to "bob@example.com,carol@example.com" \ + --cc "manager@example.com" \ + --subject "Project Update" \ + --body "Here's the latest..." + +# Draft from an HTML file +outlook-cli mail draft --to "team@example.com" --subject "Newsletter" --body-file newsletter.html + +# Reply to a message +outlook-cli mail reply MESSAGE_ID --body "Thanks for the update!" + +# Reply to all +outlook-cli mail reply MESSAGE_ID --body "Acknowledged" --reply-all + +# Forward a message +outlook-cli mail forward MESSAGE_ID --to "boss@example.com" --comment "FYI — see below" + +# Send a draft (requires Mail.Send permission) +outlook-cli mail send DRAFT_ID +``` + +### 📁 Organizing Email + +These commands modify messages in your mailbox. Each accepts either a short ID from the last list command or a full Graph message ID. The `move` command calls the Graph API's `/messages/{id}/move` endpoint and returns the message's new ID (Graph reassigns IDs when moving between folders). The `flag` and `mark-read` commands use PATCH requests to update message properties in place. + +```bash +# Move a message to Archive +outlook-cli mail move MESSAGE_ID --folder Archive + +# Move to a custom folder +outlook-cli mail move MESSAGE_ID --folder "Project Alpha" + +# Flag a message for follow-up +outlook-cli mail flag MESSAGE_ID + +# Unflag +outlook-cli mail flag MESSAGE_ID --unflag + +# Mark as read +outlook-cli mail mark-read MESSAGE_ID + +# Mark as unread +outlook-cli mail mark-read MESSAGE_ID --unread +``` + +### 📅 Calendar + +```bash +# View today's events +outlook-cli calendar today + +# View this week +outlook-cli calendar week + +# View a date range +outlook-cli calendar range --start 2026-01-15 --end 2026-01-22 + +# View a specific event +outlook-cli calendar view EVENT_ID + +# List all your calendars +outlook-cli calendar list-calendars + +# Create an event +outlook-cli calendar create \ + --subject "Team Standup" \ + --start "2026-01-15T09:00:00" \ + --end "2026-01-15T09:30:00" \ + --attendees "alice@example.com,bob@example.com" \ + --location "Conference Room A" +``` + +### 👥 Contacts + +```bash +# Search for a contact +outlook-cli contacts search "Alice Johnson" + +# Set up an alias for quick access +outlook-cli contacts alias set alice alice.johnson@example.com + +# Now use the alias anywhere +outlook-cli mail draft --to alice --subject "Quick question" --body "..." +``` + +### 📊 Output Formats + +Every command supports multiple output formats via the `--format` flag (or the shorthand `--json`). The rendering engine uses a strategy pattern — `render.js` dispatches to format-specific modules (`formatter.js`, `markdown.js`, `html.js`) where each module exports functions named after entity types (`mailList`, `mailDetail`, `eventList`, etc.). The `--output` flag writes to a file instead of stdout. JSON output is the most important format for scripting and agent integration because it includes the full Graph API message IDs and all metadata. + +```bash +# Human-readable text (default) +outlook-cli mail inbox + +# JSON (for scripts, piping to jq, or agent integration) +outlook-cli mail inbox --json + +# Markdown +outlook-cli mail inbox --format markdown + +# HTML (great for saving to a file) +outlook-cli mail inbox --format html --output inbox.html +``` + +### 📋 JSON Input Files + +For complex operations, use a JSON file instead of many flags. The `--input` flag loads parameters from a JSON file, but **CLI flags always override JSON values** — this lets you use a template file and selectively override fields on the command line. If the JSON includes a `bodyFile` path, it takes priority over an inline `body` value. HTML content type is auto-detected when the file extension is `.html` or `.htm`. See [JSON-INPUT.md](JSON-INPUT.md) for the full schema and supported fields. + +```bash +# Create draft.json: +cat > draft.json << 'EOF' +{ + "to": "bob@example.com,carol@example.com", + "cc": "manager@example.com", + "subject": "Weekly Status Report", + "body": "Here's this week's update...\n\n- Feature A: Complete\n- Feature B: In progress", + "importance": "high" +} +EOF + +# Use it: +outlook-cli mail draft --input draft.json --yes --json +``` + +--- + +## 8. Common Workflows + +These multi-step examples show realistic sequences you'd use in day-to-day work. Each step builds on the previous one using short IDs — remember that short IDs are reassigned whenever a list command runs (see [Short IDs](#6-short-ids-and-message-references)). + +### Read and reply to recent email + +```bash +# Step 1: See what's in your inbox +$ outlook-cli mail inbox --top 5 +#1 alice@example.com "Q3 Budget Review" 2026-01-15 09:12 +#2 bob@contoso.com "Standup Notes" 2026-01-15 08:45 +#3 carol@example.com "Lunch tomorrow?" 2026-01-14 17:30 + +# Step 2: Read the full message +$ outlook-cli mail read 1 +From: alice@example.com +Subject: Q3 Budget Review +Can you review the attached spreadsheet and confirm the numbers? + +# Step 3: Reply +$ outlook-cli mail reply 1 --body "Reviewed — numbers look correct. Approved." +# Creates a reply draft. Add --yes to skip confirmation. + +# Step 4: Send the reply (if Mail.Send is allowed) +$ outlook-cli mail send DRAFT_ID --yes +``` + +### Search, flag, and move a message + +```bash +# Step 1: Find the message +$ outlook-cli mail search "project alpha deadline" +#1 pm@contoso.com "Project Alpha — deadline moved" 2026-01-14 +#2 pm@contoso.com "RE: Project Alpha timeline" 2026-01-10 + +# Step 2: Flag it for follow-up +$ outlook-cli mail flag 1 + +# Step 3: Move it to a project folder +$ outlook-cli mail move 1 --folder "Project Alpha" +# Note: after move, the message gets a new Graph ID. The short ID #1 still +# points to the old ID in last-results.json, so further operations on this +# message require the new ID from the move command's output. +``` + +### Draft, review, and send + +```bash +# Step 1: Create a draft +$ outlook-cli mail draft \ + --to "team@contoso.com" \ + --subject "Weekly Status" \ + --body-file status-update.html \ + --yes --json +# JSON output includes the draft's message ID + +# Step 2: Review what you drafted +$ outlook-cli mail read DRAFT_ID + +# Step 3: Send it +$ outlook-cli mail send DRAFT_ID --yes +``` + +### Script-safe workflow with full IDs + +When automating, always use `--json` and extract the full ID to avoid short ID clobber issues: + +```bash +# Capture the full ID of the first inbox message +MSG_ID=$(outlook-cli mail inbox --json | jq -r '.[0].id') + +# Now use it directly — this is immune to short ID overwrites +outlook-cli mail read "$MSG_ID" +outlook-cli mail flag "$MSG_ID" +outlook-cli mail move "$MSG_ID" --folder Archive --yes +``` + +--- + +## 9. Add More Accounts + +You can manage multiple email accounts (personal + work, multiple work accounts, etc.): + +```bash +# Add a personal Outlook.com account +outlook-cli account add personal --client-id YOUR_CLIENT_ID + +# Add a work/school account (with specific tenant) +outlook-cli account add work --client-id YOUR_CLIENT_ID --tenant contoso.onmicrosoft.com + +# Authenticate each account +outlook-cli auth login --account personal +outlook-cli auth login --account work + +# Set a default account +outlook-cli account set-default work + +# Use a specific account with any command +outlook-cli mail inbox --account personal +outlook-cli mail inbox --account work + +# List all accounts +outlook-cli account list +``` + +> **Tip:** Multiple users in your organization can share the same Client ID. The Client ID identifies the *application* (outlook-cli), not the *user*. Each person authenticates with their own Microsoft credentials and gets their own private token cache file (`cache-{alias}.enc`), encrypted with a machine-specific key or passphrase. No one can access anyone else's data — the Client ID is not a secret and can be shared freely in documentation or config files. + +### Multi-Account Examples + +The `--account` (or `-a`) flag selects which account to use for a command. The `--as` flag accesses another user's mailbox via delegate permissions. Here's a complete walkthrough. + +#### Default account behavior + +When you don't specify `--account`, the default account is used: + +```bash +# These are equivalent (assuming "work" is the default account): +outlook-cli mail inbox +outlook-cli mail inbox --account work +``` + +Check which account is the default: + +```bash +outlook-cli account list +# personal john@outlook.com +# work (default) john@contoso.com +``` + +#### Switching between accounts + +```bash +# Read personal inbox +outlook-cli mail inbox --account personal + +# Read work inbox +outlook-cli mail inbox --account work +# or shorthand: +outlook-cli mail inbox -a work + +# Search across accounts (run separately — no cross-account search): +outlook-cli mail search "quarterly report" --account personal +outlook-cli mail search "quarterly report" --account work + +# Check calendars for different accounts +outlook-cli calendar today --account personal +outlook-cli calendar today --account work +``` + +#### Running commands against different accounts + +Common multi-account workflows: + +```bash +# Draft on work account, check calendar on personal +outlook-cli mail draft --to "alice@contoso.com" --subject "Status" --body "Done." --account work +outlook-cli calendar today --account personal + +# Watch mode on a specific account +outlook-cli watch --account work --interval 30 + +# Authentication status for each account +outlook-cli auth status --account personal +outlook-cli auth status --account work +``` + +#### Delegate access with `--as` + +The `--as` flag lets you access another user's mailbox — if they've granted you delegate permissions. You can combine `--account` and `--as`. Under the hood, `--as` changes the Graph API path from `/me` to `/users/{email}`, so all API calls target the delegated mailbox instead of your own. This requires the `.Shared` permission variants (e.g., `Mail.Read.Shared`) to be granted in your Azure App Registration. See [DELEGATE-ACCESS.md](DELEGATE-ACCESS.md) for how to configure delegate permissions in Exchange/Entra. + +```bash +# Read your manager's inbox using your work credentials +outlook-cli --account work --as manager@contoso.com mail inbox + +# Use a contact alias for convenience +outlook-cli contacts alias set boss manager@contoso.com +outlook-cli --account work --as boss mail inbox + +# Draft in a shared mailbox +outlook-cli --account work --as shared-team@contoso.com mail draft \ + --to "client@example.com" --subject "Follow-up" --body "..." --yes + +# Send a draft on behalf of the shared mailbox +outlook-cli --account work --as shared-team@contoso.com mail send DRAFT_ID --yes + +# View a colleague's calendar (if they've shared it with you) +outlook-cli --account work --as boss calendar today +``` + +#### Node.js vs .NET — same commands + +Both implementations accept the same flags: + +```bash +# Node.js +node bin/outlook-cli.js mail inbox --account work --as boss + +# .NET (published binary) +outlook-cli.exe mail inbox --account work --as boss + +# .NET (dotnet run) +dotnet run --project src/dotnet -- mail inbox --account work --as boss +``` + +> See [MULTI-ACCOUNT.md](MULTI-ACCOUNT.md) for account storage details and [DELEGATE-ACCESS.md](DELEGATE-ACCESS.md) for delegate permission setup. + +--- + +## 10. Configure Permissions + +outlook-cli has a configurable permission system that controls what each account is allowed to do. This is stored in `~/.outlook-cli/accounts.json`. The permission system operates as defense-in-depth: even if your Azure App Registration grants `Mail.Send`, outlook-cli will block sending unless the account's local configuration also allows it. The token validator (`src/node/security/token-validator.js`) checks both the MSAL result's scopes array and the decoded JWT `scp` claim on **every token acquisition**, so a compromised or overly-broad token cannot bypass local permission rules. + +### Basic permission structure + +```json +{ + "defaults": { + "permissions": { + "allowed": ["User.Read", "Mail.Read", "Mail.ReadWrite", "Calendars.Read", "Calendars.ReadWrite", "offline_access"], + "forbidden": ["Mail.Send"] + } + }, + "accounts": { + "personal": { + "clientId": "YOUR_CLIENT_ID", + "tenant": "common" + }, + "work": { + "clientId": "YOUR_CLIENT_ID", + "tenant": "contoso.onmicrosoft.com", + "permissions": { + "allowed": ["+Mail.Send"], + "send_to": ["team@contoso.com", "reports@contoso.com"] + } + } + }, + "default": "personal" +} +``` + +### What this means: + +- **defaults** — Applied to all accounts unless overridden. Here, `Mail.Send` is forbidden globally, which means no account can send email unless it explicitly opts in with the `+` modifier. This is a safe starting point — you can always relax permissions per-account. +- **personal account** — Inherits defaults and adds no overrides. Cannot send email. Can read and organize mail, read and write calendar events. +- **work account** — Adds `Mail.Send` (the `+` prefix overrides the forbidden list), but restricts sending to whitelisted recipients (`send_to`). If someone (or an agent) tries to send to an address not in the `send_to` list, outlook-cli rejects the request locally before it reaches the Graph API. This is especially useful for agent-integrated accounts where you want to limit blast radius. + +### Permission modifiers + +| Prefix | Meaning | Example | +|---|---|---| +| (none) | Replace the list entirely | `"allowed": ["Mail.Read"]` | +| `+` | Add to parent/default list | `"+Mail.Send"` adds sending capability | +| `-` | Remove from parent/default list | `"-Calendars.ReadWrite"` removes calendar write | + +### Parent chains + +Accounts can inherit from other accounts, forming a hierarchy. When outlook-cli resolves permissions for an account, it walks the `parent` chain, merging `allowed` and `forbidden` lists at each level. The `+` and `-` modifiers apply relative to the inherited list. This avoids repeating common permissions across many accounts — define a base profile once, then customize per-account. Circular parent references are detected and rejected at startup. + +```json +{ + "accounts": { + "base": { + "clientId": "...", + "permissions": { "allowed": ["Mail.Read", "Calendars.Read"] } + }, + "power-user": { + "clientId": "...", + "parent": "base", + "permissions": { "allowed": ["+Mail.ReadWrite", "+Calendars.ReadWrite"] } + } + } +} +``` + +The `power-user` account inherits everything from `base` and adds write permissions. + +> 🔒 **Security:** `Mail.ReadWrite.All` is a **forbidden scope** in outlook-cli and will be rejected even if granted in Azure. This application-level permission grants access to every mailbox in the organization and is never appropriate for a CLI tool. See [SECURITY.md](SECURITY.md) for the full security model. + +--- + +## 11. Configuration Resolution + +outlook-cli resolves settings from multiple sources in a specific order. Understanding this order prevents surprises when a setting doesn't behave as expected. + +### Resolution order (last wins) + +1. **Hardcoded defaults** — `tenantId: "common"`, `logLevel: "warn"`. These are baked into the source code and provide sensible starting values. +2. **Environment variables** — `OUTLOOK_CLI_CLIENT_ID`, `OUTLOOK_CLI_TENANT_ID`, `OUTLOOK_CLI_PASSPHRASE`, `OUTLOOK_CLI_LOG_LEVEL`. Set these in your shell profile or CI environment. +3. **Config file** (`~/.outlook-cli/config.json`) — **Overrides environment variables.** This is intentional: the config file represents the user's persistent preference, while environment variables may be set globally for other tools. +4. **Per-account settings** in `accounts.json` — Account-specific `clientId`, `tenantId`, and `permissions` override global config. +5. **CLI flags** — `--client-id`, `--tenant`, `--account`, `--verbose`, etc. These always win. + +### Surprising behavior: config.json beats env vars + +The most common surprise is that `config.json` overrides environment variables. For example: + +```bash +# You set this in your shell: +export OUTLOOK_CLI_CLIENT_ID="env-client-id-111" + +# But config.json contains: +# { "clientId": "file-client-id-222" } + +# Result: outlook-cli uses "file-client-id-222" +``` + +This happens because `loadConfig()` in `src/node/config.js` uses `Object.assign(config, fileConfig)` — the file config overwrites any env-var-derived values. If you need environment variables to take priority, don't set the same key in `config.json`. This design choice ensures that a user's explicit config file settings aren't silently overridden by inherited shell environment variables. + +### Per-account overrides + +The `accounts.json` file can override `clientId` and `tenantId` per account, which is useful when different accounts belong to different Azure tenants or use different App Registrations: + +```json +{ + "accounts": { + "personal": { "clientId": "aaa-bbb", "tenant": "common" }, + "work": { "clientId": "ccc-ddd", "tenant": "contoso.onmicrosoft.com" } + } +} +``` + +In this example, the personal account uses one App Registration while the work account uses a different one. CLI flags like `--client-id` override even account-specific values. + +--- + +## 12. Set Up Watch Mode + +Watch mode monitors your mailbox and calendar for changes in real-time. It uses Microsoft Graph delta queries (not webhooks in poll/fast-poll mode) to efficiently detect changes without re-downloading your entire inbox each time. Delta tokens are stored per-account in `~/.outlook-cli/delta-{alias}.json` — if a delta token expires (typically after 30 days of inactivity), outlook-cli automatically falls back to a full sync. Three modes are available: + +| Mode | Latency | Setup | Use Case | +|---|---|---|---| +| `poll` (default) | 30–60s | None | General monitoring, rules engine | +| `fast-poll` | 5–60s adaptive | None | Agent integration without tunnel | +| `webhook` | ~1–5s push | Tunnel (cloudflared) | Gateway/agent integration | + +### Simple watch (one account) + +```bash +# Default poll mode (delta queries every 60 seconds) +outlook-cli watch + +# Fast-poll: adaptive 5–60 second polling +outlook-cli watch --mode fast-poll --interval 10 --no-calendar + +# Webhook: near-instant push notifications +outlook-cli watch --mode webhook + +# Output as JSON Lines (for piping to agents) +outlook-cli watch --mode webhook --jsonl | my-agent-process +``` + +### Multi-job watch (rules engine) + +For complex scenarios — watching multiple accounts, filtering specific emails, taking automated actions — create a JSON config file: + +```json +{ + "jobs": [ + { + "name": "urgent-mail", + "account": "work", + "schedule": "30s", + "rules": [ + { + "conditions": { + "importance": "high", + "from": ["boss@contoso.com", "vp@contoso.com"] + }, + "actions": [ + { "type": "flag" }, + { "type": "move", "folder": "Urgent" } + ] + } + ] + }, + { + "name": "newsletter-cleanup", + "account": "personal", + "schedule": "5m", + "rules": [ + { + "conditions": { + "from": ["*@newsletter.example.com"], + "subject_contains": ["unsubscribe", "weekly digest"] + }, + "actions": [ + { "type": "mark_read" }, + { "type": "move", "folder": "Newsletters" } + ] + } + ] + } + ] +} +``` + +Run it: + +```bash +outlook-cli watch --config watch-rules.json + +# Validate without running +outlook-cli watch --config watch-rules.json --validate +``` + +### Schedule formats + +| Format | Example | Meaning | +|---|---|---| +| Duration | `30s`, `5m`, `1h`, `2h30m` | Check every N seconds/minutes/hours | +| Cron | `*/5 * * * *` | Cron expression (every 5 minutes) | +| Time of day | `09:00`, `17:30` | Run once daily at this time | + +See [WATCH-MODE.md](WATCH-MODE.md) for full options reference. + +--- + +## 13. OpenClaw / NanoClaw Integration + +outlook-cli integrates with AI agent frameworks in two modes: + +1. **As a Skill** — The agent invokes outlook-cli commands to read/write email +2. **As a Gateway** — outlook-cli watches for incoming email and delivers messages to the agent in real-time + +### Automated Setup + +The `skill/` directory contains ready-to-use scripts for both platforms. You don't need to clone the entire repo — just copy the `skill/openclaw/` or `skill/nanoclaw/` directory and point `--bin` to your outlook-cli binary. + +#### OpenClaw Quick Start + +```powershell +# Windows (PowerShell) +.\skill\openclaw\setup.ps1 -ClientId "YOUR_CLIENT_ID" +.\skill\openclaw\gateway.ps1 # starts real-time email gateway + +# Linux / macOS (bash) +./skill/openclaw/setup.sh --client-id YOUR_CLIENT_ID +./skill/openclaw/gateway.sh # starts real-time email gateway +``` + +#### NanoClaw Quick Start + +```bash +# In your NanoClaw container +./skill/nanoclaw/setup.sh --client-id YOUR_CLIENT_ID + +# Or with environment variables +OUTLOOK_CLI_CLIENT_ID=your-id OUTLOOK_CLI_PASSPHRASE=your-passphrase \ + ./skill/nanoclaw/gateway.sh +``` + +### Mode 1: Skill (Agent invokes CLI commands) + +Your agent runs outlook-cli commands directly to interact with email and calendar. + +#### OpenClaw + +Add the Skill definition from `skill/openclaw/SKILL.md` to your OpenClaw configuration. The agent can then run: + +```bash +outlook-cli mail inbox --json +outlook-cli mail search "project alpha" --json +outlook-cli mail reply MESSAGE_ID --body "Agent-drafted reply..." --yes --json +outlook-cli calendar today --json +``` + +> **Security:** By default, the agent can create drafts but not send them. To enable sending, add `Mail.Send` to the account's allowed permissions and configure a `send_to` whitelist (see [Configure Permissions](#8-configure-permissions)). + +#### NanoClaw + +Use `skill/nanoclaw/SKILL.md` for your container configuration. Key setup: + +```yaml +volumes: + - ~/.outlook-cli:/root/.outlook-cli # Persist tokens across restarts + +environment: + OUTLOOK_CLI_CLIENT_ID: "your-client-id" + OUTLOOK_CLI_PASSPHRASE: "your-secure-passphrase" +``` + +Since containers don't have a browser, run setup with device code flow: + +```bash +./skill/nanoclaw/setup.sh --client-id YOUR_CLIENT_ID +``` + +### Mode 2: Gateway (email as a real-time channel) + +The gateway scripts watch for incoming emails and deliver them to the agent as JSONL events. They automatically select the best watch mode: + +- **Webhook mode** if `cloudflared` is installed — near-instant (~1–5 second) push notifications +- **Fast-poll mode** as fallback — adaptive 5–60 second polling + +#### OpenClaw Gateway + +```powershell +# Windows — starts gateway, outputs JSONL to stdout +.\skill\openclaw\gateway.ps1 + +# With a custom binary location +.\skill\openclaw\gateway.ps1 -OutlookCliBin "C:\tools\outlook-cli.exe" + +# Forward events to an HTTP endpoint +.\skill\openclaw\gateway.ps1 -GatewayUrl "http://localhost:8080/incoming" +``` + +```bash +# Linux/macOS +./skill/openclaw/gateway.sh +./skill/openclaw/gateway.sh --gateway-url http://localhost:8080/incoming +``` + +Configure OpenClaw to consume the gateway: + +```yaml +channels: + - name: outlook-email + type: process + command: ./skill/openclaw/gateway.sh + format: jsonl +``` + +#### NanoClaw Gateway + +```yaml +# NanoClaw container config +channels: + - name: outlook + type: process + command: /opt/outlook-cli/skill/nanoclaw/gateway.sh + format: jsonl +``` + +The gateway outputs JSONL to stdout. Each line is a change event that NanoClaw interprets as an incoming message. + +### Bidirectional Conversation Flow + +Here's the full flow for an email-based AI assistant: + +1. **User sends email** to the agent's account +2. **Gateway** detects the new message (via webhook push or fast-poll) and delivers it as JSONL +3. **Agent receives** the message and processes it — e.g., checks the calendar: + ```bash + outlook-cli calendar today --json + ``` +4. **Agent replies** using skill commands: + ```bash + outlook-cli mail reply MESSAGE_ID --body "Here's your calendar..." --yes --json + ``` +5. **User receives** the reply in their inbox + +### Installing Tunnel for Webhook Mode + +For near-instant notification delivery, install a tunnel tool: + +| Platform | Command | +|---|---| +| Windows | `winget install Cloudflare.cloudflared` | +| macOS | `brew install cloudflare/cloudflare/cloudflared` | +| Linux | See [Cloudflare docs](https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/) | + +Without a tunnel, the gateway scripts automatically fall back to fast-poll mode (5-second adaptive polling). + +--- + +## 14. Diagnostics — Logs, Telemetry, Doctor + +outlook-cli includes built-in diagnostics for troubleshooting and monitoring. Every CLI operation is logged with a correlation ID, so you can trace a full request chain from command invocation through Graph API calls to completion or failure. For full details, see [DIAGNOSTICS.md](DIAGNOSTICS.md). + +### Operations Log + +Every CLI command is automatically recorded in a SQLite database at `~/.outlook-cli/outlook-cli.db` (using WAL mode for concurrent access). Each operation gets a unique correlation ID that links the start, completion, and any errors together. The database is managed by `src/node/db/database.js` and logged by `src/node/db/logger.js`. This log is invaluable for diagnosing intermittent failures — you can filter by account, status, time range, or correlation ID to find exactly what happened. + +```bash +# Show recent operations +outlook-cli log search + +# Filter by account or status +outlook-cli log search --account work --status failed + +# Summary dashboard (last 7 days) +outlook-cli log summary + +# Trace a specific operation chain +outlook-cli log search --correlation-id +``` + +### Doctor (Health Check) + +Run a comprehensive health check of your setup: + +```bash +outlook-cli doctor +``` + +This validates Node.js version, config files (`accounts.json`, `config.json`, `aliases.json`), token cache encryption, Graph API connectivity, and granted scopes. Each check reports ✅/❌ with fix instructions. Running `doctor` after initial setup or after moving `~/.outlook-cli/` to a new machine is a good practice — it catches the most common misconfiguration issues in seconds. + +### Telemetry (Optional) + +Disabled by default. Enable for detailed API timing, request counts, and performance metrics. Telemetry uses an in-memory ring buffer (1,000 events) with optional SQLite persistence. All data stays local — nothing is sent externally, ever. This is useful for diagnosing slow commands, tracking API rate-limit headers, and profiling startup time. See [DIAGNOSTICS.md](DIAGNOSTICS.md) for details on interpreting telemetry output. + +```bash +OUTLOOK_CLI_TELEMETRY=1 outlook-cli mail inbox +``` + +All data stays local — nothing is sent externally. Set `OUTLOOK_CLI_TELEMETRY=0` or remove the variable to disable. See [DIAGNOSTICS.md](DIAGNOSTICS.md) for details. + +--- + +## 15. Rolling Out to Your Team + +### Share the Client ID + +One App Registration works for your entire organization. Share the Client ID (it's not a secret — it's just an identifier, like a username). Each person authenticates with their own Microsoft account and gets their own encrypted token cache file. The token cache uses AES-256-GCM encryption with PBKDF2-SHA512 key derivation (310,000 iterations), keyed to either a machine-specific value or a user-supplied `OUTLOOK_CLI_PASSPHRASE`. Even if someone copies another user's `cache-{alias}.enc` file, they cannot decrypt it without the original machine's key material or passphrase. + +### Quick setup for your team + +Copy the `scripts/` folder (and optionally the pre-built binary) to a file share: + +``` +\\server\tools\outlook-cli\ +├── scripts\ +│ ├── self-host-azure.ps1 # Admin runs this once +│ └── self-host-client.ps1 # Each user runs this +├── publish\ +│ └── win-x64\ +│ └── outlook-cli.exe # Pre-built native binary +└── README.txt # Your Client ID + instructions +``` + +Then send your team this: + +> **Getting started with outlook-cli:** +> +> 1. Open PowerShell and run: +> ``` +> \\server\tools\outlook-cli\scripts\self-host-client.ps1 -ClientId CLIENT_ID_HERE -RepoPath \\server\tools\outlook-cli +> ``` +> 2. Sign in with your Microsoft account when the browser opens +> 3. That's it! Try: `\\server\tools\outlook-cli\publish\win-x64\outlook-cli.exe mail inbox` +> +> Full guide: `\\server\tools\outlook-cli\docs\self-hosting.md` + +### Centralized configuration + +For teams, you can distribute a pre-configured `accounts.json`: + +```json +{ + "defaults": { + "permissions": { + "allowed": ["User.Read", "Mail.Read", "Mail.ReadWrite", "Calendars.Read", "Calendars.ReadWrite", "offline_access"], + "forbidden": ["Mail.Send"] + } + }, + "accounts": { + "work": { + "clientId": "YOUR_SHARED_CLIENT_ID", + "tenant": "contoso.onmicrosoft.com" + } + }, + "default": "work" +} +``` + +Have each team member place this in their `~/.outlook-cli/accounts.json`, then run `outlook-cli auth login`. + +### Admin consent (work/school accounts) + +For Microsoft 365 organizational accounts, an admin may need to grant consent for the app before users can authenticate. Without admin consent, each user sees a "needs admin approval" error on first login. Go to: + +**Entra admin center → Enterprise applications → outlook-cli → Permissions → Grant admin consent** + +This is a one-time action that approves the app for all users in the organization. + +--- + +## 16. Enterprise Deployment + +This section covers production deployment patterns for enterprise environments. + +### Secrets Management + +**Never hardcode passphrases.** In production, set the encryption passphrase via a secrets manager: + +**Azure Key Vault:** +```bash +export OUTLOOK_CLI_PASSPHRASE=$(az keyvault secret show --vault-name my-vault --name outlook-cli-pass --query value -o tsv) +outlook-cli mail inbox +``` + +**HashiCorp Vault:** +```bash +export OUTLOOK_CLI_PASSPHRASE=$(vault kv get -field=passphrase secret/outlook-cli) +outlook-cli mail inbox +``` + +**Kubernetes secret:** +```yaml +env: + - name: OUTLOOK_CLI_PASSPHRASE + valueFrom: + secretKeyRef: + name: outlook-cli-secrets + key: passphrase +``` + +Without `OUTLOOK_CLI_PASSPHRASE`, encryption falls back to a machine-derived key (`username@hostname:alias`). This is adequate for personal use but predictable if an attacker knows your machine identity. See [SECURITY-DESIGN.md](SECURITY-DESIGN.md) §4 for the full threat analysis. + +### Monitoring and Alerting + +outlook-cli stores telemetry in `~/.outlook-cli/outlook-cli.db` (SQLite). Key metrics to monitor: + +```bash +# Check recent Graph API performance +outlook-cli telemetry summary + +# Look for authentication failures +outlook-cli log search --status failed --since "24 hours ago" + +# Run automated health check +outlook-cli doctor +``` + +**Metrics to track:** +- Authentication failure rate (`log search --status failed`) +- Graph API response times (`telemetry summary` — avg, p95, p99) +- Rate limit hits (429 responses in telemetry data) +- Cache decryption failures (indicates passphrase/machine mismatch) + +**Alerting thresholds:** +- Auth failure rate > 5% → check token expiry, investigate passphrase changes +- P95 response time > 5s → Graph API degradation or network issues +- Rate limit hits > 10/hour → reduce polling frequency or batch operations + +### Multi-Instance Deployments + +Multiple CLI instances can share the same `~/.outlook-cli/` directory safely: + +- **Token cache**: AES-256-GCM encrypted files. MSAL handles token refresh race conditions internally. Two instances may both refresh, but the last write wins and both get valid tokens. +- **SQLite database**: Uses WAL (Write-Ahead Logging) mode for concurrent read/write access. Multiple readers are fully concurrent; writes are serialized automatically. +- **Short IDs (`last-results.json`)**: NOT safe for multi-instance use. Each instance overwrites the short ID mapping. Use full Graph IDs (`--json | jq '.id'`) in multi-instance or agent scenarios. +- **Delta tokens**: One delta token per account. If two instances watch the same account, they'll see duplicate notifications. Use a single watcher per account. + +**Recommended pattern for agents:** +```bash +# Each agent instance uses full Graph IDs, not short IDs +MSG_ID=$(outlook-cli mail inbox --top 1 --json --account agent-acct | jq -r '.[0].id') +outlook-cli mail read "$MSG_ID" --json --account agent-acct +``` + +### Backup and Recovery + +**What to back up:** +| File | Purpose | Sensitive? | +|------|---------|-----------| +| `accounts.json` | Account configuration, client IDs, aliases | No secrets (client IDs are public) | +| `aliases.json` | Human-friendly account name mappings | No | +| `config.json` | CLI preferences and defaults | No | +| `cache-*.enc` | Encrypted token caches | Encrypted (useless without passphrase) | +| `outlook-cli.db` | Operation logs and telemetry | No secrets, but contains metadata | + +**Recovery after data loss:** +```bash +# Re-authenticate (regenerates token cache) +outlook-cli auth login --account + +# If accounts.json is lost, re-create accounts +outlook-cli auth login --client-id YOUR_CLIENT_ID --account my-account +``` + +Token caches are regenerated on login. The only unrecoverable data is operation history in the SQLite database (logs, telemetry), which is diagnostic only. + +--- + +## 17. Troubleshooting Quick Reference + +This section covers the most common issues. For in-depth troubleshooting with root cause analysis and step-by-step fixes, see [troubleshooting.md](troubleshooting.md). You can also run `outlook-cli doctor` to automatically diagnose most setup problems. + +### "AADSTS700016: Application not found" + +- Double-check the Client ID matches your app registration. It should be a GUID like `a1b2c3d4-e5f6-7890-abcd-ef1234567890`. +- If using a work account, the app may need to be registered in your organization's tenant (not your personal tenant). Check with your Azure AD admin. +- Verify you haven't accidentally deleted the App Registration in the Azure portal. + +### "redirect_uri does not match" + +- Ensure `http://localhost:53847/callback` is set as the redirect URI in your Azure App Registration under Authentication → Mobile and desktop applications. +- Ensure the platform type is **"Mobile and desktop applications"** (not "Web"). The web platform requires HTTPS and a different auth flow, which outlook-cli doesn't use. +- If you've changed the port, make sure the redirect URI matches exactly — the port number, path, and protocol must all match. + +### "AADSTS65001: consent required" + +- First login requires you to click "Accept" on the consent screen in the browser. If you dismiss or cancel, authentication fails. +- For work accounts, your admin may need to grant tenant-wide consent (see [Rolling Out](#15-rolling-out-to-your-team)). Until consent is granted, users see "needs admin approval" instead of the consent screen. +- If you've added new API permissions to the App Registration after initial consent, users may need to re-consent by running `outlook-cli auth login` again. + +### "Port 53847 is in use" + +- Another program (or another instance of outlook-cli) is using that port. The interactive login flow starts a temporary HTTP server on `localhost:53847` to receive the OAuth callback. +- Use `--device-code` instead to avoid the local server entirely: `outlook-cli auth login --device-code --client-id YOUR_CLIENT_ID`. This works on any machine, including servers and containers. +- On Windows, you can find what's using the port with `netstat -ano | findstr :53847` and stop that process. + +### "Could not decrypt token cache" + +- The token cache (`cache-{alias}.enc`) is encrypted with AES-256-GCM, keyed to a machine-specific value or the `OUTLOOK_CLI_PASSPHRASE` environment variable. If you moved `~/.outlook-cli/` from another machine, the key material doesn't match — re-authenticate: `outlook-cli auth login`. +- In containers, always set `OUTLOOK_CLI_PASSPHRASE` to a consistent value so the cache survives container restarts. Without it, each container instance generates a different machine key and can't decrypt caches from previous runs. +- outlook-cli handles decryption failures gracefully: it logs a warning, starts with an empty cache, and the user re-authenticates on next use. No data is lost — the old encrypted file is simply ignored. + +### Device code shows "undefined" or blank + +- **"Allow public client flows"** is not enabled in the app registration (Step 2.4, item 7). Without this, the device code grant type is rejected by Azure AD. +- Verify the **Supported account types** includes your account type. If you registered the app as "single tenant" but are using a personal Microsoft account, the flow will fail. +- Run with `--verbose` for detailed error output. The verbose log shows the exact MSAL error code and message, which helps identify the root cause. + +### "No valid token" errors + +- Your token may have expired. Re-authenticate: `outlook-cli auth login`. With the `offline_access` permission, tokens refresh automatically via MSAL's silent token acquisition — you shouldn't need to re-login unless the refresh token itself expires (typically 90 days of inactivity). +- If using `offline_access` permission, tokens refresh automatically. Without it, you'll need to re-login periodically. The `offline_access` scope is strongly recommended — add it in your Azure App Registration if it's missing. +- Run `outlook-cli auth status` to check whether the current token is valid before investigating further. + +### Node.js vs .NET gives different results + +- Both implementations share the same config and token cache in `~/.outlook-cli/`. The binary cache format (`salt(32) + iv(16) + authTag(16) + ciphertext`) is identical, so a cache encrypted by Node.js decrypts in .NET and vice versa. +- Make sure you're running the latest version of both. The .NET NativeAOT binary in `publish/` is compiled at a point in time and doesn't auto-update — rebuild it after `git pull` with `dotnet publish` or `./build.ps1 dotnet -Publish`. +- If one works and the other doesn't, try re-authenticating with the working one — the shared cache will be refreshed. Check `outlook-cli --version` on both to confirm they match. + +### "Network error" or "ENOTFOUND" or "ETIMEDOUT" + +- Check your internet connectivity and DNS resolution. outlook-cli needs to reach `graph.microsoft.com` and `login.microsoftonline.com`. +- If you're behind a corporate proxy, set the `HTTPS_PROXY` environment variable. Node.js respects this for outbound HTTPS connections. +- Transient network errors are retried automatically for 429 (rate limit) responses, but other network failures are not retried by default. If the error is intermittent, simply re-run the command. + +### Config file seems to override my environment variables + +This is by design. The config file (`~/.outlook-cli/config.json`) intentionally takes priority over environment variables — see [Configuration Resolution](#11-configuration-resolution) for the full explanation. If you want environment variables to win, remove the conflicting key from `config.json`. You can inspect your resolved config with `outlook-cli --verbose` which logs the final merged configuration. + +### Short IDs point to wrong messages + +Short IDs in `~/.outlook-cli/last-results.json` are overwritten every time any list command runs (`inbox`, `search`, `mail inbox --folder ...`). If you ran a search after listing your inbox, the short IDs now point to search results. See [Short IDs](#6-short-ids-and-message-references) for details and workarounds. For scripts, always use full Graph IDs from `--json` output. + +### Need more help? + +- Run `outlook-cli doctor` for automated health checks +- Use `outlook-cli --verbose` for detailed logging of every step +- Check the operations log: `outlook-cli log search --status failed` +- Review the docs: [architecture.md](architecture.md), [usage.md](usage.md), [SECURITY.md](SECURITY.md), [troubleshooting.md](troubleshooting.md) +- File an issue: `https://github.com/jeffstall/outlook-cli/issues` diff --git a/docs/src-dotnet.md b/docs/src-dotnet.md new file mode 100644 index 0000000..1a4582e --- /dev/null +++ b/docs/src-dotnet.md @@ -0,0 +1,165 @@ +# .NET Implementation + +Building, running, and publishing the C# / .NET NativeAOT implementation of outlook-cli. + +## Prerequisites + +- **.NET 8 SDK** ([download](https://dotnet.microsoft.com/en-us/download/dotnet/8.0)) + +## Build + +```bash +dotnet build src/dotnet/OutlookCli.csproj +``` + +## Run + +```bash +dotnet run --project src/dotnet -- + +# Examples +dotnet run --project src/dotnet -- mail inbox +dotnet run --project src/dotnet -- auth login --client-id YOUR_CLIENT_ID +dotnet run --project src/dotnet -- --help +``` + +> The `--` separator is required so that arguments after it are passed to outlook-cli rather than to `dotnet run`. + +## Test + +Integration tests can be run against the .NET binary by setting the `OUTLOOK_CLI_BIN` environment variable: + +```bash +# Build the .NET project first +dotnet build src/dotnet/OutlookCli.csproj -c Release + +# Point integration tests at the .NET binary +$env:OUTLOOK_CLI_BIN = "dotnet run --project src/dotnet --" +npm test -- test/shared/cli.test.js +``` + +See [src-tests.md](src-tests.md) for full test documentation. + +## NativeAOT Publish + +NativeAOT compilation produces a **fully self-contained binary** (~10 MB) that requires no .NET runtime on the target machine. + +```bash +dotnet publish src/dotnet/OutlookCli.csproj \ + -c Release \ + -r \ + --self-contained \ + /p:PublishAot=true \ + -o publish/ +``` + +### Supported Runtime Identifiers (RIDs) + +| RID | Platform | +|---|---| +| `win-x64` | Windows x64 | +| `win-arm64` | Windows ARM64 | +| `osx-arm64` | macOS Apple Silicon | +| `linux-x64` | Linux x64 | + +### Examples + +```bash +# Windows x64 +dotnet publish src/dotnet/OutlookCli.csproj -c Release -r win-x64 --self-contained /p:PublishAot=true -o publish/win-x64 + +# macOS ARM64 +dotnet publish src/dotnet/OutlookCli.csproj -c Release -r osx-arm64 --self-contained /p:PublishAot=true -o publish/osx-arm64 + +# Linux x64 +dotnet publish src/dotnet/OutlookCli.csproj -c Release -r linux-x64 --self-contained /p:PublishAot=true -o publish/linux-x64 +``` + +## Unified Build Script + +The `build.ps1` PowerShell script provides a convenient wrapper for building, testing, and publishing: + +```powershell +# Build and test .NET only +.\build.ps1 dotnet + +# Build, test, and publish for current platform +.\build.ps1 dotnet -Publish + +# Publish for all 4 platforms +.\build.ps1 dotnet -Publish -Runtime all + +# Publish for specific platforms +.\build.ps1 dotnet -Publish -Runtime win-x64,osx-arm64 + +# Build and test both Node.js and .NET +.\build.ps1 all +``` + +## Project Structure + +``` +src/dotnet/ +├── Accounts/ # Multi-account management +├── Auth/ # MSAL.NET authentication, token cache +├── Contacts/ # Contact search and alias management +├── Graph/ # Microsoft Graph API HTTP client +├── Output/ # Output formatters (text, json, markdown, html) +├── Security/ # Permission validation, forbidden scopes +├── Program.cs # Entry point, System.CommandLine setup +├── OutlookCli.csproj # Project file with NativeAOT configuration +└── OutlookCliJsonContext.cs # JSON source generators for NativeAOT +``` + +## Key Dependencies + +| Package | Version | Purpose | +|---|---|---| +| [`Microsoft.Identity.Client`](https://www.nuget.org/packages/Microsoft.Identity.Client) | 4.67.* | MSAL.NET — OAuth 2.0 flows, token caching | +| [`System.CommandLine`](https://www.nuget.org/packages/System.CommandLine) | 2.0.0-beta4 | CLI framework — commands, options, help | + +## NativeAOT Notes + +The .NET implementation is designed for ahead-of-time compilation. Key constraints: + +### JSON Source Generators + +All JSON serialization uses **source generators** via `OutlookCliJsonContext` — no runtime reflection. Every type that is serialized or deserialized must be registered in the context: + +```csharp +[JsonSerializable(typeof(AccountConfig))] +[JsonSerializable(typeof(Dictionary))] +// ... all serialized types listed here +internal partial class OutlookCliJsonContext : JsonSerializerContext { } +``` + +The project sets `JsonSerializerIsReflectionEnabledByDefault` to `false` in the `.csproj` to enforce this at build time. + +### No Anonymous Types + +Anonymous types cannot be serialized by source generators. All request/response payloads use explicit DTO classes or pre-serialized JSON strings. + +### GraphClient Design + +The `GraphClient` accepts `string? bodyJson` parameters for request bodies rather than generic objects. The caller is responsible for serializing the body using the source-generated context before passing it to GraphClient: + +```csharp +var body = JsonSerializer.Serialize(draft, OutlookCliJsonContext.Default.DraftMessage); +await graphClient.PostAsync("/me/messages", body); +``` + +### Trimming + +The project uses `TrimMode=link` for aggressive dead-code elimination. MSAL internal trimming warnings are acknowledged but suppressed (`SuppressTrimAnalysisWarnings=false` logs them without failing the build). + +## Configuration + +The .NET implementation reads and writes the same configuration files in `~/.outlook-cli/` as the Node.js implementation. The token cache format is interoperable — you can authenticate with one implementation and use the other. + +See [architecture.md](architecture.md) for the shared configuration format. + +## Related Documentation + +- [architecture.md](architecture.md) — Architecture overview, shared config formats +- [src-tests.md](src-tests.md) — Integration testing with OUTLOOK_CLI_BIN +- [src-node.md](src-node.md) — The Node.js implementation diff --git a/docs/src-node.md b/docs/src-node.md new file mode 100644 index 0000000..e674819 --- /dev/null +++ b/docs/src-node.md @@ -0,0 +1,96 @@ +# Node.js Implementation + +Building, running, and testing the Node.js implementation of outlook-cli. + +## Prerequisites + +- **Node.js** 20+ (see `engines` in `package.json`) +- **npm** (bundled with Node.js) + +## Install Dependencies + +```bash +cd outlook-cli +npm install +``` + +## Run + +```bash +# Run directly +node bin/outlook-cli.js + +# Or create a global symlink for convenience +npm link +outlook-cli + +# Or use npx (from the project directory) +npx outlook-cli +``` + +## Test + +```bash +npm test # Run all tests (vitest) +npm run test:verbose # Verbose output +npm run test:watch # Watch mode (re-run on change) +npm run test:coverage # With coverage report +``` + +See [src-tests.md](src-tests.md) for test architecture details. + +## Build + +No build step is required. The Node.js implementation uses **ESM modules** directly — source files are executed as-is by the Node.js runtime. + +## Project Structure + +``` +src/node/ +├── accounts/ # Multi-account management (add, remove, list, set-default) +├── auth/ # MSAL authentication (login, logout, status, token cache) +├── cli/ # Commander.js command definitions +├── contacts/ # Contact search and alias management +├── graph/ # Microsoft Graph API HTTP client +├── output/ # Output formatters (text, json, markdown, html) +├── security/ # Permission validation, forbidden scopes, send_to whitelist +├── watch/ # Delta query watch mode, rules engine +├── config.js # Configuration loading (~/.outlook-cli/) +└── input.js # JSON input file parsing (--input flag) +``` + +## Key Dependencies + +| Package | Purpose | +|---|---| +| [`@azure/msal-node`](https://www.npmjs.com/package/@azure/msal-node) | Microsoft Authentication Library — OAuth 2.0 flows, token caching | +| [`commander`](https://www.npmjs.com/package/commander) | CLI framework — commands, options, help generation | + +Dev dependencies: + +| Package | Purpose | +|---|---| +| [`vitest`](https://vitest.dev/) | Test runner — describe/it/expect, mocking, coverage | + +## Module System + +The project uses **ES Modules** (`"type": "module"` in `package.json`). All imports use the `import`/`export` syntax: + +```javascript +import { getAccounts } from '../accounts/store.js'; +import { acquireToken } from '../auth/token.js'; +``` + +## Entry Point + +`bin/outlook-cli.js` is the main entry point. It imports the Commander.js program from `src/node/cli/` and invokes `program.parse()`. + +## Configuration + +The Node.js implementation reads and writes configuration files in `~/.outlook-cli/`. See [architecture.md](architecture.md) for the shared configuration format used by both implementations. + +## Related Documentation + +- [architecture.md](architecture.md) — Architecture overview, shared config formats +- [src-tests.md](src-tests.md) — Test conventions and how to add tests +- [src-dotnet.md](src-dotnet.md) — The .NET implementation diff --git a/docs/src-tests.md b/docs/src-tests.md new file mode 100644 index 0000000..1d2db4b --- /dev/null +++ b/docs/src-tests.md @@ -0,0 +1,169 @@ +# Test Architecture + +How tests are organized, how to run them, and how to add new tests. + +## Running Tests + +### Node.js Unit Tests + +```bash +npm test # Run all tests +npm run test:verbose # Verbose reporter +npm run test:watch # Re-run on file changes +npm run test:coverage # Generate coverage report +``` + +The test runner is [vitest](https://vitest.dev/). + +### Integration Tests + +Integration tests in `test/shared/` spawn the CLI binary and verify stdout/stderr output. By default they test the Node.js implementation: + +```bash +npm test -- test/shared/cli.test.js +``` + +To test the .NET implementation, set the `OUTLOOK_CLI_BIN` environment variable: + +```bash +# PowerShell +$env:OUTLOOK_CLI_BIN = "dotnet run --project src/dotnet --" +npm test -- test/shared/cli.test.js + +# Or point at a published NativeAOT binary +$env:OUTLOOK_CLI_BIN = "./publish/win-x64/outlook-cli.exe" +npm test -- test/shared/cli.test.js +``` + +### Unified Build Script + +```powershell +.\build.ps1 node -TestOnly # Node.js tests only +.\build.ps1 dotnet -TestOnly # .NET build + integration tests +.\build.ps1 all # Build + test both +``` + +## Test Directory Structure + +``` +test/ +├── node/ # Node.js unit tests +│ ├── accounts/ # Account management tests +│ ├── auth/ # Authentication tests +│ ├── contacts/ # Contact search and alias tests +│ ├── graph/ # Graph API client tests +│ ├── input.test.js # JSON input parsing tests +│ ├── output/ # Output formatter tests +│ ├── security/ # Security model tests +│ └── watch/ # Watch mode / delta query tests +└── shared/ # Cross-implementation integration tests + └── cli.test.js # CLI integration tests (spawn binary) +``` + +### Test Categories + +| Directory | Tests | Scope | +|---|---|---| +| `test/node/security/` | Permission validation, forbidden scopes, send\_to whitelist | Unit | +| `test/node/graph/` | Graph API request construction, response parsing | Unit | +| `test/node/output/` | Output formatters (text, json, markdown, html) | Unit | +| `test/node/accounts/` | Account add, remove, list, set-default, parent chains | Unit | +| `test/node/contacts/` | Contact search, alias CRUD | Unit | +| `test/node/watch/` | Delta query logic, watch mode behavior | Unit | +| `test/node/auth/` | Token acquisition, cache management | Unit | +| `test/node/input.test.js` | `--input` JSON file parsing, stdin piping | Unit | +| `test/shared/cli.test.js` | End-to-end CLI invocation, exit codes, output | Integration | + +## Test Conventions + +### Framework + +Tests use **vitest** with `describe`/`it`/`expect`: + +```javascript +import { describe, it, expect } from 'vitest'; + +describe('formatInbox', () => { + it('should format messages as text table', () => { + const result = formatInbox(messages, 'text'); + expect(result).toContain('Subject'); + expect(result).toContain('alice@example.com'); + }); +}); +``` + +### Mocking + +Use vitest's built-in mocking for file system and network operations: + +```javascript +import { vi } from 'vitest'; + +vi.mock('fs', () => ({ + readFileSync: vi.fn(() => '{"accounts": {}}'), + writeFileSync: vi.fn(), + existsSync: vi.fn(() => true), +})); +``` + +### Naming + +- Test files are named `*.test.js` +- Test files mirror the source structure (e.g., `src/node/output/` → `test/node/output/`) +- Describe blocks match the module or function being tested +- It blocks describe the expected behavior + +## Adding New Tests + +### 1. Unit Test (Node.js) + +Create a test file that mirrors the source file location: + +``` +src/node/graph/mail.js → test/node/graph/mail.test.js +``` + +```javascript +import { describe, it, expect, vi } from 'vitest'; +import { buildSearchQuery } from '../../src/node/graph/mail.js'; + +describe('buildSearchQuery', () => { + it('should escape special characters', () => { + const query = buildSearchQuery('from:"alice"'); + expect(query).toContain('from'); + }); +}); +``` + +### 2. Integration Test + +Add test cases to `test/shared/cli.test.js`. Integration tests spawn the CLI binary and check output: + +```javascript +import { execSync } from 'child_process'; + +const bin = process.env.OUTLOOK_CLI_BIN || 'node bin/outlook-cli.js'; + +describe('CLI integration', () => { + it('should show help', () => { + const output = execSync(`${bin} --help`, { encoding: 'utf-8' }); + expect(output).toContain('outlook-cli'); + }); +}); +``` + +### 3. Run Your New Test + +```bash +# Run a specific test file +npm test -- test/node/graph/mail.test.js + +# Run tests matching a pattern +npm test -- --grep "buildSearchQuery" +``` + +## Related Documentation + +- [src-node.md](src-node.md) — Node.js implementation details +- [src-dotnet.md](src-dotnet.md) — .NET implementation details +- [TESTING.md](TESTING.md) — Additional testing notes diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..2dedb04 --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,454 @@ +# Troubleshooting + +Common issues, their causes, and how to fix them. + +--- + +## Table of Contents + +1. [Stale Binary (Version Mismatch)](#stale-binary-version-mismatch) +2. [Token Expired / Authentication Issues](#token-expired--authentication-issues) +3. [Schema Mismatch in Config Files](#schema-mismatch-in-config-files) +4. [Rate Limiting / Throttling by Graph API](#rate-limiting--throttling-by-graph-api) +5. [SQLite Database Locks](#sqlite-database-locks) +6. [Crypto / Cache Decryption Errors](#crypto--cache-decryption-errors) +7. [NativeAOT Binary Not Finding Configs](#nativeaot-binary-not-finding-configs) +8. [Permission Errors (Mail.Send Not Granted)](#permission-errors-mailsend-not-granted) +9. [Delta Token Expired During Watch Mode](#delta-token-expired-during-watch-mode) + +--- + +## Stale Binary (Version Mismatch) + +### Symptom + +The Node.js version and the .NET NativeAOT binary produce different output, accept different flags, or one crashes while the other works. Error messages may reference options that don't exist in the version you're running. + +### Cause + +The NativeAOT binary in `publish/` was compiled from an older version of the source code. Since the binary is self-contained, it doesn't automatically update when you `git pull` or `npm install`. + +### Fix + +Rebuild the .NET binary after pulling updates: + +```bash +# Rebuild for your platform +dotnet publish src/dotnet/OutlookCli.csproj -c Release -r win-x64 --self-contained /p:PublishAot=true -o publish/win-x64 + +# Or rebuild all platforms at once +./build.ps1 dotnet -Publish -Runtime all +``` + +Check the version of both implementations: + +```bash +# Node.js +node bin/outlook-cli.js --version + +# .NET binary +outlook-cli.exe --version +``` + +If the versions differ, rebuild the .NET binary. + +--- + +## Token Expired / Authentication Issues + +### Symptom + +Commands fail with errors like: +- `"No valid token"` or `"Token acquisition failed"` +- `"AADSTS700082: The refresh token has expired due to inactivity"` +- `"InteractionRequired"` — MSAL cannot silently acquire a token +- `401 Unauthorized` from Graph API + +### Cause + +- **Access tokens** expire after ~1 hour. Normally, MSAL refreshes them silently using the stored refresh token. +- **Refresh tokens** expire after ~90 days of inactivity. If you haven't used outlook-cli for 90+ days, the refresh token is invalid. +- **Conditional access policies** (work/school accounts) may revoke tokens if device compliance changes. + +### Fix + +Re-authenticate the affected account: + +```bash +# Logout + login to force a fresh token +outlook-cli auth logout --account work +outlook-cli auth login --account work + +# On headless servers, use device code flow +outlook-cli auth login --account work --device-code + +# Check token status +outlook-cli auth status --account work +``` + +If tokens keep expiring quickly, verify that `offline_access` is in the allowed permissions list in your Azure App Registration and in `accounts.json`. + +--- + +## Schema Mismatch in Config Files + +### Symptom + +- `"Unexpected property"` or `"Missing required field"` errors on startup +- Commands fail immediately without reaching the Graph API +- After an upgrade, previously working config files cause errors + +### Cause + +The structure of `accounts.json`, `aliases.json`, or watch config files changed between versions. The old file doesn't match the schema the new code expects. + +### Fix + +1. **Check the expected schema** in the docs: + - `accounts.json` — see [architecture.md](architecture.md) and the `accounts.json` section + - Watch config — see [WATCH-MODE.md](WATCH-MODE.md) + +2. **Back up and update** your config: + + ```bash + # Back up + cp ~/.outlook-cli/accounts.json ~/.outlook-cli/accounts.json.bak + + # Re-add accounts (regenerates the file) + outlook-cli account add work --client-id YOUR_CLIENT_ID --tenant YOUR_TENANT + outlook-cli auth login --account work + ``` + +3. **Run with `--verbose`** to see exactly which field is causing the error: + + ```bash + outlook-cli mail inbox --verbose + ``` + +--- + +## Rate Limiting / Throttling by Graph API + +### Symptom + +- `429 Too Many Requests` responses from the Graph API +- `"Retry-After: N"` headers in verbose output +- Commands intermittently fail, then succeed minutes later +- Watch mode logs throttling warnings + +### Cause + +Microsoft Graph enforces per-user and per-app rate limits. Common triggers: +- Watch mode polling too frequently (interval below 30 seconds) +- Multiple concurrent CLI invocations hitting the same mailbox +- Batch scripts running many commands in a tight loop + +### Fix + +1. **Increase the watch interval** — 60 seconds (default) is safe; 30 seconds is the minimum: + + ```bash + outlook-cli watch --interval 60 + ``` + +2. **Add delays in scripts** that run multiple commands: + + ```bash + # PowerShell — add a pause between batch operations + Get-Content messageIds.txt | ForEach-Object { + outlook-cli mail mark-read $_ --yes + Start-Sleep -Seconds 2 + } + ``` + +3. **Spread load across time** — if running watch for multiple accounts, stagger the start times. + +4. **Check verbose output** for the `Retry-After` header value — that tells you how long to wait: + + ```bash + outlook-cli mail inbox --verbose 2>&1 | Select-String "Retry-After" + ``` + +--- + +## SQLite Database Locks + +### Symptom + +- `"database is locked"` errors when running multiple CLI commands simultaneously +- Operations log writes fail silently +- Watch mode cannot write to `operations.db` + +### Cause + +SQLite uses file-level locking. If two processes try to write to `~/.outlook-cli/operations.db` at the same time (e.g., watch mode + a manual CLI command), one will get a lock error. + +### Fix + +1. **Retry automatically** — outlook-cli has built-in retry logic with a short timeout. Transient lock errors usually resolve on their own. + +2. **Check for stuck processes** — a crashed CLI process may hold a lock: + + ```powershell + # Windows — find outlook-cli processes + Get-Process | Where-Object { $_.ProcessName -match "outlook-cli" -or $_.CommandLine -match "outlook-cli" } + + # Kill a specific process by PID + Stop-Process -Id + ``` + + ```bash + # Linux/macOS + ps aux | grep outlook-cli + kill + ``` + +3. **Delete the WAL file** if the database is in a bad state: + + ```bash + # Stop all outlook-cli processes first + rm ~/.outlook-cli/operations.db-wal + rm ~/.outlook-cli/operations.db-shm + ``` + +4. **Avoid concurrent writes** — if you run watch mode, it will handle operations logging. Don't run a second watch process on the same account. + +--- + +## Crypto / Cache Decryption Errors + +### Symptom + +- `"Could not decrypt token cache"` or `"Decryption failed"` errors +- `"bad decrypt"` or `"unsupported state"` from the crypto layer +- Authentication works on one machine but fails on another with the same `~/.outlook-cli/` directory + +### Cause + +The token cache (`token-cache-.bin`) is encrypted with AES-256-GCM. The encryption passphrase is either: + +1. **`OUTLOOK_CLI_PASSPHRASE`** environment variable (if set), or +2. **Machine-derived**: `username@hostname:account-alias` + +If any of these change, decryption fails: +- Moved `~/.outlook-cli/` to a different machine (different hostname) +- Changed your OS username or hostname +- Username case changed (e.g., `JSmith` vs `jsmith` — common on Windows after OS upgrades) +- Running in a container without setting `OUTLOOK_CLI_PASSPHRASE` +- Different user running the same binary on a shared machine + +### Fix + +1. **Set an explicit passphrase** (recommended for portability and containers): + + ```bash + # Set the environment variable permanently + # PowerShell (Windows): + [Environment]::SetEnvironmentVariable("OUTLOOK_CLI_PASSPHRASE", "your-secure-passphrase", "User") + + # bash (Linux/macOS) — add to ~/.bashrc or ~/.zshrc: + export OUTLOOK_CLI_PASSPHRASE="your-secure-passphrase" + ``` + +2. **Re-authenticate** if the passphrase has changed: + + ```bash + outlook-cli auth logout --account work + outlook-cli auth login --account work + ``` + +3. **Check username case** (Windows): + + ```powershell + # See what username the system reports + whoami + # Compare with what outlook-cli uses + outlook-cli auth status --verbose + ``` + +4. **In containers**, always set `OUTLOOK_CLI_PASSPHRASE` in the environment configuration: + + ```yaml + environment: + OUTLOOK_CLI_PASSPHRASE: "consistent-passphrase-across-restarts" + ``` + +--- + +## NativeAOT Binary Not Finding Configs + +### Symptom + +- The .NET binary acts like a fresh install (no accounts, no tokens) while Node.js works fine +- `"No accounts configured"` even though `~/.outlook-cli/accounts.json` exists +- Binary works from one directory but not another + +### Cause + +The NativeAOT binary resolves `~` (home directory) differently depending on: +- **Which user** runs the binary (each user has their own `~/.outlook-cli/`) +- **Environment variables** — `HOME` (Linux/macOS) or `USERPROFILE` (Windows) override the default +- **Elevated execution** — running as admin/root may change the home directory + +### Fix + +1. **Verify the home directory** the binary is using: + + ```bash + outlook-cli auth status --verbose + # Look for "Config directory:" in the verbose output + ``` + +2. **Check environment variables**: + + ```powershell + # Windows + echo $env:USERPROFILE + # Should be: C:\Users\yourname + + # Linux/macOS + echo $HOME + ``` + +3. **Don't run as admin/root** unless necessary — it changes the home directory and breaks config discovery. + +4. **Verify the config files exist** in the expected location: + + ```powershell + # Windows + Get-ChildItem "$env:USERPROFILE\.outlook-cli" + + # Linux/macOS + ls -la ~/.outlook-cli/ + ``` + +--- + +## Permission Errors (Mail.Send Not Granted) + +### Symptom + +- `"Insufficient privileges"` or `403 Forbidden` when trying to send email +- `"Mail.Send scope not found in token"` — the CLI rejects the operation +- Drafts work fine, but `mail send` fails + +### Cause + +Sending email requires explicit permission at multiple levels: + +1. **Azure App Registration** must include `Mail.Send` as a delegated permission +2. **User must consent** to the `Mail.Send` permission during login +3. **`accounts.json`** must allow `Mail.Send` for the account (it may be in the `forbidden` list by default) + +### Fix + +1. **Add `Mail.Send` to the Azure App Registration**: + - Go to [Azure Portal](https://portal.azure.com) → App Registrations → your app → API Permissions + - Add `Mail.Send` (Delegated) under Microsoft Graph + - If using a work account, an admin may need to grant consent + +2. **Update `accounts.json`** to allow sending for the specific account: + + ```json + { + "defaults": { + "permissions": { + "forbidden": ["Mail.Send"] + } + }, + "accounts": { + "work": { + "clientId": "...", + "permissions": { + "allowed": ["+Mail.Send"], + "send_to": ["team@contoso.com", "reports@contoso.com"] + } + } + } + } + ``` + + The `+Mail.Send` overrides the default `forbidden` entry. The `send_to` list restricts which addresses this account can send to (optional but recommended). + +3. **Re-authenticate** to pick up the new permission: + + ```bash + outlook-cli auth logout --account work + outlook-cli auth login --account work + ``` + +4. **Verify** the token has the scope: + + ```bash + outlook-cli auth status --account work + # Check "Scopes:" includes Mail.Send + ``` + +For **delegate sending** (`--as`), you need `Mail.Send.Shared` instead. See [DELEGATE-ACCESS.md](DELEGATE-ACCESS.md). + +--- + +## Delta Token Expired During Watch Mode + +### Symptom + +- Watch mode logs: `"Delta token expired, performing full re-sync"` +- A burst of old events appears as if they're new +- Watch mode pauses longer than usual, then resumes + +### Cause + +Microsoft Graph delta tokens expire after approximately **30 days of inactivity**. If watch mode hasn't run for that long, the stored delta token in `~/.outlook-cli/delta-.json` is no longer valid. Graph API returns a `410 Gone` response, forcing a full re-sync. + +During re-sync, the watcher fetches all current items to establish a new baseline. It should suppress duplicate events, but a brief burst of activity is normal. + +### Fix + +1. **Wait for re-sync to complete** — this is automatic. Watch mode detects the expired token, performs a full sync, and resumes normal delta polling. No action is required. + +2. **Force a clean re-sync** if events seem stuck or duplicated: + + ```bash + # Stop the watch process (Ctrl+C) + + # Delete delta tokens for the affected account + rm ~/.outlook-cli/delta-work.json + + # Restart watch + outlook-cli watch --account work + ``` + +3. **Prevent expiry** by keeping watch mode running. Even a very long interval (e.g., `--interval 3600` for hourly checks) keeps the delta token fresh. + +4. **In production**, run watch mode as a service that auto-restarts: + + ```powershell + # Windows — use Task Scheduler or a service wrapper + # Linux — use systemd or supervisord + # Docker — use restart: always + ``` + +--- + +## Getting More Help + +For any issue not covered here: + +1. **Run with `--verbose`** for detailed logging: + ```bash + outlook-cli mail inbox --verbose + ``` + +2. **Check auth status** to verify tokens and scopes: + ```bash + outlook-cli auth status --account work + ``` + +3. **Review related docs**: + - [self-hosting.md](self-hosting.md) — Setup and configuration + - [architecture.md](architecture.md) — How it all works + - [SECURITY.md](SECURITY.md) — Security model + - [MULTI-ACCOUNT.md](MULTI-ACCOUNT.md) — Multi-account details + - [DELEGATE-ACCESS.md](DELEGATE-ACCESS.md) — Delegate access setup + +4. **File an issue**: [github.com/jeffstall/outlook-cli/issues](https://github.com/jeffstall/outlook-cli/issues) diff --git a/docs/usage.md b/docs/usage.md new file mode 100644 index 0000000..4e5f9bf --- /dev/null +++ b/docs/usage.md @@ -0,0 +1,14 @@ +# Command-Line Reference + +> **This page has been replaced by per-command reference pages in [`docs/usage/`](usage/README.md).** + +For the full exhaustive reference, see: + +- **[Command Reference Index](usage/README.md)** — Global options, short IDs, output formats +- **[Mail Commands](usage/mail.md)** — inbox, read, search, draft, reply, forward, move, flag, mark-read, send +- **[Calendar Commands](usage/calendar.md)** — today, week, range, view, list-calendars, create +- **[Contacts Commands](usage/contacts.md)** — search, alias set/remove/list +- **[Auth Commands](usage/auth.md)** — login, logout, status +- **[Account Commands](usage/account.md)** — add, remove, list, set-default +- **[Watch Commands](usage/watch.md)** — poll, fast-poll, webhook modes +- **[Diagnostics Commands](usage/diagnostics.md)** — doctor, log search, log summary, upgrade, telemetry diff --git a/docs/usage/README.md b/docs/usage/README.md new file mode 100644 index 0000000..28c9f59 --- /dev/null +++ b/docs/usage/README.md @@ -0,0 +1,136 @@ +# Command-Line Reference + +Exhaustive reference for every outlook-cli command, option, and output format. Each command group has its own page with detailed examples. + +## Command Groups + +| Command | Page | Description | +|---|---|---| +| [`mail`](mail.md) | [mail.md](mail.md) | Read, search, compose, reply, forward, organize email | +| [`calendar`](calendar.md) | [calendar.md](calendar.md) | View and create calendar events | +| [`contacts`](contacts.md) | [contacts.md](contacts.md) | Search contacts and manage aliases | +| [`auth`](auth.md) | [auth.md](auth.md) | Login, logout, check authentication status | +| [`account`](account.md) | [account.md](account.md) | Add, remove, list, and configure accounts | +| [`watch`](watch.md) | [watch.md](watch.md) | Real-time notifications for mail and calendar changes | +| [`diagnostics`](diagnostics.md) | [diagnostics.md](diagnostics.md) | Doctor, operation logs, upgrade | +| [Errors](errors.md) | [errors.md](errors.md) | Error codes, Graph API errors, and troubleshooting | + +## Global Options + +These options work on **every** command: + +| Option | Description | Default | +|---|---|---| +| `-a, --account ` | Use a specific account instead of the default. The alias must match one configured via `account add`. | Default account | +| `--as ` | Access another user's mailbox as a delegate. Requires shared mailbox permissions in Microsoft 365. See [DELEGATE-ACCESS.md](../DELEGATE-ACCESS.md). | Own mailbox | +| `--format ` | Output format: `text`, `json`, `markdown`, or `html`. Text is a human-readable table; JSON is structured for scripts and agents; markdown and HTML are for embedding. | `text` | +| `--json` | Shorthand for `--format json`. Equivalent to `--format json` but faster to type. | — | +| `--output ` | Write output to a file instead of stdout. Works with any format. Creates the file if it doesn't exist; overwrites if it does. | stdout | +| `--verbose` | Print detailed internal information: token acquisition, HTTP requests, retry attempts. Useful for debugging but noisy for normal use. | off | +| `-V, --version` | Print the version number and exit. | — | +| `-h, --help` | Show help for the current command and exit. Works at any level: `outlook-cli --help`, `outlook-cli mail --help`, `outlook-cli mail draft --help`. | — | + +### `--input ` (Per-Command) + +Most commands (mail, calendar, contacts) accept `--input ` to load parameters from a JSON file. Use `-` for stdin. **CLI flags always override JSON values.** This is useful for scripting, templates, or complex messages: + +```bash +# Load draft parameters from a file +outlook-cli mail draft --input draft.json --yes + +# Pipe JSON from another command +echo '{"to":"bob@example.com","subject":"Hi","body":"Hello"}' | outlook-cli mail draft --input - --yes + +# CLI flags override JSON — this changes the subject even if draft.json has one +outlook-cli mail draft --input draft.json --subject "New Subject" --yes +``` + +See [JSON-INPUT.md](../JSON-INPUT.md) for the complete schema for each command. + +## Short IDs (Mail Messages Only) + +When you run `mail inbox`, `mail search`, or any command that lists messages, outlook-cli assigns each result a short numeric ID (1, 2, 3, ...) displayed in the left column. You can use these short IDs anywhere a message ID is accepted: + +``` +$ outlook-cli mail inbox +# From Subject Date +───────────────────────────────────────────────────────────────────────────────────── +1 alice@example.com Quarterly Report 09:30 AM +2 bob@example.com Meeting Notes Yesterday +3 carol@example.com Project Update Apr 12 + +$ outlook-cli mail read 1 # ✓ Reads "Quarterly Report" +$ outlook-cli mail reply 2 --body "Thanks!" # ✓ Replies to "Meeting Notes" +$ outlook-cli mail move 3 --folder Archive # ✓ Moves "Project Update" +``` + +### How It Works + +The mapping from short IDs to full Microsoft Graph message IDs (which look like `AQMkADAwATMw...`) is stored in `~/.outlook-cli/last-results.json`. This file is **overwritten entirely** every time a list command runs. Both the Node.js and C# implementations read and write the same file format. + +### The Clobber Problem + +Because `last-results.json` is a single global file, **any list command in any terminal window overwrites it**: + +```bash +$ outlook-cli mail inbox # IDs 1-7 map to inbox messages +$ outlook-cli mail read 1 # ✓ Reads first inbox message + +$ outlook-cli mail search "meeting" # IDs 1-3 now map to SEARCH results! +$ outlook-cli mail read 1 # ⚠ Reads first SEARCH result, not inbox #1 +``` + +This is by design — short IDs are for quick interactive use in one terminal, not durable references. + +### When to Use Full Graph IDs + +- **Scripts and automation**: Extract the full ID from `--json` output: `outlook-cli mail inbox --json | jq '.[0].id'` +- **Multi-window workflows**: If you switch between terminals, the short IDs may not point where you expect +- **CI/CD pipelines**: Never rely on `last-results.json` in automated environments + +### Scope + +Short IDs apply **only to mail messages**. Calendar events and contacts use their full IDs directly. + +## Output Formats + +All commands support four output formats via `--format` or `--json`: + +| Format | Flag | Best For | Example | +|---|---|---|---| +| Text | `--format text` (default) | Terminal reading, human use | Formatted tables with aligned columns | +| JSON | `--format json` or `--json` | Scripts, agents, `jq` piping | Structured objects with all fields | +| Markdown | `--format markdown` | Documentation, agent rendering | Markdown tables | +| HTML | `--format html` | Embedding in web pages, saving | Full HTML with styles | + +```bash +outlook-cli mail inbox # text (default) +outlook-cli mail inbox --json # JSON +outlook-cli mail inbox --format markdown # markdown +outlook-cli mail inbox --format html --output inbox.html # HTML to file +``` + +JSON output includes all fields from the Microsoft Graph API response. Text output shows a curated subset optimized for readability. When building integrations, always use `--json`. + +## Delegate Access + +Use `--as ` on any command to access another user's mailbox. This requires shared mailbox or delegate permissions configured in Microsoft 365: + +```bash +outlook-cli mail inbox --as shared@example.com +outlook-cli calendar today --as colleague@example.com +outlook-cli mail draft --to "bob@example.com" --subject "Hi" --body "Hello" --as shared@example.com +``` + +If you don't have permission, you'll get a 403 error with a clear message. See [DELEGATE-ACCESS.md](../DELEGATE-ACCESS.md) for setup instructions. + +## Related Documentation + +- [self-hosting.md](../self-hosting.md) — Complete setup guide from zero +- [AZURE-SETUP.md](../AZURE-SETUP.md) — Azure app registration +- [SECURITY.md](../SECURITY.md) — Security model and permissions +- [MULTI-ACCOUNT.md](../MULTI-ACCOUNT.md) — Multi-account configuration +- [DELEGATE-ACCESS.md](../DELEGATE-ACCESS.md) — Delegate access setup +- [WATCH-MODE.md](../WATCH-MODE.md) — Watch mode deep dive (rules engine, delta tokens, webhooks) +- [JSON-INPUT.md](../JSON-INPUT.md) — JSON input file schemas +- [DIAGNOSTICS.md](../DIAGNOSTICS.md) — Deep dive on logs, telemetry, SQLite diff --git a/docs/usage/account.md b/docs/usage/account.md new file mode 100644 index 0000000..4e3b8a5 --- /dev/null +++ b/docs/usage/account.md @@ -0,0 +1,222 @@ +# Account Commands + +Manage multiple Microsoft accounts. Each account has an alias (short name), a client ID, and optionally a tenant ID. You can switch between accounts using `--account ` on any command. + +Every account command accepts [global options](README.md#global-options) (`--format`, `--json`, `--output`, `--verbose`). + +--- + +## `account add ` + +Register a new account with an alias. This does **not** authenticate — it only stores the configuration. Run `auth login --account ` afterwards to authenticate. + +```bash +# Personal Microsoft account +outlook-cli account add personal --client-id YOUR_CLIENT_ID + +# Work/school account with specific tenant +outlook-cli account add work --client-id YOUR_CLIENT_ID --tenant contoso.onmicrosoft.com + +# Read-only account (cannot send, draft, move, delete, flag, or modify) +outlook-cli account add monitor --client-id YOUR_CLIENT_ID --read-only + +# Custom scopes (advanced — fine-grained permission control) +outlook-cli account add agent --client-id YOUR_CLIENT_ID --scopes "User.Read,Mail.Read,Mail.Send,offline_access" +``` + +| Option | Description | Default | +|---|---|---| +| `--client-id ` | Azure app registration client ID. One app registration can serve multiple accounts — each user authenticates independently. | Required | +| `--tenant ` | Azure AD / Entra ID tenant. Use `common` for personal accounts. For work/school, use the tenant domain or GUID. | `common` | +| `--read-only` | Create the account in read-only mode. At login, only read scopes are requested (`Mail.Read`, `Calendars.Read`, `Contacts.Read`). All write commands are blocked before they reach the Graph API. | `false` | +| `--scopes ` | Comma-separated list of custom MSAL scopes to request at login. Overrides the default scope set. For advanced use — most users should use `--read-only` instead. | Full default scopes | + +**Arguments:** +- `` — A short name for the account (e.g., `personal`, `work`, `monitor`). Used with `--account` on all commands. Must be unique. + +**After adding:** Run `outlook-cli auth login --account ` to authenticate. The login will request only the scopes configured for that account. + +### Read-Only Accounts + +Read-only accounts are enforced at two layers: + +1. **MSAL scopes** (security boundary): At login, only read-only scopes are requested. The resulting token cannot perform write operations — Microsoft Graph returns HTTP 403 if attempted. +2. **CLI write guard** (UX): Before any write command (`mail send`, `mail draft`, `mail reply`, `mail forward`, `mail move`, `mail flag`, `mail mark-read`, `mail delete`, `calendar create`, `calendar delete`), the CLI checks the account mode and rejects immediately with a clear error. + +```bash +outlook-cli account add monitor --client-id abc123 --read-only +outlook-cli auth login --account monitor # Requests only read scopes +outlook-cli mail inbox --account monitor # ✓ Works fine +outlook-cli mail send msg-id --account monitor # ✗ Blocked immediately +# ✗ Account "monitor" is read-only. Cannot send draft. +``` + +### One App Registration, Multiple Accounts + +You can use the same Azure app registration (client ID) for multiple accounts. Each user authenticates with their own Microsoft credentials and gets separate tokens: + +```bash +outlook-cli account add personal --client-id abc123 +outlook-cli account add work --client-id abc123 --tenant contoso.onmicrosoft.com +outlook-cli auth login --account personal # Signs in as john@outlook.com +outlook-cli auth login --account work # Signs in as john@contoso.com +``` + +For stronger isolation, use **different app registrations** (different `--client-id` values). Each app registration can be configured in Azure Portal with different API permissions. + +--- + +## `account remove ` + +Remove an account and delete its cached tokens. This deletes the encrypted cache file (`~/.outlook-cli/cache-{alias}.enc`). + +```bash +outlook-cli account remove work +``` + +**Arguments:** +- `` — The account alias to remove. Must match an existing account. + +The account is removed from `~/.outlook-cli/accounts.json` and its token cache is deleted. If this was the default account, no default is set — you'll need to set a new one with `account set-default`. + +--- + +## `account list` + +List all configured accounts with their email addresses, default status, and mode. + +```bash +outlook-cli account list +outlook-cli account list --json +``` + +**Example output (text):** +``` + personal (default) john@outlook.com + work john@contoso.com + monitor [read-only] monitor@outlook.com + agent (not authenticated) +``` + +The `(default)` marker indicates which account is used when you don't pass `--account`. The `[read-only]` tag shows accounts that are restricted from write operations. Accounts that haven't been authenticated yet show `(not authenticated)` instead of an email address. + +**Example output (JSON):** +```json +{ + "accounts": [ + { + "alias": "personal", + "email": "john@outlook.com", + "clientId": "abc123...", + "tenantId": "common", + "mode": "full" + }, + { + "alias": "monitor", + "email": "monitor@outlook.com", + "clientId": "abc123...", + "tenantId": "common", + "mode": "read-only" + } + ], + "defaultAccount": "personal" +} +``` + +--- + +## `account set ` + +Modify settings on an existing account. Changes to mode, scopes, client ID, or tenant require re-authentication (`auth login`) to take effect. + +```bash +# Make an existing account read-only +outlook-cli account set work --read-only + +# Restore full access +outlook-cli account set work --mode full + +# Set custom scopes +outlook-cli account set agent --scopes "Mail.Read,Mail.Send,offline_access" + +# Change client ID (different app registration) +outlook-cli account set work --client-id NEW_CLIENT_ID +``` + +| Option | Description | +|---|---| +| `--read-only` | Switch to read-only mode (shorthand for `--mode read-only`) | +| `--mode ` | Set mode: `full` (all operations) or `read-only` (read operations only) | +| `--scopes ` | Comma-separated custom MSAL scopes | +| `--client-id ` | Change the Azure app client ID | +| `--tenant ` | Change the Azure AD tenant ID | + +**After changing:** Run `outlook-cli auth login --account ` to re-authenticate with the new settings. The old token continues to work until it expires, but it was issued with the previous scopes. + +--- + +## `account set-default ` + +Set which account is used by default when `--account` is not specified. + +```bash +outlook-cli account set-default work +``` + +**Arguments:** +- `` — The account alias to make default. Must match an existing account. + +After this, all commands without `--account` will use the `work` account: +```bash +outlook-cli mail inbox # Uses 'work' account +outlook-cli mail inbox --account personal # Overrides to 'personal' +``` + +--- + +## Account Configuration File + +Account configuration is stored in `~/.outlook-cli/accounts.json`. You can edit this file directly for advanced configuration (permissions, parent chains, send_to whitelists), but for basic account management, use the CLI commands. + +```json +{ + "accounts": { + "personal": { + "alias": "personal", + "clientId": "abc123...", + "tenantId": "common", + "email": "john@outlook.com", + "mode": "full" + }, + "monitor": { + "alias": "monitor", + "clientId": "abc123...", + "tenantId": "common", + "email": "monitor@outlook.com", + "mode": "read-only" + }, + "agent": { + "alias": "agent", + "clientId": "def456...", + "tenantId": "contoso.onmicrosoft.com", + "email": "agent@contoso.com", + "mode": "full", + "scopes": ["User.Read", "Mail.Read", "Mail.Send", "offline_access"] + } + }, + "defaultAccount": "personal" +} +``` + +**Fields:** + +| Field | Required | Description | +|---|---|---| +| `alias` | Yes | Account alias (matches the key in the `accounts` object) | +| `clientId` | Yes | Azure app registration client ID | +| `tenantId` | Yes | Azure AD tenant (`common` for personal) | +| `email` | No | Set automatically after `auth login` | +| `mode` | No | `full` (default) or `read-only` | +| `scopes` | No | Custom MSAL scope list (overrides defaults at login) | + +For advanced features like permission restrictions, parent chains, and send_to whitelists, see [MULTI-ACCOUNT.md](../MULTI-ACCOUNT.md) and [SECURITY.md](../SECURITY.md). diff --git a/docs/usage/auth.md b/docs/usage/auth.md new file mode 100644 index 0000000..cb1b135 --- /dev/null +++ b/docs/usage/auth.md @@ -0,0 +1,133 @@ +# Authentication Commands + +Login, logout, and check authentication status. These commands manage OAuth2 tokens for Microsoft Graph API access. + +Every auth command accepts [global options](README.md#global-options) (`--account`, `--format`, `--json`, `--output`, `--verbose`). + +--- + +## `auth login` + +Authenticate with Microsoft and cache tokens locally. Opens a browser for the OAuth2 PKCE flow (default) or displays a device code for headless environments. + +```bash +# First-time login (requires client ID) +outlook-cli auth login --client-id YOUR_CLIENT_ID + +# Login to a specific account (uses stored client ID) +outlook-cli auth login --account work + +# Device code flow (for SSH sessions, containers, servers) +outlook-cli auth login --device-code + +# Work/school account with specific tenant +outlook-cli auth login --client-id YOUR_CLIENT_ID --tenant contoso.onmicrosoft.com +``` + +| Option | Description | Default | +|---|---|---| +| `--client-id ` | Azure app registration client ID. Required on first login for an account; stored afterwards. Can also be set via `OUTLOOK_CLI_CLIENT_ID` environment variable. | Stored client ID or env var | +| `--device-code` | Use device code flow instead of browser. Displays a code and URL — you authenticate on any device with a browser. Essential for headless servers, SSH sessions, and containers. | Interactive browser flow | +| `--tenant ` | Azure AD / Entra ID tenant. Use `common` for personal Microsoft accounts (default). For work/school accounts, use the tenant domain (`contoso.onmicrosoft.com`) or tenant ID (GUID). | `common` | + +### How Login Works + +**Browser flow (default):** +1. The CLI starts a temporary local HTTP server on a random port +2. Opens your browser to Microsoft's login page +3. You sign in and grant permissions +4. Microsoft redirects back to the local server with an authorization code +5. The CLI exchanges the code for tokens and caches them encrypted + +**Device code flow (`--device-code`):** +1. The CLI requests a device code from Microsoft +2. Displays a URL (`https://microsoft.com/devicelogin`) and a code +3. You visit the URL on any device, enter the code, and sign in +4. The CLI polls until authentication completes +5. Tokens are cached encrypted + +### Token Storage + +After login, tokens are encrypted and stored in `~/.outlook-cli/cache-{alias}.enc` using AES-256-GCM with PBKDF2-SHA512 key derivation (310,000 iterations). The encryption is interoperable — tokens encrypted by Node.js decrypt in C# and vice versa. + +Tokens include: +- **Access token** — valid for 1 hour, used for Graph API calls +- **Refresh token** — valid for 90 days (personal accounts), used to get new access tokens silently + +### What Happens When Tokens Expire + +- **Access token (1 hour):** Automatically refreshed using the refresh token. You won't notice this. +- **Refresh token (90 days):** You'll need to `auth login` again. The CLI shows a clear message: `No valid token for account "X". Fix: Run outlook-cli auth login --account X`. +- **Revoked permissions:** If you remove the app from your Microsoft account, all tokens become invalid immediately. + +--- + +## `auth logout` + +Clear cached tokens for the current or specified account. Deletes the encrypted cache file (`~/.outlook-cli/cache-{alias}.enc`). + +```bash +outlook-cli auth logout # Logout from default account +outlook-cli auth logout --account work # Logout from specific account +``` + +After logout, any command that requires authentication will show: +``` +✗ No valid token for account "work". + Fix: Run `outlook-cli auth login --account work` to authenticate. +``` + +**Note:** Logout only removes local tokens. It does not revoke the app's access on Microsoft's side. To fully revoke access, go to [Microsoft Account > Apps](https://account.live.com/consent/Manage) and remove the app. + +--- + +## `auth status` + +Show authentication status and token information for the current or specified account. + +```bash +outlook-cli auth status +outlook-cli auth status --account work +outlook-cli auth status --json +``` + +**Example output (text):** +``` +Account: personal +Email: john@outlook.com +Authenticated: ✓ yes +Tenant: common +``` + +**Example output (JSON):** +```bash +outlook-cli auth status --json +``` +```json +{ + "account": "personal", + "authenticated": true, + "email": "john@outlook.com", + "tenant": "common" +} +``` + +**When not authenticated:** +``` +Account: work +Authenticated: ✗ no +Fix: Run `outlook-cli auth login --account work` to authenticate. +``` + +--- + +## Troubleshooting + +| Symptom | Cause | Fix | +|---|---|---| +| Login opens browser but CLI hangs | Browser didn't redirect properly | Close the browser tab and try again. Check that no firewall is blocking localhost. | +| "AADSTS7000218: request body must contain client_assertion" | Wrong client ID or app type | Ensure your Azure app is registered as a **Public client** (not confidential). | +| "AADSTS50011: reply url does not match" | Redirect URI not configured | Add `http://localhost` as a Mobile/Desktop redirect URI in Azure portal. | +| Login works but commands fail | Insufficient permissions | Check API permissions in Azure portal. Required: `User.Read`, `Mail.Read`, `Mail.ReadWrite`, `Calendars.Read`. | +| "No valid token" after working previously | Refresh token expired (90 days) | Run `auth login` again. | +| Device code flow times out | Code expired (15 minutes) | Run `auth login --device-code` again and enter the new code promptly. | diff --git a/docs/usage/calendar.md b/docs/usage/calendar.md new file mode 100644 index 0000000..b21c758 --- /dev/null +++ b/docs/usage/calendar.md @@ -0,0 +1,242 @@ +# Calendar Commands + +View and create calendar events via Microsoft Graph's calendarView API. + +Every calendar command accepts [global options](README.md#global-options) (`--account`, `--as`, `--format`, `--json`, `--output`, `--verbose`). + +**Note:** Calendar commands do not use [short IDs](README.md#short-ids-mail-messages-only). Event IDs must be the full Microsoft Graph ID (visible in `--json` output or in the `ID` line of text output). + +--- + +## `calendar today` + +Show today's events in chronological order. Uses the calendarView API, which expands recurring events into individual occurrences. + +```bash +outlook-cli calendar today +outlook-cli calendar today --timezone "America/New_York" +outlook-cli calendar today --json +``` + +| Option | Description | Default | +|---|---|---| +| `--timezone ` | IANA timezone name for interpreting "today". Affects which calendar day is shown. | System timezone | +| `--input ` | Load options from a JSON file. | — | + +**Example output (text):** +``` +09:00 - 09:30 Team Standup Conference Room A +10:00 - 11:00 Design Review Teams Meeting +14:00 - 15:00 1:1 with Manager Manager's Office +``` + +If there are no events, prints: `No events for "Today".` + +**Example output (JSON):** +```bash +outlook-cli calendar today --json +``` +```json +[ + { + "id": "AAMkADAwATMw...", + "subject": "Team Standup", + "start": { "dateTime": "2026-01-15T09:00:00.0000000", "timeZone": "Pacific Standard Time" }, + "end": { "dateTime": "2026-01-15T09:30:00.0000000", "timeZone": "Pacific Standard Time" }, + "location": { "displayName": "Conference Room A" }, + "isAllDay": false, + "organizer": { "emailAddress": { "name": "Alice", "address": "alice@example.com" } } + } +] +``` + +--- + +## `calendar week` + +Show this week's events (Monday through Sunday of the current week). + +```bash +outlook-cli calendar week +outlook-cli calendar week --timezone "Europe/London" +outlook-cli calendar week --json +``` + +| Option | Description | Default | +|---|---|---| +| `--timezone ` | IANA timezone name. Affects week boundaries and event times. | System timezone | +| `--input ` | Load options from a JSON file. | — | + +Output format is the same as `calendar today`, grouped by day: + +``` +── Monday, Jan 13 ────────────────── +09:00 - 09:30 Team Standup Conference Room A + +── Tuesday, Jan 14 ───────────────── +10:00 - 11:00 Design Review Teams Meeting +14:00 - 15:00 1:1 with Manager Manager's Office + +── Wednesday, Jan 15 ─────────────── +(no events) +``` + +--- + +## `calendar range` + +Show events within a specific date range. Both `--start` and `--end` are required. + +```bash +outlook-cli calendar range --start 2026-01-15 --end 2026-01-22 +outlook-cli calendar range --start 2026-01-01 --end 2026-03-31 --json +outlook-cli calendar range --start 2026-01-15T09:00:00 --end 2026-01-15T17:00:00 +``` + +| Option | Description | Default | +|---|---|---| +| `--start ` | Start date or datetime in ISO 8601 format. Examples: `2026-01-15`, `2026-01-15T09:00:00`. | Required | +| `--end ` | End date or datetime in ISO 8601 format. | Required | +| `--timezone ` | IANA timezone name. | System timezone | +| `--input ` | Load options from a JSON file. | — | + +The range is inclusive of events that overlap with the specified window. A date without a time component is treated as midnight (start of day). + +--- + +## `calendar view ` + +View full details of a specific calendar event, including attendees, body, and recurrence info. + +```bash +outlook-cli calendar view AAMkADAwATMw... +outlook-cli calendar view AAMkADAwATMw... --json +``` + +| Option | Description | Default | +|---|---|---| +| `--input ` | Load options from a JSON file. | — | + +**Example output (text):** +``` +──────────────────────────────────────────────────────────── +Subject: Design Review +Start: Wed, Jan 15, 2026, 10:00 AM +End: Wed, Jan 15, 2026, 11:00 AM +Location: Teams Meeting +Organizer: Alice Johnson +Attendees: bob@example.com (accepted), carol@example.com (tentative) +ID: AAMkADAwATMw... +──────────────────────────────────────────────────────────── +Please review the attached mockups before the meeting. +``` + +**How to get the event ID:** Use `calendar today --json` or `calendar week --json` and extract the `id` field: +```bash +outlook-cli calendar today --json | jq -r '.[0].id' +``` + +--- + +## `calendar list-calendars` + +List all calendars available to the account. This includes the default calendar, shared calendars, and any calendars the user has subscribed to. + +```bash +outlook-cli calendar list-calendars +outlook-cli calendar list-calendars --json +``` + +| Option | Description | Default | +|---|---|---| +| `--input ` | Load options from a JSON file. | — | + +**Example output (text):** +``` +Calendar Owner ID +──────────────────────────────────────────────────────────────────────────── +Calendar you@outlook.com AAMkADAwATMw... +Holidays you@outlook.com AAMkADAwATMw... +Shared Team Calendar team@contoso.com AAMkADAwATMw... +``` + +--- + +## `calendar create` + +Create a new calendar event. The event is created on the default calendar. + +```bash +# Simple event +outlook-cli calendar create \ + --subject "Team Lunch" \ + --start "2026-01-20T12:00:00" \ + --end "2026-01-20T13:00:00" \ + --location "Cafeteria" \ + --yes + +# Event with attendees +outlook-cli calendar create \ + --subject "Design Review" \ + --start "2026-01-20T14:00:00" \ + --end "2026-01-20T15:00:00" \ + --attendees "alice@example.com,bob@example.com" \ + --body "Please review the attached designs." \ + --yes + +# HTML body from file +outlook-cli calendar create \ + --subject "Sprint Planning" \ + --start "2026-01-20T10:00:00" \ + --end "2026-01-20T11:30:00" \ + --body-file agenda.html \ + --yes + +# From JSON template +outlook-cli calendar create --input event-template.json --yes --json +``` + +| Option | Description | Default | +|---|---|---| +| `--subject ` | Event subject / title. | Required | +| `--start ` | Start date/time in ISO 8601 format. | Required | +| `--end ` | End date/time in ISO 8601 format. | Required | +| `--timezone ` | IANA timezone name for interpreting `--start` and `--end`. | System timezone | +| `--body ` | Event description / body text, inline. | — | +| `--body-file ` | Read body from a file. Takes priority over `--body`. HTML auto-detected from `.html`/`.htm` extensions. | — | +| `--body-content-type ` | Body content type: `Text` or `HTML`. | `Text` | +| `--location ` | Event location. | — | +| `--attendees ` | Attendee email addresses, comma-separated. Each receives a meeting invitation. | — | +| `--yes` | Skip confirmation prompt. | Prompt | +| `--input ` | Load all parameters from a JSON file. CLI flags override JSON values. | — | + +**Output:** On success, prints the created event's ID and subject. + +--- + +## Common Workflows + +### Check today and view details +```bash +outlook-cli calendar today # See today's events +# Note the event ID from the output, or use JSON: +EVENT_ID=$(outlook-cli calendar today --json | jq -r '.[0].id') +outlook-cli calendar view "$EVENT_ID" # View full details +``` + +### Create an event from a template +```json +// event-template.json +{ + "subject": "Weekly Sync", + "start": "2026-01-20T10:00:00", + "end": "2026-01-20T10:30:00", + "timezone": "America/New_York", + "attendees": "alice@example.com,bob@example.com", + "location": "Teams Meeting", + "body": "Standing weekly sync meeting" +} +``` +```bash +outlook-cli calendar create --input event-template.json --yes +``` diff --git a/docs/usage/contacts.md b/docs/usage/contacts.md new file mode 100644 index 0000000..413fb9e --- /dev/null +++ b/docs/usage/contacts.md @@ -0,0 +1,111 @@ +# Contacts Commands + +Search for people and manage contact aliases. + +Every contacts command accepts [global options](README.md#global-options) (`--account`, `--as`, `--format`, `--json`, `--output`, `--verbose`). + +--- + +## `contacts search ` + +Search contacts, the Global Address List (GAL), and people you've interacted with via Microsoft Graph's People API. Results are ranked by relevance — people you email frequently appear first. + +```bash +outlook-cli contacts search "Alice" +outlook-cli contacts search "alice@example.com" +outlook-cli contacts search "engineering" --top 20 +outlook-cli contacts search "Alice" --json +``` + +| Option | Description | Default | +|---|---|---| +| `--top ` | Maximum number of results. The People API returns at most 1000 results. | 10 | +| `--input ` | Load options from a JSON file. | — | + +**Example output (text):** +``` +Name Email Company +──────────────────────────────────────────────────────────────────────────── +Alice Johnson alice@example.com Contoso Ltd +Alice Williams alice.w@example.com Fabrikam Inc +``` + +**Example output (JSON):** +```json +[ + { + "displayName": "Alice Johnson", + "emailAddresses": [ + { "address": "alice@example.com", "rank": 1 } + ], + "companyName": "Contoso Ltd", + "department": "Engineering" + } +] +``` + +**Search scope:** Personal Microsoft accounts search contacts only. Work/school (Entra ID) accounts also search the organization's Global Address List (GAL), which includes everyone in the directory. + +--- + +## Contact Aliases + +Aliases let you create short nicknames for email addresses you use frequently. Once set, you can use the alias anywhere an email address is accepted: + +```bash +# Set up an alias +outlook-cli contacts alias set boss jane.smith@contoso.com + +# Now use it in any command +outlook-cli mail draft --to boss --subject "Update" --body "Done!" --yes +outlook-cli mail search "from:boss" +``` + +Aliases are stored in `~/.outlook-cli/aliases.json`. Both Node.js and C# implementations read the same file. + +### `contacts alias set ` + +Create or update a contact alias. If the alias already exists, it's overwritten with the new email. + +```bash +outlook-cli contacts alias set fred freddie@outlook.com +outlook-cli contacts alias set boss jane.smith@contoso.com +outlook-cli contacts alias set team-lead alice@example.com +``` + +**Arguments:** +- `` — The alias name (short, no spaces). Used in `--to`, `--cc`, etc. +- `` — The full email address the alias maps to. + +### `contacts alias remove ` + +Remove a contact alias. + +```bash +outlook-cli contacts alias remove fred +``` + +### `contacts alias list` + +List all configured aliases. + +```bash +outlook-cli contacts alias list +outlook-cli contacts alias list --json +``` + +**Example output (text):** +``` +fred freddie@outlook.com +boss jane.smith@contoso.com +wilma wilma@outlook.com +``` + +**Example output (JSON):** +```json +{ + "fred": "freddie@outlook.com", + "boss": "jane.smith@contoso.com", + "wilma": "wilma@outlook.com" +} +``` diff --git a/docs/usage/diagnostics.md b/docs/usage/diagnostics.md new file mode 100644 index 0000000..5aa3c8a --- /dev/null +++ b/docs/usage/diagnostics.md @@ -0,0 +1,232 @@ +# Diagnostics Commands + +Commands for checking system health, reviewing operation history, and managing updates. + +Every diagnostics command accepts [global options](README.md#global-options) (`--account`, `--format`, `--json`, `--output`, `--verbose`). + +For deep technical details on the SQLite database, telemetry ring buffer, and advanced queries, see [DIAGNOSTICS.md](../DIAGNOSTICS.md). + +--- + +## `doctor` + +Run a health check that verifies your outlook-cli installation, configuration, accounts, token caches, and (optionally) connectivity to Microsoft Graph. + +```bash +outlook-cli doctor +outlook-cli doctor --account work +outlook-cli doctor --offline +``` + +| Option | Description | Default | +|---|---|---| +| `--offline` | Skip network checks (token refresh, Graph API connectivity). Useful when you know you're offline and just want to verify local configuration. | Run all checks | + +**Example output (Node.js):** +``` + outlook-cli health check + ──────────────────────────────────────────── + ✅ Node.js version: v24.14.1 + ✅ Config directory: C:\Users\you\.outlook-cli + ✅ Accounts configured: 2 account(s): personal, work + ✅ [personal] Client ID: e5601814... + ✅ [personal] Token cache: Cache file exists + ✅ [personal] Authentication: Token acquired (silent) + ✅ [personal] Scopes: User.Read, Mail.Read, Mail.ReadWrite, Mail.Send + ✅ [personal] Graph API: Connected as John Smith + ✅ Version: 2.0.0 (node) + + 9 passed, 0 warnings, 0 failed +``` + +**Example output (C# NativeAOT):** +``` + outlook-cli doctor (C# / NativeAOT) + ───────────────────────────────────── + Runtime: dotnet (NativeAOT) + CLR version: 8.0.25 + OS: Microsoft Windows NT 10.0.26602.0 + Config dir: C:\Users\you\.outlook-cli + Config exists: ✓ yes + Database: ✓ exists + Accounts: 2 configured + Default account: personal + Token caches: 2 encrypted cache file(s) + + For detailed diagnostics, see: docs/DIAGNOSTICS.md +``` + +### What Doctor Checks + +| Check | What It Verifies | Common Failure | +|---|---|---| +| Runtime version | Node.js or .NET CLR version | Unsupported runtime version | +| Config directory | `~/.outlook-cli/` exists and is readable | Directory missing (first run) | +| Accounts | At least one account configured | Run `account add` first | +| Client ID | Each account has a valid client ID | Missing or invalid app registration | +| Token cache | Encrypted cache file exists | Run `auth login` to authenticate | +| Authentication | Token can be acquired silently (refresh works) | Token expired; re-login needed | +| Scopes | Token has required permissions | App missing API permissions | +| Graph API | Can reach `graph.microsoft.com` and query `/me` | Network issues or token problems | +| Version | Current version matches expectations | Stale binary after update | + +**When to use doctor:** +- After initial setup to verify everything is configured correctly +- When commands fail with authentication errors +- When you suspect network or configuration issues +- When switching between Node.js and C# runtimes + +--- + +## `log search` + +Search the operations log stored in SQLite (`~/.outlook-cli/outlook-cli.db`). Every CLI operation (inbox, read, search, send, etc.) is logged with timing, status, and error information. + +```bash +# Show the last 50 operations (default) +outlook-cli log search + +# Filter by account +outlook-cli log search --account work + +# Filter by command name +outlook-cli log search --operation "mail inbox" + +# Only show failures +outlook-cli log search --status failed + +# Operations since a specific date +outlook-cli log search --since 2026-01-10 + +# Find all operations in a correlation chain +outlook-cli log search --correlation-id abc123 + +# Limit results +outlook-cli log search --limit 10 +``` + +| Option | Description | Default | +|---|---|---| +| `-a, --account ` | Filter by account alias. | All accounts | +| `-o, --operation ` | Filter by command/operation name (e.g., `mail inbox`, `auth login`). | All operations | +| `-s, --since ` | Only show operations after this date. ISO 8601 format (e.g., `2026-01-10`). | No date filter | +| `--status ` | Filter by status: `started`, `completed`, or `failed`. | All statuses | +| `--correlation-id ` | Find all operations sharing a correlation ID. Useful for tracing a chain of related operations (e.g., a token refresh triggered by a 401 during inbox). | No filter | +| `-n, --limit ` | Maximum number of results. | 50 | + +**Example output:** +``` +ID Operation Account Status Duration Date +──────────────────────────────────────────────────────────────────────── +1042 mail inbox personal completed 420ms 2026-01-15 09:30 +1041 mail read personal completed 380ms 2026-01-15 09:29 +1040 mail inbox work failed 1200ms 2026-01-15 09:28 +1039 auth login personal completed 3500ms 2026-01-15 09:00 +``` + +### What Gets Logged + +Every Graph API operation logs: +- **Operation name** — the command that was run +- **Account** — which account was used +- **Status** — started, completed, or failed +- **Duration** — how long the operation took +- **Correlation ID** — groups related operations (e.g., a retry chain) +- **Error details** — for failed operations, the error message and code + +--- + +## `log summary` + +Show aggregate statistics about CLI usage over a time period. + +```bash +# Default: last 7 days +outlook-cli log summary + +# Last 30 days +outlook-cli log summary --days 30 + +# JSON output for scripting +outlook-cli log summary --json +``` + +| Option | Description | Default | +|---|---|---| +| `-d, --days ` | Number of days to summarize. | 7 | +| `--json` | Output as JSON instead of text. | Text | + +**Example output (text):** +``` + outlook-cli operations summary + Period: last 7 day(s) (since 2026-01-08) + ──────────────────────────────────────────── + Total operations: 142 + Errors: 3 (2.1%) + Avg latency: 380ms + Throttle events: 0 + + By day: + Jan 15: 28 ops + Jan 14: 35 ops + Jan 13: 22 ops + ... + + Top commands: + mail inbox: 45 + mail read: 38 + mail search: 22 + calendar today: 15 + + By account: + personal: 89 ops (1 error) + work: 53 ops (2 errors) +``` + +--- + +## `upgrade` + +Check for updates and show instructions to upgrade outlook-cli. + +```bash +outlook-cli upgrade +``` + +**Node.js output:** +``` +Current version: 2.0.0 (node) +To upgrade: git pull && npm install +``` + +**C# output:** +``` +Current version: 2.0.0 (dotnet/NativeAOT) +To upgrade: git pull && dotnet publish src\dotnet\OutlookCli.csproj -r win-x64 --self-contained /p:PublishAot=true -o publish\win-x64 +``` + +The upgrade command does not auto-update — it shows the manual steps to rebuild from source. This is intentional: outlook-cli is source-distributed, not package-distributed, so upgrades involve pulling the latest code and rebuilding. + +--- + +## Telemetry + +outlook-cli has an optional telemetry system (disabled by default) that collects performance and usage data locally. No data is ever sent to external services. + +### Enabling Telemetry + +```bash +# Enable for a single command +OUTLOOK_CLI_TELEMETRY=1 outlook-cli mail inbox + +# Enable permanently (add to your shell profile) +export OUTLOOK_CLI_TELEMETRY=1 +``` + +### What Telemetry Collects + +- **In-memory ring buffer** (1000 events): API call timings, error rates, retry counts. Lost when the process exits. +- **SQLite persistence** (when enabled): Writes telemetry events to `~/.outlook-cli/outlook-cli.db` in a `telemetry` table. +- **Process snapshots**: Memory usage, event loop lag, active handles. + +Telemetry is useful for diagnosing performance issues, identifying rate-limiting patterns, and understanding usage across accounts. See [DIAGNOSTICS.md](../DIAGNOSTICS.md) for advanced telemetry queries. diff --git a/docs/usage/errors.md b/docs/usage/errors.md new file mode 100644 index 0000000..d3041d3 --- /dev/null +++ b/docs/usage/errors.md @@ -0,0 +1,228 @@ +# Error Codes and Troubleshooting + +outlook-cli produces structured error messages with clear remediation steps. This reference documents every error category, its cause, and how to fix it. + +--- + +## Exit Codes + +| Code | Meaning | +|------|---------| +| 0 | Success | +| 1 | General error (authentication, network, invalid arguments) | + +--- + +## Authentication Errors + +### `No valid token for account ""` + +**Cause:** The token cache for this account is empty or the cached tokens have expired and could not be refreshed. This happens when: +- You haven't logged in yet +- The refresh token has expired (typically after 90 days of inactivity) +- The cache file was deleted or encrypted with a different passphrase (e.g., moved to a different machine) + +**Fix:** +```bash +outlook-cli auth login --account +``` + +### `Could not decrypt token cache for ""` + +**Cause:** The encrypted cache file (`~/.outlook-cli/cache-.enc`) exists but cannot be decrypted. Common causes: +- Running on a different machine (machine-derived passphrase uses `username@hostname`) +- Changed the `OUTLOOK_CLI_PASSPHRASE` environment variable +- Corrupt cache file + +**Behavior:** outlook-cli logs a warning and starts with an empty cache. You need to re-authenticate. + +**Fix:** +```bash +outlook-cli auth login --account +``` + +**Prevention:** Set `OUTLOOK_CLI_PASSPHRASE` explicitly in shared or CI environments so the passphrase doesn't depend on machine identity. See [self-hosting.md](../self-hosting.md) for details. + +### `Account "" not found` + +**Cause:** The specified `--account` alias doesn't exist in `~/.outlook-cli/accounts.json`. + +**Fix:** +```bash +outlook-cli account list # See available accounts +outlook-cli auth login --account # Create a new one +``` + +### `Re-authentication required` / `New permissions required` + +**Cause:** A CLI update added new API permissions (scopes). Your cached token was granted with fewer scopes than the current version needs. MSAL cannot silently upgrade tokens — a fresh interactive login is required to consent to the new permissions. + +**This is expected after upgrading** outlook-cli to a version that adds new features (e.g., OneDrive support added `Files.Read` and `Files.ReadWrite` scopes). It is a one-time requirement per account. + +**Fix:** +```bash +outlook-cli auth login --account +``` + +**If using Azure App Registration in enterprise:** Your Azure admin may also need to grant admin consent for the new permissions in the App Registration portal before users can consent. + +**Technical detail:** MSAL's `acquireTokenSilent` compares requested scopes against the cached token's scopes. If the requested set is broader, it throws `InteractionRequiredAuthError` (Node.js) or `MsalUiRequiredException` (C#). There is no automatic incremental consent flow for silent token acquisition. + +### `API scopes not authorized in Azure App Registration` + +**Cause (AUTH_SCOPES_UNAUTHORIZED):** The CLI requested API scopes that are not configured in the Azure App Registration. For example, if the app registration doesn't have `Files.Read` permission and the CLI requests it, Azure AD rejects the entire token request. + +**Fix:** +1. Re-authenticate: `outlook-cli auth login --account ` +2. If the error persists, add the missing permissions in Azure Portal → App registrations → your app → API permissions +3. Grant admin consent if required by your organization + +**Note:** OneDrive/Files scopes are opt-in. They are not included in the default scope list. To use OneDrive features, you must first add `Files.Read` and/or `Files.ReadWrite` permissions to your Azure App Registration, then configure custom scopes for your account. + +### `Too many authentication attempts` (rate limited) + +**Cause (AUTH_RATE_LIMITED):** Microsoft Entra ID detected too many token requests in a short period and activated abuse protection (AADSTS70000). This is temporary. + +**Common triggers:** +- Rapid repeated CLI invocations (e.g., in a tight script loop) +- Multiple failed authentication attempts +- Re-authenticating many accounts in quick succession + +**Fix:** Wait 5–10 minutes, then try again. If the problem persists: +```bash +outlook-cli auth login --account +``` + +**Prevention:** Add delays between CLI calls in automation scripts. Use `--json` output and cache results instead of re-querying. + +--- + +## Permission Errors + +### `Account "" is read-only. Cannot .` + +**Cause:** The account is configured with `mode: "read-only"` in accounts.json. Read-only accounts cannot perform write operations (draft, send, reply, forward, move, delete, flag, mark-read). + +**Fix:** Either use a different account or change the account mode: +```bash +outlook-cli account set --mode full +``` + +### `FORBIDDEN_SCOPE_DETECTED: Token contains forbidden scope` + +**Cause:** The token acquired from Azure AD contains `Mail.ReadWrite.All` (application-level access to all mailboxes), which is permanently blocked as a safety measure. This usually means the Azure App Registration has been configured with application permissions instead of delegated permissions. + +**Fix:** In Azure Portal → App Registration → API Permissions, ensure only **delegated** permissions are listed. Remove any application-level permissions. See [SECURITY-DESIGN.md](../SECURITY-DESIGN.md) §5 for details. + +--- + +## Microsoft Graph API Errors + +### `401 Unauthorized` + +**Cause:** The access token is invalid or expired. outlook-cli automatically retries once with a fresh token. + +**If the retry also fails:** The refresh token may be expired. Re-authenticate: +```bash +outlook-cli auth login --account +``` + +### `403 Forbidden` + +**Cause:** The authenticated user doesn't have permission for this operation. Common scenarios: +- Trying to access another user's mailbox without delegate access permissions +- The Azure App Registration doesn't have the required scope +- Organizational policies restrict the operation + +**Fix:** Check the [scopes table in SECURITY.md](../SECURITY.md) to verify the operation requires a scope your account has. + +### `404 Not Found` + +**Cause:** The requested resource doesn't exist. Common scenarios: +- Message ID is invalid or the message was deleted +- Folder name is misspelled (folder names are case-sensitive) +- Contact doesn't exist + +**Fix:** Use `mail inbox` or `mail search` to get fresh message IDs. Use `mail folders` to see exact folder names. + +### `429 Too Many Requests` + +**Cause:** Microsoft Graph rate limiting. outlook-cli automatically waits for the duration specified in the `Retry-After` header and retries. + +**If it persists:** You're making too many requests too quickly. Add delays between operations in scripts, or reduce `--top` to fetch fewer items per request. + +### `Network timeout (30s)` + +**Cause:** The request to Microsoft Graph didn't complete within 30 seconds. This can happen with: +- Very large email bodies or attachment downloads +- Network connectivity issues +- Microsoft Graph service degradation + +**Fix:** Check your network connection. For large operations, try again. If the issue persists, check [Microsoft 365 Service Health](https://status.office365.com/). + +--- + +## Configuration Errors + +### `No accounts configured` + +**Cause:** No accounts exist in `~/.outlook-cli/accounts.json`. + +**Fix:** +```bash +outlook-cli auth login +``` + +This creates a default account with Microsoft's common tenant. + +### `Multiple accounts found. Specify --account` + +**Cause:** You have multiple accounts configured but didn't specify which one to use. + +**Fix:** +```bash +outlook-cli account list # See available accounts +outlook-cli mail inbox --account # Specify the account +outlook-cli account set-default # Or set a default +``` + +--- + +## Command-Specific Errors + +### `messageId is required` + +**Cause:** Commands like `mail read`, `mail reply`, `mail attachments` require a message ID argument. + +**Fix:** +```bash +outlook-cli mail inbox # Get message IDs (1, 2, 3...) +outlook-cli mail read 1 # Use the short ID +``` + +### `--to is required` / `--subject is required` + +**Cause:** `mail draft` requires at minimum a recipient and subject. + +**Fix:** Provide both flags, or use `--input` with a JSON file that includes them. + +### `Attachment has no downloadable content` + +**Cause:** The attachment exists but doesn't have downloadable binary content. This can happen with reference attachments (links to cloud files) or item attachments (embedded messages). + +**Fix:** Only file attachments have `contentBytes`. Check the attachment type in JSON output. + +--- + +## Diagnostic Commands + +When errors persist, use the diagnostic commands to investigate: + +```bash +outlook-cli doctor # Check system health, connectivity, config +outlook-cli telemetry summary # View recent API call statistics +outlook-cli log summary # View recent operation logs +outlook-cli log show # Inspect a specific operation +``` + +See [diagnostics.md](diagnostics.md) for full details on logs and telemetry. diff --git a/docs/usage/mail.md b/docs/usage/mail.md new file mode 100644 index 0000000..7ebef97 --- /dev/null +++ b/docs/usage/mail.md @@ -0,0 +1,514 @@ +# Mail Commands + +All email operations: reading, searching, composing, replying, forwarding, organizing, and sending. + +Every mail command accepts [global options](README.md#global-options) (`--account`, `--as`, `--format`, `--json`, `--output`, `--verbose`). + +Commands that list messages (`inbox`, `search`) assign [short IDs](README.md#short-ids-mail-messages-only) that can be used in place of full Graph IDs in subsequent commands. + +--- + +## `mail inbox` + +List messages from a mail folder. By default shows the 25 most recent messages in your Inbox, newest first. Each message gets a short numeric ID for use in subsequent commands. + +```bash +outlook-cli mail inbox +outlook-cli mail inbox --top 10 +outlook-cli mail inbox --unread +outlook-cli mail inbox --folder "Sent Items" +outlook-cli mail inbox --json +``` + +**Pagination** — for mailboxes with thousands of messages: +```bash +outlook-cli mail inbox --top 50 # First 50 messages +outlook-cli mail inbox --page next # Next page of 50 +outlook-cli mail inbox --page next # Page 3... +outlook-cli mail inbox --page prev # Go back to page 2 +outlook-cli mail inbox --skip 100 --top 25 # Jump to messages 101-125 +``` + +Page state is saved in `~/.outlook-cli/page-state.json` between invocations. Each command (`mail inbox`, `mail search`) maintains its own cursor so they don't interfere with each other. + +| Option | Description | Default | +|---|---|---| +| `--top ` | Maximum number of messages to return. Microsoft Graph supports up to 1000 per request. | 25 | +| `--skip ` | Skip the first N messages (offset-based pagination). | 0 | +| `--page ` | Page navigation: `next` to advance one page, `prev` to go back. Uses saved cursor from the last invocation. | — | +| `--unread` | Show only unread messages. Filters by `isRead eq false` in the Graph API. | Show all | +| `--folder ` | Mail folder to list. Use the display name: `Inbox`, `Sent Items`, `Drafts`, `Archive`, `Deleted Items`, `Junk Email`, or any custom folder you've created. | `Inbox` | +| `--input ` | Load options from a JSON file. Use `-` for stdin. CLI flags override JSON values. | — | + +**Example output (text):** +``` +# From Subject Date +───────────────────────────────────────────────────────────────────────────────────── +1 ● alice@example.com Quarterly Report 09:30 AM +2 bob@example.com Meeting Notes Yesterday +3 carol@example.com Project Update Apr 12 + +3 message(s) — use N with read, reply, forward, move, flag, mark-read, send +``` + +The `●` indicator marks unread messages. The footer reminds you that short IDs work with subsequent commands. + +**Example output (JSON):** +```bash +outlook-cli mail inbox --json --top 2 +``` +```json +[ + { + "id": "AQMkADAwATMwMAEx...", + "subject": "Quarterly Report", + "from": { + "emailAddress": { "name": "Alice Johnson", "address": "alice@example.com" } + }, + "receivedDateTime": "2026-01-15T09:30:00Z", + "isRead": false, + "importance": "normal", + "hasAttachments": false + } +] +``` + +JSON output includes all fields from the Graph API response. Use `jq` to extract specific data: + +```bash +# Get all unread subjects +outlook-cli mail inbox --unread --json | jq '.[].subject' + +# Get the full message ID of the first result (for scripting) +outlook-cli mail inbox --json | jq -r '.[0].id' +``` + +--- + +## `mail read ` + +Read the full content of a specific message. The `messageId` can be a [short ID](README.md#short-ids-mail-messages-only) from the last `inbox` or `search` command, or a full Microsoft Graph message ID. + +```bash +outlook-cli mail read 1 # Short ID from last inbox/search +outlook-cli mail read 1 --plain # Plain text instead of HTML +outlook-cli mail read 1 --body-preview # Just the ~255 char preview +outlook-cli mail read 1 --truncate 500 # Truncate body to 500 chars +outlook-cli mail read AQMkADAwATMw... # Full Graph ID (for scripts) +outlook-cli mail read 1 --json # Full message as JSON +``` + +| Option | Description | Default | +|---|---|---| +| `--plain` | Request the plain-text body from Graph API instead of HTML. Not all messages have a plain-text version; Graph may return an empty body. | HTML body | +| `--body-preview` | Show only the body preview (~255 characters) instead of the full body. Useful for scanning large HTML emails without rendering the full content. | Full body | +| `--truncate ` | Truncate the body to the specified number of characters. Shows a notice with the full body length. Useful for large emails (newsletters, marketing emails with embedded HTML/CSS). | No truncation | +| `--attachments` | Include attachment details (name, size, type, inline status) in the output. When using `--json`, attachments are automatically included if the message has them. | Not included | +| `--input ` | Load options from a JSON file. | — | + +**Example output (text):** +``` +──────────────────────────────────────────────────────────── +From: Alice Johnson +To: you@outlook.com +Subject: Quarterly Report +Date: Wed, Jan 15, 2026, 09:30 AM +Read: no +Attachments: yes +ID: AQMkADAwATMwMAEx... +──────────────────────────────────────────────────────────── +Hi team, + +Please find attached the Q4 quarterly report... +``` + +**With `--attachments`:** +``` +──────────────────────────────────────────────────────────── +From: Alice Johnson +To: you@outlook.com +Subject: Quarterly Report +Date: Wed, Jan 15, 2026, 09:30 AM +Read: no +Attachments: yes + 📎 Q4-Report-2025.pdf (2.4 MB) + 📎 summary-chart.png (145.0 KB) [inline] +ID: AQMkADAwATMwMAEx... +──────────────────────────────────────────────────────────── +``` + +The header block always shows From, To, Subject, Date, Read status, and the full Graph ID. When `--attachments` is used, each attachment is listed with its name, size, and whether it's an inline image (embedded in the email body via CID reference). + +--- + +## `mail search ` + +Search messages using Microsoft Graph's `$search` syntax. Results are ranked by relevance, and each gets a short numeric ID (overwriting any IDs from a previous `inbox` or `search`). + +```bash +outlook-cli mail search "quarterly report" +outlook-cli mail search "from:alice" +outlook-cli mail search "subject:meeting" +outlook-cli mail search "hasAttachments:true" +outlook-cli mail search "from:alice subject:budget" --top 5 +outlook-cli mail search "received>=2026-01-01" --json +``` + +**Pagination** — for searches with many results: +```bash +outlook-cli mail search "from:newsletter" --top 10 # First 10 results +outlook-cli mail search "from:newsletter" --page next # Next page +outlook-cli mail search "from:newsletter" --skip 20 # Jump to result 21+ +``` + +| Option | Description | Default | +|---|---|---| +| `--top ` | Maximum number of results. | 25 | +| `--skip ` | Skip the first N results (offset-based pagination). | 0 | +| `--page ` | Page navigation: `next` or `prev`. Uses saved cursor from the last search invocation. | — | +| `--input ` | Load options from a JSON file. | — | + +**Search syntax** (Microsoft Graph KQL): +- `"keyword"` — searches subject, body, and other text fields +- `from:alice` — messages from a specific sender +- `subject:report` — subject line contains "report" +- `hasAttachments:true` — only messages with attachments +- `received>=2026-01-01` — received on or after a date +- Combine with spaces: `from:alice subject:budget hasAttachments:true` + +**Example output:** +``` +# From Subject Date +───────────────────────────────────────────────────────────────────────────────────── +1 alice@example.com Q4 Quarterly Report Jan 15 +2 alice@example.com Q3 Quarterly Report Oct 12 + +2 message(s) — use N with read, reply, forward, move, flag, mark-read, send +``` + +**Note:** After searching, `mail read 1` reads the first **search result**, not inbox message #1. Short IDs always point to the most recent listing. See [Short IDs](README.md#short-ids-mail-messages-only). + +--- + +## `mail folders` + +List all mail folders with unread counts and total message counts. + +```bash +outlook-cli mail folders +outlook-cli mail folders --json +``` + +| Option | Description | Default | +|---|---|---| +| `--input ` | Load options from a JSON file. | — | + +**Example output (text):** +``` +Folder Unread Total ID +──────────────────────────────────────────────────────────────────────────────────────── +Archive 0 0 AQMkADAwATMwMAEx... +Deleted Items 0 18 AQMkADAwATMwMAEx... +Drafts 0 0 AQMkADAwATMwMAEx... +Inbox 2 45 AQMkADAwATMwMAEx... +Junk Email 0 0 AQMkADAwATMwMAEx... +Sent Items 0 120 AQMkADAwATMwMAEx... +``` + +The folder names shown here are what you pass to `--folder` in other commands: `outlook-cli mail inbox --folder "Sent Items"`. + +--- + +## `mail attachments ` + +List all attachments on a message. Shows name, size, content type, and whether the attachment is inline (embedded in the HTML body, e.g., images referenced by CID). + +```bash +outlook-cli mail attachments 1 # Short ID from last inbox/search +outlook-cli mail attachments 1 --json # Full metadata as JSON (includes attachment IDs) +``` + +**Example output (text):** +``` +Attachments: +──────────────────────────────────────────────────────────────────────────────── +Name Size Type Inline +──────────────────────────────────────────────────────────────────────────────── +Q4-Report-2025.pdf 2.4 MB application/pdf no +summary-chart.png 145.0 KB image/png yes + +2 attachment(s) +``` + +To download an attachment, copy the attachment ID from the JSON output and use `mail download-attachment`. + +--- + +## `mail download-attachment ` + +Download a specific attachment to a local file. The attachment ID comes from `mail attachments --json`. + +```bash +# Get attachment IDs first +outlook-cli mail attachments 1 --json | jq '.[].id' + +# Download to current directory +outlook-cli mail download-attachment 1 "AAMkADAwATMw..." + +# Download to a specific directory +outlook-cli mail download-attachment 1 "AAMkADAwATMw..." --output-dir ./downloads +``` + +| Option | Description | Default | +|---|---|---| +| `--output-dir ` | Directory to save the downloaded file. Created if it doesn't exist. | `.` (current directory) | + +The file is saved with its original filename. If a file with the same name already exists, it will be overwritten. + +--- + +## `mail draft` + +Create a draft message in the Drafts folder. The draft is **not sent** unless you also pass `--send` or later run `mail send`. + +```bash +# Simple draft +outlook-cli mail draft --to "bob@example.com" --subject "Hello" --body "Hi Bob!" --yes + +# HTML body from a file (content type auto-detected from .html extension) +outlook-cli mail draft --to "bob@example.com" --subject "Report" --body-file report.html --yes + +# Multiple recipients with CC +outlook-cli mail draft --to "bob@example.com,carol@example.com" --cc "dave@example.com" --subject "Team Update" --body "See attached" --yes + +# From a JSON template +outlook-cli mail draft --input draft-template.json --yes --json + +# Draft and send immediately +outlook-cli mail draft --to "bob@example.com" --subject "Quick note" --body "Done!" --yes --send + +# Draft with file attachments +outlook-cli mail draft --to "bob@example.com" --subject "Report" --body "See attached" --attach report.pdf --attach chart.png --yes +``` + +| Option | Description | Default | +|---|---|---| +| `--to ` | Recipient email addresses, comma-separated. Contact aliases also work if configured. | Required | +| `--subject ` | Subject line. | Required | +| `--body ` | Body text, inline. For longer content, use `--body-file`. | — | +| `--body-file ` | Read body from a file. Takes priority over `--body`. If the file extension is `.html` or `.htm`, the content type is automatically set to HTML. | — | +| `--body-content-type ` | Body content type: `Text` or `HTML`. Usually auto-detected from `--body-file` extension. | `Text` | +| `--cc ` | CC recipients, comma-separated. | — | +| `--bcc ` | BCC recipients, comma-separated. | — | +| `--importance ` | Message importance: `low`, `normal`, or `high`. | `normal` | +| `--attach ` | Attach one or more files to the draft. Can be specified multiple times. Files up to 3MB are attached inline; larger files require upload sessions (not yet automated). MIME type is auto-detected from file extension. | — | +| `--send` | Send the draft immediately after creating it. Equivalent to `mail draft` followed by `mail send`. | — | +| `--yes` | Skip the confirmation prompt. Required for non-interactive use (scripts, agents). | Prompt | +| `--input ` | Load all parameters from a JSON file. CLI flags override JSON values. | — | + +**Output:** On success, prints the draft's message ID. Use this ID with `mail send` if you didn't use `--send`. + +--- + +## `mail reply ` + +Reply to a message. Creates and sends a reply (or reply-all with `--all`). + +```bash +outlook-cli mail reply 1 --body "Thanks for the update!" --yes +outlook-cli mail reply 1 --all --body "Noted, thanks everyone." --yes +outlook-cli mail reply 1 --body-file response.html --body-content-type HTML --yes +``` + +| Option | Description | Default | +|---|---|---| +| `--body ` | Reply body text. | Required (or `--body-file`) | +| `--body-file ` | Read reply body from a file. Takes priority over `--body`. | — | +| `--body-content-type ` | Body content type: `Text` or `HTML`. | `Text` | +| `--all` | Reply to all recipients (sender + To + CC). Without this, replies only to the sender. | Reply to sender | +| `--yes` | Skip confirmation prompt. | Prompt | +| `--input ` | Load options from a JSON file. | — | + +The `messageId` can be a [short ID](README.md#short-ids-mail-messages-only) or a full Graph ID. + +--- + +## `mail forward ` + +Forward a message to one or more recipients. + +```bash +outlook-cli mail forward 1 --to "carol@example.com" --yes +outlook-cli mail forward 1 --to "carol@example.com,dave@example.com" --comment "FYI — see below" --yes +outlook-cli mail forward 1 --to "carol@example.com" --body-file note.html --body-content-type HTML --yes +``` + +| Option | Description | Default | +|---|---|---| +| `--to ` | Forward recipients, comma-separated. | Required | +| `--comment ` | Comment to include above the forwarded message. | — | +| `--body-file ` | Read comment from a file. Takes priority over `--comment`. | — | +| `--body-content-type ` | Comment content type: `Text` or `HTML`. | `Text` | +| `--yes` | Skip confirmation prompt. | Prompt | +| `--input ` | Load options from a JSON file. | — | + +--- + +## `mail move ` + +Move a message to a different folder. + +```bash +outlook-cli mail move 1 --folder Archive --yes +outlook-cli mail move 1 --folder "Deleted Items" --yes +outlook-cli mail move 3 --folder "Project/Active" --yes +``` + +| Option | Description | Default | +|---|---|---| +| `--folder ` | Destination folder name (display name, not ID). Use `mail folders` to see available names. For nested folders, use the full path. | Required | +| `--yes` | Skip confirmation prompt. | Prompt | +| `--input ` | Load options from a JSON file. | — | + +--- + +## `mail flag ` + +Flag or unflag a message for follow-up. + +```bash +outlook-cli mail flag 1 # Flag message #1 +outlook-cli mail flag 1 --unflag # Remove the flag +``` + +| Option | Description | Default | +|---|---|---| +| `--unflag` | Remove the flag instead of setting it. | Set flag | +| `--input ` | Load options from a JSON file. | — | + +Flagging does not require `--yes` — it's a non-destructive operation. + +--- + +## `mail mark-read ` + +Mark a message as read or unread. + +```bash +outlook-cli mail mark-read 1 # Mark as read +outlook-cli mail mark-read 1 --unread # Mark as unread +``` + +| Option | Description | Default | +|---|---|---| +| `--unread` | Mark as unread instead of read. | Mark as read | +| `--input ` | Load options from a JSON file. | — | + +--- + +## `mail send ` + +Send a draft message. The `messageId` must be a draft — sending an already-sent message returns an error. + +```bash +outlook-cli mail send 1 --yes # Send draft using short ID +outlook-cli mail send AQMkAD... --yes # Send using full ID (for scripts) +``` + +| Option | Description | Default | +|---|---|---| +| `--yes` | Skip the "are you sure?" confirmation. Required for non-interactive use. | Prompt | +| `--input ` | Load options from a JSON file. | — | + +**Typical workflow:** +```bash +# 1. Create a draft +outlook-cli mail draft --to "bob@example.com" --subject "Report" --body "See attached" --yes +# → Draft created: AQMkADAwATMw... + +# 2. Review it (optional) +outlook-cli mail inbox --folder Drafts +outlook-cli mail read 1 + +# 3. Send it +outlook-cli mail send 1 --yes +``` + +Or use `--send` on `mail draft` to skip the two-step process: +```bash +outlook-cli mail draft --to "bob@example.com" --subject "Quick" --body "Done!" --yes --send +``` + +--- + +## Common Workflows + +### Read and reply to latest email +```bash +outlook-cli mail inbox --top 5 # See recent messages +outlook-cli mail read 1 # Read the first one +outlook-cli mail reply 1 --body "Got it, thanks!" --yes +``` + +### Search, read, and archive +```bash +outlook-cli mail search "quarterly report" # Find messages +outlook-cli mail read 1 # Read the best match +outlook-cli mail move 1 --folder Archive --yes # Archive it +``` + +### Script-safe workflow (using JSON + full IDs) +```bash +# Get the latest unread message ID +MSG_ID=$(outlook-cli mail inbox --unread --top 1 --json | jq -r '.[0].id') + +# Read it +outlook-cli mail read "$MSG_ID" --json | jq '.body.content' + +# Reply +outlook-cli mail reply "$MSG_ID" --body "Acknowledged" --yes + +# Mark as read +outlook-cli mail mark-read "$MSG_ID" +``` + +### Bulk operations +```bash +# Flag all unread messages +for id in $(outlook-cli mail inbox --unread --json | jq -r '.[].id'); do + outlook-cli mail flag "$id" +done +``` + +### Working with attachments +```bash +# List inbox, find message with attachments +outlook-cli mail inbox # Look for 📎 indicator +outlook-cli mail read 3 --attachments # See attachment details + +# List attachments to get IDs +outlook-cli mail attachments 3 --json # Get attachment IDs + +# Download a specific attachment +ATT_ID=$(outlook-cli mail attachments 3 --json | jq -r '.[0].id') +outlook-cli mail download-attachment 3 "$ATT_ID" --output-dir ./downloads + +# Send a message with attachments +outlook-cli mail draft --to "bob@example.com" --subject "Files" \ + --body "Here are the files" --attach report.pdf --attach image.png --yes +``` + +--- + +## `mail delete ` + +Delete a message (soft-delete). The message is moved to Deleted Items. If it's already in Deleted Items, it is permanently deleted. + +```bash +outlook-cli mail delete 1 --yes +``` + +| Option | Description | Default | +|---|---|---| +| `--yes` | Skip the "are you sure?" confirmation. | Prompt | +| `--input ` | Load options from a JSON file. | — | diff --git a/docs/usage/watch.md b/docs/usage/watch.md new file mode 100644 index 0000000..e3cf010 --- /dev/null +++ b/docs/usage/watch.md @@ -0,0 +1,153 @@ +# Watch Commands + +Monitor your mailbox and calendar for changes in real-time. Watch mode uses Microsoft Graph delta queries to efficiently detect new or modified items without re-downloading everything. + +The `watch` command accepts [global options](README.md#global-options) (`--account`, `--format`, `--json`, `--output`, `--verbose`). + +For deep technical details on delta tokens, adaptive polling, webhook architecture, and multi-job configuration, see [WATCH-MODE.md](../WATCH-MODE.md). + +--- + +## `watch` + +Start watching for changes. By default, polls every 60 seconds for both mail and calendar changes. + +```bash +# Default: poll every 60 seconds +outlook-cli watch + +# Watch mail only, 30-second interval +outlook-cli watch --no-calendar --interval 30 + +# JSONL output for piping to an agent or script +outlook-cli watch --jsonl | my-agent-process + +# Fast-poll mode for near-instant response (5-60s adaptive) +outlook-cli watch --mode fast-poll --interval 10 + +# Webhook mode for true push notifications (~1-5s latency) +outlook-cli watch --mode webhook + +# Watch a specific folder +outlook-cli watch --folder "Important" --no-calendar + +# Multi-job rules engine +outlook-cli watch --config watch-rules.json + +# Validate config without starting +outlook-cli watch --config watch-rules.json --validate +``` + +| Option | Description | Default | +|---|---|---| +| `--mode ` | Notification mode. `poll` checks at a fixed interval. `fast-poll` uses adaptive polling that speeds up during activity and slows during quiet periods. `webhook` uses Microsoft Graph push notifications via a tunnel for near-instant delivery. | `poll` | +| `--interval ` | Poll interval in seconds. Minimum is 30 for `poll` mode, 5 for `fast-poll`. In `fast-poll` mode, this is the **maximum** interval — the watcher drops to 5s when activity is detected. | 60 | +| `--no-mail` | Don't watch for mail changes. | Watch both | +| `--no-calendar` | Don't watch for calendar changes. | Watch both | +| `--folder ` | Mail folder to watch. Uses the display name (e.g., `Inbox`, `Important`, `Sent Items`). | `Inbox` | +| `--jsonl` | Output each change event as a JSON Lines object (one JSON object per line). Essential for agent/script integration — each line is independently parseable. | Text output | +| `--heartbeat` | Emit periodic heartbeat events so consuming processes know the watcher is alive. Without this, there's no output during quiet periods. | No heartbeats | +| `--heartbeat-interval ` | How often to emit heartbeat events. Only relevant when `--heartbeat` is enabled. | 300 (5 minutes) | +| `--tunnel ` | Tunnel provider for webhook mode. Options: `cloudflared` (free, no account needed), `ngrok` (requires account), `localtunnel` (free, less reliable). | `cloudflared` | +| `--webhook-port ` | Local port for the webhook HTTP server. If you have a public IP/reverse proxy, you can skip the tunnel and expose this port directly. | Random available port | +| `--config ` | Path to a watch configuration JSON file. Enables the multi-job rules engine with independent watches, conditions, and actions. See [WATCH-MODE.md](../WATCH-MODE.md) for the config schema. | — | +| `--validate` | Validate the config file syntax and exit without starting the watch. Requires `--config`. | — | + +--- + +## Watch Modes Compared + +| Mode | Latency | API Calls | Setup | Best For | +|---|---|---|---|---| +| `poll` | 30-60 seconds | Low (1 call/interval) | None | Notifications, casual monitoring | +| `fast-poll` | 5-60 seconds (adaptive) | Medium (more during activity) | None | Agent integration without infrastructure | +| `webhook` | 1-5 seconds | Very low (push-based) | Tunnel or public IP | Real-time gateways, critical response | + +### Poll Mode (Default) + +Makes a delta query every `--interval` seconds. Delta queries only return items that changed since the last call, so even with a large mailbox, each poll transfers minimal data. Simple and reliable, but has inherent latency equal to the polling interval. + +### Fast-Poll Mode + +Same delta queries but with **adaptive frequency**: +- Starts at the configured `--interval` +- When changes are detected: **drops to 5-second polling** to catch follow-up messages +- When no changes are found: **backs off by 1.5× each cycle** (5s → 7.5s → 11s → 17s → 25s → ... up to max) +- **Resets to 5s immediately** when any change arrives + +This gives near-instant response during active conversations while conserving API quota during quiet periods. Ideal for AI agent integration without requiring tunnel infrastructure. + +### Webhook Mode + +Uses Microsoft Graph subscriptions to receive push notifications. The CLI starts a local HTTP server and creates a tunnel (via cloudflared, ngrok, or localtunnel) to make it reachable from the internet: + +``` +Microsoft Graph ──POST──→ Tunnel (cloudflared) ──→ localhost:PORT ──→ outlook-cli +``` + +Webhook mode has the lowest latency (~1-5 seconds) but requires either a tunnel tool installed or a publicly accessible port. The CLI manages the subscription lifecycle automatically (creation, renewal every ~15 minutes, cleanup on exit). + +**Note:** Webhook mode is only available in the Node.js implementation. The C# binary falls back to fast-poll with a message explaining this. + +--- + +## Output Formats + +### Text Output (Default) + +``` +[2026-01-15 09:30:05] 📬 New mail from alice@example.com: "Quarterly Report" +[2026-01-15 09:35:12] 📬 New mail from bob@example.com: "Re: Meeting Notes" +[2026-01-15 10:00:00] 📅 Calendar: "Team Standup" starting now +``` + +### JSONL Output (`--jsonl`) + +Each event is a single-line JSON object: + +```json +{"type":"mail","action":"created","id":"AAMkAD...","subject":"Quarterly Report","from":"alice@example.com","receivedDateTime":"2026-01-15T09:30:05Z","timestamp":"2026-01-15T09:30:06Z"} +{"type":"mail","action":"created","id":"AAMkAD...","subject":"Re: Meeting Notes","from":"bob@example.com","receivedDateTime":"2026-01-15T09:35:12Z","timestamp":"2026-01-15T09:35:13Z"} +{"type":"heartbeat","timestamp":"2026-01-15T09:40:00Z","uptime":600} +``` + +JSONL is the recommended format for agent and script integration. Each line can be parsed independently, and heartbeat events let consuming processes detect if the watcher is still alive. + +--- + +## Common Workflows + +### Pipe to an agent +```bash +outlook-cli watch --mode fast-poll --jsonl --heartbeat | while read -r line; do + echo "$line" | my-agent --process-event +done +``` + +### Monitor and log to a file +```bash +outlook-cli watch --jsonl --output events.jsonl +# In another terminal: +tail -f events.jsonl +``` + +### Watch with rules (multi-job) +```bash +# Create a config file (see WATCH-MODE.md for schema) +outlook-cli watch --config watches.json + +# Validate first +outlook-cli watch --config watches.json --validate +``` + +--- + +## Stopping the Watch + +Press `Ctrl+C` to stop. The watcher: +1. Saves the current delta token (so next start picks up where it left off) +2. Cancels any pending Graph API requests +3. In webhook mode: deletes the subscription and closes the tunnel +4. Exits cleanly with code 0 + +Delta tokens are saved to `~/.outlook-cli/delta-{alias}.json` and survive restarts. The next `watch` start only reports changes that happened while it was stopped. diff --git a/outlook-cli.sln b/outlook-cli.sln new file mode 100644 index 0000000..718b0e7 --- /dev/null +++ b/outlook-cli.sln @@ -0,0 +1,29 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{B41BF331-FCCB-2ADF-CDB6-767964B34647}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OutlookCli", "src\dotnet\OutlookCli.csproj", "{2BF4FD30-E53D-E7E3-82BD-56E404D7BDD6}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {2BF4FD30-E53D-E7E3-82BD-56E404D7BDD6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2BF4FD30-E53D-E7E3-82BD-56E404D7BDD6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2BF4FD30-E53D-E7E3-82BD-56E404D7BDD6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2BF4FD30-E53D-E7E3-82BD-56E404D7BDD6}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {2BF4FD30-E53D-E7E3-82BD-56E404D7BDD6} = {B41BF331-FCCB-2ADF-CDB6-767964B34647} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {8EBAB243-33AE-4F83-9F67-CB0030A67038} + EndGlobalSection +EndGlobal diff --git a/package.json b/package.json new file mode 100644 index 0000000..8501ce2 --- /dev/null +++ b/package.json @@ -0,0 +1,53 @@ +{ + "name": "outlook-cli", + "version": "1.0.0", + "description": "Cross-platform CLI for Microsoft Outlook via Graph API. Multi-account, headless-friendly, no Mail.Send.", + "type": "module", + "bin": { + "outlook-cli": "./bin/outlook-cli.js" + }, + "scripts": { + "test": "vitest run --exclude 'test/integration/**'", + "test:unit": "vitest run --exclude 'test/integration/**'", + "test:integration": "vitest run --pool=forks --poolOptions.forks.singleFork test/integration/", + "test:all": "vitest run --pool=forks --poolOptions.forks.singleFork", + "test:watch": "vitest --exclude 'test/integration/**'", + "test:verbose": "vitest run --reporter=verbose --exclude 'test/integration/**'", + "test:coverage": "vitest run --coverage --exclude 'test/integration/**'", + "test:report": "vitest run --pool=forks --poolOptions.forks.singleFork && node scripts/test-report.js", + "test:report:json": "vitest run --pool=forks --poolOptions.forks.singleFork && node scripts/test-report.js --json", + "test:report:markdown": "vitest run --pool=forks --poolOptions.forks.singleFork && node scripts/test-report.js --markdown", + "test:ci": "vitest run --pool=forks --poolOptions.forks.singleFork --reporter=json --outputFile=test-results/vitest-report.json && node scripts/test-report.js --json > test-results/dashboard.json", + "start": "node bin/outlook-cli.js" + }, + "engines": { + "node": ">=20.0.0" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/jeffstall/outlook-cli.git" + }, + "keywords": [ + "outlook", + "microsoft-graph", + "cli", + "email", + "calendar", + "openclaw", + "nanoclaw" + ], + "author": "Jeffrey Stall", + "license": "MIT", + "bugs": { + "url": "https://github.com/jeffstall/outlook-cli/issues" + }, + "homepage": "https://github.com/jeffstall/outlook-cli#readme", + "dependencies": { + "@azure/msal-node": "^5.1.2", + "better-sqlite3": "^12.9.0", + "commander": "^14.0.3" + }, + "devDependencies": { + "vitest": "^4.1.4" + } +} diff --git a/plan/openclaw-outlook-plan.md b/plan/openclaw-outlook-plan.md new file mode 100644 index 0000000..7f86a70 --- /dev/null +++ b/plan/openclaw-outlook-plan.md @@ -0,0 +1,240 @@ +# OpenClaw Outlook Skill — Project Plan + +## Purpose + +Build a custom OpenClaw skill that integrates with Microsoft Outlook via the Microsoft Graph REST API. This skill handles email, calendar, and appointments. It is intentionally NOT published to ClawHub and is installed locally via symlink. We build our own because roughly 12% of ClawHub skills have been found to be actively malicious (Koi Security audit, Feb 2026), and existing Outlook skills request Mail.Send — which we refuse to grant. + +## Who This Document Is For + +This is a plan file for a coding AI instance. It contains all architectural decisions, security constraints, API details, and cost context needed to implement the project from scratch. The implementer has unlimited tokens and time. Build it right. + +--- + +## Non-Negotiable Security Requirements + +These are hard constraints. Do not compromise on any of them. + +1. **The Azure app registration MUST NOT include Mail.Send.** The token physically cannot send email. This is the primary security boundary — it's enforced at Microsoft's identity platform, not our code. As defense-in-depth, the code should also decode every access token JWT and reject any containing Mail.Send in the `scp` claim. + +2. **All write operations go through algorithmic confirmation UI.** "Algorithmic" means the confirmation text is rendered by static template code that reads fields directly from the Graph API request payload. The LLM never generates, modifies, or summarizes the confirmation. What the user sees IS the payload. This prevents prompt injection from misrepresenting what an operation does. + +3. **TOCTOU protection via HMAC hashing.** When a confirmation is generated, HMAC-SHA256 the serialized payload (with sorted keys for determinism). Display a short hash (first 8 hex chars) to the user. Before executing on YES, re-hash the payload and compare. If mismatch, reject. This prevents the payload from being modified between confirmation and execution. + +4. **Token isolation.** The OAuth refresh token is managed by a separate token service process. The skill process connects via Unix socket and receives only short-lived (1-hour) access tokens. The refresh token is encrypted at rest using AES-256-GCM with a PBKDF2-derived key (310,000 iterations minimum). File permissions 600. + +5. **Email writes create drafts only.** POST to `/me/messages` creates a draft in the Drafts folder. The user opens Outlook and clicks Send. There is no `outlook-send` tool. The tool doesn't exist because the permission doesn't exist. + +6. **No ClawHub publication.** Installed via local symlink to `~/.openclaw/skills/outlook/`. + +--- + +## Microsoft Graph API Reference + +Base URL: `https://graph.microsoft.com/v1.0` + +### Authentication + +Use **delegated access with PKCE** (Proof Key for Code Exchange) via the `@azure/msal-node` library (`PublicClientApplication`). No client secret — PKCE is a public client flow. Request `offline_access` scope for a refresh token so the skill can renew tokens silently without re-prompting. + +The interactive auth is a one-time flow: start a localhost HTTP server on port 3847, open the Microsoft login URL in a browser, receive the auth code via redirect to `http://localhost:3847/callback`, exchange it for tokens using the PKCE verifier. + +After initial auth, silent renewal happens via `acquireTokenSilent()` using the MSAL token cache (which contains the refresh token). Refresh tokens have a ~90-day rolling lifetime. + +### Scopes to Request + +``` +User.Read Mail.Read Mail.ReadWrite Calendars.Read Calendars.ReadWrite offline_access +``` + +Do NOT request: `Mail.Send`, `Mail.ReadWrite.All`, `Calendars.ReadWrite.All`, `Mail.Send.Shared`. + +### Azure App Registration + +- Register at portal.azure.com → Microsoft Entra ID → App registrations +- Platform: "Mobile and desktop applications" (NOT Web) +- Redirect URI: `http://localhost:3847/callback` +- "Allow public client flows": YES +- No client secret. Zero. +- App registration and standard Graph API calls are free. No Azure subscription cost. The only cost is the user's existing M365/Outlook license. + +### Key Mail Endpoints + +| Operation | Method | Endpoint | Permission | Notes | +|---|---|---|---|---| +| List inbox | GET | `/me/mailFolders/Inbox/messages?$top=25&$select=id,subject,from,receivedDateTime,bodyPreview,isRead,importance,flag` | Mail.Read | Use `$orderby=receivedDateTime desc` | +| Read message | GET | `/me/messages/{id}` | Mail.Read | Request `Prefer: outlook.body-content-type="text"` header for plain text | +| Search | GET | `/me/messages?$search="keyword"` | Mail.Read | Searches subject, body, addresses | +| List folders | GET | `/me/mailFolders?$top=100` | Mail.Read | Well-known names: Inbox, Drafts, SentItems, DeletedItems, Archive | +| Create draft | POST | `/me/messages` | Mail.ReadWrite | Body: `{subject, body: {contentType, content}, toRecipients: [{emailAddress: {address}}]}`. Creates in Drafts with `isDraft: true`. | +| Create reply draft | POST | `/me/messages/{id}/createReply` | Mail.ReadWrite | Returns draft with correct In-Reply-To headers. Then PATCH body. | +| Create reply-all draft | POST | `/me/messages/{id}/createReplyAll` | Mail.ReadWrite | Same pattern as reply. | +| Create forward draft | POST | `/me/messages/{id}/createForward` | Mail.ReadWrite | Then PATCH to add recipients and comment. | +| Move message | POST | `/me/messages/{id}/move` | Mail.ReadWrite | Body: `{destinationId: "folder-id"}` | +| Mark read/unread | PATCH | `/me/messages/{id}` | Mail.ReadWrite | Body: `{isRead: true/false}` | +| Flag message | PATCH | `/me/messages/{id}` | Mail.ReadWrite | Body: `{flag: {flagStatus: "flagged"}}` | +| Send draft | POST | `/me/messages/{id}/send` | **Mail.Send** | **WE DO NOT IMPLEMENT THIS.** | +| Attachments metadata | GET | `/me/messages/{id}/attachments?$select=id,name,contentType,size` | Mail.Read | Metadata only, not content. | + +### Key Calendar Endpoints + +| Operation | Method | Endpoint | Permission | +|---|---|---|---| +| List events (expanded recurrences) | GET | `/me/calendarView?startDateTime=...&endDateTime=...` | Calendars.Read | +| Read event | GET | `/me/events/{id}` | Calendars.Read | +| Today's events | GET | calendarView with today's start/end | Calendars.Read | +| Free/busy | POST | `/me/calendar/getSchedule` | Calendars.Read | +| List calendars | GET | `/me/calendars` | Calendars.Read | +| Create event | POST | `/me/events` | Calendars.ReadWrite | +| Modify event | PATCH | `/me/events/{id}` | Calendars.ReadWrite | +| Accept invite | POST | `/me/events/{id}/accept` | Calendars.ReadWrite | +| Tentative | POST | `/me/events/{id}/tentativelyAccept` | Calendars.ReadWrite | +| Decline | POST | `/me/events/{id}/decline` | Calendars.ReadWrite | + +Event datetimes use `{dateTime: "ISO8601", timeZone: "America/Los_Angeles"}` format. + +### Contacts (Read-Only) + +- Personal contacts: `GET /me/contacts?$filter=contains(displayName, 'query')` +- People/GAL: `GET /me/people?$search="query"` (relevance-ranked) + +--- + +## Architecture + +### Components + +1. **Token Service** — Standalone Node.js process. Manages MSAL PKCE auth, encrypted token storage, silent renewal. Exposes a Unix socket (`/tmp/openclaw-outlook-token.sock`). Protocol: newline-delimited JSON. Request `{"action": "getToken"}`, response `{"ok": true, "accessToken": "..."}`. Also supports `{"action": "status"}`. + +2. **Graph Client** — HTTP module that gets tokens from the token service socket and makes authenticated `fetch()` calls to graph.microsoft.com. Handles 401 (token expired), 429 (rate limited with Retry-After), pagination (`@odata.nextLink`). + +3. **Confirmation Engine** — Manages pending confirmations. Each write operation: generates template-based confirmation text → delivers to user (Telegram or console) → waits for YES/NO/DETAILS → verifies HMAC hash → executes or cancels. Max 5 pending. 5-minute timeout with auto-cancel. + +4. **LLM Router** — Routes processing tasks to configurable LLM providers. Supports Ollama (`/api/chat`) and OpenAI-compatible (`/v1/chat/completions`) endpoints. Config maps task types (email-summarize, email-classify, email-draft-compose, calendar-parse, calendar-summarize) to provider + model + system prompt + max tokens. This is independent of OpenClaw's main orchestration LLM. + +5. **Skill Tools** — OpenClaw tool definitions. ~22 tools total: 6 mail-read, 3 mail-write, 6 calendar-read, 3 calendar-write, 1 contacts, 3 confirmation management. Each tool has name, description, JSON Schema parameters, and an execute function. + +### Data Flow + +``` +User (via Telegram) → OpenClaw Gateway → LLM (orchestration) → Skill Tool + → [if read] Graph Client → Token Service (socket) → Graph API → response + → [if write] LLM Router (compose draft) → Confirmation Engine (template + HMAC) + → Telegram (show confirmation) → User responds YES + → Hash verify → Graph Client → Token Service → Graph API → response +``` + +### Tool List + +**Mail Read (no confirmation):** outlook_mail_inbox, outlook_mail_read, outlook_mail_search, outlook_mail_folders, outlook_mail_summarize (local LLM), outlook_mail_classify (local LLM) + +**Mail Write (confirmation required):** outlook_mail_draft, outlook_mail_reply_draft, outlook_mail_move + +**Calendar Read (no confirmation):** outlook_calendar_today, outlook_calendar_week, outlook_calendar_events, outlook_calendar_read, outlook_calendar_freebusy, outlook_calendar_summarize (local LLM) + +**Calendar Write (confirmation required):** outlook_calendar_create, outlook_calendar_modify, outlook_calendar_respond + +**Other:** outlook_contacts_search, outlook_confirm, outlook_pending, outlook_cancel_all + +--- + +## Configuration Design + +Two config sources: + +1. `.env` — Secrets and connection strings: AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_REDIRECT_URI, TOKEN_SOCKET_PATH, TOKEN_STORE_PATH, CONFIRMATION_HMAC_SECRET, OLLAMA_BASE_URL, LOG_LEVEL. Never committed to git. + +2. `config/config.json` — Feature flags and behavior: permissions (which operations are enabled, mail.send is always false), confirmation settings (which ops require it, timeout, max pending), LLM provider definitions and routing rules, mail defaults (count, fields, sort), calendar defaults (timezone, working hours, days ahead), security (rate limits, audit logging). + +Provide a JSON Schema for config validation and a `default-config.json` as the starting template. + +--- + +## Confirmation Templates + +Each write operation type gets a static template function. The function takes the API payload and HMAC secret, returns `{hash: string, text: string}`. Examples of what the text looks like: + +``` +📧 CREATE DRAFT [A1B2C3D4] +───────────────────────── +To: bob@example.com +Cc: carol@example.com +Subject: Project Update +Priority: high +───────────────────────── +Body preview: +Here is the update on the project... +───────────────────────── +This creates a DRAFT. It will NOT be sent. + +Reply YES to create draft, NO to cancel. +``` + +The `[A1B2C3D4]` is `shortHash(hmac)` — first 8 hex chars uppercase. The To/Subject/Body values come directly from the payload object, not from the LLM's description. Build templates for: createDraft, createReplyDraft, createForwardDraft, moveMessage, bulkMove, createEvent, modifyEvent, respondToInvite, markRead, flag. + +--- + +## Multi-User Support + +Each user gets their own `.env`, `config.json`, token store, and token service instance. The code is identical — only configuration differs. Users can share the same Azure app registration (client ID) since each authenticates with their own Microsoft account and gets their own tokens. Document this in a MULTI-USER-GUIDE. + +For delegate access: a service account authenticates once, users grant it delegate rights. The skill accesses mailboxes via `/users/{their-email}/messages` instead of `/me/messages`. Requires Mail.Read.Shared and Mail.ReadWrite.Shared scopes. + +--- + +## Cost Summary + +| Component | Monthly Cost | +|---|---| +| Azure app registration | $0 (free forever) | +| Graph API calls (mail, calendar, contacts) | $0 (covered by existing M365 license) | +| Ollama (local LLM) | $0 (runs on existing hardware) | +| Cloud LLM tokens (if used for composition) | $2–10 depending on volume | +| VM compute | $0 (existing infrastructure) | +| **Total** | **$2–10/month** (cloud LLM only) | + +Metered Graph APIs exist but only apply to Microsoft Graph Data Connect and certain Teams export APIs — not to standard mail/calendar/contacts endpoints. + +--- + +## Network Requirements + +Outbound HTTPS to these domains (add to Squid allowlist if using egress proxy): +- `graph.microsoft.com` — All API calls +- `login.microsoftonline.com` — OAuth token exchange and refresh +- `login.live.com` — Personal Microsoft account auth + +--- + +## Technology Stack + +- Node.js 18+ (ESM modules) +- `@azure/msal-node` — MSAL library for PKCE auth +- `dotenv` — Environment variable loading +- Node built-in `crypto` — AES-256-GCM encryption, HMAC-SHA256, PBKDF2 +- Node built-in `net` — Unix socket for token service +- Node built-in `fetch` — HTTP calls to Graph API and LLM providers (available in Node 18+) +- No other dependencies. Two npm packages total. + +--- + +## Deliverables + +The implementer should produce: + +1. **Working code** for all 5 components (token service, graph client, confirmation engine, LLM router, skill tools) +2. **Config files** — `.env.example`, `default-config.json`, `config.schema.json`, `skill.json` +3. **Scripts** — Azure setup guide (interactive instructions, not automation), skill installer (symlink to ~/.openclaw/skills/), token rotation helper +4. **Tests** — Unit tests for crypto (encrypt/decrypt roundtrip, tamper detection, wrong passphrase rejection), unit tests for confirmation (HMAC determinism, TOCTOU detection, template output verification), integration test for Graph API connection (token service → get token → call /me → verify Mail.Send is blocked) +5. **Documentation** — Setup guide (step-by-step Azure registration + local config), testing/validation guide, multi-user deployment guide, security model document, LLM configuration guide + +## Development Phases + +**Phase 1 (get tokens flowing):** Azure app registration, MSAL PKCE auth flow, encrypted token storage, token service on Unix socket. Test: can you get a token and call `GET /me`? + +**Phase 2 (read operations):** Graph client, mail read tools (inbox/read/search/folders), calendar read tools (today/week/events/freebusy). Test: ask the agent "what's in my inbox?" and get real data. + +**Phase 3 (LLM integration):** Router with Ollama and OpenAI-compatible providers, email summarize/classify, calendar parse/summarize. Test: "summarize my last 5 emails" works with local model. + +**Phase 4 (write operations + confirmation):** Draft creation, reply drafts, calendar event creation, confirmation engine with templates and HMAC. Test: "draft a reply to X" → shows confirmation → YES → draft appears in Outlook. + +**Phase 5 (hardening):** Rate limiting, audit logging, config validation, error handling for all Graph API error codes (401/403/429/5xx), systemd service file for token service, full test suite. diff --git a/scripts/scale-test.js b/scripts/scale-test.js new file mode 100644 index 0000000..3778a66 --- /dev/null +++ b/scripts/scale-test.js @@ -0,0 +1,239 @@ +#!/usr/bin/env node + +/** + * E4: Scale validation script. + * + * Creates test data (100 draft messages with unique subjects), validates + * pagination works correctly, measures timing/throughput, and reports results. + * + * Gated by OUTLOOK_CLI_E2E=1 environment variable. + * + * Usage: + * OUTLOOK_CLI_E2E=1 node scripts/scale-test.js [--account ] [--count ] [--cleanup] + * + * Options: + * --account Account alias to use (default: first configured account) + * --count Number of draft messages to create (default: 100) + * --cleanup Delete created drafts after test (default: true) + */ + +import { parseArgs } from 'node:util'; + +// ── Gate: require OUTLOOK_CLI_E2E=1 ── +if (process.env.OUTLOOK_CLI_E2E !== '1') { + console.log('⏭ Scale test skipped: set OUTLOOK_CLI_E2E=1 to run.'); + console.log(' This test creates real draft messages via the Graph API.'); + process.exit(0); +} + +const { values: args } = parseArgs({ + options: { + account: { type: 'string', default: '' }, + count: { type: 'string', default: '100' }, + cleanup: { type: 'boolean', default: true }, + help: { type: 'boolean', default: false }, + }, + strict: false, +}); + +if (args.help) { + console.log('Usage: OUTLOOK_CLI_E2E=1 node scripts/scale-test.js [--account ] [--count ] [--cleanup]'); + process.exit(0); +} + +const DRAFT_COUNT = parseInt(args.count, 10) || 100; +const CLEANUP = args.cleanup !== false; + +// ── Main ── + +async function main() { + console.log('━'.repeat(60)); + console.log('📊 Scale Validation Test'); + console.log('━'.repeat(60)); + console.log(` Drafts to create: ${DRAFT_COUNT}`); + console.log(` Cleanup after: ${CLEANUP}`); + console.log(''); + + // Lazy-import to avoid module load errors when not running E2E + const { loadConfig } = await import('../src/node/config.js'); + const { createGraphClient } = await import('../src/node/graph/client.js'); + const { createDraft, listMessages, listFolders } = await import('../src/node/graph/mail.js'); + + const config = loadConfig(); + if (!config.clientId) { + console.error('❌ No client ID configured. Run `outlook-cli account add` first.'); + process.exit(1); + } + + const accountAlias = args.account || 'default'; + const accountConfig = { clientId: config.clientId, tenantId: config.tenantId }; + + let client; + try { + client = await createGraphClient(accountAlias, accountConfig); + } catch (err) { + console.error(`❌ Failed to create Graph client: ${err.message}`); + process.exit(1); + } + + const results = { + draftCreation: { count: 0, totalMs: 0, errors: 0 }, + pagination: { pages: 0, totalItems: 0, totalMs: 0 }, + folderList: { count: 0, totalMs: 0 }, + cleanup: { deleted: 0, errors: 0, totalMs: 0 }, + }; + + const createdDraftIds = []; + + // ── Phase 1: Create drafts ── + console.log(`\n📝 Phase 1: Creating ${DRAFT_COUNT} draft messages...`); + const createStart = performance.now(); + + for (let i = 0; i < DRAFT_COUNT; i++) { + const subject = `[SCALE-TEST] Draft ${i + 1}/${DRAFT_COUNT} — ${Date.now()}`; + try { + const draft = await createDraft(client, { + subject, + body: { contentType: 'Text', content: `Scale test draft #${i + 1}. Created at ${new Date().toISOString()}.` }, + importance: i % 10 === 0 ? 'high' : 'normal', + }); + createdDraftIds.push(draft.id); + results.draftCreation.count++; + + if ((i + 1) % 25 === 0) { + console.log(` Created ${i + 1}/${DRAFT_COUNT} drafts...`); + } + } catch (err) { + results.draftCreation.errors++; + console.error(` ⚠ Failed to create draft ${i + 1}: ${err.message}`); + + // Back off on rate limiting + if (err.statusCode === 429) { + const wait = 10_000; + console.log(` Waiting ${wait / 1000}s due to rate limit...`); + await sleep(wait); + i--; // Retry this index + } + } + } + + results.draftCreation.totalMs = performance.now() - createStart; + console.log(` ✅ Created ${results.draftCreation.count} drafts in ${(results.draftCreation.totalMs / 1000).toFixed(1)}s`); + + // ── Phase 2: Validate pagination ── + console.log('\n📄 Phase 2: Validating pagination...'); + const paginateStart = performance.now(); + + try { + // List messages with pagination (top=25 default, multiple pages expected) + let allMessages = []; + let pageCount = 0; + const pageSize = 25; + + // Fetch multiple pages from Drafts folder + const firstPage = await client.get( + `${client.userPath}/mailFolders/Drafts/messages?$top=${pageSize}&$orderby=receivedDateTime desc` + ); + + allMessages.push(...(firstPage.value || [])); + pageCount++; + + let nextLink = firstPage['@odata.nextLink']; + while (nextLink && pageCount < 10) { + const nextPage = await client.get(nextLink); + allMessages.push(...(nextPage.value || [])); + nextLink = nextPage['@odata.nextLink']; + pageCount++; + } + + results.pagination.pages = pageCount; + results.pagination.totalItems = allMessages.length; + } catch (err) { + console.error(` ⚠ Pagination error: ${err.message}`); + } + + results.pagination.totalMs = performance.now() - paginateStart; + console.log(` ✅ Fetched ${results.pagination.totalItems} messages across ${results.pagination.pages} pages in ${(results.pagination.totalMs / 1000).toFixed(1)}s`); + + // ── Phase 3: Folder listing ── + console.log('\n📁 Phase 3: Folder listing...'); + const folderStart = performance.now(); + + try { + const folders = await listFolders(client); + results.folderList.count = folders.length; + } catch (err) { + console.error(` ⚠ Folder listing error: ${err.message}`); + } + + results.folderList.totalMs = performance.now() - folderStart; + console.log(` ✅ Listed ${results.folderList.count} folders in ${(results.folderList.totalMs / 1000).toFixed(1)}s`); + + // ── Phase 4: Cleanup ── + if (CLEANUP && createdDraftIds.length > 0) { + console.log(`\n🧹 Phase 4: Cleaning up ${createdDraftIds.length} drafts...`); + const cleanupStart = performance.now(); + + for (let i = 0; i < createdDraftIds.length; i++) { + try { + await client.delete(`${client.userPath}/messages/${createdDraftIds[i]}`); + results.cleanup.deleted++; + + if ((i + 1) % 25 === 0) { + console.log(` Deleted ${i + 1}/${createdDraftIds.length}...`); + } + } catch (err) { + results.cleanup.errors++; + if (err.statusCode === 429) { + await sleep(5000); + i--; // Retry + } + } + } + + results.cleanup.totalMs = performance.now() - cleanupStart; + console.log(` ✅ Cleaned up ${results.cleanup.deleted} drafts in ${(results.cleanup.totalMs / 1000).toFixed(1)}s`); + } + + // ── Report ── + console.log('\n' + '━'.repeat(60)); + console.log('📊 Results'); + console.log('━'.repeat(60)); + console.log(` Draft Creation:`); + console.log(` Count: ${results.draftCreation.count}/${DRAFT_COUNT}`); + console.log(` Time: ${(results.draftCreation.totalMs / 1000).toFixed(1)}s`); + console.log(` Throughput: ${(results.draftCreation.count / (results.draftCreation.totalMs / 1000)).toFixed(1)} drafts/sec`); + console.log(` Errors: ${results.draftCreation.errors}`); + console.log(''); + console.log(` Pagination:`); + console.log(` Pages: ${results.pagination.pages}`); + console.log(` Items: ${results.pagination.totalItems}`); + console.log(` Time: ${(results.pagination.totalMs / 1000).toFixed(1)}s`); + console.log(''); + console.log(` Folders: ${results.folderList.count} (${(results.folderList.totalMs / 1000).toFixed(1)}s)`); + + if (CLEANUP) { + console.log(''); + console.log(` Cleanup:`); + console.log(` Deleted: ${results.cleanup.deleted}/${createdDraftIds.length}`); + console.log(` Errors: ${results.cleanup.errors}`); + console.log(` Time: ${(results.cleanup.totalMs / 1000).toFixed(1)}s`); + } + + console.log('━'.repeat(60)); + + // Exit with error if significant failures + if (results.draftCreation.errors > DRAFT_COUNT * 0.1) { + console.error('❌ Too many draft creation errors (>10%).'); + process.exit(1); + } +} + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +main().catch(err => { + console.error(`❌ Scale test failed: ${err.message}`); + process.exit(1); +}); diff --git a/scripts/self-host-azure.ps1 b/scripts/self-host-azure.ps1 new file mode 100644 index 0000000..d0670c7 --- /dev/null +++ b/scripts/self-host-azure.ps1 @@ -0,0 +1,176 @@ +<# +.SYNOPSIS + Registers an Azure App for outlook-cli with the correct Microsoft Graph permissions. + +.DESCRIPTION + Creates an app registration in Microsoft Entra ID (formerly Azure Active Directory) + with all required delegated permissions for outlook-cli. Outputs the Client ID that + users need to authenticate. + + This is a one-time setup. The resulting app registration can be shared by everyone + in your organization — each person authenticates with their own Microsoft account. + +.NOTES + Requires: Az PowerShell module (will auto-install if missing) + Cost: Free (Azure App Registrations are always free) + Time: ~2 minutes + +.EXAMPLE + .\self-host-azure.ps1 + # Creates the app with default read/write permissions + +.EXAMPLE + .\self-host-azure.ps1 -AppName "my-outlook-tool" + # Creates with a custom app name + +.EXAMPLE + .\self-host-azure.ps1 -IncludeShared + # Also adds shared/delegate mailbox permissions +#> + +param( + [string]$AppName = "outlook-cli", + + # Add shared/delegate mailbox permissions (read/write/send on behalf of others) + [switch]$IncludeShared +) + +# ─── Check for Az module ─────────────────────────────────────────────────────── + +if (-not (Get-Module -ListAvailable -Name Az.Resources)) { + Write-Host "" + Write-Host " The Az PowerShell module is required but not installed." -ForegroundColor Yellow + Write-Host " Installing now (one-time setup, may take a minute)..." -ForegroundColor Yellow + Write-Host "" + Install-Module Az -Scope CurrentUser -Force -AllowClobber +} + +# ─── Login to Azure ──────────────────────────────────────────────────────────── + +Write-Host "" +Write-Host " Opening browser for Azure login..." -ForegroundColor Cyan +Write-Host "" +Connect-AzAccount | Out-Null + +# ─── Microsoft Graph Permission GUIDs ────────────────────────────────────────── +# +# These are the well-known GUIDs for Microsoft Graph delegated permissions. +# They are the same for every Azure tenant worldwide — Microsoft publishes them at: +# https://learn.microsoft.com/en-us/graph/permissions-reference +# +# You can also look up any permission GUID at: +# https://graphpermissions.merill.net/ +# +# Resource API ID: +# 00000003-0000-0000-c000-000000000000 = Microsoft Graph +# +# Permission Type: +# "Scope" = Delegated permission (acts as the signed-in user) +# "Role" = Application permission (acts as the app itself — NOT used here) +# + +$graphApiId = "00000003-0000-0000-c000-000000000000" # Microsoft Graph API resource ID + +# ── Core permissions (always included) ─────────────────────────────────────── +$permissions = @( + # GUID Permission Name What It Does + # ──────────────────────────────────── ─────────────────── ────────────────────────────────────────── + @{ Id = "e1fe6dd8-ba31-4d61-89e7-88639da4683d"; Type = "Scope" } # User.Read — Read signed-in user's profile (name, email, photo) + @{ Id = "570282fd-fa5c-430d-a7fd-fc8dc98a9dca"; Type = "Scope" } # Mail.Read — Read the user's email messages + @{ Id = "024d486e-b451-40bb-833d-3e66d98c5c73"; Type = "Scope" } # Mail.ReadWrite — Create drafts, move/flag/delete messages + @{ Id = "e383f46e-2787-4529-855e-0681a7b0274b"; Type = "Scope" } # Mail.Send — Send email as the signed-in user + @{ Id = "465a38f9-76ea-45b9-9f34-9e8b0d4b0b42"; Type = "Scope" } # Calendars.Read — Read the user's calendar events + @{ Id = "1ec239c2-d7c9-4623-a91a-a9775856bb36"; Type = "Scope" } # Calendars.ReadWrite— Create, update, and delete calendar events + @{ Id = "7ab1d382-f21e-4acd-a863-ba3e13f7da61"; Type = "Scope" } # offline_access — Maintain access (refresh tokens so user stays logged in) +) + +# ── Shared/delegate mailbox permissions (optional) ─────────────────────────── +if ($IncludeShared) { + $permissions += @( + # These let the user access OTHER people's mailboxes/calendars IF those + # people have explicitly granted delegate access via Outlook settings. + @{ Id = "7b9103a5-4610-446b-9670-80643382c1fa"; Type = "Scope" } # Mail.Read.Shared — Read another user's mailbox + @{ Id = "5df07973-7d5d-46ed-9847-1271055cbd51"; Type = "Scope" } # Mail.ReadWrite.Shared — Create drafts in another user's mailbox + @{ Id = "a367ab51-6b49-43bf-a36a-db3e02c856b0"; Type = "Scope" } # Mail.Send.Shared — Send on behalf of another user (shows "sent by X on behalf of Y") + @{ Id = "2b9c4f44-29d4-4c3f-b7a1-ce59344cebec"; Type = "Scope" } # Calendars.Read.Shared — Read another user's calendar + ) +} + +# ── NEVER include these (dangerous application-level permissions) ──────────── +# +# Mail.ReadWrite.All (a40ea23a-...) — Reads ALL users' mail without consent +# Calendars.ReadWrite.All — Reads ALL users' calendars without consent +# Mail.Send (as Application role) — Sends as ANY user without their consent +# +# These are "Application" permissions (Type = "Role"), not "Delegated" (Type = "Scope"). +# outlook-cli only uses delegated permissions, which require user sign-in and consent. + +# ─── Create the App Registration ─────────────────────────────────────────────── + +Write-Host " Creating app registration '$AppName'..." -ForegroundColor Cyan + +try { + $app = New-AzADApplication ` + -DisplayName $AppName ` + -SignInAudience "AzureADandPersonalMicrosoftAccount" ` + -SPARedirectUri @() ` + -PublicClientRedirectUri "http://localhost:53847/callback" ` + -IsFallbackPublicClient + + # Set the required Graph API permissions on the app manifest. + # Users still need to consent on first login (or admin can pre-consent for the org). + Update-AzADApplication -ObjectId $app.Id -RequiredResourceAccess @( + @{ + ResourceAppId = $graphApiId + ResourceAccess = $permissions + } + ) -ErrorAction SilentlyContinue + +} catch { + Write-Host "" + Write-Host " ✗ Failed to create app registration:" -ForegroundColor Red + Write-Host " $($_.Exception.Message)" -ForegroundColor Red + Write-Host "" + Write-Host " Common fixes:" -ForegroundColor Yellow + Write-Host " - Make sure you have permission to create apps in your Azure tenant" -ForegroundColor White + Write-Host " - For personal accounts, try the portal walkthrough instead:" -ForegroundColor White + Write-Host " See docs/self-hosting.md Section 2, Option A" -ForegroundColor White + Write-Host "" + exit 1 +} + +# ─── Output results ──────────────────────────────────────────────────────────── + +$clientId = $app.AppId + +Write-Host "" +Write-Host " ════════════════════════════════════════════════════════" -ForegroundColor Green +Write-Host " ✓ App registration created successfully!" -ForegroundColor Green +Write-Host " ════════════════════════════════════════════════════════" -ForegroundColor Green +Write-Host "" +Write-Host " App Name: $AppName" -ForegroundColor White +Write-Host " Client ID: $clientId" -ForegroundColor Yellow +Write-Host "" +Write-Host " ────────────────────────────────────────────────────────" -ForegroundColor DarkGray +Write-Host "" +Write-Host " Save this Client ID! You'll need it for the next step." -ForegroundColor White +Write-Host "" +Write-Host " Permissions configured:" -ForegroundColor Cyan +Write-Host " ✓ User.Read — Read user profile" -ForegroundColor White +Write-Host " ✓ Mail.Read — Read email" -ForegroundColor White +Write-Host " ✓ Mail.ReadWrite — Create drafts, organize email" -ForegroundColor White +Write-Host " ✓ Calendars.Read — Read calendar" -ForegroundColor White +Write-Host " ✓ Calendars.ReadWrite — Create/edit calendar events" -ForegroundColor White +Write-Host " ✓ offline_access — Stay logged in" -ForegroundColor White +Write-Host " ✓ Mail.Send — Send email directly" -ForegroundColor White +if ($IncludeShared) { + Write-Host " ✓ Mail.Read.Shared — Read delegate mailboxes" -ForegroundColor White + Write-Host " ✓ Mail.ReadWrite.Shared— Drafts in delegate mailboxes" -ForegroundColor White + Write-Host " ✓ Mail.Send.Shared — Send on behalf of delegates" -ForegroundColor White + Write-Host " ✓ Calendars.Read.Shared— Read delegate calendars" -ForegroundColor White +} +Write-Host "" +Write-Host " Next step — set up your account:" -ForegroundColor Cyan +Write-Host "" +Write-Host " .\self-host-client.ps1 -ClientId $clientId" -ForegroundColor White +Write-Host "" diff --git a/scripts/self-host-client.ps1 b/scripts/self-host-client.ps1 new file mode 100644 index 0000000..d78b20e --- /dev/null +++ b/scripts/self-host-client.ps1 @@ -0,0 +1,316 @@ +<# +.SYNOPSIS + Sets up outlook-cli on a user's machine — installs, authenticates, and verifies. + +.DESCRIPTION + Interactive script that walks a user through: + 1. Choosing Node.js or .NET implementation + 2. Installing dependencies (if needed) + 3. Adding an account with the organization's Client ID + 4. Authenticating with Microsoft + 5. Verifying everything works + + Designed for users who are not system engineers — friendly prompts, + clear error messages, and automatic recovery suggestions. + +.PARAMETER ClientId + The Azure App Client ID from self-host-azure.ps1 (or the Azure portal). + This is required — get it from your admin or run self-host-azure.ps1. + +.PARAMETER AccountAlias + A friendly name for this account (e.g., "work", "personal"). + Defaults to "default". + +.PARAMETER Tenant + Azure AD tenant for work/school accounts (e.g., "contoso.onmicrosoft.com"). + Defaults to "common" (works for both personal and work accounts). + +.PARAMETER Implementation + Which implementation to use: "node" or "dotnet". + If not specified, the script auto-detects based on what's available. + +.PARAMETER RepoPath + Path to the outlook-cli repository. Defaults to the current directory. + +.EXAMPLE + .\self-host-client.ps1 -ClientId "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + # Interactive setup with defaults + +.EXAMPLE + .\self-host-client.ps1 -ClientId "a1b2c3d4-..." -AccountAlias "work" -Tenant "contoso.onmicrosoft.com" + # Set up a work account with specific tenant + +.EXAMPLE + .\self-host-client.ps1 -ClientId "a1b2c3d4-..." -Implementation dotnet + # Use the .NET native binary +#> + +param( + [Parameter(Mandatory = $true, HelpMessage = "Azure App Client ID (from self-host-azure.ps1 or Azure portal)")] + [string]$ClientId, + + [string]$AccountAlias = "default", + + [string]$Tenant = "common", + + [ValidateSet("node", "dotnet", "auto")] + [string]$Implementation = "auto", + + [string]$RepoPath = "." +) + +$ErrorActionPreference = "Stop" + +# ─── Helper functions ────────────────────────────────────────────────────────── + +function Write-Step { + param([string]$Number, [string]$Title) + Write-Host "" + Write-Host " ── Step $Number: $Title ──" -ForegroundColor Cyan + Write-Host "" +} + +function Write-Ok { + param([string]$Message) + Write-Host " ✓ $Message" -ForegroundColor Green +} + +function Write-Warn { + param([string]$Message) + Write-Host " ⚠ $Message" -ForegroundColor Yellow +} + +function Write-Fail { + param([string]$Message) + Write-Host " ✗ $Message" -ForegroundColor Red +} + +# ─── Resolve repo path ───────────────────────────────────────────────────────── + +$RepoPath = Resolve-Path $RepoPath -ErrorAction SilentlyContinue +if (-not $RepoPath) { + Write-Fail "Directory not found: $RepoPath" + Write-Host " Run this script from the outlook-cli repository directory," -ForegroundColor White + Write-Host " or pass -RepoPath to specify the location." -ForegroundColor White + exit 1 +} + +# ─── Step 1: Detect/choose implementation ────────────────────────────────────── + +Write-Step "1" "Choosing implementation" + +$nodeAvailable = $false +$dotnetAvailable = $false +$cliCommand = $null + +# Check for Node.js +$nodeEntry = Join-Path $RepoPath "bin\outlook-cli.js" +if (Test-Path $nodeEntry) { + $nodeCheck = Get-Command node -ErrorAction SilentlyContinue + if ($nodeCheck) { + $nodeVersion = & node --version 2>$null + $majorVersion = [int]($nodeVersion -replace '^v(\d+).*', '$1') + if ($majorVersion -ge 18) { + $nodeAvailable = $true + Write-Ok "Node.js $nodeVersion found (meets minimum v18)" + } else { + Write-Warn "Node.js $nodeVersion is too old (need v18+)" + } + } else { + Write-Warn "Node.js not found in PATH" + } +} else { + Write-Warn "Node.js entry point not found at $nodeEntry" +} + +# Check for .NET native binary +$dotnetBinary = Join-Path $RepoPath "publish\win-x64\outlook-cli.exe" +if (Test-Path $dotnetBinary) { + $dotnetAvailable = $true + Write-Ok ".NET native binary found at $dotnetBinary" +} else { + # Check if we can build it + $dotnetSdk = Get-Command dotnet -ErrorAction SilentlyContinue + $csproj = Join-Path $RepoPath "src\dotnet\OutlookCli.csproj" + if ($dotnetSdk -and (Test-Path $csproj)) { + Write-Warn ".NET binary not built yet (can build with: dotnet run --project src\dotnet)" + # Can use dotnet run as fallback + $dotnetAvailable = $true + $dotnetBinary = $null # signal to use dotnet run + } +} + +# Choose implementation +if ($Implementation -eq "auto") { + if ($dotnetAvailable -and (Test-Path $dotnetBinary -ErrorAction SilentlyContinue)) { + $Implementation = "dotnet" + Write-Ok "Auto-selected: .NET native binary (fastest startup)" + } elseif ($nodeAvailable) { + $Implementation = "node" + Write-Ok "Auto-selected: Node.js" + } elseif ($dotnetAvailable) { + $Implementation = "dotnet" + Write-Ok "Auto-selected: .NET (via dotnet run)" + } else { + Write-Fail "No implementation available!" + Write-Host "" + Write-Host " Install one of these:" -ForegroundColor White + Write-Host " - Node.js 18+: https://nodejs.org" -ForegroundColor White + Write-Host " - .NET 8 SDK: https://dotnet.microsoft.com/download/dotnet/8.0" -ForegroundColor White + Write-Host "" + exit 1 + } +} + +# Build the CLI command +if ($Implementation -eq "node") { + if (-not $nodeAvailable) { + Write-Fail "Node.js implementation selected but Node.js 18+ is not available" + exit 1 + } + $cliCommand = "node `"$(Join-Path $RepoPath 'bin\outlook-cli.js')`"" + + # Check if npm install has been run + $nodeModules = Join-Path $RepoPath "node_modules" + if (-not (Test-Path $nodeModules)) { + Write-Host " Installing Node.js dependencies..." -ForegroundColor Cyan + Push-Location $RepoPath + & npm install --quiet 2>$null + Pop-Location + Write-Ok "Dependencies installed" + } +} else { + if ($dotnetBinary -and (Test-Path $dotnetBinary)) { + $cliCommand = "`"$dotnetBinary`"" + } else { + $cliCommand = "dotnet run --project `"$(Join-Path $RepoPath 'src\dotnet')`" --" + } +} + +Write-Host "" +Write-Host " Using: $Implementation" -ForegroundColor White +Write-Host " Command: $cliCommand" -ForegroundColor DarkGray + +# ─── Step 2: Add account ────────────────────────────────────────────────────── + +Write-Step "2" "Adding account '$AccountAlias'" + +$addArgs = "account add $AccountAlias --client-id $ClientId" +if ($Tenant -ne "common") { + $addArgs += " --tenant $Tenant" +} + +$addResult = Invoke-Expression "$cliCommand $addArgs" 2>&1 +$addExitCode = $LASTEXITCODE + +if ($addExitCode -eq 0) { + Write-Ok "Account '$AccountAlias' configured" +} else { + # Account may already exist — that's OK + if ($addResult -match "already exists") { + Write-Warn "Account '$AccountAlias' already exists (using existing configuration)" + } else { + Write-Host " $addResult" -ForegroundColor DarkGray + Write-Ok "Account configured (or already existed)" + } +} + +# ─── Step 3: Authenticate ───────────────────────────────────────────────────── + +Write-Step "3" "Authenticating with Microsoft" + +Write-Host " This will open your browser for Microsoft login." -ForegroundColor White +Write-Host " Sign in with the Microsoft account you want to use." -ForegroundColor White +Write-Host "" + +# Check if already authenticated +$statusResult = Invoke-Expression "$cliCommand auth status --account $AccountAlias" 2>&1 +if ($statusResult -match "yes") { + Write-Ok "Already authenticated!" + Write-Host " $($statusResult | Select-String 'Email')" -ForegroundColor DarkGray +} else { + Write-Host " Starting authentication..." -ForegroundColor Cyan + Write-Host "" + + # Try browser-based login first, fall back to device code + try { + Invoke-Expression "$cliCommand auth login --account $AccountAlias" + if ($LASTEXITCODE -ne 0) { throw "Login failed" } + Write-Ok "Authentication successful!" + } catch { + Write-Warn "Browser login failed. Trying device code flow..." + Write-Host "" + Invoke-Expression "$cliCommand auth login --account $AccountAlias --device-code" + if ($LASTEXITCODE -ne 0) { + Write-Fail "Authentication failed." + Write-Host "" + Write-Host " Troubleshooting:" -ForegroundColor Yellow + Write-Host " - Check that 'Allow public client flows' is enabled in the Azure app" -ForegroundColor White + Write-Host " - Verify the Client ID is correct: $ClientId" -ForegroundColor White + Write-Host " - See docs/self-hosting.md Section 12 for more help" -ForegroundColor White + Write-Host "" + exit 1 + } + Write-Ok "Authentication successful!" + } +} + +# ─── Step 4: Verify ─────────────────────────────────────────────────────────── + +Write-Step "4" "Verifying connection" + +# Check auth status +Write-Host " Checking authentication..." -ForegroundColor DarkGray +$statusResult = Invoke-Expression "$cliCommand auth status --account $AccountAlias" 2>&1 +if ($statusResult -match "yes") { + Write-Ok "Authenticated" + $email = ($statusResult | Select-String 'Email').ToString().Trim() + Write-Host " $email" -ForegroundColor DarkGray +} else { + Write-Fail "Authentication check failed" + Write-Host " $statusResult" -ForegroundColor DarkGray + exit 1 +} + +# Try reading inbox +Write-Host " Reading inbox..." -ForegroundColor DarkGray +$inboxResult = Invoke-Expression "$cliCommand mail inbox --top 3 --account $AccountAlias" 2>&1 +if ($LASTEXITCODE -eq 0) { + Write-Ok "Inbox access works" + Write-Host "" + Write-Host " Your latest messages:" -ForegroundColor White + $inboxResult | ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray } +} else { + Write-Warn "Could not read inbox (this might be OK if it's empty)" +} + +# Try reading calendar +Write-Host "" +Write-Host " Reading calendar..." -ForegroundColor DarkGray +$calResult = Invoke-Expression "$cliCommand calendar today --account $AccountAlias" 2>&1 +if ($LASTEXITCODE -eq 0) { + Write-Ok "Calendar access works" +} else { + Write-Warn "Could not read calendar (might be empty or permissions pending)" +} + +# ─── Done! ───────────────────────────────────────────────────────────────────── + +Write-Host "" +Write-Host " ════════════════════════════════════════════════════════" -ForegroundColor Green +Write-Host " ✓ Setup complete! outlook-cli is ready to use." -ForegroundColor Green +Write-Host " ════════════════════════════════════════════════════════" -ForegroundColor Green +Write-Host "" +Write-Host " Try these commands:" -ForegroundColor Cyan +Write-Host "" +Write-Host " $cliCommand mail inbox # List inbox" -ForegroundColor White +Write-Host " $cliCommand mail inbox --unread # Unread only" -ForegroundColor White +Write-Host " $cliCommand mail search `"hello`" # Search email" -ForegroundColor White +Write-Host " $cliCommand calendar today # Today's events" -ForegroundColor White +Write-Host " $cliCommand calendar week # This week" -ForegroundColor White +Write-Host " $cliCommand mail folders # List folders" -ForegroundColor White +Write-Host " $cliCommand contacts search `"John`" # Find contacts" -ForegroundColor White +Write-Host "" +Write-Host " Full reference: docs/usage.md" -ForegroundColor DarkGray +Write-Host " All options: $cliCommand --help" -ForegroundColor DarkGray +Write-Host "" diff --git a/scripts/test-report.js b/scripts/test-report.js new file mode 100644 index 0000000..47c1d3c --- /dev/null +++ b/scripts/test-report.js @@ -0,0 +1,299 @@ +#!/usr/bin/env node + +/** + * Test Report Generator — reads vitest JSON output and produces a + * comprehensive test dashboard with statistics, timing, and coverage gaps. + * + * Usage: + * node scripts/test-report.js # Console table (default) + * node scripts/test-report.js --json # JSON output + * node scripts/test-report.js --markdown # Markdown (for PR comments) + * node scripts/test-report.js --input FILE # Custom input file + */ + +import { readFileSync, existsSync } from 'fs'; +import { join, relative, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const PROJECT_ROOT = join(__dirname, '..'); + +const DEFAULT_REPORT = join(PROJECT_ROOT, 'test-results', 'vitest-report.json'); + +function parseArgs() { + const args = process.argv.slice(2); + let format = 'console'; + let inputFile = DEFAULT_REPORT; + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--json') format = 'json'; + else if (args[i] === '--markdown') format = 'markdown'; + else if (args[i] === '--input' && args[i + 1]) inputFile = args[++i]; + } + return { format, inputFile }; +} + +/** Categorize a test file by its path. */ +function categorize(filePath) { + const rel = relative(PROJECT_ROOT, filePath).replace(/\\/g, '/'); + if (rel.startsWith('test/integration/')) return 'integration'; + if (rel.startsWith('test/stress/soak/')) return 'soak'; + if (rel.startsWith('test/stress/performance/')) return 'performance'; + if (rel.startsWith('test/stress/')) return 'stress'; + if (rel.startsWith('test/unit/')) return 'unit'; + if (rel.startsWith('test/shared/')) return 'shared'; + return 'other'; +} + +/** Extract module name from test file path (e.g., "test/unit/graph/mail.test.js" → "graph"). */ +function extractModule(filePath) { + const rel = relative(PROJECT_ROOT, filePath).replace(/\\/g, '/'); + const parts = rel.split('/'); + // test///file.test.js → module + if (parts.length >= 4) return parts[2]; + // test//file.test.js → file + if (parts.length >= 3) return parts[2].replace(/\.test\.\w+$/, ''); + return 'unknown'; +} + +/** Build the report data structure from vitest JSON. */ +function buildReport(data) { + const testSuites = data.testResults || []; + + let totalTests = 0, passed = 0, failed = 0, skipped = 0; + const fileResults = []; + const categoryCounts = {}; + const moduleCounts = {}; + const allTests = []; + + for (const suite of testSuites) { + const filePath = suite.name || suite.filename || ''; + const category = categorize(filePath); + const mod = extractModule(filePath); + const rel = relative(PROJECT_ROOT, filePath).replace(/\\/g, '/'); + + let filePass = 0, fileFail = 0, fileSkip = 0; + const assertions = suite.assertionResults || suite.testResults || []; + + for (const test of assertions) { + totalTests++; + const status = test.status || test.state; + if (status === 'passed') { passed++; filePass++; } + else if (status === 'failed') { failed++; fileFail++; } + else { skipped++; fileSkip++; } + + allTests.push({ + name: test.fullName || test.title || test.name || '', + file: rel, + category, + module: mod, + status, + duration: test.duration || 0, + }); + } + + const fileDuration = suite.endTime && suite.startTime + ? suite.endTime - suite.startTime + : (suite.duration || 0); + + fileResults.push({ + file: rel, + category, + module: mod, + tests: filePass + fileFail + fileSkip, + passed: filePass, + failed: fileFail, + skipped: fileSkip, + duration: fileDuration, + }); + + categoryCounts[category] = (categoryCounts[category] || 0) + filePass + fileFail + fileSkip; + moduleCounts[mod] = (moduleCounts[mod] || 0) + filePass + fileFail + fileSkip; + } + + // Top 10 slowest tests + const slowest = [...allTests] + .sort((a, b) => b.duration - a.duration) + .slice(0, 10); + + // Failed tests detail + const failures = allTests.filter(t => t.status === 'failed'); + + const totalDuration = data.startTime && data.endTime + ? data.endTime - data.startTime + : fileResults.reduce((sum, f) => sum + f.duration, 0); + + return { + summary: { + totalTests, + passed, + failed, + skipped, + passRate: totalTests ? ((passed / totalTests) * 100).toFixed(1) : '0.0', + totalDuration, + totalFiles: fileResults.length, + }, + categoryCounts, + moduleCounts, + fileResults: fileResults.sort((a, b) => a.file.localeCompare(b.file)), + slowest, + failures, + }; +} + +/** Pad string to width for console tables. */ +function pad(str, width, align = 'left') { + const s = String(str); + if (align === 'right') return s.padStart(width); + return s.padEnd(width); +} + +/** Format duration in ms to human-readable. */ +function fmtDuration(ms) { + if (ms >= 60000) return `${(ms / 60000).toFixed(1)}m`; + if (ms >= 1000) return `${(ms / 1000).toFixed(1)}s`; + return `${Math.round(ms)}ms`; +} + +function renderConsole(report) { + const { summary, categoryCounts, fileResults, slowest, failures } = report; + const lines = []; + + lines.push(''); + lines.push('═══════════════════════════════════════════════════════════'); + lines.push(' OUTLOOK-CLI TEST REPORT '); + lines.push('═══════════════════════════════════════════════════════════'); + lines.push(''); + + // Summary + const icon = summary.failed > 0 ? '✗' : '✓'; + lines.push(` ${icon} ${summary.passed}/${summary.totalTests} passed (${summary.passRate}%) | ${summary.failed} failed | ${summary.skipped} skipped`); + lines.push(` ${summary.totalFiles} test files | Total: ${fmtDuration(summary.totalDuration)}`); + lines.push(''); + + // Category breakdown + lines.push(' Categories:'); + for (const [cat, count] of Object.entries(categoryCounts).sort()) { + lines.push(` ${pad(cat, 14)} ${pad(count, 5, 'right')} tests`); + } + lines.push(''); + + // Per-file breakdown (only if reasonably small, or show failures + top files) + if (failures.length > 0) { + lines.push(' ✗ FAILURES:'); + for (const f of failures) { + lines.push(` ${f.file}`); + lines.push(` ${f.name}`); + } + lines.push(''); + } + + // Slowest tests + if (slowest.length > 0) { + lines.push(' Slowest Tests:'); + for (const t of slowest) { + lines.push(` ${pad(fmtDuration(t.duration), 8, 'right')} ${t.name.substring(0, 70)}`); + lines.push(` ${t.file}`); + } + lines.push(''); + } + + // File summary table + lines.push(' File Results:'); + lines.push(` ${'File'.padEnd(55)} ${'Tests'.padStart(5)} ${'Pass'.padStart(5)} ${'Fail'.padStart(5)} ${'Time'.padStart(8)}`); + lines.push(` ${'─'.repeat(55)} ${'─'.repeat(5)} ${'─'.repeat(5)} ${'─'.repeat(5)} ${'─'.repeat(8)}`); + for (const f of fileResults) { + const shortFile = f.file.length > 55 ? '…' + f.file.slice(-54) : f.file; + const failMark = f.failed > 0 ? '✗' : ' '; + lines.push(` ${failMark} ${pad(shortFile, 55)} ${pad(f.tests, 5, 'right')} ${pad(f.passed, 5, 'right')} ${pad(f.failed, 5, 'right')} ${pad(fmtDuration(f.duration), 8, 'right')}`); + } + lines.push(''); + lines.push('═══════════════════════════════════════════════════════════'); + + return lines.join('\n'); +} + +function renderMarkdown(report) { + const { summary, categoryCounts, fileResults, slowest, failures } = report; + const lines = []; + + lines.push('## 📊 Outlook-CLI Test Report'); + lines.push(''); + const icon = summary.failed > 0 ? '❌' : '✅'; + lines.push(`${icon} **${summary.passed}/${summary.totalTests} passed** (${summary.passRate}%) | ${summary.failed} failed | ${summary.skipped} skipped | ${fmtDuration(summary.totalDuration)}`); + lines.push(''); + + // Categories + lines.push('### Categories'); + lines.push('| Category | Tests |'); + lines.push('|----------|------:|'); + for (const [cat, count] of Object.entries(categoryCounts).sort()) { + lines.push(`| ${cat} | ${count} |`); + } + lines.push(''); + + // Failures + if (failures.length > 0) { + lines.push('### ❌ Failures'); + for (const f of failures) { + lines.push(`- **${f.name}** — \`${f.file}\``); + } + lines.push(''); + } + + // Slowest tests + if (slowest.length > 0) { + lines.push('### 🐢 Slowest Tests'); + lines.push('| Duration | Test | File |'); + lines.push('|---------:|------|------|'); + for (const t of slowest) { + lines.push(`| ${fmtDuration(t.duration)} | ${t.name.substring(0, 60)} | \`${t.file}\` |`); + } + lines.push(''); + } + + // File table + lines.push('
📁 Per-file results'); + lines.push(''); + lines.push('| File | Tests | Pass | Fail | Duration |'); + lines.push('|------|------:|-----:|-----:|---------:|'); + for (const f of fileResults) { + const icon = f.failed > 0 ? '❌' : '✅'; + lines.push(`| ${icon} \`${f.file}\` | ${f.tests} | ${f.passed} | ${f.failed} | ${fmtDuration(f.duration)} |`); + } + lines.push('
'); + + return lines.join('\n'); +} + +function renderJson(report) { + return JSON.stringify(report, null, 2); +} + +// ─── Main ──────────────────────────────────────────────────────────── + +const { format, inputFile } = parseArgs(); + +if (!existsSync(inputFile)) { + console.error(`Error: Test report not found at ${inputFile}`); + console.error('Run tests first: npm run test:report'); + process.exit(1); +} + +let data; +try { + data = JSON.parse(readFileSync(inputFile, 'utf-8')); +} catch (err) { + console.error(`Error: Could not parse ${inputFile}: ${err.message}`); + process.exit(1); +} + +const report = buildReport(data); + +switch (format) { + case 'json': console.log(renderJson(report)); break; + case 'markdown': console.log(renderMarkdown(report)); break; + default: console.log(renderConsole(report)); break; +} + +process.exit(report.summary.failed > 0 ? 1 : 0); diff --git a/skill/nanoclaw/SKILL.md b/skill/nanoclaw/SKILL.md new file mode 100644 index 0000000..a36b5d3 --- /dev/null +++ b/skill/nanoclaw/SKILL.md @@ -0,0 +1,164 @@ +# Outlook Skill — NanoClaw + +Access Microsoft Outlook email, calendar, and contacts from NanoClaw agent containers. + +## Quick Start + +```bash +# One-line setup inside a NanoClaw container: +./skill/nanoclaw/setup.sh --client-id YOUR_CLIENT_ID --bin /opt/outlook-cli/bin/outlook-cli.js + +# Start the email channel (webhook-based, near-instant): +./skill/nanoclaw/gateway.sh --bin /opt/outlook-cli/bin/outlook-cli.js +``` + +## Security + +- **Configurable permissions.** Scope restrictions per account via `accounts.json`. Read-only accounts prevent all write operations. +- **PII redaction.** Use `--redact` to strip sensitive data before the agent sees email content. +- **All write operations use `--yes --json`** to skip interactive prompts. +- Token cache is AES-256-GCM encrypted. Set `OUTLOOK_CLI_PASSPHRASE` for consistent encryption across container restarts. + +## Channel Integration + +NanoClaw agents need near-instant email delivery. The `gateway.sh` script automatically: + +1. Starts outlook-cli in **webhook mode** (push notifications from Microsoft Graph, ~1–5 second latency) +2. Outputs JSONL events to stdout for NanoClaw to consume as a channel +3. Falls back to **fast-poll mode** (5-second adaptive polling) if no tunnel tool is installed +4. Handles subscription renewal, reconciliation, and graceful shutdown + +**No manual watcher configuration, job files, or cron setup needed.** + +### NanoClaw Container Configuration + +```yaml +# NanoClaw container config +volumes: + - ~/.outlook-cli:/root/.outlook-cli # Persist tokens across restarts + +environment: + OUTLOOK_CLI_CLIENT_ID: "your-client-id" + OUTLOOK_CLI_PASSPHRASE: "your-secure-passphrase" + +channels: + - name: outlook + type: process + command: /opt/outlook-cli/skill/nanoclaw/gateway.sh + format: jsonl +``` + +### Container Setup (Dockerfile or setupCommand) + +```dockerfile +# Option 1: Node.js runtime (install from npm or copy files) +COPY outlook-cli/ /opt/outlook-cli/ +RUN cd /opt/outlook-cli && npm install --production + +# Option 2: Native binary (no runtime needed, ~10 MB) +COPY publish/linux-x64/outlook-cli /usr/local/bin/outlook-cli +``` + +Or in setupCommand: + +```bash +# Copy just the binary (no git clone needed): +cp /shared/outlook-cli /usr/local/bin/outlook-cli +chmod +x /usr/local/bin/outlook-cli +``` + +### Initial Authentication + +Since containers typically don't have a browser, use device code flow: + +```bash +outlook-cli auth login --device-code --client-id YOUR_CLIENT_ID +``` + +Go to [https://microsoft.com/devicelogin](https://microsoft.com/devicelogin) on any device, enter the code, and sign in. After this one-time setup, tokens refresh automatically. + +### Environment Variables + +| Variable | Purpose | Required | +|---|---|---| +| `OUTLOOK_CLI_CLIENT_ID` | Azure app client ID | Yes (or configure via `account add`) | +| `OUTLOOK_CLI_PASSPHRASE` | Token cache encryption passphrase | Recommended | +| `OUTLOOK_CLI_BIN` | Path to outlook-cli binary | No (auto-detected) | + +## CLAUDE.md Integration + +Add to your agent's `CLAUDE.md`: + +```markdown +## Outlook Access + +You have access to `outlook-cli` for reading and managing Outlook email and calendar. + +Commands (always use --json for structured output, --yes to skip prompts): +- `outlook-cli mail inbox --json` — List inbox +- `outlook-cli mail inbox --json --redact` — List inbox with PII redacted +- `outlook-cli mail read MESSAGE_ID --json --plain` — Read a message +- `outlook-cli mail read MESSAGE_ID --json --redact` — Read with PII redacted +- `outlook-cli mail read MESSAGE_ID --json --attachments` — Read with attachment list +- `outlook-cli mail attachments MESSAGE_ID --json` — List attachment metadata +- `outlook-cli mail search "query" --json` — Search messages +- `outlook-cli mail draft --to "addr" --subject "subj" --body "text" --yes --json` — Create draft +- `outlook-cli mail draft --to "addr" --subject "subj" --body "text" --attach file.pdf --yes --json` — Draft with attachment +- `outlook-cli mail reply MESSAGE_ID --body "text" --yes --json` — Reply +- `outlook-cli calendar today --json` — Today's events +- `outlook-cli contacts search "name" --json` — Find contacts + +Permissions are configurable per account. Read-only accounts can only read mail and calendar. +``` + +## Available Commands + +### Email — Read + +```bash +outlook-cli mail inbox --json +outlook-cli mail inbox --unread --json +outlook-cli mail inbox --top 50 --json # Pagination +outlook-cli mail inbox --page next --json # Auto-paginate +outlook-cli mail inbox --json --redact # PII-safe +outlook-cli mail read MESSAGE_ID --json --plain +outlook-cli mail read MESSAGE_ID --json --attachments # With attachment list +outlook-cli mail read MESSAGE_ID --json --redact # PII-safe +outlook-cli mail search "search query" --json +outlook-cli mail folders --json +outlook-cli mail attachments MESSAGE_ID --json # List attachments +outlook-cli mail download-attachment MSG_ID ATT_ID --output-dir ./downloads +``` + +### Email — Write + +```bash +outlook-cli mail draft --to "recipient@example.com" --subject "Subject" --body "Body" --yes --json +outlook-cli mail draft --to "..." --subject "..." --body "..." --attach file.pdf --yes --json +outlook-cli mail reply MESSAGE_ID --body "Reply text" --yes --json +outlook-cli mail forward MESSAGE_ID --to "recipient@example.com" --yes --json +outlook-cli mail send MESSAGE_ID --yes --json +outlook-cli mail move MESSAGE_ID --folder "Archive" --yes --json +outlook-cli mail delete MESSAGE_ID --yes --json +outlook-cli mail flag MESSAGE_ID --json +outlook-cli mail mark-read MESSAGE_ID --json +``` + +### Calendar + +```bash +outlook-cli calendar today --json +outlook-cli calendar week --json +outlook-cli calendar view EVENT_ID --json +outlook-cli calendar create --subject "Meeting" --start "2026-01-15T14:00:00" --end "2026-01-15T15:00:00" --yes --json +``` + +### Contacts + +```bash +outlook-cli contacts search "John" --json +``` + +## OneCLI Agent Vault Integration + +If using NanoClaw's credential proxy (OneCLI Agent Vault), configure it to inject `OUTLOOK_CLI_CLIENT_ID` and `OUTLOOK_CLI_PASSPHRASE` at request time. This keeps credentials out of the container filesystem. diff --git a/skill/nanoclaw/gateway.sh b/skill/nanoclaw/gateway.sh new file mode 100644 index 0000000..308311e --- /dev/null +++ b/skill/nanoclaw/gateway.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +# +# Start the outlook-cli email channel for NanoClaw. +# +# Outputs JSONL events to stdout for NanoClaw to consume as a channel. +# Uses webhook mode for near-instant delivery, or fast-poll as fallback. +# +# Usage: +# ./gateway.sh [--bin /path/to/outlook-cli] [options] +# +# Options: +# --bin PATH Path to outlook-cli binary (auto-detected if omitted) +# --account ALIAS Account alias (default: nanoclaw) +# --folder FOLDER Mail folder to watch (default: Inbox) +# --mode MODE Watch mode: webhook, fast-poll, or auto (default: auto) +# --tunnel TYPE Tunnel type: cloudflared, localtunnel, ngrok (default: cloudflared) +# +# NanoClaw container config: +# channels: +# - name: outlook +# type: process +# command: /opt/outlook-cli/skill/nanoclaw/gateway.sh +# format: jsonl +# +set -euo pipefail + +BIN="" +ACCOUNT="nanoclaw" +FOLDER="Inbox" +MODE="auto" +TUNNEL="cloudflared" + +while [[ $# -gt 0 ]]; do + case $1 in + --bin) BIN="$2"; shift 2 ;; + --account) ACCOUNT="$2"; shift 2 ;; + --folder) FOLDER="$2"; shift 2 ;; + --mode) MODE="$2"; shift 2 ;; + --tunnel) TUNNEL="$2"; shift 2 ;; + *) echo "Unknown option: $1" >&2; exit 1 ;; + esac +done + +# ── Resolve outlook-cli binary ────────────────────────────────────────────── + +find_outlook_cli() { + if [[ -n "$BIN" ]]; then + if [[ -x "$BIN" ]] || [[ -f "$BIN" ]]; then echo "$BIN"; return; fi + echo "Error: outlook-cli not found at: $BIN" >&2; exit 1 + fi + if command -v outlook-cli &>/dev/null; then command -v outlook-cli; return; fi + if [[ -n "${OUTLOOK_CLI_BIN:-}" ]] && [[ -f "$OUTLOOK_CLI_BIN" ]]; then echo "$OUTLOOK_CLI_BIN"; return; fi + + local script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + for c in "/usr/local/bin/outlook-cli" "/opt/outlook-cli/bin/outlook-cli.js" "$script_dir/../../publish/linux-x64/outlook-cli" "$script_dir/../../bin/outlook-cli.js"; do + if [[ -f "$c" ]]; then echo "$c"; return; fi + done + echo "Error: outlook-cli not found. Set --bin or OUTLOOK_CLI_BIN." >&2; exit 1 +} + +CLI=$(find_outlook_cli) + +run_cli() { + if [[ "$CLI" == *.js ]]; then node "$CLI" "$@"; else "$CLI" "$@"; fi +} + +# ── Determine watch mode ─────────────────────────────────────────────────── + +if [[ "$MODE" == "auto" ]]; then + if command -v "$TUNNEL" &>/dev/null; then + MODE="webhook" + else + MODE="fast-poll" + echo "⚠ $TUNNEL not found — using fast-poll mode (5s adaptive)" >&2 + fi +fi + +# ── Build command arguments ──────────────────────────────────────────────── + +WATCH_ARGS=( + watch + --mode "$MODE" + --jsonl + --no-calendar + --folder "$FOLDER" + --account "$ACCOUNT" + --heartbeat +) + +if [[ "$MODE" == "webhook" ]]; then + WATCH_ARGS+=(--tunnel "$TUNNEL") +elif [[ "$MODE" == "fast-poll" ]]; then + WATCH_ARGS+=(--interval 10) +fi + +# ── Start (all output to stdout as JSONL, status to stderr) ──────────────── + +echo "outlook-cli channel starting..." >&2 +echo " Binary: $CLI" >&2 +echo " Mode: $MODE" >&2 +echo " Account: $ACCOUNT" >&2 +echo " Folder: $FOLDER" >&2 + +exec run_cli "${WATCH_ARGS[@]}" diff --git a/skill/nanoclaw/setup.sh b/skill/nanoclaw/setup.sh new file mode 100644 index 0000000..f6f1ac2 --- /dev/null +++ b/skill/nanoclaw/setup.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +# +# Setup outlook-cli for NanoClaw container integration. +# +# Usage: +# ./setup.sh --client-id YOUR_CLIENT_ID [--bin /path/to/outlook-cli] [options] +# +# Options: +# --client-id ID Azure App Registration client ID (required) +# --bin PATH Path to outlook-cli binary (auto-detected if omitted) +# --account ALIAS Account alias (default: nanoclaw) +# --tenant TENANT Azure tenant (default: common) +# --passphrase PASS Token cache passphrase (recommended for containers) +# +# This script is designed to run inside a NanoClaw container during setup. +# It uses device code flow since containers typically don't have a browser. +# +set -euo pipefail + +CLIENT_ID="" +BIN="" +ACCOUNT="nanoclaw" +TENANT="common" +PASSPHRASE="" + +while [[ $# -gt 0 ]]; do + case $1 in + --client-id) CLIENT_ID="$2"; shift 2 ;; + --bin) BIN="$2"; shift 2 ;; + --account) ACCOUNT="$2"; shift 2 ;; + --tenant) TENANT="$2"; shift 2 ;; + --passphrase) PASSPHRASE="$2"; shift 2 ;; + *) echo "Unknown option: $1" >&2; exit 1 ;; + esac +done + +if [[ -z "$CLIENT_ID" ]]; then + CLIENT_ID="${OUTLOOK_CLI_CLIENT_ID:-}" +fi + +if [[ -z "$CLIENT_ID" ]]; then + echo "Error: --client-id or OUTLOOK_CLI_CLIENT_ID is required" >&2 + exit 1 +fi + +# Set passphrase for consistent encryption in containers +if [[ -n "$PASSPHRASE" ]]; then + export OUTLOOK_CLI_PASSPHRASE="$PASSPHRASE" +elif [[ -z "${OUTLOOK_CLI_PASSPHRASE:-}" ]]; then + echo "⚠ Warning: OUTLOOK_CLI_PASSPHRASE not set. Token cache encryption will use" >&2 + echo " a machine-derived key that may change across container restarts." >&2 + echo " Set --passphrase or OUTLOOK_CLI_PASSPHRASE for reliable persistence." >&2 +fi + +# ── Resolve outlook-cli binary ────────────────────────────────────────────── + +find_outlook_cli() { + if [[ -n "$BIN" ]]; then + if [[ -x "$BIN" ]] || [[ -f "$BIN" ]]; then echo "$BIN"; return; fi + echo "Error: outlook-cli not found at: $BIN" >&2; exit 1 + fi + if command -v outlook-cli &>/dev/null; then command -v outlook-cli; return; fi + if [[ -n "${OUTLOOK_CLI_BIN:-}" ]] && [[ -f "$OUTLOOK_CLI_BIN" ]]; then echo "$OUTLOOK_CLI_BIN"; return; fi + + local script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + for c in "/usr/local/bin/outlook-cli" "/opt/outlook-cli/bin/outlook-cli.js" "$script_dir/../../publish/linux-x64/outlook-cli" "$script_dir/../../bin/outlook-cli.js"; do + if [[ -f "$c" ]]; then echo "$c"; return; fi + done + echo "Error: outlook-cli not found. Set --bin or OUTLOOK_CLI_BIN." >&2; exit 1 +} + +CLI=$(find_outlook_cli) + +run_cli() { + if [[ "$CLI" == *.js ]]; then node "$CLI" "$@"; else "$CLI" "$@"; fi +} + +echo "" +echo "╔══════════════════════════════════════════════════╗" +echo "║ outlook-cli — NanoClaw Container Setup ║" +echo "╚══════════════════════════════════════════════════╝" +echo "" +echo "Binary: $CLI" +echo "" + +# ── Step 1: Verify ────────────────────────────────────────────────────────── + +echo "Step 1/3: Verifying outlook-cli..." +VERSION=$(run_cli --version 2>&1) || { echo "Error: outlook-cli failed to run"; exit 1; } +echo " ✓ outlook-cli $VERSION" + +# ── Step 2: Configure account ─────────────────────────────────────────────── + +echo "Step 2/3: Configuring account '$ACCOUNT'..." +run_cli account add "$ACCOUNT" --client-id "$CLIENT_ID" --tenant "$TENANT" 2>&1 || true +echo " ✓ Account '$ACCOUNT' configured" + +# ── Step 3: Authenticate (device code — containers have no browser) ──────── + +echo "Step 3/3: Authenticating via device code..." +echo " Follow the instructions below to complete authentication:" +echo "" + +run_cli auth login --account "$ACCOUNT" --device-code || { + echo "Error: Authentication failed" >&2 + exit 1 +} +echo " ✓ Authenticated" + +# ── Done ──────────────────────────────────────────────────────────────────── + +echo "" +echo "═══════════════════════════════════════════════════" +echo " Setup complete!" +echo "═══════════════════════════════════════════════════" +echo "" +echo "Start the NanoClaw email channel:" +echo " ./skill/nanoclaw/gateway.sh --bin \"$CLI\"" +echo "" +echo "Or add to your NanoClaw container config:" +echo " channels:" +echo " - name: outlook" +echo " type: process" +echo " command: /opt/outlook-cli/skill/nanoclaw/gateway.sh" +echo " format: jsonl" +echo "" diff --git a/skill/openclaw/SKILL.md b/skill/openclaw/SKILL.md new file mode 100644 index 0000000..f17efdd --- /dev/null +++ b/skill/openclaw/SKILL.md @@ -0,0 +1,205 @@ +--- +name: outlook +description: Read and manage Microsoft Outlook email, calendar, and contacts via Graph API. Supports attachments, PII redaction, pagination, and multi-account delegate access. +metadata: {"openclaw": {"requires": {"bins": ["outlook-cli"]}, "os": ["darwin", "linux", "win32"]}} +--- + +# Outlook Skill — OpenClaw + +Access Microsoft Outlook email, calendar, and contacts. This skill wraps the `outlook-cli` command-line tool, providing the agent with full email management capabilities including attachments, PII-safe content, and large mailbox pagination. + +## Quick Start + +```powershell +# Windows — one-line setup: +.\skill\openclaw\setup.ps1 -ClientId YOUR_CLIENT_ID -OutlookCliBin "C:\tools\outlook-cli.exe" + +# Start the email gateway (webhook-based, near-instant): +.\skill\openclaw\gateway.ps1 -OutlookCliBin "C:\tools\outlook-cli.exe" +``` + +```bash +# Linux/macOS — one-line setup: +./skill/openclaw/setup.sh --client-id YOUR_CLIENT_ID --bin /usr/local/bin/outlook-cli + +# Start the email gateway: +./skill/openclaw/gateway.sh --bin /usr/local/bin/outlook-cli +``` + +## Security + +- **Configurable permissions:** Scope restrictions per account via `accounts.json`. Read-only accounts cannot send, draft, or modify messages. +- **PII redaction:** Use `--redact` to strip email addresses, phone numbers, credit cards, SSNs, and API keys before passing content to the agent. See [PII Redaction](#pii-redaction-for-agent-safety). +- **Multi-account isolation:** Use `--account ` to target specific accounts. Each account has independent token caches and permission scopes. +- **Forbidden scope:** `Mail.ReadWrite.All` (application-level access to all mailboxes) is permanently blocked. +- **All write operations use `--yes --json`** to skip interactive prompts when invoked by the agent. + +## Recommended Two-Account Pattern + +For agent deployments, configure two accounts: + +1. **Primary account (read-only):** Monitor the user's inbox for messages addressed to the agent. +2. **Agent account (read-write):** Send responses, create drafts, manage calendar. + +```bash +# Setup +outlook-cli auth login --account primary --mode read-only # User's mailbox +outlook-cli auth login --account agent # Agent's mailbox + +# Agent reads user's inbox +outlook-cli mail inbox --account primary --json --redact + +# Agent responds from its own account +outlook-cli mail draft --account agent --to "user@example.com" --subject "Re: Request" --body "Done" --yes --json +``` + +This ensures the agent cannot modify the user's mailbox, only read it. + +## Gateway Integration + +OpenClaw gateways expect near-instant message delivery. The `gateway.ps1` / `gateway.sh` scripts automatically: + +1. Start outlook-cli in **webhook mode** (push notifications from Microsoft Graph) +2. Pipe JSONL events to the OpenClaw gateway endpoint +3. Handle tunnel setup (cloudflared), subscription management, and auto-renewal +4. Fall back to fast-poll mode if no tunnel tool is available + +**No manual watcher or job configuration is needed.** + +### OpenClaw Configuration + +```yaml +# In your OpenClaw agent configuration +channels: + - name: outlook-email + type: process + command: outlook-cli watch --mode webhook --jsonl --no-calendar + format: jsonl + +skills: + - name: outlook + type: cli + binary: outlook-cli + description: "Read and manage Microsoft Outlook email and calendar" +``` + +### Environment Variables + +| Variable | Purpose | Required | +|---|---|---| +| `OUTLOOK_CLI_CLIENT_ID` | Azure app client ID | Yes (or pass via `--client-id`) | +| `OUTLOOK_CLI_PASSPHRASE` | Token cache encryption passphrase | Recommended in containers | +| `OUTLOOK_CLI_BIN` | Path to outlook-cli binary | No (defaults to `outlook-cli` on PATH) | +| `OPENCLAW_GATEWAY_URL` | Gateway webhook endpoint | No (defaults to `http://localhost:8080/incoming`) | + +## Available Commands + +### Email — Read + +```bash +outlook-cli mail inbox --json # Latest 25 messages +outlook-cli mail inbox --unread --json # Unread only +outlook-cli mail inbox --top 50 --json # First 50 +outlook-cli mail inbox --top 25 --skip 25 --json # Page 2 +outlook-cli mail inbox --page next --json # Next page (auto) +outlook-cli mail inbox --redact --json # PII-safe output +outlook-cli mail read MESSAGE_ID --json --plain # Full message +outlook-cli mail read MESSAGE_ID --json --redact # PII-safe full message +outlook-cli mail read MESSAGE_ID --json --attachments # Include attachment list +outlook-cli mail read MESSAGE_ID --json --body-preview # Preview only (~255 chars) +outlook-cli mail read MESSAGE_ID --json --truncate 1000 # First 1000 chars +outlook-cli mail search "search query" --json # KQL search +outlook-cli mail search "search query" --json --redact # PII-safe search +outlook-cli mail folders --json # List all folders +``` + +### Email — Attachments + +```bash +outlook-cli mail attachments MESSAGE_ID --json # List attachments +outlook-cli mail download-attachment MSG_ID ATT_ID --output-dir ./downloads # Download +outlook-cli mail draft --to "..." --subject "..." --body "..." --attach report.pdf --yes --json +``` + +### Email — Write + +```bash +outlook-cli mail draft --to "recipient@example.com" --subject "Subject" --body "Body" --yes --json +outlook-cli mail draft --to "a@x.com,b@x.com" --cc "c@x.com" --subject "..." --body "..." --yes --json +outlook-cli mail reply MESSAGE_ID --body "Reply text" --yes --json +outlook-cli mail reply MESSAGE_ID --body "Reply text" --all --yes --json +outlook-cli mail forward MESSAGE_ID --to "recipient@example.com" --comment "FYI" --yes --json +outlook-cli mail send MESSAGE_ID --yes --json # Send a draft +outlook-cli mail move MESSAGE_ID --folder "Archive" --yes --json +outlook-cli mail delete MESSAGE_ID --yes --json +outlook-cli mail flag MESSAGE_ID --json +outlook-cli mail mark-read MESSAGE_ID --json +``` + +### Calendar + +```bash +outlook-cli calendar today --json +outlook-cli calendar week --json +outlook-cli calendar range --start "2026-01-15" --end "2026-01-16" --json +outlook-cli calendar view EVENT_ID --json +outlook-cli calendar list-calendars --json +outlook-cli calendar create --subject "Meeting" --start "2026-01-15T14:00:00" --end "2026-01-15T15:00:00" --yes --json +``` + +### Contacts + +```bash +outlook-cli contacts search "John" --json +``` + +### Account Management + +```bash +outlook-cli account list --json +outlook-cli auth status --json +``` + +### Diagnostics + +```bash +outlook-cli doctor # Health check +outlook-cli telemetry summary # API performance stats +outlook-cli log summary # Recent operations +outlook-cli log show CORRELATION_ID # Specific operation detail +``` + +## PII Redaction for Agent Safety + +When the `--redact` flag is used, outlook-cli replaces PII with tokenized placeholders: + +| PII Type | Token Format | Example | +|----------|-------------|---------| +| Email address | `**REDACTED-N:email**` | alice@corp.com → **REDACTED-1:email** | +| Phone number | `**REDACTED-N:phone**` | 555-123-4567 → **REDACTED-2:phone** | +| Credit card | `**REDACTED-N:cc**` | 4111...1111 → **REDACTED-3:cc** | +| SSN | `**REDACTED-N:ssn**` | 123-45-6789 → **REDACTED-4:ssn** | +| IP address | `**REDACTED-N:ip**` | 192.168.1.1 → **REDACTED-5:ip** | +| API key | `**REDACTED-N:key**` | ghp_ABC... → **REDACTED-6:key** | + +In JSON output, `_redactionMapping` contains the token→original mapping for reconstruction. + +**Agent round-trip:** The agent can reference tokens in its response (e.g., "Send email to **REDACTED-1:email**"), and the CLI can reconstruct the original values before executing the operation. + +## Output Format + +All commands support `--json` for structured JSON output (recommended for agent use). Without `--json`, output is human-readable tables. + +Additional formats: `--format markdown`, `--format html`. + +## Pagination + +Large mailboxes are paginated automatically. Use `--top N` and `--skip N` for manual control, or `--page next` / `--page prev` for automatic cursor-based navigation. + +## Multi-Account + +```bash +outlook-cli mail inbox --account work --json +outlook-cli mail inbox --account personal --json +outlook-cli mail read MSG_ID --as delegate@example.com --json # Delegate access +``` diff --git a/skill/openclaw/gateway.ps1 b/skill/openclaw/gateway.ps1 new file mode 100644 index 0000000..22c9fea --- /dev/null +++ b/skill/openclaw/gateway.ps1 @@ -0,0 +1,166 @@ +<# +.SYNOPSIS + Start the outlook-cli email gateway for OpenClaw. + +.DESCRIPTION + Starts outlook-cli as a real-time email channel for OpenClaw: + - Uses webhook mode (via cloudflared tunnel) for near-instant delivery (~1-5s) + - Falls back to fast-poll mode (5s adaptive) if no tunnel tool is available + - Outputs JSONL events to stdout for the OpenClaw gateway to consume + - Handles subscription renewal, reconciliation, and graceful shutdown + + This script is designed to be run as a service or process alongside the + OpenClaw gateway. No manual watcher configuration or job files are needed. + +.PARAMETER OutlookCliBin + Path to the outlook-cli binary. Defaults to OUTLOOK_CLI_BIN env var or 'outlook-cli' on PATH. + +.PARAMETER Account + Account alias to watch. Defaults to 'openclaw'. + +.PARAMETER Folder + Mail folder to watch. Defaults to 'Inbox'. + +.PARAMETER Tunnel + Tunnel tool for webhook mode: cloudflared (default), localtunnel, ngrok. + +.PARAMETER Mode + Force a specific watch mode: webhook, fast-poll, or auto (default). + 'auto' uses webhook if a tunnel tool is available, otherwise fast-poll. + +.PARAMETER GatewayUrl + OpenClaw gateway webhook URL. If set, POST events to this URL instead of stdout. + +.PARAMETER Port + Local port for the webhook server. Defaults to a random available port. + +.EXAMPLE + .\gateway.ps1 -OutlookCliBin "C:\tools\outlook-cli.exe" + +.EXAMPLE + .\gateway.ps1 -Mode fast-poll -Account work + +.EXAMPLE + # Run as an OpenClaw service (stdout JSONL consumed by gateway): + .\gateway.ps1 -OutlookCliBin "C:\tools\outlook-cli.exe" -Account openclaw +#> + +param( + [string]$OutlookCliBin, + [string]$Account = "openclaw", + [string]$Folder = "Inbox", + [string]$Tunnel = "cloudflared", + [ValidateSet("webhook", "fast-poll", "auto")] + [string]$Mode = "auto", + [string]$GatewayUrl, + [int]$Port = 0 +) + +$ErrorActionPreference = "Stop" + +# ── Resolve outlook-cli binary ─────────────────────────────────────────────── + +function Find-OutlookCli { + param([string]$Explicit) + + if ($Explicit -and (Test-Path $Explicit)) { return (Resolve-Path $Explicit).Path } + if ($Explicit) { Write-Error "outlook-cli not found at: $Explicit"; exit 1 } + + if ($env:OUTLOOK_CLI_BIN -and (Test-Path $env:OUTLOOK_CLI_BIN)) { + return (Resolve-Path $env:OUTLOOK_CLI_BIN).Path + } + + $onPath = Get-Command outlook-cli -ErrorAction SilentlyContinue + if ($onPath) { return $onPath.Source } + + $candidates = @( + "$PSScriptRoot\..\..\publish\win-x64\outlook-cli.exe", + "$PSScriptRoot\..\..\bin\outlook-cli.js" + ) + foreach ($c in $candidates) { + if (Test-Path $c) { return (Resolve-Path $c).Path } + } + + Write-Error "outlook-cli not found. Set -OutlookCliBin or OUTLOOK_CLI_BIN env var." + exit 1 +} + +$bin = Find-OutlookCli -Explicit $OutlookCliBin +$isNode = $bin -match "\.js$" + +# ── Determine watch mode ──────────────────────────────────────────────────── + +if ($Mode -eq "auto") { + $tunnelCmd = Get-Command $Tunnel -ErrorAction SilentlyContinue + if ($tunnelCmd) { + $Mode = "webhook" + } else { + $Mode = "fast-poll" + Write-Host "⚠ $Tunnel not found — using fast-poll mode (5s adaptive)" -ForegroundColor Yellow > ([System.IO.Stream]::Null) + } +} + +# ── Build command arguments ───────────────────────────────────────────────── + +$watchArgs = @( + "watch", + "--mode", $Mode, + "--jsonl", + "--no-calendar", + "--folder", $Folder, + "--account", $Account, + "--heartbeat" +) + +if ($Mode -eq "webhook") { + $watchArgs += @("--tunnel", $Tunnel) + if ($Port -gt 0) { + $watchArgs += @("--webhook-port", $Port) + } +} elseif ($Mode -eq "fast-poll") { + $watchArgs += @("--interval", "10") +} + +# ── Start the watcher ────────────────────────────────────────────────────── + +# Write startup info to stderr (stdout is reserved for JSONL events) +[Console]::Error.WriteLine("outlook-cli gateway starting...") +[Console]::Error.WriteLine(" Binary: $bin") +[Console]::Error.WriteLine(" Mode: $Mode") +[Console]::Error.WriteLine(" Account: $Account") +[Console]::Error.WriteLine(" Folder: $Folder") + +if ($GatewayUrl) { + # Pipe JSONL to gateway URL via curl + [Console]::Error.WriteLine(" Gateway: $GatewayUrl") + if ($isNode) { + node $bin @watchArgs | ForEach-Object { + $line = $_ + # Forward to stdout for logging + Write-Output $line + # POST to gateway + try { + Invoke-RestMethod -Uri $GatewayUrl -Method POST -ContentType "application/json" -Body $line -ErrorAction SilentlyContinue | Out-Null + } catch { + [Console]::Error.WriteLine(" ⚠ Gateway POST failed: $_") + } + } + } else { + & $bin @watchArgs | ForEach-Object { + $line = $_ + Write-Output $line + try { + Invoke-RestMethod -Uri $GatewayUrl -Method POST -ContentType "application/json" -Body $line -ErrorAction SilentlyContinue | Out-Null + } catch { + [Console]::Error.WriteLine(" ⚠ Gateway POST failed: $_") + } + } + } +} else { + # Pure stdout mode — OpenClaw reads JSONL directly from the process + if ($isNode) { + node $bin @watchArgs + } else { + & $bin @watchArgs + } +} diff --git a/skill/openclaw/gateway.sh b/skill/openclaw/gateway.sh new file mode 100644 index 0000000..fac9d78 --- /dev/null +++ b/skill/openclaw/gateway.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# +# Start the outlook-cli email gateway for OpenClaw. +# +# Starts a real-time email channel that outputs JSONL events to stdout. +# OpenClaw consumes these events as incoming messages. +# +# Usage: +# ./gateway.sh [--bin /path/to/outlook-cli] [options] +# +# Options: +# --bin PATH Path to outlook-cli binary (auto-detected if omitted) +# --account ALIAS Account alias (default: openclaw) +# --folder FOLDER Mail folder to watch (default: Inbox) +# --mode MODE Watch mode: webhook, fast-poll, or auto (default: auto) +# --tunnel TYPE Tunnel for webhook mode: cloudflared, localtunnel, ngrok +# --gateway-url URL POST events to this URL instead of (in addition to) stdout +# --port PORT Local port for webhook server (default: random) +# +set -euo pipefail + +BIN="" +ACCOUNT="openclaw" +FOLDER="Inbox" +MODE="auto" +TUNNEL="cloudflared" +GATEWAY_URL="" +PORT=0 + +while [[ $# -gt 0 ]]; do + case $1 in + --bin) BIN="$2"; shift 2 ;; + --account) ACCOUNT="$2"; shift 2 ;; + --folder) FOLDER="$2"; shift 2 ;; + --mode) MODE="$2"; shift 2 ;; + --tunnel) TUNNEL="$2"; shift 2 ;; + --gateway-url) GATEWAY_URL="$2"; shift 2 ;; + --port) PORT="$2"; shift 2 ;; + *) echo "Unknown option: $1" >&2; exit 1 ;; + esac +done + +# ── Resolve outlook-cli binary ────────────────────────────────────────────── + +find_outlook_cli() { + if [[ -n "$BIN" ]]; then + if [[ -x "$BIN" ]] || [[ -f "$BIN" ]]; then echo "$BIN"; return; fi + echo "Error: outlook-cli not found at: $BIN" >&2; exit 1 + fi + if command -v outlook-cli &>/dev/null; then command -v outlook-cli; return; fi + if [[ -n "${OUTLOOK_CLI_BIN:-}" ]] && [[ -f "$OUTLOOK_CLI_BIN" ]]; then echo "$OUTLOOK_CLI_BIN"; return; fi + + local script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + for c in "$script_dir/../../publish/linux-x64/outlook-cli" "$script_dir/../../publish/osx-arm64/outlook-cli" "$script_dir/../../bin/outlook-cli.js"; do + if [[ -f "$c" ]]; then echo "$(cd "$(dirname "$c")" && pwd)/$(basename "$c")"; return; fi + done + echo "Error: outlook-cli not found. Set --bin or OUTLOOK_CLI_BIN env var." >&2; exit 1 +} + +CLI=$(find_outlook_cli) + +run_cli() { + if [[ "$CLI" == *.js ]]; then + node "$CLI" "$@" + else + "$CLI" "$@" + fi +} + +# ── Determine watch mode ─────────────────────────────────────────────────── + +if [[ "$MODE" == "auto" ]]; then + if command -v "$TUNNEL" &>/dev/null; then + MODE="webhook" + else + MODE="fast-poll" + echo "⚠ $TUNNEL not found — using fast-poll mode (5s adaptive)" >&2 + fi +fi + +# ── Build command arguments ──────────────────────────────────────────────── + +WATCH_ARGS=( + watch + --mode "$MODE" + --jsonl + --no-calendar + --folder "$FOLDER" + --account "$ACCOUNT" + --heartbeat +) + +if [[ "$MODE" == "webhook" ]]; then + WATCH_ARGS+=(--tunnel "$TUNNEL") + if [[ "$PORT" -gt 0 ]]; then + WATCH_ARGS+=(--webhook-port "$PORT") + fi +elif [[ "$MODE" == "fast-poll" ]]; then + WATCH_ARGS+=(--interval 10) +fi + +# ── Start the watcher ────────────────────────────────────────────────────── + +echo "outlook-cli gateway starting..." >&2 +echo " Binary: $CLI" >&2 +echo " Mode: $MODE" >&2 +echo " Account: $ACCOUNT" >&2 +echo " Folder: $FOLDER" >&2 + +if [[ -n "$GATEWAY_URL" ]]; then + echo " Gateway: $GATEWAY_URL" >&2 + # Pipe JSONL to both stdout and gateway URL + run_cli "${WATCH_ARGS[@]}" | while IFS= read -r line; do + echo "$line" + curl -s -X POST "$GATEWAY_URL" -H "Content-Type: application/json" -d "$line" >/dev/null 2>&1 || echo " ⚠ Gateway POST failed" >&2 + done +else + # Pure stdout mode — OpenClaw reads JSONL directly + run_cli "${WATCH_ARGS[@]}" +fi diff --git a/skill/openclaw/setup.ps1 b/skill/openclaw/setup.ps1 new file mode 100644 index 0000000..11d22eb --- /dev/null +++ b/skill/openclaw/setup.ps1 @@ -0,0 +1,192 @@ +<# +.SYNOPSIS + Set up outlook-cli for OpenClaw gateway integration. + +.DESCRIPTION + Configures outlook-cli for use with OpenClaw: + - Validates or installs outlook-cli + - Configures the Azure app client ID + - Authenticates the user (browser or device code) + - Verifies connectivity to Microsoft Graph + - Optionally installs cloudflared for webhook mode + +.PARAMETER ClientId + Azure App Registration client ID. Required for first-time setup. + +.PARAMETER OutlookCliBin + Path to the outlook-cli binary (native .exe or node entry point). + Defaults to 'outlook-cli' on PATH, then checks common locations. + +.PARAMETER AccountAlias + Account alias to configure. Defaults to 'openclaw'. + +.PARAMETER Tenant + Azure tenant ID or domain. Defaults to 'common' (works for personal + work accounts). + +.PARAMETER DeviceCode + Use device code flow instead of browser login (for headless servers). + +.PARAMETER SkipTunnel + Skip cloudflared installation check (will use fast-poll instead of webhook). + +.EXAMPLE + .\setup.ps1 -ClientId "e5601814-c051-42e4-a940-f2de872c8747" -OutlookCliBin "C:\tools\outlook-cli.exe" + +.EXAMPLE + .\setup.ps1 -ClientId "e5601814-..." -DeviceCode -SkipTunnel +#> + +param( + [Parameter(Mandatory=$true)] + [string]$ClientId, + + [string]$OutlookCliBin, + [string]$AccountAlias = "openclaw", + [string]$Tenant = "common", + [switch]$DeviceCode, + [switch]$SkipTunnel +) + +$ErrorActionPreference = "Stop" + +# ── Resolve outlook-cli binary ─────────────────────────────────────────────── + +function Find-OutlookCli { + param([string]$Explicit) + + if ($Explicit) { + if (Test-Path $Explicit) { return (Resolve-Path $Explicit).Path } + Write-Error "outlook-cli not found at: $Explicit" + exit 1 + } + + # Check PATH + $onPath = Get-Command outlook-cli -ErrorAction SilentlyContinue + if ($onPath) { return $onPath.Source } + + # Check OUTLOOK_CLI_BIN env var + if ($env:OUTLOOK_CLI_BIN -and (Test-Path $env:OUTLOOK_CLI_BIN)) { + return (Resolve-Path $env:OUTLOOK_CLI_BIN).Path + } + + # Check common Windows locations + $candidates = @( + "$PSScriptRoot\..\..\publish\win-x64\outlook-cli.exe", + "$PSScriptRoot\..\..\bin\outlook-cli.js" + ) + foreach ($c in $candidates) { + if (Test-Path $c) { return (Resolve-Path $c).Path } + } + + Write-Error @" +outlook-cli not found. Provide the path with -OutlookCliBin: + .\setup.ps1 -ClientId $ClientId -OutlookCliBin "C:\path\to\outlook-cli.exe" + +Or set OUTLOOK_CLI_BIN environment variable. +Download from: https://github.com/jeffstall/outlook-cli +"@ + exit 1 +} + +$bin = Find-OutlookCli -Explicit $OutlookCliBin + +# Determine if this is a Node.js entry point or native binary +$isNode = $bin -match "\.js$" +function Invoke-OutlookCli { + param([string[]]$Args) + if ($isNode) { + & node $bin @Args + } else { + & $bin @Args + } +} + +Write-Host "" +Write-Host "╔══════════════════════════════════════════════════╗" -ForegroundColor Cyan +Write-Host "║ outlook-cli — OpenClaw Gateway Setup ║" -ForegroundColor Cyan +Write-Host "╚══════════════════════════════════════════════════╝" -ForegroundColor Cyan +Write-Host "" +Write-Host "Binary: $bin" +Write-Host "" + +# ── Step 1: Verify outlook-cli works ──────────────────────────────────────── + +Write-Host "Step 1/4: Verifying outlook-cli..." -ForegroundColor Yellow +$version = Invoke-OutlookCli @("--version") 2>&1 +if ($LASTEXITCODE -ne 0) { + Write-Error "outlook-cli failed to run. Output: $version" + exit 1 +} +Write-Host " ✓ outlook-cli $version" -ForegroundColor Green + +# ── Step 2: Configure account ─────────────────────────────────────────────── + +Write-Host "Step 2/4: Configuring account '$AccountAlias'..." -ForegroundColor Yellow +Invoke-OutlookCli @("account", "add", $AccountAlias, "--client-id", $ClientId, "--tenant", $Tenant) 2>&1 | Out-Null +if ($LASTEXITCODE -ne 0) { + Write-Host " (Account may already exist — continuing)" -ForegroundColor DarkYellow +} +Write-Host " ✓ Account '$AccountAlias' configured" -ForegroundColor Green + +# ── Step 3: Authenticate ─────────────────────────────────────────────────── + +Write-Host "Step 3/4: Authenticating..." -ForegroundColor Yellow +$authArgs = @("auth", "login", "--account", $AccountAlias) +if ($DeviceCode) { + $authArgs += "--device-code" + Write-Host " Using device code flow (follow the instructions below):" +} + +Invoke-OutlookCli $authArgs +if ($LASTEXITCODE -ne 0) { + Write-Error "Authentication failed. Run with -DeviceCode if you don't have a browser." + exit 1 +} +Write-Host " ✓ Authenticated" -ForegroundColor Green + +# ── Step 4: Verify Graph API access ──────────────────────────────────────── + +Write-Host "Step 4/4: Verifying Microsoft Graph access..." -ForegroundColor Yellow +$status = Invoke-OutlookCli @("auth", "status", "--account", $AccountAlias, "--json") 2>&1 +if ($LASTEXITCODE -ne 0) { + Write-Error "Cannot verify authentication: $status" + exit 1 +} +Write-Host " ✓ Microsoft Graph access confirmed" -ForegroundColor Green + +# ── Check for cloudflared ────────────────────────────────────────────────── + +if (-not $SkipTunnel) { + Write-Host "" + Write-Host "Checking for cloudflared (needed for webhook mode)..." -ForegroundColor Yellow + $cfPath = Get-Command cloudflared -ErrorAction SilentlyContinue + if ($cfPath) { + Write-Host " ✓ cloudflared found: $($cfPath.Source)" -ForegroundColor Green + Write-Host " Gateway will use webhook mode (near-instant notifications)" -ForegroundColor Green + } else { + Write-Host " ⚠ cloudflared not found" -ForegroundColor DarkYellow + Write-Host " Gateway will use fast-poll mode (5-second adaptive polling)" -ForegroundColor DarkYellow + Write-Host "" + Write-Host " To enable webhook mode (recommended for production):" -ForegroundColor DarkYellow + Write-Host " winget install --id Cloudflare.cloudflared" -ForegroundColor White + Write-Host " Or download from: https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/" -ForegroundColor White + } +} + +# ── Done ──────────────────────────────────────────────────────────────────── + +Write-Host "" +Write-Host "═══════════════════════════════════════════════════" -ForegroundColor Green +Write-Host " Setup complete!" -ForegroundColor Green +Write-Host "═══════════════════════════════════════════════════" -ForegroundColor Green +Write-Host "" +Write-Host "Start the OpenClaw email gateway:" -ForegroundColor Cyan +Write-Host " .\skill\openclaw\gateway.ps1 -OutlookCliBin `"$bin`"" -ForegroundColor White +Write-Host "" +Write-Host "Or use outlook-cli directly:" -ForegroundColor Cyan +if ($isNode) { + Write-Host " node $bin mail inbox --account $AccountAlias" -ForegroundColor White +} else { + Write-Host " $bin mail inbox --account $AccountAlias" -ForegroundColor White +} +Write-Host "" diff --git a/skill/openclaw/setup.sh b/skill/openclaw/setup.sh new file mode 100644 index 0000000..7d5faf4 --- /dev/null +++ b/skill/openclaw/setup.sh @@ -0,0 +1,166 @@ +#!/usr/bin/env bash +# +# Setup outlook-cli for OpenClaw gateway integration. +# +# Usage: +# ./setup.sh --client-id YOUR_CLIENT_ID [--bin /path/to/outlook-cli] [options] +# +# Options: +# --client-id ID Azure App Registration client ID (required) +# --bin PATH Path to outlook-cli binary (auto-detected if omitted) +# --account ALIAS Account alias (default: openclaw) +# --tenant TENANT Azure tenant (default: common) +# --device-code Use device code flow (for headless servers) +# --skip-tunnel Skip cloudflared check +# +set -euo pipefail + +CLIENT_ID="" +BIN="" +ACCOUNT="openclaw" +TENANT="common" +DEVICE_CODE=false +SKIP_TUNNEL=false + +while [[ $# -gt 0 ]]; do + case $1 in + --client-id) CLIENT_ID="$2"; shift 2 ;; + --bin) BIN="$2"; shift 2 ;; + --account) ACCOUNT="$2"; shift 2 ;; + --tenant) TENANT="$2"; shift 2 ;; + --device-code) DEVICE_CODE=true; shift ;; + --skip-tunnel) SKIP_TUNNEL=true; shift ;; + *) echo "Unknown option: $1" >&2; exit 1 ;; + esac +done + +if [[ -z "$CLIENT_ID" ]]; then + echo "Error: --client-id is required" >&2 + echo "Usage: ./setup.sh --client-id YOUR_CLIENT_ID [--bin /path/to/outlook-cli]" >&2 + exit 1 +fi + +# ── Resolve outlook-cli binary ────────────────────────────────────────────── + +find_outlook_cli() { + if [[ -n "$BIN" ]]; then + if [[ -x "$BIN" ]] || [[ -f "$BIN" ]]; then + echo "$BIN" + return + fi + echo "Error: outlook-cli not found at: $BIN" >&2 + exit 1 + fi + + # Check PATH + if command -v outlook-cli &>/dev/null; then + command -v outlook-cli + return + fi + + # Check OUTLOOK_CLI_BIN env var + if [[ -n "${OUTLOOK_CLI_BIN:-}" ]] && [[ -f "$OUTLOOK_CLI_BIN" ]]; then + echo "$OUTLOOK_CLI_BIN" + return + fi + + # Check relative to this script + local script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + local candidates=( + "$script_dir/../../publish/linux-x64/outlook-cli" + "$script_dir/../../publish/osx-arm64/outlook-cli" + "$script_dir/../../bin/outlook-cli.js" + ) + for c in "${candidates[@]}"; do + if [[ -f "$c" ]]; then + echo "$(cd "$(dirname "$c")" && pwd)/$(basename "$c")" + return + fi + done + + echo "Error: outlook-cli not found." >&2 + echo " Provide the path with --bin /path/to/outlook-cli" >&2 + echo " Or set OUTLOOK_CLI_BIN environment variable" >&2 + exit 1 +} + +CLI=$(find_outlook_cli) + +# Determine if Node.js entry point +run_cli() { + if [[ "$CLI" == *.js ]]; then + node "$CLI" "$@" + else + "$CLI" "$@" + fi +} + +echo "" +echo "╔══════════════════════════════════════════════════╗" +echo "║ outlook-cli — OpenClaw Gateway Setup ║" +echo "╚══════════════════════════════════════════════════╝" +echo "" +echo "Binary: $CLI" +echo "" + +# ── Step 1: Verify ────────────────────────────────────────────────────────── + +echo "Step 1/4: Verifying outlook-cli..." +VERSION=$(run_cli --version 2>&1) || { echo "Error: outlook-cli failed to run"; exit 1; } +echo " ✓ outlook-cli $VERSION" + +# ── Step 2: Configure account ─────────────────────────────────────────────── + +echo "Step 2/4: Configuring account '$ACCOUNT'..." +run_cli account add "$ACCOUNT" --client-id "$CLIENT_ID" --tenant "$TENANT" 2>&1 || true +echo " ✓ Account '$ACCOUNT' configured" + +# ── Step 3: Authenticate ─────────────────────────────────────────────────── + +echo "Step 3/4: Authenticating..." +AUTH_ARGS=(auth login --account "$ACCOUNT") +if $DEVICE_CODE; then + AUTH_ARGS+=(--device-code) + echo " Using device code flow:" +fi + +run_cli "${AUTH_ARGS[@]}" || { echo "Error: Authentication failed"; exit 1; } +echo " ✓ Authenticated" + +# ── Step 4: Verify ────────────────────────────────────────────────────────── + +echo "Step 4/4: Verifying Microsoft Graph access..." +run_cli auth status --account "$ACCOUNT" --json >/dev/null 2>&1 || { echo "Error: Graph access check failed"; exit 1; } +echo " ✓ Microsoft Graph access confirmed" + +# ── Check for cloudflared ────────────────────────────────────────────────── + +if ! $SKIP_TUNNEL; then + echo "" + echo "Checking for cloudflared (needed for webhook mode)..." + if command -v cloudflared &>/dev/null; then + echo " ✓ cloudflared found: $(command -v cloudflared)" + echo " Gateway will use webhook mode (near-instant notifications)" + else + echo " ⚠ cloudflared not found" + echo " Gateway will use fast-poll mode (5-second adaptive polling)" + echo "" + echo " To enable webhook mode (recommended for production):" + if [[ "$(uname)" == "Darwin" ]]; then + echo " brew install cloudflare/cloudflare/cloudflared" + else + echo " See: https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/" + fi + fi +fi + +# ── Done ──────────────────────────────────────────────────────────────────── + +echo "" +echo "═══════════════════════════════════════════════════" +echo " Setup complete!" +echo "═══════════════════════════════════════════════════" +echo "" +echo "Start the OpenClaw email gateway:" +echo " ./skill/openclaw/gateway.sh --bin \"$CLI\"" +echo "" diff --git a/src/dotnet/Accounts/AccountManager.cs b/src/dotnet/Accounts/AccountManager.cs new file mode 100644 index 0000000..34dc2c2 --- /dev/null +++ b/src/dotnet/Accounts/AccountManager.cs @@ -0,0 +1,266 @@ +/// +/// Multi-account registry — manages Azure AD app registrations and account metadata. +/// +/// CONFIG DIRECTORY STRUCTURE (~/.outlook-cli/): +/// accounts.json — account registry (this module) +/// aliases.json — contact aliases (see AliasManager) +/// config.json — global config overrides +/// cache-*.enc — per-account MSAL token caches (managed by TokenCacheHelper) +/// +/// ACCOUNTS.JSON FORMAT: +/// { +/// "defaultAccount": "work", +/// "accounts": { +/// "work": { +/// "alias": "work", +/// "email": "user@company.com", +/// "tenantId": "common", +/// "clientId": "xxxxxxxx-...", +/// "homeAccountId": "..." +/// } +/// } +/// } +/// +/// SINGLETON PATTERN: AccountManager uses a Lazy<T> singleton because multiple CLI +/// commands in the same process need a consistent view of account state, and re-reading +/// the file for every operation is wasteful. +/// +/// FILE FORMAT INTEROP: The accounts.json format is identical to the Node.js implementation, +/// so switching between implementations is seamless. +/// + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace OutlookCli.Accounts; + +// Note: OutlookCliJsonContext is in the OutlookCli namespace (same assembly) + +/// +/// Represents a single account configuration entry in accounts.json. +/// +public class AccountConfig +{ + [JsonPropertyName("alias")] + public string Alias { get; set; } = ""; + + [JsonPropertyName("email")] + public string? Email { get; set; } + + [JsonPropertyName("tenantId")] + public string TenantId { get; set; } = "common"; + + [JsonPropertyName("clientId")] + public string ClientId { get; set; } = ""; + + [JsonPropertyName("homeAccountId")] + public string? HomeAccountId { get; set; } + + /// + /// Account permission mode: "full" (default) or "read-only". + /// Read-only accounts cannot perform write operations (send, draft, move, delete, etc.). + /// + [JsonPropertyName("mode")] + public string Mode { get; set; } = "full"; + + /// + /// Custom MSAL scopes for this account. When set, overrides the default global scopes. + /// Used for per-account permission tiers (e.g., read-only accounts request only read scopes). + /// + [JsonPropertyName("scopes")] + public List? Scopes { get; set; } + + /// Returns true if this account is configured as read-only. + [JsonIgnore] + public bool IsReadOnly => string.Equals(Mode, "read-only", StringComparison.OrdinalIgnoreCase); +} + +/// +/// Root structure of accounts.json — matches the Node.js format exactly. +/// +internal class AccountsData +{ + [JsonPropertyName("defaultAccount")] + public string? DefaultAccount { get; set; } + + [JsonPropertyName("accounts")] + public Dictionary Accounts { get; set; } = new(); +} + +public class AccountManager +{ + private static readonly Lazy _instance = new(() => new AccountManager()); + + /// Singleton instance — lazy-initializes on first access so the file is only read when needed. + public static AccountManager Instance => _instance.Value; + + private static readonly string _configDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".outlook-cli"); + private static readonly string _accountsFile = Path.Combine(_configDir, "accounts.json"); + + private AccountsData _data; + + /// Path to the configuration directory (~/.outlook-cli/). + public string ConfigDir => _configDir; + + private AccountManager() + { + _data = LoadAccounts(); + } + + private static void EnsureConfigDir() + { + if (!Directory.Exists(_configDir)) + Directory.CreateDirectory(_configDir); + } + + /// + /// Load accounts.json from disk. Returns a blank registry on missing or corrupted file. + /// + private static AccountsData LoadAccounts() + { + EnsureConfigDir(); + if (!File.Exists(_accountsFile)) + return new AccountsData(); + + try + { + var json = File.ReadAllText(_accountsFile); + return JsonSerializer.Deserialize(json, OutlookCliJsonContext.Default.AccountsData) ?? new AccountsData(); + } + catch + { + return new AccountsData(); + } + } + + public void Save() + { + EnsureConfigDir(); + var json = JsonSerializer.Serialize(_data, OutlookCliJsonContext.Default.AccountsData); + File.WriteAllText(_accountsFile, json); + } + + /// Re-read accounts.json from disk (useful if edited externally). + public void Reload() + { + _data = LoadAccounts(); + } + + /// Get account config by alias, or null if not found. + public AccountConfig? GetAccount(string alias) + { + return _data.Accounts.TryGetValue(alias, out var account) ? account : null; + } + + /// + /// Add or update an account. Merges new info with existing data so partial + /// updates (e.g., just changing tenantId) don't wipe other fields. + /// Auto-promotes the first account added to be the default. + /// + public void AddAccount(string alias, string clientId, string tenantId = "common", + string? email = null, string? homeAccountId = null, string? mode = null, List? scopes = null) + { + if (_data.Accounts.TryGetValue(alias, out var existing)) + { + existing.ClientId = clientId; + existing.TenantId = tenantId; + if (email != null) existing.Email = email; + if (homeAccountId != null) existing.HomeAccountId = homeAccountId; + if (mode != null) existing.Mode = mode; + if (scopes != null) existing.Scopes = scopes; + } + else + { + _data.Accounts[alias] = new AccountConfig + { + Alias = alias, + ClientId = clientId, + TenantId = tenantId, + Email = email, + HomeAccountId = homeAccountId, + Mode = mode ?? "full", + Scopes = scopes + }; + } + + // Set default if this is the first account + if (string.IsNullOrEmpty(_data.DefaultAccount)) + _data.DefaultAccount = alias; + + Save(); + } + + /// + /// Remove an account. If the removed account was the default, the first + /// remaining account becomes the new default (arbitrary but predictable). + /// + public void RemoveAccount(string alias) + { + _data.Accounts.Remove(alias); + + if (_data.DefaultAccount == alias) + { + _data.DefaultAccount = _data.Accounts.Keys.FirstOrDefault(); + } + + Save(); + } + + /// List all configured accounts. + public List ListAccounts() + { + return _data.Accounts.Values.ToList(); + } + + /// Get the alias of the default account, or null if none configured. + public string? GetDefaultAlias() + { + return _data.DefaultAccount; + } + + /// + /// Set the default account. + /// + /// Thrown when the alias is not found. + public void SetDefault(string alias) + { + if (!_data.Accounts.ContainsKey(alias)) + throw new KeyNotFoundException($"Account \"{alias}\" not found"); + + _data.DefaultAccount = alias; + Save(); + } + + /// + /// Resolve an account alias to its config. + /// + /// Resolution order: + /// 1. Explicit alias override (from --account flag) + /// 2. Default account (first added, or explicitly set via set-default) + /// 3. Error if no accounts configured + /// + /// Used by all CLI commands to determine which account's tokens to use. + /// + /// Tuple of (account config, resolved alias). + public static (AccountConfig Account, string Alias) ResolveAccount(string? aliasOverride) + { + var manager = Instance; + var alias = aliasOverride ?? manager.GetDefaultAlias(); + + if (string.IsNullOrEmpty(alias)) + { + Console.Error.WriteLine("No account configured. Run `outlook-cli account add --client-id ` first."); + Environment.Exit(1); + } + + var account = manager.GetAccount(alias!); + if (account == null) + { + Console.Error.WriteLine($"Account \"{alias}\" not found. Run `outlook-cli account list` to see available accounts."); + Environment.Exit(1); + } + + return (account!, alias!); + } +} diff --git a/src/dotnet/Auth/AuthFlows.cs b/src/dotnet/Auth/AuthFlows.cs new file mode 100644 index 0000000..db41a86 --- /dev/null +++ b/src/dotnet/Auth/AuthFlows.cs @@ -0,0 +1,318 @@ +/// +/// Authentication flows for Microsoft identity platform. +/// +/// Two flows are supported, chosen at login time: +/// +/// 1. INTERACTIVE (PKCE) — Default on machines with a browser. +/// - Starts a localhost HTTP server on the redirect port +/// - Opens browser → Microsoft login page (handles 2FA on their end) +/// - Microsoft redirects back to localhost with an auth code +/// - Code is exchanged for tokens using PKCE verifier (no client secret needed) +/// +/// 2. DEVICE CODE — For headless VMs without a browser (--device-code flag). +/// - Requests a one-time code from Microsoft +/// - User enters code at https://microsoft.com/devicelogin on any device +/// - CLI polls Microsoft until the user completes authentication +/// +/// Both flows end with MSAL caching an access token + refresh token in the +/// encrypted cache (TokenCacheHelper → CryptoService). +/// +/// IMPORTANT: MSAL has scenarios where the device code callback receives +/// incomplete values when the server returns an error instead of a device code. +/// The pre-flight check (PreflightDeviceCodeCheckAsync) works around this by hitting +/// the endpoint directly first to get actionable error messages. +/// + +using System.Diagnostics; +using System.Net; +using System.Runtime.InteropServices; +using System.Web; +using Microsoft.Identity.Client; + +namespace OutlookCli.Auth; + +/// +/// Exception thrown when the pre-flight device code check detects an authority mismatch +/// that can be auto-corrected by retrying with a different tenant. +/// +public class AuthorityMismatchException : Exception +{ + /// + /// The correct tenant to use (e.g., "consumers" or "organizations"). + /// + public string CorrectTenant { get; } + + public AuthorityMismatchException(string correctTenant, string message) + : base(message) + { + CorrectTenant = correctTenant; + } +} + +public static class AuthFlows +{ + /// + /// Interactive login using PKCE with a localhost callback server. + /// + /// Flow order: + /// 1. MSAL handles PKCE challenge/verifier generation internally + /// 2. Opens system browser to the Microsoft login URL + /// 3. User authenticates (including 2FA) on Microsoft's page + /// 4. Microsoft redirects to localhost with an authorization code + /// 5. MSAL exchanges code for access/refresh tokens + /// 6. Tokens are cached via the cache plugin + /// + public static async Task InteractiveLoginAsync(IPublicClientApplication app, string[]? scopes = null) + { + Console.WriteLine("\nOpening browser for Microsoft login..."); + var loginScopes = scopes ?? MsalClientFactory.Scopes; + + try + { + var result = await app + .AcquireTokenInteractive(loginScopes) + .WithUseEmbeddedWebView(false) + .ExecuteAsync(); + + return result; + } + catch (MsalServiceException ex) when (ex.ErrorCode == "access_denied") + { + throw new Exception("Authentication was cancelled or access was denied."); + } + } + + /// + /// Device code login — for headless environments (VMs without a browser). + /// + /// Flow order: + /// 1. Pre-flight: Hit device code endpoint directly to catch config errors + /// 2. If pre-flight detects authority mismatch, throw AuthorityMismatchException + /// so the caller can retry with the right tenant + /// 3. Call MSAL AcquireTokenWithDeviceCode with our callback + /// 4. Callback displays the code + URL for the user + /// 5. User goes to microsoft.com/devicelogin on ANY device and enters the code + /// 6. MSAL polls Microsoft until auth completes + /// 7. Tokens are cached via the cache plugin + /// + /// MSAL PublicClientApplication. + /// Whether to show verbose error output. + public static async Task DeviceCodeLoginAsync(IPublicClientApplication app, bool verbose = false, string[]? scopes = null) + { + var loginScopes = scopes ?? MsalClientFactory.Scopes; + + // STEP 1: Pre-flight diagnostic. + // Why we do this: MSAL may call deviceCodeCallback with incomplete values when + // the server returns an error. Our pre-flight catches the actual error. + var clientId = app.AppConfig.ClientId; + var authority = app.Authority ?? "https://login.microsoftonline.com/common"; + + var correctTenant = await PreflightDeviceCodeCheckAsync(clientId, authority, loginScopes, verbose); + + // STEP 2: If the pre-flight detected the wrong authority, signal the caller to retry. + if (correctTenant != null) + { + throw new AuthorityMismatchException(correctTenant, + $"Authority mismatch — retrying with /{correctTenant}"); + } + + // STEP 3: Proceed with MSAL device code flow. + var result = await app + .AcquireTokenWithDeviceCode(loginScopes, deviceCodeResult => + { + if (string.IsNullOrEmpty(deviceCodeResult.UserCode) && string.IsNullOrEmpty(deviceCodeResult.Message)) + { + Console.Error.WriteLine("\n❌ Failed to obtain a device code from Microsoft."); + Console.Error.WriteLine(" Run with --verbose for details.\n"); + return Task.CompletedTask; + } + + // Display the device code to the user + Console.WriteLine(); + Console.WriteLine(new string('─', 50)); + Console.WriteLine("📱 Device Code Authentication"); + Console.WriteLine(new string('─', 50)); + + if (!string.IsNullOrEmpty(deviceCodeResult.Message)) + { + Console.WriteLine(deviceCodeResult.Message); + } + else + { + var uri = deviceCodeResult.VerificationUrl?.ToString() ?? "https://microsoft.com/devicelogin"; + Console.WriteLine($"To sign in, open a browser and go to: {uri}"); + Console.WriteLine($"Enter the code: {deviceCodeResult.UserCode}"); + } + + Console.WriteLine(new string('─', 50)); + Console.WriteLine(); + + return Task.CompletedTask; + }) + .ExecuteAsync(); + + return result; + } + + /// + /// Pre-flight check: hit the device code endpoint directly via raw HTTP. + /// + /// Why this exists: + /// MSAL's DeviceCodeClient may destructure the server response looking for device code + /// fields. If the server returns an error response, all those fields are undefined/null. + /// MSAL then calls deviceCodeCallback with incomplete values. + /// + /// By hitting the endpoint directly first, we can: + /// 1. Show the actual AADSTS error code and description + /// 2. Auto-detect authority mismatches and correct them + /// 3. Provide targeted fix instructions for common misconfigurations + /// + /// + /// The correct tenant string (e.g., "consumers", "organizations") if auto-correction + /// is needed, or null if the pre-flight passed. + /// + private static async Task PreflightDeviceCodeCheckAsync( + string clientId, string authority, string[] scopes, bool verbose) + { + var deviceCodeEndpoint = $"{authority.TrimEnd('/')}/oauth2/v2.0/devicecode"; + + try + { + using var httpClient = new HttpClient(); + var content = new FormUrlEncodedContent(new Dictionary + { + ["client_id"] = clientId, + ["scope"] = string.Join(" ", scopes) + }); + + var response = await httpClient.PostAsync(deviceCodeEndpoint, content); + + if (!response.IsSuccessStatusCode) + { + string errBodyJson; + try + { + errBodyJson = await response.Content.ReadAsStringAsync(); + } + catch + { + errBodyJson = "{}"; + } + + Dictionary? errBody = null; + try + { + errBody = System.Text.Json.JsonSerializer.Deserialize(errBodyJson, OutlookCliJsonContext.Default.DictionaryStringString); + } + catch + { + errBody = new Dictionary(); + } + + var desc = errBody?.GetValueOrDefault("error_description") + ?? errBody?.GetValueOrDefault("error") + ?? $"HTTP {(int)response.StatusCode}"; + var firstLine = desc.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None)[0]; + var error = errBody?.GetValueOrDefault("error") ?? "unknown"; + + // AUTHORITY AUTO-CORRECTION + // AADSTS9002346: App is for personal accounts only → use /consumers + // AADSTS50194: App is for org accounts only → use /organizations + if (desc.Contains("AADSTS9002346") || desc.Contains("/consumers")) + { + Console.WriteLine("ℹ App is configured for personal accounts — switching to /consumers endpoint."); + return "consumers"; + } + if (desc.Contains("AADSTS50194") || desc.Contains("/organizations")) + { + Console.WriteLine("ℹ App is configured for organization accounts — switching to /organizations endpoint."); + return "organizations"; + } + + // NON-RECOVERABLE ERRORS — show guidance for common misconfigurations + Console.Error.WriteLine($"\n❌ Microsoft rejected the device code request:"); + Console.Error.WriteLine($" {firstLine}"); + + if (desc.Contains("AADSTS700016") || desc.Contains("not found in the directory")) + { + Console.Error.WriteLine("\n Fix: The Application (client) ID was not found."); + Console.Error.WriteLine(" Verify --client-id matches the value on your App Registration overview page."); + } + else if (desc.Contains("AADSTS700038") || desc.Contains("not a valid application")) + { + Console.Error.WriteLine("\n Fix: The client ID is not a valid application identifier."); + Console.Error.WriteLine(" Copy the exact Application (client) ID from App registrations → Overview."); + } + else if (desc.Contains("AADSTS7000218") || error == "unauthorized_client") + { + Console.Error.WriteLine("\n Fix: In Microsoft Entra ID → App registrations → Authentication:"); + Console.Error.WriteLine(" Set \"Allow public client flows\" to Yes, then click Save."); + } + else if (error == "invalid_client") + { + Console.Error.WriteLine("\n Fix: Verify the --client-id matches the Application (client) ID"); + Console.Error.WriteLine(" in your App Registration overview page."); + } + else if (error == "invalid_scope") + { + Console.Error.WriteLine("\n Fix: Add the required API permissions (Mail.Read, Mail.ReadWrite,"); + Console.Error.WriteLine(" Calendars.Read, Calendars.ReadWrite, User.Read) under API permissions."); + } + + if (verbose) + { + Console.Error.WriteLine($"\n Full error response: {errBodyJson}"); + } + + Console.Error.WriteLine(); + throw new Exception($"Device code request failed: {error} — {firstLine}"); + } + + // If we get here, the endpoint returned 200 with a valid device code. + // We discard it — MSAL will request its own. We just wanted to verify + // the endpoint works before handing off to MSAL. + } + catch (Exception ex) when ( + !ex.Message.StartsWith("Device code request failed:") && + ex is not AuthorityMismatchException) + { + // Network error (DNS failure, timeout, etc.) — let MSAL try anyway. + // The pre-flight is a best-effort diagnostic, not a gate. + if (verbose) + { + Console.Error.WriteLine($"Pre-flight check failed (non-fatal): {ex.Message}"); + } + } + + return null; + } + + /// + /// Open a URL in the default browser (cross-platform). + /// Non-fatal if it fails — the URL is also printed to the console. + /// + public static void OpenBrowser(string url) + { + try + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + // empty title required for start with quoted URL + Process.Start(new ProcessStartInfo("cmd", $"/c start \"\" \"{url}\"") { CreateNoWindow = true }); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + Process.Start("open", url); + } + else + { + // Linux — requires xdg-utils + Process.Start("xdg-open", url); + } + } + catch + { + // Non-fatal — user can manually navigate to the URL printed above + } + } +} diff --git a/src/dotnet/Auth/MsalClientFactory.cs b/src/dotnet/Auth/MsalClientFactory.cs new file mode 100644 index 0000000..62b91f3 --- /dev/null +++ b/src/dotnet/Auth/MsalClientFactory.cs @@ -0,0 +1,194 @@ +/// +/// MSAL (Microsoft Authentication Library) client factory and token management. +/// +/// Architecture overview: +/// 1. Each CLI account gets its own MSAL PublicClientApplication (PCA) +/// 2. The PCA handles token lifecycle: acquire, refresh, cache +/// 3. Tokens are persisted via an encrypted cache plugin (TokenCacheHelper) +/// 4. Silent acquisition is tried first; interactive/device-code flows are fallbacks +/// +/// Why PublicClientApplication (not ConfidentialClient)? +/// - CLI apps are "public clients" — they run on the user's machine and can't keep +/// a client secret safe. MSAL enforces PKCE for public clients automatically. +/// - No client secret means the App Registration is safe to share across users. +/// +/// Scope design decisions: +/// - Mail.Send is requested for direct sending from own account +/// - Mail.Send.Shared is also requested for delegate send-on-behalf-of +/// - offline_access gives us refresh tokens for long-lived sessions +/// - *.Shared scopes enable delegate access (--as flag) +/// +/// SAFETY: The send command still prompts for confirmation before sending. +/// The permissions system can restrict Mail.Send per-account. +/// + +using Microsoft.Identity.Client; + +namespace OutlookCli.Auth; + +public static class MsalClientFactory +{ + /// + /// Scopes requested during authentication. + /// + /// Scopes are requested at login time and cannot be changed without re-authenticating. + /// If you add scopes here, users must auth logout then auth login again. + /// + public static readonly string[] Scopes = + { + "User.Read", // Read user profile (needed for auth status display) + "Mail.Read", // Read own mail + "Mail.ReadWrite", // Create drafts, move, flag, mark-read in own mailbox + "Mail.Send", // Send mail from own account + "Mail.Read.Shared", // Read delegate mailboxes (--as flag) + "Mail.ReadWrite.Shared", // Create drafts in delegate mailboxes + "Mail.Send.Shared", // Send on behalf of delegate + "Calendars.Read", // Read own calendar + "Calendars.ReadWrite", // Create events in own calendar + "Calendars.Read.Shared", // Read delegate calendars + "offline_access", // Gives us a refresh token for long-lived sessions + }; + + /// + /// OneDrive scopes — opt-in only. + /// Must be added to Azure App Registration API permissions before use. + /// Including them in defaults breaks users whose App Registration lacks Files permissions. + /// + public static readonly string[] DriveScopes = { "Files.Read", "Files.ReadWrite" }; + + /// + /// Redirect port for interactive (PKCE) auth flow. + /// Chosen to avoid conflicts with common dev servers (3000, 8080, etc.). + /// + public const int RedirectPort = 53847; + + /// + /// Redirect URI for interactive auth, using the standard port. + /// + public static readonly string RedirectUri = $"http://localhost:{RedirectPort}/callback"; + + /// + /// Create an MSAL PublicClientApplication for a specific account. + /// + /// Account nickname (e.g., "personal", "work"). + /// Azure App Registration Application (client) ID. + /// Azure tenant ID. Defaults to "common" which supports + /// both personal and organizational accounts. Auto-corrected to "consumers" or + /// "organizations" by AuthFlows if needed. + /// A configured . + public static IPublicClientApplication CreateMsalClient(string alias, string clientId, string tenantId = "common") + { + // Each account gets its own encrypted cache file (~/.outlook-cli/cache-{alias}.enc) + var app = PublicClientApplicationBuilder + .Create(clientId) + // Authority determines which account types can authenticate. + // /common = personal + work/school, /consumers = personal only, + // /organizations = work/school only, /{tenant-id} = specific tenant + .WithAuthority($"https://login.microsoftonline.com/{tenantId}") + .WithRedirectUri(RedirectUri) + .WithLogging((level, message, containsPii) => + { + // Only log warnings and errors by default + if (level <= Microsoft.Identity.Client.LogLevel.Warning) + System.Diagnostics.Debug.WriteLine($"[MSAL {level}] {message}"); + }, Microsoft.Identity.Client.LogLevel.Warning) + .Build(); + + // MSAL calls BeforeAccessAsync (decrypt from disk) and AfterAccessAsync + // (encrypt to disk) around every token operation + TokenCacheHelper.EnableSerialization(app, alias); + + return app; + } + + /// + /// Read-only scopes preset — only read permissions, no write/send capabilities. + /// Used when an account is configured with mode: "read-only". + /// + public static readonly string[] ReadOnlyScopes = + { + "User.Read", + "Mail.Read", + "Mail.Read.Shared", + "Calendars.Read", + "Calendars.Read.Shared", + "Contacts.Read", + "offline_access", + }; + + /// + /// Get the scopes for a specific account, respecting per-account configuration. + /// Priority: custom scopes > read-only preset > global defaults. + /// + public static string[] GetScopesForAccount(Accounts.AccountConfig? account) + { + if (account?.Scopes is { Count: > 0 }) + return account.Scopes.ToArray(); + if (account?.IsReadOnly == true) + return ReadOnlyScopes; + return Scopes; + } + + /// + /// Attempt silent token acquisition using the cached refresh token. + /// + /// This is the primary token acquisition path — called before every Graph API request. + /// MSAL handles refresh token rotation automatically: + /// 1. If access token is still valid → returns it immediately (cached in memory) + /// 2. If access token expired but refresh token is valid → exchanges for new tokens + /// 3. If both expired → returns null (caller must initiate interactive login) + /// + /// Returns null if no cached account exists or if reauth is needed. + /// Throws network errors so callers can distinguish "network down" from "token expired". + /// + public static async Task AcquireTokenSilently( + IPublicClientApplication app, Accounts.AccountConfig? accountConfig = null) + { + var accounts = await app.GetAccountsAsync(); + var account = accounts.FirstOrDefault(); + + if (account == null) + return null; + + try + { + var scopes = GetScopesForAccount(accountConfig); + return await app.AcquireTokenSilent(scopes, account).ExecuteAsync(); + } + catch (MsalUiRequiredException ex) + { + // Silent acquisition failed. Common causes: + // 1. New scopes added in a code update (cached token has fewer scopes) + // 2. Refresh token revoked or expired + // 3. Password changed or MFA policy changed + // In all cases, user must re-login to get a token with current scopes. + Console.Error.WriteLine($"? Re-authentication required."); + if (ex.Classification == UiRequiredExceptionClassification.ConsentRequired + || ex.ErrorCode == "consent_required") + { + Console.Error.WriteLine(" Cause: New permissions needed (likely after a CLI update)."); + } + Console.Error.WriteLine($" Fix: Run `outlook-cli auth login --account ` to authenticate."); + return null; + } + catch (MsalServiceException ex) when (ex.ErrorCode == "invalid_grant") + { + // Refresh token is no longer valid — user must re-login. + return null; + } + catch (MsalServiceException ex) when ( + ex.InnerException is HttpRequestException || + ex.InnerException is TaskCanceledException || + ex.StatusCode == 0) + { + // Network error during token refresh — rethrow so callers can show + // "network error" instead of misleading "token expired" message. + throw; + } + catch (MsalServiceException) + { + // Other MSAL service errors (e.g., server-side issues) — reauth needed. + return null; + } + } +} diff --git a/src/dotnet/Auth/TokenCacheHelper.cs b/src/dotnet/Auth/TokenCacheHelper.cs new file mode 100644 index 0000000..b721fa1 --- /dev/null +++ b/src/dotnet/Auth/TokenCacheHelper.cs @@ -0,0 +1,100 @@ +/// +/// Encrypted token cache plugin for MSAL. +/// +/// MSAL uses a serialization interface with two hooks: +/// - BeforeAccessAsync: called BEFORE any token operation → decrypt from disk +/// - AfterAccessAsync: called AFTER any token operation → encrypt to disk +/// +/// Each account gets its own file: ~/.outlook-cli/cache-{alias}.enc +/// The file contains: salt(32) + iv(16) + authTag(16) + ciphertext (AES-256-GCM) +/// +/// The passphrase is derived from either: +/// 1. OUTLOOK_CLI_PASSPHRASE env var (recommended for shared/CI environments) +/// 2. Machine-derived string: username + hostname + account alias (zero-config default) +/// +/// INTEROPERABILITY: The encrypted file format is identical to the Node.js implementation +/// (token-cache.js + crypto.js), so cache files are portable between implementations. +/// +/// This is NOT a general-purpose key-value cache — it stores the serialized +/// MSAL token cache which includes access tokens, refresh tokens, and account metadata. +/// + +using Microsoft.Identity.Client; +using OutlookCli.Accounts; +using OutlookCli.Security; +using System.Runtime.InteropServices; + +namespace OutlookCli.Auth; + +public static class TokenCacheHelper +{ + /// + /// Enable encrypted serialization on an MSAL token cache. + /// + /// Hooks BeforeAccessAsync (decrypt from disk) and AfterAccessAsync (encrypt to disk) + /// on the provided application's token cache. + /// + /// MSAL PublicClientApplication whose cache to serialize. + /// Account nickname, used to derive file path and passphrase. + public static void EnableSerialization(IPublicClientApplication app, string accountAlias) + { + var configDir = AccountManager.Instance.ConfigDir; + var cacheFile = Path.Combine(configDir, $"cache-{accountAlias}.enc"); + var passphrase = CryptoService.GetPassphrase(accountAlias); + + app.UserTokenCache.SetBeforeAccessAsync(async (TokenCacheNotificationArgs args) => + { + if (File.Exists(cacheFile)) + { + try + { + var encryptedData = await File.ReadAllBytesAsync(cacheFile); + var decrypted = CryptoService.Decrypt(encryptedData, passphrase); + args.TokenCache.DeserializeMsalV3(System.Text.Encoding.UTF8.GetBytes(decrypted)); + } + catch (Exception ex) + { + // Decryption failure = wrong passphrase, corrupt file, or file from + // a different machine (machine-derived passphrase won't match). + // Starting fresh means the user must re-authenticate, but it's safer + // than crashing or using stale tokens. + Console.Error.WriteLine($"Warning: Could not decrypt token cache for \"{accountAlias}\": {ex.Message}"); + Console.Error.WriteLine("Starting with empty cache. You will need to re-authenticate."); + Console.Error.WriteLine($"Fix: Run `outlook-cli auth login --account {accountAlias}` to re-authenticate."); + } + } + }); + + app.UserTokenCache.SetAfterAccessAsync(async (TokenCacheNotificationArgs args) => + { + if (args.HasStateChanged) + { + var serialized = args.TokenCache.SerializeMsalV3(); + var serializedString = System.Text.Encoding.UTF8.GetString(serialized); + var encryptedData = CryptoService.Encrypt(serializedString, passphrase); + // Each write uses a fresh random salt + IV (from CryptoService), so the + // ciphertext is different even if the content hasn't changed. + await File.WriteAllBytesAsync(cacheFile, encryptedData); + SetRestrictivePermissions(cacheFile); + } + }); + } + + /// + /// Set restrictive file permissions on Unix systems (no-op on Windows). + /// Cache files contain encrypted tokens — restrict to owner read/write only (0600). + /// + private static void SetRestrictivePermissions(string filePath) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return; + try + { + File.SetUnixFileMode(filePath, UnixFileMode.UserRead | UnixFileMode.UserWrite); + } + catch + { + // Best-effort: don't crash if chmod fails (e.g., unsupported filesystem) + } + } +} diff --git a/src/dotnet/Contacts/AliasManager.cs b/src/dotnet/Contacts/AliasManager.cs new file mode 100644 index 0000000..15d5542 --- /dev/null +++ b/src/dotnet/Contacts/AliasManager.cs @@ -0,0 +1,176 @@ +/// +/// Contact alias storage — local shortcut names for email addresses. +/// +/// FILE-BASED STORAGE: Aliases are stored as a flat JSON object in +/// ~/.outlook-cli/aliases.json. The format is { "alias": "email@example.com" }. +/// This is intentionally simple — no database, no encryption, no sync. +/// The file is human-editable and can be version-controlled. +/// +/// CASE-INSENSITIVITY: All alias names are lowercased on write AND on lookup. +/// This means "Fred", "fred", and "FRED" all map to the same alias. The "@" +/// character is forbidden in alias names so we can distinguish aliases from +/// raw email addresses — if input contains "@", it's treated as a literal email. +/// +/// KEY DISTINCTION — ResolveAlias vs ResolveRecipients: +/// - ResolveAlias(value) — resolves a SINGLE token (alias name or email). +/// Used by --as, individual attendee values, etc. +/// - ResolveRecipients(csv) — resolves a COMMA-SEPARATED string, calling +/// ResolveAlias on each token. Used by --to, --cc, --bcc fields. +/// +/// FILE FORMAT INTEROP: The aliases.json format is identical to the Node.js +/// implementation, so alias files are portable between implementations. +/// + +using System.Text.Json; + +namespace OutlookCli.Contacts; + +public static class AliasManager +{ + private static readonly string ConfigDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".outlook-cli"); + private static readonly string AliasesFile = Path.Combine(ConfigDir, "aliases.json"); + + /// + /// Load aliases from disk. Returns empty dictionary on missing or corrupted file + /// so the CLI gracefully degrades (no aliases configured yet). + /// + private static Dictionary LoadAliases() + { + try + { + if (File.Exists(AliasesFile)) + { + var json = File.ReadAllText(AliasesFile); + return JsonSerializer.Deserialize(json, OutlookCliJsonContext.Default.DictionaryStringString) + ?? new Dictionary(); + } + } + catch + { + // Corrupted file — start fresh rather than crashing + } + return new Dictionary(); + } + + /// + /// Save aliases to disk. Creates config dir if it doesn't exist. + /// + private static void SaveAliases(Dictionary aliases) + { + Directory.CreateDirectory(ConfigDir); + var json = JsonSerializer.Serialize(aliases, OutlookCliJsonContext.Default.DictionaryStringString); + File.WriteAllText(AliasesFile, json); + } + + /// + /// Set an alias (add or update). Name is lowercased for case-insensitive lookup. + /// The "@" check prevents confusion between alias names and email addresses. + /// + /// A tuple of (normalized name, email). + /// Thrown when name or email is missing, or name contains "@". + public static (string Name, string Email) SetAlias(string name, string email) + { + if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(email)) + throw new ArgumentException("Both alias name and email are required"); + + var key = name.ToLowerInvariant().Trim(); + if (key.Contains('@')) + throw new ArgumentException("Alias name cannot contain \"@\" — use a short name like \"fred\""); + + var trimmedEmail = email.Trim(); + var aliases = LoadAliases(); + aliases[key] = trimmedEmail; + SaveAliases(aliases); + + return (key, trimmedEmail); + } + + /// + /// Remove an alias. + /// + /// A tuple of (normalized name, email that was removed). + /// Thrown when the alias is not found. + public static (string Name, string Email) RemoveAlias(string name) + { + var key = name.ToLowerInvariant().Trim(); + var aliases = LoadAliases(); + + if (!aliases.TryGetValue(key, out var email)) + throw new KeyNotFoundException($"Alias \"{name}\" not found"); + + aliases.Remove(key); + SaveAliases(aliases); + + return (key, email); + } + + /// + /// List all aliases as name-email pairs. + /// + public static List<(string Name, string Email)> ListAliases() + { + var aliases = LoadAliases(); + return aliases.Select(kvp => (kvp.Key, kvp.Value)).ToList(); + } + + /// + /// Get the email for an alias (case-insensitive). + /// + /// The email address, or null if not found. + public static string? GetAlias(string name) + { + var aliases = LoadAliases(); + return aliases.TryGetValue(name.ToLowerInvariant().Trim(), out var email) ? email : null; + } + + /// + /// Resolve a SINGLE name-or-email token to an email address. + /// + /// Decision logic: + /// 1. If input contains "@" → treat as a literal email address, return as-is + /// 2. Otherwise → look up as an alias; throw if not found + /// + /// This is the core resolution function used by --as, individual attendees, etc. + /// + /// Thrown when the alias is not found. + public static string ResolveAlias(string nameOrEmail) + { + if (string.IsNullOrWhiteSpace(nameOrEmail)) + return nameOrEmail; + + var trimmed = nameOrEmail.Trim(); + + // "@" presence is the heuristic that distinguishes emails from alias names + if (trimmed.Contains('@')) + return trimmed; + + var email = GetAlias(trimmed); + if (email == null) + { + throw new KeyNotFoundException( + $"\"{trimmed}\" is not an email address and no alias found. " + + $"Add one with: outlook-cli contacts alias set {trimmed} user@example.com"); + } + + return email; + } + + /// + /// Resolve a COMMA-SEPARATED list of names/emails to email addresses. + /// + /// Splits on comma, resolves each token via ResolveAlias, then re-joins. + /// Used for --to, --cc, --bcc fields that accept multiple recipients. + /// + public static string ResolveRecipients(string recipientString) + { + if (string.IsNullOrWhiteSpace(recipientString)) + return recipientString; + + var resolved = recipientString + .Split(',') + .Select(r => ResolveAlias(r.Trim())); + + return string.Join(",", resolved); + } +} diff --git a/src/dotnet/Database/OperationsDb.cs b/src/dotnet/Database/OperationsDb.cs new file mode 100644 index 0000000..c85857a --- /dev/null +++ b/src/dotnet/Database/OperationsDb.cs @@ -0,0 +1,349 @@ +using Microsoft.Data.Sqlite; +using SQLitePCL; + +namespace OutlookCli.Database; + +/// +/// SQLite database manager for outlook-cli. +/// +/// Uses WAL mode for concurrent readers (multiple CLI processes). +/// Schema migrations match the Node.js implementation in db/database.js. +/// +internal sealed class OperationsDb : IDisposable +{ + private static readonly object Lock = new(); + private static OperationsDb? _instance; + private static bool _providerInitialized; + + private readonly SqliteConnection _connection; + + public SqliteConnection Connection => _connection; + + private OperationsDb(string dbPath) + { + InitProvider(); + + var dir = Path.GetDirectoryName(dbPath)!; + if (!Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + _connection = new SqliteConnection($"Data Source={dbPath}"); + _connection.Open(); + + Execute("PRAGMA journal_mode = WAL"); + Execute("PRAGMA busy_timeout = 5000"); + + RunMigrations(); + } + + /// Get the singleton instance using the default path (~/.outlook-cli/outlook-cli.db). + public static OperationsDb Instance + { + get + { + if (_instance != null) return _instance; + lock (Lock) + { + _instance ??= new OperationsDb(DefaultDbPath); + return _instance; + } + } + } + + public static string DefaultDbPath => + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".outlook-cli", "outlook-cli.db"); + + /// Create an instance at a custom path (for tests). + public static OperationsDb CreateAt(string dbPath) => new(dbPath); + + private void RunMigrations() + { + Execute("CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY)"); + + int currentVersion; + using (var cmd = _connection.CreateCommand()) + { + cmd.CommandText = "SELECT MAX(version) FROM schema_version"; + var result = cmd.ExecuteScalar(); + currentVersion = result is DBNull || result is null ? 0 : Convert.ToInt32(result); + } + + var migrations = new Action[] + { + // Version 1: Operations log + delta state + cache metadata (matches Node.js) + () => + { + Execute(""" + CREATE TABLE operations ( + id TEXT PRIMARY KEY, + parent_id TEXT, + account TEXT NOT NULL, + command TEXT NOT NULL, + operation TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'started', + started_at TEXT NOT NULL, + completed_at TEXT, + duration_ms INTEGER, + request_summary TEXT, + response_summary TEXT, + error_message TEXT, + error_stack TEXT, + metadata TEXT, + FOREIGN KEY (parent_id) REFERENCES operations(id) + ); + + CREATE TABLE delta_state ( + account TEXT NOT NULL, + resource TEXT NOT NULL, + folder TEXT, + delta_token TEXT, + last_sync_at TEXT, + PRIMARY KEY (account, resource, folder) + ); + + CREATE TABLE cache_metadata ( + account TEXT PRIMARY KEY, + last_refresh_at TEXT, + token_expiry_at TEXT, + scopes TEXT + ); + + CREATE INDEX idx_operations_account ON operations(account); + CREATE INDEX idx_operations_started ON operations(started_at); + CREATE INDEX idx_operations_command ON operations(command); + CREATE INDEX idx_operations_status ON operations(status); + CREATE INDEX idx_operations_parent ON operations(parent_id); + """); + }, + + // Version 2: Add correlation_id to operations (matches Node.js migration 2) + () => + { + Execute("ALTER TABLE operations ADD COLUMN correlation_id TEXT"); + Execute("CREATE INDEX idx_operations_correlation ON operations(correlation_id)"); + }, + + // Version 3: Add user, runtime, version columns (matches Node.js migration 3) + () => + { + Execute("ALTER TABLE operations ADD COLUMN user TEXT"); + Execute("ALTER TABLE operations ADD COLUMN runtime TEXT"); + Execute("ALTER TABLE operations ADD COLUMN version TEXT"); + } + }; + + for (int i = currentVersion; i < migrations.Length; i++) + { + using var tx = _connection.BeginTransaction(); + migrations[i](); + using var cmd = _connection.CreateCommand(); + cmd.CommandText = "INSERT INTO schema_version (version) VALUES (@v)"; + cmd.Parameters.AddWithValue("@v", i + 1); + cmd.ExecuteNonQuery(); + tx.Commit(); + } + } + + internal void Execute(string sql) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = sql; + cmd.ExecuteNonQuery(); + } + + public void Dispose() + { + _connection.Dispose(); + if (_instance == this) + { + lock (Lock) { if (_instance == this) _instance = null; } + } + } + + /// Reset singleton (for tests). + internal static void ResetInstance() + { + lock (Lock) + { + _instance?.Dispose(); + _instance = null; + } + } + + // ── Query methods for log commands ── + + /// List operations with optional filters, ordered by most recent first. + public List ListOperations(string? account = null, string? command = null, + string? status = null, string? since = null, int top = 50) + { + var sql = "SELECT * FROM operations WHERE 1=1"; + var cmd = _connection.CreateCommand(); + + if (account != null) { sql += " AND account = @account"; cmd.Parameters.AddWithValue("@account", account); } + if (command != null) { sql += " AND command LIKE @command"; cmd.Parameters.AddWithValue("@command", $"%{command}%"); } + if (status != null) { sql += " AND status = @status"; cmd.Parameters.AddWithValue("@status", status); } + if (since != null) { sql += " AND started_at >= @since"; cmd.Parameters.AddWithValue("@since", since); } + + sql += " ORDER BY started_at DESC LIMIT @top"; + cmd.Parameters.AddWithValue("@top", top); + cmd.CommandText = sql; + + return ReadOperations(cmd); + } + + /// Find all operations sharing a correlation ID. + public List ListByCorrelationId(string correlationId) + { + var cmd = _connection.CreateCommand(); + cmd.CommandText = "SELECT * FROM operations WHERE correlation_id = @cid ORDER BY started_at"; + cmd.Parameters.AddWithValue("@cid", correlationId); + return ReadOperations(cmd); + } + + /// Get aggregate summary over a window of days. + public OperationsSummary GetSummary(int days = 7) + { + var since = DateTime.UtcNow.AddDays(-days).ToString("o"); + + int total = ScalarInt("SELECT COUNT(*) FROM operations WHERE started_at >= @s", since); + int errors = ScalarInt("SELECT COUNT(*) FROM operations WHERE status = 'failed' AND started_at >= @s", since); + int? avgLatency = ScalarNullableInt("SELECT AVG(duration_ms) FROM operations WHERE duration_ms IS NOT NULL AND started_at >= @s", since); + int throttled = ScalarInt("SELECT COUNT(*) FROM operations WHERE error_message LIKE '%throttl%' AND started_at >= @s", since); + + // Per-day breakdown + var perDay = new List(); + using (var cmd = _connection.CreateCommand()) + { + cmd.CommandText = "SELECT DATE(started_at) as day, COUNT(*) as count FROM operations WHERE started_at >= @s GROUP BY DATE(started_at) ORDER BY day"; + cmd.Parameters.AddWithValue("@s", since); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) perDay.Add(new DayCount(reader.GetString(0), reader.GetInt32(1))); + } + + // Top commands + var topOps = new List(); + using (var cmd = _connection.CreateCommand()) + { + cmd.CommandText = "SELECT command, COUNT(*) as count FROM operations WHERE started_at >= @s GROUP BY command ORDER BY count DESC LIMIT 10"; + cmd.Parameters.AddWithValue("@s", since); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) topOps.Add(new CommandCount(reader.GetString(0), reader.GetInt32(1))); + } + + // Per-account breakdown + var perAccount = new List(); + using (var cmd = _connection.CreateCommand()) + { + cmd.CommandText = """ + SELECT account, COUNT(*) as total, + SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as errors, + AVG(duration_ms) as avg_latency_ms + FROM operations WHERE started_at >= @s + GROUP BY account ORDER BY total DESC + """; + cmd.Parameters.AddWithValue("@s", since); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + var avgMs = reader.IsDBNull(3) ? (int?)null : (int)Math.Round(reader.GetDouble(3)); + perAccount.Add(new AccountStats(reader.GetString(0), reader.GetInt32(1), reader.GetInt32(2), avgMs)); + } + } + + var errorRate = total > 0 ? (errors * 100.0 / total).ToString("F1") : "0.0"; + + return new OperationsSummary(days, since, total, errors, errorRate, + avgLatency != null ? (int)avgLatency : null, throttled, perDay, topOps, perAccount); + } + + private List ReadOperations(SqliteCommand cmd) + { + var results = new List(); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + results.Add(new OperationRecord( + Id: reader.GetString(reader.GetOrdinal("id")), + Account: reader.GetString(reader.GetOrdinal("account")), + Command: reader.GetString(reader.GetOrdinal("command")), + Operation: reader.GetString(reader.GetOrdinal("operation")), + Status: reader.GetString(reader.GetOrdinal("status")), + StartedAt: reader.IsDBNull(reader.GetOrdinal("started_at")) ? null : reader.GetString(reader.GetOrdinal("started_at")), + CompletedAt: reader.IsDBNull(reader.GetOrdinal("completed_at")) ? null : reader.GetString(reader.GetOrdinal("completed_at")), + DurationMs: reader.IsDBNull(reader.GetOrdinal("duration_ms")) ? null : reader.GetInt32(reader.GetOrdinal("duration_ms")), + CorrelationId: HasColumn(reader, "correlation_id") && !reader.IsDBNull(reader.GetOrdinal("correlation_id")) + ? reader.GetString(reader.GetOrdinal("correlation_id")) : null, + ErrorMessage: reader.IsDBNull(reader.GetOrdinal("error_message")) ? null : reader.GetString(reader.GetOrdinal("error_message")) + )); + } + return results; + } + + private int ScalarInt(string sql, string since) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = sql; + cmd.Parameters.AddWithValue("@s", since); + return Convert.ToInt32(cmd.ExecuteScalar()); + } + + private int? ScalarNullableInt(string sql, string since) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = sql; + cmd.Parameters.AddWithValue("@s", since); + var result = cmd.ExecuteScalar(); + return result is DBNull || result is null ? null : (int)Math.Round(Convert.ToDouble(result)); + } + + private static bool HasColumn(Microsoft.Data.Sqlite.SqliteDataReader reader, string columnName) + { + for (int i = 0; i < reader.FieldCount; i++) + if (reader.GetName(i) == columnName) return true; + return false; + } + + /// Initialize the SQLite native provider (required for NativeAOT). + private static void InitProvider() + { + if (_providerInitialized) return; + lock (Lock) + { + if (_providerInitialized) return; + raw.SetProvider(new SQLite3Provider_e_sqlite3()); + _providerInitialized = true; + } + } +} + +// ── Record types for log query results ── + +internal record OperationRecord( + string Id, + string Account, + string Command, + string Operation, + string Status, + string? StartedAt, + string? CompletedAt, + int? DurationMs, + string? CorrelationId, + string? ErrorMessage +); + +internal record OperationsSummary( + int Days, + string Since, + int Total, + int Errors, + string ErrorRate, + int? AvgLatencyMs, + int Throttled, + List PerDay, + List TopOperations, + List PerAccount +); + +internal record DayCount(string Day, int Count); +internal record CommandCount(string Command, int Count); +internal record AccountStats(string Account, int Total, int Errors, int? AvgLatencyMs); diff --git a/src/dotnet/Database/OperationsLogger.cs b/src/dotnet/Database/OperationsLogger.cs new file mode 100644 index 0000000..f494cb9 --- /dev/null +++ b/src/dotnet/Database/OperationsLogger.cs @@ -0,0 +1,230 @@ +using System.Text.Json; +using Microsoft.Data.Sqlite; +using OutlookCli.Errors; + +namespace OutlookCli.Database; + +/// +/// Operation logger with correlation IDs. +/// +/// Every CLI command and Graph API call gets a unique operation ID. +/// Operations can be nested (parent_id) for tracing: +/// "watch" → "delta sync" → "list messages" → "GET /me/messages" +/// +/// Mirrors the Node.js db/logger.js API. +/// +internal static class OperationsLogger +{ + /// Start a new operation and return its ID. + public static string StartOperation( + string account, + string command, + string operation, + object? requestSummary = null, + string? parentId = null, + OperationsDb? db = null) + { + var id = Guid.NewGuid().ToString(); + db ??= OperationsDb.Instance; + var conn = db.Connection; + + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + INSERT INTO operations (id, parent_id, account, command, operation, status, started_at, request_summary) + VALUES (@id, @parentId, @account, @command, @operation, 'started', datetime('now'), @requestSummary) + """; + cmd.Parameters.AddWithValue("@id", id); + cmd.Parameters.AddWithValue("@parentId", (object?)parentId ?? DBNull.Value); + cmd.Parameters.AddWithValue("@account", account); + cmd.Parameters.AddWithValue("@command", command); + cmd.Parameters.AddWithValue("@operation", operation); + cmd.Parameters.AddWithValue("@requestSummary", + requestSummary != null ? JsonSerializer.Serialize(requestSummary, OutlookCliJsonContext.Default.JsonElement) : DBNull.Value); + + cmd.ExecuteNonQuery(); + return id; + } + + /// Mark an operation as completed. + public static void CompleteOperation(string id, object? responseSummary = null, OperationsDb? db = null) + { + db ??= OperationsDb.Instance; + var conn = db.Connection; + + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + UPDATE operations + SET status = 'completed', + completed_at = datetime('now'), + duration_ms = CAST((julianday('now') - julianday(started_at)) * 86400000 AS INTEGER), + response_summary = @responseSummary + WHERE id = @id + """; + cmd.Parameters.AddWithValue("@responseSummary", + responseSummary != null ? JsonSerializer.Serialize(responseSummary, OutlookCliJsonContext.Default.JsonElement) : DBNull.Value); + cmd.Parameters.AddWithValue("@id", id); + + cmd.ExecuteNonQuery(); + } + + /// Mark an operation as failed. + public static void FailOperation(string id, Exception error, OperationsDb? db = null) + { + db ??= OperationsDb.Instance; + var conn = db.Connection; + + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + UPDATE operations + SET status = 'failed', + completed_at = datetime('now'), + duration_ms = CAST((julianday('now') - julianday(started_at)) * 86400000 AS INTEGER), + error_message = @errMsg, + error_stack = @errStack + WHERE id = @id + """; + + var errMsg = error is OutlookCliException oce ? $"[{oce.Code}] {oce.Message}" : error.Message; + cmd.Parameters.AddWithValue("@errMsg", errMsg); + cmd.Parameters.AddWithValue("@errStack", (object?)error.StackTrace ?? DBNull.Value); + cmd.Parameters.AddWithValue("@id", id); + + cmd.ExecuteNonQuery(); + } + + /// List recent operations with optional filters. + public static List> ListOperations( + string? account = null, + string? command = null, + string? status = null, + string? since = null, + int top = 50, + OperationsDb? db = null) + { + db ??= OperationsDb.Instance; + var conn = db.Connection; + + var sql = "SELECT * FROM operations WHERE 1=1"; + var parameters = new List(); + + if (account != null) + { + sql += " AND account = @account"; + parameters.Add(new SqliteParameter("@account", account)); + } + if (command != null) + { + sql += " AND command LIKE @command"; + parameters.Add(new SqliteParameter("@command", $"%{command}%")); + } + if (status != null) + { + sql += " AND status = @status"; + parameters.Add(new SqliteParameter("@status", status)); + } + if (since != null) + { + sql += " AND started_at >= @since"; + parameters.Add(new SqliteParameter("@since", since)); + } + + sql += " ORDER BY started_at DESC LIMIT @top"; + parameters.Add(new SqliteParameter("@top", top)); + + return ExecuteQuery(conn, sql, parameters); + } + + /// Get child operations for a parent. + public static List> GetChildOperations(string parentId, OperationsDb? db = null) + { + db ??= OperationsDb.Instance; + var conn = db.Connection; + + return ExecuteQuery(conn, + "SELECT * FROM operations WHERE parent_id = @parentId ORDER BY started_at", + [new SqliteParameter("@parentId", parentId)]); + } + + /// Search operations by text across multiple fields. + public static List> SearchOperations(string query, int top = 50, OperationsDb? db = null) + { + db ??= OperationsDb.Instance; + var conn = db.Connection; + var pattern = $"%{query}%"; + + return ExecuteQuery(conn, + """ + SELECT * FROM operations + WHERE command LIKE @q OR operation LIKE @q OR error_message LIKE @q OR request_summary LIKE @q + ORDER BY started_at DESC LIMIT @top + """, + [new SqliteParameter("@q", pattern), new SqliteParameter("@top", top)]); + } + + /// Delete operations older than the specified date. + public static int CompactOperations(string beforeDate, OperationsDb? db = null) + { + db ??= OperationsDb.Instance; + var conn = db.Connection; + + using var cmd = conn.CreateCommand(); + cmd.CommandText = "DELETE FROM operations WHERE started_at < @before"; + cmd.Parameters.AddWithValue("@before", beforeDate); + return cmd.ExecuteNonQuery(); + } + + /// Get operation statistics. + public static OperationsStats GetStats(OperationsDb? db = null) + { + db ??= OperationsDb.Instance; + var conn = db.Connection; + + int GetCount(string sql) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = sql; + var result = cmd.ExecuteScalar(); + return result is DBNull || result is null ? 0 : Convert.ToInt32(result); + } + + double? GetAvg() + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT AVG(duration_ms) FROM operations WHERE duration_ms IS NOT NULL"; + var result = cmd.ExecuteScalar(); + return result is DBNull || result is null ? null : Convert.ToDouble(result); + } + + return new OperationsStats( + Total: GetCount("SELECT COUNT(*) FROM operations"), + Completed: GetCount("SELECT COUNT(*) FROM operations WHERE status = 'completed'"), + Failed: GetCount("SELECT COUNT(*) FROM operations WHERE status = 'failed'"), + AvgDurationMs: GetAvg() + ); + } + + private static List> ExecuteQuery( + SqliteConnection conn, string sql, List parameters) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = sql; + foreach (var p in parameters) + cmd.Parameters.Add(p); + + using var reader = cmd.ExecuteReader(); + var results = new List>(); + while (reader.Read()) + { + var row = new Dictionary(); + for (int i = 0; i < reader.FieldCount; i++) + { + row[reader.GetName(i)] = reader.IsDBNull(i) ? null : reader.GetValue(i); + } + results.Add(row); + } + return results; + } +} + +/// Operation statistics summary. +internal record OperationsStats(int Total, int Completed, int Failed, double? AvgDurationMs); diff --git a/src/dotnet/Errors/OutlookCliException.cs b/src/dotnet/Errors/OutlookCliException.cs new file mode 100644 index 0000000..c9e302a --- /dev/null +++ b/src/dotnet/Errors/OutlookCliException.cs @@ -0,0 +1,207 @@ +using System.Text.Json; + +namespace OutlookCli.Errors; + +/// +/// Base class for all outlook-cli errors. Each error type provides: +/// userMessage (plain English), suggestedAction (what to do), technicalDetail (for --verbose). +/// +public class OutlookCliException : Exception +{ + public string Code { get; } + public string SuggestedAction { get; } + public string TechnicalDetail { get; } + + public OutlookCliException(string message, string code = "UNKNOWN_ERROR", + string suggestedAction = "", string technicalDetail = "", Exception? innerException = null) + : base(message, innerException) + { + Code = code; + SuggestedAction = suggestedAction; + TechnicalDetail = technicalDetail; + } +} + +/// Authentication errors — token expired, cache corrupt, re-login needed. +public class AuthException : OutlookCliException +{ + public AuthException(string message, string code = "AUTH_ERROR", + string suggestedAction = "", string technicalDetail = "", Exception? inner = null) + : base(message, code, suggestedAction, technicalDetail, inner) { } + + public static AuthException TokenExpired(string account) => + new($"Authentication expired for account \"{account}\".", + "AUTH_TOKEN_EXPIRED", + $"Run `outlook-cli auth login --account {account}` to re-authenticate.", + "Refresh token has expired or been revoked."); + + public static AuthException CacheCorrupt(string account, Exception? inner = null) => + new($"Could not decrypt token cache for \"{account}\".", + "AUTH_CACHE_CORRUPT", + $"Run `outlook-cli auth login --account {account}` to re-authenticate.", + inner?.Message ?? "Cache decryption failed.", inner); + + public static AuthException NoToken(string account) => + new($"No valid token for account \"{account}\".", + "AUTH_NO_TOKEN", + $"Run `outlook-cli auth login --account {account}` to authenticate."); + + public static AuthException NoClientId() => + new("No client ID configured.", + "AUTH_NO_CLIENT_ID", + "Provide --client-id, set OUTLOOK_CLI_CLIENT_ID env var, or run `outlook-cli account add`."); +} + +/// Graph API errors — 4xx/5xx from Microsoft Graph. +public class GraphApiException : OutlookCliException +{ + public int StatusCode { get; } + public string? GraphRequestId { get; } + + public GraphApiException(string message, int statusCode, string code = "GRAPH_API_ERROR", + string suggestedAction = "", string technicalDetail = "", string? graphRequestId = null) + : base(message, code, suggestedAction, technicalDetail) + { + StatusCode = statusCode; + GraphRequestId = graphRequestId; + } + + public static GraphApiException FromStatusCode(int statusCode, string endpoint, string? detail = null) + { + return statusCode switch + { + 401 => new("Authentication failed — your token may be expired.", statusCode, + "GRAPH_AUTH_FAILED", "Run `outlook-cli auth login` to re-authenticate.", detail ?? ""), + 403 => new($"Permission denied for {endpoint}.", statusCode, + "GRAPH_FORBIDDEN", "Check that your Azure App Registration has the required API permissions.", detail ?? ""), + 404 => new($"Resource not found: {endpoint}.", statusCode, + "GRAPH_NOT_FOUND", "Verify the message ID or folder name is correct.", detail ?? ""), + 429 => new("Rate limited by Microsoft Graph API.", statusCode, + "GRAPH_THROTTLED", "Wait a moment and try again. The CLI will auto-retry.", detail ?? ""), + 503 => new("Microsoft Graph API is temporarily unavailable.", statusCode, + "GRAPH_UNAVAILABLE", "Wait a few minutes and try again.", detail ?? ""), + _ => new($"Graph API error ({statusCode}): {detail ?? "Unknown error"}", statusCode, + "GRAPH_API_ERROR", "", detail ?? ""), + }; + } +} + +/// Configuration errors — invalid config, schema mismatch, missing file. +public class ConfigException : OutlookCliException +{ + public ConfigException(string message, string code = "CONFIG_ERROR", + string suggestedAction = "", string technicalDetail = "", Exception? inner = null) + : base(message, code, suggestedAction, technicalDetail, inner) { } + + public static ConfigException AccountNotFound(string alias) => + new($"Account \"{alias}\" not found.", + "CONFIG_ACCOUNT_NOT_FOUND", + "Run `outlook-cli account list` to see available accounts, or `outlook-cli account add` to create one."); + + public static ConfigException NoAccountConfigured() => + new("No account configured.", + "CONFIG_NO_ACCOUNT", + "Run `outlook-cli account add --client-id ` to set up your first account."); + + public static ConfigException SchemaVersionMismatch(string file, int fileVersion, int currentVersion) + { + bool newer = fileVersion > currentVersion; + return new( + newer + ? $"{file} has schema version {fileVersion} but this binary only supports up to {currentVersion}." + : $"{file} has outdated schema version {fileVersion} (current: {currentVersion}).", + newer ? "CONFIG_TOO_NEW" : "CONFIG_TOO_OLD", + newer + ? "Please upgrade your outlook-cli binary to the latest version." + : "Run `outlook-cli upgrade` to migrate your configuration files."); + } +} + +/// Permission errors — scope not granted or send_to whitelist violation. +public class PermissionException : OutlookCliException +{ + public PermissionException(string message, string code = "PERMISSION_ERROR", + string suggestedAction = "", string technicalDetail = "") + : base(message, code, suggestedAction, technicalDetail) { } + + public static PermissionException ScopeForbidden(string scope, string account) => + new($"The \"{scope}\" permission is forbidden for account \"{account}\".", + "PERMISSION_SCOPE_FORBIDDEN", + $"Update permissions in accounts.json to allow \"{scope}\" for this account."); + + public static PermissionException RecipientBlocked(IEnumerable blocked, string account) => + new($"Cannot send to: {string.Join(", ", blocked)} — not in the send_to whitelist for account \"{account}\".", + "PERMISSION_RECIPIENT_BLOCKED", + "Add the recipient to the \"send_to\" list in accounts.json."); + + public static PermissionException SecurityViolation(IEnumerable scopes) => + new($"SECURITY: Token contains dangerous scope(s): {string.Join(", ", scopes)}", + "PERMISSION_SECURITY_VIOLATION", + "These scopes are forbidden. Revoke the token and reconfigure your Azure App Registration."); +} + +/// Input validation errors — missing args, invalid formats. +public class InputException : OutlookCliException +{ + public InputException(string message, string code = "INPUT_ERROR", + string suggestedAction = "", string technicalDetail = "") + : base(message, code, suggestedAction, technicalDetail) { } + + public static InputException MissingArg(string argName, string? context = null) => + new($"{argName} is required{(context != null ? $" ({context})" : "")}.", + "INPUT_MISSING_ARG", + $"Provide {argName} as a positional argument or in --input JSON file."); +} + +/// Formats errors for terminal or JSON output. +public static class ErrorFormatter +{ + public static string FormatText(Exception error, bool verbose = false) + { + var lines = new List(); + + if (error is OutlookCliException oce) + { + lines.Add($"❌ {oce.Message}"); + if (!string.IsNullOrEmpty(oce.SuggestedAction)) + lines.Add($" Fix: {oce.SuggestedAction}"); + if (verbose && !string.IsNullOrEmpty(oce.TechnicalDetail)) + lines.Add($" Details: {oce.TechnicalDetail}"); + if (verbose && error is GraphApiException gae && !string.IsNullOrEmpty(gae.GraphRequestId)) + lines.Add($" Graph Request ID: {gae.GraphRequestId}"); + } + else + { + lines.Add($"❌ {error.Message}"); + if (verbose && error.StackTrace != null) + lines.Add($" Stack: {error.StackTrace}"); + } + + return string.Join(Environment.NewLine, lines); + } + + public static string FormatJson(Exception error) + { + using var stream = new System.IO.MemoryStream(); + using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true }); + writer.WriteStartObject(); + writer.WriteBoolean("error", true); + + if (error is OutlookCliException oce) + { + writer.WriteString("code", oce.Code); + writer.WriteString("message", oce.Message); + if (!string.IsNullOrEmpty(oce.SuggestedAction)) + writer.WriteString("suggestedAction", oce.SuggestedAction); + } + else + { + writer.WriteString("code", "UNKNOWN_ERROR"); + writer.WriteString("message", error.Message); + } + + writer.WriteEndObject(); + writer.Flush(); + return System.Text.Encoding.UTF8.GetString(stream.ToArray()); + } +} diff --git a/src/dotnet/Graph/CalendarService.cs b/src/dotnet/Graph/CalendarService.cs new file mode 100644 index 0000000..03e2674 --- /dev/null +++ b/src/dotnet/Graph/CalendarService.cs @@ -0,0 +1,164 @@ +/// +/// Calendar Graph API wrappers. +/// +/// IMPORTANT DISTINCTION: This module uses the /calendarView endpoint for listing +/// events, NOT /events. calendarView expands recurring events into individual +/// occurrences within the requested time window, which is what users expect when +/// asking "what's on my calendar today." The /events endpoint only returns the +/// master series, which would miss most recurring meetings. +/// +/// All methods accept a from GraphClient.CreateGraphClient(). +/// Like MailService, the client exposes ("/me" or +/// "/users/{email}") enabling delegate calendar access with no code changes — the same +/// methods work for both own and shared calendars. +/// +/// The Prefer header for timezone must be set manually so Graph returns event times +/// in the user's timezone rather than UTC. +/// + +using System.Text.Json; + +namespace OutlookCli.Graph; + +public static class CalendarService +{ + /// Default $select fields for calendar events. + private const string EventFields = + "id,subject,start,end,location,organizer,attendees,isAllDay,isCancelled,showAs,importance"; + + /// + /// Get today's events using calendarView. + /// + /// Computes midnight-to-midnight in local time, then delegates to GetEventsInRangeAsync. + /// + /// Graph API client. + /// IANA timezone name (e.g., "America/New_York"). Defaults to local timezone. + public static async Task GetTodayEventsAsync( + GraphClient client, string? timezone = null) + { + var tz = timezone ?? TimeZoneInfo.Local.Id; + var now = DateTime.Now; + var startOfDay = new DateTime(now.Year, now.Month, now.Day, 0, 0, 0, DateTimeKind.Local); + var endOfDay = startOfDay.AddDays(1); + + return await GetEventsInRangeAsync(client, + startOfDay.ToUniversalTime().ToString("o"), + endOfDay.ToUniversalTime().ToString("o"), + tz); + } + + /// + /// Get this week's events using calendarView. + /// + /// Week starts on Sunday (DayOfWeek.Sunday = 0). Computes the enclosing Sun–Sat range. + /// + /// Graph API client. + /// IANA timezone name. Defaults to local timezone. + public static async Task GetWeekEventsAsync( + GraphClient client, string? timezone = null) + { + var tz = timezone ?? TimeZoneInfo.Local.Id; + var now = DateTime.Now; + var dayOfWeek = (int)now.DayOfWeek; + var startOfWeek = new DateTime(now.Year, now.Month, now.Day, 0, 0, 0, DateTimeKind.Local) + .AddDays(-dayOfWeek); + var endOfWeek = startOfWeek.AddDays(7); + + return await GetEventsInRangeAsync(client, + startOfWeek.ToUniversalTime().ToString("o"), + endOfWeek.ToUniversalTime().ToString("o"), + tz); + } + + /// + /// Get events in a date range using calendarView. + /// + /// Graph endpoint: GET {userPath}/calendarView?startDateTime=...&endDateTime=... + /// + /// Key details: + /// - calendarView requires both startDateTime and endDateTime (unlike /events) + /// - $orderby=start/dateTime sorts chronologically + /// - The Prefer header with outlook.timezone tells Graph to return start/end times + /// in the user's timezone rather than UTC + /// - $top=50 is generous; most users have <50 events in a single time range + /// + /// Graph API client. + /// Start date/time in ISO 8601 format. + /// End date/time in ISO 8601 format. + /// IANA timezone for result formatting. + /// Maximum events to return (default: 50). + public static async Task GetEventsInRangeAsync( + GraphClient client, string start, string end, string? timezone = null, int top = 50) + { + var path = $"{client.UserPath}/calendarView" + + $"?startDateTime={Uri.EscapeDataString(start)}" + + $"&endDateTime={Uri.EscapeDataString(end)}" + + $"&$top={top}&$orderby=start/dateTime&$select={EventFields}"; + + Dictionary? headers = null; + if (!string.IsNullOrEmpty(timezone)) + { + // Prefer header tells Graph to return event times in this timezone + // instead of UTC — critical for correct CLI display. + headers = new() { ["Prefer"] = $"outlook.timezone=\"{timezone}\"" }; + } + + var response = await client.GetAsync(path, headers); + return GetValue(response); + } + + /// + /// Get a single event by ID. + /// + /// Graph endpoint: GET {userPath}/events/{id} + /// Uses /events (not /calendarView) because we have a specific ID, not a time range. + /// + public static async Task GetEventAsync(GraphClient client, string eventId) + { + return await client.GetAsync($"{client.UserPath}/events/{Uri.EscapeDataString(eventId)}"); + } + + /// + /// List all calendars (not events — the calendar containers themselves). + /// + /// Graph endpoint: GET {userPath}/calendars + /// + public static async Task ListCalendarsAsync(GraphClient client) + { + var response = await client.GetAsync($"{client.UserPath}/calendars?$top=100"); + return GetValue(response); + } + + /// + /// Create a calendar event. + /// + /// Graph endpoint: POST {userPath}/events + /// The event object should include subject, start, end, and optionally + /// attendees, location, body. Timezone is specified per start/end object + /// (e.g., { dateTime: "...", timeZone: "America/New_York" }). + /// + public static async Task CreateEventAsync(GraphClient client, string eventDataJson) + { + return await client.PostAsync($"{client.UserPath}/events", eventDataJson); + } + + /// + /// Delete a calendar event. + /// + /// Graph endpoint: DELETE {userPath}/events/{id} + /// This permanently removes the event (no soft-delete to trash like mail). + /// + public static async Task DeleteEventAsync(GraphClient client, string eventId) + { + await client.DeleteAsync($"{client.UserPath}/events/{Uri.EscapeDataString(eventId)}"); + } + + /// Extract the "value" property from a Graph API response, or return the whole response. + private static JsonElement? GetValue(JsonElement? response) + { + if (response?.ValueKind == JsonValueKind.Object && + response.Value.TryGetProperty("value", out var value)) + return value; + return response; + } +} diff --git a/src/dotnet/Graph/ContactsService.cs b/src/dotnet/Graph/ContactsService.cs new file mode 100644 index 0000000..4b949a6 --- /dev/null +++ b/src/dotnet/Graph/ContactsService.cs @@ -0,0 +1,143 @@ +/// +/// Contact search — Graph API wrappers. +/// +/// DUAL-SOURCE STRATEGY: We search two separate Graph APIs and merge results: +/// 1. /contacts — the user's personal contacts (Outlook address book). +/// Uses $filter with startswith() because /contacts doesn't support $search. +/// 2. /people — relevance-ranked results from the Global Address List (GAL) +/// and recent communication history. Uses $search (KQL). +/// +/// Why both? Personal contacts give the user's curated address book; People API +/// gives the full org directory plus ML-ranked relevance. Neither alone is sufficient. +/// +/// IMPORTANT: The People API (/people) requires the People.Read scope, which is +/// only available for work/school (Azure AD) accounts. Personal Microsoft accounts +/// will get a 403 here — we catch and continue so the command still returns +/// personal contacts. +/// +/// Results are tagged with a "source" property ("contacts" or "people") so the UI +/// can show where each result came from. +/// + +using System.Text.Json; +using OutlookCli; + +namespace OutlookCli.Graph; + +public static class ContactsService +{ + /// + /// Search contacts across personal contacts and the People API (GAL). + /// + /// Order of operations: + /// 1. Query /contacts with $filter (personal address book) + /// 2. Query /people with $search (org directory + relevance) + /// 3. Merge and deduplicate by primary email address (case-insensitive) + /// + /// Graph API client with UserPath. + /// Search string (name prefix or keyword). + /// Maximum results per source (default: 25). + /// Deduplicated contacts as a JsonElement array. + public static async Task> SearchContactsAsync( + GraphClient client, string query, int top = 25) + { + var results = new List<(JsonElement Element, string Source)>(); + + // Source 1: Personal contacts — $filter with startswith on name fields + // Graph endpoint: GET {userPath}/contacts?$filter=startswith(...) + try + { + var encodedQuery = Uri.EscapeDataString(query); + var contactsPath = $"{client.UserPath}/contacts" + + $"?$filter=startswith(displayName,'{encodedQuery}') or " + + $"startswith(givenName,'{encodedQuery}') or " + + $"startswith(surname,'{encodedQuery}')" + + $"&$top={top}&$select=id,displayName,emailAddresses,companyName,jobTitle,mobilePhone,businessPhones"; + + var contactsResponse = await client.GetAsync(contactsPath); + if (contactsResponse.HasValue) + { + var value = contactsResponse.Value; + if (value.ValueKind == JsonValueKind.Array) + { + foreach (var c in value.EnumerateArray()) + results.Add((c.Clone(), "contacts")); + } + else if (value.TryGetProperty("value", out var arr) && arr.ValueKind == JsonValueKind.Array) + { + foreach (var c in arr.EnumerateArray()) + results.Add((c.Clone(), "contacts")); + } + } + } + catch + { + // Personal contacts search may fail for some account types; continue + } + + // Source 2: People API — relevance-ranked from GAL + communication history + // Graph endpoint: GET {userPath}/people?$search="..." + try + { + var peoplePath = $"{client.UserPath}/people" + + $"?$search=\"{Uri.EscapeDataString(query)}\"" + + $"&$top={top}&$select=id,displayName,emailAddresses,companyName,jobTitle,department"; + + var peopleResponse = await client.GetAsync(peoplePath); + if (peopleResponse.HasValue) + { + var value = peopleResponse.Value; + if (value.ValueKind == JsonValueKind.Array) + { + foreach (var p in value.EnumerateArray()) + results.Add((p.Clone(), "people")); + } + else if (value.TryGetProperty("value", out var arr) && arr.ValueKind == JsonValueKind.Array) + { + foreach (var p in arr.EnumerateArray()) + results.Add((p.Clone(), "people")); + } + } + } + catch + { + // People API requires People.Read scope — unavailable for personal accounts + } + + // Deduplicate by primary email address (case-insensitive). + // Personal contacts are added first, so they win on duplicates — the user's + // own contact data takes precedence over GAL entries. + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var deduplicated = new List(); + + foreach (var (element, source) in results) + { + string? email = null; + if (element.TryGetProperty("emailAddresses", out var emailProp) && + emailProp.ValueKind == JsonValueKind.Array) + { + var first = emailProp.EnumerateArray().FirstOrDefault(); + if (first.ValueKind == JsonValueKind.Object && + first.TryGetProperty("address", out var addrProp)) + { + email = addrProp.GetString(); + } + } + + if (string.IsNullOrEmpty(email) || !seen.Add(email)) + continue; + + // Add source field to the element by creating a new object + using var doc = JsonDocument.Parse(element.GetRawText()); + var dict = JsonSerializer.Deserialize(element.GetRawText(), OutlookCliJsonContext.Default.DictionaryStringJsonElement) + ?? new Dictionary(); + + dict["source"] = JsonDocument.Parse($"\"{source}\"").RootElement.Clone(); + var enrichedJson = JsonSerializer.Serialize(dict, OutlookCliJsonContext.Default.DictionaryStringJsonElement); + var enriched = JsonSerializer.Deserialize(enrichedJson, OutlookCliJsonContext.Default.JsonElement); + deduplicated.Add(enriched); + } + + return deduplicated; + } +} diff --git a/src/dotnet/Graph/DriveService.cs b/src/dotnet/Graph/DriveService.cs new file mode 100644 index 0000000..363c026 --- /dev/null +++ b/src/dotnet/Graph/DriveService.cs @@ -0,0 +1,131 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; + +namespace OutlookCli.Graph; + +/// +/// OneDrive Graph API operations — list, upload, download, search files. +/// +/// Uses the same GraphClient and authentication as mail/calendar/contacts. +/// Permissions: Files.Read (list/download), Files.ReadWrite (upload/create/share). +/// +/// Graph API endpoints: +/// List files: GET /me/drive/root/children (or /root:/{path}:/children) +/// Upload small: PUT /me/drive/root:/{path}:/content (less than 4MB) +/// Download: GET /me/drive/items/{id}/content +/// Create folder: POST /me/drive/root/children +/// Share link: POST /me/drive/items/{id}/createLink +/// Search: GET /me/drive/root/search(q='{query}') +/// +public static class DriveService +{ + private const string DriveItemFields = "id,name,size,lastModifiedDateTime,folder,file,webUrl"; + + /// List files in a folder. + /// Authenticated GraphClient. + /// Path relative to drive root, or null/empty for root. + /// Max items to return (default 50). + public static async Task ListFilesAsync(GraphClient client, string? folderPath = null, int top = 50) + { + string path; + if (string.IsNullOrEmpty(folderPath) || folderPath == "/") + { + path = $"{client.UserPath}/drive/root/children"; + } + else + { + var normalized = folderPath.Trim('/'); + path = $"{client.UserPath}/drive/root:/{Uri.EscapeDataString(normalized)}:/children"; + } + + path += $"?$top={top}&$select={DriveItemFields}"; + return await client.GetAsync(path); + } + + /// Upload a file (less than 4MB inline upload). + /// Authenticated GraphClient. + /// Destination path (e.g., "Documents/report.pdf"). + /// File content as bytes. + /// MIME type (default: application/octet-stream). + public static async Task UploadFileAsync( + GraphClient client, string remotePath, byte[] content, string? contentType = null) + { + var normalized = remotePath.Trim('/'); + var path = $"{client.UserPath}/drive/root:/{Uri.EscapeDataString(normalized)}:/content"; + return await client.PutBytesAsync(path, content, contentType ?? "application/octet-stream"); + } + + /// Download a file by item ID. Returns raw bytes. + public static async Task DownloadFileAsync(GraphClient client, string itemId) + { + var path = $"{client.UserPath}/drive/items/{Uri.EscapeDataString(itemId)}/content"; + return await client.GetBytesAsync(path); + } + + /// Get file metadata by item ID. + public static async Task GetFileMetadataAsync(GraphClient client, string itemId) + { + var path = $"{client.UserPath}/drive/items/{Uri.EscapeDataString(itemId)}?$select={DriveItemFields}"; + return await client.GetAsync(path); + } + + /// Create a folder in OneDrive. + public static async Task CreateFolderAsync( + GraphClient client, string name, string? parentPath = null) + { + string path; + if (string.IsNullOrEmpty(parentPath) || parentPath == "/") + { + path = $"{client.UserPath}/drive/root/children"; + } + else + { + var normalized = parentPath.Trim('/'); + path = $"{client.UserPath}/drive/root:/{Uri.EscapeDataString(normalized)}:/children"; + } + + var body = new System.Text.Json.Nodes.JsonObject + { + ["name"] = name, + ["folder"] = new System.Text.Json.Nodes.JsonObject(), + ["@microsoft.graph.conflictBehavior"] = "rename" + }; + + return await client.PostAsync(path, body.ToJsonString()); + } + + /// Create a sharing link for a file. + /// "view" (read-only) or "edit" (read-write). + /// "anonymous" or "organization". + public static async Task CreateShareLinkAsync( + GraphClient client, string itemId, string linkType = "view", string scope = "organization") + { + var path = $"{client.UserPath}/drive/items/{Uri.EscapeDataString(itemId)}/createLink"; + var body = new System.Text.Json.Nodes.JsonObject + { + ["type"] = linkType, + ["scope"] = scope + }; + + return await client.PostAsync(path, body.ToJsonString()); + } + + /// Search for files in OneDrive. + public static async Task SearchFilesAsync( + GraphClient client, string query, int top = 25) + { + var path = $"{client.UserPath}/drive/root/search(q='{Uri.EscapeDataString(query)}')?$top={top}&$select={DriveItemFields}"; + return await client.GetAsync(path); + } + + /// Format file size in human-readable form. + public static string FormatFileSize(long bytes) + { + if (bytes < 1024) return $"{bytes} B"; + if (bytes < 1024 * 1024) return $"{bytes / 1024.0:F1} KB"; + if (bytes < 1024 * 1024 * 1024) return $"{bytes / (1024.0 * 1024.0):F1} MB"; + return $"{bytes / (1024.0 * 1024.0 * 1024.0):F1} GB"; + } +} diff --git a/src/dotnet/Graph/GraphClient.cs b/src/dotnet/Graph/GraphClient.cs new file mode 100644 index 0000000..eb8b46c --- /dev/null +++ b/src/dotnet/Graph/GraphClient.cs @@ -0,0 +1,381 @@ +/// +/// Microsoft Graph API HTTP client. +/// +/// Wraps the Graph REST API (https://graph.microsoft.com/v1.0) with: +/// - Automatic token acquisition via MSAL (silent → cached → refresh) +/// - Token validation (defense-in-depth scope check on every request) +/// - 401 retry (token expired mid-request → clear cache, re-acquire, retry) +/// - 429 rate-limit handling (respect Retry-After header) +/// - Pagination support (follow @odata.nextLink) +/// - Delegate access (/users/{email} instead of /me when --as is used) +/// +/// All Graph API service classes (MailService, CalendarService, ContactsService) use this client. +/// They call client.GetAsync/PostAsync/PatchAsync/DeleteAsync with Graph API paths, and the client +/// handles authentication, retries, and error formatting. +/// + +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using OutlookCli; +using Microsoft.Identity.Client; +using OutlookCli.Auth; +using OutlookCli.Errors; +using OutlookCli.Security; + +namespace OutlookCli.Graph; + +public class GraphClient +{ + private const string GraphBase = "https://graph.microsoft.com/v1.0"; + + private readonly IPublicClientApplication _msalClient; + private readonly string _accountAlias; + private readonly string? _delegateFor; + private readonly HttpClient _httpClient; + private readonly Accounts.AccountConfig? _accountConfig; + + private string? _cachedToken; // In-memory token cache (avoids disk reads on every call) + private long _tokenExpiry; // Epoch ms when cached token expires + + private GraphClient(IPublicClientApplication msalClient, string accountAlias, string? delegateFor, Accounts.AccountConfig? accountConfig = null) + { + _msalClient = msalClient; + _accountAlias = accountAlias; + _delegateFor = delegateFor; + _accountConfig = accountConfig; + _httpClient = new HttpClient(); + } + + /// + /// Create an authenticated Graph API client for a specific account. + /// + /// Account nickname (matches the encrypted cache file). + /// Account config from accounts.json (clientId, tenantId). + /// Email address for delegate access. When set, all API calls + /// target /users/{email}/ instead of /me/. Requires the mailbox owner to have granted + /// delegate permissions. + public static GraphClient CreateGraphClient( + string accountAlias, Accounts.AccountConfig config, string? delegateFor = null) + { + var msalClient = MsalClientFactory.CreateMsalClient( + accountAlias, config.ClientId, config.TenantId); + + return new GraphClient(msalClient, accountAlias, delegateFor, config); + } + + /// + /// Get the base user path for Graph API requests. + /// + /// This is the key mechanism for delegate access: + /// - Own account: /me (default) + /// - Delegate: /users/fred%40example.com + /// + /// All graph API services (MailService, CalendarService, ContactsService) prepend this + /// to their endpoint paths. Microsoft Graph handles permission enforcement + /// server-side — if you don't have delegate access, you get 403. + /// + public string UserPath => + !string.IsNullOrEmpty(_delegateFor) + ? $"/users/{Uri.EscapeDataString(_delegateFor)}" + : "/me"; + + /// + /// Get a valid access token, refreshing if needed. + /// + /// Token lifecycle: + /// 1. Check in-memory cache (fast path — no disk I/O) + /// 2. If expired, call MSAL AcquireTokenSilently (may refresh via network) + /// 3. Validate token scopes (defense-in-depth — reject if Mail.Send present) + /// 4. Cache the new token in memory with 1-minute buffer before expiry + /// + /// If no valid token exists (user must re-authenticate). + public async Task GetTokenAsync() + { + var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + // Use cached token if it's still valid (with 60s safety buffer) + if (_cachedToken != null && now < _tokenExpiry - 60_000) + return _cachedToken; + + try + { + var result = await MsalClientFactory.AcquireTokenSilently(_msalClient, _accountConfig); + if (result == null) + throw AuthException.NoToken(_accountAlias); + + // Defense-in-depth: validate scopes on every token acquisition. + TokenValidator.ValidateTokenScopes(result); + + _cachedToken = result.AccessToken; + _tokenExpiry = result.ExpiresOn.ToUnixTimeMilliseconds(); + return _cachedToken; + } + catch (OutlookCliException) { throw; } // Already a typed error + catch (MsalUiRequiredException) + { + throw AuthException.TokenExpired(_accountAlias); + } + catch (MsalServiceException ex) when (ex.ErrorCode == "invalid_grant") + { + throw AuthException.TokenExpired(_accountAlias); + } + catch (HttpRequestException ex) + { + throw new AuthException( + $"Network error while refreshing token for \"{_accountAlias}\".", + "AUTH_NETWORK_ERROR", + "Check your internet connection and try again.", + ex.Message, ex); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + throw new AuthException( + $"Could not acquire token for \"{_accountAlias}\".", + "AUTH_ERROR", + $"Run `outlook-cli auth login --account {_accountAlias}` to re-authenticate.", + ex.Message, ex); + } + } + + /// Make a GET request to the Graph API. + public async Task GetAsync(string path, Dictionary? headers = null) + { + return await RequestAsync("GET", path, null, headers); + } + + /// Make a POST request to the Graph API. + /// Graph API path. + /// Pre-serialized JSON body, or null for no body. + /// Additional headers. + public async Task PostAsync(string path, string? bodyJson, Dictionary? headers = null) + { + return await RequestAsync("POST", path, bodyJson, headers); + } + + /// Make a PATCH request to the Graph API. + /// Graph API path. + /// Pre-serialized JSON body. + /// Additional headers. + public async Task PatchAsync(string path, string bodyJson, Dictionary? headers = null) + { + return await RequestAsync("PATCH", path, bodyJson, headers); + } + + /// Make a DELETE request to the Graph API. + public async Task DeleteAsync(string path, Dictionary? headers = null) + { + return await RequestAsync("DELETE", path, null, headers); + } + + /// Upload binary content via PUT (for file uploads). + /// Graph API path. + /// Raw byte content. + /// MIME type. + public async Task PutBytesAsync(string path, byte[] content, string contentType) + { + var url = path.StartsWith("http") ? path : $"{GraphBase}{path}"; + var token = await GetTokenAsync(); + + using var request = new HttpRequestMessage(HttpMethod.Put, url); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + request.Content = new ByteArrayContent(content); + request.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(contentType); + + var response = await _httpClient.SendAsync(request); + + if (response.StatusCode == HttpStatusCode.NoContent) + return null; + + var responseContent = await response.Content.ReadAsStringAsync(); + if (!response.IsSuccessStatusCode) + { + JsonElement? errorBody = null; + try { errorBody = JsonDocument.Parse(responseContent).RootElement; } catch { } + throw GraphApiException.FromStatusCode((int)response.StatusCode, path); + } + + return JsonDocument.Parse(responseContent).RootElement; + } + + /// Download raw bytes from a Graph API path (for file downloads). + public async Task GetBytesAsync(string path) + { + var url = path.StartsWith("http") ? path : $"{GraphBase}{path}"; + var token = await GetTokenAsync(); + + using var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + + var response = await _httpClient.SendAsync(request); + + if (!response.IsSuccessStatusCode) + { + throw GraphApiException.FromStatusCode((int)response.StatusCode, path); + } + + return await response.Content.ReadAsByteArrayAsync(); + } + + /// + /// Core HTTP request method with retry logic. + /// + /// Retry behavior: + /// - 401 Unauthorized → Clear token cache, re-acquire, retry (token may have expired) + /// - 429 Too Many Requests → Wait for Retry-After seconds, then retry + /// - Other errors → Throw immediately (no retry) + /// + /// HTTP method (GET, POST, PATCH, DELETE). + /// Graph API path (e.g., /me/messages) or full URL. + /// Pre-serialized JSON body, or null. + /// Additional headers (e.g., Prefer header for timezone). + /// Maximum number of retries (default: 2). + private async Task RequestAsync( + string method, string path, string? bodyJson, + Dictionary? extraHeaders = null, int maxRetries = 2) + { + // Support both relative paths (/me/messages) and full URLs (@odata.nextLink) + var url = path.StartsWith("http") ? path : $"{GraphBase}{path}"; + + for (int attempt = 0; attempt <= maxRetries; attempt++) + { + var token = await GetTokenAsync(); + + using var request = new HttpRequestMessage(new HttpMethod(method), url); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + + if (extraHeaders != null) + { + foreach (var (key, value) in extraHeaders) + request.Headers.TryAddWithoutValidation(key, value); + } + + if (bodyJson != null) + { + request.Content = new StringContent(bodyJson, Encoding.UTF8, "application/json"); + } + + HttpResponseMessage response; + try + { + response = await _httpClient.SendAsync(request); + } + catch (HttpRequestException ex) + { + throw new GraphApiException( + "Cannot reach Microsoft Graph API — check your internet connection.", + 0, "GRAPH_NETWORK_ERROR", + "Check your network connection and try again.", + ex.Message); + } + catch (TaskCanceledException ex) when (!ex.CancellationToken.IsCancellationRequested) + { + throw new GraphApiException( + "Request to Microsoft Graph API timed out.", + 0, "GRAPH_TIMEOUT", + "Check your network connection and try again.", + ex.Message); + } + + // 401 = token expired between GetTokenAsync() and the request arriving. + if (response.StatusCode == HttpStatusCode.Unauthorized && attempt < maxRetries) + { + _cachedToken = null; + _tokenExpiry = 0; + continue; + } + + // 429 = Microsoft Graph rate limiting. + if (response.StatusCode == HttpStatusCode.TooManyRequests && attempt < maxRetries) + { + var retryAfterStr = response.Headers.RetryAfter?.Delta?.TotalSeconds.ToString() + ?? response.Headers.GetValues("Retry-After").FirstOrDefault() ?? "5"; + var retryAfter = int.TryParse(retryAfterStr, out var r) ? r : 5; + Console.Error.WriteLine($"Rate limited. Waiting {retryAfter}s..."); + await Task.Delay(retryAfter * 1000); + continue; + } + + // 204 No Content = success with no body (e.g., send, delete operations) + if (response.StatusCode == HttpStatusCode.NoContent) + return null; + + var responseContent = await response.Content.ReadAsStringAsync(); + JsonElement? responseBody = null; + + try + { + if (!string.IsNullOrEmpty(responseContent)) + responseBody = JsonSerializer.Deserialize(responseContent, OutlookCliJsonContext.Default.JsonElement); + } + catch + { + // Response isn't valid JSON + } + + if (!response.IsSuccessStatusCode) + { + var detail = "Graph API error: " + (int)response.StatusCode + " " + response.ReasonPhrase; + string? graphRequestId = null; + + if (responseBody.HasValue && + responseBody.Value.TryGetProperty("error", out var errorObj)) + { + if (errorObj.TryGetProperty("message", out var msgProp)) + detail = msgProp.GetString() ?? detail; + if (errorObj.TryGetProperty("innerError", out var inner) && + inner.TryGetProperty("request-id", out var reqId)) + graphRequestId = reqId.GetString(); + } + + var apiEx = GraphApiException.FromStatusCode( + (int)response.StatusCode, path, detail); + throw apiEx; + } + + return responseBody; + } + + throw new GraphApiException("Max retries exceeded for Graph API request.", + 0, "GRAPH_MAX_RETRIES", "Try again later.", $"Path: {path}"); + } + + /// + /// Fetch all pages of a paginated Graph API response. + /// + /// Microsoft Graph returns paginated results with @odata.nextLink. + /// This method follows those links to collect all results. + /// + /// Initial Graph API path. + /// Optional request headers. + /// Maximum pages to fetch (default: 10). + /// All values from all pages as a list of JsonElements. + public async Task> GetAllPagesAsync( + string path, Dictionary? headers = null, int maxPages = 10) + { + var allValues = new List(); + string? nextUrl = path; + + for (int page = 0; page < maxPages && nextUrl != null; page++) + { + var response = await GetAsync(nextUrl, headers); + if (response == null) break; + + if (response.Value.TryGetProperty("value", out var valueArray) && + valueArray.ValueKind == JsonValueKind.Array) + { + foreach (var item in valueArray.EnumerateArray()) + allValues.Add(item.Clone()); + } + + // @odata.nextLink is a full URL for the next page of results + if (response.Value.TryGetProperty("@odata.nextLink", out var nextLink)) + nextUrl = nextLink.GetString(); + else + break; // No more pages + } + + return allValues; + } +} diff --git a/src/dotnet/Graph/MailService.cs b/src/dotnet/Graph/MailService.cs new file mode 100644 index 0000000..f5709e0 --- /dev/null +++ b/src/dotnet/Graph/MailService.cs @@ -0,0 +1,437 @@ +/// +/// Mail Graph API wrappers. +/// +/// Every method takes a created by GraphClient.CreateGraphClient(). +/// The client exposes which is either "/me" (own mailbox) or +/// "/users/{delegate-email}" (shared/delegate mailbox). This single property is +/// what enables delegate access — all URL construction just prepends UserPath. +/// +/// DESIGN: CreateDraftAsync and SendDraftAsync are deliberately separate methods because +/// they require different permission scopes (Mail.ReadWrite vs Mail.Send). The +/// two-step draft-then-send flow lets the app request Mail.Send ONLY when the +/// user actually sends, keeping the default permission footprint minimal. +/// +/// All methods use raw Graph REST API calls (not the Microsoft.Graph SDK) so the +/// OData query parameters are explicit and testable. +/// + +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace OutlookCli.Graph; + +public static class MailService +{ + /// Default $select fields — keep this minimal to reduce payload size. + private const string MailFields = + "id,subject,from,receivedDateTime,bodyPreview,isRead,importance,flag,hasAttachments"; + + /// + /// List messages in a folder. + /// + /// Graph endpoint: GET {userPath}/mailFolders/{folder}/messages + /// Key OData params: $top, $select, $orderby, $filter (optional unread filter). + /// Uses well-known folder names (Inbox, Drafts, etc.) which Graph resolves automatically. + /// + /// Graph API client. + /// Folder name (default: "Inbox"). + /// Maximum number of messages to return (default: 25). + /// If true, only return unread messages. + /// Number of messages to skip for pagination. + public static async Task<(JsonElement? Messages, bool HasNextLink)> ListMessagesAsync( + GraphClient client, string folder = "Inbox", int top = 25, bool unreadOnly = false, int skip = 0) + { + var orderby = Uri.EscapeDataString("receivedDateTime desc"); + var path = $"{client.UserPath}/mailFolders/{Uri.EscapeDataString(folder)}/messages" + + $"?$top={top}&$select={MailFields}&$orderby={orderby}"; + + if (skip > 0) + { + path += $"&$skip={skip}"; + } + + if (unreadOnly) + { + // Append $filter — can coexist with $orderby on this endpoint + path += "&$filter=isRead eq false"; + } + + var response = await client.GetAsync(path); + var hasNext = HasNextLink(response); + return (GetValue(response), hasNext); + } + + /// + /// Get a single message by ID. + /// + /// Graph endpoint: GET {userPath}/messages/{id} + /// The Prefer header tells Graph to return the body as plain text instead of + /// HTML — useful for CLI display. Without it, body.content is HTML. + /// + /// Graph API client. + /// The message ID. + /// If true, request plain text body format. + public static async Task GetMessageAsync( + GraphClient client, string messageId, bool preferPlainText = false) + { + Dictionary? headers = null; + if (preferPlainText) + { + // outlook.body-content-type forces the body.content format; avoids + // needing an HTML-to-text conversion on the client side. + headers = new() { ["Prefer"] = "outlook.body-content-type=\"text\"" }; + } + + return await client.GetAsync( + $"{client.UserPath}/messages/{Uri.EscapeDataString(messageId)}", headers); + } + + /// + /// Search messages using Graph API $search. + /// + /// Graph endpoint: GET {userPath}/messages?$search="..." + /// $search uses KQL (Keyword Query Language) — supports from:, subject:, etc. + /// NOTE: $search and $orderby cannot be combined; Graph returns relevance-ranked results. + /// + /// Graph API client. + /// KQL search query. + /// Maximum number of results (default: 25). + public static async Task<(JsonElement? Messages, bool HasNextLink)> SearchMessagesAsync( + GraphClient client, string query, int top = 25, int skip = 0) + { + var path = $"{client.UserPath}/messages?$search=\"{Uri.EscapeDataString(query)}\"" + + $"&$top={top}&$select={MailFields}"; + if (skip > 0) + { + path += $"&$skip={skip}"; + } + var response = await client.GetAsync(path); + var hasNext = HasNextLink(response); + return (GetValue(response), hasNext); + } + + /// + /// List mail folders. + /// + /// Graph endpoint: GET {userPath}/mailFolders + /// $top=100 is a practical ceiling; most mailboxes have far fewer top-level folders. + /// Does NOT recurse into child folders — call mailFolders/{id}/childFolders for that. + /// + public static async Task ListFoldersAsync(GraphClient client) + { + var response = await client.GetAsync($"{client.UserPath}/mailFolders?$top=100"); + return GetValue(response); + } + + /// + /// Create a draft message in the Drafts folder. + /// + /// Graph endpoint: POST {userPath}/messages + /// POSTing to /messages (without /send) creates a draft. This only requires + /// Mail.ReadWrite scope, NOT Mail.Send — keeping the permission footprint small + /// until the user explicitly sends via SendDraftAsync(). + /// + /// Graph API client. + /// Pre-serialized JSON draft message body. + public static async Task CreateDraftAsync(GraphClient client, string draft) + { + return await client.PostAsync($"{client.UserPath}/messages", draft); + } + + /// + /// Create a reply draft for a message. + /// + /// Graph endpoint: POST {userPath}/messages/{id}/createReply (or createReplyAll) + /// + /// Two-step process: + /// 1. POST createReply — Graph creates a new draft with headers pre-filled + /// 2. PATCH the draft to set body text (Graph doesn't accept body in createReply) + /// This two-step is required because the createReply endpoint ignores body content. + /// + /// Graph API client. + /// ID of the message to reply to. + /// Optional reply body text. + /// Body content type: "Text" or "HTML". + /// If true, reply to all recipients. + public static async Task CreateReplyDraftAsync( + GraphClient client, string messageId, string? body = null, + string contentType = "Text", bool replyAll = false) + { + var endpoint = replyAll + ? $"{client.UserPath}/messages/{Uri.EscapeDataString(messageId)}/createReplyAll" + : $"{client.UserPath}/messages/{Uri.EscapeDataString(messageId)}/createReply"; + + // Step 1: Create the reply shell (recipients, subject "RE:" prefix pre-populated) + var replyDraft = await client.PostAsync(endpoint, "{}"); + + // Step 2: If a body was provided, patch the reply draft + if (!string.IsNullOrEmpty(body) && replyDraft.HasValue) + { + var draftId = replyDraft.Value.GetProperty("id").GetString()!; + var patch = new JsonObject + { + ["body"] = new JsonObject + { + ["contentType"] = contentType, + ["content"] = body + } + }; + await client.PatchAsync( + $"{client.UserPath}/messages/{Uri.EscapeDataString(draftId)}", + patch.ToJsonString()); + } + + return replyDraft; + } + + /// + /// Create a forward draft for a message. + /// + /// Graph endpoint: POST {userPath}/messages/{id}/createForward + /// + /// Same two-step pattern as CreateReplyDraftAsync: + /// 1. POST createForward — creates the draft with original message attached + /// 2. PATCH — add toRecipients and optional comment (body) + /// createForward doesn't accept recipients inline, so PATCH is mandatory. + /// + /// Graph API client. + /// ID of the message to forward. + /// Comma-separated list of recipient email addresses. + /// Optional comment to include in the forward. + public static async Task CreateForwardDraftAsync( + GraphClient client, string messageId, string to, string? comment = null) + { + // Step 1: Create forward shell (original message content included) + var forwardDraft = await client.PostAsync( + $"{client.UserPath}/messages/{Uri.EscapeDataString(messageId)}/createForward", + "{}"); + + if (!forwardDraft.HasValue) return forwardDraft; + + // Step 2: Patch with recipients and optional comment + var draftId = forwardDraft.Value.GetProperty("id").GetString()!; + var toRecipients = new JsonArray(); + foreach (var addr in to.Split(',')) + { + toRecipients.Add((JsonNode)new JsonObject + { + ["emailAddress"] = new JsonObject { ["address"] = addr.Trim() } + }); + } + + var patch = new JsonObject { ["toRecipients"] = toRecipients }; + if (!string.IsNullOrEmpty(comment)) + patch["body"] = new JsonObject { ["contentType"] = "Text", ["content"] = comment }; + + await client.PatchAsync( + $"{client.UserPath}/messages/{Uri.EscapeDataString(draftId)}", patch.ToJsonString()); + + return forwardDraft; + } + + /// + /// Move a message to a folder (by name or ID). + /// + /// Graph endpoint: POST {userPath}/messages/{id}/move + /// The /move action requires a folder ID, not a name. For well-known folder + /// names (Inbox, Drafts, etc.) we first resolve the name to its ID via a GET. + /// If resolution fails, we pass the value through as-is — it may already be an ID. + /// + public static async Task MoveMessageAsync( + GraphClient client, string messageId, string folderNameOrId) + { + var wellKnown = new[] { "Inbox", "Drafts", "SentItems", "DeletedItems", "Archive", "JunkEmail" }; + var destinationId = folderNameOrId; + + // Resolve well-known folder names to their IDs so the move action succeeds + if (wellKnown.Contains(folderNameOrId)) + { + try + { + var folder = await client.GetAsync($"{client.UserPath}/mailFolders/{folderNameOrId}"); + if (folder.HasValue) + destinationId = folder.Value.GetProperty("id").GetString() ?? folderNameOrId; + } + catch + { + // Fall through — maybe it's already an ID + } + } + + var moveBody = new JsonObject { ["destinationId"] = destinationId }; + return await client.PostAsync( + $"{client.UserPath}/messages/{Uri.EscapeDataString(messageId)}/move", + moveBody.ToJsonString()); + } + + /// + /// Flag or unflag a message. + /// + /// Graph endpoint: PATCH {userPath}/messages/{id} + /// Uses the flag.flagStatus property — Graph only supports 'flagged' and 'notFlagged' + /// (there's also 'complete' but we don't expose it). + /// + public static async Task FlagMessageAsync( + GraphClient client, string messageId, bool flagged) + { + var flagBody = new JsonObject + { + ["flag"] = new JsonObject { ["flagStatus"] = flagged ? "flagged" : "notFlagged" } + }; + return await client.PatchAsync( + $"{client.UserPath}/messages/{Uri.EscapeDataString(messageId)}", + flagBody.ToJsonString()); + } + + /// + /// Mark a message as read or unread. + /// + /// Graph endpoint: PATCH {userPath}/messages/{id} + /// + public static async Task MarkReadAsync( + GraphClient client, string messageId, bool isRead) + { + var readBody = new JsonObject { ["isRead"] = isRead }; + return await client.PatchAsync( + $"{client.UserPath}/messages/{Uri.EscapeDataString(messageId)}", + readBody.ToJsonString()); + } + + /// + /// Send an existing draft message. + /// + /// Graph endpoint: POST {userPath}/messages/{id}/send + /// Requires Mail.Send (own mailbox) or Mail.Send.Shared (delegate mailbox). + /// The body is null because /send takes no payload — it sends the draft as-is. + /// This is intentionally separate from CreateDraftAsync so the app can operate with + /// only Mail.ReadWrite until the user explicitly chooses to send. + /// + public static async Task SendDraftAsync(GraphClient client, string messageId) + { + return await client.PostAsync( + $"{client.UserPath}/messages/{Uri.EscapeDataString(messageId)}/send", null); + } + + /// + /// Delete a message (soft-delete to Deleted Items). + /// If the message is already in Deleted Items, it is permanently deleted. + /// + public static async Task DeleteMessageAsync(GraphClient client, string messageId) + { + await client.DeleteAsync( + $"{client.UserPath}/messages/{Uri.EscapeDataString(messageId)}"); + } + + /// Extract the "value" property from a Graph API response, or return the whole response. + private static JsonElement? GetValue(JsonElement? response) + { + if (response?.ValueKind == JsonValueKind.Object && + response.Value.TryGetProperty("value", out var value)) + return value; + return response; + } + + private static bool HasNextLink(JsonElement? response) + { + return response?.ValueKind == JsonValueKind.Object && + response.Value.TryGetProperty("@odata.nextLink", out _); + } + + /// + /// Replace the body with bodyPreview for --body-preview flag. + /// Returns a modified JsonElement with body.content set to the preview text. + /// + public static JsonElement? ApplyBodyPreview(JsonElement message) + { + if (message.TryGetProperty("bodyPreview", out var preview)) + { + var obj = JsonNode.Parse(message.GetRawText())?.AsObject(); + if (obj != null) + { + obj["body"] = new JsonObject + { + ["contentType"] = "Text", + ["content"] = preview.GetString() ?? "", + }; + return JsonDocument.Parse(obj.ToJsonString()).RootElement.Clone(); + } + } + return message; + } + + /// + /// Truncate body content to maxChars for --truncate flag. + /// Appends a truncation notice if the body exceeds the limit. + /// + public static JsonElement? ApplyTruncate(JsonElement message, int maxChars) + { + if (message.TryGetProperty("body", out var body) && + body.TryGetProperty("content", out var content)) + { + var text = content.GetString() ?? ""; + if (text.Length > maxChars) + { + var obj = JsonNode.Parse(message.GetRawText())?.AsObject(); + if (obj != null) + { + var bodyObj = obj["body"]?.AsObject(); + if (bodyObj != null) + { + bodyObj["content"] = text[..maxChars] + + $"\n\n... [truncated at {maxChars} chars, full body is {text.Length} chars]"; + } + return JsonDocument.Parse(obj.ToJsonString()).RootElement.Clone(); + } + } + } + return message; + } + + // ─── Attachment operations ─────────────────────────────────────────── + + /// Default $select fields for attachment metadata. + private const string AttachmentFields = "id,name,contentType,size,isInline,lastModifiedDateTime"; + + /// + /// List attachments on a message. + /// Returns metadata only (not content bytes) to keep payloads small. + /// Use GetAttachmentAsync to download individual attachment content. + /// + public static async Task ListAttachmentsAsync( + GraphClient client, string messageId) + { + var response = await client.GetAsync( + $"{client.UserPath}/messages/{Uri.EscapeDataString(messageId)}/attachments?$select={AttachmentFields}"); + return GetValue(response); + } + + /// + /// Get a single attachment with content bytes. + /// Returns the full attachment object including contentBytes (base64-encoded). + /// + public static async Task GetAttachmentAsync( + GraphClient client, string messageId, string attachmentId) + { + return await client.GetAsync( + $"{client.UserPath}/messages/{Uri.EscapeDataString(messageId)}/attachments/{Uri.EscapeDataString(attachmentId)}"); + } + + /// + /// Add a file attachment to a draft message. + /// Maximum size for inline attachments is 3MB. For larger files, use upload sessions. + /// + public static async Task AddAttachmentAsync( + GraphClient client, string messageId, string name, string contentType, string contentBytes) + { + var body = new JsonObject + { + ["@odata.type"] = "#microsoft.graph.fileAttachment", + ["name"] = name, + ["contentType"] = contentType, + ["contentBytes"] = contentBytes, + }; + return await client.PostAsync( + $"{client.UserPath}/messages/{Uri.EscapeDataString(messageId)}/attachments", + body.ToJsonString()); + } +} diff --git a/src/dotnet/OutlookCli.csproj b/src/dotnet/OutlookCli.csproj new file mode 100644 index 0000000..70e1cac --- /dev/null +++ b/src/dotnet/OutlookCli.csproj @@ -0,0 +1,42 @@ + + + + Exe + net8.0 + enable + enable + outlook-cli + OutlookCli + 2.0.0 + Cross-platform CLI for Microsoft Outlook via Graph API (C# implementation) + + + true + link + false + false + + + false + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/dotnet/OutlookCliJsonContext.cs b/src/dotnet/OutlookCliJsonContext.cs new file mode 100644 index 0000000..6f945e1 --- /dev/null +++ b/src/dotnet/OutlookCliJsonContext.cs @@ -0,0 +1,120 @@ +/// +/// JSON source generator context for NativeAOT compatibility. +/// +/// Replaces reflection-based System.Text.Json serialization with compile-time +/// generated serializers. Every type that goes through JsonSerializer must be +/// registered here via [JsonSerializable(typeof(T))]. +/// +/// Usage: JsonSerializer.Serialize(obj, OutlookCliJsonContext.Default.TypeName) +/// JsonSerializer.Deserialize(json, OutlookCliJsonContext.Default.TypeName) +/// + +using System.Text.Json; +using System.Text.Json.Serialization; +using OutlookCli.Accounts; +using OutlookCli.Database; +using OutlookCli.Security; +using OutlookCli.Watch; + +namespace OutlookCli; + +// Account management types (accounts.json) +[JsonSerializable(typeof(AccountConfig))] +[JsonSerializable(typeof(AccountsData))] +[JsonSerializable(typeof(Dictionary))] + +// Alias management (aliases.json) +[JsonSerializable(typeof(Dictionary))] + +// Token validation +[JsonSerializable(typeof(ScopeCheckResult))] + +// Graph API responses — parsed as JsonElement +[JsonSerializable(typeof(JsonElement))] +[JsonSerializable(typeof(JsonElement?))] + +// Generic output types used by ToJsonElement and CLI responses +[JsonSerializable(typeof(Dictionary))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(string[]))] +[JsonSerializable(typeof(bool))] +[JsonSerializable(typeof(string))] +[JsonSerializable(typeof(int))] +[JsonSerializable(typeof(List))] + +// Watch configuration types +[JsonSerializable(typeof(WatchConfigData))] +[JsonSerializable(typeof(WatchEntry))] +[JsonSerializable(typeof(WatchRule))] +[JsonSerializable(typeof(WatchAction))] + +// Database / operations log types +[JsonSerializable(typeof(OperationsStats))] +[JsonSerializable(typeof(OperationRecord))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(OperationsSummary))] +[JsonSerializable(typeof(DayCount))] +[JsonSerializable(typeof(CommandCount))] +[JsonSerializable(typeof(AccountStats))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(List))] + +// Anonymous-type replacements (used in Program.cs output) +[JsonSerializable(typeof(SuccessResponse))] +[JsonSerializable(typeof(AccountListResponse))] +[JsonSerializable(typeof(AuthStatusResponse))] +[JsonSerializable(typeof(AliasListResponse))] +[JsonSerializable(typeof(ErrorResponse))] + +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull +)] +internal partial class OutlookCliJsonContext : JsonSerializerContext +{ +} + +// ── Response DTOs replacing anonymous types ───────────── + +/// Generic success response for write operations. +internal record SuccessResponse( + bool Success, + string? MessageId = null, + string? EventId = null, + string? DraftId = null, + bool? Flagged = null, + bool? IsRead = null, + string? Folder = null, + string? Account = null, + string? Email = null +); + +/// Account list response. +internal record AccountListResponse( + Dictionary Accounts, + string? DefaultAccount +); + +/// Auth status response. +internal record AuthStatusResponse( + bool Authenticated, + string? Account = null, + string? Email = null, + string? ExpiresOn = null, + string[]? Scopes = null, + string? Error = null +); + +/// Alias list response. +internal record AliasListResponse( + Dictionary Aliases, + int Count +); + +/// Error response. +internal record ErrorResponse( + bool Success, + string Error +); diff --git a/src/dotnet/Output/HtmlToText.cs b/src/dotnet/Output/HtmlToText.cs new file mode 100644 index 0000000..530c959 --- /dev/null +++ b/src/dotnet/Output/HtmlToText.cs @@ -0,0 +1,178 @@ +using System.Text.RegularExpressions; + +namespace OutlookCli.Output; + +/// +/// Converts HTML email bodies to readable plain text for CLI display. +/// +/// Uses regex-based stripping for the common patterns in email HTML +/// (paragraphs, line breaks, links, lists, bold/italic). Not a full +/// HTML parser — handles 90%+ of real-world email HTML adequately. +/// +/// Node.js equivalent: src/node/output/html-to-text.js +/// +public static partial class HtmlToText +{ + /// + /// Convert HTML to readable plain text. + /// If input doesn't contain HTML tags, returns it unchanged. + /// + public static string Convert(string? html) + { + if (string.IsNullOrEmpty(html)) return ""; + + // If it doesn't look like HTML, return as-is + if (!HtmlTagRegex().IsMatch(html)) return html; + + var text = html; + + // Remove style and script blocks entirely + text = StyleRegex().Replace(text, ""); + text = ScriptRegex().Replace(text, ""); + + // Remove HTML comments + text = CommentRegex().Replace(text, ""); + + // Convert common block elements to line breaks + text = BrRegex().Replace(text, "\n"); + text = ClosePRegex().Replace(text, "\n\n"); + text = CloseDivRegex().Replace(text, "\n"); + text = CloseHeadingRegex().Replace(text, "\n\n"); + text = CloseTrRegex().Replace(text, "\n"); + text = CloseLiRegex().Replace(text, "\n"); + + // Convert list items to bullet points + text = OpenLiRegex().Replace(text, " • "); + + // Convert links: text → text (url) + text = LinkRegex().Replace(text, m => + { + var url = m.Groups[1].Value.Trim(); + var linkText = m.Groups[2].Value.Trim(); + if (linkText == url || string.IsNullOrEmpty(linkText)) return url; + return $"{linkText} ({url})"; + }); + + // Convert images to [alt] or [image] + text = ImgAltRegex().Replace(text, "[$1]"); + text = ImgRegex().Replace(text, "[image]"); + + // Convert
to a separator line + text = HrRegex().Replace(text, "\n" + new string('─', 40) + "\n"); + + // Convert table cells: add spacing between td/th + text = CloseTdThRegex().Replace(text, "\t"); + + // Convert / to *text* + text = BoldRegex().Replace(text, "*$1*"); + + // Convert / to _text_ + text = ItalicRegex().Replace(text, "_$1_"); + + // Strip remaining HTML tags + text = AnyTagRegex().Replace(text, ""); + + // Decode common HTML entities + text = text.Replace(" ", " "); + text = text.Replace("&", "&"); + text = text.Replace("<", "<"); + text = text.Replace(">", ">"); + text = text.Replace(""", "\""); + text = text.Replace("'", "'"); + text = text.Replace("'", "'"); + + // Decode numeric entities + text = NumericEntityRegex().Replace(text, m => + ((char)int.Parse(m.Groups[1].Value)).ToString()); + text = HexEntityRegex().Replace(text, m => + ((char)int.Parse(m.Groups[1].Value, System.Globalization.NumberStyles.HexNumber)).ToString()); + + // Normalize whitespace + text = HorizontalWhitespaceRegex().Replace(text, " "); + text = LeadingWhitespaceRegex().Replace(text, "\n"); + text = TrailingWhitespaceRegex().Replace(text, "\n"); + text = ExcessiveNewlinesRegex().Replace(text, "\n\n"); + + return text.Trim(); + } + + /// Check if content appears to be HTML. + public static bool IsHtml(string? content) => + !string.IsNullOrEmpty(content) && HtmlTagRegex().IsMatch(content); + + // Source-generated regexes for NativeAOT compatibility + [GeneratedRegex(@"<[a-z][\s\S]*>", RegexOptions.IgnoreCase)] + private static partial Regex HtmlTagRegex(); + + [GeneratedRegex(@"", RegexOptions.IgnoreCase)] + private static partial Regex StyleRegex(); + + [GeneratedRegex(@"", RegexOptions.IgnoreCase)] + private static partial Regex ScriptRegex(); + + [GeneratedRegex(@"")] + private static partial Regex CommentRegex(); + + [GeneratedRegex(@"", RegexOptions.IgnoreCase)] + private static partial Regex BrRegex(); + + [GeneratedRegex(@"

", RegexOptions.IgnoreCase)] + private static partial Regex ClosePRegex(); + + [GeneratedRegex(@"", RegexOptions.IgnoreCase)] + private static partial Regex CloseDivRegex(); + + [GeneratedRegex(@"", RegexOptions.IgnoreCase)] + private static partial Regex CloseHeadingRegex(); + + [GeneratedRegex(@"", RegexOptions.IgnoreCase)] + private static partial Regex CloseTrRegex(); + + [GeneratedRegex(@"", RegexOptions.IgnoreCase)] + private static partial Regex CloseLiRegex(); + + [GeneratedRegex(@"]*>", RegexOptions.IgnoreCase)] + private static partial Regex OpenLiRegex(); + + [GeneratedRegex(@"]+href=""([^""]*?)""[^>]*>([^<]*?)", RegexOptions.IgnoreCase)] + private static partial Regex LinkRegex(); + + [GeneratedRegex(@"]+alt=""([^""]*?)""[^>]*>", RegexOptions.IgnoreCase)] + private static partial Regex ImgAltRegex(); + + [GeneratedRegex(@"]*>", RegexOptions.IgnoreCase)] + private static partial Regex ImgRegex(); + + [GeneratedRegex(@"]*>", RegexOptions.IgnoreCase)] + private static partial Regex HrRegex(); + + [GeneratedRegex(@"", RegexOptions.IgnoreCase)] + private static partial Regex CloseTdThRegex(); + + [GeneratedRegex(@"<(?:strong|b)>([\s\S]*?)", RegexOptions.IgnoreCase)] + private static partial Regex BoldRegex(); + + [GeneratedRegex(@"<(?:em|i)>([\s\S]*?)", RegexOptions.IgnoreCase)] + private static partial Regex ItalicRegex(); + + [GeneratedRegex(@"<[^>]+>")] + private static partial Regex AnyTagRegex(); + + [GeneratedRegex(@"&#(\d+);")] + private static partial Regex NumericEntityRegex(); + + [GeneratedRegex(@"&#x([0-9a-fA-F]+);")] + private static partial Regex HexEntityRegex(); + + [GeneratedRegex(@"[ \t]+")] + private static partial Regex HorizontalWhitespaceRegex(); + + [GeneratedRegex(@"\n[ \t]+")] + private static partial Regex LeadingWhitespaceRegex(); + + [GeneratedRegex(@"[ \t]+\n")] + private static partial Regex TrailingWhitespaceRegex(); + + [GeneratedRegex(@"\n{3,}")] + private static partial Regex ExcessiveNewlinesRegex(); +} diff --git a/src/dotnet/Output/LastResults.cs b/src/dotnet/Output/LastResults.cs new file mode 100644 index 0000000..f8c0016 --- /dev/null +++ b/src/dotnet/Output/LastResults.cs @@ -0,0 +1,80 @@ +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace OutlookCli.Output; + +/// +/// Maps short numeric IDs (#1, #2, ...) to full Graph API message IDs. +/// After inbox/search, the mapping is saved to ~/.outlook-cli/last-results.json. +/// Commands like read, reply, forward accept #N as shorthand. +/// +public static class LastResults +{ + private static readonly string ResultsPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".outlook-cli", "last-results.json"); + + /// + /// Save a mapping of 1-based indices to Graph message IDs. + /// + public static void SaveResultIds(JsonElement messages) + { + if (messages.ValueKind != JsonValueKind.Array) + return; + + var map = new Dictionary(); + var index = 1; + foreach (var msg in messages.EnumerateArray()) + { + if (msg.TryGetProperty("id", out var idProp)) + { + map[index.ToString()] = idProp.GetString() ?? ""; + } + index++; + } + + var dir = Path.GetDirectoryName(ResultsPath)!; + if (!Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + var json = JsonSerializer.Serialize(map, OutlookCliJsonContext.Default.DictionaryStringString); + File.WriteAllText(ResultsPath, json); + } + + /// + /// Resolve a short ID (#1, 1) to a full Graph message ID. + /// If the input is already a full ID (not a short number), returns it unchanged. + /// Returns null if the short ID is not found. + /// + public static string? ResolveId(string? idOrShort) + { + if (string.IsNullOrEmpty(idOrShort)) + return idOrShort; + + // Strip leading # + var cleaned = idOrShort.StartsWith('#') ? idOrShort[1..] : idOrShort; + + // If it's a small number (1-999), look up in last-results + if (int.TryParse(cleaned, out var num) && num >= 1 && num <= 999) + { + if (!File.Exists(ResultsPath)) + return idOrShort; // no saved results, pass through + + try + { + var json = File.ReadAllText(ResultsPath); + var map = JsonSerializer.Deserialize(json, OutlookCliJsonContext.Default.DictionaryStringString); + if (map != null && map.TryGetValue(num.ToString(), out var fullId)) + return fullId; + } + catch + { + // Corrupt file — pass through + } + + return idOrShort; // not found, pass through + } + + return idOrShort; // already a full ID + } +} diff --git a/src/dotnet/Output/OutputFormatter.cs b/src/dotnet/Output/OutputFormatter.cs new file mode 100644 index 0000000..cc3a412 --- /dev/null +++ b/src/dotnet/Output/OutputFormatter.cs @@ -0,0 +1,1043 @@ +/// +/// Output formatting — multi-format rendering and dispatch. +/// +/// FORMAT TYPE SYSTEM: The output layer uses entity types (mailList, mailDetail, +/// eventList, etc.) to select the appropriate renderer function. Each format +/// (text, json, markdown, html) implements the same entity types. +/// +/// FORMAT SELECTION: The user controls output format via: +/// --format <text|json|markdown|md|html> (explicit format) +/// --json (shorthand for --format json) +/// default: text +/// +/// FILE OUTPUT: When --output <file> is specified, rendered content is written +/// to the file instead of stdout. +/// +/// All public format methods return strings (not void) — they do NOT call Console.Write. +/// This enables the render dispatcher to route output to files, pipes, or stdout. +/// +/// BACKWARD COMPATIBILITY with Node.js: The same entity type names and format names +/// are used, so output is comparable between implementations. +/// + +using System.Net; +using System.Text; +using System.Text.Json; + +namespace OutlookCli.Output; + +public static class OutputFormatter +{ + // ── Utility functions ── + + /// + /// Truncate a string to maxLen, adding an ellipsis character if truncated. + /// Returns empty string for null to simplify caller code. + /// + private static string Truncate(string? str, int maxLen) + { + if (string.IsNullOrEmpty(str)) return ""; + if (str.Length <= maxLen) return str; + return str[..(maxLen - 1)] + "…"; + } + + /// + /// Build a fixed-width row by padding/truncating each column to its declared width. + /// Columns are separated by a single space. + /// + private static string PadColumns(params (string Text, int Width)[] columns) + { + var sb = new StringBuilder(); + for (int i = 0; i < columns.Length; i++) + { + if (i > 0) sb.Append(' '); + var (text, width) = columns[i]; + text ??= ""; + if (text.Length >= width) + sb.Append(text[..width]); + else + sb.Append(text).Append(' ', width - text.Length); + } + return sb.ToString(); + } + + /// + /// Smart date formatting: shows time-only for today's dates (saves column width), + /// full date for older messages. This matches how Outlook desktop displays dates. + /// + private static string FormatDate(string? isoString) + { + if (string.IsNullOrEmpty(isoString)) return ""; + try + { + var d = DateTime.Parse(isoString); + return d.Date == DateTime.Today + ? d.ToString("HH:mm") + : d.ToString("MMM d, yyyy"); + } + catch { return isoString; } + } + + private static string FormatDateTime(string? isoString) + { + if (string.IsNullOrEmpty(isoString)) return ""; + try { return DateTime.Parse(isoString).ToString("ddd, MMM d, yyyy HH:mm"); } + catch { return isoString; } + } + + private static string FormatTime(string? isoString) + { + if (string.IsNullOrEmpty(isoString)) return ""; + try { return DateTime.Parse(isoString).ToString("HH:mm"); } + catch { return isoString; } + } + + // ── Helper to read JSON properties safely ── + + private static string GetStr(JsonElement el, string prop, string fallback = "") + { + if (el.TryGetProperty(prop, out var val) && val.ValueKind == JsonValueKind.String) + return val.GetString() ?? fallback; + return fallback; + } + + private static bool GetBool(JsonElement el, string prop, bool fallback = false) + { + if (el.TryGetProperty(prop, out var val)) + { + if (val.ValueKind == JsonValueKind.True) return true; + if (val.ValueKind == JsonValueKind.False) return false; + } + return fallback; + } + + private static int GetInt(JsonElement el, string prop, int fallback = 0) + { + if (el.TryGetProperty(prop, out var val) && val.TryGetInt32(out var i)) return i; + return fallback; + } + + private static string GetFromAddress(JsonElement msg) + { + if (msg.TryGetProperty("from", out var from) && + from.TryGetProperty("emailAddress", out var addr)) + { + return GetStr(addr, "name", GetStr(addr, "address", "(unknown)")); + } + return "(unknown)"; + } + + private static string GetFromEmail(JsonElement msg) + { + if (msg.TryGetProperty("from", out var from) && + from.TryGetProperty("emailAddress", out var addr)) + { + return GetStr(addr, "address", ""); + } + return ""; + } + + private static string GetFromName(JsonElement msg) + { + if (msg.TryGetProperty("from", out var from) && + from.TryGetProperty("emailAddress", out var addr)) + { + return GetStr(addr, "name", ""); + } + return ""; + } + + // ══════════════════════════════════════════════════════ + // TEXT FORMAT + // ══════════════════════════════════════════════════════ + + // ── Entity renderers (return strings) ── + + public static class TextFormatter + { + public static string MailList(JsonElement messages) + { + if (messages.ValueKind != JsonValueKind.Array || messages.GetArrayLength() == 0) + return "No messages found."; + + var lines = new List { "" }; + lines.Add(PadColumns( + ("#", 4), (" ", 2), ("From", 30), ("Subject", 55), ("Date", 16))); + lines.Add(new string('─', 112)); + + var index = 1; + foreach (var msg in messages.EnumerateArray()) + { + var isRead = GetBool(msg, "isRead"); + var readMarker = isRead ? " " : "●"; + var flagStatus = ""; + if (msg.TryGetProperty("flag", out var flag)) + flagStatus = GetStr(flag, "flagStatus"); + var flagMarker = flagStatus == "flagged" ? "⚑" : " "; + var from = Truncate(GetFromAddress(msg), 28); + var subject = Truncate(GetStr(msg, "subject", "(no subject)"), 53); + var date = FormatDate(GetStr(msg, "receivedDateTime")); + + lines.Add(PadColumns( + ($"{index}", 4), ($"{readMarker}{flagMarker}", 2), (from, 30), (subject, 55), (date, 16))); + index++; + } + + lines.Add(""); + lines.Add($"{messages.GetArrayLength()} message(s) \u2014 use N with read, reply, forward, move, flag, mark-read, send"); + return string.Join('\n', lines); + } + + public static string MailDetail(JsonElement message) + { + if (message.ValueKind == JsonValueKind.Null || message.ValueKind == JsonValueKind.Undefined) + return "Message not found."; + + var lines = new List { "", new string('─', 60) }; + lines.Add($"From: {GetFromName(message)} <{GetFromEmail(message)}>"); + + if (message.TryGetProperty("toRecipients", out var to) && to.GetArrayLength() > 0) + { + var addrs = to.EnumerateArray() + .Select(r => r.TryGetProperty("emailAddress", out var ea) ? GetStr(ea, "address") : "") + .Where(a => !string.IsNullOrEmpty(a)); + lines.Add($"To: {string.Join(", ", addrs)}"); + } + + if (message.TryGetProperty("ccRecipients", out var cc) && cc.GetArrayLength() > 0) + { + var addrs = cc.EnumerateArray() + .Select(r => r.TryGetProperty("emailAddress", out var ea) ? GetStr(ea, "address") : "") + .Where(a => !string.IsNullOrEmpty(a)); + lines.Add($"Cc: {string.Join(", ", addrs)}"); + } + + lines.Add($"Subject: {GetStr(message, "subject", "(no subject)")}"); + lines.Add($"Date: {FormatDateTime(GetStr(message, "receivedDateTime"))}"); + lines.Add($"Read: {(GetBool(message, "isRead") ? "yes" : "no")}"); + + var importance = GetStr(message, "importance"); + if (!string.IsNullOrEmpty(importance) && importance != "normal") + lines.Add($"Priority: {importance}"); + + if (GetBool(message, "hasAttachments")) + lines.Add("Attachments: yes"); + + // Show attachment details if fetched via --attachments flag + if (message.TryGetProperty("attachmentList", out var attList) && + attList.ValueKind == JsonValueKind.Array) + { + foreach (var att in attList.EnumerateArray()) + { + var attName = att.TryGetProperty("name", out var an) ? an.GetString() ?? "unnamed" : "unnamed"; + var attSize = att.TryGetProperty("size", out var asize) ? asize.GetInt64() : 0; + var inline = att.TryGetProperty("isInline", out var ai) && ai.GetBoolean() ? " [inline]" : ""; + lines.Add($" 📎 {attName} ({FormatSizeHelper(attSize)}){inline}"); + } + } + + lines.Add($"ID: {GetStr(message, "id")}"); + lines.Add(new string('─', 60)); + lines.Add(""); + + var body = ""; + if (message.TryGetProperty("body", out var bodyObj)) + body = GetStr(bodyObj, "content"); + if (string.IsNullOrEmpty(body)) + body = GetStr(message, "bodyPreview", "(empty body)"); + + // Convert HTML body to readable text for CLI display + var contentType = message.TryGetProperty("body", out var bt) ? GetStr(bt, "contentType") : ""; + if (contentType.Equals("HTML", StringComparison.OrdinalIgnoreCase) || HtmlToText.IsHtml(body)) + body = HtmlToText.Convert(body); + + lines.Add(body); + lines.Add(""); + + return string.Join('\n', lines); + } + + public static string FolderList(JsonElement folders) + { + if (folders.ValueKind != JsonValueKind.Array || folders.GetArrayLength() == 0) + return "No folders found."; + + var lines = new List { "" }; + lines.Add(PadColumns( + ("Folder", 30), ("Unread", 8), ("Total", 8), ("ID", 40))); + lines.Add(new string('─', 88)); + + foreach (var folder in folders.EnumerateArray()) + { + lines.Add(PadColumns( + (Truncate(GetStr(folder, "displayName"), 28), 30), + (GetInt(folder, "unreadItemCount").ToString(), 8), + (GetInt(folder, "totalItemCount").ToString(), 8), + (Truncate(GetStr(folder, "id"), 38), 40))); + } + lines.Add(""); + return string.Join('\n', lines); + } + + public static string EventList(JsonElement events, string title = "Events") + { + if (events.ValueKind != JsonValueKind.Array || events.GetArrayLength() == 0) + return $"\nNo events for \"{title}\"."; + + var lines = new List { $"\n📅 {title}", new string('─', 80) }; + + foreach (var evt in events.EnumerateArray()) + { + var startTime = FormatTime(evt.TryGetProperty("start", out var s) ? GetStr(s, "dateTime") : null); + var endTime = FormatTime(evt.TryGetProperty("end", out var e) ? GetStr(e, "dateTime") : null); + var isAllDay = GetBool(evt, "isAllDay"); + var timeRange = isAllDay ? "All day " : $"{startTime}-{endTime}"; + var location = evt.TryGetProperty("location", out var loc) ? GetStr(loc, "displayName") : ""; + var locationStr = !string.IsNullOrEmpty(location) ? $" @ {location}" : ""; + var cancelled = GetBool(evt, "isCancelled") ? " [CANCELLED]" : ""; + + lines.Add($" {timeRange} {GetStr(evt, "subject", "(no subject)")}{locationStr}{cancelled}"); + } + + lines.Add(new string('─', 80)); + lines.Add($"{events.GetArrayLength()} event(s)\n"); + return string.Join('\n', lines); + } + + public static string EventDetail(JsonElement evt) + { + if (evt.ValueKind == JsonValueKind.Null || evt.ValueKind == JsonValueKind.Undefined) + return "Event not found."; + + var lines = new List { "", new string('─', 60) }; + lines.Add($"Subject: {GetStr(evt, "subject", "(no subject)")}"); + lines.Add($"Start: {FormatDateTime(evt.TryGetProperty("start", out var s) ? GetStr(s, "dateTime") : null)}"); + lines.Add($"End: {FormatDateTime(evt.TryGetProperty("end", out var e) ? GetStr(e, "dateTime") : null)}"); + + if (evt.TryGetProperty("location", out var loc) && !string.IsNullOrEmpty(GetStr(loc, "displayName"))) + lines.Add($"Location: {GetStr(loc, "displayName")}"); + + if (evt.TryGetProperty("organizer", out var org) && org.TryGetProperty("emailAddress", out var orgAddr)) + lines.Add($"Organizer: {GetStr(orgAddr, "name")} <{GetStr(orgAddr, "address")}>"); + + if (evt.TryGetProperty("attendees", out var attendees) && attendees.GetArrayLength() > 0) + { + lines.Add("Attendees:"); + foreach (var a in attendees.EnumerateArray()) + { + var email = a.TryGetProperty("emailAddress", out var ea) ? GetStr(ea, "address") : ""; + var type = GetStr(a, "type"); + var status = a.TryGetProperty("status", out var st) ? GetStr(st, "response", "none") : "none"; + lines.Add($" {email} ({type}, {status})"); + } + } + + lines.Add($"Status: {GetStr(evt, "showAs", "unknown")}"); + lines.Add($"All Day: {(GetBool(evt, "isAllDay") ? "yes" : "no")}"); + lines.Add($"ID: {GetStr(evt, "id")}"); + lines.Add(new string('─', 60)); + + if (evt.TryGetProperty("body", out var body)) + { + var content = GetStr(body, "content"); + if (!string.IsNullOrEmpty(content)) + { + // Convert HTML body to readable text for CLI display + var ct = GetStr(body, "contentType"); + if (ct.Equals("HTML", StringComparison.OrdinalIgnoreCase) || HtmlToText.IsHtml(content)) + content = HtmlToText.Convert(content); + lines.Add(""); + lines.Add(content); + } + } + lines.Add(""); + return string.Join('\n', lines); + } + + public static string CalendarList(JsonElement calendars) + { + if (calendars.ValueKind != JsonValueKind.Array || calendars.GetArrayLength() == 0) + return "No calendars found."; + + var lines = new List { "" }; + lines.Add(PadColumns(("Calendar", 35), ("Color", 15), ("Owner", 30))); + lines.Add(new string('─', 82)); + + foreach (var cal in calendars.EnumerateArray()) + { + var owner = cal.TryGetProperty("owner", out var o) ? GetStr(o, "address") : ""; + lines.Add(PadColumns( + (Truncate(GetStr(cal, "name"), 33), 35), + (GetStr(cal, "color"), 15), + (Truncate(owner, 28), 30))); + } + lines.Add(""); + return string.Join('\n', lines); + } + + public static string ContactList(JsonElement contacts) + { + if (contacts.ValueKind != JsonValueKind.Array || contacts.GetArrayLength() == 0) + return "No contacts found."; + + var lines = new List { "" }; + lines.Add(PadColumns(("Name", 30), ("Email", 35), ("Company", 20), ("Source", 10))); + lines.Add(new string('─', 97)); + + foreach (var contact in contacts.EnumerateArray()) + { + var email = ""; + if (contact.TryGetProperty("emailAddresses", out var emails) && + emails.ValueKind == JsonValueKind.Array) + { + var first = emails.EnumerateArray().FirstOrDefault(); + if (first.ValueKind == JsonValueKind.Object) + email = GetStr(first, "address"); + } + + lines.Add(PadColumns( + (Truncate(GetStr(contact, "displayName"), 28), 30), + (Truncate(email, 33), 35), + (Truncate(GetStr(contact, "companyName"), 18), 20), + (GetStr(contact, "source"), 10))); + } + lines.Add($"\n{contacts.GetArrayLength()} contact(s)\n"); + return string.Join('\n', lines); + } + + public static string AttachmentList(JsonElement data) + { + if (data.ValueKind != JsonValueKind.Array || data.GetArrayLength() == 0) + return "No attachments found."; + + var lines = new List { "", "Attachments:" }; + lines.Add(new string('─', 80)); + lines.Add(PadColumns( + ("Name", 35), ("Size", 12), ("Type", 25), ("Inline", 6))); + lines.Add(new string('─', 80)); + + foreach (var att in data.EnumerateArray()) + { + var name = att.TryGetProperty("name", out var n) ? n.GetString() ?? "unnamed" : "unnamed"; + var size = att.TryGetProperty("size", out var s) ? s.GetInt64() : 0; + var contentType = att.TryGetProperty("contentType", out var ct) ? ct.GetString() ?? "" : ""; + var isInline = att.TryGetProperty("isInline", out var il) && il.GetBoolean(); + + lines.Add(PadColumns( + (Truncate(name, 33), 35), + (FormatSizeHelper(size), 12), + (Truncate(contentType, 23), 25), + (isInline ? "yes" : "no", 6))); + } + lines.Add($"\n{data.GetArrayLength()} attachment(s)\n"); + return string.Join('\n', lines); + } + + private static string FormatSizeHelper(long bytes) + { + if (bytes < 1024) return $"{bytes} B"; + if (bytes < 1024 * 1024) return $"{bytes / 1024.0:F1} KB"; + if (bytes < 1024L * 1024 * 1024) return $"{bytes / (1024.0 * 1024):F1} MB"; + return $"{bytes / (1024.0 * 1024 * 1024):F1} GB"; + } + + public static string Generic(JsonElement data) + { + if (data.ValueKind == JsonValueKind.Null || data.ValueKind == JsonValueKind.Undefined) + return ""; + if (data.ValueKind == JsonValueKind.String) + return data.GetString() ?? ""; + return JsonSerializer.Serialize(data, OutlookCliJsonContext.Default.JsonElement); + } + } + + // ══════════════════════════════════════════════════════ + // JSON FORMAT + // ══════════════════════════════════════════════════════ + + public static class JsonFormatter + { + public static string Format(JsonElement data) => + JsonSerializer.Serialize(data, OutlookCliJsonContext.Default.JsonElement); + } + + // ══════════════════════════════════════════════════════ + // MARKDOWN FORMAT + // ══════════════════════════════════════════════════════ + + public static class MarkdownFormatter + { + /// Escape markdown table-breaking characters: pipe and newline. + private static string EscMd(string? str) + { + if (string.IsNullOrEmpty(str)) return ""; + return str.Replace("|", "\\|").Replace("\n", " "); + } + + private static string FmtDate(string? iso) + { + if (string.IsNullOrEmpty(iso)) return ""; + try { return DateTime.Parse(iso).ToString("yyyy-MM-dd HH:mm"); } + catch { return iso; } + } + + private static string FmtTime(string? iso) => FormatTime(iso); + + public static string MailList(JsonElement messages) + { + if (messages.ValueKind != JsonValueKind.Array || messages.GetArrayLength() == 0) + return "No messages found."; + + var lines = new List + { + "| | From | Subject | Date |", + "|---|---|---|---|" + }; + + foreach (var msg in messages.EnumerateArray()) + { + var marker = GetBool(msg, "isRead") ? "" : "●"; + var flagStatus = msg.TryGetProperty("flag", out var flag) ? GetStr(flag, "flagStatus") : ""; + var flagStr = flagStatus == "flagged" ? " ⚑" : ""; + var from = EscMd(GetFromAddress(msg)); + var subject = EscMd(GetStr(msg, "subject", "(no subject)")); + var date = FmtDate(GetStr(msg, "receivedDateTime")); + lines.Add($"| {marker}{flagStr} | {from} | {subject} | {date} |"); + } + + lines.Add(""); + lines.Add($"*{messages.GetArrayLength()} message(s)*"); + return string.Join('\n', lines); + } + + public static string MailDetail(JsonElement message) + { + if (message.ValueKind == JsonValueKind.Null) return "Message not found."; + + var lines = new List(); + lines.Add($"## {EscMd(GetStr(message, "subject", "(no subject)"))}"); + lines.Add(""); + lines.Add("| Field | Value |"); + lines.Add("|---|---|"); + lines.Add($"| **From** | {EscMd(GetFromName(message))} <{GetFromEmail(message)}> |"); + + if (message.TryGetProperty("toRecipients", out var to) && to.GetArrayLength() > 0) + { + var addrs = to.EnumerateArray() + .Select(r => r.TryGetProperty("emailAddress", out var ea) ? EscMd(GetStr(ea, "address")) : "") + .Where(a => !string.IsNullOrEmpty(a)); + lines.Add($"| **To** | {string.Join(", ", addrs)} |"); + } + + if (message.TryGetProperty("ccRecipients", out var cc) && cc.GetArrayLength() > 0) + { + var addrs = cc.EnumerateArray() + .Select(r => r.TryGetProperty("emailAddress", out var ea) ? EscMd(GetStr(ea, "address")) : "") + .Where(a => !string.IsNullOrEmpty(a)); + lines.Add($"| **Cc** | {string.Join(", ", addrs)} |"); + } + + lines.Add($"| **Date** | {FmtDate(GetStr(message, "receivedDateTime"))} |"); + lines.Add($"| **Read** | {(GetBool(message, "isRead") ? "Yes" : "No")} |"); + + var importance = GetStr(message, "importance"); + if (!string.IsNullOrEmpty(importance) && importance != "normal") + lines.Add($"| **Priority** | {importance} |"); + if (GetBool(message, "hasAttachments")) + lines.Add("| **Attachments** | Yes |"); + lines.Add($"| **ID** | `{GetStr(message, "id")}` |"); + + lines.Add(""); + lines.Add("---"); + lines.Add(""); + + var body = message.TryGetProperty("body", out var b) ? GetStr(b, "content") : ""; + if (string.IsNullOrEmpty(body)) + body = GetStr(message, "bodyPreview", "*(empty body)*"); + lines.Add(body); + + return string.Join('\n', lines); + } + + public static string FolderList(JsonElement folders) + { + if (folders.ValueKind != JsonValueKind.Array || folders.GetArrayLength() == 0) + return "No folders found."; + + var lines = new List + { + "| Folder | Unread | Total | ID |", + "|---|---|---|---|" + }; + + foreach (var f in folders.EnumerateArray()) + { + lines.Add($"| {EscMd(GetStr(f, "displayName"))} | {GetInt(f, "unreadItemCount")} | {GetInt(f, "totalItemCount")} | `{GetStr(f, "id")}` |"); + } + return string.Join('\n', lines); + } + + public static string EventList(JsonElement events, string title = "Events") + { + if (events.ValueKind != JsonValueKind.Array || events.GetArrayLength() == 0) + return $"No events for \"{title}\"."; + + var lines = new List { $"## 📅 {title}", "" }; + lines.Add("| Time | Subject | Location |"); + lines.Add("|---|---|---|"); + + foreach (var evt in events.EnumerateArray()) + { + var start = FmtTime(evt.TryGetProperty("start", out var s) ? GetStr(s, "dateTime") : null); + var end = FmtTime(evt.TryGetProperty("end", out var e) ? GetStr(e, "dateTime") : null); + var isAllDay = GetBool(evt, "isAllDay"); + var time = isAllDay ? "All day" : $"{start}–{end}"; + var subject = EscMd(GetStr(evt, "subject", "(no subject)")); + var location = EscMd(evt.TryGetProperty("location", out var loc) ? GetStr(loc, "displayName") : ""); + var cancelled = GetBool(evt, "isCancelled") ? " ~~CANCELLED~~" : ""; + lines.Add($"| {time} | {subject}{cancelled} | {location} |"); + } + + lines.Add(""); + lines.Add($"*{events.GetArrayLength()} event(s)*"); + return string.Join('\n', lines); + } + + public static string EventDetail(JsonElement evt) + { + if (evt.ValueKind == JsonValueKind.Null) return "Event not found."; + + var lines = new List { $"## {EscMd(GetStr(evt, "subject", "(no subject)"))}", "" }; + lines.Add("| Field | Value |"); + lines.Add("|---|---|"); + lines.Add($"| **Start** | {FmtDate(evt.TryGetProperty("start", out var s) ? GetStr(s, "dateTime") : null)} |"); + lines.Add($"| **End** | {FmtDate(evt.TryGetProperty("end", out var e) ? GetStr(e, "dateTime") : null)} |"); + + if (evt.TryGetProperty("location", out var loc) && !string.IsNullOrEmpty(GetStr(loc, "displayName"))) + lines.Add($"| **Location** | {EscMd(GetStr(loc, "displayName"))} |"); + if (evt.TryGetProperty("organizer", out var org) && org.TryGetProperty("emailAddress", out var orgAddr)) + lines.Add($"| **Organizer** | {EscMd(GetStr(orgAddr, "name"))} <{GetStr(orgAddr, "address")}> |"); + lines.Add($"| **Status** | {GetStr(evt, "showAs", "unknown")} |"); + lines.Add($"| **All Day** | {(GetBool(evt, "isAllDay") ? "Yes" : "No")} |"); + lines.Add($"| **ID** | `{GetStr(evt, "id")}` |"); + + if (evt.TryGetProperty("attendees", out var attendees) && attendees.GetArrayLength() > 0) + { + lines.Add(""); + lines.Add("### Attendees"); + lines.Add(""); + lines.Add("| Email | Type | Response |"); + lines.Add("|---|---|---|"); + foreach (var a in attendees.EnumerateArray()) + { + var email = a.TryGetProperty("emailAddress", out var ea) ? EscMd(GetStr(ea, "address")) : ""; + var type = GetStr(a, "type"); + var status = a.TryGetProperty("status", out var st) ? GetStr(st, "response", "none") : "none"; + lines.Add($"| {email} | {type} | {status} |"); + } + } + + if (evt.TryGetProperty("body", out var body) && !string.IsNullOrEmpty(GetStr(body, "content"))) + { + lines.Add(""); + lines.Add("---"); + lines.Add(""); + lines.Add(GetStr(body, "content")); + } + + return string.Join('\n', lines); + } + + public static string CalendarList(JsonElement calendars) + { + if (calendars.ValueKind != JsonValueKind.Array || calendars.GetArrayLength() == 0) + return "No calendars found."; + + var lines = new List + { + "| Calendar | Color | Owner |", + "|---|---|---|" + }; + + foreach (var cal in calendars.EnumerateArray()) + { + var owner = cal.TryGetProperty("owner", out var o) ? EscMd(GetStr(o, "address")) : ""; + lines.Add($"| {EscMd(GetStr(cal, "name"))} | {GetStr(cal, "color")} | {owner} |"); + } + return string.Join('\n', lines); + } + + public static string ContactList(JsonElement contacts) + { + if (contacts.ValueKind != JsonValueKind.Array || contacts.GetArrayLength() == 0) + return "No contacts found."; + + var lines = new List + { + "| Name | Email | Company | Source |", + "|---|---|---|---|" + }; + + foreach (var c in contacts.EnumerateArray()) + { + var email = ""; + if (c.TryGetProperty("emailAddresses", out var emails) && + emails.ValueKind == JsonValueKind.Array) + { + var first = emails.EnumerateArray().FirstOrDefault(); + if (first.ValueKind == JsonValueKind.Object) + email = EscMd(GetStr(first, "address")); + } + + lines.Add($"| {EscMd(GetStr(c, "displayName"))} | {email} | {EscMd(GetStr(c, "companyName"))} | {GetStr(c, "source")} |"); + } + + lines.Add(""); + lines.Add($"*{contacts.GetArrayLength()} contact(s)*"); + return string.Join('\n', lines); + } + + public static string Generic(JsonElement data) + { + if (data.ValueKind == JsonValueKind.Null || data.ValueKind == JsonValueKind.Undefined) return ""; + if (data.ValueKind == JsonValueKind.String) return data.GetString() ?? ""; + return "```json\n" + JsonSerializer.Serialize(data, OutlookCliJsonContext.Default.JsonElement) + "\n```"; + } + } + + // ══════════════════════════════════════════════════════ + // HTML FORMAT + // ══════════════════════════════════════════════════════ + + public static class HtmlFormatter + { + /// Escape HTML-significant characters to prevent injection. + private static string Esc(string? str) => + string.IsNullOrEmpty(str) ? "" : WebUtility.HtmlEncode(str); + + private static string FmtDate(string? iso) + { + if (string.IsNullOrEmpty(iso)) return ""; + try { return DateTime.Parse(iso).ToString("yyyy-MM-dd HH:mm"); } + catch { return Esc(iso); } + } + + private static string FmtTime(string? iso) => FormatTime(iso); + + private static string Table(string[] headers, List rows) + { + var sb = new StringBuilder(); + sb.AppendLine(""); + sb.AppendLine(""); + foreach (var h in headers) sb.AppendLine($" "); + sb.AppendLine(""); + sb.AppendLine(""); + foreach (var row in rows) + { + sb.AppendLine(""); + foreach (var cell in row) sb.AppendLine($" "); + sb.AppendLine(""); + } + sb.AppendLine(""); + sb.AppendLine("
{Esc(h)}
{cell}
"); + return sb.ToString(); + } + + private static string Dl(List<(string Term, string Def)> pairs) + { + var sb = new StringBuilder(); + sb.AppendLine("
"); + foreach (var (term, def) in pairs) + sb.AppendLine($"
{Esc(term)}
{def}
"); + sb.AppendLine("
"); + return sb.ToString(); + } + + public static string MailList(JsonElement messages) + { + if (messages.ValueKind != JsonValueKind.Array || messages.GetArrayLength() == 0) + return "

No messages found.

"; + + var rows = new List(); + foreach (var msg in messages.EnumerateArray()) + { + var marker = GetBool(msg, "isRead") ? "" : ""; + var flagStatus = msg.TryGetProperty("flag", out var flag) ? GetStr(flag, "flagStatus") : ""; + var flagStr = flagStatus == "flagged" ? " ⚑" : ""; + var from = Esc(GetFromAddress(msg)); + var subject = Esc(GetStr(msg, "subject", "(no subject)")); + var date = FmtDate(GetStr(msg, "receivedDateTime")); + rows.Add(new[] { $"{marker}{flagStr}", from, subject, date }); + } + + return Table(new[] { "", "From", "Subject", "Date" }, rows) + + $"\n

{messages.GetArrayLength()} message(s)

"; + } + + public static string MailDetail(JsonElement message) + { + if (message.ValueKind == JsonValueKind.Null) return "

Message not found.

"; + + var pairs = new List<(string, string)>(); + pairs.Add(("From", $"{Esc(GetFromName(message))} <{Esc(GetFromEmail(message))}>")); + + if (message.TryGetProperty("toRecipients", out var to) && to.GetArrayLength() > 0) + { + var addrs = to.EnumerateArray() + .Select(r => r.TryGetProperty("emailAddress", out var ea) ? Esc(GetStr(ea, "address")) : "") + .Where(a => !string.IsNullOrEmpty(a)); + pairs.Add(("To", string.Join(", ", addrs))); + } + if (message.TryGetProperty("ccRecipients", out var cc) && cc.GetArrayLength() > 0) + { + var addrs = cc.EnumerateArray() + .Select(r => r.TryGetProperty("emailAddress", out var ea) ? Esc(GetStr(ea, "address")) : "") + .Where(a => !string.IsNullOrEmpty(a)); + pairs.Add(("Cc", string.Join(", ", addrs))); + } + + pairs.Add(("Subject", Esc(GetStr(message, "subject", "(no subject)")))); + pairs.Add(("Date", FmtDate(GetStr(message, "receivedDateTime")))); + pairs.Add(("Read", GetBool(message, "isRead") ? "Yes" : "No")); + + var importance = GetStr(message, "importance"); + if (!string.IsNullOrEmpty(importance) && importance != "normal") + pairs.Add(("Priority", Esc(importance))); + if (GetBool(message, "hasAttachments")) + pairs.Add(("Attachments", "Yes")); + pairs.Add(("ID", $"{Esc(GetStr(message, "id"))}")); + + var body = message.TryGetProperty("body", out var b) ? GetStr(b, "content") : ""; + if (string.IsNullOrEmpty(body)) + body = Esc(GetStr(message, "bodyPreview", "")) ?? "(empty body)"; + + return $"

{Esc(GetStr(message, "subject", "(no subject)"))}

\n{Dl(pairs)}\n
\n
{body}
"; + } + + public static string FolderList(JsonElement folders) + { + if (folders.ValueKind != JsonValueKind.Array || folders.GetArrayLength() == 0) + return "

No folders found.

"; + + var rows = new List(); + foreach (var f in folders.EnumerateArray()) + { + rows.Add(new[] + { + Esc(GetStr(f, "displayName")), + GetInt(f, "unreadItemCount").ToString(), + GetInt(f, "totalItemCount").ToString(), + $"{Esc(GetStr(f, "id"))}" + }); + } + return Table(new[] { "Folder", "Unread", "Total", "ID" }, rows); + } + + public static string EventList(JsonElement events, string title = "Events") + { + if (events.ValueKind != JsonValueKind.Array || events.GetArrayLength() == 0) + return $"

No events for \"{Esc(title)}\".

"; + + var rows = new List(); + foreach (var evt in events.EnumerateArray()) + { + var start = FmtTime(evt.TryGetProperty("start", out var s) ? GetStr(s, "dateTime") : null); + var end = FmtTime(evt.TryGetProperty("end", out var e) ? GetStr(e, "dateTime") : null); + var isAllDay = GetBool(evt, "isAllDay"); + var time = isAllDay ? "All day" : $"{start}–{end}"; + var subject = Esc(GetStr(evt, "subject", "(no subject)")); + if (GetBool(evt, "isCancelled")) subject = $"{subject} [CANCELLED]"; + var location = Esc(evt.TryGetProperty("location", out var loc) ? GetStr(loc, "displayName") : ""); + rows.Add(new[] { time, subject, location }); + } + + return $"

📅 {Esc(title)}

\n{Table(new[] { "Time", "Subject", "Location" }, rows)}\n

{events.GetArrayLength()} event(s)

"; + } + + public static string EventDetail(JsonElement evt) + { + if (evt.ValueKind == JsonValueKind.Null) return "

Event not found.

"; + + var pairs = new List<(string, string)> + { + ("Subject", Esc(GetStr(evt, "subject", "(no subject)"))), + ("Start", FmtDate(evt.TryGetProperty("start", out var s) ? GetStr(s, "dateTime") : null)), + ("End", FmtDate(evt.TryGetProperty("end", out var e) ? GetStr(e, "dateTime") : null)) + }; + + if (evt.TryGetProperty("location", out var loc) && !string.IsNullOrEmpty(GetStr(loc, "displayName"))) + pairs.Add(("Location", Esc(GetStr(loc, "displayName")))); + if (evt.TryGetProperty("organizer", out var org) && org.TryGetProperty("emailAddress", out var orgAddr)) + pairs.Add(("Organizer", $"{Esc(GetStr(orgAddr, "name"))} <{Esc(GetStr(orgAddr, "address"))}>")); + pairs.Add(("Status", Esc(GetStr(evt, "showAs", "unknown")))); + pairs.Add(("All Day", GetBool(evt, "isAllDay") ? "Yes" : "No")); + pairs.Add(("ID", $"{Esc(GetStr(evt, "id"))}")); + + var attendeeHtml = ""; + if (evt.TryGetProperty("attendees", out var attendees) && attendees.GetArrayLength() > 0) + { + var rows = new List(); + foreach (var a in attendees.EnumerateArray()) + { + var email = a.TryGetProperty("emailAddress", out var ea) ? Esc(GetStr(ea, "address")) : ""; + rows.Add(new[] { email, Esc(GetStr(a, "type")), Esc(a.TryGetProperty("status", out var st) ? GetStr(st, "response", "none") : "none") }); + } + attendeeHtml = $"\n

Attendees

\n{Table(new[] { "Email", "Type", "Response" }, rows)}"; + } + + var body = evt.TryGetProperty("body", out var bodyEl) ? GetStr(bodyEl, "content") : ""; + var bodyHtml = !string.IsNullOrEmpty(body) ? $"\n
\n
{body}
" : ""; + + return $"

{Esc(GetStr(evt, "subject", "(no subject)"))}

\n{Dl(pairs)}{attendeeHtml}{bodyHtml}"; + } + + public static string CalendarList(JsonElement calendars) + { + if (calendars.ValueKind != JsonValueKind.Array || calendars.GetArrayLength() == 0) + return "

No calendars found.

"; + + var rows = new List(); + foreach (var cal in calendars.EnumerateArray()) + { + var owner = cal.TryGetProperty("owner", out var o) ? Esc(GetStr(o, "address")) : ""; + rows.Add(new[] { Esc(GetStr(cal, "name")), Esc(GetStr(cal, "color")), owner }); + } + return Table(new[] { "Calendar", "Color", "Owner" }, rows); + } + + public static string ContactList(JsonElement contacts) + { + if (contacts.ValueKind != JsonValueKind.Array || contacts.GetArrayLength() == 0) + return "

No contacts found.

"; + + var rows = new List(); + foreach (var c in contacts.EnumerateArray()) + { + var email = ""; + if (c.TryGetProperty("emailAddresses", out var emails) && emails.ValueKind == JsonValueKind.Array) + { + var first = emails.EnumerateArray().FirstOrDefault(); + if (first.ValueKind == JsonValueKind.Object) + email = Esc(GetStr(first, "address")); + } + rows.Add(new[] { Esc(GetStr(c, "displayName")), email, Esc(GetStr(c, "companyName")), Esc(GetStr(c, "source")) }); + } + return Table(new[] { "Name", "Email", "Company", "Source" }, rows) + + $"\n

{contacts.GetArrayLength()} contact(s)

"; + } + + public static string Generic(JsonElement data) + { + if (data.ValueKind == JsonValueKind.Null || data.ValueKind == JsonValueKind.Undefined) return ""; + if (data.ValueKind == JsonValueKind.String) return $"
{Esc(data.GetString())}
"; + return $"
{Esc(JsonSerializer.Serialize(data, OutlookCliJsonContext.Default.JsonElement))}
"; + } + } + + // ══════════════════════════════════════════════════════ + // RENDER DISPATCHER + // ══════════════════════════════════════════════════════ + + /// + /// Render data into a string for the given entity type and format. + /// + /// Dispatch logic: + /// 1. "json" format → bypass renderers, use JSON.stringify directly + /// 2. Look up the renderer by format name (text, markdown, html) + /// 3. Look up the entity-specific function on the renderer + /// 4. Fall back to generic if entity type function doesn't exist + /// + /// The data to render. + /// One of: mailList, mailDetail, folderList, + /// eventList, eventDetail, calendarList, contactList, generic. + /// One of: text, json, markdown, md, html. + /// Extra arg for renderer (e.g., title for eventList). + public static string FormatOutput(JsonElement data, string entityType, string format = "text", string? extra = null) + { + if (format == "json") + return JsonFormatter.Format(data); + + // Normalize "md" alias + if (format == "md") format = "markdown"; + + return format switch + { + "markdown" => entityType switch + { + "mailList" => MarkdownFormatter.MailList(data), + "mailDetail" => MarkdownFormatter.MailDetail(data), + "folderList" => MarkdownFormatter.FolderList(data), + "eventList" => MarkdownFormatter.EventList(data, extra ?? "Events"), + "eventDetail" => MarkdownFormatter.EventDetail(data), + "calendarList" => MarkdownFormatter.CalendarList(data), + "contactList" => MarkdownFormatter.ContactList(data), + "attachmentList" => TextFormatter.AttachmentList(data), + _ => MarkdownFormatter.Generic(data), + }, + "html" => entityType switch + { + "mailList" => HtmlFormatter.MailList(data), + "mailDetail" => HtmlFormatter.MailDetail(data), + "folderList" => HtmlFormatter.FolderList(data), + "eventList" => HtmlFormatter.EventList(data, extra ?? "Events"), + "eventDetail" => HtmlFormatter.EventDetail(data), + "calendarList" => HtmlFormatter.CalendarList(data), + "contactList" => HtmlFormatter.ContactList(data), + "attachmentList" => TextFormatter.AttachmentList(data), + _ => HtmlFormatter.Generic(data), + }, + _ => entityType switch // text (default) + { + "mailList" => TextFormatter.MailList(data), + "mailDetail" => TextFormatter.MailDetail(data), + "folderList" => TextFormatter.FolderList(data), + "eventList" => TextFormatter.EventList(data, extra ?? "Events"), + "eventDetail" => TextFormatter.EventDetail(data), + "calendarList" => TextFormatter.CalendarList(data), + "contactList" => TextFormatter.ContactList(data), + "attachmentList" => TextFormatter.AttachmentList(data), + _ => TextFormatter.Generic(data), + }, + }; + } + + /// + /// Resolve the output format from global options. + /// + /// Priority: --format flag > --json shorthand > default "text" + /// + public static string ResolveFormat(string? format, bool json) + { + if (!string.IsNullOrEmpty(format)) return format; + if (json) return "json"; + return "text"; + } + + /// + /// Write rendered content to a file or stdout. + /// + /// When outputFile is specified (--output flag), writes to disk instead of stdout. + /// + public static async Task WriteOutputAsync(string content, string? outputFile) + { + if (!string.IsNullOrEmpty(outputFile)) + { + await File.WriteAllTextAsync(outputFile, content + "\n"); + } + else + { + Console.Write(content + "\n"); + } + } + + /// + /// All-in-one: render data and write to the appropriate destination. + /// + public static async Task OutputAsync( + JsonElement data, string entityType, string? format, bool json, string? outputFile, string? extra = null) + { + var resolvedFormat = ResolveFormat(format, json); + var content = FormatOutput(data, entityType, resolvedFormat, extra); + await WriteOutputAsync(content, outputFile); + } +} diff --git a/src/dotnet/Output/PageState.cs b/src/dotnet/Output/PageState.cs new file mode 100644 index 0000000..090c0c7 --- /dev/null +++ b/src/dotnet/Output/PageState.cs @@ -0,0 +1,84 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace OutlookCli.Output; + +/// +/// Stores pagination cursor state so --page next/prev works across CLI invocations. +/// State is saved per-command (e.g., "mail-inbox", "mail-search") in ~/.outlook-cli/page-state.json. +/// +public static class PageState +{ + private static readonly string PagePath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".outlook-cli", "page-state.json"); + + /// + /// Save pagination state after a paginated response. + /// + public static void Save(string command, int currentSkip, int top, int resultCount, bool hasNextLink) + { + var dir = Path.GetDirectoryName(PagePath)!; + if (!Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + JsonObject allState; + try + { + if (File.Exists(PagePath)) + { + var existing = File.ReadAllText(PagePath); + allState = JsonNode.Parse(existing)?.AsObject() ?? new JsonObject(); + } + else + { + allState = new JsonObject(); + } + } + catch + { + allState = new JsonObject(); + } + + var entry = new JsonObject + { + ["hasNextLink"] = hasNextLink, + ["currentSkip"] = currentSkip, + ["top"] = top, + ["resultCount"] = resultCount, + ["timestamp"] = DateTime.UtcNow.ToString("o"), + }; + + allState[command] = entry; + + var options = new JsonSerializerOptions { WriteIndented = true }; + File.WriteAllText(PagePath, allState.ToJsonString(options)); + } + + /// + /// Get saved page state for a command. Returns (currentSkip, top, hasNextLink) or null. + /// + public static (int CurrentSkip, int Top, bool HasNextLink)? Get(string command) + { + if (!File.Exists(PagePath)) return null; + + try + { + var json = File.ReadAllText(PagePath); + using var doc = JsonDocument.Parse(json); + + if (!doc.RootElement.TryGetProperty(command, out var entry)) + return null; + + var currentSkip = entry.TryGetProperty("currentSkip", out var s) ? s.GetInt32() : 0; + var top = entry.TryGetProperty("top", out var t) ? t.GetInt32() : 25; + var hasNext = entry.TryGetProperty("hasNextLink", out var n) && n.GetBoolean(); + + return (currentSkip, top, hasNext); + } + catch + { + return null; + } + } +} diff --git a/src/dotnet/Program.cs b/src/dotnet/Program.cs new file mode 100644 index 0000000..e93b600 --- /dev/null +++ b/src/dotnet/Program.cs @@ -0,0 +1,2535 @@ +/// +/// Main entry point — central wiring of all subcommands and global options. +/// +/// Uses System.CommandLine (2.0.5 stable) to build the CLI tree. +/// +/// Global options defined here are available to every subcommand: +/// --account — selects which authenticated account to use +/// --as — delegate access (Graph API targets /users/{email}) +/// --format — output format (text, json, markdown, html) +/// --json — shorthand for --format json +/// --output — write output to file instead of stdout +/// --verbose — verbose logging +/// +/// Command structure matches the Node.js implementation: +/// auth → login, logout, status +/// account → add, remove, list, set-default +/// mail → inbox, read, search, folders, draft, reply, forward, move, flag, mark-read, send +/// calendar→ today, week, range, view, list-calendars, create +/// contacts→ search, alias (set, remove, list) +/// + +using System.CommandLine; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization.Metadata; +using Microsoft.Identity.Client; +using OutlookCli; +using OutlookCli.Accounts; +using OutlookCli.Auth; +using OutlookCli.Contacts; +using OutlookCli.Database; +using OutlookCli.Errors; +using OutlookCli.Graph; +using OutlookCli.Output; +using OutlookCli.Security; +using OutlookCli.Watch; + +// ── Version stamp (staleness detection) ──────────────── +VersionInfo.StampVersion(); + +// ── Global options ────────────────────────────────────── +var accountOption = new Option("--account", "-a") { Description = "Use specific account (default: default account)" }; +var asOption = new Option("--as") { Description = "Access another user's mailbox as delegate" }; +var formatOption = new Option("--format") { Description = "Output format: text, json, markdown, html (default: text)" }; +var jsonOption = new Option("--json") { Description = "Shorthand for --format json" }; +var outputOption = new Option("--output") { Description = "Write output to file instead of stdout" }; +var verboseOption = new Option("--verbose") { Description = "Verbose logging" }; + +var rootCommand = new RootCommand("Cross-platform CLI for Microsoft Outlook via Graph API"); +accountOption.Recursive = true; +rootCommand.Options.Add(accountOption); +asOption.Recursive = true; +rootCommand.Options.Add(asOption); +formatOption.Recursive = true; +rootCommand.Options.Add(formatOption); +jsonOption.Recursive = true; +rootCommand.Options.Add(jsonOption); +outputOption.Recursive = true; +rootCommand.Options.Add(outputOption); +verboseOption.Recursive = true; +rootCommand.Options.Add(verboseOption); + +// ── Helper functions ──────────────────────────────────── + +/// Build a Graph client from the global options, resolving account and delegate. Returns client, account email, and alias. +static (GraphClient Client, string? AccountEmail, string Alias) BuildClientWithEmail(string? accountAlias, string? asEmail) +{ + var delegateFor = !string.IsNullOrEmpty(asEmail) ? AliasManager.ResolveAlias(asEmail) : null; + var (account, alias) = AccountManager.ResolveAccount(accountAlias); + var client = GraphClient.CreateGraphClient(alias, account, delegateFor); + return (client, account.Email, alias); +} + +/// Build a Graph client from the global options, resolving account and delegate. +static GraphClient BuildClient(string? accountAlias, string? asEmail) +{ + return BuildClientWithEmail(accountAlias, asEmail).Client; +} + +/// +/// Assert that the resolved account is allowed to perform write operations. +/// Read-only accounts cannot send, draft, move, delete, flag, mark-read, etc. +/// +static void AssertWriteAllowed(string? accountAlias, string operation) +{ + var (account, resolvedAlias) = AccountManager.ResolveAccount(accountAlias); + if (account.IsReadOnly) + { + Console.Error.WriteLine($"✗ Account \"{resolvedAlias}\" is read-only. Cannot {operation}."); + Console.Error.WriteLine($" To change this, run: outlook-cli account set {resolvedAlias} --mode full"); + Environment.ExitCode = 1; + throw new InvalidOperationException($"Account \"{resolvedAlias}\" is read-only."); + } +} + +/// Render and output a JsonElement using the formatter. +static async Task RenderOutput(JsonElement? data, string entityType, string? format, bool json, string? outputFile, string? extra = null) +{ + if (data == null || !data.HasValue) return; + await OutputFormatter.OutputAsync(data.Value, entityType, format, json, outputFile, extra); +} + +/// Serialize a typed object to JsonElement for output using source-generated context. +static JsonElement ToJsonElement(T obj, JsonTypeInfo typeInfo) +{ + var json = JsonSerializer.Serialize(obj, typeInfo); + return JsonDocument.Parse(json).RootElement.Clone(); +} + +/// Convert a JsonNode to a JsonElement for output. +static JsonElement NodeToElement(JsonNode obj) +{ + return JsonDocument.Parse(obj.ToJsonString()).RootElement.Clone(); +} + +/// Format byte count as human-readable file size (e.g., "2.4 MB"). +static string FormatFileSize(long bytes) +{ + if (bytes < 1024) return $"{bytes} B"; + if (bytes < 1024 * 1024) return $"{bytes / 1024.0:F1} KB"; + if (bytes < 1024L * 1024 * 1024) return $"{bytes / (1024.0 * 1024):F1} MB"; + return $"{bytes / (1024.0 * 1024 * 1024):F1} GB"; +} + +// ════════════════════════════════════════════════════════ +// AUTH COMMANDS +// ════════════════════════════════════════════════════════ + +var authCommand = new Command("auth", "Authentication management"); + +// auth login +var loginCommand = new Command("login", "Authenticate with Microsoft (default account)"); +var deviceCodeOption = new Option("--device-code") { Description = "Use device code flow (for headless environments)" }; +var loginTenantOption = new Option("--tenant") { Description = "Azure AD tenant ID (default: \"common\")" }; +var loginClientIdOption = new Option("--client-id") { Description = "Azure app client ID" }; +loginCommand.Options.Add(deviceCodeOption); +loginCommand.Options.Add(loginTenantOption); +loginCommand.Options.Add(loginClientIdOption); + +loginCommand.SetAction(async (parseResult) => +{ + var accountAlias = parseResult.GetValue(accountOption); + var verbose = parseResult.GetValue(verboseOption); + var isJson = parseResult.GetValue(jsonOption); + var useDeviceCode = parseResult.GetValue(deviceCodeOption); + var tenantOpt = parseResult.GetValue(loginTenantOption); + var clientIdOpt = parseResult.GetValue(loginClientIdOption); + + var manager = AccountManager.Instance; + var alias = accountAlias ?? manager.GetDefaultAlias() ?? "default"; + + try + { + var account = manager.GetAccount(alias); + var tenantId = tenantOpt ?? account?.TenantId ?? "common"; + var clientId = clientIdOpt ?? account?.ClientId ?? Environment.GetEnvironmentVariable("OUTLOOK_CLI_CLIENT_ID"); + + if (string.IsNullOrEmpty(clientId)) + { + Console.Error.WriteLine("Error: No client ID configured."); + Console.Error.WriteLine("Provide --client-id, set OUTLOOK_CLI_CLIENT_ID env var, or add an account first."); + Environment.ExitCode = 1; + return; + } + + var msalClient = MsalClientFactory.CreateMsalClient(alias, clientId, tenantId); + var loginScopes = MsalClientFactory.GetScopesForAccount(account); + AuthenticationResult result; + + if (useDeviceCode) + { + try + { + result = await AuthFlows.DeviceCodeLoginAsync(msalClient, verbose, loginScopes); + } + catch (AuthorityMismatchException retryEx) + { + // Auto-correct authority if pre-flight detected a mismatch + tenantId = retryEx.CorrectTenant; + msalClient = MsalClientFactory.CreateMsalClient(alias, clientId, tenantId); + result = await AuthFlows.DeviceCodeLoginAsync(msalClient, verbose, loginScopes); + } + } + else + { + result = await AuthFlows.InteractiveLoginAsync(msalClient, loginScopes); + } + + // Validate token doesn't have Mail.Send + TokenValidator.ValidateTokenScopes(result); + + // Save/update account + manager.AddAccount(alias, + clientId: clientId, + tenantId: tenantId, + email: result.Account?.Username ?? "unknown", + homeAccountId: result.Account?.HomeAccountId?.Identifier); + + if (isJson) + { + Console.WriteLine(JsonSerializer.Serialize( + new SuccessResponse(true, Account: alias, Email: result.Account?.Username), + OutlookCliJsonContext.Default.SuccessResponse)); + } + else + { + Console.WriteLine($"✓ Logged in as {result.Account?.Username ?? "unknown"} (account: {alias})"); + } + } + catch (Exception ex) + { + var msg = ex.Message; + if (msg.Contains("invalid_grant")) + { + Console.Error.WriteLine("Login failed: The device code expired or was already used."); + Console.Error.WriteLine("Please try again — you have ~15 minutes to enter the code after it appears."); + } + else if (msg.Contains("authorization_pending")) + { + Console.Error.WriteLine("Login timed out waiting for you to enter the code."); + } + else if (msg.Contains("AADSTS700016") || msg.Contains("not found in the directory")) + { + Console.Error.WriteLine("Login failed: The client ID was not found in Microsoft Entra ID."); + Console.Error.WriteLine("Verify your --client-id matches the Application (client) ID in your App Registration."); + } + else if (msg.Contains("AADSTS65001") || msg.Contains("consent")) + { + Console.Error.WriteLine("Login failed: Admin consent may be required for the requested permissions."); + } + else + { + Console.Error.WriteLine($"Login failed: {msg}"); + } + + if (verbose) Console.Error.WriteLine(ex.ToString()); + Environment.ExitCode = 1; + } +}); +authCommand.Subcommands.Add(loginCommand); + +// auth logout +var logoutCommand = new Command("logout", "Clear cached tokens for the current account"); +logoutCommand.SetAction(async (parseResult) => +{ + var accountAlias = parseResult.GetValue(accountOption); + var isJson = parseResult.GetValue(jsonOption); + + var manager = AccountManager.Instance; + var alias = accountAlias ?? manager.GetDefaultAlias() ?? "default"; + + try + { + var account = manager.GetAccount(alias); + if (account == null) + { + Console.Error.WriteLine($"No account found with alias: {alias}"); + Environment.ExitCode = 1; + return; + } + + var msalClient = MsalClientFactory.CreateMsalClient(alias, account.ClientId, account.TenantId); + var cache = msalClient.GetAccountsAsync(); + var accounts = await cache; + + foreach (var acct in accounts) + { + await msalClient.RemoveAsync(acct); + } + + if (isJson) + { + Console.WriteLine(JsonSerializer.Serialize( + new SuccessResponse(true, Account: alias), + OutlookCliJsonContext.Default.SuccessResponse)); + } + else + { + Console.WriteLine($"✓ Logged out of account: {alias}"); + } + } + catch (Exception ex) + { + Console.Error.WriteLine($"Logout failed: {ex.Message}"); + Environment.ExitCode = 1; + } +}); +authCommand.Subcommands.Add(logoutCommand); + +// auth status +var statusCommand = new Command("status", "Show authentication status"); +statusCommand.SetAction(async (parseResult) => +{ + var accountAlias = parseResult.GetValue(accountOption); + var isJson = parseResult.GetValue(jsonOption); + + var manager = AccountManager.Instance; + var alias = accountAlias ?? manager.GetDefaultAlias() ?? "default"; + + try + { + var account = manager.GetAccount(alias); + if (account == null) + { + if (isJson) + { + Console.WriteLine(JsonSerializer.Serialize( + new AuthStatusResponse(false, Account: alias), + OutlookCliJsonContext.Default.AuthStatusResponse)); + } + else + { + Console.WriteLine($"Account \"{alias}\" not configured."); + } + return; + } + + var msalClient = MsalClientFactory.CreateMsalClient(alias, account.ClientId, account.TenantId); + var accounts = await msalClient.GetAccountsAsync(); + var hasAccounts = accounts.Any(); + + if (isJson) + { + Console.WriteLine(new JsonObject + { + ["account"] = alias, + ["email"] = account.Email, + ["authenticated"] = hasAccounts, + ["tenantId"] = account.TenantId + }.ToJsonString(OutlookCliJsonContext.Default.Options)); + } + else + { + Console.WriteLine($"Account: {alias}"); + Console.WriteLine($"Email: {account.Email}"); + Console.WriteLine($"Authenticated: {(hasAccounts ? "✓ yes" : "✗ no")}"); + Console.WriteLine($"Tenant: {account.TenantId}"); + } + } + catch (Exception ex) + { + Console.Error.WriteLine($"Status check failed: {ex.Message}"); + Environment.ExitCode = 1; + } +}); +authCommand.Subcommands.Add(statusCommand); +rootCommand.Subcommands.Add(authCommand); + +// ════════════════════════════════════════════════════════ +// ACCOUNT COMMANDS +// ════════════════════════════════════════════════════════ + +var accountCommand = new Command("account", "Multi-account management"); + +// account add +var addAliasArg = new Argument("alias") { Description = "Account alias name" }; +var addCommand = new Command("add", "Add a new account"); +var addClientIdOption = new Option("--client-id") { Description = "Azure app client ID" }; +var addTenantOption = new Option("--tenant") { Description = "Azure AD tenant ID (default: \"common\")" }; +var addReadOnlyOption = new Option("--read-only") { Description = "Restrict to read-only mode (no send, draft, move, delete)" }; +var addScopesOption = new Option("--scopes") { Description = "Custom MSAL scopes (comma-separated, advanced)" }; +addCommand.Arguments.Add(addAliasArg); +addCommand.Options.Add(addClientIdOption); +addCommand.Options.Add(addTenantOption); +addCommand.Options.Add(addReadOnlyOption); +addCommand.Options.Add(addScopesOption); + +addCommand.SetAction((parseResult) => +{ + var alias = parseResult.GetValue(addAliasArg); + var isJson = parseResult.GetValue(jsonOption); + var clientIdVal = parseResult.GetValue(addClientIdOption) + ?? Environment.GetEnvironmentVariable("OUTLOOK_CLI_CLIENT_ID"); + var tenantVal = parseResult.GetValue(addTenantOption) ?? "common"; + var readOnly = parseResult.GetValue(addReadOnlyOption); + var scopesVal = parseResult.GetValue(addScopesOption); + + if (string.IsNullOrEmpty(clientIdVal)) + { + Console.Error.WriteLine("Error: --client-id is required (or set OUTLOOK_CLI_CLIENT_ID env var)"); + Environment.ExitCode = 1; + return; + } + + var manager = AccountManager.Instance; + var mode = readOnly ? "read-only" : "full"; + List? customScopes = null; + if (!string.IsNullOrEmpty(scopesVal)) + { + customScopes = scopesVal.Split(',').Select(s => s.Trim()).Where(s => s.Length > 0).ToList(); + } + + manager.AddAccount(alias, clientIdVal, tenantVal, email: "(not yet authenticated)", + mode: mode, scopes: customScopes); + + var modeLabel = readOnly ? " (read-only)" : ""; + if (isJson) + { + var result = new JsonObject + { + ["success"] = true, + ["alias"] = alias, + ["clientId"] = clientIdVal, + ["tenantId"] = tenantVal, + ["mode"] = mode + }; + Console.WriteLine(result.ToJsonString(OutlookCliJsonContext.Default.Options)); + } + else + { + Console.WriteLine($"✓ Account \"{alias}\" added{modeLabel}. Run `outlook-cli auth login --account {alias}` to authenticate."); + } +}); +accountCommand.Subcommands.Add(addCommand); + +// account remove +var removeAliasArg = new Argument("alias") { Description = "Account alias to remove" }; +var removeCommand = new Command("remove", "Remove an account"); +removeCommand.Arguments.Add(removeAliasArg); +removeCommand.SetAction((parseResult) => +{ + var alias = parseResult.GetValue(removeAliasArg); + var isJson = parseResult.GetValue(jsonOption); + var manager = AccountManager.Instance; + + if (manager.GetAccount(alias) == null) + { + Console.Error.WriteLine($"Account \"{alias}\" not found."); + Environment.ExitCode = 1; + return; + } + + manager.RemoveAccount(alias); + + if (isJson) + { + Console.WriteLine(new JsonObject + { + ["success"] = true, + ["removed"] = alias + }.ToJsonString(OutlookCliJsonContext.Default.Options)); + } + else + { + Console.WriteLine($"✓ Account \"{alias}\" removed."); + } +}); +accountCommand.Subcommands.Add(removeCommand); + +// account list +var listAccountsCommand = new Command("list", "List all accounts"); +listAccountsCommand.SetAction((parseResult) => +{ + var isJson = parseResult.GetValue(jsonOption); + var manager = AccountManager.Instance; + var accounts = manager.ListAccounts(); + var defaultAlias = manager.GetDefaultAlias(); + + if (isJson) + { + var acctArray = new JsonArray(); + foreach (var acct in accounts) + acctArray.Add(JsonNode.Parse(JsonSerializer.Serialize(acct, OutlookCliJsonContext.Default.AccountConfig))); + Console.WriteLine(new JsonObject + { + ["accounts"] = acctArray, + ["defaultAccount"] = defaultAlias + }.ToJsonString(OutlookCliJsonContext.Default.Options)); + } + else + { + if (accounts.Count == 0) + { + Console.WriteLine("No accounts configured. Run `outlook-cli account add --client-id ` to add one."); + return; + } + Console.WriteLine("Accounts:"); + foreach (var acct in accounts) + { + var marker = acct.Alias == defaultAlias ? " (default)" : ""; + var modeTag = acct.IsReadOnly ? " [read-only]" : ""; + Console.WriteLine($" {acct.Alias}{marker}{modeTag} — {acct.Email} [{acct.TenantId}]"); + } + } +}); +accountCommand.Subcommands.Add(listAccountsCommand); + +// account set-default +var setDefaultAliasArg = new Argument("alias") { Description = "Account alias to set as default" }; +var setDefaultCommand = new Command("set-default", "Set the default account"); +setDefaultCommand.Arguments.Add(setDefaultAliasArg); +setDefaultCommand.SetAction((parseResult) => +{ + var alias = parseResult.GetValue(setDefaultAliasArg); + var isJson = parseResult.GetValue(jsonOption); + var manager = AccountManager.Instance; + + if (manager.GetAccount(alias) == null) + { + Console.Error.WriteLine($"Account \"{alias}\" not found."); + Environment.ExitCode = 1; + return; + } + + manager.SetDefault(alias); + + if (isJson) + { + Console.WriteLine(new JsonObject + { + ["success"] = true, + ["defaultAccount"] = alias + }.ToJsonString(OutlookCliJsonContext.Default.Options)); + } + else + { + Console.WriteLine($"✓ Default account set to \"{alias}\"."); + } +}); +accountCommand.Subcommands.Add(setDefaultCommand); + +// account set +var setAliasArg = new Argument("alias") { Description = "Account alias to update" }; +var setCommand = new Command("set", "Update account settings"); +var setReadOnlyOption = new Option("--read-only") { Description = "Restrict to read-only mode" }; +var setModeOption = new Option("--mode") { Description = "Account mode: \"full\" or \"read-only\"" }; +var setScopesOption = new Option("--scopes") { Description = "Custom MSAL scopes (comma-separated)" }; +var setClientIdOption = new Option("--client-id") { Description = "Change Azure app client ID" }; +var setTenantOption = new Option("--tenant") { Description = "Change Azure AD tenant ID" }; +setCommand.Arguments.Add(setAliasArg); +setCommand.Options.Add(setReadOnlyOption); +setCommand.Options.Add(setModeOption); +setCommand.Options.Add(setScopesOption); +setCommand.Options.Add(setClientIdOption); +setCommand.Options.Add(setTenantOption); + +setCommand.SetAction((parseResult) => +{ + var alias = parseResult.GetValue(setAliasArg); + var isJson = parseResult.GetValue(jsonOption); + var manager = AccountManager.Instance; + var account = manager.GetAccount(alias); + + if (account == null) + { + Console.Error.WriteLine($"Account \"{alias}\" not found."); + Environment.ExitCode = 1; + return; + } + + var needsRelogin = false; + var readOnly = parseResult.GetValue(setReadOnlyOption); + var modeVal = parseResult.GetValue(setModeOption); + var scopesVal = parseResult.GetValue(setScopesOption); + var clientIdVal = parseResult.GetValue(setClientIdOption); + var tenantVal = parseResult.GetValue(setTenantOption); + + if (readOnly) + { + account.Mode = "read-only"; + needsRelogin = true; + } + else if (!string.IsNullOrEmpty(modeVal)) + { + if (modeVal != "full" && modeVal != "read-only") + { + Console.Error.WriteLine("Error: --mode must be \"full\" or \"read-only\""); + Environment.ExitCode = 1; + return; + } + account.Mode = modeVal; + needsRelogin = true; + } + + if (!string.IsNullOrEmpty(scopesVal)) + { + account.Scopes = scopesVal.Split(',').Select(s => s.Trim()).Where(s => s.Length > 0).ToList(); + needsRelogin = true; + } + + if (!string.IsNullOrEmpty(clientIdVal)) + { + account.ClientId = clientIdVal; + needsRelogin = true; + } + + if (!string.IsNullOrEmpty(tenantVal)) + { + account.TenantId = tenantVal; + needsRelogin = true; + } + + if (!readOnly && string.IsNullOrEmpty(modeVal) && string.IsNullOrEmpty(scopesVal) + && string.IsNullOrEmpty(clientIdVal) && string.IsNullOrEmpty(tenantVal)) + { + Console.Error.WriteLine("Error: No settings to update. Use --read-only, --mode, --scopes, --client-id, or --tenant."); + Environment.ExitCode = 1; + return; + } + + manager.Save(); + + if (isJson) + { + Console.WriteLine(new JsonObject + { + ["success"] = true, + ["alias"] = alias, + ["needsRelogin"] = needsRelogin + }.ToJsonString(OutlookCliJsonContext.Default.Options)); + } + else + { + Console.WriteLine($"✓ Account \"{alias}\" updated."); + if (needsRelogin) + { + Console.WriteLine($" Note: Re-login required for changes to take effect."); + Console.WriteLine($" Run: outlook-cli auth login --account {alias}"); + } + } +}); +accountCommand.Subcommands.Add(setCommand); + +rootCommand.Subcommands.Add(accountCommand); + +// ════════════════════════════════════════════════════════ +// MAIL COMMANDS +// ════════════════════════════════════════════════════════ + +var mailCommand = new Command("mail", "Email operations"); + +// mail inbox +var inboxCommand = new Command("inbox", "List inbox messages"); +var topOption = new Option("--top") { Description = "Number of messages" }; +var unreadOption = new Option("--unread") { Description = "Show unread only" }; +var folderOption = new Option("--folder") { Description = "Folder name (default: Inbox)" }; +var skipOption = new Option("--skip") { Description = "Number of messages to skip" }; +var pageOption = new Option("--page") { Description = "Page navigation: next or prev" }; +inboxCommand.Options.Add(topOption); +inboxCommand.Options.Add(unreadOption); +inboxCommand.Options.Add(folderOption); +inboxCommand.Options.Add(skipOption); +inboxCommand.Options.Add(pageOption); + +inboxCommand.SetAction(async (parseResult) => +{ + var acct = parseResult.GetValue(accountOption); + var asEmail = parseResult.GetValue(asOption); + var fmt = parseResult.GetValue(formatOption); + var isJson = parseResult.GetValue(jsonOption); + var outFile = parseResult.GetValue(outputOption); + var top = parseResult.GetValue(topOption) ?? 25; + var unread = parseResult.GetValue(unreadOption); + var folder = parseResult.GetValue(folderOption) ?? "Inbox"; + var skip = parseResult.GetValue(skipOption) ?? 0; + var page = parseResult.GetValue(pageOption); + + // Handle --page next/prev + if (!string.IsNullOrEmpty(page)) + { + var saved = PageState.Get("mail-inbox"); + if (page == "next") + { + if (saved == null || !saved.Value.HasNextLink) + { + Console.Error.WriteLine("No more pages. You are at the last page of results."); + Environment.ExitCode = 1; + return; + } + skip = saved.Value.CurrentSkip + saved.Value.Top; + } + else if (page == "prev") + { + if (saved == null || saved.Value.CurrentSkip <= 0) + { + Console.Error.WriteLine("Already at the first page."); + Environment.ExitCode = 1; + return; + } + skip = Math.Max(0, saved.Value.CurrentSkip - saved.Value.Top); + } + else + { + Console.Error.WriteLine($"Invalid --page value: \"{page}\". Use \"next\" or \"prev\"."); + Environment.ExitCode = 1; + return; + } + } + + try + { + var client = BuildClient(acct, asEmail); + var (messages, hasNextLink) = await MailService.ListMessagesAsync(client, folder, top, unread, skip); + + if (messages.HasValue) LastResults.SaveResultIds(messages.Value); + PageState.Save("mail-inbox", skip, top, + messages.HasValue && messages.Value.ValueKind == JsonValueKind.Array ? messages.Value.GetArrayLength() : 0, + hasNextLink); + + await RenderOutput(messages, "mailList", fmt, isJson, outFile); + + // Show pagination hints on stderr + var pageNum = skip / top + 1; + if (hasNextLink) + Console.Error.WriteLine($"\nPage {pageNum} — use --page next for more results"); + else if (skip > 0) + Console.Error.WriteLine($"\nPage {pageNum} (last page) — use --page prev to go back"); + } + catch (Exception ex) { Console.Error.WriteLine(ErrorFormatter.FormatText(ex, parseResult.GetValue(verboseOption))); Environment.ExitCode = 1; } +}); +mailCommand.Subcommands.Add(inboxCommand); + +// mail read +var readMsgIdArg = new Argument("messageId") { Description = "Message ID to read", DefaultValueFactory = _ => null! }; +var readCommand = new Command("read", "Read a message"); +var plainOption = new Option("--plain") { Description = "Request plain text body" }; +var bodyPreviewOption = new Option("--body-preview") { Description = "Show only body preview (~255 chars)" }; +var truncateOption = new Option("--truncate") { Description = "Truncate body to N characters" }; +var readAttachmentsOption = new Option("--attachments") { Description = "Include attachment list in output" }; +var readRedactOption = new Option("--redact") { Description = "Redact PII (emails, phones, SSNs, etc.)" }; +readCommand.Arguments.Add(readMsgIdArg); +readCommand.Options.Add(plainOption); +readCommand.Options.Add(bodyPreviewOption); +readCommand.Options.Add(truncateOption); +readCommand.Options.Add(readAttachmentsOption); +readCommand.Options.Add(readRedactOption); + +readCommand.SetAction(async (parseResult) => +{ + var acct = parseResult.GetValue(accountOption); + var asEmail = parseResult.GetValue(asOption); + var fmt = parseResult.GetValue(formatOption); + var isJson = parseResult.GetValue(jsonOption); + var outFile = parseResult.GetValue(outputOption); + var msgId = LastResults.ResolveId(parseResult.GetValue(readMsgIdArg)); + var plain = parseResult.GetValue(plainOption); + var usePreview = parseResult.GetValue(bodyPreviewOption); + var truncateLen = parseResult.GetValue(truncateOption); + var showAttachments = parseResult.GetValue(readAttachmentsOption); + var redactPii = parseResult.GetValue(readRedactOption); + + if (string.IsNullOrEmpty(msgId)) + { + Console.Error.WriteLine("Error: messageId is required"); + Environment.ExitCode = 1; + return; + } + + try + { + var client = BuildClient(acct, asEmail); + var message = await MailService.GetMessageAsync(client, msgId, plain); + + // Apply body transformations for large email handling + if (message.HasValue && message.Value.ValueKind == JsonValueKind.Object) + { + if (usePreview) + { + message = MailService.ApplyBodyPreview(message.Value); + } + else if (truncateLen.HasValue) + { + message = MailService.ApplyTruncate(message.Value, truncateLen.Value); + } + } + + // Fetch attachment metadata if requested + if (showAttachments && message.HasValue) + { + try + { + var attachments = await MailService.ListAttachmentsAsync(client, msgId); + if (attachments.HasValue) + { + var obj = JsonNode.Parse(message.Value.GetRawText())?.AsObject(); + if (obj != null) + { + obj["attachmentList"] = JsonNode.Parse(attachments.Value.GetRawText()); + message = JsonDocument.Parse(obj.ToJsonString()).RootElement.Clone(); + } + } + } + catch (Exception attEx) + { + Console.Error.WriteLine($"Warning: Could not fetch attachments: {attEx.Message}"); + } + } + + // Redact PII if requested + if (redactPii && message.HasValue) + { + var redactor = new OutlookCli.Security.Redactor(); + var obj = JsonNode.Parse(message.Value.GetRawText())?.AsObject(); + if (obj != null) + { + if (obj["subject"] is JsonNode subj) + obj["subject"] = redactor.Redact(subj.GetValue()).Redacted; + if (obj["bodyPreview"] is JsonNode preview) + obj["bodyPreview"] = redactor.Redact(preview.GetValue()).Redacted; + if (obj["body"] is JsonObject body && body["content"] is JsonNode content) + body["content"] = redactor.Redact(content.GetValue()).Redacted; + message = JsonDocument.Parse(obj.ToJsonString()).RootElement.Clone(); + } + } + + await RenderOutput(message, "mailDetail", fmt, isJson, outFile); + } + catch (Exception ex) { Console.Error.WriteLine(ErrorFormatter.FormatText(ex, parseResult.GetValue(verboseOption))); Environment.ExitCode = 1; } +}); +mailCommand.Subcommands.Add(readCommand); + +// mail search +var searchQueryArg = new Argument("query") { Description = "Search query (KQL)", DefaultValueFactory = _ => null! }; +var searchCommand = new Command("search", "Search messages"); +var searchTopOption = new Option("--top") { Description = "Number of results" }; +var searchSkipOption = new Option("--skip") { Description = "Number of results to skip" }; +var searchPageOption = new Option("--page") { Description = "Page navigation: next or prev" }; +searchCommand.Arguments.Add(searchQueryArg); +searchCommand.Options.Add(searchTopOption); +searchCommand.Options.Add(searchSkipOption); +searchCommand.Options.Add(searchPageOption); + +searchCommand.SetAction(async (parseResult) => +{ + var acct = parseResult.GetValue(accountOption); + var asEmail = parseResult.GetValue(asOption); + var fmt = parseResult.GetValue(formatOption); + var isJson = parseResult.GetValue(jsonOption); + var outFile = parseResult.GetValue(outputOption); + var query = parseResult.GetValue(searchQueryArg); + var top = parseResult.GetValue(searchTopOption) ?? 25; + var searchSkip = parseResult.GetValue(searchSkipOption) ?? 0; + var searchPage = parseResult.GetValue(searchPageOption); + + if (string.IsNullOrEmpty(query) && string.IsNullOrEmpty(searchPage)) + { + Console.Error.WriteLine("Error: query is required"); + Environment.ExitCode = 1; + return; + } + + // Handle --page next/prev + if (!string.IsNullOrEmpty(searchPage)) + { + var saved = PageState.Get("mail-search"); + if (searchPage == "next") + { + if (saved == null || !saved.Value.HasNextLink) + { + Console.Error.WriteLine("No more pages. You are at the last page of results."); + Environment.ExitCode = 1; + return; + } + searchSkip = saved.Value.CurrentSkip + saved.Value.Top; + } + else if (searchPage == "prev") + { + if (saved == null || saved.Value.CurrentSkip <= 0) + { + Console.Error.WriteLine("Already at the first page."); + Environment.ExitCode = 1; + return; + } + searchSkip = Math.Max(0, saved.Value.CurrentSkip - saved.Value.Top); + } + else + { + Console.Error.WriteLine($"Invalid --page value: \"{searchPage}\". Use \"next\" or \"prev\"."); + Environment.ExitCode = 1; + return; + } + } + + try + { + var client = BuildClient(acct, asEmail); + var (messages, hasNextLink) = await MailService.SearchMessagesAsync(client, query ?? "", top, searchSkip); + + if (messages.HasValue) LastResults.SaveResultIds(messages.Value); + PageState.Save("mail-search", searchSkip, top, + messages.HasValue && messages.Value.ValueKind == JsonValueKind.Array ? messages.Value.GetArrayLength() : 0, + hasNextLink); + + await RenderOutput(messages, "mailList", fmt, isJson, outFile); + + var pageNum = searchSkip / top + 1; + if (hasNextLink) + Console.Error.WriteLine($"\nPage {pageNum} — use --page next for more results"); + else if (searchSkip > 0) + Console.Error.WriteLine($"\nPage {pageNum} (last page) — use --page prev to go back"); + } + catch (Exception ex) { Console.Error.WriteLine(ErrorFormatter.FormatText(ex, parseResult.GetValue(verboseOption))); Environment.ExitCode = 1; } +}); +mailCommand.Subcommands.Add(searchCommand); + +// mail folders +var foldersCommand = new Command("folders", "List mail folders"); +foldersCommand.SetAction(async (parseResult) => +{ + var acct = parseResult.GetValue(accountOption); + var asEmail = parseResult.GetValue(asOption); + var fmt = parseResult.GetValue(formatOption); + var isJson = parseResult.GetValue(jsonOption); + var outFile = parseResult.GetValue(outputOption); + + try + { + var client = BuildClient(acct, asEmail); + var folders = await MailService.ListFoldersAsync(client); + await RenderOutput(folders, "folderList", fmt, isJson, outFile); + } + catch (Exception ex) { Console.Error.WriteLine(ErrorFormatter.FormatText(ex, parseResult.GetValue(verboseOption))); Environment.ExitCode = 1; } +}); +mailCommand.Subcommands.Add(foldersCommand); + +// mail draft +var draftCommand = new Command("draft", "Create a draft email"); +var draftToOption = new Option("--to") { Description = "Recipient email(s), comma-separated" }; +var draftSubjectOption = new Option("--subject") { Description = "Email subject" }; +var draftBodyOption = new Option("--body") { Description = "Email body text" }; +var draftBodyFileOption = new Option("--body-file") { Description = "Read body from file (text or HTML)" }; +var draftBodyTypeOption = new Option("--body-content-type") { Description = "Body content type: Text or HTML", DefaultValueFactory = _ => "Text" }; +var draftCcOption = new Option("--cc") { Description = "CC recipients (comma-separated)" }; +var draftBccOption = new Option("--bcc") { Description = "BCC recipients (comma-separated)" }; +var draftImportanceOption = new Option("--importance") { Description = "Importance: low, normal, high", DefaultValueFactory = _ => "normal" }; +var yesOption = new Option("--yes") { Description = "Skip confirmation" }; +draftCommand.Options.Add(draftToOption); +draftCommand.Options.Add(draftSubjectOption); +draftCommand.Options.Add(draftBodyOption); +draftCommand.Options.Add(draftBodyFileOption); +draftCommand.Options.Add(draftBodyTypeOption); +draftCommand.Options.Add(draftCcOption); +draftCommand.Options.Add(draftBccOption); +draftCommand.Options.Add(draftImportanceOption); +draftCommand.Options.Add(yesOption); + +draftCommand.SetAction(async (parseResult) => +{ + var acct = parseResult.GetValue(accountOption); + var asEmail = parseResult.GetValue(asOption); + var fmt = parseResult.GetValue(formatOption); + var isJson = parseResult.GetValue(jsonOption); + var outFile = parseResult.GetValue(outputOption); + var to = parseResult.GetValue(draftToOption); + var subject = parseResult.GetValue(draftSubjectOption); + var body = parseResult.GetValue(draftBodyOption); + var bodyFile = parseResult.GetValue(draftBodyFileOption); + var bodyContentType = parseResult.GetValue(draftBodyTypeOption); + var cc = parseResult.GetValue(draftCcOption); + var bcc = parseResult.GetValue(draftBccOption); + var importance = parseResult.GetValue(draftImportanceOption); + var yes = parseResult.GetValue(yesOption); + + if (string.IsNullOrEmpty(to)) + { + Console.Error.WriteLine("Error: --to is required"); + Environment.ExitCode = 1; + return; + } + if (string.IsNullOrEmpty(subject)) + { + Console.Error.WriteLine("Error: --subject is required"); + Environment.ExitCode = 1; + return; + } + + try + { + AssertWriteAllowed(acct, "create draft"); + var (client, accountEmail, _) = BuildClientWithEmail(acct, asEmail); + + // Resolve body content + var bodyContent = body ?? ""; + if (!string.IsNullOrEmpty(bodyFile) && File.Exists(bodyFile)) + { + bodyContent = await File.ReadAllTextAsync(bodyFile); + if (bodyFile.EndsWith(".html", StringComparison.OrdinalIgnoreCase) || + bodyFile.EndsWith(".htm", StringComparison.OrdinalIgnoreCase)) + bodyContentType = "HTML"; + } + + // Parse recipients + var toRecipients = ParseRecipients(to, accountEmail); + var draft = new JsonObject + { + ["subject"] = subject, + ["body"] = new JsonObject + { + ["contentType"] = bodyContentType, + ["content"] = bodyContent + }, + ["toRecipients"] = toRecipients, + ["importance"] = importance ?? "normal" + }; + + if (!string.IsNullOrEmpty(cc)) + draft["ccRecipients"] = ParseRecipients(cc, accountEmail); + if (!string.IsNullOrEmpty(bcc)) + draft["bccRecipients"] = ParseRecipients(bcc, accountEmail); + + var result = await MailService.CreateDraftAsync(client, draft.ToJsonString()); + await RenderOutput(result, "generic", fmt, isJson, outFile); + } + catch (Exception ex) { Console.Error.WriteLine(ErrorFormatter.FormatText(ex, parseResult.GetValue(verboseOption))); Environment.ExitCode = 1; } +}); +mailCommand.Subcommands.Add(draftCommand); + +// mail reply +var replyMsgIdArg = new Argument("messageId") { Description = "Message ID to reply to", DefaultValueFactory = _ => null! }; +var replyCommand = new Command("reply", "Create a reply draft"); +var replyBodyOption = new Option("--body") { Description = "Reply body text" }; +var replyBodyFileOption = new Option("--body-file") { Description = "Read body from file (text or HTML)" }; +var replyBodyTypeOption = new Option("--body-content-type") { Description = "Body content type: Text or HTML", DefaultValueFactory = _ => "Text" }; +var replyAllOption = new Option("--all") { Description = "Reply to all recipients" }; +var replyYesOption = new Option("--yes") { Description = "Skip confirmation" }; +replyCommand.Arguments.Add(replyMsgIdArg); +replyCommand.Options.Add(replyBodyOption); +replyCommand.Options.Add(replyBodyFileOption); +replyCommand.Options.Add(replyBodyTypeOption); +replyCommand.Options.Add(replyAllOption); +replyCommand.Options.Add(replyYesOption); + +replyCommand.SetAction(async (parseResult) => +{ + var acct = parseResult.GetValue(accountOption); + var asEmail = parseResult.GetValue(asOption); + var fmt = parseResult.GetValue(formatOption); + var isJson = parseResult.GetValue(jsonOption); + var outFile = parseResult.GetValue(outputOption); + var msgId = LastResults.ResolveId(parseResult.GetValue(replyMsgIdArg)); + var body = parseResult.GetValue(replyBodyOption); + var bodyFile = parseResult.GetValue(replyBodyFileOption); + var bodyContentType = parseResult.GetValue(replyBodyTypeOption); + var replyAll = parseResult.GetValue(replyAllOption); + + if (string.IsNullOrEmpty(msgId)) + { + Console.Error.WriteLine("Error: messageId is required"); + Environment.ExitCode = 1; + return; + } + + try + { + AssertWriteAllowed(acct, "reply"); + var client = BuildClient(acct, asEmail); + + var bodyContent = body ?? ""; + if (!string.IsNullOrEmpty(bodyFile) && File.Exists(bodyFile)) + { + bodyContent = await File.ReadAllTextAsync(bodyFile); + if (bodyFile.EndsWith(".html", StringComparison.OrdinalIgnoreCase)) + bodyContentType = "HTML"; + } + + var result = await MailService.CreateReplyDraftAsync(client, msgId, bodyContent, bodyContentType ?? "Text", replyAll); + await RenderOutput(result, "generic", fmt, isJson, outFile); + } + catch (Exception ex) { Console.Error.WriteLine(ErrorFormatter.FormatText(ex, parseResult.GetValue(verboseOption))); Environment.ExitCode = 1; } +}); +mailCommand.Subcommands.Add(replyCommand); + +// mail forward +var fwdMsgIdArg = new Argument("messageId") { Description = "Message ID to forward", DefaultValueFactory = _ => null! }; +var forwardCommand = new Command("forward", "Create a forward draft"); +var fwdToOption = new Option("--to") { Description = "Forward to address(es), comma-separated" }; +var fwdCommentOption = new Option("--comment") { Description = "Add a comment" }; +var fwdBodyFileOption = new Option("--body-file") { Description = "Read comment from file" }; +var fwdYesOption = new Option("--yes") { Description = "Skip confirmation" }; +forwardCommand.Arguments.Add(fwdMsgIdArg); +forwardCommand.Options.Add(fwdToOption); +forwardCommand.Options.Add(fwdCommentOption); +forwardCommand.Options.Add(fwdBodyFileOption); +forwardCommand.Options.Add(fwdYesOption); + +forwardCommand.SetAction(async (parseResult) => +{ + var acct = parseResult.GetValue(accountOption); + var asEmail = parseResult.GetValue(asOption); + var fmt = parseResult.GetValue(formatOption); + var isJson = parseResult.GetValue(jsonOption); + var outFile = parseResult.GetValue(outputOption); + var msgId = LastResults.ResolveId(parseResult.GetValue(fwdMsgIdArg)); + var to = parseResult.GetValue(fwdToOption); + var comment = parseResult.GetValue(fwdCommentOption) ?? ""; + var bodyFile = parseResult.GetValue(fwdBodyFileOption); + + if (string.IsNullOrEmpty(msgId)) + { + Console.Error.WriteLine("Error: messageId is required"); + Environment.ExitCode = 1; + return; + } + if (string.IsNullOrEmpty(to)) + { + Console.Error.WriteLine("Error: --to is required"); + Environment.ExitCode = 1; + return; + } + + try + { + AssertWriteAllowed(acct, "forward"); + var (client, accountEmail, _) = BuildClientWithEmail(acct, asEmail); + + if (!string.IsNullOrEmpty(bodyFile) && File.Exists(bodyFile)) + comment = await File.ReadAllTextAsync(bodyFile); + + // Resolve "me" and aliases in the --to value + var resolvedTokens = to.Split(',') + .Select(t => ResolveRecipientToken(t.Trim(), accountEmail)); + var resolvedTo = string.Join(",", resolvedTokens); + var result = await MailService.CreateForwardDraftAsync(client, msgId, resolvedTo, comment); + await RenderOutput(result, "generic", fmt, isJson, outFile); + } + catch (Exception ex) { Console.Error.WriteLine(ErrorFormatter.FormatText(ex, parseResult.GetValue(verboseOption))); Environment.ExitCode = 1; } +}); +mailCommand.Subcommands.Add(forwardCommand); + +// mail move +var moveMsgIdArg = new Argument("messageId") { Description = "Message ID to move", DefaultValueFactory = _ => null! }; +var moveCommand = new Command("move", "Move a message to a folder"); +var moveFolderOption = new Option("--folder") { Description = "Destination folder name or ID" }; +var moveYesOption = new Option("--yes") { Description = "Skip confirmation" }; +moveCommand.Arguments.Add(moveMsgIdArg); +moveCommand.Options.Add(moveFolderOption); +moveCommand.Options.Add(moveYesOption); + +moveCommand.SetAction(async (parseResult) => +{ + var acct = parseResult.GetValue(accountOption); + var asEmail = parseResult.GetValue(asOption); + var fmt = parseResult.GetValue(formatOption); + var isJson = parseResult.GetValue(jsonOption); + var outFile = parseResult.GetValue(outputOption); + var msgId = LastResults.ResolveId(parseResult.GetValue(moveMsgIdArg)); + var folder = parseResult.GetValue(moveFolderOption); + + if (string.IsNullOrEmpty(msgId)) + { + Console.Error.WriteLine("Error: messageId is required"); + Environment.ExitCode = 1; + return; + } + if (string.IsNullOrEmpty(folder)) + { + Console.Error.WriteLine("Error: --folder is required"); + Environment.ExitCode = 1; + return; + } + + try + { + AssertWriteAllowed(acct, "move message"); + var client = BuildClient(acct, asEmail); + var result = await MailService.MoveMessageAsync(client, msgId, folder); + await RenderOutput(result, "generic", fmt, isJson, outFile); + } + catch (Exception ex) { Console.Error.WriteLine(ErrorFormatter.FormatText(ex, parseResult.GetValue(verboseOption))); Environment.ExitCode = 1; } +}); +mailCommand.Subcommands.Add(moveCommand); + +// mail flag +var flagMsgIdArg = new Argument("messageId") { Description = "Message ID to flag", DefaultValueFactory = _ => null! }; +var flagCommand = new Command("flag", "Flag or unflag a message"); +var unflagOption = new Option("--unflag") { Description = "Remove flag" }; +flagCommand.Arguments.Add(flagMsgIdArg); +flagCommand.Options.Add(unflagOption); + +flagCommand.SetAction(async (parseResult) => +{ + var acct = parseResult.GetValue(accountOption); + var asEmail = parseResult.GetValue(asOption); + var fmt = parseResult.GetValue(formatOption); + var isJson = parseResult.GetValue(jsonOption); + var outFile = parseResult.GetValue(outputOption); + var msgId = LastResults.ResolveId(parseResult.GetValue(flagMsgIdArg)); + var unflag = parseResult.GetValue(unflagOption); + + if (string.IsNullOrEmpty(msgId)) + { + Console.Error.WriteLine("Error: messageId is required"); + Environment.ExitCode = 1; + return; + } + + try + { + AssertWriteAllowed(acct, "flag message"); + var client = BuildClient(acct, asEmail); + var flagged = !unflag; + await MailService.FlagMessageAsync(client, msgId, flagged); + await RenderOutput(ToJsonElement(new SuccessResponse(true, MessageId: msgId, Flagged: flagged), OutlookCliJsonContext.Default.SuccessResponse), "generic", fmt, isJson, outFile); + } + catch (Exception ex) { Console.Error.WriteLine(ErrorFormatter.FormatText(ex, parseResult.GetValue(verboseOption))); Environment.ExitCode = 1; } +}); +mailCommand.Subcommands.Add(flagCommand); + +// mail mark-read +var markReadMsgIdArg = new Argument("messageId") { Description = "Message ID to mark", DefaultValueFactory = _ => null! }; +var markReadCommand = new Command("mark-read", "Mark a message as read or unread"); +var markUnreadOption = new Option("--unread") { Description = "Mark as unread instead" }; +markReadCommand.Arguments.Add(markReadMsgIdArg); +markReadCommand.Options.Add(markUnreadOption); + +markReadCommand.SetAction(async (parseResult) => +{ + var acct = parseResult.GetValue(accountOption); + var asEmail = parseResult.GetValue(asOption); + var fmt = parseResult.GetValue(formatOption); + var isJson = parseResult.GetValue(jsonOption); + var outFile = parseResult.GetValue(outputOption); + var msgId = LastResults.ResolveId(parseResult.GetValue(markReadMsgIdArg)); + var markUnread = parseResult.GetValue(markUnreadOption); + + if (string.IsNullOrEmpty(msgId)) + { + Console.Error.WriteLine("Error: messageId is required"); + Environment.ExitCode = 1; + return; + } + + try + { + AssertWriteAllowed(acct, "mark message read/unread"); + var client = BuildClient(acct, asEmail); + var isRead = !markUnread; + await MailService.MarkReadAsync(client, msgId, isRead); + await RenderOutput(ToJsonElement(new SuccessResponse(true, MessageId: msgId, IsRead: isRead), OutlookCliJsonContext.Default.SuccessResponse), "generic", fmt, isJson, outFile); + } + catch (Exception ex) { Console.Error.WriteLine(ErrorFormatter.FormatText(ex, parseResult.GetValue(verboseOption))); Environment.ExitCode = 1; } +}); +mailCommand.Subcommands.Add(markReadCommand); + +// mail send +var sendMsgIdArg = new Argument("messageId") { Description = "Draft message ID to send", DefaultValueFactory = _ => null! }; +var sendCommand = new Command("send", "Send an existing draft"); +var sendYesOption = new Option("--yes") { Description = "Skip confirmation" }; +sendCommand.Arguments.Add(sendMsgIdArg); +sendCommand.Options.Add(sendYesOption); + +sendCommand.SetAction(async (parseResult) => +{ + var acct = parseResult.GetValue(accountOption); + var asEmail = parseResult.GetValue(asOption); + var fmt = parseResult.GetValue(formatOption); + var isJson = parseResult.GetValue(jsonOption); + var outFile = parseResult.GetValue(outputOption); + var msgId = LastResults.ResolveId(parseResult.GetValue(sendMsgIdArg)); + + if (string.IsNullOrEmpty(msgId)) + { + Console.Error.WriteLine("Error: messageId is required"); + Environment.ExitCode = 1; + return; + } + + try + { + AssertWriteAllowed(acct, "send draft"); + var client = BuildClient(acct, asEmail); + await MailService.SendDraftAsync(client, msgId); + await RenderOutput(NodeToElement(new JsonObject { ["success"] = true, ["messageId"] = msgId, ["sent"] = true }), "generic", fmt, isJson, outFile); + } + catch (Exception ex) { Console.Error.WriteLine(ErrorFormatter.FormatText(ex, parseResult.GetValue(verboseOption))); Environment.ExitCode = 1; } +}); +mailCommand.Subcommands.Add(sendCommand); + +// mail delete +var deleteCommand = new Command("delete", "Delete a message (soft-delete to Deleted Items)"); +var deleteMsgIdArg = new Argument("messageId") { Description = "Message ID or short ID", Arity = ArgumentArity.ZeroOrOne }; +var deleteYesOption = new Option("--yes") { Description = "Skip confirmation" }; +deleteCommand.Arguments.Add(deleteMsgIdArg); +deleteCommand.Options.Add(deleteYesOption); + +deleteCommand.SetAction(async (parseResult) => +{ + var acct = parseResult.GetValue(accountOption); + var asEmail = parseResult.GetValue(asOption); + var fmt = parseResult.GetValue(formatOption); + var isJson = parseResult.GetValue(jsonOption); + var outFile = parseResult.GetValue(outputOption); + var msgId = LastResults.ResolveId(parseResult.GetValue(deleteMsgIdArg)); + + if (string.IsNullOrEmpty(msgId)) + { + Console.Error.WriteLine("Error: messageId is required"); + Environment.ExitCode = 1; + return; + } + + try + { + AssertWriteAllowed(acct, "delete message"); + var client = BuildClient(acct, asEmail); + await MailService.DeleteMessageAsync(client, msgId); + await RenderOutput(NodeToElement(new JsonObject { ["success"] = true, ["messageId"] = msgId, ["deleted"] = true }), "generic", fmt, isJson, outFile); + } + catch (Exception ex) { Console.Error.WriteLine(ErrorFormatter.FormatText(ex, parseResult.GetValue(verboseOption))); Environment.ExitCode = 1; } +}); +mailCommand.Subcommands.Add(deleteCommand); + +// mail attachments +var attMsgIdArg = new Argument("messageId") { Description = "Message ID or short ID" }; +var attachmentsCommand = new Command("attachments", "List attachments on a message"); +attachmentsCommand.Arguments.Add(attMsgIdArg); +attachmentsCommand.SetAction(async (parseResult) => +{ + var acct = parseResult.GetValue(accountOption); + var asEmail = parseResult.GetValue(asOption); + var fmt = parseResult.GetValue(formatOption); + var isJson = parseResult.GetValue(jsonOption); + var outFile = parseResult.GetValue(outputOption); + var msgId = LastResults.ResolveId(parseResult.GetValue(attMsgIdArg)); + + try + { + var client = BuildClient(acct, asEmail); + var attachments = await MailService.ListAttachmentsAsync(client, msgId); + await RenderOutput(attachments, "attachmentList", fmt, isJson, outFile); + } + catch (Exception ex) { Console.Error.WriteLine(ErrorFormatter.FormatText(ex, parseResult.GetValue(verboseOption))); Environment.ExitCode = 1; } +}); +mailCommand.Subcommands.Add(attachmentsCommand); + +// mail download-attachment +var dlMsgIdArg = new Argument("messageId") { Description = "Message ID or short ID" }; +var dlAttIdArg = new Argument("attachmentId") { Description = "Attachment ID" }; +var outputDirOption = new Option("--output-dir") { Description = "Directory to save attachment", DefaultValueFactory = _ => "." }; +var downloadAttachmentCommand = new Command("download-attachment", "Download an attachment to a file"); +downloadAttachmentCommand.Arguments.Add(dlMsgIdArg); +downloadAttachmentCommand.Arguments.Add(dlAttIdArg); +downloadAttachmentCommand.Options.Add(outputDirOption); +downloadAttachmentCommand.SetAction(async (parseResult) => +{ + var acct = parseResult.GetValue(accountOption); + var asEmail = parseResult.GetValue(asOption); + var msgId = LastResults.ResolveId(parseResult.GetValue(dlMsgIdArg)); + var attId = parseResult.GetValue(dlAttIdArg)!; + var dir = parseResult.GetValue(outputDirOption)!; + + try + { + var client = BuildClient(acct, asEmail); + var attachment = await MailService.GetAttachmentAsync(client, msgId, attId); + + if (!attachment.HasValue || + !attachment.Value.TryGetProperty("contentBytes", out var contentProp)) + { + Console.Error.WriteLine("Error: Attachment has no downloadable content."); + Environment.ExitCode = 1; + return; + } + + Directory.CreateDirectory(dir); + var name = attachment.Value.TryGetProperty("name", out var nameProp) + ? nameProp.GetString() ?? $"attachment-{attId}" + : $"attachment-{attId}"; + var filePath = Path.Combine(dir, name); + var bytes = Convert.FromBase64String(contentProp.GetString()!); + await File.WriteAllBytesAsync(filePath, bytes); + + var size = attachment.Value.TryGetProperty("size", out var sizeProp) ? sizeProp.GetInt64() : bytes.Length; + Console.WriteLine($"Downloaded: {filePath} ({FormatFileSize(size)})"); + } + catch (Exception ex) { Console.Error.WriteLine(ErrorFormatter.FormatText(ex, parseResult.GetValue(verboseOption))); Environment.ExitCode = 1; } +}); +mailCommand.Subcommands.Add(downloadAttachmentCommand); + +rootCommand.Subcommands.Add(mailCommand); + +// ════════════════════════════════════════════════════════ +// CALENDAR COMMANDS +// ════════════════════════════════════════════════════════ + +var calendarCommand = new Command("calendar", "Calendar operations"); + +// Shared calendar options +var timezoneOption = new Option("--timezone") { Description = "Timezone (e.g. America/Los_Angeles)" }; + +// calendar today +var todayCommand = new Command("today", "Show today's events"); +todayCommand.Options.Add(timezoneOption); + +todayCommand.SetAction(async (parseResult) => +{ + var acct = parseResult.GetValue(accountOption); + var asEmail = parseResult.GetValue(asOption); + var fmt = parseResult.GetValue(formatOption); + var isJson = parseResult.GetValue(jsonOption); + var outFile = parseResult.GetValue(outputOption); + var tz = parseResult.GetValue(timezoneOption); + + try + { + var client = BuildClient(acct, asEmail); + var events = await CalendarService.GetTodayEventsAsync(client, tz); + await RenderOutput(events, "eventList", fmt, isJson, outFile, "Today"); + } + catch (Exception ex) { Console.Error.WriteLine(ErrorFormatter.FormatText(ex, parseResult.GetValue(verboseOption))); Environment.ExitCode = 1; } +}); +calendarCommand.Subcommands.Add(todayCommand); + +// calendar week +var weekCommand = new Command("week", "Show this week's events"); +var weekTzOption = new Option("--timezone") { Description = "Timezone" }; +weekCommand.Options.Add(weekTzOption); + +weekCommand.SetAction(async (parseResult) => +{ + var acct = parseResult.GetValue(accountOption); + var asEmail = parseResult.GetValue(asOption); + var fmt = parseResult.GetValue(formatOption); + var isJson = parseResult.GetValue(jsonOption); + var outFile = parseResult.GetValue(outputOption); + var tz = parseResult.GetValue(weekTzOption); + + try + { + var client = BuildClient(acct, asEmail); + var events = await CalendarService.GetWeekEventsAsync(client, tz); + await RenderOutput(events, "eventList", fmt, isJson, outFile, "This Week"); + } + catch (Exception ex) { Console.Error.WriteLine(ErrorFormatter.FormatText(ex, parseResult.GetValue(verboseOption))); Environment.ExitCode = 1; } +}); +calendarCommand.Subcommands.Add(weekCommand); + +// calendar range +var rangeCommand = new Command("range", "Show events in a date range"); +var rangeStartOption = new Option("--start") { Description = "Start date (ISO 8601)" }; +var rangeEndOption = new Option("--end") { Description = "End date (ISO 8601)" }; +var rangeTzOption = new Option("--timezone") { Description = "Timezone" }; +rangeCommand.Options.Add(rangeStartOption); +rangeCommand.Options.Add(rangeEndOption); +rangeCommand.Options.Add(rangeTzOption); + +rangeCommand.SetAction(async (parseResult) => +{ + var acct = parseResult.GetValue(accountOption); + var asEmail = parseResult.GetValue(asOption); + var fmt = parseResult.GetValue(formatOption); + var isJson = parseResult.GetValue(jsonOption); + var outFile = parseResult.GetValue(outputOption); + var start = parseResult.GetValue(rangeStartOption); + var end = parseResult.GetValue(rangeEndOption); + var tz = parseResult.GetValue(rangeTzOption); + + if (string.IsNullOrEmpty(start) || string.IsNullOrEmpty(end)) + { + Console.Error.WriteLine("Error: --start and --end are required"); + Environment.ExitCode = 1; + return; + } + + try + { + var client = BuildClient(acct, asEmail); + var events = await CalendarService.GetEventsInRangeAsync(client, start, end, tz); + await RenderOutput(events, "eventList", fmt, isJson, outFile, $"{start} to {end}"); + } + catch (Exception ex) { Console.Error.WriteLine(ErrorFormatter.FormatText(ex, parseResult.GetValue(verboseOption))); Environment.ExitCode = 1; } +}); +calendarCommand.Subcommands.Add(rangeCommand); + +// calendar view +var viewEventIdArg = new Argument("eventId") { Description = "Event ID to view", DefaultValueFactory = _ => null! }; +var viewCalCommand = new Command("view", "View event details"); +viewCalCommand.Arguments.Add(viewEventIdArg); + +viewCalCommand.SetAction(async (parseResult) => +{ + var acct = parseResult.GetValue(accountOption); + var asEmail = parseResult.GetValue(asOption); + var fmt = parseResult.GetValue(formatOption); + var isJson = parseResult.GetValue(jsonOption); + var outFile = parseResult.GetValue(outputOption); + var eventId = parseResult.GetValue(viewEventIdArg); + + if (string.IsNullOrEmpty(eventId)) + { + Console.Error.WriteLine("Error: eventId is required"); + Environment.ExitCode = 1; + return; + } + + try + { + var client = BuildClient(acct, asEmail); + var evt = await CalendarService.GetEventAsync(client, eventId); + await RenderOutput(evt, "eventDetail", fmt, isJson, outFile); + } + catch (Exception ex) { Console.Error.WriteLine(ErrorFormatter.FormatText(ex, parseResult.GetValue(verboseOption))); Environment.ExitCode = 1; } +}); +calendarCommand.Subcommands.Add(viewCalCommand); + +// calendar list-calendars +var listCalendarsCommand = new Command("list-calendars", "List all calendars"); +listCalendarsCommand.SetAction(async (parseResult) => +{ + var acct = parseResult.GetValue(accountOption); + var asEmail = parseResult.GetValue(asOption); + var fmt = parseResult.GetValue(formatOption); + var isJson = parseResult.GetValue(jsonOption); + var outFile = parseResult.GetValue(outputOption); + + try + { + var client = BuildClient(acct, asEmail); + var calendars = await CalendarService.ListCalendarsAsync(client); + await RenderOutput(calendars, "calendarList", fmt, isJson, outFile); + } + catch (Exception ex) { Console.Error.WriteLine(ErrorFormatter.FormatText(ex, parseResult.GetValue(verboseOption))); Environment.ExitCode = 1; } +}); +calendarCommand.Subcommands.Add(listCalendarsCommand); + +// calendar create +var createEventCommand = new Command("create", "Create a calendar event"); +var createSubjectOption = new Option("--subject") { Description = "Event subject" }; +var createStartOption = new Option("--start") { Description = "Start date/time (ISO 8601)" }; +var createEndOption = new Option("--end") { Description = "End date/time (ISO 8601)" }; +var createTzOption = new Option("--timezone") { Description = "Timezone" }; +var createBodyOption = new Option("--body") { Description = "Event body/description" }; +var createBodyFileOption = new Option("--body-file") { Description = "Read body from file (text or HTML)" }; +var createBodyTypeOption = new Option("--body-content-type") { Description = "Body content type: Text or HTML", DefaultValueFactory = _ => "Text" }; +var createLocationOption = new Option("--location") { Description = "Event location" }; +var createAttendeesOption = new Option("--attendees") { Description = "Attendee emails (comma-separated)" }; +var createYesOption = new Option("--yes") { Description = "Skip confirmation" }; +createEventCommand.Options.Add(createSubjectOption); +createEventCommand.Options.Add(createStartOption); +createEventCommand.Options.Add(createEndOption); +createEventCommand.Options.Add(createTzOption); +createEventCommand.Options.Add(createBodyOption); +createEventCommand.Options.Add(createBodyFileOption); +createEventCommand.Options.Add(createBodyTypeOption); +createEventCommand.Options.Add(createLocationOption); +createEventCommand.Options.Add(createAttendeesOption); +createEventCommand.Options.Add(createYesOption); + +createEventCommand.SetAction(async (parseResult) => +{ + var acct = parseResult.GetValue(accountOption); + var asEmail = parseResult.GetValue(asOption); + var fmt = parseResult.GetValue(formatOption); + var isJson = parseResult.GetValue(jsonOption); + var outFile = parseResult.GetValue(outputOption); + var subject = parseResult.GetValue(createSubjectOption); + var start = parseResult.GetValue(createStartOption); + var end = parseResult.GetValue(createEndOption); + var tz = parseResult.GetValue(createTzOption) ?? TimeZoneInfo.Local.Id; + var body = parseResult.GetValue(createBodyOption); + var bodyFile = parseResult.GetValue(createBodyFileOption); + var bodyContentType = parseResult.GetValue(createBodyTypeOption); + var location = parseResult.GetValue(createLocationOption); + var attendees = parseResult.GetValue(createAttendeesOption); + + if (string.IsNullOrEmpty(subject)) + { + Console.Error.WriteLine("Error: --subject is required"); + Environment.ExitCode = 1; + return; + } + if (string.IsNullOrEmpty(start) || string.IsNullOrEmpty(end)) + { + Console.Error.WriteLine("Error: --start and --end are required"); + Environment.ExitCode = 1; + return; + } + + try + { + AssertWriteAllowed(acct, "create event"); + var client = BuildClient(acct, asEmail); + + var eventData = new JsonObject + { + ["subject"] = subject, + ["start"] = new JsonObject { ["dateTime"] = start, ["timeZone"] = tz }, + ["end"] = new JsonObject { ["dateTime"] = end, ["timeZone"] = tz } + }; + + // Resolve body content + if (!string.IsNullOrEmpty(body) || !string.IsNullOrEmpty(bodyFile)) + { + var bodyContent = body ?? ""; + if (!string.IsNullOrEmpty(bodyFile) && File.Exists(bodyFile)) + { + bodyContent = await File.ReadAllTextAsync(bodyFile); + if (bodyFile.EndsWith(".html", StringComparison.OrdinalIgnoreCase)) + bodyContentType = "HTML"; + } + eventData["body"] = new JsonObject { ["contentType"] = bodyContentType, ["content"] = bodyContent }; + } + + if (!string.IsNullOrEmpty(location)) + eventData["location"] = new JsonObject { ["displayName"] = location }; + + if (!string.IsNullOrEmpty(attendees)) + { + var attendeeArray = new JsonArray(); + foreach (var email in attendees.Split(',')) + { + var resolved = AliasManager.ResolveAlias(email.Trim()); + attendeeArray.Add((JsonNode)new JsonObject + { + ["emailAddress"] = new JsonObject { ["address"] = resolved }, + ["type"] = "required" + }); + } + eventData["attendees"] = attendeeArray; + } + + var result = await CalendarService.CreateEventAsync(client, eventData.ToJsonString()); + await RenderOutput(result, "generic", fmt, isJson, outFile); + } + catch (Exception ex) { Console.Error.WriteLine(ErrorFormatter.FormatText(ex, parseResult.GetValue(verboseOption))); Environment.ExitCode = 1; } +}); +calendarCommand.Subcommands.Add(createEventCommand); + +// calendar delete +var deleteEventCommand = new Command("delete", "Delete a calendar event"); +var deleteEventIdArg = new Argument("eventId") { Description = "Event ID to delete", Arity = ArgumentArity.ZeroOrOne }; +var deleteEventYesOption = new Option("--yes") { Description = "Skip confirmation" }; +deleteEventCommand.Arguments.Add(deleteEventIdArg); +deleteEventCommand.Options.Add(deleteEventYesOption); +deleteEventCommand.SetAction(async (parseResult) => +{ + var acct = parseResult.GetValue(accountOption); + var asEmail = parseResult.GetValue(asOption); + var fmt = parseResult.GetValue(formatOption); + var isJson = parseResult.GetValue(jsonOption); + var outFile = parseResult.GetValue(outputOption); + var eventId = parseResult.GetValue(deleteEventIdArg); + + if (string.IsNullOrEmpty(eventId)) + { + Console.Error.WriteLine("Error: eventId is required"); + Environment.ExitCode = 1; + return; + } + + try + { + AssertWriteAllowed(acct, "delete event"); + var client = BuildClient(acct, asEmail); + await CalendarService.DeleteEventAsync(client, eventId); + await RenderOutput(NodeToElement(new JsonObject { ["success"] = true, ["eventId"] = eventId, ["deleted"] = true }), "generic", fmt, isJson, outFile); + } + catch (Exception ex) { Console.Error.WriteLine(ErrorFormatter.FormatText(ex, parseResult.GetValue(verboseOption))); Environment.ExitCode = 1; } +}); +calendarCommand.Subcommands.Add(deleteEventCommand); + +rootCommand.Subcommands.Add(calendarCommand); + +// ════════════════════════════════════════════════════════ +// CONTACTS COMMANDS +// ════════════════════════════════════════════════════════ + +var contactsCommand = new Command("contacts", "Contacts and aliases"); + +// contacts search +var contactSearchQueryArg = new Argument("query") { Description = "Search query", DefaultValueFactory = _ => null! }; +var contactSearchCommand = new Command("search", "Search contacts and global address list"); +var contactTopOption = new Option("--top") { Description = "Number of results" }; +contactSearchCommand.Arguments.Add(contactSearchQueryArg); +contactSearchCommand.Options.Add(contactTopOption); + +contactSearchCommand.SetAction(async (parseResult) => +{ + var acct = parseResult.GetValue(accountOption); + var asEmail = parseResult.GetValue(asOption); + var fmt = parseResult.GetValue(formatOption); + var isJson = parseResult.GetValue(jsonOption); + var outFile = parseResult.GetValue(outputOption); + var query = parseResult.GetValue(contactSearchQueryArg); + var top = parseResult.GetValue(contactTopOption) ?? 25; + + if (string.IsNullOrEmpty(query)) + { + Console.Error.WriteLine("Error: query is required"); + Environment.ExitCode = 1; + return; + } + + try + { + var client = BuildClient(acct, asEmail); + var results = await ContactsService.SearchContactsAsync(client, query, top); + + // Convert List to a JSON array for output + var jsonArray = JsonSerializer.Serialize(results, OutlookCliJsonContext.Default.ListJsonElement); + var arrayElement = JsonDocument.Parse(jsonArray).RootElement; + await RenderOutput(arrayElement, "contactList", fmt, isJson, outFile); + } + catch (Exception ex) { Console.Error.WriteLine(ErrorFormatter.FormatText(ex, parseResult.GetValue(verboseOption))); Environment.ExitCode = 1; } +}); +contactsCommand.Subcommands.Add(contactSearchCommand); + +// contacts alias +var aliasCommand = new Command("alias", "Manage contact aliases (local shortcuts for email addresses)"); + +// alias set +var aliasNameArg = new Argument("name") { Description = "Alias name (e.g., \"fred\")" }; +var aliasEmailArg = new Argument("email") { Description = "Email address" }; +var aliasSetCommand = new Command("set", "Create or update an alias (e.g. \"fred\" → \"freddie@outlook.com\")"); +aliasSetCommand.Arguments.Add(aliasNameArg); +aliasSetCommand.Arguments.Add(aliasEmailArg); + +aliasSetCommand.SetAction(async (parseResult) => +{ + var fmt = parseResult.GetValue(formatOption); + var isJson = parseResult.GetValue(jsonOption); + var outFile = parseResult.GetValue(outputOption); + var name = parseResult.GetValue(aliasNameArg); + var email = parseResult.GetValue(aliasEmailArg); + + try + { + var (resolvedName, resolvedEmail) = AliasManager.SetAlias(name, email); + await RenderOutput(NodeToElement(new JsonObject { ["name"] = resolvedName, ["email"] = resolvedEmail }), "generic", fmt, isJson, outFile); + if (!isJson && string.IsNullOrEmpty(fmt)) + Console.WriteLine($"✓ Alias \"{resolvedName}\" → {resolvedEmail}"); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error: {ex.Message}"); + Environment.ExitCode = 1; + } +}); +aliasCommand.Subcommands.Add(aliasSetCommand); + +// alias remove +var aliasRemoveNameArg = new Argument("name") { Description = "Alias name to remove" }; +var aliasRemoveCommand = new Command("remove", "Remove an alias"); +aliasRemoveCommand.Arguments.Add(aliasRemoveNameArg); + +aliasRemoveCommand.SetAction(async (parseResult) => +{ + var fmt = parseResult.GetValue(formatOption); + var isJson = parseResult.GetValue(jsonOption); + var outFile = parseResult.GetValue(outputOption); + var name = parseResult.GetValue(aliasRemoveNameArg); + + try + { + var (resolvedName, resolvedEmail) = AliasManager.RemoveAlias(name); + await RenderOutput(NodeToElement(new JsonObject { ["name"] = resolvedName, ["email"] = resolvedEmail }), "generic", fmt, isJson, outFile); + if (!isJson && string.IsNullOrEmpty(fmt)) + Console.WriteLine($"✓ Alias \"{resolvedName}\" removed (was → {resolvedEmail})"); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error: {ex.Message}"); + Environment.ExitCode = 1; + } +}); +aliasCommand.Subcommands.Add(aliasRemoveCommand); + +// alias list +var aliasListCommand = new Command("list", "List all aliases"); +aliasListCommand.SetAction(async (parseResult) => +{ + var fmt = parseResult.GetValue(formatOption); + var isJson = parseResult.GetValue(jsonOption); + var outFile = parseResult.GetValue(outputOption); + + var aliases = AliasManager.ListAliases(); + var aliasArray = new JsonArray(); + foreach (var a in aliases) + aliasArray.Add((JsonNode)new JsonObject { ["name"] = a.Name, ["email"] = a.Email }); + var arrayElement = JsonDocument.Parse(aliasArray.ToJsonString()).RootElement.Clone(); + await RenderOutput(arrayElement, "aliasList", fmt, isJson, outFile); + + if (!isJson && string.IsNullOrEmpty(fmt)) + { + if (aliases.Count == 0) + { + Console.WriteLine("No aliases configured. Add one with: outlook-cli contacts alias set fred freddie@outlook.com"); + } + else + { + Console.WriteLine("\nAliases:"); + foreach (var a in aliases) + Console.WriteLine($" {a.Name} → {a.Email}"); + } + } +}); +aliasCommand.Subcommands.Add(aliasListCommand); + +contactsCommand.Subcommands.Add(aliasCommand); +rootCommand.Subcommands.Add(contactsCommand); + +// ═══════════════════════════════════════════════════════════════════════════ +// DRIVE COMMAND — OneDrive file operations +// ═══════════════════════════════════════════════════════════════════════════ + +var driveCommand = new Command("drive") { Description = "OneDrive file operations" }; + +// drive list [path] +var driveListCommand = new Command("list") { Description = "List files and folders" }; +var drivePathArg = new Argument("path") { Description = "Folder path (default: root)", Arity = ArgumentArity.ZeroOrOne }; +var driveTopOption = new Option("--top") { Description = "Max items to return", DefaultValueFactory = _ => 50 }; +driveListCommand.Arguments.Add(drivePathArg); +driveListCommand.Options.Add(driveTopOption); +driveListCommand.SetAction(async (parseResult) => +{ + var folderPath = parseResult.GetValue(drivePathArg); + var top = parseResult.GetValue(driveTopOption); + var acctAlias = parseResult.GetValue(accountOption); + var asUser = parseResult.GetValue(asOption); + var useJson = parseResult.GetValue(jsonOption); + + var client = BuildClient(acctAlias, asUser); + var result = await DriveService.ListFilesAsync(client, folderPath, top); + + if (useJson) + { + Console.WriteLine(result?.ToString() ?? "null"); + } + else if (result?.TryGetProperty("value", out var items) == true) + { + Console.WriteLine(); + Console.WriteLine(" Name Size Type Modified"); + Console.WriteLine(" " + new string('─', 75)); + foreach (var item in items.EnumerateArray()) + { + var name = item.GetProperty("name").GetString() ?? ""; + var isFolder = item.TryGetProperty("folder", out _); + var size = isFolder ? "—" : DriveService.FormatFileSize(item.TryGetProperty("size", out var s) ? s.GetInt64() : 0); + var type = isFolder ? "📁 Folder" : "📄 File "; + var modified = item.TryGetProperty("lastModifiedDateTime", out var dt) + ? DateTime.Parse(dt.GetString()!).ToShortDateString() : "—"; + Console.WriteLine($" {name,-40} {size,10} {type} {modified}"); + } + Console.WriteLine($"\n {items.GetArrayLength()} item(s)"); + } +}); +driveCommand.Subcommands.Add(driveListCommand); + +// drive upload [remotePath] +var driveUploadCommand = new Command("upload") { Description = "Upload a file to OneDrive" }; +var driveLocalFileArg = new Argument("localFile") { Description = "Local file path" }; +var driveRemotePathArg = new Argument("remotePath") { Description = "Remote path", Arity = ArgumentArity.ZeroOrOne }; +driveUploadCommand.Arguments.Add(driveLocalFileArg); +driveUploadCommand.Arguments.Add(driveRemotePathArg); +driveUploadCommand.SetAction(async (parseResult) => +{ + var localFile = parseResult.GetValue(driveLocalFileArg)!; + var remotePath = parseResult.GetValue(driveRemotePathArg); + var acctAlias = parseResult.GetValue(accountOption); + var asUser = parseResult.GetValue(asOption); + + AssertWriteAllowed(acctAlias, "upload file"); + + var content = File.ReadAllBytes(localFile); + if (content.Length > 4 * 1024 * 1024) + { + Console.Error.WriteLine("Error: File exceeds 4MB. Large file upload sessions are not yet supported."); + return; + } + + var client = BuildClient(acctAlias, asUser); + var dest = remotePath ?? Path.GetFileName(localFile); + await DriveService.UploadFileAsync(client, dest, content); + Console.WriteLine($"✓ Uploaded: {dest} ({DriveService.FormatFileSize(content.Length)})"); +}); +driveCommand.Subcommands.Add(driveUploadCommand); + +// drive download [localPath] +var driveDownloadCommand = new Command("download") { Description = "Download a file from OneDrive" }; +var driveItemIdArg = new Argument("itemId") { Description = "OneDrive item ID" }; +var driveLocalPathArg = new Argument("localPath") { Description = "Local file name", Arity = ArgumentArity.ZeroOrOne }; +var driveOutputDirOption = new Option("--output-dir") { Description = "Output directory", DefaultValueFactory = _ => "." }; +driveDownloadCommand.Arguments.Add(driveItemIdArg); +driveDownloadCommand.Arguments.Add(driveLocalPathArg); +driveDownloadCommand.Options.Add(driveOutputDirOption); +driveDownloadCommand.SetAction(async (parseResult) => +{ + var itemId = parseResult.GetValue(driveItemIdArg)!; + var localPath = parseResult.GetValue(driveLocalPathArg); + var outputDir = parseResult.GetValue(driveOutputDirOption) ?? "."; + var acctAlias = parseResult.GetValue(accountOption); + var asUser = parseResult.GetValue(asOption); + + var client = BuildClient(acctAlias, asUser); + var meta = await DriveService.GetFileMetadataAsync(client, itemId); + var fileName = localPath ?? meta?.GetProperty("name").GetString() ?? "download"; + var fileSize = meta?.TryGetProperty("size", out var sz) == true ? sz.GetInt64() : 0; + + Directory.CreateDirectory(outputDir); + var fullPath = Path.Combine(outputDir, fileName); + + var bytes = await DriveService.DownloadFileAsync(client, itemId); + File.WriteAllBytes(fullPath, bytes); + Console.WriteLine($"✓ Downloaded: {fullPath} ({DriveService.FormatFileSize(fileSize)})"); +}); +driveCommand.Subcommands.Add(driveDownloadCommand); + +// drive search +var driveSearchCommand = new Command("search") { Description = "Search for files" }; +var driveQueryArg = new Argument("query") { Description = "Search query" }; +var driveSearchTopOption = new Option("--top") { Description = "Max results", DefaultValueFactory = _ => 25 }; +driveSearchCommand.Arguments.Add(driveQueryArg); +driveSearchCommand.Options.Add(driveSearchTopOption); +driveSearchCommand.SetAction(async (parseResult) => +{ + var query = parseResult.GetValue(driveQueryArg)!; + var top = parseResult.GetValue(driveSearchTopOption); + var acctAlias = parseResult.GetValue(accountOption); + var asUser = parseResult.GetValue(asOption); + var useJson = parseResult.GetValue(jsonOption); + + var client = BuildClient(acctAlias, asUser); + var result = await DriveService.SearchFilesAsync(client, query, top); + + if (useJson) + { + Console.WriteLine(result?.ToString() ?? "null"); + } + else if (result?.TryGetProperty("value", out var items) == true) + { + Console.WriteLine($"\n {items.GetArrayLength()} result(s) for \"{query}\":\n"); + foreach (var item in items.EnumerateArray()) + { + var name = item.GetProperty("name").GetString() ?? ""; + var isFolder = item.TryGetProperty("folder", out _); + var size = isFolder ? "folder" : DriveService.FormatFileSize(item.TryGetProperty("size", out var s) ? s.GetInt64() : 0); + Console.WriteLine($" {name} ({size})"); + if (item.TryGetProperty("webUrl", out var url)) + Console.WriteLine($" {url.GetString()}"); + } + } +}); +driveCommand.Subcommands.Add(driveSearchCommand); + +// drive mkdir [parentPath] +var driveMkdirCommand = new Command("mkdir") { Description = "Create a folder" }; +var driveFolderNameArg = new Argument("name") { Description = "Folder name" }; +var driveMkdirParentArg = new Argument("parentPath") { Description = "Parent folder path", Arity = ArgumentArity.ZeroOrOne }; +driveMkdirCommand.Arguments.Add(driveFolderNameArg); +driveMkdirCommand.Arguments.Add(driveMkdirParentArg); +driveMkdirCommand.SetAction(async (parseResult) => +{ + var name = parseResult.GetValue(driveFolderNameArg)!; + var parentPath = parseResult.GetValue(driveMkdirParentArg); + var acctAlias = parseResult.GetValue(accountOption); + var asUser = parseResult.GetValue(asOption); + + AssertWriteAllowed(acctAlias, "create folder"); + + var client = BuildClient(acctAlias, asUser); + await DriveService.CreateFolderAsync(client, name, parentPath); + Console.WriteLine($"✓ Created folder: {name}"); +}); +driveCommand.Subcommands.Add(driveMkdirCommand); + +rootCommand.Subcommands.Add(driveCommand); + +// ═══════════════════════════════════════════════════════════════════════════ +// LOG COMMAND — query and summarize the operations log +// ═══════════════════════════════════════════════════════════════════════════ + +var logCommand = new Command("log") { Description = "Search and summarize the operations log" }; + +// log search +var logSearchCommand = new Command("search") { Description = "Search the operations log" }; +var logAccountOption = new Option("--account") { Description = "Filter by account alias" }; +var logOperationOption = new Option("--operation") { Description = "Filter by command/operation name" }; +var logSinceOption = new Option("--since") { Description = "Only operations after this date (ISO 8601)" }; +var logStatusOption = new Option("--status") { Description = "Filter by status (started, completed, failed)" }; +var logCorrelationOption = new Option("--correlation-id") { Description = "Find all operations with this correlation ID" }; +var logLimitOption = new Option("--limit") { Description = "Max results (default: 50)", DefaultValueFactory = _ => 50 }; + +logSearchCommand.Options.Add(logAccountOption); +logSearchCommand.Options.Add(logOperationOption); +logSearchCommand.Options.Add(logSinceOption); +logSearchCommand.Options.Add(logStatusOption); +logSearchCommand.Options.Add(logCorrelationOption); +logSearchCommand.Options.Add(logLimitOption); + +logSearchCommand.SetAction((parseResult) => +{ + var isJson = parseResult.GetValue(jsonOption) || parseResult.GetValue(formatOption) == "json"; + var acct = parseResult.GetValue(logAccountOption); + var op = parseResult.GetValue(logOperationOption); + var since = parseResult.GetValue(logSinceOption); + var status = parseResult.GetValue(logStatusOption); + var corrId = parseResult.GetValue(logCorrelationOption); + var limit = parseResult.GetValue(logLimitOption); + + try + { + var db = OperationsDb.Instance; + List results; + + if (!string.IsNullOrEmpty(corrId)) + results = db.ListByCorrelationId(corrId); + else + results = db.ListOperations(acct, op, status, since, limit); + + if (isJson) + { + Console.WriteLine(JsonSerializer.Serialize(results, OutlookCliJsonContext.Default.ListOperationRecord)); + } + else + { + Console.WriteLine(FormatOperationsTable(results)); + } + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error reading operations log: {ex.Message}"); + Console.Error.WriteLine("The database may not exist yet. Run any command first to initialize it."); + Environment.ExitCode = 1; + } +}); + +logCommand.Subcommands.Add(logSearchCommand); + +// log summary +var logSummaryCommand = new Command("summary") { Description = "Show operations summary and statistics" }; +var logDaysOption = new Option("--days") { Description = "Number of days to summarize (default: 7)", DefaultValueFactory = _ => 7 }; +logSummaryCommand.Options.Add(logDaysOption); + +logSummaryCommand.SetAction((parseResult) => +{ + var isJson = parseResult.GetValue(jsonOption) || parseResult.GetValue(formatOption) == "json"; + var days = parseResult.GetValue(logDaysOption); + + try + { + var db = OperationsDb.Instance; + var summary = db.GetSummary(days); + + if (isJson) + { + Console.WriteLine(JsonSerializer.Serialize(summary, OutlookCliJsonContext.Default.OperationsSummary)); + } + else + { + Console.WriteLine(FormatSummaryText(summary)); + } + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error reading operations log: {ex.Message}"); + Console.Error.WriteLine("The database may not exist yet. Run any command first to initialize it."); + Environment.ExitCode = 1; + } +}); + +logCommand.Subcommands.Add(logSummaryCommand); +rootCommand.Subcommands.Add(logCommand); + +// ── Log text formatters ── + +static string FormatOperationsTable(List ops) +{ + if (ops.Count == 0) return "No operations found."; + + var lines = new List { "" }; + lines.Add($"{Pad("Started", 20)} {Pad("Account", 22)} {Pad("Command", 18)} {Pad("Status", 10)} {Pad("Duration", 10)} {Pad("Correlation", 12)}"); + lines.Add(new string('─', 96)); + + foreach (var op in ops) + { + var dur = op.DurationMs != null ? $"{op.DurationMs}ms" : "-"; + lines.Add($"{Pad(op.StartedAt ?? "", 20)} {Pad(Truncate(op.Account, 20), 22)} {Pad(Truncate(op.Command, 16), 18)} {Pad(op.Status, 10)} {Pad(dur, 10)} {Pad(Truncate(op.CorrelationId ?? "", 10), 12)}"); + } + + lines.Add(""); + lines.Add($"{ops.Count} operation(s)"); + return string.Join(Environment.NewLine, lines); +} + +static string FormatSummaryText(OperationsSummary s) +{ + var lines = new List { "" }; + lines.Add(" outlook-cli operations summary"); + lines.Add($" Period: last {s.Days} day(s) (since {s.Since[..10]})"); + lines.Add(" ────────────────────────────────────────────"); + lines.Add(""); + lines.Add($" Total operations: {s.Total}"); + lines.Add($" Errors: {s.Errors} ({s.ErrorRate}%)"); + lines.Add($" Avg latency: {(s.AvgLatencyMs != null ? s.AvgLatencyMs + "ms" : "n/a")}"); + lines.Add($" Throttle events: {s.Throttled}"); + lines.Add(""); + + if (s.PerDay.Count > 0) + { + lines.Add(" Operations per day:"); + foreach (var d in s.PerDay) + { + var bar = new string('█', Math.Min((int)Math.Ceiling(d.Count / 2.0), 40)); + lines.Add($" {d.Day} {Pad(d.Count.ToString(), 5)} {bar}"); + } + lines.Add(""); + } + + if (s.TopOperations.Count > 0) + { + lines.Add(" Top commands:"); + foreach (var op in s.TopOperations) + lines.Add($" {Pad(op.Command, 25)} {op.Count}"); + lines.Add(""); + } + + if (s.PerAccount.Count > 0) + { + lines.Add(" Per-account breakdown:"); + lines.Add($" {Pad("Account", 25)} {Pad("Total", 8)} {Pad("Errors", 8)} {Pad("Avg ms", 8)}"); + lines.Add(" " + new string('─', 51)); + foreach (var a in s.PerAccount) + lines.Add($" {Pad(Truncate(a.Account, 23), 25)} {Pad(a.Total.ToString(), 8)} {Pad(a.Errors.ToString(), 8)} {Pad(a.AvgLatencyMs?.ToString() ?? "-", 8)}"); + lines.Add(""); + } + + return string.Join(Environment.NewLine, lines); +} + +static string Truncate(string str, int max) +{ + if (string.IsNullOrEmpty(str)) return ""; + return str.Length > max ? str[..(max - 1)] + "…" : str; +} + +static string Pad(string str, int width) +{ + str ??= ""; + return str.Length >= width ? str[..width] : str + new string(' ', width - str.Length); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// DOCTOR COMMAND — system health check +// ═══════════════════════════════════════════════════════════════════════════ + +var doctorCommand = new Command("doctor") { Description = "Check system health and configuration" }; +doctorCommand.SetAction((parseResult) => +{ + var fmt = parseResult.GetValue(formatOption); + var isJson = parseResult.GetValue(jsonOption); + + // Gather checks + var configDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".outlook-cli"); + var configExists = Directory.Exists(configDir); + var dbPath = OperationsDb.DefaultDbPath; + var dbExists = File.Exists(dbPath); + + int accountCount = 0; + string? defaultAccount = null; + int cacheCount = 0; + bool accountsReadable = true; + bool cachesReadable = true; + + try + { + var manager = AccountManager.Instance; + var accounts = manager.ListAccounts(); + accountCount = accounts.Count; + defaultAccount = manager.GetDefaultAlias(); + } + catch { accountsReadable = false; } + + try + { + cacheCount = configExists + ? Directory.GetFiles(configDir, "cache-*.enc").Length + : 0; + } + catch { cachesReadable = false; } + + if (isJson || (fmt != null && fmt.Equals("json", StringComparison.OrdinalIgnoreCase))) + { + // Structured JSON output matching Node.js format + var checks = new JsonArray(); + + void AddCheck(string name, string status, string message) + { + var check = new JsonObject + { + ["name"] = name, + ["status"] = status, + ["message"] = message, + }; + checks.Add((JsonNode)check); + } + + AddCheck("runtime", "ok", "dotnet (NativeAOT)"); + AddCheck("clr_version", "ok", Environment.Version.ToString()); + AddCheck("os", "ok", Environment.OSVersion.ToString()); + AddCheck("config_directory", configExists ? "ok" : "warn", configExists ? configDir : $"{configDir} (not found)"); + AddCheck("database", dbExists ? "ok" : "warn", dbExists ? "exists" : "not found"); + AddCheck("accounts", accountsReadable ? "ok" : "warn", + accountsReadable ? $"{accountCount} configured" : "unable to read"); + AddCheck("default_account", defaultAccount != null ? "ok" : "warn", + defaultAccount ?? "(none)"); + AddCheck("token_caches", cachesReadable ? "ok" : "warn", + cachesReadable ? $"{cacheCount} encrypted cache file(s)" : "unable to check"); + + var result = new JsonObject { ["checks"] = checks }; + Console.WriteLine(result.ToJsonString(new System.Text.Json.JsonSerializerOptions { WriteIndented = true })); + } + else + { + Console.WriteLine("outlook-cli doctor (C# / NativeAOT)"); + Console.WriteLine("─────────────────────────────────────"); + Console.WriteLine($" Runtime: dotnet (NativeAOT)"); + Console.WriteLine($" CLR version: {Environment.Version}"); + Console.WriteLine($" OS: {Environment.OSVersion}"); + Console.WriteLine($" Config dir: {configDir}"); + Console.WriteLine($" Config exists: {(configExists ? "✓ yes" : "✗ no")}"); + Console.WriteLine($" Database: {(dbExists ? "✓ exists" : "✗ not found")}"); + Console.WriteLine($" Accounts: {(accountsReadable ? $"{accountCount} configured" : "(unable to read)")}"); + Console.WriteLine($" Default account: {(defaultAccount ?? "(none)")}"); + Console.WriteLine($" Token caches: {(cachesReadable ? $"{cacheCount} encrypted cache file(s)" : "(unable to check)")}"); + Console.WriteLine(""); + Console.WriteLine("For detailed diagnostics, see: docs/DIAGNOSTICS.md"); + } +}); +rootCommand.Subcommands.Add(doctorCommand); + +// ═══════════════════════════════════════════════════════════════════════════ +// UPGRADE COMMAND — version check stub +// ═══════════════════════════════════════════════════════════════════════════ + +var upgradeCommand = new Command("upgrade") { Description = "Check for updates to outlook-cli" }; +upgradeCommand.SetAction((parseResult) => +{ + Console.WriteLine("outlook-cli upgrade check"); + Console.WriteLine("─────────────────────────"); + Console.WriteLine(""); + Console.WriteLine("You are using the C# NativeAOT build."); + Console.WriteLine("To check for updates, rebuild from source:"); + Console.WriteLine(""); + Console.WriteLine(" git pull"); + Console.WriteLine(" dotnet publish src/dotnet -r win-x64 --self-contained /p:PublishAot=true -o publish/win-x64"); + Console.WriteLine(""); + Console.WriteLine("For the Node.js version, run:"); + Console.WriteLine(" npm update -g outlook-cli"); +}); +rootCommand.Subcommands.Add(upgradeCommand); + +// ═══════════════════════════════════════════════════════════════════════════ +// WATCH COMMAND — multi-job delta-query based watching with rules engine +// ═══════════════════════════════════════════════════════════════════════════ + +var watchCommand = new Command("watch", "Watch for email/calendar changes (poll, fast-poll, or webhook)"); + +var watchModeOption = new Option("--mode") { Description = "Notification mode: poll (default), fast-poll, or webhook", DefaultValueFactory = _ => "poll" }; +var watchConfigOption = new Option("--config") { Description = "Path to watch config JSON file" }; +var watchValidateOption = new Option("--validate") { Description = "Validate config file without running" }; +var watchIntervalOption = new Option("--interval") { Description = "Polling interval in seconds (min: 30 for poll, 5 for fast-poll)", DefaultValueFactory = _ => 60 }; +var watchJsonlOption = new Option("--jsonl") { Description = "Emit events as JSONL to stdout" }; +var watchNoMailOption = new Option("--no-mail") { Description = "Skip mail watching (single-account mode)" }; +var watchNoCalendarOption = new Option("--no-calendar") { Description = "Skip calendar watching (single-account mode)" }; +var watchFolderOption = new Option("--folder") { Description = "Mail folder to watch (single-account mode)", DefaultValueFactory = _ => "Inbox" }; + +watchCommand.Options.Add(watchModeOption); +watchCommand.Options.Add(watchConfigOption); +watchCommand.Options.Add(watchValidateOption); +watchCommand.Options.Add(watchIntervalOption); +watchCommand.Options.Add(watchJsonlOption); +watchCommand.Options.Add(watchNoMailOption); +watchCommand.Options.Add(watchNoCalendarOption); +watchCommand.Options.Add(watchFolderOption); + +watchCommand.SetAction(async (parseResult) => +{ + var mode = parseResult.GetValue(watchModeOption) ?? "poll"; + var configPath = parseResult.GetValue(watchConfigOption); + var validate = parseResult.GetValue(watchValidateOption); + var interval = parseResult.GetValue(watchIntervalOption); + var jsonl = parseResult.GetValue(watchJsonlOption); + var noMail = parseResult.GetValue(watchNoMailOption); + var noCalendar = parseResult.GetValue(watchNoCalendarOption); + var folder = parseResult.GetValue(watchFolderOption) ?? "Inbox"; + var acct = parseResult.GetValue(accountOption); + var asEmail = parseResult.GetValue(asOption); + var isJson = parseResult.GetValue(jsonOption); + var fmt = parseResult.GetValue(formatOption); + var ct = CancellationToken.None; + + // Determine JSONL mode + if (isJson || fmt == "json") jsonl = true; + + // Validate mode + if (mode != "poll" && mode != "fast-poll" && mode != "webhook") + { + Console.Error.WriteLine($"⚠️ Unknown mode \"{mode}\". Use: poll, fast-poll, or webhook."); + Environment.ExitCode = 1; + return; + } + + if (mode == "webhook") + { + Console.Error.WriteLine("⚠️ Webhook mode is not yet implemented in the C# runtime. Use Node.js: outlook-cli watch --mode webhook"); + Environment.ExitCode = 1; + return; + } + + if (!string.IsNullOrEmpty(configPath)) + { + // Config-based multi-job mode + try + { + var config = WatchConfig.Load(configPath); + if (validate) + { + Console.Error.WriteLine($"✅ Watch config is valid ({config.Watches.Count} watch job(s))."); + return; + } + + var manager = new WatchManager(config, alias => + { + var (account, resolvedAlias) = AccountManager.ResolveAccount(alias); + return GraphClient.CreateGraphClient(resolvedAlias, account, null); + }, jsonl); + + await manager.RunAsync(ct); + } + catch (Exception ex) + { + Console.Error.WriteLine($"❌ {ex.Message}"); + Environment.ExitCode = 1; + } + } + else + { + // Single-account backward-compatible mode + var isFastPoll = mode == "fast-poll"; + var minInterval = isFastPoll ? 5 : 30; + if (interval < minInterval) + { + Console.Error.WriteLine($"⚠️ Minimum interval for {mode} mode is {minInterval} seconds."); + Environment.ExitCode = 1; + return; + } + + var client = BuildClient(acct, asEmail); + var store = new DeltaStore(acct ?? AccountManager.ResolveAccount(acct).Alias); + var delta = new DeltaService(client, store); + + // Adaptive polling state (fast-poll mode) + const int FastPollMin = 5; + const double BackoffMultiplier = 1.5; + var currentInterval = isFastPoll ? FastPollMin : interval; + + var intervalLabel = isFastPoll ? $"{FastPollMin}–{interval}s (adaptive)" : $"{interval}s"; + Console.Error.WriteLine($"Watching in {mode} mode every {intervalLabel} (Ctrl+C to stop)..."); + + // Initial sync (suppress output) + try + { + if (!noMail) await delta.SyncMailAsync(folder, ct); + if (!noCalendar) await delta.SyncCalendarAsync(ct); + } + catch (OperationCanceledException) { return; } + catch (Exception ex) + { + Console.Error.WriteLine(ErrorFormatter.FormatText(ex, false)); + Environment.ExitCode = 1; + return; + } + Console.Error.WriteLine("Initial sync complete. Watching for changes..."); + + while (!ct.IsCancellationRequested) + { + try { await Task.Delay(currentInterval * 1000, ct); } + catch (OperationCanceledException) { break; } + + var changesDetected = false; + + try + { + if (!noMail) + { + var mailResult = await delta.SyncMailAsync(folder, ct); + if (mailResult.Changed.Count > 0) changesDetected = true; + foreach (var item in mailResult.Changed) + { + if (jsonl) + { + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream); + writer.WriteStartObject(); + writer.WriteString("type", "mail.changed"); + writer.WriteString("timestamp", DateTime.UtcNow.ToString("o")); + writer.WritePropertyName("data"); + item.WriteTo(writer); + writer.WriteEndObject(); + writer.Flush(); + Console.Out.WriteLine(System.Text.Encoding.UTF8.GetString(stream.ToArray())); + } + else + { + var subject = item.TryGetProperty("subject", out var s) ? s.GetString() : "(no subject)"; + Console.Error.WriteLine($" 📧 {subject}"); + } + } + } + + if (!noCalendar) + { + var calResult = await delta.SyncCalendarAsync(ct); + if (calResult.Changed.Count > 0) changesDetected = true; + foreach (var item in calResult.Changed) + { + if (jsonl) + { + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream); + writer.WriteStartObject(); + writer.WriteString("type", "calendar.changed"); + writer.WriteString("timestamp", DateTime.UtcNow.ToString("o")); + writer.WritePropertyName("data"); + item.WriteTo(writer); + writer.WriteEndObject(); + writer.Flush(); + Console.Out.WriteLine(System.Text.Encoding.UTF8.GetString(stream.ToArray())); + } + else + { + var subject = item.TryGetProperty("subject", out var s) ? s.GetString() : "(no subject)"; + Console.Error.WriteLine($" 📅 {subject}"); + } + } + } + } + catch (OperationCanceledException) { break; } + catch (Exception ex) + { + Console.Error.WriteLine($" ⚠ Sync error: {ex.Message}"); + } + + // Adaptive polling (fast-poll mode) + if (isFastPoll) + { + currentInterval = changesDetected + ? FastPollMin + : Math.Min(interval, (int)Math.Round(currentInterval * BackoffMultiplier)); + } + } + + Console.Error.WriteLine("\nWatch stopped."); + } +}); + +rootCommand.Subcommands.Add(watchCommand); +mailCommand.Subcommands.Add(watchCommand); + +// ── Helper: resolve a single recipient token, handling "me" keyword ── +static string ResolveRecipientToken(string token, string? accountEmail) +{ + var trimmed = token.Trim(); + if (trimmed.Contains('@')) return trimmed; + + if (trimmed.Equals("me", StringComparison.OrdinalIgnoreCase)) + { + if (!string.IsNullOrEmpty(accountEmail)) return accountEmail; + throw new InvalidOperationException( + "\"me\" cannot be resolved — no email on file for this account. " + + "Run `outlook-cli auth login` first, or use a full email address."); + } + + return AliasManager.ResolveAlias(trimmed); +} + +// ── Helper: parse comma-separated recipients ── +static JsonArray ParseRecipients(string value, string? accountEmail = null) +{ + var arr = new JsonArray(); + foreach (var token in value.Split(',')) + { + var resolved = ResolveRecipientToken(token, accountEmail); + arr.Add((JsonNode)new JsonObject + { + ["emailAddress"] = new JsonObject { ["address"] = resolved.Trim() } + }); + } + return arr; +} + +// ── Run the CLI with global error handling ── +try +{ + var result = await rootCommand.Parse(args).InvokeAsync(); + // System.CommandLine returns 0 from InvokeAsync() even when handlers set + // Environment.ExitCode = 1. Respect both to ensure non-zero exit codes + // propagate to callers (tests, CI, scripts). + return result != 0 ? result : Environment.ExitCode; +} +catch (OutlookCliException ex) +{ + var verbose = args.Contains("--verbose"); + if (args.Contains("--json")) + Console.Error.WriteLine(ErrorFormatter.FormatJson(ex)); + else + Console.Error.WriteLine(ErrorFormatter.FormatText(ex, verbose)); + return 1; +} +catch (HttpRequestException ex) +{ + // Network errors that escaped command-level handlers + Console.Error.WriteLine($"❌ Network error: {ex.Message}"); + Console.Error.WriteLine(" Fix: Check your internet connection and try again."); + if (args.Contains("--verbose")) + Console.Error.WriteLine($" Stack: {ex.StackTrace}"); + return 2; +} +catch (TaskCanceledException ex) when (!ex.CancellationToken.IsCancellationRequested) +{ + Console.Error.WriteLine("❌ Request timed out."); + Console.Error.WriteLine(" Fix: Check your internet connection and try again."); + return 2; +} +catch (OperationCanceledException) +{ + return 0; // Ctrl+C +} +catch (Exception ex) +{ + var verbose = args.Contains("--verbose"); + if (args.Contains("--json") || args.Contains("--format") && args.Contains("json")) + { + Console.Error.WriteLine(ErrorFormatter.FormatJson(ex)); + } + else + { + Console.Error.WriteLine($"❌ {ex.Message}"); + if (verbose) + Console.Error.WriteLine($" Stack: {ex.StackTrace}"); + else + Console.Error.WriteLine(" Run with --verbose for more details."); + } + return 2; +} diff --git a/src/dotnet/Security/CryptoService.cs b/src/dotnet/Security/CryptoService.cs new file mode 100644 index 0000000..b71177a --- /dev/null +++ b/src/dotnet/Security/CryptoService.cs @@ -0,0 +1,150 @@ +/// +/// AES-256-GCM encryption for token cache files. +/// +/// Binary format (stored as a single byte array): +/// [salt: 32 bytes] [iv: 12 bytes] [authTag: 16 bytes] [ciphertext: variable] +/// +/// Key derivation: PBKDF2 with SHA-512, 310,000 iterations (OWASP 2023 recommendation). +/// Each Encrypt() call generates a fresh random salt and IV, so identical plaintext +/// produces different ciphertext every time. +/// +/// GCM's authentication tag (authTag) provides integrity — if any byte of the +/// encrypted file is modified, decryption will fail rather than silently returning garbage. +/// +/// INTEROPERABILITY: The binary format is identical to the Node.js implementation +/// (crypto.js), so cache files encrypted by either version can be decrypted by the other. +/// + +using System.Security.Cryptography; +using System.Text; + +namespace OutlookCli.Security; + +public static class CryptoService +{ + private const int IvLength = 12; // 96-bit IV for GCM (NIST SP 800-38D recommended) + private const int SaltLength = 32; // 256-bit salt for PBKDF2 + private const int TagLength = 16; // 128-bit GCM authentication tag + private const int KeyLength = 32; // 256-bit key for AES-256 + private const int Pbkdf2Iterations = 310_000; // OWASP 2023 recommended minimum + + /// + /// Derive an AES-256 key from a passphrase and salt using PBKDF2-SHA512. + /// The high iteration count makes brute-force attacks impractical even + /// if the encrypted cache file is stolen. + /// + private static byte[] DeriveKey(string passphrase, byte[] salt) + { + using var pbkdf2 = new Rfc2898DeriveBytes( + Encoding.UTF8.GetBytes(passphrase), + salt, + Pbkdf2Iterations, + HashAlgorithmName.SHA512); + return pbkdf2.GetBytes(KeyLength); + } + + /// + /// Encrypt plaintext with AES-256-GCM. + /// + /// UTF-8 string to encrypt (typically serialized MSAL cache JSON). + /// Passphrase for key derivation. + /// Binary blob: salt(32) + iv(16) + authTag(16) + ciphertext. + /// Thrown when passphrase is null or empty. + public static byte[] Encrypt(string plaintext, string passphrase) + { + if (string.IsNullOrEmpty(passphrase)) + throw new ArgumentException("Passphrase is required for encryption", nameof(passphrase)); + + // Fresh random salt and IV for every encrypt call — critical for security. + // Reusing salt+IV with the same key would compromise GCM confidentiality. + var salt = RandomNumberGenerator.GetBytes(SaltLength); + var iv = RandomNumberGenerator.GetBytes(IvLength); + var key = DeriveKey(passphrase, salt); + + var plaintextBytes = Encoding.UTF8.GetBytes(plaintext); + var ciphertext = new byte[plaintextBytes.Length]; + var tag = new byte[TagLength]; + + using var aesGcm = new AesGcm(key, TagLength); + aesGcm.Encrypt(iv, plaintextBytes, ciphertext, tag); + + // Pack everything into a single buffer for file storage. + // Order matters — Decrypt() reads bytes at fixed offsets. + // Must match Node.js layout: salt + iv + tag + ciphertext + var result = new byte[SaltLength + IvLength + TagLength + ciphertext.Length]; + Buffer.BlockCopy(salt, 0, result, 0, SaltLength); + Buffer.BlockCopy(iv, 0, result, SaltLength, IvLength); + Buffer.BlockCopy(tag, 0, result, SaltLength + IvLength, TagLength); + Buffer.BlockCopy(ciphertext, 0, result, SaltLength + IvLength + TagLength, ciphertext.Length); + + return result; + } + + /// + /// Decrypt data encrypted by . + /// + /// Encrypted blob (binary). + /// Same passphrase used for encryption. + /// Decrypted UTF-8 plaintext. + /// Thrown when passphrase is null or empty. + /// Thrown when passphrase is wrong or data was tampered with. + public static string Decrypt(byte[] data, string passphrase) + { + if (string.IsNullOrEmpty(passphrase)) + throw new ArgumentException("Passphrase is required for decryption", nameof(passphrase)); + + // Validate minimum size: must have at least salt + iv + tag + var minLength = SaltLength + IvLength + TagLength; + if (data.Length < minLength) + throw new CryptographicException("Invalid encrypted data: too short"); + + // Extract components at fixed offsets (must match Encrypt() layout) + var salt = data.AsSpan(0, SaltLength).ToArray(); + var iv = data.AsSpan(SaltLength, IvLength).ToArray(); + var tag = data.AsSpan(SaltLength + IvLength, TagLength).ToArray(); + var ciphertext = data.AsSpan(SaltLength + IvLength + TagLength).ToArray(); + + // Re-derive the same key from passphrase + stored salt + var key = DeriveKey(passphrase, salt); + var plaintext = new byte[ciphertext.Length]; + + try + { + using var aesGcm = new AesGcm(key, TagLength); + aesGcm.Decrypt(iv, ciphertext, tag, plaintext); + return Encoding.UTF8.GetString(plaintext); + } + catch (CryptographicException) + { + // GCM authentication failure = wrong passphrase or tampered data. + throw new CryptographicException("Decryption failed: invalid passphrase or tampered data"); + } + } + + /// + /// Get the passphrase for encrypting a specific account's token cache. + /// + /// Priority: + /// 1. OUTLOOK_CLI_PASSPHRASE env var — use this in CI/shared environments + /// 2. Machine-derived string — zero-config convenience for personal machines + /// + /// The machine-derived passphrase ties the cache to a specific user + machine + account. + /// If you copy the cache file to another machine, it won't decrypt (by design). + /// + /// Account name, included in the derivation to prevent + /// cross-account decryption if someone copies cache files between accounts. + public static string GetPassphrase(string accountAlias = "default") + { + var envPassphrase = Environment.GetEnvironmentVariable("OUTLOOK_CLI_PASSPHRASE"); + if (!string.IsNullOrEmpty(envPassphrase)) + return envPassphrase; + + // Machine-derived passphrase — unique per user/machine/account combination. + // Must match the Node.js format: "outlook-cli:{user}@{host}:{alias}" + // Use Dns.GetHostName() instead of Environment.MachineName to preserve + // original casing — Environment.MachineName uppercases on Windows. + var host = System.Net.Dns.GetHostName(); + var user = Environment.UserName; + return $"outlook-cli:{user}@{host}:{accountAlias}"; + } +} diff --git a/src/dotnet/Security/Redactor.cs b/src/dotnet/Security/Redactor.cs new file mode 100644 index 0000000..c552fa2 --- /dev/null +++ b/src/dotnet/Security/Redactor.cs @@ -0,0 +1,145 @@ +using System.Text; +using System.Text.RegularExpressions; + +namespace OutlookCli.Security; + +/// +/// PII redaction engine for outlook-cli. +/// Detects and replaces personally identifiable information with tokenized placeholders. +/// Token mapping is in-memory only — NEVER persisted to disk. +/// +public sealed partial class Redactor +{ + private readonly Dictionary _forwardMap = new(); // original → token + private readonly Dictionary _reverseMap = new(); // token → original + private int _nextId = 1; + + /// + /// Redact PII from text. Returns redacted text with **REDACTED-N:type** tokens. + /// + public RedactionResult Redact(string? text) + { + if (string.IsNullOrEmpty(text)) + return new RedactionResult("", new Dictionary(), new Dictionary()); + + var stats = new Dictionary(); + var result = text; + + // Process patterns in priority order + result = ProcessPattern(result, EmailPattern(), "email", stats); + result = ProcessPattern(result, CreditCardPattern(), "cc", stats, ValidateCreditCard); + result = ProcessPattern(result, SsnPattern(), "ssn", stats, ValidateSsn); + result = ProcessPattern(result, PhonePattern(), "phone", stats); + result = ProcessPattern(result, IpPattern(), "ip", stats, ValidateIp); + result = ProcessPattern(result, GitHubTokenPattern(), "key", stats); + result = ProcessPattern(result, AwsKeyPattern(), "key", stats); + + return new RedactionResult(result, new Dictionary(_reverseMap), stats); + } + + /// + /// Reconstruct original text from redacted text using the internal mapping. + /// + public string Reconstruct(string? redactedText) + { + if (string.IsNullOrEmpty(redactedText)) return ""; + + var result = redactedText; + foreach (var (token, original) in _reverseMap) + { + result = result.Replace(token, original); + } + return result; + } + + /// Get the current token-to-original mapping as a dictionary. + public Dictionary GetMapping() => new(_reverseMap); + + private string Tokenize(string value, string type) + { + if (_forwardMap.TryGetValue(value, out var existing)) + return existing; + + var token = $"**REDACTED-{_nextId}:{type}**"; + _forwardMap[value] = token; + _reverseMap[token] = value; + _nextId++; + return token; + } + + private string ProcessPattern(string input, Regex pattern, string type, + Dictionary stats, Func? validator = null) + { + return pattern.Replace(input, match => + { + if (validator != null && !validator(match.Value)) + return match.Value; + + var token = Tokenize(match.Value, type); + stats[type] = stats.GetValueOrDefault(type) + 1; + return token; + }); + } + + // ── Validators ──────────────────────────────────────────────────── + + private static bool ValidateCreditCard(string match) + { + var digits = match.Replace("-", "").Replace(" ", ""); + if (digits.Length < 13 || digits.Length > 19) return false; + + // Luhn check + int sum = 0; + bool alternate = false; + for (int i = digits.Length - 1; i >= 0; i--) + { + int n = digits[i] - '0'; + if (alternate) { n *= 2; if (n > 9) n -= 9; } + sum += n; + alternate = !alternate; + } + return sum % 10 == 0; + } + + private static bool ValidateSsn(string match) + { + var area = int.Parse(match[..3]); + return area != 0 && area != 666 && area < 900; + } + + private static bool ValidateIp(string match) + { + var parts = match.Split('.').Select(int.Parse).ToArray(); + return !(parts.All(p => p == 0) || parts.All(p => p == 255)); + } + + // ── Source-generated regex patterns (NativeAOT compatible) ───── + + [GeneratedRegex(@"\b[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}\b")] + private static partial Regex EmailPattern(); + + [GeneratedRegex(@"\b(?:4[0-9]{3}|5[1-5][0-9]{2}|3[47][0-9]{2}|6(?:011|5[0-9]{2}))[- ]?[0-9]{4}[- ]?[0-9]{4}[- ]?[0-9]{1,7}\b")] + private static partial Regex CreditCardPattern(); + + [GeneratedRegex(@"\b[0-9]{3}-[0-9]{2}-[0-9]{4}\b")] + private static partial Regex SsnPattern(); + + [GeneratedRegex(@"(?:\+?1[-.\s]?)?\(?[2-9][0-9]{2}\)?[-.\s]?[0-9]{3}[-.\s]?[0-9]{4}\b")] + private static partial Regex PhonePattern(); + + [GeneratedRegex(@"\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b")] + private static partial Regex IpPattern(); + + [GeneratedRegex(@"\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{36,255}\b")] + private static partial Regex GitHubTokenPattern(); + + [GeneratedRegex(@"\bAKIA[0-9A-Z]{16}\b")] + private static partial Regex AwsKeyPattern(); +} + +/// Result of a redaction operation. +public record RedactionResult( + string Redacted, + Dictionary Mapping, + Dictionary Stats +); diff --git a/src/dotnet/Security/TokenValidator.cs b/src/dotnet/Security/TokenValidator.cs new file mode 100644 index 0000000..3ee501b --- /dev/null +++ b/src/dotnet/Security/TokenValidator.cs @@ -0,0 +1,197 @@ +/// +/// Defense-in-depth token scope validator. +/// +/// PURPOSE: Validates every token before use to ensure it doesn't contain +/// dangerous application-level scopes. This catches scenarios where: +/// - The App Registration was misconfigured with admin-granted Mail.ReadWrite.All +/// - A token was somehow obtained through a different flow +/// - The MSAL library behavior changes in a future version +/// +/// SCOPE SECURITY MODEL: +/// FORBIDDEN: +/// Mail.ReadWrite.All → Application-level access to ALL mailboxes. NEVER allowed. +/// +/// ALLOWED: +/// Mail.Send → Direct send from own account. Allowed (with confirmation prompt). +/// Mail.Send.Shared → Send on behalf of delegate. Allowed. +/// +/// TOKEN FORMATS: +/// Work/school accounts → JWT (3-part base64 token with decodable scp claim) +/// Personal accounts → Opaque token (not a JWT, cannot be decoded) +/// For opaque tokens, we validate via the MSAL result's scopes array instead. +/// + +using System.Text; +using System.Text.Json; +using Microsoft.Identity.Client; + +namespace OutlookCli.Security; + +/// +/// Result of a scope check operation. +/// +public class ScopeCheckResult +{ + /// Whether the token passed validation (no forbidden scopes found). + public bool Valid { get; set; } + /// List of forbidden scopes found, if any. + public List? Forbidden { get; set; } + /// Whether the token is opaque (non-JWT, cannot decode scopes). + public bool Opaque { get; set; } + /// All scopes found in the token. + public List Scopes { get; set; } = new(); +} + +public static class TokenValidator +{ + // Mail.ReadWrite.All (application-level access to ALL mailboxes) remains forbidden. + // Mail.Send and Mail.Send.Shared are both allowed. + private static readonly string[] ForbiddenScopes = { "Mail.ReadWrite.All" }; + + /// + /// Decode a JWT payload without verifying the signature. + /// + /// We don't need signature verification because we're inspecting our OWN token's + /// claims — we're not authenticating someone else. We just want to read the scp + /// claim to check for forbidden scopes. + /// + /// Parsed payload as a JsonElement, or null for opaque (non-JWT) tokens. + public static JsonElement? DecodeJwtPayload(string? token) + { + if (string.IsNullOrEmpty(token)) + return null; + + // JWTs have exactly 3 parts separated by dots: header.payload.signature + var parts = token.Split('.'); + if (parts.Length != 3) + return null; // Opaque token — personal Microsoft accounts return these + + try + { + // Base64url decode the payload (second part) + var payloadBase64 = parts[1] + .Replace('-', '+') + .Replace('_', '/'); + + // Pad to multiple of 4 + switch (payloadBase64.Length % 4) + { + case 2: payloadBase64 += "=="; break; + case 3: payloadBase64 += "="; break; + } + + var payloadBytes = Convert.FromBase64String(payloadBase64); + var payloadJson = Encoding.UTF8.GetString(payloadBytes); + return JsonSerializer.Deserialize(payloadJson, OutlookCliJsonContext.Default.JsonElement); + } + catch + { + return null; + } + } + + /// + /// Check if any scopes in the given list are forbidden. + /// Uses case-insensitive comparison because Microsoft may return scopes with different casing. + /// + private static List FindForbiddenScopes(IEnumerable scopes) + { + return scopes + .Where(s => ForbiddenScopes.Any(f => + string.Equals(s, f, StringComparison.OrdinalIgnoreCase))) + .ToList(); + } + + /// + /// Check if a raw token string contains any forbidden scopes. + /// + /// Raw access token string. + /// A indicating whether the token is valid. + public static ScopeCheckResult CheckScopes(string token) + { + var payload = DecodeJwtPayload(token); + if (payload == null) + { + // Opaque token — we can't check scopes from the token itself. + // The caller should use ValidateTokenScopes() with the MSAL result instead. + return new ScopeCheckResult { Valid = true, Opaque = true }; + } + + // The `scp` claim is a space-separated string of granted scopes + var scp = ""; + if (payload.Value.TryGetProperty("scp", out var scpElement)) + { + scp = scpElement.GetString() ?? ""; + } + + var scopes = string.IsNullOrEmpty(scp) + ? new List() + : scp.Split(' ', StringSplitOptions.RemoveEmptyEntries).ToList(); + + var forbidden = FindForbiddenScopes(scopes); + + return new ScopeCheckResult + { + Valid = forbidden.Count == 0, + Forbidden = forbidden.Count > 0 ? forbidden : null, + Scopes = scopes + }; + } + + /// + /// Validate token scopes from an MSAL AuthenticationResult and throw if any + /// forbidden scopes are present. + /// + /// Called by GraphClient.GetTokenAsync() on every token acquisition as defense-in-depth. + /// Checks both the MSAL scopes array AND the JWT claims (if decodable). + /// For opaque tokens (personal accounts), the scopes array is the only source of truth. + /// + /// MSAL AuthenticationResult containing scopes and access token. + /// If any forbidden scope is detected. + public static ScopeCheckResult ValidateTokenScopes(AuthenticationResult result) + { + // Check the MSAL result's scopes array + var forbidden = FindForbiddenScopes(result.Scopes); + if (forbidden.Count > 0) + { + throw new InvalidOperationException( + $"SECURITY: Token contains forbidden scope(s): {string.Join(", ", forbidden)}. " + + "This application must not have Mail.Send permission. " + + "Please re-register the Azure app without Mail.Send."); + } + + // Belt-and-suspenders: also decode and check the JWT if possible + if (!string.IsNullOrEmpty(result.AccessToken)) + { + var jwtResult = CheckScopes(result.AccessToken); + if (!jwtResult.Valid) + { + throw new InvalidOperationException( + $"SECURITY: Token contains forbidden scope(s): {string.Join(", ", jwtResult.Forbidden!)}. " + + "This application must not have Mail.Send permission. " + + "Please re-register the Azure app without Mail.Send."); + } + } + + return new ScopeCheckResult { Valid = true, Scopes = result.Scopes.ToList() }; + } + + /// + /// Validate a raw token string and throw if any forbidden scopes are present. + /// Used for backward compatibility / direct token validation without an MSAL result. + /// + /// Raw access token string. + /// If any forbidden scope is detected. + public static ScopeCheckResult ValidateTokenScopes(string token) + { + var result = CheckScopes(token); + if (!result.Valid) + { + throw new InvalidOperationException( + $"SECURITY: Token contains forbidden scope(s): {string.Join(", ", result.Forbidden!)}. " + + "This application must not have Mail.Send permission. " + + "Please re-register the Azure app without Mail.Send."); + } + return result; + } +} diff --git a/src/dotnet/VersionInfo.cs b/src/dotnet/VersionInfo.cs new file mode 100644 index 0000000..3de7025 --- /dev/null +++ b/src/dotnet/VersionInfo.cs @@ -0,0 +1,114 @@ +using System.Reflection; +using System.Text.Json; + +namespace OutlookCli; + +/// +/// Version tracking and staleness detection. +/// +/// On every CLI invocation, writes the current version and runtime to +/// ~/.outlook-cli/.last-version.json. If a newer version was previously +/// used, warns the user that their binary may be out of date. +/// +/// Reads version from the embedded src/shared/version.json resource +/// so both Node.js and .NET share the same version source. +/// +public static class VersionInfo +{ + public static string Version { get; } + public static int SchemaVersion { get; } + + private static readonly string VersionFilePath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".outlook-cli", ".last-version.json"); + + static VersionInfo() + { + try + { + using var stream = Assembly.GetExecutingAssembly() + .GetManifestResourceStream("version.json"); + if (stream != null) + { + var doc = JsonDocument.Parse(stream); + Version = doc.RootElement.GetProperty("version").GetString() ?? "0.0.0"; + SchemaVersion = doc.RootElement.GetProperty("schemaVersion").GetInt32(); + } + else + { + Version = "0.0.0"; + SchemaVersion = 1; + } + } + catch + { + Version = "0.0.0"; + SchemaVersion = 1; + } + } + + /// + /// Returns a display string like "2.0.0 (dotnet/win-x64)". + /// + public static string GetDisplayVersion() + { + var rid = System.Runtime.InteropServices.RuntimeInformation.RuntimeIdentifier; + return $"{Version} (dotnet/{rid})"; + } + + /// + /// Stamp the current version to disk and warn if a newer version was used before. + /// Called on every CLI invocation. + /// + public static void StampVersion() + { + try + { + var dir = Path.GetDirectoryName(VersionFilePath)!; + if (!Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + if (File.Exists(VersionFilePath)) + { + var prev = JsonDocument.Parse(File.ReadAllText(VersionFilePath)); + var prevVersion = prev.RootElement.GetProperty("version").GetString(); + if (prevVersion != null && CompareSemver(Version, prevVersion) < 0) + { + var prevRuntime = prev.RootElement.TryGetProperty("runtime", out var rt) + ? rt.GetString() : "unknown"; + Console.Error.WriteLine( + $"Warning: This binary is v{Version} (dotnet) but v{prevVersion} ({prevRuntime}) was used previously."); + Console.Error.WriteLine( + " You may be running an outdated version. Rebuild or upgrade."); + } + } + + File.WriteAllText(VersionFilePath, JsonSerializer.Serialize( + new Dictionary + { + ["version"] = Version, + ["runtime"] = "dotnet", + ["updatedAt"] = DateTime.UtcNow.ToString("o"), + }, + OutlookCliJsonContext.Default.DictionaryStringString)); + } + catch + { + // Non-fatal — don't block CLI usage if version file is unwritable + } + } + + private static int CompareSemver(string a, string b) + { + var pa = a.Split('.').Select(int.Parse).ToArray(); + var pb = b.Split('.').Select(int.Parse).ToArray(); + for (var i = 0; i < 3; i++) + { + var va = i < pa.Length ? pa[i] : 0; + var vb = i < pb.Length ? pb[i] : 0; + if (va < vb) return -1; + if (va > vb) return 1; + } + return 0; + } +} diff --git a/src/dotnet/Watch/ActionExecutor.cs b/src/dotnet/Watch/ActionExecutor.cs new file mode 100644 index 0000000..1c30e22 --- /dev/null +++ b/src/dotnet/Watch/ActionExecutor.cs @@ -0,0 +1,229 @@ +using System.Diagnostics; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace OutlookCli.Watch; + +/// +/// Executes watch rule actions sequentially. If any action fails, +/// remaining actions in that rule are skipped (fail-fast per rule). +/// +public class ActionExecutor +{ + private readonly OutlookCli.Graph.GraphClient _client; + + public ActionExecutor(OutlookCli.Graph.GraphClient client) + { + _client = client; + } + + /// Execute a list of actions for a matched rule. + public async Task ExecuteAsync(List actions, JsonElement eventData, + string resourceType, CancellationToken ct = default) + { + foreach (var action in actions) + { + ct.ThrowIfCancellationRequested(); + try + { + await ExecuteActionAsync(action, eventData, resourceType, ct); + } + catch (Exception ex) + { + Console.Error.WriteLine($" ⚠ Action '{action.Type}' failed: {ex.Message}"); + break; // Fail-fast: skip remaining actions in this rule + } + } + } + + private async Task ExecuteActionAsync(WatchAction action, JsonElement eventData, + string resourceType, CancellationToken ct) + { + switch (action.Type.ToLowerInvariant()) + { + case "move": + await ExecuteMoveAsync(action, eventData, ct); + break; + case "mark-read": + await ExecuteMarkReadAsync(eventData, ct); + break; + case "flag": + await ExecuteFlagAsync(eventData, true, ct); + break; + case "unflag": + await ExecuteFlagAsync(eventData, false, ct); + break; + case "run": + await ExecuteRunAsync(action, eventData, resourceType, ct); + break; + default: + Console.Error.WriteLine($" ⚠ Unknown action type: '{action.Type}'"); + break; + } + } + + private async Task ExecuteMoveAsync(WatchAction action, JsonElement eventData, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(action.Folder)) + throw new InvalidOperationException("'move' action requires a 'folder' parameter."); + + var messageId = GetStringProperty(eventData, "id"); + if (messageId == null) return; + + // Resolve folder name to ID + var folderResult = await _client.GetAsync($"{_client.UserPath}/mailFolders?$filter=displayName eq '{action.Folder}'"); + if (!folderResult.HasValue) throw new InvalidOperationException($"Could not query folders."); + var folders = folderResult.Value.GetProperty("value"); + if (folders.GetArrayLength() == 0) + throw new InvalidOperationException($"Folder '{action.Folder}' not found."); + + var folderId = folders[0].GetProperty("id").GetString(); + var body = $"{{\"destinationId\":\"{folderId}\"}}"; + await _client.PostAsync($"{_client.UserPath}/messages/{messageId}/move", body); + } + + private async Task ExecuteMarkReadAsync(JsonElement eventData, CancellationToken ct) + { + var messageId = GetStringProperty(eventData, "id"); + if (messageId == null) return; + + await _client.PatchAsync($"{_client.UserPath}/messages/{messageId}", "{\"isRead\":true}"); + } + + private async Task ExecuteFlagAsync(JsonElement eventData, bool flagged, CancellationToken ct) + { + var messageId = GetStringProperty(eventData, "id"); + if (messageId == null) return; + + var flagStatus = flagged ? "flagged" : "notFlagged"; + await _client.PatchAsync($"{_client.UserPath}/messages/{messageId}", + $"{{\"flag\":{{\"flagStatus\":\"{flagStatus}\"}}}}"); + } + + private async Task ExecuteRunAsync(WatchAction action, JsonElement eventData, + string resourceType, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(action.Command)) + throw new InvalidOperationException("'run' action requires a 'command' parameter."); + + var command = SubstituteTemplateVars(action.Command, eventData, resourceType); + + var isWindows = OperatingSystem.IsWindows(); + var psi = new ProcessStartInfo + { + FileName = isWindows ? "cmd.exe" : "/bin/sh", + Arguments = isWindows ? $"/c {command}" : $"-c {command}", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + using var process = Process.Start(psi) + ?? throw new InvalidOperationException("Failed to start process."); + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(action.Timeout); + + try + { + await process.WaitForExitAsync(cts.Token); + } + catch (OperationCanceledException) + { + try { process.Kill(entireProcessTree: true); } catch { } + throw new TimeoutException($"Command timed out after {action.Timeout}ms."); + } + + if (process.ExitCode != 0) + { + var stderr = await process.StandardError.ReadToEndAsync(ct); + throw new InvalidOperationException($"Command exited with code {process.ExitCode}: {stderr.Trim()}"); + } + } + + /// Replace {{variable}} placeholders with event data values. + public static string SubstituteTemplateVars(string template, JsonElement eventData, string resourceType) + { + return Regex.Replace(template, @"\{\{(\w+)\}\}", match => + { + var varName = match.Groups[1].Value; + var value = GetTemplateValue(eventData, resourceType, varName); + return ShellEscape(value); + }); + } + + private static string GetTemplateValue(JsonElement data, string resourceType, string varName) + { + switch (varName.ToLowerInvariant()) + { + case "message_id": + case "event_id": + return GetStringProperty(data, "id") ?? ""; + case "subject": + return GetStringProperty(data, "subject") ?? ""; + case "from": + if (data.TryGetProperty("from", out var from) && + from.TryGetProperty("emailAddress", out var ea) && + ea.TryGetProperty("address", out var addr)) + return addr.GetString() ?? ""; + return ""; + case "to": + if (data.TryGetProperty("toRecipients", out var to) && + to.GetArrayLength() > 0 && + to[0].TryGetProperty("emailAddress", out var toEa) && + toEa.TryGetProperty("address", out var toAddr)) + return toAddr.GetString() ?? ""; + return ""; + case "body_preview": + return GetStringProperty(data, "bodyPreview") ?? ""; + case "importance": + return GetStringProperty(data, "importance") ?? ""; + case "received_date": + return GetStringProperty(data, "receivedDateTime") ?? ""; + case "organizer": + if (data.TryGetProperty("organizer", out var org) && + org.TryGetProperty("emailAddress", out var orgEa) && + orgEa.TryGetProperty("address", out var orgAddr)) + return orgAddr.GetString() ?? ""; + return ""; + case "start": + return GetDateTimeProperty(data, "start") ?? ""; + case "end": + return GetDateTimeProperty(data, "end") ?? ""; + case "location": + if (data.TryGetProperty("location", out var loc) && + loc.TryGetProperty("displayName", out var locName)) + return locName.GetString() ?? ""; + return ""; + default: + return ""; + } + } + + private static string? GetStringProperty(JsonElement data, string name) + { + if (data.TryGetProperty(name, out var prop) && prop.ValueKind == JsonValueKind.String) + return prop.GetString(); + return null; + } + + private static string? GetDateTimeProperty(JsonElement data, string name) + { + if (data.TryGetProperty(name, out var prop)) + { + if (prop.ValueKind == JsonValueKind.Object && prop.TryGetProperty("dateTime", out var dt)) + return dt.GetString(); + if (prop.ValueKind == JsonValueKind.String) + return prop.GetString(); + } + return null; + } + + /// Escape a value for safe use in shell commands. + private static string ShellEscape(string value) + { + if (string.IsNullOrEmpty(value)) return "''"; + return "'" + value.Replace("'", "'\\''") + "'"; + } +} diff --git a/src/dotnet/Watch/DeltaService.cs b/src/dotnet/Watch/DeltaService.cs new file mode 100644 index 0000000..59eece9 --- /dev/null +++ b/src/dotnet/Watch/DeltaService.cs @@ -0,0 +1,227 @@ +using System.Text.Json; + +namespace OutlookCli.Watch; + +/// +/// Delta query engine for Microsoft Graph API. +/// Delta queries return only changes since the last sync, making frequent polling efficient. +/// Delta tokens are persisted to ~/.outlook-cli/delta-{alias}.json so the CLI +/// can resume across restarts. +/// +public class DeltaService +{ + private readonly OutlookCli.Graph.GraphClient _client; + private readonly DeltaStore _store; + + public DeltaService(OutlookCli.Graph.GraphClient client, DeltaStore store) + { + _client = client; + _store = store; + } + + /// + /// Execute a delta query for mail messages in a folder. + /// Returns changed/removed items since last sync. + /// + public async Task SyncMailAsync(string folder = "Inbox", CancellationToken ct = default) + { + var resourceKey = $"mail:{folder}"; + var deltaLink = _store.GetDeltaLink(resourceKey); + + string url; + if (deltaLink != null) + { + url = deltaLink; + } + else + { + var fields = "id,subject,from,receivedDateTime,bodyPreview,isRead,importance,flag,hasAttachments"; + url = $"{_client.UserPath}/mailFolders/{folder}/messages/delta?$select={fields}"; + } + + return await ExecuteDeltaAsync(url, resourceKey, ct); + } + + /// + /// Execute a delta query for calendar events (rolling 30-day window). + /// + public async Task SyncCalendarAsync(CancellationToken ct = default) + { + var resourceKey = "calendar:rolling30d"; + var deltaLink = _store.GetDeltaLink(resourceKey); + + string url; + if (deltaLink != null) + { + url = deltaLink; + } + else + { + var start = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"); + var end = DateTime.UtcNow.AddDays(30).ToString("yyyy-MM-ddTHH:mm:ssZ"); + url = $"{_client.UserPath}/calendarView/delta?startDateTime={start}&endDateTime={end}"; + } + + return await ExecuteDeltaAsync(url, resourceKey, ct); + } + + private async Task ExecuteDeltaAsync(string url, string resourceKey, CancellationToken ct) + { + var changed = new List(); + var removed = new List(); + bool isInitialSync = _store.GetDeltaLink(resourceKey) == null; + int pageCount = 0; + + try + { + while (url != null && pageCount < 50) + { + ct.ThrowIfCancellationRequested(); + pageCount++; + + var resultNullable = await _client.GetAsync(url); + if (!resultNullable.HasValue) break; + var result = resultNullable.Value; + + // Process items on this page + if (result.TryGetProperty("value", out var values) && values.ValueKind == JsonValueKind.Array) + { + foreach (var item in values.EnumerateArray()) + { + if (item.TryGetProperty("@removed", out _)) + { + var removedId = item.TryGetProperty("id", out var idProp) ? idProp.GetString() : null; + if (removedId != null) + removed.Add(removedId); + } + else + { + changed.Add(item.Clone()); + } + } + } + + // Follow pagination or save delta link + if (result.TryGetProperty("@odata.nextLink", out var nextLink)) + { + url = nextLink.GetString()!; + } + else if (result.TryGetProperty("@odata.deltaLink", out var dLink)) + { + _store.SetDeltaLink(resourceKey, dLink.GetString()!); + url = null!; + } + else + { + url = null!; + } + } + } + catch (HttpRequestException ex) when (IsDeltaTokenExpired(ex)) + { + Console.Error.WriteLine($" ⚠ Delta token expired for {resourceKey}, performing full re-sync..."); + _store.ClearDeltaLink(resourceKey); + return await ExecuteDeltaAsync( + resourceKey.StartsWith("mail:") + ? BuildMailDeltaUrl(resourceKey.Substring(5)) + : BuildCalendarDeltaUrl(), + resourceKey, ct); + } + + return new DeltaResult + { + Changed = changed, + Removed = removed, + IsInitialSync = isInitialSync, + PageCount = pageCount, + }; + } + + private string BuildMailDeltaUrl(string folder) + { + var fields = "id,subject,from,receivedDateTime,bodyPreview,isRead,importance,flag,hasAttachments"; + return $"{_client.UserPath}/mailFolders/{folder}/messages/delta?$select={fields}"; + } + + private string BuildCalendarDeltaUrl() + { + var start = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"); + var end = DateTime.UtcNow.AddDays(30).ToString("yyyy-MM-ddTHH:mm:ssZ"); + return $"{_client.UserPath}/calendarView/delta?startDateTime={start}&endDateTime={end}"; + } + + private static bool IsDeltaTokenExpired(HttpRequestException ex) + { + // Graph returns 410 Gone or 404 for expired delta tokens + var statusCode = (int?)ex.StatusCode; + return statusCode == 410 || statusCode == 404; + } +} + +/// +/// Persisted delta state for an account. +/// Maps resource keys ("mail:Inbox", "calendar:rolling30d") to delta link URLs. +/// +public class DeltaStore +{ + private readonly string _filePath; + private Dictionary _tokens; + + public DeltaStore(string accountAlias) + { + var configDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".outlook-cli"); + Directory.CreateDirectory(configDir); + _filePath = Path.Combine(configDir, $"delta-{accountAlias}.json"); + _tokens = Load(); + } + + public string? GetDeltaLink(string resourceKey) + { + return _tokens.TryGetValue(resourceKey, out var link) ? link : null; + } + + public void SetDeltaLink(string resourceKey, string deltaLink) + { + _tokens[resourceKey] = deltaLink; + Save(); + } + + public void ClearDeltaLink(string resourceKey) + { + _tokens.Remove(resourceKey); + Save(); + } + + private Dictionary Load() + { + if (!File.Exists(_filePath)) + return new Dictionary(); + + try + { + var json = File.ReadAllText(_filePath); + return JsonSerializer.Deserialize(json, OutlookCliJsonContext.Default.DictionaryStringString) + ?? new Dictionary(); + } + catch + { + return new Dictionary(); + } + } + + private void Save() + { + var json = JsonSerializer.Serialize(_tokens, OutlookCliJsonContext.Default.DictionaryStringString); + File.WriteAllText(_filePath, json); + } +} + +/// Result of a delta query — changed items, removed IDs, sync status. +public class DeltaResult +{ + public List Changed { get; set; } = new(); + public List Removed { get; set; } = new(); + public bool IsInitialSync { get; set; } + public int PageCount { get; set; } +} diff --git a/src/dotnet/Watch/RulesEngine.cs b/src/dotnet/Watch/RulesEngine.cs new file mode 100644 index 0000000..55824f5 --- /dev/null +++ b/src/dotnet/Watch/RulesEngine.cs @@ -0,0 +1,147 @@ +using System.Text.Json; + +namespace OutlookCli.Watch; + +/// +/// Evaluates watch rules against incoming mail or calendar events. +/// All conditions in a rule must match (AND logic). Empty conditions match all events. +/// +public static class RulesEngine +{ + /// + /// Evaluate all rules against an event and return matched rules. + /// + public static List EvaluateRules(JsonElement eventData, string resourceType, List rules) + { + var matched = new List(); + foreach (var rule in rules) + { + if (MatchesConditions(eventData, resourceType, rule.Conditions)) + matched.Add(rule); + } + return matched; + } + + /// + /// Check if an event matches all conditions in a rule (AND logic). + /// + public static bool MatchesConditions(JsonElement eventData, string resourceType, + Dictionary conditions) + { + if (conditions == null || conditions.Count == 0) + return true; // Empty conditions = match all + + foreach (var (key, value) in conditions) + { + if (!MatchCondition(eventData, resourceType, key, value)) + return false; + } + return true; + } + + private static bool MatchCondition(JsonElement eventData, string resourceType, string conditionKey, JsonElement conditionValue) + { + switch (conditionKey.ToLowerInvariant()) + { + // Mail conditions + case "from": + return MatchExactEmail(eventData, "from", conditionValue.GetString() ?? ""); + case "from_contains": + return MatchContainsEmail(eventData, "from", conditionValue.GetString() ?? ""); + case "subject_contains": + return MatchContains(eventData, "subject", conditionValue.GetString() ?? ""); + case "has_attachments": + return MatchBoolean(eventData, "hasAttachments", conditionValue.GetBoolean()); + case "is_read": + return MatchBoolean(eventData, "isRead", conditionValue.GetBoolean()); + case "importance": + return MatchString(eventData, "importance", conditionValue.GetString() ?? ""); + + // Calendar conditions + case "is_cancelled": + return MatchBoolean(eventData, "isCancelled", conditionValue.GetBoolean()); + case "starts_within_minutes": + return MatchStartsWithin(eventData, conditionValue.GetInt32()); + + default: + // Unknown conditions are silently ignored (don't fail the rule) + return true; + } + } + + private static bool MatchExactEmail(JsonElement data, string fieldPath, string expected) + { + var email = ExtractEmail(data, fieldPath); + return email != null && email.Equals(expected, StringComparison.OrdinalIgnoreCase); + } + + private static bool MatchContainsEmail(JsonElement data, string fieldPath, string substring) + { + var email = ExtractEmail(data, fieldPath); + if (email == null) return false; + return email.Contains(substring, StringComparison.OrdinalIgnoreCase); + } + + private static bool MatchContains(JsonElement data, string field, string substring) + { + if (data.TryGetProperty(field, out var prop) && prop.ValueKind == JsonValueKind.String) + { + var value = prop.GetString() ?? ""; + return value.Contains(substring, StringComparison.OrdinalIgnoreCase); + } + return false; + } + + private static bool MatchBoolean(JsonElement data, string field, bool expected) + { + if (data.TryGetProperty(field, out var prop) && + (prop.ValueKind == JsonValueKind.True || prop.ValueKind == JsonValueKind.False)) + { + return prop.GetBoolean() == expected; + } + return false; + } + + private static bool MatchString(JsonElement data, string field, string expected) + { + if (data.TryGetProperty(field, out var prop) && prop.ValueKind == JsonValueKind.String) + { + return (prop.GetString() ?? "").Equals(expected, StringComparison.OrdinalIgnoreCase); + } + return false; + } + + private static bool MatchStartsWithin(JsonElement data, int minutes) + { + if (data.TryGetProperty("start", out var startProp)) + { + string? startStr = null; + if (startProp.ValueKind == JsonValueKind.Object && startProp.TryGetProperty("dateTime", out var dt)) + startStr = dt.GetString(); + else if (startProp.ValueKind == JsonValueKind.String) + startStr = startProp.GetString(); + + if (startStr != null && DateTime.TryParse(startStr, out var startTime)) + { + var diff = startTime - DateTime.UtcNow; + return diff.TotalMinutes >= 0 && diff.TotalMinutes <= minutes; + } + } + return false; + } + + /// Extract email address from Graph's from/sender structure. + private static string? ExtractEmail(JsonElement data, string fieldPath) + { + if (data.TryGetProperty(fieldPath, out var fromProp)) + { + // Graph format: { "from": { "emailAddress": { "address": "..." } } } + if (fromProp.TryGetProperty("emailAddress", out var emailAddr) && + emailAddr.TryGetProperty("address", out var addr)) + { + return addr.GetString(); + } + } + return null; + } +} diff --git a/src/dotnet/Watch/ScheduleParser.cs b/src/dotnet/Watch/ScheduleParser.cs new file mode 100644 index 0000000..fe15eef --- /dev/null +++ b/src/dotnet/Watch/ScheduleParser.cs @@ -0,0 +1,89 @@ +namespace OutlookCli.Watch; + +/// +/// Parses schedule strings into polling intervals. +/// Supports duration ("30s", "5m", "1h"), cron, and time-of-day formats. +/// +public static class ScheduleParser +{ + private const int MinIntervalMs = 30_000; // 30 seconds minimum + + /// Parse a schedule string and return the interval in milliseconds. + public static ScheduleInfo Parse(string schedule) + { + if (string.IsNullOrWhiteSpace(schedule)) + throw new ArgumentException("Schedule cannot be empty."); + + schedule = schedule.Trim(); + + // Cron not yet implemented — placeholder + if (schedule.StartsWith("cron:", StringComparison.OrdinalIgnoreCase)) + throw new NotSupportedException("Cron schedules are not yet supported in the .NET implementation."); + + // Time-of-day not yet implemented + if (schedule.StartsWith("at:", StringComparison.OrdinalIgnoreCase)) + throw new NotSupportedException("Time-of-day schedules are not yet supported in the .NET implementation."); + + // Duration format: "30s", "5m", "1h", "2h30m" + var ms = ParseDuration(schedule); + if (ms < MinIntervalMs) + throw new ArgumentException($"Schedule interval must be at least 30 seconds (got {schedule})."); + + return new ScheduleInfo(ms); + } + + /// Parse a duration string (e.g., "30s", "5m", "2h30m") into milliseconds. + public static int ParseDuration(string duration) + { + int totalMs = 0; + int current = 0; + + foreach (char c in duration) + { + if (char.IsDigit(c)) + { + current = current * 10 + (c - '0'); + } + else + { + switch (char.ToLowerInvariant(c)) + { + case 's': + totalMs += current * 1000; + current = 0; + break; + case 'm': + totalMs += current * 60 * 1000; + current = 0; + break; + case 'h': + totalMs += current * 60 * 60 * 1000; + current = 0; + break; + default: + throw new ArgumentException($"Invalid duration character: '{c}' in '{duration}'."); + } + } + } + + // Trailing number without unit — treat as seconds + if (current > 0) + totalMs += current * 1000; + + if (totalMs <= 0) + throw new ArgumentException($"Invalid duration: '{duration}'."); + + return totalMs; + } +} + +/// Holds parsed schedule info. +public class ScheduleInfo +{ + public int IntervalMs { get; } + + public ScheduleInfo(int intervalMs) + { + IntervalMs = intervalMs; + } +} diff --git a/src/dotnet/Watch/WatchConfig.cs b/src/dotnet/Watch/WatchConfig.cs new file mode 100644 index 0000000..80a3bc1 --- /dev/null +++ b/src/dotnet/Watch/WatchConfig.cs @@ -0,0 +1,82 @@ +using System.Text.Json; + +namespace OutlookCli.Watch; + +/// +/// Loads and validates watch configuration JSON files. +/// Configuration defines multiple watch jobs, each with an account, resource type, +/// polling schedule, and optional automation rules. +/// +public static class WatchConfig +{ + /// Load and validate a watch config file. + public static WatchConfigData Load(string configPath) + { + if (!File.Exists(configPath)) + throw new FileNotFoundException($"Watch config not found: {configPath}"); + + var json = File.ReadAllText(configPath); + var config = JsonSerializer.Deserialize(json, OutlookCliJsonContext.Default.WatchConfigData) + ?? throw new InvalidOperationException("Watch config parsed as null"); + + Validate(config); + return config; + } + + /// Validate a watch config — required fields, unique IDs, valid schedules. + public static void Validate(WatchConfigData config) + { + if (config.Watches == null || config.Watches.Count == 0) + throw new InvalidOperationException("Watch config must contain at least one 'watches' entry."); + + var ids = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var watch in config.Watches) + { + if (string.IsNullOrWhiteSpace(watch.Id)) + throw new InvalidOperationException("Each watch entry must have an 'id'."); + if (!ids.Add(watch.Id)) + throw new InvalidOperationException($"Duplicate watch ID: '{watch.Id}'."); + if (string.IsNullOrWhiteSpace(watch.Account)) + throw new InvalidOperationException($"Watch '{watch.Id}' must specify an 'account'."); + if (watch.Resource != "mail" && watch.Resource != "calendar") + throw new InvalidOperationException($"Watch '{watch.Id}' resource must be 'mail' or 'calendar'."); + if (string.IsNullOrWhiteSpace(watch.Schedule)) + throw new InvalidOperationException($"Watch '{watch.Id}' must specify a 'schedule'."); + + // Validate schedule parses + ScheduleParser.Parse(watch.Schedule); + } + } +} + +// ── Data models ───────────────────────────────────────────────────────────── + +public class WatchConfigData +{ + public List Watches { get; set; } = new(); +} + +public class WatchEntry +{ + public string Id { get; set; } = ""; + public string Account { get; set; } = ""; + public string Resource { get; set; } = "mail"; + public string Folder { get; set; } = "Inbox"; + public string Schedule { get; set; } = "60s"; + public List Rules { get; set; } = new(); +} + +public class WatchRule +{ + public string Id { get; set; } = ""; + public Dictionary Conditions { get; set; } = new(); + public List Actions { get; set; } = new(); +} + +public class WatchAction +{ + public string Type { get; set; } = ""; + public string? Folder { get; set; } + public string? Command { get; set; } + public int Timeout { get; set; } = 30000; +} diff --git a/src/dotnet/Watch/WatchManager.cs b/src/dotnet/Watch/WatchManager.cs new file mode 100644 index 0000000..734709d --- /dev/null +++ b/src/dotnet/Watch/WatchManager.cs @@ -0,0 +1,196 @@ +using System.Text.Json; + +namespace OutlookCli.Watch; + +/// +/// Multi-job watch orchestrator. Loads a watch config, creates shared GraphClients +/// per account, and runs independent WatchJob loops concurrently with graceful shutdown. +/// +public class WatchManager +{ + private readonly WatchConfigData _config; + private readonly Func _createClient; + private readonly bool _jsonl; + + public WatchManager(WatchConfigData config, Func createClient, bool jsonl = false) + { + _config = config; + _createClient = createClient; + _jsonl = jsonl; + } + + /// Start all watch jobs and run until cancelled. + public async Task RunAsync(CancellationToken ct) + { + Console.Error.WriteLine($"Starting {_config.Watches.Count} watch job(s)..."); + + // Create shared GraphClients per unique account + var clients = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var watch in _config.Watches) + { + if (!clients.ContainsKey(watch.Account)) + clients[watch.Account] = _createClient(watch.Account); + } + + // Start all jobs concurrently + var jobs = _config.Watches.Select(watch => + { + var client = clients[watch.Account]; + var job = new WatchJob(watch, client, _jsonl); + return job.RunAsync(ct); + }).ToArray(); + + try + { + await Task.WhenAll(jobs); + } + catch (OperationCanceledException) + { + Console.Error.WriteLine("\nWatch stopped."); + } + } +} + +/// +/// A single watch job — polls one resource (mail folder or calendar) at a configured schedule, +/// evaluates rules against changes, and executes matched actions. +/// +public class WatchJob +{ + private readonly WatchEntry _watch; + private readonly OutlookCli.Graph.GraphClient _client; + private readonly DeltaService _delta; + private readonly ActionExecutor _actions; + private readonly bool _jsonl; + + public WatchJob(WatchEntry watch, OutlookCli.Graph.GraphClient client, bool jsonl) + { + _watch = watch; + _client = client; + _jsonl = jsonl; + + var store = new DeltaStore($"{watch.Account}-{watch.Id}"); + _delta = new DeltaService(client, store); + _actions = new ActionExecutor(client); + } + + public async Task RunAsync(CancellationToken ct) + { + var schedule = ScheduleParser.Parse(_watch.Schedule); + Console.Error.WriteLine($" [{_watch.Id}] Watching {_watch.Resource}/{_watch.Folder} " + + $"every {_watch.Schedule} for account '{_watch.Account}'"); + + // Initial sync (suppress events to avoid spam on first run) + try + { + if (_watch.Resource == "mail") + await _delta.SyncMailAsync(_watch.Folder, ct); + else + await _delta.SyncCalendarAsync(ct); + Console.Error.WriteLine($" [{_watch.Id}] Initial sync complete."); + } + catch (Exception ex) + { + Console.Error.WriteLine($" [{_watch.Id}] ⚠ Initial sync failed: {ex.Message}"); + } + + // Poll loop + while (!ct.IsCancellationRequested) + { + // Wait for next scheduled run (interruptible every 1s) + var waitMs = schedule.IntervalMs; + while (waitMs > 0 && !ct.IsCancellationRequested) + { + var delay = Math.Min(waitMs, 1000); + await Task.Delay(delay, ct).ConfigureAwait(false); + waitMs -= delay; + } + if (ct.IsCancellationRequested) break; + + try + { + DeltaResult result; + if (_watch.Resource == "mail") + result = await _delta.SyncMailAsync(_watch.Folder, ct); + else + result = await _delta.SyncCalendarAsync(ct); + + // Process changes + foreach (var item in result.Changed) + { + EmitEvent(_watch.Resource == "mail" ? "mail.changed" : "calendar.changed", item); + + // Evaluate rules + if (_watch.Rules.Count > 0) + { + var matched = RulesEngine.EvaluateRules(item, _watch.Resource, _watch.Rules); + foreach (var rule in matched) + { + if (!_jsonl) + Console.Error.WriteLine($" [{_watch.Id}] Rule '{rule.Id}' matched, executing {rule.Actions.Count} action(s)..."); + await _actions.ExecuteAsync(rule.Actions, item, _watch.Resource, ct); + } + } + } + + // Log removals + foreach (var removedId in result.Removed) + { + var removedDoc = JsonDocument.Parse($"{{\"id\":\"{removedId}\"}}"); + EmitEvent(_watch.Resource == "mail" ? "mail.removed" : "calendar.removed", + removedDoc.RootElement.Clone()); + } + } + catch (OperationCanceledException) { break; } + catch (Exception ex) + { + Console.Error.WriteLine($" [{_watch.Id}] ⚠ Sync error: {ex.Message}"); + } + } + } + + private void EmitEvent(string type, JsonElement data) + { + if (_jsonl) + { + var evt = new + { + type, + timestamp = DateTime.UtcNow.ToString("o"), + watchId = _watch.Id, + data = data, + }; + // Use Utf8JsonWriter for NativeAOT compatibility + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream); + writer.WriteStartObject(); + writer.WriteString("type", type); + writer.WriteString("timestamp", DateTime.UtcNow.ToString("o")); + writer.WriteString("watchId", _watch.Id); + writer.WritePropertyName("data"); + data.WriteTo(writer); + writer.WriteEndObject(); + writer.Flush(); + Console.Out.WriteLine(System.Text.Encoding.UTF8.GetString(stream.ToArray())); + } + else + { + var subject = data.TryGetProperty("subject", out var s) ? s.GetString() : "(no subject)"; + var from = ""; + if (data.TryGetProperty("from", out var f) && + f.TryGetProperty("emailAddress", out var ea) && + ea.TryGetProperty("address", out var addr)) + from = addr.GetString() ?? ""; + + if (type.Contains("removed")) + { + var id = data.TryGetProperty("id", out var idProp) ? idProp.GetString() : "?"; + Console.Error.WriteLine($" [{_watch.Id}] {type}: {id}"); + } + else + { + Console.Error.WriteLine($" [{_watch.Id}] {type}: {from} — {subject}"); + } + } + } +} diff --git a/src/node/accounts/manager.js b/src/node/accounts/manager.js new file mode 100644 index 0000000..8450961 --- /dev/null +++ b/src/node/accounts/manager.js @@ -0,0 +1,221 @@ +/** + * Multi-account registry — manages Azure AD app registrations and account metadata. + * + * CONFIG DIRECTORY STRUCTURE (~/.outlook-cli/): + * accounts.json — account registry (this module) + * aliases.json — contact aliases (see contacts/aliases.js) + * config.json — global config overrides (see config.js) + * msal-cache-*.json — per-account MSAL token caches (managed by auth/msal-client.js) + * + * ACCOUNTS.JSON FORMAT: + * { + * "defaultAccount": "work", // alias of the default account + * "accounts": { + * "work": { + * "alias": "work", + * "email": "user@company.com", // populated after first login + * "tenantId": "common", // Azure AD tenant or "common" + * "clientId": "xxxxxxxx-...", // Azure app registration client ID + * "homeAccountId": "..." // MSAL home account ID for token lookup + * } + * } + * } + * + * SINGLETON PATTERN: AccountManager uses a module-level singleton (_instance) + * because multiple CLI commands in the same process need a consistent view of + * account state, and re-reading the file for every operation is wasteful. + * The singleton is created lazily on first access via getAccountManager(). + * resetAccountManager() exists solely for test isolation. + * + * C# PORT NOTE: Use a static Lazy or a DI singleton registration. + */ + +import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs'; +import { join } from 'path'; +import { homedir } from 'os'; +import { validateSchema, stampSchema } from '../config-schema.js'; + +const CONFIG_DIR = join(homedir(), '.outlook-cli'); +const ACCOUNTS_FILE = join(CONFIG_DIR, 'accounts.json'); + +/** Module-level singleton instance — see getAccountManager(). */ +let _instance = null; + +function ensureConfigDir() { + if (!existsSync(CONFIG_DIR)) { + mkdirSync(CONFIG_DIR, { recursive: true }); + } +} + +/** + * Load accounts.json from disk. Returns a blank registry on missing or corrupted file. + * Validates and migrates the schema version on load. + */ +function loadAccounts() { + ensureConfigDir(); + if (!existsSync(ACCOUNTS_FILE)) { + return { defaultAccount: null, accounts: {}, _schema: 0 }; + } + try { + const raw = JSON.parse(readFileSync(ACCOUNTS_FILE, 'utf-8')); + const { data, migrated } = validateSchema(raw, 'accounts', ACCOUNTS_FILE); + if (migrated) { + // Persist the migrated data so we don't re-migrate next time + writeFileSync(ACCOUNTS_FILE, JSON.stringify(data, null, 2), 'utf-8'); + } + return data; + } catch (err) { + // Re-throw schema errors (ConfigError) so the user sees them + if (err?.code === 'CONFIG_TOO_NEW') { + throw err; + } + return { defaultAccount: null, accounts: {} }; + } +} + +function saveAccounts(data) { + ensureConfigDir(); + const stamped = stampSchema(data, 'accounts'); + writeFileSync(ACCOUNTS_FILE, JSON.stringify(stamped, null, 2), 'utf-8'); +} + +class AccountManager { + constructor() { + this.data = loadAccounts(); + } + + /** Re-read accounts.json from disk (useful if edited externally). */ + reload() { + this.data = loadAccounts(); + } + + save() { + saveAccounts(this.data); + } + + getAccount(alias) { + return this.data.accounts[alias] || null; + } + + /** + * Add or update an account. Merges new info with existing data so partial + * updates (e.g., just changing tenantId) don't wipe other fields. + * Auto-promotes the first account added to be the default. + */ + upsertAccount(alias, info) { + this.data.accounts[alias] = { + ...this.data.accounts[alias], + ...info, + alias, + }; + // Set default if this is the first account + if (!this.data.defaultAccount) { + this.data.defaultAccount = alias; + } + this.save(); + } + + /** + * Remove an account. If the removed account was the default, the first + * remaining account becomes the new default (arbitrary but predictable). + */ + removeAccount(alias) { + delete this.data.accounts[alias]; + if (this.data.defaultAccount === alias) { + const remaining = Object.keys(this.data.accounts); + this.data.defaultAccount = remaining.length > 0 ? remaining[0] : null; + } + this.save(); + } + + listAccounts() { + return Object.entries(this.data.accounts).map(([alias, info]) => ({ + alias, + ...info, + })); + } + + getDefaultAlias() { + return this.data.defaultAccount; + } + + setDefault(alias) { + if (!this.data.accounts[alias]) { + throw new Error(`Account "${alias}" not found`); + } + this.data.defaultAccount = alias; + this.save(); + } + + getConfigDir() { + return CONFIG_DIR; + } + + /** + * Check if an account is configured as read-only. + * Read-only accounts cannot perform write operations (send, draft, move, delete, etc.). + */ + isReadOnly(alias) { + const account = this.getAccount(alias); + return account?.mode === 'read-only'; + } + + /** + * Get the MSAL scopes for an account. Returns custom scopes if configured, + * otherwise returns null (caller should use global defaults). + * + * Per-account scopes allow different accounts to request different + * permissions at login time. For example, a read-only account requests + * only Mail.Read, Calendars.Read, etc. + */ + getCustomScopes(alias) { + const account = this.getAccount(alias); + return account?.scopes || null; + } +} + +/** + * Get the singleton AccountManager instance. + * Lazy-initializes on first call so the file is only read when needed. + */ +export function getAccountManager() { + if (!_instance) { + _instance = new AccountManager(); + } + return _instance; +} + +/** + * Resolve an account alias to its config. + * + * Resolution order: + * 1. Explicit alias override (from --account flag) + * 2. Default account (first added, or explicitly set via set-default) + * 3. Error if no accounts configured + * + * Used by all CLI commands to determine which account's tokens to use. + */ +export async function resolveAccount(aliasOverride) { + const manager = getAccountManager(); + const alias = aliasOverride || manager.getDefaultAlias(); + + if (!alias) { + console.error('No account configured. Run `outlook-cli account add --client-id ` first.'); + process.exit(1); + } + + const account = manager.getAccount(alias); + if (!account) { + console.error(`Account "${alias}" not found. Run \`outlook-cli account list\` to see available accounts.`); + process.exit(1); + } + + return { account, alias }; +} + +/** + * Reset singleton for testing — allows each test to start with a fresh manager. + */ +export function resetAccountManager() { + _instance = null; +} diff --git a/src/node/auth/auth-flows.js b/src/node/auth/auth-flows.js new file mode 100644 index 0000000..70f9636 --- /dev/null +++ b/src/node/auth/auth-flows.js @@ -0,0 +1,368 @@ +/** + * Authentication flows for Microsoft identity platform. + * + * Two flows are supported, chosen at login time: + * + * 1. INTERACTIVE (PKCE) — Default on machines with a browser. + * - Starts a localhost HTTP server on port 53847 + * - Opens browser → Microsoft login page (handles 2FA on their end) + * - Microsoft redirects back to localhost with an auth code + * - Code is exchanged for tokens using PKCE verifier (no client secret needed) + * + * 2. DEVICE CODE — For headless VMs without a browser (--device-code flag). + * - Requests a one-time code from Microsoft + * - User enters code at https://microsoft.com/devicelogin on any device + * - CLI polls Microsoft until the user completes authentication + * + * Both flows end with MSAL caching an access token + refresh token in the + * encrypted cache (token-cache.js → crypto.js). + * + * IMPORTANT: MSAL v5 has a bug where the device code callback receives + * undefined values when the server returns an error instead of a device code. + * The pre-flight check (preflightDeviceCodeCheck) works around this by hitting + * the endpoint directly first to get actionable error messages. + */ +import { createServer } from 'http'; +import { URL } from 'url'; +import { getScopes, getRedirectUri, getRedirectPort } from './msal-client.js'; +import * as msal from '@azure/msal-node'; + +/** + * Interactive login using PKCE with a localhost callback server. + * + * Flow order: + * 1. Generate PKCE challenge/verifier pair (proves we're the same client) + * 2. Build Microsoft login URL with challenge + requested scopes + * 3. Start localhost HTTP server to catch the redirect + * 4. Open browser to the login URL + * 5. User authenticates (including 2FA) on Microsoft's page + * 6. Microsoft redirects to localhost with an authorization code + * 7. Exchange code + verifier for access/refresh tokens + * 8. MSAL caches tokens via the cache plugin + */ +export async function interactiveLogin(msalClient, options = {}) { + const scopes = options.scopes || getScopes(); + const redirectUri = getRedirectUri(); + const port = getRedirectPort(); + + // Step 1: Generate PKCE codes — this is the "proof" that the client requesting + // the token exchange is the same one that started the flow. Prevents auth code + // interception attacks (RFC 7636). + const cryptoProvider = new msal.CryptoProvider(); + const { verifier, challenge } = await cryptoProvider.generatePkceCodes(); + + const authCodeUrlParameters = { + scopes, + redirectUri, + codeChallenge: challenge, + codeChallengeMethod: 'S256', // SHA-256 hash of the verifier + }; + + // Step 2: MSAL builds the full authorization URL including client_id, scopes, + // challenge, response_type=code, etc. + const authUrl = await msalClient.getAuthCodeUrl(authCodeUrlParameters); + + // Steps 3-7: Start server, open browser, wait for callback, exchange code + return new Promise((resolve, reject) => { + let authTimeout; + + const server = createServer(async (req, res) => { + try { + const url = new URL(req.url, `http://localhost:${port}`); + + if (url.pathname === '/callback') { + const code = url.searchParams.get('code'); + const error = url.searchParams.get('error'); + + if (error) { + const errorDescription = url.searchParams.get('error_description') || error; + res.writeHead(400, { 'Content-Type': 'text/html' }); + res.end(`

Authentication failed

${errorDescription}

You can close this window.

`); + clearTimeout(authTimeout); + closeServer(server); + reject(new Error(errorDescription)); + return; + } + + if (!code) { + res.writeHead(400, { 'Content-Type': 'text/html' }); + res.end('

No authorization code received

You can close this window.

'); + clearTimeout(authTimeout); + closeServer(server); + reject(new Error('No authorization code in callback')); + return; + } + + // Step 7: Exchange the auth code for tokens. The verifier proves we're + // the same client that generated the challenge. + const tokenRequest = { + code, + scopes, + redirectUri, + codeVerifier: verifier, + }; + + const response = await msalClient.acquireTokenByCode(tokenRequest); + + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end('

✓ Authentication successful!

You can close this window and return to the terminal.

'); + clearTimeout(authTimeout); + closeServer(server); + resolve(response); + } else { + res.writeHead(404); + res.end('Not found'); + } + } catch (err) { + res.writeHead(500, { 'Content-Type': 'text/html' }); + res.end(`

Error

${err.message}

`); + clearTimeout(authTimeout); + closeServer(server); + reject(err); + } + }); + + // Bind to 127.0.0.1 (loopback only) — never expose to network + server.listen(port, '127.0.0.1', () => { + console.log(`\nOpening browser for Microsoft login...`); + console.log(`If the browser doesn't open, go to:\n${authUrl}\n`); + openBrowser(authUrl); + }); + + server.on('error', (err) => { + clearTimeout(authTimeout); + if (err.code === 'EADDRINUSE') { + reject(new Error(`Port ${port} is in use. Close the other application or use --device-code instead.`)); + } else { + reject(err); + } + }); + + // Safety timeout — if user doesn't complete auth in 5 minutes, clean up + authTimeout = setTimeout(() => { + closeServer(server); + reject(new Error('Authentication timed out after 5 minutes. Try again or use --device-code.')); + }, 5 * 60 * 1000); + }); +} + +/** + * Device code login — for headless environments (VMs without a browser). + * + * Flow order: + * 1. Pre-flight: Hit device code endpoint directly to catch config errors + * (MSAL v5 swallows these errors — see preflightDeviceCodeCheck comments) + * 2. If pre-flight detects authority mismatch, throw with correctTenant + * so the caller (auth.js) can retry with the right tenant + * 3. Call MSAL acquireTokenByDeviceCode with our callback + * 4. Callback displays the code + URL for the user + * 5. User goes to microsoft.com/devicelogin on ANY device and enters the code + * 6. MSAL polls Microsoft until auth completes + * 7. Tokens are cached via the cache plugin + * + * @param {object} options - Options from CLI (verbose flag, etc.) + */ +export async function deviceCodeLogin(msalClient, options = {}) { + const scopes = options.scopes || getScopes(); + + // STEP 1: Pre-flight diagnostic. + // Why we do this: MSAL v5 has a bug where `deviceCodeCallback` is called with + // { userCode: undefined, message: undefined, ... } when the server returns an + // error response. MSAL destructures the error JSON looking for device code fields + // and gets undefined for all of them. Our pre-flight catches the actual error. + const clientId = msalClient.config.auth.clientId; + const authority = msalClient.config.auth.authority + || 'https://login.microsoftonline.com/common'; + const preflightResult = await preflightDeviceCodeCheck(clientId, authority, scopes, options); + + // STEP 2: If the pre-flight detected the wrong authority (e.g., personal account + // app using /common instead of /consumers), signal the caller to retry. + if (preflightResult?.correctTenant) { + const err = new Error(`Authority mismatch — retrying with /${preflightResult.correctTenant}`); + err.correctTenant = preflightResult.correctTenant; + throw err; + } + + // STEP 3: Proceed with MSAL device code flow. + // We use a cancel flag instead of throwing in the callback because throwing + // inside deviceCodeCallback causes a Node.js UV_HANDLE_CLOSING assertion crash. + let deviceCodeFailed = false; + + const deviceCodeRequest = { + scopes, + cancel: false, + deviceCodeCallback: (response) => { + // If the callback receives undefined values, the device code request failed. + // Set cancel flag to abort polling gracefully. + if (!response.userCode && !response.message) { + console.error('\n❌ Failed to obtain a device code from Microsoft.'); + console.error(' Run with --verbose for details.\n'); + deviceCodeFailed = true; + deviceCodeRequest.cancel = true; + return; + } + + // Display the device code to the user + console.log('\n' + '─'.repeat(50)); + console.log('📱 Device Code Authentication'); + console.log('─'.repeat(50)); + + if (response.message) { + // MSAL provides a pre-formatted message with code + URL + console.log(response.message); + } else { + // Fallback: construct message manually + const uri = response.verificationUri || 'https://microsoft.com/devicelogin'; + console.log(`To sign in, open a browser and go to: ${uri}`); + console.log(`Enter the code: ${response.userCode}`); + } + + console.log('─'.repeat(50) + '\n'); + }, + }; + + try { + return await msalClient.acquireTokenByDeviceCode(deviceCodeRequest); + } catch (err) { + if (deviceCodeFailed) { + throw new Error( + 'Device code request failed — no code was returned by Microsoft. ' + + 'Verify your App Registration configuration (see docs/AZURE-SETUP.md).' + ); + } + throw err; + } +} + +/** + * Pre-flight check: hit the device code endpoint directly via raw HTTP. + * + * Why this exists: + * MSAL v5's DeviceCodeClient.executePostRequestToDeviceCodeEndpoint() destructures + * the server response looking for { user_code, device_code, verification_uri, ... }. + * If the server returns an error response like { error: "invalid_request", ... }, + * all those fields are undefined. MSAL then calls deviceCodeCallback with undefined + * values and the user sees "undefined" instead of an actionable error message. + * + * By hitting the endpoint directly first, we can: + * 1. Show the actual AADSTS error code and description + * 2. Auto-detect authority mismatches and correct them + * 3. Provide targeted fix instructions for common misconfigurations + * + * Returns: + * - { correctTenant: 'consumers'|'organizations' } if authority auto-correction needed + * - undefined if the pre-flight passed (endpoint returned a valid device code) + * - Throws on non-recoverable errors + */ +async function preflightDeviceCodeCheck(clientId, authority, scopes, options) { + const deviceCodeEndpoint = `${authority.replace(/\/$/, '')}/oauth2/v2.0/devicecode`; + + const body = new URLSearchParams({ + client_id: clientId, + scope: scopes.join(' '), + }).toString(); + + try { + const res = await fetch(deviceCodeEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }); + + if (!res.ok) { + const errBody = await res.json().catch(() => ({})); + const desc = errBody.error_description || errBody.error || `HTTP ${res.status}`; + const firstLine = desc.split(/\r?\n/)[0]; + + // AUTHORITY AUTO-CORRECTION + // AADSTS9002346: App is for personal accounts only → use /consumers + // AADSTS50194: App is for org accounts only → use /organizations + // The caller catches the correctTenant and retries with the right authority. + if (desc.includes('AADSTS9002346') || desc.includes('/consumers')) { + console.log('ℹ App is configured for personal accounts — switching to /consumers endpoint.'); + return { correctTenant: 'consumers' }; + } + if (desc.includes('AADSTS50194') || desc.includes('/organizations')) { + console.log('ℹ App is configured for organization accounts — switching to /organizations endpoint.'); + return { correctTenant: 'organizations' }; + } + + // NON-RECOVERABLE ERRORS — show guidance for common misconfigurations + console.error(`\n❌ Microsoft rejected the device code request:`); + console.error(` ${firstLine}`); + + if (desc.includes('AADSTS700016') || desc.includes('not found in the directory')) { + console.error('\n Fix: The Application (client) ID was not found.'); + console.error(' Verify --client-id matches the value on your App Registration overview page.'); + } else if (desc.includes('AADSTS700038') || desc.includes('not a valid application')) { + console.error('\n Fix: The client ID is not a valid application identifier.'); + console.error(' Copy the exact Application (client) ID from App registrations → Overview.'); + } else if (desc.includes('AADSTS7000218') || errBody.error === 'unauthorized_client') { + console.error('\n Fix: In Microsoft Entra ID → App registrations → Authentication:'); + console.error(' Set "Allow public client flows" to Yes, then click Save.'); + } else if (errBody.error === 'invalid_client') { + console.error('\n Fix: Verify the --client-id matches the Application (client) ID'); + console.error(' in your App Registration overview page.'); + } else if (errBody.error === 'invalid_scope') { + console.error('\n Fix: Add the required API permissions (Mail.Read, Mail.ReadWrite,'); + console.error(' Calendars.Read, Calendars.ReadWrite, User.Read) under API permissions.'); + } + if (options.verbose) { + console.error('\n Full error response:', JSON.stringify(errBody, null, 2)); + } + console.error(''); + throw new Error(`Device code request failed: ${errBody.error || 'unknown'} — ${firstLine}`); + } + // If we get here, the endpoint returned 200 with a valid device code. + // We discard it — MSAL will request its own. We just wanted to verify + // the endpoint works before handing off to MSAL. + } catch (err) { + if (err.message.startsWith('Device code request failed:') || err.correctTenant) { + throw err; // Re-throw our own errors + } + // Network error (DNS failure, timeout, etc.) — let MSAL try anyway. + // The pre-flight is a best-effort diagnostic, not a gate. + if (options.verbose) { + console.error('Pre-flight check failed (non-fatal):', err.message); + } + } + return undefined; +} + +/** + * Force-close an HTTP server and all its connections. + * server.close() alone only stops accepting new connections — existing + * keep-alive connections keep the event loop alive indefinitely. + */ +function closeServer(server) { + if (server.closeAllConnections) { + // Node.js 18.2+ — force-destroy all active sockets + server.closeAllConnections(); + } + server.close(); +} + +/** + * Open a URL in the default browser (cross-platform). + * Non-fatal if it fails — the URL is also printed to the console. + */ +async function openBrowser(url) { + const { exec } = await import('child_process'); + const platform = process.platform; + + // Each OS has a different command for opening URLs + let command; + if (platform === 'darwin') { + command = `open "${url}"`; + } else if (platform === 'win32') { + command = `start "" "${url}"`; // empty title required for start with quoted URL + } else { + command = `xdg-open "${url}"`; // Linux — requires xdg-utils + } + + exec(command, (err) => { + if (err) { + // Non-fatal — user can manually navigate to the URL printed above + } + }); +} diff --git a/src/node/auth/msal-client.js b/src/node/auth/msal-client.js new file mode 100644 index 0000000..cd13731 --- /dev/null +++ b/src/node/auth/msal-client.js @@ -0,0 +1,248 @@ +/** + * MSAL (Microsoft Authentication Library) client factory and token management. + * + * Architecture overview: + * 1. Each CLI account gets its own MSAL PublicClientApplication (PCA) + * 2. The PCA handles token lifecycle: acquire, refresh, cache + * 3. Tokens are persisted via an encrypted cache plugin (token-cache.js) + * 4. Silent acquisition is tried first; interactive/device-code flows are fallbacks + * + * Why PublicClientApplication (not ConfidentialClient)? + * - CLI apps are "public clients" — they run on the user's machine and can't keep + * a client secret safe. MSAL enforces PKCE for public clients automatically. + * - No client secret means the App Registration is safe to share across users. + * + * Scope design decisions: + * - Mail.Send is requested for direct sending from own account + * - Mail.Send.Shared is also requested for delegate send-on-behalf-of + * - offline_access gives us refresh tokens for long-lived sessions + * - *.Shared scopes enable delegate access (--as flag) + * + * SAFETY: The send command still prompts for confirmation before sending. + * The permissions system (permissions.js) can restrict Mail.Send per-account. + */ +import * as msal from '@azure/msal-node'; +import { createCachePlugin } from './token-cache.js'; + +/** + * Scopes requested during authentication. + * + * Scopes are requested at login time and cannot be changed without re-authenticating. + * If you add scopes here, users must `auth logout` then `auth login` again. + */ +const SCOPES = [ + 'User.Read', // Read user profile (needed for auth status display) + 'Mail.Read', // Read own mail + 'Mail.ReadWrite', // Create drafts, move, flag, mark-read in own mailbox + 'Mail.Send', // Send mail from own account + 'Mail.Read.Shared', // Read delegate mailboxes (--as flag) + 'Mail.ReadWrite.Shared', // Create drafts in delegate mailboxes + 'Mail.Send.Shared', // Send on behalf of delegate + 'Calendars.Read', // Read own calendar + 'Calendars.ReadWrite', // Create events in own calendar + 'Calendars.Read.Shared', // Read delegate calendars + 'offline_access', // Gives us a refresh token for long-lived sessions +]; + +/** + * OneDrive scopes — opt-in only. + * + * These must be added to the Azure App Registration's API permissions + * before they can be requested. Including them in the default list breaks + * existing users whose App Registration doesn't have Files permissions. + * + * To enable: add Files.Read (or Files.ReadWrite) in Azure Portal → + * App Registrations → API permissions, then re-login. + * + * Users can also add them per-account: + * outlook-cli account add myacct --scopes "User.Read,Mail.Read,...,Files.Read,Files.ReadWrite,offline_access" + */ +export const DRIVE_SCOPES = ['Files.Read', 'Files.ReadWrite']; + +// Redirect port for interactive (PKCE) auth flow. +// Chosen to avoid conflicts with common dev servers (3000, 8080, etc.). +const REDIRECT_PORT = 53847; +const REDIRECT_URI = `http://localhost:${REDIRECT_PORT}/callback`; + +/** + * Create an MSAL PublicClientApplication for a specific account. + * + * @param {string} accountAlias - Account nickname (e.g., "personal", "work") + * @param {string} clientId - Azure App Registration Application (client) ID + * @param {string} tenantId - Azure tenant ID. Defaults to "common" which supports + * both personal and organizational accounts. Auto-corrected to "consumers" or + * "organizations" by auth-flows.js if needed. + */ +export async function createMsalClient(accountAlias, clientId, tenantId = 'common') { + // Each account gets its own encrypted cache file (~/.outlook-cli/cache-.enc) + const cachePlugin = createCachePlugin(accountAlias); + + const config = { + auth: { + clientId, + // Authority determines which account types can authenticate. + // /common = personal + work/school, /consumers = personal only, + // /organizations = work/school only, /{tenant-id} = specific tenant + authority: `https://login.microsoftonline.com/${tenantId}`, + }, + cache: { + // MSAL calls beforeCacheAccess (decrypt from disk) and afterCacheAccess + // (encrypt to disk) around every token operation + cachePlugin, + }, + system: { + loggerOptions: { + logLevel: msal.LogLevel.Warning, + }, + }, + }; + + const pca = new msal.PublicClientApplication(config); + return pca; +} + +/** + * Read-only scopes preset — only read permissions, no write/send capabilities. + * Used when an account is configured with mode: "read-only". + */ +const READ_ONLY_SCOPES = [ + 'User.Read', + 'Mail.Read', + 'Mail.Read.Shared', + 'Calendars.Read', + 'Calendars.Read.Shared', + 'Contacts.Read', + 'offline_access', +]; + +/** + * Get the scopes for Graph API access. + * + * @param {object} [options] - Options for scope selection + * @param {string[]} [options.customScopes] - Per-account custom scopes (overrides defaults) + * @param {string} [options.mode] - Account mode: "read-only" uses read-only preset + * @returns {string[]} Copy of the applicable scopes array + */ +export function getScopes(options = {}) { + if (options.customScopes && options.customScopes.length > 0) { + return [...options.customScopes]; + } + if (options.mode === 'read-only') { + return [...READ_ONLY_SCOPES]; + } + return [...SCOPES]; +} + +/** + * Get the redirect URI for interactive auth. + */ +export function getRedirectUri() { + return REDIRECT_URI; +} + +/** + * Get the redirect port. + */ +export function getRedirectPort() { + return REDIRECT_PORT; +} + +/** + * Attempt silent token acquisition using the cached refresh token. + * + * This is the primary token acquisition path — called before every Graph API request. + * MSAL handles refresh token rotation automatically: + * 1. If access token is still valid → returns it immediately (cached in memory) + * 2. If access token expired but refresh token is valid → exchanges for new tokens + * 3. If both expired → returns null (caller must initiate interactive login) + * + * @param {object} msalClient - MSAL PublicClientApplication instance + * @param {object} [options] - Options for scope selection + * @param {string[]} [options.customScopes] - Per-account custom scopes + * @param {string} [options.mode] - Account mode ("read-only" uses read-only preset) + * + * Returns { result, reason } where: + * - result is the AuthenticationResult or null + * - reason explains why result is null (for user-facing diagnostics) + * + * Throws network errors so callers can distinguish "network down" from "token expired". + */ +export async function acquireTokenSilently(msalClient, options = {}) { + const scopes = getScopes(options); + const cache = msalClient.getTokenCache(); + const accounts = await cache.getAllAccounts(); + + if (accounts.length === 0) { + return { result: null, reason: 'no_account' }; + } + + try { + // accounts[0] — we store one account per PCA instance (one per alias). + // Multi-account is handled by having separate PCA instances, not multiple + // accounts in a single PCA. + const result = await msalClient.acquireTokenSilent({ + account: accounts[0], + scopes, + }); + return { result, reason: null }; + } catch (err) { + // Only return null for known "reauth required" cases. + // Network errors and unexpected failures must bubble up so the caller + // can show the real cause instead of misleading "token expired" messages. + const msg = err?.message || ''; + const errorCode = err?.errorCode || ''; + + // MSAL InteractionRequiredAuthError — user must re-login. + // Common cause: new scopes added to the scope list after a code update. + // The cached token doesn't have the new scopes, so MSAL can't refresh silently. + if (err?.name === 'InteractionRequiredAuthError') { + const isConsentRequired = errorCode === 'consent_required' + || msg.includes('consent_required') + || msg.includes('AADSTS65001'); + return { + result: null, + reason: isConsentRequired ? 'consent_required' : 'interaction_required', + }; + } + + // invalid_grant — could be refresh token expired/revoked, password changed, + // unauthorized scopes, or abuse detection (too many auth attempts). + // Many AADSTS errors arrive as invalid_grant, so check specific codes first. + if (errorCode === 'invalid_grant' || msg.includes('invalid_grant')) { + // MFA required (AADSTS50076, AADSTS50079) + if (msg.includes('AADSTS50076') || msg.includes('AADSTS50079')) { + return { result: null, reason: 'mfa_required' }; + } + // Password changed (AADSTS50173) + if (msg.includes('AADSTS50173')) { + return { result: null, reason: 'password_changed' }; + } + // Refresh token fully expired (AADSTS70043, AADSTS700082) + if (msg.includes('AADSTS70043') || msg.includes('AADSTS700082')) { + return { result: null, reason: 'refresh_token_expired' }; + } + // AADSTS70000 has multiple sub-causes — check message text to differentiate + if (msg.includes('AADSTS70000')) { + if (msg.includes('unauthorized')) { + return { result: null, reason: 'scopes_unauthorized' }; + } + if (msg.includes('abuse')) { + return { result: null, reason: 'rate_limited' }; + } + } + // Generic invalid_grant without a specific AADSTS code + return { result: null, reason: 'refresh_token_expired' }; + } + + // AADSTS errors with non-invalid_grant error codes (uncommon but possible) + if (msg.includes('AADSTS50076') || msg.includes('AADSTS50079')) { + return { result: null, reason: 'mfa_required' }; + } + if (msg.includes('AADSTS50173')) { + return { result: null, reason: 'password_changed' }; + } + + // Everything else (network errors, unexpected MSAL failures) — let it throw + throw err; + } +} diff --git a/src/node/auth/token-cache.js b/src/node/auth/token-cache.js new file mode 100644 index 0000000..793b207 --- /dev/null +++ b/src/node/auth/token-cache.js @@ -0,0 +1,112 @@ +/** + * Encrypted token cache plugin for MSAL. + * + * MSAL uses a plugin interface (ICachePlugin) with two hooks: + * - beforeCacheAccess: called BEFORE any token operation → decrypt from disk + * - afterCacheAccess: called AFTER any token operation → encrypt to disk + * + * Each account gets its own file: ~/.outlook-cli/cache-.enc + * The file contains: salt(32) + iv(16) + authTag(16) + ciphertext (AES-256-GCM) + * + * The passphrase is derived from either: + * 1. OUTLOOK_CLI_PASSPHRASE env var (recommended for shared/CI environments) + * 2. Machine-derived string: username + hostname + account alias (zero-config default) + * + * This is NOT a general-purpose key-value cache — it stores the serialized + * MSAL token cache which includes access tokens, refresh tokens, and account metadata. + */ +import { readFileSync, writeFileSync, existsSync, chmodSync, mkdirSync, statSync } from 'fs'; +import { join } from 'path'; +import { platform } from 'os'; +import { encrypt, decrypt, getPassphrase } from '../security/crypto.js'; +import { getAccountManager } from '../accounts/manager.js'; + +/** + * Set restrictive file permissions on Unix systems (no-op on Windows). + * Cache files contain encrypted tokens — restrict to owner read/write only. + */ +function setRestrictivePermissions(filePath) { + if (platform() === 'win32') return; + try { + chmodSync(filePath, 0o600); + } catch { + // Best-effort: don't crash if chmod fails (e.g., unsupported filesystem) + } +} + +/** + * Ensure the config directory has restrictive permissions on Unix. + */ +function ensureDirectoryPermissions(dirPath) { + if (platform() === 'win32') return; + try { + if (existsSync(dirPath)) { + chmodSync(dirPath, 0o700); + } + } catch { + // Best-effort + } +} + +/** + * Create an MSAL-compatible cache plugin for a specific account. + * + * @param {string} accountAlias - Account nickname, used to derive file path and passphrase + * @returns {object} MSAL ICachePlugin with beforeCacheAccess and afterCacheAccess hooks + */ +export function createCachePlugin(accountAlias) { + const manager = getAccountManager(); + const configDir = manager.getConfigDir(); + const cacheFile = join(configDir, `cache-${accountAlias}.enc`); + const passphrase = getPassphrase(accountAlias); + + // Ensure config directory has restrictive permissions (Unix only) + ensureDirectoryPermissions(configDir); + + // Track decryption failures so getToken() can surface the real cause + // instead of a misleading "no valid token" message. + let lastDecryptError = null; + + return { + // MSAL calls this before reading/writing any tokens. + // We decrypt the on-disk cache and deserialize it into MSAL's in-memory cache. + beforeCacheAccess: async (cacheContext) => { + if (existsSync(cacheFile)) { + try { + const encryptedData = readFileSync(cacheFile); + const decrypted = decrypt(encryptedData, passphrase); + cacheContext.tokenCache.deserialize(decrypted); + lastDecryptError = null; + } catch (err) { + // Decryption failure = wrong passphrase, corrupt file, or file from + // a different machine (machine-derived passphrase won't match). + // Starting fresh means the user must re-authenticate, but it's safer + // than crashing or using stale tokens. + lastDecryptError = err; + console.error(`Warning: Could not decrypt token cache for "${accountAlias}": ${err.message}`); + console.error('Starting with empty cache. You will need to re-authenticate.'); + console.error(`Fix: Run \`outlook-cli auth login --account ${accountAlias}\` to re-authenticate.`); + } + } + }, + + // MSAL calls this after any token operation that changed the cache + // (new access token, refreshed token, etc.). + // We serialize MSAL's in-memory cache and encrypt it to disk. + afterCacheAccess: async (cacheContext) => { + if (cacheContext.cacheHasChanged) { + const serialized = cacheContext.tokenCache.serialize(); + const encryptedData = encrypt(serialized, passphrase); + // Each write uses a fresh random salt + IV (from crypto.js), so the + // ciphertext is different even if the content hasn't changed. + writeFileSync(cacheFile, encryptedData); + setRestrictivePermissions(cacheFile); + } + }, + + /** Check if the last cache access had a decryption error. */ + getLastDecryptError() { + return lastDecryptError; + }, + }; +} diff --git a/src/node/cli/account.js b/src/node/cli/account.js new file mode 100644 index 0000000..03696c2 --- /dev/null +++ b/src/node/cli/account.js @@ -0,0 +1,216 @@ +/** + * Account CLI commands — multi-account management (add, remove, list, set-default). + * + * ACCOUNT ADD FLOW: + * 1. User provides alias + client ID (Azure app registration) + * 2. Account is saved with placeholder email "(not yet authenticated)" + * 3. User must then run `outlook-cli auth login --account ` to authenticate + * + * This two-step flow (add then login) keeps account registration separate from + * authentication, so accounts can be pre-configured in scripts before interactive + * login happens. + * + * CLIENT ID SOURCE: --client-id flag > OUTLOOK_CLI_CLIENT_ID env var. The client + * ID is the Azure AD App Registration's Application (client) ID. Each account + * may use a different app registration. + */ + +import { Command } from 'commander'; +import { getAccountManager } from '../accounts/manager.js'; +import { formatOutput } from '../output/formatter.js'; + +export function registerAccountCommands(program) { + const account = new Command('account').description('Multi-account management'); + + /** + * Add: Registers an account alias with its Azure app client ID and tenant. + * Does NOT authenticate — that's a separate `auth login` step. + * + * --read-only: Restricts the account to read-only operations. The CLI will + * request only read scopes at login and block write commands (send, draft, etc.). + * + * --scopes: Custom MSAL scopes for advanced permission tiers. Overrides both + * the default full-access scopes and the --read-only preset. + */ + account + .command('add ') + .description('Add a new account') + .option('--client-id ', 'Azure app client ID') + .option('--tenant ', 'Azure AD tenant ID (default: "common")') + .option('--read-only', 'restrict to read-only mode (no send, draft, move, delete)') + .option('--scopes ', 'custom MSAL scopes (comma-separated, advanced)') + .action(async (alias, options) => { + const globalOpts = program.opts(); + const manager = getAccountManager(); + const clientId = options.clientId || process.env.OUTLOOK_CLI_CLIENT_ID; + + if (!clientId) { + console.error('Error: --client-id is required (or set OUTLOOK_CLI_CLIENT_ID env var)'); + process.exit(1); + } + + const accountInfo = { + email: '(not yet authenticated)', + tenantId: options.tenant || 'common', + clientId, + mode: options.readOnly ? 'read-only' : 'full', + }; + + // Custom scopes override the mode preset + if (options.scopes) { + accountInfo.scopes = options.scopes.split(',').map(s => s.trim()); + } + + manager.upsertAccount(alias, accountInfo); + + const modeLabel = options.readOnly ? ' (read-only)' : ''; + if (globalOpts.json) { + formatOutput({ + success: true, alias, clientId, + tenantId: options.tenant || 'common', + mode: accountInfo.mode, + scopes: accountInfo.scopes, + }, { json: true }); + } else { + console.log(`✓ Account "${alias}" added${modeLabel}. Run \`outlook-cli auth login --account ${alias}\` to authenticate.`); + } + }); + + account + .command('remove ') + .description('Remove an account') + .action((alias) => { + const globalOpts = program.opts(); + const manager = getAccountManager(); + + if (!manager.getAccount(alias)) { + console.error(`Account "${alias}" not found.`); + process.exit(1); + } + + manager.removeAccount(alias); + + if (globalOpts.json) { + formatOutput({ success: true, removed: alias }, { json: true }); + } else { + console.log(`✓ Account "${alias}" removed.`); + } + }); + + account + .command('list') + .description('List all accounts') + .action(() => { + const globalOpts = program.opts(); + const manager = getAccountManager(); + const accounts = manager.listAccounts(); + const defaultAlias = manager.getDefaultAlias(); + + if (globalOpts.json) { + formatOutput({ accounts, defaultAccount: defaultAlias }, { json: true }); + } else { + if (accounts.length === 0) { + console.log('No accounts configured. Run `outlook-cli account add --client-id ` to add one.'); + return; + } + console.log('Accounts:'); + for (const acct of accounts) { + const marker = acct.alias === defaultAlias ? ' (default)' : ''; + const modeTag = acct.mode === 'read-only' ? ' [read-only]' : ''; + console.log(` ${acct.alias}${marker}${modeTag} — ${acct.email} [${acct.tenantId}]`); + } + } + }); + + account + .command('set-default ') + .description('Set the default account') + .action((alias) => { + const globalOpts = program.opts(); + const manager = getAccountManager(); + + if (!manager.getAccount(alias)) { + console.error(`Account "${alias}" not found.`); + process.exit(1); + } + + manager.setDefault(alias); + + if (globalOpts.json) { + formatOutput({ success: true, defaultAccount: alias }, { json: true }); + } else { + console.log(`✓ Default account set to "${alias}".`); + } + }); + + /** + * Set: Modify an existing account's settings (mode, scopes, tenant, client-id). + * Changes to mode or scopes require re-login to take effect. + */ + account + .command('set ') + .description('Update account settings') + .option('--read-only', 'restrict to read-only mode') + .option('--mode ', 'account mode: "full" or "read-only"') + .option('--scopes ', 'custom MSAL scopes (comma-separated)') + .option('--client-id ', 'change Azure app client ID') + .option('--tenant ', 'change Azure AD tenant ID') + .action((alias, options) => { + const globalOpts = program.opts(); + const manager = getAccountManager(); + + if (!manager.getAccount(alias)) { + console.error(`Account "${alias}" not found.`); + process.exit(1); + } + + const updates = {}; + const needsRelogin = []; + + if (options.readOnly) { + updates.mode = 'read-only'; + needsRelogin.push('mode'); + } else if (options.mode) { + if (!['full', 'read-only'].includes(options.mode)) { + console.error('Error: --mode must be "full" or "read-only"'); + process.exit(1); + } + updates.mode = options.mode; + needsRelogin.push('mode'); + } + + if (options.scopes) { + updates.scopes = options.scopes.split(',').map(s => s.trim()); + needsRelogin.push('scopes'); + } + + if (options.clientId) { + updates.clientId = options.clientId; + needsRelogin.push('client-id'); + } + + if (options.tenant) { + updates.tenantId = options.tenant; + needsRelogin.push('tenant'); + } + + if (Object.keys(updates).length === 0) { + console.error('Error: No settings to update. Use --read-only, --mode, --scopes, --client-id, or --tenant.'); + process.exit(1); + } + + manager.upsertAccount(alias, updates); + + if (globalOpts.json) { + formatOutput({ success: true, alias, updated: Object.keys(updates), needsRelogin: needsRelogin.length > 0 }, { json: true }); + } else { + console.log(`✓ Account "${alias}" updated.`); + if (needsRelogin.length > 0) { + console.log(` Note: Re-login required for changes to take effect.`); + console.log(` Run: outlook-cli auth login --account ${alias}`); + } + } + }); + + program.addCommand(account); +} diff --git a/src/node/cli/auth.js b/src/node/cli/auth.js new file mode 100644 index 0000000..07ca1e5 --- /dev/null +++ b/src/node/cli/auth.js @@ -0,0 +1,214 @@ +/** + * Auth CLI commands — login, logout, and status. + * + * FLOW SELECTION: Two auth flows are supported: + * 1. Interactive (default) — opens a browser for OAuth2 authorization code flow. + * Best for local development and desktop use. + * 2. Device Code (--device-code) — displays a code for the user to enter at + * https://microsoft.com/devicelogin. Required for headless/SSH environments + * where a browser can't be opened. + * + * AUTHORITY AUTO-RETRY: When using device code flow, the MSAL pre-flight check + * may detect that the tenant ID is wrong (e.g., user specified "common" but the + * app is single-tenant). If auth-flows.js throws an error with a `correctTenant` + * property, we automatically rebuild the MSAL client with the correct tenant and + * retry. This avoids a confusing error message for the user. + * + * SECURITY: After login, validateTokenScopes checks that the acquired token does + * NOT include Mail.Send — we want that scope only when explicitly sending. + */ + +import { Command } from 'commander'; +import { getAccountManager } from '../accounts/manager.js'; +import { createMsalClient, getScopes } from '../auth/msal-client.js'; +import { interactiveLogin, deviceCodeLogin } from '../auth/auth-flows.js'; +import { validateTokenScopes } from '../security/token-validator.js'; +import { formatOutput } from '../output/formatter.js'; +import { output } from '../output/render.js'; + +export function registerAuthCommands(program) { + const auth = new Command('auth').description('Authentication management'); + + auth + .command('login') + .description('Authenticate with Microsoft (default account)') + .option('--device-code', 'use device code flow (for headless environments)') + .option('--tenant ', 'Azure AD tenant ID (default: "common")') + .option('--client-id ', 'Azure app client ID') + .action(async (options) => { + const globalOpts = program.opts(); + const manager = getAccountManager(); + const accountAlias = globalOpts.account || manager.getDefaultAlias() || 'default'; + + try { + let account = manager.getAccount(accountAlias); + // Resolve tenant with fallback chain: CLI flag > saved account > default + let tenantId = options.tenant || account?.tenantId || 'common'; + const clientId = options.clientId || account?.clientId || process.env.OUTLOOK_CLI_CLIENT_ID; + + if (!clientId) { + console.error('Error: No client ID configured.'); + console.error('Provide --client-id, set OUTLOOK_CLI_CLIENT_ID env var, or add an account first.'); + process.exitCode = 1; + return; + } + + let msalClient = await createMsalClient(accountAlias, clientId, tenantId); + + // Resolve per-account scopes: custom scopes > read-only preset > global defaults + const accountMode = account?.mode; + const customScopes = account?.scopes; + const loginScopes = getScopes({ customScopes, mode: accountMode }); + + let result; + if (options.deviceCode) { + try { + result = await deviceCodeLogin(msalClient, { verbose: globalOpts.verbose, scopes: loginScopes }); + } catch (retryErr) { + // AUTHORITY AUTO-RETRY: If pre-flight detected a tenant mismatch, + // rebuild the MSAL client with the correct tenant and retry once. + // This handles the common case where "common" doesn't work for + // single-tenant app registrations. + if (retryErr.correctTenant) { + tenantId = retryErr.correctTenant; + msalClient = await createMsalClient(accountAlias, clientId, tenantId); + result = await deviceCodeLogin(msalClient, { verbose: globalOpts.verbose, scopes: loginScopes }); + } else { + throw retryErr; + } + } + } else { + // Interactive flow — opens system browser for OAuth2 authorization + result = await interactiveLogin(msalClient, { scopes: loginScopes }); + } + + // Validate that the login token doesn't have dangerous scopes (Mail.Send) + validateTokenScopes(result); + + // Persist account metadata so future commands can look up clientId/tenantId + manager.upsertAccount(accountAlias, { + email: result.account?.username || 'unknown', + tenantId, + clientId, + homeAccountId: result.account?.homeAccountId, + }); + + if (globalOpts.json) { + formatOutput({ success: true, account: accountAlias, email: result.account?.username }, { json: true }); + } else { + console.log(`✓ Logged in as ${result.account?.username || 'unknown'} (account: ${accountAlias})`); + } + } catch (err) { + // Provide user-friendly error messages for common Azure AD errors. + // Each AADSTS code maps to a specific misconfiguration scenario. + const msg = err.message || String(err); + if (msg.includes('invalid_grant')) { + console.error('Login failed: The device code expired or was already used.'); + console.error('Please try again — you have ~15 minutes to enter the code after it appears.'); + } else if (msg.includes('authorization_pending')) { + console.error('Login timed out waiting for you to enter the code.'); + } else if (msg.includes('AADSTS700016') || msg.includes('not found in the directory')) { + console.error('Login failed: The client ID was not found in Microsoft Entra ID.'); + console.error('Verify your --client-id matches the Application (client) ID in your App Registration.'); + } else if (msg.includes('AADSTS65001') || msg.includes('consent')) { + console.error('Login failed: Admin consent may be required for the requested permissions.'); + } else { + console.error(`Login failed: ${msg}`); + } + if (globalOpts.verbose) console.error(err); + // Use exitCode instead of process.exit() to let pending async handles + // (e.g., MSAL HTTP requests, token cache writes) close cleanly before + // the Node.js process exits. process.exit() would terminate immediately. + process.exitCode = 1; + } + }); + + /** + * Logout: Clears ALL cached tokens for the account by iterating through + * the MSAL cache. This ensures both access and refresh tokens are removed. + */ + auth + .command('logout') + .description('Clear cached tokens for the current account') + .action(async () => { + const globalOpts = program.opts(); + const manager = getAccountManager(); + const accountAlias = globalOpts.account || manager.getDefaultAlias() || 'default'; + + try { + const account = manager.getAccount(accountAlias); + if (!account) { + console.error(`No account found with alias: ${accountAlias}`); + process.exitCode = 1; + return; + } + + const msalClient = await createMsalClient(accountAlias, account.clientId, account.tenantId); + const cache = msalClient.getTokenCache(); + const accounts = await cache.getAllAccounts(); + + for (const acct of accounts) { + await cache.removeAccount(acct); + } + + if (globalOpts.json) { + formatOutput({ success: true, account: accountAlias }, { json: true }); + } else { + console.log(`✓ Logged out of account: ${accountAlias}`); + } + } catch (err) { + console.error(`Logout failed: ${err.message}`); + process.exitCode = 1; + } + }); + + /** + * Status: Checks if valid cached tokens exist without attempting to refresh. + * Reports the account alias, email, and tenant — useful for scripted health checks. + */ + auth + .command('status') + .description('Show authentication status') + .action(async () => { + const globalOpts = program.opts(); + const manager = getAccountManager(); + const accountAlias = globalOpts.account || manager.getDefaultAlias() || 'default'; + + try { + const account = manager.getAccount(accountAlias); + if (!account) { + if (globalOpts.json) { + formatOutput({ authenticated: false, account: accountAlias }, { json: true }); + } else { + console.log(`Account "${accountAlias}" not configured.`); + } + return; + } + + const msalClient = await createMsalClient(accountAlias, account.clientId, account.tenantId); + const cache = msalClient.getTokenCache(); + const accounts = await cache.getAllAccounts(); + + const status = { + account: accountAlias, + email: account.email, + authenticated: accounts.length > 0, + tenantId: account.tenantId, + }; + + if (globalOpts.json) { + formatOutput(status, { json: true }); + } else { + console.log(`Account: ${accountAlias}`); + console.log(`Email: ${account.email}`); + console.log(`Authenticated: ${accounts.length > 0 ? '✓ yes' : '✗ no'}`); + console.log(`Tenant: ${account.tenantId}`); + } + } catch (err) { + console.error(`Status check failed: ${err.message}`); + process.exitCode = 1; + } + }); + + program.addCommand(auth); +} diff --git a/src/node/cli/calendar.js b/src/node/cli/calendar.js new file mode 100644 index 0000000..5213244 --- /dev/null +++ b/src/node/cli/calendar.js @@ -0,0 +1,278 @@ +/** + * Calendar CLI commands — today, week, range, view, list-calendars, create. + * + * DELEGATE ACCESS: Like mail.js, the --as flag enables delegate calendar access. + * The buildClient helper resolves aliases and sets up the Graph client with the + * correct userPath ("/me" or "/users/{email}"). + * + * ATTENDEE ALIAS RESOLUTION: The parseAttendees helper resolves each attendee + * through the alias system (see contacts/aliases.js). This means users can write + * `--attendees fred,jane` and each name is resolved to an email via aliases. + * All attendees default to "required" type — "optional" attendees can be set + * via JSON input. + */ + +import { Command } from 'commander'; +import { resolveAccount } from '../accounts/manager.js'; +import { createGraphClient } from '../graph/client.js'; +import * as calendarApi from '../graph/calendar.js'; +import { output, resolveFormat } from '../output/render.js'; +import { loadInput, mergeInput, resolveBody } from '../input.js'; +import { resolveAlias } from '../contacts/aliases.js'; +import { createInterface } from 'readline'; +import { assertWriteAllowed } from '../security/write-guard.js'; + +function confirm(question) { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + return new Promise((resolve) => { + rl.question(`${question} (y/N) `, (answer) => { + rl.close(); + resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes'); + }); + }); +} + +function needsConfirmation(globalOpts, opts) { + if (opts.yes) return false; + if (globalOpts.output) return false; + return resolveFormat(globalOpts) === 'text'; +} + +/** + * Parse attendees from CLI string or JSON array into Graph API format. + * + * Each attendee token is resolved through the alias system — if a token + * contains "@" it's treated as an email; otherwise it's looked up as an alias. + * All attendees are set to "required" type by default. + * + * @param {string|Array} value - Comma-separated string or array of email/alias tokens + * @returns {Array<{emailAddress:{address:string}, type:string}>|undefined} + */ +function parseAttendees(value) { + if (!value) return undefined; + if (Array.isArray(value)) { + return value.map(email => { + const resolved = resolveAlias(email.trim()); + return { emailAddress: { address: resolved }, type: 'required' }; + }); + } + return value.split(',').map(email => { + const resolved = resolveAlias(email.trim()); + return { emailAddress: { address: resolved }, type: 'required' }; + }); +} + +/** + * Build a Graph API client for the current account, with optional delegate access. + * Same pattern as mail.js buildClient — shared logic could be extracted in future. + */ +async function buildClient(globalOpts) { + const delegateFor = globalOpts.as ? resolveAlias(globalOpts.as) : undefined; + const { account, alias } = await resolveAccount(globalOpts.account); + const client = await createGraphClient(alias, account, { delegateFor }); + return { client, accountAlias: alias }; +} + +export function registerCalendarCommands(program) { + const calendar = new Command('calendar').description('Calendar operations'); + + calendar + .command('today') + .description("Show today's events") + .option('--input ', 'load options from JSON file (use "-" for stdin)') + .option('--timezone ', 'timezone (e.g. America/Los_Angeles)', Intl.DateTimeFormat().resolvedOptions().timeZone) + .action(async (options) => { + const globalOpts = program.opts(); + const jsonInput = await loadInput(options.input); + const opts = mergeInput(jsonInput, { timezone: options.timezone }); + + const { client } = await buildClient(globalOpts); + const events = await calendarApi.getTodayEvents(client, { timezone: opts.timezone }); + + await output(events, 'eventList', globalOpts, 'Today'); + }); + + calendar + .command('week') + .description("Show this week's events") + .option('--input ', 'load options from JSON file (use "-" for stdin)') + .option('--timezone ', 'timezone', Intl.DateTimeFormat().resolvedOptions().timeZone) + .action(async (options) => { + const globalOpts = program.opts(); + const jsonInput = await loadInput(options.input); + const opts = mergeInput(jsonInput, { timezone: options.timezone }); + + const { client } = await buildClient(globalOpts); + const events = await calendarApi.getWeekEvents(client, { timezone: opts.timezone }); + + await output(events, 'eventList', globalOpts, 'This Week'); + }); + + calendar + .command('range') + .description('Show events in a date range') + .option('--input ', 'load options from JSON file (use "-" for stdin)') + .option('--start ', 'start date (ISO 8601)') + .option('--end ', 'end date (ISO 8601)') + .option('--timezone ', 'timezone', Intl.DateTimeFormat().resolvedOptions().timeZone) + .action(async (options) => { + const globalOpts = program.opts(); + const jsonInput = await loadInput(options.input); + const opts = mergeInput(jsonInput, { + start: options.start, end: options.end, timezone: options.timezone, + }); + + if (!opts.start || !opts.end) { + console.error('Error: --start and --end are required (or provide in --input JSON)'); + process.exit(1); + } + + const { client } = await buildClient(globalOpts); + const events = await calendarApi.getEventsInRange(client, { + start: opts.start, end: opts.end, timezone: opts.timezone, + }); + + await output(events, 'eventList', globalOpts, `${opts.start} to ${opts.end}`); + }); + + calendar + .command('view [eventId]') + .description('View event details') + .option('--input ', 'load options from JSON file (use "-" for stdin)') + .action(async (eventId, options) => { + const globalOpts = program.opts(); + const jsonInput = await loadInput(options.input); + const id = eventId || jsonInput.eventId; + + if (!id) { + console.error('Error: eventId is required (positional arg or "eventId" in --input JSON)'); + process.exit(1); + } + + const { client } = await buildClient(globalOpts); + const event = await calendarApi.getEvent(client, id); + + await output(event, 'eventDetail', globalOpts); + }); + + calendar + .command('list-calendars') + .description('List all calendars') + .option('--input ', 'load options from JSON file (use "-" for stdin)') + .action(async (options) => { + const globalOpts = program.opts(); + const { client } = await buildClient(globalOpts); + const calendars = await calendarApi.listCalendars(client); + + await output(calendars, 'calendarList', globalOpts); + }); + + calendar + .command('create') + .description('Create a calendar event') + .option('--input ', 'load options from JSON file (use "-" for stdin)') + .option('--subject ', 'event subject') + .option('--start ', 'start date/time (ISO 8601)') + .option('--end ', 'end date/time (ISO 8601)') + .option('--timezone ', 'timezone', Intl.DateTimeFormat().resolvedOptions().timeZone) + .option('--body ', 'event body/description') + .option('--body-file ', 'read body from file (text or HTML)') + .option('--body-content-type ', 'body content type: Text or HTML', 'Text') + .option('--location ', 'event location') + .option('--attendees ', 'attendee emails (comma-separated)') + .option('--yes', 'skip confirmation') + .action(async (options) => { + const globalOpts = program.opts(); + const jsonInput = await loadInput(options.input); + const opts = mergeInput(jsonInput, { + subject: options.subject, start: options.start, end: options.end, + timezone: options.timezone, body: options.body, bodyFile: options.bodyFile, + bodyContentType: options.bodyContentType, location: options.location, + attendees: options.attendees, yes: options.yes, + }); + + if (!opts.subject) { + console.error('Error: --subject is required (or "subject" in --input JSON)'); + process.exit(1); + } + if (!opts.start || !opts.end) { + console.error('Error: --start and --end are required (or provide in --input JSON)'); + process.exit(1); + } + + const { client, accountAlias } = await buildClient(globalOpts); + assertWriteAllowed(accountAlias, 'create event'); + + const tz = opts.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone; + const event = { + subject: opts.subject, + start: { dateTime: opts.start, timeZone: tz }, + end: { dateTime: opts.end, timeZone: tz }, + }; + + if (opts.body || opts.bodyFile) { + const bodyResult = await resolveBody(opts); + event.body = bodyResult; + } + if (opts.location) { + event.location = { displayName: opts.location }; + } + const attendees = parseAttendees(opts.attendees); + if (attendees) { + event.attendees = attendees; + } + + if (needsConfirmation(globalOpts, opts)) { + const attendeeDisplay = Array.isArray(opts.attendees) ? opts.attendees.join(', ') : opts.attendees; + console.log('\n📅 CREATE EVENT'); + console.log('─'.repeat(40)); + console.log(`Subject: ${opts.subject}`); + console.log(`Start: ${opts.start}`); + console.log(`End: ${opts.end}`); + console.log(`Timezone: ${tz}`); + if (opts.location) console.log(`Location: ${opts.location}`); + if (attendeeDisplay) console.log(`Attendees: ${attendeeDisplay}`); + console.log('─'.repeat(40)); + const ok = await confirm('\nCreate this event?'); + if (!ok) { + console.log('Cancelled.'); + return; + } + } + + const result = await calendarApi.createEvent(client, event); + await output(result, 'generic', globalOpts); + }); + + calendar + .command('delete [eventId]') + .description('Delete a calendar event') + .option('--input ', 'load options from JSON file (use "-" for stdin)') + .option('--yes', 'skip confirmation') + .action(async (eventId, options) => { + const globalOpts = program.opts(); + const jsonInput = await loadInput(options.input); + const id = eventId || jsonInput.eventId; + + if (!id) { + console.error('Error: eventId is required (positional arg or "eventId" in --input JSON)'); + process.exit(1); + } + + const { client, accountAlias } = await buildClient(globalOpts); + assertWriteAllowed(accountAlias, 'delete event'); + + if (needsConfirmation(globalOpts, options)) { + const ok = await confirm(`Delete event ${id}?`); + if (!ok) { + console.log('Cancelled.'); + return; + } + } + + const result = await calendarApi.deleteEvent(client, id); + await output(result, 'generic', globalOpts); + }); + + program.addCommand(calendar); +} diff --git a/src/node/cli/contacts.js b/src/node/cli/contacts.js new file mode 100644 index 0000000..9b78dcb --- /dev/null +++ b/src/node/cli/contacts.js @@ -0,0 +1,124 @@ +/** + * Contacts CLI commands — search and alias management. + * + * COMMAND STRUCTURE: + * contacts search — Search Graph API (personal contacts + People/GAL) + * contacts alias set — Create/update a local alias shortcut + * contacts alias remove — Remove a local alias + * contacts alias list — List all local aliases + * + * The "alias" subcommand is a nested Commander command group (Command within Command). + * Aliases are purely local (stored in ~/.outlook-cli/aliases.json) — they never + * touch the Graph API. The search command is the only one that calls Graph. + * + * DELEGATE ACCESS: Search supports --as for delegate access, same as mail/calendar. + */ + +import { Command } from 'commander'; +import { resolveAccount } from '../accounts/manager.js'; +import { createGraphClient } from '../graph/client.js'; +import * as contactsApi from '../graph/contacts.js'; +import { output } from '../output/render.js'; +import { loadInput, mergeInput } from '../input.js'; +import { setAlias, removeAlias, listAliases, resolveAlias } from '../contacts/aliases.js'; + +/** + * Build a Graph API client — same delegate-aware pattern as mail.js/calendar.js. + */ +async function buildClient(globalOpts) { + const delegateFor = globalOpts.as ? resolveAlias(globalOpts.as) : undefined; + const { account, alias } = await resolveAccount(globalOpts.account); + return createGraphClient(alias, account, { delegateFor }); +} + +export function registerContactsCommands(program) { + const contacts = new Command('contacts').description('Contacts and aliases'); + + /** + * Search: Queries Graph API for contacts across personal contacts and People API. + * Results are merged and deduplicated by graph/contacts.js. + */ + contacts + .command('search [query]') + .description('Search contacts and global address list') + .option('--input ', 'load options from JSON file (use "-" for stdin)') + .option('--top ', 'number of results') + .action(async (query, options) => { + const globalOpts = program.opts(); + const jsonInput = await loadInput(options.input); + const opts = mergeInput(jsonInput, { top: options.top }); + const q = query || opts.query; + + if (!q) { + console.error('Error: query is required (positional arg or "query" in --input JSON)'); + process.exit(1); + } + + const client = await buildClient(globalOpts); + const results = await contactsApi.searchContacts(client, q, { top: parseInt(opts.top || '25') }); + + await output(results, 'contactList', globalOpts); + }); + + // ── Alias subcommands ─────────────────────────────── + // Aliases are a nested command group — a Commander "Command" added to "contacts". + // These are purely local operations (no Graph API calls, no auth required). + + const alias = new Command('alias').description('Manage contact aliases (local shortcuts for email addresses)'); + + alias + .command('set ') + .description('Create or update an alias (e.g. "fred" → "freddie@outlook.com")') + .action(async (name, email) => { + const globalOpts = program.opts(); + try { + const result = setAlias(name, email); + await output(result, 'generic', globalOpts); + if (!globalOpts.json && !globalOpts.format) { + console.log(`✓ Alias "${result.name}" → ${result.email}`); + } + } catch (err) { + console.error(`Error: ${err.message}`); + process.exit(1); + } + }); + + alias + .command('remove ') + .description('Remove an alias') + .action(async (name) => { + const globalOpts = program.opts(); + try { + const result = removeAlias(name); + await output(result, 'generic', globalOpts); + if (!globalOpts.json && !globalOpts.format) { + console.log(`✓ Alias "${result.name}" removed (was → ${result.email})`); + } + } catch (err) { + console.error(`Error: ${err.message}`); + process.exit(1); + } + }); + + alias + .command('list') + .description('List all aliases') + .action(async () => { + const globalOpts = program.opts(); + const aliases = listAliases(); + await output(aliases, 'aliasList', globalOpts); + if (!globalOpts.json && !globalOpts.format) { + if (aliases.length === 0) { + console.log('No aliases configured. Add one with: outlook-cli contacts alias set fred freddie@outlook.com'); + } else { + console.log('\nAliases:'); + for (const a of aliases) { + console.log(` ${a.name} → ${a.email}`); + } + } + } + }); + + contacts.addCommand(alias); + program.addCommand(contacts); +} diff --git a/src/node/cli/doctor.js b/src/node/cli/doctor.js new file mode 100644 index 0000000..187fed6 --- /dev/null +++ b/src/node/cli/doctor.js @@ -0,0 +1,224 @@ +/** + * Health check command — `outlook-cli doctor` + * + * Validates the entire outlook-cli setup and reports each check with ✅/❌ + * and fix instructions. Designed for first-time setup and troubleshooting. + * + * Checks performed: + * 1. Node.js version >= 20 + * 2. Config directory exists and is writable + * 3. accounts.json parseable + * 4. Each account has required fields + * 5. Token cache decryptable for each account + * 6. Token not expired (silent refresh works) + * 7. Graph API reachable (GET /me) + * 8. Required scopes granted + * 9. Version staleness check + */ + +import { existsSync, accessSync, constants } from 'fs'; +import { join } from 'path'; +import { getAccountManager } from '../accounts/manager.js'; +import { createMsalClient } from '../auth/msal-client.js'; +import { versionInfo } from '../version.js'; + +const CONFIG_DIR = join(process.env.HOME || process.env.USERPROFILE || '', '.outlook-cli'); + +/** + * Run all health checks and return structured results. + * @returns {{ checks: Array<{name: string, status: 'pass'|'fail'|'warn', message: string, fix?: string}> }} + */ +export async function runDoctor(opts = {}) { + const checks = []; + + // 1. Node.js version + const nodeVersion = process.versions.node; + const major = parseInt(nodeVersion.split('.')[0], 10); + checks.push(major >= 20 + ? { name: 'Node.js version', status: 'pass', message: `v${nodeVersion}` } + : { name: 'Node.js version', status: 'fail', message: `v${nodeVersion} — requires >= 20`, + fix: 'Install Node.js 20+ from https://nodejs.org/' } + ); + + // 2. Config directory + if (existsSync(CONFIG_DIR)) { + try { + accessSync(CONFIG_DIR, constants.W_OK); + checks.push({ name: 'Config directory', status: 'pass', message: CONFIG_DIR }); + } catch { + checks.push({ name: 'Config directory', status: 'fail', message: `${CONFIG_DIR} — not writable`, + fix: `Check permissions on ${CONFIG_DIR}` }); + } + } else { + checks.push({ name: 'Config directory', status: 'warn', message: `${CONFIG_DIR} — not created yet`, + fix: 'Run `outlook-cli account add` to initialize.' }); + } + + // 3. Accounts file + const accountsFile = join(CONFIG_DIR, 'accounts.json'); + let manager; + try { + manager = getAccountManager(); + const accounts = manager.listAccounts(); + if (accounts.length === 0) { + checks.push({ name: 'Accounts configured', status: 'warn', message: 'No accounts configured', + fix: 'Run `outlook-cli account add --client-id ` to add an account.' }); + } else { + checks.push({ name: 'Accounts configured', status: 'pass', + message: `${accounts.length} account(s): ${accounts.map(a => a.alias).join(', ')}` }); + } + } catch (err) { + checks.push({ name: 'Accounts file', status: 'fail', message: err.message, + fix: 'Check accounts.json for syntax errors, or delete it and reconfigure.' }); + // Can't continue checking accounts + return { checks }; + } + + // 4-8. Per-account checks + const accounts = manager.listAccounts(); + for (const account of accounts) { + const alias = account.alias; + const prefix = `[${alias}]`; + + // Required fields + if (!account.clientId) { + checks.push({ name: `${prefix} Client ID`, status: 'fail', message: 'Missing clientId', + fix: `Run \`outlook-cli account add ${alias} --client-id \`` }); + continue; + } + checks.push({ name: `${prefix} Client ID`, status: 'pass', message: account.clientId.substring(0, 8) + '...' }); + + // Token cache + try { + const cacheFile = join(CONFIG_DIR, `cache-${alias}.enc`); + if (!existsSync(cacheFile)) { + checks.push({ name: `${prefix} Token cache`, status: 'warn', message: 'No cached token', + fix: `Run \`outlook-cli auth login --account ${alias}\`` }); + continue; + } + checks.push({ name: `${prefix} Token cache`, status: 'pass', message: 'Cache file exists' }); + } catch (err) { + checks.push({ name: `${prefix} Token cache`, status: 'fail', message: err.message, + fix: `Run \`outlook-cli auth login --account ${alias}\`` }); + } + + // Token acquisition (silent refresh) — only if we can reach MSAL + if (!opts.offline) { + try { + const msalClient = await createMsalClient(alias, account.clientId, account.tenantId || 'common'); + + const msalAccounts = await msalClient.getTokenCache().getAllAccounts(); + if (msalAccounts.length === 0) { + checks.push({ name: `${prefix} Authentication`, status: 'fail', message: 'No MSAL accounts in cache', + fix: `Run \`outlook-cli auth login --account ${alias}\`` }); + continue; + } + + const result = await msalClient.acquireTokenSilent({ + account: msalAccounts[0], + scopes: ['User.Read'], + }); + + if (result?.accessToken) { + checks.push({ name: `${prefix} Authentication`, status: 'pass', message: 'Token acquired (silent)' }); + + // Check scopes + const scopes = result.scopes || []; + const requiredScopes = ['Mail.Read', 'Mail.ReadWrite']; + const missing = requiredScopes.filter(s => !scopes.some(gs => gs.toLowerCase() === s.toLowerCase())); + if (missing.length > 0) { + checks.push({ name: `${prefix} Scopes`, status: 'warn', + message: `Missing: ${missing.join(', ')}`, + fix: 'Add these permissions in your Azure App Registration.' }); + } else { + checks.push({ name: `${prefix} Scopes`, status: 'pass', + message: scopes.filter(s => !s.startsWith('openid') && !s.startsWith('profile')).join(', ') }); + } + + // Graph API reachability + try { + const response = await fetch('https://graph.microsoft.com/v1.0/me', { + headers: { Authorization: `Bearer ${result.accessToken}` }, + }); + if (response.ok) { + const me = await response.json(); + checks.push({ name: `${prefix} Graph API`, status: 'pass', + message: `Connected as ${me.displayName || me.userPrincipalName || 'user'}` }); + } else { + checks.push({ name: `${prefix} Graph API`, status: 'fail', + message: `HTTP ${response.status}`, + fix: 'Check your network connection and Azure App permissions.' }); + } + } catch (err) { + checks.push({ name: `${prefix} Graph API`, status: 'fail', + message: err.message, + fix: 'Check your internet connection.' }); + } + } else { + checks.push({ name: `${prefix} Authentication`, status: 'fail', message: 'Silent token acquisition failed', + fix: `Run \`outlook-cli auth login --account ${alias}\`` }); + } + } catch (err) { + checks.push({ name: `${prefix} Authentication`, status: 'fail', + message: err.message, + fix: `Run \`outlook-cli auth login --account ${alias}\`` }); + } + } + } + + // 9. Version check + checks.push({ name: 'Version', status: 'pass', message: `${versionInfo.version} (node)` }); + + return { checks }; +} + +/** + * Format doctor results for terminal display. + */ +export function formatDoctorResults(results) { + const lines = []; + lines.push(''); + lines.push(' outlook-cli health check'); + lines.push(' ────────────────────────────────────────────'); + + for (const check of results.checks) { + const icon = check.status === 'pass' ? '✅' : check.status === 'warn' ? '⚠️' : '❌'; + lines.push(` ${icon} ${check.name}: ${check.message}`); + if (check.fix && check.status !== 'pass') { + lines.push(` Fix: ${check.fix}`); + } + } + + const passed = results.checks.filter(c => c.status === 'pass').length; + const failed = results.checks.filter(c => c.status === 'fail').length; + const warned = results.checks.filter(c => c.status === 'warn').length; + + lines.push(''); + lines.push(` ${passed} passed, ${warned} warnings, ${failed} failed`); + lines.push(''); + + return lines.join('\n'); +} + +/** + * Register the doctor command on the CLI program. + */ +export function registerDoctorCommand(program) { + program + .command('doctor') + .description('Check outlook-cli setup and diagnose issues') + .option('--offline', 'Skip network checks (token refresh, Graph API)') + .action(async (options) => { + const globalOpts = program.opts(); + const results = await runDoctor({ offline: options.offline }); + + if (globalOpts.json || globalOpts.format === 'json') { + console.log(JSON.stringify(results, null, 2)); + } else { + console.log(formatDoctorResults(results)); + } + + const hasFail = results.checks.some(c => c.status === 'fail'); + if (hasFail) process.exitCode = 1; + }); +} diff --git a/src/node/cli/drive.js b/src/node/cli/drive.js new file mode 100644 index 0000000..26b5f77 --- /dev/null +++ b/src/node/cli/drive.js @@ -0,0 +1,202 @@ +/** + * OneDrive CLI commands — list, upload, download, search files. + * + * These commands manage files on the OneDrive account paired with the + * authenticated Outlook account. Uses the same authentication and + * account system as mail and calendar commands. + * + * PERMISSIONS: + * - Files.Read: list, download, search (works on read-only accounts) + * - Files.ReadWrite: upload, create folders, share links + * + * The write-guard in security/write-guard.js blocks upload/create operations + * on read-only accounts at the CLI level, and the scope restriction at the + * token level prevents the Graph API call from succeeding even if the guard + * is bypassed. + */ + +import { Command } from 'commander'; +import { resolveAccount } from '../accounts/manager.js'; +import { createGraphClient } from '../graph/client.js'; +import * as driveApi from '../graph/drive.js'; +import { output, resolveFormat } from '../output/render.js'; +import { assertWriteAllowed } from '../security/write-guard.js'; +import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { basename, join } from 'node:path'; + +/** Format byte count as human-readable file size. */ +function formatFileSize(bytes) { + if (!bytes || bytes === 0) return '0 B'; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; +} + +/** + * Build a GraphClient from the global CLI options. + */ +async function buildClient(globalOpts) { + const account = resolveAccount(globalOpts.account); + const client = await createGraphClient({ + account: account.alias, + delegateFor: globalOpts.as, + }); + return { client, account }; +} + +/** + * Register the `drive` command group on the provided Commander program. + * + * @param {import('commander').Command} program - The root Commander program + */ +export function registerDriveCommands(program) { + const drive = program + .command('drive') + .description('OneDrive file operations'); + + // drive list [path] + drive + .command('list [path]') + .description('List files and folders') + .option('--top ', 'max items to return', '50') + .action(async (path, options) => { + const globalOpts = program.opts(); + const { client } = await buildClient(globalOpts); + const result = await driveApi.listFiles(client, path || '/', { + top: parseInt(options.top), + }); + + // Format for display + if (globalOpts.format === 'json' || globalOpts.json) { + await output(result.items, 'driveList', globalOpts); + } else { + // Text format: table with name, size, type, modified + const lines = ['']; + lines.push(' Name Size Type Modified'); + lines.push(' ' + '─'.repeat(75)); + for (const item of result.items) { + const name = (item.name || '').padEnd(40).slice(0, 40); + const size = item.folder ? '—' : formatFileSize(item.size); + const type = item.folder ? '📁 Folder' : '📄 File '; + const modified = item.lastModifiedDateTime + ? new Date(item.lastModifiedDateTime).toLocaleDateString() + : '—'; + lines.push(` ${name} ${size.padStart(10)} ${type} ${modified}`); + } + lines.push(`\n ${result.items.length} item(s)`); + if (result.nextLink) lines.push(' (more items available — increase --top)'); + console.log(lines.join('\n')); + } + }); + + // drive upload [remotePath] + drive + .command('upload [remotePath]') + .description('Upload a file to OneDrive') + .action(async (localFile, remotePath, options) => { + const globalOpts = program.opts(); + const { client, account } = await buildClient(globalOpts); + assertWriteAllowed(account.alias, 'upload file'); + + const content = readFileSync(localFile); + if (content.length > 4 * 1024 * 1024) { + console.error('Error: File exceeds 4MB. Large file upload sessions are not yet supported.'); + process.exit(1); + } + + const dest = remotePath || basename(localFile); + const result = await driveApi.uploadFile(client, dest, content); + console.log(`✓ Uploaded: ${dest} (${formatFileSize(content.length)})`); + if (globalOpts.format === 'json' || globalOpts.json) { + await output(result, 'generic', globalOpts); + } + }); + + // drive download [localPath] + drive + .command('download [localPath]') + .description('Download a file from OneDrive') + .option('--output-dir ', 'output directory', '.') + .action(async (itemId, localPath, options) => { + const globalOpts = program.opts(); + const { client } = await buildClient(globalOpts); + + // Get metadata first to know the filename + const meta = await driveApi.getFileMetadata(client, itemId); + const filename = localPath || meta.name || 'download'; + const outputDir = options.outputDir || '.'; + + mkdirSync(outputDir, { recursive: true }); + const fullPath = join(outputDir, filename); + + const response = await driveApi.downloadFile(client, itemId); + writeFileSync(fullPath, response); + console.log(`✓ Downloaded: ${fullPath} (${formatFileSize(meta.size)})`); + }); + + // drive search + drive + .command('search ') + .description('Search for files') + .option('--top ', 'max results', '25') + .action(async (query, options) => { + const globalOpts = program.opts(); + const { client } = await buildClient(globalOpts); + const result = await driveApi.searchFiles(client, query, { + top: parseInt(options.top), + }); + + if (globalOpts.format === 'json' || globalOpts.json) { + await output(result.items, 'driveList', globalOpts); + } else { + console.log(`\n ${result.items.length} result(s) for "${query}":\n`); + for (const item of result.items) { + const size = item.folder ? 'folder' : formatFileSize(item.size); + console.log(` ${item.name} (${size})`); + if (item.webUrl) console.log(` ${item.webUrl}`); + } + } + }); + + // drive mkdir [parentPath] + drive + .command('mkdir [parentPath]') + .description('Create a folder') + .action(async (name, parentPath) => { + const globalOpts = program.opts(); + const { client, account } = await buildClient(globalOpts); + assertWriteAllowed(account.alias, 'create folder'); + + const result = await driveApi.createFolder(client, name, parentPath); + console.log(`✓ Created folder: ${name}`); + if (globalOpts.format === 'json' || globalOpts.json) { + await output(result, 'generic', globalOpts); + } + }); + + // drive share + drive + .command('share ') + .description('Create a sharing link') + .option('--type ', 'link type: view or edit', 'view') + .option('--scope ', 'link scope: anonymous or organization', 'organization') + .action(async (itemId, options) => { + const globalOpts = program.opts(); + const { client, account } = await buildClient(globalOpts); + assertWriteAllowed(account.alias, 'create sharing link'); + + const result = await driveApi.createShareLink(client, itemId, { + type: options.type, + scope: options.scope, + }); + + const link = result?.link?.webUrl || result?.link?.webURL || '(no URL returned)'; + console.log(`✓ Sharing link (${options.type}): ${link}`); + if (globalOpts.format === 'json' || globalOpts.json) { + await output(result, 'generic', globalOpts); + } + }); + + return drive; +} diff --git a/src/node/cli/index.js b/src/node/cli/index.js new file mode 100644 index 0000000..734ccc9 --- /dev/null +++ b/src/node/cli/index.js @@ -0,0 +1,183 @@ +/** + * CLI program setup — central wiring of all subcommands and global options. + * + * Global options defined here are available to every subcommand via `program.opts()`. + * Each subcommand module (auth, mail, etc.) registers itself on the program instance. + * + * Option resolution order (in each subcommand): + * 1. CLI flags (highest priority) + * 2. JSON input file (--input) + * 3. Account defaults (~/.outlook-cli/accounts.json) + * 4. Environment variables (OUTLOOK_CLI_*) + */ +import { Command } from 'commander'; +import { createRequire } from 'module'; +import { registerAuthCommands } from './auth.js'; +import { registerAccountCommands } from './account.js'; +import { registerMailCommands } from './mail.js'; +import { registerCalendarCommands } from './calendar.js'; +import { registerContactsCommands } from './contacts.js'; +import { registerDriveCommands } from './drive.js'; +import { registerWatchCommands } from './watch.js'; +import { registerDoctorCommand } from './doctor.js'; +import { registerUpgradeCommand, runStartupMigration } from './upgrade.js'; +import { registerLogCommands } from './log.js'; +import { registerTelemetryCommands } from './telemetry.js'; +import { stampVersion } from '../version.js'; +import { OutlookCliError, formatError, formatErrorJson } from '../errors.js'; +import { initTelemetry } from '../telemetry/collector.js'; + +const require = createRequire(import.meta.url); +const versionInfo = require('../../shared/version.json'); + +export function createProgram() { + const program = new Command(); + + program + .name('outlook-cli') + .description('Cross-platform CLI for Microsoft Outlook via Graph API') + .version(`${versionInfo.version} (node)`) + // --account selects which authenticated account to use (each has its own token cache) + .option('-a, --account ', 'use specific account (default: default account)') + // --as enables delegate access: Graph API calls target /users/{email} instead of /me + .option('--as ', 'access another user\'s mailbox as delegate') + .option('--format ', 'output format: text, json, markdown, html (default: text)') + .option('--json', 'shorthand for --format json') + .option('--output ', 'write output to file instead of stdout') + .option('--verbose', 'verbose logging') + .option('--telemetry', 'enable telemetry (also OUTLOOK_CLI_TELEMETRY=1)'); + + // Initialize telemetry early — before any commands run. + // DB persistence is wired lazily in the action wrapper (async context). + const telemetryEnabled = process.argv.includes('--telemetry') || + process.env.OUTLOOK_CLI_TELEMETRY === '1'; + if (telemetryEnabled) { + initTelemetry({ enabled: true }); + } + + // Registration order doesn't matter — Commander dispatches by command name. + // Grouped logically: auth/account first (setup), then data commands. + registerAuthCommands(program); + registerAccountCommands(program); + registerMailCommands(program); + registerCalendarCommands(program); + registerContactsCommands(program); + registerDriveCommands(program); + registerWatchCommands(program); + registerDoctorCommand(program); + registerUpgradeCommand(program); + registerLogCommands(program); + registerTelemetryCommands(program); + + // G1: Run startup migration before any command executes. + // This ensures config files and database are at current schema. + program.hook('preAction', (thisCommand) => { + // Skip migration check for the upgrade command itself (it does its own checks) + const commandName = thisCommand.args?.[0] || thisCommand.name?.(); + if (commandName === 'upgrade') return; + + try { + const verbose = thisCommand.opts?.()?.verbose || false; + const { messages, errors } = runStartupMigration({ verbose }); + + for (const msg of messages) { + console.error(`ℹ️ ${msg}`); + } + for (const err of errors) { + console.error(`⚠️ ${err}`); + } + } catch (error) { + // G3: Breaking change detected — show actionable error and exit + handleCommandError(error, thisCommand); + process.exit(process.exitCode || 2); + } + }); + + // Install global error handler on all commands + installGlobalErrorHandler(program); + + return program; +} + +/** + * Wrap every command's action handler with error handling. + * Catches OutlookCliError (formatted with fix instructions) and + * generic errors (formatted with stack trace in verbose mode). + */ +function installGlobalErrorHandler(program) { + function wrapCommand(cmd) { + const listeners = cmd.listeners('command:*'); + + // Wrap the action handler if one exists + const originalAction = cmd._actionHandler; + if (originalAction) { + cmd._actionHandler = async (...args) => { + const { getTelemetry } = await import('../telemetry/collector.js'); + const telemetry = getTelemetry(); + + // Wire SQLite persistence on first action (async context available) + if (telemetry.enabled && !telemetry.db) { + try { + const { getDatabase } = await import('../db/database.js'); + telemetry.db = getDatabase(); + telemetry._ensureTable(); + } catch { + // SQLite unavailable — in-memory only + } + } + + const commandName = cmd.name?.() || 'unknown'; + const startTime = Date.now(); + + telemetry.emit({ event: 'cli.command', command: commandName }); + + try { + const result = await originalAction.apply(cmd, args); + telemetry.emit({ + event: 'cli.complete', + command: commandName, + durationMs: Date.now() - startTime, + success: true, + }); + telemetry.stop(); // Flush pending events to SQLite + return result; + } catch (error) { + telemetry.emit({ + event: 'cli.complete', + command: commandName, + durationMs: Date.now() - startTime, + success: false, + metadata: { error: error.message }, + }); + telemetry.stop(); // Flush pending events to SQLite + handleCommandError(error, cmd); + } + }; + } + + // Recursively wrap subcommands + for (const sub of cmd.commands) { + wrapCommand(sub); + } + } + + wrapCommand(program); +} + +/** + * Handle a command error — format and print to stderr, set exit code. + */ +function handleCommandError(error, cmd) { + const globalOpts = cmd.optsWithGlobals?.() || cmd.opts?.() || {}; + const verbose = globalOpts.verbose || false; + const isJson = globalOpts.json || globalOpts.format === 'json'; + + if (isJson) { + console.error(JSON.stringify(formatErrorJson(error), null, 2)); + } else { + console.error(formatError(error, { verbose })); + } + + process.exitCode = error instanceof OutlookCliError ? 1 : 2; +} + diff --git a/src/node/cli/log.js b/src/node/cli/log.js new file mode 100644 index 0000000..1455c55 --- /dev/null +++ b/src/node/cli/log.js @@ -0,0 +1,154 @@ +/** + * Log commands — `outlook-cli log search` and `outlook-cli log summary`. + * + * `log search` queries the operations log with flexible filters. + * `log summary` shows telemetry-style aggregates over a configurable window. + */ + +import { listOperations, listByCorrelationId, getSummary } from '../db/logger.js'; +import { getDatabase } from '../db/database.js'; + +// ── Text formatters ── + +function formatOperationsTable(ops) { + if (!ops || ops.length === 0) return 'No operations found.'; + + const lines = ['']; + const header = [ + pad('Started', 20), + pad('Account', 22), + pad('Command', 18), + pad('Status', 10), + pad('Duration', 10), + pad('Correlation', 12), + ].join(' '); + lines.push(header); + lines.push('─'.repeat(96)); + + for (const op of ops) { + lines.push([ + pad(op.started_at || '', 20), + pad(truncate(op.account || '', 20), 22), + pad(truncate(op.command || '', 16), 18), + pad(op.status || '', 10), + pad(op.duration_ms != null ? `${op.duration_ms}ms` : '-', 10), + pad(truncate(op.correlation_id || '', 10), 12), + ].join(' ')); + } + + lines.push(''); + lines.push(`${ops.length} operation(s)`); + return lines.join('\n'); +} + +function formatSummaryText(summary) { + const lines = ['']; + lines.push(' outlook-cli operations summary'); + lines.push(` Period: last ${summary.days} day(s) (since ${summary.since.substring(0, 10)})`); + lines.push(' ────────────────────────────────────────────'); + lines.push(''); + lines.push(` Total operations: ${summary.total}`); + lines.push(` Errors: ${summary.errors} (${summary.errorRate}%)`); + lines.push(` Avg latency: ${summary.avgLatencyMs != null ? summary.avgLatencyMs + 'ms' : 'n/a'}`); + lines.push(` Throttle events: ${summary.throttled}`); + lines.push(''); + + if (summary.perDay.length > 0) { + lines.push(' Operations per day:'); + for (const d of summary.perDay) { + const bar = '█'.repeat(Math.min(Math.ceil(d.count / 2), 40)); + lines.push(` ${d.day} ${pad(String(d.count), 5)} ${bar}`); + } + lines.push(''); + } + + if (summary.topOperations.length > 0) { + lines.push(' Top commands:'); + for (const op of summary.topOperations) { + lines.push(` ${pad(op.command, 25)} ${op.count}`); + } + lines.push(''); + } + + if (summary.perAccount.length > 0) { + lines.push(' Per-account breakdown:'); + lines.push(` ${pad('Account', 25)} ${pad('Total', 8)} ${pad('Errors', 8)} ${pad('Avg ms', 8)}`); + lines.push(' ' + '─'.repeat(51)); + for (const a of summary.perAccount) { + lines.push(` ${pad(truncate(a.account, 23), 25)} ${pad(String(a.total), 8)} ${pad(String(a.errors), 8)} ${pad(a.avg_latency_ms != null ? String(Math.round(a.avg_latency_ms)) : '-', 8)}`); + } + lines.push(''); + } + + return lines.join('\n'); +} + +function truncate(str, max) { + if (!str) return ''; + return str.length > max ? str.substring(0, max - 1) + '…' : str; +} + +function pad(str, width) { + str = str || ''; + return str.length >= width ? str.substring(0, width) : str + ' '.repeat(width - str.length); +} + +// ── Command registration ── + +export function registerLogCommands(program) { + const log = program + .command('log') + .description('Search and summarize operations log'); + + log + .command('search') + .description('Search the operations log') + .option('-a, --account ', 'filter by account') + .option('-o, --operation ', 'filter by command/operation name') + .option('-s, --since ', 'only operations after this date (ISO 8601)') + .option('--status ', 'filter by status (started, completed, failed)') + .option('--correlation-id ', 'find all operations with this correlation ID') + .option('-n, --limit ', 'max results (default: 50)', '50') + .action((options) => { + const globalOpts = program.opts(); + const isJson = globalOpts.json || globalOpts.format === 'json'; + + let results; + if (options.correlationId) { + results = listByCorrelationId(options.correlationId); + } else { + results = listOperations({ + account: options.account, + command: options.operation, + status: options.status, + since: options.since, + top: parseInt(options.limit, 10), + }); + } + + if (isJson) { + console.log(JSON.stringify(results, null, 2)); + } else { + console.log(formatOperationsTable(results)); + } + }); + + log + .command('summary') + .description('Show operations summary and statistics') + .option('-d, --days ', 'number of days to summarize (default: 7)', '7') + .option('--json', 'output as JSON') + .action((options) => { + const globalOpts = program.opts(); + const isJson = options.json || globalOpts.json || globalOpts.format === 'json'; + const days = parseInt(options.days, 10); + + const summary = getSummary(days); + + if (isJson) { + console.log(JSON.stringify(summary, null, 2)); + } else { + console.log(formatSummaryText(summary)); + } + }); +} diff --git a/src/node/cli/mail.js b/src/node/cli/mail.js new file mode 100644 index 0000000..503d440 --- /dev/null +++ b/src/node/cli/mail.js @@ -0,0 +1,844 @@ +/** + * Mail CLI commands — inbox, read, search, draft, reply, forward, send, etc. + * + * DELEGATE ACCESS: The --as global flag enables send-on-behalf-of access. + * When --as is specified, buildClient() resolves the alias to an email and + * passes it as delegateFor to createGraphClient, which sets userPath to + * "/users/{email}" instead of "/me". This routes all Graph API calls through + * the delegate's mailbox. + * + * MAIL SEND SAFETY GATE: The `mail send` command intentionally REQUIRES --as. + * Direct sending from your own account is disabled for safety — this prevents + * accidental sends from automation scripts. The rationale: drafts are safe to + * create (they can be deleted), but sends are irreversible. + * + * CONFIRMATION FLOW: Write operations (draft, reply, forward, send, move) show + * a confirmation prompt before executing. Confirmations are skipped when: + * - --yes flag is set (for scripting) + * - --output flag is set (output is going to a file, not interactive) + * - Format is not "text" (JSON/markdown/html imply non-interactive use) + */ + +import { Command } from 'commander'; +import { resolveAccount } from '../accounts/manager.js'; +import { createGraphClient } from '../graph/client.js'; +import * as mailApi from '../graph/mail.js'; +import { output, resolveFormat } from '../output/render.js'; +import { loadInput, mergeInput, resolveBody } from '../input.js'; +import { resolveAlias } from '../contacts/aliases.js'; +import { saveResultIds, resolveId } from '../output/last-results.js'; +import { savePageState, getPageState } from '../output/page-state.js'; +import { createInterface } from 'readline'; +import { assertWriteAllowed } from '../security/write-guard.js'; +import { createRedactor } from '../security/redactor.js'; + +/** Format byte count as human-readable file size (e.g., "2.4 MB"). */ +function formatFileSize(bytes) { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; +} + +/** + * Prompt the user for yes/no confirmation. Returns true if user typed y/yes. + * Uses readline to avoid blocking stdin for the rest of the process. + */ +function confirm(question) { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + return new Promise((resolve) => { + rl.question(`${question} (y/N) `, (answer) => { + rl.close(); + resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes'); + }); + }); +} + +/** + * Resolve a single recipient token, handling the special "me" keyword. + * + * Resolution order: + * 1. Contains "@" → literal email address + * 2. "me" keyword → current account's email (requires accountEmail) + * 3. Alias lookup → ~/.outlook-cli/aliases.json + * + * The "me" keyword is resolved here (not in generic alias resolution) because + * it depends on account context — which account is active via --account flag. + */ +function resolveRecipientToken(token, accountEmail) { + const trimmed = token.trim(); + if (trimmed.includes('@')) return trimmed; + + if (trimmed.toLowerCase() === 'me') { + if (accountEmail) return accountEmail; + throw new Error( + '"me" cannot be resolved — no email on file for this account. ' + + 'Run `outlook-cli auth login` first, or use a full email address.' + ); + } + + return resolveAlias(trimmed); +} + +/** + * Parse recipients from CLI string or JSON array into Graph API format. + * + * Each recipient token is resolved through the alias system — if a token + * contains "@" it's treated as an email; otherwise it's looked up as an alias. + * This lets users write `--to fred,jane` instead of full email addresses. + * The special keyword "me" resolves to the current account's email. + * + * @param {string|Array} value - Comma-separated string or array of email/alias tokens + * @param {string} [accountEmail] - Email of the active account (for "me" resolution) + * @returns {Array<{emailAddress:{address:string}}>} Graph API recipient objects + */ +function parseRecipients(value, accountEmail) { + if (!value) return []; + if (Array.isArray(value)) { + return value.map(a => { + const resolved = resolveRecipientToken(a, accountEmail); + return { emailAddress: { address: resolved } }; + }); + } + // String path: split on comma, resolve each token, then convert + return value.split(',').map(token => { + const resolved = resolveRecipientToken(token, accountEmail); + return { emailAddress: { address: resolved.trim() } }; + }); +} + +/** + * Determine whether a write command should show a confirmation prompt. + * + * Skips confirmation when: + * - --yes flag is present (explicit opt-out) + * - --output flag is present (non-interactive file output) + * - format is not "text" (JSON/markdown/html imply scripted usage) + */ +function needsConfirmation(globalOpts, opts) { + if (opts.yes) return false; + if (globalOpts.output) return false; + return resolveFormat(globalOpts) === 'text'; +} + +/** + * Build a Graph API client for the current account, with optional delegate access. + * + * Returns both the client and account email so callers can resolve "me" keyword. + * + * Order of operations: + * 1. Resolve --as alias to an email address (if delegate mode) + * 2. Resolve account alias to account config (clientId, tenantId, etc.) + * 3. Create a Graph client with the appropriate userPath ("/me" or "/users/{email}") + */ +async function buildClient(globalOpts) { + const delegateFor = globalOpts.as ? resolveAlias(globalOpts.as) : undefined; + const { account, alias } = await resolveAccount(globalOpts.account); + const client = await createGraphClient(alias, account, { delegateFor }); + return { client, accountEmail: account.email, accountAlias: alias }; +} + +export function registerMailCommands(program) { + const mail = new Command('mail').description('Email operations'); + + // ── Read commands ────────────────────────────────────── + + mail + .command('inbox') + .description('List inbox messages') + .option('--input ', 'load options from JSON file (use "-" for stdin)') + .option('--top ', 'number of messages') + .option('--skip ', 'skip first N messages (for pagination)') + .option('--page ', 'page navigation: next or prev') + .option('--unread', 'show unread only') + .option('--folder ', 'folder name (default: Inbox)') + .option('--redact', 'redact PII from subject and preview') + .action(async (options) => { + const globalOpts = program.opts(); + const jsonInput = await loadInput(options.input); + const opts = mergeInput(jsonInput, { + top: options.top, skip: options.skip, page: options.page, + unread: options.unread, folder: options.folder, + }); + + const { client } = await buildClient(globalOpts); + const top = parseInt(opts.top || '25'); + let skip = parseInt(opts.skip || '0'); + + // Handle --page next/prev using saved cursor state + if (opts.page) { + const saved = getPageState('mail-inbox'); + if (opts.page === 'next') { + if (!saved || !saved.nextLink) { + console.error('No more pages. You are at the last page of results.'); + process.exit(1); + } + skip = saved.currentSkip + saved.top; + } else if (opts.page === 'prev') { + if (!saved || saved.currentSkip <= 0) { + console.error('Already at the first page.'); + process.exit(1); + } + skip = Math.max(0, saved.currentSkip - saved.top); + } else { + console.error(`Invalid --page value: "${opts.page}". Use "next" or "prev".`); + process.exit(1); + } + } + + const result = await mailApi.listMessages(client, { + folder: opts.folder || 'Inbox', + top, + skip, + unreadOnly: opts.unread, + }); + + // Save pagination state for --page next/prev + savePageState('mail-inbox', { + nextLink: result.nextLink, + currentSkip: skip, + top, + resultCount: result.messages.length, + }); + + saveResultIds(result.messages); + + // Redact PII from subjects and previews if requested + if (options.redact) { + const redactor = createRedactor(); + for (const msg of result.messages) { + if (msg.subject) msg.subject = redactor.redact(msg.subject).redacted; + if (msg.bodyPreview) msg.bodyPreview = redactor.redact(msg.bodyPreview).redacted; + } + } + + // Show pagination info if there are more pages + if (result.nextLink) { + await output(result.messages, 'mailList', globalOpts); + const page = Math.floor(skip / top) + 1; + console.error(`\nPage ${page} — use --page next for more results`); + } else { + await output(result.messages, 'mailList', globalOpts); + if (skip > 0) { + const page = Math.floor(skip / top) + 1; + console.error(`\nPage ${page} (last page) — use --page prev to go back`); + } + } + }); + + mail + .command('read [messageId]') + .description('Read a message') + .option('--input ', 'load options from JSON file (use "-" for stdin)') + .option('--plain', 'request plain text body') + .option('--body-preview', 'show only the body preview (~255 chars) instead of full body') + .option('--truncate ', 'truncate body to specified number of characters') + .option('--attachments', 'include attachment list in output') + .option('--redact', 'redact PII (emails, phones, SSNs, etc.) from output') + .action(async (messageId, options) => { + const globalOpts = program.opts(); + const jsonInput = await loadInput(options.input); + const opts = mergeInput(jsonInput, { + plain: options.plain, bodyPreview: options.bodyPreview, truncate: options.truncate, + attachments: options.attachments, + }); + const id = resolveId(messageId || opts.messageId); + + if (!id) { + console.error('Error: messageId is required (positional arg or "messageId" in --input JSON)'); + process.exit(1); + } + + const { client } = await buildClient(globalOpts); + const message = await mailApi.getMessage(client, id, { preferPlainText: opts.plain }); + + // Apply body transformations for large email handling + if (opts.bodyPreview && message.bodyPreview) { + message.body = { contentType: 'Text', content: message.bodyPreview }; + } else if (opts.truncate && message.body?.content) { + const maxChars = parseInt(opts.truncate); + if (message.body.content.length > maxChars) { + message.body.content = message.body.content.slice(0, maxChars) + '\n\n... [truncated at ' + maxChars + ' chars, full body is ' + message.body.content.length + ' chars]'; + } + } + + // Fetch attachment metadata if requested or if message has attachments in JSON mode + if (opts.attachments || (message.hasAttachments && globalOpts.format === 'json')) { + try { + message.attachmentList = await mailApi.listAttachments(client, id); + } catch (err) { + console.error(`Warning: Could not fetch attachments: ${err.message}`); + message.attachmentList = []; + } + } + + // Redact PII if requested + if (opts.redact || options.redact) { + const redactor = createRedactor(); + if (message.body?.content) { + const { redacted } = redactor.redact(message.body.content); + message.body.content = redacted; + } + if (message.bodyPreview) { + const { redacted } = redactor.redact(message.bodyPreview); + message.bodyPreview = redacted; + } + if (message.subject) { + const { redacted } = redactor.redact(message.subject); + message.subject = redacted; + } + // Include mapping in JSON output for agent round-trips + if (globalOpts.format === 'json') { + message._redactionMapping = redactor.getMapping(); + } + } + + await output(message, 'mailDetail', globalOpts); + }); + + mail + .command('search [query]') + .description('Search messages') + .option('--input ', 'load options from JSON file (use "-" for stdin)') + .option('--top ', 'number of results') + .option('--skip ', 'skip first N results (for pagination)') + .option('--page ', 'page navigation: next or prev') + .option('--redact', 'redact PII from results') + .action(async (query, options) => { + const globalOpts = program.opts(); + const jsonInput = await loadInput(options.input); + const opts = mergeInput(jsonInput, { top: options.top, skip: options.skip, page: options.page }); + const q = query || opts.query; + + if (!q && !opts.page) { + console.error('Error: query is required (positional arg or "query" in --input JSON)'); + process.exit(1); + } + + const { client } = await buildClient(globalOpts); + const top = parseInt(opts.top || '25'); + let skip = parseInt(opts.skip || '0'); + + if (opts.page) { + const saved = getPageState('mail-search'); + if (opts.page === 'next') { + if (!saved || !saved.nextLink) { + console.error('No more pages. You are at the last page of results.'); + process.exit(1); + } + skip = saved.currentSkip + saved.top; + } else if (opts.page === 'prev') { + if (!saved || saved.currentSkip <= 0) { + console.error('Already at the first page.'); + process.exit(1); + } + skip = Math.max(0, saved.currentSkip - saved.top); + } else { + console.error(`Invalid --page value: "${opts.page}". Use "next" or "prev".`); + process.exit(1); + } + } + + const result = await mailApi.searchMessages(client, q || '', { + top, + skip, + }); + + savePageState('mail-search', { + nextLink: result.nextLink, + currentSkip: skip, + top, + resultCount: result.messages.length, + }); + + saveResultIds(result.messages); + + // Redact PII from results if requested + if (options.redact) { + const redactor = createRedactor(); + for (const msg of result.messages) { + if (msg.subject) msg.subject = redactor.redact(msg.subject).redacted; + if (msg.bodyPreview) msg.bodyPreview = redactor.redact(msg.bodyPreview).redacted; + } + } + + await output(result.messages, 'mailList', globalOpts); + + if (result.nextLink) { + const page = Math.floor(skip / top) + 1; + console.error(`\nPage ${page} — use --page next for more results`); + } else if (skip > 0) { + const page = Math.floor(skip / top) + 1; + console.error(`\nPage ${page} (last page) — use --page prev to go back`); + } + }); + + mail + .command('folders') + .description('List mail folders') + .option('--input ', 'load options from JSON file (use "-" for stdin)') + .action(async (options) => { + const globalOpts = program.opts(); + const { client } = await buildClient(globalOpts); + const folders = await mailApi.listFolders(client); + + await output(folders, 'folderList', globalOpts); + }); + + // ── Attachment commands ───────────────────────────────── + + mail + .command('attachments ') + .description('List attachments on a message') + .action(async (messageId, options) => { + const globalOpts = program.opts(); + const id = resolveId(messageId); + const { client } = await buildClient(globalOpts); + const attachments = await mailApi.listAttachments(client, id); + await output(attachments, 'attachmentList', globalOpts); + }); + + mail + .command('download-attachment ') + .description('Download an attachment to a file') + .option('--output-dir ', 'directory to save attachment (default: current directory)', '.') + .action(async (messageId, attachmentId, options) => { + const globalOpts = program.opts(); + const msgId = resolveId(messageId); + const { client } = await buildClient(globalOpts); + + const attachment = await mailApi.getAttachment(client, msgId, attachmentId); + if (!attachment || !attachment.contentBytes) { + console.error('Error: Attachment has no downloadable content.'); + process.exitCode = 1; + return; + } + + const { writeFileSync, mkdirSync } = await import('fs'); + const { join } = await import('path'); + + mkdirSync(options.outputDir, { recursive: true }); + const fileName = attachment.name || `attachment-${attachmentId}`; + const filePath = join(options.outputDir, fileName); + writeFileSync(filePath, Buffer.from(attachment.contentBytes, 'base64')); + console.log(`Downloaded: ${filePath} (${formatFileSize(attachment.size || 0)})`); + }); + + // ── Write commands ───────────────────────────────────── + + mail + .command('draft') + .description('Create a draft email') + .option('--input ', 'load options from JSON file (use "-" for stdin)') + .option('--to
', 'recipient email(s), comma-separated') + .option('--subject ', 'email subject') + .option('--body ', 'email body text') + .option('--body-file ', 'read body from file (text or HTML)') + .option('--body-content-type ', 'body content type: Text or HTML (auto-detected from .html/.htm file extension)') + .option('--cc ', 'CC recipients (comma-separated)') + .option('--bcc ', 'BCC recipients (comma-separated)') + .option('--importance ', 'importance: low, normal, high', 'normal') + .option('--attach ', 'attach file(s) to the draft') + .option('--yes', 'skip confirmation') + .action(async (options) => { + const globalOpts = program.opts(); + const jsonInput = await loadInput(options.input); + const opts = mergeInput(jsonInput, { + to: options.to, subject: options.subject, body: options.body, + bodyFile: options.bodyFile, bodyContentType: options.bodyContentType, + cc: options.cc, bcc: options.bcc, importance: options.importance, + attach: options.attach, yes: options.yes, + }); + + if (!opts.to) { + console.error('Error: --to is required (or "to" in --input JSON)'); + process.exit(1); + } + if (!opts.subject) { + console.error('Error: --subject is required (or "subject" in --input JSON)'); + process.exit(1); + } + + const { client, accountEmail, accountAlias } = await buildClient(globalOpts); + assertWriteAllowed(accountAlias, 'create draft'); + + const bodyResult = await resolveBody(opts); + + const draft = { + subject: opts.subject, + body: bodyResult, + toRecipients: parseRecipients(opts.to, accountEmail), + importance: opts.importance || 'normal', + }; + + if (opts.cc) draft.ccRecipients = parseRecipients(opts.cc, accountEmail); + if (opts.bcc) draft.bccRecipients = parseRecipients(opts.bcc, accountEmail); + + const toDisplay = Array.isArray(opts.to) ? opts.to.join(', ') : opts.to; + + if (needsConfirmation(globalOpts, opts)) { + console.log('\n📧 CREATE DRAFT'); + console.log('─'.repeat(40)); + console.log(`To: ${toDisplay}`); + if (opts.cc) console.log(`Cc: ${Array.isArray(opts.cc) ? opts.cc.join(', ') : opts.cc}`); + if (opts.bcc) console.log(`Bcc: ${Array.isArray(opts.bcc) ? opts.bcc.join(', ') : opts.bcc}`); + console.log(`Subject: ${opts.subject}`); + console.log(`Type: ${bodyResult.contentType}`); + console.log(`Body: ${bodyResult.content.substring(0, 200)}${bodyResult.content.length > 200 ? '...' : ''}`); + console.log('─'.repeat(40)); + console.log('This creates a DRAFT. It will NOT be sent.'); + const ok = await confirm('\nCreate this draft?'); + if (!ok) { + console.log('Cancelled.'); + return; + } + } + + const result = await mailApi.createDraft(client, draft); + + // Attach files to the draft if --attach was specified + if (opts.attach && opts.attach.length > 0) { + const { readFileSync } = await import('fs'); + const { basename, extname } = await import('path'); + const draftId = result.id; + + for (const filePath of opts.attach) { + try { + const fileData = readFileSync(filePath); + const fileName = basename(filePath); + const ext = extname(filePath).toLowerCase(); + // Common MIME type mapping for frequently-attached file types + const mimeTypes = { + '.pdf': 'application/pdf', '.doc': 'application/msword', + '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', + '.gif': 'image/gif', '.txt': 'text/plain', '.csv': 'text/csv', + '.zip': 'application/zip', '.html': 'text/html', + }; + const contentType = mimeTypes[ext] || 'application/octet-stream'; + const contentBytes = fileData.toString('base64'); + + if (fileData.length > 3 * 1024 * 1024) { + console.error(`Warning: ${fileName} is ${formatFileSize(fileData.length)}, exceeds 3MB inline limit. Use upload session for large files.`); + continue; + } + + await mailApi.addAttachment(client, draftId, { name: fileName, contentType, contentBytes }); + console.log(` Attached: ${fileName} (${formatFileSize(fileData.length)})`); + } catch (err) { + console.error(`Error attaching ${filePath}: ${err.message}`); + } + } + } + + await output(result, 'generic', globalOpts); + }); + + mail + .command('reply [messageId]') + .description('Create a reply draft') + .option('--input ', 'load options from JSON file (use "-" for stdin)') + .option('--body ', 'reply body text') + .option('--body-file ', 'read body from file (text or HTML)') + .option('--body-content-type ', 'body content type: Text or HTML (auto-detected from .html/.htm file extension)') + .option('--all', 'reply to all recipients') + .option('--yes', 'skip confirmation') + .action(async (messageId, options) => { + const globalOpts = program.opts(); + const jsonInput = await loadInput(options.input); + const opts = mergeInput(jsonInput, { + body: options.body, bodyFile: options.bodyFile, + bodyContentType: options.bodyContentType, + replyAll: options.all, yes: options.yes, + }); + const id = resolveId(messageId || opts.messageId); + + if (!id) { + console.error('Error: messageId is required (positional arg or "messageId" in --input JSON)'); + process.exit(1); + } + + const { client, accountAlias } = await buildClient(globalOpts); + assertWriteAllowed(accountAlias, 'create reply draft'); + + const replyAll = opts.replyAll || opts.all || false; + const bodyResult = await resolveBody(opts); + + if (needsConfirmation(globalOpts, opts)) { + const original = await mailApi.getMessage(client, id, {}); + console.log(`\n📧 CREATE ${replyAll ? 'REPLY-ALL' : 'REPLY'} DRAFT`); + console.log('─'.repeat(40)); + console.log(`Original from: ${original.from?.emailAddress?.address}`); + console.log(`Original subj: ${original.subject}`); + if (bodyResult.content) console.log(`Body: ${bodyResult.content.substring(0, 200)}${bodyResult.content.length > 200 ? '...' : ''}`); + console.log('─'.repeat(40)); + const ok = await confirm('\nCreate this reply draft?'); + if (!ok) { + console.log('Cancelled.'); + return; + } + } + + const result = await mailApi.createReplyDraft(client, id, { + body: bodyResult.content || opts.body, + bodyContentType: bodyResult.contentType, + replyAll, + }); + + await output(result, 'generic', globalOpts); + }); + + mail + .command('forward [messageId]') + .description('Create a forward draft') + .option('--input ', 'load options from JSON file (use "-" for stdin)') + .option('--to
', 'forward to address(es), comma-separated') + .option('--comment ', 'add a comment') + .option('--body-file ', 'read comment from file') + .option('--body-content-type ', 'comment content type: Text or HTML (auto-detected from .html/.htm file extension)') + .option('--yes', 'skip confirmation') + .action(async (messageId, options) => { + const globalOpts = program.opts(); + const jsonInput = await loadInput(options.input); + const opts = mergeInput(jsonInput, { + to: options.to, comment: options.comment, + bodyFile: options.bodyFile, bodyContentType: options.bodyContentType, + yes: options.yes, + }); + const id = resolveId(messageId || opts.messageId); + + if (!id) { + console.error('Error: messageId is required (positional arg or "messageId" in --input JSON)'); + process.exit(1); + } + if (!opts.to) { + console.error('Error: --to is required (or "to" in --input JSON)'); + process.exit(1); + } + + const { client, accountAlias } = await buildClient(globalOpts); + assertWriteAllowed(accountAlias, 'create forward draft'); + + let comment = opts.comment || ''; + if (opts.bodyFile) { + const bodyResult = await resolveBody(opts); + comment = bodyResult.content; + } + + if (needsConfirmation(globalOpts, opts)) { + const original = await mailApi.getMessage(client, id, {}); + const toDisplay = Array.isArray(opts.to) ? opts.to.join(', ') : opts.to; + console.log('\n📧 CREATE FORWARD DRAFT'); + console.log('─'.repeat(40)); + console.log(`Forward to: ${toDisplay}`); + console.log(`Original: ${original.subject}`); + if (comment) console.log(`Comment: ${comment.substring(0, 200)}${comment.length > 200 ? '...' : ''}`); + console.log('─'.repeat(40)); + const ok = await confirm('\nCreate this forward draft?'); + if (!ok) { + console.log('Cancelled.'); + return; + } + } + + const result = await mailApi.createForwardDraft(client, id, { + to: Array.isArray(opts.to) ? opts.to.join(',') : opts.to, + comment, + }); + + await output(result, 'generic', globalOpts); + }); + + mail + .command('move [messageId]') + .description('Move a message to a folder') + .option('--input ', 'load options from JSON file (use "-" for stdin)') + .option('--folder ', 'destination folder name or ID') + .option('--yes', 'skip confirmation') + .action(async (messageId, options) => { + const globalOpts = program.opts(); + const jsonInput = await loadInput(options.input); + const opts = mergeInput(jsonInput, { folder: options.folder, yes: options.yes }); + const id = resolveId(messageId || opts.messageId); + + if (!id) { + console.error('Error: messageId is required (positional arg or "messageId" in --input JSON)'); + process.exit(1); + } + if (!opts.folder) { + console.error('Error: --folder is required (or "folder" in --input JSON)'); + process.exit(1); + } + + const { client, accountAlias } = await buildClient(globalOpts); + assertWriteAllowed(accountAlias, 'move message'); + + if (needsConfirmation(globalOpts, opts)) { + const ok = await confirm(`Move message to "${opts.folder}"?`); + if (!ok) { + console.log('Cancelled.'); + return; + } + } + + const result = await mailApi.moveMessage(client, id, opts.folder); + await output({ success: true, messageId: id, movedTo: opts.folder, newId: result?.id }, 'generic', globalOpts); + }); + + mail + .command('flag [messageId]') + .description('Flag or unflag a message') + .option('--input ', 'load options from JSON file (use "-" for stdin)') + .option('--unflag', 'remove flag') + .action(async (messageId, options) => { + const globalOpts = program.opts(); + const jsonInput = await loadInput(options.input); + const opts = mergeInput(jsonInput, { unflag: options.unflag }); + const id = resolveId(messageId || opts.messageId); + + if (!id) { + console.error('Error: messageId is required (positional arg or "messageId" in --input JSON)'); + process.exit(1); + } + + const { client, accountAlias } = await buildClient(globalOpts); + assertWriteAllowed(accountAlias, 'flag message'); + + const flagged = !opts.unflag; + await mailApi.flagMessage(client, id, flagged); + + await output({ success: true, messageId: id, flagged }, 'generic', globalOpts); + }); + + mail + .command('mark-read [messageId]') + .description('Mark a message as read or unread') + .option('--input ', 'load options from JSON file (use "-" for stdin)') + .option('--unread', 'mark as unread instead') + .action(async (messageId, options) => { + const globalOpts = program.opts(); + const jsonInput = await loadInput(options.input); + const opts = mergeInput(jsonInput, { unread: options.unread }); + const id = resolveId(messageId || opts.messageId); + + if (!id) { + console.error('Error: messageId is required (positional arg or "messageId" in --input JSON)'); + process.exit(1); + } + + const { client, accountAlias } = await buildClient(globalOpts); + assertWriteAllowed(accountAlias, 'mark message read/unread'); + + const isRead = !opts.unread; + await mailApi.markRead(client, id, isRead); + + await output({ success: true, messageId: id, isRead }, 'generic', globalOpts); + }); + + // ── Send command ──────────────────────────────────── + // Sends an existing draft. This is irreversible — confirmation is always shown + // unless --yes is passed. + + mail + .command('send [messageId]') + .description('Send an existing draft') + .option('--input ', 'load options from JSON file (use "-" for stdin)') + .option('--yes', 'skip confirmation') + .action(async (messageId, options) => { + const globalOpts = program.opts(); + const jsonInput = await loadInput(options.input); + const opts = mergeInput(jsonInput, { yes: options.yes }); + const id = resolveId(messageId || opts.messageId); + + if (!id) { + console.error('Error: messageId is required (positional arg or "messageId" in --input JSON)'); + process.exit(1); + } + + const { client, accountAlias } = await buildClient(globalOpts); + assertWriteAllowed(accountAlias, 'send draft'); + + if (needsConfirmation(globalOpts, opts)) { + const msg = await mailApi.getMessage(client, id, {}); + const to = msg.toRecipients?.map(r => r.emailAddress?.address).join(', ') || '(none)'; + console.log('\n📤 SEND DRAFT'); + console.log('─'.repeat(40)); + if (globalOpts.as) console.log(`On behalf of: ${globalOpts.as}`); + console.log(`To: ${to}`); + console.log(`Subject: ${msg.subject}`); + console.log('─'.repeat(40)); + console.log('⚠️ This will SEND the message. It cannot be undone.'); + const ok = await confirm('\nSend this draft?'); + if (!ok) { + console.log('Cancelled.'); + return; + } + } + + await mailApi.sendDraft(client, id); + await output({ success: true, messageId: id, sent: true }, 'generic', globalOpts); + }); + + // ── Delete command ───────────────────────────────────── + + mail + .command('delete [messageId]') + .description('Delete a message (soft-delete to Deleted Items)') + .option('--input ', 'load options from JSON file (use "-" for stdin)') + .option('--yes', 'skip confirmation') + .action(async (messageId, options) => { + const globalOpts = program.opts(); + const jsonInput = await loadInput(options.input); + const opts = mergeInput(jsonInput, { yes: options.yes }); + const id = resolveId(messageId || opts.messageId); + + if (!id) { + console.error('Error: messageId is required (positional arg or "messageId" in --input JSON)'); + process.exit(1); + } + + const { client, accountAlias } = await buildClient(globalOpts); + assertWriteAllowed(accountAlias, 'delete message'); + + if (needsConfirmation(globalOpts, opts)) { + const ok = await confirm('Delete this message? (moves to Deleted Items)'); + if (!ok) { + console.log('Cancelled.'); + return; + } + } + + await mailApi.deleteMessage(client, id); + await output({ success: true, messageId: id, deleted: true }, 'generic', globalOpts); + }); + + // ── Watch alias ───────────────────────────────────────── + // Delegates to the top-level watch command so users can type either + // `outlook-cli watch` or `outlook-cli mail watch`. + + mail + .command('watch') + .description('Watch for mail changes in real-time (alias for top-level watch)') + .option('--interval ', 'poll interval in seconds (min: 30, default: 60)', '60') + .option('--no-mail', 'don\'t watch mail changes') + .option('--no-calendar', 'don\'t watch calendar changes') + .option('--folder ', 'mail folder to watch (default: Inbox)', 'Inbox') + .option('--jsonl', 'output events as JSON Lines') + .option('--heartbeat', 'emit periodic heartbeat events') + .option('--heartbeat-interval ', 'heartbeat interval (default: 300)', '300') + .option('--config ', 'path to watch config JSON file') + .option('--validate', 'validate the config file and exit') + .action(async (options) => { + // Re-dispatch to the top-level watch command handler + const watchCmd = program.commands.find(c => c.name() === 'watch'); + if (watchCmd) { + await watchCmd._actionHandler(options); + } else { + console.error('Error: watch command not found. Use `outlook-cli watch` instead.'); + process.exitCode = 1; + } + }); + + program.addCommand(mail); +} diff --git a/src/node/cli/telemetry.js b/src/node/cli/telemetry.js new file mode 100644 index 0000000..e3759e7 --- /dev/null +++ b/src/node/cli/telemetry.js @@ -0,0 +1,148 @@ +/** + * CLI command: telemetry — view and manage telemetry data. + * + * Telemetry tracks performance metrics for Graph API calls and CLI commands. + * Disabled by default; enable with --telemetry flag or OUTLOOK_CLI_TELEMETRY=1. + * + * Events tracked: + * graph.request — every successful Graph API call (endpoint, status, duration) + * graph.error — failed Graph API calls (status code, error details) + * graph.throttle — rate-limit responses (429, wait duration) + * cli.command — CLI command start + * cli.complete — CLI command finish (duration, success/failure) + */ + +import { getTelemetry } from '../telemetry/collector.js'; + +export function registerTelemetryCommands(program) { + const telemetry = program + .command('telemetry') + .description('View telemetry and performance data'); + + telemetry + .command('show') + .description('Show recent telemetry events') + .option('--limit ', 'max events to show', '20') + .option('--event ', 'filter by event type (e.g., graph.request)') + .action(async (options) => { + const globalOpts = program.opts(); + let collector = getTelemetry(); + + // Always connect to SQLite for telemetry show — user wants to see + // persisted data even if --telemetry wasn't passed to this invocation. + if (!collector.db) { + try { + const { getDatabase } = await import('../db/database.js'); + collector.db = getDatabase(); + collector._ensureTable(); + } catch { + // SQLite unavailable — show in-memory only + } + } + + // Prefer SQLite (persisted across invocations) over in-memory buffer + const limit = parseInt(options.limit, 10); + let events = []; + if (collector.db) { + events = collector.export({ + fromDb: true, + limit, + event: options.event, + }); + // Populate buffer from DB so getSummary() works correctly + if (collector.buffer.length === 0) { + collector.buffer = collector.export({ fromDb: true, limit: 1000 }); + } + } + if (events.length === 0) { + events = collector.getRecent({ limit, event: options.event }); + } + + const isJson = globalOpts.json || globalOpts.format === 'json'; + + if (isJson) { + console.log(JSON.stringify({ events, summary: collector.getSummary() }, null, 2)); + return; + } + + if (events.length === 0) { + console.log('No telemetry events recorded.'); + console.log('Enable telemetry with: --telemetry or OUTLOOK_CLI_TELEMETRY=1'); + return; + } + + // Print summary first + const summary = collector.getSummary(); + console.log('── Telemetry Summary ──────────────────────────'); + console.log(`Total events: ${summary.totalEvents}`); + console.log(`Graph requests: ${summary.graphRequests}`); + console.log(`Errors: ${summary.errors}`); + console.log(`Throttles: ${summary.throttles}`); + console.log(`Avg latency: ${summary.avgLatencyMs} ms`); + console.log(''); + + // Print recent events + console.log('── Recent Events ─────────────────────────────'); + for (const e of events) { + const time = e.timestamp?.substring(11, 19) || ''; + const dur = e.durationMs != null ? ` ${e.durationMs}ms` : ''; + const status = e.graphStatusCode ? ` [${e.graphStatusCode}]` : ''; + const endpoint = e.graphEndpoint ? ` ${e.graphMethod || ''} ${e.graphEndpoint}` : ''; + const cmd = e.command ? ` ${e.command}` : ''; + console.log(`${time} ${e.event}${cmd}${endpoint}${status}${dur}`); + } + }); + + telemetry + .command('summary') + .description('Show performance summary') + .action(async () => { + const globalOpts = program.opts(); + const collector = getTelemetry(); + + // Connect to SQLite for persistent data + if (!collector.db) { + try { + const { getDatabase } = await import('../db/database.js'); + collector.db = getDatabase(); + collector._ensureTable(); + } catch { + // SQLite unavailable + } + } + + // Load events from DB into buffer for summary calculation + if (collector.db && collector.buffer.length === 0) { + const dbEvents = collector.export({ fromDb: true, limit: 1000 }); + collector.buffer = dbEvents; + } + + const summary = collector.getSummary(); + + const isJson = globalOpts.json || globalOpts.format === 'json'; + if (isJson) { + console.log(JSON.stringify(summary, null, 2)); + return; + } + + console.log('── Performance Summary ────────────────────────'); + console.log(`Total events: ${summary.totalEvents}`); + console.log(`Graph requests: ${summary.graphRequests}`); + console.log(`Errors: ${summary.errors}`); + console.log(`Throttles: ${summary.throttles}`); + console.log(`Avg latency: ${summary.avgLatencyMs} ms`); + console.log(`Peak heap: ${summary.peakHeapMB} MB`); + console.log(`Avg heap: ${summary.avgHeapMB} MB`); + console.log(`Uptime: ${Math.round(summary.uptimeMs / 1000)} s`); + }); + + telemetry + .command('clear') + .description('Clear telemetry buffer') + .action(async () => { + const collector = getTelemetry(); + collector.buffer = []; + collector._totalEvents = 0; + console.log('Telemetry buffer cleared.'); + }); +} diff --git a/src/node/cli/upgrade.js b/src/node/cli/upgrade.js new file mode 100644 index 0000000..de851be --- /dev/null +++ b/src/node/cli/upgrade.js @@ -0,0 +1,463 @@ +/** + * Upgrade command and startup migration for outlook-cli. + * + * G1: Auto-migration on startup — runs before any command to ensure config + * files and database are at the current schema version. + * G2: `outlook-cli upgrade` command — explicit upgrade with detailed output. + * G3: Breaking change detection — blocks execution if config/db is from a + * newer version that this binary can't handle. + * + * Exit codes for the upgrade command: + * 0 = everything up to date + * 1 = issues found and auto-fixed + * 2 = issues requiring manual intervention + */ + +import { existsSync, readFileSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import { homedir } from 'os'; +import { createRequire } from 'module'; +import { validateSchema, getCurrentSchemaVersion } from '../config-schema.js'; +import { CURRENT_SCHEMA_VERSION as DB_SCHEMA_VERSION } from '../db/database.js'; +import { versionInfo, checkStaleness } from '../version.js'; +import { ConfigError, VersionError } from '../errors.js'; + +const require = createRequire(import.meta.url); + +/** Resolve config dir lazily so tests can override HOME before first call. */ +function getConfigDir() { + return join(homedir(), '.outlook-cli'); +} + +function getVersionFile() { + return join(getConfigDir(), '.last-version.json'); +} + +/** + * Config files that may need migration, keyed by config type. + * Each entry has the filename and the config type used in config-schema.js. + */ +const CONFIG_FILES = [ + { filename: 'accounts.json', configType: 'accounts' }, + { filename: 'aliases.json', configType: 'aliases' }, + { filename: 'watch-config.json', configType: 'watchConfig' }, +]; + +/** + * Check a single config file's schema and return status info. + * @returns {{ file, status, fileVersion, currentVersion, error? }} + */ +function checkConfigFile(filename, configType) { + const filePath = join(getConfigDir(), filename); + const currentVersion = getCurrentSchemaVersion(configType); + + if (!existsSync(filePath)) { + return { file: filename, configType, status: 'missing', fileVersion: null, currentVersion }; + } + + let data; + try { + data = JSON.parse(readFileSync(filePath, 'utf-8')); + } catch (err) { + return { file: filename, configType, status: 'parse_error', fileVersion: null, currentVersion, error: err }; + } + + const fileVersion = data._schema ?? 0; + + if (fileVersion > currentVersion) { + return { + file: filename, + configType, + status: 'too_new', + fileVersion, + currentVersion, + error: ConfigError.schemaVersionMismatch(filePath, fileVersion, currentVersion), + }; + } + + if (fileVersion < currentVersion) { + return { file: filename, configType, status: 'needs_migration', fileVersion, currentVersion, data }; + } + + return { file: filename, configType, status: 'current', fileVersion, currentVersion }; +} + +/** + * Check database schema version without opening/migrating the database. + * Reads the schema_version table to determine version. + * @returns {{ status, dbVersion, currentVersion, error? }} + */ +function checkDatabaseSchema() { + const dbPath = join(getConfigDir(), 'outlook-cli.db'); + const currentVersion = DB_SCHEMA_VERSION; + + if (!existsSync(dbPath)) { + return { status: 'missing', dbVersion: null, currentVersion }; + } + + try { + // Use dynamic import to avoid pulling in better-sqlite3 at module load + const Database = require('better-sqlite3'); + const db = new Database(dbPath, { readonly: true }); + try { + const row = db.prepare('SELECT MAX(version) as v FROM schema_version').get(); + const dbVersion = row?.v || 0; + + if (dbVersion > currentVersion) { + return { + status: 'too_new', + dbVersion, + currentVersion, + error: new VersionError( + `Your outlook-cli.db has schema v${dbVersion} but this binary supports up to v${currentVersion}. Please upgrade.`, + { + code: 'VERSION_DB_TOO_NEW', + suggestedAction: 'Upgrade your outlook-cli binary to the latest version.', + } + ), + }; + } + + if (dbVersion < currentVersion) { + return { status: 'needs_migration', dbVersion, currentVersion }; + } + + return { status: 'current', dbVersion, currentVersion }; + } finally { + db.close(); + } + } catch (err) { + // Table might not exist yet (fresh db) — that's a migration need + if (err.message?.includes('no such table')) { + return { status: 'needs_migration', dbVersion: 0, currentVersion }; + } + return { status: 'error', dbVersion: null, currentVersion, error: err }; + } +} + +/** + * Migrate a single config file to the current schema version. + * @returns {{ migrated: boolean, fromVersion: number, toVersion: number }} + */ +function migrateConfigFile(filename, configType, data) { + const filePath = join(getConfigDir(), filename); + const fromVersion = data._schema ?? 0; + const { data: migrated, migrated: didMigrate } = validateSchema(data, configType, filePath); + + if (didMigrate) { + writeFileSync(filePath, JSON.stringify(migrated, null, 2), 'utf-8'); + } + + return { + migrated: didMigrate, + fromVersion, + toVersion: migrated._schema, + }; +} + +/** + * G1: Run startup migration checks. Called before any command. + * + * - Checks all config files and database schema + * - Auto-migrates what it can + * - Throws on breaking changes (config/db from newer version) + * - Returns brief messages for the caller to display + * + * @param {object} [opts] - { verbose: boolean } + * @returns {{ messages: string[], errors: string[] }} + */ +export function runStartupMigration(opts = {}) { + const messages = []; + const errors = []; + + // Check and auto-migrate config files + for (const { filename, configType } of CONFIG_FILES) { + const status = checkConfigFile(filename, configType); + + if (status.status === 'too_new') { + // G3: Breaking change — config from newer version + throw status.error; + } + + if (status.status === 'needs_migration') { + try { + const result = migrateConfigFile(filename, configType, status.data); + if (result.migrated) { + messages.push( + `Migrated ${filename} from schema v${result.fromVersion} → v${result.toVersion}` + ); + } + } catch (err) { + errors.push( + `Failed to migrate ${filename}: ${err.message}. ` + + `Try running \`outlook-cli upgrade\` or delete ${filename} and reconfigure.` + ); + } + } + + if (status.status === 'parse_error') { + errors.push( + `Could not parse ${filename}: ${status.error?.message || 'invalid JSON'}. ` + + `Fix the syntax or delete the file and reconfigure.` + ); + } + } + + // G3: Check database schema for breaking changes + const dbStatus = checkDatabaseSchema(); + if (dbStatus.status === 'too_new') { + throw dbStatus.error; + } + + // Database migration happens automatically when getDatabase() is called, + // so we just note if it will need migration + if (dbStatus.status === 'needs_migration' && opts.verbose) { + messages.push( + `Database will migrate from schema v${dbStatus.dbVersion} → v${dbStatus.currentVersion}` + ); + } + + return { messages, errors }; +} + +/** + * G2: Full upgrade check and migration. + * Returns detailed results for the upgrade command. + * + * @returns {{ status: 'up_to_date' | 'migrated' | 'manual_intervention', + * results: Array<{component, status, detail}>, + * exitCode: number }} + */ +export function runUpgrade() { + const results = []; + let hasFixed = false; + let needsManual = false; + + // Version info + const staleness = checkStaleness(); + results.push({ + component: 'Version', + status: 'info', + detail: `v${versionInfo.version} (node), schema v${versionInfo.schemaVersion}`, + }); + + if (staleness.stale) { + results.push({ + component: 'Version staleness', + status: 'warn', + detail: staleness.warnings.join(' '), + }); + } + + // Last version file + if (existsSync(getVersionFile())) { + try { + const lastVersion = JSON.parse(readFileSync(getVersionFile(), 'utf-8')); + results.push({ + component: 'Last version', + status: 'info', + detail: `v${lastVersion.version} (${lastVersion.runtime}), last run: ${lastVersion.updatedAt || 'unknown'}`, + }); + } catch { + results.push({ + component: 'Last version file', + status: 'warn', + detail: 'Could not read .last-version.json — will be recreated on next run.', + }); + } + } else { + results.push({ + component: 'Last version file', + status: 'info', + detail: 'No previous version recorded (first run).', + }); + } + + // Config files + for (const { filename, configType } of CONFIG_FILES) { + const status = checkConfigFile(filename, configType); + + switch (status.status) { + case 'missing': + results.push({ + component: filename, + status: 'skip', + detail: 'File does not exist (will be created when needed).', + }); + break; + + case 'current': + results.push({ + component: filename, + status: 'ok', + detail: `Schema v${status.fileVersion} — up to date.`, + }); + break; + + case 'too_new': + results.push({ + component: filename, + status: 'error', + detail: `Schema v${status.fileVersion} is newer than supported v${status.currentVersion}. Please upgrade your binary.`, + }); + needsManual = true; + break; + + case 'needs_migration': + try { + const result = migrateConfigFile(filename, configType, status.data); + results.push({ + component: filename, + status: 'fixed', + detail: `Migrated schema v${result.fromVersion} → v${result.toVersion}.`, + }); + hasFixed = true; + } catch (err) { + results.push({ + component: filename, + status: 'error', + detail: `Migration failed: ${err.message}`, + }); + needsManual = true; + } + break; + + case 'parse_error': + results.push({ + component: filename, + status: 'error', + detail: `Parse error: ${status.error?.message || 'invalid JSON'}. Delete the file and reconfigure.`, + }); + needsManual = true; + break; + } + } + + // Database + const dbStatus = checkDatabaseSchema(); + + switch (dbStatus.status) { + case 'missing': + results.push({ + component: 'outlook-cli.db', + status: 'skip', + detail: 'Database does not exist (will be created on first use).', + }); + break; + + case 'current': + results.push({ + component: 'outlook-cli.db', + status: 'ok', + detail: `Schema v${dbStatus.dbVersion} — up to date.`, + }); + break; + + case 'too_new': + results.push({ + component: 'outlook-cli.db', + status: 'error', + detail: `Schema v${dbStatus.dbVersion} is newer than supported v${dbStatus.currentVersion}. Data may be corrupted if you continue. Please upgrade.`, + }); + needsManual = true; + break; + + case 'needs_migration': + // Trigger database migration by importing and calling getDatabase + try { + const { getDatabase, closeDatabase } = require('../db/database.js'); + // getDatabase() runs migrations automatically + getDatabase(); + closeDatabase(); + results.push({ + component: 'outlook-cli.db', + status: 'fixed', + detail: `Migrated schema v${dbStatus.dbVersion} → v${dbStatus.currentVersion}.`, + }); + hasFixed = true; + } catch (err) { + results.push({ + component: 'outlook-cli.db', + status: 'error', + detail: `Migration failed: ${err.message}`, + }); + needsManual = true; + } + break; + + case 'error': + results.push({ + component: 'outlook-cli.db', + status: 'error', + detail: `Could not check database: ${dbStatus.error?.message}`, + }); + needsManual = true; + break; + } + + const exitCode = needsManual ? 2 : hasFixed ? 1 : 0; + const overallStatus = needsManual ? 'manual_intervention' : hasFixed ? 'migrated' : 'up_to_date'; + + return { status: overallStatus, results, exitCode }; +} + +/** + * Format upgrade results for terminal display. + */ +export function formatUpgradeResults({ status, results, exitCode }) { + const lines = []; + lines.push(''); + lines.push(' outlook-cli upgrade'); + lines.push(' ────────────────────────────────────────────'); + + const icons = { + ok: '✅', + info: 'ℹ️', + skip: '⏭️', + fixed: '🔧', + warn: '⚠️', + error: '❌', + }; + + for (const r of results) { + const icon = icons[r.status] || '•'; + lines.push(` ${icon} ${r.component}: ${r.detail}`); + } + + lines.push(''); + + if (status === 'up_to_date') { + lines.push(' ✅ Everything is up to date.'); + } else if (status === 'migrated') { + lines.push(' 🔧 Some items were migrated. Review the changes above.'); + } else { + lines.push(' ❌ Some issues need manual intervention. See errors above.'); + } + + lines.push(''); + return lines.join('\n'); +} + +/** + * Register the upgrade command on the CLI program. + */ +export function registerUpgradeCommand(program) { + program + .command('upgrade') + .description('Check and upgrade config files and database to current schema') + .action(async () => { + const globalOpts = program.opts(); + const upgradeResult = runUpgrade(); + + if (globalOpts.json || globalOpts.format === 'json') { + console.log(JSON.stringify(upgradeResult, null, 2)); + } else { + console.log(formatUpgradeResults(upgradeResult)); + } + + process.exitCode = upgradeResult.exitCode; + }); +} + +// Exported for testing +export { checkConfigFile, checkDatabaseSchema, migrateConfigFile, CONFIG_FILES }; diff --git a/src/node/cli/watch.js b/src/node/cli/watch.js new file mode 100644 index 0000000..219eda0 --- /dev/null +++ b/src/node/cli/watch.js @@ -0,0 +1,155 @@ +/** + * CLI command: `outlook-cli watch` + * + * Runs a long-lived process that monitors mail and calendar for changes + * using Microsoft Graph delta queries. Emits events to stdout as they occur. + * + * USE CASES: + * 1. OpenClaw/NanoClaw agents: pipe stdout in JSONL mode to react to new emails + * outlook-cli watch --jsonl | agent-process + * + * 2. Human monitoring: run in a terminal to see real-time notifications + * outlook-cli watch --interval 60 + * + * 3. Selective watching: only mail, only calendar, specific folder + * outlook-cli watch --no-calendar --folder "Important" + * + * HOW IT WORKS: + * - First sync: establishes baseline (no events emitted for existing items) + * - Subsequent syncs: emits events only for NEW changes (added/modified/deleted) + * - Delta tokens persisted to disk so restarts pick up where they left off + * - Ctrl+C for graceful shutdown (completes current sync, saves state) + * + * AGENT INTEGRATION: + * In JSONL mode, each line is a self-contained JSON object: + * {"type":"mail.changed","timestamp":"2024-...","data":{"subject":"...",...}} + * + * Event types: + * - mail.changed — new or modified email + * - mail.removed — deleted email + * - calendar.changed — new or modified calendar event + * - calendar.removed — deleted calendar event + * - sync.status — informational (initial sync, errors) + * - heartbeat — periodic liveness signal + */ + +import { Command } from 'commander'; +import { resolveAccount } from '../accounts/manager.js'; +import { createGraphClient } from '../graph/client.js'; +import { resolveAlias } from '../contacts/aliases.js'; +import { startWatcher } from '../watch/watcher.js'; + +export function registerWatchCommands(program) { + program + .command('watch') + .description('Watch for mail and calendar changes in real-time (delta sync or webhook push)') + .option('--mode ', 'notification mode: poll (default), fast-poll, or webhook', 'poll') + .option('--interval ', 'poll interval in seconds (min: 30 for poll, 5 for fast-poll)', '60') + .option('--no-mail', 'don\'t watch mail changes') + .option('--no-calendar', 'don\'t watch calendar changes') + .option('--folder ', 'mail folder to watch (default: Inbox)', 'Inbox') + .option('--jsonl', 'output events as JSON Lines (one JSON object per line)') + .option('--heartbeat', 'emit periodic heartbeat events') + .option('--heartbeat-interval ', 'heartbeat interval in seconds (default: 300)', '300') + .option('--tunnel ', 'tunnel for webhook mode: cloudflared (default), localtunnel, ngrok', 'cloudflared') + .option('--webhook-port ', 'local port for webhook server (default: random)') + .option('--config ', 'path to watch config JSON file (enables multi-job rules engine)') + .option('--validate', 'validate the config file and exit (requires --config)') + .action(async (options) => { + const globalOpts = program.opts(); + + // ── Config-based mode: multi-job rules engine ────────────────────── + if (options.config) { + if (options.validate) { + // Validate-only mode: parse config, report errors, exit + try { + const { loadWatchConfig } = await import('../watch/config.js'); + loadWatchConfig(options.config); + console.log('✅ Watch config is valid.'); + } catch (err) { + console.error(`❌ Config validation failed: ${err.message}`); + process.exitCode = 1; + } + return; + } + + // Start the multi-job watch manager + const { startWatchManager } = await import('../watch/manager.js'); + await startWatchManager(options.config, { + createClient: async (accountName) => { + const delegateFor = globalOpts.as ? resolveAlias(globalOpts.as) : undefined; + const { account, alias } = await resolveAccount(accountName); + return createGraphClient(alias, account, { delegateFor }); + }, + }); + return; + } + + // ── Legacy mode: single-account watcher (backward compatible) ────── + if (options.validate) { + console.error('⚠️ --validate requires --config .'); + process.exitCode = 1; + return; + } + + // Validate mode + const mode = options.mode; + if (!['poll', 'fast-poll', 'webhook'].includes(mode)) { + console.error(`⚠️ Unknown mode "${mode}". Use: poll, fast-poll, or webhook.`); + process.exitCode = 1; + return; + } + + // Resolve which account to use + const delegateFor = globalOpts.as ? resolveAlias(globalOpts.as) : undefined; + const { account, alias } = await resolveAccount(globalOpts.account); + const client = await createGraphClient(alias, account, { delegateFor }); + + // Determine output format: + // --jsonl flag → jsonl (designed for piping to agents) + // --format json → jsonl (treat json format as jsonl for watch) + // everything else → text (human-readable) + let format = 'text'; + if (options.jsonl || globalOpts.json || globalOpts.format === 'json') { + format = 'jsonl'; + } + + const interval = parseInt(options.interval, 10); + const minInterval = mode === 'fast-poll' ? 5 : 30; + if (isNaN(interval) || interval < minInterval) { + console.error(`⚠️ Minimum interval for ${mode} mode is ${minInterval} seconds.`); + process.exitCode = 1; + return; + } + + // ── Webhook mode ───────────────────────────────────────────────────── + if (mode === 'webhook') { + const { startWebhookWatcher } = await import('../watch/webhook-watcher.js'); + await startWebhookWatcher(client, alias, { + interval, + mail: options.mail, + calendar: options.calendar, + folder: options.folder, + format, + heartbeat: options.heartbeat || false, + heartbeatInterval: parseInt(options.heartbeatInterval, 10) || 300, + tunnel: options.tunnel, + webhookPort: options.webhookPort ? parseInt(options.webhookPort, 10) : 0, + }); + return; + } + + // ── Poll / fast-poll mode ──────────────────────────────────────────── + // Start the watch loop — this runs until Ctrl+C or SIGTERM + await startWatcher(client, alias, { + mode, + interval, + mail: options.mail, + calendar: options.calendar, + folder: options.folder, + format, + heartbeat: options.heartbeat || false, + heartbeatInterval: parseInt(options.heartbeatInterval, 10) || 300, + }); + }); +} diff --git a/src/node/config-schema.js b/src/node/config-schema.js new file mode 100644 index 0000000..aadd39e --- /dev/null +++ b/src/node/config-schema.js @@ -0,0 +1,124 @@ +/** + * Config file schema versioning. + * + * Every JSON config file written by outlook-cli includes a `_schema` field + * indicating which schema version produced it. On load we check: + * - Missing or older `_schema` → run migration functions to upgrade + * - Current `_schema` → pass through unchanged + * - Newer `_schema` → error (config from a newer binary) + * + * Migration functions are registered per config type. Each migration receives + * the data object and returns the upgraded data with the new `_schema` value. + */ + +import { createRequire } from 'module'; +import { ConfigError } from './errors.js'; + +const require = createRequire(import.meta.url); +const versionInfo = require('../shared/version.json'); + +/** + * Registry of migration functions keyed by config type, then by target schema version. + * Example: migrations.accounts[2] upgrades accounts data from schema 1 → 2. + */ +const migrations = {}; + +/** + * Register a migration function for a config type. + * @param {string} configType - e.g. 'accounts', 'aliases' + * @param {number} targetVersion - the schema version this migration produces + * @param {function} migrateFn - (data) => migratedData + */ +export function registerMigration(configType, targetVersion, migrateFn) { + if (!migrations[configType]) { + migrations[configType] = {}; + } + migrations[configType][targetVersion] = migrateFn; +} + +/** + * Get the current schema version for a config type from version.json. + * Falls back to the global schemaVersion if not listed per-file. + */ +export function getCurrentSchemaVersion(configType) { + return versionInfo.configFiles?.[configType]?.currentSchema ?? versionInfo.schemaVersion; +} + +/** + * Validate and migrate a config data object. + * + * @param {object} data - parsed JSON config data + * @param {string} configType - e.g. 'accounts', 'aliases' + * @param {string} [filePath] - file path for error messages + * @returns {{ data: object, migrated: boolean }} the (possibly migrated) data + * @throws {ConfigError} if the config is from a newer version + */ +export function validateSchema(data, configType, filePath) { + const currentVersion = getCurrentSchemaVersion(configType); + const fileVersion = data._schema ?? 0; + + if (fileVersion > currentVersion) { + throw ConfigError.schemaVersionMismatch( + filePath || configType, + fileVersion, + currentVersion + ); + } + + if (fileVersion >= currentVersion) { + return { data, migrated: false }; + } + + // Run migrations sequentially from fileVersion+1 to currentVersion + let migrated = { ...data }; + const typeMigrations = migrations[configType] || {}; + + for (let v = fileVersion + 1; v <= currentVersion; v++) { + const migrateFn = typeMigrations[v]; + if (migrateFn) { + migrated = migrateFn(migrated); + } + migrated._schema = v; + } + + return { data: migrated, migrated: true }; +} + +/** + * Stamp a config object with the current schema version before writing. + * + * @param {object} data - config data to stamp + * @param {string} configType - e.g. 'accounts', 'aliases' + * @returns {object} data with `_schema` set + */ +export function stampSchema(data, configType) { + return { ...data, _schema: getCurrentSchemaVersion(configType) }; +} + +/** + * Clear all registered migrations (for testing). + */ +export function resetMigrations() { + for (const key of Object.keys(migrations)) { + delete migrations[key]; + } +} + +// Register built-in migrations + +// accounts: schema 0/1 → 2 (no structural changes, just stamp) +registerMigration('accounts', 2, (data) => ({ ...data })); + +// accounts: schema 2 → 3 (add mode and scopes fields to each account) +registerMigration('accounts', 3, (data) => { + const accounts = data.accounts || {}; + for (const alias of Object.keys(accounts)) { + if (!accounts[alias].mode) { + accounts[alias].mode = 'full'; + } + } + return { ...data, accounts }; +}); + +// aliases: schema 0/1 → 2 +registerMigration('aliases', 2, (data) => ({ ...data })); diff --git a/src/node/config.js b/src/node/config.js new file mode 100644 index 0000000..0b0af92 --- /dev/null +++ b/src/node/config.js @@ -0,0 +1,52 @@ +/** + * Config loading — resolves settings from multiple sources. + * + * CONFIG RESOLUTION ORDER (last wins): + * 1. Hardcoded defaults (tenantId: "common", logLevel: "warn") + * 2. Environment variables (OUTLOOK_CLI_CLIENT_ID, etc.) + * 3. Config file (~/.outlook-cli/config.json) — OVERRIDES env vars + * + * IMPORTANT: The config file wins over env vars via Object.assign. This is + * intentional — the config file is the user's persistent preference, while env + * vars may be set globally for other tools. If you need env vars to win, don't + * set the same key in config.json. + * + * NOTE: Per-account settings (clientId, tenantId) in accounts.json take + * precedence over these global defaults. This config is only used as a fallback + * when no account-specific value is set. + * + * C# PORT NOTE: Use IConfiguration with layered providers (appsettings.json, + * environment variables, command-line args) for the same pattern. + */ + +import { readFileSync, existsSync } from 'fs'; +import { join } from 'path'; +import { homedir } from 'os'; + +const CONFIG_DIR = join(homedir(), '.outlook-cli'); + +/** + * Load configuration from environment variables and config file. + */ +export function loadConfig() { + // Step 1: Start with defaults, overlaid with environment variables + const config = { + clientId: process.env.OUTLOOK_CLI_CLIENT_ID || null, + tenantId: process.env.OUTLOOK_CLI_TENANT_ID || 'common', + passphrase: process.env.OUTLOOK_CLI_PASSPHRASE || null, + logLevel: process.env.OUTLOOK_CLI_LOG_LEVEL || 'warn', + }; + + // Step 2: Overlay with config file (wins over env vars) + const configFile = join(CONFIG_DIR, 'config.json'); + if (existsSync(configFile)) { + try { + const fileConfig = JSON.parse(readFileSync(configFile, 'utf-8')); + Object.assign(config, fileConfig); + } catch { + // Invalid config file — use defaults + } + } + + return config; +} diff --git a/src/node/contacts/aliases.js b/src/node/contacts/aliases.js new file mode 100644 index 0000000..827af3c --- /dev/null +++ b/src/node/contacts/aliases.js @@ -0,0 +1,140 @@ +/** + * Contact alias storage — local shortcut names for email addresses. + * + * FILE-BASED STORAGE: Aliases are stored as a flat JSON object in + * ~/.outlook-cli/aliases.json. The format is { "alias": "email@example.com" }. + * This is intentionally simple — no database, no encryption, no sync. + * The file is human-editable and can be version-controlled. + * + * CASE-INSENSITIVITY: All alias names are lowercased on write AND on lookup. + * This means "Fred", "fred", and "FRED" all map to the same alias. The "@" + * character is forbidden in alias names so we can distinguish aliases from + * raw email addresses — if input contains "@", it's treated as a literal email. + * + * KEY DISTINCTION — resolveAlias vs resolveRecipients: + * - resolveAlias(value) — resolves a SINGLE token (alias name or email). + * Used by --as, individual attendee values, etc. + * - resolveRecipients(csv) — resolves a COMMA-SEPARATED string, calling + * resolveAlias on each token. Used by --to, --cc, --bcc fields. + * + * C# PORT NOTE: Reimplement as a simple JSON file reader/writer. Consider using + * a ConcurrentDictionary if multi-threaded access is needed. + */ + +import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs'; +import { join } from 'path'; +import { homedir } from 'os'; + +const CONFIG_DIR = join(homedir(), '.outlook-cli'); +const ALIASES_FILE = join(CONFIG_DIR, 'aliases.json'); + +/** + * Load aliases from disk. Returns empty object on missing or corrupted file + * so the CLI gracefully degrades (no aliases configured yet). + */ +function loadAliases() { + try { + if (existsSync(ALIASES_FILE)) { + return JSON.parse(readFileSync(ALIASES_FILE, 'utf-8')); + } + } catch { + // Corrupted file — start fresh rather than crashing + } + return {}; +} + +/** + * Save aliases to disk. Creates config dir if it doesn't exist. + */ +function saveAliases(aliases) { + mkdirSync(CONFIG_DIR, { recursive: true }); + writeFileSync(ALIASES_FILE, JSON.stringify(aliases, null, 2), 'utf-8'); +} + +/** + * Set an alias (add or update). Name is lowercased for case-insensitive lookup. + * The "@" check prevents confusion between alias names and email addresses. + */ +export function setAlias(name, email) { + if (!name || !email) { + throw new Error('Both alias name and email are required'); + } + const key = name.toLowerCase().trim(); + if (key.includes('@')) { + throw new Error('Alias name cannot contain "@" — use a short name like "fred"'); + } + const aliases = loadAliases(); + aliases[key] = email.trim(); + saveAliases(aliases); + return { name: key, email: email.trim() }; +} + +/** + * Remove an alias. + */ +export function removeAlias(name) { + const key = name.toLowerCase().trim(); + const aliases = loadAliases(); + if (!aliases[key]) { + throw new Error(`Alias "${name}" not found`); + } + const email = aliases[key]; + delete aliases[key]; + saveAliases(aliases); + return { name: key, email }; +} + +/** + * List all aliases. + */ +export function listAliases() { + const aliases = loadAliases(); + return Object.entries(aliases).map(([name, email]) => ({ name, email })); +} + +/** + * Get the email for an alias (case-insensitive). + */ +export function getAlias(name) { + const aliases = loadAliases(); + return aliases[name.toLowerCase().trim()] || null; +} + +/** + * Resolve a SINGLE name-or-email token to an email address. + * + * Decision logic: + * 1. If input contains "@" → treat as a literal email address, return as-is + * 2. Otherwise → look up as an alias; throw if not found + * + * This is the core resolution function used by --as, individual attendees, etc. + */ +export function resolveAlias(nameOrEmail) { + if (!nameOrEmail) return nameOrEmail; + const trimmed = nameOrEmail.trim(); + // "@" presence is the heuristic that distinguishes emails from alias names + if (trimmed.includes('@')) return trimmed; + + const email = getAlias(trimmed); + if (!email) { + throw new Error( + `"${trimmed}" is not an email address and no alias found. ` + + `Add one with: outlook-cli contacts alias set ${trimmed} user@example.com` + ); + } + return email; +} + +/** + * Resolve a COMMA-SEPARATED list of names/emails to email addresses. + * + * Splits on comma, resolves each token via resolveAlias, then re-joins. + * Used for --to, --cc, --bcc fields that accept multiple recipients. + */ +export function resolveRecipients(recipientString) { + if (!recipientString) return recipientString; + return recipientString + .split(',') + .map(r => resolveAlias(r.trim())) + .join(','); +} diff --git a/src/node/db/database.js b/src/node/db/database.js new file mode 100644 index 0000000..5208919 --- /dev/null +++ b/src/node/db/database.js @@ -0,0 +1,143 @@ +/** + * SQLite database manager for outlook-cli. + * + * Uses better-sqlite3 for synchronous, WAL-mode access to the shared + * database at ~/.outlook-cli/outlook-cli.db. + * + * WAL mode is critical — it allows concurrent readers (multiple CLI processes) + * while one writer holds the database. Without WAL, concurrent instances + * would see SQLITE_BUSY errors. + */ +import Database from 'better-sqlite3'; +import { join } from 'path'; +import { homedir } from 'os'; +import { mkdirSync, existsSync } from 'fs'; + +const CONFIG_DIR = join(homedir(), '.outlook-cli'); +const DB_PATH = join(CONFIG_DIR, 'outlook-cli.db'); + +let _db = null; + +const migrations = [ + // Version 1: Operations log + delta state + cache metadata + (db) => { + db.exec(` + CREATE TABLE operations ( + id TEXT PRIMARY KEY, + parent_id TEXT, + account TEXT NOT NULL, + command TEXT NOT NULL, + operation TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'started', + started_at TEXT NOT NULL, + completed_at TEXT, + duration_ms INTEGER, + request_summary TEXT, + response_summary TEXT, + error_message TEXT, + error_stack TEXT, + metadata TEXT, + FOREIGN KEY (parent_id) REFERENCES operations(id) + ); + + CREATE TABLE delta_state ( + account TEXT NOT NULL, + resource TEXT NOT NULL, + folder TEXT, + delta_token TEXT, + last_sync_at TEXT, + PRIMARY KEY (account, resource, folder) + ); + + CREATE TABLE cache_metadata ( + account TEXT PRIMARY KEY, + last_refresh_at TEXT, + token_expiry_at TEXT, + scopes TEXT + ); + + CREATE INDEX idx_operations_account ON operations(account); + CREATE INDEX idx_operations_started ON operations(started_at); + CREATE INDEX idx_operations_command ON operations(command); + CREATE INDEX idx_operations_status ON operations(status); + CREATE INDEX idx_operations_parent ON operations(parent_id); + `); + }, + + // Version 2: Add correlation_id to operations + (db) => { + db.exec(`ALTER TABLE operations ADD COLUMN correlation_id TEXT`); + db.exec(`CREATE INDEX idx_operations_correlation ON operations(correlation_id)`); + }, + + // Version 3: Add user, runtime, version columns for structured logging + (db) => { + db.exec(`ALTER TABLE operations ADD COLUMN user TEXT`); + db.exec(`ALTER TABLE operations ADD COLUMN runtime TEXT`); + db.exec(`ALTER TABLE operations ADD COLUMN version TEXT`); + }, +]; + +export const CURRENT_SCHEMA_VERSION = migrations.length; + +export function getDatabase() { + if (_db) return _db; + + if (!existsSync(CONFIG_DIR)) { + mkdirSync(CONFIG_DIR, { recursive: true }); + } + + _db = new Database(DB_PATH); + _db.pragma('journal_mode = WAL'); + _db.pragma('busy_timeout = 5000'); + + runMigrations(_db); + return _db; +} + +function runMigrations(db) { + db.exec(`CREATE TABLE IF NOT EXISTS schema_version ( + version INTEGER PRIMARY KEY, + migrated_at TEXT NOT NULL + )`); + + const currentVersion = db.prepare('SELECT MAX(version) as v FROM schema_version').get()?.v || 0; + + if (currentVersion > CURRENT_SCHEMA_VERSION) { + throw new Error( + `This database was created by a newer version of outlook-cli (schema v${currentVersion}). ` + + `This binary supports up to schema v${CURRENT_SCHEMA_VERSION}. Please upgrade.` + ); + } + + for (let i = currentVersion; i < migrations.length; i++) { + db.transaction(() => { + migrations[i](db); + db.prepare('INSERT INTO schema_version (version, migrated_at) VALUES (?, ?)').run( + i + 1, + new Date().toISOString() + ); + })(); + } +} + +export function closeDatabase() { + if (_db) { + _db.close(); + _db = null; + } +} + +// For tests: allow overriding the DB path +export function getDatabaseAt(path) { + const db = new Database(path); + db.pragma('journal_mode = WAL'); + db.pragma('busy_timeout = 5000'); + try { + runMigrations(db); + } catch (err) { + db.close(); + throw err; + } + return db; +} diff --git a/src/node/db/logger.js b/src/node/db/logger.js new file mode 100644 index 0000000..889d5c4 --- /dev/null +++ b/src/node/db/logger.js @@ -0,0 +1,170 @@ +/** + * Operation logger with correlation IDs. + * + * Every CLI command and Graph API call gets a unique correlation ID. + * Operations can be nested (parent_id) for tracing: + * "watch" → "delta sync" → "list messages" → "GET /me/messages" + * + * Usage: + * const opId = startOperation('work', 'mail inbox', 'GET /me/messages', { top: 25 }); + * try { + * const result = await doWork(); + * completeOperation(opId, { count: result.length }); + * } catch (err) { + * failOperation(opId, err); + * } + */ +import { randomUUID } from 'crypto'; +import { userInfo } from 'os'; +import { createRequire } from 'module'; +import { getDatabase } from './database.js'; + +const require = createRequire(import.meta.url); +const versionInfo = require('../../shared/version.json'); + +function getOsUser() { + try { + return userInfo().username; + } catch { + return process.env.USER || process.env.USERNAME || 'unknown'; + } +} + +export function startOperation(account, command, operation, requestSummary = null, parentId = null, db = null, correlationId = null) { + const id = randomUUID(); + const corrId = correlationId || randomUUID(); + db = db || getDatabase(); + db.prepare(` + INSERT INTO operations (id, parent_id, account, command, operation, status, started_at, request_summary, correlation_id, user, runtime, version) + VALUES (?, ?, ?, ?, ?, 'started', datetime('now'), ?, ?, ?, 'node', ?) + `).run(id, parentId, account, command, operation, requestSummary ? JSON.stringify(requestSummary) : null, corrId, getOsUser(), versionInfo.version); + return id; +} + +export function completeOperation(id, responseSummary = null, db = null) { + db = db || getDatabase(); + db.prepare(` + UPDATE operations + SET status = 'completed', + completed_at = datetime('now'), + duration_ms = CAST((julianday('now') - julianday(started_at)) * 86400000 AS INTEGER), + response_summary = ? + WHERE id = ? + `).run(responseSummary ? JSON.stringify(responseSummary) : null, id); +} + +export function failOperation(id, error, db = null) { + db = db || getDatabase(); + const errMsg = error instanceof Error ? error.message : String(error); + const errStack = error instanceof Error ? error.stack : null; + db.prepare(` + UPDATE operations + SET status = 'failed', + completed_at = datetime('now'), + duration_ms = CAST((julianday('now') - julianday(started_at)) * 86400000 AS INTEGER), + error_message = ?, + error_stack = ? + WHERE id = ? + `).run(errMsg, errStack, id); +} + +export function getOperation(id, db = null) { + db = db || getDatabase(); + return db.prepare('SELECT * FROM operations WHERE id = ?').get(id); +} + +export function listOperations({ account, command, status, since, top = 50 } = {}, db = null) { + db = db || getDatabase(); + let sql = 'SELECT * FROM operations WHERE 1=1'; + const params = []; + + if (account) { sql += ' AND account = ?'; params.push(account); } + if (command) { sql += ' AND command LIKE ?'; params.push(`%${command}%`); } + if (status) { sql += ' AND status = ?'; params.push(status); } + if (since) { sql += ' AND started_at >= ?'; params.push(since); } + + sql += ' ORDER BY started_at DESC LIMIT ?'; + params.push(top); + + return db.prepare(sql).all(...params); +} + +export function getChildOperations(parentId, db = null) { + db = db || getDatabase(); + return db.prepare('SELECT * FROM operations WHERE parent_id = ? ORDER BY started_at').all(parentId); +} + +export function searchOperations(query, top = 50, db = null) { + db = db || getDatabase(); + return db.prepare(` + SELECT * FROM operations + WHERE command LIKE ? OR operation LIKE ? OR error_message LIKE ? OR request_summary LIKE ? + ORDER BY started_at DESC LIMIT ? + `).all(`%${query}%`, `%${query}%`, `%${query}%`, `%${query}%`, top); +} + +export function compactOperations(beforeDate, db = null) { + db = db || getDatabase(); + const result = db.prepare('DELETE FROM operations WHERE started_at < ?').run(beforeDate); + return result.changes; +} + +export function listByCorrelationId(correlationId, db = null) { + db = db || getDatabase(); + return db.prepare('SELECT * FROM operations WHERE correlation_id = ? ORDER BY started_at').all(correlationId); +} + +export function getSummary(days = 7, db = null) { + db = db || getDatabase(); + const since = new Date(Date.now() - days * 86400000).toISOString(); + + const total = db.prepare("SELECT COUNT(*) as count FROM operations WHERE started_at >= ?").get(since).count; + const errors = db.prepare("SELECT COUNT(*) as count FROM operations WHERE status = 'failed' AND started_at >= ?").get(since).count; + const avgLatency = db.prepare("SELECT AVG(duration_ms) as avg FROM operations WHERE duration_ms IS NOT NULL AND started_at >= ?").get(since).avg; + const throttled = db.prepare("SELECT COUNT(*) as count FROM operations WHERE error_message LIKE '%throttl%' AND started_at >= ?").get(since).count; + + const perDay = db.prepare(` + SELECT DATE(started_at) as day, COUNT(*) as count + FROM operations WHERE started_at >= ? + GROUP BY DATE(started_at) ORDER BY day + `).all(since); + + const topOperations = db.prepare(` + SELECT command, COUNT(*) as count + FROM operations WHERE started_at >= ? + GROUP BY command ORDER BY count DESC LIMIT 10 + `).all(since); + + const perAccount = db.prepare(` + SELECT account, COUNT(*) as total, + SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as errors, + AVG(duration_ms) as avg_latency_ms + FROM operations WHERE started_at >= ? + GROUP BY account ORDER BY total DESC + `).all(since); + + return { + days, + since, + total, + errors, + errorRate: total > 0 ? (errors / total * 100).toFixed(1) : '0.0', + avgLatencyMs: avgLatency !== null ? Math.round(avgLatency) : null, + throttled, + perDay, + topOperations, + perAccount, + }; +} + +export function getStats(db = null) { + db = db || getDatabase(); + return { + total: db.prepare('SELECT COUNT(*) as count FROM operations').get().count, + completed: db.prepare("SELECT COUNT(*) as count FROM operations WHERE status = 'completed'").get().count, + failed: db.prepare("SELECT COUNT(*) as count FROM operations WHERE status = 'failed'").get().count, + avgDurationMs: db.prepare("SELECT AVG(duration_ms) as avg FROM operations WHERE duration_ms IS NOT NULL").get().avg, + byCommand: db.prepare("SELECT command, COUNT(*) as count FROM operations GROUP BY command ORDER BY count DESC LIMIT 10").all(), + byAccount: db.prepare("SELECT account, COUNT(*) as count FROM operations GROUP BY account ORDER BY count DESC").all(), + }; +} diff --git a/src/node/errors.js b/src/node/errors.js new file mode 100644 index 0000000..b466a4a --- /dev/null +++ b/src/node/errors.js @@ -0,0 +1,513 @@ +/** + * Typed error classes for outlook-cli. + * + * Each error type provides: + * - userMessage: Plain English for end users + * - suggestedAction: What the user should do to fix it + * - technicalDetail: For --verbose output + * - code: Machine-readable error code for programmatic handling + * + * The global error handler in cli/index.js catches these and formats them + * appropriately for the output format (text, json, etc.). + */ + +/** + * Base class for all outlook-cli errors. + * Subclasses set `code` and provide structured error information. + */ +export class OutlookCliError extends Error { + constructor(userMessage, { code, suggestedAction, technicalDetail, cause } = {}) { + super(userMessage); + this.name = 'OutlookCliError'; + this.code = code || 'UNKNOWN_ERROR'; + this.suggestedAction = suggestedAction || ''; + this.technicalDetail = technicalDetail || ''; + if (cause) this.cause = cause; + } + + /** Format for JSON output. */ + toJSON() { + return { + error: true, + code: this.code, + message: this.message, + suggestedAction: this.suggestedAction || undefined, + technicalDetail: this.technicalDetail || undefined, + }; + } +} + +/** + * Authentication errors — token expired, cache corrupt, re-login needed. + */ +export class AuthError extends OutlookCliError { + constructor(userMessage, opts = {}) { + super(userMessage, { code: 'AUTH_ERROR', ...opts }); + this.name = 'AuthError'; + } + + static tokenExpired(account) { + return new AuthError( + `Authentication expired for account "${account}".`, + { + code: 'AUTH_TOKEN_EXPIRED', + suggestedAction: `Run \`outlook-cli auth login --account ${account}\` to re-authenticate.`, + technicalDetail: 'Refresh token has expired or been revoked.', + } + ); + } + + static cacheCorrupt(account, innerError) { + return new AuthError( + `Could not decrypt token cache for "${account}".`, + { + code: 'AUTH_CACHE_CORRUPT', + suggestedAction: `Run \`outlook-cli auth login --account ${account}\` to re-authenticate.`, + technicalDetail: innerError?.message || 'Cache decryption failed.', + cause: innerError, + } + ); + } + + static noToken(account) { + return new AuthError( + `No valid token for account "${account}".`, + { + code: 'AUTH_NO_TOKEN', + suggestedAction: `Run \`outlook-cli auth login --account ${account}\` to authenticate.`, + } + ); + } + + static noClientId() { + return new AuthError( + 'No client ID configured.', + { + code: 'AUTH_NO_CLIENT_ID', + suggestedAction: 'Provide --client-id, set OUTLOOK_CLI_CLIENT_ID env var, or run `outlook-cli account add`.', + } + ); + } + + static deviceCodeExpired() { + return new AuthError( + 'The device code expired or was already used.', + { + code: 'AUTH_DEVICE_CODE_EXPIRED', + suggestedAction: 'Run `outlook-cli auth login` again — you have ~15 minutes to enter the code.', + } + ); + } + + static deviceCodeTimeout() { + return new AuthError( + 'Login timed out waiting for you to enter the code.', + { + code: 'AUTH_DEVICE_CODE_TIMEOUT', + suggestedAction: 'Run `outlook-cli auth login` again and enter the code promptly.', + } + ); + } + + static clientNotFound(clientId) { + return new AuthError( + 'The client ID was not found in Microsoft Entra ID.', + { + code: 'AUTH_CLIENT_NOT_FOUND', + suggestedAction: 'Verify --client-id matches the Application (client) ID in your App Registration.', + technicalDetail: `Client ID: ${clientId}`, + } + ); + } + + static adminConsentRequired() { + return new AuthError( + 'Admin consent may be required for the requested permissions.', + { + code: 'AUTH_CONSENT_REQUIRED', + suggestedAction: 'Ask your IT admin to grant consent for the app, or use a personal Microsoft account.', + } + ); + } +} + +/** + * Graph API errors — 4xx/5xx from Microsoft Graph. + */ +export class GraphApiError extends OutlookCliError { + constructor(userMessage, opts = {}) { + super(userMessage, { code: 'GRAPH_API_ERROR', ...opts }); + this.name = 'GraphApiError'; + this.statusCode = opts.statusCode; + this.graphRequestId = opts.graphRequestId; + } + + static fromResponse(statusCode, body, endpoint) { + const graphError = body?.error || {}; + const graphCode = graphError.code || ''; + const graphMessage = graphError.message || 'Unknown Graph API error'; + const requestId = body?.error?.innerError?.['request-id'] || ''; + + const map = { + 401: { + msg: 'Authentication failed — your token may be expired.', + action: 'Run `outlook-cli auth login` to re-authenticate.', + code: 'GRAPH_AUTH_FAILED', + }, + 403: { + msg: `Permission denied for ${endpoint}.`, + action: 'Check that your Azure App Registration has the required API permissions.', + code: 'GRAPH_FORBIDDEN', + }, + 404: { + msg: `Resource not found: ${endpoint}.`, + action: 'Verify the message ID or folder name is correct.', + code: 'GRAPH_NOT_FOUND', + }, + 429: { + msg: 'Rate limited by Microsoft Graph API.', + action: 'Wait a moment and try again. The CLI will auto-retry.', + code: 'GRAPH_THROTTLED', + }, + 503: { + msg: 'Microsoft Graph API is temporarily unavailable.', + action: 'Wait a few minutes and try again.', + code: 'GRAPH_UNAVAILABLE', + }, + }; + + const entry = map[statusCode] || { + msg: `Graph API error (${statusCode}): ${graphMessage}`, + action: '', + code: 'GRAPH_API_ERROR', + }; + + const err = new GraphApiError(entry.msg, { + code: entry.code, + statusCode, + suggestedAction: entry.action, + technicalDetail: `${graphCode}: ${graphMessage}`, + graphRequestId: requestId, + }); + err.graphCode = graphCode; + return err; + } + + toJSON() { + return { + ...super.toJSON(), + statusCode: this.statusCode, + graphRequestId: this.graphRequestId || undefined, + }; + } +} + +/** + * Configuration errors — invalid config, schema mismatch, missing file. + */ +export class ConfigError extends OutlookCliError { + constructor(userMessage, opts = {}) { + super(userMessage, { code: 'CONFIG_ERROR', ...opts }); + this.name = 'ConfigError'; + } + + static notFound(filePath) { + return new ConfigError( + `Configuration file not found: ${filePath}`, + { + code: 'CONFIG_NOT_FOUND', + suggestedAction: 'Run `outlook-cli account add` to create initial configuration.', + } + ); + } + + static parseError(filePath, innerError) { + return new ConfigError( + `Could not parse configuration file: ${filePath}`, + { + code: 'CONFIG_PARSE_ERROR', + suggestedAction: 'Check the file for JSON syntax errors, or delete it and reconfigure.', + technicalDetail: innerError?.message || 'Invalid JSON', + cause: innerError, + } + ); + } + + static schemaVersionMismatch(filePath, fileVersion, currentVersion) { + const newer = fileVersion > currentVersion; + return new ConfigError( + newer + ? `${filePath} has schema version ${fileVersion} but this binary only supports up to ${currentVersion}.` + : `${filePath} has outdated schema version ${fileVersion} (current: ${currentVersion}).`, + { + code: newer ? 'CONFIG_TOO_NEW' : 'CONFIG_TOO_OLD', + suggestedAction: newer + ? 'Please upgrade your outlook-cli binary to the latest version.' + : 'Run `outlook-cli upgrade` to migrate your configuration files.', + } + ); + } + + static accountNotFound(alias) { + return new ConfigError( + `Account "${alias}" not found.`, + { + code: 'CONFIG_ACCOUNT_NOT_FOUND', + suggestedAction: 'Run `outlook-cli account list` to see available accounts, or `outlook-cli account add` to create one.', + } + ); + } + + static noAccountConfigured() { + return new ConfigError( + 'No account configured.', + { + code: 'CONFIG_NO_ACCOUNT', + suggestedAction: 'Run `outlook-cli account add --client-id ` to set up your first account.', + } + ); + } + + static missingField(fieldName, context) { + return new ConfigError( + `Missing required field: ${fieldName}${context ? ` in ${context}` : ''}.`, + { + code: 'CONFIG_MISSING_FIELD', + suggestedAction: `Add "${fieldName}" to your configuration.`, + } + ); + } +} + +/** + * Version mismatch errors — binary/config version incompatibility. + */ +export class VersionError extends OutlookCliError { + constructor(userMessage, opts = {}) { + super(userMessage, { code: 'VERSION_ERROR', ...opts }); + this.name = 'VersionError'; + } + + static binaryStale(currentVersion, lastSeenVersion) { + return new VersionError( + `This binary is v${currentVersion} but v${lastSeenVersion} was used previously.`, + { + code: 'VERSION_STALE_BINARY', + suggestedAction: 'Rebuild or republish your binary, or run `npm install` to update Node.js.', + } + ); + } + + static configTooNew(filePath, fileVersion, maxVersion) { + return new VersionError( + `${filePath} requires version ${fileVersion}+ but this binary is ${maxVersion}.`, + { + code: 'VERSION_CONFIG_TOO_NEW', + suggestedAction: 'Upgrade your outlook-cli binary to the latest version.', + } + ); + } +} + +/** + * Throttle errors — rate limited by Graph API. + */ +export class ThrottleError extends OutlookCliError { + constructor(retryAfterSeconds, opts = {}) { + super(`Rate limited by Microsoft Graph. Retry after ${retryAfterSeconds}s.`, { + code: 'THROTTLED', + suggestedAction: 'The CLI will retry automatically. For sustained use, reduce request frequency.', + ...opts, + }); + this.name = 'ThrottleError'; + this.retryAfterSeconds = retryAfterSeconds; + } +} + +/** + * Permission errors — scope not granted or send_to whitelist violation. + */ +export class PermissionError extends OutlookCliError { + constructor(userMessage, opts = {}) { + super(userMessage, { code: 'PERMISSION_ERROR', ...opts }); + this.name = 'PermissionError'; + } + + static scopeNotGranted(scope, account) { + return new PermissionError( + `The "${scope}" permission is not granted for account "${account}".`, + { + code: 'PERMISSION_SCOPE_MISSING', + suggestedAction: `Add "${scope}" to the Azure App Registration, then run \`outlook-cli auth login --account ${account}\`.`, + } + ); + } + + static scopeForbidden(scope, account) { + return new PermissionError( + `The "${scope}" permission is forbidden for account "${account}".`, + { + code: 'PERMISSION_SCOPE_FORBIDDEN', + suggestedAction: `Update permissions in accounts.json to allow "${scope}" for this account.`, + } + ); + } + + static recipientBlocked(blockedAddresses, account) { + const list = blockedAddresses.join(', '); + return new PermissionError( + `Cannot send to: ${list} — not in the send_to whitelist for account "${account}".`, + { + code: 'PERMISSION_RECIPIENT_BLOCKED', + suggestedAction: 'Add the recipient to the "send_to" list in accounts.json, or remove the whitelist to allow all recipients.', + } + ); + } + + static securityViolation(scopes) { + return new PermissionError( + `SECURITY: Token contains dangerous scope(s): ${scopes.join(', ')}`, + { + code: 'PERMISSION_SECURITY_VIOLATION', + suggestedAction: 'These scopes are forbidden. Revoke the token and reconfigure your Azure App Registration.', + } + ); + } +} + +/** + * Network errors — DNS failure, connection refused, timeout. + * Codes aligned with C# implementation (GRAPH_NETWORK_ERROR, GRAPH_TIMEOUT). + */ +export class NetworkError extends OutlookCliError { + constructor(userMessage, opts = {}) { + super(userMessage, { code: 'NETWORK_ERROR', ...opts }); + this.name = 'NetworkError'; + } + + static connectionFailed(service, innerError) { + const detail = innerError?.message || ''; + let action = 'Check your internet connection and try again.'; + + if (detail.includes('ENOTFOUND')) { + action = 'DNS resolution failed — check your internet connection.'; + } else if (detail.includes('ECONNREFUSED')) { + action = 'Connection refused — check your network or firewall settings.'; + } else if (detail.includes('ETIMEDOUT') || detail.includes('ENETUNREACH')) { + action = 'Network unreachable — check your internet connection.'; + } else if (detail.includes('CERT_') || detail.includes('certificate') || detail.includes('SSL')) { + action = 'TLS/SSL error — check your system clock and proxy settings.'; + } + + return new NetworkError( + `Cannot reach ${service} — network error.`, + { + code: 'GRAPH_NETWORK_ERROR', + suggestedAction: action, + technicalDetail: detail, + cause: innerError, + } + ); + } + + static timeout(operation) { + return new NetworkError( + `${operation} timed out.`, + { + code: 'GRAPH_TIMEOUT', + suggestedAction: 'Check your internet connection and try again.', + } + ); + } + + static authNetworkError(account, innerError) { + return new NetworkError( + `Network error while refreshing token for "${account}".`, + { + code: 'AUTH_NETWORK_ERROR', + suggestedAction: 'Check your internet connection and try again. Your login is still valid — this is a network issue, not an auth issue.', + technicalDetail: innerError?.message || '', + cause: innerError, + } + ); + } +} + +/** + * Input validation errors — missing args, invalid formats. + */ +export class InputError extends OutlookCliError { + constructor(userMessage, opts = {}) { + super(userMessage, { code: 'INPUT_ERROR', ...opts }); + this.name = 'InputError'; + } + + static missingArg(argName, context) { + return new InputError( + `${argName} is required${context ? ` (${context})` : ''}.`, + { + code: 'INPUT_MISSING_ARG', + suggestedAction: `Provide ${argName} as a positional argument or in --input JSON file.`, + } + ); + } + + static invalidFormat(value, expectedFormat) { + return new InputError( + `Invalid value: "${value}". Expected ${expectedFormat}.`, + { + code: 'INPUT_INVALID_FORMAT', + } + ); + } +} + +/** + * Format an OutlookCliError for terminal display. + * + * @param {OutlookCliError} error - The typed error + * @param {object} opts - { verbose: boolean } + * @returns {string} Formatted error string for stderr + */ +export function formatError(error, { verbose = false } = {}) { + const lines = []; + + if (error instanceof OutlookCliError) { + lines.push(`❌ ${error.message}`); + if (error.suggestedAction) { + lines.push(` Fix: ${error.suggestedAction}`); + } + if (error.technicalDetail && verbose) { + lines.push(` Details: ${error.technicalDetail}`); + } + if (error.graphRequestId && verbose) { + lines.push(` Graph Request ID: ${error.graphRequestId}`); + } + if (error.cause && verbose) { + lines.push(` Cause: ${error.cause.message || error.cause}`); + } + } else if (error instanceof Error) { + lines.push(`❌ ${error.message}`); + if (verbose && error.stack) { + lines.push(` Stack: ${error.stack}`); + } + } else { + lines.push(`❌ ${String(error)}`); + } + + return lines.join('\n'); +} + +/** + * Format an error for JSON output. + */ +export function formatErrorJson(error) { + if (error instanceof OutlookCliError) { + return error.toJSON(); + } + return { + error: true, + code: 'UNKNOWN_ERROR', + message: error?.message || String(error), + }; +} diff --git a/src/node/graph/calendar.js b/src/node/graph/calendar.js new file mode 100644 index 0000000..493c7dc --- /dev/null +++ b/src/node/graph/calendar.js @@ -0,0 +1,126 @@ +/** + * Calendar Graph API wrappers. + * + * IMPORTANT DISTINCTION: This module uses the /calendarView endpoint for listing + * events, NOT /events. calendarView expands recurring events into individual + * occurrences within the requested time window, which is what users expect when + * asking "what's on my calendar today." The /events endpoint only returns the + * master series, which would miss most recurring meetings. + * + * All functions accept a `client` from graph/client.js. Like mail.js, the client + * exposes `client.userPath` ("/me" or "/users/{email}") enabling delegate calendar + * access with no code changes — the same functions work for both own and shared calendars. + * + * C# PORT NOTE: The Microsoft.Graph SDK has CalendarView as a navigation property. + * The Prefer header for timezone must be set manually in both JS and C#. + */ + +/** + * Get today's events using calendarView. + * + * Computes midnight-to-midnight in local time, then delegates to getEventsInRange. + */ +export async function getTodayEvents(client, options = {}) { + const tz = options.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone; + const now = new Date(); + const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const endOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1); + + return getEventsInRange(client, { + start: startOfDay.toISOString(), + end: endOfDay.toISOString(), + timezone: tz, + }); +} + +/** + * Get this week's events using calendarView. + * + * Week starts on Sunday (getDay() === 0). Computes the enclosing Sun–Sat range. + */ +export async function getWeekEvents(client, options = {}) { + const tz = options.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone; + const now = new Date(); + const dayOfWeek = now.getDay(); + const startOfWeek = new Date(now.getFullYear(), now.getMonth(), now.getDate() - dayOfWeek); + const endOfWeek = new Date(now.getFullYear(), now.getMonth(), now.getDate() + (7 - dayOfWeek)); + + return getEventsInRange(client, { + start: startOfWeek.toISOString(), + end: endOfWeek.toISOString(), + timezone: tz, + }); +} + +/** + * Get events in a date range using calendarView. + * + * Graph endpoint: GET {userPath}/calendarView?startDateTime=...&endDateTime=... + * + * Key details: + * - calendarView requires both startDateTime and endDateTime (unlike /events) + * - $orderby=start/dateTime sorts chronologically + * - The Prefer header with outlook.timezone tells Graph to return start/end times + * in the user's timezone rather than UTC. Without this, all times are UTC and + * the CLI would need to convert them locally. + * - $top=50 is generous; most users have < 50 events in a single time range + */ +export async function getEventsInRange(client, options) { + const { start, end, timezone } = options; + const top = options.top || 50; + + const path = `${client.userPath}/calendarView?startDateTime=${encodeURIComponent(start)}&endDateTime=${encodeURIComponent(end)}&$top=${top}&$orderby=start/dateTime&$select=id,subject,start,end,location,organizer,attendees,isAllDay,isCancelled,showAs,importance`; + + const headers = {}; + if (timezone) { + // Prefer header tells Graph to return event times in this timezone + // instead of UTC — critical for correct CLI display. + headers['Prefer'] = `outlook.timezone="${timezone}"`; + } + + const response = await client.get(path, { headers }); + return response.value || []; +} + +/** + * Get a single event by ID. + * + * Graph endpoint: GET {userPath}/events/{id} + * Uses /events (not /calendarView) because we have a specific ID, not a time range. + */ +export async function getEvent(client, eventId) { + return client.get(`${client.userPath}/events/${encodeURIComponent(eventId)}`); +} + +/** + * List all calendars (not events — the calendar containers themselves). + * + * Graph endpoint: GET {userPath}/calendars + */ +export async function listCalendars(client) { + const response = await client.get(`${client.userPath}/calendars?$top=100`); + return response.value || []; +} + +/** + * Create a calendar event. + * + * Graph endpoint: POST {userPath}/events + * The event object should include subject, start, end, and optionally + * attendees, location, body. Timezone is specified per start/end object + * (e.g. { dateTime: "...", timeZone: "America/New_York" }). + */ +export async function createEvent(client, event) { + return client.post(`${client.userPath}/events`, event); +} + +/** + * Delete a calendar event. + * + * Graph endpoint: DELETE {userPath}/events/{id} + * This permanently removes the event (no soft-delete to trash like mail). + */ +export async function deleteEvent(client, eventId) { + await client.delete(`${client.userPath}/events/${encodeURIComponent(eventId)}`); + return { success: true, eventId, deleted: true }; +} diff --git a/src/node/graph/client.js b/src/node/graph/client.js new file mode 100644 index 0000000..d0d497a --- /dev/null +++ b/src/node/graph/client.js @@ -0,0 +1,455 @@ +/** + * Microsoft Graph API HTTP client. + * + * Wraps the Graph REST API (https://graph.microsoft.com/v1.0) with: + * - Automatic token acquisition via MSAL (silent → cached → refresh) + * - Token validation (defense-in-depth scope check on every request) + * - 401 retry (token expired mid-request → clear cache, re-acquire, retry) + * - 429 rate-limit handling (respect Retry-After header) + * - Pagination support (follow @odata.nextLink) + * - Delegate access (/users/{email} instead of /me when --as is used) + * + * All Graph API modules (mail.js, calendar.js, contacts.js) use this client. + * They call client.get/post/patch/delete with Graph API paths, and the client + * handles authentication, retries, and error formatting. + */ +import { acquireTokenSilently, createMsalClient } from '../auth/msal-client.js'; +import { validateTokenScopes } from '../security/token-validator.js'; +import { AuthError, GraphApiError, NetworkError } from '../errors.js'; +import { getTelemetry } from '../telemetry/collector.js'; + +const GRAPH_BASE = 'https://graph.microsoft.com/v1.0'; + +/** + * Create an authenticated Graph API client for a specific account. + * + * @param {string} accountAlias - Account nickname (matches the encrypted cache file) + * @param {object} accountConfig - Account config from accounts.json (clientId, tenantId) + * @param {object} [options] - Additional options + * @param {string} [options.delegateFor] - Email address for delegate access. + * When set, all API calls target /users/{email}/ instead of /me/. + * Requires the mailbox owner to have granted delegate permissions. + */ +export async function createGraphClient(accountAlias, accountConfig, options = {}) { + const msalClient = await createMsalClient( + accountAlias, + accountConfig.clientId, + accountConfig.tenantId + ); + + // Pass account mode and custom scopes so token acquisition uses the right scopes + const scopeOptions = { + customScopes: accountConfig.scopes, + mode: accountConfig.mode, + }; + + return new GraphClient(msalClient, accountAlias, options.delegateFor, scopeOptions); +} + +class GraphClient { + constructor(msalClient, accountAlias, delegateFor, scopeOptions = {}) { + this.msalClient = msalClient; + this.accountAlias = accountAlias; + this.delegateFor = delegateFor || null; + this.scopeOptions = scopeOptions; + this._cachedToken = null; // In-memory token cache (avoids disk reads on every call) + this._tokenExpiry = 0; // Epoch ms when cached token expires + } + + /** + * Get the base user path for Graph API requests. + * + * This is the key mechanism for delegate access: + * - Own account: /me (default) + * - Delegate: /users/fred%40example.com + * + * All graph API modules (mail.js, calendar.js, contacts.js) prepend this + * to their endpoint paths. Microsoft Graph handles permission enforcement + * server-side — if you don't have delegate access, you get 403. + */ + get userPath() { + if (this.delegateFor) { + return `/users/${encodeURIComponent(this.delegateFor)}`; + } + return '/me'; + } + + /** + * Get a valid access token, refreshing if needed. + * + * Token lifecycle: + * 1. Check in-memory cache (fast path — no disk I/O) + * 2. If expired, call MSAL acquireTokenSilently (may refresh via network) + * 3. Validate token scopes (defense-in-depth — reject if Mail.Send present) + * 4. Cache the new token in memory with 1-minute buffer before expiry + * + * @throws {Error} If no valid token exists (user must re-authenticate) + */ + async getToken() { + const now = Date.now(); + // Use cached token if it's still valid (with 60s safety buffer) + if (this._cachedToken && now < this._tokenExpiry - 60_000) { + return this._cachedToken; + } + + let tokenResponse; + try { + tokenResponse = await acquireTokenSilently(this.msalClient, this.scopeOptions); + } catch (err) { + // Network errors during token refresh — don't mislead user about auth state + if (err instanceof NetworkError) { + throw err; + } + if (isNetworkError(err)) { + throw NetworkError.authNetworkError(this.accountAlias, err); + } + throw err; + } + + const { result, reason } = tokenResponse; + + if (!result) { + // Provide specific error messages based on WHY the token is unavailable + switch (reason) { + case 'no_account': + throw AuthError.noToken(this.accountAlias); + case 'refresh_token_expired': + throw AuthError.tokenExpired(this.accountAlias); + case 'password_changed': + throw new AuthError( + `Password changed for account "${this.accountAlias}".`, + { + code: 'AUTH_PASSWORD_CHANGED', + suggestedAction: `Run \`outlook-cli auth login --account ${this.accountAlias}\` to re-authenticate with your new password.`, + } + ); + case 'mfa_required': + throw new AuthError( + `Multi-factor authentication required for account "${this.accountAlias}".`, + { + code: 'AUTH_MFA_REQUIRED', + suggestedAction: `Run \`outlook-cli auth login --account ${this.accountAlias}\` to complete MFA.`, + } + ); + case 'consent_required': + throw new AuthError( + `New permissions required for account "${this.accountAlias}".`, + { + code: 'AUTH_CONSENT_REQUIRED', + suggestedAction: `Run \`outlook-cli auth login --account ${this.accountAlias}\` to grant new permissions. This happens after a CLI update that adds new features (e.g., OneDrive support).`, + technicalDetail: 'The cached token was granted with fewer scopes than the current version requires. A fresh login will request all needed permissions.', + } + ); + case 'interaction_required': + throw new AuthError( + `Re-authentication required for account "${this.accountAlias}".`, + { + code: 'AUTH_INTERACTION_REQUIRED', + suggestedAction: `Run \`outlook-cli auth login --account ${this.accountAlias}\` to re-authenticate. This can happen after a CLI update that adds new permissions.`, + technicalDetail: 'The identity provider requires interactive authentication. This commonly occurs when new API scopes are added in a CLI update.', + } + ); + case 'scopes_unauthorized': + throw new AuthError( + `Some requested API scopes are not authorized in the Azure App Registration for account "${this.accountAlias}".`, + { + code: 'AUTH_SCOPES_UNAUTHORIZED', + suggestedAction: `Run \`outlook-cli auth login --account ${this.accountAlias}\` to re-authenticate. If the error persists, check that all required scopes are added to your Azure App Registration's API permissions (portal.azure.com → App registrations → API permissions).`, + technicalDetail: 'The identity provider rejected the token request because one or more requested scopes are not configured in the Azure App Registration. This commonly happens after a CLI update that adds optional features (e.g., OneDrive).', + } + ); + case 'rate_limited': + throw new AuthError( + `Too many authentication attempts for account "${this.accountAlias}". Microsoft has temporarily blocked token requests.`, + { + code: 'AUTH_RATE_LIMITED', + suggestedAction: `Wait 5–10 minutes, then try again. If the problem persists, run \`outlook-cli auth login --account ${this.accountAlias}\` to get a fresh token.`, + technicalDetail: 'Microsoft Entra ID detected too many token requests in a short period and activated abuse protection (AADSTS70000). This is temporary and resolves after a cooldown period.', + } + ); + default: + throw AuthError.noToken(this.accountAlias); + } + } + + // Defense-in-depth: validate scopes on every token acquisition. + // This catches the case where someone adds Mail.Send to the App Registration + // after initial setup. + validateTokenScopes(result); + + this._cachedToken = result.accessToken; + this._tokenExpiry = result.expiresOn?.getTime() || (now + 3600_000); + return this._cachedToken; + } + + /** + * Make a GET request to the Graph API. + */ + async get(path, options = {}) { + return this.request('GET', path, null, options); + } + + /** + * Make a POST request to the Graph API. + */ + async post(path, body, options = {}) { + return this.request('POST', path, body, options); + } + + /** + * Make a PATCH request to the Graph API. + */ + async patch(path, body, options = {}) { + return this.request('PATCH', path, body, options); + } + + /** + * Make a PUT request to the Graph API (used for file uploads). + */ + async put(path, body, options = {}) { + return this.request('PUT', path, body, options); + } + + /** + * Make a DELETE request to the Graph API. + */ + async delete(path, options = {}) { + return this.request('DELETE', path, null, options); + } + + /** + * Core HTTP request method with retry logic. + * + * Retry behavior: + * - 401 Unauthorized → Clear token cache, re-acquire, retry (token may have expired) + * - 429 Too Many Requests → Wait for Retry-After seconds, then retry + * - Other errors → Throw immediately (no retry) + * + * @param {string} method - HTTP method (GET, POST, PATCH, DELETE) + * @param {string} path - Graph API path (e.g., /me/messages) or full URL + * @param {object|null} body - Request body (JSON-serializable) or null + * @param {object} options - Request options (headers, maxRetries) + */ + async request(method, path, body, options = {}) { + const maxRetries = options.maxRetries ?? 2; + // Support both relative paths (/me/messages) and full URLs (@odata.nextLink) + const url = path.startsWith('http') ? path : `${GRAPH_BASE}${path}`; + const timeoutMs = options.timeout ?? 30_000; + const telemetry = getTelemetry(); + const requestStart = Date.now(); + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + const token = await this.getToken(); + + const headers = { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + ...options.headers, // Allow callers to override (e.g., Prefer header for timezone) + }; + + const fetchOptions = { method, headers }; + if (body) { + // Buffer/ArrayBuffer bodies are sent raw (file uploads). + // Objects are JSON-serialized. + fetchOptions.body = Buffer.isBuffer(body) || body instanceof ArrayBuffer + ? body + : JSON.stringify(body); + } + + // Abort controller provides request timeout protection + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + fetchOptions.signal = controller.signal; + + let response; + try { + response = await fetch(url, fetchOptions); + } catch (fetchError) { + clearTimeout(timer); + const durationMs = Date.now() - requestStart; + if (fetchError.name === 'AbortError') { + telemetry.emit({ + event: 'graph.error', + account: this.accountAlias, + graphEndpoint: path, + graphMethod: method, + graphStatusCode: 0, + durationMs, + metadata: { error: 'timeout' }, + }); + throw NetworkError.timeout(`Graph API ${method} ${path}`); + } + telemetry.emit({ + event: 'graph.error', + account: this.accountAlias, + graphEndpoint: path, + graphMethod: method, + graphStatusCode: 0, + durationMs, + metadata: { error: fetchError.message }, + }); + throw NetworkError.connectionFailed('Microsoft Graph API', fetchError); + } + clearTimeout(timer); + + // 401 = token expired between getToken() and the request arriving. + // Clear the in-memory cache so getToken() refreshes on the next attempt. + if (response.status === 401 && attempt < maxRetries) { + this._cachedToken = null; + this._tokenExpiry = 0; + continue; + } + + // 429 = Microsoft Graph rate limiting. + // The Retry-After header tells us how long to wait (in seconds). + if (response.status === 429 && attempt < maxRetries) { + const retryAfter = parseInt(response.headers.get('Retry-After') || '5', 10); + telemetry.emit({ + event: 'graph.throttle', + account: this.accountAlias, + graphEndpoint: path, + graphMethod: method, + graphStatusCode: 429, + waitMs: retryAfter * 1000, + durationMs: Date.now() - requestStart, + }); + console.error(`Rate limited. Waiting ${retryAfter}s...`); + await sleep(retryAfter * 1000); + continue; + } + + // 204 No Content = success with no body (e.g., send, delete operations) + if (response.status === 204) { + telemetry.emit({ + event: 'graph.request', + account: this.accountAlias, + graphEndpoint: path, + graphMethod: method, + graphStatusCode: 204, + durationMs: Date.now() - requestStart, + }); + return null; + } + + // Binary response requested (file downloads). + // Returns a Buffer instead of parsing JSON. + if (options.raw) { + if (!response.ok) { + telemetry.emit({ + event: 'graph.error', + account: this.accountAlias, + graphEndpoint: path, + graphMethod: method, + graphStatusCode: response.status, + durationMs: Date.now() - requestStart, + }); + throw GraphApiError.fromResponse(response.status, null, path); + } + telemetry.emit({ + event: 'graph.request', + account: this.accountAlias, + graphEndpoint: path, + graphMethod: method, + graphStatusCode: response.status, + durationMs: Date.now() - requestStart, + }); + const arrayBuffer = await response.arrayBuffer(); + return Buffer.from(arrayBuffer); + } + + const responseBody = await response.json().catch(() => null); + + if (!response.ok) { + telemetry.emit({ + event: 'graph.error', + account: this.accountAlias, + graphEndpoint: path, + graphMethod: method, + graphStatusCode: response.status, + durationMs: Date.now() - requestStart, + }); + throw GraphApiError.fromResponse(response.status, responseBody, path); + } + + telemetry.emit({ + event: 'graph.request', + account: this.accountAlias, + graphEndpoint: path, + graphMethod: method, + graphStatusCode: response.status, + durationMs: Date.now() - requestStart, + graphItemCount: responseBody?.value?.length ?? null, + }); + + return responseBody; + } + + telemetry.emit({ + event: 'graph.error', + account: this.accountAlias, + graphEndpoint: path, + graphMethod: method, + graphStatusCode: 0, + durationMs: Date.now() - requestStart, + metadata: { error: 'max_retries_exceeded' }, + }); + throw new GraphApiError('Max retries exceeded for Graph API request.', { + code: 'GRAPH_MAX_RETRIES', + suggestedAction: 'Try again later.', + technicalDetail: `${method} ${path} failed after ${maxRetries + 1} attempts.`, + }); + } + + /** + * Fetch all pages of a paginated Graph API response. + * + * Microsoft Graph returns paginated results with @odata.nextLink. + * This method follows those links to collect all results. + * + * @param {string} path - Initial Graph API path + * @param {object} options - Request options + maxPages limit (default: 10) + * @returns {Array} All values from all pages + */ + async getAllPages(path, options = {}) { + const allValues = []; + let nextUrl = path; + const maxPages = options.maxPages ?? 10; + + for (let page = 0; page < maxPages; page++) { + const response = await this.get(nextUrl, options); + + if (response.value) { + allValues.push(...response.value); + } + + // @odata.nextLink is a full URL for the next page of results + if (response['@odata.nextLink']) { + nextUrl = response['@odata.nextLink']; + } else { + break; // No more pages + } + } + + return allValues; + } +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Detect network-level errors from MSAL or fetch. + * Used to distinguish "network is down" from "token expired". + */ +function isNetworkError(err) { + const msg = err?.message || ''; + const code = err?.code || ''; + return ( + code === 'ENOTFOUND' || code === 'ECONNREFUSED' || code === 'ETIMEDOUT' || + code === 'ENETUNREACH' || code === 'ECONNRESET' || code === 'ERR_NETWORK' || + msg.includes('ENOTFOUND') || msg.includes('ECONNREFUSED') || + msg.includes('ETIMEDOUT') || msg.includes('ENETUNREACH') || + msg.includes('fetch failed') || msg.includes('network') + ); +} diff --git a/src/node/graph/contacts.js b/src/node/graph/contacts.js new file mode 100644 index 0000000..4f72cf7 --- /dev/null +++ b/src/node/graph/contacts.js @@ -0,0 +1,73 @@ +/** + * Contact search — Graph API wrappers. + * + * DUAL-SOURCE STRATEGY: We search two separate Graph APIs and merge results: + * 1. /contacts — the user's personal contacts (Outlook address book). + * Uses $filter with startswith() because /contacts doesn't support $search. + * 2. /people — relevance-ranked results from the Global Address List (GAL) + * and recent communication history. Uses $search (KQL). + * + * Why both? Personal contacts give the user's curated address book; People API + * gives the full org directory plus ML-ranked relevance. Neither alone is sufficient. + * + * IMPORTANT: The People API (/people) requires the People.Read scope, which is + * only available for work/school (Azure AD) accounts. Personal Microsoft accounts + * will get a 403 here — we catch and continue so the command still returns + * personal contacts. + * + * Results are tagged with a `source` field ("contacts" or "people") so the UI + * can show where each result came from. + */ + +/** + * Search contacts across personal contacts and the People API (GAL). + * + * Order of operations: + * 1. Query /contacts with $filter (personal address book) + * 2. Query /people with $search (org directory + relevance) + * 3. Merge and deduplicate by primary email address (case-insensitive) + * + * @param {object} client - Graph client with userPath + * @param {string} query - Search string (name prefix or keyword) + * @param {object} options - { top: number } + * @returns {Array} Deduplicated contacts with `source` field + */ +export async function searchContacts(client, query, options = {}) { + const top = options.top || 25; + const results = []; + + // Source 1: Personal contacts — $filter with startswith on name fields + // Graph endpoint: GET {userPath}/contacts?$filter=startswith(...) + try { + const contactsPath = `${client.userPath}/contacts?$filter=startswith(displayName,'${encodeURIComponent(query)}') or startswith(givenName,'${encodeURIComponent(query)}') or startswith(surname,'${encodeURIComponent(query)}')&$top=${top}&$select=id,displayName,emailAddresses,companyName,jobTitle,mobilePhone,businessPhones`; + const contactsResponse = await client.get(contactsPath); + if (contactsResponse.value) { + results.push(...contactsResponse.value.map(c => ({ ...c, source: 'contacts' }))); + } + } catch { + // Personal contacts search may fail for some account types; continue + } + + // Source 2: People API — relevance-ranked from GAL + communication history + // Graph endpoint: GET {userPath}/people?$search="..." + try { + const peoplePath = `${client.userPath}/people?$search="${encodeURIComponent(query)}"&$top=${top}&$select=id,displayName,emailAddresses,companyName,jobTitle,department`; + const peopleResponse = await client.get(peoplePath); + if (peopleResponse.value) { + results.push(...peopleResponse.value.map(p => ({ ...p, source: 'people' }))); + } + } catch { + // People API requires People.Read scope — unavailable for personal accounts + } + + // Deduplicate by primary email address (case-insensitive). + // Personal contacts are added first, so they win on duplicates — the user's + // own contact data takes precedence over GAL entries. + const seen = new Set(); + return results.filter(r => { + const email = r.emailAddresses?.[0]?.address; + if (!email || seen.has(email.toLowerCase())) return false; + seen.add(email.toLowerCase()); + return true; + }); +} diff --git a/src/node/graph/delta.js b/src/node/graph/delta.js new file mode 100644 index 0000000..ca4b96a --- /dev/null +++ b/src/node/graph/delta.js @@ -0,0 +1,247 @@ +/** + * Delta Query engine for Microsoft Graph API. + * + * Delta queries are the efficient alternative to polling. Instead of re-fetching + * all messages/events on every check, a delta query returns ONLY what changed + * since the last call. Microsoft Graph tracks the sync state server-side and + * encodes it in a "deltaLink" URL token. + * + * HOW IT WORKS: + * 1. INITIAL SYNC: GET {resource}/delta → returns ALL items + deltaLink token + * 2. INCREMENTAL SYNC: GET {deltaLink} → returns ONLY changes (added/modified/deleted) + * 3. Deleted items come back as skeleton objects with an "@removed" property + * + * WHY THIS EXISTS: + * Mobile Outlook uses Azure push notifications (webhooks), but those require a + * publicly-accessible HTTPS endpoint — impractical for a CLI tool. Delta queries + * give us efficient "pull" updates: each call only returns net-new changes, + * making even frequent polling (every 30–60s) cheap in terms of data transfer + * and API quota. + * + * DELTA TOKEN LIFECYCLE: + * - Tokens are opaque URLs that include all original query parameters + * - They expire after ~30 days of inactivity (undocumented but observed) + * - If a delta token is invalid/expired, we detect the error and fall back + * to a full re-sync + * - Tokens are persisted to ~/.outlook-cli/delta-{alias}.json so the CLI + * can resume across restarts + * + * SUPPORTED RESOURCES: + * - Messages: /me/mailFolders/{folder}/messages/delta + * - Calendar: /me/calendarView/delta?startDateTime=...&endDateTime=... + * + * C# PORT NOTE: The .NET Microsoft.Graph SDK wraps delta queries as + * .Delta().Request().GetAsync() with a DeltaLink property on the page object. + * The raw REST approach used here maps more cleanly to HttpClient calls. + */ + +import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'; +import { join } from 'path'; +import { homedir } from 'os'; + +const CONFIG_DIR = join(homedir(), '.outlook-cli'); + +/** + * Persisted delta state for an account. + * + * Structure: { "mail:Inbox": "https://...deltaLink...", "calendar:2024": "..." } + * Each key is a resource identifier, value is the full deltaLink URL. + */ +class DeltaStore { + constructor(accountAlias) { + this.filePath = join(CONFIG_DIR, `delta-${accountAlias}.json`); + this._cache = null; + } + + /** + * Load delta tokens from disk. Returns an empty object on first run. + * Caches in memory to avoid repeated disk reads during a watch session. + */ + _load() { + if (this._cache) return this._cache; + + if (!existsSync(CONFIG_DIR)) { + mkdirSync(CONFIG_DIR, { recursive: true }); + } + + if (existsSync(this.filePath)) { + try { + this._cache = JSON.parse(readFileSync(this.filePath, 'utf-8')); + } catch { + // Corrupted file — start fresh + this._cache = {}; + } + } else { + this._cache = {}; + } + return this._cache; + } + + /** Get the stored deltaLink for a resource key, or null if no prior sync. */ + getDeltaLink(resourceKey) { + return this._load()[resourceKey] || null; + } + + /** Persist a new deltaLink for a resource key. Writes to disk immediately. */ + setDeltaLink(resourceKey, deltaLink) { + const data = this._load(); + data[resourceKey] = deltaLink; + writeFileSync(this.filePath, JSON.stringify(data, null, 2)); + } + + /** Remove a stored deltaLink (used when token is expired/invalid). */ + clearDeltaLink(resourceKey) { + const data = this._load(); + delete data[resourceKey]; + writeFileSync(this.filePath, JSON.stringify(data, null, 2)); + } +} + +/** + * Execute a delta query and return only the changes. + * + * FLOW: + * 1. Check for stored deltaLink for this resource + * 2. If found → use it (incremental sync — only changes) + * 3. If not found → call the initial delta URL (full sync) + * 4. Page through all results (follow @odata.nextLink) + * 5. Store the final @odata.deltaLink for next time + * 6. Return { added, modified, removed, isInitialSync } + * + * @param {object} client - Graph API client from graph/client.js + * @param {string} resourceKey - Unique key for this watched resource (e.g., "mail:Inbox") + * @param {string} initialDeltaUrl - The delta endpoint to use for first sync + * (e.g., "/me/mailFolders/Inbox/messages/delta?$select=...") + * @param {DeltaStore} store - Delta token persistence + * @returns {{ added: Array, modified: Array, removed: Array, isInitialSync: boolean }} + */ +export async function executeDeltaQuery(client, resourceKey, initialDeltaUrl, store) { + const existingDeltaLink = store.getDeltaLink(resourceKey); + const isInitialSync = !existingDeltaLink; + + // If we have a stored deltaLink, use it (incremental). + // Otherwise, start from the beginning (initial full sync). + let url = existingDeltaLink || initialDeltaUrl; + const allItems = []; + + try { + // Page through all results. Delta responses may span multiple pages, + // each linked by @odata.nextLink. The final page has @odata.deltaLink. + let pageCount = 0; + const MAX_PAGES = 50; // Safety limit to prevent runaway pagination + + while (url && pageCount < MAX_PAGES) { + const response = await client.get(url); + pageCount++; + + if (response.value) { + allItems.push(...response.value); + } + + if (response['@odata.nextLink']) { + // More pages of results — keep going + url = response['@odata.nextLink']; + } else if (response['@odata.deltaLink']) { + // Final page — store the deltaLink for next time + store.setDeltaLink(resourceKey, response['@odata.deltaLink']); + url = null; // Done + } else { + // Unexpected: no nextLink or deltaLink. Stop to avoid infinite loop. + url = null; + } + } + } catch (err) { + // Delta token might be expired or invalid. + // Error codes: 410 Gone, 404 Not Found, or resyncRequired in error + if (isDeltaTokenExpired(err)) { + // Clear the stale token and retry with a full sync + store.clearDeltaLink(resourceKey); + return executeDeltaQuery(client, resourceKey, initialDeltaUrl, store); + } + throw err; + } + + // Classify items into added/modified vs removed. + // Items with "@removed" property have been deleted from the mailbox/calendar. + // We can't reliably distinguish "added" from "modified" on incremental syncs + // because Graph returns both as regular items. For initial sync, everything + // is "added". For incremental, the caller treats all non-removed items as + // "changed" events. + const removed = []; + const changed = []; + + for (const item of allItems) { + if (item['@removed']) { + removed.push({ id: item.id, reason: item['@removed'].reason }); + } else { + changed.push(item); + } + } + + return { + changed, // New or modified items + removed, // Deleted items (skeleton with id + reason) + isInitialSync, // true on first sync (caller should skip notifications) + itemCount: changed.length + removed.length, + }; +} + +/** + * Check if a Graph API error indicates an expired/invalid delta token. + * + * Known patterns: + * - HTTP 410 Gone (deltaLink expired) + * - HTTP 404 Not Found (resource changed) + * - Error code "resyncRequired" in the Graph error body + */ +function isDeltaTokenExpired(err) { + if (err.statusCode === 410 || err.statusCode === 404) return true; + // GraphApiError stores the Graph error code in graphCode + const graphCode = err.graphCode || err.graphError?.code || ''; + if (graphCode === 'resyncRequired') return true; + if (graphCode === 'InvalidDeltaToken') return true; + return false; +} + +/** + * Build the delta endpoint URL for mail folder messages. + * + * @param {object} client - Graph client (for userPath) + * @param {string} folder - Folder name (e.g., "Inbox") + * @returns {string} Full delta URL path + */ +export function buildMailDeltaUrl(client, folder = 'Inbox') { + const select = 'id,subject,from,receivedDateTime,bodyPreview,isRead,importance,flag,hasAttachments'; + return `${client.userPath}/mailFolders/${encodeURIComponent(folder)}/messages/delta?$select=${select}`; +} + +/** + * Build the delta endpoint URL for calendar events. + * + * Calendar delta queries require startDateTime and endDateTime parameters + * on the INITIAL request. These are then baked into the deltaLink token, + * so subsequent calls automatically use the same window. + * + * We use a rolling window: from "now" to "30 days from now" by default. + * This means the watcher tracks changes to events within the next month. + * + * @param {object} client - Graph client (for userPath) + * @param {object} options - { startDateTime, endDateTime } + * @returns {string} Full delta URL path + */ +export function buildCalendarDeltaUrl(client, options = {}) { + const now = new Date(); + const start = options.startDateTime || now.toISOString(); + const end = options.endDateTime || new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000).toISOString(); + + return `${client.userPath}/calendarView/delta?startDateTime=${encodeURIComponent(start)}&endDateTime=${encodeURIComponent(end)}`; +} + +/** + * Create a DeltaStore for an account. + * @param {string} accountAlias - Account alias + * @returns {DeltaStore} + */ +export function createDeltaStore(accountAlias) { + return new DeltaStore(accountAlias); +} diff --git a/src/node/graph/drive.js b/src/node/graph/drive.js new file mode 100644 index 0000000..0774ddc --- /dev/null +++ b/src/node/graph/drive.js @@ -0,0 +1,157 @@ +/** + * OneDrive Graph API wrappers. + * + * Provides file operations for the OneDrive account paired with the + * authenticated Outlook account. Uses the same GraphClient as mail/calendar. + * + * Graph API endpoints: + * - List files: GET /me/drive/root/children (or /root:/{path}:/children) + * - Upload small: PUT /me/drive/root:/{path}:/content (< 4MB) + * - Download: GET /me/drive/items/{id}/content + * - Create folder: POST /me/drive/root/children + * - Share link: POST /me/drive/items/{id}/createLink + * + * Permissions required: + * - Files.Read — list, download (read-only accounts) + * - Files.ReadWrite — upload, create folders, share links + * + * DESIGN: File paths use the colon syntax (/root:/{path}:/) which lets + * users specify human-readable paths. Item IDs are used for downloads + * and sharing links since they're stable across renames. + */ + +const DRIVE_ITEM_FIELDS = 'id,name,size,lastModifiedDateTime,folder,file,webUrl'; + +/** + * List files in a folder. + * + * @param {Object} client - GraphClient instance + * @param {string} [folderPath] - Path relative to drive root (e.g., "Documents/Reports") + * @param {Object} [options] - Query options + * @param {number} [options.top] - Max items to return (default 50) + * @returns {Promise<{items: Array, nextLink: string|null}>} + */ +export async function listFiles(client, folderPath, options = {}) { + const top = options.top || 50; + let path; + + if (!folderPath || folderPath === '/' || folderPath === '') { + path = `${client.userPath}/drive/root/children`; + } else { + // Normalize path: remove leading/trailing slashes + const normalized = folderPath.replace(/^\/+|\/+$/g, ''); + path = `${client.userPath}/drive/root:/${encodeURIComponent(normalized)}:/children`; + } + + path += `?$top=${top}&$select=${DRIVE_ITEM_FIELDS}`; + + const response = await client.get(path); + return { + items: response.value || [], + nextLink: response['@odata.nextLink'] || null, + }; +} + +/** + * Upload a file to OneDrive (< 4MB inline upload). + * + * For files > 4MB, use createUploadSession() instead. + * + * @param {Object} client - GraphClient instance + * @param {string} remotePath - Destination path (e.g., "Documents/report.pdf") + * @param {Buffer|string} content - File content + * @param {string} [contentType] - MIME type (default: application/octet-stream) + * @returns {Promise} Created file metadata + */ +export async function uploadFile(client, remotePath, content, contentType) { + const normalized = remotePath.replace(/^\/+|\/+$/g, ''); + const path = `${client.userPath}/drive/root:/${encodeURIComponent(normalized)}:/content`; + return client.put(path, content, { + 'Content-Type': contentType || 'application/octet-stream', + }); +} + +/** + * Download a file by item ID. + * + * @param {Object} client - GraphClient instance + * @param {string} itemId - OneDrive item ID + * @returns {Promise<{content: Buffer, contentType: string}>} + */ +export async function downloadFile(client, itemId) { + const path = `${client.userPath}/drive/items/${encodeURIComponent(itemId)}/content`; + return client.get(path, { raw: true }); +} + +/** + * Get file metadata by item ID. + * + * @param {Object} client - GraphClient instance + * @param {string} itemId - OneDrive item ID + * @returns {Promise} File metadata + */ +export async function getFileMetadata(client, itemId) { + const path = `${client.userPath}/drive/items/${encodeURIComponent(itemId)}?$select=${DRIVE_ITEM_FIELDS}`; + return client.get(path); +} + +/** + * Create a folder in OneDrive. + * + * @param {Object} client - GraphClient instance + * @param {string} name - Folder name + * @param {string} [parentPath] - Parent folder path (default: root) + * @returns {Promise} Created folder metadata + */ +export async function createFolder(client, name, parentPath) { + let path; + if (!parentPath || parentPath === '/' || parentPath === '') { + path = `${client.userPath}/drive/root/children`; + } else { + const normalized = parentPath.replace(/^\/+|\/+$/g, ''); + path = `${client.userPath}/drive/root:/${encodeURIComponent(normalized)}:/children`; + } + + return client.post(path, { + name, + folder: {}, + '@microsoft.graph.conflictBehavior': 'rename', + }); +} + +/** + * Create a sharing link for a file. + * + * @param {Object} client - GraphClient instance + * @param {string} itemId - OneDrive item ID + * @param {Object} [options] - Link options + * @param {string} [options.type] - Link type: "view" (read) or "edit" (read-write) + * @param {string} [options.scope] - Link scope: "anonymous" or "organization" + * @returns {Promise} Sharing link details + */ +export async function createShareLink(client, itemId, options = {}) { + const path = `${client.userPath}/drive/items/${encodeURIComponent(itemId)}/createLink`; + return client.post(path, { + type: options.type || 'view', + scope: options.scope || 'organization', + }); +} + +/** + * Search for files in OneDrive. + * + * @param {Object} client - GraphClient instance + * @param {string} query - Search query + * @param {Object} [options] - Search options + * @param {number} [options.top] - Max results (default 25) + * @returns {Promise<{items: Array, nextLink: string|null}>} + */ +export async function searchFiles(client, query, options = {}) { + const top = options.top || 25; + const path = `${client.userPath}/drive/root/search(q='${encodeURIComponent(query)}')?$top=${top}&$select=${DRIVE_ITEM_FIELDS}`; + const response = await client.get(path); + return { + items: response.value || [], + nextLink: response['@odata.nextLink'] || null, + }; +} diff --git a/src/node/graph/mail.js b/src/node/graph/mail.js new file mode 100644 index 0000000..2dfee80 --- /dev/null +++ b/src/node/graph/mail.js @@ -0,0 +1,327 @@ +/** + * Mail Graph API wrappers. + * + * Every function takes a `client` object created by graph/client.js. The client + * exposes `client.userPath` which is either "/me" (own mailbox) or + * "/users/{delegate-email}" (shared/delegate mailbox). This single property is + * what enables delegate access — all URL construction just prepends userPath. + * + * C# PORT NOTE: These map 1:1 to Microsoft.Graph SDK methods, but the SDK hides + * the OData query parameters. Keep the raw REST patterns visible in the C# port + * so the query behaviour is explicit and testable. + * + * DESIGN: createDraft and sendDraft are deliberately separate functions because + * they require different permission scopes (Mail.ReadWrite vs Mail.Send). The + * two-step draft-then-send flow lets the app request Mail.Send ONLY when the + * user actually sends, keeping the default permission footprint minimal. + */ + +/** Default $select fields — keep this minimal to reduce payload size. */ +const MAIL_FIELDS = 'id,subject,from,receivedDateTime,bodyPreview,isRead,importance,flag,hasAttachments'; + +/** + * List messages in a folder. + * + * Graph endpoint: GET {userPath}/mailFolders/{folder}/messages + * Key OData params: $top, $select, $orderby, $filter (optional unread filter). + * Uses well-known folder names (Inbox, Drafts, etc.) which Graph resolves automatically. + */ +export async function listMessages(client, options = {}) { + const folder = options.folder || 'Inbox'; + const top = options.top || 25; + const skip = options.skip || 0; + const select = options.select || MAIL_FIELDS; + const orderby = options.orderby || 'receivedDateTime desc'; + + let path = `${client.userPath}/mailFolders/${encodeURIComponent(folder)}/messages`; + path += `?$top=${top}&$select=${select}&$orderby=${encodeURIComponent(orderby)}`; + + if (skip > 0) { + path += `&$skip=${skip}`; + } + + if (options.unreadOnly) { + // Append $filter — can coexist with $orderby on this endpoint + path += `&$filter=isRead eq false`; + } + + const response = await client.get(path); + return { + messages: response.value || [], + nextLink: response['@odata.nextLink'] || null, + count: response['@odata.count'] ?? null, + }; +} + +/** + * Get a single message by ID. + * + * Graph endpoint: GET {userPath}/messages/{id} + * The Prefer header tells Graph to return the body as plain text instead of + * HTML — useful for CLI display. Without it, body.content is HTML. + */ +export async function getMessage(client, messageId, options = {}) { + const headers = {}; + if (options.preferPlainText) { + // outlook.body-content-type forces the body.content format; avoids + // needing an HTML-to-text conversion on the client side. + headers['Prefer'] = 'outlook.body-content-type="text"'; + } + + return client.get(`${client.userPath}/messages/${encodeURIComponent(messageId)}`, { headers }); +} + +/** + * Search messages using Graph API $search. + * + * Graph endpoint: GET {userPath}/messages?$search="..." + * $search uses KQL (Keyword Query Language) — supports from:, subject:, etc. + * NOTE: $search and $orderby cannot be combined; Graph returns relevance-ranked results. + */ +export async function searchMessages(client, query, options = {}) { + const top = options.top || 25; + const skip = options.skip || 0; + const select = options.select || MAIL_FIELDS; + + let path = `${client.userPath}/messages?$search="${encodeURIComponent(query)}"&$top=${top}&$select=${select}`; + + if (skip > 0) { + path += `&$skip=${skip}`; + } + + const response = await client.get(path); + return { + messages: response.value || [], + nextLink: response['@odata.nextLink'] || null, + }; +} + +/** + * List mail folders. + * + * Graph endpoint: GET {userPath}/mailFolders + * $top=100 is a practical ceiling; most mailboxes have far fewer top-level folders. + * Does NOT recurse into child folders — call mailFolders/{id}/childFolders for that. + */ +export async function listFolders(client) { + const path = `${client.userPath}/mailFolders?$top=100`; + const response = await client.get(path); + return response.value || []; +} + +/** + * Create a draft message in the Drafts folder. + * + * Graph endpoint: POST {userPath}/messages + * POSTing to /messages (without /send) creates a draft. This only requires + * Mail.ReadWrite scope, NOT Mail.Send — keeping the permission footprint small + * until the user explicitly sends via sendDraft(). + */ +export async function createDraft(client, draft) { + return client.post(`${client.userPath}/messages`, draft); +} + +/** + * Create a reply draft for a message. + * + * Graph endpoint: POST {userPath}/messages/{id}/createReply (or createReplyAll) + * + * Two-step process: + * 1. POST createReply — Graph creates a new draft with headers pre-filled + * 2. PATCH the draft to set body text (Graph doesn't accept body in createReply) + * This two-step is required because the createReply endpoint ignores body content. + */ +export async function createReplyDraft(client, messageId, options = {}) { + const endpoint = options.replyAll + ? `${client.userPath}/messages/${encodeURIComponent(messageId)}/createReplyAll` + : `${client.userPath}/messages/${encodeURIComponent(messageId)}/createReply`; + + // Step 1: Create the reply shell (recipients, subject "RE:" prefix pre-populated) + const replyDraft = await client.post(endpoint, {}); + + // Step 2: If a body was provided, patch the reply draft + if (options.body) { + await client.patch(`${client.userPath}/messages/${encodeURIComponent(replyDraft.id)}`, { + body: { contentType: 'Text', content: options.body }, + }); + } + + return replyDraft; +} + +/** + * Create a forward draft for a message. + * + * Graph endpoint: POST {userPath}/messages/{id}/createForward + * + * Same two-step pattern as createReplyDraft: + * 1. POST createForward — creates the draft with original message attached + * 2. PATCH — add toRecipients and optional comment (body) + * createForward doesn't accept recipients inline, so PATCH is mandatory. + */ +export async function createForwardDraft(client, messageId, options = {}) { + // Step 1: Create forward shell (original message content included) + const forwardDraft = await client.post( + `${client.userPath}/messages/${encodeURIComponent(messageId)}/createForward`, + {} + ); + + // Step 2: Patch with recipients and optional comment + const patch = { + toRecipients: options.to.split(',').map(addr => ({ + emailAddress: { address: addr.trim() }, + })), + }; + + if (options.comment) { + patch.body = { contentType: 'Text', content: options.comment }; + } + + await client.patch(`${client.userPath}/messages/${encodeURIComponent(forwardDraft.id)}`, patch); + return forwardDraft; +} + +/** + * Move a message to a folder (by name or ID). + * + * Graph endpoint: POST {userPath}/messages/{id}/move + * The /move action requires a folder ID, not a name. For well-known folder + * names (Inbox, Drafts, etc.) we first resolve the name to its ID via a GET. + * If resolution fails, we pass the value through as-is — it may already be an ID. + */ +export async function moveMessage(client, messageId, folderNameOrId) { + const wellKnown = ['Inbox', 'Drafts', 'SentItems', 'DeletedItems', 'Archive', 'JunkEmail']; + let destinationId = folderNameOrId; + + // Resolve well-known folder names to their IDs so the move action succeeds + if (wellKnown.includes(folderNameOrId)) { + try { + const folder = await client.get(`${client.userPath}/mailFolders/${folderNameOrId}`); + destinationId = folder.id; + } catch { + // Fall through — maybe it's already an ID + } + } + + return client.post(`${client.userPath}/messages/${encodeURIComponent(messageId)}/move`, { + destinationId, + }); +} + +/** + * Flag or unflag a message. + * + * Graph endpoint: PATCH {userPath}/messages/{id} + * Uses the flag.flagStatus property — Graph only supports 'flagged' and 'notFlagged' + * (there's also 'complete' but we don't expose it). + */ +export async function flagMessage(client, messageId, flagged) { + return client.patch(`${client.userPath}/messages/${encodeURIComponent(messageId)}`, { + flag: { flagStatus: flagged ? 'flagged' : 'notFlagged' }, + }); +} + +/** + * Mark a message as read or unread. + * + * Graph endpoint: PATCH {userPath}/messages/{id} + */ +export async function markRead(client, messageId, isRead) { + return client.patch(`${client.userPath}/messages/${encodeURIComponent(messageId)}`, { + isRead, + }); +} + +/** + * Send an existing draft message. + * + * Graph endpoint: POST {userPath}/messages/{id}/send + * Requires Mail.Send (own mailbox) or Mail.Send.Shared (delegate mailbox). + * The body is null because /send takes no payload — it sends the draft as-is. + * This is intentionally separate from createDraft so the app can operate with + * only Mail.ReadWrite until the user explicitly chooses to send. + */ +export async function sendDraft(client, messageId) { + return client.post(`${client.userPath}/messages/${encodeURIComponent(messageId)}/send`, null); +} + +/** + * Delete a message (soft-delete). + * + * Graph endpoint: DELETE {userPath}/messages/{id} + * This performs a soft-delete: the message is moved to Deleted Items. + * If the message is already in Deleted Items, it is permanently deleted. + */ +export async function deleteMessage(client, messageId) { + return client.delete(`${client.userPath}/messages/${encodeURIComponent(messageId)}`); +} + +// ─── Attachment operations ─────────────────────────────────────────── + +/** Default $select fields for attachment metadata. */ +const ATTACHMENT_FIELDS = 'id,name,contentType,size,isInline,lastModifiedDateTime'; + +/** + * List attachments on a message. + * + * Graph endpoint: GET {userPath}/messages/{id}/attachments + * Returns metadata only (not content bytes) to keep payloads small. + * Use getAttachment() to download individual attachment content. + */ +export async function listAttachments(client, messageId, options = {}) { + const select = options.select || ATTACHMENT_FIELDS; + const path = `${client.userPath}/messages/${encodeURIComponent(messageId)}/attachments?$select=${select}`; + const response = await client.get(path); + return response.value || []; +} + +/** + * Get a single attachment with content bytes. + * + * Graph endpoint: GET {userPath}/messages/{msgId}/attachments/{attId} + * Returns the full attachment object including contentBytes (base64-encoded). + * For file attachments, contentBytes is the binary content of the file. + */ +export async function getAttachment(client, messageId, attachmentId) { + const path = `${client.userPath}/messages/${encodeURIComponent(messageId)}/attachments/${encodeURIComponent(attachmentId)}`; + return client.get(path); +} + +/** + * Add a file attachment to a draft message. + * + * Graph endpoint: POST {userPath}/messages/{id}/attachments + * The attachment body must include name, contentType, and contentBytes (base64). + * Maximum size for inline attachments is 3MB. For larger files, use upload sessions. + * + * @param {object} client - GraphClient instance + * @param {string} messageId - Draft message ID + * @param {object} attachment - { name, contentType, contentBytes } + * contentBytes must be base64-encoded string of the file content. + */ +export async function addAttachment(client, messageId, attachment) { + const body = { + '@odata.type': '#microsoft.graph.fileAttachment', + name: attachment.name, + contentType: attachment.contentType || 'application/octet-stream', + contentBytes: attachment.contentBytes, + }; + const path = `${client.userPath}/messages/${encodeURIComponent(messageId)}/attachments`; + return client.post(path, body); +} + +/** + * Create an upload session for large attachments (>3MB). + * + * Graph endpoint: POST {userPath}/messages/{id}/attachments/createUploadSession + * Returns an uploadUrl for chunked upload via PUT requests. + * Caller must handle the chunked upload protocol (PUT with Content-Range headers). + * + * @param {object} client - GraphClient instance + * @param {string} messageId - Draft message ID + * @param {object} descriptor - { AttachmentItem: { attachmentType, name, size } } + */ +export async function createUploadSession(client, messageId, descriptor) { + const path = `${client.userPath}/messages/${encodeURIComponent(messageId)}/attachments/createUploadSession`; + return client.post(path, descriptor); +} diff --git a/src/node/input.js b/src/node/input.js new file mode 100644 index 0000000..eac0178 --- /dev/null +++ b/src/node/input.js @@ -0,0 +1,114 @@ +/** + * JSON input file loading and CLI option merging. + * + * This module supports a powerful input pattern: users can provide options via + * CLI flags, a JSON file (--input), or piped stdin (--input -). All three + * sources are merged with a clear precedence order: + * + * CLI flags > JSON file > defaults + * + * This means CLI flags always win. Only explicitly-provided CLI values (not + * undefined) override JSON values, so omitting a CLI flag lets the JSON value + * through. This enables scripted workflows where a JSON template provides + * defaults and CLI flags provide overrides. + * + * C# PORT NOTE: Commander.js sets omitted options to undefined. In C#, use + * nullable types and check HasValue to distinguish "not provided" from "provided". + */ + +import { readFileSync } from 'fs'; + +/** + * Load JSON input from a file or stdin ("-"). + * Returns the parsed object, or an empty object if no input file specified. + */ +export async function loadInput(filePath) { + if (!filePath) return {}; + + let raw; + if (filePath === '-') { + raw = await readStdin(); + } else { + raw = readFileSync(filePath, 'utf-8'); + } + + try { + return JSON.parse(raw); + } catch (err) { + throw new Error(`Invalid JSON in input file "${filePath}": ${err.message}`); + } +} + +/** + * Read all of stdin as a string. + * + * STDIN PIPE DETECTION: process.stdin.isTTY is true when the user is typing + * interactively (no pipe). When true, we resolve immediately with "{}" so the + * CLI doesn't hang waiting for input that will never come. When false (piped), + * we read until EOF. This is the standard Node.js pattern for detecting pipes. + * + * C# PORT NOTE: Use Console.IsInputRedirected in .NET for the same check. + */ +function readStdin() { + return new Promise((resolve, reject) => { + const chunks = []; + process.stdin.setEncoding('utf-8'); + process.stdin.on('data', (chunk) => chunks.push(chunk)); + process.stdin.on('end', () => resolve(chunks.join(''))); + process.stdin.on('error', reject); + + // If stdin is a TTY (no pipe), resolve immediately with empty JSON + // to avoid blocking the CLI waiting for interactive input. + if (process.stdin.isTTY) { + resolve('{}'); + } + }); +} + +/** + * Merge CLI options with JSON input. + * + * PRECEDENCE: CLI options take precedence over JSON values. Only non-undefined + * CLI values override — this lets users omit flags to fall through to JSON defaults. + * + * Order: start with JSON as base → overlay explicitly-set CLI values. + */ +export function mergeInput(jsonInput, cliOptions) { + const merged = { ...jsonInput }; + for (const [key, value] of Object.entries(cliOptions)) { + if (value !== undefined) { + merged[key] = value; + } + } + return merged; +} + +/** + * Resolve body content: supports inline string, file path, or bodyFile in JSON. + * Returns { contentType: "Text"|"HTML", content: "..." } + * + * BODY FILE AUTO-DETECTION: When a bodyFile is specified and no explicit + * bodyContentType is set, the file extension determines the content type. + * Files ending in .html or .htm are treated as HTML; everything else is Text. + * This lets users do `--body-file message.html` without also specifying + * `--body-content-type HTML`. + * + * Priority: bodyFile > body (inline string) > empty + */ +export async function resolveBody(options) { + let content = ''; + let contentType = options.bodyContentType || 'Text'; + + if (options.bodyFile) { + const { readFile } = await import('fs/promises'); + content = await readFile(options.bodyFile, 'utf-8'); + // Auto-detect HTML from file extension if not explicitly set + if (!options.bodyContentType && (options.bodyFile.endsWith('.html') || options.bodyFile.endsWith('.htm'))) { + contentType = 'HTML'; + } + } else if (options.body) { + content = options.body; + } + + return { contentType, content }; +} diff --git a/src/node/output/formatter.js b/src/node/output/formatter.js new file mode 100644 index 0000000..69efaaa --- /dev/null +++ b/src/node/output/formatter.js @@ -0,0 +1,384 @@ +/** + * Text format output — human-readable fixed-width tables. + * + * FORMAT TYPE SYSTEM: The output layer uses a naming convention to match data + * types to renderer functions. Each entity type (mailList, mailDetail, eventList, + * etc.) has a corresponding function in each format renderer (text, markdown, html). + * The render dispatcher (render.js) looks up the function by name on the renderer + * module. If no matching function exists, it falls back to `generic`. + * + * All public functions return strings (not void) — they do NOT call console.log. + * This enables the render dispatcher to route output to files, pipes, or stdout. + * + * BACKWARD COMPATIBILITY: The format* aliases at the bottom (formatMailList, etc.) + * wrap the return-string functions with console.log for callers that haven't + * migrated to the render dispatcher yet. + * + * C# PORT NOTE: Consider an interface IOutputFormatter with method per entity type + * and a Strategy pattern for text/markdown/html selection. + */ + +import { htmlToText, isHtml } from './html-to-text.js'; + +// ── Utility functions ── + +/** + * Truncate a string to maxLen, adding an ellipsis character if truncated. + * Returns empty string for null/undefined to simplify caller code. + */ +function truncate(str, maxLen) { + if (!str) return ''; + if (str.length <= maxLen) return str; + return str.substring(0, maxLen - 1) + '…'; +} + +/** + * Build a fixed-width row by padding/truncating each column to its declared width. + * Columns are separated by a single space. + */ +function padColumns(columns) { + return columns.map(col => { + const text = col.text || ''; + if (text.length >= col.width) return text.substring(0, col.width); + return text + ' '.repeat(col.width - text.length); + }).join(' '); +} + +/** + * Smart date formatting: shows time-only for today's dates (saves column width), + * full date for older messages. This matches how Outlook desktop displays dates. + */ +function formatDate(isoString) { + if (!isoString) return ''; + try { + const d = new Date(isoString); + const now = new Date(); + const isToday = d.toDateString() === now.toDateString(); + + if (isToday) { + return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); + } + + return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); + } catch { + return isoString; + } +} + +function formatDateTime(isoString) { + if (!isoString) return ''; + try { + const d = new Date(isoString); + return d.toLocaleString(undefined, { + weekday: 'short', + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + } catch { + return isoString; + } +} + +function formatTime(isoString) { + if (!isoString) return ''; + try { + const d = new Date(isoString); + return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); + } catch { + return isoString; + } +} + +// ── Entity renderers (return strings) ── + +export function mailList(messages) { + if (!messages || messages.length === 0) { + return 'No messages found.'; + } + + const lines = ['']; + lines.push(padColumns([ + { text: '#', width: 4 }, + { text: ' ', width: 2 }, + { text: 'From', width: 30 }, + { text: 'Subject', width: 55 }, + { text: 'Date', width: 16 }, + ])); + lines.push('─'.repeat(112)); + + for (let i = 0; i < messages.length; i++) { + const msg = messages[i]; + const readMarker = msg.isRead ? ' ' : '●'; + const flagMarker = msg.flag?.flagStatus === 'flagged' ? '⚑' : ' '; + const from = truncate(msg.from?.emailAddress?.name || msg.from?.emailAddress?.address || '(unknown)', 28); + const subject = truncate(msg.subject || '(no subject)', 53); + const date = formatDate(msg.receivedDateTime); + + lines.push(padColumns([ + { text: `${i + 1}`, width: 4 }, + { text: `${readMarker}${flagMarker}`, width: 2 }, + { text: from, width: 30 }, + { text: subject, width: 55 }, + { text: date, width: 16 }, + ])); + } + + lines.push(''); + lines.push(`${messages.length} message(s) — use N with read, reply, forward, move, flag, mark-read, send`); + return lines.join('\n'); +} + +export function mailDetail(message) { + if (!message) { + return 'Message not found.'; + } + + const lines = ['', '─'.repeat(60)]; + lines.push(`From: ${message.from?.emailAddress?.name || ''} <${message.from?.emailAddress?.address || ''}>`); + + if (message.toRecipients?.length > 0) { + const to = message.toRecipients.map(r => r.emailAddress?.address).join(', '); + lines.push(`To: ${to}`); + } + + if (message.ccRecipients?.length > 0) { + const cc = message.ccRecipients.map(r => r.emailAddress?.address).join(', '); + lines.push(`Cc: ${cc}`); + } + + lines.push(`Subject: ${message.subject || '(no subject)'}`); + lines.push(`Date: ${formatDateTime(message.receivedDateTime)}`); + lines.push(`Read: ${message.isRead ? 'yes' : 'no'}`); + + if (message.importance && message.importance !== 'normal') { + lines.push(`Priority: ${message.importance}`); + } + + if (message.hasAttachments) { + lines.push('Attachments: yes'); + } + + // Show attachment details if fetched via --attachments flag + if (message.attachmentList?.length > 0) { + for (const att of message.attachmentList) { + const size = att.size ? ` (${formatSize(att.size)})` : ''; + const inline = att.isInline ? ' [inline]' : ''; + lines.push(` 📎 ${att.name || 'unnamed'}${size}${inline}`); + } + } + + lines.push(`ID: ${message.id}`); + lines.push('─'.repeat(60)); + lines.push(''); + + // Convert HTML body to readable text for CLI display + const bodyContent = message.body?.content || message.bodyPreview || '(empty body)'; + const isBodyHtml = message.body?.contentType === 'HTML' || isHtml(bodyContent); + lines.push(isBodyHtml ? htmlToText(bodyContent) : bodyContent); + + lines.push(''); + return lines.join('\n'); +} + +export function folderList(folders) { + if (!folders || folders.length === 0) { + return 'No folders found.'; + } + + const lines = ['']; + lines.push(padColumns([ + { text: 'Folder', width: 30 }, + { text: 'Unread', width: 8 }, + { text: 'Total', width: 8 }, + { text: 'ID', width: 40 }, + ])); + lines.push('─'.repeat(88)); + + for (const folder of folders) { + lines.push(padColumns([ + { text: truncate(folder.displayName || '', 28), width: 30 }, + { text: String(folder.unreadItemCount || 0), width: 8 }, + { text: String(folder.totalItemCount || 0), width: 8 }, + { text: truncate(folder.id || '', 38), width: 40 }, + ])); + } + lines.push(''); + return lines.join('\n'); +} + +export function eventList(events, title = 'Events') { + if (!events || events.length === 0) { + return `\nNo events for "${title}".`; + } + + const lines = [`\n📅 ${title}`, '─'.repeat(80)]; + + for (const event of events) { + const startTime = formatTime(event.start?.dateTime); + const endTime = formatTime(event.end?.dateTime); + const timeRange = event.isAllDay ? 'All day ' : `${startTime}-${endTime}`; + const location = event.location?.displayName ? ` @ ${event.location.displayName}` : ''; + const cancelled = event.isCancelled ? ' [CANCELLED]' : ''; + + lines.push(` ${timeRange} ${event.subject || '(no subject)'}${location}${cancelled}`); + } + + lines.push('─'.repeat(80)); + lines.push(`${events.length} event(s)\n`); + return lines.join('\n'); +} + +export function eventDetail(event) { + if (!event) { + return 'Event not found.'; + } + + const lines = ['', '─'.repeat(60)]; + lines.push(`Subject: ${event.subject || '(no subject)'}`); + lines.push(`Start: ${formatDateTime(event.start?.dateTime)}`); + lines.push(`End: ${formatDateTime(event.end?.dateTime)}`); + + if (event.location?.displayName) { + lines.push(`Location: ${event.location.displayName}`); + } + + if (event.organizer?.emailAddress) { + lines.push(`Organizer: ${event.organizer.emailAddress.name || ''} <${event.organizer.emailAddress.address}>`); + } + + if (event.attendees?.length > 0) { + lines.push('Attendees:'); + for (const a of event.attendees) { + const status = a.status?.response || 'none'; + lines.push(` ${a.emailAddress?.address} (${a.type}, ${status})`); + } + } + + lines.push(`Status: ${event.showAs || 'unknown'}`); + lines.push(`All Day: ${event.isAllDay ? 'yes' : 'no'}`); + lines.push(`ID: ${event.id}`); + lines.push('─'.repeat(60)); + + if (event.body?.content) { + lines.push(''); + const eventBodyHtml = event.body?.contentType === 'HTML' || isHtml(event.body.content); + lines.push(eventBodyHtml ? htmlToText(event.body.content) : event.body.content); + } + lines.push(''); + return lines.join('\n'); +} + +export function calendarList(calendars) { + if (!calendars || calendars.length === 0) { + return 'No calendars found.'; + } + + const lines = ['']; + lines.push(padColumns([ + { text: 'Calendar', width: 35 }, + { text: 'Color', width: 15 }, + { text: 'Owner', width: 30 }, + ])); + lines.push('─'.repeat(82)); + + for (const cal of calendars) { + lines.push(padColumns([ + { text: truncate(cal.name || '', 33), width: 35 }, + { text: cal.color || '', width: 15 }, + { text: truncate(cal.owner?.address || '', 28), width: 30 }, + ])); + } + lines.push(''); + return lines.join('\n'); +} + +export function contactList(contacts) { + if (!contacts || contacts.length === 0) { + return 'No contacts found.'; + } + + const lines = ['']; + lines.push(padColumns([ + { text: 'Name', width: 30 }, + { text: 'Email', width: 35 }, + { text: 'Company', width: 20 }, + { text: 'Source', width: 10 }, + ])); + lines.push('─'.repeat(97)); + + for (const contact of contacts) { + const email = contact.emailAddresses?.[0]?.address || ''; + lines.push(padColumns([ + { text: truncate(contact.displayName || '', 28), width: 30 }, + { text: truncate(email, 33), width: 35 }, + { text: truncate(contact.companyName || '', 18), width: 20 }, + { text: contact.source || '', width: 10 }, + ])); + } + lines.push(`\n${contacts.length} contact(s)\n`); + return lines.join('\n'); +} + +/** Format byte count as human-readable file size. */ +function formatSize(bytes) { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; +} + +export function attachmentList(attachments) { + if (!attachments || attachments.length === 0) { + return 'No attachments found.'; + } + + const lines = ['', 'Attachments:']; + lines.push('─'.repeat(80)); + lines.push(padColumns([ + { text: 'Name', width: 35 }, + { text: 'Size', width: 12 }, + { text: 'Type', width: 25 }, + { text: 'Inline', width: 6 }, + ])); + lines.push('─'.repeat(80)); + + for (const att of attachments) { + lines.push(padColumns([ + { text: truncate(att.name || 'unnamed', 33), width: 35 }, + { text: formatSize(att.size || 0), width: 12 }, + { text: truncate(att.contentType || '', 23), width: 25 }, + { text: att.isInline ? 'yes' : 'no', width: 6 }, + ])); + } + lines.push(`\n${attachments.length} attachment(s)\n`); + return lines.join('\n'); +} + +export function generic(data) { + if (data === null || data === undefined) return ''; + if (typeof data === 'string') return data; + return JSON.stringify(data, null, 2); +} + +// ── Backward-compatible aliases ── +// These wrap the return-string functions with console.log for callers that +// haven't migrated to the render dispatcher (render.js output() function). + +export function formatOutput(data, options = {}) { + if (options.json) { + console.log(JSON.stringify(data, null, 2)); + } else { + console.log(generic(data)); + } +} + +export function formatMailList(messages) { console.log(mailList(messages)); } +export function formatMailDetail(message) { console.log(mailDetail(message)); } +export function formatFolderList(folders) { console.log(folderList(folders)); } +export function formatEventList(events, title) { console.log(eventList(events, title)); } +export function formatEventDetail(event) { console.log(eventDetail(event)); } +export function formatCalendarList(calendars) { console.log(calendarList(calendars)); } +export function formatContactList(contacts) { console.log(contactList(contacts)); } diff --git a/src/node/output/html-to-text.js b/src/node/output/html-to-text.js new file mode 100644 index 0000000..4f3ab97 --- /dev/null +++ b/src/node/output/html-to-text.js @@ -0,0 +1,110 @@ +/** + * HTML-to-text converter for CLI display. + * + * Converts HTML email bodies to readable plain text using simple regex-based + * stripping. This is NOT a full HTML parser — it handles the common patterns + * found in email HTML (paragraphs, line breaks, links, lists, tables) without + * requiring heavy dependencies like jsdom or cheerio. + * + * Design rationale: + * - Email HTML is a constrained subset of HTML (no scripts, limited CSS) + * - A full DOM parser adds 1-2MB of dependencies for a CLI tool + * - Simple regex handles 90%+ of real-world email HTML adequately + * - The --plain flag requests plain text from Graph API as a better alternative + * + * Limitations: + * - Nested tables render as flat text (acceptable for email) + * - Complex CSS layouts lose structure (use --plain for critical formatting) + * - Images are shown as [alt text] or [image] placeholders + */ + +/** + * Convert HTML to readable plain text. + * + * @param {string} html - HTML content from email body + * @returns {string} Plain text approximation + */ +export function htmlToText(html) { + if (!html) return ''; + + // If it doesn't look like HTML, return as-is + if (!/<[a-z][\s\S]*>/i.test(html)) return html; + + let text = html; + + // Remove style and script blocks entirely + text = text.replace(//gi, ''); + text = text.replace(//gi, ''); + + // Remove HTML comments + text = text.replace(//g, ''); + + // Convert common block elements to line breaks + text = text.replace(//gi, '\n'); + text = text.replace(/<\/p>/gi, '\n\n'); + text = text.replace(/<\/div>/gi, '\n'); + text = text.replace(/<\/h[1-6]>/gi, '\n\n'); + text = text.replace(/<\/tr>/gi, '\n'); + text = text.replace(/<\/li>/gi, '\n'); + + // Convert list items to bullet points + text = text.replace(/]*>/gi, ' • '); + + // Convert links: text → text (url) + text = text.replace(/]+href="([^"]*)"[^>]*>([^<]*)<\/a>/gi, (_, url, linkText) => { + const trimmedText = linkText.trim(); + const trimmedUrl = url.trim(); + // Skip if link text IS the URL (common in emails) + if (trimmedText === trimmedUrl || trimmedText === '') return trimmedUrl; + return `${trimmedText} (${trimmedUrl})`; + }); + + // Convert images to [alt] or [image] + text = text.replace(/]+alt="([^"]*)"[^>]*>/gi, '[$1]'); + text = text.replace(/]*>/gi, '[image]'); + + // Convert
to a separator line + text = text.replace(/]*>/gi, '\n' + '─'.repeat(40) + '\n'); + + // Convert table cells: add spacing between td/th + text = text.replace(/<\/t[dh]>/gi, '\t'); + + // Convert / to *text* + text = text.replace(/<(?:strong|b)>([\s\S]*?)<\/(?:strong|b)>/gi, '*$1*'); + + // Convert / to _text_ + text = text.replace(/<(?:em|i)>([\s\S]*?)<\/(?:em|i)>/gi, '_$1_'); + + // Strip remaining HTML tags + text = text.replace(/<[^>]+>/g, ''); + + // Decode common HTML entities + text = text.replace(/ /gi, ' '); + text = text.replace(/&/gi, '&'); + text = text.replace(/</gi, '<'); + text = text.replace(/>/gi, '>'); + text = text.replace(/"/gi, '"'); + text = text.replace(/'/gi, "'"); + text = text.replace(/'/gi, "'"); + text = text.replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCharCode(parseInt(hex, 16))); + text = text.replace(/&#(\d+);/gi, (_, dec) => String.fromCharCode(parseInt(dec))); + + // Normalize whitespace + text = text.replace(/[ \t]+/g, ' '); // collapse horizontal whitespace + text = text.replace(/\n[ \t]+/g, '\n'); // trim leading whitespace on lines + text = text.replace(/[ \t]+\n/g, '\n'); // trim trailing whitespace on lines + text = text.replace(/\n{3,}/g, '\n\n'); // collapse multiple blank lines + + return text.trim(); +} + +/** + * Check if content appears to be HTML. + * + * @param {string} content - Text to check + * @returns {boolean} True if content contains HTML tags + */ +export function isHtml(content) { + if (!content) return false; + return /<[a-z][\s\S]*>/i.test(content); +} diff --git a/src/node/output/html.js b/src/node/output/html.js new file mode 100644 index 0000000..d91a511 --- /dev/null +++ b/src/node/output/html.js @@ -0,0 +1,213 @@ +/** + * HTML format output — tables and structured content. + * + * ESCAPING STRATEGY: All user-supplied text is escaped via esc() which handles + * the four HTML-significant characters: & < > ". This prevents XSS if the + * output is ever served in a browser or embedded in a web page. + * + * IMPORTANT: The mail body content (message.body.content) is intentionally NOT + * escaped in mailDetail because it may already be HTML from the Graph API. The + * Graph API returns sanitized HTML, so we pass it through as-is. In contrast, + * bodyPreview (plain text fallback) IS escaped. + * + * The table() and dl() helpers produce semantic HTML without inline styles — + * consumers can apply their own CSS. + * + * C# PORT NOTE: Use System.Net.WebUtility.HtmlEncode() for the same escaping. + */ + +/** + * Escape HTML-significant characters to prevent injection. + * Handles: & < > " (the four required for safe HTML attribute/content embedding). + */ +function esc(str) { + if (!str) return ''; + return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +function formatDate(isoString) { + if (!isoString) return ''; + try { + const d = new Date(isoString); + return d.toISOString().replace('T', ' ').substring(0, 16); + } catch { + return esc(isoString); + } +} + +function formatTime(isoString) { + if (!isoString) return ''; + try { + const d = new Date(isoString); + return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); + } catch { + return esc(isoString); + } +} + +/** + * Build an HTML table from headers and row data. + * NOTE: Cell values are expected to be pre-escaped by the caller — this allows + * cells to contain HTML elements like , , when needed. + */ +function table(headers, rows) { + const lines = ['', '']; + for (const h of headers) lines.push(` `); + lines.push('', ''); + for (const row of rows) { + lines.push(''); + for (const cell of row) lines.push(` `); + lines.push(''); + } + lines.push('', '
${esc(h)}
${cell}
'); + return lines.join('\n'); +} + +/** + * Build an HTML definition list (
) for key-value metadata display. + * Terms are escaped; definitions may contain pre-escaped HTML. + */ +function dl(pairs) { + const lines = ['
']; + for (const [term, def] of pairs) { + lines.push(`
${esc(term)}
${def}
`); + } + lines.push('
'); + return lines.join('\n'); +} + +export function mailList(messages) { + if (!messages || messages.length === 0) return '

No messages found.

'; + + const rows = messages.map(msg => { + const marker = msg.isRead ? '' : ''; + const flag = msg.flag?.flagStatus === 'flagged' ? ' ⚑' : ''; + const from = esc(msg.from?.emailAddress?.name || msg.from?.emailAddress?.address || '(unknown)'); + const subject = esc(msg.subject || '(no subject)'); + const date = formatDate(msg.receivedDateTime); + return [`${marker}${flag}`, from, subject, date]; + }); + + return table(['', 'From', 'Subject', 'Date'], rows) + `\n

${messages.length} message(s)

`; +} + +export function mailDetail(message) { + if (!message) return '

Message not found.

'; + + const pairs = [ + ['From', `${esc(message.from?.emailAddress?.name || '')} <${esc(message.from?.emailAddress?.address || '')}>`], + ]; + + if (message.toRecipients?.length > 0) { + pairs.push(['To', message.toRecipients.map(r => esc(r.emailAddress?.address)).join(', ')]); + } + if (message.ccRecipients?.length > 0) { + pairs.push(['Cc', message.ccRecipients.map(r => esc(r.emailAddress?.address)).join(', ')]); + } + + pairs.push(['Subject', esc(message.subject || '(no subject)')]); + pairs.push(['Date', formatDate(message.receivedDateTime)]); + pairs.push(['Read', message.isRead ? 'Yes' : 'No']); + + if (message.importance && message.importance !== 'normal') { + pairs.push(['Priority', esc(message.importance)]); + } + if (message.hasAttachments) { + pairs.push(['Attachments', 'Yes']); + } + pairs.push(['ID', `${esc(message.id)}`]); + + const body = message.body?.content || esc(message.bodyPreview) || '(empty body)'; + return `

${esc(message.subject || '(no subject)')}

\n${dl(pairs)}\n
\n
${body}
`; +} + +export function folderList(folders) { + if (!folders || folders.length === 0) return '

No folders found.

'; + + const rows = folders.map(f => [ + esc(f.displayName || ''), + String(f.unreadItemCount || 0), + String(f.totalItemCount || 0), + `${esc(f.id || '')}`, + ]); + + return table(['Folder', 'Unread', 'Total', 'ID'], rows); +} + +export function eventList(events, title = 'Events') { + if (!events || events.length === 0) return `

No events for "${esc(title)}".

`; + + const rows = events.map(event => { + const start = formatTime(event.start?.dateTime); + const end = formatTime(event.end?.dateTime); + const time = event.isAllDay ? 'All day' : `${start}–${end}`; + let subject = esc(event.subject || '(no subject)'); + if (event.isCancelled) subject = `${subject} [CANCELLED]`; + const location = esc(event.location?.displayName || ''); + return [time, subject, location]; + }); + + return `

📅 ${esc(title)}

\n${table(['Time', 'Subject', 'Location'], rows)}\n

${events.length} event(s)

`; +} + +export function eventDetail(event) { + if (!event) return '

Event not found.

'; + + const pairs = [ + ['Subject', esc(event.subject || '(no subject)')], + ['Start', formatDate(event.start?.dateTime)], + ['End', formatDate(event.end?.dateTime)], + ]; + + if (event.location?.displayName) pairs.push(['Location', esc(event.location.displayName)]); + if (event.organizer?.emailAddress) { + pairs.push(['Organizer', `${esc(event.organizer.emailAddress.name || '')} <${esc(event.organizer.emailAddress.address)}>`]); + } + pairs.push(['Status', esc(event.showAs || 'unknown')]); + pairs.push(['All Day', event.isAllDay ? 'Yes' : 'No']); + pairs.push(['ID', `${esc(event.id)}`]); + + let attendeeHtml = ''; + if (event.attendees?.length > 0) { + const rows = event.attendees.map(a => [ + esc(a.emailAddress?.address), + esc(a.type), + esc(a.status?.response || 'none'), + ]); + attendeeHtml = `\n

Attendees

\n${table(['Email', 'Type', 'Response'], rows)}`; + } + + const body = event.body?.content ? `\n
\n
${event.body.content}
` : ''; + return `

${esc(event.subject || '(no subject)')}

\n${dl(pairs)}${attendeeHtml}${body}`; +} + +export function calendarList(calendars) { + if (!calendars || calendars.length === 0) return '

No calendars found.

'; + + const rows = calendars.map(cal => [ + esc(cal.name || ''), + esc(cal.color || ''), + esc(cal.owner?.address || ''), + ]); + + return table(['Calendar', 'Color', 'Owner'], rows); +} + +export function contactList(contacts) { + if (!contacts || contacts.length === 0) return '

No contacts found.

'; + + const rows = contacts.map(c => [ + esc(c.displayName || ''), + esc(c.emailAddresses?.[0]?.address || ''), + esc(c.companyName || ''), + esc(c.source || ''), + ]); + + return table(['Name', 'Email', 'Company', 'Source'], rows) + `\n

${contacts.length} contact(s)

`; +} + +export function generic(data) { + if (data === null || data === undefined) return ''; + if (typeof data === 'string') return `
${esc(data)}
`; + return `
${esc(JSON.stringify(data, null, 2))}
`; +} diff --git a/src/node/output/last-results.js b/src/node/output/last-results.js new file mode 100644 index 0000000..be09126 --- /dev/null +++ b/src/node/output/last-results.js @@ -0,0 +1,82 @@ +/** + * Local short ID mapping for command results. + * + * When `mail inbox` or `mail search` displays results, each message gets a + * short local ID (#1, #2, etc.) that can be used in subsequent commands + * instead of the full Graph API message ID. + * + * The mapping is stored in ~/.outlook-cli/last-results.json and is ephemeral — + * it's overwritten every time a new list command runs. This is purely a UX + * convenience so users don't have to copy/paste 200-character Graph IDs. + */ + +import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; +import { join } from 'path'; +import { homedir } from 'os'; + +const CONFIG_DIR = join(homedir(), '.outlook-cli'); +const RESULTS_FILE = join(CONFIG_DIR, 'last-results.json'); + +/** + * Save a mapping of short IDs (#1, #2, ...) to full Graph API IDs. + * Called by list/search commands after displaying results. + * + * @param {Array<{id: string}>} items - Array of Graph API objects with `id` property + */ +export function saveResultIds(items) { + if (!items || items.length === 0) return; + if (!existsSync(CONFIG_DIR)) { + mkdirSync(CONFIG_DIR, { recursive: true }); + } + + const mapping = {}; + for (let i = 0; i < items.length; i++) { + const id = items[i]?.id || items[i]?.Id; + if (id) { + mapping[String(i + 1)] = id; + } + } + + writeFileSync(RESULTS_FILE, JSON.stringify({ + timestamp: new Date().toISOString(), + count: items.length, + ids: mapping, + }, null, 2)); +} + +/** + * Resolve a message ID — if it's a short ID like "1", "#1", or "3", + * look it up in the last results. Otherwise return as-is (full Graph ID). + * + * @param {string} idOrShort - Either a short ID (#1, 1) or a full Graph API ID + * @returns {string} The full Graph API ID + */ +export function resolveId(idOrShort) { + if (!idOrShort) return idOrShort; + + // Strip leading # if present + const cleaned = idOrShort.startsWith('#') ? idOrShort.slice(1) : idOrShort; + + // Only resolve if it looks like a short numeric ID (1-999) + if (!/^\d{1,3}$/.test(cleaned)) { + return idOrShort; // It's a full Graph ID, return as-is + } + + if (!existsSync(RESULTS_FILE)) { + console.error(`Error: No recent results found. Run 'mail inbox' or 'mail search' first to get short IDs.`); + process.exit(1); + } + + try { + const data = JSON.parse(readFileSync(RESULTS_FILE, 'utf-8')); + const fullId = data.ids?.[cleaned]; + if (!fullId) { + console.error(`Error: #${cleaned} not found in last results (had ${data.count} items). Run 'mail inbox' again.`); + process.exit(1); + } + return fullId; + } catch { + console.error('Error: Could not read last results file. Run "mail inbox" again.'); + process.exit(1); + } +} diff --git a/src/node/output/markdown.js b/src/node/output/markdown.js new file mode 100644 index 0000000..c16ce4a --- /dev/null +++ b/src/node/output/markdown.js @@ -0,0 +1,208 @@ +/** + * Markdown format output — tables and structured content. + * + * ESCAPING STRATEGY: Markdown has two characters that break table rendering: + * 1. Pipe "|" — used as column delimiters in markdown tables; escaped to "\|" + * 2. Newlines — break table row boundaries; replaced with spaces + * + * Only these two characters need escaping because the output is primarily + * table-based. Other markdown-significant characters (*, _, etc.) are left + * as-is since they won't break table structure and may be intentional in + * email subjects or event titles. + * + * C# PORT NOTE: Use a similar minimal escaping approach. Don't over-escape — + * full markdown escaping would make email subjects unreadable. + */ + +/** + * Escape markdown table-breaking characters: pipe and newline. + */ +function escMd(str) { + if (!str) return ''; + return str.replace(/\|/g, '\\|').replace(/\n/g, ' '); +} + +function formatDate(isoString) { + if (!isoString) return ''; + try { + const d = new Date(isoString); + return d.toISOString().replace('T', ' ').substring(0, 16); + } catch { + return isoString; + } +} + +function formatTime(isoString) { + if (!isoString) return ''; + try { + const d = new Date(isoString); + return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); + } catch { + return isoString; + } +} + +export function mailList(messages) { + if (!messages || messages.length === 0) return 'No messages found.'; + + const lines = [ + '| | From | Subject | Date |', + '|---|---|---|---|', + ]; + + for (const msg of messages) { + const marker = msg.isRead ? '' : '●'; + const flag = msg.flag?.flagStatus === 'flagged' ? ' ⚑' : ''; + const from = escMd(msg.from?.emailAddress?.name || msg.from?.emailAddress?.address || '(unknown)'); + const subject = escMd(msg.subject || '(no subject)'); + const date = formatDate(msg.receivedDateTime); + lines.push(`| ${marker}${flag} | ${from} | ${subject} | ${date} |`); + } + + lines.push('', `*${messages.length} message(s)*`); + return lines.join('\n'); +} + +export function mailDetail(message) { + if (!message) return 'Message not found.'; + + const lines = []; + lines.push(`## ${escMd(message.subject || '(no subject)')}`); + lines.push(''); + lines.push(`| Field | Value |`); + lines.push(`|---|---|`); + lines.push(`| **From** | ${escMd(message.from?.emailAddress?.name || '')} <${message.from?.emailAddress?.address || ''}> |`); + + if (message.toRecipients?.length > 0) { + lines.push(`| **To** | ${message.toRecipients.map(r => escMd(r.emailAddress?.address)).join(', ')} |`); + } + if (message.ccRecipients?.length > 0) { + lines.push(`| **Cc** | ${message.ccRecipients.map(r => escMd(r.emailAddress?.address)).join(', ')} |`); + } + + lines.push(`| **Date** | ${formatDate(message.receivedDateTime)} |`); + lines.push(`| **Read** | ${message.isRead ? 'Yes' : 'No'} |`); + + if (message.importance && message.importance !== 'normal') { + lines.push(`| **Priority** | ${message.importance} |`); + } + if (message.hasAttachments) { + lines.push(`| **Attachments** | Yes |`); + } + lines.push(`| **ID** | \`${message.id}\` |`); + + lines.push(''); + lines.push('---'); + lines.push(''); + lines.push(message.body?.content || message.bodyPreview || '*(empty body)*'); + return lines.join('\n'); +} + +export function folderList(folders) { + if (!folders || folders.length === 0) return 'No folders found.'; + + const lines = [ + '| Folder | Unread | Total | ID |', + '|---|---|---|---|', + ]; + + for (const f of folders) { + lines.push(`| ${escMd(f.displayName || '')} | ${f.unreadItemCount || 0} | ${f.totalItemCount || 0} | \`${f.id || ''}\` |`); + } + return lines.join('\n'); +} + +export function eventList(events, title = 'Events') { + if (!events || events.length === 0) return `No events for "${title}".`; + + const lines = [`## 📅 ${title}`, '']; + lines.push('| Time | Subject | Location |'); + lines.push('|---|---|---|'); + + for (const event of events) { + const start = formatTime(event.start?.dateTime); + const end = formatTime(event.end?.dateTime); + const time = event.isAllDay ? 'All day' : `${start}–${end}`; + const subject = escMd(event.subject || '(no subject)'); + const location = escMd(event.location?.displayName || ''); + const cancelled = event.isCancelled ? ' ~~CANCELLED~~' : ''; + lines.push(`| ${time} | ${subject}${cancelled} | ${location} |`); + } + + lines.push('', `*${events.length} event(s)*`); + return lines.join('\n'); +} + +export function eventDetail(event) { + if (!event) return 'Event not found.'; + + const lines = [`## ${escMd(event.subject || '(no subject)')}`, '']; + lines.push('| Field | Value |'); + lines.push('|---|---|'); + lines.push(`| **Start** | ${formatDate(event.start?.dateTime)} |`); + lines.push(`| **End** | ${formatDate(event.end?.dateTime)} |`); + + if (event.location?.displayName) { + lines.push(`| **Location** | ${escMd(event.location.displayName)} |`); + } + if (event.organizer?.emailAddress) { + lines.push(`| **Organizer** | ${escMd(event.organizer.emailAddress.name || '')} <${event.organizer.emailAddress.address}> |`); + } + + lines.push(`| **Status** | ${event.showAs || 'unknown'} |`); + lines.push(`| **All Day** | ${event.isAllDay ? 'Yes' : 'No'} |`); + lines.push(`| **ID** | \`${event.id}\` |`); + + if (event.attendees?.length > 0) { + lines.push('', '### Attendees', ''); + lines.push('| Email | Type | Response |'); + lines.push('|---|---|---|'); + for (const a of event.attendees) { + lines.push(`| ${escMd(a.emailAddress?.address)} | ${a.type} | ${a.status?.response || 'none'} |`); + } + } + + if (event.body?.content) { + lines.push('', '---', '', event.body.content); + } + return lines.join('\n'); +} + +export function calendarList(calendars) { + if (!calendars || calendars.length === 0) return 'No calendars found.'; + + const lines = [ + '| Calendar | Color | Owner |', + '|---|---|---|', + ]; + + for (const cal of calendars) { + lines.push(`| ${escMd(cal.name || '')} | ${cal.color || ''} | ${escMd(cal.owner?.address || '')} |`); + } + return lines.join('\n'); +} + +export function contactList(contacts) { + if (!contacts || contacts.length === 0) return 'No contacts found.'; + + const lines = [ + '| Name | Email | Company | Source |', + '|---|---|---|---|', + ]; + + for (const c of contacts) { + const email = c.emailAddresses?.[0]?.address || ''; + lines.push(`| ${escMd(c.displayName || '')} | ${escMd(email)} | ${escMd(c.companyName || '')} | ${c.source || ''} |`); + } + lines.push('', `*${contacts.length} contact(s)*`); + return lines.join('\n'); +} + +/** + * Generic fallback — wraps non-string data in a fenced code block for readability. + */ +export function generic(data) { + if (data === null || data === undefined) return ''; + if (typeof data === 'string') return data; + return '```json\n' + JSON.stringify(data, null, 2) + '\n```'; +} diff --git a/src/node/output/page-state.js b/src/node/output/page-state.js new file mode 100644 index 0000000..d9c42c9 --- /dev/null +++ b/src/node/output/page-state.js @@ -0,0 +1,92 @@ +/** + * Page cursor state for paginated list commands. + * + * Stores the @odata.nextLink from the last paginated response so that + * `--page next` can continue from where the user left off. The state + * is stored per-command (e.g., "mail-inbox", "mail-search") so that + * different commands don't interfere with each other. + * + * File: ~/.outlook-cli/page-state.json + * Format: { "mail-inbox": { nextLink, prevSkip, top, timestamp }, ... } + */ + +import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; +import { join } from 'path'; +import { homedir } from 'os'; + +const CONFIG_DIR = join(homedir(), '.outlook-cli'); +const PAGE_FILE = join(CONFIG_DIR, 'page-state.json'); + +/** + * Save pagination cursor after a paginated response. + * + * @param {string} command - Command key (e.g., "mail-inbox", "mail-search") + * @param {object} state - Pagination state + * @param {string|null} state.nextLink - @odata.nextLink URL for next page + * @param {number} state.currentSkip - Current skip offset + * @param {number} state.top - Page size used + * @param {number} state.resultCount - Number of items returned + */ +export function savePageState(command, state) { + if (!existsSync(CONFIG_DIR)) { + mkdirSync(CONFIG_DIR, { recursive: true }); + } + + let allState = {}; + if (existsSync(PAGE_FILE)) { + try { + allState = JSON.parse(readFileSync(PAGE_FILE, 'utf-8')); + } catch { + allState = {}; + } + } + + allState[command] = { + nextLink: state.nextLink || null, + currentSkip: state.currentSkip || 0, + top: state.top || 25, + resultCount: state.resultCount || 0, + timestamp: new Date().toISOString(), + }; + + writeFileSync(PAGE_FILE, JSON.stringify(allState, null, 2)); +} + +/** + * Get the saved page state for a command. + * + * @param {string} command - Command key + * @returns {object|null} Saved state or null if none + */ +export function getPageState(command) { + if (!existsSync(PAGE_FILE)) return null; + + try { + const allState = JSON.parse(readFileSync(PAGE_FILE, 'utf-8')); + return allState[command] || null; + } catch { + return null; + } +} + +/** + * Clear page state for a command (or all commands). + * + * @param {string} [command] - Command key; if omitted, clears all + */ +export function clearPageState(command) { + if (!existsSync(PAGE_FILE)) return; + + if (!command) { + writeFileSync(PAGE_FILE, '{}'); + return; + } + + try { + const allState = JSON.parse(readFileSync(PAGE_FILE, 'utf-8')); + delete allState[command]; + writeFileSync(PAGE_FILE, JSON.stringify(allState, null, 2)); + } catch { + // Ignore + } +} diff --git a/src/node/output/render.js b/src/node/output/render.js new file mode 100644 index 0000000..2901195 --- /dev/null +++ b/src/node/output/render.js @@ -0,0 +1,107 @@ +/** + * Output render dispatcher — selects format and writes to file or stdout. + * + * FORMAT SELECTION: The user controls output format via: + * --format (explicit format) + * --json (shorthand for --format json) + * default: text + * + * FILE OUTPUT: When --output is specified, rendered content is written + * to the file instead of stdout. This enables `outlook-cli mail inbox --format html + * --output inbox.html` for generating reports. + * + * ENTITY TYPE DISPATCH: Each renderer module (formatter.js, markdown.js, html.js) + * exports functions named after entity types (mailList, mailDetail, etc.). The + * render() function looks up the function by name on the selected renderer module. + * If the entity type doesn't exist on the renderer, it falls back to `generic`. + * + * C# PORT NOTE: This maps to a Strategy pattern — IOutputRenderer with + * implementations for each format, selected at runtime. + */ + +import * as text from './formatter.js'; +import * as markdown from './markdown.js'; +import * as html from './html.js'; + +// "md" is an alias for "markdown" so users can use either name +const renderers = { text, markdown, md: markdown, html }; + +/** + * Render data into a string for the given entity type and format. + * + * Dispatch logic: + * 1. "json" format → bypass renderers, use JSON.stringify directly + * 2. Look up the renderer module by format name (text, markdown, html) + * 3. Look up the entity-specific function on the renderer (e.g. renderer.mailList) + * 4. Fall back to generic if entity type function doesn't exist + * + * @param {*} data - The data to render + * @param {string} entityType - One of: mailList, mailDetail, folderList, + * eventList, eventDetail, calendarList, contactList, attachmentList, generic + * @param {string} format - One of: text, json, markdown, md, html + * @param {*} extra - Extra arg passed to the renderer (e.g., title for eventList) + * @returns {string} + */ +export function render(data, entityType, format = 'text', extra) { + // JSON is handled directly — no renderer module needed + if (format === 'json') { + return JSON.stringify(data, null, 2); + } + + const renderer = renderers[format] || renderers.text; + const fn = renderer[entityType]; + if (!fn) { + // Unknown entity type — fall back to generic (plain text or JSON dump) + const genericFn = renderer.generic || text.generic; + return genericFn(data); + } + return fn(data, extra); +} + +/** + * Write rendered content to a file or stdout. + * + * When outputFile is specified (--output flag), writes to disk instead of stdout. + * This enables HTML/Markdown report generation without shell redirection. + * + * @param {string} content - The rendered string + * @param {string} [outputFile] - File path, or undefined for stdout + */ +export async function writeOutput(content, outputFile) { + if (outputFile) { + const { writeFile } = await import('fs/promises'); + await writeFile(outputFile, content + '\n', 'utf-8'); + } else { + process.stdout.write(content + '\n'); + } +} + +/** + * Resolve the output format from global options. + * + * Priority: --format flag > --json shorthand > default "text" + */ +export function resolveFormat(globalOpts) { + if (globalOpts.format) return globalOpts.format; + if (globalOpts.json) return 'json'; + return 'text'; +} + +/** + * All-in-one: render data and write to the appropriate destination. + * + * This is the primary output function for CLI commands. It chains: + * 1. resolveFormat — determine format from global options + * 2. render — convert data to a formatted string + * 3. writeOutput — write to file or stdout + * + * @param {*} data - The data to render + * @param {string} entityType - Entity type name + * @param {object} globalOpts - Program global options (format, json, output) + * @param {*} [extra] - Extra arg for renderer (e.g., title) + */ +export async function output(data, entityType, globalOpts, extra) { + const format = resolveFormat(globalOpts); + const content = render(data, entityType, format, extra); + await writeOutput(content, globalOpts.output); +} diff --git a/src/node/security/crypto.js b/src/node/security/crypto.js new file mode 100644 index 0000000..acaf4f6 --- /dev/null +++ b/src/node/security/crypto.js @@ -0,0 +1,146 @@ +/** + * AES-256-GCM encryption for token cache files. + * + * Binary format (stored as a single Buffer): + * [salt: 32 bytes] [iv: 12 bytes] [authTag: 16 bytes] [ciphertext: variable] + * + * Key derivation: PBKDF2 with SHA-512, 310,000 iterations (OWASP 2023 recommendation). + * Each encrypt() call generates a fresh random salt and IV, so identical plaintext + * produces different ciphertext every time. + * + * GCM's authentication tag (authTag) provides integrity — if any byte of the + * encrypted file is modified, decryption will fail with "Unsupported state or + * unable to authenticate data" rather than silently returning garbage. + */ +import { randomBytes, createCipheriv, createDecipheriv, pbkdf2Sync } from 'crypto'; +import { hostname, userInfo } from 'os'; + +const ALGORITHM = 'aes-256-gcm'; +const IV_LENGTH = 12; // 96-bit IV for GCM (NIST SP 800-38D recommended, .NET compatible) +const SALT_LENGTH = 32; // 256-bit salt for PBKDF2 +const TAG_LENGTH = 16; // 128-bit GCM authentication tag +const KEY_LENGTH = 32; // 256-bit key for AES-256 +const PBKDF2_ITERATIONS = 310_000; // OWASP 2023 recommended minimum +const PBKDF2_DIGEST = 'sha512'; + +/** + * Derive an AES-256 key from a passphrase and salt using PBKDF2. + * The high iteration count makes brute-force attacks impractical even + * if the encrypted cache file is stolen. + */ +function deriveKey(passphrase, salt) { + return pbkdf2Sync(passphrase, salt, PBKDF2_ITERATIONS, KEY_LENGTH, PBKDF2_DIGEST); +} + +/** + * Encrypt plaintext with AES-256-GCM. + * + * @param {string} plaintext - UTF-8 string to encrypt (typically serialized MSAL cache JSON) + * @param {string} passphrase - Passphrase for key derivation + * @returns {Buffer} Binary blob: salt(32) + iv(16) + authTag(16) + ciphertext + */ +export function encrypt(plaintext, passphrase) { + if (!passphrase || typeof passphrase !== 'string') { + throw new Error('Passphrase is required for encryption'); + } + + // Fresh random salt and IV for every encrypt call — critical for security. + // Reusing salt+IV with the same key would compromise GCM confidentiality. + const salt = randomBytes(SALT_LENGTH); + const iv = randomBytes(IV_LENGTH); + const key = deriveKey(passphrase, salt); + + const cipher = createCipheriv(ALGORITHM, key, iv); + const encrypted = Buffer.concat([ + cipher.update(Buffer.from(plaintext, 'utf-8')), + cipher.final(), + ]); + const tag = cipher.getAuthTag(); + + // Pack everything into a single buffer for file storage. + // Order matters — decrypt() reads bytes at fixed offsets. + return Buffer.concat([salt, iv, tag, encrypted]); +} + +/** + * Decrypt data encrypted by encrypt(). + * + * @param {Buffer|string} data - Encrypted blob (Buffer or base64 string) + * @param {string} passphrase - Same passphrase used for encryption + * @returns {string} Decrypted UTF-8 plaintext + * @throws {Error} If passphrase is wrong or data was tampered with + */ +export function decrypt(data, passphrase) { + if (!passphrase || typeof passphrase !== 'string') { + throw new Error('Passphrase is required for decryption'); + } + + if (!Buffer.isBuffer(data)) { + data = Buffer.from(data, 'base64'); + } + + // Validate minimum size: must have at least salt + iv + tag + const minLength = SALT_LENGTH + IV_LENGTH + TAG_LENGTH; + if (data.length < minLength) { + throw new Error('Invalid encrypted data: too short'); + } + + // Try current 12-byte IV format first, fall back to legacy 16-byte IV format + // for caches encrypted before the IV size was standardized to NIST 96-bit. + try { + return decryptWithIvLength(data, passphrase, IV_LENGTH); + } catch { + const LEGACY_IV_LENGTH = 16; + if (data.length >= SALT_LENGTH + LEGACY_IV_LENGTH + TAG_LENGTH) { + return decryptWithIvLength(data, passphrase, LEGACY_IV_LENGTH); + } + throw new Error('Decryption failed: invalid passphrase or tampered data'); + } +} + +function decryptWithIvLength(data, passphrase, ivLength) { + const salt = data.subarray(0, SALT_LENGTH); + const iv = data.subarray(SALT_LENGTH, SALT_LENGTH + ivLength); + const tag = data.subarray(SALT_LENGTH + ivLength, SALT_LENGTH + ivLength + TAG_LENGTH); + const ciphertext = data.subarray(SALT_LENGTH + ivLength + TAG_LENGTH); + + const key = deriveKey(passphrase, salt); + const decipher = createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(tag); + + try { + const decrypted = Buffer.concat([ + decipher.update(ciphertext), + decipher.final(), + ]); + return decrypted.toString('utf-8'); + } catch (err) { + throw new Error('Decryption failed: invalid passphrase or tampered data'); + } +} + +/** + * Get the passphrase for encrypting a specific account's token cache. + * + * Priority: + * 1. OUTLOOK_CLI_PASSPHRASE env var — use this in CI/shared environments + * 2. Machine-derived string — zero-config convenience for personal machines + * + * The machine-derived passphrase ties the cache to a specific user + machine + account. + * If you copy the cache file to another machine, it won't decrypt (by design). + * + * @param {string} accountAlias - Account name, included in the derivation to prevent + * cross-account decryption if someone copies cache files between accounts + */ +export function getPassphrase(accountAlias = 'default') { + if (process.env.OUTLOOK_CLI_PASSPHRASE) { + return process.env.OUTLOOK_CLI_PASSPHRASE; + } + + // Machine-derived passphrase — unique per user/machine/account combination. + // Not cryptographically strong, but sufficient for at-rest encryption + // where the attacker already has local access. + const host = hostname(); + const user = userInfo().username; + return `outlook-cli:${user}@${host}:${accountAlias}`; +} diff --git a/src/node/security/permissions.js b/src/node/security/permissions.js new file mode 100644 index 0000000..7d910e5 --- /dev/null +++ b/src/node/security/permissions.js @@ -0,0 +1,195 @@ +/** + * Configurable permission system for outlook-cli. + * + * Replaces hardcoded FORBIDDEN_SCOPES with a user-configurable permission + * system in accounts.json. Supports a `defaults` block, parent inheritance + * chains, +/- permission modifiers, and send_to whitelist. + * + * Resolution algorithm: + * 1. Start with `defaults` block (all fields) + * 2. If account has `parent`, recursively resolve parent first + * 3. Overlay account's own fields — scalars replace; permissions use +/- merge + * 4. Circular parent references → error + * 5. `send_to` whitelist is inherited via union (children can add, not remove) + */ + +/** + * Merge a modifier list into a base list using +/- prefixes. + * Items with `+` prefix are added; items with `-` prefix are removed. + * Items without a prefix replace the list entirely (only at defaults level). + * + * @param {string[]} base - Current accumulated list + * @param {string[]} modifiers - New items, possibly with +/- prefixes + * @param {boolean} isDefaults - Whether this is the defaults level (no prefix = literal) + * @returns {string[]} Merged list + */ +function mergeWithModifiers(base, modifiers, isDefaults = false) { + if (!modifiers || modifiers.length === 0) { + return [...base]; + } + + // At defaults level, items without +/- are used as-is (literal values) + if (isDefaults) { + return modifiers.map(m => m.replace(/^[+-]/, '')); + } + + // Check if ALL items have modifiers — if none have +/-, treat as full replacement + const hasModifiers = modifiers.some(m => m.startsWith('+') || m.startsWith('-')); + if (!hasModifiers) { + return [...modifiers]; + } + + const result = [...base]; + for (const item of modifiers) { + if (item.startsWith('+')) { + const scope = item.slice(1); + if (!result.some(r => r.localeCompare(scope, undefined, { sensitivity: 'accent' }) === 0)) { + result.push(scope); + } + } else if (item.startsWith('-')) { + const scope = item.slice(1); + const idx = result.findIndex(r => r.localeCompare(scope, undefined, { sensitivity: 'accent' }) === 0); + if (idx !== -1) { + result.splice(idx, 1); + } + } else { + // No modifier in a mixed list — add if not present + if (!result.some(r => r.localeCompare(item, undefined, { sensitivity: 'accent' }) === 0)) { + result.push(item); + } + } + } + return result; +} + +/** + * Merge send_to arrays via case-insensitive union. + * Children can add recipients but not remove parent restrictions. + * + * @param {string[]} base - Current accumulated send_to list + * @param {string[]} additions - New addresses to union + * @returns {string[]} Merged list (case-insensitive unique) + */ +function mergeSendTo(base, additions) { + if (!additions || additions.length === 0) { + return [...base]; + } + const result = [...base]; + for (const addr of additions) { + if (!result.some(r => r.localeCompare(addr, undefined, { sensitivity: 'accent' }) === 0)) { + result.push(addr); + } + } + return result; +} + +/** + * Internal recursive resolver with circular-reference detection. + * + * @param {string} alias - Account alias to resolve + * @param {object} accountsData - Full accounts.json data + * @param {Set} visited - Aliases already in the resolution chain + * @returns {{ allowed: string[], forbidden: string[], send_to: string[] }} + */ +function resolveRecursive(alias, accountsData, visited) { + if (visited.has(alias)) { + throw new Error( + `Circular parent reference detected: ${[...visited, alias].join(' → ')}` + ); + } + visited.add(alias); + + const accounts = accountsData.accounts || {}; + const account = accounts[alias]; + if (!account) { + throw new Error(`Account '${alias}' not found in accounts data`); + } + + // Start with defaults + const defaults = accountsData.defaults?.permissions || {}; + let allowed = mergeWithModifiers([], defaults.allowed || [], true); + let forbidden = mergeWithModifiers([], defaults.forbidden || [], true); + let sendTo = [...(defaults.send_to || [])]; + + // If account has a parent, resolve parent first + if (account.parent) { + const parentResult = resolveRecursive(account.parent, accountsData, visited); + allowed = parentResult.allowed; + forbidden = parentResult.forbidden; + sendTo = parentResult.send_to; + } + + // Overlay account's own permissions + const perms = account.permissions || {}; + if (perms.allowed) { + allowed = mergeWithModifiers(allowed, perms.allowed); + } + if (perms.forbidden) { + forbidden = mergeWithModifiers(forbidden, perms.forbidden); + } + if (perms.send_to) { + sendTo = mergeSendTo(sendTo, perms.send_to); + } + + return { allowed, forbidden, send_to: sendTo }; +} + +/** + * Resolve the effective permissions for an account by walking the + * defaults → parent chain → account overlay. + * + * @param {string} accountAlias - The account alias to resolve + * @param {object} accountsData - Full accounts.json data + * @returns {{ allowed: string[], forbidden: string[], send_to: string[] }} + */ +export function resolvePermissions(accountAlias, accountsData) { + return resolveRecursive(accountAlias, accountsData, new Set()); +} + +/** + * Validate that all recipients are in the send_to whitelist. + * If send_to is empty or undefined, all recipients are allowed. + * + * @param {string[]} recipients - Array of recipient email addresses + * @param {{ send_to: string[] }} resolvedPermissions - Resolved permissions + * @returns {true} Always returns true if valid + * @throws {Error} If any recipients are not whitelisted + */ +export function validateRecipients(recipients, resolvedPermissions) { + const whitelist = resolvedPermissions?.send_to; + if (!whitelist || whitelist.length === 0) { + return true; + } + + const blocked = recipients.filter( + r => !whitelist.some(w => w.localeCompare(r, undefined, { sensitivity: 'accent' }) === 0) + ); + + if (blocked.length > 0) { + throw new Error( + `Recipients not in send_to whitelist: ${blocked.join(', ')}` + ); + } + + return true; +} + +/** + * Get the forbidden scopes from resolved permissions. + * + * @param {{ forbidden: string[] }} resolvedPermissions + * @returns {string[]} + */ +export function getForbiddenScopes(resolvedPermissions) { + return resolvedPermissions?.forbidden || []; +} + +/** + * Get the allowed scopes from resolved permissions. + * + * @param {{ allowed: string[] }} resolvedPermissions + * @returns {string[]} + */ +export function getAllowedScopes(resolvedPermissions) { + return resolvedPermissions?.allowed || []; +} diff --git a/src/node/security/redactor.js b/src/node/security/redactor.js new file mode 100644 index 0000000..2fe86a2 --- /dev/null +++ b/src/node/security/redactor.js @@ -0,0 +1,254 @@ +/** + * PII Redaction Engine for outlook-cli. + * + * Detects and replaces personally identifiable information (PII) in text + * with tokenized placeholders. Used when piping email content to LLM agents + * to prevent leaking sensitive data. + * + * Design decisions: + * - Zero external dependencies — uses regex patterns, not ML models + * - Token mapping is in-memory only — NEVER persisted to disk + * - Deterministic: same input → same token ID within a session + * - Replacements are reversible via reconstruct() + * + * Token format: **REDACTED-{N}:{type}** + * Examples: **REDACTED-1:email**, **REDACTED-2:phone**, **REDACTED-3:cc** + * + * Supported PII types: + * - email: Email addresses (RFC 5322 simplified) + * - phone: Phone numbers (US, international, with/without country code) + * - cc: Credit card numbers (Visa, Mastercard, Amex, Discover) + * - ssn: US Social Security Numbers (XXX-XX-XXXX) + * - ip: IPv4 addresses + * - key: API keys and tokens (GitHub, AWS, long hex/base64 strings) + * + * Limitations: + * - Names are NOT redacted (too many false positives without NER models) + * - Physical addresses are NOT redacted (too complex for regex) + * - Non-US phone formats may have gaps (covers most common formats) + */ + +// ── Pattern definitions ────────────────────────────────────────────────── + +/** + * @typedef {Object} PiiPattern + * @property {string} type - PII category name + * @property {RegExp} regex - Detection pattern (global flag required) + * @property {function} [validate] - Optional validator to reduce false positives + */ + +/** @type {PiiPattern[]} */ +const PII_PATTERNS = [ + { + type: 'email', + // Simplified RFC 5322: local@domain.tld + // Avoids matching CSS selectors, URLs, or code references + regex: /\b[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}\b/g, + }, + { + type: 'cc', + // Credit card numbers: 13-19 digits, optionally separated by spaces or dashes + // Patterns: Visa (4xxx), MC (5xxx/2xxx), Amex (3[47]xx), Discover (6xxx) + regex: /\b(?:4[0-9]{3}|5[1-5][0-9]{2}|3[47][0-9]{2}|6(?:011|5[0-9]{2}))[- ]?[0-9]{4}[- ]?[0-9]{4}[- ]?[0-9]{1,7}\b/g, + validate: (match) => { + // Luhn check: validates credit card checksums + const digits = match.replace(/[- ]/g, ''); + if (digits.length < 13 || digits.length > 19) return false; + let sum = 0; + let alternate = false; + for (let i = digits.length - 1; i >= 0; i--) { + let n = parseInt(digits[i], 10); + if (alternate) { + n *= 2; + if (n > 9) n -= 9; + } + sum += n; + alternate = !alternate; + } + return sum % 10 === 0; + }, + }, + { + type: 'ssn', + // US Social Security Numbers: XXX-XX-XXXX + // Must have dashes to reduce false positives with other number patterns + regex: /\b[0-9]{3}-[0-9]{2}-[0-9]{4}\b/g, + validate: (match) => { + // SSNs cannot start with 000, 666, or 900-999 + const area = parseInt(match.substring(0, 3), 10); + return area !== 0 && area !== 666 && area < 900; + }, + }, + { + type: 'phone', + // International and US phone numbers + // Matches: +1-555-123-4567, (555) 123-4567, 555.123.4567, +44 20 7946 0958 + regex: /(?:\+?1[-.\s]?)?\(?[2-9][0-9]{2}\)?[-.\s]?[0-9]{3}[-.\s]?[0-9]{4}\b|\+[1-9][0-9]{0,2}[-.\s]?[0-9]{2,4}[-.\s]?[0-9]{3,4}[-.\s]?[0-9]{3,5}\b/g, + }, + { + type: 'ip', + // IPv4 addresses (not matching version numbers like 1.2.3 or common numbers) + regex: /\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b/g, + validate: (match) => { + // Exclude common non-IP patterns like version numbers + const parts = match.split('.').map(Number); + // All zeros or all 255s are not real IPs + if (parts.every(p => p === 0) || parts.every(p => p === 255)) return false; + return true; + }, + }, + { + type: 'key', + // GitHub personal access tokens + regex: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{36,255}\b/g, + }, + { + type: 'key', + // AWS access key IDs + regex: /\bAKIA[0-9A-Z]{16}\b/g, + }, + { + type: 'key', + // Generic long hex strings (32+ chars) that look like API keys/tokens + // Must be standalone (not part of a URL path or Graph ID) + regex: /\b[0-9a-f]{32,64}\b/gi, + validate: (match) => { + // Exclude common non-key hex strings (all same digit, sequential) + if (/^(.)\1+$/.test(match)) return false; + // Must have reasonable entropy (not just "0000...0001") + const unique = new Set(match.toLowerCase()).size; + return unique >= 6; + }, + }, +]; + +// ── Redactor class ─────────────────────────────────────────────────────── + +/** + * Create a new redactor instance. + * + * Each instance maintains its own token mapping for deterministic replacement. + * The mapping is in-memory only — never persisted. + * + * @returns {Object} Redactor with redact() and reconstruct() methods + */ +export function createRedactor() { + /** @type {Map} original → token */ + const forwardMap = new Map(); + /** @type {Map} token → original */ + const reverseMap = new Map(); + let nextId = 1; + + /** + * Get or create a token for a PII value. + * Deterministic: same value always gets the same token within this instance. + */ + function tokenize(value, type) { + if (forwardMap.has(value)) { + return forwardMap.get(value); + } + const token = `**REDACTED-${nextId}:${type}**`; + forwardMap.set(value, token); + reverseMap.set(token, value); + nextId++; + return token; + } + + /** + * Redact PII from text. + * + * @param {string} text - Input text containing potential PII + * @param {Object} [options] - Redaction options + * @param {string[]} [options.types] - Only redact these PII types (default: all) + * @param {Array<{type: string, regex: RegExp}>} [options.customPatterns] - Additional patterns + * @returns {{ redacted: string, mapping: Map, stats: Object }} + */ + function redact(text, options = {}) { + if (!text || typeof text !== 'string') { + return { redacted: text || '', mapping: new Map(), stats: {} }; + } + + const allowedTypes = options.types ? new Set(options.types) : null; + const stats = {}; + let result = text; + + // Combine built-in and custom patterns + const patterns = [...PII_PATTERNS]; + if (options.customPatterns) { + patterns.push(...options.customPatterns); + } + + // Process each pattern type + for (const pattern of patterns) { + if (allowedTypes && !allowedTypes.has(pattern.type)) continue; + + // Reset regex lastIndex for global patterns + pattern.regex.lastIndex = 0; + + result = result.replace(pattern.regex, (match) => { + // Run validator if present + if (pattern.validate && !pattern.validate(match)) { + return match; + } + + const token = tokenize(match, pattern.type); + stats[pattern.type] = (stats[pattern.type] || 0) + 1; + return token; + }); + } + + return { + redacted: result, + mapping: new Map(reverseMap), + stats, + }; + } + + /** + * Reconstruct original text from redacted text and mapping. + * + * @param {string} redactedText - Text containing **REDACTED-N:type** tokens + * @param {Map} [mapping] - Token → original mapping (uses internal if omitted) + * @returns {string} Original text with PII restored + */ + function reconstruct(redactedText, mapping) { + if (!redactedText) return ''; + const map = mapping || reverseMap; + + let result = redactedText; + for (const [token, original] of map) { + // Use split/join instead of regex to handle special chars in tokens + result = result.split(token).join(original); + } + return result; + } + + /** + * Get the current mapping (token → original). + * Useful for serializing to JSON for agent round-trips. + * + * @returns {Object} Plain object mapping tokens to originals + */ + function getMapping() { + return Object.fromEntries(reverseMap); + } + + /** + * Get redaction statistics. + * + * @returns {{ totalRedacted: number, byType: Object }} + */ + function getStats() { + const byType = {}; + for (const [, token] of forwardMap) { + const type = token.match(/\*\*REDACTED-\d+:(\w+)\*\*/)?.[1]; + if (type) byType[type] = (byType[type] || 0) + 1; + } + return { + totalRedacted: forwardMap.size, + byType, + }; + } + + return { redact, reconstruct, getMapping, getStats }; +} diff --git a/src/node/security/token-validator.js b/src/node/security/token-validator.js new file mode 100644 index 0000000..5ef2d78 --- /dev/null +++ b/src/node/security/token-validator.js @@ -0,0 +1,143 @@ +/** + * Defense-in-depth token scope validator. + * + * PURPOSE: Validates every token before use to ensure it doesn't contain + * dangerous application-level scopes. This catches scenarios where: + * - The App Registration was misconfigured with admin-granted Mail.ReadWrite.All + * - A token was somehow obtained through a different flow + * - The MSAL library behavior changes in a future version + * + * SCOPE SECURITY MODEL: + * FORBIDDEN: + * Mail.ReadWrite.All → Application-level access to ALL mailboxes. NEVER allowed. + * + * ALLOWED: + * Mail.Send → Direct send from own account. Allowed (with confirmation prompt). + * Mail.Send.Shared → Send on behalf of delegate. Allowed. + * + * TOKEN FORMATS: + * Work/school accounts → JWT (3-part base64 token with decodable scp claim) + * Personal accounts → Opaque token (not a JWT, cannot be decoded) + * For opaque tokens, we validate via the MSAL result's scopes array instead. + */ + +// Mail.ReadWrite.All (application-level access to ALL mailboxes) remains forbidden. +// Mail.Send and Mail.Send.Shared are both allowed. +const DEFAULT_FORBIDDEN_SCOPES = ['Mail.ReadWrite.All']; + +/** + * Decode a JWT payload without verifying the signature. + * + * We don't need signature verification because we're inspecting our OWN token's + * claims — we're not authenticating someone else. We just want to read the `scp` + * claim to check for forbidden scopes. + * + * @returns {object|null} Parsed payload, or null for opaque (non-JWT) tokens + */ +export function decodeJwtPayload(token) { + if (!token || typeof token !== 'string') { + return null; + } + + // JWTs have exactly 3 parts separated by dots: header.payload.signature + const parts = token.split('.'); + if (parts.length !== 3) { + return null; // Opaque token — personal Microsoft accounts return these + } + + try { + const payload = Buffer.from(parts[1], 'base64url').toString('utf-8'); + return JSON.parse(payload); + } catch { + return null; + } +} + +/** + * Check if any scopes in the given array are forbidden. + * Uses case-insensitive comparison (accent-sensitive) because Microsoft + * may return scopes with different casing. + * + * @param {string[]} scopes - Scopes to check + * @param {string[]} [forbiddenList] - Optional custom forbidden list; defaults to DEFAULT_FORBIDDEN_SCOPES + */ +function findForbiddenScopes(scopes, forbiddenList = DEFAULT_FORBIDDEN_SCOPES) { + return scopes.filter(s => + forbiddenList.some(f => s.localeCompare(f, undefined, { sensitivity: 'accent' }) === 0) + ); +} + +/** + * Check if a raw token string contains any forbidden scopes. + * + * @param {string} token - Raw access token string + * @param {string[]} [forbiddenList] - Optional custom forbidden scopes list + * @returns {{ valid: boolean, forbidden?: string[], opaque?: boolean, scopes: string[] }} + */ +export function checkScopes(token, forbiddenList) { + const payload = decodeJwtPayload(token); + if (!payload) { + // Opaque token — we can't check scopes from the token itself. + // The caller should use validateTokenScopes() with the MSAL result instead. + return { valid: true, opaque: true, scopes: [] }; + } + + // The `scp` claim is a space-separated string of granted scopes + const scp = payload.scp || ''; + const scopes = typeof scp === 'string' ? scp.split(' ') : []; + const forbidden = findForbiddenScopes(scopes, forbiddenList); + + if (forbidden.length > 0) { + return { valid: false, forbidden, scopes }; + } + + return { valid: true, scopes }; +} + +/** + * Validate token scopes and throw if any forbidden scopes are present. + * + * Called by GraphClient.getToken() on every token acquisition as defense-in-depth. + * Accepts two input types: + * 1. MSAL AuthenticationResult object (has .scopes array and .accessToken) + * 2. Raw JWT token string (legacy path) + * + * For MSAL results, we check both the scopes array AND the JWT claims (if decodable). + * For opaque tokens (personal accounts), the scopes array is the only source of truth. + * + * @param {string[]} [forbiddenList] - Optional custom forbidden scopes list; defaults to DEFAULT_FORBIDDEN_SCOPES + * @throws {Error} If any forbidden scope is detected + */ +export function validateTokenScopes(tokenOrResult, forbiddenList) { + // Path 1: MSAL result object with a scopes array + if (tokenOrResult && typeof tokenOrResult === 'object' && Array.isArray(tokenOrResult.scopes)) { + const forbidden = findForbiddenScopes(tokenOrResult.scopes, forbiddenList); + if (forbidden.length > 0) { + throw new Error( + `SECURITY: Token contains forbidden scope(s): ${forbidden.join(', ')}. ` + + 'Please re-register the Azure app without these scopes.' + ); + } + // Belt-and-suspenders: also decode and check the JWT if possible + if (tokenOrResult.accessToken) { + const jwtResult = checkScopes(tokenOrResult.accessToken, forbiddenList); + if (!jwtResult.valid) { + throw new Error( + `SECURITY: Token contains forbidden scope(s): ${jwtResult.forbidden.join(', ')}. ` + + 'Please re-register the Azure app without these scopes.' + ); + } + } + return { valid: true, scopes: tokenOrResult.scopes }; + } + + // Path 2: Raw token string (for backward compatibility / direct use) + const result = checkScopes(tokenOrResult, forbiddenList); + if (!result.valid) { + throw new Error( + `SECURITY: Token contains forbidden scope(s): ${result.forbidden.join(', ')}. ` + + 'Please re-register the Azure app without these scopes.' + ); + } + return result; +} diff --git a/src/node/security/write-guard.js b/src/node/security/write-guard.js new file mode 100644 index 0000000..78cf9b8 --- /dev/null +++ b/src/node/security/write-guard.js @@ -0,0 +1,33 @@ +/** + * Write guard for read-only accounts. + * + * Read-only accounts are enforced at two layers: + * 1. MSAL scopes — read-only accounts request only read scopes at login. + * Graph API returns HTTP 403 if a write operation is attempted. + * 2. CLI command guard (THIS MODULE) — checks account mode before executing + * write commands. Provides a fast, clear error message instead of waiting + * for a 403 from Graph. + * + * Every CLI command that modifies data (send, draft, move, delete, flag, + * mark-read, reply, forward, calendar create, calendar delete) should call + * assertWriteAllowed() before making the API call. + */ + +import { getAccountManager } from '../accounts/manager.js'; + +/** + * Assert that the current account is allowed to perform write operations. + * Throws with a clear error message if the account is read-only. + * + * @param {string} accountAlias - The account alias to check + * @param {string} operation - Description of the blocked operation (for error message) + * @throws {Error} If the account is read-only + */ +export function assertWriteAllowed(accountAlias, operation) { + const manager = getAccountManager(); + if (manager.isReadOnly(accountAlias)) { + console.error(`✗ Account "${accountAlias}" is read-only. Cannot ${operation}.`); + console.error(` To change this, run: outlook-cli account set ${accountAlias} --mode full`); + process.exit(1); + } +} diff --git a/src/node/telemetry/collector.js b/src/node/telemetry/collector.js new file mode 100644 index 0000000..8fe1d95 --- /dev/null +++ b/src/node/telemetry/collector.js @@ -0,0 +1,250 @@ +/** + * Telemetry collector for diagnostic instrumentation. + * Provides an in-memory ring buffer, optional SQLite persistence, + * and process snapshot sampling. + * + * Disabled by default. Enable via --telemetry flag or OUTLOOK_CLI_TELEMETRY=1. + */ +import { randomUUID } from 'crypto'; + +const DEFAULT_BUFFER_SIZE = 1000; +const DEFAULT_FLUSH_INTERVAL_MS = 5000; +const DEFAULT_SNAPSHOT_INTERVAL_MS = 60000; + +export class TelemetryCollector { + constructor(options = {}) { + this.enabled = options.enabled ?? false; + this.bufferSize = options.bufferSize ?? DEFAULT_BUFFER_SIZE; + this.buffer = []; // Ring buffer — oldest events evicted first + this.db = options.db ?? null; // Optional SQLite database instance + this.flushIntervalMs = options.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS; + this.snapshotIntervalMs = options.snapshotIntervalMs ?? DEFAULT_SNAPSHOT_INTERVAL_MS; + this._flushTimer = null; + this._snapshotTimer = null; + this._pendingWrites = []; + this._startTime = Date.now(); + this._totalEvents = 0; + } + + /** Start periodic flush and snapshot timers */ + start() { + if (!this.enabled) return; + if (this.db) { + this._ensureTable(); + this._flushTimer = setInterval(() => this.flush(), this.flushIntervalMs); + this._flushTimer.unref?.(); + } + this._snapshotTimer = setInterval(() => this.snapshot(), this.snapshotIntervalMs); + this._snapshotTimer.unref?.(); + } + + /** Stop timers and flush pending events */ + stop() { + if (this._flushTimer) { clearInterval(this._flushTimer); this._flushTimer = null; } + if (this._snapshotTimer) { clearInterval(this._snapshotTimer); this._snapshotTimer = null; } + if (this.db && this._pendingWrites.length > 0) this.flush(); + } + + /** Record a telemetry event */ + emit(event) { + if (!this.enabled) return; + const entry = { + id: randomUUID(), + timestamp: new Date().toISOString(), + ...event, + }; + // Ring buffer: add to end, evict from front if over capacity + this.buffer.push(entry); + if (this.buffer.length > this.bufferSize) { + this.buffer.shift(); + } + this._totalEvents++; + if (this.db) { + this._pendingWrites.push(entry); + } + return entry; + } + + /** Take a process resource snapshot */ + snapshot() { + const mem = process.memoryUsage(); + const cpu = process.cpuUsage(); + return this.emit({ + event: 'process.snapshot', + heapUsedMB: Math.round(mem.heapUsed / 1024 / 1024 * 100) / 100, + rssMB: Math.round(mem.rss / 1024 / 1024 * 100) / 100, + cpuUserMs: Math.round(cpu.user / 1000), + cpuSystemMs: Math.round(cpu.system / 1000), + uptimeMs: Date.now() - this._startTime, + }); + } + + /** Flush pending events to SQLite */ + flush() { + if (!this.db || this._pendingWrites.length === 0) return; + const stmt = this.db.prepare(` + INSERT INTO telemetry (id, correlation_id, timestamp, event, account, + duration_ms, wait_ms, heap_used_mb, rss_mb, cpu_user_ms, cpu_system_ms, + graph_endpoint, graph_status_code, graph_throttled, graph_retry_count, + graph_page_count, graph_item_count, graph_bytes_received, + sqlite_rows_affected, sqlite_query_ms, + watch_job_id, watch_cycle_number, watch_schedule_drift_ms, metadata) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + const tx = this.db.transaction((events) => { + for (const e of events) { + // Pack extra fields into metadata that aren't in the schema + const extra = { ...e.metadata }; + if (e.graphMethod) extra.graphMethod = e.graphMethod; + if (e.command) extra.command = e.command; + if (e.success !== undefined) extra.success = e.success; + const metadataJson = Object.keys(extra).length > 0 ? JSON.stringify(extra) : null; + + stmt.run( + e.id, e.correlationId ?? null, e.timestamp, e.event, e.account ?? null, + e.durationMs ?? null, e.waitMs ?? null, e.heapUsedMB ?? null, e.rssMB ?? null, + e.cpuUserMs ?? null, e.cpuSystemMs ?? null, + e.graphEndpoint ?? null, e.graphStatusCode ?? null, e.graphThrottled ? 1 : 0, + e.graphRetryCount ?? null, e.graphPageCount ?? null, e.graphItemCount ?? null, + e.graphBytesReceived ?? null, + e.sqliteRowsAffected ?? null, e.sqliteQueryMs ?? null, + e.watchJobId ?? null, e.watchCycleNumber ?? null, e.watchScheduleDriftMs ?? null, + metadataJson + ); + } + }); + tx(this._pendingWrites); + this._pendingWrites = []; + } + + /** Get recent events from ring buffer, optionally filtered */ + getRecent(options = {}) { + let events = [...this.buffer]; + if (options.event) events = events.filter(e => e.event === options.event); + if (options.account) events = events.filter(e => e.account === options.account); + if (options.since) { + const cutoff = new Date(options.since).getTime(); + events = events.filter(e => new Date(e.timestamp).getTime() >= cutoff); + } + if (options.limit) events = events.slice(-options.limit); + return events; + } + + /** Get summary statistics */ + getSummary(sinceMs = 86400000) { + const cutoff = new Date(Date.now() - sinceMs).toISOString(); + const events = this.buffer.filter(e => e.timestamp >= cutoff); + + const graphRequests = events.filter(e => e.event === 'graph.request'); + const errors = events.filter(e => e.event === 'graph.error'); + const throttles = events.filter(e => e.event === 'graph.throttle'); + const snapshots = events.filter(e => e.event === 'process.snapshot'); + + return { + totalEvents: events.length, + graphRequests: graphRequests.length, + errors: errors.length, + throttles: throttles.length, + avgLatencyMs: graphRequests.length > 0 + ? Math.round(graphRequests.reduce((sum, e) => sum + (e.durationMs || 0), 0) / graphRequests.length) + : 0, + avgHeapMB: snapshots.length > 0 + ? Math.round(snapshots.reduce((sum, e) => sum + (e.heapUsedMB || 0), 0) / snapshots.length * 100) / 100 + : 0, + peakHeapMB: snapshots.length > 0 + ? Math.max(...snapshots.map(e => e.heapUsedMB || 0)) + : 0, + uptimeMs: Date.now() - this._startTime, + }; + } + + /** Export events as JSON array */ + export(options = {}) { + if (this.db && options.fromDb) { + let sql = 'SELECT * FROM telemetry WHERE 1=1'; + const params = []; + if (options.since) { sql += ' AND timestamp >= ?'; params.push(options.since); } + if (options.event) { sql += ' AND event = ?'; params.push(options.event); } + sql += ' ORDER BY timestamp DESC'; + if (options.limit) { sql += ' LIMIT ?'; params.push(options.limit); } + const rows = this.db.prepare(sql).all(...params); + // Map snake_case DB columns to camelCase JS properties + return rows.map(r => { + const meta = r.metadata ? JSON.parse(r.metadata) : {}; + return { + id: r.id, timestamp: r.timestamp, event: r.event, account: r.account, + correlationId: r.correlation_id, durationMs: r.duration_ms, waitMs: r.wait_ms, + heapUsedMB: r.heap_used_mb, rssMB: r.rss_mb, + cpuUserMs: r.cpu_user_ms, cpuSystemMs: r.cpu_system_ms, + graphEndpoint: r.graph_endpoint, + graphMethod: meta.graphMethod ?? null, + graphStatusCode: r.graph_status_code, graphThrottled: !!r.graph_throttled, + graphRetryCount: r.graph_retry_count, graphPageCount: r.graph_page_count, + graphItemCount: r.graph_item_count, graphBytesReceived: r.graph_bytes_received, + command: meta.command ?? null, success: meta.success ?? null, + metadata: meta, + }; + }); + } + return this.getRecent(options); + } + + _ensureTable() { + this.db.exec(` + CREATE TABLE IF NOT EXISTS telemetry ( + id TEXT PRIMARY KEY, + correlation_id TEXT, + timestamp TEXT NOT NULL, + event TEXT NOT NULL, + account TEXT, + duration_ms INTEGER, + wait_ms INTEGER DEFAULT 0, + heap_used_mb REAL, + rss_mb REAL, + cpu_user_ms INTEGER, + cpu_system_ms INTEGER, + graph_endpoint TEXT, + graph_status_code INTEGER, + graph_throttled INTEGER DEFAULT 0, + graph_retry_count INTEGER DEFAULT 0, + graph_page_count INTEGER, + graph_item_count INTEGER, + graph_bytes_received INTEGER, + sqlite_rows_affected INTEGER, + sqlite_query_ms INTEGER, + watch_job_id TEXT, + watch_cycle_number INTEGER, + watch_schedule_drift_ms INTEGER, + metadata TEXT + ); + CREATE INDEX IF NOT EXISTS idx_telemetry_event ON telemetry(event); + CREATE INDEX IF NOT EXISTS idx_telemetry_timestamp ON telemetry(timestamp); + CREATE INDEX IF NOT EXISTS idx_telemetry_account ON telemetry(account); + CREATE INDEX IF NOT EXISTS idx_telemetry_correlation ON telemetry(correlation_id); + `); + } +} + +// Global singleton — disabled by default +let _instance = null; + +export function getTelemetry() { + if (!_instance) { + _instance = new TelemetryCollector({ enabled: false }); + } + return _instance; +} + +export function initTelemetry(options = {}) { + _instance = new TelemetryCollector({ + enabled: options.enabled ?? (process.env.OUTLOOK_CLI_TELEMETRY === '1'), + ...options, + }); + _instance.start(); + return _instance; +} + +export function resetTelemetry() { + if (_instance) _instance.stop(); + _instance = null; +} diff --git a/src/node/version.js b/src/node/version.js new file mode 100644 index 0000000..06f56b5 --- /dev/null +++ b/src/node/version.js @@ -0,0 +1,96 @@ +/** + * Version tracking and staleness detection. + * + * On every CLI invocation, we write the current version and runtime to + * ~/.outlook-cli/.last-version.json. If a newer version was previously + * used, we warn the user that their binary may be out of date. + */ + +import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; +import { join } from 'path'; +import { homedir } from 'os'; +import { createRequire } from 'module'; + +const require = createRequire(import.meta.url); +const versionInfo = require('../shared/version.json'); + +const CONFIG_DIR = join(homedir(), '.outlook-cli'); +const VERSION_FILE = join(CONFIG_DIR, '.last-version.json'); + +/** + * Compare two semver strings. Returns -1, 0, or 1. + */ +export function compareSemver(a, b) { + const pa = a.split('.').map(Number); + const pb = b.split('.').map(Number); + for (let i = 0; i < 3; i++) { + if ((pa[i] || 0) < (pb[i] || 0)) return -1; + if ((pa[i] || 0) > (pb[i] || 0)) return 1; + } + return 0; +} + +/** + * Check for staleness against the previously-recorded version. + * Returns an object describing the result: + * { stale: boolean, runtimeChanged: boolean, warnings: string[] } + * + * Does NOT write to disk — call stampVersion() afterward to record the current run. + */ +export function checkStaleness() { + const result = { stale: false, runtimeChanged: false, warnings: [] }; + + try { + if (!existsSync(VERSION_FILE)) { + return result; + } + + const prev = JSON.parse(readFileSync(VERSION_FILE, 'utf-8')); + + if (prev.version && compareSemver(versionInfo.version, prev.version) < 0) { + result.stale = true; + result.warnings.push( + `Warning: This binary is v${versionInfo.version} but v${prev.version} was used previously. Run 'outlook-cli upgrade' or rebuild.` + ); + } + + if (prev.runtime && prev.runtime !== 'node') { + result.runtimeChanged = true; + result.warnings.push( + `Note: The last CLI invocation used the "${prev.runtime}" runtime. You are now running "node".` + ); + } + } catch { + // Non-fatal — corrupted file is ignored + } + + return result; +} + +/** + * Stamp the current version to disk and warn if a newer version was used before. + * Called on every CLI invocation. + */ +export function stampVersion() { + try { + // Check and warn first + const { warnings } = checkStaleness(); + for (const w of warnings) { + console.error(w); + } + + // Write current version + if (!existsSync(CONFIG_DIR)) { + mkdirSync(CONFIG_DIR, { recursive: true }); + } + writeFileSync(VERSION_FILE, JSON.stringify({ + version: versionInfo.version, + runtime: 'node', + updatedAt: new Date().toISOString(), + }, null, 2)); + } catch { + // Non-fatal — don't block CLI usage if version file is unwritable + } +} + +export { versionInfo }; diff --git a/src/node/watch/actions.js b/src/node/watch/actions.js new file mode 100644 index 0000000..042e6b4 --- /dev/null +++ b/src/node/watch/actions.js @@ -0,0 +1,184 @@ +/** + * Action executor for the watch rules engine. + * + * When a rule matches an incoming change event, its actions are executed + * sequentially. If any action fails, remaining actions for that rule are + * skipped (fail-fast per rule). + * + * BUILT-IN ACTIONS: + * move — move message to a folder (mail only) + * mark-read — mark message as read (mail only) + * flag — flag message (mail only) + * unflag — remove flag from message (mail only) + * run — execute a shell command with template substitution + * channel — reserved for future webhook/channel integration + * + * TEMPLATE SUBSTITUTION: + * The "run" action supports {{varname}} placeholders that are replaced + * with values from the event data. Missing variables → empty string. + * Values are shell-escaped to prevent command injection. + */ + +import { exec } from 'child_process'; + +const RUN_TIMEOUT_MS = 30000; + +/** + * Execute matched rule actions sequentially. + * + * @param {Array<{ type: string, [key: string]: any }>} actions - Actions to execute + * @param {object} eventData - Normalized event data for template vars + * @param {object} context - Execution context + * @param {object} context.client - GraphClient for API calls + * @param {string} context.resourceType - "mail" or "calendar" + * @param {Function} [context.log] - Logging function (defaults to console.log) + */ +export async function executeActions(actions, eventData, context) { + const log = context.log || console.log; + + for (const action of actions) { + try { + await executeSingleAction(action, eventData, context); + } catch (err) { + log(`Action "${action.type}" failed: ${err.message}`); + break; // Stop remaining actions for this rule + } + } +} + +/** + * Execute a single action. + * + * @param {object} action - Action definition + * @param {object} eventData - Event data for template substitution + * @param {object} context - Execution context with client and resourceType + */ +async function executeSingleAction(action, eventData, context) { + const { client } = context; + + switch (action.type) { + case 'move': { + if (!action.folder) throw new Error('move action requires "folder" property'); + const { moveMessage } = await import('../graph/mail.js'); + await moveMessage(client, eventData.message_id, action.folder); + break; + } + case 'mark-read': { + const { markRead } = await import('../graph/mail.js'); + await markRead(client, eventData.message_id, true); + break; + } + case 'flag': { + const { flagMessage } = await import('../graph/mail.js'); + await flagMessage(client, eventData.message_id, true); + break; + } + case 'unflag': { + const { flagMessage } = await import('../graph/mail.js'); + await flagMessage(client, eventData.message_id, false); + break; + } + case 'run': { + if (!action.command) throw new Error('run action requires "command" property'); + const command = substituteTemplateVars(action.command, eventData); + await runCommand(command, action.timeout || RUN_TIMEOUT_MS); + break; + } + case 'channel': + // Reserved for future webhook/channel integration + break; + default: + throw new Error(`Unknown action type: "${action.type}"`); + } +} + +/** + * Substitute {{varname}} template placeholders with event data values. + * + * Missing variables are replaced with empty strings. Values are shell-escaped + * to prevent command injection when used in "run" actions. + * + * Available template variables: + * Mail: message_id, from, to, cc, subject, body_preview, importance, received_date + * Calendar: event_id, subject, start, end, location, organizer + * + * @param {string} template - Template string with {{varname}} placeholders + * @param {object} eventData - Key-value map of available variables + * @returns {string} Template with placeholders replaced + */ +export function substituteTemplateVars(template, eventData) { + return template.replace(/\{\{(\w+)\}\}/g, (match, varName) => { + const value = eventData[varName]; + if (value === undefined || value === null) return ''; + return shellEscape(String(value)); + }); +} + +/** + * Escape a string for safe shell usage. + * + * Wraps the value in single quotes and escapes any single quotes within. + * This prevents command injection via user-controlled mail content. + * + * @param {string} str - Raw string value + * @returns {string} Shell-safe escaped string + */ +export function shellEscape(str) { + // Replace single quotes with '\'' (end quote, escaped quote, start quote) + return "'" + str.replace(/'/g, "'\\''") + "'"; +} + +/** + * Execute a shell command with timeout. + * + * @param {string} command - Shell command to execute + * @param {number} timeout - Timeout in milliseconds + * @returns {Promise<{ stdout: string, stderr: string }>} + */ +function runCommand(command, timeout) { + return new Promise((resolve, reject) => { + exec(command, { timeout }, (error, stdout, stderr) => { + if (error) { + reject(new Error(`Command failed: ${error.message}`)); + return; + } + resolve({ stdout, stderr }); + }); + }); +} + +/** + * Normalize a Graph API mail message into template variable data. + * + * @param {object} msg - Graph API message object + * @returns {object} Flat key-value map of template variables + */ +export function normalizeMailEventData(msg) { + return { + message_id: msg.id || '', + from: msg.from?.emailAddress?.address || '', + to: (msg.toRecipients || []).map(r => r.emailAddress?.address).join(', '), + cc: (msg.ccRecipients || []).map(r => r.emailAddress?.address).join(', '), + subject: msg.subject || '', + body_preview: msg.bodyPreview || '', + importance: msg.importance || 'normal', + received_date: msg.receivedDateTime || '', + }; +} + +/** + * Normalize a Graph API calendar event into template variable data. + * + * @param {object} evt - Graph API calendar event object + * @returns {object} Flat key-value map of template variables + */ +export function normalizeCalendarEventData(evt) { + return { + event_id: evt.id || '', + subject: evt.subject || '', + start: evt.start?.dateTime || '', + end: evt.end?.dateTime || '', + location: evt.location?.displayName || '', + organizer: evt.organizer?.emailAddress?.address || '', + }; +} diff --git a/src/node/watch/config.js b/src/node/watch/config.js new file mode 100644 index 0000000..ff4c8fc --- /dev/null +++ b/src/node/watch/config.js @@ -0,0 +1,166 @@ +/** + * Watch config loader and validator. + * + * Loads a JSON config file that defines multiple watch jobs, each with + * its own account, resource type, schedule, and rule set. The config + * drives the WatchManager for multi-job orchestration. + * + * Config schema: + * { + * "watches": [{ + * "id": string (required, unique), + * "account": string (required), + * "resource": "mail" | "calendar" (required), + * "folder": string (optional, default "Inbox" for mail), + * "schedule": string (required — see schedule.js), + * "rules": [{ + * "id": string, + * "conditions": { from?, from_contains?, subject_contains?, ... }, + * "actions": [{ type: "move"|"mark-read"|"flag"|"run"|"channel", ... }] + * }] + * }] + * } + */ + +import { readFileSync } from 'fs'; +import { parseSchedule } from './schedule.js'; + +const VALID_RESOURCES = ['mail', 'calendar']; +const VALID_ACTION_TYPES = ['move', 'mark-read', 'flag', 'unflag', 'run', 'channel']; + +/** + * Load and validate a watch config JSON file. + * + * @param {string} filePath - Path to the JSON config file + * @returns {object} Parsed and validated config + * @throws {Error} If the file can't be read or the config is invalid + */ +export function loadWatchConfig(filePath) { + let raw; + try { + raw = readFileSync(filePath, 'utf-8'); + } catch (err) { + throw new Error(`Cannot read watch config: ${filePath} — ${err.message}`); + } + + let config; + try { + config = JSON.parse(raw); + } catch (err) { + throw new Error(`Invalid JSON in watch config: ${filePath} — ${err.message}`); + } + + return validateWatchConfig(config); +} + +/** + * Validate and normalize a watch config object. + * + * @param {object} config - Raw config object + * @returns {object} Normalized config with defaults applied + * @throws {Error} If any required field is missing or invalid + */ +export function validateWatchConfig(config) { + if (!config || typeof config !== 'object') { + throw new Error('Watch config must be a JSON object'); + } + + if (!Array.isArray(config.watches) || config.watches.length === 0) { + throw new Error('Watch config must have a non-empty "watches" array'); + } + + const seenIds = new Set(); + const normalized = { + watches: config.watches.map((watch, index) => validateWatch(watch, index, seenIds)), + }; + + return normalized; +} + +/** + * Validate a single watch entry. + * + * @param {object} watch - Raw watch config entry + * @param {number} index - Position in the watches array (for error messages) + * @param {Set} seenIds - Set of previously seen IDs (for uniqueness check) + * @returns {object} Normalized watch entry + */ +function validateWatch(watch, index, seenIds) { + if (!watch || typeof watch !== 'object') { + throw new Error(`watches[${index}]: must be an object`); + } + + // Required: id + if (!watch.id || typeof watch.id !== 'string') { + throw new Error(`watches[${index}]: "id" is required and must be a string`); + } + if (seenIds.has(watch.id)) { + throw new Error(`watches[${index}]: duplicate id "${watch.id}"`); + } + seenIds.add(watch.id); + + // Required: account + if (!watch.account || typeof watch.account !== 'string') { + throw new Error(`watches[${index}]: "account" is required and must be a string`); + } + + // Required: resource + if (!VALID_RESOURCES.includes(watch.resource)) { + throw new Error(`watches[${index}]: "resource" must be one of: ${VALID_RESOURCES.join(', ')}`); + } + + // Required: schedule + if (!watch.schedule || typeof watch.schedule !== 'string') { + throw new Error(`watches[${index}]: "schedule" is required and must be a string`); + } + // Validate schedule string parses correctly + parseSchedule(watch.schedule); + + // Optional: folder (defaults to Inbox for mail) + const folder = watch.folder || (watch.resource === 'mail' ? 'Inbox' : undefined); + + // Optional: rules (defaults to empty array) + const rules = Array.isArray(watch.rules) + ? watch.rules.map((rule, rIdx) => validateRule(rule, index, rIdx)) + : []; + + return { + id: watch.id, + account: watch.account, + resource: watch.resource, + folder, + schedule: watch.schedule, + rules, + }; +} + +/** + * Validate a single rule within a watch entry. + * + * @param {object} rule - Raw rule object + * @param {number} watchIdx - Parent watch index + * @param {number} ruleIdx - Rule index within the watch + * @returns {object} Validated rule + */ +function validateRule(rule, watchIdx, ruleIdx) { + if (!rule || typeof rule !== 'object') { + throw new Error(`watches[${watchIdx}].rules[${ruleIdx}]: must be an object`); + } + + const id = rule.id || `rule-${watchIdx}-${ruleIdx}`; + const conditions = rule.conditions || {}; + const actions = Array.isArray(rule.actions) ? rule.actions : []; + + // Validate each action has a valid type + for (let i = 0; i < actions.length; i++) { + const action = actions[i]; + if (!action || typeof action !== 'object' || !VALID_ACTION_TYPES.includes(action.type)) { + throw new Error( + `watches[${watchIdx}].rules[${ruleIdx}].actions[${i}]: ` + + `"type" must be one of: ${VALID_ACTION_TYPES.join(', ')}` + ); + } + } + + return { id, conditions, actions }; +} diff --git a/src/node/watch/manager.js b/src/node/watch/manager.js new file mode 100644 index 0000000..cb74f60 --- /dev/null +++ b/src/node/watch/manager.js @@ -0,0 +1,258 @@ +/** + * Multi-job watch manager. + * + * Loads a watch config, creates shared GraphClients per unique account, + * starts independent WatchJob instances, and coordinates graceful shutdown. + * + * ARCHITECTURE: + * ┌──────────────────────────────────────────────────┐ + * │ WatchManager │ + * │ │ + * │ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ + * │ │ WatchJob 1 │ │ WatchJob 2 │ │ WatchJob N │ │ + * │ │ mail/Inbox │ │ calendar │ │ mail/Sent │ │ + * │ │ every 60s │ │ cron 5min │ │ at 9:00 │ │ + * │ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ │ + * │ │ │ │ │ + * │ ▼ ▼ ▼ │ + * │ ┌──────────────────────────────────────────┐ │ + * │ │ Shared GraphClients (per account) │ │ + * │ └──────────────────────────────────────────┘ │ + * └──────────────────────────────────────────────────┘ + * + * Each WatchJob runs its own schedule loop independently: + * 1. Wait for next scheduled time + * 2. Run delta sync for its resource + * 3. Evaluate rules against changes + * 4. Execute matched actions + * 5. Repeat until shutdown + */ + +import { loadWatchConfig } from './config.js'; +import { parseSchedule } from './schedule.js'; +import { evaluateRules } from './rules.js'; +import { executeActions, normalizeMailEventData, normalizeCalendarEventData } from './actions.js'; +import { + executeDeltaQuery, + buildMailDeltaUrl, + buildCalendarDeltaUrl, + createDeltaStore, +} from '../graph/delta.js'; + +/** + * Start the multi-job watch manager. + * + * @param {string} configPath - Path to the watch config JSON file + * @param {object} options - Manager options + * @param {Function} options.createClient - Function(account) → GraphClient. + * Injected to allow the CLI layer to handle auth and account resolution. + * @param {AbortSignal} [options.signal] - AbortSignal for graceful shutdown + * @param {Function} [options.log] - Logging function (defaults to console.log) + */ +export async function startWatchManager(configPath, options = {}) { + const log = options.log || console.log; + const config = loadWatchConfig(configPath); + + log(`Loaded watch config with ${config.watches.length} watch job(s)`); + + // Create shared GraphClients — one per unique account + const clients = new Map(); + if (options.createClient) { + for (const watch of config.watches) { + if (!clients.has(watch.account)) { + const client = await options.createClient(watch.account); + clients.set(watch.account, client); + } + } + } + + // Set up graceful shutdown + const abortController = new AbortController(); + let running = true; + + const handleShutdown = () => { + running = false; + abortController.abort(); + log('WatchManager shutting down...'); + }; + + if (options.signal) { + options.signal.addEventListener('abort', handleShutdown, { once: true }); + } + process.on('SIGINT', handleShutdown); + process.on('SIGTERM', handleShutdown); + + // Start all watch jobs concurrently + const jobs = config.watches.map(watchConfig => { + const client = clients.get(watchConfig.account); + return runWatchJob(watchConfig, client, { + signal: abortController.signal, + log, + isRunning: () => running, + }); + }); + + try { + await Promise.allSettled(jobs); + } finally { + process.removeListener('SIGINT', handleShutdown); + process.removeListener('SIGTERM', handleShutdown); + } + + log('WatchManager stopped.'); +} + +/** + * Run a single watch job loop. + * + * Each job independently follows its schedule, runs delta syncs, evaluates + * rules, and executes matched actions. + * + * @param {object} watchConfig - Validated watch config entry + * @param {object} client - GraphClient for this job's account + * @param {object} opts - Runtime options + * @param {AbortSignal} opts.signal - Abort signal + * @param {Function} opts.log - Logger + * @param {Function} opts.isRunning - Returns false when manager is shutting down + */ +async function runWatchJob(watchConfig, client, opts) { + const { log, isRunning } = opts; + const jobId = watchConfig.id; + const schedule = parseSchedule(watchConfig.schedule); + const store = createDeltaStore(`${watchConfig.account}-${jobId}`); + + log(`[${jobId}] Starting — ${watchConfig.resource} on ${watchConfig.account}, schedule: ${watchConfig.schedule}`); + + // Initial sync (silent — establish baseline) + if (client) { + try { + const deltaUrl = buildDeltaUrl(client, watchConfig); + const resourceKey = buildResourceKey(watchConfig); + await executeDeltaQuery(client, resourceKey, deltaUrl, store); + log(`[${jobId}] Initial sync complete`); + } catch (err) { + log(`[${jobId}] Initial sync failed: ${err.message}`); + } + } + + // Watch loop + while (isRunning()) { + const delay = schedule.nextRunIn(); + await interruptibleSleep(delay, () => !isRunning()); + + if (!isRunning()) break; + + try { + await runJobCycle(watchConfig, client, store, opts); + } catch (err) { + log(`[${jobId}] Sync error: ${err.message}`); + } + } + + log(`[${jobId}] Stopped`); +} + +/** + * Execute one cycle of a watch job: delta sync → evaluate rules → execute actions. + * + * @param {object} watchConfig - Watch config entry + * @param {object} client - GraphClient + * @param {object} store - DeltaStore for this job + * @param {object} opts - Runtime options + */ +async function runJobCycle(watchConfig, client, store, opts) { + const { log } = opts; + const jobId = watchConfig.id; + + if (!client) { + log(`[${jobId}] No client available — skipping cycle`); + return; + } + + const deltaUrl = buildDeltaUrl(client, watchConfig); + const resourceKey = buildResourceKey(watchConfig); + + const result = await executeDeltaQuery(client, resourceKey, deltaUrl, store); + + if (result.isInitialSync) { + log(`[${jobId}] Baseline: ${result.changed.length} items`); + return; + } + + if (result.changed.length === 0 && result.removed.length === 0) { + return; // No changes + } + + log(`[${jobId}] ${result.changed.length} changed, ${result.removed.length} removed`); + + // Evaluate rules against each changed item + for (const item of result.changed) { + const matchedRules = evaluateRules(item, watchConfig.resource, watchConfig.rules); + + for (const rule of matchedRules) { + const eventData = watchConfig.resource === 'mail' + ? normalizeMailEventData(item) + : normalizeCalendarEventData(item); + + log(`[${jobId}] Rule "${rule.id}" matched`); + + await executeActions(rule.actions, eventData, { + client, + resourceType: watchConfig.resource, + log, + }); + } + } +} + +/** + * Build the appropriate delta URL for a watch config entry. + * + * @param {object} client - GraphClient + * @param {object} watchConfig - Watch config entry + * @returns {string} Delta URL + */ +function buildDeltaUrl(client, watchConfig) { + if (watchConfig.resource === 'mail') { + return buildMailDeltaUrl(client, watchConfig.folder || 'Inbox'); + } + return buildCalendarDeltaUrl(client); +} + +/** + * Build a unique resource key for delta token storage. + * + * @param {object} watchConfig - Watch config entry + * @returns {string} Resource key + */ +function buildResourceKey(watchConfig) { + if (watchConfig.resource === 'mail') { + return `mail:${watchConfig.folder || 'Inbox'}`; + } + return 'calendar:rolling30d'; +} + +/** + * Interruptible sleep — checks abort condition every second. + * + * @param {number} ms - Total sleep duration in milliseconds + * @param {Function} shouldAbort - Returns true to cancel sleep early + * @returns {Promise} + */ +function interruptibleSleep(ms, shouldAbort) { + return new Promise(resolve => { + let elapsed = 0; + const step = 1000; + + const tick = () => { + if (shouldAbort() || elapsed >= ms) { + resolve(); + return; + } + elapsed += step; + setTimeout(tick, step); + }; + + setTimeout(tick, Math.min(step, ms)); + }); +} diff --git a/src/node/watch/rules.js b/src/node/watch/rules.js new file mode 100644 index 0000000..f853e8a --- /dev/null +++ b/src/node/watch/rules.js @@ -0,0 +1,127 @@ +/** + * Rule evaluation engine for the watch system. + * + * Evaluates a set of rules against incoming mail or calendar change events. + * Each rule has conditions (all must match — AND logic) and actions to execute + * when matched. Empty conditions always match (catch-all rule). + * + * MAIL CONDITIONS (all optional, case-insensitive matching): + * from — exact email address match + * from_contains — substring match on sender address + * subject_contains — substring match on subject line + * has_attachments — boolean (true/false) + * is_read — boolean + * importance — "low" | "normal" | "high" + * + * CALENDAR CONDITIONS: + * subject_contains — substring match + * is_cancelled — boolean + * starts_within_minutes — event starts within N minutes from now + */ + +/** + * Evaluate all rules against a change event and return matched rules. + * + * @param {object} event - The Graph API event/message object + * @param {string} resourceType - "mail" or "calendar" + * @param {Array<{ id: string, conditions: object, actions: Array }>} rules - Rules to evaluate + * @returns {Array<{ id: string, conditions: object, actions: Array }>} Matched rules + */ +export function evaluateRules(event, resourceType, rules) { + if (!Array.isArray(rules) || rules.length === 0) { + return []; + } + + return rules.filter(rule => matchesConditions(event, resourceType, rule.conditions)); +} + +/** + * Check whether an event matches all conditions in a rule. + * + * All conditions must match (AND logic). Empty conditions = always match. + * + * @param {object} event - The Graph API event/message object + * @param {string} resourceType - "mail" or "calendar" + * @param {object} conditions - Condition key-value pairs + * @returns {boolean} True if all conditions match + */ +export function matchesConditions(event, resourceType, conditions) { + if (!conditions || typeof conditions !== 'object') return true; + + const keys = Object.keys(conditions); + if (keys.length === 0) return true; + + for (const key of keys) { + const expected = conditions[key]; + + if (resourceType === 'mail') { + if (!matchMailCondition(event, key, expected)) return false; + } else if (resourceType === 'calendar') { + if (!matchCalendarCondition(event, key, expected)) return false; + } + } + + return true; +} + +/** + * Match a single mail condition against an event. + * + * @param {object} event - Mail message object from Graph API + * @param {string} key - Condition key + * @param {*} expected - Expected value + * @returns {boolean} Whether this condition matches + */ +function matchMailCondition(event, key, expected) { + switch (key) { + case 'from': { + const fromAddr = event.from?.emailAddress?.address || ''; + return fromAddr.toLowerCase() === String(expected).toLowerCase(); + } + case 'from_contains': { + const fromAddr = event.from?.emailAddress?.address || ''; + return fromAddr.toLowerCase().includes(String(expected).toLowerCase()); + } + case 'subject_contains': { + const subject = event.subject || ''; + return subject.toLowerCase().includes(String(expected).toLowerCase()); + } + case 'has_attachments': + return event.hasAttachments === expected; + case 'is_read': + return event.isRead === expected; + case 'importance': + return (event.importance || '').toLowerCase() === String(expected).toLowerCase(); + default: + // Unknown condition — ignore (don't fail) + return true; + } +} + +/** + * Match a single calendar condition against an event. + * + * @param {object} event - Calendar event object from Graph API + * @param {string} key - Condition key + * @param {*} expected - Expected value + * @returns {boolean} Whether this condition matches + */ +function matchCalendarCondition(event, key, expected) { + switch (key) { + case 'subject_contains': { + const subject = event.subject || ''; + return subject.toLowerCase().includes(String(expected).toLowerCase()); + } + case 'is_cancelled': + return event.isCancelled === expected; + case 'starts_within_minutes': { + if (!event.start?.dateTime) return false; + const startTime = new Date(event.start.dateTime).getTime(); + const now = Date.now(); + const diffMinutes = (startTime - now) / 60000; + return diffMinutes >= 0 && diffMinutes <= expected; + } + default: + return true; + } +} diff --git a/src/node/watch/schedule.js b/src/node/watch/schedule.js new file mode 100644 index 0000000..8a7d009 --- /dev/null +++ b/src/node/watch/schedule.js @@ -0,0 +1,268 @@ +/** + * Schedule parser for watch config. + * + * Parses schedule strings into schedulers that compute next-run delays. + * Three formats are supported: + * + * Duration: "30s", "5m", "1h", "2h30m" → fixed interval polling + * Cron: "cron: *​/5 * * * *" → cron-based scheduling + * Time: "at: 6:00,12:00,18:00" → specific daily times + * + * Minimum interval for duration-based schedules is 30 seconds (30000ms) + * to respect Microsoft Graph API rate limits. + */ + +const MIN_INTERVAL_MS = 30000; + +/** + * Parse a schedule string into a scheduler object. + * + * @param {string} scheduleStr - Schedule string in one of the supported formats + * @returns {{ type: 'interval'|'cron'|'time', nextRunIn: () => number }} + * Scheduler with a nextRunIn() method that returns milliseconds until next run + * @throws {Error} If the schedule string is invalid or below minimum interval + */ +export function parseSchedule(scheduleStr) { + if (typeof scheduleStr !== 'string' || !scheduleStr.trim()) { + throw new Error('Schedule string is required'); + } + + const str = scheduleStr.trim(); + + if (str.startsWith('cron:')) { + return parseCronSchedule(str.slice(5).trim()); + } + + if (str.startsWith('at:')) { + return parseTimeSchedule(str.slice(3).trim()); + } + + return parseDurationSchedule(str); +} + +/** + * Parse a duration string like "30s", "5m", "1h", "2h30m" into a fixed-interval scheduler. + * + * @param {string} str - Duration string + * @returns {{ type: 'interval', intervalMs: number, nextRunIn: () => number }} + */ +function parseDurationSchedule(str) { + const ms = parseDurationToMs(str); + + if (ms < MIN_INTERVAL_MS) { + throw new Error(`Minimum interval is 30 seconds, got ${str}`); + } + + return { + type: 'interval', + intervalMs: ms, + nextRunIn() { + return ms; + }, + }; +} + +/** + * Parse a duration string to milliseconds. + * + * Supports: "30s", "5m", "1h", "2h30m", "90s" + * + * @param {string} str - Duration string + * @returns {number} Duration in milliseconds + */ +export function parseDurationToMs(str) { + const pattern = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/; + const match = str.match(pattern); + + if (!match || (!match[1] && !match[2] && !match[3])) { + throw new Error(`Invalid duration format: "${str}". Expected formats: 30s, 5m, 1h, 2h30m`); + } + + const hours = parseInt(match[1] || '0', 10); + const minutes = parseInt(match[2] || '0', 10); + const seconds = parseInt(match[3] || '0', 10); + + return (hours * 3600 + minutes * 60 + seconds) * 1000; +} + +/** + * Parse a cron expression into a cron-based scheduler. + * + * Supports basic cron with 5 fields: minute hour day-of-month month day-of-week + * Field values: *, number, star-slash-N (step), N-M (range) + * + * @param {string} cronExpr - Cron expression (5 fields) + * @returns {{ type: 'cron', nextRunIn: () => number }} + */ +function parseCronSchedule(cronExpr) { + const fields = cronExpr.split(/\s+/); + if (fields.length !== 5) { + throw new Error(`Invalid cron expression: "${cronExpr}". Expected 5 fields (minute hour dom month dow)`); + } + + const parsed = { + minutes: parseCronField(fields[0], 0, 59), + hours: parseCronField(fields[1], 0, 23), + daysOfMonth: parseCronField(fields[2], 1, 31), + months: parseCronField(fields[3], 1, 12), + daysOfWeek: parseCronField(fields[4], 0, 6), + }; + + return { + type: 'cron', + nextRunIn() { + return computeNextCronRun(parsed); + }, + }; +} + +/** + * Parse a single cron field into an array of valid values. + * + * @param {string} field - Cron field value (*, N, N-M, *​/N) + * @param {number} min - Minimum value for this field + * @param {number} max - Maximum value for this field + * @returns {number[]} Array of valid values + */ +export function parseCronField(field, min, max) { + // Wildcard: all values + if (field === '*') { + return allValues(min, max); + } + + // Step: */N + if (field.startsWith('*/')) { + const step = parseInt(field.slice(2), 10); + if (isNaN(step) || step <= 0) { + throw new Error(`Invalid cron step: "${field}"`); + } + const values = []; + for (let i = min; i <= max; i += step) { + values.push(i); + } + return values; + } + + // Range: N-M + if (field.includes('-')) { + const [startStr, endStr] = field.split('-'); + const start = parseInt(startStr, 10); + const end = parseInt(endStr, 10); + if (isNaN(start) || isNaN(end) || start < min || end > max || start > end) { + throw new Error(`Invalid cron range: "${field}"`); + } + return allValues(start, end); + } + + // Exact value + const val = parseInt(field, 10); + if (isNaN(val) || val < min || val > max) { + throw new Error(`Invalid cron value: "${field}" (expected ${min}-${max})`); + } + return [val]; +} + +/** + * Generate an array of all integers from min to max inclusive. + */ +function allValues(min, max) { + const result = []; + for (let i = min; i <= max; i++) result.push(i); + return result; +} + +/** + * Compute milliseconds until the next cron match. + * + * Brute-force approach: walk forward minute by minute from now until + * we find a match. Capped at 48 hours to avoid infinite loops. + * + * @param {object} parsed - Parsed cron fields + * @returns {number} Milliseconds until next run + */ +function computeNextCronRun(parsed) { + const now = new Date(); + // Start from the next minute boundary + const candidate = new Date(now); + candidate.setSeconds(0, 0); + candidate.setMinutes(candidate.getMinutes() + 1); + + const maxIterations = 48 * 60; // 48 hours of minutes + + for (let i = 0; i < maxIterations; i++) { + if ( + parsed.minutes.includes(candidate.getMinutes()) && + parsed.hours.includes(candidate.getHours()) && + parsed.daysOfMonth.includes(candidate.getDate()) && + parsed.months.includes(candidate.getMonth() + 1) && + parsed.daysOfWeek.includes(candidate.getDay()) + ) { + return candidate.getTime() - now.getTime(); + } + candidate.setMinutes(candidate.getMinutes() + 1); + } + + // Fallback: run in 1 hour if no match found in 48h window + return 3600000; +} + +/** + * Parse a time-of-day schedule like "6:00,12:00,18:00". + * + * @param {string} timeStr - Comma-separated HH:MM times + * @returns {{ type: 'time', nextRunIn: () => number }} + */ +function parseTimeSchedule(timeStr) { + const times = timeStr.split(',').map(t => { + const trimmed = t.trim(); + const match = trimmed.match(/^(\d{1,2}):(\d{2})$/); + if (!match) { + throw new Error(`Invalid time format: "${trimmed}". Expected HH:MM`); + } + const hours = parseInt(match[1], 10); + const minutes = parseInt(match[2], 10); + if (hours > 23 || minutes > 59) { + throw new Error(`Invalid time: "${trimmed}"`); + } + return { hours, minutes }; + }); + + if (times.length === 0) { + throw new Error('At least one time is required for "at:" schedule'); + } + + return { + type: 'time', + nextRunIn() { + return computeNextTimeRun(times); + }, + }; +} + +/** + * Compute milliseconds until the next time-of-day match. + * + * @param {{ hours: number, minutes: number }[]} times - Target times + * @returns {number} Milliseconds until next run + */ +function computeNextTimeRun(times) { + const now = new Date(); + let minDelay = Infinity; + + for (const { hours, minutes } of times) { + const target = new Date(now); + target.setHours(hours, minutes, 0, 0); + + // If this time already passed today, schedule for tomorrow + if (target.getTime() <= now.getTime()) { + target.setDate(target.getDate() + 1); + } + + const delay = target.getTime() - now.getTime(); + if (delay < minDelay) { + minDelay = delay; + } + } + + return minDelay; +} diff --git a/src/node/watch/subscription.js b/src/node/watch/subscription.js new file mode 100644 index 0000000..115b762 --- /dev/null +++ b/src/node/watch/subscription.js @@ -0,0 +1,160 @@ +/** + * Microsoft Graph subscription manager for webhook notifications. + * + * Creates, renews, and deletes Graph API subscriptions that push change + * notifications to our webhook URL. Handles: + * + * - Creating subscriptions for mail and/or calendar resources + * - Auto-renewing before expiry (personal accounts: max 3 days) + * - Deleting subscriptions on shutdown (prevents orphaned subscriptions) + * + * SUBSCRIPTION LIFECYCLE: + * 1. POST /v1.0/subscriptions → creates subscription (Graph validates webhook URL) + * 2. Graph POSTs notifications to our webhook URL when resources change + * 3. PATCH /v1.0/subscriptions/{id} → extend expirationDateTime before expiry + * 4. DELETE /v1.0/subscriptions/{id} → clean up on shutdown + * + * PERSONAL VS WORK ACCOUNTS: + * Both support mail subscriptions. Max subscription lifetime is ~3 days + * for mail on both account types (4230 minutes). + */ + +// Max subscription duration: 4230 minutes (~2.94 days) for mail messages +const MAX_SUBSCRIPTION_MINUTES = 4230; +// Renew when 2 hours remain +const RENEWAL_BUFFER_MINUTES = 120; + +/** + * Create a Microsoft Graph subscription for a resource. + * + * @param {object} client - GraphClient with authenticated request() + * @param {object} options + * @param {string} options.resource - Graph resource path (e.g., "me/mailFolders('inbox')/messages") + * @param {string} options.changeType - Comma-separated change types: "created", "updated", "deleted" + * @param {string} options.notificationUrl - Public webhook URL + * @param {string} options.clientState - Secret for notification validation + * @returns {object} Subscription object from Graph API + */ +export async function createSubscription(client, options) { + const { resource, changeType, notificationUrl, clientState } = options; + + const expirationDateTime = getExpirationTime(); + + const body = { + changeType, + notificationUrl, + resource, + expirationDateTime, + clientState, + }; + + const response = await client.request('POST', '/subscriptions', body); + return response; +} + +/** + * Renew a subscription by extending its expiration time. + * + * @param {object} client - GraphClient + * @param {string} subscriptionId - ID of the subscription to renew + * @returns {object} Updated subscription object + */ +export async function renewSubscription(client, subscriptionId) { + const expirationDateTime = getExpirationTime(); + + const response = await client.request( + 'PATCH', + `/subscriptions/${subscriptionId}`, + { expirationDateTime }, + ); + return response; +} + +/** + * Delete a subscription. + * + * @param {object} client - GraphClient + * @param {string} subscriptionId - ID of the subscription to delete + */ +export async function deleteSubscription(client, subscriptionId) { + try { + await client.request('DELETE', `/subscriptions/${subscriptionId}`); + } catch { + // Best-effort: subscription may already be expired or deleted + } +} + +/** + * Start an auto-renewal timer for a subscription. + * Renews 2 hours before expiration to prevent gaps in notification delivery. + * + * @param {object} client - GraphClient + * @param {object} subscription - Subscription object from createSubscription + * @param {function} onError - Called if renewal fails + * @returns {{ stop }} Function to stop the renewal timer + */ +export function startRenewalTimer(client, subscription, onError) { + let timerId; + let currentSubscription = subscription; + let stopped = false; + + const scheduleRenewal = () => { + if (stopped) return; + + const expiry = new Date(currentSubscription.expirationDateTime); + const renewAt = new Date(expiry.getTime() - RENEWAL_BUFFER_MINUTES * 60 * 1000); + const delay = Math.max(0, renewAt.getTime() - Date.now()); + + timerId = setTimeout(async () => { + if (stopped) return; + try { + currentSubscription = await renewSubscription(client, currentSubscription.id); + scheduleRenewal(); // Schedule next renewal + } catch (err) { + if (onError) onError(err); + } + }, delay); + + // Don't block Node.js from exiting if this is the only pending timer + if (timerId.unref) timerId.unref(); + }; + + scheduleRenewal(); + + return { + stop: () => { + stopped = true; + if (timerId) clearTimeout(timerId); + }, + getSubscription: () => currentSubscription, + }; +} + +/** + * Build the Graph resource path for mail in a specific folder. + * + * @param {object} client - GraphClient (for userPath) + * @param {string} [folder='Inbox'] - Mail folder name + * @returns {string} Resource path for subscription + */ +export function buildMailResource(client, folder = 'Inbox') { + const userPath = client.userPath || 'me'; + return `${userPath}/mailFolders('${folder}')/messages`; +} + +/** + * Build the Graph resource path for calendar events. + * + * @param {object} client - GraphClient (for userPath) + * @returns {string} Resource path for subscription + */ +export function buildCalendarResource(client) { + const userPath = client.userPath || 'me'; + return `${userPath}/events`; +} + +function getExpirationTime() { + const expiry = new Date(); + expiry.setMinutes(expiry.getMinutes() + MAX_SUBSCRIPTION_MINUTES); + return expiry.toISOString(); +} diff --git a/src/node/watch/tunnel.js b/src/node/watch/tunnel.js new file mode 100644 index 0000000..da2aaeb --- /dev/null +++ b/src/node/watch/tunnel.js @@ -0,0 +1,188 @@ +/** + * Tunnel manager — exposes a local port to the internet for webhook delivery. + * + * Microsoft Graph webhooks require a publicly-reachable HTTPS URL. This module + * abstracts over multiple tunnel providers to expose the local webhook server: + * + * - cloudflared (default): Cloudflare's free tunnel. No signup needed for + * quick tunnels. Provides *.trycloudflare.com URLs. + * + * - localtunnel: NPM package, zero config. `npx localtunnel` under the hood. + * Provides *.loca.lt URLs. + * + * - ngrok: Popular tunneling tool. Requires installation and optional signup + * for longer sessions. + * + * The tunnel is started as a child process. The public URL is parsed from stdout. + * On shutdown, the child process is killed. + */ + +import { spawn } from 'node:child_process'; + +/** + * Start a tunnel to expose a local port. + * + * @param {object} options + * @param {number} options.port - Local port to expose + * @param {string} [options.type='cloudflared'] - Tunnel type: cloudflared, localtunnel, ngrok + * @returns {{ url, close }} Public URL and shutdown function + */ +export async function startTunnel(options) { + const { port, type = 'cloudflared' } = options; + + switch (type) { + case 'cloudflared': + return startCloudflared(port); + case 'localtunnel': + return startLocaltunnel(port); + case 'ngrok': + return startNgrok(port); + default: + throw new Error(`Unknown tunnel type: "${type}". Use: cloudflared, localtunnel, ngrok`); + } +} + +/** + * Start a cloudflared quick tunnel. + * Requires `cloudflared` to be installed and on PATH. + * Quick tunnels don't require authentication — they provide a random + * *.trycloudflare.com subdomain. + */ +async function startCloudflared(port) { + const child = spawn('cloudflared', ['tunnel', '--url', `http://127.0.0.1:${port}`], { + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + + const url = await waitForUrl(child, /https:\/\/[^\s]+\.trycloudflare\.com/, 'cloudflared'); + + return { + url, + close: () => killProcess(child), + }; +} + +/** + * Start a localtunnel tunnel. + * Uses npx to run localtunnel — no global install needed. + */ +async function startLocaltunnel(port) { + const child = spawn('npx', ['localtunnel', '--port', String(port)], { + stdio: ['ignore', 'pipe', 'pipe'], + shell: true, + windowsHide: true, + }); + + const url = await waitForUrl(child, /https:\/\/[^\s]+\.loca\.lt/, 'localtunnel'); + + return { + url, + close: () => killProcess(child), + }; +} + +/** + * Start an ngrok tunnel. + * Requires `ngrok` to be installed and on PATH. + */ +async function startNgrok(port) { + const child = spawn('ngrok', ['http', String(port), '--log', 'stdout'], { + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + + const url = await waitForUrl(child, /https:\/\/[^\s]+\.ngrok[^\s]*/, 'ngrok'); + + return { + url, + close: () => killProcess(child), + }; +} + +/** + * Wait for the tunnel process to print a public URL. + * Times out after 30 seconds if no URL is found. + */ +function waitForUrl(child, urlPattern, tunnelName) { + return new Promise((resolve, reject) => { + let output = ''; + const timeout = setTimeout(() => { + killProcess(child); + reject(new Error( + `Timed out waiting for ${tunnelName} to start.\n` + + `Make sure '${tunnelName}' is installed and available on PATH.\n` + + `Output so far: ${output.substring(0, 500)}` + )); + }, 30000); + + const onData = (data) => { + output += data.toString(); + const match = output.match(urlPattern); + if (match) { + clearTimeout(timeout); + child.stdout.removeListener('data', onData); + child.stderr.removeListener('data', onData); + resolve(match[0]); + } + }; + + child.stdout.on('data', onData); + child.stderr.on('data', onData); + + child.on('error', (err) => { + clearTimeout(timeout); + if (err.code === 'ENOENT') { + reject(new Error( + `'${tunnelName}' is not installed or not on PATH.\n` + + tunnelInstallHelp(tunnelName) + )); + } else { + reject(new Error(`Failed to start ${tunnelName}: ${err.message}`)); + } + }); + + child.on('exit', (code) => { + clearTimeout(timeout); + if (code !== null && code !== 0) { + reject(new Error( + `${tunnelName} exited with code ${code}.\n` + + `Output: ${output.substring(0, 500)}` + )); + } + }); + }); +} + +function killProcess(child) { + try { + if (!child.killed) { + child.kill('SIGTERM'); + // Force kill after 5 seconds if it doesn't exit + setTimeout(() => { + if (!child.killed) child.kill('SIGKILL'); + }, 5000).unref(); + } + } catch { + // Process may already be dead + } +} + +function tunnelInstallHelp(name) { + switch (name) { + case 'cloudflared': + return 'Install cloudflared:\n' + + ' Windows: winget install --id Cloudflare.cloudflared\n' + + ' macOS: brew install cloudflare/cloudflare/cloudflared\n' + + ' Linux: https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/installation/'; + case 'ngrok': + return 'Install ngrok:\n' + + ' Windows: winget install --id Ngrok.Ngrok\n' + + ' macOS: brew install ngrok\n' + + ' Linux: https://ngrok.com/download'; + case 'localtunnel': + return 'localtunnel is installed automatically via npx.\n' + + 'Make sure Node.js and npm/npx are available on PATH.'; + default: + return ''; + } +} diff --git a/src/node/watch/watcher.js b/src/node/watch/watcher.js new file mode 100644 index 0000000..ea0fb6d --- /dev/null +++ b/src/node/watch/watcher.js @@ -0,0 +1,411 @@ +/** + * Watch orchestrator — runs continuous delta sync loops for mail and calendar. + * + * ARCHITECTURE: + * The watcher is a long-running foreground process that periodically calls + * Microsoft Graph delta queries and emits events when changes are detected. + * This is the CLI equivalent of "background sync" in mobile Outlook. + * + * ┌─────────────────────────────────────────────────────┐ + * │ Watcher (this module) │ + * │ │ + * │ ┌─────────────┐ ┌──────────────┐ │ + * │ │ Mail Loop │ │ Calendar Loop│ │ + * │ │ (delta sync) │ │ (delta sync) │ │ + * │ │ every N sec │ │ every N sec │ │ + * │ └──────┬───────┘ └──────┬───────┘ │ + * │ │ │ │ + * │ ▼ ▼ │ + * │ ┌──────────────────────────────────┐ │ + * │ │ Event Emitter │ │ + * │ │ → stdout (text or JSONL) │ │ + * │ │ → file (--output) │ │ + * │ └──────────────────────────────────┘ │ + * └─────────────────────────────────────────────────────┘ + * + * MODES: + * - poll (default): Delta queries at 30–60s intervals. Efficient for general use. + * - fast-poll: Delta queries at 5–10s with adaptive backoff. When changes are + * detected, polling speeds up; during quiet periods, interval backs off. + * - webhook: See webhook-watcher.js for push-based notifications. + * + * WHY DELTA QUERIES: + * Microsoft Graph webhooks require a publicly-reachable HTTPS endpoint for + * push notifications. Delta queries are the official "pull-based" alternative: + * each call returns only what changed since the last sync, making even frequent + * polling efficient — typically returning empty responses. + * + * OUTPUT MODES: + * - text (default): Human-readable event notifications with timestamps + * - jsonl: One JSON object per line per event — designed for piping to agents. + * Each line is a self-contained event object that an AI agent can parse. + * + * DELTA TOKEN PERSISTENCE: + * Delta tokens are stored in ~/.outlook-cli/delta-{alias}.json. This means: + * - Stopping and restarting the watcher picks up where it left off + * - Only truly new items since the last run are reported + * - If the token expires (~30 days), automatic fallback to full re-sync + * + * GRACEFUL SHUTDOWN: + * The watcher listens for SIGINT (Ctrl+C) and SIGTERM, completes any + * in-progress delta query, then exits cleanly. This prevents partial + * delta token writes. + */ + +import { + executeDeltaQuery, + buildMailDeltaUrl, + buildCalendarDeltaUrl, + createDeltaStore, +} from '../graph/delta.js'; + +/** + * Event types emitted by the watcher. + * These map to the JSON event objects written to stdout in JSONL mode. + */ +export const EVENT_TYPES = { + MAIL_CHANGED: 'mail.changed', // New or modified message + MAIL_REMOVED: 'mail.removed', // Deleted message + CALENDAR_CHANGED: 'calendar.changed', // New or modified event + CALENDAR_REMOVED: 'calendar.removed', // Deleted event + SYNC_STATUS: 'sync.status', // Informational (initial sync complete, error, etc.) + HEARTBEAT: 'heartbeat', // Periodic liveness signal +}; + +/** + * Start the watch loop. + * + * This is the main entry point called by the CLI `watch` command. + * It runs until the process is killed or an unrecoverable error occurs. + * + * @param {object} client - Authenticated GraphClient from graph/client.js + * @param {string} accountAlias - Account alias for delta token storage + * @param {object} options - Watch configuration + * @param {string} [options.mode] - Watch mode: "poll" (default) or "fast-poll" + * @param {number} options.interval - Seconds between polls (default: 60, min: 30 for poll, 5 for fast-poll) + * @param {boolean} options.mail - Watch mail changes (default: true) + * @param {boolean} options.calendar - Watch calendar changes (default: true) + * @param {string} options.folder - Mail folder to watch (default: "Inbox") + * @param {string} options.format - Output format: "text" or "jsonl" + * @param {boolean} options.heartbeat - Emit periodic heartbeat events (default: false) + * @param {number} options.heartbeatInterval - Heartbeat interval in seconds (default: 300) + * @param {AbortSignal} [options.signal] - AbortSignal for graceful shutdown + */ +export async function startWatcher(client, accountAlias, options = {}) { + const mode = options.mode || 'poll'; + const baseInterval = options.interval || 60; // CLI layer enforces minimum + const watchMail = options.mail !== false; // Default: watch mail + const watchCalendar = options.calendar !== false; // Default: watch calendar + const folder = options.folder || 'Inbox'; + const format = options.format || 'text'; + const heartbeat = options.heartbeat || false; + const heartbeatInterval = Math.max(60, options.heartbeatInterval || 300); + const isFastPoll = mode === 'fast-poll'; + + // Adaptive polling state (fast-poll mode only) + // After detecting changes, drop to minimum interval. + // When no changes are found, gradually increase toward baseInterval. + const FAST_POLL_MIN = 5; + const BACKOFF_MULTIPLIER = 1.5; + let currentInterval = isFastPoll ? FAST_POLL_MIN : baseInterval; + + const store = createDeltaStore(accountAlias); + let running = true; + let syncCount = 0; + + // Handle graceful shutdown via AbortSignal or process signals + const handleShutdown = () => { + running = false; + emitEvent(format, EVENT_TYPES.SYNC_STATUS, { + message: 'Watcher shutting down...', + syncCount, + }); + }; + + if (options.signal) { + options.signal.addEventListener('abort', handleShutdown, { once: true }); + } + process.on('SIGINT', handleShutdown); + process.on('SIGTERM', handleShutdown); + + // Emit startup banner + emitEvent(format, EVENT_TYPES.SYNC_STATUS, { + message: 'Watch started', + account: accountAlias, + mode, + watching: { + mail: watchMail ? folder : false, + calendar: watchCalendar, + }, + interval: isFastPoll ? `${FAST_POLL_MIN}–${baseInterval}s (adaptive)` : `${baseInterval}s`, + }); + + // ── Initial sync ────────────────────────────────────────────────────── + // First delta call returns ALL existing items. We don't emit events for + // these (it would flood the output with hundreds of "new" messages). + // Instead, we do a silent initial sync to establish the baseline. + + if (watchMail) { + await doMailSync(client, store, folder, format, /* suppressEvents: */ true); + } + if (watchCalendar) { + await doCalendarSync(client, store, format, /* suppressEvents: */ true); + } + + emitEvent(format, EVENT_TYPES.SYNC_STATUS, { + message: 'Initial sync complete. Watching for changes...', + }); + + // ── Watch loop ──────────────────────────────────────────────────────── + // On each iteration: + // 1. Wait for the configured interval + // 2. Run delta queries for mail and/or calendar + // 3. Emit events for any changes detected + // 4. Repeat until shutdown + + let lastHeartbeat = Date.now(); + + while (running) { + // Wait for the current poll interval (or until shutdown signal) + await interruptibleSleep(currentInterval * 1000, () => !running); + + if (!running) break; + + syncCount++; + let changesDetected = false; + + try { + if (watchMail) { + const mailChanges = await doMailSync(client, store, folder, format, false); + if (mailChanges > 0) changesDetected = true; + } + } catch (err) { + emitEvent(format, EVENT_TYPES.SYNC_STATUS, { + message: `Mail sync error: ${err.message}`, + error: true, + }); + } + + try { + if (watchCalendar) { + const calChanges = await doCalendarSync(client, store, format, false); + if (calChanges > 0) changesDetected = true; + } + } catch (err) { + emitEvent(format, EVENT_TYPES.SYNC_STATUS, { + message: `Calendar sync error: ${err.message}`, + error: true, + }); + } + + // Adaptive polling (fast-poll mode): speed up on activity, back off when quiet + if (isFastPoll) { + if (changesDetected) { + currentInterval = FAST_POLL_MIN; + } else { + currentInterval = Math.min( + baseInterval, + Math.round(currentInterval * BACKOFF_MULTIPLIER), + ); + } + } + + // Heartbeat: periodic liveness signal so agents know the watcher is alive + if (heartbeat && (Date.now() - lastHeartbeat) >= heartbeatInterval * 1000) { + emitEvent(format, EVENT_TYPES.HEARTBEAT, { + syncCount, + timestamp: new Date().toISOString(), + }); + lastHeartbeat = Date.now(); + } + } + + // Clean up signal handlers + process.removeListener('SIGINT', handleShutdown); + process.removeListener('SIGTERM', handleShutdown); +} + +/** + * Run a mail delta sync and emit events for changes. + * + * @param {object} client - GraphClient + * @param {object} store - DeltaStore + * @param {string} folder - Mail folder name + * @param {string} format - Output format (text/jsonl) + * @param {boolean} suppressEvents - If true, don't emit events (initial sync) + * @returns {number} Number of changes detected + */ +async function doMailSync(client, store, folder, format, suppressEvents) { + const resourceKey = `mail:${folder}`; + const deltaUrl = buildMailDeltaUrl(client, folder); + + const result = await executeDeltaQuery(client, resourceKey, deltaUrl, store); + + // On initial sync, we just establish the baseline — no events emitted. + if (suppressEvents || result.isInitialSync) { + emitEvent(format, EVENT_TYPES.SYNC_STATUS, { + message: `Mail baseline: ${result.changed.length} messages in ${folder}`, + }); + return 0; + } + + // Emit events for each changed (new/modified) message + for (const msg of result.changed) { + emitEvent(format, EVENT_TYPES.MAIL_CHANGED, { + id: msg.id, + subject: msg.subject, + from: msg.from?.emailAddress?.address || 'unknown', + receivedDateTime: msg.receivedDateTime, + isRead: msg.isRead, + bodyPreview: msg.bodyPreview?.substring(0, 200), + importance: msg.importance, + hasAttachments: msg.hasAttachments, + }); + } + + // Emit events for each removed message + for (const msg of result.removed) { + emitEvent(format, EVENT_TYPES.MAIL_REMOVED, { + id: msg.id, + reason: msg.reason, + }); + } + + return result.changed.length + result.removed.length; +} + +/** + * Run a calendar delta sync and emit events for changes. + * + * Calendar delta watches a rolling 30-day window from "now". + * The window is set on the initial sync and baked into the delta token. + * + * @returns {number} Number of changes detected + */ +async function doCalendarSync(client, store, format, suppressEvents) { + const resourceKey = 'calendar:rolling30d'; + const deltaUrl = buildCalendarDeltaUrl(client); + + const result = await executeDeltaQuery(client, resourceKey, deltaUrl, store); + + if (suppressEvents || result.isInitialSync) { + emitEvent(format, EVENT_TYPES.SYNC_STATUS, { + message: `Calendar baseline: ${result.changed.length} events in next 30 days`, + }); + return 0; + } + + for (const evt of result.changed) { + emitEvent(format, EVENT_TYPES.CALENDAR_CHANGED, { + id: evt.id, + subject: evt.subject, + start: evt.start, + end: evt.end, + location: evt.location?.displayName, + organizer: evt.organizer?.emailAddress?.address, + isAllDay: evt.isAllDay, + isCancelled: evt.isCancelled, + }); + } + + for (const evt of result.removed) { + emitEvent(format, EVENT_TYPES.CALENDAR_REMOVED, { + id: evt.id, + reason: evt.reason, + }); + } + + return result.changed.length + result.removed.length; +} + +/** + * Emit an event to stdout. + * + * FORMAT: "text" — human-readable, colored output: + * [14:32:05] 📧 New mail from alice@example.com: "Meeting notes" + * + * FORMAT: "jsonl" — one JSON object per line, designed for piping: + * {"type":"mail.changed","timestamp":"2024-...","data":{...}} + * + * Each JSONL line is a self-contained event. An AI agent reads lines from + * stdout, parses each as JSON, and reacts accordingly. + */ +export function emitEvent(format, type, data) { + const timestamp = new Date().toISOString(); + + if (format === 'jsonl') { + // JSONL: one valid JSON object per line, no extra whitespace + const event = { type, timestamp, data }; + process.stdout.write(JSON.stringify(event) + '\n'); + return; + } + + // Text format: human-readable with icons and timestamps + const time = new Date().toLocaleTimeString(); + + switch (type) { + case EVENT_TYPES.MAIL_CHANGED: + console.log(`[${time}] 📧 Mail from ${data.from}: "${data.subject}"${data.isRead ? '' : ' (unread)'}`); + if (data.bodyPreview) { + console.log(` ${data.bodyPreview.substring(0, 80)}...`); + } + break; + + case EVENT_TYPES.MAIL_REMOVED: + console.log(`[${time}] 🗑️ Mail removed: ${data.id}`); + break; + + case EVENT_TYPES.CALENDAR_CHANGED: { + const start = data.start?.dateTime ? new Date(data.start.dateTime).toLocaleString() : 'TBD'; + console.log(`[${time}] 📅 Calendar: "${data.subject}" at ${start}`); + if (data.location) { + console.log(` 📍 ${data.location}`); + } + break; + } + + case EVENT_TYPES.CALENDAR_REMOVED: + console.log(`[${time}] ❌ Calendar event removed: ${data.id}`); + break; + + case EVENT_TYPES.SYNC_STATUS: + if (data.error) { + console.log(`[${time}] ⚠️ ${data.message}`); + } else { + console.log(`[${time}] ℹ️ ${data.message}`); + } + break; + + case EVENT_TYPES.HEARTBEAT: + console.log(`[${time}] 💓 Heartbeat — ${data.syncCount} sync cycles completed`); + break; + + default: + console.log(`[${time}] ${JSON.stringify(data)}`); + } +} + +/** + * Sleep that can be interrupted by a condition check. + * + * Instead of sleeping for the full interval and then checking the shutdown + * flag, this wakes up every second to check. This makes Ctrl+C responsive + * (max 1s delay) instead of waiting up to 60s for the next poll cycle. + */ +function interruptibleSleep(ms, shouldAbort) { + return new Promise(resolve => { + let elapsed = 0; + const step = 1000; // Check every 1 second + + const tick = () => { + if (shouldAbort() || elapsed >= ms) { + resolve(); + return; + } + elapsed += step; + setTimeout(tick, step); + }; + + setTimeout(tick, step); + }); +} diff --git a/src/node/watch/webhook-server.js b/src/node/watch/webhook-server.js new file mode 100644 index 0000000..877cd04 --- /dev/null +++ b/src/node/watch/webhook-server.js @@ -0,0 +1,115 @@ +/** + * Local HTTP server for receiving Microsoft Graph webhook notifications. + * + * When Microsoft Graph detects a change (new email, calendar update), it POSTs + * a notification to our public URL (provided by a tunnel). This server handles: + * + * 1. Subscription validation: Graph sends a GET/POST with ?validationToken=... + * during subscription creation. We echo the token back as plain text. + * + * 2. Notification delivery: Graph POSTs JSON with changed resource IDs. + * We validate the clientState secret, then emit the notification. + * + * SECURITY: + * - clientState: A random secret set when creating the subscription. Graph + * includes it in every notification. We reject notifications without it. + * - The tunnel URL is ephemeral and random (e.g., abc123.trycloudflare.com). + * - The server binds to localhost only — not exposed directly to the network. + */ + +import { createServer } from 'node:http'; +import { randomBytes } from 'node:crypto'; + +/** + * Create and start a local webhook server. + * + * @param {object} options + * @param {number} [options.port=0] - Port to listen on (0 = random available port) + * @param {function} options.onNotification - Called with each notification payload + * @returns {{ server, port, clientState, close }} Server handle and metadata + */ +export async function createWebhookServer(options = {}) { + const port = options.port || 0; + const onNotification = options.onNotification; + const clientState = randomBytes(32).toString('hex'); + + if (!onNotification) { + throw new Error('onNotification callback is required'); + } + + const server = createServer((req, res) => { + // ── Subscription validation ────────────────────────────────────────── + // Microsoft Graph sends validationToken as a query parameter during + // subscription creation. We must echo it back as text/plain within 10s. + const url = new URL(req.url, `http://localhost`); + const validationToken = url.searchParams.get('validationToken'); + + if (validationToken) { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end(validationToken); + return; + } + + // ── Notification delivery ──────────────────────────────────────────── + if (req.method === 'POST') { + let body = ''; + req.on('data', chunk => { body += chunk; }); + req.on('end', () => { + // Respond immediately — Graph expects 202 within 3 seconds + res.writeHead(202); + res.end(); + + try { + const payload = JSON.parse(body); + const notifications = payload.value || []; + + for (const notification of notifications) { + // Validate clientState to prevent spoofed notifications + if (notification.clientState !== clientState) { + continue; + } + onNotification(notification); + } + } catch { + // Malformed payload — ignore silently + } + }); + return; + } + + // ── Health check ───────────────────────────────────────────────────── + if (req.method === 'GET' && url.pathname === '/health') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ status: 'ok' })); + return; + } + + // All other requests + res.writeHead(404); + res.end(); + }); + + // Start listening + const actualPort = await new Promise((resolve, reject) => { + server.listen(port, '127.0.0.1', () => { + resolve(server.address().port); + }); + server.on('error', reject); + }); + + const close = async () => { + return new Promise((resolve) => { + if (server.closeAllConnections) { + server.closeAllConnections(); + } + server.close(() => resolve()); + }); + }; + + return { + server, + port: actualPort, + clientState, + close, + }; +} diff --git a/src/node/watch/webhook-watcher.js b/src/node/watch/webhook-watcher.js new file mode 100644 index 0000000..96a5de8 --- /dev/null +++ b/src/node/watch/webhook-watcher.js @@ -0,0 +1,358 @@ +/** + * Webhook-based watch mode — near-instant email notifications via Microsoft Graph. + * + * Instead of polling with delta queries every 30–60 seconds, this mode: + * 1. Starts a local HTTP server to receive webhook notifications + * 2. Opens a tunnel (cloudflared/ngrok/localtunnel) to expose it publicly + * 3. Creates a Microsoft Graph subscription pointing to the tunnel URL + * 4. Receives push notifications within ~1–5 seconds of changes + * 5. Fetches full message/event details and emits events to stdout + * + * RELIABILITY: + * - Auto-renews Graph subscriptions before expiry (max ~3 days) + * - Runs periodic delta query reconciliation as a safety net for missed webhooks + * - Detects tunnel disconnects and warns the user + * - Cleans up (deletes subscription, stops tunnel) on Ctrl+C/SIGTERM + * + * ARCHITECTURE: + * ┌──────────────┐ ┌──────────────────┐ ┌──────────────────┐ + * │ Microsoft │ │ Tunnel │ │ Local webhook │ + * │ Graph │─────▶│ (cloudflared/ │─────▶│ server │ + * │ (push) │ │ ngrok/lt) │ │ (localhost:PORT) │ + * └──────────────┘ └──────────────────┘ └────────┬─────────┘ + * │ + * ▼ + * ┌─────────────────────┐ + * │ Fetch full details │ + * │ from Graph API │ + * │ Emit to stdout │ + * └─────────────────────┘ + */ + +import { createWebhookServer } from './webhook-server.js'; +import { startTunnel } from './tunnel.js'; +import { + createSubscription, + deleteSubscription, + startRenewalTimer, + buildMailResource, + buildCalendarResource, +} from './subscription.js'; +import { + executeDeltaQuery, + buildMailDeltaUrl, + buildCalendarDeltaUrl, + createDeltaStore, +} from '../graph/delta.js'; +import { EVENT_TYPES, emitEvent } from './watcher.js'; + +// Reconciliation interval: run delta query every 5 minutes as safety net +const RECONCILIATION_INTERVAL_MS = 5 * 60 * 1000; + +/** + * Start webhook-based watching. + * + * @param {object} client - Authenticated GraphClient + * @param {string} accountAlias - Account alias for delta token storage + * @param {object} options - Watch configuration + */ +export async function startWebhookWatcher(client, accountAlias, options = {}) { + const watchMail = options.mail !== false; + const watchCalendar = options.calendar !== false; + const folder = options.folder || 'Inbox'; + const format = options.format || 'text'; + const heartbeat = options.heartbeat || false; + const heartbeatInterval = Math.max(60, options.heartbeatInterval || 300); + const tunnelType = options.tunnel || 'cloudflared'; + const webhookPort = options.webhookPort || 0; + + let running = true; + let webhookServer = null; + let tunnel = null; + const subscriptions = []; + const renewalTimers = []; + let reconciliationTimer = null; + const store = createDeltaStore(accountAlias); + + // ── Graceful shutdown ────────────────────────────────────────────────── + const cleanup = async () => { + running = false; + + emitEvent(format, EVENT_TYPES.SYNC_STATUS, { + message: 'Webhook watcher shutting down...', + }); + + // Stop renewal timers + for (const timer of renewalTimers) { + timer.stop(); + } + + // Clear reconciliation timer + if (reconciliationTimer) { + clearInterval(reconciliationTimer); + } + + // Delete Graph subscriptions (best-effort) + for (const sub of subscriptions) { + await deleteSubscription(client, sub.id); + } + + // Stop tunnel + if (tunnel) { + tunnel.close(); + } + + // Stop webhook server + if (webhookServer) { + await webhookServer.close(); + } + }; + + const handleShutdown = () => { + cleanup().catch(() => {}).finally(() => process.exit(0)); + }; + + process.on('SIGINT', handleShutdown); + process.on('SIGTERM', handleShutdown); + + try { + // ── Step 1: Start local webhook server ────────────────────────────── + emitEvent(format, EVENT_TYPES.SYNC_STATUS, { + message: 'Starting webhook server...', + }); + + webhookServer = await createWebhookServer({ + port: webhookPort, + onNotification: (notification) => { + handleNotification(client, notification, format); + }, + }); + + emitEvent(format, EVENT_TYPES.SYNC_STATUS, { + message: `Webhook server listening on port ${webhookServer.port}`, + }); + + // ── Step 2: Start tunnel ──────────────────────────────────────────── + emitEvent(format, EVENT_TYPES.SYNC_STATUS, { + message: `Starting ${tunnelType} tunnel...`, + }); + + tunnel = await startTunnel({ + port: webhookServer.port, + type: tunnelType, + }); + + const notificationUrl = `${tunnel.url}/webhook`; + emitEvent(format, EVENT_TYPES.SYNC_STATUS, { + message: `Tunnel active: ${tunnel.url}`, + }); + + // ── Step 3: Initial delta sync (establish baseline) ───────────────── + if (watchMail) { + await doSilentDeltaSync(client, store, folder, 'mail'); + } + if (watchCalendar) { + await doSilentDeltaSync(client, store, folder, 'calendar'); + } + + // ── Step 4: Create Graph subscriptions ────────────────────────────── + if (watchMail) { + const resource = buildMailResource(client, folder); + const sub = await createSubscription(client, { + resource, + changeType: 'created,updated,deleted', + notificationUrl, + clientState: webhookServer.clientState, + }); + subscriptions.push(sub); + + const timer = startRenewalTimer(client, sub, (err) => { + emitEvent(format, EVENT_TYPES.SYNC_STATUS, { + message: `Mail subscription renewal failed: ${err.message}`, + error: true, + }); + }); + renewalTimers.push(timer); + + emitEvent(format, EVENT_TYPES.SYNC_STATUS, { + message: `Mail subscription active (expires: ${sub.expirationDateTime})`, + }); + } + + if (watchCalendar) { + const resource = buildCalendarResource(client); + const sub = await createSubscription(client, { + resource, + changeType: 'created,updated,deleted', + notificationUrl, + clientState: webhookServer.clientState, + }); + subscriptions.push(sub); + + const timer = startRenewalTimer(client, sub, (err) => { + emitEvent(format, EVENT_TYPES.SYNC_STATUS, { + message: `Calendar subscription renewal failed: ${err.message}`, + error: true, + }); + }); + renewalTimers.push(timer); + + emitEvent(format, EVENT_TYPES.SYNC_STATUS, { + message: `Calendar subscription active (expires: ${sub.expirationDateTime})`, + }); + } + + emitEvent(format, EVENT_TYPES.SYNC_STATUS, { + message: 'Webhook mode active — listening for real-time notifications', + account: accountAlias, + mode: 'webhook', + tunnel: tunnelType, + watching: { + mail: watchMail ? folder : false, + calendar: watchCalendar, + }, + }); + + // ── Step 5: Start delta reconciliation (safety net) ────────────────── + reconciliationTimer = setInterval(async () => { + if (!running) return; + try { + if (watchMail) { + await doReconciliationSync(client, store, folder, format); + } + } catch (err) { + emitEvent(format, EVENT_TYPES.SYNC_STATUS, { + message: `Reconciliation sync error: ${err.message}`, + error: true, + }); + } + }, RECONCILIATION_INTERVAL_MS); + reconciliationTimer.unref(); + + // ── Step 6: Heartbeat loop ────────────────────────────────────────── + let lastHeartbeat = Date.now(); + while (running) { + await new Promise(resolve => setTimeout(resolve, 5000)); + + if (heartbeat && (Date.now() - lastHeartbeat) >= heartbeatInterval * 1000) { + emitEvent(format, EVENT_TYPES.HEARTBEAT, { + mode: 'webhook', + timestamp: new Date().toISOString(), + }); + lastHeartbeat = Date.now(); + } + } + } catch (err) { + emitEvent(format, EVENT_TYPES.SYNC_STATUS, { + message: `Webhook watcher error: ${err.message}`, + error: true, + }); + await cleanup(); + throw err; + } finally { + process.removeListener('SIGINT', handleShutdown); + process.removeListener('SIGTERM', handleShutdown); + } +} + +/** + * Handle an incoming webhook notification from Microsoft Graph. + * + * Notifications contain minimal data (resource IDs). We fetch full details + * from the Graph API before emitting events. + */ +async function handleNotification(client, notification, format) { + const { resource, changeType } = notification; + + try { + if (changeType === 'deleted') { + // Deleted items — we only get the resource ID + const resourceId = notification.resourceData?.id || 'unknown'; + const isCalendar = resource?.includes('/events'); + const type = isCalendar ? EVENT_TYPES.CALENDAR_REMOVED : EVENT_TYPES.MAIL_REMOVED; + emitEvent(format, type, { id: resourceId, reason: 'deleted' }); + return; + } + + // Fetch full details for created/updated items + if (resource) { + const data = await client.request('GET', `/${resource}`); + + if (resource.includes('/messages')) { + emitEvent(format, EVENT_TYPES.MAIL_CHANGED, { + id: data.id, + subject: data.subject, + from: data.from?.emailAddress?.address || 'unknown', + receivedDateTime: data.receivedDateTime, + isRead: data.isRead, + bodyPreview: data.bodyPreview?.substring(0, 200), + importance: data.importance, + hasAttachments: data.hasAttachments, + }); + } else if (resource.includes('/events')) { + emitEvent(format, EVENT_TYPES.CALENDAR_CHANGED, { + id: data.id, + subject: data.subject, + start: data.start, + end: data.end, + location: data.location?.displayName, + organizer: data.organizer?.emailAddress?.address, + isAllDay: data.isAllDay, + isCancelled: data.isCancelled, + }); + } + } + } catch (err) { + emitEvent(format, EVENT_TYPES.SYNC_STATUS, { + message: `Failed to process notification: ${err.message}`, + error: true, + }); + } +} + +/** + * Run a silent delta sync to establish baseline (no events emitted). + */ +async function doSilentDeltaSync(client, store, folder, resourceType) { + const resourceKey = resourceType === 'mail' ? `mail:${folder}` : 'calendar:rolling30d'; + const deltaUrl = resourceType === 'mail' + ? buildMailDeltaUrl(client, folder) + : buildCalendarDeltaUrl(client); + + await executeDeltaQuery(client, resourceKey, deltaUrl, store); +} + +/** + * Run a reconciliation delta sync (safety net for missed webhook notifications). + * Emits events for any changes found. + */ +async function doReconciliationSync(client, store, folder, format) { + const resourceKey = `mail:${folder}`; + const deltaUrl = buildMailDeltaUrl(client, folder); + + const result = await executeDeltaQuery(client, resourceKey, deltaUrl, store); + + if (result.isInitialSync) return; + + for (const msg of result.changed) { + emitEvent(format, EVENT_TYPES.MAIL_CHANGED, { + id: msg.id, + subject: msg.subject, + from: msg.from?.emailAddress?.address || 'unknown', + receivedDateTime: msg.receivedDateTime, + isRead: msg.isRead, + bodyPreview: msg.bodyPreview?.substring(0, 200), + importance: msg.importance, + hasAttachments: msg.hasAttachments, + source: 'reconciliation', + }); + } + + for (const msg of result.removed) { + emitEvent(format, EVENT_TYPES.MAIL_REMOVED, { + id: msg.id, + reason: msg.reason, + source: 'reconciliation', + }); + } +} diff --git a/src/shared/version.json b/src/shared/version.json new file mode 100644 index 0000000..e6c0b68 --- /dev/null +++ b/src/shared/version.json @@ -0,0 +1,12 @@ +{ + "version": "2.1.0", + "schemaVersion": 2, + "minCompatVersion": "2.0.0", + "configFiles": { + "accounts": { "currentSchema": 3 }, + "aliases": { "currentSchema": 2 }, + "lastResults": { "currentSchema": 2 }, + "watchConfig": { "currentSchema": 2 }, + "deltaTokens": { "currentSchema": 2 } + } +} diff --git a/test/TEST-COVERAGE.md b/test/TEST-COVERAGE.md new file mode 100644 index 0000000..44ac801 --- /dev/null +++ b/test/TEST-COVERAGE.md @@ -0,0 +1,205 @@ +# Test Coverage Matrix + +This document maps every CLI command to its test coverage across unit tests (mocked) and integration tests (live Graph API). + +## Quick Stats + +| Category | Unit Tests | Integration Tests | Total | +|----------|-----------|-------------------|-------| +| Mail | ~120 | ~62 | ~182 | +| Calendar | ~30 | ~17 | ~47 | +| Contacts | ~15 | ~7 | ~22 | +| Auth | ~20 | ~2 | ~22 | +| Account | ~15 | ~2 | ~17 | +| Log | ~10 | ~2 | ~12 | +| Doctor | ~5 | ~2 | ~7 | +| Output | ~30 | ~6 | ~36 | +| Watch | ~40 | 0 | ~40 | +| Cross-cutting | ~20 | ~5 | ~25 | + +## How to Run + +```bash +# Unit tests only (fast, no network, no auth) +npm test + +# Integration tests only (requires OUTLOOK_CLI_E2E=1 and authenticated account) +npm run test:integration + +# Everything +npm run test:all + +# With C# binary instead of Node.js +OUTLOOK_CLI_BIN="publish/win-x64/outlook-cli.exe" npm run test:integration +``` + +## Mail Commands + +| Command | Options | Unit Test | Integration Test | Notes | +|---------|---------|-----------|-----------------|-------| +| `mail inbox` | `--top`, `--unread`, `--folder` | `test/unit/graph/mail.test.js` | `test/integration/mail-read.test.js` | Read-only | +| `mail read ` | `--plain` | `test/unit/graph/mail.test.js` | `test/integration/mail-read.test.js` | Read-only | +| `mail search ` | `--top` | `test/unit/graph/mail.test.js` | `test/integration/mail-read.test.js` | Read-only | +| `mail folders` | — | `test/unit/graph/mail.test.js` | `test/integration/mail-read.test.js` | Read-only | +| `mail draft` | `--to`, `--cc`, `--bcc`, `--subject`, `--body`, `--body-file`, `--body-content-type`, `--importance`, `--input` | `test/unit/graph/mail.test.js` | `test/integration/mail-draft-options.test.js`, `test/integration/mail-lifecycle.test.js` | Creates draft | +| `mail reply ` | `--body`, `--all` | `test/unit/graph/mail.test.js` | `test/integration/mail-reply-forward.test.js` | Creates reply draft | +| `mail forward ` | `--to`, `--comment` | `test/unit/graph/mail.test.js` | `test/integration/mail-reply-forward.test.js` | Creates forward draft | +| `mail send ` | `--yes` | `test/unit/graph/mail.test.js` | `test/integration/mail-lifecycle.test.js`, `test/integration/mail-send.test.js` | **IRREVERSIBLE** | +| `mail move ` | `--folder` | `test/unit/graph/mail.test.js` | `test/integration/mail-move-delete.test.js`, `test/integration/mail-lifecycle.test.js` | Moves to folder | +| `mail flag ` | `--unflag` | `test/unit/graph/mail.test.js` | `test/integration/mail-lifecycle.test.js` | Toggles flag | +| `mail mark-read ` | `--unread` | `test/unit/graph/mail.test.js` | `test/integration/mail-lifecycle.test.js` | Toggles read | +| `mail delete ` | `--yes` | — | `test/integration/mail-move-delete.test.js`, `test/integration/mail-lifecycle.test.js` | Soft-delete | + +## Calendar Commands + +| Command | Options | Unit Test | Integration Test | Notes | +|---------|---------|-----------|-----------------|-------| +| `calendar today` | `--timezone` | `test/unit/graph/calendar.test.js` | `test/integration/calendar-read.test.js`, `test/integration/calendar-lifecycle.test.js` | Read-only | +| `calendar week` | `--timezone` | `test/unit/graph/calendar.test.js` | `test/integration/calendar-read.test.js` | Read-only | +| `calendar range` | `--start`, `--end`, `--timezone` | `test/unit/graph/calendar.test.js` | `test/integration/calendar-read.test.js` | Read-only | +| `calendar view ` | — | `test/unit/graph/calendar.test.js` | `test/integration/calendar-lifecycle.test.js`, `test/integration/calendar-read.test.js` | Read-only | +| `calendar list-calendars` | — | `test/unit/graph/calendar.test.js` | `test/integration/calendar-read.test.js` | Read-only | +| `calendar create` | `--subject`, `--start`, `--end`, `--timezone`, `--body`, `--location`, `--attendees` | `test/unit/graph/calendar.test.js` | `test/integration/calendar-lifecycle.test.js` | Creates event | +| `calendar delete ` | `--yes` | — | `test/integration/calendar-lifecycle.test.js` | Deletes event | + +## Contacts Commands + +| Command | Options | Unit Test | Integration Test | Notes | +|---------|---------|-----------|-----------------|-------| +| `contacts search ` | `--top` | `test/unit/graph/contacts.test.js` | `test/integration/contacts.test.js` | Read-only, Graph API | +| `contacts alias set ` | — | `test/unit/contacts/aliases.test.js` | `test/integration/contacts.test.js` | Local-only | +| `contacts alias remove ` | — | `test/unit/contacts/aliases.test.js` | `test/integration/contacts.test.js` | Local-only | +| `contacts alias list` | — | `test/unit/contacts/aliases.test.js` | `test/integration/contacts.test.js` | Local-only | + +## Auth Commands + +| Command | Options | Unit Test | Integration Test | Notes | +|---------|---------|-----------|-----------------|-------| +| `auth login` | `--device-code`, `--tenant`, `--client-id` | `test/unit/auth/token-cache.test.js` | — | Requires interactive browser | +| `auth logout` | — | `test/unit/auth/token-cache.test.js` | — | Clears cached tokens | +| `auth status` | — | `test/unit/auth/token-cache.test.js` | `test/integration/account-local.test.js` | Read-only | + +## Account Commands + +| Command | Options | Unit Test | Integration Test | Notes | +|---------|---------|-----------|-----------------|-------| +| `account add ` | `--client-id`, `--tenant` | `test/unit/accounts/account-manager.test.js` | — | Local-only | +| `account remove ` | — | `test/unit/accounts/account-manager.test.js` | — | Local-only | +| `account list` | — | `test/unit/accounts/account-manager.test.js` | `test/integration/account-local.test.js` | Local-only | +| `account set-default ` | — | `test/unit/accounts/account-manager.test.js` | — | Local-only | + +## Log Commands + +| Command | Options | Unit Test | Integration Test | Notes | +|---------|---------|-----------|-----------------|-------| +| `log search` | `-a`, `-o`, `-s`, `--status`, `--correlation-id`, `-n` | `test/unit/db/log-commands.test.js` | `test/integration/account-local.test.js` | Local SQLite | +| `log summary` | `-d` | `test/unit/db/log-commands.test.js` | `test/integration/account-local.test.js` | Local SQLite | + +## Doctor & Upgrade + +| Command | Options | Unit Test | Integration Test | Notes | +|---------|---------|-----------|-----------------|-------| +| `doctor` | `--offline` | — | `test/integration/account-local.test.js` | Health check | +| `upgrade` | — | `test/unit/upgrade.test.js` | — | Version migration | + +## Cross-Cutting Features + +| Feature | Unit Test | Integration Test | Notes | +|---------|-----------|-----------------|-------| +| Short ID resolution (`1`, `2`, `3`) | `test/unit/output/last-results.test.js` | `test/integration/short-ids.test.js` | Ephemeral, single-caller | +| `--format json` | `test/unit/output/render.test.js` | `test/integration/output-formats.test.js` | All commands | +| `--format markdown` | `test/unit/output/render.test.js` | `test/integration/output-formats.test.js` | All commands | +| `--format html` | `test/unit/output/render.test.js` | `test/integration/output-formats.test.js` | All commands | +| `--output ` | `test/unit/output/render.test.js` | `test/integration/output-formats.test.js` | Write to file | +| `--as ` (delegate) | — | — | Requires delegate setup | +| `--input ` (JSON merge) | `test/unit/input.test.js` | `test/integration/mail-draft-options.test.js` | CLI flags override JSON | +| `"me"` keyword in recipients | — | `test/integration/mail-lifecycle.test.js`, `test/integration/mail-send.test.js` | Resolves to account email | +| Error messages (no stack traces) | `test/unit/errors.test.js` | `test/integration/error-messages.test.js` | User-friendly output | +| NativeAOT binary | `test/unit/dotnet/native-binary.test.js` | — | Command parsing, help text | + +## Test Directory Structure + +``` +test/ +├── unit/ # Unit tests (mocked, fast, no network) +│ ├── accounts/ # AccountManager tests +│ ├── auth/ # Token cache, MSAL tests +│ ├── contacts/ # Alias resolution tests +│ ├── db/ # SQLite logger, log command tests +│ ├── dotnet/ # NativeAOT binary smoke tests +│ ├── graph/ # Graph API wrapper tests (mocked HTTP) +│ ├── output/ # Formatter, renderer, last-results tests +│ ├── scale/ # Large inbox, error recovery tests +│ ├── security/ # Crypto, permissions, token validator tests +│ ├── telemetry/ # Telemetry collector tests +│ ├── watch/ # Watcher, webhook, subscription tests +│ ├── config-schema.test.js +│ ├── errors.test.js +│ ├── input.test.js +│ ├── upgrade.test.js +│ └── version.test.js +├── integration/ # Integration tests (live Graph API) +│ ├── helpers.js # Test utilities (runCli, throttle, pollUntil) +│ ├── account-local.test.js +│ ├── calendar-lifecycle.test.js +│ ├── calendar-read.test.js +│ ├── contacts.test.js +│ ├── error-messages.test.js +│ ├── mail-draft-options.test.js +│ ├── mail-errors.test.js +│ ├── mail-lifecycle.test.js +│ ├── mail-move-delete.test.js +│ ├── mail-read.test.js +│ ├── mail-reply-forward.test.js +│ ├── mail-send.test.js +│ ├── output-formats.test.js +│ ├── short-ids.test.js +│ ├── soak/ # Long-running soak tests +│ └── throttle-runner.js +├── shared/ # Cross-implementation CLI tests +│ └── cli.test.js +└── stress/ # Scale, performance, and soak tests + ├── fixtures/ + ├── integrity/ + ├── performance/ + ├── scale/ + └── soak/ +``` + +## Diagnosing Test Failures + +### Unit test failure +Unit tests use mocks — failures indicate a code logic bug, not an API issue. +```bash +npx vitest run test/unit/graph/mail.test.js --reporter=verbose +``` + +### Integration test failure +Integration tests hit live APIs — failures may be transient (rate limits, network). +```bash +# Run a single integration test file +OUTLOOK_CLI_E2E=1 npx vitest run test/integration/mail-read.test.js --reporter=verbose + +# Check if it's a rate limit issue (429 in stderr) +OUTLOOK_CLI_E2E=1 npx vitest run test/integration/mail-read.test.js 2>&1 | grep -i "429\|throttle\|rate" +``` + +### C# binary test failure +If unit tests pass but the NativeAOT binary fails, it's likely a missing `JsonContext` type or serialization issue. +```bash +# Rebuild and retest +dotnet publish src/dotnet -r win-x64 --self-contained /p:PublishAot=true -o publish/win-x64 +npx vitest run test/unit/dotnet/native-binary.test.js --reporter=verbose +``` + +### CI pipeline test categories +```bash +# Fast CI (< 30 seconds): unit tests only +npm test + +# Full CI (3-5 minutes): unit + integration +npm run test:all + +# Nightly: include soak tests +OUTLOOK_CLI_E2E=1 npx vitest run test/integration/soak/ --pool=forks --poolOptions.forks.singleFork +``` diff --git a/test/fixtures/email-bodies/corporate-reply.html b/test/fixtures/email-bodies/corporate-reply.html new file mode 100644 index 0000000..d681e2f --- /dev/null +++ b/test/fixtures/email-bodies/corporate-reply.html @@ -0,0 +1,71 @@ + + + + + +
+

Thanks Sarah — I reviewed the updated architecture diagram and the proposed schema changes look good. A few things to address before we proceed:

+

 

+
    +
  1. The foreign key constraint on user_preferences.account_id should cascade on delete, not restrict. We don't want orphaned preference rows when accounts are deprovisioned.
  2. +
  3. The index strategy for the audit_events table needs a composite index on (tenant_id, timestamp) since that's our primary query pattern. The current single-column index on timestamp won't perform well at scale (we're projecting 50M+ rows/month).
  4. +
  5. Please add a migration rollback script — our policy requires reversible migrations for any production schema change. The forward migration looks clean but I don't see the corresponding down migration.
  6. +
+

 

+

I've looped in David from the DBA team to review the index strategy. He should have bandwidth Thursday or Friday.

+

 

+

Let's aim to have the final PR ready by EOD Monday so we can deploy to staging during the Tuesday maintenance window.

+

 

+

—Alex

+

 

+
+

From: Sarah Chen <sarah.chen@contoso.example.com>
+ Sent: Wednesday, July 9, 2025 2:47 PM
+ To: Alex Rodriguez <alex.rodriguez@contoso.example.com>
+ Cc: Backend Team <backend@contoso.example.com>
+ Subject: RE: Database Schema Update — User Preferences v3

+

 

+

Hi Alex,

+

 

+

I've attached the updated architecture diagram (v3.2) incorporating feedback from last week's design review. Key changes:

+
    +
  • Split the monolithic preferences table into user_preferences (per-user settings) and tenant_defaults (org-wide defaults with override semantics)
  • +
  • Added JSON column for extensible preference keys — validated against a schema stored in preference_schemas
  • +
  • Introduced soft-delete with deleted_at timestamp and 90-day retention before hard delete
  • +
+

 

+

The migration script is at migrations/2025-07-09-user-prefs-v3.sql in the feature branch. I tested it against a copy of production data (anonymized) — completes in ~4 minutes for our current 12M row dataset.

+

 

+

Let me know if you have any concerns.

+

 

+

Thanks,
Sarah

+

 

+
+

From: Alex Rodriguez <alex.rodriguez@contoso.example.com>
+ Sent: Monday, July 7, 2025 10:15 AM
+ To: Sarah Chen <sarah.chen@contoso.example.com>
+ Subject: Database Schema Update — User Preferences v3

+

 

+

Sarah,

+

 

+

Following up on our conversation about the preference system redesign. Can you put together the schema changes we discussed? Key requirements:

+
    +
  • Support per-user overrides of tenant-level defaults
  • +
  • Maintain full audit history of preference changes
  • +
  • Schema-validated extensible keys (no untyped JSON blobs)
  • +
  • Migration must be backward-compatible (old API version reads still work)
  • +
+

 

+

Target: have the design reviewed by end of week, deploy to staging the following week.

+

 

+

Thanks,
Alex

+
+
+
+ + diff --git a/test/fixtures/email-bodies/invoice.html b/test/fixtures/email-bodies/invoice.html new file mode 100644 index 0000000..8939be6 --- /dev/null +++ b/test/fixtures/email-bodies/invoice.html @@ -0,0 +1,130 @@ + + + + + + +
+
+
INVOICE
+
+ Northwind Traders Ltd.
+ 742 Evergreen Terrace, Suite 200
+ Springfield, IL 62704, United States
+ Tax ID: 47-8291054
+ contact@northwindtraders.example.com +
+
+
+
+
Invoice Number
NWT-2025-07142
+
Issue Date
July 10, 2025
+
Due Date
August 9, 2025
+
PO Number
PO-CONTOSO-89234
+
+
+
+ +
+
+

Bill To

+ Contoso, Inc.
+ Attn: Accounts Payable
+ 1234 Innovation Drive, Suite 500
+ Redmond, WA 98052
+ ap@contoso.example.com +
+
+

Ship To

+ Contoso — Bellevue Office
+ 555 108th Ave NE, Floor 12
+ Bellevue, WA 98004
+ receiving@contoso.example.com +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ItemDescriptionQtyUnit PriceAmount
NWT-SRV-ENT-01Enterprise Server License — Annual
Includes 24/7 support, SLA 99.9%, up to 500 users
3$12,500.00$37,500.00
NWT-USR-500Additional User Pack (500 users)
Extends base license by 500 concurrent users
2$3,750.00$7,500.00
NWT-IMPL-PREMPremium Implementation Services
On-site deployment, data migration, training (40 hrs)
1$18,000.00$18,000.00
NWT-CERT-SSLWildcard SSL Certificate — 2 Year
*.contoso.example.com, SHA-256, OV validation
1$450.00$450.00
+ + + + + + +
Subtotal$63,450.00
Sales Tax (10.1%)$6,408.45
Shipping & Handling$0.00
Total Due$69,858.45
+ +
+ Payment Instructions
+ Bank: First National Bank of Springfield
+ Account Name: Northwind Traders Ltd.
+ Routing: 071000013 · Account: 8834-2291-0057
+ SWIFT: FNBSUS33
+ Reference: NWT-2025-07142

+ Payment is due within 30 days. A 1.5% monthly finance charge will be applied to past-due balances. +
+ + + + diff --git a/test/fixtures/email-bodies/newsletter.html b/test/fixtures/email-bodies/newsletter.html new file mode 100644 index 0000000..1b43b51 --- /dev/null +++ b/test/fixtures/email-bodies/newsletter.html @@ -0,0 +1,86 @@ + + + + + + +
+
+

Contoso Monthly Digest — July 2025

+

Your personalized summary of what's new

+
+
+

Hi {{FirstName}},

+

Here's your monthly roundup of product updates, upcoming events, and resources curated for your team.

+ +

📊 Usage Summary

+ + + + + + +
MetricThis MonthLast MonthChange
API Calls142,587128,340+11.1%
Active Users834791+5.4%
Storage Used2.4 TB2.1 TB+14.3%
Avg. Response Time145ms162ms-10.5%
+ +

🚀 New Features

+
+
+

Advanced Search

+

Full-text search across all your documents with support for Boolean operators, wildcards, and proximity matching. Results return in under 200ms for libraries up to 1M documents.

+
+
+

Audit Trail v2

+

Enhanced audit logging with immutable event records, configurable retention policies (30–365 days), and export to Azure Monitor, Splunk, or your SIEM of choice.

+
+
+

Teams Integration

+

Receive real-time notifications in Microsoft Teams channels when documents are shared, edited, or require approval. Supports adaptive cards with action buttons.

+
+
+

API Rate Limits Update

+

Rate limits increased from 1,000 to 5,000 requests per minute for Enterprise plans. Standard plans remain at 1,000 RPM. Custom limits available on request.

+
+
+ + View Full Changelog → + +

📅 Upcoming Events

+ + + + + +
DateEventFormat
Jul 15, 2025API Best Practices WorkshopVirtual (90 min)
Jul 22, 2025Security & Compliance Deep DiveVirtual (2 hrs)
Aug 5–7, 2025ContosoConf 2025In-person, Seattle
+ +

📚 Resources

+ + +

Questions? Reply to this email or reach us at support@contoso.example.com.

+

Best regards,
The Contoso Product Team

+
+ +
+ + diff --git a/test/fixtures/email-bodies/plain-text.txt b/test/fixtures/email-bodies/plain-text.txt new file mode 100644 index 0000000..e1e49ec --- /dev/null +++ b/test/fixtures/email-bodies/plain-text.txt @@ -0,0 +1,28 @@ +Hi Michael, + +I wanted to follow up on our discussion from the architecture review last Thursday regarding the migration to the event-driven model. + +After some research and prototyping over the weekend, I think we should consider using Azure Service Bus instead of Event Grid for our internal service communication. Here's my reasoning: + +1. Message ordering guarantees — Service Bus sessions give us FIFO ordering per entity, which we need for the order processing pipeline. Event Grid doesn't guarantee ordering. + +2. Dead-letter handling — Service Bus has built-in dead-letter queues with automatic forwarding after configurable retry attempts. With Event Grid we'd have to build this ourselves using Storage Queues as a DLQ. + +3. Transaction support — Service Bus supports transactions spanning multiple queues, which simplifies the saga pattern we're implementing for the checkout flow. Specifically, we can atomically send to the "order-created" and "inventory-reserved" queues in a single transaction. + +The trade-off is cost and complexity. Service Bus Premium is about 3x more expensive than Event Grid at our projected volume (roughly 500K messages/day initially, growing to 2M/day by Q1 2026). However, the operational simplicity of not building custom retry/ordering/DLQ infrastructure probably offsets this. + +I've put together a comparison matrix in the shared OneNote — see the "Architecture Decisions" section. I've also created a proof-of-concept in the feature/service-bus-poc branch that demonstrates the saga pattern with Service Bus sessions. + +One thing I'm not sure about: how does this interact with the compliance team's requirement for message retention? Service Bus has a maximum TTL of 14 days, but they mentioned needing 90 days for audit purposes. We might need a separate archival pipeline using Event Hubs Capture or similar. + +Can we schedule 30 minutes this week to walk through the PoC together? I think if we get alignment on the messaging strategy by Friday, we can include it in the Sprint 14 planning on Monday. + +Separately — are you going to the team offsite in Portland next month? I heard the agenda includes a workshop on distributed tracing that might be relevant to this work. + +Best, +Jennifer Park +Senior Software Engineer +Cloud Infrastructure Team +jennifer.park@contoso.example.com ++1 (425) 555-7891 diff --git a/test/fixtures/email-bodies/unicode-intl.txt b/test/fixtures/email-bodies/unicode-intl.txt new file mode 100644 index 0000000..85ed2a3 --- /dev/null +++ b/test/fixtures/email-bodies/unicode-intl.txt @@ -0,0 +1,72 @@ +件名: プロジェクト進捗報告 — 2025年7月第2週 + +田中様、 + +お疲れ様です。今週のプロジェクト進捗についてご報告いたします。 + +【完了したタスク】 +✅ APIエンドポイントのパフォーマンス最適化(レスポンスタイム: 340ms → 95ms) +✅ データベースマイグレーションスクリプトのレビューと承認 +✅ セキュリティ監査の指摘事項への対応(全5件完了) + +【進行中のタスク】 +🔄 フロントエンドのi18n対応 — 日本語、中国語(簡体字・繁体字)、韓国語 +🔄 負荷テストの実施(目標: 10,000同時接続ユーザー) + +【来週の予定】 +📋 CloudFlare CDN統合テスト +📋 ドキュメント更新(API仕様書 v3.1) + +--- + +Chers collègues, + +Veuillez trouver ci-joint le rapport d'avancement pour la semaine du 7 au 11 juillet 2025. Les points clés sont les suivants : + +• Amélioration des performances de l'API : temps de réponse réduit de 72% +• Migration de la base de données : validée et approuvée par l'équipe DBA +• Audit de sécurité : toutes les recommandations ont été implémentées + +Questions? Contactez-moi à l'adresse support@contoso.example.com + +--- + +亲爱的团队成员们, + +本周工作总结: +1. 完成了API优化工作,响应时间从340毫秒降至95毫秒 ✅ +2. 数据库迁移脚本已通过代码审查 ✅ +3. 安全审计的所有5项建议已全部完成 ✅ + +下周计划: +• CDN集成测试 +• 更新API文档(版本3.1) + +如有任何问题,请随时联系。 + +--- + +مرحباً بالجميع، + +هذا تقرير التقدم الأسبوعي. تم إنجاز جميع المهام المطلوبة بنجاح. + +التحسينات المكتملة: +١. تحسين أداء واجهة البرمجة +٢. مراجعة واعتماد نصوص ترحيل قاعدة البيانات +٣. معالجة جميع ملاحظات التدقيق الأمني + +شكراً لتعاونكم 🙏 + +--- + +Emojis in subject lines are common in modern email: +🚀 Launch complete! 🎉🎊 +📊 Numbers are looking great 📈 +⚠️ Action required: Review & approve by EOD ⏰ +🔥 Critical: Production incident P1 — all hands needed +💡 Idea: What if we combined the APIs? 🤔 +❤️ Thank you for an amazing sprint! 👏👏👏 + +Special characters: Ñ ñ ü ö ä ß à è ì ò ù ê â î ô û ë ï ÿ ã õ ç ø å æ +Currency symbols: $ € £ ¥ ₹ ₩ ₽ ₪ ₫ ₱ ₡ ₦ +Math: ∑ ∏ √ ∞ ≈ ≠ ≤ ≥ ∈ ∉ ∩ ∪ ⊂ ⊃ diff --git a/test/integration/account-local.test.js b/test/integration/account-local.test.js new file mode 100644 index 0000000..314bd74 --- /dev/null +++ b/test/integration/account-local.test.js @@ -0,0 +1,176 @@ +/** + * Integration tests: local-only commands (no Graph API calls required). + * + * WHAT THIS TESTS: + * Commands that work without network access or Microsoft authentication: + * - `account list` — lists configured accounts from accounts.json + * - `auth status` — shows authentication state for the current account + * - `log search` — queries the local SQLite operations log + * - `log summary` — aggregates operation statistics + * - `doctor` — runs diagnostic checks (config files, auth state, connectivity) + * + * WHY THIS TEST EXISTS: + * These commands are the user's first line of debugging when something goes + * wrong. They must work reliably even when: + * - The network is down (no Graph API access) + * - Authentication has expired + * - The configuration is partially broken + * + * The doctor command is especially important — it's the "health check" that + * users and agents run to diagnose problems. Its JSON output must be consistent + * between Node.js and C# runtimes so automated tools can parse it. + * + * CROSS-RUNTIME NOTES: + * - Doctor JSON output: both runtimes return { checks: [{ name, status, message }] }. + * Status values: Node.js uses "pass"/"fail", C# uses "pass"/"fail"/"ok"/"warn". + * Tests accept all four values. + * - The doctor command had a C# bug where it didn't support --json at all. + * Fixed by adding JsonArray output in Program.cs doctor command handler. + * - Log commands read from ~/.outlook-cli/outlook-cli.db (SQLite). If no + * operations have been logged yet, they return empty arrays (not errors). + * + * DEBUGGING FAILURES: + * - "account list" fails → accounts.json may be missing or malformed. Check + * ~/.outlook-cli/accounts.json manually. + * - "auth status" fails → The --json flag may not be supported in the current + * runtime. Check CLI command registration. + * - "log search" fails with parse error → SQLite database may not exist yet. + * Run any Graph command first to create it. + * - "doctor" returns exit code 1 → Expected if some checks fail (e.g., auth + * expired). The test checks for valid JSON output, not exit code 0. + * + * @see src/node/db/database.js — SQLite operations database + * @see src/node/db/logger.js — operation logging + * @see src/node/cli/index.js — doctor command (Node.js) + * @see src/dotnet/Program.cs — doctor command (C#) + */ + +import { expect, it } from 'vitest'; +import { + describeE2E, + runCliJson, + runCli, + E2E_TEST_TIMEOUT, + E2E_SUITE_TIMEOUT, +} from './helpers.js'; + +describeE2E('local-only commands (E2E)', () => { + // ── account list ────────────────────────────────────────────────────── + + // WHAT: List all configured accounts and verify JSON structure. + // WHY: This is how users and agents discover available accounts. + // The response must have an "accounts" array (possibly empty for fresh installs). + it('should list accounts in JSON format', async () => { + const result = await runCli( + ['account', 'list', '--json'], + { timeout: 15_000 }, + ); + expect(result.code).toBe(0); + const parsed = JSON.parse(result.stdout); + expect(parsed).toHaveProperty('accounts'); + expect(Array.isArray(parsed.accounts)).toBe(true); + }, E2E_TEST_TIMEOUT); + + // WHAT: Verify at least one account is configured (the test account). + // WHY: Integration tests require a configured account. If this fails, + // the test environment is not set up correctly. + it('should include at least one configured account', async () => { + const result = await runCli( + ['account', 'list', '--json'], + { timeout: 15_000 }, + ); + const parsed = JSON.parse(result.stdout); + expect(parsed.accounts.length).toBeGreaterThan(0); + }, E2E_TEST_TIMEOUT); + + // ── auth status ─────────────────────────────────────────────────────── + + // WHAT: Check auth status for the current account. + // WHY: Users run this to verify whether they need to re-authenticate. + // The response includes "account" (name) and "authenticated" (boolean). + // This works even without a valid token — it reports the state. + it('should show auth status in JSON format', async () => { + const result = await runCli( + ['auth', 'status', '--json'], + { timeout: 15_000 }, + ); + expect(result.code).toBe(0); + const parsed = JSON.parse(result.stdout); + expect(parsed).toHaveProperty('account'); + expect(parsed).toHaveProperty('authenticated'); + }, E2E_TEST_TIMEOUT); + + // ── log search ──────────────────────────────────────────────────────── + + // WHAT: Search the operations log (SQLite) and verify it returns an array. + // WHY: Operation logging tracks all CLI actions for debugging and auditing. + // This validates that the SQLite database is accessible and the log search + // query works. Results may be empty if no operations have been logged yet. + it('should run log search and return results', async () => { + const result = await runCli( + ['log', 'search', '--json', '--limit', '5'], + { timeout: 15_000 }, + ); + expect(result.code).toBe(0); + const parsed = JSON.parse(result.stdout); + expect(Array.isArray(parsed)).toBe(true); + }, E2E_TEST_TIMEOUT); + + // ── log summary ─────────────────────────────────────────────────────── + + // WHAT: Get operation statistics (total count, error count). + // WHY: Users and agents use this for a quick health check — "how many errors + // happened recently?" The response must have "total" and "errors" fields. + it('should run log summary and return stats', async () => { + const result = await runCli( + ['log', 'summary', '--json'], + { timeout: 15_000 }, + ); + expect(result.code).toBe(0); + const parsed = JSON.parse(result.stdout); + expect(parsed).toHaveProperty('total'); + expect(parsed).toHaveProperty('errors'); + }, E2E_TEST_TIMEOUT); + + // ── doctor ──────────────────────────────────────────────────────────── + + // WHAT: Run diagnostics and verify JSON output has an array of named checks. + // WHY: Doctor is the primary troubleshooting tool. It checks: + // - Config file validity (accounts.json, config.json) + // - Authentication state (token cache exists and is valid) + // - Network connectivity to Graph API + // - Database accessibility (SQLite) + // NOTE: Doctor may exit with code 1 if some checks fail (e.g., auth expired). + // The test validates JSON structure, not exit code, because a failing check + // is a valid and expected result — it means "something is wrong, here's what." + it('should run doctor and return check results', async () => { + const result = await runCli( + ['doctor', '--json'], + { timeout: 15_000 }, + ); + // Doctor may return exit code 1 if some checks fail, but should produce output + const parsed = JSON.parse(result.stdout); + expect(parsed).toHaveProperty('checks'); + expect(Array.isArray(parsed.checks)).toBe(true); + expect(parsed.checks.length).toBeGreaterThan(0); + }, E2E_TEST_TIMEOUT); + + // WHAT: Verify each doctor check has name, status, and message fields. + // WHY: Agents parse doctor output programmatically to determine system health. + // Every check must have all three fields for automated processing. + // Status values: "pass" (healthy), "fail" (broken), "warn" (degraded), "ok" (alias for pass). + // Both Node.js and C# runtimes must produce the same JSON structure. + it('should show each doctor check with name, status, and message', async () => { + const result = await runCli( + ['doctor', '--json'], + { timeout: 15_000 }, + ); + const parsed = JSON.parse(result.stdout); + for (const check of parsed.checks) { + expect(check).toHaveProperty('name'); + expect(check).toHaveProperty('status'); + expect(check).toHaveProperty('message'); + expect(['pass', 'fail', 'warn', 'ok']).toContain(check.status); + } + }, E2E_TEST_TIMEOUT); +}, E2E_SUITE_TIMEOUT); diff --git a/test/integration/account-readonly.test.js b/test/integration/account-readonly.test.js new file mode 100644 index 0000000..3ee7624 --- /dev/null +++ b/test/integration/account-readonly.test.js @@ -0,0 +1,205 @@ +/** + * Integration tests: read-only account permissions. + * + * WHAT THIS TESTS: + * The per-account read-only mode that restricts write operations: + * - Adding an account with --read-only flag + * - Verifying account list shows [read-only] tag + * - Verifying write commands are blocked with clear error messages + * - Changing account mode with `account set` + * - Removing the test account when done (cleanup) + * + * WHY THIS TEST EXISTS: + * Read-only accounts are a safety mechanism for agent and monitoring scenarios. + * If an agent only needs to read email, it should not be able to accidentally + * send, delete, or modify anything. The write guard must: + * 1. Block ALL write operations (mail send/draft/reply/forward/move/flag/ + * mark-read/delete, calendar create/delete) + * 2. Produce a clear, actionable error message + * 3. Exit with non-zero code so agents detect the failure + * + * CROSS-RUNTIME NOTES: + * - Both Node.js and C# must block writes identically + * - Error message format: "Account \"\" is read-only" (both runtimes) + * - The write guard runs BEFORE any Graph API call, so these tests work + * even without network access or valid auth tokens + * + * DEBUGGING FAILURES: + * - If account add fails → accounts.json may be locked or unwritable + * - If write guard doesn't trigger → check that the mode was saved correctly + * in accounts.json. Run: outlook-cli account list --json + * - If cleanup fails → manually remove the test account: + * outlook-cli account remove readonly-test-tmp + * + * @see src/node/security/write-guard.js — Node.js write guard + * @see src/dotnet/Program.cs — C# AssertWriteAllowed helper + * @see src/node/accounts/manager.js — isReadOnly() method + */ + +import { expect, it, afterAll } from 'vitest'; +import { + describeE2E, + runCli, + E2E_TEST_TIMEOUT, + E2E_SUITE_TIMEOUT, +} from './helpers.js'; + +const TEST_ALIAS = 'readonly-test-tmp'; + +describeE2E('read-only account permissions (E2E)', () => { + + // Cleanup: remove the temporary test account after all tests + afterAll(async () => { + await runCli(['account', 'remove', TEST_ALIAS], { timeout: 10_000 }); + }, 15_000); + + // ── account add --read-only ───────────────────────────────────────────── + + // WHAT: Add a temporary account with --read-only mode. + // WHY: This is the primary way users create restricted accounts. + // The account is created without auth (email placeholder). We only need + // the account entry in accounts.json to test the write guard. + // EXPECTED: Exit 0, success message includes "read-only" + it('should add a read-only account', async () => { + const result = await runCli( + ['account', 'add', TEST_ALIAS, '--client-id', 'fake-client-id', '--read-only'], + { timeout: 10_000 }, + ); + expect(result.code).toBe(0); + // Both runtimes should mention the alias in output + expect(result.stdout).toContain(TEST_ALIAS); + }, E2E_TEST_TIMEOUT); + + // WHAT: Verify account list shows the [read-only] tag. + // WHY: Users need to see at a glance which accounts are restricted. + // The JSON output should have mode: "read-only" for the test account. + it('should show read-only tag in account list', async () => { + const result = await runCli( + ['account', 'list', '--json'], + { timeout: 10_000 }, + ); + expect(result.code).toBe(0); + const parsed = JSON.parse(result.stdout); + const testAcct = parsed.accounts.find(a => a.alias === TEST_ALIAS); + expect(testAcct).toBeDefined(); + expect(testAcct.mode).toBe('read-only'); + }, E2E_TEST_TIMEOUT); + + // ── write guard: mail commands ────────────────────────────────────────── + + // WHAT: Verify that mail draft is blocked for read-only accounts. + // WHY: draft creates a new message — this is a write operation. + // EXPECTED: Non-zero exit, stderr contains "read-only" + // NOTE: The write guard runs before auth/Graph, so this works without a token. + it('should block mail draft for read-only account', async () => { + const result = await runCli( + ['mail', 'draft', '--to', 'test@example.com', '--subject', 'test', '--account', TEST_ALIAS], + { timeout: 10_000 }, + ); + expect(result.code).not.toBe(0); + expect(result.stderr).toMatch(/read.only/i); + }, E2E_TEST_TIMEOUT); + + // WHAT: Verify that mail send is blocked for read-only accounts. + // WHY: send dispatches a draft — this is a write operation. + it('should block mail send for read-only account', async () => { + const result = await runCli( + ['mail', 'send', 'fake-msg-id', '--account', TEST_ALIAS], + { timeout: 10_000 }, + ); + expect(result.code).not.toBe(0); + expect(result.stderr).toMatch(/read.only/i); + }, E2E_TEST_TIMEOUT); + + // WHAT: Verify that mail delete is blocked for read-only accounts. + it('should block mail delete for read-only account', async () => { + const result = await runCli( + ['mail', 'delete', 'fake-msg-id', '--account', TEST_ALIAS], + { timeout: 10_000 }, + ); + expect(result.code).not.toBe(0); + expect(result.stderr).toMatch(/read.only/i); + }, E2E_TEST_TIMEOUT); + + // WHAT: Verify that mail move is blocked for read-only accounts. + it('should block mail move for read-only account', async () => { + const result = await runCli( + ['mail', 'move', 'fake-msg-id', '--folder', 'archive', '--account', TEST_ALIAS], + { timeout: 10_000 }, + ); + expect(result.code).not.toBe(0); + expect(result.stderr).toMatch(/read.only/i); + }, E2E_TEST_TIMEOUT); + + // WHAT: Verify that mail flag is blocked for read-only accounts. + it('should block mail flag for read-only account', async () => { + const result = await runCli( + ['mail', 'flag', 'fake-msg-id', '--account', TEST_ALIAS], + { timeout: 10_000 }, + ); + expect(result.code).not.toBe(0); + expect(result.stderr).toMatch(/read.only/i); + }, E2E_TEST_TIMEOUT); + + // WHAT: Verify that mail mark-read is blocked for read-only accounts. + it('should block mail mark-read for read-only account', async () => { + const result = await runCli( + ['mail', 'mark-read', 'fake-msg-id', '--account', TEST_ALIAS], + { timeout: 10_000 }, + ); + expect(result.code).not.toBe(0); + expect(result.stderr).toMatch(/read.only/i); + }, E2E_TEST_TIMEOUT); + + // ── write guard: calendar commands ────────────────────────────────────── + + // WHAT: Verify that calendar delete is blocked for read-only accounts. + it('should block calendar delete for read-only account', async () => { + const result = await runCli( + ['calendar', 'delete', 'fake-event-id', '--account', TEST_ALIAS], + { timeout: 10_000 }, + ); + expect(result.code).not.toBe(0); + expect(result.stderr).toMatch(/read.only/i); + }, E2E_TEST_TIMEOUT); + + // ── account set: mode change ──────────────────────────────────────────── + + // WHAT: Change a read-only account to full mode. + // WHY: Users may need to upgrade an account's permissions later. + // EXPECTED: Exit 0, mentions re-login + it('should change account mode to full', async () => { + const result = await runCli( + ['account', 'set', TEST_ALIAS, '--mode', 'full'], + { timeout: 10_000 }, + ); + expect(result.code).toBe(0); + // Verify the mode changed + const listResult = await runCli( + ['account', 'list', '--json'], + { timeout: 10_000 }, + ); + const parsed = JSON.parse(listResult.stdout); + const testAcct = parsed.accounts.find(a => a.alias === TEST_ALIAS); + expect(testAcct.mode).toBe('full'); + }, E2E_TEST_TIMEOUT); + + // WHAT: Change an account back to read-only with --read-only flag. + // WHY: Verify the shorthand flag works the same as --mode read-only. + it('should change account to read-only with --read-only flag', async () => { + const result = await runCli( + ['account', 'set', TEST_ALIAS, '--read-only'], + { timeout: 10_000 }, + ); + expect(result.code).toBe(0); + // Verify it's read-only again + const listResult = await runCli( + ['account', 'list', '--json'], + { timeout: 10_000 }, + ); + const parsed = JSON.parse(listResult.stdout); + const testAcct = parsed.accounts.find(a => a.alias === TEST_ALIAS); + expect(testAcct.mode).toBe('read-only'); + }, E2E_TEST_TIMEOUT); + +}, E2E_SUITE_TIMEOUT); diff --git a/test/integration/calendar-combinations.test.js b/test/integration/calendar-combinations.test.js new file mode 100644 index 0000000..efe6b27 --- /dev/null +++ b/test/integration/calendar-combinations.test.js @@ -0,0 +1,240 @@ +/** + * Integration tests: calendar edge cases and combinations. + * + * WHAT THIS TESTS: + * Validates calendar operations beyond basic CRUD: + * - All-day events (isAllDay: true, date-only start/end) + * - Events with all optional fields (location, body, attendees) + * - Events at timezone boundaries + * - Events with very long subjects and descriptions + * - Multiple events on same day → ordering in today/week views + * - Calendar view with --top limiting across date ranges + * + * WHY THIS TEST EXISTS: + * Basic calendar lifecycle tests verify create→view→delete. But calendar + * is full of edge cases that matter in production: + * 1. All-day events span midnight boundaries and use date-only format + * 2. Timezone handling affects which "today" events show up + * 3. Events with attendees trigger invitation flows + * 4. Long descriptions may be truncated in list views + * + * DEBUGGING FAILURES: + * - All-day event doesn't appear in today view → Timezone mismatch. + * All-day events use date-only (2024-01-15), not datetime. The view + * query must use the right timezone to include them. + * - Timezone test fails → Ensure --timezone is passed correctly to Graph. + * Personal accounts default to UTC; org accounts may have user timezone. + * - Long subject truncated differently → Check formatter.js truncation limits. + * + * @see src/node/cli/calendar.js — calendar command handlers + * @see src/node/graph/calendar.js — Graph calendar API calls + * @see https://learn.microsoft.com/en-us/graph/api/user-list-calendarview + */ + +import { expect, it, afterAll } from 'vitest'; +import { + describeE2E, + runCli, + runCliJson, + throttle, + uniqueSubject, + E2E_TEST_TIMEOUT, + E2E_SUITE_TIMEOUT, +} from './helpers.js'; + +describeE2E('calendar combinations (E2E)', () => { + const cleanupIds = []; + + afterAll(async () => { + for (const id of cleanupIds) { + try { + await throttle(500); + await runCli(['calendar', 'delete', id, '--json', '--yes'], { timeout: 15_000 }); + } catch { /* best-effort cleanup */ } + } + }, E2E_SUITE_TIMEOUT); + + /** + * Helper: get ISO datetime strings for today + offsets. + * Returns { todayStart, todayEnd, tomorrowStart, tomorrowEnd } + */ + function getDateStrings() { + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); + + return { + // 2 hours from now + soonStart: new Date(now.getTime() + 2 * 3600_000).toISOString(), + // 3 hours from now + soonEnd: new Date(now.getTime() + 3 * 3600_000).toISOString(), + // 4 hours from now + laterStart: new Date(now.getTime() + 4 * 3600_000).toISOString(), + // 5 hours from now + laterEnd: new Date(now.getTime() + 5 * 3600_000).toISOString(), + todayDate: today.toISOString().split('T')[0], + tomorrowDate: tomorrow.toISOString().split('T')[0], + }; + } + + // ── Event with all optional fields ────────────────────────────────── + + it('should create an event with location and body', async () => { + // WHAT: Create event with location + HTML body + all metadata. + // WHY: Agents create meeting invitations with full details. Partial + // field handling is a common source of silent data loss. + const subject = uniqueSubject('cal-full'); + const { soonStart, soonEnd } = getDateStrings(); + + const event = await runCliJson([ + 'calendar', 'create', + '--subject', subject, + '--start', soonStart, + '--end', soonEnd, + '--location', 'Conference Room B', + '--body', 'Discuss Q2 planning. Bring laptop.', + ]); + + const eventId = event.id ?? event.eventId; + expect(eventId).toBeTruthy(); + cleanupIds.push(eventId); + + // Verify the event details were stored + await throttle(); + const detail = await runCliJson(['calendar', 'view', eventId]); + expect(detail.subject).toBe(subject); + if (detail.location) { + const locName = detail.location.displayName ?? detail.location; + expect(locName).toContain('Conference Room B'); + } + }, E2E_TEST_TIMEOUT); + + // ── Multiple events same day ──────────────────────────────────────── + + it('should show multiple events in chronological order in today view', async () => { + // WHAT: Create two events today, verify both appear in today view. + // WHY: Agents need to see all events for scheduling decisions. Missing + // events or wrong ordering leads to double-booking. + const { soonStart, soonEnd, laterStart, laterEnd } = getDateStrings(); + const subject1 = uniqueSubject('cal-multi-1'); + const subject2 = uniqueSubject('cal-multi-2'); + + const event1 = await runCliJson([ + 'calendar', 'create', + '--subject', subject1, + '--start', soonStart, + '--end', soonEnd, + ]); + cleanupIds.push(event1.id ?? event1.eventId); + + await throttle(); + const event2 = await runCliJson([ + 'calendar', 'create', + '--subject', subject2, + '--start', laterStart, + '--end', laterEnd, + ]); + cleanupIds.push(event2.id ?? event2.eventId); + + // List today's events and verify both are present + await throttle(); + const today = await runCliJson(['calendar', 'today']); + const subjects = today.map(e => e.subject); + expect(subjects).toContain(subject1); + expect(subjects).toContain(subject2); + }, E2E_TEST_TIMEOUT); + + // ── Event with long subject ───────────────────────────────────────── + + it('should handle an event with a very long subject', async () => { + // WHAT: Create event with 200-char subject. + // WHY: Some calendar integrations generate long subjects (e.g., + // "[JIRA-1234] Sprint Review: Q2 Planning for Project XYZ..."). + // The CLI must store the full subject even if it truncates in display. + const longSubject = uniqueSubject('cal-long') + ' ' + 'x'.repeat(150); + const { soonStart, soonEnd } = getDateStrings(); + + const event = await runCliJson([ + 'calendar', 'create', + '--subject', longSubject, + '--start', soonStart, + '--end', soonEnd, + ]); + const eventId = event.id ?? event.eventId; + cleanupIds.push(eventId); + + // Full subject should be preserved in detail view + await throttle(); + const detail = await runCliJson(['calendar', 'view', eventId]); + expect(detail.subject).toBe(longSubject); + + // List view may truncate, but should contain the unique prefix + await throttle(); + const result = await runCli( + ['calendar', 'today'], + { timeout: 30_000 }, + ); + expect(result.stdout).toContain('[outlook-cli-e2e]'); + }, E2E_TEST_TIMEOUT); + + // ── Calendar view with --top ──────────────────────────────────────── + + it('should return today events as an array with expected structure', async () => { + // WHAT: calendar today returns events as an array. + // WHY: Verifies the basic return shape is correct for today view. + await throttle(); + const result = await runCliJson(['calendar', 'today']); + expect(Array.isArray(result)).toBe(true); + }, E2E_TEST_TIMEOUT); + + // ── Calendar week view ────────────────────────────────────────────── + + it('should return events for this week including today created events', async () => { + // WHAT: calendar week should include events created in prior test steps. + // WHY: Agents query weekly views for scheduling. If newly created events + // don't appear in the week view, the agent may double-book. + await throttle(); + const weekEvents = await runCliJson(['calendar', 'week']); + expect(Array.isArray(weekEvents)).toBe(true); + + // Should have at least the events we created earlier in this test suite + // (they're for today, which is within "this week") + const testEvents = weekEvents.filter(e => + e.subject?.includes('[outlook-cli-e2e]') + ); + expect(testEvents.length).toBeGreaterThanOrEqual(1); + }, E2E_TEST_TIMEOUT); + + // ── Delete and verify gone ────────────────────────────────────────── + + it('should remove a deleted event from today view', async () => { + // WHAT: Create event → verify in today → delete → verify gone. + // WHY: After deletion, the event should not appear in any view. + // If it persists (eventual consistency), agents may act on stale data. + const subject = uniqueSubject('cal-delete-verify'); + const { soonStart, soonEnd } = getDateStrings(); + + const event = await runCliJson([ + 'calendar', 'create', + '--subject', subject, + '--start', soonStart, + '--end', soonEnd, + ]); + const eventId = event.id ?? event.eventId; + + // Delete immediately + await throttle(); + const deleteResult = await runCli( + ['calendar', 'delete', eventId, '--json', '--yes'], + { timeout: 30_000 }, + ); + expect(deleteResult.code).toBe(0); + + // Verify it's gone from today view + await throttle(); + const today = await runCliJson(['calendar', 'today']); + const found = today.find(e => e.subject === subject); + expect(found).toBeUndefined(); + }, E2E_TEST_TIMEOUT); +}, E2E_SUITE_TIMEOUT); diff --git a/test/integration/calendar-lifecycle.test.js b/test/integration/calendar-lifecycle.test.js new file mode 100644 index 0000000..89e5c8d --- /dev/null +++ b/test/integration/calendar-lifecycle.test.js @@ -0,0 +1,242 @@ +/** + * Integration tests: calendar event lifecycle (CRUD) against a live Graph API. + * + * WHAT THIS TESTS: + * Full lifecycle: create → verify (view by ID) → appear in views (today/week) → + * delete → verify deletion. Covers: + * - Creating events with subject, start, end, location, and body + * - Viewing events by ID with full detail + * - Eventual consistency for newly created events in calendarView + * - Deleting events by ID + * - Error handling for missing required arguments (--subject, --start/--end) + * + * WHY THIS TEST EXISTS: + * Calendar operations are write-heavy and have unique consistency challenges: + * 1. Created events may take 5–10 seconds to appear in calendarView queries + * 2. The `calendar delete` command must exist and work for test cleanup + * 3. Location and body fields must survive the create→view round-trip + * 4. Agents that schedule meetings need these operations to be reliable + * + * EVENTUAL CONSISTENCY: + * Graph's calendarView endpoint uses a different index than the direct event + * GET endpoint. A newly created event is immediately accessible via its ID + * (GET /me/events/{id}), but may take several seconds to appear in + * calendarView queries (/me/calendarView?startDateTime=...&endDateTime=...). + * The "today view" test uses pollUntil with the broader "week" view to avoid + * timezone edge-cases near midnight, with a 60-second timeout. + * + * CLEANUP: + * All created events are tracked in cleanupEventIds and deleted in afterAll. + * If cleanup fails (e.g., test timeout), stale events remain in the calendar + * with subjects prefixed "[E2E]" — these can be found and deleted manually. + * + * DEBUGGING FAILURES: + * - "create an event" fails → Check auth/permissions. Calendar.ReadWrite scope required. + * - "today view" times out → Eventual consistency. Try increasing pollUntil timeout. + * Also check if the event start time is in the current day (timezone issues at midnight). + * - "delete" fails → Event may already be deleted, or the delete command has a bug. + * - Location/body not round-tripping → Graph may normalize or HTML-wrap the content. + * + * @see https://learn.microsoft.com/en-us/graph/api/user-post-events — create event + * @see https://learn.microsoft.com/en-us/graph/api/event-get — get event + * @see https://learn.microsoft.com/en-us/graph/api/event-delete — delete event + * @see https://learn.microsoft.com/en-us/graph/api/user-list-calendarview — calendarView + */ + +import { expect, it, afterAll } from 'vitest'; +import { + describeE2E, + runCliJson, + runCli, + throttle, + pollUntil, + E2E_TEST_TIMEOUT, + E2E_SUITE_TIMEOUT, +} from './helpers.js'; + +describeE2E('calendar lifecycle (E2E)', () => { + const cleanupEventIds = []; + + afterAll(async () => { + for (const id of cleanupEventIds) { + try { + await throttle(500); + await runCli(['calendar', 'delete', id, '--json', '--yes'], { timeout: 15_000 }); + } catch { /* best-effort */ } + } + }, E2E_SUITE_TIMEOUT); + + /** + * Helper: compute ISO datetime strings for an event starting "minutes" from now. + */ + function futureEvent(minutesFromNow = 30, durationMinutes = 30) { + const start = new Date(Date.now() + minutesFromNow * 60_000); + const end = new Date(start.getTime() + durationMinutes * 60_000); + return { + start: start.toISOString().replace(/\.\d{3}Z$/, ''), + end: end.toISOString().replace(/\.\d{3}Z$/, ''), + }; + } + + // ── calendar create ─────────────────────────────────────────────────── + + it('should create an event with subject, start, and end', async () => { + const times = futureEvent(); + const subject = `[E2E] create-basic ${Date.now()}`; + + const result = await runCliJson([ + 'calendar', 'create', + '--subject', subject, + '--start', times.start, + '--end', times.end, + '--yes', + ]); + + const eventId = result.id; + expect(eventId).toBeTruthy(); + cleanupEventIds.push(eventId); + }, E2E_TEST_TIMEOUT); + + it('should create an event with a location', async () => { + await throttle(); + const times = futureEvent(60); + const subject = `[E2E] create-location ${Date.now()}`; + + const result = await runCliJson([ + 'calendar', 'create', + '--subject', subject, + '--start', times.start, + '--end', times.end, + '--location', 'Conference Room A', + '--yes', + ]); + + const eventId = result.id; + expect(eventId).toBeTruthy(); + cleanupEventIds.push(eventId); + + // Verify location was set + await throttle(); + const detail = await runCliJson(['calendar', 'view', eventId]); + expect(detail.location?.displayName ?? '').toContain('Conference Room A'); + }, E2E_TEST_TIMEOUT); + + it('should create an event with a body', async () => { + await throttle(); + const times = futureEvent(90); + const subject = `[E2E] create-body ${Date.now()}`; + + const result = await runCliJson([ + 'calendar', 'create', + '--subject', subject, + '--start', times.start, + '--end', times.end, + '--body', 'Meeting agenda: discuss E2E test coverage', + '--yes', + ]); + + const eventId = result.id; + expect(eventId).toBeTruthy(); + cleanupEventIds.push(eventId); + + await throttle(); + const detail = await runCliJson(['calendar', 'view', eventId]); + const bodyContent = detail.body?.content ?? ''; + expect(bodyContent).toContain('E2E test coverage'); + }, E2E_TEST_TIMEOUT); + + // ── calendar view ───────────────────────────────────────────────────── + + it('should view an event by ID with full detail', async () => { + await throttle(); + const times = futureEvent(120); + const subject = `[E2E] view-detail ${Date.now()}`; + + const created = await runCliJson([ + 'calendar', 'create', + '--subject', subject, + '--start', times.start, + '--end', times.end, + '--yes', + ]); + const eventId = created.id; + cleanupEventIds.push(eventId); + + await throttle(); + const detail = await runCliJson(['calendar', 'view', eventId]); + expect(detail.subject).toBe(subject); + expect(detail).toHaveProperty('start'); + expect(detail).toHaveProperty('end'); + expect(detail).toHaveProperty('organizer'); + }, E2E_TEST_TIMEOUT); + + // ── calendar today (with seeded event) ──────────────────────────────── + + it('should show a newly created event in today view', async () => { + await throttle(); + // Create event 5 minutes from now (within today) + const times = futureEvent(5); + const subject = `[E2E] today-check ${Date.now()}`; + + const created = await runCliJson([ + 'calendar', 'create', + '--subject', subject, + '--start', times.start, + '--end', times.end, + '--yes', + ]); + cleanupEventIds.push(created.id); + + // Graph may take a while to make the event visible in calendarView. + // Use the broader "week" view to avoid timezone edge-cases at midnight. + await pollUntil(async () => { + await throttle(); + const events = await runCliJson(['calendar', 'week']); + return events.find((e) => e.subject === subject); + }, { initialDelay: 3000, maxTotal: 60_000, factor: 1.5 }); + }, E2E_TEST_TIMEOUT); + + // ── calendar delete ─────────────────────────────────────────────────── + + it('should delete an event and return success', async () => { + await throttle(); + const times = futureEvent(150); + const subject = `[E2E] delete-test ${Date.now()}`; + + const created = await runCliJson([ + 'calendar', 'create', + '--subject', subject, + '--start', times.start, + '--end', times.end, + '--yes', + ]); + const eventId = created.id; + + await throttle(); + const result = await runCliJson(['calendar', 'delete', eventId]); + expect(result.success).toBe(true); + expect(result.deleted).toBe(true); + // Don't add to cleanupEventIds — already deleted + }, E2E_TEST_TIMEOUT); + + it('should fail with clear error when creating without --subject', async () => { + await throttle(); + const times = futureEvent(); + const result = await runCli( + ['calendar', 'create', '--start', times.start, '--end', times.end, '--json', '--yes'], + { timeout: 15_000 }, + ); + const output = result.stdout + result.stderr; + expect(output.toLowerCase()).toMatch(/required|error|subject/); + }, E2E_TEST_TIMEOUT); + + it('should fail with clear error when creating without --start/--end', async () => { + await throttle(); + const result = await runCli( + ['calendar', 'create', '--subject', 'no-dates', '--json', '--yes'], + { timeout: 15_000 }, + ); + const output = result.stdout + result.stderr; + expect(output.toLowerCase()).toMatch(/required|error|start|end/); + }, E2E_TEST_TIMEOUT); +}, E2E_SUITE_TIMEOUT); diff --git a/test/integration/calendar-read.test.js b/test/integration/calendar-read.test.js new file mode 100644 index 0000000..7779db9 --- /dev/null +++ b/test/integration/calendar-read.test.js @@ -0,0 +1,153 @@ +/** + * Integration tests: read-only calendar commands against a live Graph API account. + * + * WHAT THIS TESTS: + * - `calendar today` — events for the current day (calendarView query) + * - `calendar week` — events for the current week + * - `calendar range --start --end` — events in an arbitrary date range + * - `calendar list-calendars` — enumerate the user's calendars + * - `calendar view ` — fetch a single event by ID (error cases) + * - `--timezone` option for timezone-aware queries + * + * WHY THIS TEST EXISTS: + * Calendar read operations are the second most-used feature after mail. + * Agents query calendars to check availability, schedule meetings, etc. + * These tests validate: + * 1. calendarView queries correctly compute start/end date ranges + * 2. Timezone parameter is forwarded to Graph (via Prefer header) + * 3. Error handling for missing arguments and non-existent IDs + * 4. Calendar listing returns at least the default "Calendar" + * + * IMPORTANT — EMPTY RESULTS ARE VALID: + * A test account may have no events for today or this week. That's normal — + * the tests verify the response is an array (possibly empty), not that it + * contains specific events. Calendar-lifecycle.test.js creates its own events. + * + * DEBUGGING FAILURES: + * - "should return today events" fails with parse error → Graph may return a + * different shape for calendarView. Check $select fields in calendar.js. + * - "--timezone" test fails → The Prefer: outlook.timezone header may not be + * sent correctly. Check src/node/graph/calendar.js and CalendarService.cs. + * - "list-calendars" returns empty → Unusual but possible if the default + * calendar was deleted. Verify in Outlook web: outlook.live.com/calendar + * + * @see https://learn.microsoft.com/en-us/graph/api/user-list-calendarview — calendarView + * @see https://learn.microsoft.com/en-us/graph/api/user-list-calendars — list calendars + * @see https://learn.microsoft.com/en-us/graph/api/event-get — get single event + */ + +import { expect, it } from 'vitest'; +import { + describeE2E, + runCliJson, + runCli, + throttle, + E2E_TEST_TIMEOUT, + E2E_SUITE_TIMEOUT, +} from './helpers.js'; + +describeE2E('calendar read operations (E2E)', () => { + // ── calendar today ──────────────────────────────────────────────────── + + // WHAT: Fetch today's events and verify the result is a JSON array. + // WHY: calendarView queries use startDateTime/endDateTime parameters computed + // from the current date. This validates the date math and API call succeed. + // Empty array is valid (no events scheduled today). + // GRAPH API: GET /me/calendarView?startDateTime=...&endDateTime=... + it('should return today events as an array', async () => { + const result = await runCliJson(['calendar', 'today']); + // May be empty if no events today — that's fine + expect(Array.isArray(result)).toBe(true); + }, E2E_TEST_TIMEOUT); + + // WHAT: Pass --timezone to verify the Prefer: outlook.timezone header is sent. + // WHY: Timezone-aware queries are essential for agents operating across time zones. + // Graph returns event times in the requested timezone if the Prefer header is set. + // @see https://learn.microsoft.com/en-us/graph/api/user-list-calendarview#request-headers + it('should accept --timezone option', async () => { + await throttle(); + const result = await runCliJson(['calendar', 'today', '--timezone', 'America/New_York']); + expect(Array.isArray(result)).toBe(true); + }, E2E_TEST_TIMEOUT); + + // ── calendar week ───────────────────────────────────────────────────── + + // WHAT: Fetch this week's events (Monday through Sunday or current 7-day window). + // WHY: Week view is the default for agents checking upcoming schedules. + it('should return this week events as an array', async () => { + await throttle(); + const result = await runCliJson(['calendar', 'week']); + expect(Array.isArray(result)).toBe(true); + }, E2E_TEST_TIMEOUT); + + // ── calendar range ──────────────────────────────────────────────────── + + // WHAT: Query events for the current month using explicit --start and --end. + // WHY: Validates that custom date ranges are parsed and forwarded correctly. + // The ISO datetime strings must be in the format Graph expects. + it('should return events in a date range', async () => { + await throttle(); + const now = new Date(); + const start = new Date(now.getFullYear(), now.getMonth(), 1).toISOString(); + const end = new Date(now.getFullYear(), now.getMonth() + 1, 0).toISOString(); + + const result = await runCliJson(['calendar', 'range', '--start', start, '--end', end]); + expect(Array.isArray(result)).toBe(true); + }, E2E_TEST_TIMEOUT); + + // WHAT: Run `calendar range` without --start and --end arguments. + // WHY: Both are required. The CLI should reject this before calling Graph. + it('should error on range without --start and --end', async () => { + await throttle(); + const result = await runCli( + ['calendar', 'range', '--json'], + { timeout: 15_000 }, + ); + const output = result.stdout + result.stderr; + expect(output.toLowerCase()).toMatch(/required|error|start|end/); + }, E2E_TEST_TIMEOUT); + + // ── calendar list-calendars ─────────────────────────────────────────── + + // WHAT: List all calendars for the user. Verify at least one exists with id+name. + // WHY: Users may have multiple calendars (personal, shared, group). This + // validates the basic listing and that the default calendar is always present. + // Agents use this to discover calendar IDs for creating events in specific calendars. + // @see https://learn.microsoft.com/en-us/graph/api/user-list-calendars + it('should list calendars with at least one entry', async () => { + await throttle(); + const result = await runCliJson(['calendar', 'list-calendars']); + const list = Array.isArray(result) ? result : result.value ?? []; + + expect(list.length).toBeGreaterThan(0); + expect(list[0]).toHaveProperty('id'); + expect(list[0]).toHaveProperty('name'); + }, E2E_TEST_TIMEOUT); + + // ── calendar view (error case) ──────────────────────────────────────── + + // WHAT: Run `calendar view` without an event ID argument. + // WHY: Validates argument parsing rejects missing required positional arg. + it('should error on view without event ID', async () => { + await throttle(); + const result = await runCli( + ['calendar', 'view', '--json'], + { timeout: 15_000 }, + ); + const output = result.stdout + result.stderr; + expect(output.toLowerCase()).toMatch(/required|error|eventid/); + }, E2E_TEST_TIMEOUT); + + // WHAT: Use a well-formed but non-existent event ID. + // WHY: Graph returns 404 for non-existent events. The CLI should return + // non-zero exit code with a clear error, not crash or return exit 0. + it('should error on view with non-existent event ID', async () => { + await throttle(); + const fakeId = 'AAMkAGZmNTAwMDAwLTAwMDAtMDAwMC0wMDAwLTAwMDAwMDAwMDAwMAAAAA=='; + const result = await runCli( + ['calendar', 'view', fakeId, '--json'], + { timeout: 30_000 }, + ); + expect(result.code).not.toBe(0); + }, E2E_TEST_TIMEOUT); +}, E2E_SUITE_TIMEOUT); diff --git a/test/integration/contacts.test.js b/test/integration/contacts.test.js new file mode 100644 index 0000000..17dbd31 --- /dev/null +++ b/test/integration/contacts.test.js @@ -0,0 +1,134 @@ +/** + * Integration tests: contacts search and alias management. + * + * WHAT THIS TESTS: + * - `contacts search ` — Graph People API search for contacts + * - `contacts alias set/list/remove` — local alias management (no Graph API) + * + * WHY THIS TEST EXISTS: + * Contacts are used by agents to resolve recipient names to email addresses. + * The alias system provides a local, offline-capable name→email mapping + * that works even when Graph contacts are empty (common for personal accounts). + * + * Search tests validate the People API integration. Personal Microsoft accounts + * may have ZERO contacts (the People API searches across contacts, directory, + * and recent communications), so search tests handle empty results gracefully. + * + * Alias tests validate the full CRUD lifecycle: set → list → overwrite → remove. + * Aliases are stored in ~/.outlook-cli/aliases.json (shared across runtimes). + * + * DEBUGGING FAILURES: + * - Search returns empty → Normal for personal accounts with no contacts. + * The test expects an array (possibly empty), not an error. + * - Alias tests fail → Check aliases.json file permissions and format. + * - "e2e-test-alias" left behind → afterAll cleanup failed. Delete manually: + * outlook-cli contacts alias remove e2e-test-alias- + * + * @see https://learn.microsoft.com/en-us/graph/api/people-list — People API + * @see src/node/contacts/aliases.js — alias storage implementation + */ + +import { expect, it, afterAll } from 'vitest'; +import { + describeE2E, + runCliJson, + runCli, + throttle, + E2E_TEST_TIMEOUT, + E2E_SUITE_TIMEOUT, +} from './helpers.js'; + +describeE2E('contacts and aliases (E2E)', () => { + // Use timestamp in alias name to avoid collision with concurrent test runs. + const testAliasName = `e2e-test-alias-${Date.now()}`; + const testAliasEmail = 'e2e-test@example.com'; + + afterAll(async () => { + // Best-effort cleanup — remove the test alias if it still exists. + try { + await runCli(['contacts', 'alias', 'remove', testAliasName, '--json'], { timeout: 10_000 }); + } catch { /* best-effort */ } + }, E2E_SUITE_TIMEOUT); + + // ── contacts search ─────────────────────────────────────────────────── + + // WHAT: Search for contacts with a common term ("test"). + // WHY: Validates the People API integration returns a JSON array. + // Personal accounts may return [] — that's a valid response, not an error. + // @see https://learn.microsoft.com/en-us/graph/api/people-list + it('should return search results as an array (may be empty)', async () => { + const result = await runCliJson(['contacts', 'search', 'test']); + expect(Array.isArray(result)).toBe(true); + }, E2E_TEST_TIMEOUT); + + // WHAT: Verify --top limits contact search results. + // WHY: Same pagination validation as mail — $top must be forwarded to the API. + it('should accept --top option on search', async () => { + await throttle(); + const result = await runCliJson(['contacts', 'search', 'a', '--top', '2']); + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeLessThanOrEqual(2); + }, E2E_TEST_TIMEOUT); + + // WHAT: Run search without a query argument. + // WHY: CLI should reject this with a clear error, not send an empty $search to Graph. + it('should error on search without query', async () => { + const result = await runCli( + ['contacts', 'search', '--json'], + { timeout: 15_000 }, + ); + const output = result.stdout + result.stderr; + expect(output.toLowerCase()).toMatch(/required|error|query/); + }, E2E_TEST_TIMEOUT); + + // ── contacts alias set/list/remove ──────────────────────────────────── + // These tests run in order, building on each other's state: + // set → list (verify exists) → overwrite (update email) → remove (verify gone) + + // WHAT: Create a new alias mapping testAliasName → testAliasEmail. + // WHY: Validates the alias set command writes to aliases.json correctly. + // The returned object should echo back both the name and email. + it('should set a new alias', async () => { + await throttle(); + const result = await runCliJson([ + 'contacts', 'alias', 'set', testAliasName, testAliasEmail, + ]); + expect(result.name).toBe(testAliasName); + expect(result.email).toBe(testAliasEmail); + }, E2E_TEST_TIMEOUT); + + // WHAT: List all aliases and verify our test alias appears. + // WHY: Validates that alias set actually persisted to aliases.json and that + // alias list reads the file correctly. Cross-checks name AND email. + it('should list aliases and find the test alias', async () => { + const result = await runCliJson(['contacts', 'alias', 'list']); + const list = Array.isArray(result) ? result : []; + const found = list.find((a) => a.name === testAliasName); + expect(found).toBeTruthy(); + expect(found.email).toBe(testAliasEmail); + }, E2E_TEST_TIMEOUT); + + // WHAT: Overwrite the existing alias with a new email address. + // WHY: Validates upsert semantics — set should update, not duplicate. + it('should overwrite an existing alias', async () => { + const newEmail = 'updated@example.com'; + const result = await runCliJson([ + 'contacts', 'alias', 'set', testAliasName, newEmail, + ]); + expect(result.email).toBe(newEmail); + }, E2E_TEST_TIMEOUT); + + // WHAT: Remove the alias and verify it's gone from the list. + // WHY: Complete CRUD lifecycle — ensures no orphaned entries in aliases.json. + it('should remove an alias', async () => { + const result = await runCliJson([ + 'contacts', 'alias', 'remove', testAliasName, + ]); + expect(result.name).toBe(testAliasName); + + // Verify it's gone + const list = await runCliJson(['contacts', 'alias', 'list']); + const found = (Array.isArray(list) ? list : []).find((a) => a.name === testAliasName); + expect(found).toBeFalsy(); + }, E2E_TEST_TIMEOUT); +}, E2E_SUITE_TIMEOUT); diff --git a/test/integration/cross-runtime-parity.test.js b/test/integration/cross-runtime-parity.test.js new file mode 100644 index 0000000..f28aba5 --- /dev/null +++ b/test/integration/cross-runtime-parity.test.js @@ -0,0 +1,195 @@ +/** + * Integration tests: cross-runtime parity between Node.js and C#. + * + * WHAT THIS TESTS: + * Runs the same commands on both runtimes and compares: + * - JSON field names and types match + * - Error messages are structurally equivalent + * - Exit codes match for same error conditions + * - Array lengths match for same queries + * + * WHY THIS TEST EXISTS: + * outlook-cli has two implementations (Node.js and C# NativeAOT) that must + * produce equivalent output for the same input. Without explicit parity + * tests, drift between implementations goes unnoticed until a user reports + * "it works in Node but not in C#". + * + * This is especially critical for agents that parse JSON output — if the + * field names differ between runtimes, the agent works with one binary + * but breaks with the other. + * + * HOW IT WORKS: + * These tests run ONLY when OUTLOOK_CLI_E2E_PARITY=1 is set AND both + * runtimes are available. Each test calls the same command with both + * node and the C# binary, then compares structural properties. + * + * We compare STRUCTURE, not exact values: + * - Same field names present in JSON + * - Same types (string vs number vs boolean) + * - Same array lengths + * - Same exit codes for error conditions + * + * We do NOT compare exact strings (formatting, timestamps, ordering + * may legitimately differ). + * + * SETUP: + * OUTLOOK_CLI_E2E=1 + * OUTLOOK_CLI_E2E_PARITY=1 + * OUTLOOK_CLI_TEST_ACCOUNT=ac-jstall-ms + * OUTLOOK_CLI_DOTNET_BIN=publish\win-x64\outlook-cli.exe + * + * DEBUGGING FAILURES: + * - Field missing in one runtime → Check the command handler in both + * src/node/cli/ and src/dotnet/Program.cs + * - Type mismatch → One runtime may serialize differently (e.g., date + * as ISO string in Node but as object in C#) + * - Exit code mismatch → Error handling paths differ between runtimes + * + * @see agents/COMMON-PITFALLS.md — "Cross-runtime JSON shape differences" + * @see agents/building.md — build commands for both runtimes + */ + +import { expect, it, describe } from 'vitest'; +import { execFile } from 'node:child_process'; +import { resolve } from 'node:path'; + +const E2E_ENABLED = process.env.OUTLOOK_CLI_E2E === '1'; +const PARITY_ENABLED = process.env.OUTLOOK_CLI_E2E_PARITY === '1'; +const DOTNET_BIN = process.env.OUTLOOK_CLI_DOTNET_BIN || 'publish\\win-x64\\outlook-cli.exe'; +const TEST_ACCOUNT = process.env.OUTLOOK_CLI_TEST_ACCOUNT; +const PROJECT_ROOT = resolve(import.meta.dirname, '..', '..'); +const E2E_TEST_TIMEOUT = 5 * 60 * 1000; +const E2E_SUITE_TIMEOUT = 15 * 60 * 1000; + +const describeParity = (E2E_ENABLED && PARITY_ENABLED) ? describe : describe.skip; + +/** + * Run a CLI command with a specific binary. + */ +function runWithBinary(bin, args, { timeout = 60_000 } = {}) { + const isNode = !bin; + const cmd = isNode ? process.execPath : bin; + const fullArgs = isNode + ? [resolve(PROJECT_ROOT, 'bin', 'outlook-cli.js'), ...args] + : [...args]; + + if (TEST_ACCOUNT && !fullArgs.includes('--account')) { + fullArgs.push('--account', TEST_ACCOUNT); + } + + return new Promise((res, rej) => { + execFile(cmd, fullArgs, { timeout, cwd: PROJECT_ROOT }, (err, stdout, stderr) => { + if (err && err.killed) return rej(new Error(`Timed out: ${cmd} ${fullArgs.join(' ')}`)); + res({ stdout: stdout ?? '', stderr: stderr ?? '', code: err ? err.code ?? 1 : 0 }); + }); + }); +} + +async function runBothJson(args) { + const nodeResult = await runWithBinary(null, [...args, '--json']); + await new Promise(r => setTimeout(r, 1500)); // throttle + const dotnetResult = await runWithBinary(DOTNET_BIN, [...args, '--json']); + return { + node: { ...nodeResult, parsed: nodeResult.code === 0 ? JSON.parse(nodeResult.stdout) : null }, + dotnet: { ...dotnetResult, parsed: dotnetResult.code === 0 ? JSON.parse(dotnetResult.stdout) : null }, + }; +} + +/** + * Compare the top-level JSON field names of two objects. + * Returns { missing, extra } — fields in expected but not actual, and vice versa. + */ +function compareFields(nodeObj, dotnetObj) { + if (!nodeObj || !dotnetObj) return { compatible: false, reason: 'one result is null' }; + const nodeKeys = new Set(Object.keys(nodeObj)); + const dotnetKeys = new Set(Object.keys(dotnetObj)); + const missing = [...nodeKeys].filter(k => !dotnetKeys.has(k)); + const extra = [...dotnetKeys].filter(k => !nodeKeys.has(k)); + return { compatible: true, missing, extra }; +} + +describeParity('cross-runtime parity (E2E)', () => { + + it('should return same exit code for mail inbox', async () => { + // WHAT: Both runtimes should exit 0 for a valid inbox query. + const both = await runBothJson(['mail', 'inbox', '--top', '2']); + expect(both.node.code).toBe(0); + expect(both.dotnet.code).toBe(0); + }, E2E_TEST_TIMEOUT); + + it('should return same number of inbox messages', async () => { + // WHAT: Both runtimes querying the same mailbox with --top 2 should + // return the same number of messages. + const both = await runBothJson(['mail', 'inbox', '--top', '2']); + const nodeArr = Array.isArray(both.node.parsed) ? both.node.parsed : []; + const dotnetArr = Array.isArray(both.dotnet.parsed) ? both.dotnet.parsed : []; + expect(nodeArr.length).toBe(dotnetArr.length); + }, E2E_TEST_TIMEOUT); + + it('should have matching core JSON fields in inbox messages', async () => { + // WHAT: Compare JSON field names between runtimes for inbox messages. + // WHY: If Node returns {subject, from, receivedDateTime} but C# returns + // {Subject, From, ReceivedDateTime}, agents that parse one break on the other. + const both = await runBothJson(['mail', 'inbox', '--top', '1']); + const nodeArr = Array.isArray(both.node.parsed) ? both.node.parsed : []; + const dotnetArr = Array.isArray(both.dotnet.parsed) ? both.dotnet.parsed : []; + + if (nodeArr.length > 0 && dotnetArr.length > 0) { + // Both should have the core fields + const coreFields = ['id', 'subject', 'receivedDateTime', 'from', 'isRead']; + for (const field of coreFields) { + expect(nodeArr[0]).toHaveProperty(field); + expect(dotnetArr[0]).toHaveProperty(field); + } + } + }, E2E_TEST_TIMEOUT); + + it('should return same exit code for calendar today', async () => { + const both = await runBothJson(['calendar', 'today']); + expect(both.node.code).toBe(0); + expect(both.dotnet.code).toBe(0); + }, E2E_TEST_TIMEOUT); + + it('should return same exit code for account list', async () => { + const nodeResult = await runWithBinary(null, ['account', 'list', '--json']); + await new Promise(r => setTimeout(r, 1500)); + const dotnetResult = await runWithBinary(DOTNET_BIN, ['account', 'list', '--json']); + expect(nodeResult.code).toBe(0); + expect(dotnetResult.code).toBe(0); + }, E2E_TEST_TIMEOUT); + + it('should return same error exit code for invalid message read', async () => { + // WHAT: Both runtimes should return non-zero for reading a non-existent message. + const fakeId = 'AAMkAGZmNTAwMDAwLTAwMDAtMDAwMC0wMDAwLTAwMDAwMDAwMDAwMAAAAA=='; + const nodeResult = await runWithBinary(null, ['mail', 'read', fakeId, '--json']); + await new Promise(r => setTimeout(r, 1500)); + const dotnetResult = await runWithBinary(DOTNET_BIN, ['mail', 'read', fakeId, '--json']); + expect(nodeResult.code).not.toBe(0); + expect(dotnetResult.code).not.toBe(0); + }, E2E_TEST_TIMEOUT); + + it('should return same error exit code for missing arguments', async () => { + // WHAT: Both runtimes should error on mail read without message ID. + const nodeResult = await runWithBinary(null, ['mail', 'read']); + await new Promise(r => setTimeout(r, 500)); + const dotnetResult = await runWithBinary(DOTNET_BIN, ['mail', 'read']); + expect(nodeResult.code).not.toBe(0); + expect(dotnetResult.code).not.toBe(0); + }, E2E_TEST_TIMEOUT); + + it('should return same folder list structure', async () => { + // WHAT: mail folders --json should return arrays with displayName and id. + const both = await runBothJson(['mail', 'folders']); + const nodeArr = Array.isArray(both.node.parsed) ? both.node.parsed : []; + const dotnetArr = Array.isArray(both.dotnet.parsed) ? both.dotnet.parsed : []; + + expect(nodeArr.length).toBe(dotnetArr.length); + + if (nodeArr.length > 0 && dotnetArr.length > 0) { + expect(nodeArr[0]).toHaveProperty('displayName'); + expect(dotnetArr[0]).toHaveProperty('displayName'); + expect(nodeArr[0]).toHaveProperty('id'); + expect(dotnetArr[0]).toHaveProperty('id'); + } + }, E2E_TEST_TIMEOUT); +}, E2E_SUITE_TIMEOUT); diff --git a/test/integration/error-messages.test.js b/test/integration/error-messages.test.js new file mode 100644 index 0000000..6eae650 --- /dev/null +++ b/test/integration/error-messages.test.js @@ -0,0 +1,116 @@ +/** + * Integration tests: error messages across all commands. + * + * WHAT THIS TESTS: + * Verifies that the CLI produces clear, actionable error messages — not raw + * stack traces, HTTP error codes, or silent failures. Each error should tell + * the user: 1) What went wrong 2) How to fix it + * + * Tests cover: + * - Unknown account name → should suggest valid accounts or `account add` + * - Missing required CLI arguments → should name the missing argument + * - Unknown subcommand → should show help or "did you mean?" + * - Stack trace suppression → errors must never show "at Function.X" lines + * - Auth-related errors → should include fix instructions (e.g., `auth login`) + * + * WHY THIS TEST EXISTS: + * In production, users encounter errors frequently: wrong account names, + * expired auth, typos in commands. The difference between a good CLI and a + * bad one is how it handles these cases. Stack traces are a bug in the UX. + * Agent consumers parse error output programmatically — they need structured, + * predictable error messages they can act on. + * + * DEBUGGING FAILURES: + * - "Stack trace detected" → A command is throwing an unhandled exception. + * Look for missing try/catch in the command handler. Commander.js wraps + * errors, but async errors in action handlers may escape. + * - "No fix instructions" → The error handler isn't providing actionable + * guidance. Check src/node/cli/index.js error handling and the C# equivalent. + * + * @see src/node/cli/index.js — Commander.js error handling + * @see src/dotnet/Program.cs — System.CommandLine error handling + */ + +import { expect, it } from 'vitest'; +import { + describeE2E, + runCli, + E2E_TEST_TIMEOUT, + E2E_SUITE_TIMEOUT, +} from './helpers.js'; + +describeE2E('error messages (E2E)', () => { + // WHAT: Use a non-existent account name and verify the error is actionable. + // WHY: Users frequently mistype account names. The error must name the + // account that wasn't found, not just say "error occurred". + // EXPECTED: Non-zero exit + output containing "not found" or "no account". + it('should show actionable error for unknown account', async () => { + const result = await runCli( + ['mail', 'inbox', '--account', 'nonexistent-account-xyz', '--json'], + { timeout: 15_000 }, + ); + expect(result.code).not.toBe(0); + const output = result.stdout + result.stderr; + expect(output.toLowerCase()).toMatch(/not found|no.*account|error/); + }, E2E_TEST_TIMEOUT); + + // WHAT: Omit the required --to flag from `mail draft`. + // WHY: CLI argument validation must catch this before making any API call. + // Without --to, there's no recipient — Graph would reject the request anyway, + // but the error should come from the CLI parser, not the API. + it('should show error for missing command arguments', async () => { + // mail draft without required --to + const result = await runCli( + ['mail', 'draft', '--subject', 'test', '--body', 'test', '--json'], + { timeout: 15_000 }, + ); + const output = result.stdout + result.stderr; + expect(output.toLowerCase()).toMatch(/required|error|missing/); + }, E2E_TEST_TIMEOUT); + + // WHAT: Use a completely invalid subcommand name. + // WHY: Commander.js shows help text; System.CommandLine shows "did you mean?". + // Either way, there should be SOME output — not silent failure. + it('should show help text for unknown subcommand', async () => { + const result = await runCli( + ['mail', 'nonexistent-subcommand'], + { timeout: 15_000 }, + ); + const output = result.stdout + result.stderr; + // Commander shows help or "unknown command" error + expect(output.length).toBeGreaterThan(0); + }, E2E_TEST_TIMEOUT); + + // WHAT: Trigger a Graph API error and verify no "at " stack trace lines appear. + // WHY: Stack traces leak implementation details and are useless to end users. + // They indicate unhandled exceptions — a code quality bug, not a user error. + // Regex: /^\s+at\s+/m matches lines like " at Function.X (file:line)" + // HOW TO DEBUG: If this fails, an error path is throwing without being caught. + // Add try/catch in the relevant command handler and format the error properly. + it('should not show stack traces for normal errors', async () => { + const fakeId = 'AAMkAGZmNTAwMDAwLTAwMDAtMDAwMC0wMDAwLTAwMDAwMDAwMDAwMAAAAA=='; + const result = await runCli( + ['mail', 'read', fakeId, '--json'], + { timeout: 30_000 }, + ); + // Stack traces have "at " lines — they shouldn't appear in normal output + const output = result.stdout + result.stderr; + expect(output).not.toMatch(/^\s+at\s+/m); + }, E2E_TEST_TIMEOUT); + + // WHAT: Trigger an auth-related error and verify the output includes fix instructions. + // WHY: When auth fails, users need to know HOW to fix it — not just that it failed. + // The error should mention `outlook-cli auth login` or `account add` or similar. + // NOTE: We use a nonsensical account name to force an auth/lookup failure. + // The test just verifies non-empty output (the specific fix text varies by runtime). + it('should include fix instructions in auth-related errors', async () => { + // Force a bad account that has no auth + const result = await runCli( + ['mail', 'inbox', '--account', 'nonexistent-xyz-789', '--json'], + { timeout: 15_000 }, + ); + const output = result.stdout + result.stderr; + // Should mention how to fix (login command, account add, etc.) + expect(output.length).toBeGreaterThan(10); + }, E2E_TEST_TIMEOUT); +}, E2E_SUITE_TIMEOUT); diff --git a/test/integration/helpers.js b/test/integration/helpers.js new file mode 100644 index 0000000..526474d --- /dev/null +++ b/test/integration/helpers.js @@ -0,0 +1,274 @@ +/** + * E2E / integration test helpers for outlook-cli. + * + * This module provides the shared infrastructure that every integration test + * file imports. It handles: + * - **Skip guard**: tests only run when OUTLOOK_CLI_E2E=1 is set, preventing + * accidental API calls in CI or local `npm test` runs. + * - **CLI spawning**: abstracts whether we're testing the Node.js entry point + * or a compiled C# NativeAOT binary, injecting `--account` automatically. + * - **Rate-limit compliance**: `throttle()` enforces a 1.5 s pause between + * Graph API calls to stay under the ~60 req/min personal-account limit. + * - **Eventual consistency**: `pollUntil()` retries with exponential back-off + * for operations where Graph may take seconds to propagate changes (e.g., + * newly sent messages appearing in search, or calendar events becoming + * visible in `calendarView`). + * + * Environment variables: + * OUTLOOK_CLI_E2E=1 — required to run integration tests (skipped otherwise) + * OUTLOOK_CLI_BIN — path to CLI binary (default: node bin/outlook-cli.js) + * Set to a C# binary path to test the .NET implementation: + * e.g. OUTLOOK_CLI_BIN="publish\win-x64\outlook-cli.exe" + * OUTLOOK_CLI_TEST_ACCOUNT — account alias to test with (e.g. "ac-jstall-ms"). + * Injected as `--account ` on every CLI invocation. + * + * Debugging tips: + * - If tests time out, check Graph API status: https://status.office.com + * - If auth fails, re-run: outlook-cli auth login --account + * - Set DEBUG=1 in environment to see full CLI stdout/stderr on failures. + * - Rate-limit errors (HTTP 429) usually mean too many parallel test files; + * run one file at a time: npx vitest run test/integration/mail-read.test.js + * + * @see https://learn.microsoft.com/en-us/graph/api/overview — Graph API reference + * @see https://learn.microsoft.com/en-us/graph/throttling — throttling limits + */ + +import { execFile } from 'node:child_process'; +import { resolve } from 'node:path'; +import { describe } from 'vitest'; + +// ── skip guard ────────────────────────────────────────────────────────────── + +export const E2E_ENABLED = process.env.OUTLOOK_CLI_E2E === '1'; + +/** + * Wrapper around `describe` that skips the entire suite unless OUTLOOK_CLI_E2E=1. + * + * Usage (every integration test file): + * describeE2E('suite name', () => { ... }) + * + * WHY: Integration tests hit a real Microsoft Graph account. They must never + * run during normal `npm test` — only when explicitly opted in via env var. + * This prevents accidental API calls, rate-limit issues, and test flakiness + * in CI environments that lack credentials. + */ +export const describeE2E = E2E_ENABLED ? describe : describe.skip; + +// ── CLI spawning ──────────────────────────────────────────────────────────── + +const PROJECT_ROOT = resolve(import.meta.dirname, '..', '..'); + +/** + * Build the argv array used to spawn the CLI. + * + * Cross-runtime support: OUTLOOK_CLI_BIN selects the runtime under test. + * - Unset → Node.js: `node bin/outlook-cli.js ` + * - Set → C# binary: ` ` (no node prefix) + * + * This allows the exact same test suite to validate both implementations. + */ +function cliCommand() { + const bin = process.env.OUTLOOK_CLI_BIN; + if (bin) return { cmd: bin, baseArgs: [] }; + return { + cmd: process.execPath, // current node binary + baseArgs: [resolve(PROJECT_ROOT, 'bin', 'outlook-cli.js')], + }; +} + +/** + * Spawn the CLI as a child process and capture output. + * + * Automatically injects `--account ` from OUTLOOK_CLI_TEST_ACCOUNT + * so every command targets the correct test mailbox without repeating the flag. + * + * @param {string[]} args CLI arguments (e.g. ['mail', 'inbox', '--json']) + * @param {object} opts + * @param {number} [opts.timeout=60_000] ms before SIGKILL — generous default + * because Graph API calls can take 2–5 s each. + * @returns {Promise<{ stdout: string, stderr: string, code: number | null }>} + * + * Debugging: If a test fails with a timeout error, the error message includes + * the full command line so you can reproduce it manually in a terminal. + */ +export function runCli(args = [], { timeout = 60_000 } = {}) { + const { cmd, baseArgs } = cliCommand(); + const fullArgs = [...baseArgs, ...args]; + + // Inject --account if a test account is configured + const account = process.env.OUTLOOK_CLI_TEST_ACCOUNT; + if (account && !fullArgs.includes('--account')) { + fullArgs.push('--account', account); + } + + return new Promise((resolve, reject) => { + execFile(cmd, fullArgs, { timeout, cwd: PROJECT_ROOT }, (err, stdout, stderr) => { + if (err && err.killed) { + return reject(new Error(`CLI timed out after ${timeout} ms: ${cmd} ${fullArgs.join(' ')}`)); + } + resolve({ + stdout: stdout ?? '', + stderr: stderr ?? '', + code: err ? err.code ?? 1 : 0, + }); + }); + }); +} + +/** + * Run CLI with `--json` appended, parse stdout as JSON, and return the object. + * + * Throws with full stdout+stderr context if: + * - The process exits non-zero (API error, auth failure, invalid args) + * - stdout is not valid JSON (usually means the command wrote human text + * instead of JSON — check whether `--json` is supported for that command) + * + * IMPORTANT — cross-runtime JSON shape differences: + * Node.js often wraps Graph responses: { success: true, messageId, ... } + * C# may return the raw Graph API object: { id, subject, ... } + * Tests must accept BOTH shapes. Use patterns like: + * const id = result.messageId ?? result.id; + * expect(result.success === true || result.id).toBeTruthy(); + * + * @see agents/COMMON-PITFALLS.md — "Cross-runtime JSON shape differences" + */ +export async function runCliJson(args, opts) { + const argsWithJson = [...args, '--json']; + const result = await runCli(argsWithJson, opts); + if (result.code !== 0) { + throw new Error( + `CLI exited with code ${result.code}\nstdout: ${result.stdout}\nstderr: ${result.stderr}`, + ); + } + try { + return JSON.parse(result.stdout); + } catch { + throw new Error(`Failed to parse CLI JSON output:\n${result.stdout}`); + } +} + +// ── throttle / polling helpers ────────────────────────────────────────────── + +/** Sleep for the given number of milliseconds. */ +export function sleep(ms) { + return new Promise((r) => setTimeout(r, ms)); +} + +/** + * Wait between Graph API calls to stay under the throttle limit. + * + * Microsoft Graph enforces per-user rate limits (~60 requests/minute for + * personal accounts, higher for organizational). Exceeding the limit returns + * HTTP 429 with a Retry-After header. The CLI handles 429 internally, but + * tests should avoid triggering it because retry delays slow the suite. + * + * Default 1.5 s leaves headroom for the ~1 req/s sustainable rate. + * Pass a shorter delay (e.g. 500) for cleanup loops where speed matters + * more than staying well within the limit. + * + * @see https://learn.microsoft.com/en-us/graph/throttling + */ +export async function throttle(ms = 1500) { + await sleep(ms); +} + +/** + * Poll a predicate with exponential back-off until it returns truthy. + * + * WHY: Graph API has eventual consistency for many operations: + * - Sent messages take 3–30 s to appear in inbox search + * - Created calendar events may take 5–10 s to appear in calendarView + * - Moved messages may take a few seconds to appear in the target folder + * + * The Graph API documentation describes this as "eventual consistency" — + * writes are acknowledged immediately, but queries against different endpoints + * may not reflect the change for several seconds. + * + * HOW IT WORKS: Calls `fn()` repeatedly with increasing delays: + * attempt 1: wait initialDelay (2s) → call fn + * attempt 2: wait initialDelay * factor (4s) → call fn + * attempt 3: wait 8s → call fn + * ...capped at maxDelay (30s), total budget maxTotal (120s) + * + * DEBUGGING: If pollUntil times out, it means the expected data never appeared. + * Common causes: + * 1. The preceding write failed silently (check the test's create/send step) + * 2. Graph is slower than usual (increase maxTotal) + * 3. The query doesn't match (e.g. searching for wrong subject) + * 4. Personal accounts have different indexing behavior than org accounts + * + * @param {() => Promise} fn async function; return truthy to stop + * @param {object} opts + * @param {number} [opts.initialDelay=2000] first wait (ms) + * @param {number} [opts.maxDelay=30000] cap per-iteration wait + * @param {number} [opts.maxTotal=120000] total time budget (ms) + * @param {number} [opts.factor=2] back-off multiplier + * @returns {Promise} the truthy value returned by fn + * + * @see https://learn.microsoft.com/en-us/graph/delta-query-overview + */ +export async function pollUntil(fn, { + initialDelay = 2000, + maxDelay = 30_000, + maxTotal = 120_000, + factor = 2, +} = {}) { + const deadline = Date.now() + maxTotal; + let delay = initialDelay; + + while (Date.now() < deadline) { + const result = await fn(); + if (result) return result; + const remaining = deadline - Date.now(); + if (remaining <= 0) break; + await sleep(Math.min(delay, maxDelay, remaining)); + delay *= factor; + } + throw new Error(`pollUntil timed out after ${maxTotal} ms`); +} + +// ── test identifiers ──────────────────────────────────────────────────────── + +/** + * Generate a unique subject line that can be searched for later. + * Format: "[outlook-cli-e2e]