1 # Copyright (C) 2020 all contributors <meta@public-inbox.org>
2 # License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
4 # Each instance of this represents an IMAP client connected to
5 # public-inbox-imapd. Much of this was taken from NNTP, but
6 # further refined while experimenting on future ideas to handle
10 # * NNTP article numbers are UIDs and message sequence numbers (MSNs)
11 # * Message sequence numbers (MSNs) can be stable since we're read-only.
12 # Most IMAP clients use UIDs (I hope), and we can return a dummy
13 # message if a client requests a non-existent MSN.
15 package PublicInbox::IMAP;
17 use base qw(PublicInbox::DS);
18 use fields qw(imapd logged_in ibx long_cb -login_tag
19 uid_min -idle_tag -idle_max);
21 use PublicInbox::EmlContentFoo qw(parse_content_disposition);
22 use PublicInbox::DS qw(now);
23 use PublicInbox::Syscall qw(EPOLLIN EPOLLONESHOT);
24 use PublicInbox::GitAsyncCat;
25 use Text::ParseWords qw(parse_line);
29 for my $mod (qw(Email::Address::XS Mail::Address)) {
30 eval "require $mod" or next;
31 $Address = $mod and last;
33 die "neither Email::Address::XS nor Mail::Address loaded: $@" if !$Address;
35 sub LINE_MAX () { 512 } # does RFC 3501 have a limit like RFC 977?
37 # changing this will cause grief for clients which cache
38 sub UID_BLOCK () { 50_000 }
40 my %FETCH_NEED_BLOB = ( # for future optimization
45 'RFC822.SIZE' => 1, # needs CRLF conversion :<
55 my %FETCH_ATT = map { $_ => [ $_ ] } keys %FETCH_NEED_BLOB;
57 # aliases (RFC 3501 section 6.4.5)
58 $FETCH_ATT{FAST} = [ qw(FLAGS INTERNALDATE RFC822.SIZE) ];
59 $FETCH_ATT{ALL} = [ @{$FETCH_ATT{FAST}}, 'ENVELOPE' ];
60 $FETCH_ATT{FULL} = [ @{$FETCH_ATT{ALL}}, 'BODY' ];
62 for my $att (keys %FETCH_ATT) {
63 my %h = map { $_ => 1 } @{$FETCH_ATT{$att}};
64 $FETCH_ATT{$att} = \%h;
67 my $valid_range = '[0-9]+|[0-9]+:[0-9]+|[0-9]+:\*';
68 $valid_range = qr/\A(?:$valid_range)(?:,(?:$valid_range))*\z/;
72 my $capa = capa($self);
73 $self->write(\"* OK [$capa] public-inbox-imapd ready\r\n");
77 my ($class, $sock, $imapd) = @_;
78 my $self = fields::new($class);
81 if ($sock->can('accept_SSL') && !$sock->accept_SSL) {
82 return CORE::close($sock) if $! != EAGAIN;
83 $ev = PublicInbox::TLS::epollbit();
84 $wbuf = [ \&PublicInbox::DS::accept_tls_step, \&greet ];
86 $self->SUPER::new($sock, $ev | EPOLLONESHOT);
87 $self->{imapd} = $imapd;
89 $self->{wbuf} = $wbuf;
93 $self->update_idle_time;
100 # dovecot advertises IDLE pre-login; perhaps because some clients
101 # depend on it, so we'll do the same
102 my $capa = 'CAPABILITY IMAP4rev1 IDLE';
103 if ($self->{logged_in}) {
104 $capa .= ' COMPRESS=DEFLATE';
106 if (!($self->{sock} // $self)->can('accept_SSL') &&
107 $self->{imapd}->{accept_tls}) {
108 $capa .= ' STARTTLS';
110 $capa .= ' AUTH=ANONYMOUS';
114 sub login_success ($$) {
115 my ($self, $tag) = @_;
116 $self->{logged_in} = 1;
117 my $capa = capa($self);
118 "$tag OK [$capa] Logged in\r\n";
121 sub auth_challenge_ok ($) {
123 my $tag = delete($self->{-login_tag}) or return;
124 login_success($self, $tag);
127 sub cmd_login ($$$$) {
128 my ($self, $tag) = @_; # ignore ($user, $password) = ($_[2], $_[3])
129 login_success($self, $tag);
133 my ($self, $tag) = @_;
134 delete $self->{uid_min};
135 delete $self->{ibx} ? "$tag OK Close done\r\n"
136 : "$tag BAD No mailbox\r\n";
139 sub cmd_logout ($$) {
140 my ($self, $tag) = @_;
141 delete $self->{logged_in};
142 $self->write(\"* BYE logging out\r\n$tag OK Logout done\r\n");
143 $self->shutdn; # PublicInbox::DS::shutdn
147 sub cmd_authenticate ($$$) {
148 my ($self, $tag) = @_; # $method = $_[2], should be "ANONYMOUS"
149 $self->{-login_tag} = $tag;
153 sub cmd_capability ($$) {
154 my ($self, $tag) = @_;
155 '* '.capa($self)."\r\n$tag OK Capability done\r\n";
158 sub cmd_noop ($$) { "$_[1] OK Noop done\r\n" }
160 # called by PublicInbox::InboxIdle
161 sub on_inbox_unlock {
162 my ($self, $ibx) = @_;
163 my $new = $ibx->mm->max;
164 defined(my $old = $self->{-idle_max}) or die 'BUG: -idle_max unset';
166 $self->{-idle_max} = $new;
167 $self->msg_more("* $_ EXISTS\r\n") for (($old + 1)..($new - 1));
168 $self->write(\"* $new EXISTS\r\n");
173 my ($self, $tag) = @_;
174 # IDLE seems allowed by dovecot w/o a mailbox selected *shrug*
175 my $ibx = $self->{ibx} or return "$tag BAD no mailbox selected\r\n";
176 $ibx->subscribe_unlock(fileno($self->{sock}), $self);
177 $self->{imapd}->idler_start;
178 $self->{-idle_tag} = $tag;
179 $self->{-idle_max} = $ibx->mm->max // 0;
184 my ($self, $tag) = @_; # $tag is "DONE" (case-insensitive)
185 defined(my $idle_tag = delete $self->{-idle_tag}) or
186 return "$tag BAD not idle\r\n";
187 my $ibx = $self->{ibx} or do {
188 warn "BUG: idle_tag set w/o inbox";
189 return "$tag BAD internal bug\r\n";
191 $ibx->unsubscribe_unlock(fileno($self->{sock}));
192 "$idle_tag OK Idle done\r\n";
195 sub ensure_old_ranges_exist ($$$) {
196 my ($self, $ibx, $uid_min) = @_;
197 my $groups = $self->{imapd}->{groups};
198 my $mailbox = $ibx->{newsgroup};
200 $uid_min -= UID_BLOCK;
201 my $uid_end = $uid_min + UID_BLOCK - 1;
202 while ($uid_min > 0) {
203 my $sub_mailbox = "$mailbox.$uid_min-$uid_end";
204 last if exists $groups->{$sub_mailbox};
205 $groups->{$sub_mailbox} = $ibx;
206 $uid_end -= UID_BLOCK;
207 $uid_min -= UID_BLOCK;
208 push @created, $sub_mailbox;
210 return unless @created;
211 my $l = $self->{imapd}->{inboxlist};
213 / \Q$mailbox\E\r\n\z/ and s/\(\\HasNoChildren/\(\\HasChildren/;
215 push @$l, map { qq[* LIST (\\HasNoChildren) "." $_\r\n] } @created;
218 sub cmd_examine ($$$) {
219 my ($self, $tag, $mailbox) = @_;
220 my ($ibx, $mm, $max);
222 if ($mailbox =~ /\A(.+)\.([0-9]+)-([0-9]+)\z/) {
223 # old mail: inbox.comp.foo.$uid_min-$uid_end
224 my ($mb_top, $uid_min, $uid_end) = ($1, $2 + 0, $3 + 0);
225 $ibx = $self->{imapd}->{groups}->{lc $mb_top};
226 if (!$ibx || ($uid_end % UID_BLOCK) != 0 ||
227 ($uid_min + UID_BLOCK - 1) != $uid_end) {
228 return "$tag NO Mailbox doesn't exist: $mailbox\r\n";
231 $max = $mm->max // 0;
233 # don't let users create inboxes w/ not-yet-possible range:
235 return "$tag NO Mailbox doesn't exist: $mailbox\r\n";
237 $max = $uid_min + UID_BLOCK + 1;
238 $self->{uid_min} = $uid_min;
239 ensure_old_ranges_exist($self, $ibx, $uid_min);
240 } else { # current mailbox (most recent UID_BLOCK messages)
241 $ibx = $self->{imapd}->{groups}->{lc $mailbox} or
242 return "$tag NO Mailbox doesn't exist: $mailbox\r\n";
245 $max = $mm->max // 0;
247 my $uid_min = UID_BLOCK * int($max/UID_BLOCK) + 1;
248 if ($uid_min == 1) { # normal inbox with <UID_BLOCK messages
249 delete $self->{uid_min}; # implicit cmd_close
250 } else { # we have a giant inbox:
251 $self->{uid_min} = $uid_min;
252 ensure_old_ranges_exist($self, $ibx, $uid_min);
256 # RFC 3501 2.3.1.1 - "A good UIDVALIDITY value to use in
257 # this case is a 32-bit representation of the creation
258 # date/time of the mailbox"
259 my $uidvalidity = $mm->created_at or return "$tag BAD UIDVALIDITY\r\n";
260 my $uidnext = $max + 1;
262 # XXX: do we need this? RFC 5162/7162
263 my $ret = $self->{ibx} ? "* OK [CLOSED] previous closed\r\n" : '';
269 * OK [PERMANENTFLAGS ()] Read-only mailbox\r
271 * OK [UIDNEXT $uidnext]\r
272 * OK [UIDVALIDITY $uidvalidity]\r
273 $tag OK [READ-ONLY] EXAMINE/SELECT done\r
281 } elsif ($v =~ /[{"\r\n%*\\\[]/) { # literal string
282 '{' . length($v) . "}\r\n" . $v;
283 } else { # quoted string
288 sub addr_envelope ($$;$) {
289 my ($eml, $x, $y) = @_;
290 my $v = $eml->header_raw($x) //
291 ($y ? $eml->header_raw($y) : undef) // return 'NIL';
293 my @x = $Address->parse($v) or return 'NIL';
295 map { '(' . join(' ',
296 _esc($_->name), 'NIL',
297 _esc($_->user), _esc($_->host)
303 sub eml_envelope ($) {
306 _esc($eml->header_raw('Date')),
307 _esc($eml->header_raw('Subject')),
308 addr_envelope($eml, 'From'),
309 addr_envelope($eml, 'Sender', 'From'),
310 addr_envelope($eml, 'Reply-To', 'From'),
311 addr_envelope($eml, 'To'),
312 addr_envelope($eml, 'Cc'),
313 addr_envelope($eml, 'Bcc'),
314 _esc($eml->header_raw('In-Reply-To')),
315 _esc($eml->header_raw('Message-ID')),
321 if ($hash && scalar keys %$hash) {
322 $hash = [ %$hash ]; # flatten hash into 1-dimensional array
323 '(' . join(' ', map { _esc($_) } @$hash) . ')';
329 sub body_disposition ($) {
331 my $cd = $eml->header_raw('Content-Disposition') or return 'NIL';
332 $cd = parse_content_disposition($cd);
333 my $buf = '('._esc($cd->{type});
334 $buf .= ' ' . _esc_hash(delete $cd->{attributes});
338 sub body_leaf ($$;$) {
339 my ($eml, $structure, $hold) = @_;
341 $eml->{is_submsg} and # parent was a message/(rfc822|news|global)
342 $buf .= eml_envelope($eml). ' ';
344 $buf .= '('._esc($ct->{type}).' ';
345 $buf .= _esc($ct->{subtype});
346 $buf .= ' ' . _esc_hash(delete $ct->{attributes});
347 $buf .= ' ' . _esc($eml->header_raw('Content-ID'));
348 $buf .= ' ' . _esc($eml->header_raw('Content-Description'));
349 my $cte = $eml->header_raw('Content-Transfer-Encoding') // '7bit';
350 $buf .= ' ' . _esc($cte);
351 $buf .= ' ' . $eml->{imap_body_len};
352 $buf .= ' '.($eml->body_raw =~ tr/\n/\n/) if lc($ct->{type}) eq 'text';
354 # for message/(rfc822|global|news), $hold[0] should have envelope
355 $buf .= ' ' . (@$hold ? join('', @$hold) : 'NIL') if $hold;
358 $buf .= ' '._esc($eml->header_raw('Content-MD5'));
359 $buf .= ' '. body_disposition($eml);
360 $buf .= ' '._esc($eml->header_raw('Content-Language'));
361 $buf .= ' '._esc($eml->header_raw('Content-Location'));
366 sub body_parent ($$$) {
367 my ($eml, $structure, $hold) = @_;
369 my $type = lc($ct->{type});
370 if ($type eq 'multipart') {
372 $buf .= @$hold ? join('', @$hold) : 'NIL';
373 $buf .= ' '._esc($ct->{subtype});
375 $buf .= ' '._esc_hash(delete $ct->{attributes});
376 $buf .= ' '.body_disposition($eml);
377 $buf .= ' '._esc($eml->header_raw('Content-Language'));
378 $buf .= ' '._esc($eml->header_raw('Content-Location'));
382 } else { # message/(rfc822|global|news)
383 @$hold = (body_leaf($eml, $structure, $hold));
387 # this is gross, but we need to process the parent part AFTER
388 # the child parts are done
389 sub bodystructure_prep {
391 my ($eml, $depth) = @$p; # ignore idx
392 # set length here, as $eml->{bdy} gets deleted for message/rfc822
393 $eml->{imap_body_len} = length($eml->body_raw);
394 push @$q, $eml, $depth;
397 # for FETCH BODY and FETCH BODYSTRUCTURE
398 sub fetch_body ($;$) {
399 my ($eml, $structure) = @_;
401 $eml->each_part(\&bodystructure_prep, \@q, 0, 1);
405 my ($part, $depth) = splice(@q, -2);
406 my $is_mp_parent = $depth == ($cur_depth - 1);
410 body_parent($part, $structure, \@hold);
412 unshift @hold, body_leaf($part, $structure);
418 sub dummy_message ($$) {
419 my ($self, $seqno) = @_;
421 From: nobody\@localhost\r
422 To: nobody\@localhost\r
423 Date: Thu, 01 Jan 1970 00:00:00 +0000\r
424 Message-ID: <dummy-$seqno\@$self->{ibx}->{newsgroup}>\r
425 Subject: dummy message #$seqno\r
427 You're seeing this message because your IMAP client didn't use UIDs.\r
428 The message which used to use this sequence number was likely spam\r
429 and removed by the administrator.\r
434 sub requeue_once ($) {
436 # COMPRESS users all share the same DEFLATE context.
437 # Flush it here to ensure clients don't see
441 # no recursion, schedule another call ASAP,
442 # but only after all pending writes are done.
444 my $new_size = push(@{$self->{wbuf}}, \&long_step);
446 # wbuf may be populated by $cb, no need to rearm if so:
447 $self->requeue if $new_size == 1;
450 sub uid_fetch_cb { # called by git->cat_async via git_async_cat
451 my ($bref, $oid, $type, $size, $fetch_m_arg) = @_;
452 my ($self, undef, $msgs, undef, $want) = @$fetch_m_arg;
453 my $smsg = shift @$msgs or die 'BUG: no smsg';
454 if (!defined($oid)) {
455 # it's possible to have TOCTOU if an admin runs
456 # public-inbox-(edit|purge), just move onto the next message
457 return requeue_once($self) unless defined $want->{-seqno};
458 $bref = dummy_message($self, $smsg->{num});
460 $smsg->{blob} eq $oid or die "BUG: $smsg->{blob} != $oid";
463 $$bref =~ s/(?<!\r)\n/\r\n/sg; # make strict clients happy
465 # fixup old bug from import (pre-a0c07cba0e5d8b6a)
466 $$bref =~ s/\A[\r\n]*From [^\r\n]*\r\n//s;
468 $self->msg_more("* $smsg->{num} FETCH (UID $smsg->{num}");
470 $want->{'RFC822.SIZE'} and
471 $self->msg_more(' RFC822.SIZE '.length($$bref));
472 $want->{INTERNALDATE} and
473 $self->msg_more(' INTERNALDATE "'.$smsg->internaldate.'"');
474 $want->{FLAGS} and $self->msg_more(' FLAGS ()');
475 for ('RFC822', 'BODY[]') {
477 $self->msg_more(" $_ {".length($$bref)."}\r\n");
478 $self->msg_more($$bref);
481 my $eml = PublicInbox::Eml->new($bref);
483 $want->{ENVELOPE} and
484 $self->msg_more(' ENVELOPE '.eml_envelope($eml));
486 for ('RFC822.HEADER', 'BODY[HEADER]') {
488 $self->msg_more(" $_ {".length(${$eml->{hdr}})."}\r\n");
489 $self->msg_more(${$eml->{hdr}});
491 for ('RFC822.TEXT', 'BODY[TEXT]') {
493 $self->msg_more(" $_ {".length($$bref)."}\r\n");
494 $self->msg_more($$bref);
496 $want->{BODYSTRUCTURE} and
497 $self->msg_more(' BODYSTRUCTURE '.fetch_body($eml, 1));
499 $self->msg_more(' BODY '.fetch_body($eml));
500 if (my $partial = $want->{-partial}) {
501 partial_emit($self, $partial, $eml);
503 $self->msg_more(")\r\n");
507 sub range_step ($$) {
508 my ($self, $range_csv) = @_;
509 my ($beg, $end, $range);
510 if ($$range_csv =~ s/\A([^,]+),//) {
513 $range = $$range_csv;
516 if ($range =~ /\A([0-9]+):([0-9]+)\z/) {
517 ($beg, $end) = ($1 + 0, $2 + 0);
518 } elsif ($range =~ /\A([0-9]+):\*\z/) {
520 $end = $self->{ibx}->mm->max // 0;
521 $beg = $end if $beg > $end;
522 } elsif ($range =~ /\A[0-9]+\z/) {
523 $beg = $end = $range + 0;
526 return 'BAD fetch range';
528 if (defined($range) && (my $uid_min = $self->{uid_min})) {
529 my $uid_end = $uid_min + UID_BLOCK - 1;
530 $beg = $uid_min if $beg < $uid_min;
531 $end = $uid_end if $end > $uid_end;
533 [ $beg, $end, $$range_csv ];
536 sub refill_range ($$$) {
537 my ($self, $msgs, $range_info) = @_;
538 my ($beg, $end, $range_csv) = @$range_info;
539 if (scalar(@$msgs = @{$self->{ibx}->over->query_xover($beg, $end)})) {
540 $range_info->[0] = $msgs->[-1]->{num} + 1;
543 return 'OK Fetch done' if !$range_csv;
544 my $next_range = range_step($self, \$range_csv);
545 return $next_range if !ref($next_range); # error
546 @$range_info = @$next_range;
547 undef; # keep looping
550 sub uid_fetch_m { # long_response
551 my ($self, $tag, $msgs, $range_info, $want) = @_;
552 while (!@$msgs) { # rare
553 if (my $end = refill_range($self, $msgs, $range_info)) {
554 $self->write(\"$tag $end\r\n");
558 git_async_cat($self->{ibx}->git, $msgs->[0]->{blob},
559 \&uid_fetch_cb, \@_);
562 sub cmd_status ($$$;@) {
563 my ($self, $tag, $mailbox, @items) = @_;
564 my $ibx = $self->{imapd}->{groups}->{$mailbox} or
565 return "$tag NO Mailbox doesn't exist: $mailbox\r\n";
566 return "$tag BAD no items\r\n" if !scalar(@items);
567 ($items[0] !~ s/\A\(//s || $items[-1] !~ s/\)\z//s) and
568 return "$tag BAD invalid args\r\n";
572 for my $it (@items) {
575 if ($it =~ /\A(?:MESSAGES|UNSEEN|RECENT)\z/) {
576 push(@it, ($max //= $mm->max // 0));
577 } elsif ($it eq 'UIDNEXT') {
578 push(@it, ($max //= $mm->max // 0) + 1);
579 } elsif ($it eq 'UIDVALIDITY') {
580 push(@it, $mm->created_at //
581 return("$tag BAD UIDVALIDITY\r\n"));
583 return "$tag BAD invalid item\r\n";
586 return "$tag BAD no items\r\n" if !@it;
587 "* STATUS $mailbox (".join(' ', @it).")\r\n" .
588 "$tag OK Status done\r\n";
591 my %patmap = ('*' => '.*', '%' => '[^\.]*');
592 sub cmd_list ($$$$) {
593 my ($self, $tag, $refname, $wildcard) = @_;
594 my $l = $self->{imapd}->{inboxlist};
595 if ($refname eq '' && $wildcard eq '') {
596 # request for hierarchy delimiter
597 $l = [ qq[* LIST (\\Noselect) "." ""\r\n] ];
598 } elsif ($refname ne '' || $wildcard ne '*') {
599 $wildcard =~ s!([^a-z0-9_])!$patmap{$1} // "\Q$1"!eig;
600 $l = [ grep(/ \Q$refname\E$wildcard\r\n\z/s, @$l) ];
602 \(join('', @$l, "$tag OK List done\r\n"));
605 sub cmd_lsub ($$$$) {
606 my (undef, $tag) = @_; # same args as cmd_list
607 "$tag OK Lsub done\r\n";
610 sub eml_index_offs_i { # PublicInbox::Eml::each_part callback
612 my ($eml, undef, $idx) = @$p;
613 if ($idx && lc($eml->ct->{type}) eq 'multipart') {
614 $eml->{imap_bdy} = $eml->{bdy} // \'';
616 $all->{$idx} = $eml; # $idx => Eml
619 # prepares an index for BODY[$SECTION_IDX] fetches
620 sub eml_body_idx ($$) {
621 my ($eml, $section_idx) = @_;
622 my $idx = $eml->{imap_all_parts} //= do {
624 $eml->each_part(\&eml_index_offs_i, $all, 0, 1);
625 # top-level of multipart, BODY[0] not allowed (nz-number)
629 $idx->{$section_idx};
632 # BODY[($SECTION_IDX)?(.$SECTION_NAME)?]<$offset.$bytes>
634 my ($eml, $section_idx, $section_name) = @_;
635 if (defined $section_idx) {
636 $eml = eml_body_idx($eml, $section_idx) or return;
638 if (defined $section_name) {
639 if ($section_name eq 'MIME') {
640 # RFC 3501 6.4.5 states:
641 # The MIME part specifier MUST be prefixed
642 # by one or more numeric part specifiers
643 return unless defined $section_idx;
644 return $eml->header_obj->as_string . "\r\n";
646 my $bdy = $eml->{bdy} // $eml->{imap_bdy} // \'';
647 $eml = PublicInbox::Eml->new($$bdy);
648 if ($section_name eq 'TEXT') {
649 return $eml->body_raw;
650 } elsif ($section_name eq 'HEADER') {
651 return $eml->header_obj->as_string . "\r\n";
653 die "BUG: bad section_name=$section_name";
656 ${$eml->{bdy} // $eml->{imap_bdy} // \''};
659 # similar to what's in PublicInbox::Eml::re_memo, but doesn't memoize
660 # to avoid OOM with malicious users
661 sub hdrs_regexp ($) {
663 my $names = join('|', map { "\Q$_" } split(/[ \t]+/, $hdrs));
664 qr/^(?:$names):[ \t]*[^\n]*\r?\n # 1st line
665 # continuation lines:
666 (?:[^:\n]*?[ \t]+[^\n]*\r?\n)*
670 # BODY[($SECTION_IDX.)?HEADER.FIELDS.NOT ($HDRS)]<$offset.$bytes>
671 sub partial_hdr_not {
672 my ($eml, $section_idx, $hdrs_re) = @_;
673 if (defined $section_idx) {
674 $eml = eml_body_idx($eml, $section_idx) or return;
676 my $str = $eml->header_obj->as_string;
677 $str =~ s/$hdrs_re//g;
681 # BODY[($SECTION_IDX.)?HEADER.FIELDS ($HDRS)]<$offset.$bytes>
682 sub partial_hdr_get {
683 my ($eml, $section_idx, $hdrs_re) = @_;
684 if (defined $section_idx) {
685 $eml = eml_body_idx($eml, $section_idx) or return;
687 my $str = $eml->header_obj->as_string;
688 join('', ($str =~ m/($hdrs_re)/g), "\r\n");
691 sub partial_prepare ($$$) {
692 my ($partial, $want, $att) = @_;
694 # recombine [ "BODY[1.HEADER.FIELDS", "(foo", "bar)]" ]
695 # back to: "BODY[1.HEADER.FIELDS (foo bar)]"
696 return unless $att =~ /\ABODY\[/s;
697 until (rindex($att, ']') >= 0) {
698 my $next = shift @$want or return;
699 $att .= ' ' . uc($next);
701 if ($att =~ /\ABODY\[([0-9]+(?:\.[0-9]+)*)? # 1 - section_idx
702 (?:\.(HEADER|MIME|TEXT))? # 2 - section_name
703 \](?:<([0-9]+)(?:\.([0-9]+))?>)?\z/sx) { # 3, 4
704 $partial->{$att} = [ \&partial_body, $1, $2, $3, $4 ];
705 } elsif ($att =~ /\ABODY\[(?:([0-9]+(?:\.[0-9]+)*)\.)? # 1 - section_idx
706 (?:HEADER\.FIELDS(\.NOT)?)\x20 # 2
707 \(([A-Z0-9\-\x20]+)\) # 3 - hdrs
708 \](?:<([0-9]+)(?:\.([0-9]+))?>)?\z/sx) { # 4 5
709 my $tmp = $partial->{$att} = [ $2 ? \&partial_hdr_not
712 $tmp->[2] = hdrs_regexp($3);
718 sub partial_emit ($$$) {
719 my ($self, $partial, $eml) = @_;
721 my ($k, $cb, @args) = @$_;
722 my ($offset, $len) = splice(@args, -2);
723 # $cb is partial_body|partial_hdr_get|partial_hdr_not
724 my $str = $cb->($eml, @args) // '';
725 if (defined $offset) {
727 $str = substr($str, $offset, $len);
728 $k =~ s/\.$len>\z/>/ or warn
729 "BUG: unable to remove `.$len>' from `$k'";
731 $str = substr($str, $offset);
737 $self->msg_more(" $k {$len}\r\n");
738 $self->msg_more($str);
742 sub fetch_common ($$$$) {
743 my ($self, $tag, $range_csv, $want) = @_;
744 my $ibx = $self->{ibx} or return "$tag BAD No mailbox selected\r\n";
745 if ($want->[0] =~ s/\A\(//s) {
746 $want->[-1] =~ s/\)\z//s or return "$tag BAD no rparen\r\n";
748 my (%partial, %want);
749 while (defined(my $att = shift @$want)) {
751 $att =~ s/\ABODY\.PEEK\[/BODY\[/; # we're read-only
752 my $x = $FETCH_ATT{$att};
754 %want = (%want, %$x);
755 } elsif (!partial_prepare(\%partial, $want, $att)) {
756 return "$tag BAD param: $att\r\n";
760 # stabilize partial order for consistency and ease-of-debugging:
761 if (scalar keys %partial) {
762 $want{-partial} = [ map {;
763 [ $_, @{$partial{$_}} ]
764 } sort keys %partial ];
766 $range_csv = 'bad' if $range_csv !~ $valid_range;
767 my $range_info = range_step($self, \$range_csv);
768 return "$tag $range_info\r\n" if !ref($range_info);
769 [ $tag, [], $range_info, \%want ];
772 sub cmd_uid_fetch ($$$;@) {
773 my ($self, $tag, $range_csv, @want) = @_;
774 my $args = fetch_common($self, $tag, $range_csv, \@want);
775 ref($args) eq 'ARRAY' ?
776 long_response($self, \&uid_fetch_m, @$args) :
780 sub seq_fetch_m { # long_response
781 my ($self, $tag, $msgs, $range_info, $want) = @_;
782 while (!@$msgs) { # rare
783 if (my $end = refill_range($self, $msgs, $range_info)) {
784 $self->write(\"$tag $end\r\n");
788 my $seq = $want->{-seqno}++;
789 my $cur_num = $msgs->[0]->{num};
790 if ($cur_num == $seq) { # as expected
791 git_async_cat($self->{ibx}->git, $msgs->[0]->{blob},
792 \&uid_fetch_cb, \@_);
793 } elsif ($cur_num > $seq) {
794 # send dummy messages until $seq catches up to $cur_num
795 my $smsg = bless { num => $seq, ts => 0 }, 'PublicInbox::Smsg';
796 unshift @$msgs, $smsg;
797 my $bref = dummy_message($self, $seq);
798 uid_fetch_cb($bref, undef, undef, undef, \@_);
799 $smsg; # blessed response since uid_fetch_cb requeues
800 } else { # should not happen
801 die "BUG: cur_num=$cur_num < seq=$seq";
805 sub cmd_fetch ($$$;@) {
806 my ($self, $tag, $range_csv, @want) = @_;
807 my $args = fetch_common($self, $tag, $range_csv, \@want);
808 ref($args) eq 'ARRAY' ? do {
809 my $want = $args->[-1];
810 $want->{-seqno} = $args->[2]->[0]; # $beg == $range_info->[0];
811 long_response($self, \&seq_fetch_m, @$args)
815 sub uid_search_all { # long_response
816 my ($self, $tag, $num) = @_;
817 my $uids = $self->{ibx}->mm->ids_after($num);
818 if (scalar(@$uids)) {
819 $self->msg_more(join(' ', '', @$uids));
821 $self->write(\"\r\n$tag OK Search done\r\n");
826 sub uid_search_uid_range { # long_response
827 my ($self, $tag, $beg, $end) = @_;
828 my $uids = $self->{ibx}->mm->msg_range($beg, $end, 'num');
830 $self->msg_more(join('', map { " $_->[0]" } @$uids));
832 $self->write(\"\r\n$tag OK Search done\r\n");
837 sub cmd_uid_search ($$$;) {
838 my ($self, $tag, $arg, @rest) = @_;
839 my $ibx = $self->{ibx} or return "$tag BAD No mailbox selected\r\n";
841 if ($arg eq 'ALL' && !@rest) {
842 $self->msg_more('* SEARCH');
844 long_response($self, \&uid_search_all, $tag, \$num);
845 } elsif ($arg eq 'UID' && scalar(@rest) == 1) {
846 if ($rest[0] =~ /\A([0-9]+):([0-9]+|\*)\z/s) {
847 my ($beg, $end) = ($1, $2);
848 $end = $ibx->mm->max if $end eq '*';
849 $self->msg_more('* SEARCH');
850 long_response($self, \&uid_search_uid_range,
852 } elsif ($rest[0] =~ /\A[0-9]+\z/s) {
854 $uid = $ibx->over->get_art($uid) ? " $uid" : '';
855 "* SEARCH$uid\r\n$tag OK Search done\r\n";
857 "$tag BAD Error\r\n";
860 "$tag BAD Error\r\n";
864 sub args_ok ($$) { # duplicated from PublicInbox::NNTP
865 my ($cb, $argc) = @_;
866 my $tot = prototype $cb;
867 my ($nreq, undef) = split(';', $tot);
868 $nreq = ($nreq =~ tr/$//) - 1;
869 $tot = ($tot =~ tr/$//) - 1;
870 ($argc <= $tot && $argc >= $nreq);
873 # returns 1 if we can continue, 0 if not due to buffered writes or disconnect
874 sub process_line ($$) {
876 my ($tag, $req, @args) = parse_line('[ \t]+', 0, $l);
877 pop(@args) if (@args && !defined($args[-1]));
878 if (@args && uc($req) eq 'UID') {
879 $req .= "_".(shift @args);
882 if (my $cmd = $self->can('cmd_'.lc($req // ''))) {
883 defined($self->{-idle_tag}) ?
884 "$self->{-idle_tag} BAD expected DONE\r\n" :
885 $cmd->($self, $tag, @args);
886 } elsif (uc($tag // '') eq 'DONE' && !defined($req)) {
887 cmd_done($self, $tag);
888 } else { # this is weird
889 auth_challenge_ok($self) //
890 "$tag BAD Error in IMAP command $req: ".
891 "Unknown command\r\n";
895 if ($err && $self->{sock}) {
897 err($self, 'error from: %s (%s)', $l, $err);
898 $res = "$tag BAD program fault - command not performed\r\n";
900 return 0 unless defined $res;
906 # wbuf is unset or empty, here; {long} may add to it
907 my ($fd, $cb, $t0, @args) = @{$self->{long_cb}};
908 my $more = eval { $cb->($self, @args) };
909 if ($@ || !$self->{sock}) { # something bad happened...
910 delete $self->{long_cb};
911 my $elapsed = now() - $t0;
914 "%s during long response[$fd] - %0.6f",
917 out($self, " deferred[$fd] aborted - %0.6f", $elapsed);
919 } elsif ($more) { # $self->{wbuf}:
920 $self->update_idle_time;
922 # control passed to $more may be a GitAsyncCat object
923 requeue_once($self) if !ref($more);
925 delete $self->{long_cb};
926 my $elapsed = now() - $t0;
927 my $fd = fileno($self->{sock});
928 out($self, " deferred[$fd] done - %0.6f", $elapsed);
929 my $wbuf = $self->{wbuf}; # do NOT autovivify
931 $self->requeue unless $wbuf && @$wbuf;
936 my ($self, $fmt, @args) = @_;
937 printf { $self->{imapd}->{err} } $fmt."\n", @args;
941 my ($self, $fmt, @args) = @_;
942 printf { $self->{imapd}->{out} } $fmt."\n", @args;
945 sub long_response ($$;@) {
946 my ($self, $cb, @args) = @_; # cb returns true if more, false if done
948 my $sock = $self->{sock} or return;
949 # make sure we disable reading during a long response,
950 # clients should not be sending us stuff and making us do more
951 # work while we are stream a response to them
952 $self->{long_cb} = [ fileno($sock), $cb, now(), @args ];
953 long_step($self); # kick off!
957 # callback used by PublicInbox::DS for any (e)poll (in/out/hup/err)
961 return unless $self->flush_write && $self->{sock} && !$self->{long_cb};
963 $self->update_idle_time;
964 # only read more requests if we've drained the write buffer,
965 # otherwise we can be buffering infinitely w/o backpressure
967 my $rbuf = $self->{rbuf} // \(my $x = '');
968 my $line = index($$rbuf, "\n");
970 return $self->close if length($$rbuf) >= LINE_MAX;
971 $self->do_read($rbuf, LINE_MAX, length($$rbuf)) or return;
972 $line = index($$rbuf, "\n");
974 $line = substr($$rbuf, 0, $line + 1, '');
975 $line =~ s/\r?\n\z//s;
976 return $self->close if $line =~ /[[:cntrl:]]/s;
978 my $fd = fileno($self->{sock});
979 my $r = eval { process_line($self, $line) };
980 my $pending = $self->{wbuf} ? ' pending' : '';
981 out($self, "[$fd] %s - %0.6f$pending - $r", $line, now() - $t0);
983 return $self->close if $r < 0;
984 $self->rbuf_idle($rbuf);
985 $self->update_idle_time;
987 # maybe there's more pipelined data, or we'll have
988 # to register it for socket-readiness notifications
989 $self->requeue unless $pending;
992 sub compressed { undef }
994 sub zflush {} # overridden by IMAPdeflate
997 sub cmd_compress ($$$) {
998 my ($self, $tag, $alg) = @_;
999 return "$tag BAD DEFLATE only\r\n" if uc($alg) ne "DEFLATE";
1000 return "$tag BAD COMPRESS active\r\n" if $self->compressed;
1002 # CRIME made TLS compression obsolete
1003 # return "$tag NO [COMPRESSIONACTIVE]\r\n" if $self->tls_compressed;
1005 PublicInbox::IMAPdeflate->enable($self, $tag);
1010 sub cmd_starttls ($$) {
1011 my ($self, $tag) = @_;
1012 my $sock = $self->{sock} or return;
1013 if ($sock->can('stop_SSL') || $self->compressed) {
1014 return "$tag BAD TLS or compression already enabled\r\n";
1016 my $opt = $self->{imapd}->{accept_tls} or
1017 return "$tag BAD can not initiate TLS negotiation\r\n";
1018 $self->write(\"$tag OK begin TLS negotiation now\r\n");
1019 $self->{sock} = IO::Socket::SSL->start_SSL($sock, %$opt);
1020 $self->requeue if PublicInbox::DS::accept_tls_step($self);
1024 # for graceful shutdown in PublicInbox::Daemon:
1026 my ($self, $now) = @_;
1027 ($self->{rbuf} || $self->{wbuf} || $self->not_idle_long($now));
1032 if (my $ibx = delete $self->{ibx}) {
1033 if (my $sock = $self->{sock}) {;
1034 $ibx->unsubscribe_unlock(fileno($sock));
1037 $self->SUPER::close; # PublicInbox::DS::close
1040 # we're read-only, so SELECT and EXAMINE do the same thing
1042 *cmd_select = \&cmd_examine;