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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Changelog

## Unreleased

### Added
- **`from` parameter on every send path.** `send_email`, `reply_email`, `forward_email`, `create_draft`, and `update_draft` now accept `from`, so an account with several addresses can pick which one it speaks as. Previously the sender was whatever the provider defaulted to: the Gmail path never emitted a `From` header at all (so Gmail used the primary address), while IMAP and JMAP hardcoded the account address. `buildRawMimeMessage` already supported `from`; nothing upstream ever passed it. Accepts `alias@example.com` or `Name <alias@example.com>`, matched case-insensitively.
- **The address is validated before the message goes out.** Gmail quietly rewrites the `From` header to the primary address when it names an unverified alias, so sending as the wrong identity looked like a clean success. Gmail now checks the address against the account's send-as list (rejecting `pending` aliases) and JMAP against its identities, and both fail with the addresses that would have worked. IMAP has no alias list, so `from` is passed to the SMTP relay to accept or reject.

### Fixed
- **JMAP submissions did not carry an `identityId`.** `EmailSubmission/set` left the server to guess the sending identity, which is what made a non-default sender impossible. The resolved identity's id is now attached to the submission, and `urn:ietf:params:jmap:submission` was added to the `using` list it should always have declared.

## 0.9.2 — 2026-06-17

### Security
Expand Down
28 changes: 22 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ This opens a browser window to complete the OAuth flow. Your tokens are stored l
In Claude Code, run:

```
authenticate alias="work" provider="imap" email="you@company.com" host="imap.company.com" smtpHost="smtp.company.com" username="you@company.com" password="your-app-password"
authenticate alias="work" provider="imap" email="you@company.com" host="imap.company.com" smtpHost="smtp.company.com" username="you@company.com" password="<app-password>"
```

Credentials are encrypted at rest using AES-256-GCM.
Expand All @@ -89,7 +89,7 @@ Credentials are encrypted at rest using AES-256-GCM.
In Claude Code, run:

```
authenticate alias="fastmail" provider="jmap" email="you@fastmail.com" host="fastmail.com" username="you@fastmail.com" password="your-app-password"
authenticate alias="fastmail" provider="jmap" email="you@fastmail.com" host="fastmail.com" username="you@fastmail.com" password="<app-password>"
```

JMAP auto-discovers the API endpoint via `.well-known/jmap`. Credentials are encrypted at rest using AES-256-GCM.
Expand All @@ -116,10 +116,10 @@ JMAP auto-discovers the API endpoint via `.well-known/jmap`. Credentials are enc
| `multi_account_search` | Run the same query across every configured account in parallel |
| `read_email` | Read a message |
| `read_thread` | Read a conversation thread (Gmail + JMAP) |
| `send_email` | Send a new email (supports `attachments`) |
| `reply_email` | Reply to a message (supports `attachments`) |
| `forward_email` | Forward a message (supports `attachments`) |
| `create_draft` | Create a draft (supports reply drafts via `in_reply_to`, `attachments`) |
| `send_email` | Send a new email (supports `from`, `attachments`) |
| `reply_email` | Reply to a message (supports `from`, `attachments`) |
| `forward_email` | Forward a message (supports `from`, `attachments`) |
| `create_draft` | Create a draft (supports reply drafts via `in_reply_to`, plus `from`, `attachments`) |
| `list_drafts` | List drafts for an account |
| `send_draft` | Send an existing draft |
| `trash_emails` | Trash messages |
Expand Down Expand Up @@ -170,6 +170,22 @@ send_email account="personal" to=["friend@example.com"] subject="The report" bod
- Gmail routes messages with attachments through the multipart upload endpoint (35 MB API limit) instead of the JSON endpoint, so the 25 MB message cap is the real ceiling.
- JMAP uploads each file to the server's upload URL first, then references the resulting blobIds in the Email/set call.

## Choosing the sender address

One account often speaks for several addresses. `send_email`, `reply_email`, `forward_email`, `create_draft`, and `update_draft` accept an optional `from` parameter; without it, the account's primary address is used.

```
reply_email account="personal" message_id="18f..." from="jean@example.com" body="Thanks, sorted."
```

Accepted forms are `alias@example.com` and `Name <alias@example.com>`; matching is case-insensitive.

The address is checked against the account before anything is sent, and the call fails with the list of usable addresses if it doesn't match. This matters because Gmail silently falls back to the primary address when the `From` header names an address you haven't verified, so a message can be sent from the wrong identity and still look like it succeeded. Some recipients (Amazon's customer service, for one) reject mail that doesn't come from the address on file.

- Gmail: the address must be a send-as alias with verification completed. Run `list_send_as` to see them. Pending aliases are refused.
- JMAP: the address must match one of the account's identities; its `identityId` is attached to the submission.
- IMAP: no alias list exists to check against, so any `from` is passed to the SMTP relay, which accepts or rejects it at send time.

## License

MIT
Expand Down
34 changes: 32 additions & 2 deletions src/providers/gmail.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Readable } from "node:stream";
import { buildRawMimeMessage } from "./mime.js";
import { ensureReplyPrefix, ensureForwardPrefix, splitAddressList } from "./headers.js";
import { ensureReplyPrefix, ensureForwardPrefix, splitAddressList, extractAddress } from "./headers.js";

// Lightweight types matching the gmail_v1 shapes we use, to avoid importing
// the massive googleapis type definitions (which add ~2min to tsc builds).
Expand Down Expand Up @@ -106,6 +106,7 @@ export type GmailEncodeOptions = SendOptions & { inReplyTo?: string; references?

export function buildEmailBuffer(to: string[], subject: string, body: string, options?: GmailEncodeOptions): Buffer {
return buildRawMimeMessage({
from: options?.from,
to, subject, body,
cc: options?.cc, bcc: options?.bcc,
replyTo: options?.replyTo,
Expand Down Expand Up @@ -139,6 +140,31 @@ export class GmailProvider implements MailProvider {

constructor(private gmail: GmailClient) {}

private sendAsCache?: string[];

/**
* Gmail silently falls back to the primary address when the From header
* names an address that isn't a verified send-as alias, so a wrong sender
* looks like a successful send. Check up front and fail loudly instead.
* Pending (unverified) aliases are rejected; Gmail will not send as them.
*/
async assertCanSendAs(from: string): Promise<void> {
if (!this.sendAsCache) {
const res = await this.gmail.users.settings.sendAs.list({ userId: "me" });
const addresses: string[] = (res.data.sendAs ?? [])
.filter((a: any) => a.verificationStatus !== "pending")
.map((a: any) => String(a.sendAsEmail).toLowerCase());
this.sendAsCache = addresses;
}
const allowed: string[] = this.sendAsCache;
const address = extractAddress(from);
if (!allowed.includes(address)) {
throw new Error(
`Cannot send as "${address}". Verified send-as addresses on this account: ${allowed.join(", ")}.`,
);
}
}

async searchMessages(query: string, maxResults: number = 20, folder?: string): Promise<EmailSummary[]> {
const q = folder ? `label:${folder} ${query}`.trim() : query;
const res = await this.gmail.users.messages.list({ userId: "me", q, maxResults });
Expand Down Expand Up @@ -200,6 +226,7 @@ export class GmailProvider implements MailProvider {
}

async sendMessage(to: string[], subject: string, body: string, options?: SendOptions): Promise<string> {
if (options?.from) await this.assertCanSendAs(options.from);
const rawBuffer = buildEmailBuffer(to, subject, body, options);
if (shouldUseMediaUpload(rawBuffer, options)) {
const res = await this.gmail.users.messages.send({
Expand All @@ -217,6 +244,7 @@ export class GmailProvider implements MailProvider {
}

async replyToMessage(messageId: string, body: string, options?: ReplyOptions): Promise<string> {
if (options?.from) await this.assertCanSendAs(options.from);
const original = await this.gmail.users.messages.get({
userId: "me", id: messageId, format: "metadata",
metadataHeaders: ["From", "To", "Cc", "Subject", "Message-ID", "Reply-To"],
Expand All @@ -236,6 +264,7 @@ export class GmailProvider implements MailProvider {

const reSubject = ensureReplyPrefix(subject);
const encodeOpts: GmailEncodeOptions = {
from: options?.from,
html: options?.html,
cc: options?.cc,
bcc: options?.bcc,
Expand Down Expand Up @@ -266,10 +295,11 @@ export class GmailProvider implements MailProvider {
? `${options.message}\n\n---------- Forwarded message ----------\n${original.body}`
: `---------- Forwarded message ----------\n${original.body}`;
const fwdSubject = ensureForwardPrefix(original.subject);
return this.sendMessage(to, fwdSubject, fwdBody, { html: options?.html, attachments: options?.attachments });
return this.sendMessage(to, fwdSubject, fwdBody, { from: options?.from, html: options?.html, attachments: options?.attachments });
}

async createDraft(to: string[], subject: string, body: string, options?: DraftOptions): Promise<string> {
if (options?.from) await this.assertCanSendAs(options.from);
let threadId: string | undefined;
let replyHeaders: { inReplyTo?: string; references?: string } | undefined;

Expand Down
10 changes: 10 additions & 0 deletions src/providers/headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,16 @@ export function ensureForwardPrefix(subject: string): string {
return /^\s*fwd?\s*:/i.test(subject) ? subject : `Fwd: ${subject}`;
}

/**
* Pull the bare addr-spec out of a header value, so `Jean <a@b.com>` and
* `a@b.com` both compare equal. Lowercased because mail domains are
* case-insensitive and alias lists are matched by equality.
*/
export function extractAddress(raw: string): string {
const angled = raw.match(/<([^>]*)>/);
return (angled ? angled[1] : raw).trim().toLowerCase();
}

/**
* Split a header value like `"Smith, John" <j@x>, Other <o@y>` into the
* individual address strings. Commas inside double-quoted display names or
Expand Down
10 changes: 6 additions & 4 deletions src/providers/imap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -376,8 +376,10 @@ export class ImapProvider implements MailProvider {
}

async sendMessage(to: string[], subject: string, body: string, options?: SendOptions): Promise<string> {
// No alias list to check against on plain IMAP/SMTP — the relay is the
// authority on which senders it will accept, and it rejects at send time.
const result = await this.smtp.sendMail({
from: this.email,
from: options?.from ? stripCRLF(options.from) : this.email,
to: stripCRLF(to.join(", ")),
cc: options?.cc ? stripCRLF(options.cc.join(", ")) : undefined,
bcc: options?.bcc ? stripCRLF(options.bcc.join(", ")) : undefined,
Expand All @@ -394,7 +396,7 @@ export class ImapProvider implements MailProvider {
const to = [replyAddress];
if (options?.replyAll) { to.push(...original.to, ...original.cc); }
const subject = ensureReplyPrefix(original.subject);
return this.sendMessage(to, subject, body, { cc: options?.cc, bcc: options?.bcc, html: options?.html, attachments: options?.attachments });
return this.sendMessage(to, subject, body, { from: options?.from, cc: options?.cc, bcc: options?.bcc, html: options?.html, attachments: options?.attachments });
}

async forwardMessage(messageId: string, to: string[], options?: ForwardOptions): Promise<string> {
Expand All @@ -403,12 +405,12 @@ export class ImapProvider implements MailProvider {
? `${options.message}\n\n---------- Forwarded message ----------\n${original.body}`
: `---------- Forwarded message ----------\n${original.body}`;
const subject = ensureForwardPrefix(original.subject);
return this.sendMessage(to, subject, fwdBody, { html: options?.html, attachments: options?.attachments });
return this.sendMessage(to, subject, fwdBody, { from: options?.from, html: options?.html, attachments: options?.attachments });
}

async createDraft(to: string[], subject: string, body: string, options?: DraftOptions): Promise<string> {
const raw = buildRawMimeMessage({
from: this.email,
from: options?.from ?? this.email,
to, subject, body,
cc: options?.cc, bcc: options?.bcc,
html: options?.html,
Expand Down
9 changes: 9 additions & 0 deletions src/providers/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@ export interface Label {
}

export interface SendOptions {
/**
* Sender address. Must be an address the account is allowed to send as
* (a Gmail send-as alias, a JMAP identity). Defaults to the account's
* primary address.
*/
from?: string;
cc?: string[];
bcc?: string[];
html?: boolean;
Expand All @@ -74,6 +80,7 @@ export interface SendOptions {
}

export interface ReplyOptions {
from?: string;
replyAll?: boolean;
cc?: string[];
bcc?: string[];
Expand All @@ -82,12 +89,14 @@ export interface ReplyOptions {
}

export interface ForwardOptions {
from?: string;
message?: string;
html?: boolean;
attachments?: Attachment[];
}

export interface DraftOptions {
from?: string;
cc?: string[];
bcc?: string[];
html?: boolean;
Expand Down
41 changes: 34 additions & 7 deletions src/providers/jmap.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// src/providers/jmap.ts
import { stripCRLF, validateNoSSRF } from "../security/validation.js";
import { ensureReplyPrefix, ensureForwardPrefix } from "./headers.js";
import { ensureReplyPrefix, ensureForwardPrefix, extractAddress } from "./headers.js";
import type {
MailProvider, ProviderCapabilities, EmailSummary, EmailMessage,
EmailThread, Label, SendOptions, ReplyOptions, ForwardOptions,
Expand Down Expand Up @@ -127,7 +127,7 @@ export class JmapProvider implements MailProvider {
Authorization: this.authHeader,
},
body: JSON.stringify({
using: ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
using: ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail", "urn:ietf:params:jmap:submission"],
methodCalls,
}),
redirect: "error",
Expand Down Expand Up @@ -420,11 +420,35 @@ export class JmapProvider implements MailProvider {
return parts;
}

private identityCache?: { email: string; id: string }[];

/**
* A JMAP server only accepts a submission whose From matches one of the
* account's identities, and it needs that identity's id to do so. Resolve
* both here, or fail with the addresses that would have worked.
*/
private async resolveIdentity(from?: string): Promise<{ email: string; identityId?: string }> {
if (!from) return { email: this.email };
if (!this.identityCache) {
const responses = await this.apiCall([["Identity/get", { accountId: (await this.ensureSession()).accountId }, "0"]]);
const list = responses.find((r: any) => r[0] === "Identity/get")?.[1]?.list ?? [];
this.identityCache = list.map((i: any) => ({ email: String(i.email).toLowerCase(), id: i.id }));
}
const address = extractAddress(from);
const match = this.identityCache!.find((i) => i.email === address);
if (!match) {
const known = this.identityCache!.map((i) => i.email).join(", ");
throw new Error(`Cannot send as "${address}". Identities on this account: ${known || "none"}.`);
}
return { email: match.email, identityId: match.id };
}

async sendMessage(to: string[], subject: string, body: string, options?: SendOptions): Promise<string> {
const session = await this.ensureSession();
const identity = await this.resolveIdentity(options?.from);
const attachmentParts = await this.buildAttachmentParts(options?.attachments);
const emailCreate: any = {
from: [{ email: this.email }],
from: [{ email: identity.email }],
to: to.map(e => ({ email: stripCRLF(e) })),
subject: stripCRLF(subject),
textBody: [{ value: body, type: "text/plain" }],
Expand All @@ -436,9 +460,11 @@ export class JmapProvider implements MailProvider {
delete emailCreate.textBody;
}
if (attachmentParts) emailCreate.attachments = attachmentParts;
const submission: any = { emailId: "#draft0" };
if (identity.identityId) submission.identityId = identity.identityId;
const responses = await this.apiCall([
["Email/set", { accountId: session.accountId, create: { draft0: emailCreate } }, "0"],
["EmailSubmission/set", { accountId: session.accountId, create: { sub0: { emailId: "#draft0" } } }, "1"],
["EmailSubmission/set", { accountId: session.accountId, create: { sub0: submission } }, "1"],
]);
const created = responses.find((r: any) => r[0] === "Email/set")?.[1]?.created?.draft0;
return created?.id ?? "";
Expand All @@ -450,7 +476,7 @@ export class JmapProvider implements MailProvider {
const to = [replyAddress];
if (options?.replyAll) { to.push(...original.to, ...original.cc); }
const subject = ensureReplyPrefix(original.subject);
return this.sendMessage(to, subject, body, { cc: options?.cc, bcc: options?.bcc, html: options?.html, attachments: options?.attachments });
return this.sendMessage(to, subject, body, { from: options?.from, cc: options?.cc, bcc: options?.bcc, html: options?.html, attachments: options?.attachments });
}

async forwardMessage(messageId: string, to: string[], options?: ForwardOptions): Promise<string> {
Expand All @@ -459,15 +485,16 @@ export class JmapProvider implements MailProvider {
? `${options.message}\n\n---------- Forwarded message ----------\n${original.body}`
: `---------- Forwarded message ----------\n${original.body}`;
const subject = ensureForwardPrefix(original.subject);
return this.sendMessage(to, subject, fwdBody, { html: options?.html, attachments: options?.attachments });
return this.sendMessage(to, subject, fwdBody, { from: options?.from, html: options?.html, attachments: options?.attachments });
}

async createDraft(to: string[], subject: string, body: string, options?: DraftOptions): Promise<string> {
const session = await this.ensureSession();
const identity = await this.resolveIdentity(options?.from);
const draftsMailbox = await this.findMailboxByRole("drafts");
const attachmentParts = await this.buildAttachmentParts(options?.attachments);
const emailCreate: any = {
from: [{ email: this.email }],
from: [{ email: identity.email }],
to: to.map(e => ({ email: stripCRLF(e) })),
subject: stripCRLF(subject),
mailboxIds: { [draftsMailbox.id]: true },
Expand Down
Loading