Skip to content
Open
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
89 changes: 89 additions & 0 deletions packages/email-mcp/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,95 @@ describe('cli/EMAIL_AGENT_MCP_HOME', () => {
});
});

describe('cli/Token Subcommand', () => {
let tmpDir: string;
let originalHome: string | undefined;
let stderrSpy: ReturnType<typeof vi.spyOn>;

beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'email-agent-mcp-token-test-'));
originalHome = process.env['EMAIL_AGENT_MCP_HOME'];
process.env['EMAIL_AGENT_MCP_HOME'] = tmpDir;
stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
});

afterEach(async () => {
stderrSpy.mockRestore();
if (originalHome === undefined) {
delete process.env['EMAIL_AGENT_MCP_HOME'];
} else {
process.env['EMAIL_AGENT_MCP_HOME'] = originalHome;
}
await rm(tmpDir, { recursive: true, force: true });
});

it('Scenario: Token with no mailboxes returns exit 1', async () => {
const exitCode = await runCli(['token']);
expect(exitCode).toBe(1);
expect(stderrSpy).toHaveBeenCalledWith(
expect.stringContaining('no mailboxes configured'),
);
});

it('Scenario: Token with --mailbox nonexistent returns exit 2', async () => {
const exitCode = await runCli(['token', '--mailbox', 'nonexistent']);
expect(exitCode).toBe(2);
expect(stderrSpy).toHaveBeenCalledWith(
expect.stringContaining('not found'),
);
});

it('Scenario: Token with multiple mailboxes and no --mailbox returns exit 2', async () => {
const { writeFile, mkdir } = await import('node:fs/promises');
const tokensDir = join(tmpDir, 'tokens');
await mkdir(tokensDir, { recursive: true });

await writeFile(join(tokensDir, 'alice-at-example-com.json'), JSON.stringify({
mailboxName: 'alice-at-example-com',
emailAddress: '[email protected]',
clientId: 'test-client-id',
}));
await writeFile(join(tokensDir, 'bob-at-example-com.json'), JSON.stringify({
mailboxName: 'bob-at-example-com',
emailAddress: '[email protected]',
clientId: 'test-client-id',
}));

const exitCode = await runCli(['token']);
expect(exitCode).toBe(2);
expect(stderrSpy).toHaveBeenCalledWith(
expect.stringContaining('multiple mailboxes configured'),
);
});

it('Scenario: Token with single mailbox and expired auth returns exit 1', async () => {
const { writeFile, mkdir } = await import('node:fs/promises');
const tokensDir = join(tmpDir, 'tokens');
await mkdir(tokensDir, { recursive: true });

await writeFile(join(tokensDir, 'expired-at-example-com.json'), JSON.stringify({
mailboxName: 'expired-at-example-com',
emailAddress: '[email protected]',
clientId: 'test-client-id',
}));

// reconnect() will fail because there are no real cached credentials
const exitCode = await runCli(['token']);
expect(exitCode).toBe(1);
});

it('Scenario: Token arg parsing', () => {
const opts = parseCliArgs(['token']);
expect(opts.command).toBe('token');
});

it('Scenario: Token with --mailbox arg parsing', () => {
const opts = parseCliArgs(['token', '--mailbox', '[email protected]']);
expect(opts.command).toBe('token');
expect(opts.mailbox).toBe('[email protected]');
});
});

describe('cli/Poll Interval Validation', () => {
it('Scenario: Poll interval below 2 is clamped', () => {
const opts = parseCliArgs(['watch', '--poll-interval', '1']);
Expand Down
62 changes: 61 additions & 1 deletion packages/email-mcp/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,8 @@ export async function runCli(args: string[]): Promise<number> {
return await runConfigure(opts);
case 'status':
return await runStatus();
case 'token':
return await runToken(opts);
case 'help':
printHelp();
return 0;
Expand Down Expand Up @@ -653,6 +655,63 @@ export async function runStatus(): Promise<number> {
return 0;
}

async function runToken(opts: CliOptions): Promise<number> {
const {
DelegatedAuthManager,
listConfiguredMailboxesWithMetadata,
loadMailboxMetadata,
} = await import('@usejunior/provider-microsoft');

let metadata;

if (opts.mailbox) {
metadata = await loadMailboxMetadata(opts.mailbox);
if (!metadata) {
process.stderr.write(`Error: mailbox "${opts.mailbox}" not found.\n`);
return 2;
}
} else {
const mailboxes = await listConfiguredMailboxesWithMetadata();
if (mailboxes.length === 0) {
process.stderr.write('Error: no mailboxes configured. Run: npx email-agent-mcp configure\n');
return 1;
}
if (mailboxes.length > 1) {
process.stderr.write('Error: multiple mailboxes configured. Use --mailbox to select:\n');
for (const m of mailboxes) {
process.stderr.write(` ${m.emailAddress ?? m.mailboxName}\n`);
}
return 2;
}
metadata = mailboxes[0]!;
}

const auth = new DelegatedAuthManager(
{ mode: 'delegated', clientId: metadata.clientId },
metadata.mailboxName,
);

try {
await auth.reconnect();
const token = await auth.getAccessToken();

// Await stdout flush — the CLI wrapper calls process.exit() after runCli()
// resolves, which can truncate piped output if the write hasn't drained.
await new Promise<void>((resolve, reject) => {
process.stdout.write(token, (err) => err ? reject(err) : resolve());
});
return 0;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes('interaction_required') || msg.includes('invalid_grant')) {
process.stderr.write('Error: authentication expired. Run: npx email-agent-mcp configure\n');
} else {
process.stderr.write(`Error: ${msg}\n`);
}
return 1;
}
}

function printHelp(): void {
console.error(`
email-agent-mcp — Email connectivity for AI agents
Expand All @@ -667,6 +726,7 @@ COMMANDS:
email-agent-mcp setup Configure a mailbox (interactive)
email-agent-mcp configure Configure a mailbox (interactive, alias for setup)
email-agent-mcp status Show account + connection health
email-agent-mcp token Print a Graph API bearer token to stdout
email-agent-mcp serve Force MCP server mode
email-agent-mcp help Show this help

Expand All @@ -677,7 +737,7 @@ OPTIONS:
--poll-interval <sec> Poll interval in seconds (default 10, min 2)
--nemoclaw NemoClaw egress bootstrap
--log-level <level> Log level (debug, info, warn, error)
--mailbox <name> Mailbox name (default: "default")
--mailbox <name> Mailbox alias or email address (required if multiple configured)
--provider <name> Auth provider (microsoft, gmail)
--client-id <id> OAuth client ID override
`.trim());
Expand Down
Loading