diff --git a/README.md b/README.md
index 56bce43a..eba48055 100644
--- a/README.md
+++ b/README.md
@@ -293,6 +293,27 @@ 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 |
+| `EMAIL_SMTP_HOST` | _(same as IMAP)_ | SMTP server hostname for sending |
+| `EMAIL_SMTP_PORT` | `587` | SMTP server port (STARTTLS) |
+
**Optional — Sandbox:**
| Variable | Default | Description |
@@ -472,6 +493,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.
diff --git a/config.yaml b/config.yaml
index 31299f25..326b309a 100644
--- a/config.yaml
+++ b/config.yaml
@@ -10,6 +10,13 @@ providers:
maxTokens: 4096
rateLimit:
requestsPerMinute: 120
+email:
+ provider:
+ type: gmail
+ userId: me
+ defaultFolder: INBOX
+ maxAttachments: 10
+ maxAttachmentSize: 25mb
sandbox:
paths:
- "./"
@@ -31,6 +38,16 @@ sandbox:
- NODE_ENV
- OPENAI_API_KEY
- AUTH_API_KEY
+ - 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
permissions:
- filesystem:read
- filesystem:write
diff --git a/openspec/changes/archive/2026-08-16-email-integration/.openspec.yaml b/openspec/changes/archive/2026-08-16-email-integration/.openspec.yaml
new file mode 100644
index 00000000..f161d5cc
--- /dev/null
+++ b/openspec/changes/archive/2026-08-16-email-integration/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-08-16
diff --git a/openspec/changes/archive/2026-08-16-email-integration/design.md b/openspec/changes/archive/2026-08-16-email-integration/design.md
new file mode 100644
index 00000000..af92ec97
--- /dev/null
+++ b/openspec/changes/archive/2026-08-16-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/archive/2026-08-16-email-integration/proposal.md b/openspec/changes/archive/2026-08-16-email-integration/proposal.md
new file mode 100644
index 00000000..5534c6ca
--- /dev/null
+++ b/openspec/changes/archive/2026-08-16-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/archive/2026-08-16-email-integration/specs/email-auth/spec.md b/openspec/changes/archive/2026-08-16-email-integration/specs/email-auth/spec.md
new file mode 100644
index 00000000..3b40d5da
--- /dev/null
+++ b/openspec/changes/archive/2026-08-16-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/archive/2026-08-16-email-integration/specs/email-providers/spec.md b/openspec/changes/archive/2026-08-16-email-integration/specs/email-providers/spec.md
new file mode 100644
index 00000000..ae3265d0
--- /dev/null
+++ b/openspec/changes/archive/2026-08-16-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/archive/2026-08-16-email-integration/specs/email-tools/spec.md b/openspec/changes/archive/2026-08-16-email-integration/specs/email-tools/spec.md
new file mode 100644
index 00000000..f4345c8c
--- /dev/null
+++ b/openspec/changes/archive/2026-08-16-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/archive/2026-08-16-email-integration/tasks.md b/openspec/changes/archive/2026-08-16-email-integration/tasks.md
new file mode 100644
index 00000000..19ccbe32
--- /dev/null
+++ b/openspec/changes/archive/2026-08-16-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
diff --git a/openspec/specs/email-auth/spec.md b/openspec/specs/email-auth/spec.md
new file mode 100644
index 00000000..21d5911a
--- /dev/null
+++ b/openspec/specs/email-auth/spec.md
@@ -0,0 +1,85 @@
+# 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 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 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 reads them from environment variables
+
+#### Scenario: Rotate OAuth2 credentials
+- **WHEN** a new refresh token is obtained during token refresh
+- **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 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.
+
+#### Scenario: Store IMAP credentials securely
+- **WHEN** IMAP credentials are configured
+- **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
+- **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
+- **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 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 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 config structure and required fields (host, user, password)
+
+#### 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
+
+### 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-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..ca2c0687
--- /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 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
+- **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
+
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/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/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 6f7295d1..bc423470 100644
--- a/src/config/schemas/providers.js
+++ b/src/config/schemas/providers.js
@@ -84,3 +84,38 @@ 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"),
+ 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"),
+ 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", [
+ GmailProviderSchema,
+ GraphProviderSchema,
+ ImapProviderSchema,
+]);
+
+export const EmailConfigSchema = z.object({
+ provider: EmailProviderSchema,
+ defaultFolder: z.string().nullable().default("INBOX"),
+ maxAttachments: z.number().int().positive().default(10),
+ maxAttachmentSize: z.string().nullable().default("25mb"),
+});
diff --git a/src/tools/email/index.js b/src/tools/email/index.js
new file mode 100644
index 00000000..bcfe1b34
--- /dev/null
+++ b/src/tools/email/index.js
@@ -0,0 +1,90 @@
+import { GmailProvider } from "./providers/gmail.js";
+import { GraphProvider } from "./providers/graph.js";
+import { ImapProvider } from "./providers/imap.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 {import("./providers/base.js").default}
+ */
+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 by checking required env vars.
+ * @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 (!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 (!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 (!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}`);
+ }
+
+ 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";
diff --git a/src/tools/email/providers/base.js b/src/tools/email/providers/base.js
new file mode 100644
index 00000000..191ce74c
--- /dev/null
+++ b/src/tools/email/providers/base.js
@@ -0,0 +1,138 @@
+/**
+ * Abstract email provider interface.
+ * All concrete providers (Gmail, Graph, IMAP) extend this.
+ */
+export class EmailProvider {
+ /**
+ * @type {string}
+ */
+ name;
+
+ /**
+ * @type {string}
+ */
+ 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
+ }
+
+ /**
+ * 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 };
+ }
+}
diff --git a/src/tools/email/providers/gmail.js b/src/tools/email/providers/gmail.js
new file mode 100644
index 00000000..a113b728
--- /dev/null
+++ b/src/tools/email/providers/gmail.js
@@ -0,0 +1,539 @@
+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 {import('googleapis').google.auth.OAuth2}
+ */
+ #oauth2;
+
+ /**
+ * @type {string}
+ */
+ #userId;
+
+ /**
+ * @type {string}
+ */
+ #fromAddress;
+
+ /**
+ * @type {AbortController|null}
+ */
+ #currentAbort = null;
+
+ /**
+ * @param {object} config - Gmail provider configuration
+ * @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) {
+ 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,
+ clientSecret,
+ redirectUri: "http://localhost",
+ });
+
+ if (refreshToken) {
+ this.#oauth2.setCredentials({ refresh_token: refreshToken });
+ }
+ if (accessToken) {
+ this.#oauth2.setCredentials({ access_token: accessToken });
+ }
+
+ 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 by checking required env vars.
+ * @returns {{ valid: boolean, errors?: string[] }}
+ */
+ validateConfig() {
+ const errors = [];
+ 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 };
+ }
+
+ /**
+ * Cancel any in-flight request.
+ */
+ cancel() {
+ if (this.#currentAbort) {
+ this.#currentAbort.abort();
+ this.#currentAbort = null;
+ }
+ }
+
+ /**
+ * 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<*>}
+ */
+ 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 });
+ } 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;
+ }
+ }
+
+ /**
+ * @param {object} params - Send parameters
+ * @returns {Promise<{ ok: boolean, messageId?: string, error?: string }>}
+ */
+ async send(params) {
+ try {
+ 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,
+ };
+ } catch (err) {
+ return { ok: false, error: `Gmail send failed: ${this.#sanitizeError(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.#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.#withTimeout(async () =>
+ 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: ${this.#sanitizeError(err.message)}` };
+ }
+ }
+
+ /**
+ * @param {object} params - Search parameters
+ * @returns {Promise<{ ok: boolean, messages?: object[], error?: string }>}
+ */
+ async search(params) {
+ try {
+ 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.#withTimeout(async () =>
+ 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: ${this.#sanitizeError(err.message)}` };
+ }
+ }
+
+ /**
+ * @param {object} params - Draft parameters
+ * @returns {Promise<{ ok: boolean, draftId?: string, error?: string }>}
+ */
+ async saveDraft(params) {
+ try {
+ 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: ${this.#sanitizeError(err.message)}` };
+ }
+ }
+
+ /**
+ * @param {object} params - List parameters
+ * @returns {Promise<{ ok: boolean, drafts?: object[], error?: string }>}
+ */
+ async listDrafts(params = {}) {
+ try {
+ 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.#withTimeout(async () =>
+ 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: ${this.#sanitizeError(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, 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: ${this.#sanitizeError(err.message)}` };
+ }
+ }
+
+ /**
+ * @param {string} draftId - Draft identifier
+ * @returns {Promise<{ ok: boolean, error?: string }>}
+ */
+ async deleteDraft(draftId) {
+ try {
+ 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: ${this.#sanitizeError(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.#withTimeout(async () =>
+ 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_FORUMS",
+ ],
+ };
+ for (const id of messageIds) {
+ 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.#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.#withTimeout(async () =>
+ 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: ${this.#sanitizeError(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;
+ const from = params.from || this.#fromAddress;
+
+ 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`;
+ 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`;
+ }
+
+ // Standard base64 — Gmail API expects standard, not URL-safe encoding
+ return Buffer.from(mime).toString("base64");
+ }
+
+ /**
+ * 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 || "",
+ };
+ }
+}
diff --git a/src/tools/email/providers/graph.js b/src/tools/email/providers/graph.js
new file mode 100644
index 00000000..71f339d1
--- /dev/null
+++ b/src/tools/email/providers/graph.js
@@ -0,0 +1,621 @@
+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;
+
+ /**
+ * @type {AbortController|null}
+ */
+ #currentAbort = null;
+
+ /**
+ * @param {object} config - Graph provider configuration
+ * @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,
+ clientSecret,
+ refreshToken,
+ tenantId,
+ };
+
+ if (accessToken) {
+ this.#accessToken = accessToken;
+ }
+ }
+
+ /**
+ * Validate provider configuration by checking required env vars.
+ * @returns {{ valid: boolean, errors?: string[] }}
+ */
+ validateConfig() {
+ const errors = [];
+ 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 };
+ }
+
+ /**
+ * Cancel any in-flight request.
+ */
+ cancel() {
+ if (this.#currentAbort) {
+ this.#currentAbort.abort();
+ this.#currentAbort = null;
+ }
+ }
+
+ /**
+ * 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}
+ */
+ 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 });
+
+ // 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);
+ this.#currentAbort = null;
+ }
+ }
+
+ /**
+ * 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 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;
+ }
+
+ /**
+ * 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 }>}
+ */
+ 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 this.#fetchWithTimeout(
+ `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: ${this.#sanitizeError(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) {
+ 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}`);
+
+ if (filtersList.length > 0) {
+ url += `&$filter=${filtersList.join(" and ")}`;
+ }
+
+ const response = await this.#fetchWithTimeout(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 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}` },
+ },
+ );
+
+ 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 this.#fetchWithTimeout(
+ `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 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}` },
+ },
+ );
+
+ 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 this.#fetchWithTimeout(
+ `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 this.#fetchWithTimeout(
+ `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 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" } }),
+ },
+ );
+ }
+ break;
+ }
+ case "markUnread": {
+ for (const id of messageIds) {
+ 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" } }),
+ },
+ );
+ }
+ break;
+ }
+ case "archive": {
+ for (const id of messageIds) {
+ await this.#fetchWithTimeout(
+ `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: "deleteditems" }),
+ },
+ );
+ }
+ break;
+ }
+ case "addLabel": {
+ // Graph uses categories for labels
+ for (const id of messageIds) {
+ 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] }),
+ },
+ );
+ }
+ break;
+ }
+ case "removeLabel": {
+ for (const id of messageIds) {
+ 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: [] }),
+ },
+ );
+ }
+ 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 || "",
+ };
+ }
+}
diff --git a/src/tools/email/providers/imap.js b/src/tools/email/providers/imap.js
new file mode 100644
index 00000000..75dc22c4
--- /dev/null
+++ b/src/tools/email/providers/imap.js
@@ -0,0 +1,442 @@
+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;
+
+ /**
+ * @type {AbortController|null}
+ */
+ #currentAbort = null;
+
+ /**
+ * @param {object} config - IMAP provider configuration
+ * @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 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;
+
+ if (!user || !password) {
+ throw new Error("IMAP provider requires EMAIL_IMAP_USER and EMAIL_IMAP_PASSWORD env vars");
+ }
+
+ this.#config = {
+ imapHost,
+ imapPort,
+ imapSecure,
+ smtpHost,
+ smtpPort,
+ user,
+ password,
+ };
+ }
+
+ /**
+ * Validate provider configuration by checking required env vars.
+ * @returns {{ valid: boolean, errors?: string[] }}
+ */
+ validateConfig() {
+ const errors = [];
+ 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 };
+ }
+
+ /**
+ * Cancel any in-flight request.
+ */
+ cancel() {
+ if (this.#currentAbort) {
+ this.#currentAbort.abort();
+ this.#currentAbort = null;
+ }
+ }
+
+ /**
+ * 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
+ * @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 }>}
+ */
+ async send(params) {
+ try {
+ const transport = createTransport({
+ host: this.#config.smtpHost,
+ port: this.#config.smtpPort,
+ 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 this.#withTimeout(async () => transport.sendMail(mailOptions));
+ return { ok: true, messageId: result.messageId };
+ } catch (err) {
+ return { ok: false, error: `IMAP send failed: ${this.#sanitizeError(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.imapHost,
+ port: this.#config.imapPort,
+ secure: this.#config.imapSecure,
+ 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 });
+
+ // Use UID-based pagination to avoid fetching all messages
+ const result = [];
+ 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);
+ await connection.disconnect();
+
+ return { ok: true, messages: result };
+ } catch (err) {
+ return { ok: false, error: `IMAP read failed: ${this.#sanitizeError(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.imapHost,
+ port: this.#config.imapPort,
+ secure: this.#config.imapSecure,
+ auth: {
+ user: this.#config.user,
+ pass: this.#config.password,
+ },
+ });
+
+ 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 = [];
+ 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(folder);
+ await connection.disconnect();
+
+ return { ok: true, messages: result };
+ } catch (err) {
+ return { ok: false, error: `IMAP search failed: ${this.#sanitizeError(err.message)}` };
+ }
+ }
+
+ /**
+ * @param {object} params - Draft parameters
+ * @returns {Promise<{ ok: boolean, draftId?: string, error?: string }>}
+ */
+ async saveDraft(params) {
+ try {
+ const { default: ImapSimple } = await import("imap-simple");
+ const connection = await ImapSimple.connect({
+ host: this.#config.imapHost,
+ port: this.#config.imapPort,
+ secure: this.#config.imapSecure,
+ auth: {
+ user: this.#config.user,
+ pass: this.#config.password,
+ },
+ });
+
+ // 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.openBox("DRAFTS");
+ const result = await connection.addMessage("DRAFTS", rfc822);
+ await connection.closeBox("DRAFTS");
+ await connection.disconnect();
+
+ // 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: ${this.#sanitizeError(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.imapHost,
+ port: this.#config.imapPort,
+ secure: this.#config.imapSecure,
+ auth: {
+ user: this.#config.user,
+ pass: this.#config.password,
+ },
+ });
+
+ await connection.openBox("DRAFTS");
+
+ const messages = await connection.search(["ALL"], { recent: false });
+
+ // Use UID-based pagination to avoid fetching all messages
+ const result = [];
+ 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");
+ await connection.disconnect();
+
+ return { ok: true, drafts: result };
+ } catch (err) {
+ return { ok: false, error: `IMAP listDrafts failed: ${this.#sanitizeError(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 {
+ // 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}` };
+ }
+ }
+
+ /**
+ * @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.imapHost,
+ port: this.#config.imapPort,
+ secure: this.#config.imapSecure,
+ auth: {
+ user: this.#config.user,
+ pass: this.#config.password,
+ },
+ });
+
+ await connection.openBox("DRAFTS");
+ await connection.setFlags({ uid: [draftId] }, ["\\Deleted"]);
+ await connection.expunge();
+ 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.imapHost,
+ port: this.#config.imapPort,
+ secure: this.#config.imapSecure,
+ auth: {
+ user: this.#config.user,
+ pass: this.#config.password,
+ },
+ });
+
+ const folder = params.folder || "INBOX";
+ await connection.openBox(folder);
+
+ 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(folder);
+ await connection.disconnect();
+ return { ok: false, error: `Unknown organize action: ${params.action}` };
+ }
+
+ await connection.closeBox(folder);
+ await connection.disconnect();
+
+ return { ok: true };
+ } catch (err) {
+ return { ok: false, error: `IMAP organize failed: ${this.#sanitizeError(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,
+ };
+ }
+}
diff --git a/src/tools/email/tools.js b/src/tools/email/tools.js
new file mode 100644
index 00000000..70dc76d6
--- /dev/null
+++ b/src/tools/email/tools.js
@@ -0,0 +1,259 @@
+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 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 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)",
+ };
+ }
+ 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}` };
+ }
+ }
+
+ case "send": {
+ 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.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}` };
+ }
+ }
+
+ 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}` };
+ }
+ }
+
+ 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}` };
+ }
+ }
+
+ 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}` };
+ }
+ }
+
+ 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}` };
+ }
+ }
+
+ 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}` };
+ }
+ }
+
+ 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}` };
+ }
+ }
+
+ default:
+ return {
+ ok: false,
+ error: `Unknown action: "${action}". Valid actions: read, send, draftSave, draftList, draftUpdate, draftDelete, organize, search`,
+ };
+ }
+}
+
+/**
+ * Email tool — read, send, manage drafts, organize, and search emails.
+ * Single tool with action parameter dispatching to provider operations.
+ */
+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 9ee98206..4180ce6c 100644
--- a/src/tools/index.js
+++ b/src/tools/index.js
@@ -19,6 +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 { email } from "./email/tools.js";
/**
* Maps tool names to required permission scopes.
@@ -48,6 +49,7 @@ export const TOOL_PERMISSIONS = {
xlsx: ["filesystem:read"],
pdf: ["filesystem:read"],
reflectionSessions: ["filesystem:read"],
+ email: ["network:outbound"],
};
/**
@@ -108,6 +110,7 @@ export const TOOL_CLASSIFICATIONS = {
xlsx: ["search", "research", "coding", "documentation", "debug"],
pdf: ["search", "research", "coding", "documentation", "debug"],
reflectionSessions: ["orchestrator"],
+ email: ["search", "research", "coding", "documentation", "debug"],
};
/**
@@ -170,6 +173,7 @@ export const TOOLS = {
xlsx: xlsxTool,
pdf: pdfTool,
reflectionSessions,
+ email,
};
/**
diff --git a/tests/unit/config/providers.test.js b/tests/unit/config/providers.test.js
new file mode 100644
index 00000000..48ee410e
--- /dev/null
+++ b/tests/unit/config/providers.test.js
@@ -0,0 +1,158 @@
+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);
+ });
+ });
+});
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..30d19469
--- /dev/null
+++ b/tests/unit/tools/email/email-tools.test.js
@@ -0,0 +1,79 @@
+import { test, describe } from "node:test";
+import assert from "node:assert";
+import { emailImpl } from "../../../../src/tools/email/tools.js";
+
+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("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("email read returns structured error when no provider", async () => {
+ const result = await emailImpl({ action: "read" }, {});
+ assert.ok(!result.ok);
+ assert.ok(result.error);
+ });
+
+ 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("email draftSave returns structured error when no provider", async () => {
+ const result = await emailImpl({ action: "draftSave" }, {});
+ assert.ok(!result.ok);
+ assert.ok(result.error);
+ });
+
+ 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("email draftUpdate returns structured error when no provider", async () => {
+ const result = await emailImpl({ action: "draftUpdate" }, {});
+ assert.ok(!result.ok);
+ assert.ok(result.error);
+ });
+
+ 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("email organize returns structured error when no provider", async () => {
+ const result = await emailImpl({ action: "organize" }, {});
+ assert.ok(!result.ok);
+ assert.ok(result.error);
+ });
+
+ test("email search returns structured error when no provider", async () => {
+ const result = await emailImpl({ action: "search" }, {});
+ assert.ok(!result.ok);
+ assert.ok(result.error);
+ });
+});
diff --git a/tests/unit/tools/email/factory.test.js b/tests/unit/tools/email/factory.test.js
new file mode 100644
index 00000000..3e39878c
--- /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);
+ });
+});
diff --git a/tests/unit/tools/email/index.test.js b/tests/unit/tools/email/index.test.js
new file mode 100644
index 00000000..2c157257
--- /dev/null
+++ b/tests/unit/tools/email/index.test.js
@@ -0,0 +1,94 @@
+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);
+ });
+});
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..16988287
--- /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");
+ });
+});
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..ddcfacc1
--- /dev/null
+++ b/tests/unit/tools/email/providers/gmail.test.js
@@ -0,0 +1,445 @@
+import { test, describe, before, after, mock } from "node:test";
+import assert from "node:assert";
+import { GmailProvider } from "../../../../../src/tools/email/providers/gmail.js";
+
+describe("GmailProvider — happy paths", () => {
+ /** @type {import('googleapis').google} */
+ let mockGmail;
+ /** @type {import('googleapis').google.auth.OAuth2} */
+ let mockOAuth2;
+ /** @type {typeof import('googleapis')} */
+ let origGoogle;
+
+ before(async () => {
+ origGoogle = await import("googleapis");
+
+ 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);
+ });
+
+ after(() => {
+ mock.restore();
+ });
+
+ 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);
+ });
+ });
+
+ 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);
+ });
+
+ 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);
+ });
+
+ 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);
+ });
+
+ 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);
+ });
+ });
+
+ 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);
+ });
+ });
+
+ 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);
+ });
+ });
+
+ 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 ");
+ });
+ });
+
+ 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));
+ });
+ });
+
+ 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);
+ });
+ });
+});
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..3f5e6b05
--- /dev/null
+++ b/tests/unit/tools/email/providers/graph.test.js
@@ -0,0 +1,789 @@
+import { test, describe, before, after } from "node:test";
+import assert from "node:assert";
+import { GraphProvider } from "../../../../../src/tools/email/providers/graph.js";
+
+describe("GraphProvider — happy paths", () => {
+ let origFetch;
+
+ before(() => {
+ origFetch = globalThis.fetch;
+ });
+
+ after(() => {
+ globalThis.fetch = origFetch;
+ });
+
+ test("read() should return messages from Graph API", async () => {
+ globalThis.fetch = async (url) => {
+ 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 };
+ };
+
+ const provider = new GraphProvider({
+ clientId: "id",
+ clientSecret: "secret",
+ refreshToken: "token",
+ tenantId: "tenant",
+ });
+
+ const result = await provider.read({ limit: 5 });
+
+ 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) => {
+ 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 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: ["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: "Body",
+ });
+
+ 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("search() should query messages with $q parameter", async () => {
+ const fetchCalls = [];
+ globalThis.fetch = async (url) => {
+ 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: ["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) => {
+ 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) => {
+ 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: "Body",
+ });
+
+ 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("normalizeMessage() should handle Graph message format", async () => {
+ globalThis.fetch = async (url) => {
+ 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.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("read() should handle empty message list", async () => {
+ globalThis.fetch = async (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.read({});
+
+ assert.strictEqual(result.ok, true);
+ assert.ok(result.messages);
+ assert.strictEqual(result.messages.length, 0);
+ });
+
+ test("read() should use custom folder", async () => {
+ const fetchCalls = [];
+ globalThis.fetch = async (url) => {
+ 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({ folder: "sentitems" });
+
+ const messageUrl = fetchCalls.find((u) => u.includes("/sentitems/"));
+ assert.ok(messageUrl);
+ });
+
+ test("Graph API error should return structured error", async () => {
+ globalThis.fetch = async (url) => {
+ 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.read({});
+
+ assert.strictEqual(result.ok, false);
+ assert.ok(result.error.includes("401"));
+ });
+
+ test("token refresh failure should propagate error", async () => {
+ globalThis.fetch = async (url) => {
+ 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: "bad-token",
+ tenantId: "tenant",
+ });
+
+ const result = await provider.send({
+ to: ["recipient@example.com"],
+ subject: "Test",
+ body: "Body",
+ });
+
+ 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) => {
+ 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);
+ });
+});
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..6911c823
--- /dev/null
+++ b/tests/unit/tools/email/providers/imap.test.js
@@ -0,0 +1,1066 @@
+import { test, describe, before, after, mock } from "node:test";
+import assert from "node:assert";
+import { ImapProvider } from "../../../../../src/tools/email/providers/imap.js";
+
+describe("ImapProvider — happy paths", () => {
+ let origFetch;
+ let nodemailerMod;
+ let imapSimpleMod;
+
+ before(async () => {
+ origFetch = globalThis.fetch;
+ nodemailerMod = await import("nodemailer");
+ imapSimpleMod = await import("imap-simple");
+ });
+
+ after(() => {
+ globalThis.fetch = origFetch;
+ mock.restore();
+ });
+
+ 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, 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("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: ["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: "Body",
+ });
+
+ const sendCall = mock.methodCalls(mockSendMail);
+ assert.strictEqual(sendCall[0].arguments[0].cc, "cc@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: "Body",
+ });
+
+ const sendCall = mock.methodCalls(mockSendMail);
+ assert.strictEqual(sendCall[0].arguments[0].bcc, "bcc@example.com");
+ });
+
+ 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("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("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("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("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);
+ });
+});