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

All notable changes to this project are documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

Targeted for the 0.2.6 release.


### Added
- `mail batch` subcommand: execute multiple label/unlabel/archive/move/flag/delete
operations from a JSON array over a single IMAP session, with per-op validation and
an optional `--stop-on-error` flag. Reported by @Juan-de-Costa-Rica (#10).
- Server-side filtering for `mail list` (`--unread` via IMAP `SEARCH UNSEEN`, new
`--flagged` via `SEARCH FLAGGED`), additional envelope fields (`from_address`, `to`,
`message_id`, `in_reply_to`), and `--fields` selection. Reported by @Juan-de-Costa-Rica (#9).
- `PM_CLI_BRIDGE_PASSWORD` environment variable as a fallback for the Bridge password,
for headless/no-keyring environments. Reported by @Juan-de-Costa-Rica (#8).
70 changes: 70 additions & 0 deletions docs/batch-format.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Batch Operation Format

`pm-cli mail batch` reads a JSON array of operations from stdin (or from a file via `--file`) and executes them in order over a single IMAP connection.

## Input

A JSON array of operation objects:

```json
[
{"op": "label", "uids": ["uid:123"], "label": "Important"},
{"op": "flag", "uids": ["uid:456"], "read": true},
{"op": "archive", "uids": ["uid:789"]}
]
```

Input is limited to 10MB. An empty array, malformed JSON, or any invalid operation causes the whole batch to be rejected **before** a connection is opened — nothing is executed.

## Operation fields

| Field | Type | Applies to | Description |
|-------|------|-----------|-------------|
| `op` | string | all | One of `label`, `unlabel`, `archive`, `move`, `flag`, `delete`. **Required.** |
| `uids` | string[] | all | Message selectors — `uid:<n>` or a bare sequence number. **Required, non-empty.** Do not mix UID and sequence selectors within one operation. |
| `mailbox` | string | all | Source mailbox. Defaults to `INBOX`. |
| `label` | string | `label`, `unlabel` | Label name (mapped to the `Labels/<name>` folder). **Required** for these ops. |
| `to` | string | `move` | Destination mailbox. **Required** for `move`. |
| `read` | bool | `flag` | Mark messages read (`\Seen`). |
| `unread` | bool | `flag` | Mark messages unread (remove `\Seen`). |
| `star` | bool | `flag` | Star messages (`\Flagged`). |
| `unstar` | bool | `flag` | Unstar messages (remove `\Flagged`). |

A `flag` operation requires at least one of `read`, `unread`, `star`, `unstar`.

Mailbox and label names containing IMAP special characters (`{`, `*`, `%`, CR, LF) are rejected.

## Operations

| op | Effect |
|----|--------|
| `label` | Copies the messages into `Labels/<label>` (adds the label; original is kept). |
| `unlabel` | Removes the messages from `Labels/<label>` (removes the label). |
| `archive` | Moves the messages from `mailbox` to `Archive`. |
| `move` | Moves the messages from `mailbox` to `to`. |
| `flag` | Adds/removes `\Seen` / `\Flagged` per the boolean fields. |
| `delete` | Marks the messages deleted in `mailbox` (moves to Trash; not a permanent expunge). |

## Output

With `--json`, the command prints per-operation results plus totals:

```json
{
"results": [
{"op": "label", "success": true},
{"op": "flag", "success": false, "error": "no messages matched the given ID(s) in INBOX"}
],
"total": 2,
"succeeded": 1,
"failed": 1
}
```

By default every operation is attempted regardless of earlier failures. Pass `--stop-on-error` to halt after the first failure (the already-attempted results are still reported).

## Notes

- Operations execute sequentially in array order over one connection.
- There is no transaction/rollback: a partial batch leaves partial state. Inspect `results` to see exactly what succeeded.
- Accurate per-operation success reporting depends on the IMAP layer detecting no-op STORE/COPY commands. See issue #11.
53 changes: 53 additions & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ These flags are available on all commands:
| `-v, --verbose` | Verbose output |
| `-q, --quiet` | Suppress non-essential output |

## Environment Variables

| Variable | Description |
|----------|-------------|
| `PM_CLI_BRIDGE_PASSWORD` | Bridge password. When set (non-empty) it is used in preference to the system keyring. Intended for headless/CI environments with no secret service; interactive users should prefer the keyring (`pm-cli config init`). |

---

## config
Expand Down Expand Up @@ -121,20 +127,37 @@ pm-cli mail list [flags]
| `--offset` | Skip first N messages | 0 |
| `-p, --page` | Page number (1-based) | 0 |
| `--unread` | Only show unread messages | false |
| `--flagged` | Only show flagged/starred messages | false |
| `--fields` | Comma-separated fields for JSON output | (all) |
| `--compact` | Bare JSON array instead of wrapper object | false |

**Filtering:**
- `--unread` and `--flagged` use a server-side IMAP `SEARCH`, so the result honors `--limit` instead of being thinned out by client-side filtering. They can be combined.

**Pagination:**
- Use `--offset` to skip messages (e.g., `--offset 20` skips the 20 most recent)
- Use `--page` for page-based navigation (e.g., `-p 2 -n 20` shows messages 21-40)
- JSON output includes `offset`, `limit`, and `page` fields

**JSON fields (`--json`):** Each message includes `uid`, `seq_num`, `from`, `from_address`, `to`, `message_id`, `in_reply_to`, `subject`, `date`, `date_iso`, `seen`, `flagged`. (`from` is the display name when present; `from_address` is always the bare address.) Empty fields are omitted.

**Field selection (JSON only):**
- `--fields` projects each message onto only the named fields, e.g. `--fields uid,subject,from_address`. Valid names: `uid`, `seq`, `from`, `from_address`, `to`, `message_id`, `in_reply_to`, `subject`, `date`, `date_iso`, `seen`, `flagged`.
- `--compact` emits a bare JSON array (no `mailbox`/`count`/`messages` wrapper). Both flags only affect `--json` mode; text output is unchanged.

**Examples:**
```bash
pm-cli mail list
pm-cli mail list -n 50
pm-cli mail list -m Sent
pm-cli mail list --unread
pm-cli mail list --flagged --json
pm-cli mail list --json

# Field selection
pm-cli mail list --json --fields uid,subject,from_address
pm-cli mail list --json --fields uid,subject --compact

# Pagination
pm-cli mail list --offset 20 # Skip 20 most recent
pm-cli mail list -p 2 -n 20 # Page 2 (messages 21-40)
Expand Down Expand Up @@ -415,6 +438,36 @@ pm-cli mail download 123 0
pm-cli mail download 123 0 -o ~/Downloads/report.pdf
```

### mail batch

Run multiple operations over a single IMAP session. Reads a JSON array of operations from stdin (or a file via `--file`), avoiding a fresh connection and auth round-trip per operation. Intended for automated triage that applies labels, flags, and moves to many messages per run.

```bash
pm-cli mail batch [flags]
```

**Flags:**
| Flag | Description |
|------|-------------|
| `-f, --file` | Read operations from a file instead of stdin |
| `--stop-on-error` | Stop after the first failed operation |

All operations are validated up front (unknown ops, missing required fields, and IMAP special characters in names are rejected before any connection is opened). Input is capped at 10MB. Output (`--json`) reports per-operation success/failure plus totals.

See [batch-format.md](batch-format.md) for the full operation schema.

**Examples:**
```bash
# Mark a message read, then archive another, in one session
echo '[
{"op": "flag", "uids": ["uid:123"], "read": true},
{"op": "archive", "uids": ["uid:456"]}
]' | pm-cli mail batch --json

# Apply a label and stop on the first failure
pm-cli mail batch --file ops.json --json --stop-on-error
```

### mail draft

Manage email drafts.
Expand Down
210 changes: 210 additions & 0 deletions internal/cli/batch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
package cli

import (
"encoding/json"
"fmt"
"io"
"os"
"strings"

"github.com/bscott/pm-cli/internal/imap"
"github.com/bscott/pm-cli/internal/safetext"
)

// maxBatchInputSize caps batch input to guard against unbounded reads from
// stdin or an oversized file.
const maxBatchInputSize = 10 * 1024 * 1024 // 10 MB

// batchOp is a single operation in a batch request.
type batchOp struct {
Op string `json:"op"`
UIDs []string `json:"uids"`
Label string `json:"label,omitempty"`
To string `json:"to,omitempty"`
Mailbox string `json:"mailbox,omitempty"`
Read bool `json:"read,omitempty"`
Unread bool `json:"unread,omitempty"`
Star bool `json:"star,omitempty"`
Unstar bool `json:"unstar,omitempty"`
}

// batchResult reports the outcome of a single operation.
type batchResult struct {
Op string `json:"op"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}

// batchOutput is the top-level JSON response.
type batchOutput struct {
Results []batchResult `json:"results"`
Total int `json:"total"`
Succeeded int `json:"succeeded"`
Failed int `json:"failed"`
}

var validBatchOps = map[string]bool{
"label": true,
"unlabel": true,
"archive": true,
"move": true,
"flag": true,
"delete": true,
}

func (c *MailBatchCmd) Run(ctx *Context) error {
if ctx.Config.Bridge.Email == "" {
return fmt.Errorf("not configured - run 'pm-cli config init' first")
}

ops, err := readBatchOps(c.File)
if err != nil {
return err
}

// Validate every operation up front so malformed input fails before we
// open a connection or mutate any mailbox.
for i, op := range ops {
if err := validateBatchOp(i, op); err != nil {
return err
}
}

client, err := imap.NewClient(ctx.Config)
if err != nil {
return err
}
if err := client.Connect(); err != nil {
return err
}
defer client.Close()

output := batchOutput{
Results: make([]batchResult, 0, len(ops)),
Total: len(ops),
}

for _, op := range ops {
result := executeBatchOp(client, op)
output.Results = append(output.Results, result)
if result.Success {
output.Succeeded++
} else {
output.Failed++
if c.StopOnError {
break
}
}
}

return ctx.Formatter.PrintJSON(output)
}

// readBatchOps reads and decodes the batch request from a file or stdin.
func readBatchOps(file string) ([]batchOp, error) {
var r io.Reader = os.Stdin
if file != "" {
f, err := os.Open(file)
if err != nil {
return nil, fmt.Errorf("failed to open batch file: %w", err)
}
defer f.Close()
r = f
}

// Read one extra byte so we can detect input that exceeds the cap.
data, err := io.ReadAll(io.LimitReader(r, maxBatchInputSize+1))
if err != nil {
return nil, fmt.Errorf("failed to read input: %w", err)
}
if len(data) > maxBatchInputSize {
return nil, fmt.Errorf("input exceeds maximum size of %d bytes", maxBatchInputSize)
}

return decodeBatchOps(data)
}

// decodeBatchOps parses a JSON array of operations and rejects empty input.
func decodeBatchOps(data []byte) ([]batchOp, error) {
var ops []batchOp
if err := json.Unmarshal(data, &ops); err != nil {
return nil, fmt.Errorf("invalid JSON input: %w", err)
}
if len(ops) == 0 {
return nil, fmt.Errorf("no operations in input")
}
return ops, nil
}

// validateBatchOp checks required fields and rejects names containing IMAP
// special characters before any IMAP command is issued.
func validateBatchOp(index int, op batchOp) error {
if !validBatchOps[op.Op] {
return fmt.Errorf("operation %d: unknown op %q (valid: label, unlabel, archive, move, flag, delete)", index, op.Op)
}
if len(op.UIDs) == 0 {
return fmt.Errorf("operation %d (%s): uids is required", index, op.Op)
}
if err := validateMailboxName(op.Mailbox); err != nil {
return fmt.Errorf("operation %d (%s): source %w", index, op.Op, err)
}

switch op.Op {
case "label", "unlabel":
if op.Label == "" {
return fmt.Errorf("operation %d (%s): label is required", index, op.Op)
}
if err := validateMailboxName(op.Label); err != nil {
return fmt.Errorf("operation %d (%s): %w", index, op.Op, err)
}
case "move":
if op.To == "" {
return fmt.Errorf("operation %d (move): to is required", index)
}
if err := validateMailboxName(op.To); err != nil {
return fmt.Errorf("operation %d (move): %w", index, err)
}
case "flag":
if !op.Read && !op.Unread && !op.Star && !op.Unstar {
return fmt.Errorf("operation %d (flag): at least one of read, unread, star, unstar is required", index)
}
}
return nil
}

// validateMailboxName rejects names containing IMAP wildcards or CR/LF. An
// empty name is allowed (callers default it to INBOX).
func validateMailboxName(name string) error {
if strings.ContainsAny(name, "{*%\r\n") {
return fmt.Errorf("invalid mailbox/label name %q: contains IMAP special characters", name)
}
return nil
}

func executeBatchOp(client *imap.Client, op batchOp) batchResult {
mailbox := op.Mailbox
if mailbox == "" {
mailbox = "INBOX"
}

var err error
switch op.Op {
case "label":
err = client.CopyMessages(mailbox, op.UIDs, "Labels/"+op.Label)
case "unlabel":
err = client.DeleteMessages("Labels/"+op.Label, op.UIDs, true)
case "archive":
err = client.MoveMessages(mailbox, op.UIDs, "Archive")
case "move":
err = client.MoveMessages(mailbox, op.UIDs, op.To)
case "flag":
err = client.SetFlagsMultiple(mailbox, op.UIDs, op.Read, op.Unread, op.Star, op.Unstar)
case "delete":
err = client.DeleteMessages(mailbox, op.UIDs, false)
}

if err != nil {
return batchResult{Op: op.Op, Error: safetext.SanitizeForTerminal(err.Error())}
}
return batchResult{Op: op.Op, Success: true}
}
Loading
Loading