]> Sergey Matveev's repositories - public-inbox.git/blob - lib/PublicInbox/LeiMailSync.pm
lei_mail_sync: ensure URLs and folder names are stored as binary
[public-inbox.git] / lib / PublicInbox / LeiMailSync.pm
1 # Copyright (C) all contributors <meta@public-inbox.org>
2 # License: AGPL-3.0+ <https://www.gnu.org/licenses/agpl-3.0.txt>
3
4 # for maintaining synchronization between lei/store <=> Maildir|MH|IMAP|JMAP
5 package PublicInbox::LeiMailSync;
6 use strict;
7 use v5.10.1;
8 use parent qw(PublicInbox::Lock);
9 use DBI qw(:sql_types); # SQL_BLOB
10 use PublicInbox::ContentHash qw(git_sha);
11 use Carp ();
12
13 sub dbh_new {
14         my ($self, $rw) = @_;
15         my $f = $self->{filename};
16         my $creat = $rw && !-s $f;
17         if ($creat) {
18                 require PublicInbox::Syscall;
19                 open my $fh, '+>>', $f or Carp::croak "open($f): $!";
20                 PublicInbox::Syscall::nodatacow_fh($fh);
21         }
22         my $dbh = DBI->connect("dbi:SQLite:dbname=$f",'','', {
23                 AutoCommit => 1,
24                 RaiseError => 1,
25                 PrintError => 0,
26                 ReadOnly => !$rw,
27                 sqlite_use_immediate_transaction => 1,
28         });
29         # no sqlite_unicode, here, all strings are binary
30         create_tables($self, $dbh) if $rw;
31         $dbh->do('PRAGMA journal_mode = WAL') if $creat;
32         $dbh->do('PRAGMA case_sensitive_like = ON');
33         $dbh;
34 }
35
36 sub new {
37         my ($cls, $f) = @_;
38         bless {
39                 filename => $f,
40                 fmap => {},
41                 lock_path => "$f.flock",
42         }, $cls;
43 }
44
45 sub lms_write_prepare { ($_[0]->{dbh} //= dbh_new($_[0], 1)); $_[0] }
46
47 sub lms_pause {
48         my ($self) = @_;
49         $self->{fmap} = {};
50         my $dbh = delete $self->{dbh};
51         eval { $dbh->do('PRAGMA optimize') } if $dbh;
52 }
53
54 sub create_tables {
55         my ($self, $dbh) = @_;
56         my $lk = $self->lock_for_scope;
57
58         $dbh->do(<<'');
59 CREATE TABLE IF NOT EXISTS folders (
60         fid INTEGER PRIMARY KEY,
61         loc VARBINARY NOT NULL, /* URL;UIDVALIDITY=$N or $TYPE:/pathname */
62         UNIQUE (loc)
63 )
64
65         $dbh->do(<<'');
66 CREATE TABLE IF NOT EXISTS blob2num (
67         oidbin VARBINARY NOT NULL,
68         fid INTEGER NOT NULL, /* folder ID */
69         uid INTEGER NOT NULL, /* NNTP article number, IMAP UID, MH number */
70         /* not UNIQUE(fid, uid), since we may have broken servers */
71         UNIQUE (oidbin, fid, uid)
72 )
73
74         # speeds up LeiImport->ck_update_kw (for "lei import") by 5-6x:
75         $dbh->do(<<'');
76 CREATE INDEX IF NOT EXISTS idx_fid_uid ON blob2num(fid,uid)
77
78         $dbh->do(<<'');
79 CREATE TABLE IF NOT EXISTS blob2name (
80         oidbin VARBINARY NOT NULL,
81         fid INTEGER NOT NULL, /* folder ID */
82         name VARBINARY NOT NULL, /* Maildir basename, JMAP blobId */
83         /* not UNIQUE(fid, name), since we may have broken software */
84         UNIQUE (oidbin, fid, name)
85 )
86
87         # speeds up LeiImport->pmdir_cb (for "lei import") by ~6x:
88         $dbh->do(<<'');
89 CREATE INDEX IF NOT EXISTS idx_fid_name ON blob2name(fid,name)
90
91 }
92
93 # used to fixup pre-1.7.0 folders
94 sub update_fid ($$$) {
95         my ($dbh, $fid, $loc) = @_;
96         my $sth = $dbh->prepare(<<'');
97 UPDATE folders SET loc = ? WHERE fid = ?
98
99         $sth->bind_param(1, $loc, SQL_BLOB);
100         $sth->bind_param(2, $fid);
101         $sth->execute;
102 }
103
104 sub get_fid ($$$) {
105         my ($sth, $folder, $dbh) = @_; # $dbh is set iff RW
106         $sth->bind_param(1, $folder, SQL_BLOB);
107         $sth->execute;
108         my ($fid) = $sth->fetchrow_array;
109         if (defined $fid) { # for downgrade+upgrade (1.8 -> 1.7 -> 1.8)
110                 $dbh->do('DELETE FROM folders WHERE loc = ? AND fid != ?',
111                         undef, $folder, $fid) if defined($dbh);
112         } else {
113                 $sth->execute($folder); # fixup old stuff
114                 ($fid) = $sth->fetchrow_array;
115                 update_fid($dbh, $fid, $folder) if defined($fid) && $dbh;
116         }
117         $fid;
118 }
119
120 sub fid_for {
121         my ($self, $folder, $rw) = @_;
122         my $dbh = $self->{dbh} //= dbh_new($self, $rw);
123         my $sth = $dbh->prepare_cached(<<'', undef, 1);
124 SELECT fid FROM folders WHERE loc = ? LIMIT 1
125
126         my $rw_dbh = $rw ? $dbh : undef;
127         my $fid = get_fid($sth, $folder, $rw_dbh);
128         return $fid if defined($fid);
129
130         # caller had trailing slash (LeiToMail)
131         if ($folder =~ s!\A((?:maildir|mh):.*?)/+\z!$1!i) {
132                 $fid = get_fid($sth, $folder, $rw_dbh);
133                 if (defined $fid) {
134                         update_fid($dbh, $fid, $folder) if $rw;
135                         return $fid;
136                 }
137         # sometimes we stored trailing slash..
138         } elsif ($folder =~ m!\A(?:maildir|mh):!i) {
139                 $fid = get_fid($sth, $folder, $rw_dbh);
140                 if (defined $fid) {
141                         update_fid($dbh, $fid, $folder) if $rw;
142                         return $fid;
143                 }
144         } elsif ($rw && $folder =~ m!\Aimaps?://!i) {
145                 require PublicInbox::URIimap;
146                 PublicInbox::URIimap->new($folder)->uidvalidity //
147                         Carp::croak("BUG: $folder has no UIDVALIDITY");
148         }
149         return unless $rw;
150
151         ($fid) = $dbh->selectrow_array('SELECT MAX(fid) FROM folders');
152
153         $fid += 1;
154         # in case we're reusing, clobber existing stale refs:
155         $dbh->do('DELETE FROM blob2name WHERE fid = ?', undef, $fid);
156         $dbh->do('DELETE FROM blob2num WHERE fid = ?', undef, $fid);
157
158         $sth = $dbh->prepare('INSERT INTO folders (fid, loc) VALUES (?, ?)');
159         $sth->bind_param(1, $fid);
160         $sth->bind_param(2, $folder, SQL_BLOB);
161         $sth->execute;
162
163         $fid;
164 }
165
166 sub add_folders {
167         my ($self, @folders) = @_;
168         my $lk = $self->lock_for_scope;
169         for my $f (@folders) { $self->{fmap}->{$f} //= fid_for($self, $f, 1) }
170 }
171
172 sub set_src {
173         my ($self, $oidbin, $folder, $id) = @_;
174         my $lk = $self->lock_for_scope;
175         my $fid = $self->{fmap}->{$folder} //= fid_for($self, $folder, 1);
176         my $sth;
177         if (ref($id)) { # scalar name
178                 $id = $$id;
179                 $sth = $self->{dbh}->prepare_cached(<<'');
180 INSERT OR IGNORE INTO blob2name (oidbin, fid, name) VALUES (?, ?, ?)
181
182         } else { # numeric ID (IMAP UID, MH number)
183                 $sth = $self->{dbh}->prepare_cached(<<'');
184 INSERT OR IGNORE INTO blob2num (oidbin, fid, uid) VALUES (?, ?, ?)
185
186         }
187         $sth->execute($oidbin, $fid, $id);
188 }
189
190 sub clear_src {
191         my ($self, $folder, $id) = @_;
192         my $lk = $self->lock_for_scope;
193         my $fid = $self->{fmap}->{$folder} //= fid_for($self, $folder, 1);
194         my $sth;
195         if (ref($id)) { # scalar name
196                 $id = $$id;
197                 $sth = $self->{dbh}->prepare_cached(<<'');
198 DELETE FROM blob2name WHERE fid = ? AND name = ?
199
200         } else {
201                 $sth = $self->{dbh}->prepare_cached(<<'');
202 DELETE FROM blob2num WHERE fid = ? AND uid = ?
203
204         }
205         $sth->execute($fid, $id);
206 }
207
208 # Maildir-only
209 sub mv_src {
210         my ($self, $folder, $oidbin, $id, $newbn) = @_;
211         my $lk = $self->lock_for_scope;
212         my $fid = $self->{fmap}->{$folder} //= fid_for($self, $folder, 1);
213         $self->{dbh}->begin_work;
214         my $sth = $self->{dbh}->prepare_cached(<<'');
215 UPDATE blob2name SET name = ? WHERE fid = ? AND oidbin = ? AND name = ?
216
217         # eval since unique constraint may fail due to race
218         my $nr = eval { $sth->execute($newbn, $fid, $oidbin, $$id) };
219         if (!defined($nr) || $nr == 0) { # $nr may be `0E0'
220                 # may race with a clear_src, ensure new value exists
221                 $sth = $self->{dbh}->prepare_cached(<<'');
222 INSERT OR IGNORE INTO blob2name (oidbin, fid, name) VALUES (?, ?, ?)
223
224                 $sth->execute($oidbin, $fid, $newbn);
225         }
226         $self->{dbh}->commit;
227 }
228
229 # read-only, iterates every oidbin + UID or name for a given folder
230 sub each_src {
231         my ($self, $folder, $cb, @args) = @_;
232         my $dbh = $self->{dbh} //= dbh_new($self);
233         my ($fid, @rng);
234         my $and_ge_le = '';
235         if (ref($folder) eq 'HASH') {
236                 $fid = $folder->{fid} // die "BUG: no `fid'";
237                 @rng = grep(defined, @$folder{qw(min max)});
238                 $and_ge_le = 'AND uid >= ? AND uid <= ?' if @rng;
239         } else {
240                 $fid = $self->{fmap}->{$folder} //=
241                         fid_for($self, $folder) // return;
242         }
243
244         # minimize implicit txn time to avoid blocking writers by
245         # batching SELECTs.  This looks wonky but is necessary since
246         # $cb-> may access the DB on its own.
247         my $ary = $dbh->selectall_arrayref(<<"", undef, $fid, @rng);
248 SELECT _rowid_,oidbin,uid FROM blob2num WHERE fid = ? $and_ge_le
249 ORDER BY _rowid_ ASC LIMIT 1000
250
251         my $min = @$ary ? $ary->[-1]->[0] : undef;
252         while (defined $min) {
253                 for my $row (@$ary) { $cb->($row->[1], $row->[2], @args) }
254
255                 $ary = $dbh->selectall_arrayref(<<"", undef, $fid, @rng, $min);
256 SELECT _rowid_,oidbin,uid FROM blob2num
257 WHERE fid = ? $and_ge_le AND _rowid_ > ?
258 ORDER BY _rowid_ ASC LIMIT 1000
259
260                 $min = @$ary ? $ary->[-1]->[0] : undef;
261         }
262
263         $ary = $dbh->selectall_arrayref(<<'', undef, $fid);
264 SELECT _rowid_,oidbin,name FROM blob2name WHERE fid = ?
265 ORDER BY _rowid_ ASC LIMIT 1000
266
267         $min = @$ary ? $ary->[-1]->[0] : undef;
268         while (defined $min) {
269                 for my $row (@$ary) { $cb->($row->[1], \($row->[2]), @args) }
270
271                 $ary = $dbh->selectall_arrayref(<<'', undef, $fid, $min);
272 SELECT _rowid_,oidbin,name FROM blob2name WHERE fid = ? AND _rowid_ > ?
273 ORDER BY _rowid_ ASC LIMIT 1000
274
275                 $min = @$ary ? $ary->[-1]->[0] : undef;
276         }
277 }
278
279 sub location_stats {
280         my ($self, $folder) = @_;
281         my $dbh = $self->{dbh} //= dbh_new($self);
282         my $fid;
283         my $ret = {};
284         $fid = $self->{fmap}->{$folder} //= fid_for($self, $folder) // return;
285         my ($row) = $dbh->selectrow_array(<<"", undef, $fid);
286 SELECT COUNT(name) FROM blob2name WHERE fid = ?
287
288         $ret->{'name.count'} = $row if $row;
289         my $ntype = ($folder =~ m!\A(?:nntps?|s?news)://!i) ? 'article' :
290                 (($folder =~ m!\Aimaps?://!i) ? 'uid' : "TODO<$folder>");
291         for my $op (qw(count min max)) {
292                 ($row) = $dbh->selectrow_array(<<"", undef, $fid);
293 SELECT $op(uid) FROM blob2num WHERE fid = ?
294
295                 $row or last;
296                 $ret->{"$ntype.$op"} = $row;
297         }
298         $ret;
299 }
300
301 # returns a { location => [ list-of-ids-or-names ] } mapping
302 sub locations_for {
303         my ($self, $oidbin) = @_;
304         my ($fid, $sth, $id, %fid2id);
305         my $dbh = $self->{dbh} //= dbh_new($self);
306         $sth = $dbh->prepare('SELECT fid,uid FROM blob2num WHERE oidbin = ?');
307         $sth->execute($oidbin);
308         while (my ($fid, $uid) = $sth->fetchrow_array) {
309                 push @{$fid2id{$fid}}, $uid;
310         }
311         $sth = $dbh->prepare('SELECT fid,name FROM blob2name WHERE oidbin = ?');
312         $sth->execute($oidbin);
313         while (my ($fid, $name) = $sth->fetchrow_array) {
314                 push @{$fid2id{$fid}}, $name;
315         }
316         $sth = $dbh->prepare('SELECT loc FROM folders WHERE fid = ? LIMIT 1');
317         my $ret = {};
318         while (my ($fid, $ids) = each %fid2id) {
319                 $sth->execute($fid);
320                 my ($loc) = $sth->fetchrow_array;
321                 unless (defined $loc) {
322                         my $oidhex = unpack('H*', $oidbin);
323                         warn "E: fid=$fid for $oidhex unknown:\n", map {
324                                         'E: '.(ref() ? $$_ : "#$_")."\n";
325                                 } @$ids;
326                         next;
327                 }
328                 $ret->{$loc} = $ids;
329         }
330         scalar(keys %$ret) ? $ret : undef;
331 }
332
333 # returns a list of folders used for completion
334 sub folders {
335         my ($self, @pfx) = @_;
336         my $sql = 'SELECT loc FROM folders';
337         my $re;
338         if (defined($pfx[0])) {
339                 $sql .= ' WHERE loc REGEXP ?'; # DBD::SQLite uses perlre
340                 $re = !!$pfx[1] ? '.*' : '';
341                 $re .= quotemeta($pfx[0]);
342                 $re .= '.*';
343         }
344         my $sth = ($self->{dbh} //= dbh_new($self))->prepare($sql);
345         $sth->bind_param(1, $re) if defined($re);
346         $sth->execute;
347         map { $_->[0] } @{$sth->fetchall_arrayref};
348 }
349
350 sub local_blob {
351         my ($self, $oidhex, $vrfy) = @_;
352         my $dbh = $self->{dbh} //= dbh_new($self);
353         my $b2n = $dbh->prepare(<<'');
354 SELECT f.loc,b.name FROM blob2name b
355 LEFT JOIN folders f ON b.fid = f.fid
356 WHERE b.oidbin = ?
357
358         $b2n->execute(pack('H*', $oidhex));
359         while (my ($d, $n) = $b2n->fetchrow_array) {
360                 substr($d, 0, length('maildir:')) = '';
361                 # n.b. both mbsync and offlineimap use ":2," as a suffix
362                 # in "new/", despite (from what I understand of reading
363                 # <https://cr.yp.to/proto/maildir.html>), the ":2," only
364                 # applies to files in "cur/".
365                 my @try = $n =~ /:2,[a-zA-Z]+\z/ ? qw(cur new) : qw(new cur);
366                 for my $x (@try) {
367                         my $f = "$d/$x/$n";
368                         open my $fh, '<', $f or next;
369                         # some (buggy) Maildir writers are non-atomic:
370                         next unless -s $fh;
371                         local $/;
372                         my $raw = <$fh>;
373                         if ($vrfy) {
374                                 my $got = git_sha(1, \$raw)->hexdigest;
375                                 if ($got ne $oidhex) {
376                                         warn "$f changed $oidhex => $got\n";
377                                         next;
378                                 }
379                         }
380                         return \$raw;
381                 }
382         }
383         undef;
384 }
385
386 sub match_imap_url {
387         my ($self, $url, $all) = @_; # $all = [ $lms->folders ];
388         $all //= [ $self->folders ];
389         require PublicInbox::URIimap;
390         my $want = PublicInbox::URIimap->new($url)->canonical;
391         my ($s, $h, $mb) = ($want->scheme, $want->host, $want->mailbox);
392         my @uri = map { PublicInbox::URIimap->new($_)->canonical }
393                 grep(m!\A\Q$s\E://.*?\Q$h\E\b.*?/\Q$mb\E\b!, @$all);
394         my @match;
395         for my $x (@uri) {
396                 next if $x->mailbox ne $want->mailbox;
397                 next if $x->host ne $want->host;
398                 next if $x->port != $want->port;
399                 my $x_uidval = $x->uidvalidity;
400                 next if ($want->uidvalidity // $x_uidval) != $x_uidval;
401
402                 # allow nothing in want to possibly match ";AUTH=ANONYMOUS"
403                 if (defined($x->auth) && !defined($want->auth) &&
404                                 !defined($want->user)) {
405                         push @match, $x;
406                 # or maybe user was forgotten on CLI:
407                 } elsif (defined($x->user) && !defined($want->user)) {
408                         push @match, $x;
409                 } elsif (($x->user//"\0") eq ($want->user//"\0")) {
410                         push @match, $x;
411                 }
412         }
413         return @match if wantarray;
414         scalar(@match) <= 1 ? $match[0] :
415                         "E: `$url' is ambiguous:\n\t".join("\n\t", @match)."\n";
416 }
417
418 sub match_nntp_url ($$$) {
419         my ($self, $url, $all) = @_; # $all = [ $lms->folders ];
420         $all //= [ $self->folders ];
421         require PublicInbox::URInntps;
422         my $want = PublicInbox::URInntps->new($url)->canonical;
423         my ($s, $h, $p) = ($want->scheme, $want->host, $want->port);
424         my $ng = $want->group; # force scalar (no article ranges)
425         my @uri = map { PublicInbox::URInntps->new($_)->canonical }
426                 grep(m!\A\Q$s\E://.*?\Q$h\E\b.*?/\Q$ng\E\b!, @$all);
427         my @match;
428         for my $x (@uri) {
429                 next if $x->group ne $ng || $x->host ne $h || $x->port != $p;
430                 # maybe user was forgotten on CLI:
431                 if (defined($x->userinfo) && !defined($want->userinfo)) {
432                         push @match, $x;
433                 } elsif (($x->userinfo//"\0") eq ($want->userinfo//"\0")) {
434                         push @match, $x;
435                 }
436         }
437         return @match if wantarray;
438         scalar(@match) <= 1 ? $match[0] :
439                         "E: `$url' is ambiguous:\n\t".join("\n\t", @match)."\n";
440 }
441
442 # returns undef on failure, number on success
443 sub group2folders {
444         my ($self, $lei, $all, $folders) = @_;
445         return $lei->fail(<<EOM) if @$folders;
446 --all= not compatible with @$folders on command-line
447 EOM
448         my %x = map { $_ => $_ } split(/,/, $all);
449         my @ok = grep(defined, delete(@x{qw(local remote), ''}));
450         push(@ok, '') if $all eq '';
451         my @no = keys %x;
452         if (@no) {
453                 @no = (join(',', @no));
454                 return $lei->fail(<<EOM);
455 --all=@no not accepted (must be `local' and/or `remote')
456 EOM
457         }
458         my (%seen, @inc);
459         my @all = $self->folders;
460         for my $ok (@ok) {
461                 if ($ok eq 'local') {
462                         @inc = grep(!m!\A[a-z0-9\+]+://!i, @all);
463                 } elsif ($ok eq 'remote') {
464                         @inc = grep(m!\A[a-z0-9\+]+://!i, @all);
465                 } elsif ($ok ne '') {
466                         return $lei->fail("--all=$all not understood");
467                 } else {
468                         @inc = @all;
469                 }
470                 push(@$folders, (grep { !$seen{$_}++ } @inc));
471         }
472         scalar(@$folders) || $lei->fail(<<EOM);
473 no --mail-sync folders known to lei
474 EOM
475 }
476
477 # map CLI args to folder table entries, returns undef on failure
478 sub arg2folder {
479         my ($self, $lei, $folders) = @_;
480         my @all = $self->folders;
481         my %all = map { $_ => 1 } @all;
482         my @no;
483         for (@$folders) {
484                 next if $all{$_}; # ok
485                 if (m!\A(maildir|mh):(.+)!i) {
486                         my $type = lc $1;
487                         my $d = "$type:".$lei->abs_path($2);
488                         push(@no, $_) unless $all{$d};
489                         $_ = $d;
490                 } elsif (-d "$_/new" && -d "$_/cur") {
491                         my $d = 'maildir:'.$lei->abs_path($_);
492                         push(@no, $_) unless $all{$d};
493                         $_ = $d;
494                 } elsif (m!\Aimaps?://!i) {
495                         my $orig = $_;
496                         my $res = match_imap_url($self, $orig, \@all);
497                         if (ref $res) {
498                                 $_ = $$res;
499                                 $lei->qerr(<<EOM);
500 # using `$res' instead of `$orig'
501 EOM
502                         } else {
503                                 warn($res, "\n") if defined $res;
504                                 push @no, $orig;
505                         }
506                 } elsif (m!\A(?:nntps?|s?news)://!i) {
507                         my $orig = $_;
508                         my $res = match_nntp_url($self, $orig, \@all);
509                         if (ref $res) {
510                                 $_ = $$res;
511                                 $lei->qerr(<<EOM);
512 # using `$res' instead of `$orig'
513 EOM
514                         } else {
515                                 warn($res, "\n") if defined $res;
516                                 push @no, $orig;
517                         }
518                 } else {
519                         push @no, $_;
520                 }
521         }
522         if (@no) {
523                 my $no = join("\n\t", @no);
524                 die <<EOF;
525 No sync information for: $no
526 Run `lei ls-mail-sync' to display valid choices
527 EOF
528         }
529 }
530
531 sub forget_folders {
532         my ($self, @folders) = @_;
533         my $lk = $self->lock_for_scope;
534         for my $folder (@folders) {
535                 my $fid = delete($self->{fmap}->{$folder}) //
536                         fid_for($self, $folder) // next;
537                 for my $t (qw(blob2name blob2num folders)) {
538                         $self->{dbh}->do("DELETE FROM $t WHERE fid = ?",
539                                         undef, $fid);
540                 }
541         }
542 }
543
544 # only used for changing canonicalization errors
545 sub rename_folder {
546         my ($self, $old, $new) = @_;
547         my $lk = $self->lock_for_scope;
548         my $ofid = delete($self->{fmap}->{$old}) //
549                 fid_for($self, $old) // return;
550         eval {
551                 $self->{dbh}->do(<<EOM, undef, $new, $ofid);
552 UPDATE folders SET loc = ? WHERE fid = ?
553 EOM
554         };
555         if ($@ =~ /\bunique\b/i) {
556                 my $nfid = $self->{fmap}->{$new} // fid_for($self, $new);
557                 for my $t (qw(blob2name blob2num)) {
558                         $self->{dbh}->do(<<EOM, undef, $nfid, $ofid);
559 UPDATE OR REPLACE $t SET fid = ? WHERE fid = ?
560 EOM
561                 }
562                 $self->{dbh}->do(<<EOM, undef, $ofid);
563 DELETE FROM folders WHERE fid = ?
564 EOM
565         }
566 }
567
568 sub num_oidbin ($$$) {
569         my ($self, $url, $uid) = @_; # $url MUST have UIDVALIDITY if IMAP
570         my $fid = $self->{fmap}->{$url} //= fid_for($self, $url) // return ();
571         my $sth = $self->{dbh}->prepare_cached(<<EOM, undef, 1);
572 SELECT oidbin FROM blob2num WHERE fid = ? AND uid = ? ORDER BY _rowid_
573 EOM
574         $sth->execute($fid, $uid);
575         map { $_->[0] } @{$sth->fetchall_arrayref};
576 }
577
578 sub name_oidbin ($$$) {
579         my ($self, $mdir, $nm) = @_;
580         my $fid = $self->{fmap}->{$mdir} //= fid_for($self, $mdir) // return;
581         my $sth = $self->{dbh}->prepare_cached(<<EOM, undef, 1);
582 SELECT oidbin FROM blob2name WHERE fid = ? AND name = ?
583 EOM
584         $sth->execute($fid, $nm);
585         map { $_->[0] } @{$sth->fetchall_arrayref};
586 }
587
588 sub imap_oidhex {
589         my ($self, $lei, $uid_uri) = @_;
590         my $mailbox_uri = $uid_uri->clone;
591         $mailbox_uri->uid(undef);
592         my $folders = [ $$mailbox_uri ];
593         eval { $self->arg2folder($lei, $folders) };
594         $lei->qerr("# no sync information for $mailbox_uri") if $@;
595         map { unpack('H*',$_) } num_oidbin($self, $folders->[0], $uid_uri->uid)
596 }
597
598 1;