This is page 3 of 4. Use http://codebase.md/aaronsb/google-workspace-mcp?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .dockerignore
├── .eslintrc.json
├── .github
│   ├── config.yml
│   ├── FUNDING.yml
│   ├── ISSUE_TEMPLATE
│   │   ├── bug_report.md
│   │   └── feature_request.md
│   ├── pull_request_template.md
│   └── workflows
│       ├── ci.yml
│       └── docker-publish.yml
├── .gitignore
├── ARCHITECTURE.md
├── cline_docs
│   ├── activeContext.md
│   ├── productContext.md
│   ├── progress.md
│   ├── systemPatterns.md
│   └── techContext.md
├── CODE_OF_CONDUCT.md
├── config
│   ├── accounts.example.json
│   ├── credentials
│   │   └── README.md
│   └── gauth.example.json
├── CONTRIBUTING.md
├── docker-entrypoint.sh
├── Dockerfile
├── Dockerfile.local
├── docs
│   ├── API.md
│   ├── assets
│   │   └── robot-assistant.png
│   ├── automatic-oauth-flow.md
│   ├── ERRORS.md
│   ├── EXAMPLES.md
│   └── TOOL_DISCOVERY.md
├── jest.config.cjs
├── jest.setup.cjs
├── LICENSE
├── llms-install.md
├── package-lock.json
├── package.json
├── README.md
├── scripts
│   ├── build-local.sh
│   └── local-entrypoint.sh
├── SECURITY.md
├── smithery.yaml
├── src
│   ├── __fixtures__
│   │   └── accounts.ts
│   ├── __helpers__
│   │   ├── package.json
│   │   └── testSetup.ts
│   ├── __mocks__
│   │   ├── @modelcontextprotocol
│   │   │   ├── sdk
│   │   │   │   ├── server
│   │   │   │   │   ├── index.js
│   │   │   │   │   └── stdio.js
│   │   │   │   └── types.ts
│   │   │   └── sdk.ts
│   │   ├── googleapis.ts
│   │   └── logger.ts
│   ├── __tests__
│   │   └── modules
│   │       ├── accounts
│   │       │   ├── manager.test.ts
│   │       │   └── token.test.ts
│   │       ├── attachments
│   │       │   └── index.test.ts
│   │       ├── calendar
│   │       │   └── service.test.ts
│   │       └── gmail
│   │           └── service.test.ts
│   ├── api
│   │   ├── handler.ts
│   │   ├── request.ts
│   │   └── validators
│   │       ├── endpoint.ts
│   │       └── parameter.ts
│   ├── index.ts
│   ├── modules
│   │   ├── accounts
│   │   │   ├── callback-server.ts
│   │   │   ├── index.ts
│   │   │   ├── manager.ts
│   │   │   ├── oauth.ts
│   │   │   ├── token.ts
│   │   │   └── types.ts
│   │   ├── attachments
│   │   │   ├── cleanup-service.ts
│   │   │   ├── index-service.ts
│   │   │   ├── response-transformer.ts
│   │   │   ├── service.ts
│   │   │   ├── transformer.ts
│   │   │   └── types.ts
│   │   ├── calendar
│   │   │   ├── __tests__
│   │   │   │   └── scopes.test.ts
│   │   │   ├── index.ts
│   │   │   ├── scopes.ts
│   │   │   ├── service.ts
│   │   │   └── types.ts
│   │   ├── contacts
│   │   │   ├── index.ts
│   │   │   ├── scopes.ts
│   │   │   └── types.ts
│   │   ├── drive
│   │   │   ├── __tests__
│   │   │   │   ├── scopes.test.ts
│   │   │   │   └── service.test.ts
│   │   │   ├── index.ts
│   │   │   ├── scopes.ts
│   │   │   ├── service.ts
│   │   │   └── types.ts
│   │   ├── gmail
│   │   │   ├── __tests__
│   │   │   │   ├── label.test.ts
│   │   │   │   └── scopes.test.ts
│   │   │   ├── constants.ts
│   │   │   ├── index.ts
│   │   │   ├── scopes.ts
│   │   │   ├── service.ts
│   │   │   ├── services
│   │   │   │   ├── attachment.ts
│   │   │   │   ├── base.ts
│   │   │   │   ├── draft.ts
│   │   │   │   ├── email.ts
│   │   │   │   ├── label.ts
│   │   │   │   ├── search.ts
│   │   │   │   └── settings.ts
│   │   │   └── types.ts
│   │   └── tools
│   │       ├── __tests__
│   │       │   ├── registry.test.ts
│   │       │   └── scope-registry.test.ts
│   │       ├── registry.ts
│   │       └── scope-registry.ts
│   ├── oauth
│   │   └── client.ts
│   ├── scripts
│   │   ├── health-check.ts
│   │   ├── setup-environment.ts
│   │   └── setup-google-env.ts
│   ├── services
│   │   ├── base
│   │   │   └── BaseGoogleService.ts
│   │   ├── calendar
│   │   │   └── index.ts
│   │   ├── contacts
│   │   │   └── index.ts
│   │   ├── drive
│   │   │   └── index.ts
│   │   └── gmail
│   │       └── index.ts
│   ├── tools
│   │   ├── account-handlers.ts
│   │   ├── calendar-handlers.ts
│   │   ├── contacts-handlers.ts
│   │   ├── definitions.ts
│   │   ├── drive-handlers.ts
│   │   ├── gmail-handlers.ts
│   │   ├── server.ts
│   │   ├── type-guards.ts
│   │   └── types.ts
│   ├── types
│   │   └── modelcontextprotocol__sdk.d.ts
│   ├── types.ts
│   └── utils
│       ├── account.ts
│       ├── logger.ts
│       ├── service-initializer.ts
│       ├── token.ts
│       └── workspace.ts
├── TODO.md
└── tsconfig.json
```
# Files
--------------------------------------------------------------------------------
/src/modules/accounts/token.ts:
--------------------------------------------------------------------------------
```typescript
  1 | import fs from 'fs/promises';
  2 | import path from 'path';
  3 | import { AccountError, TokenStatus, TokenRenewalResult } from './types.js';
  4 | import { GoogleOAuthClient } from './oauth.js';
  5 | import logger from '../../utils/logger.js';
  6 | 
  7 | /**
  8 |  * Manages OAuth token operations.
  9 |  * Focuses on basic token storage, retrieval, and refresh.
 10 |  * Auth issues are handled via 401 responses rather than pre-validation.
 11 |  */
 12 | export class TokenManager {
 13 |   private readonly credentialsPath: string;
 14 |   private oauthClient?: GoogleOAuthClient;
 15 |   private readonly TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000; // 5 minutes buffer
 16 | 
 17 |   constructor(oauthClient?: GoogleOAuthClient) {
 18 |     // Use environment variable or config, fallback to Docker default
 19 |     const defaultPath = process.env.CREDENTIALS_PATH || 
 20 |                        (process.env.MCP_MODE ? path.resolve(process.env.HOME || '', '.mcp/google-workspace-mcp/credentials') : '/app/config/credentials');
 21 |     this.credentialsPath = defaultPath;
 22 |     this.oauthClient = oauthClient;
 23 |   }
 24 | 
 25 |   setOAuthClient(client: GoogleOAuthClient) {
 26 |     this.oauthClient = client;
 27 |   }
 28 | 
 29 |   private getTokenPath(email: string): string {
 30 |     const sanitizedEmail = email.replace(/[^a-zA-Z0-9]/g, '-');
 31 |     return path.join(this.credentialsPath, `${sanitizedEmail}.token.json`);
 32 |   }
 33 | 
 34 |   async saveToken(email: string, tokenData: any): Promise<void> {
 35 |     logger.info(`Saving token for account: ${email}`);
 36 |     try {
 37 |       // Ensure base credentials directory exists
 38 |       await fs.mkdir(this.credentialsPath, { recursive: true });
 39 |       const tokenPath = this.getTokenPath(email);
 40 |       await fs.writeFile(tokenPath, JSON.stringify(tokenData, null, 2));
 41 |       logger.debug(`Token saved successfully at: ${tokenPath}`);
 42 |     } catch (error) {
 43 |       throw new AccountError(
 44 |         'Failed to save token',
 45 |         'TOKEN_SAVE_ERROR',
 46 |         'Please ensure the credentials directory is writable'
 47 |       );
 48 |     }
 49 |   }
 50 | 
 51 |   async loadToken(email: string): Promise<any> {
 52 |     logger.debug(`Loading token for account: ${email}`);
 53 |     try {
 54 |       const tokenPath = this.getTokenPath(email);
 55 |       const data = await fs.readFile(tokenPath, 'utf-8');
 56 |       return JSON.parse(data);
 57 |     } catch (error) {
 58 |       if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
 59 |         // File doesn't exist - return null to trigger OAuth flow
 60 |         return null;
 61 |       }
 62 |       throw new AccountError(
 63 |         'Failed to load token',
 64 |         'TOKEN_LOAD_ERROR',
 65 |         'Please ensure the token file exists and is readable'
 66 |       );
 67 |     }
 68 |   }
 69 | 
 70 |   async deleteToken(email: string): Promise<void> {
 71 |     logger.info(`Deleting token for account: ${email}`);
 72 |     try {
 73 |       const tokenPath = this.getTokenPath(email);
 74 |       await fs.unlink(tokenPath);
 75 |       logger.debug('Token file deleted successfully');
 76 |     } catch (error) {
 77 |       if (error instanceof Error && 'code' in error && error.code !== 'ENOENT') {
 78 |         throw new AccountError(
 79 |           'Failed to delete token',
 80 |           'TOKEN_DELETE_ERROR',
 81 |           'Please ensure you have permission to delete the token file'
 82 |         );
 83 |       }
 84 |     }
 85 |   }
 86 | 
 87 |   /**
 88 |    * Basic token validation - just checks if token exists and isn't expired.
 89 |    * No scope validation - auth issues handled via 401 responses.
 90 |    */
 91 |   /**
 92 |    * Attempts to automatically renew a token if it's expired or near expiry
 93 |    * Returns the renewal result and new token if successful
 94 |    */
 95 |   async autoRenewToken(email: string): Promise<TokenRenewalResult> {
 96 |     logger.debug(`Attempting auto-renewal for account: ${email}`);
 97 |     
 98 |     try {
 99 |       const token = await this.loadToken(email);
100 |       
101 |       if (!token) {
102 |         return {
103 |           success: false,
104 |           status: 'NO_TOKEN',
105 |           reason: 'No token found'
106 |         };
107 |       }
108 | 
109 |       if (!token.expiry_date) {
110 |         return {
111 |           success: false,
112 |           status: 'INVALID',
113 |           reason: 'Invalid token format'
114 |         };
115 |       }
116 | 
117 |       // Check if token is expired or will expire soon
118 |       const now = Date.now();
119 |       if (token.expiry_date <= now + this.TOKEN_EXPIRY_BUFFER_MS) {
120 |         if (!token.refresh_token || !this.oauthClient) {
121 |           return {
122 |             success: false,
123 |             status: 'REFRESH_FAILED',
124 |             reason: 'No refresh token or OAuth client available'
125 |           };
126 |         }
127 | 
128 |         try {
129 |           // Attempt to refresh the token
130 |           const newToken = await this.oauthClient.refreshToken(token.refresh_token);
131 |           await this.saveToken(email, newToken);
132 |           logger.info('Token refreshed successfully');
133 |           return {
134 |             success: true,
135 |             status: 'REFRESHED',
136 |             token: newToken
137 |           };
138 |         } catch (error) {
139 |           // Check if the error indicates an invalid/revoked refresh token
140 |           const errorMessage = error instanceof Error ? error.message.toLowerCase() : '';
141 |           const isRefreshTokenInvalid = 
142 |             errorMessage.includes('invalid_grant') || 
143 |             errorMessage.includes('token has been revoked') ||
144 |             errorMessage.includes('token not found');
145 | 
146 |           if (!isRefreshTokenInvalid) {
147 |             // If it's not a refresh token issue, try one more time
148 |             try {
149 |               logger.warn('First refresh attempt failed, trying once more');
150 |               const newToken = await this.oauthClient.refreshToken(token.refresh_token);
151 |               await this.saveToken(email, newToken);
152 |               logger.info('Token refreshed successfully on second attempt');
153 |               return {
154 |                 success: true,
155 |                 status: 'REFRESHED',
156 |                 token: newToken
157 |               };
158 |             } catch (secondError) {
159 |               logger.error('Both refresh attempts failed, but refresh token may still be valid');
160 |               return {
161 |                 success: false,
162 |                 status: 'REFRESH_FAILED',
163 |                 reason: 'Token refresh failed, temporary error',
164 |                 canRetry: true
165 |               };
166 |             }
167 |           }
168 | 
169 |           // Refresh token is invalid, need full reauth
170 |           logger.error('Refresh token is invalid or revoked');
171 |           return {
172 |             success: false,
173 |             status: 'REFRESH_FAILED',
174 |             reason: 'Refresh token is invalid or revoked',
175 |             canRetry: false
176 |           };
177 |         }
178 |       }
179 | 
180 |       // Token is still valid
181 |       return {
182 |         success: true,
183 |         status: 'VALID',
184 |         token
185 |       };
186 |     } catch (error) {
187 |       logger.error('Token auto-renewal error', error as Error);
188 |       return {
189 |         success: false,
190 |         status: 'ERROR',
191 |         reason: 'Token auto-renewal failed'
192 |       };
193 |     }
194 |   }
195 | 
196 |   async validateToken(email: string, skipValidationForNew: boolean = false): Promise<TokenStatus> {
197 |     logger.debug(`Validating token for account: ${email}`);
198 |     
199 |     try {
200 |       const token = await this.loadToken(email);
201 |       
202 |       if (!token) {
203 |         logger.debug('No token found');
204 |         return {
205 |           valid: false,
206 |           status: 'NO_TOKEN',
207 |           reason: 'No token found'
208 |         };
209 |       }
210 | 
211 |       // Skip validation if this is a new account setup
212 |       if (skipValidationForNew) {
213 |         logger.debug('Skipping validation for new account setup');
214 |         return {
215 |           valid: true,
216 |           status: 'VALID',
217 |           token
218 |         };
219 |       }
220 | 
221 |       if (!token.expiry_date) {
222 |         logger.debug('Token missing expiry date');
223 |         return {
224 |           valid: false,
225 |           status: 'INVALID',
226 |           reason: 'Invalid token format'
227 |         };
228 |       }
229 | 
230 |       if (token.expiry_date < Date.now()) {
231 |         logger.debug('Token has expired, attempting refresh');
232 |         if (token.refresh_token && this.oauthClient) {
233 |           try {
234 |             const newToken = await this.oauthClient.refreshToken(token.refresh_token);
235 |             await this.saveToken(email, newToken);
236 |             logger.info('Token refreshed successfully');
237 |       return {
238 |         valid: true,
239 |         status: 'REFRESHED',
240 |         token: newToken,
241 |         requiredScopes: newToken.scope ? newToken.scope.split(' ') : undefined
242 |       };
243 |           } catch (error) {
244 |             logger.error('Token refresh failed', error as Error);
245 |             return {
246 |               valid: false,
247 |               status: 'REFRESH_FAILED',
248 |               reason: 'Token refresh failed'
249 |             };
250 |           }
251 |         }
252 |         logger.debug('No refresh token available');
253 |         return {
254 |           valid: false,
255 |           status: 'EXPIRED',
256 |           reason: 'Token expired and no refresh token available'
257 |         };
258 |       }
259 | 
260 |       logger.debug('Token is valid');
261 |       return {
262 |         valid: true,
263 |         status: 'VALID',
264 |         token,
265 |         requiredScopes: token.scope ? token.scope.split(' ') : undefined
266 |       };
267 |     } catch (error) {
268 |       logger.error('Token validation error', error as Error);
269 |       return {
270 |         valid: false,
271 |         status: 'ERROR',
272 |         reason: 'Token validation failed'
273 |       };
274 |     }
275 |   }
276 | }
277 | 
```
--------------------------------------------------------------------------------
/src/__tests__/modules/accounts/manager.test.ts:
--------------------------------------------------------------------------------
```typescript
  1 | import { AccountManager } from '../../../modules/accounts/manager.js';
  2 | import { mockAccounts, mockTokens } from '../../../__fixtures__/accounts.js';
  3 | 
  4 | // Simple mocks for token and oauth
  5 | jest.mock('../../../modules/accounts/token.js', () => ({
  6 |   TokenManager: jest.fn().mockImplementation(() => ({
  7 |     validateToken: jest.fn().mockResolvedValue({
  8 |       valid: true,
  9 |       status: 'VALID',
 10 |       token: { access_token: 'test-token' }
 11 |     }),
 12 |     saveToken: jest.fn().mockResolvedValue(undefined),
 13 |     deleteToken: jest.fn().mockResolvedValue(undefined),
 14 |     autoRenewToken: jest.fn().mockResolvedValue({
 15 |       success: true,
 16 |       status: 'VALID',
 17 |       token: { access_token: 'test-token' }
 18 |     })
 19 |   }))
 20 | }));
 21 | 
 22 | jest.mock('../../../modules/accounts/oauth.js', () => ({
 23 |   GoogleOAuthClient: jest.fn().mockImplementation(() => ({
 24 |     ensureInitialized: jest.fn().mockResolvedValue(undefined),
 25 |     getTokenFromCode: jest.fn().mockResolvedValue(mockTokens.valid),
 26 |     refreshToken: jest.fn().mockResolvedValue(mockTokens.valid),
 27 |     generateAuthUrl: jest.fn().mockReturnValue('https://mock-auth-url'),
 28 |     getAuthClient: jest.fn().mockReturnValue({
 29 |       setCredentials: jest.fn(),
 30 |       getAccessToken: jest.fn().mockResolvedValue({ token: 'test-token' })
 31 |     })
 32 |   }))
 33 | }));
 34 | 
 35 | // Simple file system mock
 36 | jest.mock('fs/promises', () => ({
 37 |   readFile: jest.fn(),
 38 |   writeFile: jest.fn().mockResolvedValue(undefined),
 39 |   mkdir: jest.fn().mockResolvedValue(undefined)
 40 | }));
 41 | 
 42 | jest.mock('path', () => ({
 43 |   resolve: jest.fn().mockReturnValue('/mock/accounts.json'),
 44 |   dirname: jest.fn().mockReturnValue('/mock')
 45 | }));
 46 | 
 47 | describe('AccountManager', () => {
 48 |   let accountManager: AccountManager;
 49 |   const fs = require('fs/promises');
 50 | 
 51 |   beforeEach(() => {
 52 |     jest.clearAllMocks();
 53 |     jest.resetModules();
 54 |     // Clear environment variables that affect path resolution
 55 |     delete process.env.MCP_MODE;
 56 |     delete process.env.HOME;
 57 |     process.env.ACCOUNTS_PATH = '/mock/accounts.json';
 58 |     // Default successful file read
 59 |     fs.readFile.mockResolvedValue(JSON.stringify(mockAccounts));
 60 |     
 61 |     // Reset TokenManager mock to default implementation
 62 |     const TokenManager = require('../../../modules/accounts/token.js').TokenManager;
 63 |     TokenManager.mockImplementation(() => ({
 64 |       validateToken: jest.fn().mockResolvedValue({ valid: true }),
 65 |       saveToken: jest.fn().mockResolvedValue(undefined),
 66 |       deleteToken: jest.fn().mockResolvedValue(undefined),
 67 |       autoRenewToken: jest.fn().mockResolvedValue({
 68 |         success: true,
 69 |         status: 'VALID',
 70 |         token: { access_token: 'test-token' }
 71 |       })
 72 |     }));
 73 |     
 74 |     accountManager = new AccountManager();
 75 |   });
 76 | 
 77 |   // Basic account operations
 78 |   describe('account operations', () => {
 79 |     it('should load accounts from file', async () => {
 80 |       await accountManager.initialize();
 81 |       const accounts = await accountManager.listAccounts();
 82 |       
 83 |       expect(accounts).toHaveLength(mockAccounts.accounts.length);
 84 |       expect(accounts[0].email).toBe(mockAccounts.accounts[0].email);
 85 |     });
 86 | 
 87 |     it('should add new account', async () => {
 88 |       await accountManager.initialize();
 89 |       const newAccount = await accountManager.addAccount(
 90 |         '[email protected]',
 91 |         'work',
 92 |         'New Test Account'
 93 |       );
 94 | 
 95 |       expect(newAccount.email).toBe('[email protected]');
 96 |       expect(fs.writeFile).toHaveBeenCalled();
 97 |     });
 98 | 
 99 |     it('should not add duplicate account', async () => {
100 |       await accountManager.initialize();
101 |       await expect(accountManager.addAccount(
102 |         mockAccounts.accounts[0].email,
103 |         'work',
104 |         'Duplicate'
105 |       )).rejects.toThrow('Account already exists');
106 |     });
107 |   });
108 | 
109 |   // File system operations
110 |   describe('file operations', () => {
111 |     it('should handle missing accounts file', async () => {
112 |       // Reset all mocks and modules
113 |       jest.clearAllMocks();
114 |       jest.resetModules();
115 |       
116 |       // Re-require fs after reset
117 |       const fs = require('fs/promises');
118 |       
119 |       // Setup error for first read attempt
120 |       const error = new Error('File not found');
121 |       (error as any).code = 'ENOENT';
122 |       fs.readFile.mockRejectedValueOnce(error);
123 |       
124 |       // Mock path module
125 |       jest.doMock('path', () => ({
126 |         resolve: jest.fn().mockReturnValue('/mock/accounts.json'),
127 |         dirname: jest.fn().mockReturnValue('/mock')
128 |       }));
129 |       
130 |       // Re-require AccountManager to use fresh mocks
131 |       const { AccountManager } = require('../../../modules/accounts/manager.js');
132 |       accountManager = new AccountManager();
133 |       
134 |       // Initialize with empty file system
135 |       await accountManager.initialize();
136 |       
137 |       // Mock empty response for listAccounts call
138 |       fs.readFile.mockResolvedValueOnce('{"accounts":[]}');
139 |       const accounts = await accountManager.listAccounts();
140 |       
141 |       // Verify results
142 |       expect(accounts).toHaveLength(0);
143 |       
144 |       // Verify write was called with correct data
145 |       expect(fs.writeFile).toHaveBeenCalledTimes(1);
146 |       const [path, content] = fs.writeFile.mock.calls[0];
147 |       expect(path).toBe('/mock/accounts.json');
148 |       
149 |       // Parse and re-stringify to normalize formatting
150 |       const parsedContent = JSON.parse(content);
151 |       expect(parsedContent).toEqual({ accounts: [] });
152 |     });
153 | 
154 |     it('should handle invalid JSON', async () => {
155 |       fs.readFile.mockResolvedValueOnce('invalid json');
156 |       
157 |       await expect(accountManager.initialize())
158 |         .rejects
159 |         .toThrow('Failed to parse accounts configuration');
160 |     });
161 |   });
162 | 
163 |   // Token validation (simplified)
164 |   describe('token validation', () => {
165 |     const testEmail = mockAccounts.accounts[0].email;
166 | 
167 |     it('should validate token successfully', async () => {
168 |       await accountManager.initialize();
169 |       const account = await accountManager.validateAccount(testEmail);
170 |       
171 |       expect(account.email).toBe(testEmail);
172 |       expect(account.auth_status).toEqual({
173 |         valid: true,
174 |         status: 'VALID'
175 |       });
176 |     });
177 | 
178 |     it('should handle token validation failure', async () => {
179 |       // Reset all mocks and modules
180 |       jest.clearAllMocks();
181 |       jest.resetModules();
182 |       
183 |       // Re-require fs and setup fresh state
184 |       const fs = require('fs/promises');
185 |       fs.readFile.mockResolvedValue(JSON.stringify(mockAccounts));
186 |       
187 |       // Create simple mock implementation
188 |       const mockValidateToken = jest.fn().mockResolvedValue({ 
189 |         valid: false,
190 |         status: 'EXPIRED',
191 |         reason: 'Token expired'
192 |       });
193 |       
194 |       // Setup TokenManager with tracked mock
195 |       jest.doMock('../../../modules/accounts/token.js', () => ({
196 |         TokenManager: jest.fn().mockImplementation(() => ({
197 |           validateToken: mockValidateToken,
198 |           saveToken: jest.fn(),
199 |           deleteToken: jest.fn()
200 |         }))
201 |       }));
202 |       
203 |       // Re-require AccountManager to use new mocks
204 |       const { AccountManager } = require('../../../modules/accounts/manager.js');
205 |       accountManager = new AccountManager();
206 |       await accountManager.initialize();
207 |       
208 |       const account = await accountManager.validateAccount(testEmail);
209 |       
210 |       expect(mockValidateToken).toHaveBeenCalledWith(testEmail, false);
211 |       expect(account.auth_status).toMatchObject({
212 |         valid: false,
213 |         status: 'EXPIRED',
214 |         reason: 'Token expired'
215 |       });
216 |       expect(account.auth_status).toHaveProperty('authUrl');
217 |     });
218 |   });
219 | 
220 |   // OAuth operations (simplified)
221 |   describe('oauth operations', () => {
222 |     it('should handle token from auth code', async () => {
223 |       await accountManager.initialize();
224 |       const token = await accountManager.getTokenFromCode('test-code');
225 |       
226 |       expect(token).toEqual(mockTokens.valid);
227 |     });
228 | 
229 |     it('should save token for account', async () => {
230 |       // Reset all mocks and modules
231 |       jest.clearAllMocks();
232 |       jest.resetModules();
233 |       
234 |       // Re-require fs and setup fresh state
235 |       const fs = require('fs/promises');
236 |       fs.readFile.mockResolvedValue(JSON.stringify(mockAccounts));
237 |       
238 |       // Create mock implementation
239 |       const mockSaveToken = jest.fn().mockResolvedValue(undefined);
240 |       
241 |       // Setup TokenManager with tracked mock
242 |       jest.doMock('../../../modules/accounts/token.js', () => ({
243 |         TokenManager: jest.fn().mockImplementation(() => ({
244 |           validateToken: jest.fn().mockResolvedValue({ valid: true }),
245 |           saveToken: mockSaveToken,
246 |           deleteToken: jest.fn()
247 |         }))
248 |       }));
249 |       
250 |       // Re-require AccountManager to use new mocks
251 |       const { AccountManager } = require('../../../modules/accounts/manager.js');
252 |       accountManager = new AccountManager();
253 |       await accountManager.initialize();
254 |       
255 |       const testEmail = '[email protected]';
256 |       await accountManager.saveToken(testEmail, mockTokens.valid);
257 |       
258 |       expect(mockSaveToken).toHaveBeenCalledWith(testEmail, mockTokens.valid);
259 |       expect(mockSaveToken).toHaveBeenCalledTimes(1);
260 |     });
261 |   });
262 | });
263 | 
```
--------------------------------------------------------------------------------
/llms-install.md:
--------------------------------------------------------------------------------
```markdown
  1 | # Google Workspace MCP Server - AI Assistant Installation Guide
  2 | 
  3 | This guide provides step-by-step instructions for setting up the Google Workspace MCP server with AI assistants like Claude Desktop and Cline.
  4 | 
  5 | ## Overview
  6 | 
  7 | The Google Workspace MCP server enables AI assistants to interact with your Google Workspace services (Gmail, Calendar, Drive, Contacts) through a secure OAuth 2.0 authentication flow. The server runs in a Docker container and handles all API interactions.
  8 | 
  9 | ## Prerequisites
 10 | 
 11 | ### System Requirements
 12 | - Docker installed and running
 13 | - Internet connection for Google API access
 14 | - Available port 8080 for OAuth callback handling
 15 | 
 16 | ### Google Cloud Setup
 17 | 
 18 | 1. **Create Google Cloud Project**:
 19 |    - Visit [Google Cloud Console](https://console.cloud.google.com)
 20 |    - Create a new project or select an existing one
 21 |    - Note your project ID for reference
 22 | 
 23 | 2. **Enable Required APIs**:
 24 |    Navigate to "APIs & Services" > "Library" and enable:
 25 |    - Gmail API
 26 |    - Google Calendar API
 27 |    - Google Drive API
 28 |    - People API (for Contacts)
 29 | 
 30 | 3. **Configure OAuth Consent Screen**:
 31 |    - Go to "APIs & Services" > "OAuth consent screen"
 32 |    - Choose "External" user type
 33 |    - Fill in required application information:
 34 |      - App name: "Google Workspace MCP Server" (or your preference)
 35 |      - User support email: Your email address
 36 |      - Developer contact information: Your email address
 37 |    - Add yourself as a test user in the "Test users" section
 38 | 
 39 | 4. **Create OAuth 2.0 Credentials**:
 40 |    - Go to "APIs & Services" > "Credentials"
 41 |    - Click "Create Credentials" > "OAuth 2.0 Client IDs"
 42 |    - **Important**: Select "Web application" (not Desktop application)
 43 |    - Set application name: "Google Workspace MCP Server"
 44 |    - Add authorized redirect URI: `http://localhost:8080`
 45 |    - Save and note your Client ID and Client Secret
 46 | 
 47 | ## Installation Steps
 48 | 
 49 | ### Step 1: Create Configuration Directory
 50 | 
 51 | Create a local directory for storing authentication tokens:
 52 | 
 53 | ```bash
 54 | mkdir -p ~/.mcp/google-workspace-mcp
 55 | ```
 56 | 
 57 | ### Step 2: Configure Your MCP Client
 58 | 
 59 | Choose the appropriate configuration for your AI assistant:
 60 | 
 61 | #### For Claude Desktop
 62 | 
 63 | Edit your Claude Desktop configuration file:
 64 | - **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
 65 | - **Windows**: `%APPDATA%\Claude\claude_desktop_config.json`
 66 | - **Linux**: `~/.config/Claude/claude_desktop_config.json`
 67 | 
 68 | Add the following configuration:
 69 | 
 70 | ```json
 71 | {
 72 |   "mcpServers": {
 73 |     "google-workspace-mcp": {
 74 |       "command": "docker",
 75 |       "args": [
 76 |         "run",
 77 |         "--rm",
 78 |         "-i",
 79 |         "-p", "8080:8080",
 80 |         "-v", "~/.mcp/google-workspace-mcp:/app/config",
 81 |         "-v", "~/Documents/workspace-mcp-files:/app/workspace",
 82 |         "-e", "GOOGLE_CLIENT_ID",
 83 |         "-e", "GOOGLE_CLIENT_SECRET",
 84 |         "-e", "LOG_MODE=strict",
 85 |         "ghcr.io/aaronsb/google-workspace-mcp:latest"
 86 |       ],
 87 |       "env": {
 88 |         "GOOGLE_CLIENT_ID": "your-client-id.apps.googleusercontent.com",
 89 |         "GOOGLE_CLIENT_SECRET": "your-client-secret"
 90 |       }
 91 |     }
 92 |   }
 93 | }
 94 | ```
 95 | 
 96 | #### For Cline (VS Code Extension)
 97 | 
 98 | Edit your Cline MCP settings file:
 99 | `~/.config/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json`
100 | 
101 | ```json
102 | {
103 |   "mcpServers": {
104 |     "google-workspace-mcp": {
105 |       "command": "docker",
106 |       "args": [
107 |         "run",
108 |         "--rm",
109 |         "-i",
110 |         "-p", "8080:8080",
111 |         "-v", "~/.mcp/google-workspace-mcp:/app/config",
112 |         "-v", "~/Documents/workspace-mcp-files:/app/workspace",
113 |         "-e", "GOOGLE_CLIENT_ID",
114 |         "-e", "GOOGLE_CLIENT_SECRET",
115 |         "-e", "LOG_MODE=strict",
116 |         "ghcr.io/aaronsb/google-workspace-mcp:latest"
117 |       ],
118 |       "env": {
119 |         "GOOGLE_CLIENT_ID": "your-client-id.apps.googleusercontent.com",
120 |         "GOOGLE_CLIENT_SECRET": "your-client-secret"
121 |       }
122 |     }
123 |   }
124 | }
125 | ```
126 | 
127 | **Important Configuration Notes**:
128 | - Replace `your-client-id.apps.googleusercontent.com` with your actual Google OAuth Client ID
129 | - Replace `your-client-secret` with your actual Google OAuth Client Secret
130 | - The `-p 8080:8080` port mapping is required for OAuth callback handling
131 | - Adjust volume paths if you prefer different local directories
132 | 
133 | ### Step 3: Restart Your AI Assistant
134 | 
135 | After updating the configuration:
136 | - **Claude Desktop**: Completely quit and restart the application
137 | - **Cline**: Restart VS Code or reload the Cline extension
138 | 
139 | ## Authentication Process
140 | 
141 | ### Initial Account Setup
142 | 
143 | 1. **Start Authentication**:
144 |    Ask your AI assistant: "Add my Google account" or "Set up Google Workspace access"
145 | 
146 | 2. **OAuth Flow**:
147 |    - The assistant will provide a Google authorization URL
148 |    - Click the URL to open it in your browser
149 |    - Sign in to your Google account
150 |    - Review and accept the requested permissions
151 |    - You'll be redirected to a success page showing your authorization code
152 | 
153 | 3. **Complete Authentication**:
154 |    - Copy the authorization code from the success page
155 |    - Provide the code back to your AI assistant
156 |    - The assistant will complete the authentication and save your tokens
157 | 
158 | ### Managing Multiple Accounts
159 | 
160 | You can authenticate multiple Google accounts:
161 | - Each account is stored separately with its own tokens
162 | - Use account categorization (e.g., "work", "personal") for organization
163 | - Switch between accounts as needed for different operations
164 | 
165 | ## Verification
166 | 
167 | ### Test Your Setup
168 | 
169 | After authentication, verify the setup works:
170 | 
171 | 1. **List Accounts**: Ask "List my Google accounts" to see authenticated accounts
172 | 2. **Test Gmail**: Ask "Show me my recent emails"
173 | 3. **Test Calendar**: Ask "What's on my calendar today?"
174 | 4. **Test Drive**: Ask "List files in my Google Drive"
175 | 
176 | ### Common Usage Examples
177 | 
178 | - "Search for emails from [email protected] in the last week"
179 | - "Create a calendar event for tomorrow at 2 PM"
180 | - "Upload this document to my Google Drive"
181 | - "Show me my contacts with 'Smith' in the name"
182 | 
183 | ## Troubleshooting
184 | 
185 | ### Authentication Issues
186 | 
187 | **Problem**: "Invalid OAuth credentials" error
188 | **Solution**:
189 | - Verify Client ID and Client Secret are correctly copied
190 | - Ensure OAuth consent screen is properly configured
191 | - Check that you're added as a test user
192 | 
193 | **Problem**: "Connection refused" on localhost:8080
194 | **Solution**:
195 | - Verify port 8080 is not blocked by firewall
196 | - Ensure Docker has permission to bind to port 8080
197 | - Check that no other service is using port 8080
198 | 
199 | ### Configuration Issues
200 | 
201 | **Problem**: MCP server not starting
202 | **Solution**:
203 | - Verify Docker is running
204 | 
205 |    macOS:
206 |    - Shut down Docker fully from command line with `pkill -SIGHUP -f /Applications/Docker.app 'docker serve'`
207 |    - Restart Docker Desktop
208 |    - Restart your MCP client (Claude Desktop or Cursor/Cline/etc.)
209 | 
210 |    Windows:
211 |    - Open Task Manager (Ctrl+Shift+Esc)
212 |    - Find and end the "Docker Desktop" process
213 |    - Restart Docker Desktop from the Start menu
214 |    - Restart your MCP client (Claude Desktop or Cursor/Cline/etc.)
215 | 
216 | - Check that configuration directory exists and has proper permissions
217 | - Ensure Docker image can be pulled from registry
218 | 
219 | **Problem**: "Directory not found" errors
220 | **Solution**:
221 | - Create the config directory: `mkdir -p ~/.mcp/google-workspace-mcp`
222 | - Verify volume mount paths in configuration are correct
223 | - Check file permissions on mounted directories
224 | 
225 | ### API Issues
226 | 
227 | **Problem**: "API not enabled" errors
228 | **Solution**:
229 | - Verify all required APIs are enabled in Google Cloud Console
230 | - Wait a few minutes after enabling APIs for changes to propagate
231 | - Check API quotas and limits in Google Cloud Console
232 | 
233 | ## Security Best Practices
234 | 
235 | 1. **Credential Management**:
236 |    - Store OAuth credentials securely in MCP configuration
237 |    - Never commit credentials to version control
238 |    - Regularly rotate OAuth client secrets
239 | 
240 | 2. **Access Control**:
241 |    - Use minimal required API scopes
242 |    - Regularly review and audit account access
243 |    - Remove unused accounts from authentication
244 | 
245 | 3. **Network Security**:
246 |    - OAuth callback server only runs during authentication
247 |    - All API communication uses HTTPS
248 |    - Tokens are stored locally and encrypted
249 | 
250 | ## Advanced Configuration
251 | 
252 | ### Custom File Workspace
253 | 
254 | To use a different directory for file operations:
255 | 
256 | ```json
257 | "args": [
258 |   "run",
259 |   "--rm",
260 |   "-i",
261 |   "-p", "8080:8080",
262 |   "-v", "~/.mcp/google-workspace-mcp:/app/config",
263 |   "-v", "/path/to/your/workspace:/app/workspace",
264 |   "-e", "WORKSPACE_BASE_PATH=/app/workspace",
265 |   "ghcr.io/aaronsb/google-workspace-mcp:latest"
266 | ]
267 | ```
268 | 
269 | ### Logging Configuration
270 | 
271 | For debugging, you can adjust logging levels:
272 | 
273 | ```json
274 | "env": {
275 |   "GOOGLE_CLIENT_ID": "your-client-id",
276 |   "GOOGLE_CLIENT_SECRET": "your-client-secret",
277 |   "LOG_MODE": "normal",
278 |   "LOG_LEVEL": "debug"
279 | }
280 | ```
281 | 
282 | ## Getting Help
283 | 
284 | If you encounter issues:
285 | 
286 | 1. Check the [main documentation](README.md) for additional troubleshooting
287 | 2. Review [error documentation](docs/ERRORS.md) for specific error codes
288 | 3. Examine Docker logs: `docker logs <container-id>`
289 | 4. Submit issues on the project's GitHub repository
290 | 
291 | ## Next Steps
292 | 
293 | Once setup is complete:
294 | - Explore the [API documentation](docs/API.md) for detailed tool usage
295 | - Review [examples](docs/EXAMPLES.md) for common use cases
296 | - Consider setting up multiple accounts for different workflows
297 | 
```
--------------------------------------------------------------------------------
/src/modules/accounts/callback-server.ts:
--------------------------------------------------------------------------------
```typescript
  1 | import http from 'http';
  2 | import url from 'url';
  3 | import logger from '../../utils/logger.js';
  4 | 
  5 | export class OAuthCallbackServer {
  6 |   private static instance?: OAuthCallbackServer;
  7 |   private server?: http.Server;
  8 |   private port: number = 8080;
  9 |   private isRunning: boolean = false;
 10 |   private pendingPromises: Map<string, { resolve: (code: string) => void; reject: (error: Error) => void }> = new Map();
 11 |   private authHandler?: (code: string, state: string) => Promise<void>;
 12 |   
 13 |   private constructor() {}
 14 |   
 15 |   static getInstance(): OAuthCallbackServer {
 16 |     if (!OAuthCallbackServer.instance) {
 17 |       OAuthCallbackServer.instance = new OAuthCallbackServer();
 18 |     }
 19 |     return OAuthCallbackServer.instance;
 20 |   }
 21 |   
 22 |   async ensureServerRunning(): Promise<void> {
 23 |     if (this.isRunning) {
 24 |       return;
 25 |     }
 26 |     
 27 |     return new Promise((resolve, reject) => {
 28 |       this.server = http.createServer((req, res) => {
 29 |         const parsedUrl = url.parse(req.url || '', true);
 30 |         
 31 |         // Handle the auto-complete endpoint
 32 |         if (parsedUrl.pathname === '/complete-auth' && req.method === 'POST') {
 33 |           let body = '';
 34 |           req.on('data', chunk => {
 35 |             body += chunk.toString();
 36 |           });
 37 |           req.on('end', async () => {
 38 |             try {
 39 |               const { code, state } = JSON.parse(body);
 40 |               
 41 |               // Automatically complete the authentication
 42 |               if (this.authHandler) {
 43 |                 await this.authHandler(code, state || 'default');
 44 |                 logger.info('OAuth authentication completed automatically');
 45 |               }
 46 |               
 47 |               // Also resolve any pending promises (for backward compatibility)
 48 |               const pending = this.pendingPromises.get(state || 'default');
 49 |               if (pending) {
 50 |                 pending.resolve(code);
 51 |                 this.pendingPromises.delete(state || 'default');
 52 |               }
 53 |               
 54 |               res.writeHead(200, { 'Content-Type': 'application/json' });
 55 |               res.end(JSON.stringify({ success: true }));
 56 |             } catch (error) {
 57 |               logger.error('Failed to process auto-complete request:', error);
 58 |               res.writeHead(400, { 'Content-Type': 'application/json' });
 59 |               res.end(JSON.stringify({ success: false, error: 'Invalid request' }));
 60 |             }
 61 |           });
 62 |           return;
 63 |         }
 64 |         
 65 |         if (parsedUrl.pathname === '/') {
 66 |           const code = parsedUrl.query.code as string;
 67 |           const error = parsedUrl.query.error as string;
 68 |           const state = parsedUrl.query.state as string || 'default';
 69 |           
 70 |           if (error) {
 71 |             res.writeHead(400, { 'Content-Type': 'text/html' });
 72 |             res.end(`
 73 |               <html>
 74 |                 <body>
 75 |                   <h1>Authorization Failed</h1>
 76 |                   <p>Error: ${error}</p>
 77 |                   <p>You can close this window.</p>
 78 |                 </body>
 79 |               </html>
 80 |             `);
 81 |             
 82 |             // Reject any pending promises
 83 |             const pending = this.pendingPromises.get(state);
 84 |             if (pending) {
 85 |               pending.reject(new Error(`OAuth error: ${error}`));
 86 |               this.pendingPromises.delete(state);
 87 |             }
 88 |             return;
 89 |           }
 90 |           
 91 |           if (code) {
 92 |             res.writeHead(200, { 'Content-Type': 'text/html' });
 93 |             res.end(`
 94 |               <html>
 95 |                 <head>
 96 |                   <title>Google OAuth Authorization Successful</title>
 97 |                   <style>
 98 |                     body { font-family: Arial, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
 99 |                     .success-message { 
100 |                       background: #4CAF50; 
101 |                       color: white; 
102 |                       padding: 20px; 
103 |                       border-radius: 5px; 
104 |                       margin-bottom: 20px;
105 |                       text-align: center;
106 |                     }
107 |                     .status { 
108 |                       background: #e7f3ff; 
109 |                       padding: 15px; 
110 |                       border-left: 4px solid #2196F3;
111 |                       margin: 20px 0;
112 |                     }
113 |                     .loading {
114 |                       display: inline-block;
115 |                       width: 20px;
116 |                       height: 20px;
117 |                       border: 3px solid #f3f3f3;
118 |                       border-top: 3px solid #3498db;
119 |                       border-radius: 50%;
120 |                       animation: spin 1s linear infinite;
121 |                       margin-left: 10px;
122 |                       vertical-align: middle;
123 |                     }
124 |                     @keyframes spin {
125 |                       0% { transform: rotate(0deg); }
126 |                       100% { transform: rotate(360deg); }
127 |                     }
128 |                     .code-fallback {
129 |                       font-family: monospace;
130 |                       background: #f5f5f5;
131 |                       padding: 10px;
132 |                       margin: 10px 0;
133 |                       word-break: break-all;
134 |                       display: none;
135 |                     }
136 |                   </style>
137 |                 </head>
138 |                 <body>
139 |                   <div class="success-message">
140 |                     <h1>✅ Authorization Successful!</h1>
141 |                   </div>
142 |                   
143 |                   <div class="status" id="status">
144 |                     <h3>Completing authentication automatically...</h3>
145 |                     <p>Please wait while we complete the authentication process <span class="loading"></span></p>
146 |                   </div>
147 |                   
148 |                   <div class="code-fallback" id="codeFallback">
149 |                     <p>If automatic authentication fails, you can manually copy this code:</p>
150 |                     <code>${code}</code>
151 |                   </div>
152 |                   
153 |                   <script>
154 |                     // Automatically submit the authorization code to complete the flow
155 |                     async function completeAuth() {
156 |                       try {
157 |                         // Send the code to a special endpoint that will trigger the promise resolution
158 |                         const response = await fetch('/complete-auth', {
159 |                           method: 'POST',
160 |                           headers: {
161 |                             'Content-Type': 'application/json',
162 |                           },
163 |                           body: JSON.stringify({ 
164 |                             code: '${code}',
165 |                             state: '${state}'
166 |                           })
167 |                         });
168 |                         
169 |                         if (response.ok) {
170 |                           document.getElementById('status').innerHTML = 
171 |                             '<h3>✅ Authentication Complete!</h3>' +
172 |                             '<p>You can now close this window and return to Claude Desktop.</p>';
173 |                         } else {
174 |                           throw new Error('Failed to complete authentication');
175 |                         }
176 |                       } catch (error) {
177 |                         console.error('Auto-complete failed:', error);
178 |                         document.getElementById('status').innerHTML = 
179 |                           '<h3>⚠️ Automatic completion failed</h3>' +
180 |                           '<p>Please copy the code below and paste it back to Claude Desktop:</p>';
181 |                         document.getElementById('codeFallback').style.display = 'block';
182 |                       }
183 |                     }
184 |                     
185 |                     // Start the auto-completion process
186 |                     setTimeout(completeAuth, 500);
187 |                   </script>
188 |                 </body>
189 |               </html>
190 |             `);
191 |             
192 |             // Immediately trigger the authentication completion
193 |             // by posting to our own complete-auth endpoint
194 |             // Don't resolve here anymore - let the auto-complete endpoint handle it
195 |             return;
196 |           }
197 |         }
198 |         
199 |         res.writeHead(404, { 'Content-Type': 'text/html' });
200 |         res.end('<html><body><h1>Not Found</h1></body></html>');
201 |       });
202 |       
203 |       this.server.listen(this.port, () => {
204 |         this.isRunning = true;
205 |         logger.info(`OAuth callback server listening on http://localhost:${this.port}`);
206 |         resolve();
207 |       });
208 |       
209 |       this.server.on('error', (err) => {
210 |         this.isRunning = false;
211 |         reject(err);
212 |       });
213 |     });
214 |   }
215 |   
216 |   async waitForAuthorizationCode(sessionId: string = 'default'): Promise<string> {
217 |     await this.ensureServerRunning();
218 |     
219 |     return new Promise((resolve, reject) => {
220 |       // Store the promise resolvers for this session
221 |       this.pendingPromises.set(sessionId, { resolve, reject });
222 |       
223 |       // Set a timeout to avoid hanging forever
224 |       setTimeout(() => {
225 |         if (this.pendingPromises.has(sessionId)) {
226 |           this.pendingPromises.delete(sessionId);
227 |           reject(new Error('OAuth timeout - no authorization received within 5 minutes'));
228 |         }
229 |       }, 5 * 60 * 1000); // 5 minutes timeout
230 |     });
231 |   }
232 |   
233 |   getCallbackUrl(): string {
234 |     return `http://localhost:${this.port}`;
235 |   }
236 |   
237 |   isServerRunning(): boolean {
238 |     return this.isRunning;
239 |   }
240 | 
241 |   setAuthHandler(handler: (code: string, state: string) => Promise<void>) {
242 |     this.authHandler = handler;
243 |   }
244 | }
245 | 
```
--------------------------------------------------------------------------------
/src/modules/drive/service.ts:
--------------------------------------------------------------------------------
```typescript
  1 | import { google } from 'googleapis';
  2 | import { BaseGoogleService } from '../../services/base/BaseGoogleService.js';
  3 | import { DriveOperationResult, FileDownloadOptions, FileListOptions, FileSearchOptions, FileUploadOptions, PermissionOptions } from './types.js';
  4 | import { Readable } from 'stream';
  5 | import { DRIVE_SCOPES } from './scopes.js';
  6 | import { workspaceManager } from '../../utils/workspace.js';
  7 | import fs from 'fs/promises';
  8 | import { GaxiosResponse } from 'gaxios';
  9 | 
 10 | export class DriveService extends BaseGoogleService<ReturnType<typeof google.drive>> {
 11 |   private initialized = false;
 12 | 
 13 |   constructor() {
 14 |     super({
 15 |       serviceName: 'Google Drive',
 16 |       version: 'v3'
 17 |     });
 18 |   }
 19 | 
 20 |   /**
 21 |    * Initialize the Drive service and all dependencies
 22 |    */
 23 |   public async initialize(): Promise<void> {
 24 |     try {
 25 |       await super.initialize();
 26 |       this.initialized = true;
 27 |     } catch (error) {
 28 |       throw this.handleError(error, 'Failed to initialize Drive service');
 29 |     }
 30 |   }
 31 | 
 32 |   /**
 33 |    * Ensure the Drive service is initialized
 34 |    */
 35 |   public async ensureInitialized(): Promise<void> {
 36 |     if (!this.initialized) {
 37 |       await this.initialize();
 38 |     }
 39 |   }
 40 | 
 41 |   /**
 42 |    * Check if the service is initialized
 43 |    */
 44 |   private checkInitialized(): void {
 45 |     if (!this.initialized) {
 46 |       throw this.handleError(
 47 |         new Error('Drive service not initialized'),
 48 |         'Please ensure the service is initialized before use'
 49 |       );
 50 |     }
 51 |   }
 52 | 
 53 |   async listFiles(email: string, options: FileListOptions = {}): Promise<DriveOperationResult> {
 54 |     try {
 55 |       await this.ensureInitialized();
 56 |       this.checkInitialized();
 57 |       await this.validateScopes(email, [DRIVE_SCOPES.FILE]);
 58 |       const client = await this.getAuthenticatedClient(
 59 |         email,
 60 |         (auth) => google.drive({ version: 'v3', auth })
 61 |       );
 62 | 
 63 |       const query = [];
 64 |       if (options.folderId) {
 65 |         query.push(`'${options.folderId}' in parents`);
 66 |       }
 67 |       if (options.query) {
 68 |         query.push(options.query);
 69 |       }
 70 | 
 71 |       const response = await client.files.list({
 72 |         q: query.join(' and ') || undefined,
 73 |         pageSize: options.pageSize,
 74 |         orderBy: options.orderBy?.join(','),
 75 |         fields: options.fields?.join(',') || 'files(id, name, mimeType, modifiedTime, size)',
 76 |       });
 77 | 
 78 |       return {
 79 |         success: true,
 80 |         data: response.data,
 81 |       };
 82 |     } catch (error) {
 83 |       return {
 84 |         success: false,
 85 |         error: error instanceof Error ? error.message : 'Unknown error occurred',
 86 |       };
 87 |     }
 88 |   }
 89 | 
 90 |   async uploadFile(email: string, options: FileUploadOptions): Promise<DriveOperationResult> {
 91 |     try {
 92 |       await this.ensureInitialized();
 93 |       await this.validateScopes(email, [DRIVE_SCOPES.FILE]);
 94 |       const client = await this.getAuthenticatedClient(
 95 |         email,
 96 |         (auth) => google.drive({ version: 'v3', auth })
 97 |       );
 98 | 
 99 |       // Save content to workspace first
100 |       const uploadPath = await workspaceManager.getUploadPath(email, options.name);
101 |       await fs.writeFile(uploadPath, options.content);
102 | 
103 |       const fileContent = await fs.readFile(uploadPath);
104 |       const media = {
105 |         mimeType: options.mimeType || 'application/octet-stream',
106 |         body: Readable.from([fileContent]),
107 |       };
108 | 
109 |       const response = await client.files.create({
110 |         requestBody: {
111 |           name: options.name,
112 |           mimeType: options.mimeType,
113 |           parents: options.parents,
114 |         },
115 |         media,
116 |         fields: 'id, name, mimeType, webViewLink',
117 |       });
118 | 
119 |       return {
120 |         success: true,
121 |         data: response.data,
122 |       };
123 |     } catch (error) {
124 |       return {
125 |         success: false,
126 |         error: error instanceof Error ? error.message : 'Unknown error occurred',
127 |       };
128 |     }
129 |   }
130 | 
131 |   async downloadFile(email: string, options: FileDownloadOptions): Promise<DriveOperationResult> {
132 |     try {
133 |       await this.ensureInitialized();
134 |       await this.validateScopes(email, [DRIVE_SCOPES.FILE]);
135 |       const client = await this.getAuthenticatedClient(
136 |         email,
137 |         (auth) => google.drive({ version: 'v3', auth })
138 |       );
139 | 
140 |       // First get file metadata to check mime type and name
141 |       const file = await client.files.get({
142 |         fileId: options.fileId,
143 |         fields: 'name,mimeType',
144 |       });
145 | 
146 |       const fileName = file.data.name || options.fileId;
147 | 
148 |       // Handle Google Workspace files differently
149 |       if (file.data.mimeType?.startsWith('application/vnd.google-apps')) {
150 |         let exportMimeType = options.mimeType || 'text/plain';
151 |         
152 |         // Default export formats if not specified
153 |         if (!options.mimeType) {
154 |           switch (file.data.mimeType) {
155 |             case 'application/vnd.google-apps.document':
156 |               exportMimeType = 'text/markdown';
157 |               break;
158 |             case 'application/vnd.google-apps.spreadsheet':
159 |               exportMimeType = 'text/csv';
160 |               break;
161 |             case 'application/vnd.google-apps.presentation':
162 |               exportMimeType = 'text/plain';
163 |               break;
164 |             case 'application/vnd.google-apps.drawing':
165 |               exportMimeType = 'image/png';
166 |               break;
167 |           }
168 |         }
169 | 
170 |         const response = await client.files.export({
171 |           fileId: options.fileId,
172 |           mimeType: exportMimeType
173 |         }, {
174 |           responseType: 'arraybuffer'
175 |         }) as unknown as GaxiosResponse<Uint8Array>;
176 | 
177 |         const downloadPath = await workspaceManager.getDownloadPath(email, fileName);
178 |         await fs.writeFile(downloadPath, Buffer.from(response.data));
179 | 
180 |         return {
181 |           success: true,
182 |           data: response.data,
183 |           mimeType: exportMimeType,
184 |           filePath: downloadPath
185 |         };
186 |       }
187 | 
188 |       // For regular files
189 |       const response = await client.files.get({
190 |         fileId: options.fileId,
191 |         alt: 'media'
192 |       }, {
193 |         responseType: 'arraybuffer'
194 |       }) as unknown as GaxiosResponse<Uint8Array>;
195 | 
196 |       const downloadPath = await workspaceManager.getDownloadPath(email, fileName);
197 |       await fs.writeFile(downloadPath, Buffer.from(response.data));
198 | 
199 |       return {
200 |         success: true,
201 |         data: response.data,
202 |         filePath: downloadPath
203 |       };
204 |     } catch (error) {
205 |       return {
206 |         success: false,
207 |         error: error instanceof Error ? error.message : 'Unknown error occurred',
208 |       };
209 |     }
210 |   }
211 | 
212 |   async createFolder(email: string, name: string, parentId?: string): Promise<DriveOperationResult> {
213 |     try {
214 |       await this.ensureInitialized();
215 |       await this.validateScopes(email, [DRIVE_SCOPES.FILE]);
216 |       const client = await this.getAuthenticatedClient(
217 |         email,
218 |         (auth) => google.drive({ version: 'v3', auth })
219 |       );
220 | 
221 |       const response = await client.files.create({
222 |         requestBody: {
223 |           name,
224 |           mimeType: 'application/vnd.google-apps.folder',
225 |           parents: parentId ? [parentId] : undefined,
226 |         },
227 |         fields: 'id, name, mimeType, webViewLink',
228 |       });
229 | 
230 |       return {
231 |         success: true,
232 |         data: response.data,
233 |       };
234 |     } catch (error) {
235 |       return {
236 |         success: false,
237 |         error: error instanceof Error ? error.message : 'Unknown error occurred',
238 |       };
239 |     }
240 |   }
241 | 
242 |   async searchFiles(email: string, options: FileSearchOptions): Promise<DriveOperationResult> {
243 |     try {
244 |       await this.ensureInitialized();
245 |       await this.validateScopes(email, [DRIVE_SCOPES.FILE]);
246 |       const client = await this.getAuthenticatedClient(
247 |         email,
248 |         (auth) => google.drive({ version: 'v3', auth })
249 |       );
250 | 
251 |       const query = [];
252 |       
253 |       if (options.fullText) {
254 |         const escapedQuery = options.fullText.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
255 |         query.push(`fullText contains '${escapedQuery}'`);
256 |       }
257 |       if (options.mimeType) {
258 |         query.push(`mimeType = '${options.mimeType}'`);
259 |       }
260 |       if (options.folderId) {
261 |         query.push(`'${options.folderId}' in parents`);
262 |       }
263 |       if (options.trashed !== undefined) {
264 |         query.push(`trashed = ${options.trashed}`);
265 |       }
266 |       if (options.query) {
267 |         query.push(options.query);
268 |       }
269 | 
270 |       const response = await client.files.list({
271 |         q: query.join(' and ') || undefined,
272 |         pageSize: options.pageSize,
273 |         orderBy: options.orderBy?.join(','),
274 |         fields: options.fields?.join(',') || 'files(id, name, mimeType, modifiedTime, size)',
275 |       });
276 | 
277 |       return {
278 |         success: true,
279 |         data: response.data,
280 |       };
281 |     } catch (error) {
282 |       return {
283 |         success: false,
284 |         error: error instanceof Error ? error.message : 'Unknown error occurred',
285 |       };
286 |     }
287 |   }
288 | 
289 |   async updatePermissions(email: string, options: PermissionOptions): Promise<DriveOperationResult> {
290 |     try {
291 |       await this.ensureInitialized();
292 |       await this.validateScopes(email, [DRIVE_SCOPES.FILE]);
293 |       const client = await this.getAuthenticatedClient(
294 |         email,
295 |         (auth) => google.drive({ version: 'v3', auth })
296 |       );
297 | 
298 |       const response = await client.permissions.create({
299 |         fileId: options.fileId,
300 |         requestBody: {
301 |           role: options.role,
302 |           type: options.type,
303 |           emailAddress: options.emailAddress,
304 |           domain: options.domain,
305 |           allowFileDiscovery: options.allowFileDiscovery,
306 |         },
307 |       });
308 | 
309 |       return {
310 |         success: true,
311 |         data: response.data,
312 |       };
313 |     } catch (error) {
314 |       return {
315 |         success: false,
316 |         error: error instanceof Error ? error.message : 'Unknown error occurred',
317 |       };
318 |     }
319 |   }
320 | 
321 |   async deleteFile(email: string, fileId: string): Promise<DriveOperationResult> {
322 |     try {
323 |       await this.ensureInitialized();
324 |       await this.validateScopes(email, [DRIVE_SCOPES.FILE]);
325 |       const client = await this.getAuthenticatedClient(
326 |         email,
327 |         (auth) => google.drive({ version: 'v3', auth })
328 |       );
329 | 
330 |       await client.files.delete({
331 |         fileId,
332 |       });
333 | 
334 |       return {
335 |         success: true,
336 |       };
337 |     } catch (error) {
338 |       return {
339 |         success: false,
340 |         error: error instanceof Error ? error.message : 'Unknown error occurred',
341 |       };
342 |     }
343 |   }
344 | }
345 | 
```
--------------------------------------------------------------------------------
/src/modules/gmail/services/draft.ts:
--------------------------------------------------------------------------------
```typescript
  1 | 
  2 | import { google } from 'googleapis';
  3 | import { 
  4 |   GmailError, 
  5 |   OutgoingGmailAttachment,
  6 |   IncomingGmailAttachment 
  7 | } from '../types.js';
  8 | import { GmailAttachmentService } from './attachment.js';
  9 | 
 10 | export type DraftAction = 'create' | 'read' | 'update' | 'delete' | 'send';
 11 | 
 12 | export interface ManageDraftParams {
 13 |   email: string;
 14 |   action: DraftAction;
 15 |   draftId?: string;
 16 |   data?: DraftData;
 17 | }
 18 | 
 19 | export interface DraftData {
 20 |   to: string[];
 21 |   subject: string;
 22 |   body: string;
 23 |   cc?: string[];
 24 |   bcc?: string[];
 25 |   threadId?: string; // For reply drafts
 26 |   attachments?: OutgoingGmailAttachment[];
 27 | }
 28 | 
 29 | export class DraftService {
 30 |   private gmailClient?: ReturnType<typeof google.gmail>;
 31 |   constructor(private attachmentService: GmailAttachmentService) {}
 32 | 
 33 |   async initialize(): Promise<void> {
 34 |     // Initialization will be handled by Gmail service
 35 |   }
 36 | 
 37 |   updateClient(client: ReturnType<typeof google.gmail>) {
 38 |     this.gmailClient = client;
 39 |   }
 40 | 
 41 |   private ensureClient(): ReturnType<typeof google.gmail> {
 42 |     if (!this.gmailClient) {
 43 |       throw new GmailError(
 44 |         'Gmail client not initialized',
 45 |         'CLIENT_ERROR',
 46 |         'Please ensure the service is initialized'
 47 |       );
 48 |     }
 49 |     return this.gmailClient;
 50 |   }
 51 | 
 52 |   async createDraft(email: string, data: DraftData) {
 53 |     try {
 54 |       const client = this.ensureClient();
 55 | 
 56 |       // Validate and prepare attachments
 57 |       const processedAttachments = data.attachments?.map(attachment => {
 58 |         this.attachmentService.validateAttachment(attachment);
 59 |         return this.attachmentService.prepareAttachment(attachment);
 60 |       }) || [];
 61 | 
 62 |       // Construct email with attachments
 63 |       const boundary = `boundary_${Date.now()}`;
 64 |       const messageParts = [
 65 |         'MIME-Version: 1.0\n',
 66 |         `Content-Type: multipart/mixed; boundary="${boundary}"\n`,
 67 |         `To: ${data.to.join(', ')}\n`,
 68 |         data.cc?.length ? `Cc: ${data.cc.join(', ')}\n` : '',
 69 |         data.bcc?.length ? `Bcc: ${data.bcc.join(', ')}\n` : '',
 70 |         `Subject: ${data.subject}\n\n`,
 71 |         `--${boundary}\n`,
 72 |         'Content-Type: text/plain; charset="UTF-8"\n',
 73 |         'Content-Transfer-Encoding: 7bit\n\n',
 74 |         data.body,
 75 |         '\n'
 76 |       ];
 77 | 
 78 |       // Add attachments
 79 |       for (const attachment of processedAttachments) {
 80 |         messageParts.push(
 81 |           `--${boundary}\n`,
 82 |           `Content-Type: ${attachment.mimeType}\n`,
 83 |           'Content-Transfer-Encoding: base64\n',
 84 |           `Content-Disposition: attachment; filename="${attachment.filename}"\n\n`,
 85 |             attachment.content.toString(),
 86 |           '\n'
 87 |         );
 88 |       }
 89 | 
 90 |       messageParts.push(`--${boundary}--`);
 91 |       const fullMessage = messageParts.join('');
 92 | 
 93 |       // Create draft with threadId if it's a reply
 94 |       const { data: draft } = await client.users.drafts.create({
 95 |         userId: 'me',
 96 |         requestBody: {
 97 |           message: {
 98 |             raw: Buffer.from(fullMessage).toString('base64'),
 99 |             threadId: data.threadId // Include threadId for replies
100 |           }
101 |         }
102 |       });
103 | 
104 |       return {
105 |         id: draft.id || '',
106 |         message: {
107 |           id: draft.message?.id || '',
108 |           threadId: draft.message?.threadId || '',
109 |           labelIds: draft.message?.labelIds || []
110 |         },
111 |         updated: new Date().toISOString(),
112 |         attachments: data.attachments
113 |       };
114 |     } catch (error) {
115 |       throw new GmailError(
116 |         'Failed to create draft',
117 |         'CREATE_ERROR',
118 |         error instanceof Error ? error.message : 'Unknown error'
119 |       );
120 |     }
121 |   }
122 | 
123 |   async listDrafts(email: string) {
124 |     try {
125 |       const client = this.ensureClient();
126 |       const { data } = await client.users.drafts.list({
127 |         userId: 'me'
128 |       });
129 | 
130 |       // Get full details for each draft
131 |       const drafts = await Promise.all((data.drafts || [])
132 |         .filter((draft): draft is { id: string } => typeof draft.id === 'string')
133 |         .map(async draft => {
134 |           try {
135 |             return await this.getDraft(email, draft.id);
136 |           } catch (error) {
137 |             // Log error but continue with other drafts
138 |             console.error(`Failed to get draft ${draft.id}:`, error);
139 |             return null;
140 |           }
141 |         })
142 |       );
143 | 
144 |       // Filter out any failed draft fetches
145 |       const successfulDrafts = drafts.filter((draft): draft is NonNullable<typeof draft> => draft !== null);
146 | 
147 |       return {
148 |         drafts: successfulDrafts,
149 |         nextPageToken: data.nextPageToken || undefined,
150 |         resultSizeEstimate: data.resultSizeEstimate || 0
151 |       };
152 |     } catch (error) {
153 |       throw new GmailError(
154 |         'Failed to list drafts',
155 |         'LIST_ERROR',
156 |         error instanceof Error ? error.message : 'Unknown error'
157 |       );
158 |     }
159 |   }
160 | 
161 |   async getDraft(email: string, draftId: string) {
162 |     try {
163 |       const client = this.ensureClient();
164 |       const { data } = await client.users.drafts.get({
165 |         userId: 'me',
166 |         id: draftId,
167 |         format: 'full'
168 |       });
169 | 
170 |       if (!data.id || !data.message?.id || !data.message?.threadId) {
171 |         throw new GmailError(
172 |           'Invalid response from Gmail API',
173 |           'GET_ERROR',
174 |           'Message ID or Thread ID is missing'
175 |         );
176 |       }
177 | 
178 |       return {
179 |         id: data.id,
180 |         message: {
181 |           id: data.message.id,
182 |           threadId: data.message.threadId,
183 |           labelIds: data.message.labelIds || []
184 |         },
185 |         updated: new Date().toISOString() // Gmail API doesn't provide updated time, using current time
186 |       };
187 |     } catch (error) {
188 |       throw new GmailError(
189 |         'Failed to get draft',
190 |         'GET_ERROR',
191 |         error instanceof Error ? error.message : 'Unknown error'
192 |       );
193 |     }
194 |   }
195 | 
196 |   async updateDraft(email: string, draftId: string, data: DraftData) {
197 |     try {
198 |       const client = this.ensureClient();
199 | 
200 |       // Validate and prepare attachments
201 |       const processedAttachments = data.attachments?.map(attachment => {
202 |         this.attachmentService.validateAttachment(attachment);
203 |         return this.attachmentService.prepareAttachment(attachment);
204 |       }) || [];
205 | 
206 |       // Construct updated email
207 |       const boundary = `boundary_${Date.now()}`;
208 |       const messageParts = [
209 |         'MIME-Version: 1.0\n',
210 |         `Content-Type: multipart/mixed; boundary="${boundary}"\n`,
211 |         `To: ${data.to.join(', ')}\n`,
212 |         data.cc?.length ? `Cc: ${data.cc.join(', ')}\n` : '',
213 |         data.bcc?.length ? `Bcc: ${data.bcc.join(', ')}\n` : '',
214 |         `Subject: ${data.subject}\n\n`,
215 |         `--${boundary}\n`,
216 |         'Content-Type: text/plain; charset="UTF-8"\n',
217 |         'Content-Transfer-Encoding: 7bit\n\n',
218 |         data.body,
219 |         '\n'
220 |       ];
221 | 
222 |       // Add attachments
223 |       for (const attachment of processedAttachments) {
224 |         messageParts.push(
225 |           `--${boundary}\n`,
226 |           `Content-Type: ${attachment.mimeType}\n`,
227 |           'Content-Transfer-Encoding: base64\n',
228 |           `Content-Disposition: attachment; filename="${attachment.filename}"\n\n`,
229 |           attachment.content,
230 |           '\n'
231 |         );
232 |       }
233 | 
234 |       messageParts.push(`--${boundary}--`);
235 |       const fullMessage = messageParts.join('');
236 | 
237 |       // Update draft
238 |       const { data: draft } = await client.users.drafts.update({
239 |         userId: 'me',
240 |         id: draftId,
241 |         requestBody: {
242 |           message: {
243 |             raw: Buffer.from(fullMessage).toString('base64')
244 |           }
245 |         }
246 |       });
247 | 
248 |       return {
249 |         id: draft.id || '',
250 |         message: {
251 |           id: draft.message?.id || '',
252 |           threadId: draft.message?.threadId || '',
253 |           labelIds: draft.message?.labelIds || []
254 |         },
255 |         updated: new Date().toISOString(),
256 |         attachments: data.attachments
257 |       };
258 |     } catch (error) {
259 |       throw new GmailError(
260 |         'Failed to update draft',
261 |         'UPDATE_ERROR',
262 |         error instanceof Error ? error.message : 'Unknown error'
263 |       );
264 |     }
265 |   }
266 | 
267 |   async deleteDraft(email: string, draftId: string) {
268 |     try {
269 |       const client = this.ensureClient();
270 |       await client.users.drafts.delete({
271 |         userId: 'me',
272 |         id: draftId
273 |       });
274 | 
275 |       return;
276 |     } catch (error) {
277 |       throw new GmailError(
278 |         'Failed to delete draft',
279 |         'DELETE_ERROR',
280 |         error instanceof Error ? error.message : 'Unknown error'
281 |       );
282 |     }
283 |   }
284 | 
285 |   async manageDraft(params: ManageDraftParams) {
286 |     const { email, action, draftId, data } = params;
287 | 
288 |     switch (action) {
289 |       case 'create':
290 |         if (!data) {
291 |           throw new GmailError(
292 |             'Draft data is required for create action',
293 |             'INVALID_PARAMS'
294 |           );
295 |         }
296 |         return this.createDraft(email, data);
297 | 
298 |       case 'read':
299 |         if (!draftId) {
300 |           return this.listDrafts(email);
301 |         }
302 |         return this.getDraft(email, draftId);
303 | 
304 |       case 'update':
305 |         if (!draftId || !data) {
306 |           throw new GmailError(
307 |             'Draft ID and data are required for update action',
308 |             'INVALID_PARAMS'
309 |           );
310 |         }
311 |         return this.updateDraft(email, draftId, data);
312 | 
313 |       case 'delete':
314 |         if (!draftId) {
315 |           throw new GmailError(
316 |             'Draft ID is required for delete action',
317 |             'INVALID_PARAMS'
318 |           );
319 |         }
320 |         return this.deleteDraft(email, draftId);
321 | 
322 |       case 'send':
323 |         if (!draftId) {
324 |           throw new GmailError(
325 |             'Draft ID is required for send action',
326 |             'INVALID_PARAMS'
327 |           );
328 |         }
329 |         return this.sendDraft(email, draftId);
330 | 
331 |       default:
332 |         throw new GmailError(
333 |           'Invalid action',
334 |           'INVALID_PARAMS',
335 |           'Supported actions are: create, read, update, delete, send'
336 |         );
337 |     }
338 |   }
339 | 
340 |   async sendDraft(email: string, draftId: string) {
341 |     try {
342 |       const client = this.ensureClient();
343 |       const { data } = await client.users.drafts.send({
344 |         userId: 'me',
345 |         requestBody: {
346 |           id: draftId
347 |         }
348 |       });
349 | 
350 |       if (!data.id || !data.threadId) {
351 |         throw new GmailError(
352 |           'Invalid response from Gmail API',
353 |           'SEND_ERROR',
354 |           'Message ID or Thread ID is missing'
355 |         );
356 |       }
357 |       
358 |       return {
359 |         messageId: data.id,
360 |         threadId: data.threadId,
361 |         labelIds: data.labelIds || undefined
362 |       };
363 |     } catch (error) {
364 |       throw new GmailError(
365 |         'Failed to send draft',
366 |         'SEND_ERROR',
367 |         error instanceof Error ? error.message : 'Unknown error'
368 |       );
369 |     }
370 |   }
371 | }
372 | 
```
--------------------------------------------------------------------------------
/src/__tests__/modules/calendar/service.test.ts:
--------------------------------------------------------------------------------
```typescript
  1 | import { CalendarService } from '../../../modules/calendar/service.js';
  2 | import { calendar_v3 } from 'googleapis';
  3 | import { getAccountManager } from '../../../modules/accounts/index.js';
  4 | import { AccountManager } from '../../../modules/accounts/manager.js';
  5 | import { CreateEventParams } from '../../../modules/calendar/types.js';
  6 | 
  7 | jest.mock('../../../modules/accounts/index.js');
  8 | jest.mock('../../../modules/accounts/manager.js');
  9 | 
 10 | describe('CalendarService', () => {
 11 |   let calendarService: CalendarService;
 12 |   let mockCalendarClient: jest.Mocked<calendar_v3.Calendar>;
 13 |   let mockAccountManager: jest.Mocked<AccountManager>;
 14 |   const mockEmail = '[email protected]';
 15 | 
 16 |   beforeEach(() => {
 17 |     // Simplified mock setup with proper typing
 18 |     mockCalendarClient = {
 19 |       events: {
 20 |         list: jest.fn().mockImplementation(() => Promise.resolve({ data: {} })),
 21 |         get: jest.fn().mockImplementation(() => Promise.resolve({ data: {} })),
 22 |         insert: jest.fn().mockImplementation(() => Promise.resolve({ data: {} })),
 23 |         patch: jest.fn().mockImplementation(() => Promise.resolve({ data: {} })),
 24 |       },
 25 |     } as unknown as jest.Mocked<calendar_v3.Calendar>;
 26 | 
 27 |     mockAccountManager = {
 28 |       validateToken: jest.fn().mockResolvedValue({ valid: true, token: {} }),
 29 |       getAuthClient: jest.fn().mockResolvedValue({}),
 30 |     } as unknown as jest.Mocked<AccountManager>;
 31 | 
 32 |     (getAccountManager as jest.Mock).mockReturnValue(mockAccountManager);
 33 |     calendarService = new CalendarService();
 34 |     (calendarService as any).getCalendarClient = jest.fn().mockResolvedValue(mockCalendarClient);
 35 |   });
 36 | 
 37 |   describe('getEvents', () => {
 38 |     it('should return events list', async () => {
 39 |       const mockEvents = [
 40 |         { id: 'event1', summary: 'Test Event 1' },
 41 |         { id: 'event2', summary: 'Test Event 2' }
 42 |       ];
 43 |       
 44 |       (mockCalendarClient.events.list as jest.Mock).mockImplementation(() => 
 45 |         Promise.resolve({ data: { items: mockEvents } })
 46 |       );
 47 | 
 48 |       const result = await calendarService.getEvents({ email: mockEmail });
 49 | 
 50 |       expect(result).toEqual(expect.arrayContaining([
 51 |         expect.objectContaining({ id: 'event1' }),
 52 |         expect.objectContaining({ id: 'event2' })
 53 |       ]));
 54 |     });
 55 | 
 56 |     it('should handle empty results', async () => {
 57 |       (mockCalendarClient.events.list as jest.Mock).mockImplementation(() => 
 58 |         Promise.resolve({ data: {} })
 59 |       );
 60 |       const result = await calendarService.getEvents({ email: mockEmail });
 61 |       expect(result).toEqual([]);
 62 |     });
 63 | 
 64 |     it('should handle invalid date format', async () => {
 65 |       await expect(calendarService.getEvents({
 66 |         email: mockEmail,
 67 |         timeMin: 'invalid-date'
 68 |       })).rejects.toThrow('Invalid date format');
 69 |     });
 70 |   });
 71 | 
 72 |   describe('createEvent', () => {
 73 |     const mockEvent = {
 74 |       email: mockEmail,
 75 |       summary: 'Meeting',
 76 |       start: { dateTime: '2024-01-15T10:00:00Z' },
 77 |       end: { dateTime: '2024-01-15T11:00:00Z' }
 78 |     };
 79 | 
 80 |     it('should create event', async () => {
 81 |       (mockCalendarClient.events.insert as jest.Mock).mockImplementation(() =>
 82 |         Promise.resolve({
 83 |           data: { id: 'new-1', summary: 'Meeting', htmlLink: 'url' }
 84 |         })
 85 |       );
 86 | 
 87 |       const result = await calendarService.createEvent(mockEvent);
 88 | 
 89 |       expect(result).toEqual(expect.objectContaining({
 90 |         id: 'new-1',
 91 |         summary: 'Meeting'
 92 |       }));
 93 |     });
 94 | 
 95 |     it('should handle creation failure', async () => {
 96 |       (mockCalendarClient.events.insert as jest.Mock).mockImplementation(() =>
 97 |         Promise.reject(new Error('Failed'))
 98 |       );
 99 |       await expect(calendarService.createEvent(mockEvent)).rejects.toThrow();
100 |     });
101 |   });
102 | 
103 |   describe('manageEvent', () => {
104 |     beforeEach(() => {
105 |       (mockCalendarClient.events.get as jest.Mock).mockImplementation(() =>
106 |         Promise.resolve({
107 |           data: {
108 |             id: 'event1',
109 |             summary: 'Test Event',
110 |             attendees: [{ email: mockEmail }]
111 |           }
112 |         })
113 |       );
114 |     });
115 | 
116 |     it('should accept event', async () => {
117 |       (mockCalendarClient.events.patch as jest.Mock).mockImplementation(() =>
118 |         Promise.resolve({
119 |           data: { id: 'event1', status: 'accepted' }
120 |         })
121 |       );
122 | 
123 |       const result = await calendarService.manageEvent({
124 |         email: mockEmail,
125 |         eventId: 'event1',
126 |         action: 'accept'
127 |       });
128 | 
129 |       expect(result.success).toBe(true);
130 |       expect(result.status).toBe('completed');
131 |     });
132 | 
133 |     it('should handle invalid action', async () => {
134 |       await expect(calendarService.manageEvent({
135 |         email: mockEmail,
136 |         eventId: 'event1',
137 |         action: 'invalid_action' as any
138 |       })).rejects.toThrow();
139 |     });
140 | 
141 |     it('should validate new times for propose action', async () => {
142 |       await expect(calendarService.manageEvent({
143 |         email: mockEmail,
144 |         eventId: 'event1',
145 |         action: 'propose_new_time'
146 |       })).rejects.toThrow('No proposed times provided');
147 |     });
148 |   });
149 | 
150 |   describe('getEvent', () => {
151 |     it('should get single event', async () => {
152 |       (mockCalendarClient.events.get as jest.Mock).mockImplementation(() =>
153 |         Promise.resolve({
154 |           data: { id: 'event1', summary: 'Test' }
155 |         })
156 |       );
157 | 
158 |       const result = await calendarService.getEvent(mockEmail, 'event1');
159 |       expect(result).toEqual(expect.objectContaining({ id: 'event1' }));
160 |     });
161 | 
162 |     it('should handle not found', async () => {
163 |       (mockCalendarClient.events.get as jest.Mock).mockImplementation(() =>
164 |         Promise.reject(new Error('Not found'))
165 |       );
166 |       await expect(calendarService.getEvent(mockEmail, 'nonexistent'))
167 |         .rejects.toThrow();
168 |     });
169 |   });
170 | 
171 |   describe('deleteEvent', () => {
172 |     beforeEach(() => {
173 |       // Add delete method to mock
174 |       mockCalendarClient.events.delete = jest.fn().mockResolvedValue({});
175 |     });
176 | 
177 |     it('should delete a single event with default parameters', async () => {
178 |       await calendarService.deleteEvent(mockEmail, 'event1');
179 |       
180 |       expect(mockCalendarClient.events.delete).toHaveBeenCalledWith({
181 |         calendarId: 'primary',
182 |         eventId: 'event1',
183 |         sendUpdates: 'all'
184 |       });
185 |     });
186 | 
187 |     it('should delete a single event with custom sendUpdates', async () => {
188 |       await calendarService.deleteEvent(mockEmail, 'event1', 'none');
189 |       
190 |       expect(mockCalendarClient.events.delete).toHaveBeenCalledWith({
191 |         calendarId: 'primary',
192 |         eventId: 'event1',
193 |         sendUpdates: 'none'
194 |       });
195 |     });
196 | 
197 |     it('should delete entire series of a recurring event', async () => {
198 |       await calendarService.deleteEvent(mockEmail, 'recurring1', 'all', 'entire_series');
199 |       
200 |       expect(mockCalendarClient.events.delete).toHaveBeenCalledWith({
201 |         calendarId: 'primary',
202 |         eventId: 'recurring1',
203 |         sendUpdates: 'all'
204 |       });
205 |     });
206 | 
207 |     it('should handle "this_and_following" for a recurring event instance', async () => {
208 |       // Mock a recurring event instance
209 |       (mockCalendarClient.events.get as jest.Mock).mockResolvedValueOnce({
210 |         data: {
211 |           id: 'instance1',
212 |           recurringEventId: 'master1',
213 |           start: { dateTime: '2024-05-15T10:00:00Z' }
214 |         }
215 |       });
216 | 
217 |       // Mock the master event
218 |       (mockCalendarClient.events.get as jest.Mock).mockResolvedValueOnce({
219 |         data: {
220 |           id: 'master1',
221 |           recurrence: ['RRULE:FREQ=WEEKLY;COUNT=10'],
222 |           start: { dateTime: '2024-05-01T10:00:00Z' }
223 |         }
224 |       });
225 | 
226 |       await calendarService.deleteEvent(mockEmail, 'instance1', 'all', 'this_and_following');
227 |       
228 |       // Should get the instance
229 |       expect(mockCalendarClient.events.get).toHaveBeenCalledWith({
230 |         calendarId: 'primary',
231 |         eventId: 'instance1'
232 |       });
233 | 
234 |       // Should get the master event
235 |       expect(mockCalendarClient.events.get).toHaveBeenCalledWith({
236 |         calendarId: 'primary',
237 |         eventId: 'master1'
238 |       });
239 | 
240 |       // Should update the master event's recurrence rule
241 |       expect(mockCalendarClient.events.patch).toHaveBeenCalledWith(
242 |         expect.objectContaining({
243 |           calendarId: 'primary',
244 |           eventId: 'master1',
245 |           requestBody: {
246 |             recurrence: expect.arrayContaining([
247 |               expect.stringMatching(/RRULE:FREQ=WEEKLY;COUNT=10;UNTIL=\d+/)
248 |             ])
249 |           }
250 |         })
251 |       );
252 | 
253 |       // Should delete the instance
254 |       expect(mockCalendarClient.events.delete).toHaveBeenCalledWith({
255 |         calendarId: 'primary',
256 |         eventId: 'instance1',
257 |         sendUpdates: 'all'
258 |       });
259 |     });
260 | 
261 |     it('should handle "this_and_following" for a master recurring event', async () => {
262 |       // Mock a master recurring event
263 |       (mockCalendarClient.events.get as jest.Mock).mockResolvedValueOnce({
264 |         data: {
265 |           id: 'master1',
266 |           recurrence: ['RRULE:FREQ=WEEKLY;COUNT=10'],
267 |           start: { dateTime: '2024-05-01T10:00:00Z' }
268 |         }
269 |       });
270 | 
271 |       await calendarService.deleteEvent(mockEmail, 'master1', 'all', 'this_and_following');
272 |       
273 |       // Should update the recurrence rule
274 |       expect(mockCalendarClient.events.patch).toHaveBeenCalledWith(
275 |         expect.objectContaining({
276 |           calendarId: 'primary',
277 |           eventId: 'master1',
278 |           requestBody: {
279 |             recurrence: expect.arrayContaining([
280 |               expect.stringMatching(/RRULE:FREQ=WEEKLY;COUNT=10;UNTIL=\d+/)
281 |             ])
282 |           }
283 |         })
284 |       );
285 |     });
286 | 
287 |     it('should throw error when using "this_and_following" on a non-recurring event', async () => {
288 |       // Mock a non-recurring event
289 |       (mockCalendarClient.events.get as jest.Mock).mockResolvedValueOnce({
290 |         data: {
291 |           id: 'single1',
292 |           summary: 'Single Event',
293 |           start: { dateTime: '2024-05-01T10:00:00Z' }
294 |         }
295 |       });
296 | 
297 |       await expect(
298 |         calendarService.deleteEvent(mockEmail, 'single1', 'all', 'this_and_following')
299 |       ).rejects.toThrow('Deletion scope can only be applied to recurring events');
300 |     });
301 | 
302 |     it('should handle errors gracefully', async () => {
303 |       (mockCalendarClient.events.get as jest.Mock).mockRejectedValueOnce(new Error('API error'));
304 |       (mockCalendarClient.events.delete as jest.Mock).mockResolvedValueOnce({});
305 | 
306 |       // Should fall back to simple delete
307 |       await calendarService.deleteEvent(mockEmail, 'event1', 'all', 'this_and_following');
308 |       
309 |       expect(mockCalendarClient.events.delete).toHaveBeenCalledWith({
310 |         calendarId: 'primary',
311 |         eventId: 'event1',
312 |         sendUpdates: 'all'
313 |       });
314 |     });
315 |   });
316 | });
317 | 
```
--------------------------------------------------------------------------------
/src/modules/gmail/services/email.ts:
--------------------------------------------------------------------------------
```typescript
  1 | import { google, gmail_v1 } from 'googleapis';
  2 | import {
  3 |   EmailResponse,
  4 |   GetEmailsParams,
  5 |   GetEmailsResponse,
  6 |   SendEmailParams,
  7 |   SendEmailResponse,
  8 |   ThreadInfo,
  9 |   GmailError,
 10 |   IncomingGmailAttachment,
 11 |   OutgoingGmailAttachment
 12 | } from '../types.js';
 13 | import { SearchService } from './search.js';
 14 | import { GmailAttachmentService } from './attachment.js';
 15 | import { AttachmentResponseTransformer } from '../../attachments/response-transformer.js';
 16 | import { AttachmentIndexService } from '../../attachments/index-service.js';
 17 | 
 18 | type GmailMessage = gmail_v1.Schema$Message;
 19 | 
 20 | export class EmailService {
 21 |   private responseTransformer: AttachmentResponseTransformer;
 22 | 
 23 |   constructor(
 24 |     private searchService: SearchService,
 25 |     private attachmentService: GmailAttachmentService,
 26 |     private gmailClient?: ReturnType<typeof google.gmail>
 27 |   ) {
 28 |     this.responseTransformer = new AttachmentResponseTransformer(AttachmentIndexService.getInstance());
 29 |   }
 30 | 
 31 |   /**
 32 |    * Updates the Gmail client instance
 33 |    * @param client - New Gmail client instance
 34 |    */
 35 |   updateClient(client: ReturnType<typeof google.gmail>) {
 36 |     this.gmailClient = client;
 37 |   }
 38 | 
 39 |   private ensureClient(): ReturnType<typeof google.gmail> {
 40 |     if (!this.gmailClient) {
 41 |       throw new GmailError(
 42 |         'Gmail client not initialized',
 43 |         'CLIENT_ERROR',
 44 |         'Please ensure the service is initialized'
 45 |       );
 46 |     }
 47 |     return this.gmailClient;
 48 |   }
 49 | 
 50 |   /**
 51 |    * Extracts all headers into a key-value map
 52 |    */
 53 |   private extractHeaders(headers: { name: string; value: string }[]): { [key: string]: string } {
 54 |     return headers.reduce((acc, header) => {
 55 |       acc[header.name] = header.value;
 56 |       return acc;
 57 |     }, {} as { [key: string]: string });
 58 |   }
 59 | 
 60 |   /**
 61 |    * Groups emails by thread ID and extracts thread information
 62 |    */
 63 |   private groupEmailsByThread(emails: EmailResponse[]): { [threadId: string]: ThreadInfo } {
 64 |     return emails.reduce((threads, email) => {
 65 |       if (!threads[email.threadId]) {
 66 |         threads[email.threadId] = {
 67 |           messages: [],
 68 |           participants: [],
 69 |           subject: email.subject,
 70 |           lastUpdated: email.date
 71 |         };
 72 |       }
 73 | 
 74 |       const thread = threads[email.threadId];
 75 |       thread.messages.push(email.id);
 76 |       
 77 |       if (!thread.participants.includes(email.from)) {
 78 |         thread.participants.push(email.from);
 79 |       }
 80 |       if (email.to && !thread.participants.includes(email.to)) {
 81 |         thread.participants.push(email.to);
 82 |       }
 83 |       
 84 |       const emailDate = new Date(email.date);
 85 |       const threadDate = new Date(thread.lastUpdated);
 86 |       if (emailDate > threadDate) {
 87 |         thread.lastUpdated = email.date;
 88 |       }
 89 | 
 90 |       return threads;
 91 |     }, {} as { [threadId: string]: ThreadInfo });
 92 |   }
 93 | 
 94 |   /**
 95 |    * Get attachment metadata from message parts
 96 |    */
 97 |   private getAttachmentMetadata(message: GmailMessage): IncomingGmailAttachment[] {
 98 |     const attachments: IncomingGmailAttachment[] = [];
 99 |     
100 |     if (!message.payload?.parts) {
101 |       return attachments;
102 |     }
103 | 
104 |     for (const part of message.payload.parts) {
105 |       if (part.filename && part.body?.attachmentId) {
106 |         attachments.push({
107 |           id: part.body.attachmentId,
108 |           name: part.filename,
109 |           mimeType: part.mimeType || 'application/octet-stream',
110 |           size: parseInt(String(part.body.size || '0'))
111 |         });
112 |       }
113 |     }
114 | 
115 |     return attachments;
116 |   }
117 | 
118 |   /**
119 |    * Enhanced getEmails method with support for advanced search criteria and options
120 |    */
121 |   async getEmails({ email, search = {}, options = {}, messageIds }: GetEmailsParams): Promise<GetEmailsResponse> {
122 |     try {
123 |       const maxResults = options.maxResults || 10;
124 |       
125 |       let messages;
126 |       let nextPageToken: string | undefined;
127 |       
128 |       if (messageIds && messageIds.length > 0) {
129 |         messages = { messages: messageIds.map(id => ({ id })) };
130 |       } else {
131 |         // Build search query from criteria
132 |         const query = this.searchService.buildSearchQuery(search);
133 |         
134 |         // List messages matching query
135 |         const client = this.ensureClient();
136 |         const { data } = await client.users.messages.list({
137 |           userId: 'me',
138 |           q: query,
139 |           maxResults,
140 |           pageToken: options.pageToken,
141 |         });
142 |         
143 |         messages = data;
144 |         nextPageToken = data.nextPageToken || undefined;
145 |       }
146 | 
147 |       if (!messages.messages || messages.messages.length === 0) {
148 |         return {
149 |           emails: [],
150 |           resultSummary: {
151 |             total: 0,
152 |             returned: 0,
153 |             hasMore: false,
154 |             searchCriteria: search
155 |           }
156 |         };
157 |       }
158 | 
159 |       // Get full message details
160 |       const emails = await Promise.all(
161 |         messages.messages.map(async (message) => {
162 |           const client = this.ensureClient();
163 |           const { data: email } = await client.users.messages.get({
164 |             userId: 'me',
165 |             id: message.id!,
166 |             format: options.format || 'full',
167 |           });
168 | 
169 |           const headers = (email.payload?.headers || []).map(h => ({
170 |             name: h.name || '',
171 |             value: h.value || ''
172 |           }));
173 |           const subject = headers.find(h => h.name === 'Subject')?.value || '';
174 |           const from = headers.find(h => h.name === 'From')?.value || '';
175 |           const to = headers.find(h => h.name === 'To')?.value || '';
176 |           const date = headers.find(h => h.name === 'Date')?.value || '';
177 | 
178 |           // Get email body
179 |           let body = '';
180 |           if (email.payload?.body?.data) {
181 |             body = Buffer.from(email.payload.body.data, 'base64').toString();
182 |           } else if (email.payload?.parts) {
183 |             const textPart = email.payload.parts.find(part => part.mimeType === 'text/plain');
184 |             if (textPart?.body?.data) {
185 |               body = Buffer.from(textPart.body.data, 'base64').toString();
186 |             }
187 |           }
188 | 
189 |           // Get attachment metadata if present and store in index
190 |           const hasAttachments = email.payload?.parts?.some(part => part.filename && part.filename.length > 0) || false;
191 |           let attachments;
192 |           if (hasAttachments) {
193 |             attachments = this.getAttachmentMetadata(email);
194 |             // Store each attachment's metadata in the index
195 |             attachments.forEach(attachment => {
196 |               this.attachmentService.addAttachment(email.id!, attachment);
197 |             });
198 |           }
199 | 
200 |           const response: EmailResponse = {
201 |             id: email.id!,
202 |             threadId: email.threadId!,
203 |             labelIds: email.labelIds || undefined,
204 |             snippet: email.snippet || undefined,
205 |             subject,
206 |             from,
207 |             to,
208 |             date,
209 |             body,
210 |             headers: options.includeHeaders ? this.extractHeaders(headers) : undefined,
211 |             isUnread: email.labelIds?.includes('UNREAD') || false,
212 |             hasAttachment: hasAttachments,
213 |             attachments
214 |           };
215 | 
216 |           return response;
217 |         })
218 |       );
219 | 
220 |       // Handle threaded view if requested
221 |       const threads = options.threadedView ? this.groupEmailsByThread(emails) : undefined;
222 | 
223 |       // Sort emails if requested
224 |       if (options.sortOrder) {
225 |         emails.sort((a, b) => {
226 |           const dateA = new Date(a.date).getTime();
227 |           const dateB = new Date(b.date).getTime();
228 |           return options.sortOrder === 'asc' ? dateA - dateB : dateB - dateA;
229 |         });
230 |       }
231 | 
232 |       // Transform response to simplify attachments
233 |       const transformedResponse = this.responseTransformer.transformResponse({
234 |         emails,
235 |         nextPageToken,
236 |         resultSummary: {
237 |           total: messages.resultSizeEstimate || emails.length,
238 |           returned: emails.length,
239 |           hasMore: Boolean(nextPageToken),
240 |           searchCriteria: search
241 |         },
242 |         threads
243 |       });
244 | 
245 |       return transformedResponse;
246 |     } catch (error) {
247 |       if (error instanceof GmailError) {
248 |         throw error;
249 |       }
250 |       throw new GmailError(
251 |         'Failed to get emails',
252 |         'FETCH_ERROR',
253 |         `Error: ${error instanceof Error ? error.message : 'Unknown error'}`
254 |       );
255 |     }
256 |   }
257 | 
258 |   async sendEmail({ email, to, subject, body, cc = [], bcc = [], attachments = [] }: SendEmailParams): Promise<SendEmailResponse> {
259 |     try {
260 |       // Validate and prepare attachments for sending
261 |       const processedAttachments = attachments?.map(attachment => {
262 |         this.attachmentService.validateAttachment(attachment);
263 |         const prepared = this.attachmentService.prepareAttachment(attachment);
264 |         return {
265 |           id: attachment.id,
266 |           name: prepared.filename,
267 |           mimeType: prepared.mimeType,
268 |           size: attachment.size,
269 |           content: prepared.content
270 |         } as OutgoingGmailAttachment;
271 |       }) || [];
272 | 
273 |       // Construct email with attachments
274 |       const boundary = `boundary_${Date.now()}`;
275 |       const messageParts = [
276 |         'MIME-Version: 1.0\n',
277 |         `Content-Type: multipart/mixed; boundary="${boundary}"\n`,
278 |         `To: ${to.join(', ')}\n`,
279 |         cc.length > 0 ? `Cc: ${cc.join(', ')}\n` : '',
280 |         bcc.length > 0 ? `Bcc: ${bcc.join(', ')}\n` : '',
281 |         `Subject: ${subject}\n\n`,
282 |         `--${boundary}\n`,
283 |         'Content-Type: text/plain; charset="UTF-8"\n',
284 |         'Content-Transfer-Encoding: 7bit\n\n',
285 |         body,
286 |         '\n'
287 |       ];
288 | 
289 |       // Add attachments directly from content
290 |       for (const attachment of processedAttachments) {
291 |         messageParts.push(
292 |           `--${boundary}\n`,
293 |           `Content-Type: ${attachment.mimeType}\n`,
294 |           'Content-Transfer-Encoding: base64\n',
295 |           `Content-Disposition: attachment; filename="${attachment.name}"\n\n`,
296 |           attachment.content,
297 |           '\n'
298 |         );
299 |       }
300 | 
301 |       messageParts.push(`--${boundary}--`);
302 |       const fullMessage = messageParts.join('');
303 | 
304 |       // Encode the email in base64
305 |       const encodedMessage = Buffer.from(fullMessage)
306 |         .toString('base64')
307 |         .replace(/\+/g, '-')
308 |         .replace(/\//g, '_')
309 |         .replace(/=+$/, '');
310 | 
311 |       // Send the email
312 |       const client = this.ensureClient();
313 |       const { data } = await client.users.messages.send({
314 |         userId: 'me',
315 |         requestBody: {
316 |           raw: encodedMessage,
317 |         },
318 |       });
319 | 
320 |       const response: SendEmailResponse = {
321 |         messageId: data.id!,
322 |         threadId: data.threadId!,
323 |         labelIds: data.labelIds || undefined
324 |       };
325 | 
326 |       if (processedAttachments.length > 0) {
327 |         response.attachments = processedAttachments;
328 |       }
329 | 
330 |       return response;
331 |     } catch (error) {
332 |       if (error instanceof GmailError) {
333 |         throw error;
334 |       }
335 |       throw new GmailError(
336 |         'Failed to send email',
337 |         'SEND_ERROR',
338 |         `Error: ${error instanceof Error ? error.message : 'Unknown error'}`
339 |       );
340 |     }
341 |   }
342 | }
343 | 
```
--------------------------------------------------------------------------------
/docs/API.md:
--------------------------------------------------------------------------------
```markdown
  1 | # Google Workspace MCP API Reference
  2 | 
  3 | IMPORTANT: Before using any workspace operations, you MUST call list_workspace_accounts first to:
  4 | 1. Check for existing authenticated accounts
  5 | 2. Determine which account to use if multiple exist
  6 | 3. Verify required API scopes are authorized
  7 | 
  8 | ## Account Management (Required First)
  9 | 
 10 | ### list_workspace_accounts
 11 | List all configured Google workspace accounts and their authentication status.
 12 | 
 13 | This tool MUST be called first before any other workspace operations. It serves as the foundation for all account-based operations by:
 14 | 1. Checking for existing authenticated accounts
 15 | 2. Determining which account to use if multiple exist
 16 | 3. Verifying required API scopes are authorized
 17 | 
 18 | Common Response Patterns:
 19 | - Valid account exists → Proceed with requested operation
 20 | - Multiple accounts exist → Ask user which to use
 21 | - Token expired → Proceed normally (auto-refresh occurs)
 22 | - No accounts exist → Start authentication flow
 23 | 
 24 | **Input Schema**: Empty object `{}`
 25 | 
 26 | **Output**: Array of account objects with authentication status
 27 | 
 28 | ### authenticate_workspace_account
 29 | Add and authenticate a Google account for API access.
 30 | 
 31 | IMPORTANT: Only use this tool if list_workspace_accounts shows:
 32 | 1. No existing accounts, OR
 33 | 2. When using the account it seems to lack necessary auth scopes.
 34 | 
 35 | To prevent wasted time, DO NOT use this tool:
 36 | - Without checking list_workspace_accounts first
 37 | - When token is just expired (auto-refresh handles this)
 38 | - To re-authenticate an already valid account
 39 | 
 40 | **Input Schema**:
 41 | ```typescript
 42 | {
 43 |   email: string;          // Required: Email address to authenticate
 44 |   category?: string;      // Optional: Account category (e.g., work, personal)
 45 |   description?: string;   // Optional: Account description
 46 |   auth_code?: string;     // Optional: OAuth code for completing authentication
 47 | }
 48 | ```
 49 | 
 50 | ### remove_workspace_account
 51 | Remove a Google account and delete associated tokens.
 52 | 
 53 | **Input Schema**:
 54 | ```typescript
 55 | {
 56 |   email: string;  // Required: Email address to remove
 57 | }
 58 | ```
 59 | 
 60 | ## Gmail Operations
 61 | 
 62 | IMPORTANT: All Gmail operations require prior verification of account access using list_workspace_accounts.
 63 | 
 64 | ### search_workspace_emails
 65 | Search emails with advanced filtering.
 66 | 
 67 | Response Format (v1.1):
 68 | - Attachments are simplified to just filename
 69 | - Full metadata is maintained internally
 70 | - Example response:
 71 | ```json
 72 | {
 73 |   "id": "message123",
 74 |   "attachments": [{
 75 |     "name": "document.pdf"
 76 |   }]
 77 | }
 78 | ```
 79 | 
 80 | Common Query Patterns:
 81 | - Meeting emails: "from:(*@zoom.us OR zoom.us OR [email protected]) subject:(meeting OR sync OR invite)"
 82 | - HR/Admin: "from:(*@workday.com OR *@adp.com) subject:(time off OR PTO OR benefits)"
 83 | - Team updates: "from:(*@company.com) -from:([email protected])"
 84 | - Newsletters: "subject:(newsletter OR digest) from:(*@company.com)"
 85 | 
 86 | Search Tips:
 87 | - Date format: YYYY-MM-DD (e.g., "2024-02-18")
 88 | - Labels: Case-sensitive, exact match (e.g., "INBOX", "SENT")
 89 | - Wildcards: Use * for partial matches (e.g., "*@domain.com")
 90 | - Operators: OR, -, (), has:attachment, larger:size, newer_than:date
 91 | 
 92 | **Input Schema**:
 93 | ```typescript
 94 | {
 95 |   email: string;           // Required: Gmail account email
 96 |   search?: {              // Optional: Search criteria
 97 |     from?: string | string[];
 98 |     to?: string | string[];
 99 |     subject?: string;
100 |     content?: string;     // Complex Gmail query
101 |     after?: string;       // YYYY-MM-DD
102 |     before?: string;      // YYYY-MM-DD
103 |     hasAttachment?: boolean;
104 |     labels?: string[];
105 |     excludeLabels?: string[];
106 |     includeSpam?: boolean;
107 |     isUnread?: boolean;
108 |   };
109 |   maxResults?: number;    // Optional: Max results to return
110 | }
111 | ```
112 | 
113 | ### send_workspace_email
114 | Send an email.
115 | 
116 | **Input Schema**:
117 | ```typescript
118 | {
119 |   email: string;           // Required: Sender email
120 |   to: string[];           // Required: Recipients
121 |   subject: string;        // Required: Email subject
122 |   body: string;           // Required: Email content
123 |   cc?: string[];         // Optional: CC recipients
124 |   bcc?: string[];        // Optional: BCC recipients
125 | }
126 | ```
127 | 
128 | ### manage_workspace_draft
129 | Manage email drafts.
130 | 
131 | **Input Schema**:
132 | ```typescript
133 | {
134 |   email: string;          // Required: Gmail account
135 |   action: 'create' | 'read' | 'update' | 'delete' | 'send';
136 |   draftId?: string;      // Required for read/update/delete/send
137 |   data?: {               // Required for create/update
138 |     to?: string[];
139 |     subject?: string;
140 |     body?: string;
141 |     cc?: string[];
142 |     bcc?: string[];
143 |     replyToMessageId?: string;
144 |     threadId?: string;
145 |   }
146 | }
147 | ```
148 | 
149 | ## Calendar Operations
150 | 
151 | IMPORTANT: All Calendar operations require prior verification of account access using list_workspace_accounts.
152 | 
153 | ### list_workspace_calendar_events
154 | List calendar events.
155 | 
156 | Common Usage Patterns:
157 | - Default view: Current week's events
158 | - Specific range: Use timeMin/timeMax
159 | - Search: Use query for text search
160 | 
161 | Example Flows:
162 | 1. User asks "check my calendar":
163 |    - Verify account access
164 |    - Show current week by default
165 |    - Include upcoming events
166 | 
167 | 2. User asks "find meetings about project":
168 |    - Check account access
169 |    - Search with relevant query
170 |    - Focus on recent/upcoming events
171 | 
172 | **Input Schema**:
173 | ```typescript
174 | {
175 |   email: string;          // Required: Calendar owner email
176 |   query?: string;        // Optional: Text search
177 |   maxResults?: number;   // Optional: Max events to return
178 |   timeMin?: string;      // Optional: Start time (ISO string)
179 |   timeMax?: string;      // Optional: End time (ISO string)
180 | }
181 | ```
182 | 
183 | ### create_workspace_calendar_event
184 | Create a calendar event.
185 | 
186 | **Input Schema**:
187 | ```typescript
188 | {
189 |   email: string;          // Required: Calendar owner
190 |   summary: string;        // Required: Event title
191 |   description?: string;   // Optional: Event description
192 |   start: {               // Required: Start time
193 |     dateTime: string;    // ISO-8601 format
194 |     timeZone?: string;   // IANA timezone
195 |   };
196 |   end: {                 // Required: End time
197 |     dateTime: string;    // ISO-8601 format
198 |     timeZone?: string;   // IANA timezone
199 |   };
200 |   attendees?: {          // Optional: Event attendees
201 |     email: string;
202 |   }[];
203 |   recurrence?: string[]; // Optional: RRULE strings
204 | }
205 | ```
206 | 
207 | ## Drive Operations
208 | 
209 | IMPORTANT: All Drive operations require prior verification of account access using list_workspace_accounts.
210 | 
211 | ### list_drive_files
212 | List files in Google Drive.
213 | 
214 | Common Usage Patterns:
215 | - List all files: No options needed
216 | - List folder contents: Provide folderId
217 | - Custom queries: Use query parameter
218 | 
219 | Example Flow:
220 | 1. Check account access
221 | 2. Apply any filters
222 | 3. Return file list with metadata
223 | 
224 | **Input Schema**:
225 | ```typescript
226 | {
227 |   email: string;           // Required: Drive account email
228 |   options?: {             // Optional: List options
229 |     folderId?: string;    // Filter by parent folder
230 |     query?: string;       // Custom query string
231 |     pageSize?: number;    // Max files to return
232 |     orderBy?: string[];   // Sort fields
233 |     fields?: string[];    // Response fields to include
234 |   }
235 | }
236 | ```
237 | 
238 | ### search_drive_files
239 | Search files with advanced filtering.
240 | 
241 | **Input Schema**:
242 | ```typescript
243 | {
244 |   email: string;           // Required: Drive account email
245 |   options: {              // Required: Search options
246 |     fullText?: string;    // Full text search
247 |     mimeType?: string;    // Filter by file type
248 |     folderId?: string;    // Filter by parent folder
249 |     trashed?: boolean;    // Include trashed files
250 |     query?: string;       // Additional query string
251 |     pageSize?: number;    // Max results
252 |     orderBy?: string[];   // Sort order
253 |     fields?: string[];    // Response fields
254 |   }
255 | }
256 | ```
257 | 
258 | ### upload_drive_file
259 | Upload a file to Drive.
260 | 
261 | **Input Schema**:
262 | ```typescript
263 | {
264 |   email: string;           // Required: Drive account email
265 |   options: {              // Required: Upload options
266 |     name: string;         // Required: File name
267 |     content: string;      // Required: File content (string/base64)
268 |     mimeType?: string;    // Optional: Content type
269 |     parents?: string[];   // Optional: Parent folder IDs
270 |   }
271 | }
272 | ```
273 | 
274 | ### download_drive_file
275 | Download a file from Drive.
276 | 
277 | **Input Schema**:
278 | ```typescript
279 | {
280 |   email: string;           // Required: Drive account email
281 |   fileId: string;         // Required: File to download
282 |   mimeType?: string;      // Optional: Export format for Google files
283 | }
284 | ```
285 | 
286 | ### create_drive_folder
287 | Create a new folder.
288 | 
289 | **Input Schema**:
290 | ```typescript
291 | {
292 |   email: string;           // Required: Drive account email
293 |   name: string;           // Required: Folder name
294 |   parentId?: string;      // Optional: Parent folder ID
295 | }
296 | ```
297 | 
298 | ### update_drive_permissions
299 | Update file/folder sharing settings.
300 | 
301 | **Input Schema**:
302 | ```typescript
303 | {
304 |   email: string;           // Required: Drive account email
305 |   options: {              // Required: Permission options
306 |     fileId: string;       // Required: File/folder ID
307 |     role: 'owner' | 'organizer' | 'fileOrganizer' | 
308 |           'writer' | 'commenter' | 'reader';
309 |     type: 'user' | 'group' | 'domain' | 'anyone';
310 |     emailAddress?: string; // Required for user/group
311 |     domain?: string;      // Required for domain
312 |     allowFileDiscovery?: boolean;
313 |   }
314 | }
315 | ```
316 | 
317 | ### delete_drive_file
318 | Delete a file or folder.
319 | 
320 | **Input Schema**:
321 | ```typescript
322 | {
323 |   email: string;           // Required: Drive account email
324 |   fileId: string;         // Required: File/folder to delete
325 | }
326 | ```
327 | 
328 | ## Label Management
329 | 
330 | ### manage_workspace_label
331 | Manage Gmail labels.
332 | 
333 | **Input Schema**:
334 | ```typescript
335 | {
336 |   email: string;           // Required: Gmail account
337 |   action: 'create' | 'read' | 'update' | 'delete';
338 |   labelId?: string;       // Required for read/update/delete
339 |   data?: {               // Required for create/update
340 |     name?: string;       // Label name
341 |     messageListVisibility?: 'show' | 'hide';
342 |     labelListVisibility?: 'labelShow' | 'labelHide' | 'labelShowIfUnread';
343 |     color?: {
344 |       textColor?: string;
345 |       backgroundColor?: string;
346 |     }
347 |   }
348 | }
349 | ```
350 | 
351 | ### manage_workspace_label_assignment
352 | Manage label assignments.
353 | 
354 | **Input Schema**:
355 | ```typescript
356 | {
357 |   email: string;           // Required: Gmail account
358 |   action: 'add' | 'remove';
359 |   messageId: string;      // Required: Message to modify
360 |   labelIds: string[];     // Required: Labels to add/remove
361 | }
362 | ```
363 | 
364 | ### manage_workspace_label_filter
365 | Manage Gmail filters.
366 | 
367 | **Input Schema**:
368 | ```typescript
369 | {
370 |   email: string;           // Required: Gmail account
371 |   action: 'create' | 'read' | 'update' | 'delete';
372 |   filterId?: string;      // Required for update/delete
373 |   labelId?: string;       // Required for create/update
374 |   data?: {
375 |     criteria?: {
376 |       from?: string[];
377 |       to?: string[];
378 |       subject?: string;
379 |       hasWords?: string[];
380 |       doesNotHaveWords?: string[];
381 |       hasAttachment?: boolean;
382 |       size?: {
383 |         operator: 'larger' | 'smaller';
384 |         size: number;
385 |       }
386 |     };
387 |     actions?: {
388 |       addLabel: boolean;
389 |       markImportant?: boolean;
390 |       markRead?: boolean;
391 |       archive?: boolean;
392 |     }
393 |   }
394 | }
395 | 
```
--------------------------------------------------------------------------------
/ARCHITECTURE.md:
--------------------------------------------------------------------------------
```markdown
  1 | # Architecture
  2 | 
  3 | ## Design Philosophy: Simplest Viable Design
  4 | 
  5 | This project follows the "simplest viable design" principle, which emerged from our experience with AI systems' tendency toward over-engineering, particularly in OAuth scope handling. This principle addresses a pattern we term "scope fondling" - where AI systems optimize for maximum anticipated flexibility rather than minimal necessary permissions.
  6 | 
  7 | Key aspects of this approach:
  8 | - Minimize complexity in permission structures
  9 | - Handle auth through simple HTTP response codes (401/403)
 10 | - Move OAuth mechanics entirely into platform infrastructure
 11 | - Present simple verb-noun interfaces to AI agents
 12 | - Focus on core operational requirements over edge cases
 13 | 
 14 | This principle helps prevent goal misgeneralization, where AI systems might otherwise create unnecessary complexity in authentication paths, connection management, and permission hierarchies.
 15 | 
 16 | ## System Overview
 17 | 
 18 | The Google Workspace MCP Server implements a modular architecture with comprehensive support for Gmail, Calendar, and Drive services. The system is built around core modules that handle authentication, account management, and service-specific operations.
 19 | 
 20 | ```mermaid
 21 | graph TB
 22 |     subgraph "Google Workspace MCP Tools"
 23 |         AM[Account Management]
 24 |         GM[Gmail Management]
 25 |         CM[Calendar Management]
 26 |         DM[Drive Management]
 27 |         
 28 |         subgraph "Account Tools"
 29 |             AM --> LA[list_workspace_accounts]
 30 |             AM --> AA[authenticate_workspace_account]
 31 |             AM --> RA[remove_workspace_account]
 32 |         end
 33 |         
 34 |         subgraph "Gmail Tools"
 35 |             GM --> SE[search_workspace_emails]
 36 |             GM --> SWE[send_workspace_email]
 37 |             GM --> GS[get_workspace_gmail_settings]
 38 |             GM --> MD[manage_workspace_draft]
 39 |             
 40 |             subgraph "Label Management"
 41 |                 LM[Label Tools]
 42 |                 LM --> ML[manage_workspace_label]
 43 |                 LM --> MLA[manage_workspace_label_assignment]
 44 |                 LM --> MLF[manage_workspace_label_filter]
 45 |             end
 46 |         end
 47 |         
 48 |         subgraph "Calendar Tools"
 49 |             CM --> LCE[list_workspace_calendar_events]
 50 |             CM --> GCE[get_workspace_calendar_event]
 51 |             CM --> MCE[manage_workspace_calendar_event]
 52 |             CM --> CCE[create_workspace_calendar_event]
 53 |             CM --> DCE[delete_workspace_calendar_event]
 54 |         end
 55 |         
 56 |         subgraph "Drive Tools"
 57 |             DM --> LDF[list_drive_files]
 58 |             DM --> SDF[search_drive_files]
 59 |             DM --> UDF[upload_drive_file]
 60 |             DM --> DDF[download_drive_file]
 61 |             DM --> CDF[create_drive_folder]
 62 |             DM --> UDP[update_drive_permissions]
 63 |             DM --> DEL[delete_drive_file]
 64 |         end
 65 |     end
 66 | 
 67 |     %% Service Dependencies
 68 |     LA -.->|Required First| SE
 69 |     LA -.->|Required First| CM
 70 |     LA -.->|Required First| DM
 71 |     AA -.->|Auth Flow| LA
 72 | ```
 73 | 
 74 | Key characteristics:
 75 | - Authentication-first architecture with list_workspace_accounts as the foundation
 76 | - Comprehensive service modules for Gmail, Calendar, and Drive
 77 | - Integrated label management within Gmail
 78 | - Rich tool sets for each service domain
 79 | 
 80 | ## Core Components
 81 | 
 82 | ### 1. Scope Registry (src/modules/tools/scope-registry.ts)
 83 | - Simple scope collection system for OAuth
 84 | - Gathers required scopes at startup
 85 | - Used for initial auth setup and validation
 86 | - Handles runtime scope verification
 87 | 
 88 | ### 2. MCP Server (src/index.ts)
 89 | - Registers and manages available tools
 90 | - Handles request routing and validation
 91 | - Provides consistent error handling
 92 | - Manages server lifecycle
 93 | 
 94 | ### 3. Account Module (src/modules/accounts/*)
 95 | - OAuth Client:
 96 |   - Implements Google OAuth 2.0 flow
 97 |   - Handles token exchange and refresh
 98 |   - Provides authentication URLs
 99 |   - Manages client credentials
100 | - Token Manager:
101 |   - Handles token lifecycle
102 |   - Validates and refreshes tokens
103 |   - Manages token storage
104 | - Account Manager:
105 |   - Manages account configurations
106 |   - Handles account persistence
107 |   - Validates account status
108 | 
109 | ### 4. Service Modules
110 | 
111 | #### Attachment System
112 | - Singleton-based attachment management:
113 |   - AttachmentIndexService: Central metadata cache with size limits
114 |   - AttachmentResponseTransformer: Handles response simplification
115 |   - AttachmentCleanupService: Manages cache expiry
116 | - Cache Management:
117 |   - Map-based storage using messageId + filename as key
118 |   - Built-in size limit (256 entries)
119 |   - Automatic expiry handling (1 hour timeout)
120 |   - LRU-style cleanup for capacity management
121 | - Abstraction Layers:
122 |   - Service layer for Gmail/Calendar operations
123 |   - Transformer layer for response formatting
124 |   - Index layer for metadata storage
125 |   - Cleanup layer for maintenance
126 | 
127 | #### Gmail Module (src/modules/gmail/*)
128 | - Comprehensive email operations:
129 |   - Email search and sending
130 |   - Draft management
131 |   - Label and filter control
132 |   - Settings configuration
133 |   - Simplified attachment handling
134 | - Manages Gmail API integration
135 | - Handles Gmail authentication scopes
136 | - Integrates with attachment system
137 | 
138 | #### Calendar Module (src/modules/calendar/*)
139 | - Complete calendar operations:
140 |   - Event listing and search
141 |   - Event creation and management
142 |   - Event response handling
143 |   - Recurring event support
144 | - Manages Calendar API integration
145 | - Handles calendar permissions
146 | 
147 | #### Drive Module (src/modules/drive/*)
148 | - Full file management capabilities:
149 |   - File listing and search
150 |   - Upload and download
151 |   - Folder management
152 |   - Permission control
153 |   - File operations (create, update, delete)
154 | - Manages Drive API integration
155 | - Handles file system operations
156 | 
157 | ## Data Flows
158 | 
159 | ### Operation Flow
160 | ```mermaid
161 | sequenceDiagram
162 |     participant TR as Tool Request
163 |     participant S as Service
164 |     participant API as Google API
165 | 
166 |     TR->>S: Request
167 |     S->>API: API Call
168 |     alt Success
169 |         API-->>TR: Return Response
170 |     else Auth Error (401/403)
171 |         S->>S: Refresh Token
172 |         S->>API: Retry API Call
173 |         API-->>TR: Return Response
174 |     end
175 | ```
176 | 
177 | ### Auth Flow
178 | ```mermaid
179 | sequenceDiagram
180 |     participant TR as Tool Request
181 |     participant S as Service
182 |     participant AM as Account Manager
183 |     participant API as Google API
184 | 
185 |     TR->>S: Request
186 |     S->>API: API Call
187 |     alt Success
188 |         API-->>TR: Return Response
189 |     else Auth Error
190 |         S->>AM: Refresh Token
191 |         alt Refresh Success
192 |             S->>API: Retry API Call
193 |             API-->>TR: Return Response
194 |         else Refresh Failed
195 |             AM-->>TR: Request Re-auth
196 |         end
197 |     end
198 | ```
199 | 
200 | ## Implementation Details
201 | 
202 | ### Testing Strategy
203 | 
204 | The project follows a simplified unit testing approach that emphasizes:
205 | 
206 | ```mermaid
207 | graph TD
208 |     A[Unit Tests] --> B[Simplified Mocks]
209 |     A --> C[Focused Tests]
210 |     A --> D[Clean State]
211 |     
212 |     B --> B1[Static Responses]
213 |     B --> B2[Simple File System]
214 |     B --> B3[Basic OAuth]
215 |     
216 |     C --> C1[Grouped by Function]
217 |     C --> C2[Single Responsibility]
218 |     C --> C3[Clear Assertions]
219 |     
220 |     D --> D1[Reset Modules]
221 |     D --> D2[Fresh Instances]
222 |     D --> D3[Tracked Mocks]
223 | ```
224 | 
225 | #### Key Testing Principles
226 | 
227 | 1. **Logging Strategy**
228 |    - All logs are directed to stderr to maintain MCP protocol integrity
229 |    - Prevents log messages from corrupting stdout JSON communication
230 |    - Enables clean separation of logs and tool responses
231 |    - Logger is mocked in tests to prevent console.error noise
232 |    - Consistent logging approach across all modules
233 | 
234 | 2. **Simplified Mocking**
235 |    - Use static mock responses instead of complex simulations
236 |    - Mock external dependencies with minimal implementations
237 |    - Focus on behavior verification over implementation details
238 |    - Avoid end-to-end complexity in unit tests
239 | 
240 | 2. **Test Organization**
241 |    - Group tests by functional area (e.g., account operations, file operations)
242 |    - Each test verifies a single piece of functionality
243 |    - Clear test descriptions that document behavior
244 |    - Independent test cases that don't rely on shared state
245 | 
246 | 3. **Mock Management**
247 |    - Reset modules and mocks between tests
248 |    - Track mock function calls explicitly
249 |    - Re-require modules after mock changes
250 |    - Verify both function calls and results
251 | 
252 | 4. **File System Testing**
253 |    - Use simple JSON structures
254 |    - Focus on data correctness over formatting
255 |    - Test error scenarios explicitly
256 |    - Verify operations without implementation details
257 | 
258 | 5. **Token Handling**
259 |    - Mock token validation with static responses
260 |    - Test success and failure scenarios separately
261 |    - Focus on account manager's token handling logic
262 |    - Avoid OAuth complexity in unit tests
263 | 
264 | This approach ensures tests are:
265 | - Reliable and predictable
266 | - Easy to maintain
267 | - Quick to execute
268 | - Clear in intent
269 | - Focused on behavior
270 | 
271 | ### Security
272 | - OAuth 2.0 implementation with offline access
273 | - Secure token storage and management
274 | - Scope-based access control
275 | - Environment-based configuration
276 | - Secure credential handling
277 | 
278 | ### Error Handling
279 | - Simplified auth error handling through 401/403 responses
280 | - Automatic token refresh on auth failures
281 | - Service-specific error types
282 | - Clear authentication error guidance
283 | 
284 | ### Configuration
285 | - OAuth credentials via environment variables
286 | - Secure token storage in user's home directory
287 | - Account configuration management
288 | - Token persistence handling
289 | 
290 | ## Project Structure
291 | 
292 | ### Docker Container Structure
293 | ```
294 | /app/
295 | ├── src/              # Application source code
296 | │   ├── index.ts     # MCP server implementation
297 | │   ├── modules/     # Core functionality modules
298 | │   └── scripts/     # Utility scripts
299 | ├── config/          # Mount point for persistent data
300 | │   ├── accounts.json     # Account configurations
301 | │   └── credentials/     # Token storage
302 | └── Dockerfile       # Container definition
303 | ```
304 | 
305 | ### Local Development Structure
306 | ```
307 | project/
308 | ├── src/             # Source code (mounted in container)
309 | ├── Dockerfile       # Container definition
310 | └── docker-entrypoint.sh  # Container startup script
311 | ```
312 | 
313 | ### Host Machine Structure
314 | ```
315 | ~/.mcp/google-workspace-mcp/  # Persistent data directory
316 | ├── accounts.json        # Account configurations
317 | └── credentials/        # Token storage
318 | ```
319 | 
320 | ## Configuration
321 | 
322 | ### Container Environment Variables
323 | ```
324 | GOOGLE_CLIENT_ID      - OAuth client ID
325 | GOOGLE_CLIENT_SECRET  - OAuth client secret
326 | GOOGLE_REDIRECT_URI   - OAuth redirect URI (optional)
327 | ```
328 | 
329 | ### Volume Mounts
330 | ```
331 | ~/.mcp/google-workspace-mcp:/app/config  # Persistent data storage
332 | ```
333 | 
334 | ### Data Directory Structure
335 | The server uses a Docker volume mounted at `/app/config` to store:
336 | 
337 | 1. Account Configuration (accounts.json):
338 | ```json
339 | {
340 |   "accounts": [{
341 |     "email": "[email protected]",
342 |     "category": "work",
343 |     "description": "Work Account"
344 |   }]
345 | }
346 | ```
347 | 
348 | 2. Credentials Directory:
349 | - Contains OAuth tokens for each account
350 | - Tokens are stored securely with appropriate permissions
351 | - Each token file is named using the account's email address
352 | 
353 | ## Version History
354 | 
355 | ### Version 1.1
356 | - Simplified attachment data in responses (filename only)
357 | - Maintained full metadata in index service
358 | - Improved attachment system architecture
359 | - Enhanced documentation and examples
360 | - Verified download functionality with simplified format
361 | 
362 | ## Future Extensions
363 | 
364 | ### Planned Services
365 | - Admin SDK support for workspace management
366 | - Additional Google Workspace integrations
367 | 
368 | ### Planned Features
369 | - Rate limiting
370 | - Response caching
371 | - Request logging
372 | - Performance monitoring
373 | - Multi-account optimization
374 | 
```
--------------------------------------------------------------------------------
/src/tools/server.ts:
--------------------------------------------------------------------------------
```typescript
  1 | import { Server } from "@modelcontextprotocol/sdk/server/index.js";
  2 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
  3 | import logger from '../utils/logger.js';
  4 | import {
  5 |   CallToolRequestSchema,
  6 |   ListToolsRequestSchema,
  7 |   Tool
  8 | } from "@modelcontextprotocol/sdk/types.js";
  9 | 
 10 | // Get docker hash from environment
 11 | const DOCKER_HASH = process.env.DOCKER_HASH || 'unknown';
 12 | 
 13 | // Import tool definitions and registry
 14 | import { allTools } from './definitions.js';
 15 | import { ToolRegistry } from '../modules/tools/registry.js';
 16 | 
 17 | // Import handlers
 18 | import {
 19 |   handleListWorkspaceAccounts,
 20 |   handleAuthenticateWorkspaceAccount,
 21 |   handleCompleteWorkspaceAuth,
 22 |   handleRemoveWorkspaceAccount
 23 | } from './account-handlers.js';
 24 | 
 25 | import {
 26 |   handleSearchWorkspaceEmails,
 27 |   handleSendWorkspaceEmail,
 28 |   handleGetWorkspaceGmailSettings,
 29 |   handleManageWorkspaceDraft,
 30 |   handleManageWorkspaceLabel,
 31 |   handleManageWorkspaceLabelAssignment,
 32 |   handleManageWorkspaceLabelFilter,
 33 |   handleManageWorkspaceAttachment
 34 | } from './gmail-handlers.js';
 35 | 
 36 | import {
 37 |   handleListWorkspaceCalendarEvents,
 38 |   handleGetWorkspaceCalendarEvent,
 39 |   handleManageWorkspaceCalendarEvent,
 40 |   handleCreateWorkspaceCalendarEvent,
 41 |   handleDeleteWorkspaceCalendarEvent
 42 | } from './calendar-handlers.js';
 43 | 
 44 | import {
 45 |   handleListDriveFiles,
 46 |   handleSearchDriveFiles,
 47 |   handleUploadDriveFile,
 48 |   handleDownloadDriveFile,
 49 |   handleCreateDriveFolder,
 50 |   handleUpdateDrivePermissions,
 51 |   handleDeleteDriveFile
 52 | } from './drive-handlers.js';
 53 | 
 54 | // Import contact handlers
 55 | import { handleGetContacts } from './contacts-handlers.js';
 56 | 
 57 | // Import error types
 58 | import { AccountError } from '../modules/accounts/types.js';
 59 | import { GmailError } from '../modules/gmail/types.js';
 60 | import { CalendarError } from '../modules/calendar/types.js';
 61 | import { ContactsError } from '../modules/contacts/types.js';
 62 | 
 63 | // Import service initializer
 64 | import { initializeAllServices } from '../utils/service-initializer.js';
 65 | 
 66 | // Import types and type guards
 67 | import {
 68 |   CalendarEventParams,
 69 |   SendEmailArgs,
 70 |   AuthenticateAccountArgs,
 71 |   ManageDraftParams,
 72 |   ManageAttachmentParams
 73 | } from './types.js';
 74 | import {
 75 |   ManageLabelParams,
 76 |   ManageLabelAssignmentParams,
 77 |   ManageLabelFilterParams
 78 | } from '../modules/gmail/services/label.js';
 79 | 
 80 | import {
 81 |   assertBaseToolArguments,
 82 |   assertCalendarEventParams,
 83 |   assertEmailEventIdArgs,
 84 |   assertSendEmailArgs,
 85 |   assertManageDraftParams,
 86 |   assertManageLabelParams,
 87 |   assertManageLabelAssignmentParams,
 88 |   assertManageLabelFilterParams,
 89 |   assertDriveFileListArgs,
 90 |   assertDriveSearchArgs,
 91 |   assertDriveUploadArgs,
 92 |   assertDriveDownloadArgs,
 93 |   assertDriveFolderArgs,
 94 |   assertDrivePermissionArgs,
 95 |   assertDriveDeleteArgs,
 96 |   assertManageAttachmentParams,
 97 |   assertGetContactsParams
 98 | } from './type-guards.js';
 99 | 
100 | export class GSuiteServer {
101 |   private server: Server;
102 |   private toolRegistry: ToolRegistry;
103 | 
104 |   constructor() {
105 |     this.toolRegistry = new ToolRegistry(allTools);
106 |     this.server = new Server(
107 |       {
108 |         name: "Google Workspace MCP Server",
109 |         version: "0.1.0"
110 |       },
111 |       {
112 |         capabilities: {
113 |           tools: {
114 |             list: true,
115 |             call: true
116 |           }
117 |         }
118 |       }
119 |     );
120 | 
121 |     this.setupRequestHandlers();
122 |   }
123 | 
124 |   private setupRequestHandlers(): void {
125 |     // Tools are registered through the ToolRegistry which serves as a single source of truth
126 |     // for both tool discovery (ListToolsRequestSchema) and execution (CallToolRequestSchema).
127 |     // Tools only need to be defined once in allTools and the registry handles making them
128 |     // available to both handlers.
129 |     
130 |     // List available tools
131 |     this.server.setRequestHandler(ListToolsRequestSchema, async () => {
132 |       // Get tools with categories organized
133 |       const categories = this.toolRegistry.getCategories();
134 |       const toolsByCategory: { [key: string]: Tool[] } = {};
135 |       
136 |       for (const category of categories) {
137 |         // Convert ToolMetadata to Tool (strip out category and aliases for SDK compatibility)
138 |         toolsByCategory[category.name] = category.tools.map(tool => ({
139 |           name: tool.name,
140 |           description: tool.description,
141 |           inputSchema: tool.inputSchema
142 |         }));
143 |       }
144 | 
145 |       return {
146 |         tools: allTools.map(tool => ({
147 |           name: tool.name,
148 |           description: tool.description,
149 |           inputSchema: tool.inputSchema
150 |         })),
151 |         _meta: {
152 |           categories: toolsByCategory,
153 |           aliases: Object.fromEntries(
154 |             allTools.flatMap(tool => 
155 |               (tool.aliases || []).map(alias => [alias, tool.name])
156 |             )
157 |           )
158 |         }
159 |       };
160 |     });
161 | 
162 |     // Handle tool calls
163 |     this.server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
164 |       try {
165 |         const args = request.params.arguments || {};
166 |         const toolName = request.params.name;
167 |         
168 |         // Look up the tool using the registry
169 |         const tool = this.toolRegistry.getTool(toolName);
170 |         if (!tool) {
171 |           // Generate helpful error message with suggestions
172 |           const errorMessage = this.toolRegistry.formatErrorWithSuggestions(toolName);
173 |           throw new Error(errorMessage);
174 |         }
175 |         
176 |         let result;
177 |         // Use the canonical tool name for the switch
178 |         switch (tool.name) {
179 |           // Account Management
180 |           case 'list_workspace_accounts':
181 |             result = await handleListWorkspaceAccounts();
182 |             break;
183 |           case 'authenticate_workspace_account':
184 |             result = await handleAuthenticateWorkspaceAccount(args as AuthenticateAccountArgs);
185 |             break;
186 |           case 'complete_workspace_auth':
187 |             assertBaseToolArguments(args);
188 |             result = await handleCompleteWorkspaceAuth(args);
189 |             break;
190 |           case 'remove_workspace_account':
191 |             assertBaseToolArguments(args);
192 |             result = await handleRemoveWorkspaceAccount(args);
193 |             break;
194 | 
195 |           // Gmail Operations
196 |           case 'search_workspace_emails':
197 |             assertBaseToolArguments(args);
198 |             result = await handleSearchWorkspaceEmails(args);
199 |             break;
200 |           case 'send_workspace_email':
201 |             assertSendEmailArgs(args);
202 |             result = await handleSendWorkspaceEmail(args as SendEmailArgs);
203 |             break;
204 |           case 'get_workspace_gmail_settings':
205 |             assertBaseToolArguments(args);
206 |             result = await handleGetWorkspaceGmailSettings(args);
207 |             break;
208 |           case 'manage_workspace_draft':
209 |             assertManageDraftParams(args);
210 |             result = await handleManageWorkspaceDraft(args as ManageDraftParams);
211 |             break;
212 | 
213 |           case 'manage_workspace_attachment':
214 |             assertManageAttachmentParams(args);
215 |             result = await handleManageWorkspaceAttachment(args as ManageAttachmentParams);
216 |             break;
217 | 
218 |           // Calendar Operations
219 |           case 'list_workspace_calendar_events':
220 |             assertCalendarEventParams(args);
221 |             result = await handleListWorkspaceCalendarEvents(args as CalendarEventParams);
222 |             break;
223 |           case 'get_workspace_calendar_event':
224 |             assertEmailEventIdArgs(args);
225 |             result = await handleGetWorkspaceCalendarEvent(args);
226 |             break;
227 |           case 'manage_workspace_calendar_event':
228 |             assertBaseToolArguments(args);
229 |             result = await handleManageWorkspaceCalendarEvent(args);
230 |             break;
231 |           case 'create_workspace_calendar_event':
232 |             assertBaseToolArguments(args);
233 |             result = await handleCreateWorkspaceCalendarEvent(args);
234 |             break;
235 |           case 'delete_workspace_calendar_event':
236 |             assertEmailEventIdArgs(args);
237 |             result = await handleDeleteWorkspaceCalendarEvent(args);
238 |             break;
239 | 
240 |           // Label Management
241 |           case 'manage_workspace_label':
242 |             assertManageLabelParams(args);
243 |             result = await handleManageWorkspaceLabel(args as unknown as ManageLabelParams);
244 |             break;
245 |           case 'manage_workspace_label_assignment':
246 |             assertManageLabelAssignmentParams(args);
247 |             result = await handleManageWorkspaceLabelAssignment(args as unknown as ManageLabelAssignmentParams);
248 |             break;
249 |           case 'manage_workspace_label_filter':
250 |             assertManageLabelFilterParams(args);
251 |             result = await handleManageWorkspaceLabelFilter(args as unknown as ManageLabelFilterParams);
252 |             break;
253 | 
254 |           // Drive Operations
255 |           case 'list_drive_files':
256 |             assertDriveFileListArgs(args);
257 |             result = await handleListDriveFiles(args);
258 |             break;
259 |           case 'search_drive_files':
260 |             assertDriveSearchArgs(args);
261 |             result = await handleSearchDriveFiles(args);
262 |             break;
263 |           case 'upload_drive_file':
264 |             assertDriveUploadArgs(args);
265 |             result = await handleUploadDriveFile(args);
266 |             break;
267 |           case 'download_drive_file':
268 |             assertDriveDownloadArgs(args);
269 |             result = await handleDownloadDriveFile(args);
270 |             break;
271 |           case 'create_drive_folder':
272 |             assertDriveFolderArgs(args);
273 |             result = await handleCreateDriveFolder(args);
274 |             break;
275 |           case 'update_drive_permissions':
276 |             assertDrivePermissionArgs(args);
277 |             result = await handleUpdateDrivePermissions(args);
278 |             break;
279 |           case 'delete_drive_file':
280 |             assertDriveDeleteArgs(args);
281 |             result = await handleDeleteDriveFile(args);
282 |             break;
283 | 
284 |           // Contact Operations
285 |           case 'get_workspace_contacts':
286 |             assertGetContactsParams(args);
287 |             result = await handleGetContacts(args);
288 |             break;
289 | 
290 |           default:
291 |             throw new Error(`Unknown tool: ${request.params.name}`);
292 |         }
293 | 
294 |         // Wrap result in McpToolResponse format
295 |         // Handle undefined results (like from void functions)
296 |         const responseText = result === undefined ? 
297 |           JSON.stringify({ status: 'success', message: 'Operation completed successfully' }, null, 2) : 
298 |           JSON.stringify(result, null, 2);
299 |         
300 |         return {
301 |           content: [{
302 |             type: 'text',
303 |             text: responseText
304 |           }],
305 |           _meta: {}
306 |         };
307 |       } catch (error) {
308 |         const response = this.formatErrorResponse(error);
309 |         return {
310 |           content: [{ type: 'text', text: JSON.stringify(response, null, 2) }],
311 |           isError: true,
312 |           _meta: {}
313 |         };
314 |       }
315 |     });
316 |   }
317 | 
318 |   private formatErrorResponse(error: unknown) {
319 |     if (error instanceof AccountError || error instanceof GmailError || error instanceof CalendarError || error instanceof ContactsError) {
320 |       const details = error instanceof GmailError ? error.details :
321 |                      error instanceof AccountError ? error.resolution :
322 |                      error instanceof CalendarError ? error.message :
323 |                      error instanceof ContactsError ? error.details :
324 |                      'Please try again or contact support if the issue persists';
325 |       
326 |       return {
327 |         status: 'error',
328 |         error: error.message,
329 |         resolution: details
330 |       };
331 |     }
332 | 
333 |     return {
334 |       status: 'error',
335 |       error: error instanceof Error ? error.message : 'Unknown error occurred',
336 |       resolution: 'Please try again or contact support if the issue persists'
337 |     };
338 |   }
339 | 
340 |   async run(): Promise<void> {
341 |     try {
342 |       // Initialize server
343 |       logger.info(`google-workspace-mcp v0.9.0 (docker: ${DOCKER_HASH})`);
344 |       
345 |       // Initialize all services
346 |       await initializeAllServices();
347 |       
348 |       // Set up error handler
349 |       this.server.onerror = (error) => console.error('MCP Error:', error);
350 |       
351 |       // Connect transport
352 |       const transport = new StdioServerTransport();
353 |       await this.server.connect(transport);
354 |       logger.info('Google Workspace MCP server running on stdio');
355 |     } catch (error) {
356 |       logger.error('Fatal server error:', error);
357 |       throw error;
358 |     }
359 |   }
360 | }
361 | 
```
--------------------------------------------------------------------------------
/src/services/gmail/index.ts:
--------------------------------------------------------------------------------
```typescript
  1 | import { google } from 'googleapis';
  2 | import { BaseGoogleService } from '../base/BaseGoogleService.js';
  3 | import {
  4 |   GetEmailsParams,
  5 |   SendEmailParams,
  6 |   EmailResponse,
  7 |   SendEmailResponse,
  8 |   GetGmailSettingsParams,
  9 |   GetGmailSettingsResponse,
 10 |   SearchCriteria,
 11 |   GetEmailsResponse,
 12 |   ThreadInfo
 13 | } from '../../modules/gmail/types.js';
 14 | 
 15 | /**
 16 |  * Gmail service implementation extending BaseGoogleService.
 17 |  * Handles Gmail-specific operations while leveraging common Google API functionality.
 18 |  */
 19 | export class GmailService extends BaseGoogleService<ReturnType<typeof google.gmail>> {
 20 |   constructor() {
 21 |     super({
 22 |       serviceName: 'gmail',
 23 |       version: 'v1'
 24 |     });
 25 |   }
 26 | 
 27 |   /**
 28 |    * Gets an authenticated Gmail client
 29 |    */
 30 |   private async getGmailClient(email: string) {
 31 |     return this.getAuthenticatedClient(
 32 |       email,
 33 |       (auth) => google.gmail({ version: 'v1', auth })
 34 |     );
 35 |   }
 36 | 
 37 |   /**
 38 |    * Extracts all headers into a key-value map
 39 |    */
 40 |   private extractHeaders(headers: { name: string; value: string }[]): { [key: string]: string } {
 41 |     return headers.reduce((acc, header) => {
 42 |       acc[header.name] = header.value;
 43 |       return acc;
 44 |     }, {} as { [key: string]: string });
 45 |   }
 46 | 
 47 |   /**
 48 |    * Groups emails by thread ID and extracts thread information
 49 |    */
 50 |   private groupEmailsByThread(emails: EmailResponse[]): { [threadId: string]: ThreadInfo } {
 51 |     return emails.reduce((threads, email) => {
 52 |       if (!threads[email.threadId]) {
 53 |         threads[email.threadId] = {
 54 |           messages: [],
 55 |           participants: [],
 56 |           subject: email.subject,
 57 |           lastUpdated: email.date
 58 |         };
 59 |       }
 60 | 
 61 |       const thread = threads[email.threadId];
 62 |       thread.messages.push(email.id);
 63 |       
 64 |       if (!thread.participants.includes(email.from)) {
 65 |         thread.participants.push(email.from);
 66 |       }
 67 |       if (email.to && !thread.participants.includes(email.to)) {
 68 |         thread.participants.push(email.to);
 69 |       }
 70 |       
 71 |       const emailDate = new Date(email.date);
 72 |       const threadDate = new Date(thread.lastUpdated);
 73 |       if (emailDate > threadDate) {
 74 |         thread.lastUpdated = email.date;
 75 |       }
 76 | 
 77 |       return threads;
 78 |     }, {} as { [threadId: string]: ThreadInfo });
 79 |   }
 80 | 
 81 |   /**
 82 |    * Builds a Gmail search query string from SearchCriteria
 83 |    */
 84 |   private buildSearchQuery(criteria: SearchCriteria = {}): string {
 85 |     const queryParts: string[] = [];
 86 | 
 87 |     if (criteria.from) {
 88 |       const fromAddresses = Array.isArray(criteria.from) ? criteria.from : [criteria.from];
 89 |       if (fromAddresses.length === 1) {
 90 |         queryParts.push(`from:${fromAddresses[0]}`);
 91 |       } else {
 92 |         queryParts.push(`{${fromAddresses.map(f => `from:${f}`).join(' OR ')}}`);
 93 |       }
 94 |     }
 95 | 
 96 |     if (criteria.to) {
 97 |       const toAddresses = Array.isArray(criteria.to) ? criteria.to : [criteria.to];
 98 |       if (toAddresses.length === 1) {
 99 |         queryParts.push(`to:${toAddresses[0]}`);
100 |       } else {
101 |         queryParts.push(`{${toAddresses.map(t => `to:${t}`).join(' OR ')}}`);
102 |       }
103 |     }
104 | 
105 |     if (criteria.subject) {
106 |       const escapedSubject = criteria.subject.replace(/["\\]/g, '\\$&');
107 |       queryParts.push(`subject:"${escapedSubject}"`);
108 |     }
109 | 
110 |     if (criteria.content) {
111 |       const escapedContent = criteria.content.replace(/["\\]/g, '\\$&');
112 |       queryParts.push(`"${escapedContent}"`);
113 |     }
114 | 
115 |     if (criteria.after) {
116 |       const afterDate = new Date(criteria.after);
117 |       const afterStr = `${afterDate.getFullYear()}/${(afterDate.getMonth() + 1).toString().padStart(2, '0')}/${afterDate.getDate().toString().padStart(2, '0')}`;
118 |       queryParts.push(`after:${afterStr}`);
119 |     }
120 |     if (criteria.before) {
121 |       const beforeDate = new Date(criteria.before);
122 |       const beforeStr = `${beforeDate.getFullYear()}/${(beforeDate.getMonth() + 1).toString().padStart(2, '0')}/${beforeDate.getDate().toString().padStart(2, '0')}`;
123 |       queryParts.push(`before:${beforeStr}`);
124 |     }
125 | 
126 |     if (criteria.hasAttachment) {
127 |       queryParts.push('has:attachment');
128 |     }
129 | 
130 |     if (criteria.labels && criteria.labels.length > 0) {
131 |       criteria.labels.forEach(label => {
132 |         queryParts.push(`label:${label}`);
133 |       });
134 |     }
135 | 
136 |     if (criteria.excludeLabels && criteria.excludeLabels.length > 0) {
137 |       criteria.excludeLabels.forEach(label => {
138 |         queryParts.push(`-label:${label}`);
139 |       });
140 |     }
141 | 
142 |     if (criteria.includeSpam) {
143 |       queryParts.push('in:anywhere');
144 |     }
145 | 
146 |     if (criteria.isUnread !== undefined) {
147 |       queryParts.push(criteria.isUnread ? 'is:unread' : 'is:read');
148 |     }
149 | 
150 |     return queryParts.join(' ');
151 |   }
152 | 
153 |   /**
154 |    * Gets emails with proper scope handling for search and content access.
155 |    */
156 |   async getEmails({ email, search = {}, options = {}, messageIds }: GetEmailsParams): Promise<GetEmailsResponse> {
157 |     try {
158 |       const gmail = await this.getGmailClient(email);
159 |       const maxResults = options.maxResults || 10;
160 |       
161 |       let messages;
162 |       let nextPageToken: string | undefined;
163 |       
164 |       if (messageIds && messageIds.length > 0) {
165 |         messages = { messages: messageIds.map(id => ({ id })) };
166 |       } else {
167 |         const query = this.buildSearchQuery(search);
168 |         
169 |         const { data } = await gmail.users.messages.list({
170 |           userId: 'me',
171 |           q: query,
172 |           maxResults,
173 |           pageToken: options.pageToken,
174 |         });
175 |         
176 |         messages = data;
177 |         nextPageToken = data.nextPageToken || undefined;
178 |       }
179 | 
180 |       if (!messages.messages || messages.messages.length === 0) {
181 |         return {
182 |           emails: [],
183 |           resultSummary: {
184 |             total: 0,
185 |             returned: 0,
186 |             hasMore: false,
187 |             searchCriteria: search
188 |           }
189 |         };
190 |       }
191 | 
192 |       const emails = await Promise.all(
193 |         messages.messages.map(async (message) => {
194 |           const { data: email } = await gmail.users.messages.get({
195 |             userId: 'me',
196 |             id: message.id!,
197 |             format: options.format || 'full',
198 |           });
199 | 
200 |           const headers = (email.payload?.headers || []).map(h => ({
201 |             name: h.name || '',
202 |             value: h.value || ''
203 |           }));
204 |           const subject = headers.find(h => h.name === 'Subject')?.value || '';
205 |           const from = headers.find(h => h.name === 'From')?.value || '';
206 |           const to = headers.find(h => h.name === 'To')?.value || '';
207 |           const date = headers.find(h => h.name === 'Date')?.value || '';
208 | 
209 |           let body = '';
210 |           if (email.payload?.body?.data) {
211 |             body = Buffer.from(email.payload.body.data, 'base64').toString();
212 |           } else if (email.payload?.parts) {
213 |             const textPart = email.payload.parts.find(part => part.mimeType === 'text/plain');
214 |             if (textPart?.body?.data) {
215 |               body = Buffer.from(textPart.body.data, 'base64').toString();
216 |             }
217 |           }
218 | 
219 |           const response: EmailResponse = {
220 |             id: email.id!,
221 |             threadId: email.threadId!,
222 |             labelIds: email.labelIds || undefined,
223 |             snippet: email.snippet || undefined,
224 |             subject,
225 |             from,
226 |             to,
227 |             date,
228 |             body,
229 |             headers: options.includeHeaders ? this.extractHeaders(headers) : undefined,
230 |             isUnread: email.labelIds?.includes('UNREAD') || false,
231 |             hasAttachment: email.payload?.parts?.some(part => part.filename && part.filename.length > 0) || false
232 |           };
233 | 
234 |           return response;
235 |         })
236 |       );
237 | 
238 |       const threads = options.threadedView ? this.groupEmailsByThread(emails) : undefined;
239 | 
240 |       if (options.sortOrder) {
241 |         emails.sort((a, b) => {
242 |           const dateA = new Date(a.date).getTime();
243 |           const dateB = new Date(b.date).getTime();
244 |           return options.sortOrder === 'asc' ? dateA - dateB : dateB - dateA;
245 |         });
246 |       }
247 | 
248 |       return {
249 |         emails,
250 |         nextPageToken,
251 |         resultSummary: {
252 |           total: messages.resultSizeEstimate || emails.length,
253 |           returned: emails.length,
254 |           hasMore: Boolean(nextPageToken),
255 |           searchCriteria: search
256 |         },
257 |         threads
258 |       };
259 |     } catch (error) {
260 |       throw this.handleError(error, 'Failed to get emails');
261 |     }
262 |   }
263 | 
264 |   /**
265 |    * Sends an email from the specified account
266 |    */
267 |   async sendEmail({ email, to, subject, body, cc = [], bcc = [] }: SendEmailParams): Promise<SendEmailResponse> {
268 |     try {
269 |       const gmail = await this.getGmailClient(email);
270 | 
271 |       const message = [
272 |         'Content-Type: text/plain; charset="UTF-8"\n',
273 |         'MIME-Version: 1.0\n',
274 |         'Content-Transfer-Encoding: 7bit\n',
275 |         `To: ${to.join(', ')}\n`,
276 |         cc.length > 0 ? `Cc: ${cc.join(', ')}\n` : '',
277 |         bcc.length > 0 ? `Bcc: ${bcc.join(', ')}\n` : '',
278 |         `Subject: ${subject}\n\n`,
279 |         body,
280 |       ].join('');
281 | 
282 |       const encodedMessage = Buffer.from(message)
283 |         .toString('base64')
284 |         .replace(/\+/g, '-')
285 |         .replace(/\//g, '_')
286 |         .replace(/=+$/, '');
287 | 
288 |       const { data } = await gmail.users.messages.send({
289 |         userId: 'me',
290 |         requestBody: {
291 |           raw: encodedMessage,
292 |         },
293 |       });
294 | 
295 |       return {
296 |         messageId: data.id!,
297 |         threadId: data.threadId!,
298 |         labelIds: data.labelIds || undefined,
299 |       };
300 |     } catch (error) {
301 |       throw this.handleError(error, 'Failed to send email');
302 |     }
303 |   }
304 | 
305 |   /**
306 |    * Gets Gmail settings and profile information
307 |    */
308 |   async getWorkspaceGmailSettings({ email }: GetGmailSettingsParams): Promise<GetGmailSettingsResponse> {
309 |     try {
310 |       const gmail = await this.getGmailClient(email);
311 | 
312 |       const { data: profile } = await gmail.users.getProfile({
313 |         userId: 'me'
314 |       });
315 | 
316 |       const [
317 |         { data: autoForwarding },
318 |         { data: imap },
319 |         { data: language },
320 |         { data: pop },
321 |         { data: vacation }
322 |       ] = await Promise.all([
323 |         gmail.users.settings.getAutoForwarding({ userId: 'me' }),
324 |         gmail.users.settings.getImap({ userId: 'me' }),
325 |         gmail.users.settings.getLanguage({ userId: 'me' }),
326 |         gmail.users.settings.getPop({ userId: 'me' }),
327 |         gmail.users.settings.getVacation({ userId: 'me' })
328 |       ]);
329 | 
330 |       const nullSafeString = (value: string | null | undefined): string | undefined => 
331 |         value === null ? undefined : value;
332 | 
333 |       return {
334 |         profile: {
335 |           emailAddress: profile.emailAddress ?? '',
336 |           messagesTotal: typeof profile.messagesTotal === 'number' ? profile.messagesTotal : 0,
337 |           threadsTotal: typeof profile.threadsTotal === 'number' ? profile.threadsTotal : 0,
338 |           historyId: profile.historyId ?? ''
339 |         },
340 |         settings: {
341 |           ...(language?.displayLanguage && {
342 |             language: {
343 |               displayLanguage: language.displayLanguage
344 |             }
345 |           }),
346 |           ...(autoForwarding && {
347 |             autoForwarding: {
348 |               enabled: Boolean(autoForwarding.enabled),
349 |               ...(autoForwarding.emailAddress && {
350 |                 emailAddress: autoForwarding.emailAddress
351 |               })
352 |             }
353 |           }),
354 |           ...(imap && {
355 |             imap: {
356 |               enabled: Boolean(imap.enabled),
357 |               ...(typeof imap.autoExpunge === 'boolean' && {
358 |                 autoExpunge: imap.autoExpunge
359 |               }),
360 |               ...(imap.expungeBehavior && {
361 |                 expungeBehavior: imap.expungeBehavior
362 |               })
363 |             }
364 |           }),
365 |           ...(pop && {
366 |             pop: {
367 |               enabled: Boolean(pop.accessWindow),
368 |               ...(pop.accessWindow && {
369 |                 accessWindow: pop.accessWindow
370 |               })
371 |             }
372 |           }),
373 |           ...(vacation && {
374 |             vacationResponder: {
375 |               enabled: Boolean(vacation.enableAutoReply),
376 |               ...(vacation.startTime && {
377 |                 startTime: vacation.startTime
378 |               }),
379 |               ...(vacation.endTime && {
380 |                 endTime: vacation.endTime
381 |               }),
382 |               ...(vacation.responseSubject && {
383 |                 responseSubject: vacation.responseSubject
384 |               }),
385 |               ...((vacation.responseBodyHtml || vacation.responseBodyPlainText) && {
386 |                 message: vacation.responseBodyHtml ?? vacation.responseBodyPlainText ?? ''
387 |               })
388 |             }
389 |           })
390 |         }
391 |       } as GetGmailSettingsResponse;
392 |     } catch (error) {
393 |       throw this.handleError(error, 'Failed to get Gmail settings');
394 |     }
395 |   }
396 | }
397 | 
```
--------------------------------------------------------------------------------
/src/modules/accounts/manager.ts:
--------------------------------------------------------------------------------
```typescript
  1 | import fs from 'fs/promises';
  2 | import path from 'path';
  3 | import { Account, AccountsConfig, AccountError, AccountModuleConfig } from './types.js';
  4 | import { scopeRegistry } from '../tools/scope-registry.js';
  5 | import { TokenManager } from './token.js';
  6 | import { GoogleOAuthClient } from './oauth.js';
  7 | import logger from '../../utils/logger.js';
  8 | 
  9 | export class AccountManager {
 10 |   private readonly accountsPath: string;
 11 |   private accounts: Map<string, Account>;
 12 |   private tokenManager!: TokenManager;
 13 |   private oauthClient!: GoogleOAuthClient;
 14 |   private currentAuthEmail?: string;
 15 | 
 16 |   constructor(config?: AccountModuleConfig) {
 17 |     // Use environment variable or config, fallback to Docker default
 18 |     const defaultPath = process.env.ACCOUNTS_PATH || 
 19 |                        (process.env.MCP_MODE ? path.resolve(process.env.HOME || '', '.mcp/google-workspace-mcp/accounts.json') : '/app/config/accounts.json');
 20 |     this.accountsPath = config?.accountsPath || defaultPath;
 21 |     this.accounts = new Map();
 22 |   }
 23 | 
 24 |   async initialize(): Promise<void> {
 25 |     logger.info('Initializing AccountManager...');
 26 |     this.oauthClient = new GoogleOAuthClient();
 27 |     this.tokenManager = new TokenManager(this.oauthClient);
 28 |     
 29 |     // Set up automatic authentication completion
 30 |     const { OAuthCallbackServer } = await import('./callback-server.js');
 31 |     const callbackServer = OAuthCallbackServer.getInstance();
 32 |     callbackServer.setAuthHandler(async (code: string) => {
 33 |       if (this.currentAuthEmail) {
 34 |         try {
 35 |           logger.info(`Auto-completing authentication for ${this.currentAuthEmail}`);
 36 |           const tokenData = await this.getTokenFromCode(code);
 37 |           await this.saveToken(this.currentAuthEmail, tokenData);
 38 |           logger.info(`Authentication completed automatically for ${this.currentAuthEmail}`);
 39 |           this.currentAuthEmail = undefined;
 40 |         } catch (error) {
 41 |           logger.error('Failed to auto-complete authentication:', error);
 42 |           this.currentAuthEmail = undefined;
 43 |         }
 44 |       }
 45 |     });
 46 |     
 47 |     await this.loadAccounts();
 48 |     logger.info('AccountManager initialized successfully');
 49 |   }
 50 | 
 51 |   async listAccounts(): Promise<Account[]> {
 52 |     logger.debug('Listing accounts with auth status');
 53 |     const accounts = Array.from(this.accounts.values());
 54 |     
 55 |     // Add auth status to each account and attempt auto-renewal if needed
 56 |     for (const account of accounts) {
 57 |       const renewalResult = await this.tokenManager.autoRenewToken(account.email);
 58 |       
 59 |       if (renewalResult.success) {
 60 |         account.auth_status = {
 61 |           valid: true,
 62 |           status: renewalResult.status
 63 |         };
 64 |       } else {
 65 |         // If auto-renewal failed, try to get an auth URL for re-authentication
 66 |         account.auth_status = {
 67 |           valid: false,
 68 |           status: renewalResult.status,
 69 |           reason: renewalResult.reason,
 70 |           authUrl: await this.generateAuthUrl()
 71 |         };
 72 |       }
 73 |     }
 74 |     
 75 |     logger.debug(`Found ${accounts.length} accounts`);
 76 |     return accounts;
 77 |   }
 78 | 
 79 |   /**
 80 |    * Wrapper for tool operations that handles token renewal
 81 |    * @param email Account email
 82 |    * @param operation Function that performs the actual operation
 83 |    */
 84 |   async withTokenRenewal<T>(
 85 |     email: string,
 86 |     operation: () => Promise<T>
 87 |   ): Promise<T> {
 88 |     try {
 89 |       // Attempt auto-renewal before operation
 90 |       const renewalResult = await this.tokenManager.autoRenewToken(email);
 91 |       if (!renewalResult.success) {
 92 |         if (renewalResult.canRetry) {
 93 |           // If it's a temporary error, let the operation proceed
 94 |           // The 401 handler below will catch and retry if needed
 95 |           logger.warn('Token renewal failed but may be temporary - proceeding with operation');
 96 |         } else {
 97 |           // Only require re-auth if refresh token is invalid/revoked
 98 |           throw new AccountError(
 99 |             'Token renewal failed',
100 |             'TOKEN_RENEWAL_FAILED',
101 |             renewalResult.reason || 'Please re-authenticate your account'
102 |           );
103 |         }
104 |       }
105 | 
106 |       // Perform the operation
107 |       return await operation();
108 |     } catch (error) {
109 |       if (error instanceof Error && 'code' in error && error.code === '401') {
110 |         // If we get a 401 during operation, try one more token renewal
111 |         logger.warn('Received 401 during operation, attempting final token renewal');
112 |         const finalRenewal = await this.tokenManager.autoRenewToken(email);
113 |         
114 |         if (finalRenewal.success) {
115 |           // Retry the operation with renewed token
116 |           return await operation();
117 |         }
118 |         
119 |         // Check if we should trigger full OAuth
120 |         if (!finalRenewal.canRetry) {
121 |           // Refresh token is invalid/revoked, need full reauth
122 |           throw new AccountError(
123 |             'Authentication failed',
124 |             'AUTH_REQUIRED',
125 |             finalRenewal.reason || 'Please re-authenticate your account'
126 |           );
127 |         } else {
128 |           // Temporary error, let caller handle retry
129 |           throw new AccountError(
130 |             'Token refresh failed temporarily',
131 |             'TEMPORARY_AUTH_ERROR',
132 |             'Please try again later'
133 |           );
134 |         }
135 |       }
136 |       throw error;
137 |     }
138 |   }
139 | 
140 |   private validateEmail(email: string): boolean {
141 |     const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
142 |     return emailRegex.test(email);
143 |   }
144 | 
145 |   private async loadAccounts(): Promise<void> {
146 |     try {
147 |       logger.debug(`Loading accounts from ${this.accountsPath}`);
148 |       // Ensure directory exists
149 |       await fs.mkdir(path.dirname(this.accountsPath), { recursive: true });
150 |       
151 |       let data: string;
152 |       try {
153 |         data = await fs.readFile(this.accountsPath, 'utf-8');
154 |       } catch (error) {
155 |         if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
156 |           // Create empty accounts file if it doesn't exist
157 |           logger.info('Creating new accounts file');
158 |           data = JSON.stringify({ accounts: [] });
159 |           await fs.writeFile(this.accountsPath, data);
160 |         } else {
161 |           throw new AccountError(
162 |             'Failed to read accounts configuration',
163 |             'ACCOUNTS_READ_ERROR',
164 |             'Please ensure the accounts file is readable'
165 |           );
166 |         }
167 |       }
168 | 
169 |       try {
170 |         const config = JSON.parse(data) as AccountsConfig;
171 |         this.accounts.clear();
172 |         for (const account of config.accounts) {
173 |           this.accounts.set(account.email, account);
174 |         }
175 |       } catch (error) {
176 |         throw new AccountError(
177 |           'Failed to parse accounts configuration',
178 |           'ACCOUNTS_PARSE_ERROR',
179 |           'Please ensure the accounts file contains valid JSON'
180 |         );
181 |       }
182 |     } catch (error) {
183 |       if (error instanceof AccountError) {
184 |         throw error;
185 |       }
186 |       throw new AccountError(
187 |         'Failed to load accounts configuration',
188 |         'ACCOUNTS_LOAD_ERROR',
189 |         'Please ensure accounts.json exists and is valid'
190 |       );
191 |     }
192 |   }
193 | 
194 |   private async saveAccounts(): Promise<void> {
195 |     try {
196 |       const config: AccountsConfig = {
197 |         accounts: Array.from(this.accounts.values())
198 |       };
199 |       await fs.writeFile(
200 |         this.accountsPath,
201 |         JSON.stringify(config, null, 2)
202 |       );
203 |     } catch (error) {
204 |       throw new AccountError(
205 |         'Failed to save accounts configuration',
206 |         'ACCOUNTS_SAVE_ERROR',
207 |         'Please ensure accounts.json is writable'
208 |       );
209 |     }
210 |   }
211 | 
212 |   async addAccount(email: string, category: string, description: string): Promise<Account> {
213 |     logger.info(`Adding new account: ${email}`);
214 |     if (!this.validateEmail(email)) {
215 |       logger.error(`Invalid email format: ${email}`);
216 |       throw new AccountError(
217 |         'Invalid email format',
218 |         'INVALID_EMAIL',
219 |         'Please provide a valid email address'
220 |       );
221 |     }
222 | 
223 |     if (this.accounts.has(email)) {
224 |       throw new AccountError(
225 |         'Account already exists',
226 |         'DUPLICATE_ACCOUNT',
227 |         'Use updateAccount to modify existing accounts'
228 |       );
229 |     }
230 | 
231 |     const account: Account = {
232 |       email,
233 |       category,
234 |       description
235 |     };
236 | 
237 |     this.accounts.set(email, account);
238 |     await this.saveAccounts();
239 |     return account;
240 |   }
241 | 
242 |   async updateAccount(email: string, updates: Partial<Omit<Account, 'email'>>): Promise<Account> {
243 |     const account = this.accounts.get(email);
244 |     if (!account) {
245 |       throw new AccountError(
246 |         'Account not found',
247 |         'ACCOUNT_NOT_FOUND',
248 |         'Please ensure the account exists before updating'
249 |       );
250 |     }
251 | 
252 |     const updatedAccount: Account = {
253 |       ...account,
254 |       ...updates
255 |     };
256 | 
257 |     this.accounts.set(email, updatedAccount);
258 |     await this.saveAccounts();
259 |     return updatedAccount;
260 |   }
261 | 
262 |   async removeAccount(email: string): Promise<void> {
263 |     logger.info(`Removing account: ${email}`);
264 |     if (!this.accounts.has(email)) {
265 |       logger.error(`Account not found: ${email}`);
266 |       throw new AccountError(
267 |         'Account not found',
268 |         'ACCOUNT_NOT_FOUND',
269 |         'Cannot remove non-existent account'
270 |       );
271 |     }
272 | 
273 |     // Delete token first
274 |     await this.tokenManager.deleteToken(email);
275 |     
276 |     // Then remove account
277 |     this.accounts.delete(email);
278 |     await this.saveAccounts();
279 |     logger.info(`Successfully removed account: ${email}`);
280 |   }
281 | 
282 |   async getAccount(email: string): Promise<Account | null> {
283 |     return this.accounts.get(email) || null;
284 |   }
285 | 
286 |   async validateAccount(
287 |     email: string,
288 |     category?: string,
289 |     description?: string
290 |   ): Promise<Account> {
291 |     logger.debug(`Validating account: ${email}`);
292 |     let account = await this.getAccount(email);
293 |     const isNewAccount: boolean = Boolean(!account && category && description);
294 | 
295 |     try {
296 |       // Handle new account creation
297 |       if (isNewAccount && category && description) {
298 |         logger.info('Creating new account during validation');
299 |         account = await this.addAccount(email, category, description);
300 |       } else if (!account) {
301 |         throw new AccountError(
302 |           'Account not found',
303 |           'ACCOUNT_NOT_FOUND',
304 |           'Please provide category and description for new accounts'
305 |         );
306 |       }
307 | 
308 |       // Validate token with appropriate flags for new accounts
309 |       const tokenStatus = await this.tokenManager.validateToken(email, isNewAccount);
310 |       
311 |       // Map token status to account auth status
312 |       switch (tokenStatus.status) {
313 |         case 'NO_TOKEN':
314 |           account.auth_status = {
315 |             valid: false,
316 |             status: tokenStatus.status,
317 |             reason: isNewAccount ? 'New account requires authentication' : 'No token found',
318 |             authUrl: await this.generateAuthUrl()
319 |           };
320 |           break;
321 |           
322 |         case 'VALID':
323 |         case 'REFRESHED':
324 |           account.auth_status = {
325 |             valid: true,
326 |             status: tokenStatus.status
327 |           };
328 |           break;
329 |           
330 |         case 'INVALID':
331 |         case 'REFRESH_FAILED':
332 |         case 'EXPIRED':
333 |           account.auth_status = {
334 |             valid: false,
335 |             status: tokenStatus.status,
336 |             reason: tokenStatus.reason,
337 |             authUrl: await this.generateAuthUrl()
338 |           };
339 |           break;
340 |           
341 |         case 'ERROR':
342 |           account.auth_status = {
343 |             valid: false,
344 |             status: tokenStatus.status,
345 |             reason: 'Authentication error occurred',
346 |             authUrl: await this.generateAuthUrl()
347 |           };
348 |           break;
349 |       }
350 | 
351 |       logger.debug(`Account validation complete for ${email}. Status: ${tokenStatus.status}`);
352 |       return account;
353 |       
354 |     } catch (error) {
355 |       logger.error('Account validation failed', error as Error);
356 |       if (error instanceof AccountError) {
357 |         throw error;
358 |       }
359 |       throw new AccountError(
360 |         'Account validation failed',
361 |         'VALIDATION_ERROR',
362 |         'An unexpected error occurred during account validation'
363 |       );
364 |     }
365 |   }
366 | 
367 |   // OAuth related methods
368 |   async generateAuthUrl(): Promise<string> {
369 |     const allScopes = scopeRegistry.getAllScopes();
370 |     return this.oauthClient.generateAuthUrl(allScopes);
371 |   }
372 |   
373 |   async startAuthentication(email: string): Promise<string> {
374 |     this.currentAuthEmail = email;
375 |     logger.info(`Starting authentication for ${email}`);
376 |     return this.generateAuthUrl();
377 |   }
378 | 
379 |   async waitForAuthorizationCode(): Promise<string> {
380 |     return this.oauthClient.waitForAuthorizationCode();
381 |   }
382 | 
383 |   async getTokenFromCode(code: string): Promise<any> {
384 |     const token = await this.oauthClient.getTokenFromCode(code);
385 |     return token;
386 |   }
387 | 
388 |   async refreshToken(refreshToken: string): Promise<any> {
389 |     return this.oauthClient.refreshToken(refreshToken);
390 |   }
391 | 
392 |   async getAuthClient() {
393 |     return this.oauthClient.getAuthClient();
394 |   }
395 | 
396 |   // Token related methods
397 |   async validateToken(email: string, skipValidationForNew: boolean = false) {
398 |     return this.tokenManager.validateToken(email, skipValidationForNew);
399 |   }
400 | 
401 |   async saveToken(email: string, tokenData: any) {
402 |     return this.tokenManager.saveToken(email, tokenData);
403 |   }
404 | }
405 | 
```
--------------------------------------------------------------------------------
/src/tools/type-guards.ts:
--------------------------------------------------------------------------------
```typescript
  1 | import {
  2 | BaseToolArguments,
  3 |   CalendarEventParams,
  4 |   SendEmailArgs,
  5 |   ManageLabelParams,
  6 |   ManageLabelAssignmentParams,
  7 |   ManageLabelFilterParams,
  8 |   ManageDraftParams,
  9 |   DriveFileListArgs,
 10 |   DriveSearchArgs,
 11 |   DriveUploadArgs,
 12 |   DriveDownloadArgs,
 13 |   DriveFolderArgs,
 14 |   DrivePermissionArgs,
 15 |   DriveDeleteArgs,
 16 |   ManageAttachmentParams
 17 | } from './types.js';
 18 | import { GetContactsParams } from '../modules/contacts/types.js';
 19 | 
 20 | // Base Tool Arguments
 21 | export function isBaseToolArguments(args: Record<string, unknown>): args is BaseToolArguments {
 22 |   return typeof args.email === 'string';
 23 | }
 24 | 
 25 | export function assertBaseToolArguments(args: Record<string, unknown>): asserts args is BaseToolArguments {
 26 |   if (!isBaseToolArguments(args)) {
 27 |     throw new Error('Missing required email parameter');
 28 |   }
 29 | }
 30 | 
 31 | // Calendar Type Guards
 32 | export function isCalendarEventParams(args: Record<string, unknown>): args is CalendarEventParams {
 33 |   return typeof args.email === 'string' &&
 34 |     (args.query === undefined || typeof args.query === 'string') &&
 35 |     (args.maxResults === undefined || typeof args.maxResults === 'number') &&
 36 |     (args.timeMin === undefined || typeof args.timeMin === 'string') &&
 37 |     (args.timeMax === undefined || typeof args.timeMax === 'string');
 38 | }
 39 | 
 40 | export function assertCalendarEventParams(args: Record<string, unknown>): asserts args is CalendarEventParams {
 41 |   if (!isCalendarEventParams(args)) {
 42 |     throw new Error('Invalid calendar event parameters');
 43 |   }
 44 | }
 45 | 
 46 | export function isEmailEventIdArgs(args: Record<string, unknown>): args is { email: string; eventId: string } {
 47 |   return typeof args.email === 'string' && typeof args.eventId === 'string';
 48 | }
 49 | 
 50 | export function assertEmailEventIdArgs(args: Record<string, unknown>): asserts args is { email: string; eventId: string } {
 51 |   if (!isEmailEventIdArgs(args)) {
 52 |     throw new Error('Missing required email or eventId parameter');
 53 |   }
 54 | }
 55 | 
 56 | // Gmail Type Guards
 57 | export function isSendEmailArgs(args: Record<string, unknown>): args is SendEmailArgs {
 58 |   return typeof args.email === 'string' &&
 59 |     Array.isArray(args.to) &&
 60 |     args.to.every(to => typeof to === 'string') &&
 61 |     typeof args.subject === 'string' &&
 62 |     typeof args.body === 'string' &&
 63 |     (args.cc === undefined || (Array.isArray(args.cc) && args.cc.every(cc => typeof cc === 'string'))) &&
 64 |     (args.bcc === undefined || (Array.isArray(args.bcc) && args.bcc.every(bcc => typeof bcc === 'string')));
 65 | }
 66 | 
 67 | export function assertSendEmailArgs(args: Record<string, unknown>): asserts args is SendEmailArgs {
 68 |   if (!isSendEmailArgs(args)) {
 69 |     throw new Error('Invalid email parameters. Required: email, to, subject, body');
 70 |   }
 71 | }
 72 | 
 73 | // Drive Type Guards
 74 | export function isDriveFileListArgs(args: unknown): args is DriveFileListArgs {
 75 |   if (typeof args !== 'object' || args === null) return false;
 76 |   const params = args as Partial<DriveFileListArgs>;
 77 |   
 78 |   return typeof params.email === 'string' &&
 79 |     (params.options === undefined || (() => {
 80 |       const opts = params.options as any;
 81 |       return (opts.folderId === undefined || typeof opts.folderId === 'string') &&
 82 |         (opts.query === undefined || typeof opts.query === 'string') &&
 83 |         (opts.pageSize === undefined || typeof opts.pageSize === 'number') &&
 84 |         (opts.orderBy === undefined || (Array.isArray(opts.orderBy) && opts.orderBy.every((o: unknown) => typeof o === 'string'))) &&
 85 |         (opts.fields === undefined || (Array.isArray(opts.fields) && opts.fields.every((f: unknown) => typeof f === 'string')));
 86 |     })());
 87 | }
 88 | 
 89 | export function assertDriveFileListArgs(args: unknown): asserts args is DriveFileListArgs {
 90 |   if (!isDriveFileListArgs(args)) {
 91 |     throw new Error('Invalid file list parameters. Required: email');
 92 |   }
 93 | }
 94 | 
 95 | export function isDriveSearchArgs(args: unknown): args is DriveSearchArgs {
 96 |   if (typeof args !== 'object' || args === null) return false;
 97 |   const params = args as Partial<DriveSearchArgs>;
 98 |   
 99 |   return typeof params.email === 'string' &&
100 |     typeof params.options === 'object' && params.options !== null &&
101 |     (params.options.fullText === undefined || typeof params.options.fullText === 'string') &&
102 |     (params.options.mimeType === undefined || typeof params.options.mimeType === 'string') &&
103 |     (params.options.folderId === undefined || typeof params.options.folderId === 'string') &&
104 |     (params.options.trashed === undefined || typeof params.options.trashed === 'boolean') &&
105 |     (params.options.query === undefined || typeof params.options.query === 'string') &&
106 |     (params.options.pageSize === undefined || typeof params.options.pageSize === 'number');
107 | }
108 | 
109 | export function assertDriveSearchArgs(args: unknown): asserts args is DriveSearchArgs {
110 |   if (!isDriveSearchArgs(args)) {
111 |     throw new Error('Invalid search parameters. Required: email, options');
112 |   }
113 | }
114 | 
115 | export function isDriveUploadArgs(args: unknown): args is DriveUploadArgs {
116 |   if (typeof args !== 'object' || args === null) return false;
117 |   const params = args as Partial<DriveUploadArgs>;
118 |   
119 |   return typeof params.email === 'string' &&
120 |     typeof params.options === 'object' && params.options !== null &&
121 |     typeof params.options.name === 'string' &&
122 |     typeof params.options.content === 'string' &&
123 |     (params.options.mimeType === undefined || typeof params.options.mimeType === 'string') &&
124 |     (params.options.parents === undefined || (Array.isArray(params.options.parents) && params.options.parents.every(p => typeof p === 'string')));
125 | }
126 | 
127 | export function assertDriveUploadArgs(args: unknown): asserts args is DriveUploadArgs {
128 |   if (!isDriveUploadArgs(args)) {
129 |     throw new Error('Invalid upload parameters. Required: email, options.name, options.content');
130 |   }
131 | }
132 | 
133 | export function isDriveDownloadArgs(args: unknown): args is DriveDownloadArgs {
134 |   if (typeof args !== 'object' || args === null) return false;
135 |   const params = args as Partial<DriveDownloadArgs>;
136 |   
137 |   return typeof params.email === 'string' &&
138 |     typeof params.fileId === 'string' &&
139 |     (params.mimeType === undefined || typeof params.mimeType === 'string');
140 | }
141 | 
142 | export function assertDriveDownloadArgs(args: unknown): asserts args is DriveDownloadArgs {
143 |   if (!isDriveDownloadArgs(args)) {
144 |     throw new Error('Invalid download parameters. Required: email, fileId');
145 |   }
146 | }
147 | 
148 | export function isDriveFolderArgs(args: unknown): args is DriveFolderArgs {
149 |   if (typeof args !== 'object' || args === null) return false;
150 |   const params = args as Partial<DriveFolderArgs>;
151 |   
152 |   return typeof params.email === 'string' &&
153 |     typeof params.name === 'string' &&
154 |     (params.parentId === undefined || typeof params.parentId === 'string');
155 | }
156 | 
157 | export function assertDriveFolderArgs(args: unknown): asserts args is DriveFolderArgs {
158 |   if (!isDriveFolderArgs(args)) {
159 |     throw new Error('Invalid folder parameters. Required: email, name');
160 |   }
161 | }
162 | 
163 | export function isDrivePermissionArgs(args: unknown): args is DrivePermissionArgs {
164 |   if (typeof args !== 'object' || args === null) return false;
165 |   const params = args as Partial<DrivePermissionArgs>;
166 |   
167 |   return typeof params.email === 'string' &&
168 |     typeof params.options === 'object' && params.options !== null &&
169 |     typeof params.options.fileId === 'string' &&
170 |     ['owner', 'organizer', 'fileOrganizer', 'writer', 'commenter', 'reader'].includes(params.options.role) &&
171 |     ['user', 'group', 'domain', 'anyone'].includes(params.options.type) &&
172 |     (params.options.emailAddress === undefined || typeof params.options.emailAddress === 'string') &&
173 |     (params.options.domain === undefined || typeof params.options.domain === 'string') &&
174 |     (params.options.allowFileDiscovery === undefined || typeof params.options.allowFileDiscovery === 'boolean');
175 | }
176 | 
177 | export function assertDrivePermissionArgs(args: unknown): asserts args is DrivePermissionArgs {
178 |   if (!isDrivePermissionArgs(args)) {
179 |     throw new Error('Invalid permission parameters. Required: email, options.fileId, options.role, options.type');
180 |   }
181 | }
182 | 
183 | export function isDriveDeleteArgs(args: unknown): args is DriveDeleteArgs {
184 |   if (typeof args !== 'object' || args === null) return false;
185 |   const params = args as Partial<DriveDeleteArgs>;
186 |   
187 |   return typeof params.email === 'string' &&
188 |     typeof params.fileId === 'string';
189 | }
190 | 
191 | export function assertDriveDeleteArgs(args: unknown): asserts args is DriveDeleteArgs {
192 |   if (!isDriveDeleteArgs(args)) {
193 |     throw new Error('Invalid delete parameters. Required: email, fileId');
194 |   }
195 | }
196 | 
197 | // Label Management Type Guards
198 | export function isManageLabelParams(args: unknown): args is ManageLabelParams {
199 |   if (typeof args !== 'object' || args === null) return false;
200 |   const params = args as Partial<ManageLabelParams>;
201 |   
202 |   return typeof params.email === 'string' &&
203 |     typeof params.action === 'string' &&
204 |     ['create', 'read', 'update', 'delete'].includes(params.action) &&
205 |     (params.labelId === undefined || typeof params.labelId === 'string') &&
206 |     (params.data === undefined || (() => {
207 |       if (typeof params.data !== 'object' || params.data === null) return false;
208 |       const data = params.data as {
209 |         name?: string;
210 |         messageListVisibility?: string;
211 |         labelListVisibility?: string;
212 |       };
213 |       return (data.name === undefined || typeof data.name === 'string') &&
214 |         (data.messageListVisibility === undefined || ['show', 'hide'].includes(data.messageListVisibility)) &&
215 |         (data.labelListVisibility === undefined || ['labelShow', 'labelHide', 'labelShowIfUnread'].includes(data.labelListVisibility));
216 |     })());
217 | }
218 | 
219 | export function assertManageLabelParams(args: unknown): asserts args is ManageLabelParams {
220 |   if (!isManageLabelParams(args)) {
221 |     throw new Error('Invalid label management parameters. Required: email, action');
222 |   }
223 | }
224 | 
225 | export function isManageLabelAssignmentParams(args: unknown): args is ManageLabelAssignmentParams {
226 |   if (typeof args !== 'object' || args === null) return false;
227 |   const params = args as Partial<ManageLabelAssignmentParams>;
228 |   
229 |   return typeof params.email === 'string' &&
230 |     typeof params.action === 'string' &&
231 |     ['add', 'remove'].includes(params.action) &&
232 |     typeof params.messageId === 'string' &&
233 |     Array.isArray(params.labelIds) &&
234 |     params.labelIds.every((id: unknown) => typeof id === 'string');
235 | }
236 | 
237 | export function assertManageLabelAssignmentParams(args: unknown): asserts args is ManageLabelAssignmentParams {
238 |   if (!isManageLabelAssignmentParams(args)) {
239 |     throw new Error('Invalid label assignment parameters. Required: email, action, messageId, labelIds');
240 |   }
241 | }
242 | 
243 | export function isManageLabelFilterParams(args: unknown): args is ManageLabelFilterParams {
244 |   if (typeof args !== 'object' || args === null) return false;
245 |   const params = args as Partial<ManageLabelFilterParams>;
246 |   
247 |   return typeof params.email === 'string' &&
248 |     typeof params.action === 'string' &&
249 |     ['create', 'read', 'update', 'delete'].includes(params.action) &&
250 |     (params.filterId === undefined || typeof params.filterId === 'string') &&
251 |     (params.labelId === undefined || typeof params.labelId === 'string') &&
252 |     (params.data === undefined || (() => {
253 |       if (typeof params.data !== 'object' || params.data === null) return false;
254 |       const data = params.data as {
255 |         criteria?: { [key: string]: unknown };
256 |         actions?: { addLabel: boolean; markImportant?: boolean; markRead?: boolean; archive?: boolean };
257 |       };
258 |       return (data.criteria === undefined || (typeof data.criteria === 'object' && data.criteria !== null)) &&
259 |         (data.actions === undefined || (
260 |           typeof data.actions === 'object' &&
261 |           data.actions !== null &&
262 |           typeof data.actions.addLabel === 'boolean'
263 |         ));
264 |     })());
265 | }
266 | 
267 | export function assertManageLabelFilterParams(args: unknown): asserts args is ManageLabelFilterParams {
268 |   if (!isManageLabelFilterParams(args)) {
269 |     throw new Error('Invalid label filter parameters. Required: email, action');
270 |   }
271 | }
272 | 
273 | // Draft Management Type Guards
274 | export function isManageDraftParams(args: unknown): args is ManageDraftParams {
275 |   if (typeof args !== 'object' || args === null) return false;
276 |   const params = args as Partial<ManageDraftParams>;
277 |   
278 |   return typeof params.email === 'string' &&
279 |     typeof params.action === 'string' &&
280 |     ['create', 'read', 'update', 'delete', 'send'].includes(params.action) &&
281 |     (params.draftId === undefined || typeof params.draftId === 'string') &&
282 |     (params.data === undefined || (() => {
283 |       if (typeof params.data !== 'object' || params.data === null) return false;
284 |       const data = params.data as {
285 |         to?: string[];
286 |         subject?: string;
287 |         body?: string;
288 |         cc?: string[];
289 |         bcc?: string[];
290 |         replyToMessageId?: string;
291 |         threadId?: string;
292 |         references?: string[];
293 |         inReplyTo?: string;
294 |       };
295 |       return (data.to === undefined || (Array.isArray(data.to) && data.to.every(to => typeof to === 'string'))) &&
296 |         (data.subject === undefined || typeof data.subject === 'string') &&
297 |         (data.body === undefined || typeof data.body === 'string') &&
298 |         (data.cc === undefined || (Array.isArray(data.cc) && data.cc.every(cc => typeof cc === 'string'))) &&
299 |         (data.bcc === undefined || (Array.isArray(data.bcc) && data.bcc.every(bcc => typeof bcc === 'string'))) &&
300 |         (data.replyToMessageId === undefined || typeof data.replyToMessageId === 'string') &&
301 |         (data.threadId === undefined || typeof data.threadId === 'string') &&
302 |         (data.references === undefined || (Array.isArray(data.references) && data.references.every(ref => typeof ref === 'string'))) &&
303 |         (data.inReplyTo === undefined || typeof data.inReplyTo === 'string');
304 |     })());
305 | }
306 | 
307 | export function isManageAttachmentParams(args: unknown): args is ManageAttachmentParams {
308 |   if (typeof args !== 'object' || args === null) return false;
309 |   const params = args as Partial<ManageAttachmentParams>;
310 |   
311 |   return typeof params.email === 'string' &&
312 |     typeof params.action === 'string' &&
313 |     ['download', 'upload', 'delete'].includes(params.action) &&
314 |     typeof params.source === 'string' &&
315 |     ['email', 'calendar'].includes(params.source) &&
316 |     typeof params.messageId === 'string' &&
317 |     typeof params.filename === 'string' &&
318 |     (params.content === undefined || typeof params.content === 'string');
319 | }
320 | 
321 | export function assertManageAttachmentParams(args: unknown): asserts args is ManageAttachmentParams {
322 |   if (!isManageAttachmentParams(args)) {
323 |     throw new Error('Invalid attachment management parameters. Required: email, action, source, messageId, filename');
324 |   }
325 | }
326 | 
327 | export function assertManageDraftParams(args: unknown): asserts args is ManageDraftParams {
328 |   if (!isManageDraftParams(args)) {
329 |     throw new Error('Invalid draft management parameters. Required: email, action');
330 |   }
331 | }
332 | 
333 | // Contacts Type Guards
334 | export function isGetContactsParams(args: unknown): args is GetContactsParams {
335 |   if (typeof args !== 'object' || args === null) return false;
336 |   const params = args as Partial<GetContactsParams>;
337 |   
338 |   return typeof params.email === 'string' &&
339 |     typeof params.personFields === 'string' &&
340 |     (params.pageSize === undefined || typeof params.pageSize === 'number') &&
341 |     (params.pageToken === undefined || typeof params.pageToken === 'string');
342 | }
343 | 
344 | export function assertGetContactsParams(args: unknown): asserts args is GetContactsParams {
345 |   if (!isGetContactsParams(args)) {
346 |     throw new Error('Invalid contacts parameters. Required: email, personFields');
347 |   }
348 | }
349 | 
```