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
113 changes: 105 additions & 8 deletions packages/atxp/src/commands/email.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,67 @@
import { callTool } from '../call-tool.js';
import chalk from 'chalk';
import { readFileSync } from 'fs';
import path from 'path';

const SERVER = 'email.mcp.atxp.ai';

interface EmailOptions {
to?: string;
subject?: string;
body?: string;
attach?: string[];
}

interface Attachment {
filename: string;
contentType: string;
content: string;
}

const MIME_TYPES: Record<string, string> = {
'.pdf': 'application/pdf',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
'.txt': 'text/plain',
'.csv': 'text/csv',
'.json': 'application/json',
'.xml': 'application/xml',
'.html': 'text/html',
'.htm': 'text/html',
'.zip': 'application/zip',
'.gz': 'application/gzip',
'.tar': 'application/x-tar',
'.doc': 'application/msword',
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'.xls': 'application/vnd.ms-excel',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.ppt': 'application/vnd.ms-powerpoint',
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'.mp3': 'audio/mpeg',
'.wav': 'audio/wav',
'.mp4': 'video/mp4',
'.webm': 'video/webm',
};

function getMimeType(filePath: string): string {
const ext = path.extname(filePath).toLowerCase();
return MIME_TYPES[ext] || 'application/octet-stream';
}

function loadAttachments(filePaths: string[]): Attachment[] {
return filePaths.map((filePath) => {
const resolved = path.resolve(filePath);
const content = readFileSync(resolved);
return {
filename: path.basename(resolved),
contentType: getMimeType(resolved),
content: content.toString('base64'),
};
});
}

function showEmailHelp(): void {
Expand All @@ -26,6 +81,7 @@ function showEmailHelp(): void {
console.log(' ' + chalk.yellow('--to') + ' ' + chalk.gray('<email>') + ' ' + 'Recipient email address (required for send)');
console.log(' ' + chalk.yellow('--subject') + ' ' + chalk.gray('<text>') + ' ' + 'Email subject line (required for send)');
console.log(' ' + chalk.yellow('--body') + ' ' + chalk.gray('<text>') + ' ' + 'Email body content (required)');
console.log(' ' + chalk.yellow('--attach') + ' ' + chalk.gray('<file>') + ' ' + 'Attach a file (repeatable)');
console.log();
console.log(chalk.bold('Get Attachment Options:'));
console.log(' ' + chalk.yellow('--message') + ' ' + chalk.gray('<id>') + ' ' + 'Message ID (required)');
Expand All @@ -35,7 +91,10 @@ function showEmailHelp(): void {
console.log(' npx atxp email inbox');
console.log(' npx atxp email read msg_abc123');
console.log(' npx atxp email send --to user@example.com --subject "Hello" --body "Hi there!"');
console.log(' npx atxp email send --to user@example.com --subject "Report" --body "See attached." --attach report.pdf');
console.log(' npx atxp email send --to user@example.com --subject "Files" --body "Two files." --attach a.pdf --attach b.png');
console.log(' npx atxp email reply msg_abc123 --body "Thanks for your message!"');
console.log(' npx atxp email reply msg_abc123 --body "Updated version attached." --attach report-v2.pdf');
console.log(' npx atxp email search "invoice"');
console.log(' npx atxp email delete msg_abc123');
console.log(' npx atxp email get-attachment --message msg_abc123 --index 0');
Expand Down Expand Up @@ -135,10 +194,19 @@ async function checkInbox(): Promise<void> {

for (const email of parsed.messages) {
const readIndicator = email.read === false ? chalk.yellow(' [UNREAD]') : '';
console.log(chalk.gray('ID: ' + email.messageId) + readIndicator);
const attachIndicator = email.attachments && email.attachments.length > 0
? chalk.magenta(` [${email.attachments.length} attachment(s)]`)
: '';
console.log(chalk.gray('ID: ' + email.messageId) + readIndicator + attachIndicator);
console.log(chalk.bold('From: ') + email.from);
console.log(chalk.bold('Subject: ') + email.subject);
console.log(chalk.bold('Date: ') + email.date);
if (email.attachments && email.attachments.length > 0) {
for (let i = 0; i < email.attachments.length; i++) {
const att = email.attachments[i];
console.log(chalk.gray(` [${i}] ${att.filename} (${att.contentType}, ${att.size} bytes)`));
}
}
console.log(chalk.gray('─'.repeat(50)));
}

Expand Down Expand Up @@ -224,11 +292,19 @@ async function sendEmail(options: EmailOptions): Promise<void> {
process.exit(1);
}

const result = await callTool(SERVER, 'email_send_email', {
to,
subject,
body,
});
const args: Record<string, unknown> = { to, subject, body };

if (options.attach && options.attach.length > 0) {
try {
args.attachments = loadAttachments(options.attach);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
console.error(chalk.red('Error reading attachment: ' + msg));
process.exit(1);
}
}

const result = await callTool(SERVER, 'email_send_email', args);

try {
const parsed = JSON.parse(result);
Expand Down Expand Up @@ -264,7 +340,19 @@ async function replyToEmail(messageId?: string, options?: EmailOptions): Promise
process.exit(1);
}

const result = await callTool(SERVER, 'email_reply', { messageId, body });
const args: Record<string, unknown> = { messageId, body };

if (options?.attach && options.attach.length > 0) {
try {
args.attachments = loadAttachments(options.attach);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
console.error(chalk.red('Error reading attachment: ' + msg));
process.exit(1);
}
}

const result = await callTool(SERVER, 'email_reply', args);

try {
const parsed = JSON.parse(result);
Expand Down Expand Up @@ -314,10 +402,19 @@ async function searchEmails(query?: string): Promise<void> {

for (const email of parsed.messages) {
const readIndicator = email.read === false ? chalk.yellow(' [UNREAD]') : '';
console.log(chalk.gray('ID: ' + email.messageId) + readIndicator);
const attachIndicator = email.attachments && email.attachments.length > 0
? chalk.magenta(` [${email.attachments.length} attachment(s)]`)
: '';
console.log(chalk.gray('ID: ' + email.messageId) + readIndicator + attachIndicator);
console.log(chalk.bold('From: ') + email.from);
console.log(chalk.bold('Subject: ') + email.subject);
console.log(chalk.bold('Date: ') + email.date);
if (email.attachments && email.attachments.length > 0) {
for (let i = 0; i < email.attachments.length; i++) {
const att = email.attachments[i];
console.log(chalk.gray(` [${i}] ${att.filename} (${att.contentType}, ${att.size} bytes)`));
}
}
console.log(chalk.gray('─'.repeat(50)));
}
} catch {
Expand Down
2 changes: 2 additions & 0 deletions packages/atxp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ interface EmailOptions {
to?: string;
subject?: string;
body?: string;
attach?: string[];
}

interface PhoneOptionsLocal {
Expand Down Expand Up @@ -251,6 +252,7 @@ function parseArgs(): {
to: getArgValue('--to', ''),
subject: getArgValue('--subject', ''),
body: getArgValue('--body', ''),
attach: getAllArgValues('--attach'),
};

// Parse phone options
Expand Down
36 changes: 36 additions & 0 deletions skills/atxp/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,42 @@ Each agent gets a unique address: `{user_id}@atxp.email`. Claim a username ($1.0
| `npx atxp@latest email claim-username <username>` | $1.00 | Claim a username so your email becomes `{username}@atxp.email` instead of `{user_id}@atxp.email`. Username: 3-32 chars, starts with letter, lowercase alphanumeric/hyphens/underscores. |
| `npx atxp@latest email release-username` | Free | Release username |

#### Email Attachments

**Sending attachments:** Use the `--attach` flag (repeatable) with `email send` or `email reply` to attach local files. The CLI reads each file, detects its MIME type from the extension, and base64-encodes the content automatically.

```bash
# Send with one attachment
npx atxp@latest email send --to user@example.com --subject "Report" --body "See attached." --attach report.pdf

# Send with multiple attachments
npx atxp@latest email send --to user@example.com --subject "Files" --body "Two files." --attach report.pdf --attach chart.png

# Reply with an attachment
npx atxp@latest email reply msg_abc123 --body "Updated version attached." --attach report-v2.pdf
```

**Receiving attachments:** When listing emails (`email inbox`, `email search`) or reading a message (`email read`), attachment metadata (filename, MIME type, size) is displayed automatically. Attachment content is **not** included inline — use `email get-attachment` to download.

```bash
# Download a specific attachment by index (0-based)
npx atxp@latest email get-attachment --message msg_abc123 --index 0
```

**MCP tool parameters for attachments:**

The `email_send_email` and `email_reply` MCP tools accept an optional `attachments` array:

| Field | Type | Description |
|-------|------|-------------|
| `filename` | string | Display name (e.g. `"report.pdf"`) |
| `contentType` | string | MIME type (e.g. `"application/pdf"`) |
| `content` | string | File bytes, base64-encoded |

The `email_get_attachment` MCP tool accepts `messageId` (string) and `attachmentIndex` (zero-based integer) and returns the file content as base64.

**Limits:** Total message size (body + all attachments) must not exceed 10 MB. Base64 encoding adds ~33% overhead, so the effective raw payload is ~7.5 MB per message.

### Phone

Register a phone number to send/receive SMS and make/receive voice calls. The phone command is async — calls and inbound messages arrive asynchronously, so check `phone calls` and `phone sms` for updates.
Expand Down
Loading