Documentation/standards.perl | 2 +-
lib/PublicInbox/IMAP.pm | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++---
lib/PublicInbox/IMAPD.pm | 20 +++++++++++++++++++-
lib/PublicInbox/NNTPD.pm | 6 +++---
t/imapd.t | 28 ++++++++++++++++++++++++++--
diff --git a/Documentation/standards.perl b/Documentation/standards.perl
index 37309956f39faccb8c7981976ffbcae854291b89..8fc852c722bae5e48d6bcd3686ee29f7ea442662 100755
--- a/Documentation/standards.perl
+++ b/Documentation/standards.perl
@@ -43,7 +43,7 @@ 822 => 'Internet message format (1982)',
2822 => 'Internet message format (2001)',
5322 => 'Internet message format (2008)',
3501 => 'IMAP4rev1',
- 2177 => 'IMAP IDLE', # TODO
+ 2177 => 'IMAP IDLE',
# 5032 = 'WITHIN search extension for IMAP',
4978 => 'IMAP COMPRESS Extension',
# 5182 = 'IMAP Extension for Referencing the Last SEARCH Result',
diff --git a/lib/PublicInbox/IMAP.pm b/lib/PublicInbox/IMAP.pm
index c0636066b9ff0fce0aa6e725549311960440afc8..99c6c817fd799b48995e068d232d09467d12770b 100644
--- a/lib/PublicInbox/IMAP.pm
+++ b/lib/PublicInbox/IMAP.pm
@@ -15,7 +15,8 @@
package PublicInbox::IMAP;
use strict;
use base qw(PublicInbox::DS);
-use fields qw(imapd logged_in ibx long_cb -login_tag);
+use fields qw(imapd logged_in ibx long_cb -login_tag
+ -idle_tag -idle_max);
use PublicInbox::Eml;
use PublicInbox::DS qw(now);
use PublicInbox::Syscall qw(EPOLLIN EPOLLONESHOT);
@@ -88,7 +89,10 @@ }
sub capa ($) {
my ($self) = @_;
- my $capa = 'CAPABILITY IMAP4rev1';
+
+ # dovecot advertises IDLE pre-login; perhaps because some clients
+ # depend on it, so we'll do the same
+ my $capa = 'CAPABILITY IMAP4rev1 IDLE';
if ($self->{logged_in}) {
$capa .= ' COMPRESS=DEFLATE';
} else {
@@ -139,6 +143,40 @@ }
sub cmd_noop ($$) { "$_[1] OK NOOP completed\r\n" }
+# called by PublicInbox::InboxIdle
+sub on_inbox_unlock {
+ my ($self, $ibx) = @_;
+ my $new = ($ibx->mm->minmax)[1];
+ defined(my $old = $self->{-idle_max}) or die 'BUG: -idle_max unset';
+ if ($new > $old) {
+ $self->{-idle_max} = $new;
+ $self->msg_more("* $_ EXISTS\r\n") for (($old + 1)..($new - 1));
+ $self->write(\"* $new EXISTS\r\n");
+ }
+}
+
+sub cmd_idle ($$) {
+ my ($self, $tag) = @_;
+ # IDLE seems allowed by dovecot w/o a mailbox selected *shrug*
+ my $ibx = $self->{ibx} or return "$tag BAD no mailbox selected\r\n";
+ $ibx->subscribe_unlock(fileno($self->{sock}), $self);
+ $self->{-idle_tag} = $tag;
+ $self->{-idle_max} = ($ibx->mm->minmax)[1] // 0;
+ "+ idling\r\n"
+}
+
+sub cmd_done ($$) {
+ my ($self, $tag) = @_; # $tag is "DONE" (case-insensitive)
+ defined(my $idle_tag = delete $self->{-idle_tag}) or
+ return "$tag BAD not idle\r\n";
+ my $ibx = $self->{ibx} or do {
+ warn "BUG: idle_tag set w/o inbox";
+ return "$tag BAD internal bug\r\n";
+ };
+ $ibx->unsubscribe_unlock(fileno($self->{sock}));
+ "$idle_tag OK Idle completed\r\n";
+}
+
sub cmd_examine ($$$) {
my ($self, $tag, $mailbox) = @_;
my $ibx = $self->{imapd}->{groups}->{$mailbox} or
@@ -361,7 +399,11 @@ $req .= "_".(shift @args);
}
my $res = eval {
if (my $cmd = $self->can('cmd_'.lc($req // ''))) {
- $cmd->($self, $tag, @args);
+ defined($self->{-idle_tag}) ?
+ "$self->{-idle_tag} BAD expected DONE\r\n" :
+ $cmd->($self, $tag, @args);
+ } elsif (uc($tag // '') eq 'DONE' && !defined($req)) {
+ cmd_done($self, $tag);
} else { # this is weird
auth_challenge_ok($self) //
"$tag BAD Error in IMAP command $req: ".
@@ -514,6 +556,16 @@ # for graceful shutdown in PublicInbox::Daemon:
sub busy {
my ($self, $now) = @_;
($self->{rbuf} || $self->{wbuf} || $self->not_idle_long($now));
+}
+
+sub close {
+ my ($self) = @_;
+ if (my $ibx = delete $self->{ibx}) {
+ if (my $sock = $self->{sock}) {;
+ $ibx->unsubscribe_unlock(fileno($sock));
+ }
+ }
+ $self->SUPER::close; # PublicInbox::DS::close
}
# we're read-only, so SELECT and EXAMINE do the same thing
diff --git a/lib/PublicInbox/IMAPD.pm b/lib/PublicInbox/IMAPD.pm
index 1011d6a413b1cf4d5d9f6b0976b65cc4b37f2e5b..1922c16046a3119723e560eb18fdfc010bbc9bd2 100644
--- a/lib/PublicInbox/IMAPD.pm
+++ b/lib/PublicInbox/IMAPD.pm
@@ -6,10 +6,28 @@ # see script/public-inbox-imapd for how it is used
package PublicInbox::IMAPD;
use strict;
use parent qw(PublicInbox::NNTPD);
+use PublicInbox::InboxIdle;
sub new {
my ($class) = @_;
- $class->SUPER::new; # PublicInbox::NNTPD->new
+ bless {
+ groups => {},
+ err => \*STDERR,
+ out => \*STDOUT,
+ grouplist => [],
+ # accept_tls => { SSL_server => 1, ..., SSL_reuse_ctx => ... }
+ # idler => PublicInbox::InboxIdle
+ }, $class;
+}
+
+sub refresh_groups {
+ my ($self) = @_;
+ if (my $old_idler = delete $self->{idler}) {
+ $old_idler->close; # PublicInbox::DS::close
+ }
+ my $pi_config = PublicInbox::Config->new;
+ $self->{idler} = PublicInbox::InboxIdle->new($pi_config);
+ $self->SUPER::refresh_groups($pi_config);
}
1;
diff --git a/lib/PublicInbox/NNTPD.pm b/lib/PublicInbox/NNTPD.pm
index b8ec84ed6b0e24490c71cad2fa15787578a467dc..ed5cf7cc8c0af2bdf01d9644fa051b486d7b42f2 100644
--- a/lib/PublicInbox/NNTPD.pm
+++ b/lib/PublicInbox/NNTPD.pm
@@ -30,9 +30,9 @@ # accept_tls => { SSL_server => 1, ..., SSL_reuse_ctx => ... }
}, $class;
}
-sub refresh_groups () {
- my ($self) = @_;
- my $pi_config = PublicInbox::Config->new;
+sub refresh_groups {
+ my ($self, $pi_config) = @_;
+ $pi_config //= PublicInbox::Config->new;
my $new = {};
my @list;
$pi_config->each_inbox(sub {
diff --git a/t/imapd.t b/t/imapd.t
index f28a663bf9dacbb9622120b8647eab5ae64b7dba..359c4c033b28bfecf6fd8b1a39a46e76e13394f3 100644
--- a/t/imapd.t
+++ b/t/imapd.t
@@ -3,8 +3,10 @@ # Copyright (C) 2020 all contributors
# License: AGPL-3.0+
use strict;
use Test::More;
+use Time::HiRes ();
use PublicInbox::TestCommon;
-require_mods(qw(DBD::SQLite Mail::IMAPClient));
+use PublicInbox::Config;
+require_mods(qw(DBD::SQLite Mail::IMAPClient Linux::Inotify2));
my $level = '-Lbasic';
SKIP: {
require_mods('Search::Xapian', 1);
@@ -12,7 +14,7 @@ $level = '-Lmedium';
};
my @V = (1);
-#push(@V, 2) if require_git('2.6', 1);
+push(@V, 2) if require_git('2.6', 1);
my ($tmpdir, $for_destroy) = tmpdir();
my $home = "$tmpdir/home";
@@ -138,6 +140,28 @@
is_deeply([$mic->has_capability('COMPRESS')], ['DEFLATE'], 'deflate cap');
ok($mic->compress, 'compress enabled');
$compress_logout->($mic);
+
+my $pi_config = PublicInbox::Config->new;
+$pi_config->each_inbox(sub {
+ my ($ibx) = @_;
+ my $name = $ibx->{name};
+ my $ng = $ibx->{newsgroup};
+ my $mic = Mail::IMAPClient->new(%mic_opt);
+ ok($mic && $mic->login && $mic->IsAuthenticated, "authed $name");
+ is_deeply([$mic->has_capability('IDLE')], ['IDLE'], "IDLE capa $name");
+ ok(!$mic->idle, "IDLE fails w/o SELECT/EXAMINE $name");
+ ok($mic->examine($ng), "EXAMINE $ng succeeds");
+ ok($mic->idle, "IDLE succeeds on $ng");
+
+ open(my $fh, '<', 't/data/message_embed.eml') or BAIL_OUT("open: $!");
+ my $env = { ORIGINAL_RECIPIENT => $ibx->{-primary_address} };
+ run_script(['-mda', '--no-precheck'], $env, { 0 => $fh }) or
+ BAIL_OUT('-mda delivery');
+ my $t0 = Time::HiRes::time();
+ ok(my @res = $mic->idle_data(11), "IDLE succeeds on $ng");
+ ok(grep(/\A\* [0-9] EXISTS\b/, @res), 'got EXISTS message');
+ ok((Time::HiRes::time() - $t0) < 10, 'IDLE client notified');
+});
$td->kill;
$td->join;