#!/opt/bin/perl

=encoding utf8

=head1 NAME

mcp-imap - inspect and manage one IMAP account through MCP

=head1 SYNOPSIS

   mcp-imap [OPTION]...
   mcp-imap [OPTION]... login [method password|oauth2] [server HOST]
       [user USER] [tls ssl|starttls] [port PORT] [oauth-KEY VALUE]...
   mcp-imap [OPTION]... providers [SEARCH]

=head1 DESCRIPTION

Each C<--name> selects one IMAP account and an independent MCP permission
profile. Configure the account with C<login>, then run the MCP server with
C<--stdio>, C<--connect>, or an HTTP listener. Login takes passwords from
the controlling terminal without echo, never from command-line arguments
or MCP tools.

The account is stored as C<imap-NAME.ini> under
C<$XDG_CONFIG_HOME/anyevent-mcp> or C<~/.config/anyevent-mcp>, mode 0600.
Password data is Base64 in that private file, not encrypted. Protect the
file like a password. Repeating C<login> replaces the saved password after
a successful connection. For C<login method oauth2>, it prints a browser
authorization URL and accepts the resulting redirect URL from the terminal,
without listening on a callback port. The private file stores the refresh
token; later IMAP sessions refresh access tokens automatically. Neither
passwords nor OAuth tokens are accepted as command-line values.
Unencrypted IMAP authentication is not supported.

C<providers> searches Thunderbird's ISPDB domain index and OAuth provider
definitions. With no argument it lists known domains. A domain, substring,
or full email address selects a provider and looks up provider-hosted HTTPS
autoconfig (preferred) or Thunderbird's ISPDB as a fallback. It prints
shell-quoted password login commands for supported IMAP/TLS alternatives,
provider setup instructions, and OAuth commands if a matching public OAuth
registration is known. An email address fills in the username.
The commands are NOT executed. Thunderbird's client registrations may not
be usable by unrelated applications. OAuth authorization requests need
registered redirect URIs and may require provider-specific optional
C<oauth-auth-NAME VALUE> arguments (e.g. C<oauth-auth-access_type offline>).

OAuth2 login accepts C<oauth-client-id>, optional C<oauth-client-secret>,
C<oauth-authorization-endpoint>, C<oauth-token-endpoint>, C<oauth-scope>,
and C<oauth-redirect-uri>. Optional C<oauth-client-auth> selects
C<none>, C<client_secret_post> (default when a secret is supplied) or
C<client_secret_basic>. C<oauth-pkce> defaults to C<1> (S256), or C<0>
when a provider registration does not support PKCE. C<oauth-sasl> selects
C<xoauth2> (default) or C<oauthbearer>, depending on the IMAP server.
Additional authorization query parameters use C<oauth-auth-NAME VALUE>.
Successful OAuth2 login requires a refresh token; provider-specific offline
access parameters may be necessary. Authorization is human-initiated only,
never exposed through an MCP tool. After authorization, the browser will
normally show an error page because no server is listening on the loopback
redirect URI. B<This is expected; copy the full redirect URL from the address
bar anyway.> Port 1 works with Chromium's error-page behavior, but provider
redirect registrations and other browsers may differ.

IMAP runs in a separate persistent synchronous worker, so an IMAP command
does not block the MCP event loop. One request at a time is processed by the
worker. A dropped connection during a write can leave an unknown outcome;
do not blindly retry such writes.

Message references consist of mailbox, UIDVALIDITY and UID. Read operations
use BODY.PEEK, never mark messages Seen. Deleted flags do not automatically
expunge. EXPUNGE is explicit. Selecting another mailbox or logging out does
not expunge. Search is bounded by UID windows, potentially returning a
partial/empty page with a continuation cursor when matches are sparse.

=head1 FIRST USE

Discover your provider's settings before logging in:

   mcp-imap --name personal providers alice@example.net

Replace the example address. This reports possible IMAP settings and
login commands; provider-specific OAuth client registrations may be
restricted to their original application. For a password-based provider,
for example:

   mcp-imap --name personal login method password \
      server imap.example.net user alice@example.net tls ssl port 993

The password is read interactively. For OAuth2, run C<login method oauth2>
with your provider-approved client ID, authorization/token endpoints,
scope and redirect URI. For example, the structure of such a command
(the provider URLs and registration below are placeholders) is:

   mcp-imap --name personal login method oauth2 \
      server imap.example.net user alice@example.net tls ssl port 993 \
      oauth-client-id REGISTERED_CLIENT_ID \
      oauth-authorization-endpoint https://login.example.net/authorize \
      oauth-token-endpoint https://login.example.net/token \
      oauth-scope MAIL_SCOPE \
      oauth-redirect-uri http://127.0.0.1:1

The redirect URI must be allowed by the OAuth client registration. Some
providers additionally require C<oauth-auth-access_type offline> or
other authorization parameters to issue a refresh token. After opening
the printed authorization URL, authorize the intended account and paste
the B<full> redirect URL from the browser's address bar even if the browser
reports an unreachable loopback page. Login checks IMAP authentication
before saving the account.

Then configure your MCP client to start:

   mcp-imap --name personal --stdio

Or expose it through a multibroker:

   mcp-multibroker --stdio mail --- mcp-imap --name personal --stdio ---

Ask the assistant:

   List my subscribed mailboxes, then show the ten newest subjects
   and senders in INBOX. Do not modify flags or move messages.

For example, to subscribe a folder called C<Receipts>, invoke C<mailbox>
with C<{"operation":"subscribe","mailbox":"Receipts"}>, then call C<lsub>
to see subscribed folders. Read operations request C<read> permission;
changing subscriptions requires C<manage>. A mailbox-specific permission
prompt can allow just that mailbox or all mailboxes.

To let a separately started server join an existing multibroker with
an HTTP/extension listener, run:

   mcp-imap --name personal --connect http://127.0.0.1:31740/TOKEN/mail

Replace C<TOKEN> with the token obtained using the multibroker's
C<--print-url>. Use C<https://> with a trusted, matching certificate.

The C<lsub> tool lists subscribed mailboxes; C<mailboxes> lists all
visible mailboxes. Subscriptions affect folder selection in clients,
not message delivery or permissions. See
L<AnyEvent::MCP::Intro/Read and organize mail> for search and safety
examples.

=head1 TOOLS

=over

=item mailboxes

List visible mailboxes. The C<mailboxes> result contains triples of
C<[attributes, hierarchy_separator, name]>. One entry might be
C<[["\\HasNoChildren"], "/", "INBOX"]>. Mailboxes marked C<\\Noselect>
are containers, not selectable message folders.

=item lsub

List subscribed mailboxes (IMAP LSUB). Uses the same result format as C<mailboxes>.

=item messages

List at most 100 message summaries, newest UID first. Optional SEARCH
filter, summary fields, header names and UIDVALIDITY-aware cursor.
C<incomplete> means the search reached its 20000-UID scan budget:
continue with C<next_cursor> even if the page contains no messages.

C<filter> combines its keys with AND. Text search keys are C<from>, C<to>,
C<cc>, C<bcc>, C<subject>, C<body> and C<text> (headers and body).
C<header> maps header names to search strings. Date keys are C<since>,
C<before>, C<on>, C<sent_since>, C<sent_before> and C<sent_on>, with dates
as C<D-Mon-YYYY>. C<since>/C<before>/C<on> use the IMAP internal date;
C<sent_*> keys use the message's Date header. C<larger> and C<smaller>
filter by message size. Boolean flags include C<seen>, C<unseen>,
C<answered>, C<unanswered>, C<flagged>, C<unflagged>, C<deleted>,
C<undeleted>, C<draft>, C<undraft>, C<recent>, C<old> and C<new>.
C<all> and C<any> contain arrays of nested filters (AND and OR);
C<not> contains one nested filter. For example:

   {
     "mailbox": "INBOX",
     "limit": 20,
     "filter": {
       "since": "1-Nov-2026",
       "any": [
         {"from": "alice@example.net"},
         {"subject": "invoice", "body": "overdue"}
       ],
       "not": {"flagged": true}
     }
   }

The example finds messages with an internal date since November 1 from
Alice or containing both invoice in the subject and overdue in the body,
excluding flagged mail. Filter nesting and size are bounded; search
values are explicitly quoted as IMAP arguments, never interpolated into
raw SEARCH commands.

=item message

Read ENVELOPE, flags, dates, size and MIME BODYSTRUCTURE for a UID.
Use the C<uidvalidity> and C<uid> returned by C<messages> for the selected
mailbox. For example, if the current UIDVALIDITY is 13 and the UID is 42:

   {"mailbox": "INBOX", "uidvalidity": "13", "uid": 42}

=item part

Read a MIME part using BODY.PEEK with a byte ceiling. Returns Base64
raw transferred bytes; C<decode_text> additionally decodes complete text
parts using MIME transfer encoding and charset.

=item raw

Read raw RFC 5322 message bytes (bounded, Base64). Never sets Seen.

=item flags

Add/remove a set of IMAP flags, including Deleted (but never expunge).

=item copy, move

Copy or move named UIDs. MOVE requires server MOVE capability and has no
unsafe emulation.

=item append

APPEND a Base64-encoded RFC 5322 message up to 1 MiB. Use CRLF line
endings. Reject unescaped mbox From_ separator-like lines in the raw
message unless C<--allow-mbox-from> is set. Refusal identifies the raw
line number and explains the mboxo hazard. This uploads bytes unchanged;
it is not an SMTP send operation.

=item expunge

Permanently expunge explicitly named deleted UIDs when UIDPLUS is available,
or all Deleted messages in the selected mailbox when C<uids> is omitted.
Never fall back from UID EXPUNGE to whole-mailbox EXPUNGE.

=item mailbox

Create, rename, delete, subscribe or unsubscribe a mailbox.

=back

=head1 PERMISSIONS

The independent profile is C<imap-NAME>, with C<read>, C<modify>,
C<manage> and C<expunge> actions. Each tool checks permission before
network activity. Mailbox-specific operations can be approved for one
mailbox or all mailboxes; C<mailboxes> and C<lsub> list folders without
selecting a mailbox. This worker is NOT filesystem- or network-sandboxed;
permissions authorize operations, not OS-level confinement.

=head1 OPTIONS

Shared switches are documented in
L<AnyEvent::MCP::Server::Simple/COMMON COMMAND-LINE OPTIONS>.
The following switches are specific to C<mcp-imap>.

=over

=item --ask MODE

Choose where permission prompts appear: C<helper> (default when run
independently) uses a local helper; C<tty> and C<yad> force those
local interfaces without fallback. C<mcp> delegates prompts to the
MCP client; C<auto> uses client elicitation when advertised and
otherwise falls back to the helper. A server spawned by a multibroker
defaults to C<mcp> so the broker can present its prompts.

=item --allow-mbox-from

Allow APPEND messages with unescaped mbox From_ separator-like lines.
Unsafe with mboxo servers which fail to escape these lines. Off by default;
can only be enabled by the server operator, not through an MCP tool.

=item --provider-file FILE

Use FILE as a local Thunderbird OAuth JavaScript source with the
C<providers> command. Invalid for normal server startup or C<login>.

=item --ispdb-file FILE

Use FILE as a local Thunderbird autoconfiguration XML source with the
C<providers> command. Invalid for normal server startup or C<login>.

=back

=cut

use common::sense;
use MIME::Base64 ();
use AnyEvent ();

use AnyEvent::Fork::Early;

use Term::ReadKey ();
use AnyEvent::Fork ();
use AnyEvent::Fork::RPC ();
use AnyEvent::MCP ();
use AnyEvent::MCP::File ();
use AnyEvent::MCP::Config ();
use AnyEvent::MCP::Server::Permissions ();
use JSON::XS ();

use AnyEvent::MCP::Server::Simple;

my $options = mcp_getoptions
   {
      usage       => "Usage: mcp-imap [OPTION]... [login [key value ...] | providers [SEARCH]]\n",
      description => "Serve one named IMAP account; 'login' configures password or OAuth2; 'providers' discovers mail setup.",
      options     => [["--ask MODE", "Permission prompts: helper (default), tty, yad, mcp, auto"],
                      ["--allow-mbox-from", "Allow potentially unsafe unescaped mbox From_ lines in APPEND"],
                      ["--provider-file FILE", "Use local Thunderbird OAuth source with providers"],
                      ["--ispdb-file FILE", "Use local autoconfig XML with providers"]],
      examples    => "  mcp-imap --name doom login server doom user root tls starttls\n"
                   . "  mcp-imap --name gmail providers gmail\n"
                   . "  mcp-imap --name doom --stdio",
   },
   "ask=s" => \my $ask,
   "allow-mbox-from!" => \my $allow_mbox_from,
   "provider-file=s" => \my $provider_file,
   "ispdb-file=s" => \my $ispdb_file,
;

my $name = $options->{name};
if (@ARGV && $ARGV[0] eq "providers") {
   shift @ARGV;
   require AnyEvent::MCP::IMAP::Providers;
   AnyEvent::MCP::IMAP::Providers::run ($name, $provider_file, $ispdb_file, @ARGV);
   exit;
}
(defined $provider_file || defined $ispdb_file)
   and die "--provider-file and --ispdb-file are only for providers\n";

my $directory = ($ENV{XDG_CONFIG_HOME} || ($ENV{HOME} || die "HOME is not set\n") . "/.config")
              . "/anyevent-mcp";
my $file = "$directory/imap-$name.ini";

sub load_account {
   return {} unless -e $file;

   open my $fh, "<:raw", $file or die "$file: $!\n";
   local $/;
   my $text = <$fh>;
   close $fh;
   my %kv;
   AnyEvent::MCP::parse_ini $text, sub {
      my ($section, $values) = @_;
      $section eq "" || $section eq "account"
         or die "$file: unexpected section $section\n";
      %kv = (%kv, %$values);
   }, $file;

   \%kv
}

sub settings {
   my ($kv) = @_;

   for (qw(server user tls port)) {
      defined $kv->{$_} && length $kv->{$_} or die "$file: missing $_; run mcp-imap --name $name login\n";
   }
   $kv->{tls} =~ /\A(?:ssl|starttls)\z/ or die "$file: invalid tls mode\n";
   $kv->{port} =~ /\A[0-9]+\z/ && $kv->{port} >= 1 && $kv->{port} <= 65535
      or die "$file: invalid port\n";
   $kv->{server} !~ /[\s\x00-\x1f]/ && length $kv->{server} <= 255
      or die "$file: invalid server\n";
   $kv->{user} !~ /[\r\n\x00]/ or die "$file: invalid user\n";

   my %connection = map +($_ => $kv->{$_}), qw(server user port tls);
   my $method = $kv->{method} || "password";
   if ($method eq "oauth2") {
      require AnyEvent::MCP::IMAP::OAuth2;
      AnyEvent::MCP::IMAP::OAuth2::validate ($kv);
      defined $kv->{"oauth-refresh-token-base64"}
         && $kv->{"oauth-refresh-token-base64"} =~ /\A[A-Za-z0-9+\/]*={0,2}\z/
         && length $kv->{"oauth-refresh-token-base64"}
         or die "$file: missing or invalid OAuth refresh token; run mcp-imap login\n";
      return {
         %connection, method => "oauth2", account_file => $file, oauth => { %$kv },
      };
   }

   $method eq "password" or die "$file: invalid login method\n";
   defined $kv->{"password-base64"}
      && $kv->{"password-base64"} =~ /\A[A-Za-z0-9+\/]*={0,2}\z/
      && length $kv->{"password-base64"}
      or die "$file: missing or invalid password encoding\n";
   +{
      %connection, method => "password",
      password => MIME::Base64::decode_base64 ($kv->{"password-base64"}),
   }
}

sub login {
   @ARGV % 2 == 0 or die "login expects key/value pairs\n";
   my %input;
   while (@ARGV) {
      my ($key, $value) = splice @ARGV, 0, 2;
      $key =~ /\A(?:method|server|user|tls|port|oauth-(?:client-id|client-secret|client-auth|authorization-endpoint|token-endpoint|scope|pkce|redirect-uri|sasl|auth-[A-Za-z0-9_]+))\z/
         or die "Unknown login key '$key' (passwords and tokens cannot be given on argv)\n";
      $value !~ /[\r\n\x00]/ or die "Invalid $key\n";
      $input{$key} = $value;
   }
   my $old = load_account ();
   my $method = $input{method} || $old->{method} || "password";
   $method =~ /\A(?:password|oauth2)\z/ or die "Unknown login method\n";

   my %kv = %$old;
   %kv = map +(exists $old->{$_} ? ($_ => $old->{$_}) : ()), qw(server user tls port)
      if ($old->{method} || "password") ne $method;
   %kv = (%kv, %input);
   $kv{method} = $method;
   delete $kv{"password-base64"} if $method eq "oauth2";
   delete @kv{grep /^oauth-/, keys %kv} if $method eq "password";

   for my $key (qw(server user)) {
      next if defined $kv{$key} && length $kv{$key};
      print STDERR "$key: ";
      chomp ($kv{$key} = <STDIN> // "");
   }
   $kv{tls} ||= "ssl";
   $kv{port} ||= $kv{tls} eq "ssl" ? 993 : 143;

   my $account;
   if ($method eq "oauth2") {
      require AnyEvent::MCP::IMAP::OAuth2;
      AnyEvent::MCP::IMAP::OAuth2::validate (\%kv);
      my ($url, $state, $verifier) = AnyEvent::MCP::IMAP::OAuth2::authorize (\%kv);
      print STDERR "Authorize the mail account '$kv{user}', not a different account already signed into the browser.\n";
      print STDERR "Open this OAuth authorization URL in a browser:\n$url\n\n";
      print STDERR "After authorization, the browser will display an error page because no server listens at the redirect URI.\n";
      print STDERR "This is expected. Copy the FULL URL from the browser address bar and paste it here.\n";
      open my $tty, "+<", "/dev/tty"
         or die "OAuth callback requires a controlling terminal: $!\n";
      print {$tty} "Paste redirected URL: ";
      Term::ReadKey::ReadMode ("noecho", $tty);
      my $redirect = <$tty>;
      Term::ReadKey::ReadMode ("restore", $tty);
      print {$tty} "\n";
      close $tty;
      defined $redirect or die "No OAuth redirect URL supplied\n";
      $redirect =~ s/\r?\n\z//;
      my $tokens = AnyEvent::MCP::IMAP::OAuth2::finish (\%kv, $state, $verifier, $redirect);
      my $refresh = $tokens->{refresh_token}
         or die "OAuth provider returned no refresh token; configure offline access and try again\n";
      $kv{"oauth-refresh-token-base64"} = MIME::Base64::encode_base64 ($refresh, "");
      $account = settings (\%kv);
      $account->{access_token} = $tokens->{access_token};
   } else {
      open my $tty, "+<", "/dev/tty"
         or die "Password requires a controlling terminal: $!\n";
      print {$tty} "IMAP password for $kv{user}\@$kv{server}: ";
      Term::ReadKey::ReadMode ("noecho", $tty);
      my $password = <$tty>;
      Term::ReadKey::ReadMode ("restore", $tty);
      print {$tty} "\n";
      close $tty;
      defined $password or die "No password supplied\n";
      $password =~ s/\r?\n\z//;
      length $password or die "Empty password\n";
      $kv{"password-base64"} = MIME::Base64::encode_base64 ($password, "");
      $account = settings (\%kv);
   }

   require AnyEvent::MCP::Worker::IMAP;
   AnyEvent::MCP::Worker::IMAP::imap_connect ($account);

   my $lock = AnyEvent::MCP::File::lock $file;
   AnyEvent::MCP::File::replace $file, "[account]\n"
      . (join "", map "$_ = $kv{$_}\n", sort keys %kv);
   close $lock;

   print STDERR "Authenticated and saved account '$name' in $file\n";
}

if (@ARGV && $ARGV[0] eq "login") {
   shift @ARGV;
   login ();
   exit;
}

@ARGV and die "Unexpected arguments (try 'login')\n";

$ask //= $ENV{PERL_ANYEVENT_MCP_MULTIBROKER_PROTOCOL} eq "stdio"
   ? "mcp" : $ENV{PERL_ANYEVENT_MCP_PERMISSION_ASK} || "helper";
$ask =~ /\A(?:helper|tty|yad|mcp|auto)\z/ or die "Invalid --ask mode\n";

my $permissions = AnyEvent::MCP::Server::Permissions->new (
   name       => "imap-$name",
   actions    => {
      read    => "Read mailboxes and messages",
      modify  => "Change message flags, copy, move or append mail",
      manage  => "Create, rename, delete or subscribe mailboxes",
      expunge => "Permanently remove messages marked Deleted",
   },
   ask        => $ask,
);

mcp_config
   permissions        => $permissions,
   server_name        => "mcp-imap",
   server_version     => $AnyEvent::MCP::VERSION,
   short_instructions => "Inspect and manage IMAP mail, by mailbox/UIDVALIDITY/UID. Reads do not set Seen; deletion requires explicit expunge.",
   instructions       => "One account per named mcp-imap instance. Read mailboxes and pages of messages; fetch MIME parts. "
                      . "Message operations require current UIDVALIDITY. Reads never set Seen. "
                      . "Deleting flags and expunging are separate; expunge permanently removes mail. "
                      . "An IMAP write interrupted by a disconnect has unknown outcome: never blindly repeat.";

my ($rpc, $rpc_dead, $connected);
my %pending;
my $seq = 0;

sub start_worker {
   return if $rpc;
   require AnyEvent::MCP::Worker::IMAP; # dependency check, no connection until authorized
   $rpc_dead = undef;

   $rpc = AnyEvent::Fork
      ->new
      ->require ("AnyEvent::MCP::Worker::IMAP")
      ->AnyEvent::Fork::RPC::run ("AnyEvent::MCP::Worker::IMAP::run",
         serialiser => $AnyEvent::Fork::RPC::JSON_SERIALISER,
         on_error => sub {
            $rpc_dead = $_[0];
            undef $rpc;
            $connected = undef;
            for my $id (keys %pending) {
               my $req = delete $pending{$id};
               $req->error ("IMAP worker disconnected; outcome of in-flight writes unknown: $rpc_dead")
                  unless $req->{finished};
            }
         },
      );

   $connected = 0;
   my $account = settings (load_account ());
   $account->{allow_mbox_from} = $allow_mbox_from ? JSON::XS::true : JSON::XS::false;
   $rpc->("connect", $account, sub {
      my ($ok, $result) = @_;
      $connected = $ok ? 1 : $result;
   });
}

sub perform {
   my ($req, $cmd, $args) = @_;

   start_worker ();
   my $id = ++$seq;
   $pending{$id} = $req;
   $rpc->($cmd, $args, sub {
      delete $pending{$id} or return;
      my ($ok, $result) = @_;
      return if $req->{finished};
      $ok ? $req->result ($result)
          : $req->error ($result);
   });
}

sub require_mail_permission {
   my ($req, $action, $args) = @_;

   my $mailbox = $args->{mailbox};

   $req->require_permission ({
      tool           => $req->{tool},
      action         => $action,
      (defined $mailbox ? (dimensions => {
         subject => {
            label   => "Mailbox",
            value   => "mailbox:$mailbox",
            choices => [
               { label => "this mailbox", match => "mailbox:$mailbox" },
               { label => "all mailboxes", match => "^mailbox:" },
            ],
         },
      }) : ()),
      sandbox_grants => [],
      reason         => "$req->{tool} " . (defined $mailbox ? "mailbox '$mailbox'" : "mailboxes"),
   });
}

sub property {
   my ($type, $description) = @_;

   +{ type => $type, description => $description }
}

my $mailbox = property ("string", "IMAP mailbox name, e.g. INBOX");
my $validity = property ("string", "Current UIDVALIDITY from messages or message");
my $uid = property ("integer", "Message UID");
my $uids = { type => "array", items => $uid, minItems => 1, maxItems => 100 };

sub expose {
   my ($cmd, $description, $required, $properties, $action) = @_;

   mcp_tool $cmd => $description,
      {
         type                 => "object",
         required             => $required,
         additionalProperties => JSON::XS::false,
         properties           => $properties,
      },
      sub {
         my ($req, $args) = @_;

         ref $args eq "HASH" or die "Arguments must be an object\n";
         for my $key (@$required) {
            defined $args->{$key} or die "Missing $key\n";
         }
         if (defined $args->{mailbox}) {
            !ref $args->{mailbox} && length $args->{mailbox} && length $args->{mailbox} <= 1024
               or die "Invalid mailbox\n";
         }
         if (defined $args->{uidvalidity}) {
            !ref $args->{uidvalidity} && $args->{uidvalidity} =~ /\A[1-9][0-9]*\z/
               or die "Invalid UIDVALIDITY\n";
         }
         if (defined $args->{uid}) {
            !ref $args->{uid} && $args->{uid} =~ /\A[1-9][0-9]*\z/
               or die "Invalid UID\n";
         }
         for my $u (@{$args->{uids} || []}) {
            defined $u && !ref $u && $u =~ /\A[1-9][0-9]*\z/ or die "Invalid UID\n";
         }
         require_mail_permission $req, $action, $args;
         perform $req, $cmd, $args;
      };
}

expose mailboxes => "List IMAP mailboxes.", [],
   {}, "read";

expose lsub => "List subscribed IMAP mailboxes (LSUB).", [],
   {}, "read";

expose messages =>
   "Page UID summaries, newest first. SEARCH filter; cursor anchors UIDVALIDITY. Continue on incomplete pages.",
   ["mailbox"],
   {
      mailbox => $mailbox,
      limit   => { type => "integer", minimum => 1, maximum => 100 },
      cursor  => { type => "object", description => "{uidvalidity, before_uid} from previous page" },
      fields  => { type => "array", items => property ("string", "Summary field") },
      headers => { type => "array", items => property ("string", "RFC 5322 header name"), maxItems => 20 },
      filter  => {
         type        => "object",
         description => "AND of: from/to/cc/bcc/subject/body/text strings; header:{Name:string}; "
                      . "since/before/on/sent_since/sent_before/sent_on dates (D-Mon-YYYY); "
                      . "larger/smaller byte counts; seen/unseen/answered/unanswered/flagged/unflagged/"
                      . "deleted/undeleted/draft/undraft/recent/old/new booleans; "
                      . "all:[filter,...], any:[filter,...], not:filter (recursive Boolean grouping)",
      },
   }, "read";

expose message => "Read one message's envelope, flags, size and MIME BODYSTRUCTURE; no Seen change.",
   [qw(mailbox uidvalidity uid)],
   { mailbox => $mailbox, uidvalidity => $validity, uid => $uid }, "read";

expose part => "Fetch bounded MIME part using BODY.PEEK. Raw transferred bytes in Base64; optionally decode text.",
   [qw(mailbox uidvalidity uid part)],
   {
      mailbox => $mailbox, uidvalidity => $validity, uid => $uid,
      part => property ("string", "BODYSTRUCTURE part number (empty for singlepart root)"),
      max_bytes => { type => "integer", minimum => 1, maximum => 262144 },
      decode_text => { type => "boolean" },
   }, "read";

expose raw => "Fetch bounded raw RFC 5322 bytes using BODY.PEEK, Base64 encoded.",
   [qw(mailbox uidvalidity uid)],
   { mailbox => $mailbox, uidvalidity => $validity, uid => $uid,
     max_bytes => { type => "integer", minimum => 1, maximum => 262144 } }, "read";

expose flags => "Add or remove Seen, Flagged, Answered, Deleted, Draft or supported keywords. Never expunges.",
   [qw(mailbox uidvalidity uids operation flags)],
   {
      mailbox => $mailbox, uidvalidity => $validity, uids => $uids,
      operation => { type => "string", enum => ["add", "remove"] },
      flags => { type => "array", items => property ("string", "IMAP flag or keyword"), minItems => 1, maxItems => 20 },
   }, "modify";

for my $cmd (qw(copy move)) {
   expose $cmd => ($cmd eq "move" ? "MOVE UIDs to destination; requires IMAP MOVE; no emulation."
                                 : "COPY UIDs to destination mailbox."),
      [qw(mailbox uidvalidity uids destination)],
      { mailbox => $mailbox, uidvalidity => $validity, uids => $uids,
        destination => property ("string", "Destination mailbox") }, "modify";
}

expose append => "Append raw CRLF RFC 5322 message (Base64, at most 1 MiB); reject unsafe mbox From_ lines.",
   [qw(mailbox data_base64)],
   {
      mailbox => $mailbox,
      data_base64 => property ("string", "Base64-encoded RFC 5322 message, max 1 MiB"),
      flags => { type => "array", items => property ("string", "Initial IMAP flag") },
   }, "modify";

expose expunge =>
   "Permanently expunge Deleted mail: optional UIDs require UIDPLUS; omitted UIDs expunge ALL Deleted messages.",
   [qw(mailbox uidvalidity)],
   { mailbox => $mailbox, uidvalidity => $validity, uids => $uids }, "expunge";

expose mailbox => "Create, rename, delete, subscribe or unsubscribe a mailbox.",
   [qw(operation mailbox)],
   {
      operation => { type => "string", enum => [qw(create rename delete subscribe unsubscribe)] },
      mailbox => $mailbox,
      destination => property ("string", "New mailbox name for rename"),
   }, "manage";

mcp_start %$options;
