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
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
2 changes: 2 additions & 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 Expand Up @@ -234,6 +235,7 @@ cargo run -- doctor --json
cargo run -- audit labels --json
cargo run -- audit verification --json
cargo run -- sync run --profile deep-audit --json
cargo run -- automation rules suggest --json
cargo run -- automation rules validate --json
cargo run -- automation rollout --limit 10 --json
cargo run -- automation run --limit 10 --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
65 changes: 65 additions & 0 deletions src/automation/headers.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
const PRECEDENCE_VALUES: [&str; 3] = ["bulk", "list", "junk"];

pub(crate) fn normalized_precedence(header: Option<&str>) -> Option<String> {
let value = header?.trim().to_ascii_lowercase();
PRECEDENCE_VALUES
.iter()
.find(|expected| has_ascii_token(&value, expected))
.map(|matched| (*matched).to_owned())
}

pub(crate) fn match_precedence_values(
candidate: Option<&str>,
required_values: &[String],
) -> Option<Vec<String>> {
if required_values.is_empty() {
return Some(Vec::new());
}
let candidate = candidate?.trim().to_ascii_lowercase();
let matches = required_values
.iter()
.filter(|required| {
let required = required.trim().to_ascii_lowercase();
!required.is_empty() && has_ascii_token(&candidate, &required)
})
.cloned()
.collect::<Vec<_>>();
(!matches.is_empty()).then_some(matches)
}

fn has_ascii_token(value: &str, expected: &str) -> bool {
value
.split(|character: char| !character.is_ascii_alphanumeric())
.any(|token| token == expected)
}

#[cfg(test)]
mod tests {
use super::{match_precedence_values, normalized_precedence};

#[test]
fn normalized_precedence_requires_exact_tokens() {
assert_eq!(
normalized_precedence(Some("bulk")),
Some(String::from("bulk"))
);
assert_eq!(
normalized_precedence(Some("x-priority; list")),
Some(String::from("list"))
);
assert_eq!(normalized_precedence(Some("notbulk")), None);
assert_eq!(normalized_precedence(Some("xlistx")), None);
}

#[test]
fn match_precedence_values_requires_exact_tokens() {
assert_eq!(
match_precedence_values(Some("x-priority; bulk"), &[String::from("bulk")]),
Some(vec![String::from("bulk")])
);
assert_eq!(
match_precedence_values(Some("notbulk"), &[String::from("bulk")]),
None
);
}
}
13 changes: 10 additions & 3 deletions src/automation/mod.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
mod headers;
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
Loading