pidge 0.3.0

A fast CLI for e-mail and calendar
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
//! CLI argument definitions using clap

use anyhow::Result;
use clap::Parser;

/// A fast CLI for e-mail and calendar
#[derive(Parser)]
#[command(name = "pidge")]
#[command(author, version, about)]
#[command(long_about = "A fast CLI for e-mail and calendar.\n\n\
    Manage one or more e-mail accounts and browse, search, send, and reply \
    to e-mail from your terminal.")]
#[command(propagate_version = true)]
pub struct Cli {
    /// Increase output verbosity (-v for debug, -vv for trace)
    #[arg(short, long, action = clap::ArgAction::Count, global = true)]
    pub verbose: u8,

    /// Suppress non-essential output
    #[arg(short, long, global = true)]
    pub quiet: bool,

    /// Disable colored output
    #[arg(long, global = true)]
    pub no_color: bool,

    /// Output as machine-readable JSON instead of formatted text
    #[arg(long, global = true)]
    pub json: bool,

    #[command(subcommand)]
    pub command: Option<Commands>,
}

#[derive(clap::Subcommand)]
pub enum Commands {
    /// Manage AI features (shows status when run without a subcommand)
    Ai {
        #[command(subcommand)]
        command: Option<AiCommands>,
    },

    /// Manage e-mail accounts — add, remove, list, set defaults
    Account {
        #[command(subcommand)]
        command: AccountCommands,
    },

    /// Read, search, send, reply, forward, flag, archive, or delete e-mail
    Mail {
        #[command(subcommand)]
        command: MailCommands,
    },

    /// Manage the trusted-senders list (auto-renders inline images from these senders)
    Trust {
        #[command(subcommand)]
        command: TrustCommands,
    },

    /// List, edit, send, or delete draft e-mails
    Drafts {
        #[command(subcommand)]
        command: DraftsCommands,
    },

    /// Generate shell completions
    Completion {
        /// Shell to generate completions for
        #[arg(value_enum)]
        shell: Shell,
    },

    /// Show version information
    Version,
}

#[derive(clap::Subcommand)]
pub enum AiCommands {
    /// Test AI integration by sending a message
    Test {
        /// Message to send (default: "Say hello in one sentence.")
        message: Option<String>,
    },
    /// Enable AI features for pidge
    Enable,
    /// Disable AI features for pidge
    Disable,
    /// Interactively configure AI provider and model settings
    Config,
    /// Show AI status (same as running `pidge ai` without a subcommand)
    Status,
    /// AI agent skill information — emits a SKILL.md so AI agents (Claude
    /// Code, Codex, Copilot, etc.) can drive pidge on the user's behalf
    Skill {
        /// Output the SKILL.md content (ready to save as a skill file)
        #[arg(long)]
        emit: bool,

        /// When emitting, build a skill that invokes pidge via `cargo run`
        /// from the current working directory (the pidge source tree).
        /// Useful when you're developing against pidge from source instead
        /// of `cargo install pidge`. Implies --emit.
        #[arg(long)]
        from_source: bool,
    },
}

#[derive(clap::Subcommand)]
pub enum AccountCommands {
    /// Add an e-mail account (interactive sign-in)
    Add {
        /// Where to store credentials (`keychain` = OS-native, `file` = plaintext JSON at ~/.config/pidge/tokens/)
        #[arg(long, value_enum, default_value_t = StorageBackendArg::Keychain)]
        store: StorageBackendArg,
    },
    /// List signed-in accounts with default-account markers
    List,
    /// Remove an account (sign out and delete its tokens)
    Remove {
        /// E-mail of the account to remove (interactive picker if omitted and multiple accounts exist)
        email: Option<String>,
        /// Remove every signed-in account
        #[arg(long, conflicts_with = "email")]
        all: bool,
        /// Skip confirmation prompts
        #[arg(short = 'y', long)]
        yes: bool,
    },
    /// Show or set default accounts. Without a subcommand, prints both current defaults.
    Default {
        #[command(subcommand)]
        command: Option<DefaultCommands>,
    },
    /// Move an existing account's credentials between storage backends
    MigrateStorage {
        /// E-mail of the account whose tokens to migrate
        email: String,
        /// Destination backend
        #[arg(long = "to", value_enum)]
        to: StorageBackendArg,
    },
}

#[derive(clap::Subcommand)]
pub enum DefaultCommands {
    /// Set the default account used for sending and reading e-mail
    #[command(name = "e-mail")]
    EMail {
        /// E-mail address of a signed-in account
        email: String,
    },
    /// Set the default account used for calendar events and meeting invitations
    Calendar {
        /// E-mail address of a signed-in account
        email: String,
    },
}

/// CLI-facing wrapper around `pidge_core::TokenStorage`. Lives in the CLI crate
/// so `pidge-core` stays free of `clap`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum StorageBackendArg {
    /// OS-native credential store (macOS Keychain / Windows Credential Manager / libsecret)
    Keychain,
    /// Plaintext JSON file at `~/.config/pidge/tokens/<email>.json` (mode 0600 on Unix)
    File,
}

impl From<StorageBackendArg> for pidge_core::TokenStorage {
    fn from(v: StorageBackendArg) -> Self {
        match v {
            StorageBackendArg::Keychain => Self::Keychain,
            StorageBackendArg::File => Self::File,
        }
    }
}

#[derive(clap::Subcommand)]
pub enum MailCommands {
    /// List messages in the inbox, merged across all signed-in accounts
    List {
        /// Filter to a specific account (repeatable for a subset)
        #[arg(long)]
        account: Vec<String>,

        /// Maximum number of messages to show per page
        #[arg(short = 'n', long, default_value = "25")]
        limit: usize,

        /// Page number (1-based). Skips `(page-1) * limit` messages.
        #[arg(short = 'p', long, default_value = "1")]
        page: usize,

        /// Show only unread messages
        #[arg(long)]
        unread: bool,

        /// Card layout with a single-line preview per message
        #[arg(short = 'c', long, conflicts_with_all = ["table", "full"])]
        compact: bool,

        /// Tabular layout (one row per message; useful for piping to other tools)
        #[arg(short = 't', long, conflicts_with = "full")]
        table: bool,

        /// Show the entire message body for each result instead of a preview
        #[arg(short = 'f', long)]
        full: bool,
    },

    /// Display a single message identified by a fragment of its short hash
    Show {
        /// Fragment of the 8-char short hash (prefix, suffix, or substring)
        fragment: String,

        /// Also mark the message as read on the server
        #[arg(short = 'r', long)]
        mark_read: bool,

        /// Force inline image rendering for this invocation, regardless of trust list
        #[arg(long)]
        show_images: bool,

        /// Print only the raw HTML body (or plain text, if the message has no HTML).
        /// Useful for capturing a fixture to anonymize and add as a render-test case.
        #[arg(long, hide = true)]
        raw_html: bool,
    },

    /// Search e-mails using Graph's KQL `$search` syntax (e.g. `from:alice subject:budget`)
    Search {
        /// Search query (KQL syntax — `from:alice`, `subject:"q4 review"`, etc.)
        query: String,

        /// Filter to a specific account (repeatable for a subset)
        #[arg(long)]
        account: Vec<String>,

        /// Maximum number of results
        #[arg(short = 'n', long, default_value = "25")]
        limit: usize,

        /// Card layout with a single-line preview per message
        #[arg(short = 'c', long, conflicts_with_all = ["table", "full"])]
        compact: bool,

        /// Tabular layout (one row per message; useful for piping to other tools)
        #[arg(short = 't', long, conflicts_with = "full")]
        table: bool,

        /// Show the entire message body for each result instead of a preview
        #[arg(short = 'f', long)]
        full: bool,
    },

    /// Mark a message as read
    #[command(name = "mark-read")]
    MarkRead {
        /// Fragment of the 8-char short hash
        fragment: String,
    },

    /// Mark a message as unread
    #[command(name = "mark-unread")]
    MarkUnread {
        /// Fragment of the 8-char short hash
        fragment: String,
    },

    /// Set the follow-up flag on a message
    Flag {
        /// Fragment of the 8-char short hash
        fragment: String,
    },

    /// Clear the follow-up flag on a message
    Unflag {
        /// Fragment of the 8-char short hash
        fragment: String,
    },

    /// Move a message to the Archive folder
    Archive {
        /// Fragment of the 8-char short hash
        fragment: String,
    },

    /// Delete a message (moves to Deleted Items folder). Single or bulk.
    Delete {
        /// Fragment of a single message's 8-char short hash. Omit when using
        /// a bulk-mode flag like `--older-than`.
        fragment: Option<String>,

        /// BULK: delete every message in the Inbox older than this date or
        /// duration (e.g. `2026-01-01`, `30d`, `6m`, `1y`). Always requires
        /// `-y` to confirm — there is no interactive prompt for bulk delete.
        #[arg(long, conflicts_with = "fragment")]
        older_than: Option<String>,

        /// Filter bulk delete to a specific account (repeatable). Ignored
        /// for single-fragment deletes.
        #[arg(long)]
        account: Vec<String>,

        /// Skip the "Delete? [y/N]" confirmation (single) or grant required
        /// consent for bulk deletes.
        #[arg(short = 'y', long)]
        yes: bool,
    },

    /// Compose a new e-mail (full-screen form; pass flags + `-y` to skip the form for scripting)
    New(ComposeArgs),

    /// Reply to a message (the original sender only)
    Reply {
        /// Fragment of the 8-char short hash
        fragment: String,
        #[command(flatten)]
        compose: ReplyArgs,
    },

    /// Reply-all to a message (every recipient on the thread, excluding yourself)
    #[command(name = "reply-all")]
    ReplyAll {
        /// Fragment of the 8-char short hash
        fragment: String,
        #[command(flatten)]
        compose: ReplyArgs,
    },

    /// Forward a message to new recipients
    Forward {
        /// Fragment of the 8-char short hash
        fragment: String,
        #[command(flatten)]
        compose: ForwardArgs,
    },
}

/// Flags shared between `mail new` and (mostly) the explicit forms of
/// reply/forward. All optional — the TUI form fills in what's missing.
#[derive(clap::Args, Debug, Clone, Default)]
pub struct ComposeArgs {
    /// Account to send from (defaults to `account default e-mail`)
    #[arg(long)]
    pub from: Option<String>,

    /// Recipient e-mail addresses (comma-separated, repeatable)
    #[arg(long, value_delimiter = ',')]
    pub to: Vec<String>,

    /// Cc recipients (comma-separated, repeatable)
    #[arg(long, value_delimiter = ',')]
    pub cc: Vec<String>,

    /// Bcc recipients (comma-separated, repeatable)
    #[arg(long, value_delimiter = ',')]
    pub bcc: Vec<String>,

    /// Subject line
    #[arg(long)]
    pub subject: Option<String>,

    /// Body text (use `--body-file` for long content; both flags are mutually exclusive)
    #[arg(long, conflicts_with = "body_file")]
    pub body: Option<String>,

    /// Read body from a file (`-` reads from stdin)
    #[arg(long)]
    pub body_file: Option<String>,

    /// Open the TUI compose form pre-filled with your flags so you can
    /// review (and edit) before sending. Without this, a fully-specified
    /// invocation (`--to`, `--subject`, `--body`/`--body-file`) sends
    /// immediately — convenient for scripts and one-liners.
    #[arg(long)]
    pub confirm: bool,

    /// Save as a draft instead of sending. The new draft's short hash is
    /// printed; use `pidge drafts edit`, `pidge drafts send`, or
    /// `pidge drafts delete` to act on it later.
    #[arg(long)]
    pub draft: bool,

    /// Attach a file (repeatable). Each file must be < 3 MB — larger
    /// attachments require resumable uploads, not yet implemented.
    #[arg(long)]
    pub attach: Vec<std::path::PathBuf>,
}

/// Reply variants don't need `--to` (Graph fills that in from the original
/// message) and don't take a subject (Graph prepends "Re:" automatically),
/// but they DO need a body comment.
#[derive(clap::Args, Debug, Clone, Default)]
pub struct ReplyArgs {
    /// Account to reply from (defaults to the account that received the message)
    #[arg(long)]
    pub from: Option<String>,

    /// Comment text to prepend to Graph's auto-quoted original
    #[arg(long, conflicts_with = "body_file")]
    pub body: Option<String>,

    /// Read comment from a file (`-` reads from stdin)
    #[arg(long)]
    pub body_file: Option<String>,

    /// Skip the final "Send? [y/N]" confirmation
    #[arg(short = 'y', long)]
    pub yes: bool,

    /// Save as a draft instead of sending. The new draft's short hash is
    /// printed; use `pidge drafts edit`, `pidge drafts send`, or
    /// `pidge drafts delete` to act on it later.
    #[arg(long)]
    pub draft: bool,

    /// Attach a file (repeatable). Each file must be < 3 MB — larger
    /// attachments require resumable uploads, not yet implemented.
    #[arg(long)]
    pub attach: Vec<std::path::PathBuf>,
}

/// Forward needs explicit recipients (the user is sending the message to
/// someone new), plus an optional comment.
#[derive(clap::Args, Debug, Clone, Default)]
pub struct ForwardArgs {
    /// Account to forward from (defaults to the account that received the message)
    #[arg(long)]
    pub from: Option<String>,

    /// Recipient e-mail addresses (comma-separated, repeatable)
    #[arg(long, value_delimiter = ',')]
    pub to: Vec<String>,

    /// Comment text to prepend to Graph's auto-quoted original
    #[arg(long, conflicts_with = "body_file")]
    pub body: Option<String>,

    /// Read comment from a file (`-` reads from stdin)
    #[arg(long)]
    pub body_file: Option<String>,

    /// Skip the final "Send? [y/N]" confirmation
    #[arg(short = 'y', long)]
    pub yes: bool,

    /// Save as a draft instead of sending. The new draft's short hash is
    /// printed; use `pidge drafts edit`, `pidge drafts send`, or
    /// `pidge drafts delete` to act on it later.
    #[arg(long)]
    pub draft: bool,

    /// Attach a file (repeatable). Each file must be < 3 MB — larger
    /// attachments require resumable uploads, not yet implemented.
    #[arg(long)]
    pub attach: Vec<std::path::PathBuf>,
}

/// Subcommand names that `Mail` accepts directly. Used by the argv pre-processor
/// in `main.rs` to decide whether `pidge mail <X>` should route to `mail show X`
/// (X is a fragment) or pass through to clap (X is a subcommand).
///
/// Keep this list in sync with [`MailCommands`]. When you add a new variant,
/// add its kebab-case name here too — otherwise users will see "No message
/// found for fragment '<new-subcommand>'" instead of the new behavior.
pub const MAIL_SUBCOMMAND_NAMES: &[&str] = &[
    "list",
    "show",
    "search",
    "mark-read",
    "mark-unread",
    "flag",
    "unflag",
    "archive",
    "new",
    "reply",
    "reply-all",
    "forward",
    "delete",
    "help",
];

#[derive(clap::Subcommand)]
pub enum DraftsCommands {
    /// List drafts across all signed-in accounts (or a filtered subset)
    List {
        /// Filter to a specific account (repeatable for a subset)
        #[arg(long)]
        account: Vec<String>,

        /// Maximum number of drafts to show per page
        #[arg(short = 'n', long, default_value = "25")]
        limit: usize,

        /// Page number (1-based)
        #[arg(short = 'p', long, default_value = "1")]
        page: usize,

        /// One row per draft (no preview lines)
        #[arg(short = 'c', long)]
        compact: bool,
    },

    /// Display a draft by fragment of its short hash
    Show {
        /// Fragment of the 8-char short hash
        fragment: String,
    },

    /// Open the wizard pre-filled with a draft's current values, then save
    Edit {
        /// Fragment of the 8-char short hash
        fragment: String,
    },

    /// Send a draft as-is (interactive confirmation; -y to skip)
    Send {
        /// Fragment of the 8-char short hash
        fragment: String,

        /// Skip the "Send draft? [y/N]" confirmation
        #[arg(short = 'y', long)]
        yes: bool,
    },

    /// Delete a draft (moves it to Deleted Items)
    Delete {
        /// Fragment of the 8-char short hash
        fragment: String,

        /// Skip the "Delete draft? [y/N]" confirmation
        #[arg(short = 'y', long)]
        yes: bool,
    },

    /// Manage a draft's attachments
    Attachments {
        #[command(subcommand)]
        command: DraftAttachmentCommands,
    },
}

#[derive(clap::Subcommand)]
pub enum DraftAttachmentCommands {
    /// List the attachments currently on a draft
    List {
        /// Fragment of the draft's 8-char short hash
        fragment: String,
    },
    /// Attach a file to a draft (size limit: 3 MB)
    Add {
        /// Fragment of the draft's 8-char short hash
        fragment: String,
        /// Path to the file to attach
        path: std::path::PathBuf,
    },
    /// Remove an attachment from a draft by name (case-insensitive)
    Remove {
        /// Fragment of the draft's 8-char short hash
        fragment: String,
        /// Attachment filename
        name: String,
    },
}

#[derive(clap::Subcommand)]
pub enum TrustCommands {
    /// List trusted sender addresses
    List,
    /// Add an email address to the trust list (idempotent)
    Add {
        /// Email address to add
        email: String,
    },
    /// Remove an email address from the trust list (idempotent)
    Remove {
        /// Email address to remove
        email: String,
    },
}

#[derive(Clone, clap::ValueEnum)]
pub enum Shell {
    Bash,
    Zsh,
    Fish,
    Powershell,
}

impl Cli {
    pub async fn run(self) -> Result<()> {
        match self.command {
            Some(Commands::Ai { command }) => crate::commands::ai::run(command).await,
            Some(Commands::Account { command }) => {
                crate::commands::account::run(command, self.json).await
            }
            Some(Commands::Mail { command }) => {
                crate::commands::mail::run(command, self.json).await
            }
            Some(Commands::Trust { command }) => {
                crate::commands::trust::run(command, self.json).await
            }
            Some(Commands::Drafts { command }) => {
                crate::commands::drafts::run(command, self.json).await
            }
            Some(Commands::Completion { shell }) => {
                crate::commands::completion::generate_completions(shell);
                Ok(())
            }
            Some(Commands::Version) => {
                crate::banner::print_banner_with_version();
                Ok(())
            }
            None => {
                use clap::CommandFactory;
                let mut cmd = Self::command();
                cmd.print_help()?;
                println!();
                Ok(())
            }
        }
    }
}