]> Sergey Matveev's repositories - public-inbox.git/blob - lib/PublicInbox/IMAP.pm
imap: remove dummies from sequence number FETCH
[public-inbox.git] / lib / PublicInbox / IMAP.pm
1 # Copyright (C) 2020 all contributors <meta@public-inbox.org>
2 # License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
3 #
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
7 # slow storage.
8 #
9 # data notes:
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).  We may return a dummy message
13 #   in the future if a client requests a non-existent MSN, but that seems
14 #   unecessary with mutt.
15
16 package PublicInbox::IMAP;
17 use strict;
18 use base qw(PublicInbox::DS);
19 use fields qw(imapd logged_in ibx long_cb -login_tag
20         uid_min -idle_tag -idle_max);
21 use PublicInbox::Eml;
22 use PublicInbox::EmlContentFoo qw(parse_content_disposition);
23 use PublicInbox::DS qw(now);
24 use PublicInbox::Syscall qw(EPOLLIN EPOLLONESHOT);
25 use PublicInbox::GitAsyncCat;
26 use Text::ParseWords qw(parse_line);
27 use Errno qw(EAGAIN);
28 use Time::Local qw(timegm);
29 use POSIX qw(strftime);
30
31 my $Address;
32 for my $mod (qw(Email::Address::XS Mail::Address)) {
33         eval "require $mod" or next;
34         $Address = $mod and last;
35 }
36 die "neither Email::Address::XS nor Mail::Address loaded: $@" if !$Address;
37
38 sub LINE_MAX () { 512 } # does RFC 3501 have a limit like RFC 977?
39
40 # changing this will cause grief for clients which cache
41 sub UID_BLOCK () { 50_000 }
42
43 my %FETCH_NEED_BLOB = ( # for future optimization
44         'BODY[HEADER]' => 1,
45         'BODY[TEXT]' => 1,
46         'BODY[]' => 1,
47         'RFC822.HEADER' => 1,
48         'RFC822.SIZE' => 1, # needs CRLF conversion :<
49         'RFC822.TEXT' => 1,
50         BODY => 1,
51         BODYSTRUCTURE => 1,
52         ENVELOPE => 1,
53         FLAGS => 0,
54         INTERNALDATE => 0,
55         RFC822 => 1,
56         UID => 0,
57 );
58 my %FETCH_ATT = map { $_ => [ $_ ] } keys %FETCH_NEED_BLOB;
59
60 # aliases (RFC 3501 section 6.4.5)
61 $FETCH_ATT{FAST} = [ qw(FLAGS INTERNALDATE RFC822.SIZE) ];
62 $FETCH_ATT{ALL} = [ @{$FETCH_ATT{FAST}}, 'ENVELOPE' ];
63 $FETCH_ATT{FULL} = [ @{$FETCH_ATT{ALL}}, 'BODY' ];
64
65 for my $att (keys %FETCH_ATT) {
66         my %h = map { $_ => 1 } @{$FETCH_ATT{$att}};
67         $FETCH_ATT{$att} = \%h;
68 }
69
70 my $valid_range = '[0-9]+|[0-9]+:[0-9]+|[0-9]+:\*';
71 $valid_range = qr/\A(?:$valid_range)(?:,(?:$valid_range))*\z/;
72
73 my @MoY = qw(Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec);
74 my %MoY;
75 @MoY{@MoY} = (0..11);
76
77 sub greet ($) {
78         my ($self) = @_;
79         my $capa = capa($self);
80         $self->write(\"* OK [$capa] public-inbox-imapd ready\r\n");
81 }
82
83 sub new ($$$) {
84         my ($class, $sock, $imapd) = @_;
85         my $self = fields::new($class);
86         my $ev = EPOLLIN;
87         my $wbuf;
88         if ($sock->can('accept_SSL') && !$sock->accept_SSL) {
89                 return CORE::close($sock) if $! != EAGAIN;
90                 $ev = PublicInbox::TLS::epollbit();
91                 $wbuf = [ \&PublicInbox::DS::accept_tls_step, \&greet ];
92         }
93         $self->SUPER::new($sock, $ev | EPOLLONESHOT);
94         $self->{imapd} = $imapd;
95         if ($wbuf) {
96                 $self->{wbuf} = $wbuf;
97         } else {
98                 greet($self);
99         }
100         $self->update_idle_time;
101         $self;
102 }
103
104 sub capa ($) {
105         my ($self) = @_;
106
107         # dovecot advertises IDLE pre-login; perhaps because some clients
108         # depend on it, so we'll do the same
109         my $capa = 'CAPABILITY IMAP4rev1 IDLE';
110         if ($self->{logged_in}) {
111                 $capa .= ' COMPRESS=DEFLATE';
112         } else {
113                 if (!($self->{sock} // $self)->can('accept_SSL') &&
114                         $self->{imapd}->{accept_tls}) {
115                         $capa .= ' STARTTLS';
116                 }
117                 $capa .= ' AUTH=ANONYMOUS';
118         }
119 }
120
121 sub login_success ($$) {
122         my ($self, $tag) = @_;
123         $self->{logged_in} = 1;
124         my $capa = capa($self);
125         "$tag OK [$capa] Logged in\r\n";
126 }
127
128 sub auth_challenge_ok ($) {
129         my ($self) = @_;
130         my $tag = delete($self->{-login_tag}) or return;
131         login_success($self, $tag);
132 }
133
134 sub cmd_login ($$$$) {
135         my ($self, $tag) = @_; # ignore ($user, $password) = ($_[2], $_[3])
136         login_success($self, $tag);
137 }
138
139 sub cmd_close ($$) {
140         my ($self, $tag) = @_;
141         delete $self->{uid_min};
142         delete $self->{ibx} ? "$tag OK Close done\r\n"
143                                 : "$tag BAD No mailbox\r\n";
144 }
145
146 sub cmd_logout ($$) {
147         my ($self, $tag) = @_;
148         delete $self->{logged_in};
149         $self->write(\"* BYE logging out\r\n$tag OK Logout done\r\n");
150         $self->shutdn; # PublicInbox::DS::shutdn
151         undef;
152 }
153
154 sub cmd_authenticate ($$$) {
155         my ($self, $tag) = @_; # $method = $_[2], should be "ANONYMOUS"
156         $self->{-login_tag} = $tag;
157         "+\r\n"; # challenge
158 }
159
160 sub cmd_capability ($$) {
161         my ($self, $tag) = @_;
162         '* '.capa($self)."\r\n$tag OK Capability done\r\n";
163 }
164
165 sub cmd_noop ($$) { "$_[1] OK Noop done\r\n" }
166
167 # called by PublicInbox::InboxIdle
168 sub on_inbox_unlock {
169         my ($self, $ibx) = @_;
170         my $new = $ibx->mm->max;
171         defined(my $old = $self->{-idle_max}) or die 'BUG: -idle_max unset';
172         if ($new > $old) {
173                 $self->{-idle_max} = $new;
174                 $self->msg_more("* $_ EXISTS\r\n") for (($old + 1)..($new - 1));
175                 $self->write(\"* $new EXISTS\r\n");
176         }
177 }
178
179 sub cmd_idle ($$) {
180         my ($self, $tag) = @_;
181         # IDLE seems allowed by dovecot w/o a mailbox selected *shrug*
182         my $ibx = $self->{ibx} or return "$tag BAD no mailbox selected\r\n";
183         $ibx->subscribe_unlock(fileno($self->{sock}), $self);
184         $self->{imapd}->idler_start;
185         $self->{-idle_tag} = $tag;
186         $self->{-idle_max} = $ibx->mm->max // 0;
187         "+ idling\r\n"
188 }
189
190 sub cmd_done ($$) {
191         my ($self, $tag) = @_; # $tag is "DONE" (case-insensitive)
192         defined(my $idle_tag = delete $self->{-idle_tag}) or
193                 return "$tag BAD not idle\r\n";
194         my $ibx = $self->{ibx} or do {
195                 warn "BUG: idle_tag set w/o inbox";
196                 return "$tag BAD internal bug\r\n";
197         };
198         $ibx->unsubscribe_unlock(fileno($self->{sock}));
199         "$idle_tag OK Idle done\r\n";
200 }
201
202 sub ensure_ranges_exist ($$$) {
203         my ($imapd, $ibx, $max) = @_;
204         my $mailboxes = $imapd->{mailboxes};
205         my $mb_top = $ibx->{newsgroup};
206         my @created;
207         for (my $i = int($max/UID_BLOCK); $i >= 0; --$i) {
208                 my $sub_mailbox = "$mb_top.$i";
209                 last if exists $mailboxes->{$sub_mailbox};
210                 $mailboxes->{$sub_mailbox} = $ibx;
211                 push @created, $sub_mailbox;
212         }
213         return unless @created;
214         my $l = $imapd->{inboxlist} or return;
215         push @$l, map { qq[* LIST (\\HasNoChildren) "." $_\r\n] } @created;
216 }
217
218 sub inbox_lookup ($$) {
219         my ($self, $mailbox) = @_;
220         my ($ibx, $exists, $uidnext);
221         if ($mailbox =~ /\A(.+)\.([0-9]+)\z/) {
222                 # old mail: inbox.comp.foo.$uid_block_idx
223                 my ($mb_top, $uid_min) = ($1, $2 * UID_BLOCK + 1);
224
225                 $ibx = $self->{imapd}->{mailboxes}->{lc $mailbox} or return;
226                 $exists = $ibx->mm->max // 0;
227                 $self->{uid_min} = $uid_min;
228                 ensure_ranges_exist($self->{imapd}, $ibx, $exists);
229                 my $uid_end = $uid_min + UID_BLOCK - 1;
230                 $exists = $uid_end if $exists > $uid_end;
231                 $uidnext = $exists + 1;
232         } else { # check for dummy inboxes
233                 $ibx = $self->{imapd}->{mailboxes}->{lc $mailbox} or return;
234                 delete $self->{uid_min};
235                 $exists = 0;
236                 $uidnext = 1;
237         }
238         ($ibx, $exists, $uidnext);
239 }
240
241 sub cmd_examine ($$$) {
242         my ($self, $tag, $mailbox) = @_;
243         my ($ibx, $exists, $uidnext) = inbox_lookup($self, $mailbox);
244         return "$tag NO Mailbox doesn't exist: $mailbox\r\n" if !$ibx;
245
246         # XXX: do we need this? RFC 5162/7162
247         my $ret = $self->{ibx} ? "* OK [CLOSED] previous closed\r\n" : '';
248         $self->{ibx} = $ibx;
249         $ret .= <<EOF;
250 * $exists EXISTS\r
251 * $exists RECENT\r
252 * FLAGS (\\Seen)\r
253 * OK [PERMANENTFLAGS ()] Read-only mailbox\r
254 * OK [UNSEEN $exists]\r
255 * OK [UIDNEXT $uidnext]\r
256 * OK [UIDVALIDITY $ibx->{uidvalidity}]\r
257 $tag OK [READ-ONLY] EXAMINE/SELECT done\r
258 EOF
259 }
260
261 sub _esc ($) {
262         my ($v) = @_;
263         if (!defined($v)) {
264                 'NIL';
265         } elsif ($v =~ /[{"\r\n%*\\\[]/) { # literal string
266                 '{' . length($v) . "}\r\n" . $v;
267         } else { # quoted string
268                 qq{"$v"}
269         }
270 }
271
272 sub addr_envelope ($$;$) {
273         my ($eml, $x, $y) = @_;
274         my $v = $eml->header_raw($x) //
275                 ($y ? $eml->header_raw($y) : undef) // return 'NIL';
276
277         my @x = $Address->parse($v) or return 'NIL';
278         '(' . join('',
279                 map { '(' . join(' ',
280                                 _esc($_->name), 'NIL',
281                                 _esc($_->user), _esc($_->host)
282                         ) . ')'
283                 } @x) .
284         ')';
285 }
286
287 sub eml_envelope ($) {
288         my ($eml) = @_;
289         '(' . join(' ',
290                 _esc($eml->header_raw('Date')),
291                 _esc($eml->header_raw('Subject')),
292                 addr_envelope($eml, 'From'),
293                 addr_envelope($eml, 'Sender', 'From'),
294                 addr_envelope($eml, 'Reply-To', 'From'),
295                 addr_envelope($eml, 'To'),
296                 addr_envelope($eml, 'Cc'),
297                 addr_envelope($eml, 'Bcc'),
298                 _esc($eml->header_raw('In-Reply-To')),
299                 _esc($eml->header_raw('Message-ID')),
300         ) . ')';
301 }
302
303 sub _esc_hash ($) {
304         my ($hash) = @_;
305         if ($hash && scalar keys %$hash) {
306                 $hash = [ %$hash ]; # flatten hash into 1-dimensional array
307                 '(' . join(' ', map { _esc($_) } @$hash) . ')';
308         } else {
309                 'NIL';
310         }
311 }
312
313 sub body_disposition ($) {
314         my ($eml) = @_;
315         my $cd = $eml->header_raw('Content-Disposition') or return 'NIL';
316         $cd = parse_content_disposition($cd);
317         my $buf = '('._esc($cd->{type});
318         $buf .= ' ' . _esc_hash(delete $cd->{attributes});
319         $buf .= ')';
320 }
321
322 sub body_leaf ($$;$) {
323         my ($eml, $structure, $hold) = @_;
324         my $buf = '';
325         $eml->{is_submsg} and # parent was a message/(rfc822|news|global)
326                 $buf .= eml_envelope($eml). ' ';
327         my $ct = $eml->ct;
328         $buf .= '('._esc($ct->{type}).' ';
329         $buf .= _esc($ct->{subtype});
330         $buf .= ' ' . _esc_hash(delete $ct->{attributes});
331         $buf .= ' ' . _esc($eml->header_raw('Content-ID'));
332         $buf .= ' ' . _esc($eml->header_raw('Content-Description'));
333         my $cte = $eml->header_raw('Content-Transfer-Encoding') // '7bit';
334         $buf .= ' ' . _esc($cte);
335         $buf .= ' ' . $eml->{imap_body_len};
336         $buf .= ' '.($eml->body_raw =~ tr/\n/\n/) if lc($ct->{type}) eq 'text';
337
338         # for message/(rfc822|global|news), $hold[0] should have envelope
339         $buf .= ' ' . (@$hold ? join('', @$hold) : 'NIL') if $hold;
340
341         if ($structure) {
342                 $buf .= ' '._esc($eml->header_raw('Content-MD5'));
343                 $buf .= ' '. body_disposition($eml);
344                 $buf .= ' '._esc($eml->header_raw('Content-Language'));
345                 $buf .= ' '._esc($eml->header_raw('Content-Location'));
346         }
347         $buf .= ')';
348 }
349
350 sub body_parent ($$$) {
351         my ($eml, $structure, $hold) = @_;
352         my $ct = $eml->ct;
353         my $type = lc($ct->{type});
354         if ($type eq 'multipart') {
355                 my $buf = '(';
356                 $buf .= @$hold ? join('', @$hold) : 'NIL';
357                 $buf .= ' '._esc($ct->{subtype});
358                 if ($structure) {
359                         $buf .= ' '._esc_hash(delete $ct->{attributes});
360                         $buf .= ' '.body_disposition($eml);
361                         $buf .= ' '._esc($eml->header_raw('Content-Language'));
362                         $buf .= ' '._esc($eml->header_raw('Content-Location'));
363                 }
364                 $buf .= ')';
365                 @$hold = ($buf);
366         } else { # message/(rfc822|global|news)
367                 @$hold = (body_leaf($eml, $structure, $hold));
368         }
369 }
370
371 # this is gross, but we need to process the parent part AFTER
372 # the child parts are done
373 sub bodystructure_prep {
374         my ($p, $q) = @_;
375         my ($eml, $depth) = @$p; # ignore idx
376         # set length here, as $eml->{bdy} gets deleted for message/rfc822
377         $eml->{imap_body_len} = length($eml->body_raw);
378         push @$q, $eml, $depth;
379 }
380
381 # for FETCH BODY and FETCH BODYSTRUCTURE
382 sub fetch_body ($;$) {
383         my ($eml, $structure) = @_;
384         my @q;
385         $eml->each_part(\&bodystructure_prep, \@q, 0, 1);
386         my $cur_depth = 0;
387         my @hold;
388         do {
389                 my ($part, $depth) = splice(@q, -2);
390                 my $is_mp_parent = $depth == ($cur_depth - 1);
391                 $cur_depth = $depth;
392
393                 if ($is_mp_parent) {
394                         body_parent($part, $structure, \@hold);
395                 } else {
396                         unshift @hold, body_leaf($part, $structure);
397                 }
398         } while (@q);
399         join('', @hold);
400 }
401
402 sub requeue_once ($) {
403         my ($self) = @_;
404         # COMPRESS users all share the same DEFLATE context.
405         # Flush it here to ensure clients don't see
406         # each other's data
407         $self->zflush;
408
409         # no recursion, schedule another call ASAP,
410         # but only after all pending writes are done.
411         # autovivify wbuf:
412         my $new_size = push(@{$self->{wbuf}}, \&long_step);
413
414         # wbuf may be populated by $cb, no need to rearm if so:
415         $self->requeue if $new_size == 1;
416 }
417
418 sub uid_fetch_cb { # called by git->cat_async via git_async_cat
419         my ($bref, $oid, $type, $size, $fetch_m_arg) = @_;
420         my ($self, undef, $msgs, undef, $want) = @$fetch_m_arg;
421         my $smsg = shift @$msgs or die 'BUG: no smsg';
422         if (!defined($oid)) {
423                 # it's possible to have TOCTOU if an admin runs
424                 # public-inbox-(edit|purge), just move onto the next message
425                 return requeue_once($self);
426         } else {
427                 $smsg->{blob} eq $oid or die "BUG: $smsg->{blob} != $oid";
428         }
429
430         $$bref =~ s/(?<!\r)\n/\r\n/sg; # make strict clients happy
431
432         # fixup old bug from import (pre-a0c07cba0e5d8b6a)
433         $$bref =~ s/\A[\r\n]*From [^\r\n]*\r\n//s;
434
435         $self->msg_more("* $smsg->{num} FETCH (UID $smsg->{num}");
436
437         $want->{'RFC822.SIZE'} and
438                 $self->msg_more(' RFC822.SIZE '.length($$bref));
439         $want->{INTERNALDATE} and
440                 $self->msg_more(' INTERNALDATE "'.$smsg->internaldate.'"');
441         $want->{FLAGS} and $self->msg_more(' FLAGS ()');
442         for ('RFC822', 'BODY[]') {
443                 $want->{$_} or next;
444                 $self->msg_more(" $_ {".length($$bref)."}\r\n");
445                 $self->msg_more($$bref);
446         }
447
448         my $eml = PublicInbox::Eml->new($bref);
449
450         $want->{ENVELOPE} and
451                 $self->msg_more(' ENVELOPE '.eml_envelope($eml));
452
453         for ('RFC822.HEADER', 'BODY[HEADER]') {
454                 $want->{$_} or next;
455                 $self->msg_more(" $_ {".length(${$eml->{hdr}})."}\r\n");
456                 $self->msg_more(${$eml->{hdr}});
457         }
458         for ('RFC822.TEXT', 'BODY[TEXT]') {
459                 $want->{$_} or next;
460                 $self->msg_more(" $_ {".length($$bref)."}\r\n");
461                 $self->msg_more($$bref);
462         }
463         $want->{BODYSTRUCTURE} and
464                 $self->msg_more(' BODYSTRUCTURE '.fetch_body($eml, 1));
465         $want->{BODY} and
466                 $self->msg_more(' BODY '.fetch_body($eml));
467         if (my $partial = $want->{-partial}) {
468                 partial_emit($self, $partial, $eml);
469         }
470         $self->msg_more(")\r\n");
471         requeue_once($self);
472 }
473
474 sub uid_clamp ($$$) {
475         my ($self, $beg, $end) = @_;
476         my $uid_min = $self->{uid_min} or return;
477         my $uid_end = $uid_min + UID_BLOCK - 1;
478         $$beg = $uid_min if $$beg < $uid_min;
479         $$end = $uid_end if $$end > $uid_end;
480 }
481
482 sub range_step ($$) {
483         my ($self, $range_csv) = @_;
484         my ($beg, $end, $range);
485         if ($$range_csv =~ s/\A([^,]+),//) {
486                 $range = $1;
487         } else {
488                 $range = $$range_csv;
489                 $$range_csv = undef;
490         }
491         if ($range =~ /\A([0-9]+):([0-9]+)\z/) {
492                 ($beg, $end) = ($1 + 0, $2 + 0);
493         } elsif ($range =~ /\A([0-9]+):\*\z/) {
494                 $beg = $1 + 0;
495                 $end = $self->{ibx}->mm->max // 0;
496                 my $uid_end = ($self->{uid_min} // 1) - 1 + UID_BLOCK;
497                 $end = $uid_end if $end > $uid_end;
498                 $beg = $end if $beg > $end;
499         } elsif ($range =~ /\A[0-9]+\z/) {
500                 $beg = $end = $range + 0;
501                 undef $range;
502         } else {
503                 return 'BAD fetch range';
504         }
505         uid_clamp($self, \$beg, \$end) if defined($range);
506         [ $beg, $end, $$range_csv ];
507 }
508
509 sub refill_range ($$$) {
510         my ($self, $msgs, $range_info) = @_;
511         my ($beg, $end, $range_csv) = @$range_info;
512         if (scalar(@$msgs = @{$self->{ibx}->over->query_xover($beg, $end)})) {
513                 $range_info->[0] = $msgs->[-1]->{num} + 1;
514                 return;
515         }
516         return 'OK Fetch done' if !$range_csv;
517         my $next_range = range_step($self, \$range_csv);
518         return $next_range if !ref($next_range); # error
519         @$range_info = @$next_range;
520         undef; # keep looping
521 }
522
523 sub uid_fetch_m { # long_response
524         my ($self, $tag, $msgs, $range_info, $want) = @_;
525         while (!@$msgs) { # rare
526                 if (my $end = refill_range($self, $msgs, $range_info)) {
527                         $self->write(\"$tag $end\r\n");
528                         return;
529                 }
530         }
531         git_async_cat($self->{ibx}->git, $msgs->[0]->{blob},
532                         \&uid_fetch_cb, \@_);
533 }
534
535 sub cmd_status ($$$;@) {
536         my ($self, $tag, $mailbox, @items) = @_;
537         return "$tag BAD no items\r\n" if !scalar(@items);
538         ($items[0] !~ s/\A\(//s || $items[-1] !~ s/\)\z//s) and
539                 return "$tag BAD invalid args\r\n";
540         my ($ibx, $exists, $uidnext) = inbox_lookup($self, $mailbox);
541         return "$tag NO Mailbox doesn't exist: $mailbox\r\n" if !$ibx;
542         my @it;
543         for my $it (@items) {
544                 $it = uc($it);
545                 push @it, $it;
546                 if ($it =~ /\A(?:MESSAGES|UNSEEN|RECENT)\z/) {
547                         push @it, $exists;
548                 } elsif ($it eq 'UIDNEXT') {
549                         push @it, $uidnext;
550                 } elsif ($it eq 'UIDVALIDITY') {
551                         push @it, $ibx->{uidvalidity};
552                 } else {
553                         return "$tag BAD invalid item\r\n";
554                 }
555         }
556         return "$tag BAD no items\r\n" if !@it;
557         "* STATUS $mailbox (".join(' ', @it).")\r\n" .
558         "$tag OK Status done\r\n";
559 }
560
561 my %patmap = ('*' => '.*', '%' => '[^\.]*');
562 sub cmd_list ($$$$) {
563         my ($self, $tag, $refname, $wildcard) = @_;
564         my $l = $self->{imapd}->{inboxlist};
565         if ($refname eq '' && $wildcard eq '') {
566                 # request for hierarchy delimiter
567                 $l = [ qq[* LIST (\\Noselect) "." ""\r\n] ];
568         } elsif ($refname ne '' || $wildcard ne '*') {
569                 $wildcard = lc $wildcard;
570                 $wildcard =~ s!([^a-z0-9_])!$patmap{$1} // "\Q$1"!eg;
571                 $l = [ grep(/ \Q$refname\E$wildcard\r\n\z/s, @$l) ];
572         }
573         \(join('', @$l, "$tag OK List done\r\n"));
574 }
575
576 sub cmd_lsub ($$$$) {
577         my (undef, $tag) = @_; # same args as cmd_list
578         "$tag OK Lsub done\r\n";
579 }
580
581 sub eml_index_offs_i { # PublicInbox::Eml::each_part callback
582         my ($p, $all) = @_;
583         my ($eml, undef, $idx) = @$p;
584         if ($idx && lc($eml->ct->{type}) eq 'multipart') {
585                 $eml->{imap_bdy} = $eml->{bdy} // \'';
586         }
587         $all->{$idx} = $eml; # $idx => Eml
588 }
589
590 # prepares an index for BODY[$SECTION_IDX] fetches
591 sub eml_body_idx ($$) {
592         my ($eml, $section_idx) = @_;
593         my $idx = $eml->{imap_all_parts} //= do {
594                 my $all = {};
595                 $eml->each_part(\&eml_index_offs_i, $all, 0, 1);
596                 # top-level of multipart, BODY[0] not allowed (nz-number)
597                 delete $all->{0};
598                 $all;
599         };
600         $idx->{$section_idx};
601 }
602
603 # BODY[($SECTION_IDX)?(.$SECTION_NAME)?]<$offset.$bytes>
604 sub partial_body {
605         my ($eml, $section_idx, $section_name) = @_;
606         if (defined $section_idx) {
607                 $eml = eml_body_idx($eml, $section_idx) or return;
608         }
609         if (defined $section_name) {
610                 if ($section_name eq 'MIME') {
611                         # RFC 3501 6.4.5 states:
612                         #       The MIME part specifier MUST be prefixed
613                         #       by one or more numeric part specifiers
614                         return unless defined $section_idx;
615                         return $eml->header_obj->as_string . "\r\n";
616                 }
617                 my $bdy = $eml->{bdy} // $eml->{imap_bdy} // \'';
618                 $eml = PublicInbox::Eml->new($$bdy);
619                 if ($section_name eq 'TEXT') {
620                         return $eml->body_raw;
621                 } elsif ($section_name eq 'HEADER') {
622                         return $eml->header_obj->as_string . "\r\n";
623                 } else {
624                         die "BUG: bad section_name=$section_name";
625                 }
626         }
627         ${$eml->{bdy} // $eml->{imap_bdy} // \''};
628 }
629
630 # similar to what's in PublicInbox::Eml::re_memo, but doesn't memoize
631 # to avoid OOM with malicious users
632 sub hdrs_regexp ($) {
633         my ($hdrs) = @_;
634         my $names = join('|', map { "\Q$_" } split(/[ \t]+/, $hdrs));
635         qr/^(?:$names):[ \t]*[^\n]*\r?\n # 1st line
636                 # continuation lines:
637                 (?:[^:\n]*?[ \t]+[^\n]*\r?\n)*
638                 /ismx;
639 }
640
641 # BODY[($SECTION_IDX.)?HEADER.FIELDS.NOT ($HDRS)]<$offset.$bytes>
642 sub partial_hdr_not {
643         my ($eml, $section_idx, $hdrs_re) = @_;
644         if (defined $section_idx) {
645                 $eml = eml_body_idx($eml, $section_idx) or return;
646         }
647         my $str = $eml->header_obj->as_string;
648         $str =~ s/$hdrs_re//g;
649         $str .= "\r\n";
650 }
651
652 # BODY[($SECTION_IDX.)?HEADER.FIELDS ($HDRS)]<$offset.$bytes>
653 sub partial_hdr_get {
654         my ($eml, $section_idx, $hdrs_re) = @_;
655         if (defined $section_idx) {
656                 $eml = eml_body_idx($eml, $section_idx) or return;
657         }
658         my $str = $eml->header_obj->as_string;
659         join('', ($str =~ m/($hdrs_re)/g), "\r\n");
660 }
661
662 sub partial_prepare ($$$) {
663         my ($partial, $want, $att) = @_;
664
665         # recombine [ "BODY[1.HEADER.FIELDS", "(foo", "bar)]" ]
666         # back to: "BODY[1.HEADER.FIELDS (foo bar)]"
667         return unless $att =~ /\ABODY\[/s;
668         until (rindex($att, ']') >= 0) {
669                 my $next = shift @$want or return;
670                 $att .= ' ' . uc($next);
671         }
672         if ($att =~ /\ABODY\[([0-9]+(?:\.[0-9]+)*)? # 1 - section_idx
673                         (?:\.(HEADER|MIME|TEXT))? # 2 - section_name
674                         \](?:<([0-9]+)(?:\.([0-9]+))?>)?\z/sx) { # 3, 4
675                 $partial->{$att} = [ \&partial_body, $1, $2, $3, $4 ];
676         } elsif ($att =~ /\ABODY\[(?:([0-9]+(?:\.[0-9]+)*)\.)? # 1 - section_idx
677                                 (?:HEADER\.FIELDS(\.NOT)?)\x20 # 2
678                                 \(([A-Z0-9\-\x20]+)\) # 3 - hdrs
679                         \](?:<([0-9]+)(?:\.([0-9]+))?>)?\z/sx) { # 4 5
680                 my $tmp = $partial->{$att} = [ $2 ? \&partial_hdr_not
681                                                 : \&partial_hdr_get,
682                                                 $1, undef, $4, $5 ];
683                 $tmp->[2] = hdrs_regexp($3);
684         } else {
685                 undef;
686         }
687 }
688
689 sub partial_emit ($$$) {
690         my ($self, $partial, $eml) = @_;
691         for (@$partial) {
692                 my ($k, $cb, @args) = @$_;
693                 my ($offset, $len) = splice(@args, -2);
694                 # $cb is partial_body|partial_hdr_get|partial_hdr_not
695                 my $str = $cb->($eml, @args) // '';
696                 if (defined $offset) {
697                         if (defined $len) {
698                                 $str = substr($str, $offset, $len);
699                                 $k =~ s/\.$len>\z/>/ or warn
700 "BUG: unable to remove `.$len>' from `$k'";
701                         } else {
702                                 $str = substr($str, $offset);
703                                 $len = length($str);
704                         }
705                 } else {
706                         $len = length($str);
707                 }
708                 $self->msg_more(" $k {$len}\r\n");
709                 $self->msg_more($str);
710         }
711 }
712
713 sub fetch_common ($$$$) {
714         my ($self, $tag, $range_csv, $want) = @_;
715         my $ibx = $self->{ibx} or return "$tag BAD No mailbox selected\r\n";
716         if ($want->[0] =~ s/\A\(//s) {
717                 $want->[-1] =~ s/\)\z//s or return "$tag BAD no rparen\r\n";
718         }
719         my (%partial, %want);
720         while (defined(my $att = shift @$want)) {
721                 $att = uc($att);
722                 $att =~ s/\ABODY\.PEEK\[/BODY\[/; # we're read-only
723                 my $x = $FETCH_ATT{$att};
724                 if ($x) {
725                         %want = (%want, %$x);
726                 } elsif (!partial_prepare(\%partial, $want, $att)) {
727                         return "$tag BAD param: $att\r\n";
728                 }
729         }
730
731         # stabilize partial order for consistency and ease-of-debugging:
732         if (scalar keys %partial) {
733                 $want{-partial} = [ map {;
734                         [ $_, @{$partial{$_}} ]
735                 } sort keys %partial ];
736         }
737         $range_csv = 'bad' if $range_csv !~ $valid_range;
738         my $range_info = range_step($self, \$range_csv);
739         return "$tag $range_info\r\n" if !ref($range_info);
740         [ $tag, [], $range_info, \%want ];
741 }
742
743 sub cmd_uid_fetch ($$$;@) {
744         my ($self, $tag, $range_csv, @want) = @_;
745         my $args = fetch_common($self, $tag, $range_csv, \@want);
746         ref($args) eq 'ARRAY' ?
747                 long_response($self, \&uid_fetch_m, @$args) :
748                 $args; # error
749 }
750
751 sub parse_date ($) { # 02-Oct-1993
752         my ($date_text) = @_;
753         my ($dd, $mon, $yyyy) = split(/-/, $_[0], 3);
754         defined($yyyy) or return;
755         my $mm = $MoY{$mon} // return;
756         $dd =~ /\A[0123]?[0-9]\z/ or return;
757         $yyyy =~ /\A[0-9]{4,}\z/ or return; # Y10K-compatible!
758         timegm(0, 0, 0, $dd, $mm, $yyyy);
759 }
760
761 sub uid_search_uid_range { # long_response
762         my ($self, $tag, $beg, $end, $sql) = @_;
763         my $uids = $self->{ibx}->over->uid_range($$beg, $end, $sql);
764         if (@$uids) {
765                 $$beg = $uids->[-1] + 1;
766                 $self->msg_more(join(' ', '', @$uids));
767         } else {
768                 $self->write(\"\r\n$tag OK Search done\r\n");
769                 undef;
770         }
771 }
772
773 sub date_search {
774         my ($q, $k, $d) = @_;
775         my $sql = $q->{sql};
776
777         # Date: header
778         if ($k eq 'SENTON') {
779                 my $end = $d + 86399; # no leap day...
780                 my $da = strftime('%Y%m%d%H%M%S', gmtime($d));
781                 my $db = strftime('%Y%m%d%H%M%S', gmtime($end));
782                 $q->{xap} .= " dt:$da..$db";
783                 $$sql .= " AND ds >= $d AND ds <= $end" if defined($sql);
784         } elsif ($k eq 'SENTBEFORE') {
785                 $q->{xap} .= ' d:..'.strftime('%Y%m%d', gmtime($d));
786                 $$sql .= " AND ds <= $d" if defined($sql);
787         } elsif ($k eq 'SENTSINCE') {
788                 $q->{xap} .= ' d:'.strftime('%Y%m%d', gmtime($d)).'..';
789                 $$sql .= " AND ds >= $d" if defined($sql);
790
791         # INTERNALDATE (Received)
792         } elsif ($k eq 'ON') {
793                 my $end = $d + 86399; # no leap day...
794                 $q->{xap} .= " ts:$d..$end";
795                 $$sql .= " AND ts >= $d AND ts <= $end" if defined($sql);
796         } elsif ($k eq 'BEFORE') {
797                 $q->{xap} .= " ts:..$d";
798                 $$sql .= " AND ts <= $d" if defined($sql);
799         } elsif ($k eq 'SINCE') {
800                 $q->{xap} .= " ts:$d..";
801                 $$sql .= " AND ts >= $d" if defined($sql);
802         } else {
803                 die "BUG: $k not recognized";
804         }
805 }
806
807 # IMAP to Xapian search key mapping
808 my %I2X = (
809         SUBJECT => 's:',
810         BODY => 'b:',
811         FROM => 'f:',
812         TEXT => '', # n.b. does not include all headers
813         TO => 't:',
814         CC => 'c:',
815         # BCC => 'bcc:', # TODO
816         # KEYWORD # TODO ? dfpre,dfpost,...
817 );
818
819 sub parse_query {
820         my ($self, $rest) = @_;
821         if (uc($rest->[0]) eq 'CHARSET') {
822                 shift @$rest;
823                 defined(my $c = shift @$rest) or return 'BAD missing charset';
824                 $c =~ /\A(?:UTF-8|US-ASCII)\z/ or return 'NO [BADCHARSET]';
825         }
826
827         my $sql = ''; # date conditions, {sql} deleted if Xapian is needed
828         my $q = { xap => '', sql => \$sql };
829         while (@$rest) {
830                 my $k = uc(shift @$rest);
831                 # default criteria
832                 next if $k =~ /\A(?:ALL|RECENT|UNSEEN|NEW)\z/;
833                 next if $k eq 'AND'; # the default, until we support OR
834                 if ($k =~ $valid_range) { # sequence numbers == UIDs
835                         push @{$q->{uid}}, $k;
836                 } elsif ($k eq 'UID') {
837                         $k = shift(@$rest) // '';
838                         $k =~ $valid_range or return 'BAD UID range';
839                         push @{$q->{uid}}, $k;
840                 } elsif ($k =~ /\A(?:SENT)?(?:SINCE|ON|BEFORE)\z/) {
841                         my $d = parse_date(shift(@$rest) // '');
842                         defined $d or return "BAD $k date format";
843                         date_search($q, $k, $d);
844                 } elsif ($k =~ /\A(?:SMALLER|LARGER)\z/) {
845                         delete $q->{sql}; # can't use over.sqlite3
846                         my $bytes = shift(@$rest) // '';
847                         $bytes =~ /\A[0-9]+\z/ or return "BAD $k not a number";
848                         $q->{xap} .= ' bytes:' . ($k eq 'SMALLER' ?
849                                                         '..'.(--$bytes) :
850                                                         (++$bytes).'..');
851                 } elsif (defined(my $xk = $I2X{$k})) {
852                         delete $q->{sql}; # can't use over.sqlite3
853                         my $arg = shift @$rest;
854                         defined($arg) or return "BAD $k no arg";
855
856                         # Xapian can't handle [*"] in probabilistic terms
857                         $arg =~ tr/*"//d;
858                         $q->{xap} .= qq[ $xk:"$arg"];
859                 } else {
860                         # TODO: parentheses, OR, NOT ...
861                         return "BAD $k not supported (yet?)";
862                 }
863         }
864
865         # favor using over.sqlite3 if possible, since Xapian is optional
866         if (exists $q->{sql}) {
867                 delete($q->{xap});
868                 delete($q->{sql}) if $sql eq '';
869         } elsif (!$self->{ibx}->search) {
870                 return 'BAD Xapian not configured for mailbox';
871         }
872
873         if (my $uid = $q->{uid}) {
874                 ((@$uid > 1) || $uid->[0] =~ /,/) and
875                         return 'BAD multiple ranges not supported, yet';
876                 ($q->{sql} // $q->{xap}) and
877                         return 'BAD ranges and queries do not mix, yet';
878                 $q->{uid} = join(',', @$uid); # TODO: multiple ranges
879         }
880         $q;
881 }
882
883 sub cmd_uid_search ($$$;) {
884         my ($self, $tag) = splice(@_, 0, 2);
885         my $ibx = $self->{ibx} or return "$tag BAD No mailbox selected\r\n";
886         my $q = parse_query($self, \@_);
887         return "$tag $q\r\n" if !ref($q);
888         my $sql = delete $q->{sql};
889
890         if (!scalar(keys %$q)) {
891                 $self->msg_more('* SEARCH');
892                 my $beg = $self->{uid_min} // 1;
893                 my $end = $ibx->mm->max;
894                 uid_clamp($self, \$beg, \$end);
895                 long_response($self, \&uid_search_uid_range,
896                                 $tag, \$beg, $end, $sql);
897         } elsif (my $uid = $q->{uid}) {
898                 if ($uid =~ /\A([0-9]+):([0-9]+|\*)\z/s) {
899                         my ($beg, $end) = ($1, $2);
900                         $end = $ibx->mm->max if $end eq '*';
901                         uid_clamp($self, \$beg, \$end);
902                         $self->msg_more('* SEARCH');
903                         long_response($self, \&uid_search_uid_range,
904                                         $tag, \$beg, $end, $sql);
905                 } elsif ($uid =~ /\A[0-9]+\z/s) {
906                         $uid = $ibx->over->get_art($uid) ? " $uid" : '';
907                         "* SEARCH$uid\r\n$tag OK Search done\r\n";
908                 } else {
909                         "$tag BAD Error\r\n";
910                 }
911         } else {
912                 "$tag BAD Error\r\n";
913         }
914 }
915
916 sub args_ok ($$) { # duplicated from PublicInbox::NNTP
917         my ($cb, $argc) = @_;
918         my $tot = prototype $cb;
919         my ($nreq, undef) = split(';', $tot);
920         $nreq = ($nreq =~ tr/$//) - 1;
921         $tot = ($tot =~ tr/$//) - 1;
922         ($argc <= $tot && $argc >= $nreq);
923 }
924
925 # returns 1 if we can continue, 0 if not due to buffered writes or disconnect
926 sub process_line ($$) {
927         my ($self, $l) = @_;
928         my ($tag, $req, @args) = parse_line('[ \t]+', 0, $l);
929         pop(@args) if (@args && !defined($args[-1]));
930         if (@args && uc($req) eq 'UID') {
931                 $req .= "_".(shift @args);
932         }
933         my $res = eval {
934                 if (my $cmd = $self->can('cmd_'.lc($req // ''))) {
935                         defined($self->{-idle_tag}) ?
936                                 "$self->{-idle_tag} BAD expected DONE\r\n" :
937                                 $cmd->($self, $tag, @args);
938                 } elsif (uc($tag // '') eq 'DONE' && !defined($req)) {
939                         cmd_done($self, $tag);
940                 } else { # this is weird
941                         auth_challenge_ok($self) //
942                                         ($tag // '*') .
943                                         ' BAD Error in IMAP command '.
944                                         ($req // '(???)').
945                                         ": Unknown command\r\n";
946                 }
947         };
948         my $err = $@;
949         if ($err && $self->{sock}) {
950                 $l =~ s/\r?\n//s;
951                 err($self, 'error from: %s (%s)', $l, $err);
952                 $tag //= '*';
953                 $res = "$tag BAD program fault - command not performed\r\n";
954         }
955         return 0 unless defined $res;
956         $self->write($res);
957 }
958
959 sub long_step {
960         my ($self) = @_;
961         # wbuf is unset or empty, here; {long} may add to it
962         my ($fd, $cb, $t0, @args) = @{$self->{long_cb}};
963         my $more = eval { $cb->($self, @args) };
964         if ($@ || !$self->{sock}) { # something bad happened...
965                 delete $self->{long_cb};
966                 my $elapsed = now() - $t0;
967                 if ($@) {
968                         err($self,
969                             "%s during long response[$fd] - %0.6f",
970                             $@, $elapsed);
971                 }
972                 out($self, " deferred[$fd] aborted - %0.6f", $elapsed);
973                 $self->close;
974         } elsif ($more) { # $self->{wbuf}:
975                 $self->update_idle_time;
976
977                 # control passed to $more may be a GitAsyncCat object
978                 requeue_once($self) if !ref($more);
979         } else { # all done!
980                 delete $self->{long_cb};
981                 my $elapsed = now() - $t0;
982                 my $fd = fileno($self->{sock});
983                 out($self, " deferred[$fd] done - %0.6f", $elapsed);
984                 my $wbuf = $self->{wbuf}; # do NOT autovivify
985
986                 $self->requeue unless $wbuf && @$wbuf;
987         }
988 }
989
990 sub err ($$;@) {
991         my ($self, $fmt, @args) = @_;
992         printf { $self->{imapd}->{err} } $fmt."\n", @args;
993 }
994
995 sub out ($$;@) {
996         my ($self, $fmt, @args) = @_;
997         printf { $self->{imapd}->{out} } $fmt."\n", @args;
998 }
999
1000 sub long_response ($$;@) {
1001         my ($self, $cb, @args) = @_; # cb returns true if more, false if done
1002
1003         my $sock = $self->{sock} or return;
1004         # make sure we disable reading during a long response,
1005         # clients should not be sending us stuff and making us do more
1006         # work while we are stream a response to them
1007         $self->{long_cb} = [ fileno($sock), $cb, now(), @args ];
1008         long_step($self); # kick off!
1009         undef;
1010 }
1011
1012 # callback used by PublicInbox::DS for any (e)poll (in/out/hup/err)
1013 sub event_step {
1014         my ($self) = @_;
1015
1016         return unless $self->flush_write && $self->{sock} && !$self->{long_cb};
1017
1018         $self->update_idle_time;
1019         # only read more requests if we've drained the write buffer,
1020         # otherwise we can be buffering infinitely w/o backpressure
1021
1022         my $rbuf = $self->{rbuf} // \(my $x = '');
1023         my $line = index($$rbuf, "\n");
1024         while ($line < 0) {
1025                 return $self->close if length($$rbuf) >= LINE_MAX;
1026                 $self->do_read($rbuf, LINE_MAX, length($$rbuf)) or return;
1027                 $line = index($$rbuf, "\n");
1028         }
1029         $line = substr($$rbuf, 0, $line + 1, '');
1030         $line =~ s/\r?\n\z//s;
1031         return $self->close if $line =~ /[[:cntrl:]]/s;
1032         my $t0 = now();
1033         my $fd = fileno($self->{sock});
1034         my $r = eval { process_line($self, $line) };
1035         my $pending = $self->{wbuf} ? ' pending' : '';
1036         out($self, "[$fd] %s - %0.6f$pending - $r", $line, now() - $t0);
1037
1038         return $self->close if $r < 0;
1039         $self->rbuf_idle($rbuf);
1040         $self->update_idle_time;
1041
1042         # maybe there's more pipelined data, or we'll have
1043         # to register it for socket-readiness notifications
1044         $self->requeue unless $pending;
1045 }
1046
1047 sub compressed { undef }
1048
1049 sub zflush {} # overridden by IMAPdeflate
1050
1051 # RFC 4978
1052 sub cmd_compress ($$$) {
1053         my ($self, $tag, $alg) = @_;
1054         return "$tag BAD DEFLATE only\r\n" if uc($alg) ne "DEFLATE";
1055         return "$tag BAD COMPRESS active\r\n" if $self->compressed;
1056
1057         # CRIME made TLS compression obsolete
1058         # return "$tag NO [COMPRESSIONACTIVE]\r\n" if $self->tls_compressed;
1059
1060         PublicInbox::IMAPdeflate->enable($self, $tag);
1061         $self->requeue;
1062         undef
1063 }
1064
1065 sub cmd_starttls ($$) {
1066         my ($self, $tag) = @_;
1067         my $sock = $self->{sock} or return;
1068         if ($sock->can('stop_SSL') || $self->compressed) {
1069                 return "$tag BAD TLS or compression already enabled\r\n";
1070         }
1071         my $opt = $self->{imapd}->{accept_tls} or
1072                 return "$tag BAD can not initiate TLS negotiation\r\n";
1073         $self->write(\"$tag OK begin TLS negotiation now\r\n");
1074         $self->{sock} = IO::Socket::SSL->start_SSL($sock, %$opt);
1075         $self->requeue if PublicInbox::DS::accept_tls_step($self);
1076         undef;
1077 }
1078
1079 # for graceful shutdown in PublicInbox::Daemon:
1080 sub busy {
1081         my ($self, $now) = @_;
1082         ($self->{rbuf} || $self->{wbuf} || $self->not_idle_long($now));
1083 }
1084
1085 sub close {
1086         my ($self) = @_;
1087         if (my $ibx = delete $self->{ibx}) {
1088                 if (my $sock = $self->{sock}) {;
1089                         $ibx->unsubscribe_unlock(fileno($sock));
1090                 }
1091         }
1092         $self->SUPER::close; # PublicInbox::DS::close
1093 }
1094
1095 # we're read-only, so SELECT and EXAMINE do the same thing
1096 no warnings 'once';
1097 *cmd_select = \&cmd_examine;
1098 *cmd_fetch = \&cmd_uid_fetch;
1099
1100 1;