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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,8 @@ name = "personal"
email = "you@gmail.com"
full_name = "Your Name"
password = "your-app-password"
# save_to_sent = true # (default) APPEND each sent message to the Sent folder
# so it shows in webmail / Thunderbird / Apple Mail.

[accounts.imap]
host = "imap.gmail.com"
Expand Down
2 changes: 2 additions & 0 deletions src/cli/account-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,7 @@ function buildTestAccount(
maxMessages: server.smtpPoolMaxMessages,
},
},
saveToSent: true,
};
}

Expand Down Expand Up @@ -410,6 +411,7 @@ function buildRawAccount(
max_messages: server.smtpPoolMaxMessages,
},
},
save_to_sent: true,
};
}

Expand Down
4 changes: 4 additions & 0 deletions src/config/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ function loadFromEnv(): RawAppConfig | null {
max_messages: parseInt(process.env.MCP_EMAIL_SMTP_POOL_MAX_MESSAGES ?? '100', 10),
},
},
save_to_sent: process.env.MCP_EMAIL_SAVE_TO_SENT !== 'false',
},
],
};
Expand Down Expand Up @@ -179,6 +180,7 @@ function normalizeAccount(raw: RawAccountConfig): AccountConfig {
maxMessages: raw.smtp.pool.max_messages,
},
},
saveToSent: raw.save_to_sent ?? true,
};
}

Expand Down Expand Up @@ -349,6 +351,8 @@ full_name = "Your Name"
# username defaults to email if omitted
# username = "you@example.com"
password = "your-app-password"
# save_to_sent = true # (default) APPEND each sent message to the Sent folder
# via IMAP so it shows in webmail / desktop clients.

[accounts.imap]
host = "imap.example.com"
Expand Down
1 change: 1 addition & 0 deletions src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export const AccountConfigSchema = z
oauth2: OAuth2ConfigSchema.optional(),
imap: ImapConfigSchema,
smtp: SmtpConfigSchema,
save_to_sent: z.boolean().default(true),
})
.refine((data) => data.password ?? data.oauth2, {
message: 'Either password or oauth2 config is required',
Expand Down
35 changes: 35 additions & 0 deletions src/services/imap.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1079,6 +1079,41 @@ export default class ImapService {
return { email, mailbox: draftsPath };
}

// -------------------------------------------------------------------------
// Save to Sent (called by SmtpService after successful SMTP send)
// -------------------------------------------------------------------------

/**
* APPEND a sent message to the account's Sent folder so it shows up in
* webmail / desktop clients (Thunderbird, Apple Mail, etc.).
*
* Auto-detects the Sent folder via the IMAP `\Sent` special-use flag, with
* fallbacks to common names. Throws if no Sent folder can be located.
*/
async saveToSent(accountName: string, rawMessage: Buffer, date?: Date): Promise<void> {
const client = await this.connections.getImapClient(accountName);
const mailboxes = await client.list();

const bySpecialUse = mailboxes.find((mb) => mb.specialUse === '\\Sent');
let sentPath: string | undefined = bySpecialUse?.path;

if (!sentPath) {
const fallbacks = ['Sent', 'Sent Mail', 'Sent Items', 'INBOX.Sent', '[Gmail]/Sent Mail'];
const found = fallbacks.find((name) => mailboxes.some((mb) => mb.path === name));
if (found) sentPath = found;
}

if (!sentPath) {
throw new Error(
`Could not locate the Sent folder for account "${accountName}". ` +
`No mailbox advertised the \\Sent special-use flag and none of the ` +
`common names (Sent, [Gmail]/Sent Mail, INBOX.Sent, ...) were found.`,
);
}

await client.append(sentPath, rawMessage, ['\\Seen'], date);
}

/** Delete a draft after it has been sent. */
async deleteDraft(accountName: string, emailId: number, mailbox: string): Promise<void> {
const client = await this.connections.getImapClient(accountName);
Expand Down
81 changes: 78 additions & 3 deletions src/services/smtp.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ function createMockTransport() {
};
}

function createMockConnectionManager(mockTransport: ReturnType<typeof createMockTransport>) {
function createMockConnectionManager(
mockTransport: ReturnType<typeof createMockTransport>,
accountOverrides: Record<string, unknown> = {},
) {
return {
getAccount: vi.fn().mockReturnValue({
name: 'test',
Expand All @@ -22,6 +25,8 @@ function createMockConnectionManager(mockTransport: ReturnType<typeof createMock
username: 'test@example.com',
imap: { host: 'imap.example.com', port: 993, tls: true, starttls: false, verifySsl: true },
smtp: { host: 'smtp.example.com', port: 465, tls: true, starttls: false, verifySsl: true },
saveToSent: false,
...accountOverrides,
}),
getAccountNames: vi.fn().mockReturnValue(['test']),
getImapClient: vi.fn(),
Expand All @@ -37,8 +42,11 @@ function createMockRateLimiter(allowed = true) {
} as unknown as RateLimiter;
}

function createMockImapService() {
return {} as unknown as ImapService;
function createMockImapService(overrides: Partial<ImapService> = {}) {
return {
saveToSent: vi.fn().mockResolvedValue(undefined),
...overrides,
} as unknown as ImapService;
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -124,5 +132,72 @@ describe('SmtpService', () => {
expect(call.html).toBe('<h1>Hello</h1>');
expect(call.text).toBeUndefined();
});

it('pins Message-ID and Date in the sendMail payload', async () => {
await service.sendEmail('test', {
to: ['a@example.com'],
subject: 'X',
body: 'Y',
});
const call = transport.sendMail.mock.calls[0][0];
expect(typeof call.messageId).toBe('string');
expect(call.messageId).toMatch(/^<.+@.+>$/);
expect(call.date).toBeInstanceOf(Date);
});
});

describe('save_to_sent', () => {
it('calls imapService.saveToSent when account.saveToSent is true', async () => {
const imapMock = createMockImapService();
connections = createMockConnectionManager(transport, { saveToSent: true });
service = new SmtpService(connections, rateLimiter, imapMock);

await service.sendEmail('test', {
to: ['a@example.com'],
subject: 'S',
body: 'B',
});

const saveToSent = (imapMock as any).saveToSent as ReturnType<typeof vi.fn>;
expect(saveToSent).toHaveBeenCalledTimes(1);
const [accountArg, rawArg, dateArg] = saveToSent.mock.calls[0];
expect(accountArg).toBe('test');
expect(Buffer.isBuffer(rawArg)).toBe(true);
expect((rawArg as Buffer).toString('utf-8')).toContain('Subject: S');
expect(dateArg).toBeInstanceOf(Date);
Comment on lines +165 to +167
});

it('skips saveToSent when account.saveToSent is false', async () => {
// default mock already has saveToSent: false
const imapMock = createMockImapService();
service = new SmtpService(connections, rateLimiter, imapMock);

await service.sendEmail('test', {
to: ['a@example.com'],
subject: 'S',
body: 'B',
});

const saveToSent = (imapMock as any).saveToSent as ReturnType<typeof vi.fn>;
expect(saveToSent).not.toHaveBeenCalled();
});

it('does not throw when saveToSent fails — send is best-effort', async () => {
const imapMock = createMockImapService({
saveToSent: vi.fn().mockRejectedValue(new Error('IMAP unreachable')),
} as unknown as Partial<ImapService>);
connections = createMockConnectionManager(transport, { saveToSent: true });
service = new SmtpService(connections, rateLimiter, imapMock);

// Silence the expected console.error
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);

await expect(
service.sendEmail('test', { to: ['a@example.com'], subject: 'S', body: 'B' }),
).resolves.toMatchObject({ status: 'sent' });

expect(errSpy).toHaveBeenCalledWith(expect.stringContaining('IMAP unreachable'));
errSpy.mockRestore();
});
});
});
101 changes: 72 additions & 29 deletions src/services/smtp.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,85 @@
* No MCP dependency — fully unit-testable.
*/

import MailComposer from 'nodemailer/lib/mail-composer/index.js';
import type { IConnectionManager } from '../connections/types.js';
import type RateLimiter from '../safety/rate-limiter.js';
import type { SendResult } from '../types/index.js';
import type ImapService from './imap.service.js';

type MailOptions = Record<string, unknown>;

/** Build the raw RFC822 bytes for a message via nodemailer's MailComposer. */
async function buildRaw(mailOptions: MailOptions): Promise<Buffer> {
return new Promise((resolve, reject) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const composer = new (MailComposer as any)(mailOptions);
composer.compile().build((err: Error | null, raw: Buffer) => {
if (err) reject(err);
else resolve(raw);
});
});
}
Comment on lines +13 to +25

/** Generate a Message-ID in the standard `<random@host>` form. */
function generateMessageId(fromEmail: string): string {
const domain = fromEmail.split('@')[1] ?? 'localhost';
const rnd = Math.random().toString(36).slice(2) + Date.now().toString(36);
return `<${rnd}@${domain}>`;
}
Comment on lines +28 to +32

export default class SmtpService {
constructor(
private connections: IConnectionManager,
private rateLimiter: RateLimiter,
private imapService: ImapService,
) {}

// -------------------------------------------------------------------------
// Internal: send via SMTP, then APPEND to Sent if enabled for the account
// -------------------------------------------------------------------------

private async dispatch(accountName: string, mailOptions: MailOptions): Promise<SendResult> {
const account = this.connections.getAccount(accountName);
const transport = await this.connections.getSmtpTransport(accountName);

// Pin Message-ID and Date so the SMTP-sent message and the IMAP-APPENDed
// copy carry identical threading-critical headers.
const messageId =
typeof mailOptions.messageId === 'string'
? mailOptions.messageId
: generateMessageId(account.email);
const date = mailOptions.date instanceof Date ? mailOptions.date : new Date();

const finalOptions: MailOptions = {
...mailOptions,
messageId,
date,
};

const info = await transport.sendMail(finalOptions);

// Best-effort save to Sent — never let a Sent-save failure block the send.
if (account.saveToSent) {
try {
const raw = await buildRaw(finalOptions);
await this.imapService.saveToSent(accountName, raw, date);
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
// eslint-disable-next-line no-console
console.error(
`[email-mcp] save_to_sent: APPEND to Sent folder failed for ` +
`account "${accountName}": ${errMsg}`,
);
}
}

return {
messageId: typeof info.messageId === 'string' && info.messageId ? info.messageId : messageId,
status: 'sent',
};
Comment on lines +80 to +83
}

// -------------------------------------------------------------------------
// Send email
// -------------------------------------------------------------------------
Expand All @@ -34,21 +101,15 @@ export default class SmtpService {
this.checkRateLimit(accountName);

const account = this.connections.getAccount(accountName);
const transport = await this.connections.getSmtpTransport(accountName);

const result = await transport.sendMail({
return this.dispatch(accountName, {
from: account.fullName ? `"${account.fullName}" <${account.email}>` : account.email,
to: options.to.join(', '),
cc: options.cc?.join(', '),
bcc: options.bcc?.join(', '),
subject: options.subject,
...(options.html ? { html: options.body } : { text: options.body }),
});

return {
messageId: result.messageId ?? '',
status: 'sent',
};
}

// -------------------------------------------------------------------------
Expand Down Expand Up @@ -96,9 +157,7 @@ export default class SmtpService {
? original.subject
: `Re: ${original.subject}`;

const transport = await this.connections.getSmtpTransport(accountName);

const result = await transport.sendMail({
return this.dispatch(accountName, {
from: account.fullName ? `"${account.fullName}" <${account.email}>` : account.email,
to: to.join(', '),
cc: cc.length > 0 ? cc.join(', ') : undefined,
Expand All @@ -107,11 +166,6 @@ export default class SmtpService {
references: references.join(' '),
...(options.html ? { html: options.body } : { text: options.body }),
});

return {
messageId: result.messageId ?? '',
status: 'sent',
};
}

// -------------------------------------------------------------------------
Expand Down Expand Up @@ -151,20 +205,13 @@ export default class SmtpService {
const originalBody = original.bodyText ?? original.bodyHtml ?? '';
const fullBody = (options.body ?? '') + forwardHeader + originalBody;

const transport = await this.connections.getSmtpTransport(accountName);

const result = await transport.sendMail({
return this.dispatch(accountName, {
from: account.fullName ? `"${account.fullName}" <${account.email}>` : account.email,
to: options.to.join(', '),
cc: options.cc?.join(', '),
subject,
text: fullBody,
});

return {
messageId: result.messageId ?? '',
status: 'sent',
};
}

// -------------------------------------------------------------------------
Expand Down Expand Up @@ -195,12 +242,11 @@ export default class SmtpService {
);

const account = this.connections.getAccount(accountName);
const transport = await this.connections.getSmtpTransport(accountName);

const to = draft.to.map((a) => a.address).join(', ');
const cc = draft.cc?.map((a) => a.address).join(', ');

const result = await transport.sendMail({
const result = await this.dispatch(accountName, {
from: account.fullName ? `"${account.fullName}" <${account.email}>` : account.email,
to,
cc,
Expand All @@ -213,9 +259,6 @@ export default class SmtpService {
// Delete the draft after successful send
await this.imapService.deleteDraft(accountName, draftId, draftsPath);

return {
messageId: result.messageId ?? '',
status: 'sent',
};
return result;
}
}
1 change: 1 addition & 0 deletions src/services/watcher.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const testAccount: AccountConfig = {
password: 'password',
imap: { host: 'imap.example.com', port: 993, tls: true, starttls: false, verifySsl: true },
smtp: { host: 'smtp.example.com', port: 465, tls: true, starttls: false, verifySsl: true },
saveToSent: true,
};

describe('WatcherService', () => {
Expand Down
Loading