Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

### Added
- **Gmail read command** (`gscli gmail read <message-id>`) - Read full email content by ID
- Shows subject, from, to, date, and full body
- Supports both plain text and HTML emails
- Extracts body from multipart messages
- **Google Drive comments command** (`gscli drive comments <file-id>`) - List comments on files
- Shows unresolved comments by default
- `--include-resolved` flag to show all comments
- Displays author, content, creation date, and replies
- Shows quoted content from document
- Supports --account flag for multi-account usage

## [0.0.6] - 2025-11-06

### Added
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,9 @@ gscli gmail list --folder SENT
gscli gmail search "from:boss@example.com subject:report"
gscli gmail search "is:unread after:2025/11/01"

# Read a specific email by ID
gscli gmail read <message-id>

# List all folders/labels
gscli gmail folders-list
```
Expand Down Expand Up @@ -234,6 +237,12 @@ gscli drive download <slides-id> --format pptx # PowerPoint

# Download to specific directory
gscli drive download <file-id> --output ./downloads

# List comments on a file (unresolved only)
gscli drive comments <file-id>

# List all comments including resolved
gscli drive comments <file-id> --include-resolved
```

### Google Calendar Commands
Expand Down
61 changes: 59 additions & 2 deletions src/commands/drive.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { Command } from 'commander';
import { getAuthenticatedClient } from '../lib/auth.js';
import { listFiles, searchFiles, downloadFile, getFileMetadata } from '../lib/drive.js';
import { formatDriveFiles, showError, showSuccess, showInfo } from '../lib/formatter.js';
import { listFiles, searchFiles, downloadFile, getFileMetadata, listComments } from '../lib/drive.js';
import { formatDriveFiles, showError, showSuccess, showInfo, formatDate, truncate } from '../lib/formatter.js';
import ora from 'ora';
import chalk from 'chalk';

export function createDriveCommand(): Command {
const drive = new Command('drive');
Expand Down Expand Up @@ -91,6 +92,62 @@ export function createDriveCommand(): Command {
}
});

// Comments command
drive
.command('comments <file-id>')
.description('List comments on a Google Drive file')
.option('--include-resolved', 'Include resolved comments (default: only unresolved)', false)
.option('-a, --account <email>', 'Google account email to use (uses default if not specified)')
.action(async (fileId: string, options) => {
const spinner = ora('Fetching comments...').start();

try {
const auth = await getAuthenticatedClient(options.account);

// Get file metadata first
const file = await getFileMetadata(auth, fileId);

const comments = await listComments(auth, fileId, {
includeResolved: options.includeResolved,
});

spinner.stop();

if (comments.length === 0) {
console.log(chalk.yellow(`\nNo ${options.includeResolved ? '' : 'unresolved '}comments found on: ${file.name}\n`));
return;
}

console.log(chalk.bold.cyan(`\nπŸ’¬ Comments on: ${file.name}`));
console.log(chalk.gray(`Found ${comments.length} comment(s)\n`));

comments.forEach((comment, index) => {
const resolvedBadge = comment.resolved ? chalk.green(' [RESOLVED]') : chalk.yellow(' [OPEN]');
console.log(chalk.bold(`${index + 1}. ${comment.author}${resolvedBadge}`));
console.log(chalk.gray(` Created: ${formatDate(comment.createdTime)}`));

if (comment.quotedContent) {
console.log(chalk.dim(` Quoted: "${truncate(comment.quotedContent, 60)}"`));
}

console.log(chalk.white(` ${comment.content}`));

if (comment.replies.length > 0) {
console.log(chalk.gray(` Replies (${comment.replies.length}):`));
comment.replies.forEach((reply) => {
console.log(chalk.gray(` β€’ ${reply.author}: ${truncate(reply.content, 80)}`));
});
}

console.log('');
});
} catch (error: any) {
spinner.stop();
showError(error.message);
process.exit(1);
}
});

return drive;
}

38 changes: 36 additions & 2 deletions src/commands/gmail.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Command } from 'commander';
import { getAuthenticatedClient } from '../lib/auth.js';
import { listMessages, searchMessages, getLabels } from '../lib/gmail.js';
import { formatGmailMessages, showError } from '../lib/formatter.js';
import { listMessages, searchMessages, getLabels, getMessage } from '../lib/gmail.js';
import { formatGmailMessages, showError, formatDate } from '../lib/formatter.js';
import ora from 'ora';
import chalk from 'chalk';

Expand Down Expand Up @@ -59,6 +59,40 @@ export function createGmailCommand(): Command {
}
});

// Read command
gmail
.command('read <message-id>')
.description('Read a single email by its ID')
.option('-a, --account <email>', 'Google account email to use (uses default if not specified)')
.action(async (messageId: string, options) => {
const spinner = ora('Fetching message...').start();

try {
const auth = await getAuthenticatedClient(options.account);
const message = await getMessage(auth, messageId);

spinner.stop();

console.log(chalk.bold.cyan('\nπŸ“§ Email Message\n'));
console.log(chalk.bold('Subject: ') + message.subject);
console.log(chalk.gray('From: ') + message.from);
console.log(chalk.gray('To: ') + message.to);
console.log(chalk.gray('Date: ') + formatDate(message.date));
console.log(chalk.dim('ID: ') + message.id);
console.log(chalk.dim('Thread ID: ') + message.threadId);
console.log('');
console.log(chalk.bold('Body:'));
console.log(chalk.gray('─'.repeat(80)));
console.log(message.body);
console.log(chalk.gray('─'.repeat(80)));
console.log('');
} catch (error: any) {
spinner.stop();
showError(error.message);
process.exit(1);
}
});

// Folders list command
gmail
.command('folders-list')
Expand Down
64 changes: 64 additions & 0 deletions src/lib/drive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,3 +275,67 @@ export async function getFileMetadata(
}
}

export interface DriveComment {
id: string;
content: string;
author: string;
authorEmail?: string | null;
createdTime: string;
modifiedTime: string;
resolved: boolean;
quotedContent?: string;
replies: Array<{
id: string;
content: string;
author: string;
createdTime: string;
}>;
}

/**
* List comments on a file
*/
export async function listComments(
auth: OAuth2Client,
fileId: string,
options: {
includeResolved?: boolean;
} = {}
): Promise<DriveComment[]> {
const drive = google.drive({ version: 'v3', auth });

try {
const response = await drive.comments.list({
fileId,
fields: 'comments(id, content, author, createdTime, modifiedTime, resolved, quotedFileContent, replies(id, content, author, createdTime))',
includeDeleted: false,
});

const comments = response.data.comments || [];

// Filter out resolved comments if not requested
const filteredComments = options.includeResolved
? comments
: comments.filter(c => !c.resolved);

return filteredComments.map((comment) => ({
id: comment.id!,
content: comment.content!,
author: comment.author?.displayName || 'Unknown',
authorEmail: comment.author?.emailAddress,
createdTime: comment.createdTime!,
modifiedTime: comment.modifiedTime!,
resolved: comment.resolved || false,
quotedContent: comment.quotedFileContent?.value,
replies: (comment.replies || []).map((reply) => ({
id: reply.id!,
content: reply.content!,
author: reply.author?.displayName || 'Unknown',
createdTime: reply.createdTime!,
})),
}));
} catch (error: any) {
throw new Error(`Failed to list comments: ${error.message}`);
}
}

70 changes: 70 additions & 0 deletions src/lib/gmail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,76 @@ export async function searchMessages(
}
}

/**
* Get a single message by ID
*/
export async function getMessage(
auth: OAuth2Client,
messageId: string
): Promise<{
id: string;
threadId: string;
from: string;
to: string;
subject: string;
date: string;
body: string;
labels: string[];
}> {
const gmail = google.gmail({ version: 'v1', auth });

try {
const response = await gmail.users.messages.get({
userId: 'me',
id: messageId,
format: 'full',
});

const message = response.data;
const headers = message.payload?.headers || [];

const from = headers.find((h) => h.name?.toLowerCase() === 'from')?.value || 'Unknown';
const to = headers.find((h) => h.name?.toLowerCase() === 'to')?.value || 'Unknown';
const subject = headers.find((h) => h.name?.toLowerCase() === 'subject')?.value || '(No Subject)';
const date = headers.find((h) => h.name?.toLowerCase() === 'date')?.value || '';

// Extract body (try HTML first, then plain text)
let body = '';
if (message.payload?.parts) {
// Multipart message
for (const part of message.payload.parts) {
if (part.mimeType === 'text/plain' && part.body?.data) {
body = Buffer.from(part.body.data, 'base64').toString('utf-8');
break;
} else if (part.mimeType === 'text/html' && part.body?.data) {
body = Buffer.from(part.body.data, 'base64').toString('utf-8');
}
}
} else if (message.payload?.body?.data) {
// Simple message
body = Buffer.from(message.payload.body.data, 'base64').toString('utf-8');
}

// Fallback to snippet if no body found
if (!body) {
body = message.snippet || '';
}

return {
id: message.id!,
threadId: message.threadId!,
from,
to,
subject,
date,
body,
labels: message.labelIds || [],
};
} catch (error: any) {
throw new Error(`Failed to get message: ${error.message}`);
}
}

/**
* Get available labels/folders
*/
Expand Down