<commit_before>#!/usr/bin/perl -cw

package JMAP::API;

use Carp;
use JMAP::DB;
use strict;
use warnings;
use Encode;
use HTML::GenerateUtil qw(escape_html);
use JSON::XS;
use Data::Dumper;

my $json = JSON::XS->new->utf8->canonical();

sub new {
  my $class = shift;
  my $db = shift;

  return bless {db => $db}, ref($class) || $class;
}

sub push_results {
  my $Self = shift;
  my $tag = shift;
  foreach my $result (@_) {
    $result->[2] = $tag;
    push @{$Self->{results}}, $result;
    push @{$Self->{resultsbytag}{$tag}}, $result->[1]
      unless $result->[0] eq 'error';
  }
}

sub _parsepath {
  my $path = shift;
  my $item = shift;

  return $item unless $path =~ s{^/([^/]+)}{};
  # rfc6501
  my $selector = $1;
  $selector =~ s{~1}{/}g;
  $selector =~ s{~0}{~}g;

  if (ref($item) eq 'ARRAY') {
    if ($selector eq '*') {
      my @res;
      foreach my $one (@$item) {
	my $res =  _parsepath($path, $one);
        push @res, ref($res) eq 'ARRAY' ? @$res : $res;
      }
      return @res;
    }
    if ($selector =~ m/^\d+$/) {
      return _parsepath($path, $item->[$selector]);
    }
  }
  if (ref($item) eq 'HASH') {
    return _parsepath($path, $item->{$selector});
  }

  return $item;
}

sub resolve_backref {
  my $Self = shift;
  my $tag = shift;
  my $path = shift;

  my $results = $Self->{resultsbytag}{$tag};
  die "No such result $tag" unless $results;

  return _parsepath($path, @$results);
}

sub resolve_args {
  my $Self = shift;
  my $args = shift;
  my %res;
  foreach my $key (keys %$args) {
    if ($key =~ m/^\#(.*)/) {
      my $outkey = $1;
      my $res = eval { $Self->resolve_backref($args->{$key}{resultOf}, $args->{$key}{path}) };
      if ($@) {
        return (undef, { type => 'resultReference', message => $@ });
      }
      $res{$outkey} = $res;
    }
    else {
      $res{$key} = $args->{$key};
    }
  }
  return \%res;
}

sub handle_request {
  my $Self = shift;
  my $request = shift;

  delete $Self->{results};
  delete $Self->{resultsbytag};


  foreach my $item (@$request) {
    my ($command, $args, $tag) = @$item;
    my @items;
    my $FuncRef = $Self->can($command);
    warn "JMAP CMD $command";
    if ($FuncRef) {
      my ($myargs, $error) = $Self->resolve_args($args);
      if ($myargs) {
        @items = eval { $Self->$command($myargs, $tag) };
        if ($@) {
          @items = ['error', { type => "serverError", message => "$@" }];
          eval { $Self->rollback() };
        }
      }
      else {
        push @items, ['error', $error];
        next;
      }
    }
    else {
      @items = ['error', { type => 'unknownMethod' }];
    }
    $Self->push_results($tag, @items);
  }

  return @{$Self->{results}};
}


sub setid {
  my $Self = shift;
  my $key = shift;
  my $val = shift;
  $Self->{idmap}{"#$key"} = $val;
}

sub idmap {
  my $Self = shift;
  my $key = shift;
  return unless $key;
  my $val = exists $Self->{idmap}{$key} ? $Self->{idmap}{$key} : $key;
  return $val;
}

sub getAccounts {
  my $Self = shift;
  my $args = shift;

  $Self->begin();
  my $user = $Self->{db}->get_user();
  $Self->commit();

  my @list;
  push @list, {
    id => $Self->{db}->accountid(),
    name => $user->{displayname} || $user->{email},
    isPrimary => $JSON::true,
    versions => [ 0.20150115 ],
    extensions => {
      "io.jmap.proxy" => [ 1, 2 ],
    },
    capabilities => {
      maxSizeUpload => 1073741824,
    },
    mail => {
      isReadOnly => $JSON::false,
      maxSizeMessageAttachments => 1073741824,
      canDelaySend => $JSON::false,
      messageListSortOptions => [ "date", "id" ],
    },
    contacts => {
      isReadOnly => $JSON::false,
    },
    calendars => {
      isReadOnly => $JSON::false,
    },

    # legacy
    isReadOnly => $JSON::false,
    hasMail => $JSON::true,
    hasContacts => $JSON::true,
    hasCalendars => $JSON::true,
  };

  return ['accounts', {
    state => 'dummy',
    list => \@list,
  }];
}

sub refreshSyncedCalendars {
  my $Self = shift;

  $Self->{db}->sync_calendars();

  # no response
  return ();
}

sub getPreferences {
  my $Self = shift;
  my $args = shift;

  $Self->begin();
  my $user = $Self->{db}->get_user();
  $Self->commit();

  my @list;

  return ['preferences', {
    defaultPersonalityId => "P1",
    enableConversations => $JSON::true,
  }];
}

sub getSavedSearches {
  my $Self = shift;
  my $args = shift;

  $Self->begin();
  my $user = $Self->{db}->get_user();
  $Self->commit();

  my @list;

  return ['savedSearches', {
    state => 'dummy',
    list => \@list,
  }];
}

sub getPersonalities {
  my $Self = shift;
  my $args = shift;

  $Self->begin();
  my $user = $Self->{db}->get_user();
  $Self->commit();

  my @list;
  push @list, {
    id => "P1",
    displayName => $user->{displayname} || $user->{email},
    isDeletable => $JSON::false,
    email => $user->{email},
    name => $user->{displayname} || $user->{email},
    textSignature => "-- \ntext sig",
    htmlSignature => "-- <br><b>html sig</b>",
    replyTo => $user->{email},
    autoBcc => "",
    addBccOnSMTP => $JSON::false,
    saveSentTo => undef,
    saveAttachments => $JSON::false,
    saveOnSMTP => $JSON::false,
    useForAutoReply => $JSON::false,
    isAutoConfigured => $JSON::true,
    enableExternalSMTP => $JSON::false,
    smtpServer => "",
    smtpPort => 465,
    smtpSSL => "ssl",
    smtpUser => "",
    smtpPassword => "",
    smtpRemoteService => undef,
    popLinkId => undef,
  };

  return ['personalities', {
    state => 'dummy',
    list => \@list,
  }];
}

sub begin {
  my $Self = shift;
  $Self->{db}->begin();
}

sub commit {
  my $Self = shift;
  $Self->{db}->commit();
}

sub _transError {
  my $Self = shift;
  if ($Self->{db}->in_transaction()) {
    $Self->{db}->rollback();
  }
  return @_;
}

sub getMailboxes {
  my $Self = shift;
  my $args = shift;

  $Self->begin();
  my $dbh = $Self->{db}->dbh();

  my $user = $Self->{db}->get_user();
  my $accountid = $Self->{db}->accountid();
  return $Self->_transError(['error', {type => 'accountNotFound'}])
    if ($args->{accountId} and $args->{accountId} ne $accountid);

  my $newState = "$user->{jstateMailbox}";

  my $data = $dbh->selectall_arrayref("SELECT * FROM jmailboxes WHERE active = 1", {Slice => {}});

  my %ids;
  if ($args->{ids}) {
    %ids = map { $Self->idmap($_) => 1 } @{$args->{ids}};
  }
  else {
    %ids = map { $_->{jmailboxid} => 1 } @$data;
  }

  my %byrole = map { $_->{role} => $_->{jmailboxid} } grep { $_->{role} } @$data;

  my @list;

  my %ONLY_MAILBOXES;
  foreach my $item (@$data) {
    next unless delete $ids{$item->{jmailboxid}};
    $ONLY_MAILBOXES{$item->{jmailboxid}} = $item->{mustBeOnlyMailbox};

    my %rec = (
      id => "$item->{jmailboxid}",
      parentId => ($item->{parentId} ? "$item->{parentId}" : undef),
      name => Encode::decode_utf8($item->{name}),
      role => $item->{role},
      sortOrder => $item->{sortOrder},
      (map { $_ => ($item->{$_} ? $JSON::true : $JSON::false) } qw(mustBeOnlyMailbox mayReadItems mayAddItems mayRemoveItems mayCreateChild mayRename mayDelete)),
    );

    foreach my $key (keys %rec) {
      delete $rec{$key} unless _prop_wanted($args, $key);
    }

    if (_prop_wanted($args, 'totalMessages')) {
      ($rec{totalMessages}) = $dbh->selectrow_array("SELECT COUNT(DISTINCT msgid) FROM jmessages JOIN jmessagemap USING (msgid) WHERE jmailboxid = ? AND jmessages.active = 1 AND jmessagemap.active = 1", {}, $item->{jmailboxid});
      $rec{totalMessages} += 0;
    }
    if (_prop_wanted($args, 'unreadMessages')) {
      ($rec{unreadMessages}) = $dbh->selectrow_array("SELECT COUNT(DISTINCT msgid) FROM jmessages JOIN jmessagemap USING (msgid) WHERE jmailboxid = ? AND jmessages.isUnread = 1 AND jmessages.active = 1 AND jmessagemap.active = 1", {}, $item->{jmailboxid});
      $rec{unreadMessages} += 0;
    }

    if (_prop_wanted($args, 'totalThreads')) {
      ($rec{totalThreads}) = $dbh->selectrow_array("SELECT COUNT(DISTINCT thrid) FROM jmessages JOIN jmessagemap USING (msgid) WHERE jmailboxid = ? AND jmessages.active = 1 AND jmessagemap.active = 1", {}, $item->{jmailboxid});
      $rec{totalThreads} += 0;
    }

    # for 'unreadThreads' we need to know threads with ANY unread messages,
    # so long as they aren't in an ONLY_MAILBOXES folder
    if (_prop_wanted($args, 'unreadThreads')) {
      my $folderlimit = '';
      if ($ONLY_MAILBOXES{$item->{jmailboxid}}) {
        $folderlimit = "AND jmessagemap.jmailboxid = " . $dbh->quote($item->{jmailboxid});
      } else {
        my @ids = grep { $ONLY_MAILBOXES{$_} } sort keys %ONLY_MAILBOXES;
        $folderlimit = "AND jmessagemap.jmailboxid NOT IN (" . join(',', map { $dbh->quote($_) } @ids) . ")" if @ids;
      }
      my $sql ="SELECT COUNT(DISTINCT thrid) FROM jmessages JOIN jmessagemap USING (msgid) WHERE jmailboxid = ? AND jmessages.active = 1 AND jmessagemap.active = 1 AND thrid IN (SELECT thrid FROM jmessages JOIN jmessagemap USING (msgid) WHERE isUnread = 1 AND jmessages.active = 1 AND jmessagemap.active = 1 $folderlimit)";
      ($rec{unreadThreads}) = $dbh->selectrow_array($sql, {}, $item->{jmailboxid});
      $rec{unreadThreads} += 0;
    }

    push @list, \%rec;
  }
  my %missingids = %ids;

  $Self->commit();

  return ['mailboxes', {
    list => \@list,
    accountId => $accountid,
    state => $newState,
    notFound => (%missingids ? [map { "$_" } keys %missingids] : undef),
  }];
}

sub getIdentities {
  my $Self = shift;
  my $args = shift;

  $Self->begin();
  my $user = $Self->{db}->get_user();
  my $accountid = $Self->{db}->accountid();
  $Self->commit();

  return ['identities', {
    accountId => $accountid,
    list => [
      {
        email => $user->{email},
        name => $user->{displayname},
        picture => $user->{picture},
        isDefault => $JSON::true,
      },
    ],
  }];
}

sub getMailboxUpdates {
  my $Self = shift;
  my $args = shift;

  $Self->begin();
  my $dbh = $Self->{db}->dbh();
  my $user = $Self->{db}->get_user();
  my $accountid = $Self->{db}->accountid();
  return $Self->_transError(['error', {type => 'accountNotFound'}])
    if ($args->{accountId} and $args->{accountId} ne $accountid);

  my $newState = "$user->{jstateMailbox}";

  my $sinceState = $args->{sinceState};
  return $Self->_transError(['error', {type => 'invalidArguments'}])
    if not $args->{sinceState};
  return $Self->_transError(['error', {type => 'cannotCalculateChanges', newState => $newState}])
    if ($user->{jdeletedmodseq} and $sinceState <= $user->{jdeletedmodseq});

  my $data = $dbh->selectall_arrayref("SELECT * FROM jmailboxes WHERE jmodseq > ?1 OR jcountsmodseq > ?1", {Slice => {}}, $sinceState);

  my @changed;
  my @removed;
  my $onlyCounts = 1;
  foreach my $item (@$data) {
    if ($item->{active}) {
      push @changed, $item->{jmailboxid};
      $onlyCounts = 0 if $item->{jmodseq} > $sinceState;
    }
    else {
      push @removed, $item->{jmailboxid};
    }
  }

  $Self->commit();

  my @res = (['mailboxUpdates', {
    accountId => $accountid,
    oldState => "$sinceState",
    newState => $newState,
    changed => [map { "$_" } @changed],
    removed => [map { "$_" } @removed],
    onlyCountsChanged => $onlyCounts ? JSON::true : JSON::false,
  }]);

  return @res;
}

sub _patchitem {
  my $target = shift;
  my $key = shift;
  my $value = shift;

  Carp::confess "missing patch target" unless ref($target) eq 'HASH';

  if ($key =~ s{^([^/]+)/}{}) {
    my $item = $1;
    $item =~ s{~1}{/}g;<commit_msg>API: counts has been renamed<commit_after>#!/usr/bin/perl -cw

package JMAP::API;

use Carp;
use JMAP::DB;
use strict;
use warnings;
use Encode;
use HTML::GenerateUtil qw(escape_html);
use JSON::XS;
use Data::Dumper;

my $json = JSON::XS->new->utf8->canonical();

sub new {
  my $class = shift;
  my $db = shift;

  return bless {db => $db}, ref($class) || $class;
}

sub push_results {
  my $Self = shift;
  my $tag = shift;
  foreach my $result (@_) {
    $result->[2] = $tag;
    push @{$Self->{results}}, $result;
    push @{$Self->{resultsbytag}{$tag}}, $result->[1]
      unless $result->[0] eq 'error';
  }
}

sub _parsepath {
  my $path = shift;
  my $item = shift;

  return $item unless $path =~ s{^/([^/]+)}{};
  # rfc6501
  my $selector = $1;
  $selector =~ s{~1}{/}g;
  $selector =~ s{~0}{~}g;

  if (ref($item) eq 'ARRAY') {
    if ($selector eq '*') {
      my @res;
      foreach my $one (@$item) {
	my $res =  _parsepath($path, $one);
        push @res, ref($res) eq 'ARRAY' ? @$res : $res;
      }
      return @res;
    }
    if ($selector =~ m/^\d+$/) {
      return _parsepath($path, $item->[$selector]);
    }
  }
  if (ref($item) eq 'HASH') {
    return _parsepath($path, $item->{$selector});
  }

  return $item;
}

sub resolve_backref {
  my $Self = shift;
  my $tag = shift;
  my $path = shift;

  my $results = $Self->{resultsbytag}{$tag};
  die "No such result $tag" unless $results;

  return _parsepath($path, @$results);
}

sub resolve_args {
  my $Self = shift;
  my $args = shift;
  my %res;
  foreach my $key (keys %$args) {
    if ($key =~ m/^\#(.*)/) {
      my $outkey = $1;
      my $res = eval { $Self->resolve_backref($args->{$key}{resultOf}, $args->{$key}{path}) };
      if ($@) {
        return (undef, { type => 'resultReference', message => $@ });
      }
      $res{$outkey} = $res;
    }
    else {
      $res{$key} = $args->{$key};
    }
  }
  return \%res;
}

sub handle_request {
  my $Self = shift;
  my $request = shift;

  delete $Self->{results};
  delete $Self->{resultsbytag};


  foreach my $item (@$request) {
    my ($command, $args, $tag) = @$item;
    my @items;
    my $FuncRef = $Self->can($command);
    warn "JMAP CMD $command";
    if ($FuncRef) {
      my ($myargs, $error) = $Self->resolve_args($args);
      if ($myargs) {
        @items = eval { $Self->$command($myargs, $tag) };
        if ($@) {
          @items = ['error', { type => "serverError", message => "$@" }];
          eval { $Self->rollback() };
        }
      }
      else {
        push @items, ['error', $error];
        next;
      }
    }
    else {
      @items = ['error', { type => 'unknownMethod' }];
    }
    $Self->push_results($tag, @items);
  }

  return @{$Self->{results}};
}


sub setid {
  my $Self = shift;
  my $key = shift;
  my $val = shift;
  $Self->{idmap}{"#$key"} = $val;
}

sub idmap {
  my $Self = shift;
  my $key = shift;
  return unless $key;
  my $val = exists $Self->{idmap}{$key} ? $Self->{idmap}{$key} : $key;
  return $val;
}

sub getAccounts {
  my $Self = shift;
  my $args = shift;

  $Self->begin();
  my $user = $Self->{db}->get_user();
  $Self->commit();

  my @list;
  push @list, {
    id => $Self->{db}->accountid(),
    name => $user->{displayname} || $user->{email},
    isPrimary => $JSON::true,
    versions => [ 0.20150115 ],
    extensions => {
      "io.jmap.proxy" => [ 1, 2 ],
    },
    capabilities => {
      maxSizeUpload => 1073741824,
    },
    mail => {
      isReadOnly => $JSON::false,
      maxSizeMessageAttachments => 1073741824,
      canDelaySend => $JSON::false,
      messageListSortOptions => [ "date", "id" ],
    },
    contacts => {
      isReadOnly => $JSON::false,
    },
    calendars => {
      isReadOnly => $JSON::false,
    },

    # legacy
    isReadOnly => $JSON::false,
    hasMail => $JSON::true,
    hasContacts => $JSON::true,
    hasCalendars => $JSON::true,
  };

  return ['accounts', {
    state => 'dummy',
    list => \@list,
  }];
}

sub refreshSyncedCalendars {
  my $Self = shift;

  $Self->{db}->sync_calendars();

  # no response
  return ();
}

sub getPreferences {
  my $Self = shift;
  my $args = shift;

  $Self->begin();
  my $user = $Self->{db}->get_user();
  $Self->commit();

  my @list;

  return ['preferences', {
    defaultPersonalityId => "P1",
    enableConversations => $JSON::true,
  }];
}

sub getSavedSearches {
  my $Self = shift;
  my $args = shift;

  $Self->begin();
  my $user = $Self->{db}->get_user();
  $Self->commit();

  my @list;

  return ['savedSearches', {
    state => 'dummy',
    list => \@list,
  }];
}

sub getPersonalities {
  my $Self = shift;
  my $args = shift;

  $Self->begin();
  my $user = $Self->{db}->get_user();
  $Self->commit();

  my @list;
  push @list, {
    id => "P1",
    displayName => $user->{displayname} || $user->{email},
    isDeletable => $JSON::false,
    email => $user->{email},
    name => $user->{displayname} || $user->{email},
    textSignature => "-- \ntext sig",
    htmlSignature => "-- <br><b>html sig</b>",
    replyTo => $user->{email},
    autoBcc => "",
    addBccOnSMTP => $JSON::false,
    saveSentTo => undef,
    saveAttachments => $JSON::false,
    saveOnSMTP => $JSON::false,
    useForAutoReply => $JSON::false,
    isAutoConfigured => $JSON::true,
    enableExternalSMTP => $JSON::false,
    smtpServer => "",
    smtpPort => 465,
    smtpSSL => "ssl",
    smtpUser => "",
    smtpPassword => "",
    smtpRemoteService => undef,
    popLinkId => undef,
  };

  return ['personalities', {
    state => 'dummy',
    list => \@list,
  }];
}

sub begin {
  my $Self = shift;
  $Self->{db}->begin();
}

sub commit {
  my $Self = shift;
  $Self->{db}->commit();
}

sub _transError {
  my $Self = shift;
  if ($Self->{db}->in_transaction()) {
    $Self->{db}->rollback();
  }
  return @_;
}

sub getMailboxes {
  my $Self = shift;
  my $args = shift;

  $Self->begin();
  my $dbh = $Self->{db}->dbh();

  my $user = $Self->{db}->get_user();
  my $accountid = $Self->{db}->accountid();
  return $Self->_transError(['error', {type => 'accountNotFound'}])
    if ($args->{accountId} and $args->{accountId} ne $accountid);

  my $newState = "$user->{jstateMailbox}";

  my $data = $dbh->selectall_arrayref("SELECT * FROM jmailboxes WHERE active = 1", {Slice => {}});

  my %ids;
  if ($args->{ids}) {
    %ids = map { $Self->idmap($_) => 1 } @{$args->{ids}};
  }
  else {
    %ids = map { $_->{jmailboxid} => 1 } @$data;
  }

  my %byrole = map { $_->{role} => $_->{jmailboxid} } grep { $_->{role} } @$data;

  my @list;

  my %ONLY_MAILBOXES;
  foreach my $item (@$data) {
    next unless delete $ids{$item->{jmailboxid}};
    $ONLY_MAILBOXES{$item->{jmailboxid}} = $item->{mustBeOnlyMailbox};

    my %rec = (
      id => "$item->{jmailboxid}",
      parentId => ($item->{parentId} ? "$item->{parentId}" : undef),
      name => Encode::decode_utf8($item->{name}),
      role => $item->{role},
      sortOrder => $item->{sortOrder},
      (map { $_ => ($item->{$_} ? $JSON::true : $JSON::false) } qw(mustBeOnlyMailbox mayReadItems mayAddItems mayRemoveItems mayCreateChild mayRename mayDelete)),
    );

    foreach my $key (keys %rec) {
      delete $rec{$key} unless _prop_wanted($args, $key);
    }

    if (_prop_wanted($args, 'totalMessages')) {
      ($rec{totalMessages}) = $dbh->selectrow_array("SELECT COUNT(DISTINCT msgid) FROM jmessages JOIN jmessagemap USING (msgid) WHERE jmailboxid = ? AND jmessages.active = 1 AND jmessagemap.active = 1", {}, $item->{jmailboxid});
      $rec{totalMessages} += 0;
    }
    if (_prop_wanted($args, 'unreadMessages')) {
      ($rec{unreadMessages}) = $dbh->selectrow_array("SELECT COUNT(DISTINCT msgid) FROM jmessages JOIN jmessagemap USING (msgid) WHERE jmailboxid = ? AND jmessages.isUnread = 1 AND jmessages.active = 1 AND jmessagemap.active = 1", {}, $item->{jmailboxid});
      $rec{unreadMessages} += 0;
    }

    if (_prop_wanted($args, 'totalThreads')) {
      ($rec{totalThreads}) = $dbh->selectrow_array("SELECT COUNT(DISTINCT thrid) FROM jmessages JOIN jmessagemap USING (msgid) WHERE jmailboxid = ? AND jmessages.active = 1 AND jmessagemap.active = 1", {}, $item->{jmailboxid});
      $rec{totalThreads} += 0;
    }

    # for 'unreadThreads' we need to know threads with ANY unread messages,
    # so long as they aren't in an ONLY_MAILBOXES folder
    if (_prop_wanted($args, 'unreadThreads')) {
      my $folderlimit = '';
      if ($ONLY_MAILBOXES{$item->{jmailboxid}}) {
        $folderlimit = "AND jmessagemap.jmailboxid = " . $dbh->quote($item->{jmailboxid});
      } else {
        my @ids = grep { $ONLY_MAILBOXES{$_} } sort keys %ONLY_MAILBOXES;
        $folderlimit = "AND jmessagemap.jmailboxid NOT IN (" . join(',', map { $dbh->quote($_) } @ids) . ")" if @ids;
      }
      my $sql ="SELECT COUNT(DISTINCT thrid) FROM jmessages JOIN jmessagemap USING (msgid) WHERE jmailboxid = ? AND jmessages.active = 1 AND jmessagemap.active = 1 AND thrid IN (SELECT thrid FROM jmessages JOIN jmessagemap USING (msgid) WHERE isUnread = 1 AND jmessages.active = 1 AND jmessagemap.active = 1 $folderlimit)";
      ($rec{unreadThreads}) = $dbh->selectrow_array($sql, {}, $item->{jmailboxid});
      $rec{unreadThreads} += 0;
    }

    push @list, \%rec;
  }
  my %missingids = %ids;

  $Self->commit();

  return ['mailboxes', {
    list => \@list,
    accountId => $accountid,
    state => $newState,
    notFound => (%missingids ? [map { "$_" } keys %missingids] : undef),
  }];
}

sub getIdentities {
  my $Self = shift;
  my $args = shift;

  $Self->begin();
  my $user = $Self->{db}->get_user();
  my $accountid = $Self->{db}->accountid();
  $Self->commit();

  return ['identities', {
    accountId => $accountid,
    list => [
      {
        email => $user->{email},
        name => $user->{displayname},
        picture => $user->{picture},
        isDefault => $JSON::true,
      },
    ],
  }];
}

sub getMailboxUpdates {
  my $Self = shift;
  my $args = shift;

  $Self->begin();
  my $dbh = $Self->{db}->dbh();
  my $user = $Self->{db}->get_user();
  my $accountid = $Self->{db}->accountid();
  return $Self->_transError(['error', {type => 'accountNotFound'}])
    if ($args->{accountId} and $args->{accountId} ne $accountid);

  my $newState = "$user->{jstateMailbox}";

  my $sinceState = $args->{sinceState};
  return $Self->_transError(['error', {type => 'invalidArguments'}])
    if not $args->{sinceState};
  return $Self->_transError(['error', {type => 'cannotCalculateChanges', newState => $newState}])
    if ($user->{jdeletedmodseq} and $sinceState <= $user->{jdeletedmodseq});

  my $data = $dbh->selectall_arrayref("SELECT * FROM jmailboxes WHERE jmodseq > ?1 OR jcountsmodseq > ?1", {Slice => {}}, $sinceState);

  my @changed;
  my @removed;
  my $onlyCounts = 1;
  foreach my $item (@$data) {
    if ($item->{active}) {
      push @changed, $item->{jmailboxid};
      $onlyCounts = 0 if $item->{jmodseq} > $sinceState;
    }
    else {
      push @removed, $item->{jmailboxid};
    }
  }

  $Self->commit();

  my @res = (['mailboxUpdates', {
    accountId => $accountid,
    oldState => "$sinceState",
    newState => $newState,
    changed => [map { "$_" } @changed],
    removed => [map { "$_" } @removed],
    changedProperties => $onlyCounts ? ["totalMessages", "unreadMessages", "totalThreads", "unreadThreads"] : JSON::null,
  }]);

  return @res;
}

sub _patchitem {
  my $target = shift;
  my $key = shift;
  my $value = shift;

  Carp::confess "missing patch target" unless ref($target) eq 'HASH';

  if ($key =~ s{^([^/]+)/}{}) {
    my $item = $1;
    $item =~ s{~1}{/}g;
{'fraction_non_alphanumeric': 0.2033266915013428, 'fraction_numerical': 0.006410811747379364, 'mean_word_length': 3.0263341471921867, 'pattern_counts': {'":': 0, '<': 11, '<?xml version=': 0, '>': 467, 'https://': 0, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 0, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'dir': 'git-commits-cleaned', 'id': '557689', 'n_tokens_mistral': 8733, 'n_tokens_neox': 8429, 'n_words': 1924}