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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,12 @@ cargo run -- attachment fetch m-1:1.2 --json
cargo run -- attachment export m-1:1.2 --json
cargo run -- attachment export m-1:1.2 --to ./exports/statement.pdf --json
cargo run -- automation rules validate --json
cargo run -- automation rollout --limit 10 --json
cargo run -- automation run --json
cargo run -- automation run --rule archive-newsletters --limit 25 --json
cargo run -- automation show 42 --json
cargo run -- automation apply 42 --execute --json
cargo run -- automation prune --older-than-days 30 --json
cargo run -- workflow list --json
cargo run -- workflow show thread-123 --json
cargo run -- triage set thread-123 --bucket urgent --note "reply today" --json
Expand Down
44 changes: 40 additions & 4 deletions docs/operations/automation-rules-and-bulk-actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ The current automation slice owns:

- a local typed rules file at `.mailroom/automation.toml`
- rule validation and preview snapshots
- read-only rollout checks for first-wave micro-batch readiness
- persisted automation runs and append-only run events
- stale local snapshot pruning after dry-run review
- reviewed thread-first bulk actions for archive, label, and trash
- unsubscribe assistance through visible list headers in the review output

Expand Down Expand Up @@ -105,6 +107,13 @@ Validate the active file:
cargo run -- automation rules validate --json
```

Check first-wave rollout readiness without saving a run:

```bash
cargo run -- automation rollout --limit 10 --json
cargo run -- automation rollout --rule archive-newsletters --limit 10 --json
```

Create a review snapshot across all enabled rules:

```bash
Expand Down Expand Up @@ -138,6 +147,17 @@ cargo run -- automation apply 42 --execute --json
Without `--execute`, `automation apply` returns a validation-style error and
does not mutate Gmail.

Prune stale local review snapshots after inspecting the dry-run counts:

```bash
cargo run -- automation prune --older-than-days 30 --json
cargo run -- automation prune --older-than-days 30 --status previewed --execute --json
cargo run -- automation prune --older-than-days 90 --status applied --status apply-failed --json
```

`automation prune` deletes only local SQLite automation snapshot rows. It never
mutates Gmail and it never targets in-progress `applying` runs.

## Output model

`--json` uses the standard Mailroom envelope.
Expand Down Expand Up @@ -180,9 +200,20 @@ The JSON payload also includes header-derived unsubscribe hints:
- `precedence_header`
- `auto_submitted_header`

`automation rollout` returns the same standard JSON envelope with:

- `verification` readiness from `audit verification`
- optional `rules` validation detail
- preview-only candidate summaries
- blockers, warnings, next steps, and exact follow-up commands

Missing or invalid rules are reported as rollout blockers rather than as a
persisted run.

## Safety model

- `automation run` is preview-only and only writes a local snapshot
- `automation rollout` is read-only and writes no automation snapshot
- `automation apply` mutates Gmail only when `--execute` is present
- `automation apply --execute` requires working Gmail auth up front and aborts
before persisting apply results if credentials are missing, expired, or point
Expand All @@ -191,6 +222,8 @@ The JSON payload also includes header-derived unsubscribe hints:
- thread mutations reuse the same Gmail thread cleanup path as the manual
cleanup commands
- successful apply runs trigger a best-effort mailbox resync afterward
- `automation prune` is dry-run by default and deletes only local snapshot
history when `--execute` is present

If a run applies zero candidates, Mailroom records the run transition locally
but does not issue Gmail mutations.
Expand All @@ -215,10 +248,13 @@ This slice adds:

1. Sync local mailbox metadata.
2. Validate the active rules file.
3. Run a preview snapshot.
4. Inspect the saved run by ID.
5. Apply only the reviewed run with `--execute`.
6. Re-run sync or `doctor` if you want to inspect the reconciled local state.
3. Run `automation rollout --limit 10` to check readiness and preview matching
candidates without saving a run.
4. Run a preview snapshot.
5. Inspect the saved run by ID.
6. Apply only the reviewed run with `--execute`.
7. Re-run sync or `doctor` if you want to inspect the reconciled local state.
8. Periodically prune stale preview snapshots after a dry-run count review.

For the real-mailbox hardening sequence, do not jump straight from rule editing
to `automation apply --execute`. Follow
Expand Down
7 changes: 7 additions & 0 deletions docs/operations/verification-and-hardening.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,12 +162,16 @@ cp config/automation.example.toml .mailroom/automation.toml
$EDITOR .mailroom/automation.toml

cargo run -- automation rules validate --json
cargo run -- automation rollout --limit 10 --json
cargo run -- automation run --limit 10 --json
cargo run -- automation show <run-id> --json
```

Inspect:

- `blockers`
- `warnings`
- preview-only `candidates`
- `selected_rule_ids`
- `candidate_count`
- `candidate_details`
Expand All @@ -181,6 +185,7 @@ If the preview is surprising, fix the rules before any live apply.
Start with micro-batches only:

```bash
cargo run -- automation rollout --rule <rule-id> --limit 10 --json
cargo run -- automation run --rule <rule-id> --limit 10 --json
cargo run -- automation show <run-id> --json
cargo run -- automation apply <run-id> --execute --json
Expand Down Expand Up @@ -230,7 +235,9 @@ cargo run -- audit labels --json
cargo run -- audit verification --json
cargo run -- sync run --profile deep-audit --json
cargo run -- automation rules validate --json
cargo run -- automation rollout --limit 10 --json
cargo run -- automation run --limit 10 --json
cargo run -- automation show <run-id> --json
cargo run -- automation apply <run-id> --execute --json
cargo run -- automation prune --older-than-days 30 --json
```
7 changes: 5 additions & 2 deletions src/automation/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ mod output;
mod rules;
mod service;

pub use model::{AutomationRunRequest, DEFAULT_AUTOMATION_RUN_LIMIT};
pub use model::{
AutomationPruneRequest, AutomationPruneStatus, AutomationRolloutRequest, AutomationRunRequest,
DEFAULT_AUTOMATION_ROLLOUT_LIMIT, DEFAULT_AUTOMATION_RUN_LIMIT,
};
pub(crate) use service::AutomationServiceError;
pub use service::{apply_run, run_preview, show_run, validate_rules};
pub use service::{apply_run, prune_runs, rollout, run_preview, show_run, validate_rules};
76 changes: 76 additions & 0 deletions src/automation/model.rs
Original file line number Diff line number Diff line change
@@ -1,16 +1,49 @@
use crate::audit::VerificationAuditReport;
use crate::mailbox::SyncRunReport;
use crate::store::automation::{AutomationActionKind, AutomationRunDetail};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;

pub const DEFAULT_AUTOMATION_RUN_LIMIT: usize = 250;
pub const DEFAULT_AUTOMATION_ROLLOUT_LIMIT: usize = 10;

#[derive(Debug, Clone)]
pub struct AutomationRunRequest {
pub rule_ids: Vec<String>,
pub limit: usize,
}

#[derive(Debug, Clone)]
pub struct AutomationRolloutRequest {
pub rule_ids: Vec<String>,
pub limit: usize,
}

#[derive(Debug, Clone)]
pub struct AutomationPruneRequest {
pub older_than_days: u32,
pub statuses: Vec<AutomationPruneStatus>,
pub execute: bool,
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AutomationPruneStatus {
Previewed,
Applied,
ApplyFailed,
}

impl AutomationPruneStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Previewed => "previewed",
Self::Applied => "applied",
Self::ApplyFailed => "apply_failed",
}
}
}

#[derive(Debug, Clone, Serialize)]
pub struct AutomationRulesValidateReport {
pub path: PathBuf,
Expand All @@ -34,6 +67,34 @@ pub struct AutomationRunPreviewReport {
pub detail: AutomationRunDetail,
}

#[derive(Debug, Clone, Serialize)]
pub struct AutomationRolloutReport {
pub verification: VerificationAuditReport,
pub rules: Option<AutomationRulesValidateReport>,
pub selected_rule_ids: Vec<String>,
pub selected_rule_count: usize,
pub candidate_count: usize,
pub candidates: Vec<AutomationRolloutCandidateSummary>,
pub blocked_rule_ids: Vec<String>,
pub blockers: Vec<String>,
pub warnings: Vec<String>,
pub next_steps: Vec<String>,
pub command_plan: Vec<String>,
}

#[derive(Debug, Clone, Serialize)]
pub struct AutomationRolloutCandidateSummary {
pub rule_id: String,
pub thread_id: String,
pub message_id: String,
pub action_kind: String,
pub subject: String,
pub from_address: Option<String>,
pub label_names: Vec<String>,
pub has_list_unsubscribe: bool,
pub matched_predicates: Vec<String>,
}

#[derive(Debug, Clone, Serialize)]
pub struct AutomationShowReport {
pub detail: AutomationRunDetail,
Expand All @@ -48,6 +109,21 @@ pub struct AutomationApplyReport {
pub sync_report: Option<SyncRunReport>,
}

#[derive(Debug, Clone, Serialize)]
pub struct AutomationPruneReport {
pub account_id: String,
pub execute: bool,
pub older_than_days: u32,
pub cutoff_epoch_s: i64,
pub statuses: Vec<String>,
pub matched_run_count: i64,
pub matched_candidate_count: i64,
pub matched_event_count: i64,
pub deleted_run_count: i64,
pub warnings: Vec<String>,
pub next_steps: Vec<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AutomationRuleSet {
#[serde(default)]
Expand Down
114 changes: 112 additions & 2 deletions src/automation/output.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use super::model::{
AutomationApplyReport, AutomationRulesValidateReport, AutomationRunPreviewReport,
AutomationShowReport,
AutomationApplyReport, AutomationPruneReport, AutomationRolloutReport,
AutomationRulesValidateReport, AutomationRunPreviewReport, AutomationShowReport,
};
use anyhow::Result;
use std::io::{self, Write};
Expand Down Expand Up @@ -70,6 +70,79 @@ impl AutomationRunPreviewReport {
}
}

impl AutomationRolloutReport {
pub fn print(&self, json: bool) -> Result<()> {
route_output_to_stdout(json, |json, stdout| self.write(json, stdout))
}

fn render_plain(&self) -> String {
let mut lines = vec![
String::from("operation=rollout"),
format!(
"account_id={}",
sanitize(self.verification.account_id.as_deref().unwrap_or("<none>"))
),
format!("authenticated={}", self.verification.authenticated),
format!("rules_file_exists={}", self.verification.rules_file_exists),
format!("selected_rule_count={}", self.selected_rule_count),
format!(
"selected_rule_ids={}",
sanitize(&self.selected_rule_ids.join(","))
),
format!("candidate_count={}", self.candidate_count),
format!("blocked_rule_count={}", self.blocked_rule_ids.len()),
];
if !self.blocked_rule_ids.is_empty() {
lines.push(format!(
"blocked_rule_ids={}",
sanitize(&self.blocked_rule_ids.join(","))
));
}
if !self.candidates.is_empty() {
lines.push(String::from("results_format=tsv"));
lines.push(String::from(
"rule_id\tthread_id\tmessage_id\taction\thas_unsubscribe\tfrom_address\tsubject\tlabels\tmatched_predicates",
));
lines.extend(self.candidates.iter().map(|candidate| {
format!(
"{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}",
sanitize(&candidate.rule_id),
sanitize(&candidate.thread_id),
sanitize(&candidate.message_id),
sanitize(&candidate.action_kind),
candidate.has_list_unsubscribe,
sanitize(candidate.from_address.as_deref().unwrap_or_default()),
sanitize(&candidate.subject),
sanitize(&candidate.label_names.join(" | ")),
sanitize(&candidate.matched_predicates.join(", ")),
)
}));
}
for blocker in &self.blockers {
lines.push(format!("blocker={}", sanitize(blocker)));
}
for warning in &self.warnings {
lines.push(format!("warning={}", sanitize(warning)));
}
for next_step in &self.next_steps {
lines.push(format!("next_step={}", sanitize(next_step)));
}
for command in &self.command_plan {
lines.push(format!("command={}", sanitize(command)));
}
lines.join("\n") + "\n"
}

fn write<W: Write>(&self, json: bool, writer: &mut W) -> Result<()> {
if json {
crate::cli_output::write_json_success(writer, self)?;
} else {
writer.write_all(self.render_plain().as_bytes())?;
}
Ok(())
}
}

impl AutomationShowReport {
pub fn print(&self, json: bool) -> Result<()> {
route_output_to_stdout(json, |json, stdout| self.write(json, stdout))
Expand All @@ -89,6 +162,43 @@ impl AutomationShowReport {
}
}

impl AutomationPruneReport {
pub fn print(&self, json: bool) -> Result<()> {
route_output_to_stdout(json, |json, stdout| self.write(json, stdout))
}

fn render_plain(&self) -> String {
let mut lines = vec![
String::from("operation=prune"),
format!("account_id={}", sanitize(&self.account_id)),
format!("execute={}", self.execute),
format!("older_than_days={}", self.older_than_days),
format!("cutoff_epoch_s={}", self.cutoff_epoch_s),
format!("statuses={}", sanitize(&self.statuses.join(","))),
format!("matched_run_count={}", self.matched_run_count),
format!("matched_candidate_count={}", self.matched_candidate_count),
format!("matched_event_count={}", self.matched_event_count),
format!("deleted_run_count={}", self.deleted_run_count),
];
for warning in &self.warnings {
lines.push(format!("warning={}", sanitize(warning)));
}
for next_step in &self.next_steps {
lines.push(format!("next_step={}", sanitize(next_step)));
}
lines.join("\n") + "\n"
}

fn write<W: Write>(&self, json: bool, writer: &mut W) -> Result<()> {
if json {
crate::cli_output::write_json_success(writer, self)?;
} else {
writer.write_all(self.render_plain().as_bytes())?;
}
Ok(())
}
}

impl AutomationApplyReport {
pub fn print(&self, json: bool) -> Result<()> {
route_output_to_stdout(json, |json, stdout| self.write(json, stdout))
Expand Down
Loading