Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ 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 rules suggest --json
cargo run -- automation rollout --limit 10 --json
cargo run -- automation run --json
cargo run -- automation run --rule archive-newsletters --limit 25 --json
Expand Down Expand Up @@ -219,7 +220,7 @@ Advanced manual overrides still work:

## Near-term build plan

1. Use the verification and hardening runbook to canonicalize labels, deepen the local audit corpus, and stage the first real personal ruleset.
1. Use the verification and hardening runbook to canonicalize labels, deepen the local audit corpus, and generate disabled starter rules with `automation rules suggest`.
2. Expand automation ergonomics only after a few low-surprise micro-batch archive/label runs land cleanly.
3. Expand unsubscribe assistance only after the deeper sync proves out list-header coverage in the local cache.
4. Build a TUI over the existing command core, audit surfaces, and SQLite workflow model.
24 changes: 23 additions & 1 deletion docs/operations/automation-rules-and-bulk-actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ This runbook covers Mailroom’s review-first automation surface.
The current automation slice owns:

- a local typed rules file at `.mailroom/automation.toml`
- read-only starter rule suggestions from recurring local mailbox evidence
- rule validation and preview snapshots
- read-only rollout checks for first-wave micro-batch readiness
- persisted automation runs and append-only run events
Expand All @@ -33,12 +34,15 @@ cargo run -- audit labels --json
cp config/automation.example.toml .mailroom/automation.toml
```

Automation commands require:
Automation commands other than `automation rules suggest` require:

- an authenticated active Gmail account
- a locally synced mailbox
- an existing `.mailroom/automation.toml` file

`automation rules suggest` still requires an authenticated active account and
local sync evidence, but it does not require an existing rules file.

If you use label actions, the referenced label names must already exist in the
local Gmail label cache. If you created labels recently, run `mailroom sync run`
again first.
Expand All @@ -51,6 +55,18 @@ Mailroom reads only one active rule file:

Use `config/automation.example.toml` as the tracked template.

You can generate disabled starter rules from recurring older `INBOX` list or
bulk sender evidence in the local cache:

```bash
cargo run -- automation rules suggest --json
cargo run -- automation rules suggest --limit 5 --min-thread-count 4 --older-than-days 21 --json
```

Suggestions are read-only. They do not write `.mailroom/automation.toml` and do
not mutate Gmail. Review the disabled TOML snippets, copy only low-surprise
rules into `.mailroom/automation.toml`, then enable one rule at a time.

Supported match fields:

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

Generate disabled starter rules before editing the active file:

```bash
cargo run -- automation rules suggest --json
```

Check first-wave rollout readiness without saving a run:

```bash
Expand Down
1 change: 1 addition & 0 deletions docs/operations/verification-and-hardening.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ Validate the rules file and preview a bounded run:

```bash
cp config/automation.example.toml .mailroom/automation.toml
cargo run -- automation rules suggest --json
Comment thread
BjornMelin marked this conversation as resolved.
$EDITOR .mailroom/automation.toml

cargo run -- automation rules validate --json
Expand Down
1 change: 1 addition & 0 deletions docs/roadmap/v1-search-triage-draft-queue.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ The attachment catalog/export foundation is now in place too:
The review-first automation slice is now in place too:

- typed TOML rules under `.mailroom/automation.toml`
- disabled starter rule suggestions from recurring local mailbox evidence
- persisted automation run snapshots and append-only run events
- thread-first archive, label, and trash bulk actions gated behind `--execute`
- unsubscribe assistance through list headers in candidate inspection output
Expand Down
12 changes: 9 additions & 3 deletions src/automation/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,16 @@ mod model;
mod output;
mod rules;
mod service;
mod suggestions;

pub use model::{
AutomationPruneRequest, AutomationPruneStatus, AutomationRolloutRequest, AutomationRunRequest,
DEFAULT_AUTOMATION_ROLLOUT_LIMIT, DEFAULT_AUTOMATION_RUN_LIMIT,
AutomationPruneRequest, AutomationPruneStatus, AutomationRolloutRequest,
AutomationRulesSuggestRequest, AutomationRunRequest, DEFAULT_AUTOMATION_ROLLOUT_LIMIT,
DEFAULT_AUTOMATION_RUN_LIMIT, DEFAULT_AUTOMATION_SUGGESTION_LIMIT,
DEFAULT_AUTOMATION_SUGGESTION_MIN_THREAD_COUNT, DEFAULT_AUTOMATION_SUGGESTION_OLDER_THAN_DAYS,
DEFAULT_AUTOMATION_SUGGESTION_SAMPLE_LIMIT,
};
pub(crate) use service::AutomationServiceError;
pub use service::{apply_run, prune_runs, rollout, run_preview, show_run, validate_rules};
pub use service::{
apply_run, prune_runs, rollout, run_preview, show_run, suggest_rules, validate_rules,
};
50 changes: 50 additions & 0 deletions src/automation/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ use std::path::PathBuf;

pub const DEFAULT_AUTOMATION_RUN_LIMIT: usize = 250;
pub const DEFAULT_AUTOMATION_ROLLOUT_LIMIT: usize = 10;
pub const DEFAULT_AUTOMATION_SUGGESTION_LIMIT: usize = 10;
pub const DEFAULT_AUTOMATION_SUGGESTION_MIN_THREAD_COUNT: usize = 3;
pub const DEFAULT_AUTOMATION_SUGGESTION_OLDER_THAN_DAYS: u32 = 14;
pub const DEFAULT_AUTOMATION_SUGGESTION_SAMPLE_LIMIT: usize = 3;

#[derive(Debug, Clone)]
pub struct AutomationRunRequest {
Expand All @@ -26,6 +30,14 @@ pub struct AutomationPruneRequest {
pub execute: bool,
}

#[derive(Debug, Clone)]
pub struct AutomationRulesSuggestRequest {
pub limit: usize,
pub min_thread_count: usize,
pub older_than_days: u32,
pub sample_limit: usize,
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AutomationPruneStatus {
Expand Down Expand Up @@ -53,6 +65,44 @@ pub struct AutomationRulesValidateReport {
pub rules: Vec<AutomationRuleSummary>,
}

#[derive(Debug, Clone, Serialize)]
pub struct AutomationRulesSuggestReport {
pub account_id: String,
pub rules_path: PathBuf,
pub inspected_thread_count: usize,
pub eligible_thread_count: usize,
pub suggestion_count: usize,
pub min_thread_count: usize,
pub older_than_days: u32,
pub suggestions: Vec<AutomationRuleSuggestion>,
pub warnings: Vec<String>,
pub next_steps: Vec<String>,
pub command_plan: Vec<String>,
}

#[derive(Debug, Clone, Serialize)]
pub struct AutomationRuleSuggestion {
pub rule_id: String,
pub description: String,
pub confidence: String,
pub source: String,
pub matched_thread_count: usize,
pub match_fields: Vec<String>,
pub sample_threads: Vec<AutomationRuleSuggestionSample>,
pub rule: AutomationRule,
pub toml: String,
}

#[derive(Debug, Clone, Serialize)]
pub struct AutomationRuleSuggestionSample {
pub thread_id: String,
pub message_id: String,
pub subject: String,
pub from_address: Option<String>,
pub list_id_header: Option<String>,
pub internal_date_epoch_ms: i64,
}

#[derive(Debug, Clone, Serialize)]
pub struct AutomationRuleSummary {
pub id: String,
Expand Down
72 changes: 71 additions & 1 deletion src/automation/output.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use super::model::{
AutomationApplyReport, AutomationPruneReport, AutomationRolloutReport,
AutomationRulesValidateReport, AutomationRunPreviewReport, AutomationShowReport,
AutomationRulesSuggestReport, AutomationRulesValidateReport, AutomationRunPreviewReport,
AutomationShowReport,
};
use anyhow::Result;
use std::io::{self, Write};
Expand Down Expand Up @@ -51,6 +52,75 @@ impl AutomationRulesValidateReport {
}
}

impl AutomationRulesSuggestReport {
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=rules_suggest"),
format!("account_id={}", sanitize(&self.account_id)),
format!(
"rules_path={}",
sanitize(&self.rules_path.display().to_string())
),
format!("inspected_thread_count={}", self.inspected_thread_count),
format!("eligible_thread_count={}", self.eligible_thread_count),
format!("suggestion_count={}", self.suggestion_count),
format!("min_thread_count={}", self.min_thread_count),
format!("older_than_days={}", self.older_than_days),
];
if !self.suggestions.is_empty() {
lines.push(String::from("suggestions_format=tsv"));
lines.push(String::from(
"rule_id\tconfidence\tmatched_thread_count\tsource\tmatch_fields\tsample_subjects",
));
lines.extend(self.suggestions.iter().map(|suggestion| {
let sample_subjects = suggestion
.sample_threads
.iter()
.map(|sample| sample.subject.as_str())
.collect::<Vec<_>>()
.join(" | ");
format!(
"{}\t{}\t{}\t{}\t{}\t{}",
sanitize(&suggestion.rule_id),
sanitize(&suggestion.confidence),
suggestion.matched_thread_count,
sanitize(&suggestion.source),
sanitize(&suggestion.match_fields.join(", ")),
sanitize(&sample_subjects),
)
}));
lines.push(String::from("toml_snippets_format=toml"));
for suggestion in &self.suggestions {
lines.push(format!("# rule_id={}", sanitize(&suggestion.rule_id)));
lines.push(suggestion.toml.trim_end().to_owned());
}
}
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 AutomationRunPreviewReport {
pub fn print(&self, json: bool) -> Result<()> {
route_output_to_stdout(json, |json, stdout| self.write(json, stdout))
Expand Down
56 changes: 54 additions & 2 deletions src/automation/service.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
use super::model::{
AutomationApplyReport, AutomationPruneReport, AutomationPruneRequest, AutomationPruneStatus,
AutomationRolloutCandidateSummary, AutomationRolloutReport, AutomationRolloutRequest,
AutomationRule, AutomationRuleAction, AutomationRulesValidateReport,
AutomationRunPreviewReport, AutomationRunRequest, AutomationShowReport,
AutomationRule, AutomationRuleAction, AutomationRulesSuggestReport,
AutomationRulesSuggestRequest, AutomationRulesValidateReport, AutomationRunPreviewReport,
AutomationRunRequest, AutomationShowReport,
};
use super::rules::{ResolvedAutomationRules, resolve_rule_selection, validate_rule_file};
use super::suggestions::suggest_rules_from_candidates;
use crate::config::ConfigReport;
use crate::gmail::GmailClient;
use crate::store;
Expand Down Expand Up @@ -42,6 +44,14 @@ pub enum AutomationServiceError {
InvalidRolloutLimit,
#[error("automation prune --older-than-days must be greater than zero")]
InvalidPruneWindow,
#[error("automation rules suggest --limit must be greater than zero")]
InvalidSuggestionLimit,
#[error("automation rules suggest --min-thread-count must be greater than zero")]
InvalidSuggestionMinThreadCount,
#[error("automation rules suggest --older-than-days must be greater than zero")]
InvalidSuggestionOlderThanDays,
#[error("automation rules suggest --sample-limit must be greater than zero")]
InvalidSuggestionSampleLimit,
#[error("re-run with --execute to apply automation changes")]
ExecuteRequired,
#[error(
Expand Down Expand Up @@ -78,6 +88,11 @@ pub enum AutomationServiceError {
},
#[error("{message}")]
RuleValidation { message: String },
#[error("failed to render suggested automation rule TOML: {source}")]
RuleTomlSerialize {
#[source]
source: toml::ser::Error,
},
#[error("failed to join automation task: {source}")]
TaskPanic {
#[source]
Expand Down Expand Up @@ -115,6 +130,25 @@ pub async fn validate_rules(config_report: &ConfigReport) -> Result<AutomationRu
Ok(validate_rule_file(config_report).await?)
}

pub async fn suggest_rules(
config_report: &ConfigReport,
request: AutomationRulesSuggestRequest,
) -> Result<AutomationRulesSuggestReport> {
validate_suggest_request(&request)?;
ensure_runtime_dirs_task(configured_paths(config_report)?).await?;
init_store_task(config_report).await?;
let account_id = resolve_automation_account_id_task(config_report).await?;
let thread_candidates = list_latest_thread_candidates_task(config_report, &account_id).await?;
let now_epoch_ms = current_epoch_seconds()?.saturating_mul(1_000);
Ok(suggest_rules_from_candidates(
config_report,
account_id,
&thread_candidates,
&request,
now_epoch_ms,
)?)
}

pub async fn run_preview(
config_report: &ConfigReport,
request: AutomationRunRequest,
Expand Down Expand Up @@ -643,6 +677,24 @@ fn normalize_prune_statuses(statuses: Vec<AutomationPruneStatus>) -> Vec<Automat
}
}

fn validate_suggest_request(
request: &AutomationRulesSuggestRequest,
) -> Result<(), AutomationServiceError> {
if request.limit == 0 {
return Err(AutomationServiceError::InvalidSuggestionLimit);
}
if request.min_thread_count == 0 {
return Err(AutomationServiceError::InvalidSuggestionMinThreadCount);
}
if request.older_than_days == 0 {
return Err(AutomationServiceError::InvalidSuggestionOlderThanDays);
}
if request.sample_limit == 0 {
return Err(AutomationServiceError::InvalidSuggestionSampleLimit);
}
Ok(())
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

fn prune_status_to_run_status(status: AutomationPruneStatus) -> AutomationRunStatus {
match status {
AutomationPruneStatus::Previewed => AutomationRunStatus::Previewed,
Expand Down
Loading