#!/usr/bin/perl -T

#----------------------------------------------------------------------
# IMAP-to-IMAP synchronization program for QRESYNC-capable servers
# Copyright © 2015 Guilhem Moulin <guilhem@fripost.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
#----------------------------------------------------------------------

use strict;
use warnings;

our $VERSION = '0.1';
my $NAME = 'imapsync';
use Getopt::Long qw/:config posix_default no_ignore_case gnu_compat
                            bundling auto_version/;
use DBI ();
use List::Util 'first';
use POSIX 'strftime';

use lib 'lib';
use Net::IMAP::Sync qw/read_config compact_set $IMAP_text $IMAP_cond/;

# Clean up PATH
$ENV{PATH} = join ':', qw{/usr/local/bin /usr/bin /bin};
delete @ENV{qw/IFS CDPATH ENV BASH_ENV/};

my %CONFIG;
sub usage(;$) {
    my $rv = shift // 0;
    print STDERR "$NAME [OPTIONS] [--] [MAILBOX [..]]\n";
    if ($rv) {
        print STDERR "Try '$NAME --help' or consult the manpage for more information.\n";
    }
    else {
        print STDERR "Synchronize the given MAILBOXes between two QRESYNC-capable IMAP4rev1 servers.\n"
            ."Options:\n"
            ."    --config=FILE    Specify an alternate configuration file\n"
            ."    --repair         List the database anomalies and try to repair them\n"
            ."    -q, --quiet      Try to be quiet\n"
            ."    --debug          Turn on debug mode\n"
            ."Consult the manpage for more information.\n";
    }
    exit $rv;
}
usage(1) unless GetOptions(\%CONFIG, qw/config=s quiet|q repair debug help|h/);
usage(0) if $CONFIG{help};


my $CONF = read_config( delete $CONFIG{config} // $NAME
                      , [qw/_ local remote/]
                      , database => qr/\A(\P{Control}+)\z/
                      , logfile => qr/\A(\P{Control}+)\z/ );
my ($DBFILE, $LOCKFILE, $LOGGER_FD);

{
    $DBFILE = $CONF->{_}->{database} if defined $CONF->{_};
    $DBFILE //= $CONF->{remote}->{host}.'.db' if defined $CONF->{remote};
    $DBFILE //= $CONF->{local}->{host}. '.db' if defined $CONF->{local};
    die "Missing option database" unless defined $DBFILE;

    unless ($DBFILE =~ /\A\//) {
        my $dir = ($ENV{XDG_DATA_HOME} // "$ENV{HOME}/.local/share") .'/'. $NAME;
        $dir =~ /\A(\/\p{Print}+)\z/ or die "Insecure $dir";
        $dir = $1;
        $DBFILE = $dir .'/'. $DBFILE;
        unless (-d $dir) {
            mkdir $dir, 0700 or die "Can't mkdir $dir: $!\n";
        }
    }

    $LOCKFILE = $DBFILE =~ s/([^\/]+)\z/.$1.lck/r;

    if (defined $CONF->{_} and defined $CONF->{_}->{logfile}) {
        open $LOGGER_FD, '>>', $CONF->{_}->{logfile}
            or die "Can't open $CONF->{_}->{logfile}: $!\n";
        $LOGGER_FD->autoflush(1);
    }
}
my $DBH;

# Clean after us
sub cleanup() {
    logger(undef, "Cleaning up...") if $CONFIG{debug};
    unlink $LOCKFILE if defined $LOCKFILE and -f $LOCKFILE;
    close $LOGGER_FD if defined $LOGGER_FD;
    $DBH->disconnect() if defined $DBH;
}
$SIG{$_} = sub { msg(undef, $!); cleanup(); exit 1; } foreach qw/INT TERM/;
$SIG{$_} = sub { msg(undef, $!); cleanup(); exit 0; } foreach qw/HUP/;


#############################################################################
# Lock the database
{
    if (-f $LOCKFILE) {
        open my $lock, '<', $LOCKFILE or die "Can't open $LOCKFILE: $!\n";
        my $pid = <$lock>;
        close $lock;
        chomp $pid;
        my $msg = "LOCKFILE '$LOCKFILE' exists.";
        $msg .= " (Is PID $pid running?)" if defined $pid and $pid =~ /^[0-9]+$/;
        die $msg, "\n";
    }

    open my $lock, '>', $LOCKFILE or die "Can't open $LOCKFILE: $!\n";
    print $lock $$, "\n";
    close $lock;
}


#############################################################################
# Open the database and create tables

$DBH = DBI::->connect("dbi:SQLite:dbname=$DBFILE", undef, undef, {
    AutoCommit => 0,
    RaiseError => 1,
    sqlite_see_if_its_a_number => 1, # see if the bind values are numbers or not
});
$DBH->do('PRAGMA foreign_keys = ON');


{
    my @schema = (
        mailboxes => [
            q{idx        INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT},
            q{mailbox    TEXT    NOT NULL CHECK (mailbox != '') UNIQUE},
            q{subscribed BOOLEAN NOT NULL}
        ],
        local => [
            q{idx           INTEGER         NOT NULL PRIMARY KEY REFERENCES mailboxes(idx)},
            q{UIDVALIDITY   UNSIGNED INT    NOT NULL CHECK (UIDVALIDITY > 0)},
            q{UIDNEXT       UNSIGNED INT    NOT NULL}, # 0 initially
            q{HIGHESTMODSEQ UNSIGNED BIGINT NOT NULL}  # 0 initially
            # one-to-one correspondence between local.idx and remote.idx
        ],
        remote => [
            q{idx           INTEGER         NOT NULL PRIMARY KEY REFERENCES mailboxes(idx)},
            q{UIDVALIDITY   UNSIGNED INT    NOT NULL CHECK (UIDVALIDITY > 0)},
            q{UIDNEXT       UNSIGNED INT    NOT NULL}, # 0 initially
            q{HIGHESTMODSEQ UNSIGNED BIGINT NOT NULL}  # 0 initially
            # one-to-one correspondence between local.idx and remote.idx
        ],
        mapping => [
            q{idx  INTEGER      NOT NULL REFERENCES mailboxes(idx)},
            q{lUID UNSIGNED INT NOT NULL CHECK (lUID > 0)},
            q{rUID UNSIGNED INT NOT NULL CHECK (rUID > 0)},
            q{PRIMARY KEY (idx,lUID)},
            q{UNIQUE      (idx,rUID)}
            # also, lUID  < local.UIDNEXT and rUID < remote.UIDNEXT (except for interrupted syncs)
            # mapping.idx must be found among local.idx (and remote.idx)
        ],
    );

    # Invariants:
    #   * UIDVALIDITY never changes.
    #   * All changes for UID < {local,remote}.UIDNEXT and MODSEQ <
    #     {local,remote}.HIGHESTMODSEQ have been propagated.
    #   * No local (resp. remote) new message will ever have a UID <= local.UIDNEXT
    #     (resp. <= remote.UIDNEXT).
    #   * Any idx in `local` must be present in `remote` and vice-versa.
    #   * Any idx in `mapping` must be present in `local` and `remote`.
    while (@schema) {
        my $table = shift @schema;
        my $schema = shift @schema;
        my $sth = $DBH->table_info(undef, undef, $table, 'TABLE', {Escape => 1});
        my $row = $sth->fetch();
        die if defined $sth->fetch(); # sanity check
        unless (defined $row) {
            $DBH->do("CREATE TABLE $table (".join(', ',@$schema).")");
            $DBH->commit();
        }
    }
}

sub msg($@) {
    my $name = shift;
    return unless @_;
    logger($name, @_) if defined $LOGGER_FD and $LOGGER_FD ne \*STDERR;
    my $prefix = defined $name ? "$name: " : '';
    print STDERR $prefix, @_, "\n";
}
sub logger($@) {
    my $name = shift;
    return unless @_ and defined $LOGGER_FD;
    my $prefix = strftime "%b %e %H:%M:%S ", localtime;
    $prefix .= "$name: " if defined $name;
    $LOGGER_FD->say($prefix, @_);
}
logger(undef, ">>> $NAME $VERSION");


#############################################################################
# Connect to the local and remote IMAP servers

my $IMAP;
foreach my $name (qw/local remote/) {
    my %config = %{$CONF->{$name}};
    $config{$_} = $CONFIG{$_} foreach keys %CONFIG;
    $config{enable} = 'QRESYNC';
    $config{name} = $name;
    $config{'logger-fd'} = $LOGGER_FD if defined $LOGGER_FD;

    $IMAP->{$name} = { client => Net::IMAP::Sync::->new(%config) };
    my $client = $IMAP->{$name}->{client};

    die "Non $_-capable IMAP server.\n" foreach $client->incapable(qw/LIST-EXTENDED LIST-STATUS UIDPLUS/);
    # XXX We should start by listing all mailboxes matching the user's LIST
    #     criterion, then issue "SET NOTIFY (mailboxes ... (...))".  But this
    #     crashes the IMAP client:
    #     http://dovecot.org/pipermail/dovecot/2015-July/101473.html
    #my $mailboxes = $client->list((uc $config{'subscribed-only'} eq 'TRUE' ? '(SUBSCRIBED)' : '' )
    #                             .$config{mailboxes}, 'SUBSCRIBED');
    # $client->notify('SELECTED', 'MAILBOXES ('.join(' ', keys %$mailboxes).')');
    # XXX NOTIFY doesn't work as expected for INBOX
    #     http://dovecot.org/pipermail/dovecot/2015-July/101514.html
    #$client->notify(qw/SELECTED SUBSCRIBED/) if $CONFIG{watch};
    # XXX We shouldn't need to ask for STATUS responses here, and use
    #     NOTIFY's STATUS indicator instead.  However Dovecot violates RFC
    #     5464: http://dovecot.org/pipermail/dovecot/2015-July/101474.html
    @{$IMAP->{$name}}{qw/mailboxes delims/} = $client->list(q{"" "*"}, 'SUBSCRIBED', 'STATUS (UIDVALIDITY UIDNEXT HIGHESTMODSEQ)' );
}


#############################################################################
# Synchronize mailbox and subscription lists

sub make_tree(%);
sub print_tree($%);
sub mv_tree($$$%);
sub sync_tree($$%);

# Take a hash of delimiters, and recursively build a tree out of it.
# For instance ( a => "/", b => "/", "a/c" => ".", "a/c.d" => "/", "a/d" => ".")
# is transformed into the hash reference
#   { b => {},
#   { a => { "/c" => { ".d" => {} } }
#          , "/d" => {}
#          }
#   }
sub make_tree(%) {
    my %delims = @_;
    my @list = sort {length($a) <=> length($b)} keys %delims;

    my %tree;
    foreach my $x (@list) {
        next unless exists $delims{$x}; # already a children of something
        my %children;
        foreach (keys %delims) {
            next unless defined $delims{$x} and s/\A\Q$x$delims{$x}\E/$delims{$x}/;
            $children{$_} = delete $delims{"$x$_"};
        }
        delete $delims{$x};
        $tree{$x} = make_tree(%children);
    }
    return \%tree;
}
#sub print_tree($%) {
#    my $indent = shift;
#    my %tree = @_;
#    while (my ($root, $children) = each %tree) {
#        print " "x$indent, '|- ', $root, "\n";
#        print_tree($indent+2, %$children);
#    }
#}

# Retrun true if $mailbox exists for $name that is, if doesn't have the
# '\NonExistent' flag set.
sub exists_mbx($$) {
    my $name = shift;
    my $mailbox = shift;
    my $flags = $IMAP->{$name}->{mailboxes}->{$mailbox};
    return (defined $flags and !grep {lc $_ eq lc '\NonExistent'} @$flags) ? 1 : 0;
}
# Retrun true if $mailbox is subscribed for $name.
sub subscribed_mbx($$) {
    my $name = shift;
    my $mailbox = shift;
    my $flags = $IMAP->{$name}->{mailboxes}->{$mailbox};
    return (defined $flags and grep {lc $_ eq lc '\Subscribed'} @$flags) ? 1 : 0;
}

# Rename a root recursively in a tree
sub mv_tree($$$%) {
    my ($mailboxes, $mbx, $mbx2, %children) = @_;
    $mailboxes->{$mbx2} = delete $mailboxes->{$mbx};
    while (my ($root, $children) = each %children) {
        mv_tree($mailboxes, $mbx.$root, $mbx2.$root, %children);
    }
}

# Syncronize mailbox list
# XXX DELETE and RENAME not tested
sub sync_tree($$%) {
    my ($sth, $mbx, %children) = @_;
    my %exists = map { $_ => exists_mbx($_,$mbx) } qw/local remote/;

    my $rv = 0;
    if ($exists{local} xor $exists{remote}) {
        my ($exists,$missing) = $exists{local} ? ('local','remote') : ('remote','local');
        my ($sth_by_mbx, $sth_by_uidvalidity) = @$sth{($missing.'_by_mbx', $exists.'_by_uidvalidity')};

        # check if there is an entry matching $mbx for $missing in the database
        $sth_by_mbx->execute($mbx);
        my $row_by_mbx = $sth_by_mbx->fetch();
        die if defined $sth_by_mbx->fetch(); # sanity check

        if (defined $row_by_mbx) {
            # $mbx was seen on $missing during the previous round: it
            # has either been DELETEd or RENAMEd to another name on
            # $missing.

            my %uidvalidities = $IMAP->{$missing}->{client}->uidvalidity();
            my ($idx,$uidvalidity) = @$row_by_mbx;
            my @mbx2 = grep { $uidvalidities{$_} == $uidvalidity and !exists_mbx($exists,$_) }
                            keys %uidvalidities;

            if ($#mbx2 > 0) {
                # XXX this is allowed by RFC3501, but we can't guess...
                msg($missing, "Multiple mailboxes have same UIDVALIDITY $uidvalidity: ",
                              join(',',@mbx2), "\n",
                             "Dunno which one $mbx should be renamed to.");
                exit 1;
            }
            elsif (@mbx2) {
                # $mbx's known (from the DB) UIDVALIDITY is that of
                # $missing's $mbx2, which is not in the database and
                # doesn't exist on $exists
                msg($exists, "Rename mailbox $mbx to $mbx2[0]");
                $sth->{rename}->execute($mbx2[0],$idx) or
                    msg('database', "WARNING: Can't rename $mbx to $mbx2[0]");
                $IMAP->{$exists}->{client}->rename($mbx, $mbx2[0]);
                $DBH->commit();
                mv_tree($IMAP->{$exists}->{mailboxes}, $mbx, $mbx2[0], %children);
                $mbx = $mbx2[0];
            }
            else {
                # $mbx's known (from the DB) UIDVALIDITY on $missing
                # was not found in any of $missing's mailboxes.
                msg($exists, "Delete mailbox $mbx");
                push @{$IMAP->{$exists}->{mailboxes}->{$mbx}}, '\NonExistent';
                $IMAP->{$exists}->{client}->delete($mbx);
            }
        }
        else {
            # $mbx was never seen on $missing: it has either been
            # CREATEd or RENAMEd from another name on $exists.

            my ($idx,$mbx2);
            if (defined (my $uidvalidity = $IMAP->{$exists}->{client}->uidvalidity($mbx))) {
                $sth_by_uidvalidity->execute($uidvalidity);
                my $by_uidvalidity = $sth_by_uidvalidity->fetchall_arrayref();
                if (defined $by_uidvalidity and $#$by_uidvalidity > 0) {
                    # XXX this is allowed by RFC3501, but we can't guess...
                    my @mbx2 = map {$_->[1]} @$by_uidvalidity;
                    msg($exists, "Multiple mailboxes have same UIDVALIDITY $uidvalidity: ",
                                  join(',',@mbx2), "\n",
                                 "Dunno which one $mbx should be renamed to.");
                    exit 1;
                }
                ($idx,$mbx2) = @{$by_uidvalidity->[0]} if defined $by_uidvalidity and @$by_uidvalidity;
            }

            if (defined $mbx2) {
                # $mbx's UIDVALIDITY on $exists can be found in the
                # database as associated with $mbx2, which exists on
                # $missing but not on $exists
                msg($missing, "Rename mailbox $mbx2 to $mbx");
                $sth->{rename}->execute($mbx,$idx) or
                    msg('database', "WARNING: Can't rename $mbx2 to $mbx2");
                $IMAP->{$missing}->{client}->rename($mbx2, $mbx);
                $DBH->commit();
                mv_tree($IMAP->{$missing}->{mailboxes}, $mbx2, $mbx, %children);
            }
            else {
                # $mbx's UIDVALIDITY on $exists has never been found in
                # the database.
                msg($missing, "Create mailbox $mbx");
                $IMAP->{$missing}->{mailboxes}->{$mbx} =
                    grep {lc $_ ne lc '\NonExistent'} @{$IMAP->{$missing}->{mailboxes}->{$mbx} // []};
                $IMAP->{$missing}->{client}->create($mbx);
            }
        }
        $rv = 1;
    }

    while (my ($root, $children) = each %children) {
        my $r = sync_tree($sth, $mbx.$root, %$children);
        $rv ||= $r;
    }
    return $rv;
}

{
    my %delims;
    foreach my $name (qw/local remote/) {
        while (my ($mbx, $sep) = each %{$IMAP->{$name}->{delims}}) {
            if (!exists $delims{$mbx}) {
                $delims{$mbx} = $sep;
            } else {
                die "Hierarchy delimeters for mailbox $mbx don't match!\n"
                    unless (!defined $sep and !defined $delims{$mbx}) or
                           (defined $sep and defined $delims{$mbx} and $sep eq $delims{$mbx});
            }
        }
    }

    my $tree = make_tree(%delims);
    my %sth;
    $sth{$_.'_by_mbx'} = $DBH->prepare("SELECT idx,UIDVALIDITY FROM mailboxes NATURAL JOIN $_ WHERE mailbox = ?")
        foreach qw/local remote/;
    $sth{$_.'_by_uidvalidity'} = $DBH->prepare("SELECT idx,mailbox FROM mailboxes NATURAL JOIN $_ WHERE UIDVALIDITY = ?")
        foreach qw/local remote/;
    $sth{rename} = $DBH->prepare(q{UPDATE mailboxes SET mailbox = ? WHERE idx = ?});

    my $updated = 0;
    while (my ($mbx,$children) = each %$tree) {
        #print $mbx, "\n";
        #print_tree(0, %$children);
        my $u = sync_tree(\%sth, $mbx, %$children);
        $updated ||= $u;
    }

    if ($updated) {
        # refresh the mailbox list
        foreach my $name (qw/local remote/) {
            @{$IMAP->{$name}}{qw/mailboxes delims/} = $IMAP->{$name}->{client}->list(q{"" "*"}, 'SUBSCRIBED');
        }
        my %mailboxes;
        $mailboxes{$_} = 1 foreach (keys %{$IMAP->{local}->{mailboxes}}, keys %{$IMAP->{remote}->{mailboxes}});
        foreach my $mbx (keys %mailboxes) {
            die "Couldn't sync mailbox list.\n" if exists_mbx('local',$mbx) xor exists_mbx('remote',$mbx);
        }
    }
}

# Synchronize subscription list
my @SUBSCRIPTIONS;
{
    my $sth_search = $DBH->prepare(q{SELECT idx,subscribed FROM mailboxes WHERE mailbox = ?});
    my $sth_subscribe = $DBH->prepare(q{UPDATE mailboxes SET subscribed = ? WHERE idx = ?});

    my %mailboxes;
    $mailboxes{$_} = 1 foreach (keys %{$IMAP->{local}->{mailboxes}}, keys %{$IMAP->{remote}->{mailboxes}});

    foreach my $mbx (keys %mailboxes) {
        $sth_search->execute($mbx);
        my $row = $sth_search->fetch();
        die if defined $sth_search->fetch(); # sanity check

        my ($lSubscribed,$rSubscribed) = map {subscribed_mbx($_,$mbx)} qw/local remote/;
        if ($lSubscribed == $rSubscribed) {
            if (defined $row) {
                my ($idx,$status) = @$row;
                if (defined $status and $status != $lSubscribed) {
                    $sth_subscribe->execute($lSubscribed, $idx) or
                        msg('database', "WARNING: Can't (un)subscribe $mbx");
                    $DBH->commit();
                }
            }
        }
        else {
            my ($subscribed,$unsubscribed) = $lSubscribed ? qw/local remote/ : qw/remote local/;
            if (defined $row) {
                my ($idx,$status) = @$row;
                if ($status) {
                    # $mbx was SUBSCRIBEd before, UNSUBSCRIBE it now
                    msg($subscribed, "Unsubscribe to mailbox $mbx");
                    $sth_subscribe->execute(0,$idx) or
                        msg('database', "WARNING: Can't unsubscribe $mbx");
                    $IMAP->{$subscribed}->{client}->unsubscribe($mbx);
                    $DBH->commit();
                    $lSubscribed = $rSubscribed = 0;
                }
                else {
                    # $mbx was UNSUBSCRIBEd before, SUBSCRIBE it now
                    msg($unsubscribed, "Subscribe to mailbox $mbx");
                    $sth_subscribe->execute(1,$idx) or
                        msg('database', "WARNING: Can't subscribe $mbx");
                    $IMAP->{$unsubscribed}->{client}->subscribe($mbx);
                    $DBH->commit();
                    $lSubscribed = $rSubscribed = 1;
                }
            }
            else {
                # $mbx is unknown; assume the user wants to SUBSCRIBE
                msg($unsubscribed, "Subscribe to mailbox $mbx");
                $IMAP->{$unsubscribed}->{client}->subscribe($mbx);
                $lSubscribed = $rSubscribed = 1;
            }
        }
        push @SUBSCRIPTIONS, $mbx if $lSubscribed;
    }
}

# Clean database: remove mailboxes that no longer exist
{
    my $sth = $DBH->prepare(q{SELECT idx,mailbox,subscribed FROM mailboxes});
    my $sth_delete_mailboxes = $DBH->prepare(q{DELETE FROM mailboxes WHERE idx = ?});
    my $sth_delete_local     = $DBH->prepare(q{DELETE FROM local     WHERE idx = ?});
    my $sth_delete_remote    = $DBH->prepare(q{DELETE FROM remote    WHERE idx = ?});
    my $sth_delete_mapping   = $DBH->prepare(q{DELETE FROM mapping   WHERE idx = ?});

    my @idx;
    $sth->execute();
    while (defined (my $row = $sth->fetch)) {
        my ($idx,$mbx,$subscribed) = @$row;
        if (!exists_mbx('local',$mbx) and !exists_mbx('remote',$mbx)) {
            $_->execute($idx) foreach ($sth_delete_mapping,$sth_delete_local,$sth_delete_remote);
            $sth_delete_mailboxes->execute($idx) if
                !exists $IMAP->{local}->{mailboxes}->{$mbx} and
                !exists $IMAP->{remote}->{mailboxes}->{$mbx};
            $DBH->commit();
        }
    }
}



#############################################################################
# Synchronize messages
# Consider only the mailboxes in @ARGV, if the list is non-empty.

my ($lIMAP, $rIMAP) = map {$IMAP->{$_}->{client}} qw/local remote/;
undef $IMAP;


# Get all cached states from the database.
my $STH_GET_CACHE = $DBH->prepare(q{
    SELECT mailbox,
           l.UIDVALIDITY as lUIDVALIDITY, l.UIDNEXT as lUIDNEXT, l.HIGHESTMODSEQ as lHIGHESTMODSEQ,
           r.UIDVALIDITY as rUIDVALIDITY, r.UIDNEXT as rUIDNEXT, r.HIGHESTMODSEQ as rHIGHESTMODSEQ
    FROM mailboxes m JOIN local l ON m.idx = l.idx JOIN remote r ON m.idx = r.idx
});
my $STH_GET_CACHE_BY_IDX = $DBH->prepare(q{
    SELECT mailbox,
           l.UIDVALIDITY as lUIDVALIDITY, l.UIDNEXT as lUIDNEXT, l.HIGHESTMODSEQ as lHIGHESTMODSEQ,
           r.UIDVALIDITY as rUIDVALIDITY, r.UIDNEXT as rUIDNEXT, r.HIGHESTMODSEQ as rHIGHESTMODSEQ
    FROM mailboxes m JOIN local l ON m.idx = l.idx JOIN remote r ON m.idx = r.idx
    WHERE m.idx = ?
});

# Get the index associated with a mailbox.
my $STH_GET_INDEX = $DBH->prepare(q{SELECT idx FROM mailboxes WHERE mailbox = ?});

# Find local/remote UID from the map.
my $STH_GET_LOCAL_UID  = $DBH->prepare(q{SELECT lUID FROM mapping WHERE idx = ? and rUID = ?});
my $STH_GET_REMOTE_UID = $DBH->prepare(q{SELECT rUID FROM mapping WHERE idx = ? and lUID = ?});

# Delete a (idx,lUID,rUID) association.
# /!\ Don't commit before the messages have actually been EXPUNGEd on both sides!
my $STH_DELETE_MAPPING = $DBH->prepare(q{DELETE FROM mapping WHERE idx = ? and lUID = ?});

# Update the HIGHESTMODSEQ.
my $STH_UPDATE_LOCAL_HIGHESTMODSEQ  = $DBH->prepare(q{UPDATE local  SET HIGHESTMODSEQ = ? WHERE idx = ?});
my $STH_UPDATE_REMOTE_HIGHESTMODSEQ = $DBH->prepare(q{UPDATE remote SET HIGHESTMODSEQ = ? WHERE idx = ?});

# Update the HIGHESTMODSEQ and UIDNEXT.
my $STH_UPDATE_LOCAL  = $DBH->prepare(q{UPDATE local  SET UIDNEXT = ?, HIGHESTMODSEQ = ? WHERE idx = ?});
my $STH_UPDATE_REMOTE = $DBH->prepare(q{UPDATE remote SET UIDNEXT = ?, HIGHESTMODSEQ = ? WHERE idx = ?});

# Add a new mailbox.
my $STH_INSERT_MAILBOX= $DBH->prepare(q{INSERT INTO mailboxes (mailbox,subscribed) VALUES (?,?)});
my $STH_INSERT_LOCAL  = $DBH->prepare(q{INSERT INTO local  (idx,UIDVALIDITY,UIDNEXT,HIGHESTMODSEQ) VALUES (?,?,0,0)});
my $STH_INSERT_REMOTE = $DBH->prepare(q{INSERT INTO remote (idx,UIDVALIDITY,UIDNEXT,HIGHESTMODSEQ) VALUES (?,?,0,0)});

# Insert or retrieve a (idx,lUID,rUID) association.
my $STH_INSERT_MAPPING = $DBH->prepare(q{INSERT INTO mapping (idx,lUID,rUID) VALUES (?,?,?)});
my $STH_GET_MAPPING    = $DBH->prepare(q{SELECT lUID,rUID FROM mapping WHERE idx = ?});

# Get the list of interrupted mailbox syncs.
my $STH_LIST_INTERRUPTED = $DBH->prepare(q{
    SELECT mbx.idx, mailbox
    FROM mailboxes mbx JOIN local l ON mbx.idx = l.idx JOIN remote r ON mbx.idx = r.idx JOIN mapping ON mbx.idx = mapping.idx
    WHERE (lUID >= l.UIDNEXT OR rUID >= r.UIDNEXT)
    GROUP BY mbx.idx
});

# For an interrupted mailbox sync, get the pairs (lUID,rUID) that have
# already been downloaded.
my $STH_GET_INTERRUPTED_BY_IDX = $DBH->prepare(q{
    SELECT lUID, rUID
    FROM mapping m JOIN local l ON m.idx = l.idx JOIN remote r ON m.idx = r.idx
    WHERE m.idx = ? AND (lUID >= l.UIDNEXT OR rUID >= r.UIDNEXT)
});


# Download some missing UIDs from $source; returns the thew allocated UIDs
sub download_missing($$$@) {
    my $idx = shift;
    my $mailbox = shift;
    my $source = shift;
    my @set = @_;
    my @uids;

    my $target = $source eq 'local' ? 'remote' : 'local';

    my ($buff, $bufflen) = ([], 0);
    undef $buff if ($target eq 'local' ? $lIMAP : $rIMAP)->incapable('MULTIAPPEND');

    my $attrs = join ' ', qw/MODSEQ FLAGS INTERNALDATE ENVELOPE BODY.PEEK[]/;
    ($source eq 'local' ? $lIMAP : $rIMAP)->fetch(compact_set(@set), "($attrs)", sub($) {
        my $mail = shift;
        return unless exists $mail->{RFC822}; # not for us

        my $uid = $mail->{UID};
        my $from = first { defined $_ and @$_ } @{$mail->{ENVELOPE}}[2,3,4];
        $from = (defined $from and @$from) ? $from->[0]->[2].'@'.$from->[0]->[3] : '';
        msg(undef, "$source($mailbox): UID $uid from <$from> ($mail->{INTERNALDATE})") unless $CONFIG{quiet};

        callback_new_message($idx, $mailbox, $source, $mail, \@uids, $buff, \$bufflen)
    });
    push @uids, callback_new_message_flush($idx, $mailbox, $source, @$buff) if defined $buff and @$buff;
    return @uids;
}


# Solve a flag update conflict (by taking the union of the two flag lists).
sub flag_conflict($$$$$) {
    my ($mailbox, $lUID, $lFlags, $rUID, $rFlags) = @_;

    my %flags = map {$_ => 1} (split(/ /, $lFlags), split(/ /, $rFlags));
    my $flags = join ' ', sort(keys %flags);
    msg(undef, "WARNING: Conflicting flag update in $mailbox for local UID $lUID ($lFlags) ".
               "and remote UID $rUID ($rFlags). Setting both to the union ($flags).");

    return $flags
}


# Delete a mapping ($idx, $lUID)
sub delete_mapping($$) {
    my ($idx, $lUID) = @_;
    my $r = $STH_DELETE_MAPPING->execute($idx, $lUID);
    die if $r > 1; # sanity check
    msg('database', "WARNING: Can't delete (idx,lUID) = ($idx,$lUID)") if $r == 0;
}


# Check and repair synchronization of a mailbox between the two servers
# (in a very crude way, by downloading all existing UID with their flags)
my @REPAIR;
sub repair($$) {
    my ($idx, $mailbox) = @_;

    # get all existing UID with their flags
    my ($lVanished, $lModified) = $lIMAP->pull_updates(1);
    my ($rVanished, $rModified) = $rIMAP->pull_updates(1);

    my %lVanished = map {$_ => 1} @$lVanished;
    my %rVanished = map {$_ => 1} @$rVanished;

    my (@lToRemove, %lToUpdate, @lMissing);
    my (@rToRemove, %rToUpdate, @rMissing);
    my @delete_mapping;

    $STH_GET_CACHE_BY_IDX->execute($idx);
    my $cache = $STH_GET_CACHE_BY_IDX->fetchrow_hashref() // die "Missing cache for index $idx";
    die if defined $STH_GET_CACHE_BY_IDX->fetch(); # sanity check

    # process each pair ($lUID,$rUID) found in the mapping table, and
    # compare with the result from the IMAP servers to detect anomalies

    $STH_GET_MAPPING->execute($idx);
    while (defined (my $row = $STH_GET_MAPPING->fetch())) {
        my ($lUID, $rUID) = @$row;
        if (defined $lModified->{$lUID} and defined $rModified->{$rUID}) {
            # both $lUID and $rUID are known; see sync_known_messages
            # for the sync algorithm
            my ($lFlags, $rFlags) = ($lModified->{$lUID}->[1], $rModified->{$rUID}->[1]);
            if ($lFlags eq $rFlags) {
                # no conflict
            }
            elsif ($lModified->{$lUID}->[0] <= $cache->{lHIGHESTMODSEQ} and
                   $rModified->{$rUID}->[0] >  $cache->{rHIGHESTMODSEQ}) {
                # set $lUID to $rFlags
                $lToUpdate{$rFlags} //= [];
                push @{$lToUpdate{$rFlags}}, $lUID;
            }
            elsif ($lModified->{$lUID}->[0] >  $cache->{lHIGHESTMODSEQ} and
                   $rModified->{$rUID}->[0] <= $cache->{rHIGHESTMODSEQ}) {
                # set $rUID to $lFlags
                $rToUpdate{$lFlags} //= [];
                push @{$rToUpdate{$lFlags}}, $rUID;
            }
            else {
                # conflict
                msg(undef, "WARNING: Missed flag update in $mailbox for (lUID,rUID) = ($lUID,$rUID). Repairing.")
                    if $lModified->{$lUID}->[0] <= $cache->{lHIGHESTMODSEQ} and
                       $rModified->{$rUID}->[0] <= $cache->{rHIGHESTMODSEQ};
                # set both $lUID and $rUID to the union of $lFlags and $rFlags
                my $flags = flag_conflict($mailbox, $lUID => $lFlags, $rUID => $rFlags);
                $lToUpdate{$flags} //= [];
                push @{$lToUpdate{$flags}}, $lUID;
                $rToUpdate{$flags} //= [];
                push @{$rToUpdate{$flags}}, $rUID;
            }
        }
        elsif (!defined $lModified->{$lUID} and !defined $rModified->{$rUID}) {
            unless ($lVanished{$lUID} and $rVanished{$rUID}) {
                msg(undef, "WARNING: Pair (lUID,rUID) = ($lUID,$rUID) vanished from $mailbox. Repairing.");
                push @delete_mapping, $lUID;
            }
        }
        elsif (!defined $lModified->{$lUID}) {
            push @delete_mapping, $lUID;
            if ($lVanished{$lUID}) {
                push @rToRemove, $rUID;
            } else {
                msg("local($mailbox)", "WARNING: UID $lUID disappeared. Downloading remote UID $rUID again.");
                push @rMissing, $rUID;
            }
        }
        elsif (!defined $rModified->{$rUID}) {
            push @delete_mapping, $lUID;
            if ($rVanished{$rUID}) {
                push @lToRemove, $lUID;
            } else {
                msg("remote($mailbox)", "WARNING: UID $rUID disappeared. Downloading local UID $lUID again.");
                push @lMissing, $lUID;
            }
        }

        delete $lModified->{$lUID};
        delete $lVanished{$lUID};
        delete $rModified->{$rUID};
        delete $rVanished{$rUID};
    }

    # remove messages on the IMAP side; will increase HIGHESTMODSEQ
    $lIMAP->remove_message(@lToRemove) if @lToRemove;
    $rIMAP->remove_message(@rToRemove) if @rToRemove;

    # remove entries in the table
    delete_mapping($idx, $_) foreach @delete_mapping;
    $DBH->commit() if @delete_mapping;

    # push flag updates; will increase HIGHESTMODSEQ
    while (my ($lFlags,$lUIDs) = each %lToUpdate) {
        $lIMAP->push_flag_updates($lFlags, @$lUIDs);
    }
    while (my ($rFlags,$rUIDs) = each %rToUpdate) {
        $rIMAP->push_flag_updates($rFlags, @$rUIDs);
    }


    # Process UID found in IMAP but not in the mapping table.
    msg("remote($mailbox)", "WARNING: No match for vanished local UID $_. Ignoring.") foreach keys %lVanished;
    msg("local($mailbox)", "WARNING: No match for vanished remote UID $_. Ignoring.") foreach keys %rVanished;

    foreach my $lUID (keys %$lModified) {
        msg("remote($mailbox)", "WARNING: No match for modified local UID $lUID. Downloading again.");
        push @lMissing, $lUID;
    }
    foreach my $rUID (keys %$rModified) {
        msg("local($mailbox)", "WARNING: No match for modified remote UID $rUID. Downloading again.");
        push @rMissing, $rUID;
    }

    # download missing UIDs; will increase UIDNEXT and HIGHESTMODSEQ
    my @rIgnore = download_missing($idx, $mailbox, 'local',  @lMissing) if @lMissing;
    my @lIgnore = download_missing($idx, $mailbox, 'remote', @rMissing) if @rMissing;

    # download new messages; this will also update UIDNEXT and HIGHESTMODSEQ in the database
    sync_messages($idx, $mailbox, \@lIgnore, \@rIgnore);
}


# Sync known messages.  Since pull_updates is the last method call on
# $lIMAP and $rIMAP, it is safe to call get_cache on either object after
# this function, in order to update the HIGHESTMODSEQ.
# Return true if an update was detected, and false otherwise
sub sync_known_messages($$) {
    my ($idx, $mailbox) = @_;
    my $update = 0;

    # loop since processing might produce VANISHED or unsollicited FETCH responses
    while (1) {
        my ($lVanished, $lModified, $rVanished, $rModified);

        ($lVanished, $lModified) = $lIMAP->pull_updates();
        ($rVanished, $rModified) = $rIMAP->pull_updates();

        # repeat until we have nothing pending
        return $update unless %$lModified or %$rModified or @$lVanished or @$rVanished;
        $update = 1;

        # process VANISHED messages
        # /!\ this might modify the VANISHED or MODIFIED cache!
        if (@$lVanished or @$rVanished) {
            my %lVanished = map {$_ => 1} @$lVanished;
            my %rVanished = map {$_ => 1} @$rVanished;

            # For each vanished UID, get the corresponding one on the
            # other side (from the DB); consider it as to be removed if
            # it hasn't been removed already.

            my (@lToRemove, @rToRemove);
            foreach my $lUID (@$lVanished) {
                $STH_GET_REMOTE_UID->execute($idx, $lUID);
                my ($rUID) = $STH_GET_REMOTE_UID->fetchrow_array();
                die if defined $STH_GET_REMOTE_UID->fetchrow_arrayref(); # sanity check
                if (!defined $rUID) {
                    msg("remote($mailbox)", "WARNING: No match for vanished local UID $lUID. Ignoring.");
                }
                elsif (!exists $rVanished{$rUID}) {
                    push @rToRemove, $rUID;
                }
            }
            foreach my $rUID (@$rVanished) {
                $STH_GET_LOCAL_UID->execute($idx, $rUID);
                my ($lUID) = $STH_GET_LOCAL_UID->fetchrow_array();
                die if defined $STH_GET_LOCAL_UID->fetchrow_arrayref(); # sanity check
                if (!defined $lUID) {
                    msg("local($mailbox)", "WARNING: No match for vanished remote UID $rUID. Ignoring.");
                }
                elsif (!exists $lVanished{$lUID}) {
                    push @lToRemove, $lUID;
                }
            }

            $lIMAP->remove_message(@lToRemove) if @lToRemove;
            $rIMAP->remove_message(@rToRemove) if @rToRemove;

            # remove existing mappings
            foreach my $lUID (@$lVanished, @lToRemove) {
                delete_mapping($idx, $lUID);
            }
        }

        # process FLAG updates
        # /!\ this might modify the VANISHED or MODIFIED cache!
        if (%$lModified or %$rModified) {
            my (%lToUpdate, %rToUpdate);

            # Take flags updates on both sides, and get the
            # corresponding UIDs on the other side (from the DB).
            # If it wasn't modified there, make it such; if it was
            # modified with the same flags list, ignore that message;
            # otherwise there is a conflict, and take the union.
            #
            # Group by flags in order to limit the number of round
            # trips.

            while (my ($lUID,$lFlags) = each %$lModified) {
                $STH_GET_REMOTE_UID->execute($idx, $lUID);
                my ($rUID) = $STH_GET_REMOTE_UID->fetchrow_array();
                die if defined $STH_GET_REMOTE_UID->fetchrow_arrayref(); # sanity check
                if (!defined $rUID) {
                    msg("remote($mailbox)", "WARNING: No match for modified local UID $lUID. Try '--repair'.");
                }
                elsif (defined (my $rFlags = $rModified->{$rUID})) {
                    unless ($lFlags eq $rFlags) {
                        my $flags = flag_conflict($mailbox, $lUID => $lFlags, $rUID => $rFlags);
                        $lToUpdate{$flags} //= [];
                        push @{$lToUpdate{$flags}}, $lUID;
                        $rToUpdate{$flags} //= [];
                        push @{$rToUpdate{$flags}}, $rUID;
                    }
                }
                else {
                    $rToUpdate{$lFlags} //= [];
                    push @{$rToUpdate{$lFlags}}, $rUID;
                }
            }
            while (my ($rUID,$rFlags) = each %$rModified) {
                $STH_GET_LOCAL_UID->execute($idx, $rUID);
                my ($lUID) = $STH_GET_LOCAL_UID->fetchrow_array();
                die if defined $STH_GET_LOCAL_UID->fetchrow_arrayref(); # sanity check
                if (!defined $lUID) {
                    msg("local($mailbox)", "WARNING: No match for modified remote UID $rUID. Try '--repair'.");
                }
                elsif (!exists $lModified->{$lUID}) {
                    # conflicts are taken care of above
                    $lToUpdate{$rFlags} //= [];
                    push @{$lToUpdate{$rFlags}}, $lUID;
                }
            }

            while (my ($lFlags,$lUIDs) = each %lToUpdate) {
                $lIMAP->push_flag_updates($lFlags, @$lUIDs);
            }
            while (my ($rFlags,$rUIDs) = each %rToUpdate) {
                $rIMAP->push_flag_updates($rFlags, @$rUIDs);
            }
        }
    }
}


# The callback to use when FETCHing new messages from $name to add it to
# the other one.
# If defined, the array reference $UIDs will be fed with the newly added
# UIDs.
# If defined, $buff contains the list of messages to be appended with
# MULTIAPPEND.  In that case callback_new_message_flush should be called
# after the FETCH.
sub callback_new_message($$$$;$$$) {
    my ($idx, $mailbox, $name, $mail, $UIDs, $buff, $bufflen) = @_;
    return unless exists $mail->{RFC822}; # not for us

    my $length = length $mail->{RFC822};
    if ($length == 0) {
        msg("$name($mailbox)", "WARNING: Ignoring new 0-length message (UID $mail->{UID})");
        return;
    }

    my @UIDs;
    unless (defined $buff) {
        @UIDs = callback_new_message_flush($idx, $mailbox, $name, $mail);
    }
    else {
        # use MULTIAPPEND (RFC 3502)
        # proceed by batches of 1MB to save roundtrips without blowing up the memory
        if (@$buff and $$bufflen + $length > 1048576) {
            @UIDs = callback_new_message_flush($idx, $mailbox, $name, @$buff);
            @$buff = ();
            $$bufflen = 0;
        }
        push @$buff, $mail;
        $$bufflen += $length;
    }
    push @$UIDs, @UIDs if defined $UIDs;
}


# Add the given @messages (multiple messages are only allowed for
# MULTIAPPEND-capable servers) from $name to the other server.
# Returns the list of newly allocated UIDs.
sub callback_new_message_flush($$$@) {
    my ($idx, $mailbox, $name, @messages) = @_;

    my $imap = $name eq 'local' ? $rIMAP : $lIMAP; # target client
    my @sUID = map {$_->{UID}} @messages;
    my @tUID = $imap->append($mailbox, @messages);
    die unless $#sUID == $#tUID; # sanity check

    my ($lUIDs, $rUIDs) = $name eq 'local' ? (\@sUID,\@tUID) : (\@tUID,\@sUID);
    for (my $k=0; $k<=$#messages; $k++) {
        logger(undef, "Adding mapping (lUID,rUID) = ($lUIDs->[$k],$rUIDs->[$k]) for $mailbox")
            if $CONFIG{debug};
        $STH_INSERT_MAPPING->execute($idx, $lUIDs->[$k], $rUIDs->[$k]);
    }
    $DBH->commit(); # commit only once per batch

    return @tUID;
}


# Sync both known and new messages
# If the array references $lIgnore and $rIgnore are not empty, skip
# the given UIDs.
sub sync_messages($$;$$) {
    my ($idx, $mailbox, $lIgnore, $rIgnore) = @_;

    my %ignore = (local => ($lIgnore // []), remote => ($rIgnore // []));
    my $loop;
    do {
        # get new messages from $source (except @{$ignore{$source}}) and APPEND them to $target
        foreach my $source (qw/remote local/) { # pull remote mails first
            my $target = $source eq 'remote' ? 'local' : 'remote';
            my $buff    = [] unless ($target eq 'local' ? $lIMAP : $rIMAP)->incapable('MULTIAPPEND');
            my $bufflen = 0;
            my @tUIDs;

            ($source eq 'remote' ? $rIMAP : $lIMAP)->pull_new_messages(sub($) {
                callback_new_message($idx, $mailbox, $source, shift, \@tUIDs, $buff, \$bufflen)
            }, @{$ignore{$source}});

            push @tUIDs, callback_new_message_flush($idx, $mailbox, $source, @$buff)
                if defined $buff and @$buff;
            push @{$ignore{$target}}, @tUIDs;

            $loop = @tUIDs ? 1 : 0;
        }
        # since $source modifies $target's UIDNEXT upon new mails, we
        # need to check again the first $source (remote) whenever the
        # last one (local) added new messages to it
    }
    while ($loop);

    # both local and remote UIDNEXT are now up to date; proceed with
    # pending flag updates and vanished messages
    sync_known_messages($idx, $mailbox);

    # don't store the new UIDNEXTs before to avoid downloading these
    # mails again in the event of a crash
    $STH_UPDATE_LOCAL->execute($lIMAP->get_cache( qw/UIDNEXT HIGHESTMODSEQ/), $idx) or
        msg('database', "WARNING: Can't update remote UIDNEXT/HIGHESTMODSEQ for $mailbox");
    $STH_UPDATE_REMOTE->execute($rIMAP->get_cache(qw/UIDNEXT HIGHESTMODSEQ/), $idx) or
        msg('database', "WARNING: Can't update remote UIDNEXT/HIGHESTMODSEQ for $mailbox");
    $DBH->commit();
}


# Wait up to $timout seconds for notifications on either IMAP server.
# Then issue a NOOP so the connection doesn't terminate for inactivity.
sub wait_notifications(;$) {
    my $timeout = shift // 300;

    while ($timeout > 0) {
        my $r1 = $lIMAP->slurp();
        my $r2 = $rIMAP->slurp();
        last if $r1 or $r2; # got update!

        sleep 1;
        if (--$timeout == 0) {
            $lIMAP->noop();
            $rIMAP->noop();
            # might have got updates so exit the loop
        }
    }
}


# Resume interrupted mailbox syncs.
my ($MAILBOX, $IDX);
$STH_LIST_INTERRUPTED->execute();
while (defined (my $row = $STH_LIST_INTERRUPTED->fetchrow_arrayref())) {
    ($IDX, $MAILBOX) = @$row;
    msg(undef, "Resuming interrupted sync for $MAILBOX");

    my %lUIDs;
    $STH_GET_INTERRUPTED_BY_IDX->execute($IDX);
    while (defined (my $row = $STH_GET_INTERRUPTED_BY_IDX->fetchrow_arrayref())) {
        $lUIDs{$row->[0]} = $row->[1]; # pair ($lUID, $rUID)
    }
    die unless %lUIDs; # sanity check

    $lIMAP->select($MAILBOX);
    $rIMAP->select($MAILBOX);

    # FETCH all messages with their FLAGS to detect messages that have
    # vanished meanwhile, or for which there was a flag update.

    my (%lList, %rList); # The lists of existing local and remote UIDs
    my $attrs = '('.join(' ', qw/MODSEQ FLAGS/).')';
    $lIMAP->fetch(compact_set(keys   %lUIDs), $attrs, sub($){ $lList{shift->{UID}} = 1 });
    $rIMAP->fetch(compact_set(values %lUIDs), $attrs, sub($){ $rList{shift->{UID}} = 1 });

    my (@lToRemove, @rToRemove);
    while (my ($lUID,$rUID) = each %lUIDs) {
        next if $lList{$lUID} and $rList{$rUID}; # exists on both
        push @lToRemove, $lUID if $lList{$lUID};
        push @rToRemove, $rUID if $rList{$rUID};

        delete_mapping($IDX, $lUID);
    }

    $lIMAP->remove_message(@lToRemove) if @lToRemove;
    $rIMAP->remove_message(@rToRemove) if @rToRemove;
    $DBH->commit() if @lToRemove or @rToRemove; # /!\ commit *after* remove_message!

    # ignore deleted messages
    delete @lList{@lToRemove};
    delete @rList{@rToRemove};

    # Resume the sync, but skip messages that have already been
    # downloaded.  Flag updates will be processed automatically since
    # the _MODIFIED internal cache has been initialized with all our
    # UIDs.  (Since there is no reliable HIGHESTMODSEQ, any flag
    # difference is treated as a conflict.)
    sync_messages($IDX, $MAILBOX, [keys %lList], [keys %rList]);
}



# Initialize $lIMAP and $rIMAP states to detect mailbox dirtyness.
$STH_GET_CACHE->execute();
while (defined (my $row = $STH_GET_CACHE->fetchrow_hashref())) {
    $lIMAP->set_cache($row->{mailbox},
        UIDVALIDITY   => $row->{lUIDVALIDITY},
        UIDNEXT       => $row->{lUIDNEXT},
        HIGHESTMODSEQ => $row->{lHIGHESTMODSEQ}
    );
    $rIMAP->set_cache($row->{mailbox},
        UIDVALIDITY   => $row->{rUIDVALIDITY},
        UIDNEXT       => $row->{rUIDNEXT},
        HIGHESTMODSEQ => $row->{rHIGHESTMODSEQ}
    );
    push @REPAIR, $row->{mailbox} if $CONFIG{repair} and
        (!@ARGV or grep { $_ eq $row->{mailbox} } @ARGV);
}

while (@REPAIR) {
    $MAILBOX = shift @REPAIR;

    $STH_GET_INDEX->execute($MAILBOX);
    ($IDX) = $STH_GET_INDEX->fetchrow_array();
    die if defined $STH_GET_INDEX->fetch(); # sanity check

    $lIMAP->select($MAILBOX);
    $rIMAP->select($MAILBOX);
    repair($IDX, $MAILBOX);
}
exit 0 if $CONFIG{repair};


while(1) {
    while(1) {
        my $cache;
        my $update = 0;
        if (defined $MAILBOX and ($lIMAP->is_dirty($MAILBOX) or $rIMAP->is_dirty($MAILBOX))) {
            # $MAILBOX is dirty on either the local or remote mailbox
            sync_messages($IDX, $MAILBOX);
        }
        else {
            $MAILBOX = $lIMAP->next_dirty_mailbox(@ARGV) // $rIMAP->next_dirty_mailbox(@ARGV) // last;
            $MAILBOX = 'INBOX' if uc $MAILBOX eq 'INBOX'; # INBOX is case insensitive

            $STH_GET_INDEX->execute($MAILBOX);
            ($IDX) = $STH_GET_INDEX->fetchrow_array();
            die if defined $STH_GET_INDEX->fetch(); # sanity check

            $lIMAP->select($MAILBOX);
            $rIMAP->select($MAILBOX);

            # new mailbox
            if (!defined $IDX) {
                my $subscribed = (grep { $_ eq $MAILBOX} @SUBSCRIPTIONS) ? 1 : 0;
                $STH_INSERT_MAILBOX->execute($MAILBOX, $subscribed);
                $STH_GET_INDEX->execute($MAILBOX);
                ($IDX) = $STH_GET_INDEX->fetchrow_array();
                die if !defined $IDX or defined $STH_GET_INDEX->fetchrow_arrayref(); # sanity check

                $STH_INSERT_LOCAL->execute( $IDX, $lIMAP->uidvalidity($MAILBOX));
                $STH_INSERT_REMOTE->execute($IDX, $rIMAP->uidvalidity($MAILBOX));

                # don't commit before the first mapping (lUID,rUID)
            }
            elsif (sync_known_messages($IDX, $MAILBOX)) {
                # sync updates to known messages before fetching new messages
                # get_cache is safe after pull_update
                $STH_UPDATE_LOCAL_HIGHESTMODSEQ->execute( $lIMAP->get_cache('HIGHESTMODSEQ'), $IDX) or
                    msg('database', "WARNING: Can't update local HIGHESTMODSEQ for $MAILBOX");
                $STH_UPDATE_REMOTE_HIGHESTMODSEQ->execute($rIMAP->get_cache('HIGHESTMODSEQ'), $IDX) or
                    msg('database', "WARNING: Can't update remote HIGHESTMODSEQ for $MAILBOX");
                $DBH->commit();
            }
            sync_messages($IDX, $MAILBOX);
        }
    }
    # clean state!
    exit 0 unless $CONFIG{watch};
    wait_notifications(900);
}

END { cleanup(); }