diff --git a/cassandane/Cassandane/Cyrus/Delete.pm b/cassandane/Cassandane/Cyrus/Delete.pm index 885b386df69..fc37064f23e 100644 --- a/cassandane/Cassandane/Cyrus/Delete.pm +++ b/cassandane/Cassandane/Cyrus/Delete.pm @@ -944,4 +944,112 @@ sub test_cyr_expire_inherit_annot $self->assert_num_equals(0, $talk->get_response_code('exists')); } +sub test_cyr_expire_noexpire + :DelayedDelete :min_version_3_9 :NoAltNameSpace +{ + my ($self) = @_; + my $store = $self->{store}; + my $talk = $store->get_client(); + + my $noexpire_annot = '/shared/vendor/cmu/cyrus-imapd/noexpire_until'; + + xlog $self, "Create subfolder"; + my $subfolder = 'INBOX.A'; + $talk->create($subfolder) + or $self->fail("Cannot create folder $subfolder: $@"); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "Set /vendor/cmu/cyrus-imapd/expire annotation on subfolder"; + $talk->setmetadata($subfolder, "/shared/vendor/cmu/cyrus-imapd/expire", '1s'); + $self->assert_str_equals('ok', $talk->get_last_completion_response); + + xlog $self, "Create message"; + $store->set_folder($subfolder); + $self->make_message('msg1') or die; + + xlog $self, "Set $noexpire_annot annotation on inbox"; + $talk->setmetadata('INBOX', $noexpire_annot, '0'); + $self->assert_str_equals('ok', $talk->get_last_completion_response); + + $talk->unselect(); + $talk->select($subfolder); + $self->assert_num_equals(1, $talk->get_response_code('exists')); + + sleep(2); + xlog $self, "Run cyr_expire"; + $self->{instance}->run_command({ cyrus => 1 }, 'cyr_expire', '-X' => '1s', '-v', '-v', '-v' ); + + $talk->unselect(); + $talk->select($subfolder); + $self->assert_num_equals(1, $talk->get_response_code('exists')); + + xlog $self, "Remove $noexpire_annot from inbox"; + $talk->setmetadata('INBOX', $noexpire_annot, ''); + $self->assert_str_equals('ok', $talk->get_last_completion_response); + + xlog $self, "Run cyr_expire"; + $self->{instance}->run_command({ cyrus => 1 }, 'cyr_expire', '-X' => '1s', '-v', '-v', '-v' ); + + $talk->unselect(); + $talk->select($subfolder); + $self->assert_num_equals(0, $talk->get_response_code('exists')); +} + +sub test_cyr_expire_delete_noexpire + :DelayedDelete :min_version_3_9 :NoAltNameSpace +{ + my ($self) = @_; + my $store = $self->{store}; + my $adminstore = $self->{adminstore}; + my $talk = $store->get_client(); + my $admintalk = $adminstore->get_client(); + + my $noexpire_annot = '/shared/vendor/cmu/cyrus-imapd/noexpire_until'; + + my $subfoldername = 'foo'; + my $subfolder = 'INBOX.foo'; + $talk->create($subfolder) + or $self->fail("Cannot create folder $subfolder: $@"); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "Setting /vendor/cmu/cyrus-imapd/delete annotation."; + $talk->setmetadata($subfolder, "/shared/vendor/cmu/cyrus-imapd/delete", '1s'); + + $self->check_folder_ondisk($subfolder); + $self->check_folder_not_ondisk($subfolder, deleted => 1); + + xlog $self, "Delete $subfolder"; + $talk->unselect(); + $talk->delete($subfolder) + or $self->fail("Cannot delete folder $subfolder: $@"); + $self->assert_str_equals('ok', $talk->get_last_completion_response()); + + xlog $self, "Ensure we can't select $subfolder anymore"; + $talk->select($subfolder); + $self->assert_str_equals('no', $talk->get_last_completion_response()); + $self->assert_matches(qr/Mailbox does not exist/i, $talk->get_last_error()); + + $self->check_folder_not_ondisk($subfolder); + + my ($path) = $self->{instance}->folder_to_deleted_directories("user.cassandane.$subfoldername"); + $self->assert(-d "$path"); + + xlog $self, "Set $noexpire_annot annotation on inbox"; + $talk->setmetadata('INBOX', $noexpire_annot, '0'); + $self->assert_str_equals('ok', $talk->get_last_completion_response); + + sleep(2); + xlog $self, "Run cyr_expire"; + $self->{instance}->run_command({ cyrus => 1 }, 'cyr_expire', '-D' => '1s' ); + $self->assert(-d "$path"); + + xlog $self, "Remove $noexpire_annot annotation from inbox"; + $talk->setmetadata('INBOX', $noexpire_annot, ''); + $self->assert_str_equals('ok', $talk->get_last_completion_response); + + xlog $self, "Run cyr_expire"; + $self->{instance}->run_command({ cyrus => 1 }, 'cyr_expire', '-D' => '1s' ); + $self->assert(!-d "$path"); +} + 1; diff --git a/changes/next/cyr_expire_noexpire b/changes/next/cyr_expire_noexpire index 60c98ad773b..3de59d4df5d 100644 --- a/changes/next/cyr_expire_noexpire +++ b/changes/next/cyr_expire_noexpire @@ -2,6 +2,8 @@ Description: Supports non-day durations in the archive/delete/expire annotations. +Adds the 'noexpire_until' annotation to disable cyr_expire per user. + Config changes: None diff --git a/docsrc/imap/reference/manpages/systemcommands/cyr_expire.rst b/docsrc/imap/reference/manpages/systemcommands/cyr_expire.rst index 9d4cd5fe78c..e8ceed1afd9 100644 --- a/docsrc/imap/reference/manpages/systemcommands/cyr_expire.rst +++ b/docsrc/imap/reference/manpages/systemcommands/cyr_expire.rst @@ -42,11 +42,19 @@ There are various annotations that **cyr_expire** respects: messages - ``/vendor/cmu/cyrus-imapd/delete`` which controls the deletion of messages +- ``/vendor/cmu/cyrus-imapd/noexpire_until`` which disables the expire + and delete operations per user -These mailbox annotations specify the age of messages in the +The first three mailbox annotations specify the age of messages in the given mailbox that should be expired/archived/deleted. The age is specified as a duration, the default unit are days. +The last mailbox annotation specifies the UNIX epoch time in seconds +until which expiring messages or removing deleted mailboxes is blocked. +The zero epoch time represents infinity. This annotation has precedence +over any of the other annotations or command line flags. It must only +be set on the user inbox and applies to all mailboxes of that user. + The value of the ``/vendor/cmu/cyrus-imapd/expire`` annotation is inherited by all children of the mailbox on which it is set, so an entire mailbox tree can be configured by setting a single annotation on diff --git a/imap/annotate.c b/imap/annotate.c index 2512023bb6b..21e38c0b509 100644 --- a/imap/annotate.c +++ b/imap/annotate.c @@ -2359,6 +2359,26 @@ static const annotate_entrydesc_t mailbox_builtin_entries[] = annotation_set_todb, NULL, NULL + },{ + IMAP_ANNOT_NS "nodelete_until", + ATTRIB_TYPE_UINT, + BACKEND_ONLY, + ATTRIB_VALUE_SHARED, + ACL_ADMIN, + annotation_get_fromdb, + annotation_set_todb, + NULL, + NULL + },{ + IMAP_ANNOT_NS "noexpire_until", + ATTRIB_TYPE_UINT, + BACKEND_ONLY, + ATTRIB_VALUE_SHARED, + ACL_ADMIN, + annotation_get_fromdb, + annotation_set_todb, + NULL, + NULL },{ IMAP_ANNOT_NS "partition", /* _get_partition does its own access control check */ diff --git a/imap/cyr_expire.c b/imap/cyr_expire.c index ff58f5ff899..50dd604c3c5 100644 --- a/imap/cyr_expire.c +++ b/imap/cyr_expire.c @@ -89,6 +89,7 @@ /* global state */ static int verbose = 0; static const char *progname = NULL; +static time_t progtime = 0; static struct namespace expire_namespace; /* current namespace */ /* command line arguments */ @@ -282,43 +283,114 @@ static int parse_duration(const char *s, int *secondsp) } /* - * Given an annotation, reads it, and converts it into 'seconds', - * using `parse_duration`. + * Given an annotation, reads it from the mailbox or any of its + * parents if iterate is true. * * On Success: Returns 1 * On Failure: Returns 0 */ -static int get_annotation_value(const mbentry_t *mbentry, +static int get_annotation_value(const char *mboxname, const char *annot_entry, - int *secondsp, bool iterate) + struct buf *annot_value, + bool iterate) { - struct buf attrib = BUF_INITIALIZER; int ret = 0; /* mboxname needs to be copied since `mboxname_make_parent` * runs a strrchr() on it. */ - char *buf = xstrdup(mbentry->name); + char *buf = xstrdup(mboxname); /* * Mailboxes inherit /vendo/cmu/cyrus-imapd/{expire, archive, delete}, * so we need to iterate all the way up to "" (server entry). */ do { - buf_free(&attrib); - ret = annotatemore_lookup(buf, annot_entry, "", &attrib); + buf_reset(annot_value); + ret = annotatemore_lookup(buf, annot_entry, "", annot_value); if (ret || /* error */ - attrib.s) /* found an entry */ + buf_len(annot_value)) /* found an entry */ break; } while (mboxname_make_parent(buf) && iterate); - if (attrib.s && parse_duration(attrib.s, secondsp)) + free(buf); + + return buf_len(annot_value) ? 1 : 0; +} + +static int get_duration_annotation(const char *mboxname, + const char *annot_entry, + int *secondsp, bool iterate) +{ + struct buf attrib = BUF_INITIALIZER; + int ret = 0; + + if (get_annotation_value(mboxname, annot_entry, &attrib, iterate) && + parse_duration(buf_cstring(&attrib), secondsp)) ret = 1; - else - ret = 0; buf_free(&attrib); - free(buf); + return ret; +} + +static int get_time_annotation(const char *mboxname, + const char *annot_entry, + time_t *timep, bool iterate) +{ + struct buf attrib = BUF_INITIALIZER; + int ret = 0; + + if (get_annotation_value(mboxname, annot_entry, &attrib, iterate)) { + const char *end = NULL; + bit64 v64 = 0; + if (!parsenum(buf_cstring(&attrib), &end, 0, &v64) && !*end) { + *timep = v64; + ret = 1; + } + } + buf_free(&attrib); + return ret; +} + +static int noexpire_mailbox(const mbentry_t *mbentry) +{ + int ret = 0; + mbname_t *mbname = mbname_from_intname(mbentry->name); + + if (mbname_userid(mbname)) { + // Cache result for the last seen userid + static struct { + struct buf userid; + int has_noexpire; + } last_seen = { BUF_INITIALIZER, -1 }; + + if (!strcmp(mbname_userid(mbname), buf_cstring(&last_seen.userid))) { + ret = last_seen.has_noexpire; + goto done; + } + + // Determine user inbox name + if (mbname_isdeleted(mbname)) { + mbname_t *tmp = mbname_from_userid(mbname_userid(mbname)); + mbname_free(&mbname); + mbname = tmp; + } + mbname_truncate_boxes(mbname, 0); + + // Lookup annotation + time_t until; + if (get_time_annotation(mbname_intname(mbname), + IMAP_ANNOT_NS "noexpire_until", &until, false)) + ret = !until || progtime < until; + + // Update cache + buf_setcstr(&last_seen.userid, mbname_userid(mbname)); + last_seen.has_noexpire = ret; + } + +done: + if (ret) verbosep("(noexpire) %s", mbname_intname(mbname)); + mbname_free(&mbname); return ret; } @@ -378,7 +450,7 @@ static int archive(const mbentry_t *mbentry, void *rock) /* check /vendor/cmu/cyrus-imapd/archive */ if (!arock->skip_annotate && - get_annotation_value(mbentry, IMAP_ANNOT_NS "archive", + get_duration_annotation(mbentry->name, IMAP_ANNOT_NS "archive", &archive_seconds, false)) { arock->archive_mark = archive_seconds ? time(0) - archive_seconds : 0; @@ -462,12 +534,16 @@ static int expire(const mbentry_t *mbentry, void *rock) goto done; } + /* see if this mailbox should be ignored */ + if (noexpire_mailbox(mbentry)) + goto done; + /* see if we need to expire messages. * since mailboxes inherit /vendor/cmu/cyrus-imapd/expire, * we need to iterate all the way up to "" (server entry) */ if (!erock->skip_annotate && - get_annotation_value(mbentry, IMAP_ANNOT_NS "expire", + get_duration_annotation(mbentry->name, IMAP_ANNOT_NS "expire", &expire_seconds, true)) { /* add mailbox to table */ erock->expire_mark = expire_seconds ? @@ -539,9 +615,13 @@ static int delete(const mbentry_t *mbentry, void *rock) if (!mboxname_isdeletedmailbox(mbentry->name, ×tamp)) goto done; + /* see if this mailbox should be ignored */ + if (noexpire_mailbox(mbentry)) + goto done; + /* check /vendor/cmu/cyrus-imapd/delete */ if (!drock->skip_annotate && - get_annotation_value(mbentry, IMAP_ANNOT_NS "delete", + get_duration_annotation(mbentry->name, IMAP_ANNOT_NS "delete", &delete_seconds, false)) { drock->delete_mark = delete_seconds ? time(0) - delete_seconds: 0; @@ -886,6 +966,7 @@ int main(int argc, char *argv[]) int r = 0; progname = basename(argv[0]); + progtime = time(NULL); if (parse_args(argc, argv, &ctx.args) != 0) exit(EXIT_FAILURE);