diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a26180c..2c27f453 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +# Unreleased +- Add `re delete emails` to delete emails by id from a bucket + # v0.40.0 - Allow `re get emails` to filter by `--mailbox`, `--from-timestamp`, and `--to-timestamp` diff --git a/api/src/lib.rs b/api/src/lib.rs index 01348579..56839ab7 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -418,6 +418,22 @@ impl Client { ) } + /// Delete emails by id in a bucket. + pub fn delete_emails( + &self, + bucket: impl Into, + emails: &[EmailId], + ) -> Result<()> { + let bucket_full_name = match bucket.into() { + bucket @ BucketIdentifier::Id(_) => self.get_bucket(bucket)?.full_name(), + BucketIdentifier::FullName(bucket_full_name) => bucket_full_name, + }; + self.delete_query( + self.endpoints.delete_emails(&bucket_full_name)?, + Some(&id_list_query(emails.iter().map(|id| &id.0))), + ) + } + /// Get a page of comments from a source. pub fn get_comments_iter_page( &self, @@ -2459,6 +2475,13 @@ impl Endpoints { ) } + fn delete_emails(&self, bucket_name: &BucketFullName) -> Result { + construct_endpoint( + &self.base, + &["api", "_private", "buckets", &bucket_name.0, "emails"], + ) + } + fn post_user(&self, user_id: &UserId) -> Result { construct_endpoint(&self.base, &["api", "_private", "users", &user_id.0]) } diff --git a/cli/src/commands/delete.rs b/cli/src/commands/delete.rs index 7e113bb2..6227f759 100644 --- a/cli/src/commands/delete.rs +++ b/cli/src/commands/delete.rs @@ -11,7 +11,7 @@ use structopt::StructOpt; use reinfer_client::{ resources::{bucket::GetKeyedSyncStateIdsRequest, project::ForceDeleteProject}, BucketIdentifier, Client, CommentId, CommentsIter, CommentsIterTimerange, DatasetIdentifier, - ProjectName, Source, SourceIdentifier, UserIdentifier, + EmailId, ProjectName, Source, SourceIdentifier, UserIdentifier, }; use crate::progress::{Options as ProgressOptions, Progress}; @@ -38,6 +38,18 @@ pub enum DeleteArgs { comments: Vec, }, + #[structopt(name = "emails")] + /// Delete emails by id in a bucket. + Emails { + #[structopt(short = "b", long = "bucket")] + /// Name or id of the bucket to delete emails from + bucket: BucketIdentifier, + + #[structopt(name = "email id")] + /// Ids of the emails to delete + emails: Vec, + }, + #[structopt(name = "bulk")] /// Delete all comments in a given time range. BulkComments { @@ -134,6 +146,12 @@ pub fn run(delete_args: &DeleteArgs, client: Client) -> Result<()> { .context("Operation to delete comments has failed.")?; log::info!("Deleted comments."); } + DeleteArgs::Emails { bucket, emails } => { + client + .delete_emails(bucket.clone(), emails) + .context("Operation to delete emails has failed.")?; + log::info!("Deleted emails."); + } DeleteArgs::BulkComments { source: source_identifier, include_annotated, diff --git a/cli/tests/test_buckets.rs b/cli/tests/test_buckets.rs index 8205c032..23f28721 100644 --- a/cli/tests/test_buckets.rs +++ b/cli/tests/test_buckets.rs @@ -136,6 +136,89 @@ fn test_get_emails_filter_by_mailbox_and_timerange() { cli.run(["delete", "bucket", &bucket]); } +#[test] +fn test_delete_emails() { + let cli = TestCli::get(); + let owner = TestCli::project(); + + let bucket = format!("{}/test-delete-emails-{}", owner, Uuid::new_v4()); + cli.run(["create", "bucket", &bucket]); + + // Three emails in the bucket. + let emails = [ + ("keep", "alice@reinfer.io", "2020-01-01T00:00:00Z"), + ("delete-1", "bob@reinfer.io", "2020-02-01T00:00:00Z"), + ("delete-2", "carol@reinfer.io", "2020-03-01T00:00:00Z"), + ]; + let jsonl = emails + .iter() + .map(|(id, mailbox, timestamp)| { + serde_json::json!({ + "id": id, + "mailbox": mailbox, + "timestamp": timestamp, + "mime_content": format!( + "Date: {timestamp}\r\nFrom: {mailbox}\r\nTo: support@reinfer.io\r\n\ + Subject: {id}\r\nContent-Type: text/plain\r\n\r\nHello from {id}\r\n" + ), + }) + .to_string() + }) + .collect::>() + .join("\n"); + cli.run_with_stdin(["create", "emails", "-y", "-b", &bucket], jsonl.as_bytes()); + + // Sorted list of email ids currently in the bucket. + let ids = |output: &str| -> Vec { + let mut values: Vec = output + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| { + serde_json::from_str::(line).unwrap()["id"] + .as_str() + .unwrap() + .to_owned() + }) + .collect(); + values.sort(); + values + }; + + assert_eq!( + ids(&cli.run(["get", "emails", &bucket])), + vec!["delete-1", "delete-2", "keep"], + "all three emails should be present before deletion" + ); + + // Delete two of the three by id. + let output = cli.run(["delete", "emails", "-b", &bucket, "delete-1", "delete-2"]); + assert!(output.is_empty(), "{}", output); + + assert_eq!( + ids(&cli.run(["get", "emails", &bucket])), + vec!["keep"], + "only the un-deleted email should remain" + ); + + // Deletion is idempotent: deleting an already-deleted / missing id succeeds. + let output = cli.run([ + "delete", + "emails", + "-b", + &bucket, + "delete-1", + "does-not-exist", + ]); + assert!(output.is_empty(), "{}", output); + assert_eq!( + ids(&cli.run(["get", "emails", &bucket])), + vec!["keep"], + "idempotent delete of missing ids should not affect remaining emails" + ); + + cli.run(["delete", "bucket", &bucket]); +} + #[test] fn test_create_without_org_fails() { let cli = TestCli::get();