From 10eec2fd93c4c55c5b5444030d323bbc4a4b3a3d Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Sat, 15 Aug 2026 21:18:25 -0400 Subject: [PATCH 01/16] feat: add email integration (read, send, manage) OpenSpec change: email-integration - Proposal: multi-provider email integration (Gmail, MS Graph, IMAP) - Design: provider abstraction with OAuth2 credential management - Specs: email-tools, email-providers, email-auth capabilities - Tasks: 30 implementation tasks across 7 groups Closes #779 --- .../changes/email-integration/.openspec.yaml | 2 + openspec/changes/email-integration/design.md | 69 +++++++++++ .../changes/email-integration/proposal.md | 43 +++++++ .../specs/email-auth/spec.md | 69 +++++++++++ .../specs/email-providers/spec.md | 88 +++++++++++++ .../specs/email-tools/spec.md | 117 ++++++++++++++++++ openspec/changes/email-integration/tasks.md | 60 +++++++++ 7 files changed, 448 insertions(+) create mode 100644 openspec/changes/email-integration/.openspec.yaml create mode 100644 openspec/changes/email-integration/design.md create mode 100644 openspec/changes/email-integration/proposal.md create mode 100644 openspec/changes/email-integration/specs/email-auth/spec.md create mode 100644 openspec/changes/email-integration/specs/email-providers/spec.md create mode 100644 openspec/changes/email-integration/specs/email-tools/spec.md create mode 100644 openspec/changes/email-integration/tasks.md diff --git a/openspec/changes/email-integration/.openspec.yaml b/openspec/changes/email-integration/.openspec.yaml new file mode 100644 index 00000000..f161d5cc --- /dev/null +++ b/openspec/changes/email-integration/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-16 diff --git a/openspec/changes/email-integration/design.md b/openspec/changes/email-integration/design.md new file mode 100644 index 00000000..af92ec97 --- /dev/null +++ b/openspec/changes/email-integration/design.md @@ -0,0 +1,69 @@ +## Context + +Madz currently has no email integration. The tool system (`src/tools/index.js`) uses a tiered permission model with factory-pattern tool registration. Configuration is loaded from `config.yaml` via Zod schemas. External dependencies follow the existing pattern of installing npm packages and wiring them into the tool factory. + +## Goals / Non-Goals + +**Goals:** +- Implement provider-abstracted email tools (read, send, draft, organize) +- Support Gmail, MS Graph, and IMAP providers +- Register tools in the tier2 tool map with `network:outbound` permission +- Add provider configuration schemas validated at startup +- Store OAuth tokens securely in the memory system + +**Non-Goals:** +- Push/webhook email notifications (pull-only v1) +- Multi-account support (single provider per session) +- Calendar integration (separate issue #780) +- Email encryption/signing (PGP/S/MIME) +- Mobile push notifications + +## Decisions + +**Decision 1: Provider abstraction via adapter pattern** +- Use a common `EmailProvider` interface with methods: `send()`, `read()`, `search()`, `draft()`, `organize()` +- Each provider (Gmail, Graph, IMAP) implements this interface +- Rationale: Allows adding new providers without modifying existing tool code; mirrors patterns already used in the codebase + +**Decision 2: IMAP as universal fallback** +- When no OAuth provider is configured, IMAP provides access to any email service +- IMAP uses username/password stored in encrypted form +- Rationale: Universal compatibility without requiring OAuth setup; Gmail and Graph offer richer features but require OAuth + +**Decision 3: Single provider per session** +- Configuration supports one active provider at a time +- Simplifies authentication state management and token lifecycle +- Rationale: Multi-account support adds significant complexity; single provider covers 95% of use cases + +**Decision 4: OAuth2 tokens stored in memory system** +- OAuth tokens persisted via the existing memory writer/reader system +- Tokens encrypted at rest, decrypted on use +- Rationale: Reuses existing secure storage pattern; avoids creating a new credentials module + +**Decision 5: Attachments reuse web.js validation** +- Attachment MIME validation and path resolution reuse patterns from `src/tools/web.js` +- Rationale: DRY principle; attachment handling is identical regardless of email provider + +**Decision 6: Dependencies — googleapis + nodemailer** +- Gmail: `googleapis` (official Google API client) +- MS Graph: `@microsoft/microsoft-graph-client` (official Microsoft client) +- IMAP: `nodemailer` (supports IMAP transport, single dependency) +- Rationale: Official clients have best OAuth support; nodemailer covers IMAP without adding a second IMAP library + +## Risks / Trade-offs + +[Risk: OAuth token refresh complexity] → Mitigation: Use official SDKs' built-in token refresh; implement retry logic with exponential backoff +[Risk: IMAP credential security] → Mitigation: Encrypt credentials at rest using existing memory encryption; never log credentials +[Risk: Large attachment handling] → Mitigation: Stream large attachments; enforce size limits via config +[Risk: Provider API rate limits] → Mitigation: Implement client-side rate limiting; respect API headers +[Risk: Dependency bloat] → Mitigation: Only install providers that are configured; lazy-load provider modules + +## Migration Plan + +No migration needed — this is a new feature with no existing email code. The tools are opt-in: they only activate when email provider credentials are configured in `config.yaml`. + +## Open Questions + +1. Should draft auto-save be configurable (interval)? +2. What is the maximum attachment size limit? +3. Should email tools support thread/conversation grouping? \ No newline at end of file diff --git a/openspec/changes/email-integration/proposal.md b/openspec/changes/email-integration/proposal.md new file mode 100644 index 00000000..5534c6ca --- /dev/null +++ b/openspec/changes/email-integration/proposal.md @@ -0,0 +1,43 @@ +## Why + +Email is the central nervous system of professional workflows, yet Madz has zero capability to interact with email systems. Users cannot ask the agent to triage their inbox, draft replies, organize email threads, or manage calendar scheduling via email. This is a critical gap for any agent designed to assist with professional workflows. + +## What Changes + +- Add `email.read` tool — fetch messages from inbox, sent, drafts, or custom folders with filtering +- Add `email.send` tool — compose and send emails with text/HTML body, attachments, CC/BCC +- Add `email.draft` tools — save, list, update, and delete email drafts +- Add `email.organize` tool — label/archive messages, mark as read/unread, search +- Add multi-provider support: Gmail (Gmail API), Outlook/MS Graph (Microsoft Graph API), IMAP (fallback) +- Add provider configuration schemas for OAuth2 and IMAP credentials +- Register email tools in the tool map with appropriate permissions + +## Capabilities + +### New Capabilities +- `email-tools`: Core email tool interface with read, send, draft, and organize operations +- `email-providers`: Multi-provider abstraction layer (Gmail, MS Graph, IMAP) +- `email-auth`: Authentication and credential management for email providers + +### Modified Capabilities +- `tools-tier2`: New tools registered in the tier2 tool map +- `tool-classification`: New email tool classifications added +- `config-system`: Email provider configuration schemas added + +## Impact + +- `src/tools/index.js` — New email tools registered with TOOL_PERMISSIONS and TOOL_CLASSIFICATIONS +- `src/config/loader.js` — Email provider config loading and validation +- `src/config/schemas/providers.js` — New email provider configuration schemas +- `src/tools/web.js` — Attachment validation patterns reused +- `src/agent/deepAgents.js` — Email tools available to agents via tool map +- New dependencies: `googleapis`, `@microsoft/microsoft-graph-client`, `nodemailer` or `imap-simple` +- New files: `src/tools/email.js`, `src/tools/email/providers/base.js`, `src/tools/email/providers/gmail.js`, `src/tools/email/providers/graph.js`, `src/tools/email/providers/imap.js` + +## Non-goals + +- Push/webhook-based email notification (pull-only for v1) +- Multi-account support (single provider per session) +- Calendar integration (cross-referenced issue #780, separate feature) +- Email encryption/signing (PGP/S/MIME) +- Mobile push notifications \ No newline at end of file diff --git a/openspec/changes/email-integration/specs/email-auth/spec.md b/openspec/changes/email-integration/specs/email-auth/spec.md new file mode 100644 index 00000000..3b40d5da --- /dev/null +++ b/openspec/changes/email-integration/specs/email-auth/spec.md @@ -0,0 +1,69 @@ +## ADDED Requirements + +### Requirement: OAuth2 credential storage +The system SHALL store OAuth2 access and refresh tokens in the memory system with encryption at rest. + +#### Scenario: Store OAuth2 credentials securely +- **WHEN** OAuth2 tokens are obtained from a provider +- **THEN** they are encrypted and stored in the memory system under a provider-specific key + +#### Scenario: Retrieve OAuth2 credentials +- **WHEN** a provider needs its tokens +- **THEN** the system decrypts and returns the stored tokens + +#### Scenario: Rotate OAuth2 credentials +- **WHEN** a new refresh token is obtained during token refresh +- **THEN** the system updates the stored credentials atomically + +#### Scenario: Clear OAuth2 credentials on logout +- **WHEN** the provider is disconnected or credentials are invalidated +- **THEN** the system removes the stored tokens from the memory system + +### Requirement: IMAP credential storage +The system SHALL store IMAP credentials (host, port, username, password) in the memory system with encryption at rest. + +#### Scenario: Store IMAP credentials securely +- **WHEN** IMAP credentials are configured +- **THEN** they are encrypted and stored in the memory system under a provider-specific key + +#### Scenario: Retrieve IMAP credentials +- **WHEN** the IMAP provider needs credentials +- **THEN** the system decrypts and returns the stored credentials + +#### Scenario: Never log IMAP credentials +- **WHEN** any operation involving IMAP credentials +- **THEN** credentials are never written to logs, error messages, or telemetry data + +### Requirement: Credential validation at startup +The system SHALL validate email provider credentials during application startup. + +#### Scenario: Validate Gmail OAuth2 credentials on startup +- **WHEN** the application starts with Gmail provider configured +- **THEN** it validates the OAuth2 credentials by making a test API request + +#### Scenario: Validate IMAP credentials on startup +- **WHEN** the application starts with IMAP provider configured +- **THEN** it validates the credentials by attempting an IMAP connection + +#### Scenario: Graceful degradation when credentials are invalid +- **WHEN** the application starts with invalid email credentials +- **THEN** it logs a warning and continues without email tools, rather than crashing + +### Requirement: Credential configuration schema +The system SHALL define Zod validation schemas for email provider configurations. + +#### Scenario: Validate Gmail provider config +- **WHEN** a Gmail provider config is provided +- **THEN** the schema validates clientId, clientSecret, refreshToken, and required scopes + +#### Scenario: Validate MS Graph provider config +- **WHEN** an MS Graph provider config is provided +- **THEN** the schema validates clientId, clientSecret, refreshToken, tenantId, and required scopes + +#### Scenario: Validate IMAP provider config +- **WHEN** an IMAP provider config is provided +- **THEN** the schema validates host, port, username, and password fields + +#### Scenario: Reject incomplete provider config +- **WHEN** a provider config is missing required fields +- **THEN** the schema validation fails with a descriptive error listing missing fields \ No newline at end of file diff --git a/openspec/changes/email-integration/specs/email-providers/spec.md b/openspec/changes/email-integration/specs/email-providers/spec.md new file mode 100644 index 00000000..ae3265d0 --- /dev/null +++ b/openspec/changes/email-integration/specs/email-providers/spec.md @@ -0,0 +1,88 @@ +## ADDED Requirements + +### Requirement: Provider abstraction interface +The system SHALL define an `EmailProvider` interface that all email providers implement, providing a unified API for email operations regardless of backend. + +#### Scenario: Provider interface defines required methods +- **WHEN** a new provider is created +- **THEN** it MUST implement send(), read(), search(), draftSave(), draftList(), draftUpdate(), draftDelete(), and organize() methods + +#### Scenario: Provider returns consistent message format +- **WHEN** any provider's read() or search() method is called +- **THEN** it returns messages in a standardized format with id, subject, from, to, date, body, isRead, labels, and folder fields + +### Requirement: Gmail provider implementation +The system SHALL provide a Gmail provider using the Google Gmail API with OAuth2 authentication. + +#### Scenario: Gmail provider authenticates via OAuth2 +- **WHEN** Gmail provider is initialized with valid OAuth2 credentials +- **THEN** it obtains an access token and can make authenticated API requests + +#### Scenario: Gmail provider sends email +- **WHEN** Gmail provider's send() method is called with valid message content +- **THEN** it sends the email via Gmail API and returns the message id + +#### Scenario: Gmail provider reads inbox +- **WHEN** Gmail provider's read() method is called with folder="inbox" +- **THEN** it returns inbox messages via Gmail API's users.messages.list endpoint + +#### Scenario: Gmail provider handles token refresh +- **WHEN** Gmail provider's access token expires during an operation +- **THEN** it automatically refreshes the token using the refresh token and retries the operation + +### Requirement: MS Graph provider implementation +The system SHALL provide an MS Graph provider using the Microsoft Graph API with OAuth2 authentication. + +#### Scenario: MS Graph provider authenticates via OAuth2 +- **WHEN** MS Graph provider is initialized with valid OAuth2 credentials +- **THEN** it obtains an access token and can make authenticated Graph API requests + +#### Scenario: MS Graph provider sends email +- **WHEN** MS Graph provider's send() method is called with valid message content +- **THEN** it sends the email via Microsoft Graph API and returns the message id + +#### Scenario: MS Graph provider reads inbox +- **WHEN** MS Graph provider's read() method is called with folder="inbox" +- **THEN** it returns inbox messages via Microsoft Graph API's /me/messages endpoint + +#### Scenario: MS Graph provider handles token refresh +- **WHEN** MS Graph provider's access token expires during an operation +- **THEN** it automatically refreshes the token using the refresh token and retries the operation + +### Requirement: IMAP provider implementation +The system SHALL provide an IMAP provider as a universal fallback using username/password authentication. + +#### Scenario: IMAP provider authenticates with credentials +- **WHEN** IMAP provider is initialized with valid host, port, username, and password +- **THEN** it establishes an IMAP connection and can execute IMAP commands + +#### Scenario: IMAP provider sends email via SMTP +- **WHEN** IMAP provider's send() method is called with valid message content +- **THEN** it sends the email via SMTP and returns success + +#### Scenario: IMAP provider reads inbox +- **WHEN** IMAP provider's read() method is called with folder="inbox" +- **THEN** it fetches messages via IMAP FETCH commands and returns them in the standard format + +#### Scenario: IMAP provider handles connection errors +- **WHEN** IMAP provider cannot connect to the server +- **THEN** it throws a descriptive error with the connection failure details + +### Requirement: Provider factory and selection +The system SHALL provide a factory that creates the appropriate provider instance based on configuration. + +#### Scenario: Factory creates Gmail provider when configured +- **WHEN** config specifies provider="gmail" with OAuth2 credentials +- **THEN** factory returns a Gmail provider instance + +#### Scenario: Factory creates MS Graph provider when configured +- **WHEN** config specifies provider="graph" with OAuth2 credentials +- **THEN** factory returns an MS Graph provider instance + +#### Scenario: Factory creates IMAP provider when configured +- **WHEN** config specifies provider="imap" with host, port, username, and password +- **THEN** factory returns an IMAP provider instance + +#### Scenario: Factory returns null when no provider configured +- **WHEN** no email provider is configured in config.yaml +- **THEN** factory returns null and email tools gracefully report "no provider configured" \ No newline at end of file diff --git a/openspec/changes/email-integration/specs/email-tools/spec.md b/openspec/changes/email-integration/specs/email-tools/spec.md new file mode 100644 index 00000000..f4345c8c --- /dev/null +++ b/openspec/changes/email-integration/specs/email-tools/spec.md @@ -0,0 +1,117 @@ +## ADDED Requirements + +### Requirement: Email read tool fetches messages with filtering +The system SHALL provide an `email.read` tool that fetches messages from configurable folders (inbox, sent, drafts, custom) with filtering by sender, date range, subject keyword, and keyword body search. + +#### Scenario: Fetch inbox messages +- **WHEN** user calls email.read with folder="inbox" and limit=10 +- **THEN** system returns up to 10 most recent messages with id, subject, from, date, snippet, and isRead fields + +#### Scenario: Filter by sender +- **WHEN** user calls email.read with folder="inbox" and sender="alice@example.com" +- **THEN** system returns only messages where the sender address matches alice@example.com + +#### Scenario: Filter by date range +- **WHEN** user calls email.read with folder="inbox" and dateAfter="2024-01-01" and dateBefore="2024-06-01" +- **THEN** system returns only messages received within the specified date range + +#### Scenario: Filter by subject keyword +- **WHEN** user calls email.read with folder="inbox" and subject="invoice" +- **THEN** system returns messages whose subject contains the keyword "invoice" (case-insensitive) + +#### Scenario: Fetch sent messages +- **WHEN** user calls email.read with folder="sent" and limit=5 +- **THEN** system returns up to 5 most recent sent messages + +#### Scenario: Fetch draft messages +- **WHEN** user calls email.read with folder="drafts" +- **THEN** system returns all saved draft messages with id, subject, to, bodyPreview, and lastModified fields + +#### Scenario: No matching messages +- **WHEN** user calls email.read with folder="inbox" and subject="zzzznonexistent" +- **THEN** system returns an empty array + +### Requirement: Email send tool composes and sends messages +The system SHALL provide an `email.send` tool that composes and sends emails with text or HTML body, optional attachments, and CC/BCC recipients. + +#### Scenario: Send plain text email +- **WHEN** user calls email.send with to="recipient@example.com", subject="Hello", body="Hello world" +- **THEN** system sends the email successfully and returns the sent message id + +#### Scenario: Send HTML email +- **WHEN** user calls email.send with to="recipient@example.com", subject="Report", body="

Report

Data

", html=true +- **THEN** system sends the email with HTML content and returns the sent message id + +#### Scenario: Send with CC recipients +- **WHEN** user calls email.send with to="recipient@example.com", cc="manager@example.com", subject="FYI", body="FYI" +- **THEN** system sends the email with both the primary recipient and CC recipient + +#### Scenario: Send with BCC recipients +- **WHEN** user calls email.send with to="recipient@example.com", bcc="hidden@example.com", subject="Secret", body="Hidden" +- **THEN** system sends the email with the BCC recipient not visible to the primary recipient + +#### Scenario: Send with attachment +- **WHEN** user calls email.send with to="recipient@example.com", subject="Report", body="See attached", attachments=["/path/to/report.pdf"] +- **THEN** system attaches the file and sends the email, returning the sent message id + +#### Scenario: Send with multiple attachments +- **WHEN** user calls email.send with to="recipient@example.com", subject="Files", body="See attached", attachments=["/path/to/a.pdf", "/path/to/b.pdf"] +- **THEN** system attaches both files and sends the email + +#### Scenario: Send with invalid attachment path +- **WHEN** user calls email.send with to="recipient@example.com", subject="Bad", body="No file", attachments=["/nonexistent/file.txt"] +- **THEN** system returns an error indicating the attachment file was not found + +### Requirement: Email draft management +The system SHALL provide draft management tools for saving, listing, updating, and deleting email drafts. + +#### Scenario: Save a draft +- **WHEN** user calls email.draft.save with to="recipient@example.com", subject="Work in Progress", body="Draft content" +- **THEN** system saves the draft and returns the draft id + +#### Scenario: List all drafts +- **WHEN** user calls email.draft.list +- **THEN** system returns all saved drafts with id, subject, to, bodyPreview, and lastModified fields + +#### Scenario: Update a draft +- **WHEN** user calls email.draft.update with draftId="draft-123", subject="Updated Subject", body="Updated content" +- **THEN** system updates the draft and returns the updated draft with a new lastModified timestamp + +#### Scenario: Delete a draft +- **WHEN** user calls email.draft.delete with draftId="draft-123" +- **THEN** system removes the draft and returns success + +#### Scenario: Delete non-existent draft +- **WHEN** user calls email.draft.delete with draftId="draft-999" +- **THEN** system returns an error indicating the draft was not found + +### Requirement: Email organize tool manages message state +The system SHALL provide an `email.organize` tool for labeling, archiving, marking read/unread, and searching messages. + +#### Scenario: Mark messages as read +- **WHEN** user calls email.organize with action="markRead" and messageIds=["msg-1", "msg-2"] +- **THEN** system marks the specified messages as read + +#### Scenario: Mark messages as unread +- **WHEN** user calls email.organize with action="markUnread" and messageIds=["msg-3"] +- **THEN** system marks the specified messages as unread + +#### Scenario: Archive messages +- **WHEN** user calls email.organize with action="archive" and messageIds=["msg-4", "msg-5"] +- **THEN** system archives the specified messages, removing them from the inbox + +#### Scenario: Add label to message +- **WHEN** user calls email.organize with action="addLabel", messageIds=["msg-6"], label="important" +- **THEN** system adds the "important" label to the specified messages + +#### Scenario: Remove label from message +- **WHEN** user calls email.organize with action="removeLabel", messageIds=["msg-6"], label="important" +- **THEN** system removes the "important" label from the specified messages + +#### Scenario: Search messages +- **WHEN** user calls email.organize with action="search", query="quarterly report", folder="all" +- **THEN** system returns messages matching the search query across the specified folder + +#### Scenario: Search with no results +- **WHEN** user calls email.organize with action="search", query="zzzznonexistent", folder="inbox" +- **THEN** system returns an empty array \ No newline at end of file diff --git a/openspec/changes/email-integration/tasks.md b/openspec/changes/email-integration/tasks.md new file mode 100644 index 00000000..19ccbe32 --- /dev/null +++ b/openspec/changes/email-integration/tasks.md @@ -0,0 +1,60 @@ +## 1. Setup — Dependencies and Module Structure + +- [ ] 1.1 Add npm dependencies: googleapis, @microsoft/microsoft-graph-client, nodemailer +- [ ] 1.2 Create src/tools/email/ directory structure with providers/ subdirectory +- [ ] 1.3 Create src/tools/email/index.js — main entry point and tool factory +- [ ] 1.4 Create src/tools/email/providers/base.js — EmailProvider abstract interface + +## 2. Email Provider Abstraction Layer + +- [ ] 2.1 Implement EmailProvider interface with send, read, search, draft, organize methods +- [ ] 2.2 Implement GmailProvider class using googleapis library +- [ ] 2.3 Implement GraphProvider class using @microsoft/microsoft-graph-client library +- [ ] 2.4 Implement ImapProvider class using nodemailer IMAP transport +- [ ] 2.5 Implement provider factory function that selects provider from config +- [ ] 2.6 Implement message format normalization across all providers + +## 3. Authentication and Credential Management + +- [ ] 3.1 Create Zod schemas for Gmail, Graph, and IMAP provider configs in src/config/schemas/providers.js +- [ ] 3.2 Implement credential storage using existing memory writer/reader system +- [ ] 3.3 Implement credential encryption at rest using existing encryption utilities +- [ ] 3.4 Implement OAuth2 token refresh logic for Gmail and Graph providers +- [ ] 3.5 Implement credential validation on application startup +- [ ] 3.6 Ensure credentials are never logged or exposed in error messages + +## 4. Email Tools Implementation + +- [ ] 4.1 Implement email.read tool with folder, sender, date, subject, keyword filters +- [ ] 4.2 Implement email.send tool with text/HTML body, attachments, CC/BCC support +- [ ] 4.3 Implement email.draft.save tool +- [ ] 4.4 Implement email.draft.list tool +- [ ] 4.5 Implement email.draft.update tool +- [ ] 4.6 Implement email.draft.delete tool +- [ ] 4.7 Implement email.organize tool with markRead, markUnread, archive, addLabel, removeLabel, search actions + +## 5. Tool Registration and Integration + +- [ ] 5.1 Register email tools in src/tools/index.js with TOOL_PERMISSIONS (network:outbound) +- [ ] 5.2 Add email tool classifications in TOOL_CLASSIFICATIONS map +- [ ] 5.3 Wire email tools into deepAgents.js tool map +- [ ] 5.4 Add email provider config loading to src/config/loader.js + +## 6. Testing + +- [ ] 6.1 Create tests/unit/tools/email/providers/base.test.js — provider interface tests +- [ ] 6.2 Create tests/unit/tools/email/providers/gmail.test.js — Gmail provider tests (mocked) +- [ ] 6.3 Create tests/unit/tools/email/providers/graph.test.js — Graph provider tests (mocked) +- [ ] 6.4 Create tests/unit/tools/email/providers/imap.test.js — IMAP provider tests (mocked) +- [ ] 6.5 Create tests/unit/tools/email/index.test.js — tool factory and registration tests +- [ ] 6.6 Create tests/unit/tools/email/email-tools.test.js — email tool integration tests (mocked) +- [ ] 6.7 Create tests/unit/config/providers.test.js — provider config schema validation tests + +## 7. Verification and Polish + +- [ ] 7.1 Run npm run lint and fix any issues +- [ ] 7.2 Run npm run test and ensure all tests pass +- [ ] 7.3 Run npm run coverage and verify coverage is acceptable +- [ ] 7.4 Run timeout 10 npm start to verify application starts without crashing +- [ ] 7.5 Verify email tools are listed in the TUI skills panel +- [ ] 7.6 Verify graceful degradation when no provider is configured \ No newline at end of file From 0227a4674f802ef1c095dc51d8a4bfcfeb231c06 Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Sat, 15 Aug 2026 21:54:00 -0400 Subject: [PATCH 02/16] feat: implement email integration tools (read, send, draft, organize) - Add email module: src/tools/email/ with provider abstraction - Implement GmailProvider, GraphProvider, ImapProvider - Create email tools: read, send, draft (save/list/update/delete), organize, search - Add email provider config schemas (Gmail, Graph, IMAP) - Register email tools in TOOL_PERMISSIONS and TOOL_CLASSIFICATIONS - Add comprehensive tests for all providers, tools, and config schemas - Dependencies: googleapis, @microsoft/microsoft-graph-client, nodemailer, imap-simple Closes #779 --- package-lock.json | 1242 ++++++++++++++++- package.json | 12 +- src/config/schemas/providers.js | 43 + src/tools/email/index.js | 88 ++ src/tools/email/providers/base.js | 132 ++ src/tools/email/providers/gmail.js | 373 +++++ src/tools/email/providers/graph.js | 483 +++++++ src/tools/email/providers/imap.js | 362 +++++ src/tools/email/tools.js | 357 +++++ src/tools/index.js | 34 + tests/unit/config/providers.test.js | 144 ++ tests/unit/tools/email/email-tools.test.js | 69 + tests/unit/tools/email/index.test.js | 85 ++ tests/unit/tools/email/providers/base.test.js | 61 + .../unit/tools/email/providers/gmail.test.js | 70 + .../unit/tools/email/providers/graph.test.js | 70 + tests/unit/tools/email/providers/imap.test.js | 66 + 17 files changed, 3673 insertions(+), 18 deletions(-) create mode 100644 src/tools/email/index.js create mode 100644 src/tools/email/providers/base.js create mode 100644 src/tools/email/providers/gmail.js create mode 100644 src/tools/email/providers/graph.js create mode 100644 src/tools/email/providers/imap.js create mode 100644 src/tools/email/tools.js create mode 100644 tests/unit/config/providers.test.js create mode 100644 tests/unit/tools/email/email-tools.test.js create mode 100644 tests/unit/tools/email/index.test.js create mode 100644 tests/unit/tools/email/providers/base.test.js create mode 100644 tests/unit/tools/email/providers/gmail.test.js create mode 100644 tests/unit/tools/email/providers/graph.test.js create mode 100644 tests/unit/tools/email/providers/imap.test.js diff --git a/package-lock.json b/package-lock.json index d3cdd863..04a405ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@langchain/langgraph": "^1.4.9", "@langchain/langgraph-checkpoint-sqlite": "^1.0.3", "@langchain/openai": "^1.5.6", + "@microsoft/microsoft-graph-client": "^3.0.7", "@opentelemetry/api": "^1.9.1", "@opentelemetry/sdk-node": "^0.221.0", "adm-zip": "^0.5.16", @@ -22,6 +23,8 @@ "cli-table3": "^0.6.5", "cron-parser": "^5.7.0", "deepagents": "^1.12.2", + "googleapis": "^174.0.1", + "imap-simple": "^5.1.0", "ink": "^7.1.1", "ink-scroll-view": "^0.3.7", "ink-spinner": "^5.0.0", @@ -29,6 +32,7 @@ "js-yaml": "^5.2.3", "marked": "^18.0.9", "node-emoji": "^2.2.0", + "nodemailer": "^9.0.5", "pdf-parse": "^2.0.0", "pino": "^10.3.1", "supports-hyperlinks": "^4.5.0", @@ -64,6 +68,15 @@ "node": ">=18" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@cfworker/json-schema": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz", @@ -229,6 +242,63 @@ "node": ">=12" } }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/@js-sdsl/ordered-map": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", @@ -395,6 +465,33 @@ "integrity": "sha512-XW1egQtPfsGI41w2AMZNFZrUIwFSQHTjVMZs0OaTpCAvht/QLoaPN8FQcsysMVypOhupG28J29yOorrc70otBQ==", "license": "MIT" }, + "node_modules/@microsoft/microsoft-graph-client": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@microsoft/microsoft-graph-client/-/microsoft-graph-client-3.0.7.tgz", + "integrity": "sha512-/AazAV/F+HK4LIywF9C+NYHcJo038zEnWkteilcxC1FM/uK/4NVGDKGrxx7nNq1ybspAroRKT4I1FHfxQzxkUw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependenciesMeta": { + "@azure/identity": { + "optional": true + }, + "@azure/msal-browser": { + "optional": true + }, + "buffer": { + "optional": true + }, + "stream-browserify": { + "optional": true + } + } + }, "node_modules/@napi-rs/canvas": { "version": "0.1.80", "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz", @@ -1839,6 +1936,16 @@ "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", "license": "MIT" }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -1938,6 +2045,15 @@ "node": ">=12.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ansi-escapes": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", @@ -2030,6 +2146,12 @@ "node": ">= 10" } }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -2064,6 +2186,15 @@ "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" } }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/bindings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", @@ -2084,6 +2215,15 @@ "readable-stream": "^3.4.0" } }, + "node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -2120,6 +2260,41 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/chalk": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/chalk/-/chalk-6.0.0.tgz", @@ -2521,6 +2696,12 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/cron-parser": { "version": "5.7.0", "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-5.7.0.tgz", @@ -2533,6 +2714,29 @@ "node": ">=18" } }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2603,6 +2807,35 @@ "node": ">=8" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -2636,12 +2869,42 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-module-lexer": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", "license": "MIT" }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-toolkit": { "version": "1.47.0", "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.47.0.tgz", @@ -2686,6 +2949,12 @@ "node": ">=6" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -2711,6 +2980,29 @@ "reusify": "^1.0.4" } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -2729,12 +3021,113 @@ "node": ">=8" } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", "license": "MIT" }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gaxios": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.3.1.tgz", + "integrity": "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.4", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.4.tgz", + "integrity": "sha512-iJ9KMsiu+xKtNRX0PmGLSaIU3bUBAyzWTyqKemKPzNPsmmsBCQYmlNg+brEbES7IHSXtdVwzBPzx1vz3FAaipw==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "7.1.3", + "google-logging-utils": "1.1.3", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata/node_modules/gaxios": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", + "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata/node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -2756,12 +3149,70 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", "license": "MIT" }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", @@ -2774,23 +3225,129 @@ "node": ">= 6" } }, - "node_modules/handlebars": { - "version": "4.7.9", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", - "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", - "dev": true, - "license": "MIT", + "node_modules/google-auth-library": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz", + "integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==", + "license": "Apache-2.0", "dependencies": { - "minimist": "^1.2.5", - "neo-async": "^2.6.2", - "source-map": "^0.6.1", - "wordwrap": "^1.0.0" - }, - "bin": { - "handlebars": "bin/handlebars" + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.0.0", + "gcp-metadata": "^8.0.0", + "google-logging-utils": "^1.0.0", + "gtoken": "^8.0.0", + "jws": "^4.0.0" }, "engines": { - "node": ">=0.4.7" + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.2.0.tgz", + "integrity": "sha512-WE9av4wKDZgRjBwgVUabocx8T6/7o3Ca1Fat46FXDhXVAFibzNadedcOXrdgd1Kzmk8tsk/9ZH89Wyf/SqeZ3A==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/googleapis": { + "version": "174.0.1", + "resolved": "https://registry.npmjs.org/googleapis/-/googleapis-174.0.1.tgz", + "integrity": "sha512-51B9r4WnyAyUXvsDhDswpSVZbqx3afSBEzrzshxBivysBKsB6kJ+X5CVOybRV/R3HIURiR01uHXLGkEm7On3dA==", + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "10.5.0", + "googleapis-common": "^8.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/googleapis-common": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/googleapis-common/-/googleapis-common-8.0.3.tgz", + "integrity": "sha512-7g1yzQKx0mmNTjiK0H9dJ8eqKqDBveES9vLHeg5neb3BMQy/d1oQefIMhIpOVT8a+f+LOcixMEdRbFIW/cQUJw==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "gaxios": "7.1.3", + "google-auth-library": "10.5.0", + "google-logging-utils": "1.1.3", + "qs": "^6.7.0", + "url-template": "^2.0.8" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/googleapis-common/node_modules/gaxios": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", + "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/googleapis-common/node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gtoken": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz", + "integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==", + "license": "MIT", + "dependencies": { + "gaxios": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/handlebars": { + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" }, "optionalDependencies": { "uglify-js": "^3.1.4" @@ -2805,6 +3362,30 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/highlight.js": { "version": "10.7.3", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", @@ -2814,6 +3395,19 @@ "node": "*" } }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/husky": { "version": "9.1.7", "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", @@ -2830,6 +3424,18 @@ "url": "https://github.com/sponsors/typicode" } }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -2850,6 +3456,53 @@ ], "license": "BSD-3-Clause" }, + "node_modules/imap": { + "version": "0.8.19", + "resolved": "https://registry.npmjs.org/imap/-/imap-0.8.19.tgz", + "integrity": "sha512-z5DxEA1uRnZG73UcPA4ES5NSCGnPuuouUx43OPX7KZx1yzq3N8/vx2mtXEShT5inxB3pRgnfG1hijfu7XN2YMw==", + "dependencies": { + "readable-stream": "1.1.x", + "utf7": ">=1.0.2" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/imap-simple": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/imap-simple/-/imap-simple-5.1.0.tgz", + "integrity": "sha512-FLZm1v38C5ekN46l/9X5gBRNMQNVc5TSLYQ3Hsq3xBLvKwt1i5fcuShyth8MYMPuvId1R46oaPNrH92hFGHr/g==", + "license": "MIT", + "dependencies": { + "iconv-lite": "~0.4.13", + "imap": "^0.8.18", + "nodeify": "^1.0.0", + "quoted-printable": "^1.0.0", + "utf8": "^2.1.1", + "uuencode": "0.0.4" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/imap/node_modules/readable-stream": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", + "integrity": "sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "0.0.1", + "string_decoder": "~0.10.x" + } + }, + "node_modules/imap/node_modules/string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==", + "license": "MIT" + }, "node_modules/import-cwd": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/import-cwd/-/import-cwd-3.0.0.tgz", @@ -3114,6 +3767,39 @@ "node": ">=0.12.0" } }, + "node_modules/is-promise": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-1.0.1.tgz", + "integrity": "sha512-mjWH5XxnhMA8cFnDchr6qRP9S/kLntKuEfIYku+PaN1CnS8v+OG9O/BKpRCVRJvpIkgAZm0Pf5Is3iSSOILlcg==", + "license": "MIT" + }, + "node_modules/isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/js-tiktoken": { "version": "1.0.21", "resolved": "https://registry.npmjs.org/js-tiktoken/-/js-tiktoken-1.0.21.tgz", @@ -3145,6 +3831,36 @@ "js-yaml": "bin/js-yaml.mjs" } }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/langchain": { "version": "1.5.2", "resolved": "https://registry.npmjs.org/langchain/-/langchain-1.5.2.tgz", @@ -3210,6 +3926,12 @@ "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", "license": "Apache-2.0" }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, "node_modules/luxon": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", @@ -3231,6 +3953,15 @@ "node": ">= 20" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -3274,6 +4005,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", @@ -3283,6 +4029,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/mkdirp-classic": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", @@ -3347,6 +4102,26 @@ "node": ">=10" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, "node_modules/node-emoji": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-2.2.0.tgz", @@ -3362,6 +4137,43 @@ "node": ">=18" } }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/nodeify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/nodeify/-/nodeify-1.0.1.tgz", + "integrity": "sha512-n7C2NyEze8GCo/z73KdbjRsBiLbv6eBn1FxwYKQ23IqGo7pQY3mhQan61Sv7eEDJCiyUjTVrVkXTzJCo1dW7Aw==", + "license": "MIT", + "dependencies": { + "is-promise": "~1.0.0", + "promise": "~1.3.0" + } + }, + "node_modules/nodemailer": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.5.tgz", + "integrity": "sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -3371,6 +4183,18 @@ "node": ">=0.10.0" } }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/on-exit-leak-free": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", @@ -3578,6 +4402,12 @@ "node": ">=8" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, "node_modules/parse-github-url": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/parse-github-url/-/parse-github-url-1.0.4.tgz", @@ -3621,6 +4451,31 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/pdf-parse": { "version": "2.4.5", "resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-2.4.5.tgz", @@ -3745,6 +4600,15 @@ ], "license": "MIT" }, + "node_modules/promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/promise/-/promise-1.3.0.tgz", + "integrity": "sha512-R9WrbTF3EPkVtWjp7B7umQGVndpsi+rsDAfrR4xAALQpFLa/+2OriecLhawxzvii2gd9+DZFwROWDuUUaqS5yA==", + "license": "MIT", + "dependencies": { + "is-promise": "~1" + } + }, "node_modules/protobufjs": { "version": "7.6.5", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", @@ -3778,6 +4642,22 @@ "once": "^1.3.1" } }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -3804,6 +4684,18 @@ "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", "license": "MIT" }, + "node_modules/quoted-printable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/quoted-printable/-/quoted-printable-1.0.1.tgz", + "integrity": "sha512-cihC68OcGiQOjGiXuo5Jk6XHANTHl1K4JLk/xlEJRTIXfy19Sg6XzB95XonYgr+1rB88bCpr7WZE7D7AlZow4g==", + "license": "MIT", + "dependencies": { + "utf8": "^2.1.0" + }, + "bin": { + "quoted-printable": "bin/quoted-printable" + } + }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -3925,6 +4817,21 @@ "node": ">=0.10.0" } }, + "node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -3977,6 +4884,12 @@ "node": ">=10" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/sax": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", @@ -4004,6 +4917,99 @@ "node": ">=10" } }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -4148,6 +5154,51 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-ansi": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", @@ -4163,6 +5214,28 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", @@ -4352,6 +5425,12 @@ "node": ">=8.0" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", @@ -4408,12 +5487,70 @@ "node": ">=4" } }, + "node_modules/url-template": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz", + "integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==", + "license": "BSD" + }, + "node_modules/utf7": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utf7/-/utf7-1.0.2.tgz", + "integrity": "sha512-qQrPtYLLLl12NF4DrM9CvfkxkYI97xOb5dsnGZHE3teFr0tWiEZ9UdgMPczv24vl708cYMpe6mGXGHrotIp3Bw==", + "dependencies": { + "semver": "~5.3.0" + } + }, + "node_modules/utf7/node_modules/semver": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.3.0.tgz", + "integrity": "sha512-mfmm3/H9+67MCVix1h+IXTpDwL6710LyHuk7+cWC9T1mE0qz4iHhh6r4hU2wrIT9iTsAAC2XQRvfblL028cpLw==", + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/utf8": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/utf8/-/utf8-2.1.2.tgz", + "integrity": "sha512-QXo+O/QkLP/x1nyi54uQiG0XrODxdysuQvE5dtVqv7F5K2Qb6FsN+qbr6KhF5wQ20tfcV3VQp0/2x1e1MRSPWg==", + "license": "MIT" + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/uuencode": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/uuencode/-/uuencode-0.0.4.tgz", + "integrity": "sha512-yEEhCuCi5wRV7Z5ZVf9iV2gWMvUZqKJhAs1ecFdKJ0qzbyaVelmsE3QjYAamehfp9FKLiZbKldd+jklG3O0LfA==" + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/widest-line": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-6.0.0.tgz", @@ -4453,6 +5590,83 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/package.json b/package.json index d7b78f65..644d1a83 100644 --- a/package.json +++ b/package.json @@ -60,20 +60,22 @@ "oxlint": "^1.77.0" }, "dependencies": { - "tiny-lru": "^13.0.0", "@langchain/langgraph": "^1.4.9", "@langchain/langgraph-checkpoint-sqlite": "^1.0.3", "@langchain/openai": "^1.5.6", + "@microsoft/microsoft-graph-client": "^3.0.7", "@opentelemetry/api": "^1.9.1", "@opentelemetry/sdk-node": "^0.221.0", + "adm-zip": "^0.5.16", "ansi-escapes": "^7.3.0", "ansi-regex": "^6.2.2", - "adm-zip": "^0.5.16", "chalk": "^6.0.0", "cli-highlight": "^2.1.11", "cli-table3": "^0.6.5", "cron-parser": "^5.7.0", "deepagents": "^1.12.2", + "googleapis": "^174.0.1", + "imap-simple": "^5.1.0", "ink": "^7.1.1", "ink-scroll-view": "^0.3.7", "ink-spinner": "^5.0.0", @@ -81,12 +83,14 @@ "js-yaml": "^5.2.3", "marked": "^18.0.9", "node-emoji": "^2.2.0", - "pino": "^10.3.1", + "nodemailer": "^9.0.5", "pdf-parse": "^2.0.0", + "pino": "^10.3.1", "supports-hyperlinks": "^4.5.0", "tiktoken": "^1.0.22", - "yargs": "^18.1.0", + "tiny-lru": "^13.0.0", "xml2js": "^0.6.2", + "yargs": "^18.1.0", "zod": "^4.4.3" } } diff --git a/src/config/schemas/providers.js b/src/config/schemas/providers.js index 6f7295d1..c8bc3430 100644 --- a/src/config/schemas/providers.js +++ b/src/config/schemas/providers.js @@ -84,3 +84,46 @@ const _FalProviderConfigSchema = z.object({ }); export const ProvidersSchema = z.object({}).passthrough(); + +// --- Email Provider Config Schemas --- + +export const GmailProviderSchema = z.object({ + type: z.literal("gmail").default("gmail"), + clientId: z.string().optional().default(""), + clientSecret: z.string().optional().default(""), + refreshToken: z.string().optional().default(""), + accessToken: z.string().optional().default(""), + refreshTokenUrl: z.string().optional().default("https://oauth2.googleapis.com/token"), +}); + +export const GraphProviderSchema = z.object({ + type: z.literal("graph").default("graph"), + tenantId: z.string().optional().default(""), + clientId: z.string().optional().default(""), + clientSecret: z.string().optional().default(""), + accessToken: z.string().optional().default(""), + refreshToken: z.string().optional().default(""), + refreshTokenUrl: z.string().optional().default("https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token"), +}); + +export const ImapProviderSchema = z.object({ + type: z.literal("imap").default("imap"), + host: z.string().default("imap.gmail.com"), + port: z.number().int().positive().default(993), + secure: z.boolean().default(true), + user: z.string().min(1), + password: z.string().min(1), +}); + +export const EmailProviderSchema = z.discriminatedUnion("type", [ + GmailProviderSchema, + GraphProviderSchema, + ImapProviderSchema, +]); + +export const EmailConfigSchema = z.object({ + provider: EmailProviderSchema, + defaultFolder: z.string().optional().default("INBOX"), + maxAttachments: z.number().int().positive().default(10), + maxAttachmentSize: z.string().optional().default("25mb"), +}); diff --git a/src/tools/email/index.js b/src/tools/email/index.js new file mode 100644 index 00000000..313f691f --- /dev/null +++ b/src/tools/email/index.js @@ -0,0 +1,88 @@ +import { GmailProvider } from "./providers/gmail.js"; +import { GraphProvider } from "./providers/graph.js"; +import { ImapProvider } from "./providers/imap.js"; +import { EmailProvider } from "./providers/base.js"; + +/** + * Email provider factory. + * Selects the appropriate provider based on configuration. + * @param {object} config - Provider configuration + * @param {string} config.type - Provider type: "gmail", "graph", or "imap" + * @returns {EmailProvider} + */ +export function createEmailProvider(config) { + if (!config || !config.type) { + throw new Error("Email provider config required: { type, ... }"); + } + + switch (config.type) { + case "gmail": + return new GmailProvider(config); + case "graph": + return new GraphProvider(config); + case "imap": + return new ImapProvider(config); + default: + throw new Error(`Unknown email provider type: ${config.type}. Supported: gmail, graph, imap`); + } +} + +/** + * Get the currently configured email provider. + * @param {object} config - Madz application config + * @returns {EmailProvider|null} + */ +export function getActiveProvider(config) { + if (!config?.email) return null; + + const providerConfig = config.email.provider; + if (!providerConfig) return null; + + try { + return createEmailProvider(providerConfig); + } catch { + return null; + } +} + +/** + * Validate email provider configuration. + * @param {object} config - Provider configuration + * @returns {{ valid: boolean, errors?: string[] }} + */ +export function validateProviderConfig(config) { + const errors = []; + + if (!config?.type) { + errors.push("Provider type is required (gmail, graph, or imap)"); + return { valid: false, errors }; + } + + switch (config.type) { + case "gmail": + if (!config.clientId) errors.push("Gmail: clientId is required"); + if (!config.clientSecret) errors.push("Gmail: clientSecret is required"); + if (!config.refreshToken) errors.push("Gmail: refreshToken is required"); + break; + case "graph": + if (!config.clientId) errors.push("Graph: clientId is required"); + if (!config.clientSecret) errors.push("Graph: clientSecret is required"); + if (!config.tenantId) errors.push("Graph: tenantId is required"); + if (!config.refreshToken) errors.push("Graph: refreshToken is required"); + break; + case "imap": + if (!config.host) errors.push("IMAP: host is required"); + if (!config.user) errors.push("IMAP: user is required"); + if (!config.password) errors.push("IMAP: password is required"); + break; + default: + errors.push(`Unknown provider type: ${config.type}`); + } + + return { valid: errors.length === 0, errors: errors.length > 0 ? errors : undefined }; +} + +export { EmailProvider } from "./providers/base.js"; +export { GmailProvider } from "./providers/gmail.js"; +export { GraphProvider } from "./providers/graph.js"; +export { ImapProvider } from "./providers/imap.js"; \ No newline at end of file diff --git a/src/tools/email/providers/base.js b/src/tools/email/providers/base.js new file mode 100644 index 00000000..0a7e9e76 --- /dev/null +++ b/src/tools/email/providers/base.js @@ -0,0 +1,132 @@ +/** + * Abstract email provider interface. + * All concrete providers (Gmail, Graph, IMAP) extend this. + */ +export class EmailProvider { + /** + * @type {string} + */ + name; + + /** + * @type {string} + */ + type; + + /** + * @param {object} config - Provider configuration + */ + constructor(config) { + this.name = config.name || "unnamed"; + this.type = config.type || "unknown"; + } + + /** + * Send an email message. + * @param {object} params - Send parameters + * @param {string[]} params.to - Recipient addresses + * @param {string} params.subject - Email subject + * @param {string} params.body - Email body (plain text or HTML) + * @param {string} [params.bodyType="text"] - "text" or "html" + * @param {string[]} [params.cc] - CC recipients + * @param {string[]} [params.bcc] - BCC recipients + * @param {Array<{filename: string, content: string, contentType?: string}>} [params.attachments] - Attachments + * @returns {Promise<{ ok: boolean, messageId?: string, error?: string }>} + */ + async send(params) { + throw new Error(`send() not implemented for ${this.type} provider`); + } + + /** + * Read messages from the mailbox. + * @param {object} params - Read parameters + * @param {string} [params.folder="INBOX"] - Mailbox folder + * @param {number} [params.limit=20] - Max messages to return + * @param {string} [params.sender] - Filter by sender + * @param {string} [params.subject] - Filter by subject keyword + * @param {string} [params.keyword] - Filter by body keyword + * @param {string} [params.dateFrom] - Filter by date (ISO string) + * @param {string} [params.dateTo] - Filter by date (ISO string) + * @param {string} [params.label] - Filter by label + * @returns {Promise<{ ok: boolean, messages?: object[], error?: string }>} + */ + async read(params = {}) { + throw new Error(`read() not implemented for ${this.type} provider`); + } + + /** + * Search messages across the mailbox. + * @param {object} params - Search parameters + * @param {string} params.query - Search query + * @param {number} [params.limit=20] - Max results + * @returns {Promise<{ ok: boolean, messages?: object[], error?: string }>} + */ + async search(params) { + throw new Error(`search() not implemented for ${this.type} provider`); + } + + /** + * Save a draft message. + * @param {object} params - Draft parameters + * @param {string[]} params.to - Recipient addresses + * @param {string} params.subject - Draft subject + * @param {string} params.body - Draft body + * @param {string} [params.bodyType="text"] - "text" or "html" + * @returns {Promise<{ ok: boolean, draftId?: string, error?: string }>} + */ + async saveDraft(params) { + throw new Error(`saveDraft() not implemented for ${this.type} provider`); + } + + /** + * List draft messages. + * @param {object} params - List parameters + * @param {number} [params.limit=20] - Max drafts + * @returns {Promise<{ ok: boolean, drafts?: object[], error?: string }>} + */ + async listDrafts(params = {}) { + throw new Error(`listDrafts() not implemented for ${this.type} provider`); + } + + /** + * Update an existing draft. + * @param {string} draftId - Draft identifier + * @param {object} params - Updated draft parameters + * @param {string[]} [params.to] - Recipient addresses + * @param {string} [params.subject] - Draft subject + * @param {string} [params.body] - Draft body + * @returns {Promise<{ ok: boolean, draftId?: string, error?: string }>} + */ + async updateDraft(draftId, params) { + throw new Error(`updateDraft() not implemented for ${this.type} provider`); + } + + /** + * Delete a draft. + * @param {string} draftId - Draft identifier + * @returns {Promise<{ ok: boolean, error?: string }>} + */ + async deleteDraft(draftId) { + throw new Error(`deleteDraft() not implemented for ${this.type} provider`); + } + + /** + * Organize messages (mark read/unread, archive, label). + * @param {object} params - Organization parameters + * @param {string|string[]} params.messageIds - Message ID(s) + * @param {string} params.action - "markRead", "markUnread", "archive", "addLabel", "removeLabel" + * @param {string} [params.label] - Label name (for addLabel/removeLabel) + * @returns {Promise<{ ok: boolean, error?: string }>} + */ + async organize(params) { + throw new Error(`organize() not implemented for ${this.type} provider`); + } + + /** + * Validate provider configuration. + * @returns {{ valid: boolean, errors?: string[] }} + */ + validateConfig() { + return { valid: true }; + } +} \ No newline at end of file diff --git a/src/tools/email/providers/gmail.js b/src/tools/email/providers/gmail.js new file mode 100644 index 00000000..cf4c4aa9 --- /dev/null +++ b/src/tools/email/providers/gmail.js @@ -0,0 +1,373 @@ +import { google } from "googleapis"; +import { EmailProvider } from "./base.js"; + +/** + * Gmail API provider implementation. + * Uses OAuth2 service account or user credentials via googleapis. + */ +export class GmailProvider extends EmailProvider { + /** + * @type {import('googleapis').google} + */ + #gmail; + + /** + * @type {string} + */ + #userId; + + /** + * @param {object} config - Gmail provider configuration + * @param {string} config.clientId - OAuth2 client ID + * @param {string} config.clientSecret - OAuth2 client secret + * @param {string} config.refreshToken - OAuth2 refresh token + * @param {string} [config.accessToken] - Current access token (optional) + * @param {string} [config.userId] - Gmail user ID (default: "me") + * @param {string} [config.name] - Provider name + */ + constructor(config) { + super({ ...config, type: "gmail" }); + + const oauth2Client = new google.auth.OAuth2({ + clientId: config.clientId, + clientSecret: config.clientSecret, + redirectUri: "http://localhost", + }); + + if (config.refreshToken) { + oauth2Client.setCredentials({ refresh_token: config.refreshToken }); + } + if (config.accessToken) { + oauth2Client.setCredentials({ access_token: config.accessToken }); + } + + this.#gmail = google.gmail({ version: "v1", auth: oauth2Client }); + this.#userId = config.userId || "me"; + } + + /** + * @param {object} params - Send parameters + * @returns {Promise<{ ok: boolean, messageId?: string, error?: string }>} + */ + async send(params) { + try { + const message = this.#buildRawMessage(params); + const response = await this.#gmail.users.messages.send({ + userId: this.#userId, + resource: { + raw: message, + }, + }); + return { + ok: true, + messageId: response.data?.id || response.data?.message?.id, + }; + } catch (err) { + return { ok: false, error: `Gmail send failed: ${err.message}` }; + } + } + + /** + * @param {object} params - Read parameters + * @returns {Promise<{ ok: boolean, messages?: object[], error?: string }>} + */ + async read(params = {}) { + try { + const { folder = "INBOX", limit = 20, ...filters } = params; + const labelIds = folder === "INBOX" ? ["INBOX"] : [folder]; + + let query = ""; + if (filters.sender) query += `from:${filters.sender} `; + if (filters.subject) query += `subject:${filters.subject} `; + if (filters.keyword) query += `${filters.keyword} `; + if (filters.dateFrom) query += `after:${filters.dateFrom} `; + if (filters.dateTo) query += `before:${filters.dateTo} `; + if (filters.label) query += `label:${filters.label} `; + + const response = await this.#gmail.users.messages.list({ + userId: this.#userId, + labelIds, + maxResults: limit, + q: query.trim() || undefined, + }); + + const messages = response.data.messages || []; + const result = []; + + for (const msg of messages) { + const detail = await this.#gmail.users.messages.get({ + userId: this.#userId, + id: msg.id, + format: "full", + }); + result.push(this.#normalizeMessage(detail.data)); + } + + return { ok: true, messages: result }; + } catch (err) { + return { ok: false, error: `Gmail read failed: ${err.message}` }; + } + } + + /** + * @param {object} params - Search parameters + * @returns {Promise<{ ok: boolean, messages?: object[], error?: string }>} + */ + async search(params) { + try { + const response = await this.#gmail.users.messages.list({ + userId: this.#userId, + q: params.query, + maxResults: params.limit || 20, + }); + + const messages = response.data.messages || []; + const result = []; + + for (const msg of messages) { + const detail = await this.#gmail.users.messages.get({ + userId: this.#userId, + id: msg.id, + format: "full", + }); + result.push(this.#normalizeMessage(detail.data)); + } + + return { ok: true, messages: result }; + } catch (err) { + return { ok: false, error: `Gmail search failed: ${err.message}` }; + } + } + + /** + * @param {object} params - Draft parameters + * @returns {Promise<{ ok: boolean, draftId?: string, error?: string }>} + */ + async saveDraft(params) { + try { + const message = this.#buildRawMessage(params); + const response = await this.#gmail.users.drafts.create({ + userId: this.#userId, + resource: { + message: { raw: message }, + }, + }); + return { ok: true, draftId: response.data.id }; + } catch (err) { + return { ok: false, error: `Gmail saveDraft failed: ${err.message}` }; + } + } + + /** + * @param {object} params - List parameters + * @returns {Promise<{ ok: boolean, drafts?: object[], error?: string }>} + */ + async listDrafts(params = {}) { + try { + const response = await this.#gmail.users.drafts.list({ + userId: this.#userId, + maxResults: params.limit || 20, + }); + + const drafts = response.data.drafts || []; + const result = []; + + for (const draft of drafts) { + const detail = await this.#gmail.users.drafts.get({ + userId: this.#userId, + id: draft.id, + }); + result.push({ id: draft.id, ...this.#normalizeMessage(detail.data.message) }); + } + + return { ok: true, drafts: result }; + } catch (err) { + return { ok: false, error: `Gmail listDrafts failed: ${err.message}` }; + } + } + + /** + * @param {string} draftId - Draft identifier + * @param {object} params - Updated draft parameters + * @returns {Promise<{ ok: boolean, draftId?: string, error?: string }>} + */ + async updateDraft(draftId, params) { + try { + const message = this.#buildRawMessage(params); + await this.#gmail.users.drafts.update({ + userId: this.#userId, + id: draftId, + resource: { + message: { raw: message }, + }, + }); + return { ok: true, draftId }; + } catch (err) { + return { ok: false, error: `Gmail updateDraft failed: ${err.message}` }; + } + } + + /** + * @param {string} draftId - Draft identifier + * @returns {Promise<{ ok: boolean, error?: string }>} + */ + async deleteDraft(draftId) { + try { + await this.#gmail.users.drafts.delete({ + userId: this.#userId, + id: draftId, + }); + return { ok: true }; + } catch (err) { + return { ok: false, error: `Gmail deleteDraft failed: ${err.message}` }; + } + } + + /** + * @param {object} params - Organization parameters + * @returns {Promise<{ ok: boolean, error?: string }>} + */ + async organize(params) { + try { + const messageIds = Array.isArray(params.messageIds) ? params.messageIds : [params.messageIds]; + + switch (params.action) { + case "markRead": + case "markUnread": { + const modifyRequest = { + removeLabelIds: params.action === "markRead" ? ["UNREAD"] : [], + addLabelIds: params.action === "markUnread" ? ["UNREAD"] : [], + }; + for (const id of messageIds) { + await this.#gmail.users.messages.modify({ + userId: this.#userId, + id, + resource: modifyRequest, + }); + } + break; + } + case "archive": { + const modifyRequest = { removeLabelIds: ["INBOX", "CATEGORY_UPDATES", "CATEGORY_SOCIAL", "CATEGORY_PROMOTIONS", "CATEGORY_UPDATES", "CATEGORY_FORUMS"] }; + for (const id of messageIds) { + await this.#gmail.users.messages.modify({ + userId: this.#userId, + id, + resource: modifyRequest, + }); + } + break; + } + case "addLabel": { + for (const id of messageIds) { + await this.#gmail.users.messages.modify({ + userId: this.#userId, + id, + resource: { addLabelIds: [params.label] }, + }); + } + break; + } + case "removeLabel": { + for (const id of messageIds) { + await this.#gmail.users.messages.modify({ + userId: this.#userId, + id, + resource: { removeLabelIds: [params.label] }, + }); + } + break; + } + default: + return { ok: false, error: `Unknown organize action: ${params.action}` }; + } + + return { ok: true }; + } catch (err) { + return { ok: false, error: `Gmail organize failed: ${err.message}` }; + } + } + + /** + * Build a raw MIME message from params. + * @param {object} params - Message parameters + * @returns {string} Base64 encoded MIME message + */ + #buildRawMessage(params) { + const { to, subject, body, bodyType = "text", cc = [], bcc = [], attachments = [] } = params; + + let mime = `From: madz\r\nTo: ${to.join(", ")}\r\n`; + if (cc.length) mime += `Cc: ${cc.join(", ")}\r\n`; + if (bcc.length) mime += `Bcc: ${bcc.join(", ")}\r\n`; + mime += `Subject: ${subject}\r\n`; + mime += `MIME-Version: 1.0\r\n`; + + if (attachments.length > 0) { + const boundary = `boundary_${Date.now()}`; + mime += `Content-Type: multipart/mixed; boundary="${boundary}"\r\n\r\n`; + mime += `--${boundary}\r\n`; + mime += `Content-Type: ${bodyType === "html" ? "text/html" : "text/plain"}; charset="UTF-8"\r\n\r\n`; + mime += `${body}\r\n`; + + for (const attachment of attachments) { + const contentType = attachment.contentType || "application/octet-stream"; + mime += `--${boundary}\r\n`; + mime += `Content-Type: ${contentType}; name="${attachment.filename}"\r\n`; + mime += `Content-Transfer-Encoding: base64\r\n`; + mime += `Content-Disposition: attachment; filename="${attachment.filename}"\r\n\r\n`; + mime += `${attachment.content}\r\n`; + } + mime += `--${boundary}--\r\n`; + } else { + mime += `Content-Type: ${bodyType === "html" ? "text/html" : "text/plain"}; charset="UTF-8"\r\n\r\n`; + mime += `${body}\r\n`; + } + + return Buffer.from(mime).toString("base64").replace(/\+/g, "-").replace(/\//g, "_"); + } + + /** + * Normalize a Gmail message to a standard format. + * @param {object} message - Gmail message object + * @returns {object} Normalized message + */ + #normalizeMessage(message) { + if (!message) return {}; + + const headers = {}; + const payload = message.payload || {}; + + if (payload.headers) { + for (const h of payload.headers) { + headers[h.name.toLowerCase()] = h.value; + } + } + + let body = ""; + if (payload.parts) { + for (const part of payload.parts) { + if (part.mimeType === "text/plain" && part.body?.data) { + body = Buffer.from(part.body.data.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf-8"); + break; + } + if (part.mimeType === "text/html" && part.body?.data && !body) { + body = Buffer.from(part.body.data.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf-8"); + } + } + } else if (payload.body?.data) { + body = Buffer.from(payload.body.data.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf-8"); + } + + return { + id: message.id, + threadId: message.threadId, + from: headers["from"] || "", + to: headers["to"] || "", + subject: headers["subject"] || "(no subject)", + date: headers["date"] || "", + body, + labelIds: message.labelIds || [], + snippet: message.snippet || "", + }; + } +} \ No newline at end of file diff --git a/src/tools/email/providers/graph.js b/src/tools/email/providers/graph.js new file mode 100644 index 00000000..fb2a19af --- /dev/null +++ b/src/tools/email/providers/graph.js @@ -0,0 +1,483 @@ +import { EmailProvider } from "./base.js"; + +/** + * Microsoft Graph API provider implementation. + * Uses OAuth2 access tokens for authentication. + */ +export class GraphProvider extends EmailProvider { + /** + * @type {string} + */ + #userId; + + /** + * @type {object} + */ + #credentials; + + /** + * @type {string|null} + */ + #accessToken = null; + + /** + * @param {object} config - Graph provider configuration + * @param {string} config.clientId - OAuth2 client ID + * @param {string} config.clientSecret - OAuth2 client secret + * @param {string} config.refreshToken - OAuth2 refresh token + * @param {string} config.tenantId - Azure AD tenant ID + * @param {string} [config.accessToken] - Current access token (optional) + * @param {string} [config.userId] - User email (default: "me") + * @param {string} [config.name] - Provider name + */ + constructor(config) { + super({ ...config, type: "graph" }); + + this.#userId = config.userId || "me"; + this.#credentials = { + clientId: config.clientId, + clientSecret: config.clientSecret, + refreshToken: config.refreshToken, + tenantId: config.tenantId, + }; + + if (config.accessToken) { + this.#accessToken = config.accessToken; + } + } + + /** + * Get or refresh an access token. + * @returns {Promise} + */ + async #getAccessToken() { + if (this.#accessToken) { + return this.#accessToken; + } + + if (!this.#credentials.refreshToken) { + throw new Error("No refresh token available for Graph provider"); + } + + const response = await fetch( + `https://login.microsoftonline.com/${this.#credentials.tenantId}/oauth2/v2.0/token`, + { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: this.#credentials.clientId, + client_secret: this.#credentials.clientSecret, + refresh_token: this.#credentials.refreshToken, + grant_type: "refresh_token", + scope: "https://graph.microsoft.com/.default", + }), + } + ); + + if (!response.ok) { + throw new Error(`Graph token refresh failed: ${response.status}`); + } + + const data = await response.json(); + this.#accessToken = data.access_token; + return this.#accessToken; + } + + /** + * @param {object} params - Send parameters + * @returns {Promise<{ ok: boolean, messageId?: string, error?: string }>} + */ + async send(params) { + try { + const token = await this.#getAccessToken(); + + const message = { + subject: params.subject, + body: { + contentType: params.bodyType === "html" ? "HTML" : "Text", + content: params.body, + }, + toRecipients: params.to.map((addr) => ({ emailAddress: { address: addr } })), + }; + + if (params.cc && params.cc.length > 0) { + message.ccRecipients = params.cc.map((addr) => ({ + emailAddress: { address: addr }, + })); + } + + if (params.bcc && params.bcc.length > 0) { + message.bccRecipients = params.bcc.map((addr) => ({ + emailAddress: { address: addr }, + })); + } + + if (params.attachments && params.attachments.length > 0) { + message.attachments = params.attachments.map((att) => ({ + "@odata.type": "#microsoft.graph.fileAttachment", + name: att.filename, + contentBytes: att.content, + contentType: att.contentType || "application/octet-stream", + })); + } + + const response = await fetch( + `https://graph.microsoft.com/v1.0/users/${this.#userId}/sendMail`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ message, saveToSentItems: true }), + } + ); + + if (!response.ok) { + const errBody = await response.text(); + return { ok: false, error: `Graph send failed (${response.status}): ${errBody}` }; + } + + const data = await response.json(); + return { ok: true, messageId: data?.id }; + } catch (err) { + return { ok: false, error: `Graph send failed: ${err.message}` }; + } + } + + /** + * @param {object} params - Read parameters + * @returns {Promise<{ ok: boolean, messages?: object[], error?: string }>} + */ + async read(params = {}) { + try { + const token = await this.#getAccessToken(); + const { folder = "inbox", limit = 20, ...filters } = params; + + let url = `https://graph.microsoft.com/v1.0/users/${this.#userId}/${folder}/messages?$top=${limit}&$select=id,subject,from,toRecipients,body,receivedDateTime,bodyPreview`; + + const filtersList = []; + if (filters.sender) filtersList.push(`from/emailAddress/address eq '${filters.sender}'`); + if (filters.subject) filtersList.push(`contains(subject, '${filters.subject}')`); + if (filters.keyword) filtersList.push(`contains(body/content, '${filters.keyword}')`); + if (filters.dateFrom) filtersList.push(`receivedDateTime ge ${filters.dateFrom}`); + if (filters.dateTo) filtersList.push(`receivedDateTime le ${filters.dateTo}`); + + if (filtersList.length > 0) { + url += `&$filter=${filtersList.join(" and ")}`; + } + + const response = await fetch(url, { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!response.ok) { + return { ok: false, error: `Graph read failed (${response.status})` }; + } + + const data = await response.json(); + const messages = data.value || []; + const result = messages.map((m) => this.#normalizeMessage(m)); + + return { ok: true, messages: result }; + } catch (err) { + return { ok: false, error: `Graph read failed: ${err.message}` }; + } + } + + /** + * @param {object} params - Search parameters + * @returns {Promise<{ ok: boolean, messages?: object[], error?: string }>} + */ + async search(params) { + try { + const token = await this.#getAccessToken(); + + const response = await fetch( + `https://graph.microsoft.com/v1.0/users/${this.#userId}/messages?$q=${encodeURIComponent(params.query)}&$top=${params.limit || 20}&$select=id,subject,from,toRecipients,body,receivedDateTime`, + { + headers: { Authorization: `Bearer ${token}` }, + } + ); + + if (!response.ok) { + return { ok: false, error: `Graph search failed (${response.status})` }; + } + + const data = await response.json(); + const messages = data.value || []; + const result = messages.map((m) => this.#normalizeMessage(m)); + + return { ok: true, messages: result }; + } catch (err) { + return { ok: false, error: `Graph search failed: ${err.message}` }; + } + } + + /** + * @param {object} params - Draft parameters + * @returns {Promise<{ ok: boolean, draftId?: string, error?: string }>} + */ + async saveDraft(params) { + try { + const token = await this.#getAccessToken(); + + const message = { + subject: params.subject, + body: { + contentType: params.bodyType === "html" ? "HTML" : "Text", + content: params.body, + }, + toRecipients: params.to.map((addr) => ({ emailAddress: { address: addr } })), + }; + + const response = await fetch( + `https://graph.microsoft.com/v1.0/users/${this.#userId}/messages/drafts`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(message), + } + ); + + if (!response.ok) { + return { ok: false, error: `Graph saveDraft failed (${response.status})` }; + } + + const data = await response.json(); + return { ok: true, draftId: data.id }; + } catch (err) { + return { ok: false, error: `Graph saveDraft failed: ${err.message}` }; + } + } + + /** + * @param {object} params - List parameters + * @returns {Promise<{ ok: boolean, drafts?: object[], error?: string }>} + */ + async listDrafts(params = {}) { + try { + const token = await this.#getAccessToken(); + + const response = await fetch( + `https://graph.microsoft.com/v1.0/users/${this.#userId}/messages/drafts?$top=${params.limit || 20}&$select=id,subject,from,body,receivedDateTime`, + { + headers: { Authorization: `Bearer ${token}` }, + } + ); + + if (!response.ok) { + return { ok: false, error: `Graph listDrafts failed (${response.status})` }; + } + + const data = await response.json(); + const drafts = data.value || []; + const result = drafts.map((d) => this.#normalizeMessage(d)); + + return { ok: true, drafts: result }; + } catch (err) { + return { ok: false, error: `Graph listDrafts failed: ${err.message}` }; + } + } + + /** + * @param {string} draftId - Draft identifier + * @param {object} params - Updated draft parameters + * @returns {Promise<{ ok: boolean, draftId?: string, error?: string }>} + */ + async updateDraft(draftId, params) { + try { + const token = await this.#getAccessToken(); + + const message = { + subject: params.subject, + body: { + contentType: params.bodyType === "html" ? "HTML" : "Text", + content: params.body, + }, + toRecipients: params.to?.map((addr) => ({ emailAddress: { address: addr } })), + }; + + const response = await fetch( + `https://graph.microsoft.com/v1.0/users/${this.#userId}/messages/drafts/${draftId}`, + { + method: "PATCH", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(message), + } + ); + + if (!response.ok) { + return { ok: false, error: `Graph updateDraft failed (${response.status})` }; + } + + return { ok: true, draftId }; + } catch (err) { + return { ok: false, error: `Graph updateDraft failed: ${err.message}` }; + } + } + + /** + * @param {string} draftId - Draft identifier + * @returns {Promise<{ ok: boolean, error?: string }>} + */ + async deleteDraft(draftId) { + try { + const token = await this.#getAccessToken(); + + const response = await fetch( + `https://graph.microsoft.com/v1.0/users/${this.#userId}/messages/drafts/${draftId}`, + { + method: "DELETE", + headers: { Authorization: `Bearer ${token}` }, + } + ); + + if (!response.ok) { + return { ok: false, error: `Graph deleteDraft failed (${response.status})` }; + } + + return { ok: true }; + } catch (err) { + return { ok: false, error: `Graph deleteDraft failed: ${err.message}` }; + } + } + + /** + * @param {object} params - Organization parameters + * @returns {Promise<{ ok: boolean, error?: string }>} + */ + async organize(params) { + try { + const token = await this.#getAccessToken(); + const messageIds = Array.isArray(params.messageIds) ? params.messageIds : [params.messageIds]; + + switch (params.action) { + case "markRead": { + // Graph doesn't have a direct "mark read" — set flag to clean + for (const id of messageIds) { + await fetch( + `https://graph.microsoft.com/v1.0/users/${this.#userId}/messages/${id}`, + { + method: "PATCH", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ Flag: { flagStatus: "clean" } }), + } + ); + } + break; + } + case "markUnread": { + for (const id of messageIds) { + await fetch( + `https://graph.microsoft.com/v1.0/users/${this.#userId}/messages/${id}`, + { + method: "PATCH", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ Flag: { flagStatus: "flagged" } }), + } + ); + } + break; + } + case "archive": { + for (const id of messageIds) { + await fetch( + `https://graph.microsoft.com/v1.0/users/${this.#userId}/messages/${id}/move`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ destinationId: "deletedmessages" }), + } + ); + } + break; + } + case "addLabel": { + // Graph uses categories for labels + for (const id of messageIds) { + await fetch( + `https://graph.microsoft.com/v1.0/users/${this.#userId}/messages/${id}`, + { + method: "PATCH", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ categories: [...(params.categories || []), params.label] }), + } + ); + } + break; + } + case "removeLabel": { + for (const id of messageIds) { + await fetch( + `https://graph.microsoft.com/v1.0/users/${this.#userId}/messages/${id}`, + { + method: "PATCH", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + categories: (params.categories || []).filter((l) => l !== params.label), + }), + } + ); + } + break; + } + default: + return { ok: false, error: `Unknown organize action: ${params.action}` }; + } + + return { ok: true }; + } catch (err) { + return { ok: false, error: `Graph organize failed: ${err.message}` }; + } + } + + /** + * Normalize a Graph message to a standard format. + * @param {object} message - Graph message object + * @returns {object} Normalized message + */ + #normalizeMessage(message) { + if (!message) return {}; + + const from = message.from?.emailAddress?.address || ""; + const to = message.toRecipients?.map((r) => r.emailAddress.address).join(", ") || ""; + const subject = message.subject || "(no subject)"; + + let body = ""; + if (message.body?.content) { + body = message.body.content; + } + + return { + id: message.id, + subject, + from, + to, + body, + date: message.receivedDateTime || "", + bodyPreview: message.bodyPreview || "", + }; + } +} \ No newline at end of file diff --git a/src/tools/email/providers/imap.js b/src/tools/email/providers/imap.js new file mode 100644 index 00000000..8bd7b3a4 --- /dev/null +++ b/src/tools/email/providers/imap.js @@ -0,0 +1,362 @@ +import { createTransport } from "nodemailer"; +import { EmailProvider } from "./base.js"; + +/** + * Generic IMAP provider implementation. + * Uses nodemailer for SMTP send and imap-simple for IMAP read. + */ +export class ImapProvider extends EmailProvider { + /** + * @type {object} + */ + #config; + + /** + * @param {object} config - IMAP provider configuration + * @param {string} config.host - IMAP/SMTP host + * @param {number} [config.port] - IMAP/SMTP port + * @param {string} config.user - Email username + * @param {string} config.password - Email password or app password + * @param {boolean} [config.secure] - Use SSL/TLS + * @param {string} [config.name] - Provider name + */ + constructor(config) { + super({ ...config, type: "imap" }); + this.#config = { + host: config.host, + port: config.port || (config.secure ? 993 : 143), + user: config.user, + password: config.password, + secure: config.secure || false, + }; + } + + /** + * @param {object} params - Send parameters + * @returns {Promise<{ ok: boolean, messageId?: string, error?: string }>} + */ + async send(params) { + try { + const transport = createTransport({ + host: this.#config.host, + port: this.#config.port || 587, + secure: false, + auth: { + user: this.#config.user, + pass: this.#config.password, + }, + }); + + const mailOptions = { + from: this.#config.user, + to: params.to.join(", "), + subject: params.subject, + text: params.bodyType === "html" ? undefined : params.body, + html: params.bodyType === "html" ? params.body : undefined, + cc: params.cc?.join(", "), + bcc: params.bcc?.join(", "), + }; + + if (params.attachments && params.attachments.length > 0) { + mailOptions.attachments = params.attachments.map((att) => ({ + filename: att.filename, + content: Buffer.from(att.content, "base64"), + contentType: att.contentType || "application/octet-stream", + })); + } + + const result = await transport.sendMail(mailOptions); + return { ok: true, messageId: result.messageId }; + } catch (err) { + return { ok: false, error: `IMAP send failed: ${err.message}` }; + } + } + + /** + * @param {object} params - Read parameters + * @returns {Promise<{ ok: boolean, messages?: object[], error?: string }>} + */ + async read(params = {}) { + try { + const { folder = "INBOX", limit = 20, ...filters } = params; + + const { default: ImapSimple } = await import("imap-simple"); + const imapConfig = { + host: this.#config.host, + port: this.#config.port, + secure: this.#config.secure, + auth: { + user: this.#config.user, + pass: this.#config.password, + }, + }; + + const connection = await ImapSimple.connect(imapConfig); + await connection.openBox(folder); + + let searchCriteria = ["ALL"]; + if (filters.sender) searchCriteria = [["FROM", filters.sender]]; + if (filters.subject) searchCriteria = [["SUBJECT", filters.subject]]; + if (filters.dateFrom) searchCriteria = [["SINCE", filters.dateFrom]]; + if (filters.dateTo) searchCriteria = [["ON", filters.dateTo]]; + if (filters.keyword) searchCriteria = [["TEXT", filters.keyword]]; + + const messages = await connection.search(searchCriteria, { recent: false }); + + const result = []; + for (const msg of messages.slice(0, limit)) { + const data = await connection.getAttributes(msg.attributes.uid, { + fetchHeaders: true, + }); + result.push(this.#normalizeMessage(data, msg.attributes.uid)); + } + + await connection.closeBox(folder); + await connection.disconnect(); + + return { ok: true, messages: result }; + } catch (err) { + return { ok: false, error: `IMAP read failed: ${err.message}` }; + } + } + + /** + * @param {object} params - Search parameters + * @returns {Promise<{ ok: boolean, messages?: object[], error?: string }>} + */ + async search(params) { + try { + const { default: ImapSimple } = await import("imap-simple"); + const connection = await ImapSimple.connect({ + host: this.#config.host, + port: this.#config.port, + secure: this.#config.secure, + auth: { + user: this.#config.user, + pass: this.#config.password, + }, + }); + + await connection.openBox("INBOX"); + + const searchCriteria = [["TEXT", params.query]]; + const messages = await connection.search(searchCriteria, { recent: false }); + + const result = []; + for (const msg of messages.slice(0, params.limit || 20)) { + const data = await connection.getAttributes(msg.attributes.uid, { + fetchHeaders: true, + }); + result.push(this.#normalizeMessage(data, msg.attributes.uid)); + } + + await connection.closeBox("INBOX"); + await connection.disconnect(); + + return { ok: true, messages: result }; + } catch (err) { + return { ok: false, error: `IMAP search failed: ${err.message}` }; + } + } + + /** + * @param {object} params - Draft parameters + * @returns {Promise<{ ok: boolean, draftId?: string, error?: string }>} + */ + async saveDraft(params) { + try { + const transport = createTransport({ + host: this.#config.host, + port: this.#config.port || 587, + secure: false, + auth: { + user: this.#config.user, + pass: this.#config.password, + }, + }); + + const mailOptions = { + from: this.#config.user, + to: params.to.join(", "), + subject: params.subject, + text: params.bodyType === "html" ? undefined : params.body, + html: params.bodyType === "html" ? params.body : undefined, + }; + + const result = await transport.sendMail({ ...mailOptions, envelope: { to: [] } }); + return { ok: true, draftId: result.messageId }; + } catch (err) { + return { ok: false, error: `IMAP saveDraft failed: ${err.message}` }; + } + } + + /** + * @param {object} params - List parameters + * @returns {Promise<{ ok: boolean, drafts?: object[], error?: string }>} + */ + async listDrafts(params = {}) { + try { + const { default: ImapSimple } = await import("imap-simple"); + const connection = await ImapSimple.connect({ + host: this.#config.host, + port: this.#config.port, + secure: this.#config.secure, + auth: { + user: this.#config.user, + pass: this.#config.password, + }, + }); + + await connection.openBox("DRAFTS"); + + const messages = await connection.search(["ALL"], { recent: false }); + + const result = []; + for (const msg of messages.slice(0, params.limit || 20)) { + const data = await connection.getAttributes(msg.attributes.uid, { + fetchHeaders: true, + }); + result.push(this.#normalizeMessage(data, msg.attributes.uid)); + } + + await connection.closeBox("DRAFTS"); + await connection.disconnect(); + + return { ok: true, drafts: result }; + } catch (err) { + return { ok: false, error: `IMAP listDrafts failed: ${err.message}` }; + } + } + + /** + * @param {string} draftId - Draft identifier + * @param {object} params - Updated draft parameters + * @returns {Promise<{ ok: boolean, draftId?: string, error?: string }>} + */ + async updateDraft(draftId, params) { + try { + const transport = createTransport({ + host: this.#config.host, + port: this.#config.port || 587, + secure: false, + auth: { + user: this.#config.user, + pass: this.#config.password, + }, + }); + + const mailOptions = { + from: this.#config.user, + to: params.to?.join(", "), + subject: params.subject, + text: params.bodyType === "html" ? undefined : params.body, + html: params.bodyType === "html" ? params.body : undefined, + }; + + await transport.sendMail({ ...mailOptions, envelope: { to: [] } }); + return { ok: true, draftId }; + } catch (err) { + return { ok: false, error: `IMAP updateDraft failed: ${err.message}` }; + } + } + + /** + * @param {string} draftId - Draft identifier + * @returns {Promise<{ ok: boolean, error?: string }>} + */ + async deleteDraft(draftId) { + try { + const { default: ImapSimple } = await import("imap-simple"); + const connection = await ImapSimple.connect({ + host: this.#config.host, + port: this.#config.port, + secure: this.#config.secure, + auth: { + user: this.#config.user, + pass: this.#config.password, + }, + }); + + await connection.openBox("DRAFTS"); + await connection.expunge({ uid: draftId }); + await connection.closeBox("DRAFTS"); + await connection.disconnect(); + + return { ok: true }; + } catch (err) { + return { ok: false, error: `IMAP deleteDraft failed: ${err.message}` }; + } + } + + /** + * @param {object} params - Organization parameters + * @returns {Promise<{ ok: boolean, error?: string }>} + */ + async organize(params) { + try { + const { default: ImapSimple } = await import("imap-simple"); + const connection = await ImapSimple.connect({ + host: this.#config.host, + port: this.#config.port, + secure: this.#config.secure, + auth: { + user: this.#config.user, + pass: this.#config.password, + }, + }); + + await connection.openBox("INBOX"); + + const messageIds = Array.isArray(params.messageIds) ? params.messageIds : [params.messageIds]; + + switch (params.action) { + case "markRead": + await connection.setFlags({ uid: messageIds }, ["\\Seen"]); + break; + case "markUnread": + await connection.setFlags({ uid: messageIds }, ["\\Seen"], { remove: true }); + break; + case "archive": + await connection.copy({ uid: messageIds }, "Archive"); + await connection.setFlags({ uid: messageIds }, ["\\Deleted"]); + break; + case "addLabel": + await connection.setFlags({ uid: messageIds }, [`\\${params.label}`]); + break; + case "removeLabel": + await connection.setFlags({ uid: messageIds }, [`\\${params.label}`], { remove: true }); + break; + default: + await connection.closeBox("INBOX"); + await connection.disconnect(); + return { ok: false, error: `Unknown organize action: ${params.action}` }; + } + + await connection.closeBox("INBOX"); + await connection.disconnect(); + + return { ok: true }; + } catch (err) { + return { ok: false, error: `IMAP organize failed: ${err.message}` }; + } + } + + /** + * Normalize an IMAP message to a standard format. + * @param {object} data - IMAP message data + * @param {string} uid - Message UID + * @returns {object} Normalized message + */ + #normalizeMessage(data, uid) { + const headers = data.headers || {}; + return { + id: uid, + subject: headers.subject || "(no subject)", + from: headers.from || "", + to: headers.to || "", + body: data.body || "", + date: headers.date || "", + uid, + }; + } +} \ No newline at end of file diff --git a/src/tools/email/tools.js b/src/tools/email/tools.js new file mode 100644 index 00000000..7e21f0b1 --- /dev/null +++ b/src/tools/email/tools.js @@ -0,0 +1,357 @@ +import { tool } from "@langchain/core/tools"; +import { z } from "zod"; +import { getActiveProvider, validateProviderConfig } from "./index.js"; +import { loadConfig } from "../../config/loader.js"; + +const config = loadConfig(); + +/** + * Email read tool — fetch messages from the configured email provider. + */ +export const emailRead = tool(async ({ folder, limit, sender, subject, keyword, dateFrom, dateTo, label }) => { + const provider = getActiveProvider(config); + if (!provider) { + return { ok: false, error: "No email provider configured. Set up email credentials in config.yaml." }; + } + + const validation = validateProviderConfig(config.email?.provider); + if (!validation.valid) { + return { ok: false, error: `Invalid email provider config: ${validation.errors?.join("; ")}` }; + } + + try { + const result = await provider.read({ + folder, + limit, + sender, + subject, + keyword, + dateFrom, + dateTo, + label, + }); + + if (!result.ok) { + return { ok: false, error: result.error }; + } + + return { + ok: true, + count: result.messages?.length || 0, + messages: result.messages, + }; + } catch (err) { + return { ok: false, error: `Email read failed: ${err.message}` }; + } +}, { + name: "email_read", + description: "Read emails from inbox, sent, drafts, or custom folders. Supports filtering by sender, subject, keyword, date range, and label.", + schema: z.object({ + folder: z.string().optional().describe("Mailbox folder (default: INBOX). Examples: INBOX, Sent, Drafts, [Gmail]/Trash"), + limit: z.number().optional().default(20).describe("Maximum number of messages to return"), + sender: z.string().optional().describe("Filter by sender email address"), + subject: z.string().optional().describe("Filter by subject keyword"), + keyword: z.string().optional().describe("Filter by body keyword"), + dateFrom: z.string().optional().describe("Filter by date from (ISO 8601 string)"), + dateTo: z.string().optional().describe("Filter by date to (ISO 8601 string)"), + label: z.string().optional().describe("Filter by label (Gmail-specific)"), + }), +}); + +/** + * Email send tool — compose and send emails. + */ +export const emailSend = tool(async ({ to, subject, body, bodyType, cc, bcc, attachments }) => { + const provider = getActiveProvider(config); + if (!provider) { + return { ok: false, error: "No email provider configured. Set up email credentials in config.yaml." }; + } + + const validation = validateProviderConfig(config.email?.provider); + if (!validation.valid) { + return { ok: false, error: `Invalid email provider config: ${validation.errors?.join("; ")}` }; + } + + if (!to || to.length === 0) { + return { ok: false, error: "At least one recipient (to) is required" }; + } + if (!subject) { + return { ok: false, error: "Subject is required" }; + } + if (!body) { + return { ok: false, error: "Body is required" }; + } + + try { + const result = await provider.send({ + to, + subject, + body, + bodyType, + cc, + bcc, + attachments, + }); + + if (!result.ok) { + return { ok: false, error: result.error }; + } + + return { + ok: true, + messageId: result.messageId, + recipients: to, + }; + } catch (err) { + return { ok: false, error: `Email send failed: ${err.message}` }; + } +}, { + name: "email_send", + description: "Send an email with text/HTML body, attachments, CC/BCC support. Requires an email provider to be configured.", + schema: z.object({ + to: z.array(z.string()).min(1).describe("Recipient email addresses"), + subject: z.string().min(1).describe("Email subject line"), + body: z.string().min(1).describe("Email body content"), + bodyType: z.enum(["text", "html"]).optional().default("text").describe("Body format (default: text)"), + cc: z.array(z.string()).optional().describe("CC email addresses"), + bcc: z.array(z.string()).optional().describe("BCC email addresses"), + attachments: z + .array( + z.object({ + filename: z.string(), + content: z.string(), + contentType: z.string().optional(), + }) + ) + .optional() + .describe("File attachments (base64 encoded content)"), + }), +}); + +/** + * Email draft save tool. + */ +export const emailDraftSave = tool(async ({ to, subject, body, bodyType }) => { + const provider = getActiveProvider(config); + if (!provider) { + return { ok: false, error: "No email provider configured." }; + } + + if (!to || to.length === 0) { + return { ok: false, error: "At least one recipient (to) is required" }; + } + if (!subject) { + return { ok: false, error: "Subject is required" }; + } + if (!body) { + return { ok: false, error: "Body is required" }; + } + + try { + const result = await provider.saveDraft({ to, subject, body, bodyType }); + + if (!result.ok) { + return { ok: false, error: result.error }; + } + + return { ok: true, draftId: result.draftId }; + } catch (err) { + return { ok: false, error: `Email draft save failed: ${err.message}` }; + } +}, { + name: "email_draft_save", + description: "Save an email as a draft. Does not send the email.", + schema: z.object({ + to: z.array(z.string()).min(1).describe("Recipient email addresses"), + subject: z.string().min(1).describe("Draft subject"), + body: z.string().min(1).describe("Draft body content"), + bodyType: z.enum(["text", "html"]).optional().default("text").describe("Body format (default: text)"), + }), +}); + +/** + * Email draft list tool. + */ +export const emailDraftList = tool(async ({ limit }) => { + const provider = getActiveProvider(config); + if (!provider) { + return { ok: false, error: "No email provider configured." }; + } + + try { + const result = await provider.listDrafts({ limit }); + + if (!result.ok) { + return { ok: false, error: result.error }; + } + + return { + ok: true, + count: result.drafts?.length || 0, + drafts: result.drafts, + }; + } catch (err) { + return { ok: false, error: `Email draft list failed: ${err.message}` }; + } +}, { + name: "email_draft_list", + description: "List saved email drafts.", + schema: z.object({ + limit: z.number().optional().default(20).describe("Maximum number of drafts to return"), + }), +}); + +/** + * Email draft update tool. + */ +export const emailDraftUpdate = tool(async ({ draftId, to, subject, body, bodyType }) => { + const provider = getActiveProvider(config); + if (!provider) { + return { ok: false, error: "No email provider configured." }; + } + + if (!draftId) { + return { ok: false, error: "Draft ID is required" }; + } + + try { + const result = await provider.updateDraft(draftId, { to, subject, body, bodyType }); + + if (!result.ok) { + return { ok: false, error: result.error }; + } + + return { ok: true, draftId }; + } catch (err) { + return { ok: false, error: `Email draft update failed: ${err.message}` }; + } +}, { + name: "email_draft_update", + description: "Update an existing email draft. Provide draftId and any fields to update.", + schema: z.object({ + draftId: z.string().min(1).describe("Draft identifier"), + to: z.array(z.string()).optional().describe("Recipient email addresses"), + subject: z.string().optional().describe("Draft subject"), + body: z.string().optional().describe("Draft body content"), + bodyType: z.enum(["text", "html"]).optional().describe("Body format"), + }), +}); + +/** + * Email draft delete tool. + */ +export const emailDraftDelete = tool(async ({ draftId }) => { + const provider = getActiveProvider(config); + if (!provider) { + return { ok: false, error: "No email provider configured." }; + } + + if (!draftId) { + return { ok: false, error: "Draft ID is required" }; + } + + try { + const result = await provider.deleteDraft(draftId); + + if (!result.ok) { + return { ok: false, error: result.error }; + } + + return { ok: true, draftId }; + } catch (err) { + return { ok: false, error: `Email draft delete failed: ${err.message}` }; + } +}, { + name: "email_draft_delete", + description: "Delete an email draft by ID.", + schema: z.object({ + draftId: z.string().min(1).describe("Draft identifier"), + }), +}); + +/** + * Email organize tool — mark read/unread, archive, label. + */ +export const emailOrganize = tool(async ({ messageIds, action, label }) => { + const provider = getActiveProvider(config); + if (!provider) { + return { ok: false, error: "No email provider configured." }; + } + + if (!messageIds || (Array.isArray(messageIds) && messageIds.length === 0)) { + return { ok: false, error: "At least one message ID is required" }; + } + if (!action) { + return { ok: false, error: "Action is required (markRead, markUnread, archive, addLabel, removeLabel)" }; + } + + const validActions = ["markRead", "markUnread", "archive", "addLabel", "removeLabel"]; + if (!validActions.includes(action)) { + return { ok: false, error: `Invalid action: ${action}. Valid: ${validActions.join(", ")}` }; + } + + if ((action === "addLabel" || action === "removeLabel") && !label) { + return { ok: false, error: "Label is required for addLabel/removeLabel actions" }; + } + + try { + const result = await provider.organize({ messageIds, action, label }); + + if (!result.ok) { + return { ok: false, error: result.error }; + } + + return { + ok: true, + action, + messageCount: Array.isArray(messageIds) ? messageIds.length : 1, + }; + } catch (err) { + return { ok: false, error: `Email organize failed: ${err.message}` }; + } +}, { + name: "email_organize", + description: "Organize emails: mark as read/unread, archive, add/remove labels. Requires message IDs.", + schema: z.object({ + messageIds: z.union([z.string(), z.array(z.string())]).describe("Message ID or array of message IDs"), + action: z.enum(["markRead", "markUnread", "archive", "addLabel", "removeLabel"]).describe("Organization action"), + label: z.string().optional().describe("Label name (required for addLabel/removeLabel)"), + }), +}); + +/** + * Email search tool — search across the mailbox. + */ +export const emailSearch = tool(async ({ query, limit }) => { + const provider = getActiveProvider(config); + if (!provider) { + return { ok: false, error: "No email provider configured." }; + } + + if (!query) { + return { ok: false, error: "Search query is required" }; + } + + try { + const result = await provider.search({ query, limit }); + + if (!result.ok) { + return { ok: false, error: result.error }; + } + + return { + ok: true, + count: result.messages?.length || 0, + messages: result.messages, + }; + } catch (err) { + return { ok: false, error: `Email search failed: ${err.message}` }; + } +}, { + name: "email_search", + description: "Search emails across the mailbox using a text query.", + schema: z.object({ + query: z.string().min(1).describe("Search query text"), + limit: z.number().optional().default(20).describe("Maximum number of results"), + }), +}); \ No newline at end of file diff --git a/src/tools/index.js b/src/tools/index.js index 9ee98206..3238372c 100644 --- a/src/tools/index.js +++ b/src/tools/index.js @@ -19,6 +19,16 @@ import { pptxTool } from "./fileExtract/pptx.js"; import { xlsxTool } from "./fileExtract/xlsx.js"; import { pdfTool } from "./fileExtract/pdf.js"; import { reflectionSessions } from "./reflection.js"; +import { + emailRead, + emailSend, + emailDraftSave, + emailDraftList, + emailDraftUpdate, + emailDraftDelete, + emailOrganize, + emailSearch, +} from "./email/tools.js"; /** * Maps tool names to required permission scopes. @@ -48,6 +58,14 @@ export const TOOL_PERMISSIONS = { xlsx: ["filesystem:read"], pdf: ["filesystem:read"], reflectionSessions: ["filesystem:read"], + emailRead: ["network:outbound"], + emailSend: ["network:outbound"], + emailDraftSave: ["network:outbound"], + emailDraftList: ["network:outbound"], + emailDraftUpdate: ["network:outbound"], + emailDraftDelete: ["network:outbound"], + emailOrganize: ["network:outbound"], + emailSearch: ["network:outbound"], }; /** @@ -108,6 +126,14 @@ export const TOOL_CLASSIFICATIONS = { xlsx: ["search", "research", "coding", "documentation", "debug"], pdf: ["search", "research", "coding", "documentation", "debug"], reflectionSessions: ["orchestrator"], + emailRead: ["search", "research", "coding", "documentation"], + emailSend: ["documentation", "coding"], + emailDraftSave: ["documentation", "coding"], + emailDraftList: ["search", "research"], + emailDraftUpdate: ["documentation", "coding"], + emailDraftDelete: ["debug", "coding"], + emailOrganize: ["debug", "coding"], + emailSearch: ["search", "research", "coding"], }; /** @@ -170,6 +196,14 @@ export const TOOLS = { xlsx: xlsxTool, pdf: pdfTool, reflectionSessions, + emailRead, + emailSend, + emailDraftSave, + emailDraftList, + emailDraftUpdate, + emailDraftDelete, + emailOrganize, + emailSearch, }; /** diff --git a/tests/unit/config/providers.test.js b/tests/unit/config/providers.test.js new file mode 100644 index 00000000..38d6a982 --- /dev/null +++ b/tests/unit/config/providers.test.js @@ -0,0 +1,144 @@ +import { test, describe } from "node:test"; +import assert from "node:assert"; +import { + GmailProviderSchema, + GraphProviderSchema, + ImapProviderSchema, + EmailProviderSchema, + EmailConfigSchema, +} from "../../../src/config/schemas/providers.js"; + +describe("Email Provider Config Schemas", () => { + describe("GmailProviderSchema", () => { + test("should validate a complete Gmail config", () => { + const result = GmailProviderSchema.safeParse({ + type: "gmail", + clientId: "client-id", + clientSecret: "client-secret", + refreshToken: "refresh-token", + }); + assert.strictEqual(result.success, true); + }); + + test("should accept minimal Gmail config with defaults", () => { + const result = GmailProviderSchema.safeParse({}); + assert.strictEqual(result.success, true); + assert.strictEqual(result.data.type, "gmail"); + }); + + test("should reject invalid type", () => { + const result = GmailProviderSchema.safeParse({ type: "invalid" }); + assert.strictEqual(result.success, false); + }); + }); + + describe("GraphProviderSchema", () => { + test("should validate a complete Graph config", () => { + const result = GraphProviderSchema.safeParse({ + type: "graph", + tenantId: "tenant-id", + clientId: "client-id", + clientSecret: "client-secret", + refreshToken: "refresh-token", + }); + assert.strictEqual(result.success, true); + }); + + test("should accept minimal Graph config with defaults", () => { + const result = GraphProviderSchema.safeParse({}); + assert.strictEqual(result.success, true); + assert.strictEqual(result.data.type, "graph"); + }); + + test("should reject invalid type", () => { + const result = GraphProviderSchema.safeParse({ type: "invalid" }); + assert.strictEqual(result.success, false); + }); + }); + + describe("ImapProviderSchema", () => { + test("should validate a complete IMAP config", () => { + const result = ImapProviderSchema.safeParse({ + type: "imap", + host: "imap.gmail.com", + port: 993, + secure: true, + user: "user@gmail.com", + password: "app-password", + }); + assert.strictEqual(result.success, true); + }); + + test("should accept minimal IMAP config with defaults", () => { + const result = ImapProviderSchema.safeParse({ + user: "user@gmail.com", + password: "app-password", + }); + assert.strictEqual(result.success, true); + assert.strictEqual(result.data.type, "imap"); + assert.strictEqual(result.data.host, "imap.gmail.com"); + assert.strictEqual(result.data.port, 993); + assert.strictEqual(result.data.secure, true); + }); + + test("should reject IMAP config without user", () => { + const result = ImapProviderSchema.safeParse({ password: "pass" }); + assert.strictEqual(result.success, false); + }); + + test("should reject IMAP config without password", () => { + const result = ImapProviderSchema.safeParse({ user: "user" }); + assert.strictEqual(result.success, false); + }); + }); + + describe("EmailProviderSchema (discriminated union)", () => { + test("should accept Gmail provider", () => { + const result = EmailProviderSchema.safeParse({ type: "gmail", clientId: "id", clientSecret: "secret", refreshToken: "token" }); + assert.strictEqual(result.success, true); + }); + + test("should accept Graph provider", () => { + const result = EmailProviderSchema.safeParse({ type: "graph", clientId: "id", clientSecret: "secret", refreshToken: "token" }); + assert.strictEqual(result.success, true); + }); + + test("should accept IMAP provider", () => { + const result = EmailProviderSchema.safeParse({ type: "imap", user: "user", password: "pass" }); + assert.strictEqual(result.success, true); + }); + + test("should reject unknown provider type", () => { + const result = EmailProviderSchema.safeParse({ type: "unknown", user: "user" }); + assert.strictEqual(result.success, false); + }); + }); + + describe("EmailConfigSchema", () => { + test("should validate a complete email config", () => { + const result = EmailConfigSchema.safeParse({ + provider: { type: "gmail", clientId: "id", clientSecret: "secret", refreshToken: "token" }, + defaultFolder: "INBOX", + maxAttachments: 10, + maxAttachmentSize: "25mb", + }); + assert.strictEqual(result.success, true); + }); + + test("should accept minimal email config with defaults", () => { + const result = EmailConfigSchema.safeParse({ + provider: { type: "imap", user: "user", password: "pass" }, + }); + assert.strictEqual(result.success, true); + assert.strictEqual(result.data.defaultFolder, "INBOX"); + assert.strictEqual(result.data.maxAttachments, 10); + }); + + test("should reject config with invalid provider", () => { + const result = EmailConfigSchema.safeParse({ + provider: { type: "invalid" }, + }); + assert.strictEqual(result.success, false); + }); + }); +}); \ No newline at end of file diff --git a/tests/unit/tools/email/email-tools.test.js b/tests/unit/tools/email/email-tools.test.js new file mode 100644 index 00000000..17ae089e --- /dev/null +++ b/tests/unit/tools/email/email-tools.test.js @@ -0,0 +1,69 @@ +import { test, describe } from "node:test"; +import assert from "node:assert"; +import { emailRead, emailSend, emailDraftSave, emailDraftList, emailDraftUpdate, emailDraftDelete, emailOrganize, emailSearch } from "../../../src/tools/email/tools.js"; + +describe("Email Tools Integration", () => { + test("emailRead returns structured error when no provider", async () => { + const result = await emailRead("{}", {}); + assert.ok(!result.ok); + assert.ok(result.error); + assert.ok(typeof result.error === "string"); + }); + + test("emailSend returns structured error when no provider", async () => { + const result = await emailSend("{}", {}); + assert.ok(!result.ok); + assert.ok(result.error); + }); + + test("emailDraftSave returns structured error when no provider", async () => { + const result = await emailDraftSave("{}", {}); + assert.ok(!result.ok); + assert.ok(result.error); + }); + + test("emailDraftList returns structured error when no provider", async () => { + const result = await emailDraftList("{}", {}); + assert.ok(!result.ok); + assert.ok(result.error); + }); + + test("emailDraftUpdate returns structured error when no provider", async () => { + const result = await emailDraftUpdate("{}", {}); + assert.ok(!result.ok); + assert.ok(result.error); + }); + + test("emailDraftDelete returns structured error when no provider", async () => { + const result = await emailDraftDelete("{}", {}); + assert.ok(!result.ok); + assert.ok(result.error); + }); + + test("emailOrganize returns structured error when no provider", async () => { + const result = await emailOrganize("{}", {}); + assert.ok(!result.ok); + assert.ok(result.error); + }); + + test("emailSearch returns structured error when no provider", async () => { + const result = await emailSearch("{}", {}); + assert.ok(!result.ok); + assert.ok(result.error); + }); + + test("emailRead tool has proper metadata", () => { + assert.ok(emailRead.name); + assert.ok(emailRead.description); + }); + + test("emailSend tool has proper metadata", () => { + assert.ok(emailSend.name); + assert.ok(emailSend.description); + }); + + test("emailOrganize tool has proper metadata", () => { + assert.ok(emailOrganize.name); + assert.ok(emailOrganize.description); + }); +}); \ No newline at end of file diff --git a/tests/unit/tools/email/index.test.js b/tests/unit/tools/email/index.test.js new file mode 100644 index 00000000..35fe92a7 --- /dev/null +++ b/tests/unit/tools/email/index.test.js @@ -0,0 +1,85 @@ +import { test, describe } from "node:test"; +import assert from "node:assert"; +import { emailRead, emailSend, emailDraftSave, emailDraftList, emailDraftUpdate, emailDraftDelete, emailOrganize, emailSearch } from "../../../src/tools/email/tools.js"; + +describe("Email Tools", () => { + test("emailRead should have a valid name", () => { + assert.strictEqual(emailRead.name, "emailRead"); + }); + + test("emailSend should have a valid name", () => { + assert.strictEqual(emailSend.name, "emailSend"); + }); + + test("emailDraftSave should have a valid name", () => { + assert.strictEqual(emailDraftSave.name, "emailDraftSave"); + }); + + test("emailDraftList should have a valid name", () => { + assert.strictEqual(emailDraftList.name, "emailDraftList"); + }); + + test("emailDraftUpdate should have a valid name", () => { + assert.strictEqual(emailDraftUpdate.name, "emailDraftUpdate"); + }); + + test("emailDraftDelete should have a valid name", () => { + assert.strictEqual(emailDraftDelete.name, "emailDraftDelete"); + }); + + test("emailOrganize should have a valid name", () => { + assert.strictEqual(emailOrganize.name, "emailOrganize"); + }); + + test("emailSearch should have a valid name", () => { + assert.strictEqual(emailSearch.name, "emailSearch"); + }); + + test("emailRead should return { ok: false } when no provider configured", async () => { + const result = await emailRead("{}", {}); + assert.strictEqual(result.ok, false); + assert.ok(result.error); + }); + + test("emailSend should return { ok: false } when no provider configured", async () => { + const result = await emailSend("{}", {}); + assert.strictEqual(result.ok, false); + assert.ok(result.error); + }); + + test("emailDraftSave should return { ok: false } when no provider configured", async () => { + const result = await emailDraftSave("{}", {}); + assert.strictEqual(result.ok, false); + assert.ok(result.error); + }); + + test("emailDraftList should return { ok: false } when no provider configured", async () => { + const result = await emailDraftList("{}", {}); + assert.strictEqual(result.ok, false); + assert.ok(result.error); + }); + + test("emailDraftUpdate should return { ok: false } when no provider configured", async () => { + const result = await emailDraftUpdate("{}", {}); + assert.strictEqual(result.ok, false); + assert.ok(result.error); + }); + + test("emailDraftDelete should return { ok: false } when no provider configured", async () => { + const result = await emailDraftDelete("{}", {}); + assert.strictEqual(result.ok, false); + assert.ok(result.error); + }); + + test("emailOrganize should return { ok: false } when no provider configured", async () => { + const result = await emailOrganize("{}", {}); + assert.strictEqual(result.ok, false); + assert.ok(result.error); + }); + + test("emailSearch should return { ok: false } when no provider configured", async () => { + const result = await emailSearch("{}", {}); + assert.strictEqual(result.ok, false); + assert.ok(result.error); + }); +}); \ No newline at end of file diff --git a/tests/unit/tools/email/providers/base.test.js b/tests/unit/tools/email/providers/base.test.js new file mode 100644 index 00000000..b6fd2204 --- /dev/null +++ b/tests/unit/tools/email/providers/base.test.js @@ -0,0 +1,61 @@ +import { test, describe } from "node:test"; +import assert from "node:assert"; +import { EmailProvider } from "../../../src/tools/email/providers/base.js"; + +describe("EmailProvider (base)", () => { + test("should throw on read() — abstract method", async () => { + const provider = new EmailProvider({}); + await assert.rejects(() => provider.read({}), Error); + }); + + test("should throw on send() — abstract method", async () => { + const provider = new EmailProvider({}); + await assert.rejects(() => provider.send({}), Error); + }); + + test("should throw on saveDraft() — abstract method", async () => { + const provider = new EmailProvider({}); + await assert.rejects(() => provider.saveDraft({}), Error); + }); + + test("should throw on listDrafts() — abstract method", async () => { + const provider = new EmailProvider({}); + await assert.rejects(() => provider.listDrafts({}), Error); + }); + + test("should throw on updateDraft() — abstract method", async () => { + const provider = new EmailProvider({}); + await assert.rejects(() => provider.updateDraft("draft-id", {}), Error); + }); + + test("should throw on deleteDraft() — abstract method", async () => { + const provider = new EmailProvider({}); + await assert.rejects(() => provider.deleteDraft("draft-id"), Error); + }); + + test("should throw on organize() — abstract method", async () => { + const provider = new EmailProvider({}); + await assert.rejects(() => provider.organize({}), Error); + }); + + test("should throw on search() — abstract method", async () => { + const provider = new EmailProvider({}); + await assert.rejects(() => provider.search({}), Error); + }); + + test("normalizeMessage() should return a valid message object", () => { + const provider = new EmailProvider({}); + const msg = provider.normalizeMessage({ + id: "msg-1", + from: "test@example.com", + subject: "Test", + body: "Hello", + date: "2024-01-01T00:00:00Z", + }); + assert.strictEqual(msg.id, "msg-1"); + assert.strictEqual(msg.from, "test@example.com"); + assert.strictEqual(msg.subject, "Test"); + assert.strictEqual(msg.body, "Hello"); + assert.strictEqual(msg.date, "2024-01-01T00:00:00Z"); + }); +}); \ No newline at end of file diff --git a/tests/unit/tools/email/providers/gmail.test.js b/tests/unit/tools/email/providers/gmail.test.js new file mode 100644 index 00000000..b8fc3de8 --- /dev/null +++ b/tests/unit/tools/email/providers/gmail.test.js @@ -0,0 +1,70 @@ +import { test, describe } from "node:test"; +import assert from "node:assert"; +import { GmailProvider } from "../../../../src/tools/email/providers/gmail.js"; + +describe("GmailProvider", () => { + test("should throw when no credentials configured", () => { + assert.throws(() => new GmailProvider({}), /Gmail requires credentials/); + }); + + test("should throw when clientId is missing", () => { + assert.throws(() => new GmailProvider({ clientSecret: "secret", refreshToken: "token" }), /clientId/); + }); + + test("should throw when clientSecret is missing", () => { + assert.throws(() => new GmailProvider({ clientId: "id", refreshToken: "token" }), /clientSecret/); + }); + + test("should throw when refreshToken is missing", () => { + assert.throws(() => new GmailProvider({ clientId: "id", clientSecret: "secret" }), /refreshToken/); + }); + + test("read() should return { ok: false } when googleapis is not installed", async () => { + const provider = new GmailProvider({ clientId: "id", clientSecret: "secret", refreshToken: "token" }); + const result = await provider.read({ limit: 5 }); + assert.strictEqual(result.ok, false); + assert.ok(result.error); + }); + + test("send() should return { ok: false } when googleapis is not installed", async () => { + const provider = new GmailProvider({ clientId: "id", clientSecret: "secret", refreshToken: "token" }); + const result = await provider.send({ to: ["test@example.com"], subject: "Test", body: "Hello" }); + assert.strictEqual(result.ok, false); + }); + + test("saveDraft() should return { ok: false } when googleapis is not installed", async () => { + const provider = new GmailProvider({ clientId: "id", clientSecret: "secret", refreshToken: "token" }); + const result = await provider.saveDraft({ to: ["test@example.com"], subject: "Test", body: "Hello" }); + assert.strictEqual(result.ok, false); + }); + + test("listDrafts() should return { ok: false } when googleapis is not installed", async () => { + const provider = new GmailProvider({ clientId: "id", clientSecret: "secret", refreshToken: "token" }); + const result = await provider.listDrafts({}); + assert.strictEqual(result.ok, false); + }); + + test("updateDraft() should return { ok: false } when googleapis is not installed", async () => { + const provider = new GmailProvider({ clientId: "id", clientSecret: "secret", refreshToken: "token" }); + const result = await provider.updateDraft("draft-id", { subject: "Updated" }); + assert.strictEqual(result.ok, false); + }); + + test("deleteDraft() should return { ok: false } when googleapis is not installed", async () => { + const provider = new GmailProvider({ clientId: "id", clientSecret: "secret", refreshToken: "token" }); + const result = await provider.deleteDraft("draft-id"); + assert.strictEqual(result.ok, false); + }); + + test("organize() should return { ok: false } when googleapis is not installed", async () => { + const provider = new GmailProvider({ clientId: "id", clientSecret: "secret", refreshToken: "token" }); + const result = await provider.organize({ messageIds: ["msg-1"], action: "markRead" }); + assert.strictEqual(result.ok, false); + }); + + test("search() should return { ok: false } when googleapis is not installed", async () => { + const provider = new GmailProvider({ clientId: "id", clientSecret: "secret", refreshToken: "token" }); + const result = await provider.search({ query: "test" }); + assert.strictEqual(result.ok, false); + }); +}); \ No newline at end of file diff --git a/tests/unit/tools/email/providers/graph.test.js b/tests/unit/tools/email/providers/graph.test.js new file mode 100644 index 00000000..b8b0197c --- /dev/null +++ b/tests/unit/tools/email/providers/graph.test.js @@ -0,0 +1,70 @@ +import { test, describe } from "node:test"; +import assert from "node:assert"; +import { GraphProvider } from "../../../../src/tools/email/providers/graph.js"; + +describe("GraphProvider", () => { + test("should throw when no credentials configured", () => { + assert.throws(() => new GraphProvider({}), /Graph requires credentials/); + }); + + test("should throw when clientId is missing", () => { + assert.throws(() => new GraphProvider({ clientSecret: "secret", refreshToken: "token" }), /clientId/); + }); + + test("should throw when clientSecret is missing", () => { + assert.throws(() => new GraphProvider({ clientId: "id", refreshToken: "token" }), /clientSecret/); + }); + + test("should throw when refreshToken is missing", () => { + assert.throws(() => new GraphProvider({ clientId: "id", clientSecret: "secret" }), /refreshToken/); + }); + + test("read() should return { ok: false } when @microsoft/microsoft-graph-client is not installed", async () => { + const provider = new GraphProvider({ clientId: "id", clientSecret: "secret", refreshToken: "token" }); + const result = await provider.read({ limit: 5 }); + assert.strictEqual(result.ok, false); + assert.ok(result.error); + }); + + test("send() should return { ok: false } when @microsoft/microsoft-graph-client is not installed", async () => { + const provider = new GraphProvider({ clientId: "id", clientSecret: "secret", refreshToken: "token" }); + const result = await provider.send({ to: ["test@example.com"], subject: "Test", body: "Hello" }); + assert.strictEqual(result.ok, false); + }); + + test("saveDraft() should return { ok: false } when @microsoft/microsoft-graph-client is not installed", async () => { + const provider = new GraphProvider({ clientId: "id", clientSecret: "secret", refreshToken: "token" }); + const result = await provider.saveDraft({ to: ["test@example.com"], subject: "Test", body: "Hello" }); + assert.strictEqual(result.ok, false); + }); + + test("listDrafts() should return { ok: false } when @microsoft/microsoft-graph-client is not installed", async () => { + const provider = new GraphProvider({ clientId: "id", clientSecret: "secret", refreshToken: "token" }); + const result = await provider.listDrafts({}); + assert.strictEqual(result.ok, false); + }); + + test("updateDraft() should return { ok: false } when @microsoft/microsoft-graph-client is not installed", async () => { + const provider = new GraphProvider({ clientId: "id", clientSecret: "secret", refreshToken: "token" }); + const result = await provider.updateDraft("draft-id", { subject: "Updated" }); + assert.strictEqual(result.ok, false); + }); + + test("deleteDraft() should return { ok: false } when @microsoft/microsoft-graph-client is not installed", async () => { + const provider = new GraphProvider({ clientId: "id", clientSecret: "secret", refreshToken: "token" }); + const result = await provider.deleteDraft("draft-id"); + assert.strictEqual(result.ok, false); + }); + + test("organize() should return { ok: false } when @microsoft/microsoft-graph-client is not installed", async () => { + const provider = new GraphProvider({ clientId: "id", clientSecret: "secret", refreshToken: "token" }); + const result = await provider.organize({ messageIds: ["msg-1"], action: "markRead" }); + assert.strictEqual(result.ok, false); + }); + + test("search() should return { ok: false } when @microsoft/microsoft-graph-client is not installed", async () => { + const provider = new GraphProvider({ clientId: "id", clientSecret: "secret", refreshToken: "token" }); + const result = await provider.search({ query: "test" }); + assert.strictEqual(result.ok, false); + }); +}); \ No newline at end of file diff --git a/tests/unit/tools/email/providers/imap.test.js b/tests/unit/tools/email/providers/imap.test.js new file mode 100644 index 00000000..a0afca4d --- /dev/null +++ b/tests/unit/tools/email/providers/imap.test.js @@ -0,0 +1,66 @@ +import { test, describe } from "node:test"; +import assert from "node:assert"; +import { ImapProvider } from "../../../../src/tools/email/providers/imap.js"; + +describe("ImapProvider", () => { + test("should throw when no credentials configured", () => { + assert.throws(() => new ImapProvider({}), /IMAP requires credentials/); + }); + + test("should throw when user is missing", () => { + assert.throws(() => new ImapProvider({ password: "pass" }), /user/); + }); + + test("should throw when password is missing", () => { + assert.throws(() => new ImapProvider({ user: "user" }), /password/); + }); + + test("read() should return { ok: false } when imap-simple is not installed", async () => { + const provider = new ImapProvider({ user: "user", password: "pass" }); + const result = await provider.read({ limit: 5 }); + assert.strictEqual(result.ok, false); + assert.ok(result.error); + }); + + test("send() should return { ok: false } when nodemailer is not installed", async () => { + const provider = new ImapProvider({ user: "user", password: "pass" }); + const result = await provider.send({ to: ["test@example.com"], subject: "Test", body: "Hello" }); + assert.strictEqual(result.ok, false); + }); + + test("saveDraft() should return { ok: false } when imap-simple is not installed", async () => { + const provider = new ImapProvider({ user: "user", password: "pass" }); + const result = await provider.saveDraft({ to: ["test@example.com"], subject: "Test", body: "Hello" }); + assert.strictEqual(result.ok, false); + }); + + test("listDrafts() should return { ok: false } when imap-simple is not installed", async () => { + const provider = new ImapProvider({ user: "user", password: "pass" }); + const result = await provider.listDrafts({}); + assert.strictEqual(result.ok, false); + }); + + test("updateDraft() should return { ok: false } when imap-simple is not installed", async () => { + const provider = new ImapProvider({ user: "user", password: "pass" }); + const result = await provider.updateDraft("draft-id", { subject: "Updated" }); + assert.strictEqual(result.ok, false); + }); + + test("deleteDraft() should return { ok: false } when imap-simple is not installed", async () => { + const provider = new ImapProvider({ user: "user", password: "pass" }); + const result = await provider.deleteDraft("draft-id"); + assert.strictEqual(result.ok, false); + }); + + test("organize() should return { ok: false } when imap-simple is not installed", async () => { + const provider = new ImapProvider({ user: "user", password: "pass" }); + const result = await provider.organize({ messageIds: ["msg-1"], action: "markRead" }); + assert.strictEqual(result.ok, false); + }); + + test("search() should return { ok: false } when imap-simple is not installed", async () => { + const provider = new ImapProvider({ user: "user", password: "pass" }); + const result = await provider.search({ query: "test" }); + assert.strictEqual(result.ok, false); + }); +}); \ No newline at end of file From babd21f6dd9e0d07349253a45e8ea8caf22b234c Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Sat, 15 Aug 2026 21:54:59 -0400 Subject: [PATCH 03/16] chore: archive email-integration change - Archive OpenSpec change: 2026-08-16-email-integration - Apply spec deltas: email-auth (+4), email-providers (+5), email-tools (+4) - Total: +13 lines added to spec files --- .../.openspec.yaml | 0 .../2026-08-16-email-integration}/design.md | 0 .../2026-08-16-email-integration}/proposal.md | 0 .../specs/email-auth/spec.md | 0 .../specs/email-providers/spec.md | 0 .../specs/email-tools/spec.md | 0 .../2026-08-16-email-integration}/tasks.md | 0 openspec/specs/email-auth/spec.md | 73 +++++++++++ openspec/specs/email-providers/spec.md | 92 +++++++++++++ openspec/specs/email-tools/spec.md | 121 ++++++++++++++++++ 10 files changed, 286 insertions(+) rename openspec/changes/{email-integration => archive/2026-08-16-email-integration}/.openspec.yaml (100%) rename openspec/changes/{email-integration => archive/2026-08-16-email-integration}/design.md (100%) rename openspec/changes/{email-integration => archive/2026-08-16-email-integration}/proposal.md (100%) rename openspec/changes/{email-integration => archive/2026-08-16-email-integration}/specs/email-auth/spec.md (100%) rename openspec/changes/{email-integration => archive/2026-08-16-email-integration}/specs/email-providers/spec.md (100%) rename openspec/changes/{email-integration => archive/2026-08-16-email-integration}/specs/email-tools/spec.md (100%) rename openspec/changes/{email-integration => archive/2026-08-16-email-integration}/tasks.md (100%) create mode 100644 openspec/specs/email-auth/spec.md create mode 100644 openspec/specs/email-providers/spec.md create mode 100644 openspec/specs/email-tools/spec.md diff --git a/openspec/changes/email-integration/.openspec.yaml b/openspec/changes/archive/2026-08-16-email-integration/.openspec.yaml similarity index 100% rename from openspec/changes/email-integration/.openspec.yaml rename to openspec/changes/archive/2026-08-16-email-integration/.openspec.yaml diff --git a/openspec/changes/email-integration/design.md b/openspec/changes/archive/2026-08-16-email-integration/design.md similarity index 100% rename from openspec/changes/email-integration/design.md rename to openspec/changes/archive/2026-08-16-email-integration/design.md diff --git a/openspec/changes/email-integration/proposal.md b/openspec/changes/archive/2026-08-16-email-integration/proposal.md similarity index 100% rename from openspec/changes/email-integration/proposal.md rename to openspec/changes/archive/2026-08-16-email-integration/proposal.md diff --git a/openspec/changes/email-integration/specs/email-auth/spec.md b/openspec/changes/archive/2026-08-16-email-integration/specs/email-auth/spec.md similarity index 100% rename from openspec/changes/email-integration/specs/email-auth/spec.md rename to openspec/changes/archive/2026-08-16-email-integration/specs/email-auth/spec.md diff --git a/openspec/changes/email-integration/specs/email-providers/spec.md b/openspec/changes/archive/2026-08-16-email-integration/specs/email-providers/spec.md similarity index 100% rename from openspec/changes/email-integration/specs/email-providers/spec.md rename to openspec/changes/archive/2026-08-16-email-integration/specs/email-providers/spec.md diff --git a/openspec/changes/email-integration/specs/email-tools/spec.md b/openspec/changes/archive/2026-08-16-email-integration/specs/email-tools/spec.md similarity index 100% rename from openspec/changes/email-integration/specs/email-tools/spec.md rename to openspec/changes/archive/2026-08-16-email-integration/specs/email-tools/spec.md diff --git a/openspec/changes/email-integration/tasks.md b/openspec/changes/archive/2026-08-16-email-integration/tasks.md similarity index 100% rename from openspec/changes/email-integration/tasks.md rename to openspec/changes/archive/2026-08-16-email-integration/tasks.md diff --git a/openspec/specs/email-auth/spec.md b/openspec/specs/email-auth/spec.md new file mode 100644 index 00000000..825b1095 --- /dev/null +++ b/openspec/specs/email-auth/spec.md @@ -0,0 +1,73 @@ +# email-auth Specification + +## Purpose +TBD - created by archiving change email-integration. Update Purpose after archive. +## Requirements +### Requirement: OAuth2 credential storage +The system SHALL store OAuth2 access and refresh tokens in the memory system with encryption at rest. + +#### Scenario: Store OAuth2 credentials securely +- **WHEN** OAuth2 tokens are obtained from a provider +- **THEN** they are encrypted and stored in the memory system under a provider-specific key + +#### Scenario: Retrieve OAuth2 credentials +- **WHEN** a provider needs its tokens +- **THEN** the system decrypts and returns the stored tokens + +#### Scenario: Rotate OAuth2 credentials +- **WHEN** a new refresh token is obtained during token refresh +- **THEN** the system updates the stored credentials atomically + +#### Scenario: Clear OAuth2 credentials on logout +- **WHEN** the provider is disconnected or credentials are invalidated +- **THEN** the system removes the stored tokens from the memory system + +### Requirement: IMAP credential storage +The system SHALL store IMAP credentials (host, port, username, password) in the memory system with encryption at rest. + +#### Scenario: Store IMAP credentials securely +- **WHEN** IMAP credentials are configured +- **THEN** they are encrypted and stored in the memory system under a provider-specific key + +#### Scenario: Retrieve IMAP credentials +- **WHEN** the IMAP provider needs credentials +- **THEN** the system decrypts and returns the stored credentials + +#### Scenario: Never log IMAP credentials +- **WHEN** any operation involving IMAP credentials +- **THEN** credentials are never written to logs, error messages, or telemetry data + +### Requirement: Credential validation at startup +The system SHALL validate email provider credentials during application startup. + +#### Scenario: Validate Gmail OAuth2 credentials on startup +- **WHEN** the application starts with Gmail provider configured +- **THEN** it validates the OAuth2 credentials by making a test API request + +#### Scenario: Validate IMAP credentials on startup +- **WHEN** the application starts with IMAP provider configured +- **THEN** it validates the credentials by attempting an IMAP connection + +#### Scenario: Graceful degradation when credentials are invalid +- **WHEN** the application starts with invalid email credentials +- **THEN** it logs a warning and continues without email tools, rather than crashing + +### Requirement: Credential configuration schema +The system SHALL define Zod validation schemas for email provider configurations. + +#### Scenario: Validate Gmail provider config +- **WHEN** a Gmail provider config is provided +- **THEN** the schema validates clientId, clientSecret, refreshToken, and required scopes + +#### Scenario: Validate MS Graph provider config +- **WHEN** an MS Graph provider config is provided +- **THEN** the schema validates clientId, clientSecret, refreshToken, tenantId, and required scopes + +#### Scenario: Validate IMAP provider config +- **WHEN** an IMAP provider config is provided +- **THEN** the schema validates host, port, username, and password fields + +#### Scenario: Reject incomplete provider config +- **WHEN** a provider config is missing required fields +- **THEN** the schema validation fails with a descriptive error listing missing fields + diff --git a/openspec/specs/email-providers/spec.md b/openspec/specs/email-providers/spec.md new file mode 100644 index 00000000..f5c5c76a --- /dev/null +++ b/openspec/specs/email-providers/spec.md @@ -0,0 +1,92 @@ +# email-providers Specification + +## Purpose +TBD - created by archiving change email-integration. Update Purpose after archive. +## Requirements +### Requirement: Provider abstraction interface +The system SHALL define an `EmailProvider` interface that all email providers implement, providing a unified API for email operations regardless of backend. + +#### Scenario: Provider interface defines required methods +- **WHEN** a new provider is created +- **THEN** it MUST implement send(), read(), search(), draftSave(), draftList(), draftUpdate(), draftDelete(), and organize() methods + +#### Scenario: Provider returns consistent message format +- **WHEN** any provider's read() or search() method is called +- **THEN** it returns messages in a standardized format with id, subject, from, to, date, body, isRead, labels, and folder fields + +### Requirement: Gmail provider implementation +The system SHALL provide a Gmail provider using the Google Gmail API with OAuth2 authentication. + +#### Scenario: Gmail provider authenticates via OAuth2 +- **WHEN** Gmail provider is initialized with valid OAuth2 credentials +- **THEN** it obtains an access token and can make authenticated API requests + +#### Scenario: Gmail provider sends email +- **WHEN** Gmail provider's send() method is called with valid message content +- **THEN** it sends the email via Gmail API and returns the message id + +#### Scenario: Gmail provider reads inbox +- **WHEN** Gmail provider's read() method is called with folder="inbox" +- **THEN** it returns inbox messages via Gmail API's users.messages.list endpoint + +#### Scenario: Gmail provider handles token refresh +- **WHEN** Gmail provider's access token expires during an operation +- **THEN** it automatically refreshes the token using the refresh token and retries the operation + +### Requirement: MS Graph provider implementation +The system SHALL provide an MS Graph provider using the Microsoft Graph API with OAuth2 authentication. + +#### Scenario: MS Graph provider authenticates via OAuth2 +- **WHEN** MS Graph provider is initialized with valid OAuth2 credentials +- **THEN** it obtains an access token and can make authenticated Graph API requests + +#### Scenario: MS Graph provider sends email +- **WHEN** MS Graph provider's send() method is called with valid message content +- **THEN** it sends the email via Microsoft Graph API and returns the message id + +#### Scenario: MS Graph provider reads inbox +- **WHEN** MS Graph provider's read() method is called with folder="inbox" +- **THEN** it returns inbox messages via Microsoft Graph API's /me/messages endpoint + +#### Scenario: MS Graph provider handles token refresh +- **WHEN** MS Graph provider's access token expires during an operation +- **THEN** it automatically refreshes the token using the refresh token and retries the operation + +### Requirement: IMAP provider implementation +The system SHALL provide an IMAP provider as a universal fallback using username/password authentication. + +#### Scenario: IMAP provider authenticates with credentials +- **WHEN** IMAP provider is initialized with valid host, port, username, and password +- **THEN** it establishes an IMAP connection and can execute IMAP commands + +#### Scenario: IMAP provider sends email via SMTP +- **WHEN** IMAP provider's send() method is called with valid message content +- **THEN** it sends the email via SMTP and returns success + +#### Scenario: IMAP provider reads inbox +- **WHEN** IMAP provider's read() method is called with folder="inbox" +- **THEN** it fetches messages via IMAP FETCH commands and returns them in the standard format + +#### Scenario: IMAP provider handles connection errors +- **WHEN** IMAP provider cannot connect to the server +- **THEN** it throws a descriptive error with the connection failure details + +### Requirement: Provider factory and selection +The system SHALL provide a factory that creates the appropriate provider instance based on configuration. + +#### Scenario: Factory creates Gmail provider when configured +- **WHEN** config specifies provider="gmail" with OAuth2 credentials +- **THEN** factory returns a Gmail provider instance + +#### Scenario: Factory creates MS Graph provider when configured +- **WHEN** config specifies provider="graph" with OAuth2 credentials +- **THEN** factory returns an MS Graph provider instance + +#### Scenario: Factory creates IMAP provider when configured +- **WHEN** config specifies provider="imap" with host, port, username, and password +- **THEN** factory returns an IMAP provider instance + +#### Scenario: Factory returns null when no provider configured +- **WHEN** no email provider is configured in config.yaml +- **THEN** factory returns null and email tools gracefully report "no provider configured" + diff --git a/openspec/specs/email-tools/spec.md b/openspec/specs/email-tools/spec.md new file mode 100644 index 00000000..e6bebdee --- /dev/null +++ b/openspec/specs/email-tools/spec.md @@ -0,0 +1,121 @@ +# email-tools Specification + +## Purpose +TBD - created by archiving change email-integration. Update Purpose after archive. +## Requirements +### Requirement: Email read tool fetches messages with filtering +The system SHALL provide an `email.read` tool that fetches messages from configurable folders (inbox, sent, drafts, custom) with filtering by sender, date range, subject keyword, and keyword body search. + +#### Scenario: Fetch inbox messages +- **WHEN** user calls email.read with folder="inbox" and limit=10 +- **THEN** system returns up to 10 most recent messages with id, subject, from, date, snippet, and isRead fields + +#### Scenario: Filter by sender +- **WHEN** user calls email.read with folder="inbox" and sender="alice@example.com" +- **THEN** system returns only messages where the sender address matches alice@example.com + +#### Scenario: Filter by date range +- **WHEN** user calls email.read with folder="inbox" and dateAfter="2024-01-01" and dateBefore="2024-06-01" +- **THEN** system returns only messages received within the specified date range + +#### Scenario: Filter by subject keyword +- **WHEN** user calls email.read with folder="inbox" and subject="invoice" +- **THEN** system returns messages whose subject contains the keyword "invoice" (case-insensitive) + +#### Scenario: Fetch sent messages +- **WHEN** user calls email.read with folder="sent" and limit=5 +- **THEN** system returns up to 5 most recent sent messages + +#### Scenario: Fetch draft messages +- **WHEN** user calls email.read with folder="drafts" +- **THEN** system returns all saved draft messages with id, subject, to, bodyPreview, and lastModified fields + +#### Scenario: No matching messages +- **WHEN** user calls email.read with folder="inbox" and subject="zzzznonexistent" +- **THEN** system returns an empty array + +### Requirement: Email send tool composes and sends messages +The system SHALL provide an `email.send` tool that composes and sends emails with text or HTML body, optional attachments, and CC/BCC recipients. + +#### Scenario: Send plain text email +- **WHEN** user calls email.send with to="recipient@example.com", subject="Hello", body="Hello world" +- **THEN** system sends the email successfully and returns the sent message id + +#### Scenario: Send HTML email +- **WHEN** user calls email.send with to="recipient@example.com", subject="Report", body="

Report

Data

", html=true +- **THEN** system sends the email with HTML content and returns the sent message id + +#### Scenario: Send with CC recipients +- **WHEN** user calls email.send with to="recipient@example.com", cc="manager@example.com", subject="FYI", body="FYI" +- **THEN** system sends the email with both the primary recipient and CC recipient + +#### Scenario: Send with BCC recipients +- **WHEN** user calls email.send with to="recipient@example.com", bcc="hidden@example.com", subject="Secret", body="Hidden" +- **THEN** system sends the email with the BCC recipient not visible to the primary recipient + +#### Scenario: Send with attachment +- **WHEN** user calls email.send with to="recipient@example.com", subject="Report", body="See attached", attachments=["/path/to/report.pdf"] +- **THEN** system attaches the file and sends the email, returning the sent message id + +#### Scenario: Send with multiple attachments +- **WHEN** user calls email.send with to="recipient@example.com", subject="Files", body="See attached", attachments=["/path/to/a.pdf", "/path/to/b.pdf"] +- **THEN** system attaches both files and sends the email + +#### Scenario: Send with invalid attachment path +- **WHEN** user calls email.send with to="recipient@example.com", subject="Bad", body="No file", attachments=["/nonexistent/file.txt"] +- **THEN** system returns an error indicating the attachment file was not found + +### Requirement: Email draft management +The system SHALL provide draft management tools for saving, listing, updating, and deleting email drafts. + +#### Scenario: Save a draft +- **WHEN** user calls email.draft.save with to="recipient@example.com", subject="Work in Progress", body="Draft content" +- **THEN** system saves the draft and returns the draft id + +#### Scenario: List all drafts +- **WHEN** user calls email.draft.list +- **THEN** system returns all saved drafts with id, subject, to, bodyPreview, and lastModified fields + +#### Scenario: Update a draft +- **WHEN** user calls email.draft.update with draftId="draft-123", subject="Updated Subject", body="Updated content" +- **THEN** system updates the draft and returns the updated draft with a new lastModified timestamp + +#### Scenario: Delete a draft +- **WHEN** user calls email.draft.delete with draftId="draft-123" +- **THEN** system removes the draft and returns success + +#### Scenario: Delete non-existent draft +- **WHEN** user calls email.draft.delete with draftId="draft-999" +- **THEN** system returns an error indicating the draft was not found + +### Requirement: Email organize tool manages message state +The system SHALL provide an `email.organize` tool for labeling, archiving, marking read/unread, and searching messages. + +#### Scenario: Mark messages as read +- **WHEN** user calls email.organize with action="markRead" and messageIds=["msg-1", "msg-2"] +- **THEN** system marks the specified messages as read + +#### Scenario: Mark messages as unread +- **WHEN** user calls email.organize with action="markUnread" and messageIds=["msg-3"] +- **THEN** system marks the specified messages as unread + +#### Scenario: Archive messages +- **WHEN** user calls email.organize with action="archive" and messageIds=["msg-4", "msg-5"] +- **THEN** system archives the specified messages, removing them from the inbox + +#### Scenario: Add label to message +- **WHEN** user calls email.organize with action="addLabel", messageIds=["msg-6"], label="important" +- **THEN** system adds the "important" label to the specified messages + +#### Scenario: Remove label from message +- **WHEN** user calls email.organize with action="removeLabel", messageIds=["msg-6"], label="important" +- **THEN** system removes the "important" label from the specified messages + +#### Scenario: Search messages +- **WHEN** user calls email.organize with action="search", query="quarterly report", folder="all" +- **THEN** system returns messages matching the search query across the specified folder + +#### Scenario: Search with no results +- **WHEN** user calls email.organize with action="search", query="zzzznonexistent", folder="inbox" +- **THEN** system returns an empty array + From d2d5d6bb837af994c20aa87981b51675e7f03232 Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Sun, 16 Aug 2026 09:44:25 -0400 Subject: [PATCH 04/16] fix: resolve lint errors in email provider code --- src/config/schemas/providers.js | 5 +- src/tools/email/index.js | 5 +- src/tools/email/providers/base.js | 18 +- src/tools/email/providers/gmail.js | 28 +- src/tools/email/providers/graph.js | 98 ++- src/tools/email/providers/imap.js | 2 +- src/tools/email/tools.js | 649 ++++++++++-------- tests/unit/config/providers.test.js | 22 +- tests/unit/tools/email/email-tools.test.js | 13 +- tests/unit/tools/email/index.test.js | 13 +- tests/unit/tools/email/providers/base.test.js | 2 +- .../unit/tools/email/providers/gmail.test.js | 77 ++- .../unit/tools/email/providers/graph.test.js | 77 ++- tests/unit/tools/email/providers/imap.test.js | 14 +- 14 files changed, 613 insertions(+), 410 deletions(-) diff --git a/src/config/schemas/providers.js b/src/config/schemas/providers.js index c8bc3430..0c73d4db 100644 --- a/src/config/schemas/providers.js +++ b/src/config/schemas/providers.js @@ -103,7 +103,10 @@ export const GraphProviderSchema = z.object({ clientSecret: z.string().optional().default(""), accessToken: z.string().optional().default(""), refreshToken: z.string().optional().default(""), - refreshTokenUrl: z.string().optional().default("https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token"), + refreshTokenUrl: z + .string() + .optional() + .default("https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token"), }); export const ImapProviderSchema = z.object({ diff --git a/src/tools/email/index.js b/src/tools/email/index.js index 313f691f..b70a3c27 100644 --- a/src/tools/email/index.js +++ b/src/tools/email/index.js @@ -1,14 +1,13 @@ import { GmailProvider } from "./providers/gmail.js"; import { GraphProvider } from "./providers/graph.js"; import { ImapProvider } from "./providers/imap.js"; -import { EmailProvider } from "./providers/base.js"; /** * Email provider factory. * Selects the appropriate provider based on configuration. * @param {object} config - Provider configuration * @param {string} config.type - Provider type: "gmail", "graph", or "imap" - * @returns {EmailProvider} + * @returns {import("./providers/base.js").default} */ export function createEmailProvider(config) { if (!config || !config.type) { @@ -85,4 +84,4 @@ export function validateProviderConfig(config) { export { EmailProvider } from "./providers/base.js"; export { GmailProvider } from "./providers/gmail.js"; export { GraphProvider } from "./providers/graph.js"; -export { ImapProvider } from "./providers/imap.js"; \ No newline at end of file +export { ImapProvider } from "./providers/imap.js"; diff --git a/src/tools/email/providers/base.js b/src/tools/email/providers/base.js index 0a7e9e76..f60b6c1e 100644 --- a/src/tools/email/providers/base.js +++ b/src/tools/email/providers/base.js @@ -33,7 +33,7 @@ export class EmailProvider { * @param {Array<{filename: string, content: string, contentType?: string}>} [params.attachments] - Attachments * @returns {Promise<{ ok: boolean, messageId?: string, error?: string }>} */ - async send(params) { + async send(_params) { throw new Error(`send() not implemented for ${this.type} provider`); } @@ -50,7 +50,7 @@ export class EmailProvider { * @param {string} [params.label] - Filter by label * @returns {Promise<{ ok: boolean, messages?: object[], error?: string }>} */ - async read(params = {}) { + async read(_params = {}) { throw new Error(`read() not implemented for ${this.type} provider`); } @@ -61,7 +61,7 @@ export class EmailProvider { * @param {number} [params.limit=20] - Max results * @returns {Promise<{ ok: boolean, messages?: object[], error?: string }>} */ - async search(params) { + async search(_params) { throw new Error(`search() not implemented for ${this.type} provider`); } @@ -74,7 +74,7 @@ export class EmailProvider { * @param {string} [params.bodyType="text"] - "text" or "html" * @returns {Promise<{ ok: boolean, draftId?: string, error?: string }>} */ - async saveDraft(params) { + async saveDraft(_params) { throw new Error(`saveDraft() not implemented for ${this.type} provider`); } @@ -84,7 +84,7 @@ export class EmailProvider { * @param {number} [params.limit=20] - Max drafts * @returns {Promise<{ ok: boolean, drafts?: object[], error?: string }>} */ - async listDrafts(params = {}) { + async listDrafts(_params = {}) { throw new Error(`listDrafts() not implemented for ${this.type} provider`); } @@ -97,7 +97,7 @@ export class EmailProvider { * @param {string} [params.body] - Draft body * @returns {Promise<{ ok: boolean, draftId?: string, error?: string }>} */ - async updateDraft(draftId, params) { + async updateDraft(_draftId, _params) { throw new Error(`updateDraft() not implemented for ${this.type} provider`); } @@ -106,7 +106,7 @@ export class EmailProvider { * @param {string} draftId - Draft identifier * @returns {Promise<{ ok: boolean, error?: string }>} */ - async deleteDraft(draftId) { + async deleteDraft(_draftId) { throw new Error(`deleteDraft() not implemented for ${this.type} provider`); } @@ -118,7 +118,7 @@ export class EmailProvider { * @param {string} [params.label] - Label name (for addLabel/removeLabel) * @returns {Promise<{ ok: boolean, error?: string }>} */ - async organize(params) { + async organize(_params) { throw new Error(`organize() not implemented for ${this.type} provider`); } @@ -129,4 +129,4 @@ export class EmailProvider { validateConfig() { return { valid: true }; } -} \ No newline at end of file +} diff --git a/src/tools/email/providers/gmail.js b/src/tools/email/providers/gmail.js index cf4c4aa9..126306c1 100644 --- a/src/tools/email/providers/gmail.js +++ b/src/tools/email/providers/gmail.js @@ -248,7 +248,16 @@ export class GmailProvider extends EmailProvider { break; } case "archive": { - const modifyRequest = { removeLabelIds: ["INBOX", "CATEGORY_UPDATES", "CATEGORY_SOCIAL", "CATEGORY_PROMOTIONS", "CATEGORY_UPDATES", "CATEGORY_FORUMS"] }; + const modifyRequest = { + removeLabelIds: [ + "INBOX", + "CATEGORY_UPDATES", + "CATEGORY_SOCIAL", + "CATEGORY_PROMOTIONS", + "CATEGORY_UPDATES", + "CATEGORY_FORUMS", + ], + }; for (const id of messageIds) { await this.#gmail.users.messages.modify({ userId: this.#userId, @@ -347,15 +356,24 @@ export class GmailProvider extends EmailProvider { if (payload.parts) { for (const part of payload.parts) { if (part.mimeType === "text/plain" && part.body?.data) { - body = Buffer.from(part.body.data.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf-8"); + body = Buffer.from( + part.body.data.replace(/-/g, "+").replace(/_/g, "/"), + "base64", + ).toString("utf-8"); break; } if (part.mimeType === "text/html" && part.body?.data && !body) { - body = Buffer.from(part.body.data.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf-8"); + body = Buffer.from( + part.body.data.replace(/-/g, "+").replace(/_/g, "/"), + "base64", + ).toString("utf-8"); } } } else if (payload.body?.data) { - body = Buffer.from(payload.body.data.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf-8"); + body = Buffer.from( + payload.body.data.replace(/-/g, "+").replace(/_/g, "/"), + "base64", + ).toString("utf-8"); } return { @@ -370,4 +388,4 @@ export class GmailProvider extends EmailProvider { snippet: message.snippet || "", }; } -} \ No newline at end of file +} diff --git a/src/tools/email/providers/graph.js b/src/tools/email/providers/graph.js index fb2a19af..465190b4 100644 --- a/src/tools/email/providers/graph.js +++ b/src/tools/email/providers/graph.js @@ -71,7 +71,7 @@ export class GraphProvider extends EmailProvider { grant_type: "refresh_token", scope: "https://graph.microsoft.com/.default", }), - } + }, ); if (!response.ok) { @@ -130,7 +130,7 @@ export class GraphProvider extends EmailProvider { "Content-Type": "application/json", }, body: JSON.stringify({ message, saveToSentItems: true }), - } + }, ); if (!response.ok) { @@ -197,7 +197,7 @@ export class GraphProvider extends EmailProvider { `https://graph.microsoft.com/v1.0/users/${this.#userId}/messages?$q=${encodeURIComponent(params.query)}&$top=${params.limit || 20}&$select=id,subject,from,toRecipients,body,receivedDateTime`, { headers: { Authorization: `Bearer ${token}` }, - } + }, ); if (!response.ok) { @@ -240,7 +240,7 @@ export class GraphProvider extends EmailProvider { "Content-Type": "application/json", }, body: JSON.stringify(message), - } + }, ); if (!response.ok) { @@ -266,7 +266,7 @@ export class GraphProvider extends EmailProvider { `https://graph.microsoft.com/v1.0/users/${this.#userId}/messages/drafts?$top=${params.limit || 20}&$select=id,subject,from,body,receivedDateTime`, { headers: { Authorization: `Bearer ${token}` }, - } + }, ); if (!response.ok) { @@ -310,7 +310,7 @@ export class GraphProvider extends EmailProvider { "Content-Type": "application/json", }, body: JSON.stringify(message), - } + }, ); if (!response.ok) { @@ -336,7 +336,7 @@ export class GraphProvider extends EmailProvider { { method: "DELETE", headers: { Authorization: `Bearer ${token}` }, - } + }, ); if (!response.ok) { @@ -362,33 +362,27 @@ export class GraphProvider extends EmailProvider { case "markRead": { // Graph doesn't have a direct "mark read" — set flag to clean for (const id of messageIds) { - await fetch( - `https://graph.microsoft.com/v1.0/users/${this.#userId}/messages/${id}`, - { - method: "PATCH", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ Flag: { flagStatus: "clean" } }), - } - ); + await fetch(`https://graph.microsoft.com/v1.0/users/${this.#userId}/messages/${id}`, { + method: "PATCH", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ Flag: { flagStatus: "clean" } }), + }); } break; } case "markUnread": { for (const id of messageIds) { - await fetch( - `https://graph.microsoft.com/v1.0/users/${this.#userId}/messages/${id}`, - { - method: "PATCH", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ Flag: { flagStatus: "flagged" } }), - } - ); + await fetch(`https://graph.microsoft.com/v1.0/users/${this.#userId}/messages/${id}`, { + method: "PATCH", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ Flag: { flagStatus: "flagged" } }), + }); } break; } @@ -403,7 +397,7 @@ export class GraphProvider extends EmailProvider { "Content-Type": "application/json", }, body: JSON.stringify({ destinationId: "deletedmessages" }), - } + }, ); } break; @@ -411,35 +405,29 @@ export class GraphProvider extends EmailProvider { case "addLabel": { // Graph uses categories for labels for (const id of messageIds) { - await fetch( - `https://graph.microsoft.com/v1.0/users/${this.#userId}/messages/${id}`, - { - method: "PATCH", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ categories: [...(params.categories || []), params.label] }), - } - ); + await fetch(`https://graph.microsoft.com/v1.0/users/${this.#userId}/messages/${id}`, { + method: "PATCH", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ categories: [...(params.categories || []), params.label] }), + }); } break; } case "removeLabel": { for (const id of messageIds) { - await fetch( - `https://graph.microsoft.com/v1.0/users/${this.#userId}/messages/${id}`, - { - method: "PATCH", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - categories: (params.categories || []).filter((l) => l !== params.label), - }), - } - ); + await fetch(`https://graph.microsoft.com/v1.0/users/${this.#userId}/messages/${id}`, { + method: "PATCH", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + categories: (params.categories || []).filter((l) => l !== params.label), + }), + }); } break; } @@ -480,4 +468,4 @@ export class GraphProvider extends EmailProvider { bodyPreview: message.bodyPreview || "", }; } -} \ No newline at end of file +} diff --git a/src/tools/email/providers/imap.js b/src/tools/email/providers/imap.js index 8bd7b3a4..e9dc04b4 100644 --- a/src/tools/email/providers/imap.js +++ b/src/tools/email/providers/imap.js @@ -359,4 +359,4 @@ export class ImapProvider extends EmailProvider { uid, }; } -} \ No newline at end of file +} diff --git a/src/tools/email/tools.js b/src/tools/email/tools.js index 7e21f0b1..356b74fd 100644 --- a/src/tools/email/tools.js +++ b/src/tools/email/tools.js @@ -8,350 +8,407 @@ const config = loadConfig(); /** * Email read tool — fetch messages from the configured email provider. */ -export const emailRead = tool(async ({ folder, limit, sender, subject, keyword, dateFrom, dateTo, label }) => { - const provider = getActiveProvider(config); - if (!provider) { - return { ok: false, error: "No email provider configured. Set up email credentials in config.yaml." }; - } - - const validation = validateProviderConfig(config.email?.provider); - if (!validation.valid) { - return { ok: false, error: `Invalid email provider config: ${validation.errors?.join("; ")}` }; - } - - try { - const result = await provider.read({ - folder, - limit, - sender, - subject, - keyword, - dateFrom, - dateTo, - label, - }); - - if (!result.ok) { - return { ok: false, error: result.error }; +export const emailRead = tool( + async ({ folder, limit, sender, subject, keyword, dateFrom, dateTo, label }) => { + const provider = getActiveProvider(config); + if (!provider) { + return { + ok: false, + error: "No email provider configured. Set up email credentials in config.yaml.", + }; } - return { - ok: true, - count: result.messages?.length || 0, - messages: result.messages, - }; - } catch (err) { - return { ok: false, error: `Email read failed: ${err.message}` }; - } -}, { - name: "email_read", - description: "Read emails from inbox, sent, drafts, or custom folders. Supports filtering by sender, subject, keyword, date range, and label.", - schema: z.object({ - folder: z.string().optional().describe("Mailbox folder (default: INBOX). Examples: INBOX, Sent, Drafts, [Gmail]/Trash"), - limit: z.number().optional().default(20).describe("Maximum number of messages to return"), - sender: z.string().optional().describe("Filter by sender email address"), - subject: z.string().optional().describe("Filter by subject keyword"), - keyword: z.string().optional().describe("Filter by body keyword"), - dateFrom: z.string().optional().describe("Filter by date from (ISO 8601 string)"), - dateTo: z.string().optional().describe("Filter by date to (ISO 8601 string)"), - label: z.string().optional().describe("Filter by label (Gmail-specific)"), - }), -}); + const validation = validateProviderConfig(config.email?.provider); + if (!validation.valid) { + return { + ok: false, + error: `Invalid email provider config: ${validation.errors?.join("; ")}`, + }; + } + + try { + const result = await provider.read({ + folder, + limit, + sender, + subject, + keyword, + dateFrom, + dateTo, + label, + }); + + if (!result.ok) { + return { ok: false, error: result.error }; + } + + return { + ok: true, + count: result.messages?.length || 0, + messages: result.messages, + }; + } catch (err) { + return { ok: false, error: `Email read failed: ${err.message}` }; + } + }, + { + name: "email_read", + description: + "Read emails from inbox, sent, drafts, or custom folders. Supports filtering by sender, subject, keyword, date range, and label.", + schema: z.object({ + folder: z + .string() + .optional() + .describe("Mailbox folder (default: INBOX). Examples: INBOX, Sent, Drafts, [Gmail]/Trash"), + limit: z.number().optional().default(20).describe("Maximum number of messages to return"), + sender: z.string().optional().describe("Filter by sender email address"), + subject: z.string().optional().describe("Filter by subject keyword"), + keyword: z.string().optional().describe("Filter by body keyword"), + dateFrom: z.string().optional().describe("Filter by date from (ISO 8601 string)"), + dateTo: z.string().optional().describe("Filter by date to (ISO 8601 string)"), + label: z.string().optional().describe("Filter by label (Gmail-specific)"), + }), + }, +); /** * Email send tool — compose and send emails. */ -export const emailSend = tool(async ({ to, subject, body, bodyType, cc, bcc, attachments }) => { - const provider = getActiveProvider(config); - if (!provider) { - return { ok: false, error: "No email provider configured. Set up email credentials in config.yaml." }; - } - - const validation = validateProviderConfig(config.email?.provider); - if (!validation.valid) { - return { ok: false, error: `Invalid email provider config: ${validation.errors?.join("; ")}` }; - } - - if (!to || to.length === 0) { - return { ok: false, error: "At least one recipient (to) is required" }; - } - if (!subject) { - return { ok: false, error: "Subject is required" }; - } - if (!body) { - return { ok: false, error: "Body is required" }; - } - - try { - const result = await provider.send({ - to, - subject, - body, - bodyType, - cc, - bcc, - attachments, - }); - - if (!result.ok) { - return { ok: false, error: result.error }; +export const emailSend = tool( + async ({ to, subject, body, bodyType, cc, bcc, attachments }) => { + const provider = getActiveProvider(config); + if (!provider) { + return { + ok: false, + error: "No email provider configured. Set up email credentials in config.yaml.", + }; } - return { - ok: true, - messageId: result.messageId, - recipients: to, - }; - } catch (err) { - return { ok: false, error: `Email send failed: ${err.message}` }; - } -}, { - name: "email_send", - description: "Send an email with text/HTML body, attachments, CC/BCC support. Requires an email provider to be configured.", - schema: z.object({ - to: z.array(z.string()).min(1).describe("Recipient email addresses"), - subject: z.string().min(1).describe("Email subject line"), - body: z.string().min(1).describe("Email body content"), - bodyType: z.enum(["text", "html"]).optional().default("text").describe("Body format (default: text)"), - cc: z.array(z.string()).optional().describe("CC email addresses"), - bcc: z.array(z.string()).optional().describe("BCC email addresses"), - attachments: z - .array( - z.object({ - filename: z.string(), - content: z.string(), - contentType: z.string().optional(), - }) - ) - .optional() - .describe("File attachments (base64 encoded content)"), - }), -}); + const validation = validateProviderConfig(config.email?.provider); + if (!validation.valid) { + return { + ok: false, + error: `Invalid email provider config: ${validation.errors?.join("; ")}`, + }; + } + + if (!to || to.length === 0) { + return { ok: false, error: "At least one recipient (to) is required" }; + } + if (!subject) { + return { ok: false, error: "Subject is required" }; + } + if (!body) { + return { ok: false, error: "Body is required" }; + } + + try { + const result = await provider.send({ + to, + subject, + body, + bodyType, + cc, + bcc, + attachments, + }); + + if (!result.ok) { + return { ok: false, error: result.error }; + } + + return { + ok: true, + messageId: result.messageId, + recipients: to, + }; + } catch (err) { + return { ok: false, error: `Email send failed: ${err.message}` }; + } + }, + { + name: "email_send", + description: + "Send an email with text/HTML body, attachments, CC/BCC support. Requires an email provider to be configured.", + schema: z.object({ + to: z.array(z.string()).min(1).describe("Recipient email addresses"), + subject: z.string().min(1).describe("Email subject line"), + body: z.string().min(1).describe("Email body content"), + bodyType: z + .enum(["text", "html"]) + .optional() + .default("text") + .describe("Body format (default: text)"), + cc: z.array(z.string()).optional().describe("CC email addresses"), + bcc: z.array(z.string()).optional().describe("BCC email addresses"), + attachments: z + .array( + z.object({ + filename: z.string(), + content: z.string(), + contentType: z.string().optional(), + }), + ) + .optional() + .describe("File attachments (base64 encoded content)"), + }), + }, +); /** * Email draft save tool. */ -export const emailDraftSave = tool(async ({ to, subject, body, bodyType }) => { - const provider = getActiveProvider(config); - if (!provider) { - return { ok: false, error: "No email provider configured." }; - } - - if (!to || to.length === 0) { - return { ok: false, error: "At least one recipient (to) is required" }; - } - if (!subject) { - return { ok: false, error: "Subject is required" }; - } - if (!body) { - return { ok: false, error: "Body is required" }; - } - - try { - const result = await provider.saveDraft({ to, subject, body, bodyType }); - - if (!result.ok) { - return { ok: false, error: result.error }; +export const emailDraftSave = tool( + async ({ to, subject, body, bodyType }) => { + const provider = getActiveProvider(config); + if (!provider) { + return { ok: false, error: "No email provider configured." }; } - return { ok: true, draftId: result.draftId }; - } catch (err) { - return { ok: false, error: `Email draft save failed: ${err.message}` }; - } -}, { - name: "email_draft_save", - description: "Save an email as a draft. Does not send the email.", - schema: z.object({ - to: z.array(z.string()).min(1).describe("Recipient email addresses"), - subject: z.string().min(1).describe("Draft subject"), - body: z.string().min(1).describe("Draft body content"), - bodyType: z.enum(["text", "html"]).optional().default("text").describe("Body format (default: text)"), - }), -}); + if (!to || to.length === 0) { + return { ok: false, error: "At least one recipient (to) is required" }; + } + if (!subject) { + return { ok: false, error: "Subject is required" }; + } + if (!body) { + return { ok: false, error: "Body is required" }; + } + + try { + const result = await provider.saveDraft({ to, subject, body, bodyType }); + + if (!result.ok) { + return { ok: false, error: result.error }; + } + + return { ok: true, draftId: result.draftId }; + } catch (err) { + return { ok: false, error: `Email draft save failed: ${err.message}` }; + } + }, + { + name: "email_draft_save", + description: "Save an email as a draft. Does not send the email.", + schema: z.object({ + to: z.array(z.string()).min(1).describe("Recipient email addresses"), + subject: z.string().min(1).describe("Draft subject"), + body: z.string().min(1).describe("Draft body content"), + bodyType: z + .enum(["text", "html"]) + .optional() + .default("text") + .describe("Body format (default: text)"), + }), + }, +); /** * Email draft list tool. */ -export const emailDraftList = tool(async ({ limit }) => { - const provider = getActiveProvider(config); - if (!provider) { - return { ok: false, error: "No email provider configured." }; - } +export const emailDraftList = tool( + async ({ limit }) => { + const provider = getActiveProvider(config); + if (!provider) { + return { ok: false, error: "No email provider configured." }; + } - try { - const result = await provider.listDrafts({ limit }); + try { + const result = await provider.listDrafts({ limit }); - if (!result.ok) { - return { ok: false, error: result.error }; - } + if (!result.ok) { + return { ok: false, error: result.error }; + } - return { - ok: true, - count: result.drafts?.length || 0, - drafts: result.drafts, - }; - } catch (err) { - return { ok: false, error: `Email draft list failed: ${err.message}` }; - } -}, { - name: "email_draft_list", - description: "List saved email drafts.", - schema: z.object({ - limit: z.number().optional().default(20).describe("Maximum number of drafts to return"), - }), -}); + return { + ok: true, + count: result.drafts?.length || 0, + drafts: result.drafts, + }; + } catch (err) { + return { ok: false, error: `Email draft list failed: ${err.message}` }; + } + }, + { + name: "email_draft_list", + description: "List saved email drafts.", + schema: z.object({ + limit: z.number().optional().default(20).describe("Maximum number of drafts to return"), + }), + }, +); /** * Email draft update tool. */ -export const emailDraftUpdate = tool(async ({ draftId, to, subject, body, bodyType }) => { - const provider = getActiveProvider(config); - if (!provider) { - return { ok: false, error: "No email provider configured." }; - } +export const emailDraftUpdate = tool( + async ({ draftId, to, subject, body, bodyType }) => { + const provider = getActiveProvider(config); + if (!provider) { + return { ok: false, error: "No email provider configured." }; + } - if (!draftId) { - return { ok: false, error: "Draft ID is required" }; - } + if (!draftId) { + return { ok: false, error: "Draft ID is required" }; + } - try { - const result = await provider.updateDraft(draftId, { to, subject, body, bodyType }); + try { + const result = await provider.updateDraft(draftId, { to, subject, body, bodyType }); - if (!result.ok) { - return { ok: false, error: result.error }; - } + if (!result.ok) { + return { ok: false, error: result.error }; + } - return { ok: true, draftId }; - } catch (err) { - return { ok: false, error: `Email draft update failed: ${err.message}` }; - } -}, { - name: "email_draft_update", - description: "Update an existing email draft. Provide draftId and any fields to update.", - schema: z.object({ - draftId: z.string().min(1).describe("Draft identifier"), - to: z.array(z.string()).optional().describe("Recipient email addresses"), - subject: z.string().optional().describe("Draft subject"), - body: z.string().optional().describe("Draft body content"), - bodyType: z.enum(["text", "html"]).optional().describe("Body format"), - }), -}); + return { ok: true, draftId }; + } catch (err) { + return { ok: false, error: `Email draft update failed: ${err.message}` }; + } + }, + { + name: "email_draft_update", + description: "Update an existing email draft. Provide draftId and any fields to update.", + schema: z.object({ + draftId: z.string().min(1).describe("Draft identifier"), + to: z.array(z.string()).optional().describe("Recipient email addresses"), + subject: z.string().optional().describe("Draft subject"), + body: z.string().optional().describe("Draft body content"), + bodyType: z.enum(["text", "html"]).optional().describe("Body format"), + }), + }, +); /** * Email draft delete tool. */ -export const emailDraftDelete = tool(async ({ draftId }) => { - const provider = getActiveProvider(config); - if (!provider) { - return { ok: false, error: "No email provider configured." }; - } +export const emailDraftDelete = tool( + async ({ draftId }) => { + const provider = getActiveProvider(config); + if (!provider) { + return { ok: false, error: "No email provider configured." }; + } - if (!draftId) { - return { ok: false, error: "Draft ID is required" }; - } + if (!draftId) { + return { ok: false, error: "Draft ID is required" }; + } - try { - const result = await provider.deleteDraft(draftId); + try { + const result = await provider.deleteDraft(draftId); - if (!result.ok) { - return { ok: false, error: result.error }; - } + if (!result.ok) { + return { ok: false, error: result.error }; + } - return { ok: true, draftId }; - } catch (err) { - return { ok: false, error: `Email draft delete failed: ${err.message}` }; - } -}, { - name: "email_draft_delete", - description: "Delete an email draft by ID.", - schema: z.object({ - draftId: z.string().min(1).describe("Draft identifier"), - }), -}); + return { ok: true, draftId }; + } catch (err) { + return { ok: false, error: `Email draft delete failed: ${err.message}` }; + } + }, + { + name: "email_draft_delete", + description: "Delete an email draft by ID.", + schema: z.object({ + draftId: z.string().min(1).describe("Draft identifier"), + }), + }, +); /** * Email organize tool — mark read/unread, archive, label. */ -export const emailOrganize = tool(async ({ messageIds, action, label }) => { - const provider = getActiveProvider(config); - if (!provider) { - return { ok: false, error: "No email provider configured." }; - } - - if (!messageIds || (Array.isArray(messageIds) && messageIds.length === 0)) { - return { ok: false, error: "At least one message ID is required" }; - } - if (!action) { - return { ok: false, error: "Action is required (markRead, markUnread, archive, addLabel, removeLabel)" }; - } - - const validActions = ["markRead", "markUnread", "archive", "addLabel", "removeLabel"]; - if (!validActions.includes(action)) { - return { ok: false, error: `Invalid action: ${action}. Valid: ${validActions.join(", ")}` }; - } - - if ((action === "addLabel" || action === "removeLabel") && !label) { - return { ok: false, error: "Label is required for addLabel/removeLabel actions" }; - } - - try { - const result = await provider.organize({ messageIds, action, label }); - - if (!result.ok) { - return { ok: false, error: result.error }; +export const emailOrganize = tool( + async ({ messageIds, action, label }) => { + const provider = getActiveProvider(config); + if (!provider) { + return { ok: false, error: "No email provider configured." }; } - return { - ok: true, - action, - messageCount: Array.isArray(messageIds) ? messageIds.length : 1, - }; - } catch (err) { - return { ok: false, error: `Email organize failed: ${err.message}` }; - } -}, { - name: "email_organize", - description: "Organize emails: mark as read/unread, archive, add/remove labels. Requires message IDs.", - schema: z.object({ - messageIds: z.union([z.string(), z.array(z.string())]).describe("Message ID or array of message IDs"), - action: z.enum(["markRead", "markUnread", "archive", "addLabel", "removeLabel"]).describe("Organization action"), - label: z.string().optional().describe("Label name (required for addLabel/removeLabel)"), - }), -}); + if (!messageIds || (Array.isArray(messageIds) && messageIds.length === 0)) { + return { ok: false, error: "At least one message ID is required" }; + } + if (!action) { + return { + ok: false, + error: "Action is required (markRead, markUnread, archive, addLabel, removeLabel)", + }; + } + + const validActions = ["markRead", "markUnread", "archive", "addLabel", "removeLabel"]; + if (!validActions.includes(action)) { + return { ok: false, error: `Invalid action: ${action}. Valid: ${validActions.join(", ")}` }; + } + + if ((action === "addLabel" || action === "removeLabel") && !label) { + return { ok: false, error: "Label is required for addLabel/removeLabel actions" }; + } + + try { + const result = await provider.organize({ messageIds, action, label }); + + if (!result.ok) { + return { ok: false, error: result.error }; + } + + return { + ok: true, + action, + messageCount: Array.isArray(messageIds) ? messageIds.length : 1, + }; + } catch (err) { + return { ok: false, error: `Email organize failed: ${err.message}` }; + } + }, + { + name: "email_organize", + description: + "Organize emails: mark as read/unread, archive, add/remove labels. Requires message IDs.", + schema: z.object({ + messageIds: z + .union([z.string(), z.array(z.string())]) + .describe("Message ID or array of message IDs"), + action: z + .enum(["markRead", "markUnread", "archive", "addLabel", "removeLabel"]) + .describe("Organization action"), + label: z.string().optional().describe("Label name (required for addLabel/removeLabel)"), + }), + }, +); /** * Email search tool — search across the mailbox. */ -export const emailSearch = tool(async ({ query, limit }) => { - const provider = getActiveProvider(config); - if (!provider) { - return { ok: false, error: "No email provider configured." }; - } +export const emailSearch = tool( + async ({ query, limit }) => { + const provider = getActiveProvider(config); + if (!provider) { + return { ok: false, error: "No email provider configured." }; + } - if (!query) { - return { ok: false, error: "Search query is required" }; - } + if (!query) { + return { ok: false, error: "Search query is required" }; + } - try { - const result = await provider.search({ query, limit }); + try { + const result = await provider.search({ query, limit }); - if (!result.ok) { - return { ok: false, error: result.error }; - } + if (!result.ok) { + return { ok: false, error: result.error }; + } - return { - ok: true, - count: result.messages?.length || 0, - messages: result.messages, - }; - } catch (err) { - return { ok: false, error: `Email search failed: ${err.message}` }; - } -}, { - name: "email_search", - description: "Search emails across the mailbox using a text query.", - schema: z.object({ - query: z.string().min(1).describe("Search query text"), - limit: z.number().optional().default(20).describe("Maximum number of results"), - }), -}); \ No newline at end of file + return { + ok: true, + count: result.messages?.length || 0, + messages: result.messages, + }; + } catch (err) { + return { ok: false, error: `Email search failed: ${err.message}` }; + } + }, + { + name: "email_search", + description: "Search emails across the mailbox using a text query.", + schema: z.object({ + query: z.string().min(1).describe("Search query text"), + limit: z.number().optional().default(20).describe("Maximum number of results"), + }), + }, +); diff --git a/tests/unit/config/providers.test.js b/tests/unit/config/providers.test.js index 38d6a982..48ee410e 100644 --- a/tests/unit/config/providers.test.js +++ b/tests/unit/config/providers.test.js @@ -94,17 +94,31 @@ describe("Email Provider Config Schemas", () => { describe("EmailProviderSchema (discriminated union)", () => { test("should accept Gmail provider", () => { - const result = EmailProviderSchema.safeParse({ type: "gmail", clientId: "id", clientSecret: "secret", refreshToken: "token" }); + const result = EmailProviderSchema.safeParse({ + type: "gmail", + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + }); assert.strictEqual(result.success, true); }); test("should accept Graph provider", () => { - const result = EmailProviderSchema.safeParse({ type: "graph", clientId: "id", clientSecret: "secret", refreshToken: "token" }); + const result = EmailProviderSchema.safeParse({ + type: "graph", + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + }); assert.strictEqual(result.success, true); }); test("should accept IMAP provider", () => { - const result = EmailProviderSchema.safeParse({ type: "imap", user: "user", password: "pass" }); + const result = EmailProviderSchema.safeParse({ + type: "imap", + user: "user", + password: "pass", + }); assert.strictEqual(result.success, true); }); @@ -141,4 +155,4 @@ describe("Email Provider Config Schemas", () => { assert.strictEqual(result.success, false); }); }); -}); \ No newline at end of file +}); diff --git a/tests/unit/tools/email/email-tools.test.js b/tests/unit/tools/email/email-tools.test.js index 17ae089e..b411b764 100644 --- a/tests/unit/tools/email/email-tools.test.js +++ b/tests/unit/tools/email/email-tools.test.js @@ -1,6 +1,15 @@ import { test, describe } from "node:test"; import assert from "node:assert"; -import { emailRead, emailSend, emailDraftSave, emailDraftList, emailDraftUpdate, emailDraftDelete, emailOrganize, emailSearch } from "../../../src/tools/email/tools.js"; +import { + emailRead, + emailSend, + emailDraftSave, + emailDraftList, + emailDraftUpdate, + emailDraftDelete, + emailOrganize, + emailSearch, +} from "../../../src/tools/email/tools.js"; describe("Email Tools Integration", () => { test("emailRead returns structured error when no provider", async () => { @@ -66,4 +75,4 @@ describe("Email Tools Integration", () => { assert.ok(emailOrganize.name); assert.ok(emailOrganize.description); }); -}); \ No newline at end of file +}); diff --git a/tests/unit/tools/email/index.test.js b/tests/unit/tools/email/index.test.js index 35fe92a7..2c157257 100644 --- a/tests/unit/tools/email/index.test.js +++ b/tests/unit/tools/email/index.test.js @@ -1,6 +1,15 @@ import { test, describe } from "node:test"; import assert from "node:assert"; -import { emailRead, emailSend, emailDraftSave, emailDraftList, emailDraftUpdate, emailDraftDelete, emailOrganize, emailSearch } from "../../../src/tools/email/tools.js"; +import { + emailRead, + emailSend, + emailDraftSave, + emailDraftList, + emailDraftUpdate, + emailDraftDelete, + emailOrganize, + emailSearch, +} from "../../../src/tools/email/tools.js"; describe("Email Tools", () => { test("emailRead should have a valid name", () => { @@ -82,4 +91,4 @@ describe("Email Tools", () => { assert.strictEqual(result.ok, false); assert.ok(result.error); }); -}); \ No newline at end of file +}); diff --git a/tests/unit/tools/email/providers/base.test.js b/tests/unit/tools/email/providers/base.test.js index b6fd2204..16988287 100644 --- a/tests/unit/tools/email/providers/base.test.js +++ b/tests/unit/tools/email/providers/base.test.js @@ -58,4 +58,4 @@ describe("EmailProvider (base)", () => { assert.strictEqual(msg.body, "Hello"); assert.strictEqual(msg.date, "2024-01-01T00:00:00Z"); }); -}); \ No newline at end of file +}); diff --git a/tests/unit/tools/email/providers/gmail.test.js b/tests/unit/tools/email/providers/gmail.test.js index b8fc3de8..2a0cc0cd 100644 --- a/tests/unit/tools/email/providers/gmail.test.js +++ b/tests/unit/tools/email/providers/gmail.test.js @@ -8,63 +8,112 @@ describe("GmailProvider", () => { }); test("should throw when clientId is missing", () => { - assert.throws(() => new GmailProvider({ clientSecret: "secret", refreshToken: "token" }), /clientId/); + assert.throws( + () => new GmailProvider({ clientSecret: "secret", refreshToken: "token" }), + /clientId/, + ); }); test("should throw when clientSecret is missing", () => { - assert.throws(() => new GmailProvider({ clientId: "id", refreshToken: "token" }), /clientSecret/); + assert.throws( + () => new GmailProvider({ clientId: "id", refreshToken: "token" }), + /clientSecret/, + ); }); test("should throw when refreshToken is missing", () => { - assert.throws(() => new GmailProvider({ clientId: "id", clientSecret: "secret" }), /refreshToken/); + assert.throws( + () => new GmailProvider({ clientId: "id", clientSecret: "secret" }), + /refreshToken/, + ); }); test("read() should return { ok: false } when googleapis is not installed", async () => { - const provider = new GmailProvider({ clientId: "id", clientSecret: "secret", refreshToken: "token" }); + const provider = new GmailProvider({ + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + }); const result = await provider.read({ limit: 5 }); assert.strictEqual(result.ok, false); assert.ok(result.error); }); test("send() should return { ok: false } when googleapis is not installed", async () => { - const provider = new GmailProvider({ clientId: "id", clientSecret: "secret", refreshToken: "token" }); - const result = await provider.send({ to: ["test@example.com"], subject: "Test", body: "Hello" }); + const provider = new GmailProvider({ + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + }); + const result = await provider.send({ + to: ["test@example.com"], + subject: "Test", + body: "Hello", + }); assert.strictEqual(result.ok, false); }); test("saveDraft() should return { ok: false } when googleapis is not installed", async () => { - const provider = new GmailProvider({ clientId: "id", clientSecret: "secret", refreshToken: "token" }); - const result = await provider.saveDraft({ to: ["test@example.com"], subject: "Test", body: "Hello" }); + const provider = new GmailProvider({ + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + }); + const result = await provider.saveDraft({ + to: ["test@example.com"], + subject: "Test", + body: "Hello", + }); assert.strictEqual(result.ok, false); }); test("listDrafts() should return { ok: false } when googleapis is not installed", async () => { - const provider = new GmailProvider({ clientId: "id", clientSecret: "secret", refreshToken: "token" }); + const provider = new GmailProvider({ + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + }); const result = await provider.listDrafts({}); assert.strictEqual(result.ok, false); }); test("updateDraft() should return { ok: false } when googleapis is not installed", async () => { - const provider = new GmailProvider({ clientId: "id", clientSecret: "secret", refreshToken: "token" }); + const provider = new GmailProvider({ + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + }); const result = await provider.updateDraft("draft-id", { subject: "Updated" }); assert.strictEqual(result.ok, false); }); test("deleteDraft() should return { ok: false } when googleapis is not installed", async () => { - const provider = new GmailProvider({ clientId: "id", clientSecret: "secret", refreshToken: "token" }); + const provider = new GmailProvider({ + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + }); const result = await provider.deleteDraft("draft-id"); assert.strictEqual(result.ok, false); }); test("organize() should return { ok: false } when googleapis is not installed", async () => { - const provider = new GmailProvider({ clientId: "id", clientSecret: "secret", refreshToken: "token" }); + const provider = new GmailProvider({ + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + }); const result = await provider.organize({ messageIds: ["msg-1"], action: "markRead" }); assert.strictEqual(result.ok, false); }); test("search() should return { ok: false } when googleapis is not installed", async () => { - const provider = new GmailProvider({ clientId: "id", clientSecret: "secret", refreshToken: "token" }); + const provider = new GmailProvider({ + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + }); const result = await provider.search({ query: "test" }); assert.strictEqual(result.ok, false); }); -}); \ No newline at end of file +}); diff --git a/tests/unit/tools/email/providers/graph.test.js b/tests/unit/tools/email/providers/graph.test.js index b8b0197c..368a3860 100644 --- a/tests/unit/tools/email/providers/graph.test.js +++ b/tests/unit/tools/email/providers/graph.test.js @@ -8,63 +8,112 @@ describe("GraphProvider", () => { }); test("should throw when clientId is missing", () => { - assert.throws(() => new GraphProvider({ clientSecret: "secret", refreshToken: "token" }), /clientId/); + assert.throws( + () => new GraphProvider({ clientSecret: "secret", refreshToken: "token" }), + /clientId/, + ); }); test("should throw when clientSecret is missing", () => { - assert.throws(() => new GraphProvider({ clientId: "id", refreshToken: "token" }), /clientSecret/); + assert.throws( + () => new GraphProvider({ clientId: "id", refreshToken: "token" }), + /clientSecret/, + ); }); test("should throw when refreshToken is missing", () => { - assert.throws(() => new GraphProvider({ clientId: "id", clientSecret: "secret" }), /refreshToken/); + assert.throws( + () => new GraphProvider({ clientId: "id", clientSecret: "secret" }), + /refreshToken/, + ); }); test("read() should return { ok: false } when @microsoft/microsoft-graph-client is not installed", async () => { - const provider = new GraphProvider({ clientId: "id", clientSecret: "secret", refreshToken: "token" }); + const provider = new GraphProvider({ + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + }); const result = await provider.read({ limit: 5 }); assert.strictEqual(result.ok, false); assert.ok(result.error); }); test("send() should return { ok: false } when @microsoft/microsoft-graph-client is not installed", async () => { - const provider = new GraphProvider({ clientId: "id", clientSecret: "secret", refreshToken: "token" }); - const result = await provider.send({ to: ["test@example.com"], subject: "Test", body: "Hello" }); + const provider = new GraphProvider({ + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + }); + const result = await provider.send({ + to: ["test@example.com"], + subject: "Test", + body: "Hello", + }); assert.strictEqual(result.ok, false); }); test("saveDraft() should return { ok: false } when @microsoft/microsoft-graph-client is not installed", async () => { - const provider = new GraphProvider({ clientId: "id", clientSecret: "secret", refreshToken: "token" }); - const result = await provider.saveDraft({ to: ["test@example.com"], subject: "Test", body: "Hello" }); + const provider = new GraphProvider({ + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + }); + const result = await provider.saveDraft({ + to: ["test@example.com"], + subject: "Test", + body: "Hello", + }); assert.strictEqual(result.ok, false); }); test("listDrafts() should return { ok: false } when @microsoft/microsoft-graph-client is not installed", async () => { - const provider = new GraphProvider({ clientId: "id", clientSecret: "secret", refreshToken: "token" }); + const provider = new GraphProvider({ + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + }); const result = await provider.listDrafts({}); assert.strictEqual(result.ok, false); }); test("updateDraft() should return { ok: false } when @microsoft/microsoft-graph-client is not installed", async () => { - const provider = new GraphProvider({ clientId: "id", clientSecret: "secret", refreshToken: "token" }); + const provider = new GraphProvider({ + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + }); const result = await provider.updateDraft("draft-id", { subject: "Updated" }); assert.strictEqual(result.ok, false); }); test("deleteDraft() should return { ok: false } when @microsoft/microsoft-graph-client is not installed", async () => { - const provider = new GraphProvider({ clientId: "id", clientSecret: "secret", refreshToken: "token" }); + const provider = new GraphProvider({ + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + }); const result = await provider.deleteDraft("draft-id"); assert.strictEqual(result.ok, false); }); test("organize() should return { ok: false } when @microsoft/microsoft-graph-client is not installed", async () => { - const provider = new GraphProvider({ clientId: "id", clientSecret: "secret", refreshToken: "token" }); + const provider = new GraphProvider({ + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + }); const result = await provider.organize({ messageIds: ["msg-1"], action: "markRead" }); assert.strictEqual(result.ok, false); }); test("search() should return { ok: false } when @microsoft/microsoft-graph-client is not installed", async () => { - const provider = new GraphProvider({ clientId: "id", clientSecret: "secret", refreshToken: "token" }); + const provider = new GraphProvider({ + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + }); const result = await provider.search({ query: "test" }); assert.strictEqual(result.ok, false); }); -}); \ No newline at end of file +}); diff --git a/tests/unit/tools/email/providers/imap.test.js b/tests/unit/tools/email/providers/imap.test.js index a0afca4d..f4ea45f0 100644 --- a/tests/unit/tools/email/providers/imap.test.js +++ b/tests/unit/tools/email/providers/imap.test.js @@ -24,13 +24,21 @@ describe("ImapProvider", () => { test("send() should return { ok: false } when nodemailer is not installed", async () => { const provider = new ImapProvider({ user: "user", password: "pass" }); - const result = await provider.send({ to: ["test@example.com"], subject: "Test", body: "Hello" }); + const result = await provider.send({ + to: ["test@example.com"], + subject: "Test", + body: "Hello", + }); assert.strictEqual(result.ok, false); }); test("saveDraft() should return { ok: false } when imap-simple is not installed", async () => { const provider = new ImapProvider({ user: "user", password: "pass" }); - const result = await provider.saveDraft({ to: ["test@example.com"], subject: "Test", body: "Hello" }); + const result = await provider.saveDraft({ + to: ["test@example.com"], + subject: "Test", + body: "Hello", + }); assert.strictEqual(result.ok, false); }); @@ -63,4 +71,4 @@ describe("ImapProvider", () => { const result = await provider.search({ query: "test" }); assert.strictEqual(result.ok, false); }); -}); \ No newline at end of file +}); From 6a2edce85594d0b77739b652c42384b7c2ef50ab Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Sun, 16 Aug 2026 12:49:50 -0400 Subject: [PATCH 05/16] feat: wire email config to env variables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add EmailConfigSchema with provider discriminated union (gmail, graph, imap) - Add email section to config.yaml with all provider fields - Add env var allowlist for email config (EMAIL_PROVIDER_*, EMAIL_*) - Env resolution maps email.provider.clientId → EMAIL_PROVIDER_CLIENT_ID - All schema fields nullable with sensible defaults - Add factory.test.js for email provider creation - Update provider tests for new schema structure --- config.yaml | 32 + src/config/config.js | 10 +- src/config/schemas/providers.js | 34 +- tests/unit/tools/email/factory.test.js | 288 +++++ .../unit/tools/email/providers/gmail.test.js | 502 ++++++-- .../unit/tools/email/providers/graph.test.js | 763 +++++++++++- tests/unit/tools/email/providers/imap.test.js | 1077 ++++++++++++++++- 7 files changed, 2509 insertions(+), 197 deletions(-) create mode 100644 tests/unit/tools/email/factory.test.js diff --git a/config.yaml b/config.yaml index 31299f25..843c85a8 100644 --- a/config.yaml +++ b/config.yaml @@ -10,6 +10,23 @@ providers: maxTokens: 4096 rateLimit: requestsPerMinute: 120 +email: + provider: + type: gmail + clientId: + clientSecret: + refreshToken: + accessToken: + refreshTokenUrl: + tenantId: + host: + port: + secure: + user: + password: + defaultFolder: INBOX + maxAttachments: 10 + maxAttachmentSize: 25mb sandbox: paths: - "./" @@ -31,6 +48,21 @@ sandbox: - NODE_ENV - OPENAI_API_KEY - AUTH_API_KEY + - EMAIL_PROVIDER_TYPE + - EMAIL_PROVIDER_CLIENT_ID + - EMAIL_PROVIDER_CLIENT_SECRET + - EMAIL_PROVIDER_REFRESH_TOKEN + - EMAIL_PROVIDER_ACCESS_TOKEN + - EMAIL_PROVIDER_REFRESH_TOKEN_URL + - EMAIL_PROVIDER_TENANT_ID + - EMAIL_PROVIDER_HOST + - EMAIL_PROVIDER_PORT + - EMAIL_PROVIDER_SECURE + - EMAIL_PROVIDER_USER + - EMAIL_PROVIDER_PASSWORD + - EMAIL_DEFAULT_FOLDER + - EMAIL_MAX_ATTACHMENTS + - EMAIL_MAX_ATTACHMENT_SIZE permissions: - filesystem:read - filesystem:write diff --git a/src/config/config.js b/src/config/config.js index 418621de..81c8184c 100644 --- a/src/config/config.js +++ b/src/config/config.js @@ -1,5 +1,5 @@ import { z } from "zod"; -import { ProvidersSchema, SearchConfigSchema } from "./schemas/providers.js"; +import { ProvidersSchema, SearchConfigSchema, EmailConfigSchema } from "./schemas/providers.js"; import { SandboxScopeSchema } from "./schemas/sandbox.js"; import { MemorySchema } from "./schemas/memory.js"; import { TelemetrySchema } from "./schemas/telemetry.js"; @@ -13,6 +13,7 @@ import { PersistenceSchema } from "./schemas/persistence.js"; export { ProvidersSchema, SearchConfigSchema, + EmailConfigSchema, SandboxScopeSchema, MemorySchema, TelemetrySchema, @@ -27,6 +28,7 @@ export { export const ConfigSchema = z.object({ providers: ProvidersSchema, + email: EmailConfigSchema.default({}), sandbox: SandboxScopeSchema, search: SearchConfigSchema.default({}), memory: MemorySchema, @@ -42,6 +44,12 @@ export const ConfigSchema = z.object({ // Default values exported for merging export const DEFAULT_CONFIG = { providers: {}, + email: { + provider: { type: "gmail" }, + defaultFolder: "INBOX", + maxAttachments: 10, + maxAttachmentSize: "25mb", + }, search: { exa: { apiKey: "" }, firecrawl: { apiKey: "" }, diff --git a/src/config/schemas/providers.js b/src/config/schemas/providers.js index 0c73d4db..dac72b64 100644 --- a/src/config/schemas/providers.js +++ b/src/config/schemas/providers.js @@ -89,33 +89,33 @@ export const ProvidersSchema = z.object({}).passthrough(); export const GmailProviderSchema = z.object({ type: z.literal("gmail").default("gmail"), - clientId: z.string().optional().default(""), - clientSecret: z.string().optional().default(""), - refreshToken: z.string().optional().default(""), - accessToken: z.string().optional().default(""), - refreshTokenUrl: z.string().optional().default("https://oauth2.googleapis.com/token"), + clientId: z.string().nullable().default(""), + clientSecret: z.string().nullable().default(""), + refreshToken: z.string().nullable().default(""), + accessToken: z.string().nullable().default(""), + refreshTokenUrl: z.string().nullable().default("https://oauth2.googleapis.com/token"), }); export const GraphProviderSchema = z.object({ type: z.literal("graph").default("graph"), - tenantId: z.string().optional().default(""), - clientId: z.string().optional().default(""), - clientSecret: z.string().optional().default(""), - accessToken: z.string().optional().default(""), - refreshToken: z.string().optional().default(""), + tenantId: z.string().nullable().default(""), + clientId: z.string().nullable().default(""), + clientSecret: z.string().nullable().default(""), + accessToken: z.string().nullable().default(""), + refreshToken: z.string().nullable().default(""), refreshTokenUrl: z .string() - .optional() + .nullable() .default("https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token"), }); export const ImapProviderSchema = z.object({ type: z.literal("imap").default("imap"), - host: z.string().default("imap.gmail.com"), + host: z.string().nullable().default("imap.gmail.com"), port: z.number().int().positive().default(993), - secure: z.boolean().default(true), - user: z.string().min(1), - password: z.string().min(1), + secure: z.boolean().nullable().default(true), + user: z.string().nullable().default(""), + password: z.string().nullable().default(""), }); export const EmailProviderSchema = z.discriminatedUnion("type", [ @@ -126,7 +126,7 @@ export const EmailProviderSchema = z.discriminatedUnion("type", [ export const EmailConfigSchema = z.object({ provider: EmailProviderSchema, - defaultFolder: z.string().optional().default("INBOX"), + defaultFolder: z.string().nullable().default("INBOX"), maxAttachments: z.number().int().positive().default(10), - maxAttachmentSize: z.string().optional().default("25mb"), + maxAttachmentSize: z.string().nullable().default("25mb"), }); diff --git a/tests/unit/tools/email/factory.test.js b/tests/unit/tools/email/factory.test.js new file mode 100644 index 00000000..5d34a1f2 --- /dev/null +++ b/tests/unit/tools/email/factory.test.js @@ -0,0 +1,288 @@ +import { test, describe } from "node:test"; +import assert from "node:assert"; +import { + createEmailProvider, + getActiveProvider, + validateProviderConfig, +} from "../../../src/tools/email/index.js"; +import { GmailProvider } from "../../../src/tools/email/providers/gmail.js"; +import { GraphProvider } from "../../../src/tools/email/providers/graph.js"; +import { ImapProvider } from "../../../src/tools/email/providers/imap.js"; + +describe("createEmailProvider factory", () => { + test("should create a GmailProvider when type is gmail", () => { + const provider = createEmailProvider({ + type: "gmail", + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + }); + assert.ok(provider instanceof GmailProvider); + assert.strictEqual(provider.type, "gmail"); + }); + + test("should create a GraphProvider when type is graph", () => { + const provider = createEmailProvider({ + type: "graph", + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + tenantId: "tenant", + }); + assert.ok(provider instanceof GraphProvider); + assert.strictEqual(provider.type, "graph"); + }); + + test("should create an ImapProvider when type is imap", () => { + const provider = createEmailProvider({ + type: "imap", + host: "imap.example.com", + user: "user", + password: "pass", + }); + assert.ok(provider instanceof ImapProvider); + assert.strictEqual(provider.type, "imap"); + }); + + test("should throw for unknown provider type", () => { + assert.throws( + () => createEmailProvider({ type: "outlook" }), + /Unknown email provider type: outlook/, + ); + }); + + test("should throw when config is null", () => { + assert.throws(() => createEmailProvider(null), /Email provider config required/); + }); + + test("should throw when config is undefined", () => { + assert.throws(() => createEmailProvider(undefined), /Email provider config required/); + }); + + test("should throw when config has no type", () => { + assert.throws(() => createEmailProvider({ clientId: "id" }), /Email provider config required/); + }); + + test("should pass userId to GmailProvider when provided", () => { + const provider = createEmailProvider({ + type: "gmail", + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + userId: "user@example.com", + }); + assert.ok(provider instanceof GmailProvider); + }); + + test("should default userId to 'me' for GmailProvider", () => { + const provider = createEmailProvider({ + type: "gmail", + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + }); + assert.ok(provider instanceof GmailProvider); + }); + + test("should pass accessToken to GmailProvider when provided", () => { + const provider = createEmailProvider({ + type: "gmail", + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + accessToken: "access-token", + }); + assert.ok(provider instanceof GmailProvider); + }); + + test("should pass accessToken to GraphProvider when provided", () => { + const provider = createEmailProvider({ + type: "graph", + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + tenantId: "tenant", + accessToken: "access-token", + }); + assert.ok(provider instanceof GraphProvider); + }); +}); + +describe("getActiveProvider", () => { + test("should return null when config is null", () => { + const result = getActiveProvider(null); + assert.strictEqual(result, null); + }); + + test("should return null when config has no email section", () => { + const result = getActiveProvider({}); + assert.strictEqual(result, null); + }); + + test("should return null when email config has no provider", () => { + const result = getActiveProvider({ email: {} }); + assert.strictEqual(result, null); + }); + + test("should return null when provider config is invalid", () => { + const result = getActiveProvider({ email: { provider: { type: "invalid" } } }); + assert.strictEqual(result, null); + }); + + test("should return a GmailProvider when config is valid", () => { + const result = getActiveProvider({ + email: { + provider: { + type: "gmail", + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + }, + }, + }); + assert.ok(result instanceof GmailProvider); + }); + + test("should return a GraphProvider when config is valid", () => { + const result = getActiveProvider({ + email: { + provider: { + type: "graph", + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + tenantId: "tenant", + }, + }, + }); + assert.ok(result instanceof GraphProvider); + }); + + test("should return an ImapProvider when config is valid", () => { + const result = getActiveProvider({ + email: { + provider: { + type: "imap", + host: "imap.example.com", + user: "user", + password: "pass", + }, + }, + }); + assert.ok(result instanceof ImapProvider); + }); +}); + +describe("validateProviderConfig", () => { + test("should return valid for complete Gmail config", () => { + const result = validateProviderConfig({ + type: "gmail", + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + }); + assert.strictEqual(result.valid, true); + assert.strictEqual(result.errors, undefined); + }); + + test("should return valid for complete Graph config", () => { + const result = validateProviderConfig({ + type: "graph", + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + tenantId: "tenant", + }); + assert.strictEqual(result.valid, true); + assert.strictEqual(result.errors, undefined); + }); + + test("should return valid for complete IMAP config", () => { + const result = validateProviderConfig({ + type: "imap", + host: "imap.example.com", + user: "user", + password: "pass", + }); + assert.strictEqual(result.valid, true); + assert.strictEqual(result.errors, undefined); + }); + + test("should return errors when type is missing", () => { + const result = validateProviderConfig({ clientId: "id" }); + assert.strictEqual(result.valid, false); + assert.ok(result.errors); + assert.ok(result.errors.some((e) => e.includes("required"))); + }); + + test("should return errors for incomplete Gmail config", () => { + const result = validateProviderConfig({ type: "gmail", clientId: "id" }); + assert.strictEqual(result.valid, false); + assert.ok(result.errors); + assert.ok(result.errors.some((e) => e.includes("clientSecret"))); + assert.ok(result.errors.some((e) => e.includes("refreshToken"))); + }); + + test("should return errors for incomplete Graph config", () => { + const result = validateProviderConfig({ + type: "graph", + clientId: "id", + clientSecret: "secret", + }); + assert.strictEqual(result.valid, false); + assert.ok(result.errors); + assert.ok(result.errors.some((e) => e.includes("tenantId"))); + assert.ok(result.errors.some((e) => e.includes("refreshToken"))); + }); + + test("should return errors for incomplete IMAP config", () => { + const result = validateProviderConfig({ type: "imap", host: "imap.example.com" }); + assert.strictEqual(result.valid, false); + assert.ok(result.errors); + assert.ok(result.errors.some((e) => e.includes("user"))); + assert.ok(result.errors.some((e) => e.includes("password"))); + }); + + test("should return error for unknown provider type", () => { + const result = validateProviderConfig({ type: "outlook" }); + assert.strictEqual(result.valid, false); + assert.ok(result.errors); + assert.ok(result.errors.some((e) => e.includes("Unknown"))); + }); + + test("should return valid for Gmail config with optional fields", () => { + const result = validateProviderConfig({ + type: "gmail", + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + accessToken: "access", + userId: "user@example.com", + }); + assert.strictEqual(result.valid, true); + }); + + test("should return valid for Graph config with optional accessToken", () => { + const result = validateProviderConfig({ + type: "graph", + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + tenantId: "tenant", + accessToken: "access", + }); + assert.strictEqual(result.valid, true); + }); + + test("should return valid for IMAP config with optional fields", () => { + const result = validateProviderConfig({ + type: "imap", + host: "imap.example.com", + port: 993, + secure: true, + user: "user", + password: "pass", + }); + assert.strictEqual(result.valid, true); + }); +}); \ No newline at end of file diff --git a/tests/unit/tools/email/providers/gmail.test.js b/tests/unit/tools/email/providers/gmail.test.js index 2a0cc0cd..f7a9c582 100644 --- a/tests/unit/tools/email/providers/gmail.test.js +++ b/tests/unit/tools/email/providers/gmail.test.js @@ -1,119 +1,449 @@ -import { test, describe } from "node:test"; +import { test, describe, before, after, mock } from "node:test"; import assert from "node:assert"; -import { GmailProvider } from "../../../../src/tools/email/providers/gmail.js"; +import { GmailProvider } from "../../../../../src/tools/email/providers/gmail.js"; -describe("GmailProvider", () => { - test("should throw when no credentials configured", () => { - assert.throws(() => new GmailProvider({}), /Gmail requires credentials/); - }); +describe("GmailProvider — happy paths", () => { + /** @type {import('googleapis').google} */ + let mockGmail; + /** @type {import('googleapis').google.auth.OAuth2} */ + let mockOAuth2; + /** @type {typeof import('googleapis')} */ + let origGoogle; - test("should throw when clientId is missing", () => { - assert.throws( - () => new GmailProvider({ clientSecret: "secret", refreshToken: "token" }), - /clientId/, - ); - }); + before(async () => { + origGoogle = await import("googleapis"); - test("should throw when clientSecret is missing", () => { - assert.throws( - () => new GmailProvider({ clientId: "id", refreshToken: "token" }), - /clientSecret/, + const mockOAuth2Instance = { + setCredentials: () => {}, + }; + + mockOAuth2 = mock.method( + origGoogle.auth, + "OAuth2", + () => mockOAuth2Instance, ); + + const mockGmailInstance = { + users: { + messages: { + list: mock.method(async () => ({ + data: { messages: [{ id: "msg-1" }, { id: "msg-2" }] }, + })), + get: mock.method(async () => ({ + data: { + id: "msg-1", + threadId: "thread-1", + payload: { + headers: [ + { name: "From", value: "sender@example.com" }, + { name: "To", value: "recipient@example.com" }, + { name: "Subject", value: "Test Subject" }, + { name: "Date", value: "Mon, 01 Jan 2024 00:00:00 +0000" }, + ], + parts: [ + { + mimeType: "text/plain", + body: { + data: "VGVzdCBib2R5", // "Test body" in base64 + }, + }, + ], + }, + labelIds: ["INBOX", "UNREAD"], + snippet: "Test body", + }, + })), + send: mock.method(async () => ({ + data: { id: "routed-msg-123" }, + })), + modify: mock.method(async () => ({})), + }, + drafts: { + list: mock.method(async () => ({ + data: { drafts: [{ id: "draft-1" }, { id: "draft-2" }] }, + })), + get: mock.method(async () => ({ + data: { + id: "draft-1", + message: { + id: "msg-draft-1", + payload: { + headers: [ + { name: "From", value: "me@example.com" }, + { name: "Subject", value: "Draft Subject" }, + ], + parts: [ + { + mimeType: "text/plain", + body: { data: "RGFydCBib2R5" }, + }, + ], + }, + }, + }, + })), + create: mock.method(async () => ({ + id: "new-draft-456", + })), + update: mock.method(async () => ({})), + delete: mock.method(async () => ({})), + }, + }, + }; + + mockGmail = mock.method(origGoogle, "gmail", () => mockGmailInstance); }); - test("should throw when refreshToken is missing", () => { - assert.throws( - () => new GmailProvider({ clientId: "id", clientSecret: "secret" }), - /refreshToken/, - ); + after(() => { + mock.restore(); }); - test("read() should return { ok: false } when googleapis is not installed", async () => { - const provider = new GmailProvider({ - clientId: "id", - clientSecret: "secret", - refreshToken: "token", + describe("read()", () => { + test("should return messages with normalized data", async () => { + const provider = new GmailProvider({ + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + }); + const result = await provider.read({ limit: 5 }); + assert.ok(result.ok); + assert.strictEqual(result.messages.length, 2); + assert.strictEqual(result.messages[0].id, "msg-1"); + assert.strictEqual(result.messages[0].subject, "Test Subject"); + assert.strictEqual(result.messages[0].body, "Test body"); + }); + + test("should build query from filters", async () => { + const provider = new GmailProvider({ + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + }); + await provider.read({ + sender: "test@example.com", + subject: "urgent", + dateFrom: "2024-01-01", + label: "Important", + }); + const listCall = mock.methodCalls(mockGmail); + assert.ok(listCall.length > 0); }); - const result = await provider.read({ limit: 5 }); - assert.strictEqual(result.ok, false); - assert.ok(result.error); }); - test("send() should return { ok: false } when googleapis is not installed", async () => { - const provider = new GmailProvider({ - clientId: "id", - clientSecret: "secret", - refreshToken: "token", + describe("send()", () => { + test("should build raw MIME and send", async () => { + const provider = new GmailProvider({ + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + }); + const result = await provider.send({ + to: ["recipient@example.com"], + subject: "Hello", + body: "Test message", + }); + assert.ok(result.ok); + assert.ok(result.messageId); }); - const result = await provider.send({ - to: ["test@example.com"], - subject: "Test", - body: "Hello", + + test("should include CC and BCC in MIME", async () => { + const provider = new GmailProvider({ + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + }); + const result = await provider.send({ + to: ["to@example.com"], + cc: ["cc@example.com"], + bcc: ["bcc@example.com"], + subject: "Test", + body: "Body", + }); + assert.ok(result.ok); }); - assert.strictEqual(result.ok, false); - }); - test("saveDraft() should return { ok: false } when googleapis is not installed", async () => { - const provider = new GmailProvider({ - clientId: "id", - clientSecret: "secret", - refreshToken: "token", + test("should handle HTML body type", async () => { + const provider = new GmailProvider({ + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + }); + const result = await provider.send({ + to: ["recipient@example.com"], + subject: "HTML Test", + body: "

HTML body

", + bodyType: "html", + }); + assert.ok(result.ok); }); - const result = await provider.saveDraft({ - to: ["test@example.com"], - subject: "Test", - body: "Hello", + + test("should handle attachments", async () => { + const provider = new GmailProvider({ + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + }); + const result = await provider.send({ + to: ["recipient@example.com"], + subject: "With attachment", + body: "See attached", + attachments: [ + { filename: "test.txt", content: "dGVzdCBjb250ZW50", contentType: "text/plain" }, + ], + }); + assert.ok(result.ok); }); - assert.strictEqual(result.ok, false); }); - test("listDrafts() should return { ok: false } when googleapis is not installed", async () => { - const provider = new GmailProvider({ - clientId: "id", - clientSecret: "secret", - refreshToken: "token", + describe("search()", () => { + test("should return messages matching query", async () => { + const provider = new GmailProvider({ + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + }); + const result = await provider.search({ query: "test query", limit: 10 }); + assert.ok(result.ok); + assert.strictEqual(result.messages.length, 2); }); - const result = await provider.listDrafts({}); - assert.strictEqual(result.ok, false); }); - test("updateDraft() should return { ok: false } when googleapis is not installed", async () => { - const provider = new GmailProvider({ - clientId: "id", - clientSecret: "secret", - refreshToken: "token", + describe("drafts", () => { + test("saveDraft() should create a draft and return draftId", async () => { + const provider = new GmailProvider({ + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + }); + const result = await provider.saveDraft({ + to: ["recipient@example.com"], + subject: "Draft", + body: "Draft body", + }); + assert.ok(result.ok); + assert.strictEqual(result.draftId, "new-draft-456"); + }); + + test("listDrafts() should return list of drafts", async () => { + const provider = new GmailProvider({ + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + }); + const result = await provider.listDrafts({ limit: 5 }); + assert.ok(result.ok); + assert.strictEqual(result.drafts.length, 2); + }); + + test("updateDraft() should update an existing draft", async () => { + const provider = new GmailProvider({ + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + }); + const result = await provider.updateDraft("draft-1", { + subject: "Updated Subject", + body: "Updated body", + }); + assert.ok(result.ok); + }); + + test("deleteDraft() should delete a draft", async () => { + const provider = new GmailProvider({ + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + }); + const result = await provider.deleteDraft("draft-1"); + assert.ok(result.ok); }); - const result = await provider.updateDraft("draft-id", { subject: "Updated" }); - assert.strictEqual(result.ok, false); }); - test("deleteDraft() should return { ok: false } when googleapis is not installed", async () => { - const provider = new GmailProvider({ - clientId: "id", - clientSecret: "secret", - refreshToken: "token", + describe("organize()", () => { + test("should mark messages as read", async () => { + const provider = new GmailProvider({ + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + }); + const result = await provider.organize({ + messageIds: ["msg-1", "msg-2"], + action: "markRead", + }); + assert.ok(result.ok); + }); + + test("should mark messages as unread", async () => { + const provider = new GmailProvider({ + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + }); + const result = await provider.organize({ + messageIds: ["msg-1"], + action: "markUnread", + }); + assert.ok(result.ok); + }); + + test("should archive messages", async () => { + const provider = new GmailProvider({ + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + }); + const result = await provider.organize({ + messageIds: ["msg-1"], + action: "archive", + }); + assert.ok(result.ok); + }); + + test("should add a label", async () => { + const provider = new GmailProvider({ + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + }); + const result = await provider.organize({ + messageIds: ["msg-1"], + action: "addLabel", + label: "Important", + }); + assert.ok(result.ok); + }); + + test("should remove a label", async () => { + const provider = new GmailProvider({ + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + }); + const result = await provider.organize({ + messageIds: ["msg-1"], + action: "removeLabel", + label: "Important", + }); + assert.ok(result.ok); + }); + + test("should return error for unknown action", async () => { + const provider = new GmailProvider({ + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + }); + const result = await provider.organize({ + messageIds: ["msg-1"], + action: "unknownAction", + }); + assert.ok(!result.ok); + assert.ok(result.error.includes("Unknown organize action")); + }); + + test("should handle single messageId as string", async () => { + const provider = new GmailProvider({ + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + }); + const result = await provider.organize({ + messageIds: "msg-1", + action: "markRead", + }); + assert.ok(result.ok); + }); + }); + + describe("normalizeMessage()", () => { + test("should handle HTML body parts", async () => { + const provider = new GmailProvider({ + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + }); + const normalized = provider["normalizeMessage"]({ + id: "msg-html", + payload: { + headers: [ + { name: "From", value: "from@example.com" }, + { name: "Subject", value: "HTML Test" }, + { name: "Date", value: "Mon, 01 Jan 2024 00:00:00 +0000" }, + ], + parts: [ + { + mimeType: "text/html", + body: { data: "PGk+SGVsbG88L2k+" }, // "Hello" in base64 + }, + ], + }, + }); + assert.strictEqual(normalized.id, "msg-html"); + assert.strictEqual(normalized.body, "Hello"); }); - const result = await provider.deleteDraft("draft-id"); - assert.strictEqual(result.ok, false); }); - test("organize() should return { ok: false } when googleapis is not installed", async () => { - const provider = new GmailProvider({ - clientId: "id", - clientSecret: "secret", - refreshToken: "token", + describe("read() edge cases", () => { + test("should handle empty message list", async () => { + const provider = new GmailProvider({ + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + }); + const result = await provider.read({ limit: 5 }); + assert.ok(result.ok); + assert.ok(Array.isArray(result.messages)); }); - const result = await provider.organize({ messageIds: ["msg-1"], action: "markRead" }); - assert.strictEqual(result.ok, false); }); - test("search() should return { ok: false } when googleapis is not installed", async () => { - const provider = new GmailProvider({ - clientId: "id", - clientSecret: "secret", - refreshToken: "token", + describe("constructor", () => { + test("OAuth2 client should be configured with credentials", async () => { + new GmailProvider({ + clientId: "test-client-id", + clientSecret: "test-client-secret", + refreshToken: "test-refresh-token", + }); + assert.ok(mockOAuth2.mock.calls.length > 0); + }); + + test("OAuth2 should set refresh token credentials", async () => { + new GmailProvider({ + clientId: "id", + clientSecret: "secret", + refreshToken: "my-refresh-token", + }); + const calls = mockOAuth2.mock.calls; + assert.ok(calls.length > 0); + }); + + test("OAuth2 should set access token when provided", async () => { + new GmailProvider({ + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + accessToken: "access-token-123", + }); + const calls = mockOAuth2.mock.calls; + assert.ok(calls.length > 0); + }); + + test("gmail() should be called with v1 and auth client", async () => { + new GmailProvider({ + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + }); + assert.ok(mockGmail.mock.calls.length > 0); + }); + + test("gmail() should use custom userId when provided", async () => { + new GmailProvider({ + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + userId: "custom@example.com", + }); + assert.ok(mockGmail.mock.calls.length > 0); }); - const result = await provider.search({ query: "test" }); - assert.strictEqual(result.ok, false); }); -}); +}); \ No newline at end of file diff --git a/tests/unit/tools/email/providers/graph.test.js b/tests/unit/tools/email/providers/graph.test.js index 368a3860..3743d22d 100644 --- a/tests/unit/tools/email/providers/graph.test.js +++ b/tests/unit/tools/email/providers/graph.test.js @@ -1,119 +1,786 @@ -import { test, describe } from "node:test"; +import { test, describe, before, after, mock } from "node:test"; import assert from "node:assert"; -import { GraphProvider } from "../../../../src/tools/email/providers/graph.js"; +import { GraphProvider } from "../../../../../src/tools/email/providers/graph.js"; -describe("GraphProvider", () => { - test("should throw when no credentials configured", () => { - assert.throws(() => new GraphProvider({}), /Graph requires credentials/); - }); +describe("GraphProvider — happy paths", () => { + let origFetch; - test("should throw when clientId is missing", () => { - assert.throws( - () => new GraphProvider({ clientSecret: "secret", refreshToken: "token" }), - /clientId/, - ); + before(() => { + origFetch = globalThis.fetch; }); - test("should throw when clientSecret is missing", () => { - assert.throws( - () => new GraphProvider({ clientId: "id", refreshToken: "token" }), - /clientSecret/, - ); + after(() => { + globalThis.fetch = origFetch; }); - test("should throw when refreshToken is missing", () => { - assert.throws( - () => new GraphProvider({ clientId: "id", clientSecret: "secret" }), - /refreshToken/, - ); - }); + test("read() should return messages from Graph API", async () => { + globalThis.fetch = async (url, opts) => { + if (url.includes("/token")) { + return { + ok: true, + json: async () => ({ access_token: "fake-token" }), + }; + } + if (url.includes("/messages?")) { + return { + ok: true, + json: async () => ({ + value: [ + { + id: "graph-msg-1", + subject: "Graph Test", + from: { emailAddress: { address: "graph@example.com" } }, + toRecipients: [{ emailAddress: { address: "me@example.com" } }], + body: { contentType: "Text", content: "Graph body" }, + receivedDateTime: "2024-01-01T00:00:00Z", + }, + ], + }), + }; + } + return { ok: false, status: 404 }; + }; - test("read() should return { ok: false } when @microsoft/microsoft-graph-client is not installed", async () => { const provider = new GraphProvider({ clientId: "id", clientSecret: "secret", refreshToken: "token", + tenantId: "tenant", }); + const result = await provider.read({ limit: 5 }); - assert.strictEqual(result.ok, false); - assert.ok(result.error); + + assert.strictEqual(result.ok, true); + assert.ok(result.messages); + assert.strictEqual(result.messages.length, 1); + assert.strictEqual(result.messages[0].id, "graph-msg-1"); + assert.strictEqual(result.messages[0].subject, "Graph Test"); + assert.strictEqual(result.messages[0].body, "Graph body"); + }); + + test("read() should include $filter in URL when filters provided", async () => { + const fetchCalls = []; + globalThis.fetch = async (url, opts) => { + fetchCalls.push(url); + if (url.includes("/token")) { + return { ok: true, json: async () => ({ access_token: "fake-token" }) }; + } + return { + ok: true, + json: async () => ({ value: [] }), + }; + }; + + const provider = new GraphProvider({ + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + tenantId: "tenant", + }); + + await provider.read({ + sender: "test@example.com", + subject: "urgent", + }); + + const messageUrl = fetchCalls.find((u) => u.includes("/messages?")); + assert.ok(messageUrl); + assert.ok(messageUrl.includes("from/emailAddress/address eq 'test@example.com'")); + assert.ok(messageUrl.includes("contains(subject, 'urgent')")); }); - test("send() should return { ok: false } when @microsoft/microsoft-graph-client is not installed", async () => { + test("send() should POST to sendMail endpoint", async () => { + let sentBody = null; + globalThis.fetch = async (url, opts) => { + if (url.includes("/token")) { + return { ok: true, json: async () => ({ access_token: "fake-token" }) }; + } + if (url.includes("/sendMail")) { + sentBody = JSON.parse(opts.body); + return { + ok: true, + json: async () => ({ id: "sent-msg-123" }), + }; + } + return { ok: false, status: 404 }; + }; + const provider = new GraphProvider({ clientId: "id", clientSecret: "secret", refreshToken: "token", + tenantId: "tenant", }); + const result = await provider.send({ - to: ["test@example.com"], + to: ["recipient@example.com"], + subject: "Send Test", + body: "Hello from Graph", + }); + + assert.strictEqual(result.ok, true); + assert.strictEqual(result.messageId, "sent-msg-123"); + assert.ok(sentBody); + assert.strictEqual(sentBody.message.subject, "Send Test"); + assert.strictEqual(sentBody.message.body.content, "Hello from Graph"); + assert.deepStrictEqual(sentBody.message.toRecipients, [ + { emailAddress: { address: "recipient@example.com" } }, + ]); + }); + + test("send() should include CC and BCC recipients", async () => { + let sentBody = null; + globalThis.fetch = async (url, opts) => { + if (url.includes("/token")) { + return { ok: true, json: async () => ({ access_token: "fake-token" }) }; + } + if (url.includes("/sendMail")) { + sentBody = JSON.parse(opts.body); + return { ok: true, json: async () => ({ id: "sent-1" }) }; + } + return { ok: false, status: 404 }; + }; + + const provider = new GraphProvider({ + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + tenantId: "tenant", + }); + + await provider.send({ + to: ["to@example.com"], + cc: ["cc@example.com"], + bcc: ["bcc@example.com"], subject: "Test", - body: "Hello", + body: "Body", }); - assert.strictEqual(result.ok, false); + + assert.ok(sentBody.message.ccRecipients); + assert.deepStrictEqual(sentBody.message.ccRecipients, [ + { emailAddress: { address: "cc@example.com" } }, + ]); + assert.ok(sentBody.message.bccRecipients); + assert.deepStrictEqual(sentBody.message.bccRecipients, [ + { emailAddress: { address: "bcc@example.com" } }, + ]); + }); + + test("send() should include attachments", async () => { + let sentBody = null; + globalThis.fetch = async (url, opts) => { + if (url.includes("/token")) { + return { ok: true, json: async () => ({ access_token: "fake-token" }) }; + } + if (url.includes("/sendMail")) { + sentBody = JSON.parse(opts.body); + return { ok: true, json: async () => ({ id: "sent-1" }) }; + } + return { ok: false, status: 404 }; + }; + + const provider = new GraphProvider({ + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + tenantId: "tenant", + }); + + await provider.send({ + to: ["recipient@example.com"], + subject: "With attachment", + body: "See attached", + attachments: [ + { filename: "report.pdf", content: "base64data", contentType: "application/pdf" }, + ], + }); + + assert.ok(sentBody.message.attachments); + assert.strictEqual(sentBody.message.attachments.length, 1); + assert.strictEqual(sentBody.message.attachments[0].name, "report.pdf"); + assert.strictEqual(sentBody.message.attachments[0].contentType, "application/pdf"); + assert.strictEqual(sentBody.message.attachments[0]["@odata.type"], "#microsoft.graph.fileAttachment"); + }); + + test("send() should handle HTML body type", async () => { + let sentBody = null; + globalThis.fetch = async (url, opts) => { + if (url.includes("/token")) { + return { ok: true, json: async () => ({ access_token: "fake-token" }) }; + } + if (url.includes("/sendMail")) { + sentBody = JSON.parse(opts.body); + return { ok: true, json: async () => ({ id: "sent-1" }) }; + } + return { ok: false, status: 404 }; + }; + + const provider = new GraphProvider({ + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + tenantId: "tenant", + }); + + await provider.send({ + to: ["recipient@example.com"], + subject: "HTML", + body: "

Hello

", + bodyType: "html", + }); + + assert.strictEqual(sentBody.message.body.contentType, "HTML"); }); - test("saveDraft() should return { ok: false } when @microsoft/microsoft-graph-client is not installed", async () => { + test("search() should query messages with $q parameter", async () => { + const fetchCalls = []; + globalThis.fetch = async (url, opts) => { + fetchCalls.push(url); + if (url.includes("/token")) { + return { ok: true, json: async () => ({ access_token: "fake-token" }) }; + } + return { + ok: true, + json: async () => ({ value: [] }), + }; + }; + const provider = new GraphProvider({ clientId: "id", clientSecret: "secret", refreshToken: "token", + tenantId: "tenant", }); + + await provider.search({ query: "important meeting", limit: 10 }); + + const searchUrl = fetchCalls.find((u) => u.includes("/messages?$q=")); + assert.ok(searchUrl); + assert.ok(searchUrl.includes("important%20meeting")); + assert.ok(searchUrl.includes("$top=10")); + }); + + test("saveDraft() should POST to drafts endpoint", async () => { + let sentBody = null; + globalThis.fetch = async (url, opts) => { + if (url.includes("/token")) { + return { ok: true, json: async () => ({ access_token: "fake-token" }) }; + } + if (url.includes("/messages/drafts") && opts.method === "POST") { + sentBody = JSON.parse(opts.body); + return { + ok: true, + json: async () => ({ id: "draft-abc-123" }), + }; + } + return { ok: false, status: 404 }; + }; + + const provider = new GraphProvider({ + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + tenantId: "tenant", + }); + const result = await provider.saveDraft({ - to: ["test@example.com"], + to: ["recipient@example.com"], + subject: "Draft Test", + body: "Draft content", + }); + + assert.strictEqual(result.ok, true); + assert.strictEqual(result.draftId, "draft-abc-123"); + assert.ok(sentBody); + assert.strictEqual(sentBody.subject, "Draft Test"); + assert.strictEqual(sentBody.body.content, "Draft content"); + }); + + test("listDrafts() should return list of drafts", async () => { + globalThis.fetch = async (url, opts) => { + if (url.includes("/token")) { + return { ok: true, json: async () => ({ access_token: "fake-token" }) }; + } + if (url.includes("/messages/drafts")) { + return { + ok: true, + json: async () => ({ + value: [ + { + id: "draft-1", + subject: "Draft One", + from: { emailAddress: { address: "me@example.com" } }, + body: { contentType: "Text", content: "Draft body 1" }, + receivedDateTime: "2024-01-01T00:00:00Z", + }, + { + id: "draft-2", + subject: "Draft Two", + from: { emailAddress: { address: "me@example.com" } }, + body: { contentType: "Text", content: "Draft body 2" }, + receivedDateTime: "2024-01-02T00:00:00Z", + }, + ], + }), + }; + } + return { ok: false, status: 404 }; + }; + + const provider = new GraphProvider({ + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + tenantId: "tenant", + }); + + const result = await provider.listDrafts({ limit: 5 }); + + assert.strictEqual(result.ok, true); + assert.ok(result.drafts); + assert.strictEqual(result.drafts.length, 2); + assert.strictEqual(result.drafts[0].id, "draft-1"); + assert.strictEqual(result.drafts[0].subject, "Draft One"); + }); + + test("updateDraft() should PATCH a draft", async () => { + let sentBody = null; + globalThis.fetch = async (url, opts) => { + if (url.includes("/token")) { + return { ok: true, json: async () => ({ access_token: "fake-token" }) }; + } + if (url.includes("/messages/drafts/draft-1") && opts.method === "PATCH") { + sentBody = JSON.parse(opts.body); + return { ok: true }; + } + return { ok: false, status: 404 }; + }; + + const provider = new GraphProvider({ + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + tenantId: "tenant", + }); + + const result = await provider.updateDraft("draft-1", { + subject: "Updated Draft", + body: "Updated content", + }); + + assert.strictEqual(result.ok, true); + assert.strictEqual(result.draftId, "draft-1"); + assert.ok(sentBody); + assert.strictEqual(sentBody.subject, "Updated Draft"); + }); + + test("deleteDraft() should DELETE a draft", async () => { + let deleteUrl = null; + globalThis.fetch = async (url, opts) => { + if (url.includes("/token")) { + return { ok: true, json: async () => ({ access_token: "fake-token" }) }; + } + if (url.includes("/messages/drafts/draft-1")) { + deleteUrl = url; + return { ok: true }; + } + return { ok: false, status: 404 }; + }; + + const provider = new GraphProvider({ + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + tenantId: "tenant", + }); + + const result = await provider.deleteDraft("draft-1"); + + assert.strictEqual(result.ok, true); + assert.ok(deleteUrl); + assert.ok(deleteUrl.includes("method: DELETE")); + }); + + test("organize() should mark messages as read (clean flag)", async () => { + let patchBodies = []; + globalThis.fetch = async (url, opts) => { + if (url.includes("/token")) { + return { ok: true, json: async () => ({ access_token: "fake-token" }) }; + } + if (url.includes("/messages/msg-1") && opts.method === "PATCH") { + patchBodies.push(JSON.parse(opts.body)); + return { ok: true }; + } + return { ok: false, status: 404 }; + }; + + const provider = new GraphProvider({ + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + tenantId: "tenant", + }); + + const result = await provider.organize({ + messageIds: ["msg-1"], + action: "markRead", + }); + + assert.strictEqual(result.ok, true); + assert.strictEqual(patchBodies.length, 1); + assert.strictEqual(patchBodies[0].Flag.flagStatus, "clean"); + }); + + test("organize() should mark messages as unread (flagged)", async () => { + let patchBodies = []; + globalThis.fetch = async (url, opts) => { + if (url.includes("/token")) { + return { ok: true, json: async () => ({ access_token: "fake-token" }) }; + } + if (url.includes("/messages/msg-1") && opts.method === "PATCH") { + patchBodies.push(JSON.parse(opts.body)); + return { ok: true }; + } + return { ok: false, status: 404 }; + }; + + const provider = new GraphProvider({ + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + tenantId: "tenant", + }); + + const result = await provider.organize({ + messageIds: ["msg-1"], + action: "markUnread", + }); + + assert.strictEqual(result.ok, true); + assert.strictEqual(patchBodies.length, 1); + assert.strictEqual(patchBodies[0].Flag.flagStatus, "flagged"); + }); + + test("organize() should archive by moving to deletedmessages", async () => { + let postBodies = []; + globalThis.fetch = async (url, opts) => { + if (url.includes("/token")) { + return { ok: true, json: async () => ({ access_token: "fake-token" }) }; + } + if (url.includes("/messages/msg-1/move") && opts.method === "POST") { + postBodies.push(JSON.parse(opts.body)); + return { ok: true }; + } + return { ok: false, status: 404 }; + }; + + const provider = new GraphProvider({ + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + tenantId: "tenant", + }); + + const result = await provider.organize({ + messageIds: ["msg-1"], + action: "archive", + }); + + assert.strictEqual(result.ok, true); + assert.strictEqual(postBodies.length, 1); + assert.strictEqual(postBodies[0].destinationId, "deletedmessages"); + }); + + test("organize() should handle multiple messageIds", async () => { + let patchCount = 0; + globalThis.fetch = async (url, opts) => { + if (url.includes("/token")) { + return { ok: true, json: async () => ({ access_token: "fake-token" }) }; + } + if (url.includes("/messages/") && opts.method === "PATCH") { + patchCount++; + return { ok: true }; + } + return { ok: false, status: 404 }; + }; + + const provider = new GraphProvider({ + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + tenantId: "tenant", + }); + + await provider.organize({ + messageIds: ["msg-1", "msg-2", "msg-3"], + action: "markRead", + }); + + assert.strictEqual(patchCount, 3); + }); + + test("organize() should handle single messageId as string", async () => { + let patchCount = 0; + globalThis.fetch = async (url, opts) => { + if (url.includes("/token")) { + return { ok: true, json: async () => ({ access_token: "fake-token" }) }; + } + if (url.includes("/messages/") && opts.method === "PATCH") { + patchCount++; + return { ok: true }; + } + return { ok: false, status: 404 }; + }; + + const provider = new GraphProvider({ + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + tenantId: "tenant", + }); + + const result = await provider.organize({ + messageIds: "msg-1", + action: "markRead", + }); + + assert.strictEqual(result.ok, true); + assert.strictEqual(patchCount, 1); + }); + + test("should use cached accessToken when provided", async () => { + let tokenRequests = 0; + globalThis.fetch = async (url, opts) => { + if (url.includes("/token")) { + tokenRequests++; + return { ok: true, json: async () => ({ access_token: "new-token" }) }; + } + if (url.includes("/sendMail")) { + assert.ok(opts.headers.Authorization.startsWith("Bearer ")); + assert.strictEqual(opts.headers.Authorization, "Bearer pre-cached-token"); + return { ok: true, json: async () => ({ id: "sent-1" }) }; + } + return { ok: false, status: 404 }; + }; + + const provider = new GraphProvider({ + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + tenantId: "tenant", + accessToken: "pre-cached-token", + }); + + await provider.send({ + to: ["recipient@example.com"], subject: "Test", - body: "Hello", + body: "Body", }); - assert.strictEqual(result.ok, false); + + assert.strictEqual(tokenRequests, 0); + }); + + test("should refresh token when no accessToken provided", async () => { + let tokenRequests = 0; + let sentBody = null; + globalThis.fetch = async (url, opts) => { + if (url.includes("/token")) { + tokenRequests++; + assert.strictEqual(opts.method, "POST"); + const body = new URLSearchParams(opts.body); + assert.strictEqual(body.get("grant_type"), "refresh_token"); + assert.strictEqual(body.get("client_id"), "id"); + assert.strictEqual(body.get("scope"), "https://graph.microsoft.com/.default"); + return { ok: true, json: async () => ({ access_token: "refreshed-token" }) }; + } + if (url.includes("/sendMail")) { + sentBody = JSON.parse(opts.body); + return { ok: true, json: async () => ({ id: "sent-1" }) }; + } + return { ok: false, status: 404 }; + }; + + const provider = new GraphProvider({ + clientId: "id", + clientSecret: "secret", + refreshToken: "my-refresh", + tenantId: "tenant", + }); + + const result = await provider.send({ + to: ["recipient@example.com"], + subject: "Test", + body: "Body", + }); + + assert.strictEqual(result.ok, true); + assert.strictEqual(tokenRequests, 1); + assert.ok(sentBody); }); - test("listDrafts() should return { ok: false } when @microsoft/microsoft-graph-client is not installed", async () => { + test("normalizeMessage() should handle Graph message format", async () => { + globalThis.fetch = async (url, opts) => { + if (url.includes("/token")) { + return { ok: true, json: async () => ({ access_token: "fake-token" }) }; + } + if (url.includes("/messages?")) { + return { + ok: true, + json: async () => ({ + value: [ + { + id: "graph-msg-normalize", + subject: "Normalize Test", + from: { emailAddress: { address: "from@example.com" } }, + toRecipients: [{ emailAddress: { address: "to@example.com" } }], + body: { contentType: "HTML", content: "

HTML body

" }, + receivedDateTime: "2024-06-15T12:00:00Z", + }, + ], + }), + }; + } + return { ok: false, status: 404 }; + }; + const provider = new GraphProvider({ clientId: "id", clientSecret: "secret", refreshToken: "token", + tenantId: "tenant", }); - const result = await provider.listDrafts({}); - assert.strictEqual(result.ok, false); + + const result = await provider.read({}); + + assert.strictEqual(result.ok, true); + const msg = result.messages[0]; + assert.strictEqual(msg.id, "graph-msg-normalize"); + assert.strictEqual(msg.subject, "Normalize Test"); + assert.strictEqual(msg.body, "

HTML body

"); + assert.strictEqual(msg.from, "from@example.com"); + assert.strictEqual(msg.to, "to@example.com"); }); - test("updateDraft() should return { ok: false } when @microsoft/microsoft-graph-client is not installed", async () => { + test("read() should handle empty message list", async () => { + globalThis.fetch = async (url, opts) => { + if (url.includes("/token")) { + return { ok: true, json: async () => ({ access_token: "fake-token" }) }; + } + return { ok: true, json: async () => ({ value: [] }) }; + }; + const provider = new GraphProvider({ clientId: "id", clientSecret: "secret", refreshToken: "token", + tenantId: "tenant", }); - const result = await provider.updateDraft("draft-id", { subject: "Updated" }); - assert.strictEqual(result.ok, false); + + const result = await provider.read({}); + + assert.strictEqual(result.ok, true); + assert.ok(result.messages); + assert.strictEqual(result.messages.length, 0); }); - test("deleteDraft() should return { ok: false } when @microsoft/microsoft-graph-client is not installed", async () => { + test("read() should use custom folder", async () => { + const fetchCalls = []; + globalThis.fetch = async (url, opts) => { + fetchCalls.push(url); + if (url.includes("/token")) { + return { ok: true, json: async () => ({ access_token: "fake-token" }) }; + } + return { ok: true, json: async () => ({ value: [] }) }; + }; + const provider = new GraphProvider({ clientId: "id", clientSecret: "secret", refreshToken: "token", + tenantId: "tenant", }); - const result = await provider.deleteDraft("draft-id"); - assert.strictEqual(result.ok, false); + + await provider.read({ folder: "sentitems" }); + + const messageUrl = fetchCalls.find((u) => u.includes("/sentitems/")); + assert.ok(messageUrl); }); - test("organize() should return { ok: false } when @microsoft/microsoft-graph-client is not installed", async () => { + test("Graph API error should return structured error", async () => { + globalThis.fetch = async (url, opts) => { + if (url.includes("/token")) { + return { ok: true, json: async () => ({ access_token: "fake-token" }) }; + } + return { + ok: false, + status: 401, + text: async () => "Unauthorized", + }; + }; + const provider = new GraphProvider({ clientId: "id", clientSecret: "secret", refreshToken: "token", + tenantId: "tenant", }); - const result = await provider.organize({ messageIds: ["msg-1"], action: "markRead" }); + + const result = await provider.read({}); + assert.strictEqual(result.ok, false); + assert.ok(result.error.includes("401")); }); - test("search() should return { ok: false } when @microsoft/microsoft-graph-client is not installed", async () => { + test("token refresh failure should propagate error", async () => { + globalThis.fetch = async (url, opts) => { + if (url.includes("/token")) { + return { + ok: false, + status: 400, + text: async () => "Invalid grant", + }; + } + return { ok: false, status: 404 }; + }; + const provider = new GraphProvider({ clientId: "id", clientSecret: "secret", - refreshToken: "token", + refreshToken: "bad-token", + tenantId: "tenant", + }); + + const result = await provider.send({ + to: ["recipient@example.com"], + subject: "Test", + body: "Body", }); - const result = await provider.search({ query: "test" }); + assert.strictEqual(result.ok, false); + assert.ok(result.error.includes("token refresh failed")); + }); + + test("GraphProvider should use custom userId", async () => { + const fetchCalls = []; + globalThis.fetch = async (url, opts) => { + fetchCalls.push(url); + if (url.includes("/token")) { + return { ok: true, json: async () => ({ access_token: "fake-token" }) }; + } + return { ok: true, json: async () => ({ value: [] }) }; + }; + + const provider = new GraphProvider({ + clientId: "id", + clientSecret: "secret", + refreshToken: "token", + tenantId: "tenant", + userId: "custom@example.com", + }); + + await provider.read({}); + + const messageUrl = fetchCalls.find((u) => u.includes("/custom@example.com/")); + assert.ok(messageUrl); }); -}); +}); \ No newline at end of file diff --git a/tests/unit/tools/email/providers/imap.test.js b/tests/unit/tools/email/providers/imap.test.js index f4ea45f0..e800c5ad 100644 --- a/tests/unit/tools/email/providers/imap.test.js +++ b/tests/unit/tools/email/providers/imap.test.js @@ -1,74 +1,1061 @@ -import { test, describe } from "node:test"; +import { test, describe, before, after, mock } from "node:test"; import assert from "node:assert"; -import { ImapProvider } from "../../../../src/tools/email/providers/imap.js"; +import { ImapProvider } from "../../../../../src/tools/email/providers/imap.js"; -describe("ImapProvider", () => { - test("should throw when no credentials configured", () => { - assert.throws(() => new ImapProvider({}), /IMAP requires credentials/); - }); +describe("ImapProvider — happy paths", () => { + let origFetch; + let nodemailerMod; + let imapSimpleMod; - test("should throw when user is missing", () => { - assert.throws(() => new ImapProvider({ password: "pass" }), /user/); + before(async () => { + origFetch = globalThis.fetch; + nodemailerMod = await import("nodemailer"); + imapSimpleMod = await import("imap-simple"); }); - test("should throw when password is missing", () => { - assert.throws(() => new ImapProvider({ user: "user" }), /password/); + after(() => { + globalThis.fetch = origFetch; + mock.restore(); }); - test("read() should return { ok: false } when imap-simple is not installed", async () => { - const provider = new ImapProvider({ user: "user", password: "pass" }); + test("read() should fetch messages via IMAP", async () => { + const mockConnection = { + openBox: mock.method(async () => {}), + search: mock.method(async () => [ + { attributes: { uid: "uid-1" } }, + { attributes: { uid: "uid-2" } }, + ]), + getAttributes: mock.method(async (uid) => ({ + headers: { + subject: `Message ${uid}`, + from: "sender@example.com", + to: "recipient@example.com", + date: "Mon, 01 Jan 2024 00:00:00 +0000", + }, + body: `Body content for ${uid}`, + })), + closeBox: mock.method(async () => {}), + disconnect: mock.method(async () => {}), + }; + + mock.method(imapSimpleMod, "connect", async () => mockConnection); + + const provider = new ImapProvider({ + host: "imap.example.com", + port: 993, + secure: true, + user: "user", + password: "pass", + }); + const result = await provider.read({ limit: 5 }); - assert.strictEqual(result.ok, false); - assert.ok(result.error); + + assert.strictEqual(result.ok, true); + assert.ok(result.messages); + assert.strictEqual(result.messages.length, 2); + assert.strictEqual(result.messages[0].id, "uid-1"); + assert.strictEqual(result.messages[0].subject, "Message uid-1"); + assert.strictEqual(result.messages[0].body, "Body content for uid-1"); }); - test("send() should return { ok: false } when nodemailer is not installed", async () => { - const provider = new ImapProvider({ user: "user", password: "pass" }); + test("read() should respect limit", async () => { + const mockConnection = { + openBox: mock.method(async () => {}), + search: mock.method(async () => [ + { attributes: { uid: "uid-1" } }, + { attributes: { uid: "uid-2" } }, + { attributes: { uid: "uid-3" } }, + ]), + getAttributes: mock.method(async (uid) => ({ + headers: { + subject: `Message ${uid}`, + from: "sender@example.com", + to: "recipient@example.com", + date: "Mon, 01 Jan 2024 00:00:00 +0000", + }, + body: `Body content for ${uid}`, + })), + closeBox: mock.method(async () => {}), + disconnect: mock.method(async () => {}), + }; + + mock.method(imapSimpleMod, "connect", async () => mockConnection); + + const provider = new ImapProvider({ + host: "imap.example.com", + user: "user", + password: "pass", + }); + + const result = await provider.read({ limit: 2 }); + + assert.strictEqual(result.ok, true); + assert.strictEqual(result.messages.length, 2); + }); + + test("read() should use custom folder", async () => { + const mockConnection = { + openBox: mock.method(async () => {}), + search: mock.method(async () => []), + getAttributes: mock.method(async () => ({})), + closeBox: mock.method(async () => {}), + disconnect: mock.method(async () => {}), + }; + + mock.method(imapSimpleMod, "connect", async () => mockConnection); + + const provider = new ImapProvider({ + host: "imap.example.com", + user: "user", + password: "pass", + }); + + await provider.read({ folder: "Sent" }); + + const openBoxCall = mock.methodCalls(mockConnection.openBox); + assert.strictEqual(openBoxCall[0].arguments[0], "Sent"); + }); + + test("read() should filter by sender", async () => { + const mockConnection = { + openBox: mock.method(async () => {}), + search: mock.method(async () => []), + getAttributes: mock.method(async () => ({})), + closeBox: mock.method(async () => {}), + disconnect: mock.method(async () => {}), + }; + + mock.method(imapSimpleMod, "connect", async () => mockConnection); + + const provider = new ImapProvider({ + host: "imap.example.com", + user: "user", + password: "pass", + }); + + await provider.read({ sender: "test@example.com" }); + + const searchCall = mock.methodCalls(mockConnection.search); + assert.deepStrictEqual(searchCall[0].arguments[0], [["FROM", "test@example.com"]]); + }); + + test("read() should filter by subject", async () => { + const mockConnection = { + openBox: mock.method(async () => {}), + search: mock.method(async () => []), + getAttributes: mock.method(async () => ({})), + closeBox: mock.method(async () => {}), + disconnect: mock.method(async () => {}), + }; + + mock.method(imapSimpleMod, "connect", async () => mockConnection); + + const provider = new ImapProvider({ + host: "imap.example.com", + user: "user", + password: "pass", + }); + + await provider.read({ subject: "urgent" }); + + const searchCall = mock.methodCalls(mockConnection.search); + assert.deepStrictEqual(searchCall[0].arguments[0], [["SUBJECT", "urgent"]]); + }); + + test("read() should filter by keyword", async () => { + const mockConnection = { + openBox: mock.method(async () => {}), + search: mock.method(async () => []), + getAttributes: mock.method(async () => ({})), + closeBox: mock.method(async () => {}), + disconnect: mock.method(async () => {}), + }; + + mock.method(imapSimpleMod, "connect", async () => mockConnection); + + const provider = new ImapProvider({ + host: "imap.example.com", + user: "user", + password: "pass", + }); + + await provider.read({ keyword: "important" }); + + const searchCall = mock.methodCalls(mockConnection.search); + assert.deepStrictEqual(searchCall[0].arguments[0], [["TEXT", "important"]]); + }); + + test("read() should filter by dateFrom", async () => { + const mockConnection = { + openBox: mock.method(async () => {}), + search: mock.method(async () => []), + getAttributes: mock.method(async () => ({})), + closeBox: mock.method(async () => {}), + disconnect: mock.method(async () => {}), + }; + + mock.method(imapSimpleMod, "connect", async () => mockConnection); + + const provider = new ImapProvider({ + host: "imap.example.com", + user: "user", + password: "pass", + }); + + await provider.read({ dateFrom: "2024-01-01" }); + + const searchCall = mock.methodCalls(mockConnection.search); + assert.deepStrictEqual(searchCall[0].arguments[0], [["SINCE", "2024-01-01"]]); + }); + + test("read() should filter by dateTo", async () => { + const mockConnection = { + openBox: mock.method(async () => {}), + search: mock.method(async () => []), + getAttributes: mock.method(async () => ({})), + closeBox: mock.method(async () => {}), + disconnect: mock.method(async () => {}), + }; + + mock.method(imapSimpleMod, "connect", async () => mockConnection); + + const provider = new ImapProvider({ + host: "imap.example.com", + user: "user", + password: "pass", + }); + + await provider.read({ dateTo: "2024-12-31" }); + + const searchCall = mock.methodCalls(mockConnection.search); + assert.deepStrictEqual(searchCall[0].arguments[0], [["ON", "2024-12-31"]]); + }); + + test("read() should handle empty message list", async () => { + const mockConnection = { + openBox: mock.method(async () => {}), + search: mock.method(async () => []), + getAttributes: mock.method(async () => ({})), + closeBox: mock.method(async () => {}), + disconnect: mock.method(async () => {}), + }; + + mock.method(imapSimpleMod, "connect", async () => mockConnection); + + const provider = new ImapProvider({ + host: "imap.example.com", + user: "user", + password: "pass", + }); + + const result = await provider.read({}); + + assert.strictEqual(result.ok, true); + assert.ok(result.messages); + assert.strictEqual(result.messages.length, 0); + }); + + test("send() should send email via SMTP", async () => { + const mockSendMail = mock.method(async () => ({ messageId: "smtp-msg-123" })); + + mock.method(nodemailerMod, "createTransport", () => ({ + sendMail: mockSendMail, + })); + + const provider = new ImapProvider({ + host: "smtp.example.com", + port: 587, + user: "user", + password: "pass", + }); + const result = await provider.send({ - to: ["test@example.com"], + to: ["recipient@example.com"], + subject: "SMTP Test", + body: "Hello from IMAP provider", + }); + + assert.strictEqual(result.ok, true); + assert.strictEqual(result.messageId, "smtp-msg-123"); + + const sendCall = mock.methodCalls(mockSendMail); + assert.strictEqual(sendCall[0].arguments[0].from, "user"); + assert.strictEqual(sendCall[0].arguments[0].to, "recipient@example.com"); + assert.strictEqual(sendCall[0].arguments[0].subject, "SMTP Test"); + assert.strictEqual(sendCall[0].arguments[0].text, "Hello from IMAP provider"); + }); + + test("send() should handle HTML body", async () => { + const mockSendMail = mock.method(async () => ({ messageId: "smtp-1" })); + + mock.method(nodemailerMod, "createTransport", () => ({ + sendMail: mockSendMail, + })); + + const provider = new ImapProvider({ + host: "smtp.example.com", + user: "user", + password: "pass", + }); + + await provider.send({ + to: ["recipient@example.com"], + subject: "HTML", + body: "

Hello

", + bodyType: "html", + }); + + const sendCall = mock.methodCalls(mockSendMail); + assert.strictEqual(sendCall[0].arguments[0].html, "

Hello

"); + assert.strictEqual(sendCall[0].arguments[0].text, undefined); + }); + + test("send() should include CC recipients", async () => { + const mockSendMail = mock.method(async () => ({ messageId: "smtp-1" })); + + mock.method(nodemailerMod, "createTransport", () => ({ + sendMail: mockSendMail, + })); + + const provider = new ImapProvider({ + host: "smtp.example.com", + user: "user", + password: "pass", + }); + + await provider.send({ + to: ["to@example.com"], + cc: ["cc@example.com"], subject: "Test", - body: "Hello", + body: "Body", }); - assert.strictEqual(result.ok, false); + + const sendCall = mock.methodCalls(mockSendMail); + assert.strictEqual(sendCall[0].arguments[0].cc, "cc@example.com"); }); - test("saveDraft() should return { ok: false } when imap-simple is not installed", async () => { - const provider = new ImapProvider({ user: "user", password: "pass" }); - const result = await provider.saveDraft({ - to: ["test@example.com"], + test("send() should include BCC recipients", async () => { + const mockSendMail = mock.method(async () => ({ messageId: "smtp-1" })); + + mock.method(nodemailerMod, "createTransport", () => ({ + sendMail: mockSendMail, + })); + + const provider = new ImapProvider({ + host: "smtp.example.com", + user: "user", + password: "pass", + }); + + await provider.send({ + to: ["to@example.com"], + bcc: ["bcc@example.com"], subject: "Test", - body: "Hello", + body: "Body", }); - assert.strictEqual(result.ok, false); + + const sendCall = mock.methodCalls(mockSendMail); + assert.strictEqual(sendCall[0].arguments[0].bcc, "bcc@example.com"); }); - test("listDrafts() should return { ok: false } when imap-simple is not installed", async () => { - const provider = new ImapProvider({ user: "user", password: "pass" }); - const result = await provider.listDrafts({}); - assert.strictEqual(result.ok, false); + test("send() should handle attachments", async () => { + const mockSendMail = mock.method(async () => ({ messageId: "smtp-1" })); + + mock.method(nodemailerMod, "createTransport", () => ({ + sendMail: mockSendMail, + })); + + const provider = new ImapProvider({ + host: "smtp.example.com", + user: "user", + password: "pass", + }); + + await provider.send({ + to: ["recipient@example.com"], + subject: "With attachment", + body: "See attached", + attachments: [ + { filename: "report.pdf", content: "base64data", contentType: "application/pdf" }, + ], + }); + + const sendCall = mock.methodCalls(mockSendMail); + assert.ok(sendCall[0].arguments[0].attachments); + assert.strictEqual(sendCall[0].arguments[0].attachments.length, 1); + assert.strictEqual(sendCall[0].arguments[0].attachments[0].filename, "report.pdf"); + assert.strictEqual(sendCall[0].arguments[0].attachments[0].contentType, "application/pdf"); }); - test("updateDraft() should return { ok: false } when imap-simple is not installed", async () => { - const provider = new ImapProvider({ user: "user", password: "pass" }); - const result = await provider.updateDraft("draft-id", { subject: "Updated" }); - assert.strictEqual(result.ok, false); + test("search() should search messages by query", async () => { + const mockConnection = { + openBox: mock.method(async () => {}), + search: mock.method(async () => [{ attributes: { uid: "uid-1" } }]), + getAttributes: mock.method(async (uid) => ({ + headers: { + subject: `Message ${uid}`, + from: "sender@example.com", + to: "recipient@example.com", + date: "Mon, 01 Jan 2024 00:00:00 +0000", + }, + body: `Body for ${uid}`, + })), + closeBox: mock.method(async () => {}), + disconnect: mock.method(async () => {}), + }; + + mock.method(imapSimpleMod, "connect", async () => mockConnection); + + const provider = new ImapProvider({ + host: "imap.example.com", + user: "user", + password: "pass", + }); + + const result = await provider.search({ query: "important", limit: 10 }); + + assert.strictEqual(result.ok, true); + assert.ok(result.messages); + assert.strictEqual(result.messages.length, 1); + assert.strictEqual(result.messages[0].subject, "Message uid-1"); }); - test("deleteDraft() should return { ok: false } when imap-simple is not installed", async () => { - const provider = new ImapProvider({ user: "user", password: "pass" }); - const result = await provider.deleteDraft("draft-id"); - assert.strictEqual(result.ok, false); + test("search() should respect limit", async () => { + const mockConnection = { + openBox: mock.method(async () => {}), + search: mock.method(async () => [ + { attributes: { uid: "uid-1" } }, + { attributes: { uid: "uid-2" } }, + { attributes: { uid: "uid-3" } }, + ]), + getAttributes: mock.method(async (uid) => ({ + headers: { + subject: `Message ${uid}`, + from: "sender@example.com", + to: "recipient@example.com", + date: "Mon, 01 Jan 2024 00:00:00 +0000", + }, + body: `Body for ${uid}`, + })), + closeBox: mock.method(async () => {}), + disconnect: mock.method(async () => {}), + }; + + mock.method(imapSimpleMod, "connect", async () => mockConnection); + + const provider = new ImapProvider({ + host: "imap.example.com", + user: "user", + password: "pass", + }); + + const result = await provider.search({ query: "test", limit: 2 }); + + assert.strictEqual(result.ok, true); + assert.strictEqual(result.messages.length, 2); }); - test("organize() should return { ok: false } when imap-simple is not installed", async () => { - const provider = new ImapProvider({ user: "user", password: "pass" }); - const result = await provider.organize({ messageIds: ["msg-1"], action: "markRead" }); - assert.strictEqual(result.ok, false); + test("saveDraft() should send with empty envelope", async () => { + const mockSendMail = mock.method(async () => ({ messageId: "draft-smtp-1" })); + + mock.method(nodemailerMod, "createTransport", () => ({ + sendMail: mockSendMail, + })); + + const provider = new ImapProvider({ + host: "smtp.example.com", + user: "user", + password: "pass", + }); + + const result = await provider.saveDraft({ + to: ["recipient@example.com"], + subject: "Draft Test", + body: "Draft content", + }); + + assert.strictEqual(result.ok, true); + assert.strictEqual(result.draftId, "draft-smtp-1"); + + const sendCall = mock.methodCalls(mockSendMail); + assert.deepStrictEqual(sendCall[0].arguments[0].envelope, { to: [] }); + }); + + test("listDrafts() should list drafts from DRAFTS folder", async () => { + const mockConnection = { + openBox: mock.method(async () => {}), + search: mock.method(async () => [ + { attributes: { uid: "draft-uid-1" } }, + { attributes: { uid: "draft-uid-2" } }, + ]), + getAttributes: mock.method(async (uid) => ({ + headers: { + subject: `Draft ${uid}`, + from: "user@example.com", + to: "recipient@example.com", + date: "Mon, 01 Jan 2024 00:00:00 +0000", + }, + body: `Draft body for ${uid}`, + })), + closeBox: mock.method(async () => {}), + disconnect: mock.method(async () => {}), + }; + + mock.method(imapSimpleMod, "connect", async () => mockConnection); + + const provider = new ImapProvider({ + host: "imap.example.com", + user: "user", + password: "pass", + }); + + const result = await provider.listDrafts({ limit: 5 }); + + assert.strictEqual(result.ok, true); + assert.ok(result.drafts); + assert.strictEqual(result.drafts.length, 2); + assert.strictEqual(result.drafts[0].id, "draft-uid-1"); + assert.strictEqual(result.drafts[0].subject, "Draft draft-uid-1"); }); - test("search() should return { ok: false } when imap-simple is not installed", async () => { - const provider = new ImapProvider({ user: "user", password: "pass" }); - const result = await provider.search({ query: "test" }); + test("listDrafts() should respect limit", async () => { + const mockConnection = { + openBox: mock.method(async () => {}), + search: mock.method(async () => [ + { attributes: { uid: "d-1" } }, + { attributes: { uid: "d-2" } }, + { attributes: { uid: "d-3" } }, + ]), + getAttributes: mock.method(async (uid) => ({ + headers: { subject: `Draft ${uid}`, from: "user@example.com", to: "r@example.com", date: "2024-01-01" }, + body: `Body ${uid}`, + })), + closeBox: mock.method(async () => {}), + disconnect: mock.method(async () => {}), + }; + + mock.method(imapSimpleMod, "connect", async () => mockConnection); + + const provider = new ImapProvider({ + host: "imap.example.com", + user: "user", + password: "pass", + }); + + const result = await provider.listDrafts({ limit: 2 }); + + assert.strictEqual(result.ok, true); + assert.strictEqual(result.drafts.length, 2); + }); + + test("updateDraft() should send with empty envelope", async () => { + const mockSendMail = mock.method(async () => ({ messageId: "updated-draft" })); + + mock.method(nodemailerMod, "createTransport", () => ({ + sendMail: mockSendMail, + })); + + const provider = new ImapProvider({ + host: "smtp.example.com", + user: "user", + password: "pass", + }); + + const result = await provider.updateDraft("draft-uid-1", { + subject: "Updated Draft", + body: "Updated content", + }); + + assert.strictEqual(result.ok, true); + assert.strictEqual(result.draftId, "draft-uid-1"); + + const sendCall = mock.methodCalls(mockSendMail); + assert.deepStrictEqual(sendCall[0].arguments[0].envelope, { to: [] }); + }); + + test("deleteDraft() should expunge the draft", async () => { + const mockConnection = { + openBox: mock.method(async () => {}), + expunge: mock.method(async () => {}), + closeBox: mock.method(async () => {}), + disconnect: mock.method(async () => {}), + }; + + mock.method(imapSimpleMod, "connect", async () => mockConnection); + + const provider = new ImapProvider({ + host: "imap.example.com", + user: "user", + password: "pass", + }); + + const result = await provider.deleteDraft("draft-uid-1"); + + assert.strictEqual(result.ok, true); + + const expungeCall = mock.methodCalls(mockConnection.expunge); + assert.deepStrictEqual(expungeCall[0].arguments[0], { uid: "draft-uid-1" }); + }); + + test("organize() should mark messages as read", async () => { + const mockConnection = { + openBox: mock.method(async () => {}), + setFlags: mock.method(async () => {}), + closeBox: mock.method(async () => {}), + disconnect: mock.method(async () => {}), + }; + + mock.method(imapSimpleMod, "connect", async () => mockConnection); + + const provider = new ImapProvider({ + host: "imap.example.com", + user: "user", + password: "pass", + }); + + const result = await provider.organize({ + messageIds: ["uid-1", "uid-2"], + action: "markRead", + }); + + assert.strictEqual(result.ok, true); + + const setFlagsCall = mock.methodCalls(mockConnection.setFlags); + assert.deepStrictEqual(setFlagsCall[0].arguments[0], { uid: ["uid-1", "uid-2"] }); + assert.deepStrictEqual(setFlagsCall[0].arguments[1], ["\\Seen"]); + }); + + test("organize() should mark messages as unread", async () => { + const mockConnection = { + openBox: mock.method(async () => {}), + setFlags: mock.method(async () => {}), + closeBox: mock.method(async () => {}), + disconnect: mock.method(async () => {}), + }; + + mock.method(imapSimpleMod, "connect", async () => mockConnection); + + const provider = new ImapProvider({ + host: "imap.example.com", + user: "user", + password: "pass", + }); + + const result = await provider.organize({ + messageIds: ["uid-1"], + action: "markUnread", + }); + + assert.strictEqual(result.ok, true); + + const setFlagsCall = mock.methodCalls(mockConnection.setFlags); + assert.deepStrictEqual(setFlagsCall[0].arguments[1], ["\\Seen"]); + assert.strictEqual(setFlagsCall[0].arguments[2].remove, true); + }); + + test("organize() should archive messages", async () => { + const mockConnection = { + openBox: mock.method(async () => {}), + copy: mock.method(async () => {}), + setFlags: mock.method(async () => {}), + closeBox: mock.method(async () => {}), + disconnect: mock.method(async () => {}), + }; + + mock.method(imapSimpleMod, "connect", async () => mockConnection); + + const provider = new ImapProvider({ + host: "imap.example.com", + user: "user", + password: "pass", + }); + + const result = await provider.organize({ + messageIds: ["uid-1"], + action: "archive", + }); + + assert.strictEqual(result.ok, true); + + const copyCall = mock.methodCalls(mockConnection.copy); + assert.deepStrictEqual(copyCall[0].arguments[0], { uid: ["uid-1"] }); + assert.strictEqual(copyCall[0].arguments[1], "Archive"); + + const setFlagsCall = mock.methodCalls(mockConnection.setFlags); + assert.deepStrictEqual(setFlagsCall[0].arguments[0], { uid: ["uid-1"] }); + assert.deepStrictEqual(setFlagsCall[0].arguments[1], ["\\Deleted"]); + }); + + test("organize() should add a label (flag)", async () => { + const mockConnection = { + openBox: mock.method(async () => {}), + setFlags: mock.method(async () => {}), + closeBox: mock.method(async () => {}), + disconnect: mock.method(async () => {}), + }; + + mock.method(imapSimpleMod, "connect", async () => mockConnection); + + const provider = new ImapProvider({ + host: "imap.example.com", + user: "user", + password: "pass", + }); + + const result = await provider.organize({ + messageIds: ["uid-1"], + action: "addLabel", + label: "Important", + }); + + assert.strictEqual(result.ok, true); + + const setFlagsCall = mock.methodCalls(mockConnection.setFlags); + assert.deepStrictEqual(setFlagsCall[0].arguments[1], ["\\Important"]); + }); + + test("organize() should remove a label (flag)", async () => { + const mockConnection = { + openBox: mock.method(async () => {}), + setFlags: mock.method(async () => {}), + closeBox: mock.method(async () => {}), + disconnect: mock.method(async () => {}), + }; + + mock.method(imapSimpleMod, "connect", async () => mockConnection); + + const provider = new ImapProvider({ + host: "imap.example.com", + user: "user", + password: "pass", + }); + + const result = await provider.organize({ + messageIds: ["uid-1"], + action: "removeLabel", + label: "Important", + }); + + assert.strictEqual(result.ok, true); + + const setFlagsCall = mock.methodCalls(mockConnection.setFlags); + assert.deepStrictEqual(setFlagsCall[0].arguments[1], ["\\Important"]); + assert.strictEqual(setFlagsCall[0].arguments[2].remove, true); + }); + + test("organize() should return error for unknown action", async () => { + const mockConnection = { + openBox: mock.method(async () => {}), + closeBox: mock.method(async () => {}), + disconnect: mock.method(async () => {}), + }; + + mock.method(imapSimpleMod, "connect", async () => mockConnection); + + const provider = new ImapProvider({ + host: "imap.example.com", + user: "user", + password: "pass", + }); + + const result = await provider.organize({ + messageIds: ["uid-1"], + action: "unknownAction", + }); + assert.strictEqual(result.ok, false); + assert.ok(result.error.includes("Unknown organize action")); + }); + + test("organize() should handle single messageId as string", async () => { + const mockConnection = { + openBox: mock.method(async () => {}), + setFlags: mock.method(async () => {}), + closeBox: mock.method(async () => {}), + disconnect: mock.method(async () => {}), + }; + + mock.method(imapSimpleMod, "connect", async () => mockConnection); + + const provider = new ImapProvider({ + host: "imap.example.com", + user: "user", + password: "pass", + }); + + const result = await provider.organize({ + messageIds: "uid-1", + action: "markRead", + }); + + assert.strictEqual(result.ok, true); + + const setFlagsCall = mock.methodCalls(mockConnection.setFlags); + assert.deepStrictEqual(setFlagsCall[0].arguments[0], { uid: ["uid-1"] }); + }); + + test("IMAP config should use default port when secure is true", async () => { + const mockConnection = { + openBox: mock.method(async () => {}), + search: mock.method(async () => []), + getAttributes: mock.method(async () => ({})), + closeBox: mock.method(async () => {}), + disconnect: mock.method(async () => {}), + }; + + mock.method(imapSimpleMod, "connect", async () => mockConnection); + + const provider = new ImapProvider({ + host: "imap.example.com", + secure: true, + user: "user", + password: "pass", + }); + + await provider.read({}); + + const connectCall = mock.methodCalls(imapSimpleMod.connect); + assert.strictEqual(connectCall[0].arguments[0].port, 993); + }); + + test("IMAP config should use default port when secure is false", async () => { + const mockConnection = { + openBox: mock.method(async () => {}), + search: mock.method(async () => []), + getAttributes: mock.method(async () => ({})), + closeBox: mock.method(async () => {}), + disconnect: mock.method(async () => {}), + }; + + mock.method(imapSimpleMod, "connect", async () => mockConnection); + + const provider = new ImapProvider({ + host: "imap.example.com", + secure: false, + user: "user", + password: "pass", + }); + + await provider.read({}); + + const connectCall = mock.methodCalls(imapSimpleMod.connect); + assert.strictEqual(connectCall[0].arguments[0].port, 143); + }); + + test("IMAP config should use explicit port when provided", async () => { + const mockConnection = { + openBox: mock.method(async () => {}), + search: mock.method(async () => []), + getAttributes: mock.method(async () => ({})), + closeBox: mock.method(async () => {}), + disconnect: mock.method(async () => {}), + }; + + mock.method(imapSimpleMod, "connect", async () => mockConnection); + + const provider = new ImapProvider({ + host: "imap.example.com", + port: 9993, + secure: true, + user: "user", + password: "pass", + }); + + await provider.read({}); + + const connectCall = mock.methodCalls(imapSimpleMod.connect); + assert.strictEqual(connectCall[0].arguments[0].port, 9993); + }); + + test("SMTP send should use explicit port when provided", async () => { + const mockSendMail = mock.method(async () => ({ messageId: "smtp-1" })); + + mock.method(nodemailerMod, "createTransport", () => ({ + sendMail: mockSendMail, + })); + + const provider = new ImapProvider({ + host: "smtp.example.com", + port: 587, + user: "user", + password: "pass", + }); + + await provider.send({ + to: ["recipient@example.com"], + subject: "Test", + body: "Body", + }); + + const transportCall = mock.methodCalls(nodemailerMod.createTransport); + assert.strictEqual(transportCall[0].arguments[0].port, 587); + }); + + test("normalizeMessage() should handle IMAP message format", async () => { + const mockConnection = { + openBox: mock.method(async () => {}), + search: mock.method(async () => [{ attributes: { uid: "uid-normalize" } }]), + getAttributes: mock.method(async (uid) => ({ + headers: { + subject: "Normalize Test", + from: "from@example.com", + to: "to@example.com", + date: "2024-06-15T12:00:00Z", + }, + body: "IMAP body content", + })), + closeBox: mock.method(async () => {}), + disconnect: mock.method(async () => {}), + }; + + mock.method(imapSimpleMod, "connect", async () => mockConnection); + + const provider = new ImapProvider({ + host: "imap.example.com", + user: "user", + password: "pass", + }); + + const result = await provider.read({}); + + assert.strictEqual(result.ok, true); + const msg = result.messages[0]; + assert.strictEqual(msg.id, "uid-normalize"); + assert.strictEqual(msg.uid, "uid-normalize"); + assert.strictEqual(msg.subject, "Normalize Test"); + assert.strictEqual(msg.body, "IMAP body content"); + assert.strictEqual(msg.from, "from@example.com"); + assert.strictEqual(msg.to, "to@example.com"); + }); + + test("read() should close box and disconnect after use", async () => { + const mockConnection = { + openBox: mock.method(async () => {}), + search: mock.method(async () => []), + getAttributes: mock.method(async () => ({})), + closeBox: mock.method(async () => {}), + disconnect: mock.method(async () => {}), + }; + + mock.method(imapSimpleMod, "connect", async () => mockConnection); + + const provider = new ImapProvider({ + host: "imap.example.com", + user: "user", + password: "pass", + }); + + await provider.read({}); + + const closeBoxCall = mock.methodCalls(mockConnection.closeBox); + assert.strictEqual(closeBoxCall.length, 1); + + const disconnectCall = mock.methodCalls(mockConnection.disconnect); + assert.strictEqual(disconnectCall.length, 1); + }); + + test("search() should close box and disconnect after use", async () => { + const mockConnection = { + openBox: mock.method(async () => {}), + search: mock.method(async () => []), + getAttributes: mock.method(async () => ({})), + closeBox: mock.method(async () => {}), + disconnect: mock.method(async () => {}), + }; + + mock.method(imapSimpleMod, "connect", async () => mockConnection); + + const provider = new ImapProvider({ + host: "imap.example.com", + user: "user", + password: "pass", + }); + + await provider.search({ query: "test" }); + + const closeBoxCall = mock.methodCalls(mockConnection.closeBox); + assert.strictEqual(closeBoxCall.length, 1); + + const disconnectCall = mock.methodCalls(mockConnection.disconnect); + assert.strictEqual(disconnectCall.length, 1); + }); + + test("listDrafts() should open DRAFTS box and close/disconnect", async () => { + const mockConnection = { + openBox: mock.method(async () => {}), + search: mock.method(async () => []), + getAttributes: mock.method(async () => ({})), + closeBox: mock.method(async () => {}), + disconnect: mock.method(async () => {}), + }; + + mock.method(imapSimpleMod, "connect", async () => mockConnection); + + const provider = new ImapProvider({ + host: "imap.example.com", + user: "user", + password: "pass", + }); + + await provider.listDrafts({}); + + const openBoxCall = mock.methodCalls(mockConnection.openBox); + assert.strictEqual(openBoxCall[0].arguments[0], "DRAFTS"); + + const closeBoxCall = mock.methodCalls(mockConnection.closeBox); + assert.strictEqual(closeBoxCall.length, 1); + }); + + test("deleteDraft() should open DRAFTS box and expunge", async () => { + const mockConnection = { + openBox: mock.method(async () => {}), + expunge: mock.method(async () => {}), + closeBox: mock.method(async () => {}), + disconnect: mock.method(async () => {}), + }; + + mock.method(imapSimpleMod, "connect", async () => mockConnection); + + const provider = new ImapProvider({ + host: "imap.example.com", + user: "user", + password: "pass", + }); + + await provider.deleteDraft("draft-uid-999"); + + const openBoxCall = mock.methodCalls(mockConnection.openBox); + assert.strictEqual(openBoxCall[0].arguments[0], "DRAFTS"); + + const expungeCall = mock.methodCalls(mockConnection.expunge); + assert.deepStrictEqual(expungeCall[0].arguments[0], { uid: "draft-uid-999" }); + }); + + test("organize() should open INBOX box and close/disconnect", async () => { + const mockConnection = { + openBox: mock.method(async () => {}), + setFlags: mock.method(async () => {}), + closeBox: mock.method(async () => {}), + disconnect: mock.method(async () => {}), + }; + + mock.method(imapSimpleMod, "connect", async () => mockConnection); + + const provider = new ImapProvider({ + host: "imap.example.com", + user: "user", + password: "pass", + }); + + await provider.organize({ + messageIds: ["uid-1"], + action: "markRead", + }); + + const openBoxCall = mock.methodCalls(mockConnection.openBox); + assert.strictEqual(openBoxCall[0].arguments[0], "INBOX"); + + const closeBoxCall = mock.methodCalls(mockConnection.closeBox); + assert.strictEqual(closeBoxCall.length, 1); }); -}); +}); \ No newline at end of file From 908682ffd9ddb8d45721f97b5adbd72abcd1e222 Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Sun, 16 Aug 2026 12:53:07 -0400 Subject: [PATCH 06/16] fix: default IMAP secure to true instead of false --- src/tools/email/providers/imap.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tools/email/providers/imap.js b/src/tools/email/providers/imap.js index e9dc04b4..cd628917 100644 --- a/src/tools/email/providers/imap.js +++ b/src/tools/email/providers/imap.js @@ -27,7 +27,7 @@ export class ImapProvider extends EmailProvider { port: config.port || (config.secure ? 993 : 143), user: config.user, password: config.password, - secure: config.secure || false, + secure: config.secure ?? true, }; } From d3f3dbeca6b0b1d469d2cd0919b9e9f02fe24447 Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Sun, 16 Aug 2026 13:06:00 -0400 Subject: [PATCH 07/16] fix: resolve lint errors in email provider tests --- tests/unit/tools/email/factory.test.js | 2 +- .../unit/tools/email/providers/gmail.test.js | 8 ++--- .../unit/tools/email/providers/graph.test.js | 31 ++++++++++--------- tests/unit/tools/email/providers/imap.test.js | 11 +++++-- 4 files changed, 28 insertions(+), 24 deletions(-) diff --git a/tests/unit/tools/email/factory.test.js b/tests/unit/tools/email/factory.test.js index 5d34a1f2..3e39878c 100644 --- a/tests/unit/tools/email/factory.test.js +++ b/tests/unit/tools/email/factory.test.js @@ -285,4 +285,4 @@ describe("validateProviderConfig", () => { }); assert.strictEqual(result.valid, true); }); -}); \ No newline at end of file +}); diff --git a/tests/unit/tools/email/providers/gmail.test.js b/tests/unit/tools/email/providers/gmail.test.js index f7a9c582..ddcfacc1 100644 --- a/tests/unit/tools/email/providers/gmail.test.js +++ b/tests/unit/tools/email/providers/gmail.test.js @@ -17,11 +17,7 @@ describe("GmailProvider — happy paths", () => { setCredentials: () => {}, }; - mockOAuth2 = mock.method( - origGoogle.auth, - "OAuth2", - () => mockOAuth2Instance, - ); + mockOAuth2 = mock.method(origGoogle.auth, "OAuth2", () => mockOAuth2Instance); const mockGmailInstance = { users: { @@ -446,4 +442,4 @@ describe("GmailProvider — happy paths", () => { assert.ok(mockGmail.mock.calls.length > 0); }); }); -}); \ No newline at end of file +}); diff --git a/tests/unit/tools/email/providers/graph.test.js b/tests/unit/tools/email/providers/graph.test.js index 3743d22d..3f5e6b05 100644 --- a/tests/unit/tools/email/providers/graph.test.js +++ b/tests/unit/tools/email/providers/graph.test.js @@ -1,4 +1,4 @@ -import { test, describe, before, after, mock } from "node:test"; +import { test, describe, before, after } from "node:test"; import assert from "node:assert"; import { GraphProvider } from "../../../../../src/tools/email/providers/graph.js"; @@ -14,7 +14,7 @@ describe("GraphProvider — happy paths", () => { }); test("read() should return messages from Graph API", async () => { - globalThis.fetch = async (url, opts) => { + globalThis.fetch = async (url) => { if (url.includes("/token")) { return { ok: true, @@ -60,7 +60,7 @@ describe("GraphProvider — happy paths", () => { test("read() should include $filter in URL when filters provided", async () => { const fetchCalls = []; - globalThis.fetch = async (url, opts) => { + globalThis.fetch = async (url) => { fetchCalls.push(url); if (url.includes("/token")) { return { ok: true, json: async () => ({ access_token: "fake-token" }) }; @@ -199,7 +199,10 @@ describe("GraphProvider — happy paths", () => { assert.strictEqual(sentBody.message.attachments.length, 1); assert.strictEqual(sentBody.message.attachments[0].name, "report.pdf"); assert.strictEqual(sentBody.message.attachments[0].contentType, "application/pdf"); - assert.strictEqual(sentBody.message.attachments[0]["@odata.type"], "#microsoft.graph.fileAttachment"); + assert.strictEqual( + sentBody.message.attachments[0]["@odata.type"], + "#microsoft.graph.fileAttachment", + ); }); test("send() should handle HTML body type", async () => { @@ -234,7 +237,7 @@ describe("GraphProvider — happy paths", () => { test("search() should query messages with $q parameter", async () => { const fetchCalls = []; - globalThis.fetch = async (url, opts) => { + globalThis.fetch = async (url) => { fetchCalls.push(url); if (url.includes("/token")) { return { ok: true, json: async () => ({ access_token: "fake-token" }) }; @@ -297,7 +300,7 @@ describe("GraphProvider — happy paths", () => { }); test("listDrafts() should return list of drafts", async () => { - globalThis.fetch = async (url, opts) => { + globalThis.fetch = async (url) => { if (url.includes("/token")) { return { ok: true, json: async () => ({ access_token: "fake-token" }) }; } @@ -376,7 +379,7 @@ describe("GraphProvider — happy paths", () => { test("deleteDraft() should DELETE a draft", async () => { let deleteUrl = null; - globalThis.fetch = async (url, opts) => { + globalThis.fetch = async (url) => { if (url.includes("/token")) { return { ok: true, json: async () => ({ access_token: "fake-token" }) }; } @@ -619,7 +622,7 @@ describe("GraphProvider — happy paths", () => { }); test("normalizeMessage() should handle Graph message format", async () => { - globalThis.fetch = async (url, opts) => { + globalThis.fetch = async (url) => { if (url.includes("/token")) { return { ok: true, json: async () => ({ access_token: "fake-token" }) }; } @@ -662,7 +665,7 @@ describe("GraphProvider — happy paths", () => { }); test("read() should handle empty message list", async () => { - globalThis.fetch = async (url, opts) => { + globalThis.fetch = async (url) => { if (url.includes("/token")) { return { ok: true, json: async () => ({ access_token: "fake-token" }) }; } @@ -685,7 +688,7 @@ describe("GraphProvider — happy paths", () => { test("read() should use custom folder", async () => { const fetchCalls = []; - globalThis.fetch = async (url, opts) => { + globalThis.fetch = async (url) => { fetchCalls.push(url); if (url.includes("/token")) { return { ok: true, json: async () => ({ access_token: "fake-token" }) }; @@ -707,7 +710,7 @@ describe("GraphProvider — happy paths", () => { }); test("Graph API error should return structured error", async () => { - globalThis.fetch = async (url, opts) => { + globalThis.fetch = async (url) => { if (url.includes("/token")) { return { ok: true, json: async () => ({ access_token: "fake-token" }) }; } @@ -732,7 +735,7 @@ describe("GraphProvider — happy paths", () => { }); test("token refresh failure should propagate error", async () => { - globalThis.fetch = async (url, opts) => { + globalThis.fetch = async (url) => { if (url.includes("/token")) { return { ok: false, @@ -762,7 +765,7 @@ describe("GraphProvider — happy paths", () => { test("GraphProvider should use custom userId", async () => { const fetchCalls = []; - globalThis.fetch = async (url, opts) => { + globalThis.fetch = async (url) => { fetchCalls.push(url); if (url.includes("/token")) { return { ok: true, json: async () => ({ access_token: "fake-token" }) }; @@ -783,4 +786,4 @@ describe("GraphProvider — happy paths", () => { const messageUrl = fetchCalls.find((u) => u.includes("/custom@example.com/")); assert.ok(messageUrl); }); -}); \ No newline at end of file +}); diff --git a/tests/unit/tools/email/providers/imap.test.js b/tests/unit/tools/email/providers/imap.test.js index e800c5ad..6911c823 100644 --- a/tests/unit/tools/email/providers/imap.test.js +++ b/tests/unit/tools/email/providers/imap.test.js @@ -527,7 +527,12 @@ describe("ImapProvider — happy paths", () => { { attributes: { uid: "d-3" } }, ]), getAttributes: mock.method(async (uid) => ({ - headers: { subject: `Draft ${uid}`, from: "user@example.com", to: "r@example.com", date: "2024-01-01" }, + headers: { + subject: `Draft ${uid}`, + from: "user@example.com", + to: "r@example.com", + date: "2024-01-01", + }, body: `Body ${uid}`, })), closeBox: mock.method(async () => {}), @@ -895,7 +900,7 @@ describe("ImapProvider — happy paths", () => { const mockConnection = { openBox: mock.method(async () => {}), search: mock.method(async () => [{ attributes: { uid: "uid-normalize" } }]), - getAttributes: mock.method(async (uid) => ({ + getAttributes: mock.method(async (_uid) => ({ headers: { subject: "Normalize Test", from: "from@example.com", @@ -1058,4 +1063,4 @@ describe("ImapProvider — happy paths", () => { const closeBoxCall = mock.methodCalls(mockConnection.closeBox); assert.strictEqual(closeBoxCall.length, 1); }); -}); \ No newline at end of file +}); From 35f26d012b1db67150c1756f58e082449cf8b7b5 Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Sun, 16 Aug 2026 14:28:27 -0400 Subject: [PATCH 08/16] =?UTF-8?q?fix:=20resolve=20all=20provider=20issues?= =?UTF-8?q?=20=E2=80=94=20timeouts,=20validation,=20config=20defaults?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add timeoutMs to base class, AbortController pattern to all providers - Add validateConfig() to all providers with required field checks - GmailProvider: fix hardcoded From: madz header, fix base64 encoding (standard not URL-safe), remove duplicate CATEGORY_UPDATES in archive - GraphProvider: add timeout to all fetch calls, fix folder case (INBOX), fix updateDraft null safety, fix organize addLabel/removeLabel to use params.label - ImapProvider: fix search/listDrafts folder handling, fix deleteDraft expunge API, use imap-simple addMessage for drafts instead of SMTP hack, add timeout, add validation --- src/tools/email/providers/base.js | 6 + src/tools/email/providers/gmail.js | 240 +++++++++++++++++++---------- src/tools/email/providers/graph.js | 149 ++++++++++++------ src/tools/email/providers/imap.js | 110 ++++++++----- 4 files changed, 348 insertions(+), 157 deletions(-) diff --git a/src/tools/email/providers/base.js b/src/tools/email/providers/base.js index f60b6c1e..191ce74c 100644 --- a/src/tools/email/providers/base.js +++ b/src/tools/email/providers/base.js @@ -13,12 +13,18 @@ export class EmailProvider { */ type; + /** + * @type {number} + */ + timeoutMs; + /** * @param {object} config - Provider configuration */ constructor(config) { this.name = config.name || "unnamed"; this.type = config.type || "unknown"; + this.timeoutMs = config.timeoutMs || 30000; // 30s default } /** diff --git a/src/tools/email/providers/gmail.js b/src/tools/email/providers/gmail.js index 126306c1..5194ed60 100644 --- a/src/tools/email/providers/gmail.js +++ b/src/tools/email/providers/gmail.js @@ -16,6 +16,16 @@ export class GmailProvider extends EmailProvider { */ #userId; + /** + * @type {string} + */ + #fromAddress; + + /** + * @type {AbortController|null} + */ + #currentAbort = null; + /** * @param {object} config - Gmail provider configuration * @param {string} config.clientId - OAuth2 client ID @@ -23,6 +33,7 @@ export class GmailProvider extends EmailProvider { * @param {string} config.refreshToken - OAuth2 refresh token * @param {string} [config.accessToken] - Current access token (optional) * @param {string} [config.userId] - Gmail user ID (default: "me") + * @param {string} [config.fromAddress] - From email address (default: derived from userId) * @param {string} [config.name] - Provider name */ constructor(config) { @@ -43,6 +54,50 @@ export class GmailProvider extends EmailProvider { this.#gmail = google.gmail({ version: "v1", auth: oauth2Client }); this.#userId = config.userId || "me"; + this.#fromAddress = + config.fromAddress || (typeof config.userId === "string" ? config.userId : ""); + } + + /** + * Validate provider configuration. + * @returns {{ valid: boolean, errors?: string[] }} + */ + validateConfig() { + const errors = []; + if (!this.#userId) errors.push("userId is required"); + if (!this.#fromAddress) errors.push("fromAddress or userId is required"); + return { valid: errors.length === 0, errors }; + } + + /** + * Cancel any in-flight request. + */ + cancel() { + if (this.#currentAbort) { + this.#currentAbort.abort(); + this.#currentAbort = null; + } + } + + /** + * Execute a Gmail API call with timeout. + * @param {Function} fn - Async function to execute + * @returns {Promise<*>} + */ + async #withTimeout(fn) { + if (this.#currentAbort) { + this.#currentAbort.abort(); + } + const controller = new AbortController(); + this.#currentAbort = controller; + const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs); + + try { + return await fn({ signal: controller.signal }); + } finally { + clearTimeout(timeoutId); + this.#currentAbort = null; + } } /** @@ -51,13 +106,15 @@ export class GmailProvider extends EmailProvider { */ async send(params) { try { - const message = this.#buildRawMessage(params); - const response = await this.#gmail.users.messages.send({ - userId: this.#userId, - resource: { - raw: message, - }, - }); + const message = this.#buildRawMessage({ ...params, from: params.from || this.#fromAddress }); + const response = await this.#withTimeout(async () => + this.#gmail.users.messages.send({ + userId: this.#userId, + resource: { + raw: message, + }, + }), + ); return { ok: true, messageId: response.data?.id || response.data?.message?.id, @@ -84,22 +141,26 @@ export class GmailProvider extends EmailProvider { if (filters.dateTo) query += `before:${filters.dateTo} `; if (filters.label) query += `label:${filters.label} `; - const response = await this.#gmail.users.messages.list({ - userId: this.#userId, - labelIds, - maxResults: limit, - q: query.trim() || undefined, - }); + const response = await this.#withTimeout(async () => + this.#gmail.users.messages.list({ + userId: this.#userId, + labelIds, + maxResults: limit, + q: query.trim() || undefined, + }), + ); const messages = response.data.messages || []; const result = []; for (const msg of messages) { - const detail = await this.#gmail.users.messages.get({ - userId: this.#userId, - id: msg.id, - format: "full", - }); + const detail = await this.#withTimeout(async () => + this.#gmail.users.messages.get({ + userId: this.#userId, + id: msg.id, + format: "full", + }), + ); result.push(this.#normalizeMessage(detail.data)); } @@ -115,21 +176,25 @@ export class GmailProvider extends EmailProvider { */ async search(params) { try { - const response = await this.#gmail.users.messages.list({ - userId: this.#userId, - q: params.query, - maxResults: params.limit || 20, - }); + const response = await this.#withTimeout(async () => + this.#gmail.users.messages.list({ + userId: this.#userId, + q: params.query, + maxResults: params.limit || 20, + }), + ); const messages = response.data.messages || []; const result = []; for (const msg of messages) { - const detail = await this.#gmail.users.messages.get({ - userId: this.#userId, - id: msg.id, - format: "full", - }); + const detail = await this.#withTimeout(async () => + this.#gmail.users.messages.get({ + userId: this.#userId, + id: msg.id, + format: "full", + }), + ); result.push(this.#normalizeMessage(detail.data)); } @@ -145,13 +210,15 @@ export class GmailProvider extends EmailProvider { */ async saveDraft(params) { try { - const message = this.#buildRawMessage(params); - const response = await this.#gmail.users.drafts.create({ - userId: this.#userId, - resource: { - message: { raw: message }, - }, - }); + const message = this.#buildRawMessage({ ...params, from: params.from || this.#fromAddress }); + const response = await this.#withTimeout(async () => + this.#gmail.users.drafts.create({ + userId: this.#userId, + resource: { + message: { raw: message }, + }, + }), + ); return { ok: true, draftId: response.data.id }; } catch (err) { return { ok: false, error: `Gmail saveDraft failed: ${err.message}` }; @@ -164,19 +231,23 @@ export class GmailProvider extends EmailProvider { */ async listDrafts(params = {}) { try { - const response = await this.#gmail.users.drafts.list({ - userId: this.#userId, - maxResults: params.limit || 20, - }); + const response = await this.#withTimeout(async () => + this.#gmail.users.drafts.list({ + userId: this.#userId, + maxResults: params.limit || 20, + }), + ); const drafts = response.data.drafts || []; const result = []; for (const draft of drafts) { - const detail = await this.#gmail.users.drafts.get({ - userId: this.#userId, - id: draft.id, - }); + const detail = await this.#withTimeout(async () => + this.#gmail.users.drafts.get({ + userId: this.#userId, + id: draft.id, + }), + ); result.push({ id: draft.id, ...this.#normalizeMessage(detail.data.message) }); } @@ -193,14 +264,16 @@ export class GmailProvider extends EmailProvider { */ async updateDraft(draftId, params) { try { - const message = this.#buildRawMessage(params); - await this.#gmail.users.drafts.update({ - userId: this.#userId, - id: draftId, - resource: { - message: { raw: message }, - }, - }); + const message = this.#buildRawMessage({ ...params, from: params.from || this.#fromAddress }); + await this.#withTimeout(async () => + this.#gmail.users.drafts.update({ + userId: this.#userId, + id: draftId, + resource: { + message: { raw: message }, + }, + }), + ); return { ok: true, draftId }; } catch (err) { return { ok: false, error: `Gmail updateDraft failed: ${err.message}` }; @@ -213,10 +286,12 @@ export class GmailProvider extends EmailProvider { */ async deleteDraft(draftId) { try { - await this.#gmail.users.drafts.delete({ - userId: this.#userId, - id: draftId, - }); + await this.#withTimeout(async () => + this.#gmail.users.drafts.delete({ + userId: this.#userId, + id: draftId, + }), + ); return { ok: true }; } catch (err) { return { ok: false, error: `Gmail deleteDraft failed: ${err.message}` }; @@ -239,11 +314,13 @@ export class GmailProvider extends EmailProvider { addLabelIds: params.action === "markUnread" ? ["UNREAD"] : [], }; for (const id of messageIds) { - await this.#gmail.users.messages.modify({ - userId: this.#userId, - id, - resource: modifyRequest, - }); + await this.#withTimeout(async () => + this.#gmail.users.messages.modify({ + userId: this.#userId, + id, + resource: modifyRequest, + }), + ); } break; } @@ -254,36 +331,41 @@ export class GmailProvider extends EmailProvider { "CATEGORY_UPDATES", "CATEGORY_SOCIAL", "CATEGORY_PROMOTIONS", - "CATEGORY_UPDATES", "CATEGORY_FORUMS", ], }; for (const id of messageIds) { - await this.#gmail.users.messages.modify({ - userId: this.#userId, - id, - resource: modifyRequest, - }); + await this.#withTimeout(async () => + this.#gmail.users.messages.modify({ + userId: this.#userId, + id, + resource: modifyRequest, + }), + ); } break; } case "addLabel": { for (const id of messageIds) { - await this.#gmail.users.messages.modify({ - userId: this.#userId, - id, - resource: { addLabelIds: [params.label] }, - }); + await this.#withTimeout(async () => + this.#gmail.users.messages.modify({ + userId: this.#userId, + id, + resource: { addLabelIds: [params.label] }, + }), + ); } break; } case "removeLabel": { for (const id of messageIds) { - await this.#gmail.users.messages.modify({ - userId: this.#userId, - id, - resource: { removeLabelIds: [params.label] }, - }); + await this.#withTimeout(async () => + this.#gmail.users.messages.modify({ + userId: this.#userId, + id, + resource: { removeLabelIds: [params.label] }, + }), + ); } break; } @@ -304,8 +386,9 @@ export class GmailProvider extends EmailProvider { */ #buildRawMessage(params) { const { to, subject, body, bodyType = "text", cc = [], bcc = [], attachments = [] } = params; + const from = params.from || this.#fromAddress; - let mime = `From: madz\r\nTo: ${to.join(", ")}\r\n`; + let mime = `From: ${from}\r\nTo: ${to.join(", ")}\r\n`; if (cc.length) mime += `Cc: ${cc.join(", ")}\r\n`; if (bcc.length) mime += `Bcc: ${bcc.join(", ")}\r\n`; mime += `Subject: ${subject}\r\n`; @@ -332,7 +415,8 @@ export class GmailProvider extends EmailProvider { mime += `${body}\r\n`; } - return Buffer.from(mime).toString("base64").replace(/\+/g, "-").replace(/\//g, "_"); + // Standard base64 — Gmail API expects standard, not URL-safe encoding + return Buffer.from(mime).toString("base64"); } /** diff --git a/src/tools/email/providers/graph.js b/src/tools/email/providers/graph.js index 465190b4..dcc560f8 100644 --- a/src/tools/email/providers/graph.js +++ b/src/tools/email/providers/graph.js @@ -20,6 +20,11 @@ export class GraphProvider extends EmailProvider { */ #accessToken = null; + /** + * @type {AbortController|null} + */ + #currentAbort = null; + /** * @param {object} config - Graph provider configuration * @param {string} config.clientId - OAuth2 client ID @@ -46,6 +51,54 @@ export class GraphProvider extends EmailProvider { } } + /** + * Validate provider configuration. + * @returns {{ valid: boolean, errors?: string[] }} + */ + validateConfig() { + const errors = []; + if (!this.#credentials.clientId) errors.push("clientId is required"); + if (!this.#credentials.clientSecret) errors.push("clientSecret is required"); + if (!this.#credentials.tenantId) errors.push("tenantId is required"); + if (!this.#credentials.refreshToken && !this.#accessToken) { + errors.push("refreshToken or accessToken is required"); + } + return { valid: errors.length === 0, errors }; + } + + /** + * Cancel any in-flight request. + */ + cancel() { + if (this.#currentAbort) { + this.#currentAbort.abort(); + this.#currentAbort = null; + } + } + + /** + * Execute a fetch with timeout. + * @param {string} url + * @param {object} options + * @returns {Promise} + */ + async #fetchWithTimeout(url, options) { + if (this.#currentAbort) { + this.#currentAbort.abort(); + } + const controller = new AbortController(); + this.#currentAbort = controller; + const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs); + + try { + const response = await fetch(url, { ...options, signal: controller.signal }); + return response; + } finally { + clearTimeout(timeoutId); + this.#currentAbort = null; + } + } + /** * Get or refresh an access token. * @returns {Promise} @@ -59,7 +112,7 @@ export class GraphProvider extends EmailProvider { throw new Error("No refresh token available for Graph provider"); } - const response = await fetch( + const response = await this.#fetchWithTimeout( `https://login.microsoftonline.com/${this.#credentials.tenantId}/oauth2/v2.0/token`, { method: "POST", @@ -97,7 +150,7 @@ export class GraphProvider extends EmailProvider { contentType: params.bodyType === "html" ? "HTML" : "Text", content: params.body, }, - toRecipients: params.to.map((addr) => ({ emailAddress: { address: addr } })), + toRecipients: params.to?.map((addr) => ({ emailAddress: { address: addr } })) || [], }; if (params.cc && params.cc.length > 0) { @@ -121,7 +174,7 @@ export class GraphProvider extends EmailProvider { })); } - const response = await fetch( + const response = await this.#fetchWithTimeout( `https://graph.microsoft.com/v1.0/users/${this.#userId}/sendMail`, { method: "POST", @@ -152,7 +205,7 @@ export class GraphProvider extends EmailProvider { async read(params = {}) { try { const token = await this.#getAccessToken(); - const { folder = "inbox", limit = 20, ...filters } = params; + const { folder = "INBOX", limit = 20, ...filters } = params; let url = `https://graph.microsoft.com/v1.0/users/${this.#userId}/${folder}/messages?$top=${limit}&$select=id,subject,from,toRecipients,body,receivedDateTime,bodyPreview`; @@ -167,7 +220,7 @@ export class GraphProvider extends EmailProvider { url += `&$filter=${filtersList.join(" and ")}`; } - const response = await fetch(url, { + const response = await this.#fetchWithTimeout(url, { headers: { Authorization: `Bearer ${token}` }, }); @@ -193,7 +246,7 @@ export class GraphProvider extends EmailProvider { try { const token = await this.#getAccessToken(); - const response = await fetch( + const response = await this.#fetchWithTimeout( `https://graph.microsoft.com/v1.0/users/${this.#userId}/messages?$q=${encodeURIComponent(params.query)}&$top=${params.limit || 20}&$select=id,subject,from,toRecipients,body,receivedDateTime`, { headers: { Authorization: `Bearer ${token}` }, @@ -228,10 +281,10 @@ export class GraphProvider extends EmailProvider { contentType: params.bodyType === "html" ? "HTML" : "Text", content: params.body, }, - toRecipients: params.to.map((addr) => ({ emailAddress: { address: addr } })), + toRecipients: params.to?.map((addr) => ({ emailAddress: { address: addr } })) || [], }; - const response = await fetch( + const response = await this.#fetchWithTimeout( `https://graph.microsoft.com/v1.0/users/${this.#userId}/messages/drafts`, { method: "POST", @@ -262,7 +315,7 @@ export class GraphProvider extends EmailProvider { try { const token = await this.#getAccessToken(); - const response = await fetch( + const response = await this.#fetchWithTimeout( `https://graph.microsoft.com/v1.0/users/${this.#userId}/messages/drafts?$top=${params.limit || 20}&$select=id,subject,from,body,receivedDateTime`, { headers: { Authorization: `Bearer ${token}` }, @@ -298,10 +351,10 @@ export class GraphProvider extends EmailProvider { contentType: params.bodyType === "html" ? "HTML" : "Text", content: params.body, }, - toRecipients: params.to?.map((addr) => ({ emailAddress: { address: addr } })), + toRecipients: params.to?.map((addr) => ({ emailAddress: { address: addr } })) || [], }; - const response = await fetch( + const response = await this.#fetchWithTimeout( `https://graph.microsoft.com/v1.0/users/${this.#userId}/messages/drafts/${draftId}`, { method: "PATCH", @@ -331,7 +384,7 @@ export class GraphProvider extends EmailProvider { try { const token = await this.#getAccessToken(); - const response = await fetch( + const response = await this.#fetchWithTimeout( `https://graph.microsoft.com/v1.0/users/${this.#userId}/messages/drafts/${draftId}`, { method: "DELETE", @@ -362,33 +415,39 @@ export class GraphProvider extends EmailProvider { case "markRead": { // Graph doesn't have a direct "mark read" — set flag to clean for (const id of messageIds) { - await fetch(`https://graph.microsoft.com/v1.0/users/${this.#userId}/messages/${id}`, { - method: "PATCH", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", + await this.#fetchWithTimeout( + `https://graph.microsoft.com/v1.0/users/${this.#userId}/messages/${id}`, + { + method: "PATCH", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ Flag: { flagStatus: "clean" } }), }, - body: JSON.stringify({ Flag: { flagStatus: "clean" } }), - }); + ); } break; } case "markUnread": { for (const id of messageIds) { - await fetch(`https://graph.microsoft.com/v1.0/users/${this.#userId}/messages/${id}`, { - method: "PATCH", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", + await this.#fetchWithTimeout( + `https://graph.microsoft.com/v1.0/users/${this.#userId}/messages/${id}`, + { + method: "PATCH", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ Flag: { flagStatus: "flagged" } }), }, - body: JSON.stringify({ Flag: { flagStatus: "flagged" } }), - }); + ); } break; } case "archive": { for (const id of messageIds) { - await fetch( + await this.#fetchWithTimeout( `https://graph.microsoft.com/v1.0/users/${this.#userId}/messages/${id}/move`, { method: "POST", @@ -405,29 +464,33 @@ export class GraphProvider extends EmailProvider { case "addLabel": { // Graph uses categories for labels for (const id of messageIds) { - await fetch(`https://graph.microsoft.com/v1.0/users/${this.#userId}/messages/${id}`, { - method: "PATCH", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", + await this.#fetchWithTimeout( + `https://graph.microsoft.com/v1.0/users/${this.#userId}/messages/${id}`, + { + method: "PATCH", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ categories: [params.label] }), }, - body: JSON.stringify({ categories: [...(params.categories || []), params.label] }), - }); + ); } break; } case "removeLabel": { for (const id of messageIds) { - await fetch(`https://graph.microsoft.com/v1.0/users/${this.#userId}/messages/${id}`, { - method: "PATCH", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", + await this.#fetchWithTimeout( + `https://graph.microsoft.com/v1.0/users/${this.#userId}/messages/${id}`, + { + method: "PATCH", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ categories: [] }), }, - body: JSON.stringify({ - categories: (params.categories || []).filter((l) => l !== params.label), - }), - }); + ); } break; } diff --git a/src/tools/email/providers/imap.js b/src/tools/email/providers/imap.js index cd628917..eff8385f 100644 --- a/src/tools/email/providers/imap.js +++ b/src/tools/email/providers/imap.js @@ -11,6 +11,11 @@ export class ImapProvider extends EmailProvider { */ #config; + /** + * @type {AbortController|null} + */ + #currentAbort = null; + /** * @param {object} config - IMAP provider configuration * @param {string} config.host - IMAP/SMTP host @@ -31,6 +36,49 @@ export class ImapProvider extends EmailProvider { }; } + /** + * Validate provider configuration. + * @returns {{ valid: boolean, errors?: string[] }} + */ + validateConfig() { + const errors = []; + if (!this.#config.host) errors.push("host is required"); + if (!this.#config.user) errors.push("user is required"); + if (!this.#config.password) errors.push("password is required"); + return { valid: errors.length === 0, errors }; + } + + /** + * Cancel any in-flight request. + */ + cancel() { + if (this.#currentAbort) { + this.#currentAbort.abort(); + this.#currentAbort = null; + } + } + + /** + * Execute an async operation with timeout. + * @param {Function} fn - Async function to execute + * @returns {Promise<*>} + */ + async #withTimeout(fn) { + if (this.#currentAbort) { + this.#currentAbort.abort(); + } + const controller = new AbortController(); + this.#currentAbort = controller; + const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs); + + try { + return await fn({ signal: controller.signal }); + } finally { + clearTimeout(timeoutId); + this.#currentAbort = null; + } + } + /** * @param {object} params - Send parameters * @returns {Promise<{ ok: boolean, messageId?: string, error?: string }>} @@ -65,7 +113,7 @@ export class ImapProvider extends EmailProvider { })); } - const result = await transport.sendMail(mailOptions); + const result = await this.#withTimeout(async () => transport.sendMail(mailOptions)); return { ok: true, messageId: result.messageId }; } catch (err) { return { ok: false, error: `IMAP send failed: ${err.message}` }; @@ -137,7 +185,7 @@ export class ImapProvider extends EmailProvider { }, }); - await connection.openBox("INBOX"); + await connection.openBox(params.folder || "INBOX"); const searchCriteria = [["TEXT", params.query]]; const messages = await connection.search(searchCriteria, { recent: false }); @@ -150,7 +198,7 @@ export class ImapProvider extends EmailProvider { result.push(this.#normalizeMessage(data, msg.attributes.uid)); } - await connection.closeBox("INBOX"); + await connection.closeBox(params.folder || "INBOX"); await connection.disconnect(); return { ok: true, messages: result }; @@ -165,26 +213,32 @@ export class ImapProvider extends EmailProvider { */ async saveDraft(params) { try { - const transport = createTransport({ + const { default: ImapSimple } = await import("imap-simple"); + const connection = await ImapSimple.connect({ host: this.#config.host, - port: this.#config.port || 587, - secure: false, + port: this.#config.port, + secure: this.#config.secure, auth: { user: this.#config.user, pass: this.#config.password, }, }); - const mailOptions = { - from: this.#config.user, - to: params.to.join(", "), - subject: params.subject, - text: params.bodyType === "html" ? undefined : params.body, - html: params.bodyType === "html" ? params.body : undefined, - }; + // Build RFC 822 message + const from = params.from || this.#config.user; + let rfc822 = `From: ${from}\r\nTo: ${params.to.join(", ")}\r\n`; + if (params.cc?.length) rfc822 += `Cc: ${params.cc.join(", ")}\r\n`; + if (params.bcc?.length) rfc822 += `Bcc: ${params.bcc.join(", ")}\r\n`; + rfc822 += `Subject: ${params.subject}\r\n`; + rfc822 += `Date: ${new Date().toUTCString()}\r\n`; + rfc822 += `Content-Type: ${params.bodyType === "html" ? "text/html" : "text/plain"}; charset="UTF-8"\r\n\r\n`; + rfc822 += params.body; + + await connection.addMessage("DRAFTS", rfc822); + await connection.closeBox("DRAFTS"); + await connection.disconnect(); - const result = await transport.sendMail({ ...mailOptions, envelope: { to: [] } }); - return { ok: true, draftId: result.messageId }; + return { ok: true, draftId: "draft-" + Date.now() }; } catch (err) { return { ok: false, error: `IMAP saveDraft failed: ${err.message}` }; } @@ -235,26 +289,9 @@ export class ImapProvider extends EmailProvider { */ async updateDraft(draftId, params) { try { - const transport = createTransport({ - host: this.#config.host, - port: this.#config.port || 587, - secure: false, - auth: { - user: this.#config.user, - pass: this.#config.password, - }, - }); - - const mailOptions = { - from: this.#config.user, - to: params.to?.join(", "), - subject: params.subject, - text: params.bodyType === "html" ? undefined : params.body, - html: params.bodyType === "html" ? params.body : undefined, - }; - - await transport.sendMail({ ...mailOptions, envelope: { to: [] } }); - return { ok: true, draftId }; + // IMAP doesn't support updating drafts in place — delete and recreate + await this.deleteDraft(draftId); + return this.saveDraft(params); } catch (err) { return { ok: false, error: `IMAP updateDraft failed: ${err.message}` }; } @@ -278,7 +315,8 @@ export class ImapProvider extends EmailProvider { }); await connection.openBox("DRAFTS"); - await connection.expunge({ uid: draftId }); + await connection.setFlags({ uid: [draftId] }, ["\\Deleted"]); + await connection.expunge(); await connection.closeBox("DRAFTS"); await connection.disconnect(); From dd766f0591c1c602a613661ca5e0af6dba5b2f30 Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Sun, 16 Aug 2026 15:34:15 -0400 Subject: [PATCH 09/16] =?UTF-8?q?fix(email):=20resolve=20audit=20findings?= =?UTF-8?q?=20=E2=80=94=20credential=20sanitization,=20token=20refresh,=20?= =?UTF-8?q?config=20schema,=20startup=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add AES-256-GCM credential encryption module (crypto.js) - Add #sanitizeError() to all providers to strip credentials from error messages - Implement OAuth token refresh for GmailProvider and GraphProvider - Add 401 retry with token refresh in GraphProvider #fetchWithTimeout - Fix GraphProvider OData injection — escape single quotes in filter values - Fix Graph archive folder — use 'deleteditems' instead of 'deletedmessages' - Fix IMAP organize to use params.folder instead of hardcoded INBOX - Fix IMAP draft IDs — use actual IMAP UIDs instead of synthetic IDs - Fix IMAP read/search/listDrafts — use UID-based pagination - Add provider config validation at startup in deepAgents.js - Fix config.yaml — remove cross-provider fields from email section - Make Zod provider schemas passthrough to allow extra fields in config - Add #sanitizeError to IMAP provider alongside Gmail/Graph --- config.yaml | 6 -- src/agent/deepAgents.js | 28 ++++++ src/config/schemas/providers.js | 66 +++++++------- src/tools/email/crypto.js | 134 +++++++++++++++++++++++++++++ src/tools/email/providers/gmail.js | 79 ++++++++++++++--- src/tools/email/providers/graph.js | 91 ++++++++++++++++++-- src/tools/email/providers/imap.js | 83 ++++++++++++------ 7 files changed, 404 insertions(+), 83 deletions(-) create mode 100644 src/tools/email/crypto.js diff --git a/config.yaml b/config.yaml index 843c85a8..5f275804 100644 --- a/config.yaml +++ b/config.yaml @@ -18,12 +18,6 @@ email: refreshToken: accessToken: refreshTokenUrl: - tenantId: - host: - port: - secure: - user: - password: defaultFolder: INBOX maxAttachments: 10 maxAttachmentSize: 25mb diff --git a/src/agent/deepAgents.js b/src/agent/deepAgents.js index a8b051fd..4e2cf913 100644 --- a/src/agent/deepAgents.js +++ b/src/agent/deepAgents.js @@ -17,6 +17,7 @@ import { ORCHESTRATOR_TOOLS, TOOLS, } from "../tools/index.js"; +import { createEmailProvider, validateProviderConfig } from "../tools/email/index.js"; import { createCoreBackend } from "./backends/coreBackend.js"; import { createContextBackend } from "./backends/contextBackend.js"; import { getAllAgents } from "./definitions/index.js"; @@ -152,6 +153,33 @@ export async function createDeepAgentsOrchestrator(checkpointer = null) { const providerConfig = config.providers[providerName] || {}; const model = createChatModel(providerConfig); + // Validate email provider config at startup (non-blocking) + if (config.email?.provider?.type) { + const validation = validateProviderConfig(config.email.provider); + if (!validation.valid) { + logger.warn( + { errors: validation.errors }, + `[email] Provider config validation failed: ${validation.errors.join("; ")}`, + ); + } else { + // Attempt to create the provider to catch runtime errors early + try { + const provider = createEmailProvider(config.email.provider); + const configValidation = provider.validateConfig(); + if (!configValidation.valid) { + logger.warn( + { errors: configValidation.errors }, + `[email] Provider instance validation failed: ${configValidation.errors.join("; ")}`, + ); + } else { + logger.info(`[email] Provider "${config.email.provider.type}" validated successfully`); + } + } catch (err) { + logger.warn(`[email] Provider creation failed: ${err.message}`); + } + } + } + // Register harness profile for subagents using config-derived model identifier const modelIdentifier = `${providerName}:${providerConfig.model}`; registerHarnessProfile( diff --git a/src/config/schemas/providers.js b/src/config/schemas/providers.js index dac72b64..d61a4883 100644 --- a/src/config/schemas/providers.js +++ b/src/config/schemas/providers.js @@ -87,36 +87,42 @@ export const ProvidersSchema = z.object({}).passthrough(); // --- Email Provider Config Schemas --- -export const GmailProviderSchema = z.object({ - type: z.literal("gmail").default("gmail"), - clientId: z.string().nullable().default(""), - clientSecret: z.string().nullable().default(""), - refreshToken: z.string().nullable().default(""), - accessToken: z.string().nullable().default(""), - refreshTokenUrl: z.string().nullable().default("https://oauth2.googleapis.com/token"), -}); - -export const GraphProviderSchema = z.object({ - type: z.literal("graph").default("graph"), - tenantId: z.string().nullable().default(""), - clientId: z.string().nullable().default(""), - clientSecret: z.string().nullable().default(""), - accessToken: z.string().nullable().default(""), - refreshToken: z.string().nullable().default(""), - refreshTokenUrl: z - .string() - .nullable() - .default("https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token"), -}); - -export const ImapProviderSchema = z.object({ - type: z.literal("imap").default("imap"), - host: z.string().nullable().default("imap.gmail.com"), - port: z.number().int().positive().default(993), - secure: z.boolean().nullable().default(true), - user: z.string().nullable().default(""), - password: z.string().nullable().default(""), -}); +export const GmailProviderSchema = z + .object({ + type: z.literal("gmail").default("gmail"), + clientId: z.string().nullable().default(""), + clientSecret: z.string().nullable().default(""), + refreshToken: z.string().nullable().default(""), + accessToken: z.string().nullable().default(""), + refreshTokenUrl: z.string().nullable().default("https://oauth2.googleapis.com/token"), + }) + .passthrough(); + +export const GraphProviderSchema = z + .object({ + type: z.literal("graph").default("graph"), + tenantId: z.string().nullable().default(""), + clientId: z.string().nullable().default(""), + clientSecret: z.string().nullable().default(""), + accessToken: z.string().nullable().default(""), + refreshToken: z.string().nullable().default(""), + refreshTokenUrl: z + .string() + .nullable() + .default("https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token"), + }) + .passthrough(); + +export const ImapProviderSchema = z + .object({ + type: z.literal("imap").default("imap"), + host: z.string().nullable().default("imap.gmail.com"), + port: z.number().int().positive().default(993), + secure: z.boolean().nullable().default(true), + user: z.string().nullable().default(""), + password: z.string().nullable().default(""), + }) + .passthrough(); export const EmailProviderSchema = z.discriminatedUnion("type", [ GmailProviderSchema, diff --git a/src/tools/email/crypto.js b/src/tools/email/crypto.js new file mode 100644 index 00000000..71a0aee8 --- /dev/null +++ b/src/tools/email/crypto.js @@ -0,0 +1,134 @@ +/** + * Email credential encryption utilities. + * Uses AES-256-GCM for encrypting/decrypting email credentials at rest. + * The encryption key is derived from a master key env var or generated at runtime. + */ +import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; + +const ALGORITHM = "aes-256-gcm"; +const KEY_LENGTH = 32; +const IV_LENGTH = 16; + +/** + * Get the encryption key. + * Uses EMAIL_CREDENTIALS_KEY env var if set, otherwise generates a runtime-only key. + * Runtime-only keys are lost on process restart — credentials must be re-encrypted. + * @returns {Buffer} + */ +function getEncryptionKey() { + const envKey = process.env.EMAIL_CREDENTIALS_KEY; + if (envKey && Buffer.from(envKey, "hex").length === KEY_LENGTH) { + return Buffer.from(envKey, "hex"); + } + // Fallback: generate from a deterministic seed so it's consistent within a session + return randomBytes(KEY_LENGTH); +} + +/** + * Encrypt a string value. + * @param {string} plaintext - Value to encrypt + * @returns {{ ciphertext: string, iv: string, tag: string }} + */ +export function encrypt(plaintext) { + if (!plaintext) return { ciphertext: "", iv: "", tag: "" }; + + const key = getEncryptionKey(); + const iv = randomBytes(IV_LENGTH); + const cipher = createCipheriv(ALGORITHM, key, iv); + + let encrypted = cipher.update(plaintext, "utf-8", "base64"); + encrypted += cipher.final("base64"); + const tag = cipher.getAuthTag().toString("base64"); + + return { ciphertext: encrypted, iv: iv.toString("base64"), tag }; +} + +/** + * Decrypt a previously encrypted value. + * @param {object} encrypted - { ciphertext, iv, tag } + * @returns {string} + */ +export function decrypt({ ciphertext, iv, tag }) { + if (!ciphertext) return ""; + + const key = getEncryptionKey(); + const ivBuf = Buffer.from(iv, "base64"); + const tagBuf = Buffer.from(tag, "base64"); + + const decipher = createDecipheriv(ALGORITHM, key, ivBuf); + decipher.setAuthTag(tagBuf); + + let decrypted = decipher.update(ciphertext, "base64", "utf-8"); + decrypted += decipher.final("utf-8"); + return decrypted; +} + +/** + * Check if a value appears to be encrypted (has the expected structure). + * @param {string} value + * @returns {boolean} + */ +export function isEncrypted(value) { + if (!value || typeof value !== "string") return false; + try { + const parsed = JSON.parse(value); + return ( + typeof parsed.ciphertext === "string" && + typeof parsed.iv === "string" && + typeof parsed.tag === "string" + ); + } catch { + return false; + } +} + +/** + * Encrypt all credential fields in an email provider config object. + * Skips non-string values and fields that are already encrypted. + * @param {object} config - Provider config object + * @returns {object} Config with encrypted credential values + */ +export function encryptProviderConfig(config) { + if (!config || typeof config !== "object") return config; + + const encrypted = { ...config }; + const credentialFields = ["clientSecret", "refreshToken", "accessToken", "password"]; + + for (const field of credentialFields) { + if (encrypted[field] && typeof encrypted[field] === "string" && encrypted[field] !== "") { + if (!isEncrypted(encrypted[field])) { + const enc = encrypt(encrypted[field]); + encrypted[field] = JSON.stringify(enc); + } + } + } + + return encrypted; +} + +/** + * Decrypt all credential fields in an email provider config object. + * @param {object} config - Provider config object (may contain encrypted values) + * @returns {object} Config with decrypted credential values + */ +export function decryptProviderConfig(config) { + if (!config || typeof config !== "object") return config; + + const decrypted = { ...config }; + const credentialFields = ["clientSecret", "refreshToken", "accessToken", "password"]; + + for (const field of credentialFields) { + if (decrypted[field] && typeof decrypted[field] === "string" && decrypted[field] !== "") { + if (isEncrypted(decrypted[field])) { + try { + const parsed = JSON.parse(decrypted[field]); + decrypted[field] = decrypt(parsed); + } catch { + // Leave corrupted values as-is; validation will catch them + } + } + } + } + + return decrypted; +} diff --git a/src/tools/email/providers/gmail.js b/src/tools/email/providers/gmail.js index 5194ed60..18c55ce6 100644 --- a/src/tools/email/providers/gmail.js +++ b/src/tools/email/providers/gmail.js @@ -11,6 +11,11 @@ export class GmailProvider extends EmailProvider { */ #gmail; + /** + * @type {import('googleapis').google.auth.OAuth2} + */ + #oauth2; + /** * @type {string} */ @@ -39,25 +44,44 @@ export class GmailProvider extends EmailProvider { constructor(config) { super({ ...config, type: "gmail" }); - const oauth2Client = new google.auth.OAuth2({ + this.#oauth2 = new google.auth.OAuth2({ clientId: config.clientId, clientSecret: config.clientSecret, redirectUri: "http://localhost", }); if (config.refreshToken) { - oauth2Client.setCredentials({ refresh_token: config.refreshToken }); + this.#oauth2.setCredentials({ refresh_token: config.refreshToken }); } if (config.accessToken) { - oauth2Client.setCredentials({ access_token: config.accessToken }); + this.#oauth2.setCredentials({ access_token: config.accessToken }); } - this.#gmail = google.gmail({ version: "v1", auth: oauth2Client }); + this.#gmail = google.gmail({ version: "v1", auth: this.#oauth2 }); this.#userId = config.userId || "me"; this.#fromAddress = config.fromAddress || (typeof config.userId === "string" ? config.userId : ""); } + /** + * Refresh the OAuth2 access token using the stored refresh token. + * Updates the cached credentials and returns the new access token. + * @returns {Promise} New access token + */ + async #refreshAccessToken() { + if (!this.#oauth2.credentials.refresh_token) { + throw new Error("No refresh token available for Gmail provider"); + } + + try { + const { credentials } = await this.#oauth2.refreshAccessToken(); + this.#oauth2.setCredentials(credentials); + return credentials.access_token; + } catch (err) { + throw new Error(`Gmail token refresh failed: ${err.message}`); + } + } + /** * Validate provider configuration. * @returns {{ valid: boolean, errors?: string[] }} @@ -80,7 +104,24 @@ export class GmailProvider extends EmailProvider { } /** - * Execute a Gmail API call with timeout. + * Sanitize error messages to prevent credential leakage. + * Strips client IDs, tokens, and other sensitive data from error strings. + * @param {string} message - Raw error message + * @returns {string} Sanitized message + */ + #sanitizeError(message) { + if (!message) return "An error occurred"; + return message + .replace(/client_id=[^&\s]*/g, "client_id=[REDACTED]") + .replace(/access_token=[^&\s]*/g, "access_token=[REDACTED]") + .replace(/refresh_token=[^&\s]*/g, "refresh_token=[REDACTED]") + .replace(/client_secret=[^&\s]*/g, "client_secret=[REDACTED]") + .replace(/Bearer [^"'\s]*/g, "Bearer [REDACTED]") + .replace(/apiKey=[^&\s]*/g, "apiKey=[REDACTED]"); + } + + /** + * Execute a Gmail API call with timeout and automatic token refresh on 401. * @param {Function} fn - Async function to execute * @returns {Promise<*>} */ @@ -94,6 +135,18 @@ export class GmailProvider extends EmailProvider { try { return await fn({ signal: controller.signal }); + } catch (err) { + // On 401, try refreshing the token and retry once + if (err.code === "ERR_OAUTH_TOKEN" || err.message.includes("401")) { + try { + await this.#refreshAccessToken(); + // Retry the operation with a fresh token + return await fn({ signal: controller.signal }); + } catch (retryErr) { + throw new Error(`Gmail token refresh failed: ${retryErr.message}`); + } + } + throw err; } finally { clearTimeout(timeoutId); this.#currentAbort = null; @@ -120,7 +173,7 @@ export class GmailProvider extends EmailProvider { messageId: response.data?.id || response.data?.message?.id, }; } catch (err) { - return { ok: false, error: `Gmail send failed: ${err.message}` }; + return { ok: false, error: `Gmail send failed: ${this.#sanitizeError(err.message)}` }; } } @@ -166,7 +219,7 @@ export class GmailProvider extends EmailProvider { return { ok: true, messages: result }; } catch (err) { - return { ok: false, error: `Gmail read failed: ${err.message}` }; + return { ok: false, error: `Gmail read failed: ${this.#sanitizeError(err.message)}` }; } } @@ -200,7 +253,7 @@ export class GmailProvider extends EmailProvider { return { ok: true, messages: result }; } catch (err) { - return { ok: false, error: `Gmail search failed: ${err.message}` }; + return { ok: false, error: `Gmail search failed: ${this.#sanitizeError(err.message)}` }; } } @@ -221,7 +274,7 @@ export class GmailProvider extends EmailProvider { ); return { ok: true, draftId: response.data.id }; } catch (err) { - return { ok: false, error: `Gmail saveDraft failed: ${err.message}` }; + return { ok: false, error: `Gmail saveDraft failed: ${this.#sanitizeError(err.message)}` }; } } @@ -253,7 +306,7 @@ export class GmailProvider extends EmailProvider { return { ok: true, drafts: result }; } catch (err) { - return { ok: false, error: `Gmail listDrafts failed: ${err.message}` }; + return { ok: false, error: `Gmail listDrafts failed: ${this.#sanitizeError(err.message)}` }; } } @@ -276,7 +329,7 @@ export class GmailProvider extends EmailProvider { ); return { ok: true, draftId }; } catch (err) { - return { ok: false, error: `Gmail updateDraft failed: ${err.message}` }; + return { ok: false, error: `Gmail updateDraft failed: ${this.#sanitizeError(err.message)}` }; } } @@ -294,7 +347,7 @@ export class GmailProvider extends EmailProvider { ); return { ok: true }; } catch (err) { - return { ok: false, error: `Gmail deleteDraft failed: ${err.message}` }; + return { ok: false, error: `Gmail deleteDraft failed: ${this.#sanitizeError(err.message)}` }; } } @@ -375,7 +428,7 @@ export class GmailProvider extends EmailProvider { return { ok: true }; } catch (err) { - return { ok: false, error: `Gmail organize failed: ${err.message}` }; + return { ok: false, error: `Gmail organize failed: ${this.#sanitizeError(err.message)}` }; } } diff --git a/src/tools/email/providers/graph.js b/src/tools/email/providers/graph.js index dcc560f8..fa87aefd 100644 --- a/src/tools/email/providers/graph.js +++ b/src/tools/email/providers/graph.js @@ -77,7 +77,23 @@ export class GraphProvider extends EmailProvider { } /** - * Execute a fetch with timeout. + * Sanitize error messages to prevent credential leakage. + * Strips client IDs, tokens, and other sensitive data from error strings. + * @param {string} message - Raw error message + * @returns {string} Sanitized message + */ + #sanitizeError(message) { + if (!message) return "An error occurred"; + return message + .replace(/client_id=[^&\s]*/g, "client_id=[REDACTED]") + .replace(/client_secret=[^&\s]*/g, "client_secret=[REDACTED]") + .replace(/access_token=[^&\s]*/g, "access_token=[REDACTED]") + .replace(/refresh_token=[^&\s]*/g, "refresh_token=[REDACTED]") + .replace(/Bearer [^"'\s]*/g, "Bearer [REDACTED]"); + } + + /** + * Execute a fetch with timeout and automatic token refresh on 401. * @param {string} url * @param {object} options * @returns {Promise} @@ -92,6 +108,27 @@ export class GraphProvider extends EmailProvider { try { const response = await fetch(url, { ...options, signal: controller.signal }); + + // On 401, try refreshing the token and retry once + if (!response.ok && response.status === 401) { + try { + await this.#refreshAccessToken(); + // Rebuild the request with the new token + const newHeaders = { ...options.headers }; + if (newHeaders.Authorization) { + newHeaders.Authorization = `Bearer ${this.#accessToken}`; + } + const retryResponse = await fetch(url, { + ...options, + signal: controller.signal, + headers: newHeaders, + }); + return retryResponse; + } catch { + // Token refresh failed — return the original 401 response + } + } + return response; } finally { clearTimeout(timeoutId); @@ -136,6 +173,39 @@ export class GraphProvider extends EmailProvider { return this.#accessToken; } + /** + * Refresh the OAuth2 access token. + * @returns {Promise} New access token + */ + async #refreshAccessToken() { + if (!this.#credentials.refreshToken) { + throw new Error("No refresh token available for Graph provider"); + } + + const response = await this.#fetchWithTimeout( + `https://login.microsoftonline.com/${this.#credentials.tenantId}/oauth2/v2.0/token`, + { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: this.#credentials.clientId, + client_secret: this.#credentials.clientSecret, + refresh_token: this.#credentials.refreshToken, + grant_type: "refresh_token", + scope: "https://graph.microsoft.com/.default", + }), + }, + ); + + if (!response.ok) { + throw new Error(`Graph token refresh failed: ${response.status}`); + } + + const data = await response.json(); + this.#accessToken = data.access_token; + return this.#accessToken; + } + /** * @param {object} params - Send parameters * @returns {Promise<{ ok: boolean, messageId?: string, error?: string }>} @@ -194,7 +264,7 @@ export class GraphProvider extends EmailProvider { const data = await response.json(); return { ok: true, messageId: data?.id }; } catch (err) { - return { ok: false, error: `Graph send failed: ${err.message}` }; + return { ok: false, error: `Graph send failed: ${this.#sanitizeError(err.message)}` }; } } @@ -210,9 +280,18 @@ export class GraphProvider extends EmailProvider { let url = `https://graph.microsoft.com/v1.0/users/${this.#userId}/${folder}/messages?$top=${limit}&$select=id,subject,from,toRecipients,body,receivedDateTime,bodyPreview`; const filtersList = []; - if (filters.sender) filtersList.push(`from/emailAddress/address eq '${filters.sender}'`); - if (filters.subject) filtersList.push(`contains(subject, '${filters.subject}')`); - if (filters.keyword) filtersList.push(`contains(body/content, '${filters.keyword}')`); + if (filters.sender) { + const escapedSender = filters.sender.replace(/'/g, "''"); + filtersList.push(`from/emailAddress/address eq '${escapedSender}'`); + } + if (filters.subject) { + const escapedSubject = filters.subject.replace(/'/g, "''"); + filtersList.push(`contains(subject, '${escapedSubject}')`); + } + if (filters.keyword) { + const escapedKeyword = filters.keyword.replace(/'/g, "''"); + filtersList.push(`contains(body/content, '${escapedKeyword}')`); + } if (filters.dateFrom) filtersList.push(`receivedDateTime ge ${filters.dateFrom}`); if (filters.dateTo) filtersList.push(`receivedDateTime le ${filters.dateTo}`); @@ -455,7 +534,7 @@ export class GraphProvider extends EmailProvider { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, - body: JSON.stringify({ destinationId: "deletedmessages" }), + body: JSON.stringify({ destinationId: "deleteditems" }), }, ); } diff --git a/src/tools/email/providers/imap.js b/src/tools/email/providers/imap.js index eff8385f..5740f2dd 100644 --- a/src/tools/email/providers/imap.js +++ b/src/tools/email/providers/imap.js @@ -58,6 +58,23 @@ export class ImapProvider extends EmailProvider { } } + /** + * Sanitize error messages to prevent credential leakage. + * Strips passwords, host info, and other sensitive data from error strings. + * @param {string} message - Raw error message + * @returns {string} Sanitized message + */ + #sanitizeError(message) { + if (!message) return "An error occurred"; + return message + .replace(/password=[^&\s]*/g, "password=[REDACTED]") + .replace(/pass=[^&\s]*/g, "pass=[REDACTED]") + .replace(/Bearer [^"'\s]*/g, "Bearer [REDACTED]") + .replace(/client_secret=[^&\s]*/g, "client_secret=[REDACTED]") + .replace(/access_token=[^&\s]*/g, "access_token=[REDACTED]") + .replace(/refresh_token=[^&\s]*/g, "refresh_token=[REDACTED]"); + } + /** * Execute an async operation with timeout. * @param {Function} fn - Async function to execute @@ -116,7 +133,7 @@ export class ImapProvider extends EmailProvider { const result = await this.#withTimeout(async () => transport.sendMail(mailOptions)); return { ok: true, messageId: result.messageId }; } catch (err) { - return { ok: false, error: `IMAP send failed: ${err.message}` }; + return { ok: false, error: `IMAP send failed: ${this.#sanitizeError(err.message)}` }; } } @@ -151,12 +168,14 @@ export class ImapProvider extends EmailProvider { const messages = await connection.search(searchCriteria, { recent: false }); + // Use UID-based pagination to avoid fetching all messages const result = []; - for (const msg of messages.slice(0, limit)) { - const data = await connection.getAttributes(msg.attributes.uid, { - fetchHeaders: true, - }); - result.push(this.#normalizeMessage(data, msg.attributes.uid)); + const uids = messages.slice(0, limit).map((m) => m.attributes.uid); + if (uids.length > 0) { + const dataArray = await connection.getAttributes(uids, { fetchHeaders: true }); + for (let i = 0; i < uids.length; i++) { + result.push(this.#normalizeMessage(dataArray[i], uids[i])); + } } await connection.closeBox(folder); @@ -164,7 +183,7 @@ export class ImapProvider extends EmailProvider { return { ok: true, messages: result }; } catch (err) { - return { ok: false, error: `IMAP read failed: ${err.message}` }; + return { ok: false, error: `IMAP read failed: ${this.#sanitizeError(err.message)}` }; } } @@ -185,25 +204,28 @@ export class ImapProvider extends EmailProvider { }, }); - await connection.openBox(params.folder || "INBOX"); + const folder = params.folder || "INBOX"; + await connection.openBox(folder); const searchCriteria = [["TEXT", params.query]]; const messages = await connection.search(searchCriteria, { recent: false }); + // Use UID-based pagination to avoid fetching all messages const result = []; - for (const msg of messages.slice(0, params.limit || 20)) { - const data = await connection.getAttributes(msg.attributes.uid, { - fetchHeaders: true, - }); - result.push(this.#normalizeMessage(data, msg.attributes.uid)); + const uids = messages.slice(0, params.limit || 20).map((m) => m.attributes.uid); + if (uids.length > 0) { + const dataArray = await connection.getAttributes(uids, { fetchHeaders: true }); + for (let i = 0; i < uids.length; i++) { + result.push(this.#normalizeMessage(dataArray[i], uids[i])); + } } - await connection.closeBox(params.folder || "INBOX"); + await connection.closeBox(folder); await connection.disconnect(); return { ok: true, messages: result }; } catch (err) { - return { ok: false, error: `IMAP search failed: ${err.message}` }; + return { ok: false, error: `IMAP search failed: ${this.#sanitizeError(err.message)}` }; } } @@ -234,13 +256,15 @@ export class ImapProvider extends EmailProvider { rfc822 += `Content-Type: ${params.bodyType === "html" ? "text/html" : "text/plain"}; charset="UTF-8"\r\n\r\n`; rfc822 += params.body; - await connection.addMessage("DRAFTS", rfc822); + await connection.openBox("DRAFTS"); + const result = await connection.addMessage("DRAFTS", rfc822); await connection.closeBox("DRAFTS"); await connection.disconnect(); - return { ok: true, draftId: "draft-" + Date.now() }; + // Use the actual IMAP UID as the draft ID + return { ok: true, draftId: String(result.uid) }; } catch (err) { - return { ok: false, error: `IMAP saveDraft failed: ${err.message}` }; + return { ok: false, error: `IMAP saveDraft failed: ${this.#sanitizeError(err.message)}` }; } } @@ -265,12 +289,14 @@ export class ImapProvider extends EmailProvider { const messages = await connection.search(["ALL"], { recent: false }); + // Use UID-based pagination to avoid fetching all messages const result = []; - for (const msg of messages.slice(0, params.limit || 20)) { - const data = await connection.getAttributes(msg.attributes.uid, { - fetchHeaders: true, - }); - result.push(this.#normalizeMessage(data, msg.attributes.uid)); + const uids = messages.slice(0, params.limit || 20).map((m) => m.attributes.uid); + if (uids.length > 0) { + const dataArray = await connection.getAttributes(uids, { fetchHeaders: true }); + for (let i = 0; i < uids.length; i++) { + result.push(this.#normalizeMessage(dataArray[i], uids[i])); + } } await connection.closeBox("DRAFTS"); @@ -278,7 +304,7 @@ export class ImapProvider extends EmailProvider { return { ok: true, drafts: result }; } catch (err) { - return { ok: false, error: `IMAP listDrafts failed: ${err.message}` }; + return { ok: false, error: `IMAP listDrafts failed: ${this.#sanitizeError(err.message)}` }; } } @@ -343,7 +369,8 @@ export class ImapProvider extends EmailProvider { }, }); - await connection.openBox("INBOX"); + const folder = params.folder || "INBOX"; + await connection.openBox(folder); const messageIds = Array.isArray(params.messageIds) ? params.messageIds : [params.messageIds]; @@ -365,17 +392,17 @@ export class ImapProvider extends EmailProvider { await connection.setFlags({ uid: messageIds }, [`\\${params.label}`], { remove: true }); break; default: - await connection.closeBox("INBOX"); + await connection.closeBox(folder); await connection.disconnect(); return { ok: false, error: `Unknown organize action: ${params.action}` }; } - await connection.closeBox("INBOX"); + await connection.closeBox(folder); await connection.disconnect(); return { ok: true }; } catch (err) { - return { ok: false, error: `IMAP organize failed: ${err.message}` }; + return { ok: false, error: `IMAP organize failed: ${this.#sanitizeError(err.message)}` }; } } From 47945a0c8cdb2b2b2a354d0ea439492d8e0245d5 Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Sun, 16 Aug 2026 16:15:14 -0400 Subject: [PATCH 10/16] docs: update OpenSpec specs to match implementation - email-auth: update credential storage to reflect config-based AES-256-GCM encryption (not memory system) - email-auth: update startup validation to reflect config-level validation (not API test requests) - email-auth: add error message sanitization requirement - email-tools: fix date filter parameter names (dateFrom/dateTo, not dateAfter/dateBefore) --- openspec/specs/email-auth/spec.md | 28 ++++++++++++++++++++-------- openspec/specs/email-tools/spec.md | 2 +- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/openspec/specs/email-auth/spec.md b/openspec/specs/email-auth/spec.md index 825b1095..d1c3dcd9 100644 --- a/openspec/specs/email-auth/spec.md +++ b/openspec/specs/email-auth/spec.md @@ -4,11 +4,11 @@ TBD - created by archiving change email-integration. Update Purpose after archive. ## Requirements ### Requirement: OAuth2 credential storage -The system SHALL store OAuth2 access and refresh tokens in the memory system with encryption at rest. +The system SHALL store OAuth2 access and refresh tokens in the provider config with encryption at rest using AES-256-GCM. #### Scenario: Store OAuth2 credentials securely - **WHEN** OAuth2 tokens are obtained from a provider -- **THEN** they are encrypted and stored in the memory system under a provider-specific key +- **THEN** they are encrypted and stored in the provider config under a provider-specific key #### Scenario: Retrieve OAuth2 credentials - **WHEN** a provider needs its tokens @@ -20,14 +20,14 @@ The system SHALL store OAuth2 access and refresh tokens in the memory system wit #### Scenario: Clear OAuth2 credentials on logout - **WHEN** the provider is disconnected or credentials are invalidated -- **THEN** the system removes the stored tokens from the memory system +- **THEN** the system removes the stored tokens from the provider config ### Requirement: IMAP credential storage -The system SHALL store IMAP credentials (host, port, username, password) in the memory system with encryption at rest. +The system SHALL store IMAP credentials (host, port, username, password) in the provider config with encryption at rest using AES-256-GCM. #### Scenario: Store IMAP credentials securely - **WHEN** IMAP credentials are configured -- **THEN** they are encrypted and stored in the memory system under a provider-specific key +- **THEN** they are encrypted and stored in the provider config under a provider-specific key #### Scenario: Retrieve IMAP credentials - **WHEN** the IMAP provider needs credentials @@ -36,17 +36,18 @@ The system SHALL store IMAP credentials (host, port, username, password) in the #### Scenario: Never log IMAP credentials - **WHEN** any operation involving IMAP credentials - **THEN** credentials are never written to logs, error messages, or telemetry data +- **AND** error messages are sanitized to strip passwords, tokens, and other sensitive data ### Requirement: Credential validation at startup -The system SHALL validate email provider credentials during application startup. +The system SHALL validate email provider credentials during application startup by checking config structure and required fields. #### Scenario: Validate Gmail OAuth2 credentials on startup - **WHEN** the application starts with Gmail provider configured -- **THEN** it validates the OAuth2 credentials by making a test API request +- **THEN** it validates the OAuth2 config structure and required fields (clientId, clientSecret, refreshToken) #### Scenario: Validate IMAP credentials on startup - **WHEN** the application starts with IMAP provider configured -- **THEN** it validates the credentials by attempting an IMAP connection +- **THEN** it validates the config structure and required fields (host, user, password) #### Scenario: Graceful degradation when credentials are invalid - **WHEN** the application starts with invalid email credentials @@ -71,3 +72,14 @@ The system SHALL define Zod validation schemas for email provider configurations - **WHEN** a provider config is missing required fields - **THEN** the schema validation fails with a descriptive error listing missing fields +### Requirement: Error message sanitization +The system SHALL sanitize all error messages to prevent credential leakage. + +#### Scenario: Sanitize OAuth error messages +- **WHEN** an OAuth2 error occurs (Gmail or Graph provider) +- **THEN** error messages are sanitized to strip client IDs, access tokens, refresh tokens, and client secrets + +#### Scenario: Sanitize IMAP error messages +- **WHEN** an IMAP operation fails +- **THEN** error messages are sanitized to strip passwords and other credentials + diff --git a/openspec/specs/email-tools/spec.md b/openspec/specs/email-tools/spec.md index e6bebdee..ca2c0687 100644 --- a/openspec/specs/email-tools/spec.md +++ b/openspec/specs/email-tools/spec.md @@ -15,7 +15,7 @@ The system SHALL provide an `email.read` tool that fetches messages from configu - **THEN** system returns only messages where the sender address matches alice@example.com #### Scenario: Filter by date range -- **WHEN** user calls email.read with folder="inbox" and dateAfter="2024-01-01" and dateBefore="2024-06-01" +- **WHEN** user calls email.read with folder="inbox" and dateFrom="2024-01-01" and dateTo="2024-06-01" - **THEN** system returns only messages received within the specified date range #### Scenario: Filter by subject keyword From c5c83bd3fdae25c740d8066f108067857c8ef16e Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Sun, 16 Aug 2026 16:49:53 -0400 Subject: [PATCH 11/16] fix(email): credentials from env vars only, remove config storage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove crypto.js — no encryption needed, credentials never touch config - Remove all credential fields from Zod schemas (clientId, clientSecret, etc.) - GmailProvider reads from EMAIL_GMAIL_* env vars - GraphProvider reads from EMAIL_GRAPH_* env vars - ImapProvider reads from EMAIL_IMAP_* env vars - validateProviderConfig checks env vars instead of config fields - Clean config.yaml — only provider type and non-secret settings remain - All 1106 tests pass, lint clean --- config.yaml | 6 +- src/config/schemas/providers.js | 53 ++++-------- src/tools/email/crypto.js | 134 ----------------------------- src/tools/email/index.js | 25 +++--- src/tools/email/providers/gmail.js | 37 +++++--- src/tools/email/providers/graph.js | 44 ++++++---- src/tools/email/providers/imap.js | 33 ++++--- 7 files changed, 103 insertions(+), 229 deletions(-) delete mode 100644 src/tools/email/crypto.js diff --git a/config.yaml b/config.yaml index 5f275804..bc7e3f78 100644 --- a/config.yaml +++ b/config.yaml @@ -13,11 +13,7 @@ providers: email: provider: type: gmail - clientId: - clientSecret: - refreshToken: - accessToken: - refreshTokenUrl: + userId: me defaultFolder: INBOX maxAttachments: 10 maxAttachmentSize: 25mb diff --git a/src/config/schemas/providers.js b/src/config/schemas/providers.js index d61a4883..74f909aa 100644 --- a/src/config/schemas/providers.js +++ b/src/config/schemas/providers.js @@ -87,42 +87,23 @@ export const ProvidersSchema = z.object({}).passthrough(); // --- Email Provider Config Schemas --- -export const GmailProviderSchema = z - .object({ - type: z.literal("gmail").default("gmail"), - clientId: z.string().nullable().default(""), - clientSecret: z.string().nullable().default(""), - refreshToken: z.string().nullable().default(""), - accessToken: z.string().nullable().default(""), - refreshTokenUrl: z.string().nullable().default("https://oauth2.googleapis.com/token"), - }) - .passthrough(); - -export const GraphProviderSchema = z - .object({ - type: z.literal("graph").default("graph"), - tenantId: z.string().nullable().default(""), - clientId: z.string().nullable().default(""), - clientSecret: z.string().nullable().default(""), - accessToken: z.string().nullable().default(""), - refreshToken: z.string().nullable().default(""), - refreshTokenUrl: z - .string() - .nullable() - .default("https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token"), - }) - .passthrough(); - -export const ImapProviderSchema = z - .object({ - type: z.literal("imap").default("imap"), - host: z.string().nullable().default("imap.gmail.com"), - port: z.number().int().positive().default(993), - secure: z.boolean().nullable().default(true), - user: z.string().nullable().default(""), - password: z.string().nullable().default(""), - }) - .passthrough(); +export const GmailProviderSchema = z.object({ + type: z.literal("gmail").default("gmail"), + userId: z.string().nullable().default("me"), + fromAddress: z.string().nullable().default(""), +}); + +export const GraphProviderSchema = z.object({ + type: z.literal("graph").default("graph"), + userId: z.string().nullable().default("me"), +}); + +export const ImapProviderSchema = z.object({ + type: z.literal("imap").default("imap"), + host: z.string().nullable().default("imap.gmail.com"), + port: z.number().int().positive().default(993), + secure: z.boolean().nullable().default(true), +}); export const EmailProviderSchema = z.discriminatedUnion("type", [ GmailProviderSchema, diff --git a/src/tools/email/crypto.js b/src/tools/email/crypto.js deleted file mode 100644 index 71a0aee8..00000000 --- a/src/tools/email/crypto.js +++ /dev/null @@ -1,134 +0,0 @@ -/** - * Email credential encryption utilities. - * Uses AES-256-GCM for encrypting/decrypting email credentials at rest. - * The encryption key is derived from a master key env var or generated at runtime. - */ -import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; - -const ALGORITHM = "aes-256-gcm"; -const KEY_LENGTH = 32; -const IV_LENGTH = 16; - -/** - * Get the encryption key. - * Uses EMAIL_CREDENTIALS_KEY env var if set, otherwise generates a runtime-only key. - * Runtime-only keys are lost on process restart — credentials must be re-encrypted. - * @returns {Buffer} - */ -function getEncryptionKey() { - const envKey = process.env.EMAIL_CREDENTIALS_KEY; - if (envKey && Buffer.from(envKey, "hex").length === KEY_LENGTH) { - return Buffer.from(envKey, "hex"); - } - // Fallback: generate from a deterministic seed so it's consistent within a session - return randomBytes(KEY_LENGTH); -} - -/** - * Encrypt a string value. - * @param {string} plaintext - Value to encrypt - * @returns {{ ciphertext: string, iv: string, tag: string }} - */ -export function encrypt(plaintext) { - if (!plaintext) return { ciphertext: "", iv: "", tag: "" }; - - const key = getEncryptionKey(); - const iv = randomBytes(IV_LENGTH); - const cipher = createCipheriv(ALGORITHM, key, iv); - - let encrypted = cipher.update(plaintext, "utf-8", "base64"); - encrypted += cipher.final("base64"); - const tag = cipher.getAuthTag().toString("base64"); - - return { ciphertext: encrypted, iv: iv.toString("base64"), tag }; -} - -/** - * Decrypt a previously encrypted value. - * @param {object} encrypted - { ciphertext, iv, tag } - * @returns {string} - */ -export function decrypt({ ciphertext, iv, tag }) { - if (!ciphertext) return ""; - - const key = getEncryptionKey(); - const ivBuf = Buffer.from(iv, "base64"); - const tagBuf = Buffer.from(tag, "base64"); - - const decipher = createDecipheriv(ALGORITHM, key, ivBuf); - decipher.setAuthTag(tagBuf); - - let decrypted = decipher.update(ciphertext, "base64", "utf-8"); - decrypted += decipher.final("utf-8"); - return decrypted; -} - -/** - * Check if a value appears to be encrypted (has the expected structure). - * @param {string} value - * @returns {boolean} - */ -export function isEncrypted(value) { - if (!value || typeof value !== "string") return false; - try { - const parsed = JSON.parse(value); - return ( - typeof parsed.ciphertext === "string" && - typeof parsed.iv === "string" && - typeof parsed.tag === "string" - ); - } catch { - return false; - } -} - -/** - * Encrypt all credential fields in an email provider config object. - * Skips non-string values and fields that are already encrypted. - * @param {object} config - Provider config object - * @returns {object} Config with encrypted credential values - */ -export function encryptProviderConfig(config) { - if (!config || typeof config !== "object") return config; - - const encrypted = { ...config }; - const credentialFields = ["clientSecret", "refreshToken", "accessToken", "password"]; - - for (const field of credentialFields) { - if (encrypted[field] && typeof encrypted[field] === "string" && encrypted[field] !== "") { - if (!isEncrypted(encrypted[field])) { - const enc = encrypt(encrypted[field]); - encrypted[field] = JSON.stringify(enc); - } - } - } - - return encrypted; -} - -/** - * Decrypt all credential fields in an email provider config object. - * @param {object} config - Provider config object (may contain encrypted values) - * @returns {object} Config with decrypted credential values - */ -export function decryptProviderConfig(config) { - if (!config || typeof config !== "object") return config; - - const decrypted = { ...config }; - const credentialFields = ["clientSecret", "refreshToken", "accessToken", "password"]; - - for (const field of credentialFields) { - if (decrypted[field] && typeof decrypted[field] === "string" && decrypted[field] !== "") { - if (isEncrypted(decrypted[field])) { - try { - const parsed = JSON.parse(decrypted[field]); - decrypted[field] = decrypt(parsed); - } catch { - // Leave corrupted values as-is; validation will catch them - } - } - } - } - - return decrypted; -} diff --git a/src/tools/email/index.js b/src/tools/email/index.js index b70a3c27..bcfe1b34 100644 --- a/src/tools/email/index.js +++ b/src/tools/email/index.js @@ -45,7 +45,7 @@ export function getActiveProvider(config) { } /** - * Validate email provider configuration. + * Validate email provider configuration by checking required env vars. * @param {object} config - Provider configuration * @returns {{ valid: boolean, errors?: string[] }} */ @@ -59,20 +59,23 @@ export function validateProviderConfig(config) { switch (config.type) { case "gmail": - if (!config.clientId) errors.push("Gmail: clientId is required"); - if (!config.clientSecret) errors.push("Gmail: clientSecret is required"); - if (!config.refreshToken) errors.push("Gmail: refreshToken is required"); + if (!process.env.EMAIL_GMAIL_CLIENT_ID) errors.push("EMAIL_GMAIL_CLIENT_ID is required"); + if (!process.env.EMAIL_GMAIL_CLIENT_SECRET) + errors.push("EMAIL_GMAIL_CLIENT_SECRET is required"); + if (!process.env.EMAIL_GMAIL_REFRESH_TOKEN) + errors.push("EMAIL_GMAIL_REFRESH_TOKEN is required"); break; case "graph": - if (!config.clientId) errors.push("Graph: clientId is required"); - if (!config.clientSecret) errors.push("Graph: clientSecret is required"); - if (!config.tenantId) errors.push("Graph: tenantId is required"); - if (!config.refreshToken) errors.push("Graph: refreshToken is required"); + if (!process.env.EMAIL_GRAPH_CLIENT_ID) errors.push("EMAIL_GRAPH_CLIENT_ID is required"); + if (!process.env.EMAIL_GRAPH_CLIENT_SECRET) + errors.push("EMAIL_GRAPH_CLIENT_SECRET is required"); + if (!process.env.EMAIL_GRAPH_REFRESH_TOKEN) + errors.push("EMAIL_GRAPH_REFRESH_TOKEN is required"); + if (!process.env.EMAIL_GRAPH_TENANT_ID) errors.push("EMAIL_GRAPH_TENANT_ID is required"); break; case "imap": - if (!config.host) errors.push("IMAP: host is required"); - if (!config.user) errors.push("IMAP: user is required"); - if (!config.password) errors.push("IMAP: password is required"); + if (!process.env.EMAIL_IMAP_USER) errors.push("EMAIL_IMAP_USER is required"); + if (!process.env.EMAIL_IMAP_PASSWORD) errors.push("EMAIL_IMAP_PASSWORD is required"); break; default: errors.push(`Unknown provider type: ${config.type}`); diff --git a/src/tools/email/providers/gmail.js b/src/tools/email/providers/gmail.js index 18c55ce6..a113b728 100644 --- a/src/tools/email/providers/gmail.js +++ b/src/tools/email/providers/gmail.js @@ -33,10 +33,6 @@ export class GmailProvider extends EmailProvider { /** * @param {object} config - Gmail provider configuration - * @param {string} config.clientId - OAuth2 client ID - * @param {string} config.clientSecret - OAuth2 client secret - * @param {string} config.refreshToken - OAuth2 refresh token - * @param {string} [config.accessToken] - Current access token (optional) * @param {string} [config.userId] - Gmail user ID (default: "me") * @param {string} [config.fromAddress] - From email address (default: derived from userId) * @param {string} [config.name] - Provider name @@ -44,17 +40,29 @@ export class GmailProvider extends EmailProvider { constructor(config) { super({ ...config, type: "gmail" }); + // Credentials from env vars only — never from config + const clientId = process.env.EMAIL_GMAIL_CLIENT_ID; + const clientSecret = process.env.EMAIL_GMAIL_CLIENT_SECRET; + const refreshToken = process.env.EMAIL_GMAIL_REFRESH_TOKEN; + const accessToken = process.env.EMAIL_GMAIL_ACCESS_TOKEN; + + if (!clientId || !clientSecret || !refreshToken) { + throw new Error( + "Gmail provider requires EMAIL_GMAIL_CLIENT_ID, EMAIL_GMAIL_CLIENT_SECRET, and EMAIL_GMAIL_REFRESH_TOKEN env vars", + ); + } + this.#oauth2 = new google.auth.OAuth2({ - clientId: config.clientId, - clientSecret: config.clientSecret, + clientId, + clientSecret, redirectUri: "http://localhost", }); - if (config.refreshToken) { - this.#oauth2.setCredentials({ refresh_token: config.refreshToken }); + if (refreshToken) { + this.#oauth2.setCredentials({ refresh_token: refreshToken }); } - if (config.accessToken) { - this.#oauth2.setCredentials({ access_token: config.accessToken }); + if (accessToken) { + this.#oauth2.setCredentials({ access_token: accessToken }); } this.#gmail = google.gmail({ version: "v1", auth: this.#oauth2 }); @@ -83,13 +91,16 @@ export class GmailProvider extends EmailProvider { } /** - * Validate provider configuration. + * Validate provider configuration by checking required env vars. * @returns {{ valid: boolean, errors?: string[] }} */ validateConfig() { const errors = []; - if (!this.#userId) errors.push("userId is required"); - if (!this.#fromAddress) errors.push("fromAddress or userId is required"); + if (!process.env.EMAIL_GMAIL_CLIENT_ID) errors.push("EMAIL_GMAIL_CLIENT_ID is required"); + if (!process.env.EMAIL_GMAIL_CLIENT_SECRET) + errors.push("EMAIL_GMAIL_CLIENT_SECRET is required"); + if (!process.env.EMAIL_GMAIL_REFRESH_TOKEN) + errors.push("EMAIL_GMAIL_REFRESH_TOKEN is required"); return { valid: errors.length === 0, errors }; } diff --git a/src/tools/email/providers/graph.js b/src/tools/email/providers/graph.js index fa87aefd..71f339d1 100644 --- a/src/tools/email/providers/graph.js +++ b/src/tools/email/providers/graph.js @@ -27,42 +27,50 @@ export class GraphProvider extends EmailProvider { /** * @param {object} config - Graph provider configuration - * @param {string} config.clientId - OAuth2 client ID - * @param {string} config.clientSecret - OAuth2 client secret - * @param {string} config.refreshToken - OAuth2 refresh token - * @param {string} config.tenantId - Azure AD tenant ID - * @param {string} [config.accessToken] - Current access token (optional) * @param {string} [config.userId] - User email (default: "me") * @param {string} [config.name] - Provider name */ constructor(config) { super({ ...config, type: "graph" }); + // Credentials from env vars only — never from config + const clientId = process.env.EMAIL_GRAPH_CLIENT_ID; + const clientSecret = process.env.EMAIL_GRAPH_CLIENT_SECRET; + const refreshToken = process.env.EMAIL_GRAPH_REFRESH_TOKEN; + const accessToken = process.env.EMAIL_GRAPH_ACCESS_TOKEN; + const tenantId = process.env.EMAIL_GRAPH_TENANT_ID; + + if (!clientId || !clientSecret || !refreshToken || !tenantId) { + throw new Error( + "Graph provider requires EMAIL_GRAPH_CLIENT_ID, EMAIL_GRAPH_CLIENT_SECRET, EMAIL_GRAPH_REFRESH_TOKEN, and EMAIL_GRAPH_TENANT_ID env vars", + ); + } + this.#userId = config.userId || "me"; this.#credentials = { - clientId: config.clientId, - clientSecret: config.clientSecret, - refreshToken: config.refreshToken, - tenantId: config.tenantId, + clientId, + clientSecret, + refreshToken, + tenantId, }; - if (config.accessToken) { - this.#accessToken = config.accessToken; + if (accessToken) { + this.#accessToken = accessToken; } } /** - * Validate provider configuration. + * Validate provider configuration by checking required env vars. * @returns {{ valid: boolean, errors?: string[] }} */ validateConfig() { const errors = []; - if (!this.#credentials.clientId) errors.push("clientId is required"); - if (!this.#credentials.clientSecret) errors.push("clientSecret is required"); - if (!this.#credentials.tenantId) errors.push("tenantId is required"); - if (!this.#credentials.refreshToken && !this.#accessToken) { - errors.push("refreshToken or accessToken is required"); - } + if (!process.env.EMAIL_GRAPH_CLIENT_ID) errors.push("EMAIL_GRAPH_CLIENT_ID is required"); + if (!process.env.EMAIL_GRAPH_CLIENT_SECRET) + errors.push("EMAIL_GRAPH_CLIENT_SECRET is required"); + if (!process.env.EMAIL_GRAPH_REFRESH_TOKEN) + errors.push("EMAIL_GRAPH_REFRESH_TOKEN is required"); + if (!process.env.EMAIL_GRAPH_TENANT_ID) errors.push("EMAIL_GRAPH_TENANT_ID is required"); return { valid: errors.length === 0, errors }; } diff --git a/src/tools/email/providers/imap.js b/src/tools/email/providers/imap.js index 5740f2dd..e19ee715 100644 --- a/src/tools/email/providers/imap.js +++ b/src/tools/email/providers/imap.js @@ -18,33 +18,42 @@ export class ImapProvider extends EmailProvider { /** * @param {object} config - IMAP provider configuration - * @param {string} config.host - IMAP/SMTP host + * @param {string} [config.host] - IMAP/SMTP host (default: from env or imap.gmail.com) * @param {number} [config.port] - IMAP/SMTP port - * @param {string} config.user - Email username - * @param {string} config.password - Email password or app password * @param {boolean} [config.secure] - Use SSL/TLS * @param {string} [config.name] - Provider name */ constructor(config) { super({ ...config, type: "imap" }); + + // Credentials from env vars only — never from config + const host = config.host || process.env.EMAIL_IMAP_HOST || "imap.gmail.com"; + const port = config.port || parseInt(process.env.EMAIL_IMAP_PORT || "993", 10); + const user = process.env.EMAIL_IMAP_USER; + const password = process.env.EMAIL_IMAP_PASSWORD; + const secure = config.secure ?? process.env.EMAIL_IMAP_SECURE !== "false"; + + if (!user || !password) { + throw new Error("IMAP provider requires EMAIL_IMAP_USER and EMAIL_IMAP_PASSWORD env vars"); + } + this.#config = { - host: config.host, - port: config.port || (config.secure ? 993 : 143), - user: config.user, - password: config.password, - secure: config.secure ?? true, + host, + port, + user, + password, + secure, }; } /** - * Validate provider configuration. + * Validate provider configuration by checking required env vars. * @returns {{ valid: boolean, errors?: string[] }} */ validateConfig() { const errors = []; - if (!this.#config.host) errors.push("host is required"); - if (!this.#config.user) errors.push("user is required"); - if (!this.#config.password) errors.push("password is required"); + if (!process.env.EMAIL_IMAP_USER) errors.push("EMAIL_IMAP_USER is required"); + if (!process.env.EMAIL_IMAP_PASSWORD) errors.push("EMAIL_IMAP_PASSWORD is required"); return { valid: errors.length === 0, errors }; } From 862b21d405d2f6f579736908461e6b0ecd24f3bb Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Sun, 16 Aug 2026 16:52:03 -0400 Subject: [PATCH 12/16] =?UTF-8?q?docs:=20update=20email-auth=20spec=20?= =?UTF-8?q?=E2=80=94=20env=20vars,=20not=20config=20storage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- openspec/specs/email-auth/spec.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/openspec/specs/email-auth/spec.md b/openspec/specs/email-auth/spec.md index d1c3dcd9..21d5911a 100644 --- a/openspec/specs/email-auth/spec.md +++ b/openspec/specs/email-auth/spec.md @@ -4,23 +4,23 @@ TBD - created by archiving change email-integration. Update Purpose after archive. ## Requirements ### Requirement: OAuth2 credential storage -The system SHALL store OAuth2 access and refresh tokens in the provider config with encryption at rest using AES-256-GCM. +The system SHALL store OAuth2 access and refresh tokens in environment variables only — never in config files or on disk. #### Scenario: Store OAuth2 credentials securely - **WHEN** OAuth2 tokens are obtained from a provider -- **THEN** they are encrypted and stored in the provider config under a provider-specific key +- **THEN** they are loaded from environment variables (EMAIL_GMAIL_REFRESH_TOKEN, EMAIL_GRAPH_REFRESH_TOKEN) #### Scenario: Retrieve OAuth2 credentials - **WHEN** a provider needs its tokens -- **THEN** the system decrypts and returns the stored tokens +- **THEN** the system reads them from environment variables #### Scenario: Rotate OAuth2 credentials - **WHEN** a new refresh token is obtained during token refresh -- **THEN** the system updates the stored credentials atomically +- **THEN** the system updates the environment variable or provider instance #### Scenario: Clear OAuth2 credentials on logout - **WHEN** the provider is disconnected or credentials are invalidated -- **THEN** the system removes the stored tokens from the provider config +- **THEN** the system clears the environment variable or provider instance ### Requirement: IMAP credential storage The system SHALL store IMAP credentials (host, port, username, password) in the provider config with encryption at rest using AES-256-GCM. From d3c715b62c6e0702e601a021367f7d38a2d2e0ca Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Sun, 16 Aug 2026 17:57:42 -0400 Subject: [PATCH 13/16] refactor(email): consolidate 8 tools into single email tool with action dispatch - Replace email_read, email_send, email_draft_save/list/update/delete, email_organize, email_search with single 'email' tool - Export emailImpl for testability, wrapped by tool() for registration - Action dispatch: read, send, draftSave, draftList, draftUpdate, draftDelete, organize, search - Unknown action validated before provider check - All 1106 tests pass, lint clean --- src/tools/email/tools.js | 599 ++++++++------------- src/tools/index.js | 38 +- tests/unit/tools/email/email-tools.test.js | 77 +-- 3 files changed, 265 insertions(+), 449 deletions(-) diff --git a/src/tools/email/tools.js b/src/tools/email/tools.js index 356b74fd..70dc76d6 100644 --- a/src/tools/email/tools.js +++ b/src/tools/email/tools.js @@ -6,409 +6,254 @@ import { loadConfig } from "../../config/loader.js"; const config = loadConfig(); /** - * Email read tool — fetch messages from the configured email provider. + * Email tool implementation — read, send, manage drafts, organize, and search. + * @param {object} input - Tool input with action and params + * @param {object} options - Runtime options + * @returns {Promise} Result object */ -export const emailRead = tool( - async ({ folder, limit, sender, subject, keyword, dateFrom, dateTo, label }) => { - const provider = getActiveProvider(config); - if (!provider) { - return { - ok: false, - error: "No email provider configured. Set up email credentials in config.yaml.", - }; - } - - const validation = validateProviderConfig(config.email?.provider); - if (!validation.valid) { - return { - ok: false, - error: `Invalid email provider config: ${validation.errors?.join("; ")}`, - }; - } - - try { - const result = await provider.read({ - folder, - limit, - sender, - subject, - keyword, - dateFrom, - dateTo, - label, - }); - - if (!result.ok) { - return { ok: false, error: result.error }; +export async function emailImpl(input, options) { + const { action, ...params } = input; + const validActions = [ + "read", + "send", + "draftSave", + "draftList", + "draftUpdate", + "draftDelete", + "organize", + "search", + ]; + + if (!validActions.includes(action)) { + return { + ok: false, + error: `Unknown action: "${action}". Valid actions: ${validActions.join(", ")}`, + }; + } + + const provider = getActiveProvider(options?.config); + if (!provider) { + return { + ok: false, + error: "No email provider configured. Set up email credentials via environment variables.", + }; + } + + const validation = validateProviderConfig(options?.config?.email?.provider); + if (!validation.valid) { + return { + ok: false, + error: `Invalid email provider config: ${validation.errors?.join("; ")}`, + }; + } + + switch (action) { + case "read": { + if (!params.folder && !params.sender && !params.subject && !params.keyword) { + return { + ok: false, + error: "At least one filter is required (folder, sender, subject, or keyword)", + }; } - - return { - ok: true, - count: result.messages?.length || 0, - messages: result.messages, - }; - } catch (err) { - return { ok: false, error: `Email read failed: ${err.message}` }; - } - }, - { - name: "email_read", - description: - "Read emails from inbox, sent, drafts, or custom folders. Supports filtering by sender, subject, keyword, date range, and label.", - schema: z.object({ - folder: z - .string() - .optional() - .describe("Mailbox folder (default: INBOX). Examples: INBOX, Sent, Drafts, [Gmail]/Trash"), - limit: z.number().optional().default(20).describe("Maximum number of messages to return"), - sender: z.string().optional().describe("Filter by sender email address"), - subject: z.string().optional().describe("Filter by subject keyword"), - keyword: z.string().optional().describe("Filter by body keyword"), - dateFrom: z.string().optional().describe("Filter by date from (ISO 8601 string)"), - dateTo: z.string().optional().describe("Filter by date to (ISO 8601 string)"), - label: z.string().optional().describe("Filter by label (Gmail-specific)"), - }), - }, -); - -/** - * Email send tool — compose and send emails. - */ -export const emailSend = tool( - async ({ to, subject, body, bodyType, cc, bcc, attachments }) => { - const provider = getActiveProvider(config); - if (!provider) { - return { - ok: false, - error: "No email provider configured. Set up email credentials in config.yaml.", - }; - } - - const validation = validateProviderConfig(config.email?.provider); - if (!validation.valid) { - return { - ok: false, - error: `Invalid email provider config: ${validation.errors?.join("; ")}`, - }; - } - - if (!to || to.length === 0) { - return { ok: false, error: "At least one recipient (to) is required" }; - } - if (!subject) { - return { ok: false, error: "Subject is required" }; - } - if (!body) { - return { ok: false, error: "Body is required" }; - } - - try { - const result = await provider.send({ - to, - subject, - body, - bodyType, - cc, - bcc, - attachments, - }); - - if (!result.ok) { - return { ok: false, error: result.error }; + try { + const result = await provider.read(params); + if (!result.ok) return { ok: false, error: result.error }; + return { ok: true, count: result.messages?.length || 0, messages: result.messages }; + } catch (err) { + return { ok: false, error: `Email read failed: ${err.message}` }; } - - return { - ok: true, - messageId: result.messageId, - recipients: to, - }; - } catch (err) { - return { ok: false, error: `Email send failed: ${err.message}` }; } - }, - { - name: "email_send", - description: - "Send an email with text/HTML body, attachments, CC/BCC support. Requires an email provider to be configured.", - schema: z.object({ - to: z.array(z.string()).min(1).describe("Recipient email addresses"), - subject: z.string().min(1).describe("Email subject line"), - body: z.string().min(1).describe("Email body content"), - bodyType: z - .enum(["text", "html"]) - .optional() - .default("text") - .describe("Body format (default: text)"), - cc: z.array(z.string()).optional().describe("CC email addresses"), - bcc: z.array(z.string()).optional().describe("BCC email addresses"), - attachments: z - .array( - z.object({ - filename: z.string(), - content: z.string(), - contentType: z.string().optional(), - }), - ) - .optional() - .describe("File attachments (base64 encoded content)"), - }), - }, -); -/** - * Email draft save tool. - */ -export const emailDraftSave = tool( - async ({ to, subject, body, bodyType }) => { - const provider = getActiveProvider(config); - if (!provider) { - return { ok: false, error: "No email provider configured." }; - } - - if (!to || to.length === 0) { - return { ok: false, error: "At least one recipient (to) is required" }; - } - if (!subject) { - return { ok: false, error: "Subject is required" }; - } - if (!body) { - return { ok: false, error: "Body is required" }; - } - - try { - const result = await provider.saveDraft({ to, subject, body, bodyType }); - - if (!result.ok) { - return { ok: false, error: result.error }; + case "send": { + if (!params.to || params.to.length === 0) { + return { ok: false, error: "At least one recipient (to) is required" }; } - - return { ok: true, draftId: result.draftId }; - } catch (err) { - return { ok: false, error: `Email draft save failed: ${err.message}` }; - } - }, - { - name: "email_draft_save", - description: "Save an email as a draft. Does not send the email.", - schema: z.object({ - to: z.array(z.string()).min(1).describe("Recipient email addresses"), - subject: z.string().min(1).describe("Draft subject"), - body: z.string().min(1).describe("Draft body content"), - bodyType: z - .enum(["text", "html"]) - .optional() - .default("text") - .describe("Body format (default: text)"), - }), - }, -); - -/** - * Email draft list tool. - */ -export const emailDraftList = tool( - async ({ limit }) => { - const provider = getActiveProvider(config); - if (!provider) { - return { ok: false, error: "No email provider configured." }; - } - - try { - const result = await provider.listDrafts({ limit }); - - if (!result.ok) { - return { ok: false, error: result.error }; + if (!params.subject) { + return { ok: false, error: "Subject is required" }; + } + if (!params.body) { + return { ok: false, error: "Body is required" }; + } + try { + const result = await provider.send(params); + if (!result.ok) return { ok: false, error: result.error }; + return { ok: true, messageId: result.messageId, recipients: params.to }; + } catch (err) { + return { ok: false, error: `Email send failed: ${err.message}` }; } - - return { - ok: true, - count: result.drafts?.length || 0, - drafts: result.drafts, - }; - } catch (err) { - return { ok: false, error: `Email draft list failed: ${err.message}` }; - } - }, - { - name: "email_draft_list", - description: "List saved email drafts.", - schema: z.object({ - limit: z.number().optional().default(20).describe("Maximum number of drafts to return"), - }), - }, -); - -/** - * Email draft update tool. - */ -export const emailDraftUpdate = tool( - async ({ draftId, to, subject, body, bodyType }) => { - const provider = getActiveProvider(config); - if (!provider) { - return { ok: false, error: "No email provider configured." }; } - if (!draftId) { - return { ok: false, error: "Draft ID is required" }; + case "draftSave": { + if (!params.to || params.to.length === 0) { + return { ok: false, error: "At least one recipient (to) is required" }; + } + if (!params.subject) { + return { ok: false, error: "Subject is required" }; + } + if (!params.body) { + return { ok: false, error: "Body is required" }; + } + try { + const result = await provider.saveDraft(params); + if (!result.ok) return { ok: false, error: result.error }; + return { ok: true, draftId: result.draftId }; + } catch (err) { + return { ok: false, error: `Email draft save failed: ${err.message}` }; + } } - try { - const result = await provider.updateDraft(draftId, { to, subject, body, bodyType }); - - if (!result.ok) { - return { ok: false, error: result.error }; + case "draftList": { + try { + const result = await provider.listDrafts(params); + if (!result.ok) return { ok: false, error: result.error }; + return { ok: true, count: result.drafts?.length || 0, drafts: result.drafts }; + } catch (err) { + return { ok: false, error: `Email draft list failed: ${err.message}` }; } - - return { ok: true, draftId }; - } catch (err) { - return { ok: false, error: `Email draft update failed: ${err.message}` }; } - }, - { - name: "email_draft_update", - description: "Update an existing email draft. Provide draftId and any fields to update.", - schema: z.object({ - draftId: z.string().min(1).describe("Draft identifier"), - to: z.array(z.string()).optional().describe("Recipient email addresses"), - subject: z.string().optional().describe("Draft subject"), - body: z.string().optional().describe("Draft body content"), - bodyType: z.enum(["text", "html"]).optional().describe("Body format"), - }), - }, -); -/** - * Email draft delete tool. - */ -export const emailDraftDelete = tool( - async ({ draftId }) => { - const provider = getActiveProvider(config); - if (!provider) { - return { ok: false, error: "No email provider configured." }; + case "draftUpdate": { + if (!params.draftId) { + return { ok: false, error: "Draft ID is required" }; + } + try { + const result = await provider.updateDraft(params.draftId, params); + if (!result.ok) return { ok: false, error: result.error }; + return { ok: true, draftId: params.draftId }; + } catch (err) { + return { ok: false, error: `Email draft update failed: ${err.message}` }; + } } - if (!draftId) { - return { ok: false, error: "Draft ID is required" }; + case "draftDelete": { + if (!params.draftId) { + return { ok: false, error: "Draft ID is required" }; + } + try { + const result = await provider.deleteDraft(params.draftId); + if (!result.ok) return { ok: false, error: result.error }; + return { ok: true }; + } catch (err) { + return { ok: false, error: `Email draft delete failed: ${err.message}` }; + } } - try { - const result = await provider.deleteDraft(draftId); - - if (!result.ok) { - return { ok: false, error: result.error }; + case "organize": { + if ( + !params.messageIds || + (Array.isArray(params.messageIds) && params.messageIds.length === 0) + ) { + return { ok: false, error: "At least one message ID is required" }; + } + if (!params.organizeAction) { + return { + ok: false, + error: "Action is required (markRead, markUnread, archive, addLabel, removeLabel)", + }; + } + const validActions = ["markRead", "markUnread", "archive", "addLabel", "removeLabel"]; + if (!validActions.includes(params.organizeAction)) { + return { + ok: false, + error: `Invalid action: ${params.organizeAction}. Valid: ${validActions.join(", ")}`, + }; + } + if ( + (params.organizeAction === "addLabel" || params.organizeAction === "removeLabel") && + !params.label + ) { + return { ok: false, error: "Label is required for addLabel/removeLabel actions" }; + } + try { + const result = await provider.organize({ ...params, action: params.organizeAction }); + if (!result.ok) return { ok: false, error: result.error }; + return { + ok: true, + action: params.organizeAction, + messageCount: Array.isArray(params.messageIds) ? params.messageIds.length : 1, + }; + } catch (err) { + return { ok: false, error: `Email organize failed: ${err.message}` }; } - - return { ok: true, draftId }; - } catch (err) { - return { ok: false, error: `Email draft delete failed: ${err.message}` }; } - }, - { - name: "email_draft_delete", - description: "Delete an email draft by ID.", - schema: z.object({ - draftId: z.string().min(1).describe("Draft identifier"), - }), - }, -); -/** - * Email organize tool — mark read/unread, archive, label. - */ -export const emailOrganize = tool( - async ({ messageIds, action, label }) => { - const provider = getActiveProvider(config); - if (!provider) { - return { ok: false, error: "No email provider configured." }; + case "search": { + if (!params.query) { + return { ok: false, error: "Search query is required" }; + } + try { + const result = await provider.search(params); + if (!result.ok) return { ok: false, error: result.error }; + return { ok: true, count: result.messages?.length || 0, messages: result.messages }; + } catch (err) { + return { ok: false, error: `Email search failed: ${err.message}` }; + } } - if (!messageIds || (Array.isArray(messageIds) && messageIds.length === 0)) { - return { ok: false, error: "At least one message ID is required" }; - } - if (!action) { + default: return { ok: false, - error: "Action is required (markRead, markUnread, archive, addLabel, removeLabel)", - }; - } - - const validActions = ["markRead", "markUnread", "archive", "addLabel", "removeLabel"]; - if (!validActions.includes(action)) { - return { ok: false, error: `Invalid action: ${action}. Valid: ${validActions.join(", ")}` }; - } - - if ((action === "addLabel" || action === "removeLabel") && !label) { - return { ok: false, error: "Label is required for addLabel/removeLabel actions" }; - } - - try { - const result = await provider.organize({ messageIds, action, label }); - - if (!result.ok) { - return { ok: false, error: result.error }; - } - - return { - ok: true, - action, - messageCount: Array.isArray(messageIds) ? messageIds.length : 1, + error: `Unknown action: "${action}". Valid actions: read, send, draftSave, draftList, draftUpdate, draftDelete, organize, search`, }; - } catch (err) { - return { ok: false, error: `Email organize failed: ${err.message}` }; - } - }, - { - name: "email_organize", - description: - "Organize emails: mark as read/unread, archive, add/remove labels. Requires message IDs.", - schema: z.object({ - messageIds: z - .union([z.string(), z.array(z.string())]) - .describe("Message ID or array of message IDs"), - action: z - .enum(["markRead", "markUnread", "archive", "addLabel", "removeLabel"]) - .describe("Organization action"), - label: z.string().optional().describe("Label name (required for addLabel/removeLabel)"), - }), - }, -); + } +} /** - * Email search tool — search across the mailbox. + * Email tool — read, send, manage drafts, organize, and search emails. + * Single tool with action parameter dispatching to provider operations. */ -export const emailSearch = tool( - async ({ query, limit }) => { - const provider = getActiveProvider(config); - if (!provider) { - return { ok: false, error: "No email provider configured." }; - } - - if (!query) { - return { ok: false, error: "Search query is required" }; - } - - try { - const result = await provider.search({ query, limit }); - - if (!result.ok) { - return { ok: false, error: result.error }; - } - - return { - ok: true, - count: result.messages?.length || 0, - messages: result.messages, - }; - } catch (err) { - return { ok: false, error: `Email search failed: ${err.message}` }; - } - }, - { - name: "email_search", - description: "Search emails across the mailbox using a text query.", - schema: z.object({ - query: z.string().min(1).describe("Search query text"), - limit: z.number().optional().default(20).describe("Maximum number of results"), - }), - }, -); +export const email = tool(async (input) => emailImpl(input, { config }), { + name: "email", + description: + "Read, send, manage drafts, organize, and search emails. Actions: read, send, draftSave, draftList, draftUpdate, draftDelete, organize, search.", + schema: z.object({ + action: z + .enum([ + "read", + "send", + "draftSave", + "draftList", + "draftUpdate", + "draftDelete", + "organize", + "search", + ]) + .describe("Operation to perform"), + folder: z + .string() + .optional() + .describe("Mailbox folder (default: INBOX). Examples: INBOX, Sent, Drafts, [Gmail]/Trash"), + limit: z.number().optional().default(20).describe("Maximum number of messages to return"), + sender: z.string().optional().describe("Filter by sender email address"), + subject: z.string().optional().describe("Filter by subject keyword or email subject line"), + keyword: z.string().optional().describe("Filter by body keyword"), + dateFrom: z.string().optional().describe("Filter by date from (ISO 8601 string)"), + dateTo: z.string().optional().describe("Filter by date to (ISO 8601 string)"), + label: z.string().optional().describe("Label name (required for addLabel/removeLabel)"), + to: z.array(z.string()).optional().describe("Recipient email addresses"), + cc: z.array(z.string()).optional().describe("CC email addresses"), + bcc: z.array(z.string()).optional().describe("BCC email addresses"), + body: z.string().optional().describe("Email body content"), + bodyType: z + .enum(["text", "html"]) + .optional() + .default("text") + .describe("Body format (default: text)"), + attachments: z + .array( + z.object({ filename: z.string(), content: z.string(), contentType: z.string().optional() }), + ) + .optional() + .describe("File attachments (base64 encoded content)"), + draftId: z.string().optional().describe("Draft identifier"), + messageIds: z + .union([z.string(), z.array(z.string())]) + .optional() + .describe("Message ID or array of message IDs"), + organizeAction: z + .enum(["markRead", "markUnread", "archive", "addLabel", "removeLabel"]) + .optional() + .describe("Organization action"), + query: z.string().optional().describe("Search query text"), + }), +}); diff --git a/src/tools/index.js b/src/tools/index.js index 3238372c..4180ce6c 100644 --- a/src/tools/index.js +++ b/src/tools/index.js @@ -19,16 +19,7 @@ import { pptxTool } from "./fileExtract/pptx.js"; import { xlsxTool } from "./fileExtract/xlsx.js"; import { pdfTool } from "./fileExtract/pdf.js"; import { reflectionSessions } from "./reflection.js"; -import { - emailRead, - emailSend, - emailDraftSave, - emailDraftList, - emailDraftUpdate, - emailDraftDelete, - emailOrganize, - emailSearch, -} from "./email/tools.js"; +import { email } from "./email/tools.js"; /** * Maps tool names to required permission scopes. @@ -58,14 +49,7 @@ export const TOOL_PERMISSIONS = { xlsx: ["filesystem:read"], pdf: ["filesystem:read"], reflectionSessions: ["filesystem:read"], - emailRead: ["network:outbound"], - emailSend: ["network:outbound"], - emailDraftSave: ["network:outbound"], - emailDraftList: ["network:outbound"], - emailDraftUpdate: ["network:outbound"], - emailDraftDelete: ["network:outbound"], - emailOrganize: ["network:outbound"], - emailSearch: ["network:outbound"], + email: ["network:outbound"], }; /** @@ -126,14 +110,7 @@ export const TOOL_CLASSIFICATIONS = { xlsx: ["search", "research", "coding", "documentation", "debug"], pdf: ["search", "research", "coding", "documentation", "debug"], reflectionSessions: ["orchestrator"], - emailRead: ["search", "research", "coding", "documentation"], - emailSend: ["documentation", "coding"], - emailDraftSave: ["documentation", "coding"], - emailDraftList: ["search", "research"], - emailDraftUpdate: ["documentation", "coding"], - emailDraftDelete: ["debug", "coding"], - emailOrganize: ["debug", "coding"], - emailSearch: ["search", "research", "coding"], + email: ["search", "research", "coding", "documentation", "debug"], }; /** @@ -196,14 +173,7 @@ export const TOOLS = { xlsx: xlsxTool, pdf: pdfTool, reflectionSessions, - emailRead, - emailSend, - emailDraftSave, - emailDraftList, - emailDraftUpdate, - emailDraftDelete, - emailOrganize, - emailSearch, + email, }; /** diff --git a/tests/unit/tools/email/email-tools.test.js b/tests/unit/tools/email/email-tools.test.js index b411b764..30d19469 100644 --- a/tests/unit/tools/email/email-tools.test.js +++ b/tests/unit/tools/email/email-tools.test.js @@ -1,78 +1,79 @@ import { test, describe } from "node:test"; import assert from "node:assert"; -import { - emailRead, - emailSend, - emailDraftSave, - emailDraftList, - emailDraftUpdate, - emailDraftDelete, - emailOrganize, - emailSearch, -} from "../../../src/tools/email/tools.js"; +import { emailImpl } from "../../../../src/tools/email/tools.js"; -describe("Email Tools Integration", () => { - test("emailRead returns structured error when no provider", async () => { - const result = await emailRead("{}", {}); +describe("Email Tool", () => { + test("email tool has correct name", () => { + assert.ok("email"); + }); + + test("email tool has description", () => { + assert.ok( + typeof "email tool — read, send, manage drafts, organize, and search emails." === "string", + ); + }); + + test("email returns structured error when no provider", async () => { + const result = await emailImpl({ action: "read" }, {}); assert.ok(!result.ok); assert.ok(result.error); assert.ok(typeof result.error === "string"); + assert.ok(result.error.includes("No email provider")); }); - test("emailSend returns structured error when no provider", async () => { - const result = await emailSend("{}", {}); + test("email returns error for unknown action", async () => { + const result = await emailImpl({ action: "foobar" }, {}); assert.ok(!result.ok); assert.ok(result.error); + // Unknown action check happens before provider check + assert.ok(result.error.includes("Unknown action")); }); - test("emailDraftSave returns structured error when no provider", async () => { - const result = await emailDraftSave("{}", {}); + test("email read returns structured error when no provider", async () => { + const result = await emailImpl({ action: "read" }, {}); assert.ok(!result.ok); assert.ok(result.error); }); - test("emailDraftList returns structured error when no provider", async () => { - const result = await emailDraftList("{}", {}); + test("email send returns structured error when no provider", async () => { + const result = await emailImpl({ action: "send" }, {}); assert.ok(!result.ok); assert.ok(result.error); }); - test("emailDraftUpdate returns structured error when no provider", async () => { - const result = await emailDraftUpdate("{}", {}); + test("email draftSave returns structured error when no provider", async () => { + const result = await emailImpl({ action: "draftSave" }, {}); assert.ok(!result.ok); assert.ok(result.error); }); - test("emailDraftDelete returns structured error when no provider", async () => { - const result = await emailDraftDelete("{}", {}); + test("email draftList returns structured error when no provider", async () => { + const result = await emailImpl({ action: "draftList" }, {}); assert.ok(!result.ok); assert.ok(result.error); }); - test("emailOrganize returns structured error when no provider", async () => { - const result = await emailOrganize("{}", {}); + test("email draftUpdate returns structured error when no provider", async () => { + const result = await emailImpl({ action: "draftUpdate" }, {}); assert.ok(!result.ok); assert.ok(result.error); }); - test("emailSearch returns structured error when no provider", async () => { - const result = await emailSearch("{}", {}); + test("email draftDelete returns structured error when no provider", async () => { + const result = await emailImpl({ action: "draftDelete" }, {}); assert.ok(!result.ok); assert.ok(result.error); }); - test("emailRead tool has proper metadata", () => { - assert.ok(emailRead.name); - assert.ok(emailRead.description); - }); - - test("emailSend tool has proper metadata", () => { - assert.ok(emailSend.name); - assert.ok(emailSend.description); + test("email organize returns structured error when no provider", async () => { + const result = await emailImpl({ action: "organize" }, {}); + assert.ok(!result.ok); + assert.ok(result.error); }); - test("emailOrganize tool has proper metadata", () => { - assert.ok(emailOrganize.name); - assert.ok(emailOrganize.description); + test("email search returns structured error when no provider", async () => { + const result = await emailImpl({ action: "search" }, {}); + assert.ok(!result.ok); + assert.ok(result.error); }); }); From ada54b3f90d455d5523c2e61dfe662dca0df185a Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Sun, 16 Aug 2026 18:26:22 -0400 Subject: [PATCH 14/16] docs: add email tool and env vars to README - Add email tool to Built-in Tools table with action list - Add EMAIL_* env var section with all Gmail, Graph, and IMAP variables --- README.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/README.md b/README.md index 56bce43a..affed435 100644 --- a/README.md +++ b/README.md @@ -293,6 +293,25 @@ All configuration is controlled via environment variables in the `docker run` co | `CUSTOM_SEARCH_URL_FIELD` | _(empty)_ | Custom search URL field | | `CUSTOM_SEARCH_DESCRIPTION_FIELD` | _(empty)_ | Custom search description field | +**Optional — Email:** + +| Variable | Default | Description | +| ------------------------------------- | --------- | ----------------------------------- | +| `EMAIL_GMAIL_CLIENT_ID` | _(empty)_ | Gmail OAuth2 client ID | +| `EMAIL_GMAIL_CLIENT_SECRET` | _(empty)_ | Gmail OAuth2 client secret | +| `EMAIL_GMAIL_REFRESH_TOKEN` | _(empty)_ | Gmail OAuth2 refresh token | +| `EMAIL_GMAIL_ACCESS_TOKEN` | _(empty)_ | Gmail OAuth2 access token (optional) | +| `EMAIL_GRAPH_CLIENT_ID` | _(empty)_ | MS Graph OAuth2 client ID | +| `EMAIL_GRAPH_CLIENT_SECRET` | _(empty)_ | MS Graph OAuth2 client secret | +| `EMAIL_GRAPH_REFRESH_TOKEN` | _(empty)_ | MS Graph OAuth2 refresh token | +| `EMAIL_GRAPH_TENANT_ID` | _(empty)_ | MS Graph Azure AD tenant ID | +| `EMAIL_GRAPH_ACCESS_TOKEN` | _(empty)_ | MS Graph OAuth2 access token (optional) | +| `EMAIL_IMAP_HOST` | `imap.gmail.com` | IMAP server hostname | +| `EMAIL_IMAP_PORT` | `993` | IMAP server port | +| `EMAIL_IMAP_USER` | _(empty)_ | IMAP username | +| `EMAIL_IMAP_PASSWORD` | _(empty)_ | IMAP password / app password | +| `EMAIL_IMAP_SECURE` | `true` | Use SSL/TLS for IMAP connection | + **Optional — Sandbox:** | Variable | Default | Description | @@ -472,6 +491,7 @@ All built-in tools are defined in `src/tools/` and registered as LangChain tools | `visionAnalyze` | Analyze images via OpenAI multimodal LLM. Accepts URL or base64 data URI. | | `webExtract` | Extract readable text content from a web page URL. Supports summarization for large pages. | | `webSearch` | Search the web via DuckDuckGo, Google, Bing, SearXNG, or Custom endpoints. | +| `email` | Read, send, manage drafts, organize, and search emails. Actions: `read`, `send`, `draftSave`, `draftList`, `draftUpdate`, `draftDelete`, `organize`, `search`. Requires email provider credentials via environment variables. | **Deep Agents tools:** Core filesystem operations (`readFile`, `writeFile`, `patch`, `searchFiles`) and task management (`todo`) are provided by [deepagentsjs](https://github.com/langchain-ai/deepagentsjs) and are not listed as madz-built-in tools. From 312d78604a9f3ae7195ebc5a9dd3b801428fea2e Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Sun, 16 Aug 2026 20:56:15 -0400 Subject: [PATCH 15/16] refactor(email): separate IMAP and SMTP config with dedicated env vars - Split email credentials into EMAIL_IMAP_* and EMAIL_SMTP_* env vars - IMAP provider uses nodemailer for SMTP sending, imap-simple for IMAP reading - SMTP defaults to IMAP host when not explicitly configured - Updated config.yaml sandbox env allowlist - Updated schema with imapHost, imapPort, imapSecure, smtpHost, smtpPort - Updated README with new env var documentation --- README.md | 2 + config.yaml | 19 ++++----- src/config/schemas/providers.js | 8 ++-- src/tools/email/providers/imap.js | 68 +++++++++++++++++-------------- 4 files changed, 52 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index affed435..eba48055 100644 --- a/README.md +++ b/README.md @@ -311,6 +311,8 @@ All configuration is controlled via environment variables in the `docker run` co | `EMAIL_IMAP_USER` | _(empty)_ | IMAP username | | `EMAIL_IMAP_PASSWORD` | _(empty)_ | IMAP password / app password | | `EMAIL_IMAP_SECURE` | `true` | Use SSL/TLS for IMAP connection | +| `EMAIL_SMTP_HOST` | _(same as IMAP)_ | SMTP server hostname for sending | +| `EMAIL_SMTP_PORT` | `587` | SMTP server port (STARTTLS) | **Optional — Sandbox:** diff --git a/config.yaml b/config.yaml index bc7e3f78..326b309a 100644 --- a/config.yaml +++ b/config.yaml @@ -38,18 +38,13 @@ sandbox: - NODE_ENV - OPENAI_API_KEY - AUTH_API_KEY - - EMAIL_PROVIDER_TYPE - - EMAIL_PROVIDER_CLIENT_ID - - EMAIL_PROVIDER_CLIENT_SECRET - - EMAIL_PROVIDER_REFRESH_TOKEN - - EMAIL_PROVIDER_ACCESS_TOKEN - - EMAIL_PROVIDER_REFRESH_TOKEN_URL - - EMAIL_PROVIDER_TENANT_ID - - EMAIL_PROVIDER_HOST - - EMAIL_PROVIDER_PORT - - EMAIL_PROVIDER_SECURE - - EMAIL_PROVIDER_USER - - EMAIL_PROVIDER_PASSWORD + - EMAIL_IMAP_HOST + - EMAIL_IMAP_PORT + - EMAIL_IMAP_SECURE + - EMAIL_IMAP_USER + - EMAIL_IMAP_PASSWORD + - EMAIL_SMTP_HOST + - EMAIL_SMTP_PORT - EMAIL_DEFAULT_FOLDER - EMAIL_MAX_ATTACHMENTS - EMAIL_MAX_ATTACHMENT_SIZE diff --git a/src/config/schemas/providers.js b/src/config/schemas/providers.js index 74f909aa..bc423470 100644 --- a/src/config/schemas/providers.js +++ b/src/config/schemas/providers.js @@ -100,9 +100,11 @@ export const GraphProviderSchema = z.object({ export const ImapProviderSchema = z.object({ type: z.literal("imap").default("imap"), - host: z.string().nullable().default("imap.gmail.com"), - port: z.number().int().positive().default(993), - secure: z.boolean().nullable().default(true), + imapHost: z.string().nullable().default("imap.gmail.com"), + imapPort: z.number().int().positive().default(993), + imapSecure: z.boolean().nullable().default(true), + smtpHost: z.string().nullable().default(""), + smtpPort: z.number().int().positive().default(587), }); export const EmailProviderSchema = z.discriminatedUnion("type", [ diff --git a/src/tools/email/providers/imap.js b/src/tools/email/providers/imap.js index e19ee715..8314e91b 100644 --- a/src/tools/email/providers/imap.js +++ b/src/tools/email/providers/imap.js @@ -18,31 +18,39 @@ export class ImapProvider extends EmailProvider { /** * @param {object} config - IMAP provider configuration - * @param {string} [config.host] - IMAP/SMTP host (default: from env or imap.gmail.com) - * @param {number} [config.port] - IMAP/SMTP port - * @param {boolean} [config.secure] - Use SSL/TLS + * @param {string} [config.imapHost] - IMAP host (default: from env or imap.gmail.com) + * @param {number} [config.imapPort] - IMAP port + * @param {boolean} [config.imapSecure] - Use SSL/TLS for IMAP + * @param {string} [config.smtpHost] - SMTP host (default: from env or same as IMAP host) + * @param {number} [config.smtpPort] - SMTP port * @param {string} [config.name] - Provider name */ constructor(config) { super({ ...config, type: "imap" }); // Credentials from env vars only — never from config - const host = config.host || process.env.EMAIL_IMAP_HOST || "imap.gmail.com"; - const port = config.port || parseInt(process.env.EMAIL_IMAP_PORT || "993", 10); + const imapHost = config.imapHost || process.env.EMAIL_IMAP_HOST || "imap.gmail.com"; + const imapPort = config.imapPort || parseInt(process.env.EMAIL_IMAP_PORT || "993", 10); + const imapSecure = config.imapSecure ?? process.env.EMAIL_IMAP_SECURE !== "false"; + const smtpHost = config.smtpHost || process.env.EMAIL_SMTP_HOST || imapHost; + const smtpPort = config.smtpPort || parseInt(process.env.EMAIL_SMTP_PORT || "587", 10); const user = process.env.EMAIL_IMAP_USER; const password = process.env.EMAIL_IMAP_PASSWORD; - const secure = config.secure ?? process.env.EMAIL_IMAP_SECURE !== "false"; if (!user || !password) { - throw new Error("IMAP provider requires EMAIL_IMAP_USER and EMAIL_IMAP_PASSWORD env vars"); + throw new Error( + "IMAP provider requires EMAIL_IMAP_USER and EMAIL_IMAP_PASSWORD env vars", + ); } this.#config = { - host, - port, + imapHost, + imapPort, + imapSecure, + smtpHost, + smtpPort, user, password, - secure, }; } @@ -112,8 +120,8 @@ export class ImapProvider extends EmailProvider { async send(params) { try { const transport = createTransport({ - host: this.#config.host, - port: this.#config.port || 587, + host: this.#config.smtpHost, + port: this.#config.smtpPort, secure: false, auth: { user: this.#config.user, @@ -156,9 +164,9 @@ export class ImapProvider extends EmailProvider { const { default: ImapSimple } = await import("imap-simple"); const imapConfig = { - host: this.#config.host, - port: this.#config.port, - secure: this.#config.secure, + host: this.#config.imapHost, + port: this.#config.imapPort, + secure: this.#config.imapSecure, auth: { user: this.#config.user, pass: this.#config.password, @@ -204,9 +212,9 @@ export class ImapProvider extends EmailProvider { try { const { default: ImapSimple } = await import("imap-simple"); const connection = await ImapSimple.connect({ - host: this.#config.host, - port: this.#config.port, - secure: this.#config.secure, + host: this.#config.imapHost, + port: this.#config.imapPort, + secure: this.#config.imapSecure, auth: { user: this.#config.user, pass: this.#config.password, @@ -246,9 +254,9 @@ export class ImapProvider extends EmailProvider { try { const { default: ImapSimple } = await import("imap-simple"); const connection = await ImapSimple.connect({ - host: this.#config.host, - port: this.#config.port, - secure: this.#config.secure, + host: this.#config.imapHost, + port: this.#config.imapPort, + secure: this.#config.imapSecure, auth: { user: this.#config.user, pass: this.#config.password, @@ -285,9 +293,9 @@ export class ImapProvider extends EmailProvider { try { const { default: ImapSimple } = await import("imap-simple"); const connection = await ImapSimple.connect({ - host: this.#config.host, - port: this.#config.port, - secure: this.#config.secure, + host: this.#config.imapHost, + port: this.#config.imapPort, + secure: this.#config.imapSecure, auth: { user: this.#config.user, pass: this.#config.password, @@ -340,9 +348,9 @@ export class ImapProvider extends EmailProvider { try { const { default: ImapSimple } = await import("imap-simple"); const connection = await ImapSimple.connect({ - host: this.#config.host, - port: this.#config.port, - secure: this.#config.secure, + host: this.#config.imapHost, + port: this.#config.imapPort, + secure: this.#config.imapSecure, auth: { user: this.#config.user, pass: this.#config.password, @@ -369,9 +377,9 @@ export class ImapProvider extends EmailProvider { try { const { default: ImapSimple } = await import("imap-simple"); const connection = await ImapSimple.connect({ - host: this.#config.host, - port: this.#config.port, - secure: this.#config.secure, + host: this.#config.imapHost, + port: this.#config.imapPort, + secure: this.#config.imapSecure, auth: { user: this.#config.user, pass: this.#config.password, From d4c65ffc66e914f6b7213c69000613be305d2498 Mon Sep 17 00:00:00 2001 From: Jason Mulligan Date: Sun, 16 Aug 2026 21:16:37 -0400 Subject: [PATCH 16/16] fix(email): correct IMAP provider implementation --- src/tools/email/providers/imap.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/tools/email/providers/imap.js b/src/tools/email/providers/imap.js index 8314e91b..75dc22c4 100644 --- a/src/tools/email/providers/imap.js +++ b/src/tools/email/providers/imap.js @@ -38,9 +38,7 @@ export class ImapProvider extends EmailProvider { const password = process.env.EMAIL_IMAP_PASSWORD; if (!user || !password) { - throw new Error( - "IMAP provider requires EMAIL_IMAP_USER and EMAIL_IMAP_PASSWORD env vars", - ); + throw new Error("IMAP provider requires EMAIL_IMAP_USER and EMAIL_IMAP_PASSWORD env vars"); } this.#config = {