diff --git a/README.md b/README.md index 70757e5..2fee969 100644 --- a/README.md +++ b/README.md @@ -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" diff --git a/src/cli/account-commands.ts b/src/cli/account-commands.ts index cf71170..b1417d4 100644 --- a/src/cli/account-commands.ts +++ b/src/cli/account-commands.ts @@ -336,6 +336,7 @@ function buildTestAccount( maxMessages: server.smtpPoolMaxMessages, }, }, + saveToSent: true, }; } @@ -410,6 +411,7 @@ function buildRawAccount( max_messages: server.smtpPoolMaxMessages, }, }, + save_to_sent: true, }; } diff --git a/src/config/loader.ts b/src/config/loader.ts index c89c26e..8c07c0e 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -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', }, ], }; @@ -179,6 +180,7 @@ function normalizeAccount(raw: RawAccountConfig): AccountConfig { maxMessages: raw.smtp.pool.max_messages, }, }, + saveToSent: raw.save_to_sent ?? true, }; } @@ -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" diff --git a/src/config/schema.ts b/src/config/schema.ts index 194f4fa..c4eb11f 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -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', diff --git a/src/services/imap.service.ts b/src/services/imap.service.ts index 123cf9d..22bcca8 100644 --- a/src/services/imap.service.ts +++ b/src/services/imap.service.ts @@ -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 { + 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 { const client = await this.connections.getImapClient(accountName); diff --git a/src/services/smtp.service.test.ts b/src/services/smtp.service.test.ts index 33ddba1..08a2ed2 100644 --- a/src/services/smtp.service.test.ts +++ b/src/services/smtp.service.test.ts @@ -13,7 +13,10 @@ function createMockTransport() { }; } -function createMockConnectionManager(mockTransport: ReturnType) { +function createMockConnectionManager( + mockTransport: ReturnType, + accountOverrides: Record = {}, +) { return { getAccount: vi.fn().mockReturnValue({ name: 'test', @@ -22,6 +25,8 @@ function createMockConnectionManager(mockTransport: ReturnType = {}) { + return { + saveToSent: vi.fn().mockResolvedValue(undefined), + ...overrides, + } as unknown as ImapService; } // --------------------------------------------------------------------------- @@ -124,5 +132,72 @@ describe('SmtpService', () => { expect(call.html).toBe('

Hello

'); 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; + 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); + }); + + 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; + 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); + 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(); + }); }); }); diff --git a/src/services/smtp.service.ts b/src/services/smtp.service.ts index b874ef5..5731306 100644 --- a/src/services/smtp.service.ts +++ b/src/services/smtp.service.ts @@ -4,11 +4,33 @@ * 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; + +/** Build the raw RFC822 bytes for a message via nodemailer's MailComposer. */ +async function buildRaw(mailOptions: MailOptions): Promise { + 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); + }); + }); +} + +/** Generate a Message-ID in the standard `` 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}>`; +} + export default class SmtpService { constructor( private connections: IConnectionManager, @@ -16,6 +38,51 @@ export default class SmtpService { private imapService: ImapService, ) {} + // ------------------------------------------------------------------------- + // Internal: send via SMTP, then APPEND to Sent if enabled for the account + // ------------------------------------------------------------------------- + + private async dispatch(accountName: string, mailOptions: MailOptions): Promise { + 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', + }; + } + // ------------------------------------------------------------------------- // Send email // ------------------------------------------------------------------------- @@ -34,9 +101,8 @@ 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(', '), @@ -44,11 +110,6 @@ export default class SmtpService { subject: options.subject, ...(options.html ? { html: options.body } : { text: options.body }), }); - - return { - messageId: result.messageId ?? '', - status: 'sent', - }; } // ------------------------------------------------------------------------- @@ -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, @@ -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', - }; } // ------------------------------------------------------------------------- @@ -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', - }; } // ------------------------------------------------------------------------- @@ -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, @@ -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; } } diff --git a/src/services/watcher.service.test.ts b/src/services/watcher.service.test.ts index b2bbe0f..1247acd 100644 --- a/src/services/watcher.service.test.ts +++ b/src/services/watcher.service.test.ts @@ -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', () => { diff --git a/src/types/index.ts b/src/types/index.ts index 6203f97..75892f2 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -68,6 +68,13 @@ export interface AccountConfig { oauth2?: OAuth2Config; imap: ImapConfig; smtp: SmtpConfig; + /** + * When true (default), every successful SMTP send is followed by an IMAP + * APPEND of the same message into the account's Sent folder so that the + * message shows up in webmail / desktop clients (Thunderbird, Apple Mail). + * Configurable per-account via `save_to_sent` in the TOML config. + */ + saveToSent: boolean; } export interface WatcherConfig {