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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -303,8 +303,10 @@ force_black: false
<p>The body starts after the closing delimiter.</p>
```

Recognized keys are `to`, `cc`, `subject`, `reply_to`, `draft`, `format`, and
`force_black` (`draft: true` turns a `send_email` call into a draft save). A
Recognized keys are `to`, `cc`, `bcc`, `subject`, `reply_to`, `draft`, `format`,
and `force_black` (`draft: true` turns a `send_email` call into a draft save).
`bcc` applies to `create_draft` and `update_draft`; `send_email` refuses a body
file that sets it rather than sending without the Bcc. A
frontmatter value overrides the same-named tool parameter. Frontmatter is
parsed only from `.md` files, and the file extension never selects the format —
without an explicit `format` tool parameter, even a `.html` body file gets the
Expand Down
19 changes: 19 additions & 0 deletions openspec/changes/add-draft-bcc/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Add Bcc Recipients to Draft Tools

## Why

`create_draft` and `update_draft` have no `bcc` input, and `bcc` is not a recognized body-file frontmatter key, so a caller cannot stage a draft with a Bcc recipient (issue #214). The common case is replying to an introduction and moving the introducer to Bcc ("moving you to bcc"). `ComposeMessage`, `ReplyOptions`, both providers, the draft preview, and the `send_draft` allowlist gate already carry bcc; only the tool input surface is missing it.

## What Changes

- `create_draft` and `update_draft` accept `bcc: string[]` (name-address form allowed), on new drafts and on the reply path. Microsoft Graph writes `bccRecipients`; Gmail writes a `Bcc` header.
- On the reply path, providers remove Bcc addresses from the To/Cc they derive from the parent, so a thread participant moves to Bcc without restating the audience. Gmail omits the `To` header when nobody is left on it.
- An address listed on both `bcc` and `to`/`cc` in the same call is rejected with `BCC_RECIPIENT_CONFLICT`. When the persisted draft still lists a Bcc address on To/Cc (e.g. `update_draft` set only `bcc`), the response carries a `BCC_RECIPIENT_ALSO_VISIBLE` warning.
- `update_draft` `bcc` replaces the draft's Bcc line; `[]` clears it; omitting it keeps it.
- Body-file frontmatter recognizes `bcc`. `send_email`, which has no bcc support, refuses a body file that sets it (`BCC_NOT_SUPPORTED`) instead of silently sending without the Bcc.
- No change to `send_draft`: it already gates bcc against the send allowlist.

## Impact

- Affected specs: `email-write`
- Affected code: `email-core` (draft/send actions, compose helpers, frontmatter, mock provider), `provider-gmail` and `provider-microsoft` reply-draft recipient resolution
67 changes: 67 additions & 0 deletions openspec/changes/add-draft-bcc/specs/email-write/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
## ADDED Requirements

### Requirement: Draft Bcc Recipients

`create_draft` and `update_draft` SHALL accept an optional `bcc` list of recipient addresses, in bare or name-address form, and SHALL forward it to the provider: Microsoft Graph as `bccRecipients`, Gmail as a `Bcc` header. Body-file frontmatter SHALL recognize a `bcc` key with the same comma-separated form as `to` and `cc`, and it SHALL override the `bcc` parameter like other frontmatter keys.

On the reply path, providers SHALL remove every Bcc address from the To and Cc they derive from the parent message, so a thread participant can be moved to Bcc without restating the rest of the audience. A message whose To becomes empty this way SHALL be composed without a To line rather than with an empty one.

An address listed on `bcc` and on `to` or `cc` in the same call SHALL be rejected with `BCC_RECIPIENT_CONFLICT`. When the persisted draft read back for the preview still lists a Bcc address on To or Cc, the response SHALL include a `BCC_RECIPIENT_ALSO_VISIBLE` warning.

On `update_draft`, a supplied `bcc` SHALL replace the draft's Bcc line, an empty list SHALL clear it, and an omitted `bcc` SHALL leave it unchanged. The draft preview SHALL report the persisted Bcc recipients.

`send_email` has no Bcc support; when its body-file frontmatter sets `bcc`, it SHALL fail with `BCC_NOT_SUPPORTED` and send nothing.

Drafts remain allowlist-exempt. `send_draft` SHALL continue to gate the stored draft's Bcc recipients against the send allowlist exactly like its To and Cc.

#### Scenario: New draft with bcc
- **WHEN** `create_draft` is called with `{to: "alice@allowed.com", bcc: ["Audit Desk <audit@allowed.com>"], subject, body}`
- **THEN** the provider's `createDraft` receives the parsed Bcc recipient with its display name, and `preview.bcc` reports it

#### Scenario: Omitted bcc sends no bcc to the provider
- **WHEN** `create_draft` is called without `bcc`
- **THEN** the provider's `createDraft` receives no `bcc`

#### Scenario: Reply-all draft moves the introducer to bcc
- **WHEN** `create_draft` is called with `{reply_to: "<introduction message>", to: "bob@allowed.com", bcc: ["introducer@allowed.com"], body}`
- **THEN** the provider's reply-draft call receives the Bcc recipient, and the draft is addressed to Bob with the introducer only on Bcc

#### Scenario: Reply draft bcc removes the address from the derived recipients
- **WHEN** `create_draft` is called with `reply_to`, `reply_all: false`, no `to`, and `bcc` naming the parent's sender in different letter case
- **THEN** the sender is absent from the draft's To and present on its Bcc

#### Scenario: Bcc address also listed on to or cc is rejected
- **WHEN** `create_draft` is called with an address on both `cc` and `bcc`, compared case-insensitively
- **THEN** the call fails with `BCC_RECIPIENT_CONFLICT` and no draft is created

#### Scenario: Invalid bcc address is rejected
- **WHEN** `create_draft` is called with a `bcc` entry that is not a valid address
- **THEN** the call fails with `INVALID_ADDRESS` naming `bcc[0]`

#### Scenario: Bcc from body_file frontmatter
- **WHEN** a `.md` body file declares `bcc: audit@allowed.com, legal@allowed.com`
- **THEN** `create_draft` stores both addresses on Bcc

#### Scenario: send_email refuses bcc frontmatter instead of dropping it
- **WHEN** `send_email` is called with a body file whose frontmatter sets `bcc`
- **THEN** the call fails with `BCC_NOT_SUPPORTED` and nothing is sent

#### Scenario: update_draft sets, keeps, and clears bcc
- **WHEN** `update_draft` is called with `bcc`, then with only `subject`, then with `bcc: []`
- **THEN** the draft's Bcc is set, then preserved, then cleared

#### Scenario: update_draft warns when a bcc recipient is still on to or cc
- **WHEN** `update_draft` sets `bcc` to an address the draft already lists on Cc, without restating `cc`
- **THEN** the update succeeds and returns a `BCC_RECIPIENT_ALSO_VISIBLE` warning naming the address

#### Scenario: update_draft rejects a bcc that conflicts with to or cc in the same call
- **WHEN** `update_draft` is called with the same address on `to` and `bcc`
- **THEN** the call fails with `BCC_RECIPIENT_CONFLICT`

#### Scenario: send_draft blocks a draft whose bcc set via create_draft is not allowlisted
- **WHEN** a draft is created with an allowlisted To and a Bcc outside the allowlist, and `send_draft` is called on it
- **THEN** `send_draft` returns `ALLOWLIST_BLOCKED` and nothing is sent

#### Scenario: send_draft blocks a reply draft whose bcc set via update_draft is not allowlisted
- **WHEN** a reply draft is given a Bcc outside the allowlist through `update_draft`, and `send_draft` is called on it
- **THEN** `send_draft` returns `ALLOWLIST_BLOCKED` and nothing is sent
17 changes: 17 additions & 0 deletions openspec/changes/add-draft-bcc/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Tasks

## 1. Inputs
- [x] 1.1 Add `bcc` to `create_draft` and `update_draft` input schemas and pass it to `createDraft`, `createReplyDraft`, and `updateDraft`.
- [x] 1.2 Recognize `bcc` in body-file frontmatter; refuse it in `send_email`.
- [x] 1.3 Parse and validate bcc addresses; reject bcc/to/cc overlap in one call.

## 2. Providers
- [x] 2.1 Remove Bcc addresses from derived reply To/Cc in Gmail and Microsoft Graph.
- [x] 2.2 Omit an empty Gmail `To` header.

## 3. Read-back
- [x] 3.1 Warn when the persisted draft lists a Bcc address on To/Cc.

## 4. Verification
- [x] 4.1 Core, frontmatter, Gmail, and Graph tests, including `send_draft` allowlist gating of bcc set via `create_draft` and `update_draft`.
- [x] 4.2 Run build, lint, tests, and strict OpenSpec validation.
86 changes: 63 additions & 23 deletions packages/email-core/src/actions/compose-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export interface ComposeFields {
body: string;
to?: string | string[];
cc?: string[];
bcc?: string[];
subject?: string;
replyTo?: string;
draft?: boolean;
Expand All @@ -87,6 +88,7 @@ export async function resolveComposeFields(
body_file?: string;
to?: string | string[];
cc?: string[];
bcc?: string[];
subject?: string;
reply_to?: string;
draft?: boolean;
Expand All @@ -99,6 +101,7 @@ export async function resolveComposeFields(
let body: string | undefined;
let to = input.to;
let cc = input.cc;
let bcc = input.bcc;
let subject = input.subject;
let replyTo = input.reply_to;
let draft = input.draft;
Expand All @@ -117,6 +120,7 @@ export async function resolveComposeFields(
const fm = bodyResult.frontmatter;
if (fm.to !== undefined) to = fm.to;
if (fm.cc !== undefined) cc = Array.isArray(fm.cc) ? fm.cc : [fm.cc];
if (fm.bcc !== undefined) bcc = Array.isArray(fm.bcc) ? fm.bcc : [fm.bcc];
if (fm.subject !== undefined) subject = fm.subject;
if (fm.reply_to !== undefined) replyTo = fm.reply_to;
if (fm.draft !== undefined) draft = fm.draft;
Expand All @@ -132,7 +136,7 @@ export async function resolveComposeFields(
};
}

return { body: body ?? '', to, cc, subject, replyTo, draft, format, forceBlack };
return { body: body ?? '', to, cc, bcc, subject, replyTo, draft, format, forceBlack };
}

// --- Outbound attachments ---
Expand Down Expand Up @@ -282,31 +286,67 @@ export function checkRateLimit(
// --- parseRecipients ---

export type ParsedRecipients =
| { to: EmailAddress[]; cc: EmailAddress[] }
| { to: EmailAddress[]; cc: EmailAddress[]; bcc: EmailAddress[] }
| { error: ActionError };

export function parseRecipients(input: { to?: string[]; cc?: string[] }): ParsedRecipients {
const toResult = parseAddressList(input.to, 'to');
if (!toResult.ok) {
return {
error: {
code: 'INVALID_ADDRESS',
message: `${toResult.field}[${toResult.index}] invalid address: "${toResult.value}"`,
recoverable: false,
},
};
}
const ccResult = parseAddressList(input.cc, 'cc');
if (!ccResult.ok) {
return {
error: {
code: 'INVALID_ADDRESS',
message: `${ccResult.field}[${ccResult.index}] invalid address: "${ccResult.value}"`,
recoverable: false,
},
};
export function parseRecipients(
input: { to?: string[]; cc?: string[]; bcc?: string[] },
): ParsedRecipients {
const lists: Record<'to' | 'cc' | 'bcc', EmailAddress[]> = { to: [], cc: [], bcc: [] };
for (const field of ['to', 'cc', 'bcc'] as const) {
const result = parseAddressList(input[field], field);
if (!result.ok) {
return {
error: {
code: 'INVALID_ADDRESS',
message: `${result.field}[${result.index}] invalid address: "${result.value}"`,
recoverable: false,
},
};
}
lists[field] = result.addresses;
}
return { to: toResult.addresses, cc: ccResult.addresses };
return lists;
}

/** Bcc addresses that also appear on To or Cc, compared case-insensitively. */
function bccOverlap(
recipients: { to?: EmailAddress[]; cc?: EmailAddress[]; bcc?: EmailAddress[] },
): string[] {
const visible = new Set([...(recipients.to ?? []), ...(recipients.cc ?? [])].map(a => a.email.toLowerCase()));
return (recipients.bcc ?? []).filter(a => visible.has(a.email.toLowerCase())).map(a => a.email);
}

/**
* Reject a compose call that names the same address on Bcc and on To or Cc.
* Bcc exists to hide a recipient from the others; listing them visibly too
* defeats that silently, so the caller must pick one (issue #214).
*/
export function checkBccOverlap(
recipients: { to?: EmailAddress[]; cc?: EmailAddress[]; bcc?: EmailAddress[] },
): ActionError | null {
const overlap = bccOverlap(recipients);
if (overlap.length === 0) return null;
return {
code: 'BCC_RECIPIENT_CONFLICT',
message: `Listed on bcc and also on to/cc: ${overlap.join(', ')}. Name each recipient on one line only.`,
recoverable: true,
};
}

/**
* Warn when a persisted draft still shows a Bcc recipient on To or Cc — e.g.
* update_draft set bcc without restating the draft's existing To/Cc.
*/
export function bccVisibleWarning(
preview: DraftPreview | undefined,
): { code: string; message: string } | undefined {
const overlap = preview ? bccOverlap(preview) : [];
if (overlap.length === 0) return undefined;
return {
code: 'BCC_RECIPIENT_ALSO_VISIBLE',
message: `The draft lists ${overlap.join(', ')} on bcc and also on to/cc, so they are not hidden. Restate to/cc without them to keep the bcc hidden.`,
};
}

// --- Draft preview ---
Expand Down
Loading
Loading