]> diplodocus.org Git - nmh/commitdiff
expect this commit to be force-replaced periodically imap-prototype
authorEric Gillespie <epg@pretzelnet.org>
Mon, 1 Nov 2021 13:27:15 +0000 (08:27 -0500)
committerEric Gillespie <epg@pretzelnet.org>
Mon, 1 Nov 2021 13:27:15 +0000 (08:27 -0500)
stability will  come soon when i push to savannah

177 files changed:
.dir-locals.el.in [new file with mode: 0644]
Cargo.lock [new file with mode: 0644]
Cargo.toml [new file with mode: 0644]
Makefile.am
configure.ac
docs/imap/DESIGN [new file with mode: 0644]
docs/imap/testing/cyrus/cyrus.conf [new file with mode: 0644]
docs/imap/testing/cyrus/imapd.conf [new file with mode: 0644]
docs/imap/testing/dovecot.conf [new file with mode: 0644]
docs/imap/testing/notes [new file with mode: 0644]
docs/imap/testing/run [new file with mode: 0755]
guide [deleted file]
h/folder.h [new file with mode: 0644]
h/folder_manager_protocol.h [new file with mode: 0644]
h/managed_folders.h [new file with mode: 0644]
h/oauth.h
imap/folder-manager/Cargo.toml [new file with mode: 0644]
imap/folder-manager/src/cf.rs [new file with mode: 0644]
imap/folder-manager/src/error.rs [new file with mode: 0644]
imap/folder-manager/src/imap.rs [new file with mode: 0644]
imap/folder-manager/src/imap/auth.rs [new file with mode: 0644]
imap/folder-manager/src/imap/mailbox.rs [new file with mode: 0644]
imap/folder-manager/src/imap/protocol.rs [new file with mode: 0644]
imap/folder-manager/src/imap/protocol/response.rs [new file with mode: 0644]
imap/folder-manager/src/imap/protocol/tests.rs [new file with mode: 0644]
imap/folder-manager/src/imap/session.rs [new file with mode: 0644]
imap/folder-manager/src/imap/session/fetch.rs [new file with mode: 0644]
imap/folder-manager/src/imap/session/mailbox.rs [new file with mode: 0644]
imap/folder-manager/src/imap/session/tests.rs [new file with mode: 0644]
imap/folder-manager/src/logging.rs [new file with mode: 0644]
imap/folder-manager/src/mailbox_rule.rs [new file with mode: 0644]
imap/folder-manager/src/main.rs [new file with mode: 0644]
imap/folder-manager/src/manager.rs [new file with mode: 0644]
imap/folder-manager/src/manager/command.rs [new file with mode: 0644]
imap/folder-manager/src/manager/command/inc.rs [new file with mode: 0644]
imap/folder-manager/src/manager/command/mark.rs [new file with mode: 0644]
imap/folder-manager/src/manager/command/mark/add.rs [new file with mode: 0644]
imap/folder-manager/src/manager/command/mark/delete.rs [new file with mode: 0644]
imap/folder-manager/src/manager/command/pick.rs [new file with mode: 0644]
imap/folder-manager/src/manager/mapper.rs [new file with mode: 0644]
imap/folder-manager/src/manager/pick.rs [new file with mode: 0644]
imap/folder-manager/src/regex.rs [new file with mode: 0644]
imap/folder-manager/src/regex/tests.rs [new file with mode: 0644]
imap/folder-manager/src/run.rs [new file with mode: 0644]
imap/mhimap/Cargo.toml [new file with mode: 0644]
imap/mhimap/src/main.rs [new file with mode: 0644]
imap/store/Cargo.toml [new file with mode: 0644]
imap/store/src/append.rs [new file with mode: 0644]
imap/store/src/check.rs [new file with mode: 0644]
imap/store/src/lib.rs [new file with mode: 0644]
imap/store/src/mapper.rs [new file with mode: 0644]
imap/store/src/tests.rs [new file with mode: 0644]
imap/store/src/transaction.rs [new file with mode: 0644]
imap/store/src/transaction/cursor.rs [new file with mode: 0644]
imap/types/Cargo.toml [new file with mode: 0644]
imap/types/src/lib.rs [new file with mode: 0644]
imap/types/src/uid.rs [new file with mode: 0644]
imap/types/tests/tests.rs [new file with mode: 0644]
imap/types/tests/uid/mod.rs [new file with mode: 0644]
managedfolders/Cargo.toml [new file with mode: 0644]
managedfolders/src/command.rs [new file with mode: 0644]
managedfolders/src/components.rs [new file with mode: 0644]
managedfolders/src/lib.rs [new file with mode: 0644]
managedfolders/src/protocol.rs [new file with mode: 0644]
managedfolders/src/protocol/message.rs [new file with mode: 0644]
managedfolders/src/protocol/read.rs [new file with mode: 0644]
managedfolders/src/protocol/read/tests.rs [new file with mode: 0644]
managedfolders/src/protocol/write.rs [new file with mode: 0644]
managedfolders/src/protocol/write/tests.rs [new file with mode: 0644]
managedfolders/tests/tests.rs [new file with mode: 0644]
rls.in [new file with mode: 0644]
sbr/arglist.c
sbr/brkstring.c
sbr/brkstring.h
sbr/client.c
sbr/client.h
sbr/folder_manager_client.c [new file with mode: 0644]
sbr/folder_manager_protocol.c [new file with mode: 0644]
sbr/folder_operations.c [new file with mode: 0644]
sbr/folder_read.c
sbr/getarguments.c
sbr/m_convert.c
sbr/managed_folders.c [new file with mode: 0644]
sbr/netsec.c
sbr/oauth.c
sbr/read_switch_multiword.c
sbr/read_switch_multiword.h
sbr/read_switch_multiword_via_readline.c
sbr/read_switch_multiword_via_readline.h
sbr/refile.c
sbr/refile.h
sbr/rust/Cargo.toml [new file with mode: 0644]
sbr/rust/bindgen.h [new file with mode: 0644]
sbr/rust/build.rs.in [new file with mode: 0644]
sbr/rust/src/base64.rs [new file with mode: 0644]
sbr/rust/src/client.rs [new file with mode: 0644]
sbr/rust/src/error.rs [new file with mode: 0644]
sbr/rust/src/folder.rs [new file with mode: 0644]
sbr/rust/src/hacks.rs [new file with mode: 0644]
sbr/rust/src/lib.rs [new file with mode: 0644]
sbr/rust/src/message.rs [new file with mode: 0644]
sbr/rust/src/message/set.rs [new file with mode: 0644]
sbr/rust/src/netsec.rs [new file with mode: 0644]
sbr/rust/src/netsec/build.rs [new file with mode: 0644]
sbr/rust/src/netsec/netsec.rs [new file with mode: 0644]
sbr/rust/src/netsec/sasl.rs [new file with mode: 0644]
sbr/rust/src/netsec/tls.rs [new file with mode: 0644]
sbr/rust/src/profile.rs [new file with mode: 0644]
sbr/rust/src/sbr.rs [new file with mode: 0644]
sbr/rust/src/str.rs [new file with mode: 0644]
sbr/rust/src/str/tests.rs [new file with mode: 0644]
sbr/rust/tests/base64/mod.rs [new file with mode: 0644]
sbr/rust/tests/client/mod.rs [new file with mode: 0644]
sbr/rust/tests/message/mod.rs [new file with mode: 0644]
sbr/rust/tests/message/set.rs [new file with mode: 0644]
sbr/rust/tests/netsec/mod.rs [new file with mode: 0644]
sbr/rust/tests/str/mod.rs [new file with mode: 0644]
sbr/rust/tests/tests.rs [new file with mode: 0644]
sbr/seq_read.c
sbr/seq_setprev.c
sbr/seq_setunseen.c
sbr/showfile.c
sbr/showfile.h
sbr/vector.c
sbr/vector.h
test/common.sh.in
test/comp/test-draft-fcc [new file with mode: 0755]
test/fakeinetd.c [new file with mode: 0644]
test/imap/test-astring [new file with mode: 0755]
test/imap/test-bad [new file with mode: 0755]
test/imap/test-bad-fetch-body [new file with mode: 0755]
test/imap/test-folder-create-disallowed [new file with mode: 0755]
test/imap/test-gmail-all [new file with mode: 0755]
test/imap/test-inc [new file with mode: 0755]
test/imap/test-inc-missing-msg [new file with mode: 0755]
test/imap/test-inc-missing-msg-case-2 [new file with mode: 0755]
test/imap/test-live [new file with mode: 0755]
test/imap/test-login [new file with mode: 0755]
test/imap/test-mark [new file with mode: 0755]
test/imap/test-quirks-gmail [new file with mode: 0755]
test/imap/test-refile [new file with mode: 0755]
test/imap/test-rmm [new file with mode: 0755]
test/imap/test-scan [new file with mode: 0755]
test/imap/test-search [new file with mode: 0755]
test/imap/test-server-drops [new file with mode: 0755]
test/imap/test-uidvalidity [new file with mode: 0755]
test/imap/test-xoauth [new file with mode: 0755]
test/managed-folders/protocol-tester.c [new file with mode: 0644]
test/managed-folders/test-message-len [new file with mode: 0755]
test/managed-folders/test-read-message [new file with mode: 0755]
test/oauth/common.sh
test/server.c
uip/ali.c
uip/comp.c
uip/dist.c
uip/flist.c
uip/fmttest.c
uip/folder.c
uip/forw.c
uip/imaptest.c
uip/inc.c
uip/mark.c
uip/mhbuildsbr.c
uip/mhlogin.c
uip/mhlsbr.c
uip/mhstoresbr.c
uip/new.c
uip/refile.c
uip/repl.c
uip/rmf.c
uip/rmm.c
uip/scan.c
uip/send.c
uip/sendsbr.c
uip/show.c
uip/whatnowsbr.c
uip/whom.c

diff --git a/.dir-locals.el.in b/.dir-locals.el.in
new file mode 100644 (file)
index 0000000..17afff8
--- /dev/null
@@ -0,0 +1,13 @@
+(
+ (c-mode
+  . (
+     (c-file-style . "awk")
+     (indent-tabs-mode . t)
+     ))
+
+ (rust-mode
+  . (
+     (eglot-server-programs (rust-mode . (eglot-rls "@abs_builddir@/rls")))
+     (indent-tabs-mode . nil)
+     ))
+ )
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644 (file)
index 0000000..ffb7465
--- /dev/null
@@ -0,0 +1,124 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 3
+
+[[package]]
+name = "bitflags"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693"
+
+[[package]]
+name = "byteorder"
+version = "1.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08c48aae112d48ed9f069b33538ea9e3e90aa263cfa3d1c24309612b1f7472de"
+
+[[package]]
+name = "cc"
+version = "1.0.60"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef611cc68ff783f18535d77ddd080185275713d852c4f5cbb6122c462a7a825c"
+
+[[package]]
+name = "cfg-if"
+version = "0.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822"
+
+[[package]]
+name = "imap-folder-manager"
+version = "0.0.0"
+dependencies = [
+ "libc",
+ "log",
+ "managed-folders",
+ "nmh",
+ "nmhimapstore",
+ "nmhimaptypes",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.78"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa7087f49d294270db4e1928fc110c976cd4b9e5a16348e0a1df09afa99e6c98"
+
+[[package]]
+name = "lmdb-rkv"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "447a296f7aca299cfbb50f4e4f3d49451549af655fb7215d7f8c0c3d64bad42b"
+dependencies = [
+ "bitflags",
+ "byteorder",
+ "libc",
+ "lmdb-rkv-sys",
+]
+
+[[package]]
+name = "lmdb-rkv-sys"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b27470ac25167b3afdfb6af8fcd3bc1be67de50ffbdaf4073378cfded6ae24a5"
+dependencies = [
+ "cc",
+ "libc",
+ "pkg-config",
+]
+
+[[package]]
+name = "log"
+version = "0.4.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fcf3805d4480bb5b86070dcfeb9e2cb2ebc148adb753c5cca5f884d1d65a42b2"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "managed-folders"
+version = "0.0.0"
+dependencies = [
+ "log",
+ "nmh",
+]
+
+[[package]]
+name = "mhimap"
+version = "0.0.0"
+dependencies = [
+ "nmh",
+ "nmhimapstore",
+]
+
+[[package]]
+name = "nmh"
+version = "0.0.0"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "nmhimapstore"
+version = "0.0.0"
+dependencies = [
+ "lmdb-rkv",
+ "lmdb-rkv-sys",
+ "log",
+ "nmh",
+ "nmhimaptypes",
+]
+
+[[package]]
+name = "nmhimaptypes"
+version = "0.0.0"
+dependencies = [
+ "nmh",
+]
+
+[[package]]
+name = "pkg-config"
+version = "0.3.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d36492546b6af1463394d46f0c834346f31548646f6ba10849802c9c9a27ac33"
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644 (file)
index 0000000..8586262
--- /dev/null
@@ -0,0 +1,2 @@
+[workspace]
+members = ["imap/folder-manager", "imap/mhimap"]
index 57a46e3..1c62076 100644 (file)
@@ -31,6 +31,7 @@ MHNSEARCHPROG = $(srcdir)/etc/mhn.find.sh
 ##
 TESTS_ENVIRONMENT = MH_OBJ_DIR="@abs_builddir@" \
                    MH_VERSION="$(VERSION)" \
+                   MAKE=$(MAKE) \
                    OAUTH_SUPPORT='@OAUTH_SUPPORT@' \
                    CURL_USER_AGENT='@CURL_USER_AGENT@' \
                    MH_TEST_DIR=`cd "@abs_builddir@" && pwd -P`/test/testdir \
@@ -53,6 +54,7 @@ TESTS = \
     test/burst/test-burst \
     test/burst/test-burst-mime \
     test/comp/test-comp-format \
+    test/comp/test-draft-fcc \
     test/dist/test-dist \
     test/folder/test-coverage \
     test/folder/test-create \
@@ -77,6 +79,9 @@ TESTS = \
     test/forw/test-forw-coverage \
     test/forw/test-forw-digest \
     test/forw/test-forw-format \
+    test/imap/test-inc \
+    test/imap/test-live \
+    test/imap/test-scan \
     test/inc/test-deb359167 \
     test/inc/test-eom-align \
     test/inc/test-inc-scanout \
@@ -86,6 +91,7 @@ TESTS = \
     test/install-mh/test-version-check \
     test/locking/test-datalocking \
     test/locking/test-spoollocking \
+    test/managed-folders/test-read-message \
     test/manpages/test-manpages \
     test/mhbuild/test-attach \
     test/mhbuild/test-cte \
@@ -164,15 +170,19 @@ TESTS = \
     test/whom/test-whom \
     test/cleanup ## The "cleanup" test should always be last.
 
+check_DATA = fake.pem
+
 check_SCRIPTS = test/common.sh
 
 check_PROGRAMS = \
     test/fakehttp \
+    test/fakeinetd \
     test/fakepop \
     test/fakesmtp \
     test/getcanon \
     test/getcwidth \
     test/getfullname \
+    test/managed-folders/protocol-tester \
     test/runpty \
     #
 
@@ -336,7 +346,10 @@ noinst_HEADERS = \
     h/dropsbr.h \
     h/fmt_compile.h \
     h/fmt_scan.h \
+    h/folder.h \
+    h/folder_manager_protocol.h \
     h/icalendar.h \
+    h/managed_folders.h \
     h/mf.h \
     h/mh.h \
     h/mhparse.h \
@@ -980,6 +993,9 @@ test_fakesmtp_LDADD = $(POSTLINK)
 test_fakehttp_SOURCES = test/fakehttp.c test/server.c
 test_fakehttp_LDADD = $(LDADD) $(POSTLINK)
 
+test_fakeinetd_SOURCES = test/fakeinetd.c
+test_fakeinetd_LDADD = $(LDADD) $(POSTLINK)
+
 test_getcwidth_SOURCES = test/getcwidth.c
 test_getcwidth_LDADD = $(POSTLINK)
 
@@ -1100,6 +1116,9 @@ sbr_libmh_a_SOURCES = \
     sbr/folder_addmsg.c \
     sbr/folder_delmsgs.c \
     sbr/folder_free.c \
+    sbr/folder_manager_client.c \
+    sbr/folder_manager_protocol.c \
+    sbr/folder_operations.c \
     sbr/folder_pack.c \
     sbr/folder_read.c \
     sbr/folder_realloc.c \
@@ -1124,6 +1143,7 @@ sbr_libmh_a_SOURCES = \
     sbr/m_rand.c \
     sbr/maildir_read_and_sort.c \
     sbr/makedir.c \
+    sbr/managed_folders.c \
     sbr/message_id.c \
     sbr/mf.c \
     sbr/mime_type.c \
@@ -1308,6 +1328,14 @@ gcov-clean: gcov-mostlyclean
 .PHONY: gcov gcov-rebuild gcov-run gcov-process gcov-report \
     gcov-mostlyclean gcov-clean
 
+# TODO rust coverage
+#  cargo +nightly install cargo-binutils grcov rustfilt
+#  RUSTFLAGS=-Zinstrument-coverage make CARGO_FLAGS=+nightly cargo
+#  make check
+#  TODO rename .llvmprof to .profdata so grcov will see it
+#  cargo +nightly profdata -- merge -sparse test/imap/*.llvmprof -o profdata
+#  cargo +nightly cov -- show target/debug/imap-folder-manager -instr-profile=profdata "-path-equivalence=,${top_srcdir}"
+#  grcov --branch --llvm -b target/debug -o ~/public_html/cov -t html -s ../.. test/imap/cov
 
 ##
 ## Rules to upload the distribution to savannah
@@ -1331,3 +1359,27 @@ upload: dist
        @echo "Uploading to savannah"
        scp -p $(DIST_ARCHIVES)* $(SAVANNAH_USERNAME)@dl.sv.nongnu.org:/releases/nmh
 .PHONY: upload
+
+# TODO autoconf check for cargo and bindgen
+.PHONY: cargo cargo-doc
+cargo: $(srcdir)/sbr/rust/src/bindgen.rs $(abs_srcdir)/sbr/rust/build.rs
+       cd $(srcdir) && CARGO_TARGET_DIR=$(abs_builddir)/target cargo $(CARGO_FLAGS) build
+cargo-doc: cargo
+       cd $(srcdir) && CARGO_TARGET_DIR=$(abs_builddir)/target cargo $(CARGO_FLAGS) doc --no-deps
+
+# TODO default to release build with optional debug build
+# TODO install mhimap to bindir and imap-folder-manager to libexecdir
+# TODO proper build rules, including dependency on libmh.a
+target/debug/mhimap target/debug/imap-folder-manager: cargo
+
+# TODO proper out-of-tree build
+$(abs_srcdir)/sbr/rust/build.rs: $(abs_builddir)/sbr/rust/build.rs
+       [ $(abs_srcdir) = $(abs_builddir) ] || cp $(builddir)/sbr/rust/build.rs $(srcdir)/sbr/rust/build.rs
+
+$(srcdir)/sbr/rust/src/bindgen.rs: $(srcdir)/sbr/rust/bindgen.h
+       bindgen --no-derive-copy -o $(srcdir)/sbr/rust/src/bindgen.rs.tmp $(srcdir)/sbr/rust/bindgen.h -- -I$(srcdir) \
+               && mv $(srcdir)/sbr/rust/src/bindgen.rs.tmp $(srcdir)/sbr/rust/src/bindgen.rs
+
+# TODO autoconf check for openssl
+fake.pem:
+       yes '' | openssl req -new -x509 -nodes -out fake.pem -keyout fake.pem
index 1f8dc6c..a0e50ac 100644 (file)
@@ -647,9 +647,24 @@ TLS support                : ${tls_support}
 OAuth support              : ${oauth_support}
 ])])dnl
 
+dnl TODO move up with other as-yet-unwritten rust configuration
+dnl TODO ac_pwd doesn't seem right
+CARGO_TARGET_DIR=$ac_pwd/target
+AC_SUBST([CARGO_TARGET_DIR])
+dnl TODO
+RLS=rls
+AC_SUBST([RLS])
+
 dnl ---------------
 dnl OUTPUT MAKEFILE
 dnl ---------------
-AC_CONFIG_FILES([Makefile test/common.sh])
+AC_CONFIG_FILES([.dir-locals.el], [cp .dir-locals.el $ac_abs_srcdir])
+AC_CONFIG_FILES([
+       Makefile
+       sbr/rust/build.rs
+       test/common.sh
+])
+AC_CONFIG_FILES([rls], [chmod +x rls])
 AC_CONFIG_FILES([uip/spost], [chmod +x uip/spost])
+
 AC_OUTPUT
diff --git a/docs/imap/DESIGN b/docs/imap/DESIGN
new file mode 100644 (file)
index 0000000..d27450a
--- /dev/null
@@ -0,0 +1,373 @@
+#+TITLE: IMAP Synchronization for nmh
+
+* Status
+
+An EXPERIMENTAL, almost-entirely-happy-path-only partial implementation
+exists.  Most of the nmh commands that are within the scope of the project
+have not yet been updated.
+
+All functionality behind --enable-experimental-imap-folder-manager
+configuration flag.
+
+* Rationale
+
+The world is now mobile-first, and many of are are mobile-only, or nearly so.
+When my smart phone took over my life, two things happened to my nmh usage:
+
+- At work, I was frustrated constantly by messages read under nmh being unread
+  on my phone and vice versa.
+- In my personal life, that same frustration drove me away from nmh completely.
+  Whenever I need to reply to email from my computer, I find my self
+  incorporating months of messages.
+
+We can use IMAP to make nmh a first-class citizen in the modern mail world.
+
+* Goals
+
+- Treat the IMAP server as the source of truth.
+- Minimize startup penalty to nmh commands.
+- Minimize the impact to the nmh code-base.
+- Minimize round-trips to the server.
+- Enhance =inc= to incorporate mail from a hierarchy of IMAP mailboxes.
+- Enhance =inc= to delete mail locally that has been deleted on the server.
+- Enhance all commands interpreting the user's =Unseen-Sequence= (i.e. all those
+  taking message specifications (=mh-sequence(5)=), e.g. =mark=, =pick=, =scan=,
+  =show=) to interpret references to the =Unseen-Sequence= as all messages not
+  flagged as =\Seen= on the server.
+- Enhance =scan= output to reflect live =\Seen= status when it is asked to
+  display whether messages appear in the user's =Unseen-Sequence=.
+- Enhance =show= to set the =\Seen= flag on messages shown.
+- Enhance =mark= to set or clear the =\Seen= flag for messages removed from or
+  added to the user's =Unseen-Sequence=, respectively.
+- Enhance =rmm= to delete messages from the server.
+- Enhance =refile= to move messages on the server.
+- Enhance =comp= and =repl= to allow usage of a server-side draft folder.
+
+** Deferred goals
+
+- Off-line mode might be useful
+- Synchronizing flags other than =\Seen=
+- Storing sequences on the server (possibly the same problem as the previous)
+- IMAP defines a message UID as an unsigned 32-bit integer, allowing for
+  4,294,967,295 messages, but nmh uses SIGNED =int=, which is 32 bits on
+  amd64, allowing for only 2,147,483,647 messages.  It's not clear any file
+  system offers reasonable performance with directories of that size, so we
+  may need to redesign nmh folders before the =int= becomes a problem.
+  Assuming any of this is a practical concern...
+
+* Approach
+
+** Architecture
+
+*** Managed folders
+
+"Minimize startup penalty" and "Minimize round-trips" suggest we start a
+separate process which can keep state between nmh commands.  Each nmh command
+does not have to can pay the relatively expensive TLS connection setup cost.
+
+"Minimize impact to the nmh code-base" means continuing to represent folders
+ as directories and messages as numbered files within.
+
+To solve those, we introduce managed folders, which are folders in the nmh
+mail store managed by a folder manager.  When the user requests an operation
+on a managed folder, we start by informing the folder manager of the operation
+to be taken.  In some cases, the folder does nearly all the work.  In other
+cases, it simply saves some information from the server where the rest of nmh
+can easily find it and then gets out of the way.
+
+For example, =scan= hands the user-specified message sequences to the folder
+manager, which consults the IMAP server and stores the results in a special
+sequence, and then the rest of =scan= as we all know and love it takes over,
+blissfully unaware the folder is special.
+
+*** Source of truth
+
+"Treat the IMAP server as the source of truth" gives us a simple and
+robust model.
+
+If a user =rmm= a message, we first delete it from the server.  If that fails,
+we report the failure to the user and do not proceed.  The message remains in
+both the nmh folder and on the IMAP server.  If deletion from the server
+succeeds, but we fail to delete locally, we still report an error, but the
+message is now treated as though some other client had deleted it from the
+server: we will delete it on the next =inc=.
+
+** Language
+
+I've programmed in C and C++ for most of my career and I've just about had it
+with unsafe languages.  The thought of implementing IMAP synchronization in C
+discouraged me from even starting for years.
+
+In my last position at Google, my fellow tech lead and I would often joke
+about dividing the team in two, with one team continuing to chase down a
+memory corruption error we'd failed for months to find, and the other
+rewriting the system in Rust.  It was a joke, but the idea stuck with me.
+
+Since Rust supports the C ABI, we can seamlessly embed Rust into nmh programs.
+Why not give it a shot?
+
+In this case it's even easier:  write the folder manager in Rust.
+
+* Implementation
+
+** Managed folders
+
+*** New profile parameters
+
+- =Managed-Folders= is a space-separated list of folders to be managed.
+  Each folder must have a corresponding =[folder]-manager= component naming the
+  folder manager.
+- =[folder]-manager= where =folder= is the name of the folder manager to start.
+- [folder]-socket= is the path of the UNIX domain socket.
+
+*** Starting the folder manager
+
+1. Try to connect to running manager through =[folder]-socket=.  If that works, done!
+2. If error was not ~ENOENT~, ~unlink(2)~ the stale socket.
+3. ~pipe(2)~
+4. ~fork~
+5. child  - folder-manager; prints to stdout if successfully started (e.g. connected to IMAP)
+5. parent - read from pipe to child
+6. parent - if error, die
+7. parent - else, time to connect again!  this time, die if error
+
+*** Protocol
+
+The protocol isn't the best, and we might want to replace it at some point, but
+I have no plans to do that until finishing the first feature set.
+
+Before refining this protocol, we should look into IPC.
+
+nmh commands and the folder manager communicate over a UNIX domain socket
+configured in the MH profile.  An nmh command opens a connection to the folder
+manager and sends it a message encoded as a standard mail message header.
+The folder manager replies in the same format.
+
+Unlike in mail messages, these messages MUST start with a =len= component whose
+value is the total length of the messgae including the =len= component itself.
+
+**** Common messages
+
+The following common message types are defined:
+
+***** Folder
+
+This message contains only a single folder name.
+
+#+BEGIN_EXAMPLE
+folder:...
+#+END_EXAMPLE
+
+***** Mark
+
+This message contains a folder in which to operate, specifications of which
+messages to pick and sequences to add to or remove from.
+
+#+BEGIN_EXAMPLE
+folder:...
+msgs:...
+seqs:...
+#+END_EXAMPLE
+
+***** Empty response
+
+Many operations have nothing else to respond with but status and optional error message.
+
+#+BEGIN_EXAMPLE
+status:OK | NOTOK
+error:error message present only if status:NOTOK
+#+END_EXAMPLE
+
+**** inc
+
+=inc= sends the request to the folder manager, which opens the local database to
+see which which messages exist, sends IMAP =FETCH= requests (up to
+=max-fetch-messages-in-flight= at a time), stores the messages, and sends one
+response back to =inc= per message, so =inc= can scan we go.
+
+***** request
+
+The =inc= request is =op:inc= followed by the folder message:
+
+#+BEGIN_EXAMPLE
+op:inc
+folder:...
+#+END_EXAMPLE
+
+***** response
+
+THe inc response is a series of messages.
+
+First, one inc message response per message:
+
+#+BEGIN_EXAMPLE
+status:INC_FOLDER
+folder:...
+msg:N
+unseen:N
+#+END_EXAMPLE
+
+Finally a status:OK empty response indicates completion.
+
+**** mark-add / mark-del
+
+These operations are inverse of each other, so request and response are the same.
+
+***** request
+
+#+BEGIN_EXAMPLE
+op:mark-(add / del)
+folder:...
+msgs:...
+seqs:...;
+#+END_EXAMPLE
+
+***** response
+
+The empty response.
+
+**** TODO the rest
+
+** IMAP
+
+*** New profile parameters
+
+- [folder]-host, [folder]-port - to connect
+- [folder]-tls TLS configuration
+- [folder]-user: username
+- [folder]-auth: PREAUTH | SASL - how to authenticate
+- [folder]-authservice: for XOAUTH2
+- [folder]-saslmech: force a SASL mechanism in case nmh chooses the wrong default
+- [folder]-db: path to the database
+- [folder]-mailbox-root: only look for mailboxes under this root
+
+*** Folder store
+
+imap-folder-manager needs to store the following per folder (mailbox):
+
+- forward and reverse map of message UID and local message number
+- mailbox UIDNEXT
+- mailbox UIDVALIDITY
+
+We store UIDNEXT and UIDVALIDITY in plain text files.  For the UID maps, we
+need something fast.
+
+LMDB is a simple and fast embedded memory-mapped database library with ACID
+semantics, and seems like a reasonable choice.  It might be worth trying out
+some alternatives, but this is a fine place to start.
+
+lmdb-rkv is a Rust wrapper from Mozilla which seemed like a decent choice in 2020,
+but that was before the Great Rust Layoff, and it now seems to be abandoned.
+It might be worth looking into taking it over, or finding an alternative, or just
+building our own simple binding only exposing what we need.
+
+*** mhimap - a new nmh command for manipulating the imap-folder-manager
+
+Currently, the only facilities this offers are for the tests.  They are:
+
+- start
+- shutdown
+- noop
+- check
+
+=shutdown= may be useful to users who don't want to leave the authenticated
+process running indefinitely.
+
+=check= verifies the integrity of the synchronization database.
+
+*** Code organization
+
+**** /sbr/rust - crate nmh
+
+=nmh= is the base crate.  It offers Rusty wrappers around the core subroutines
+in the =sbr= directory (=libmh.a=).
+
+**** /managedfolders
+
+The =managed_folders= crate implements the folder-manager protocol.
+Nothing specific to IMAP is here; if anyone wanted to implement a different
+folder manager, they could use this.
+
+**** /imap/types
+
+Exposes types common to the 4 IMAP crates.
+
+**** /imap/store
+
+This crate implements Folder store described above.
+
+**** /imap/folder-manager
+
+This is =imap-folder-manager= itself.
+
+**** /imap/mhimap
+
+This is the =mhimap= command described above.
+
+** Dependencies
+
+*** Direct
+libc = 0.2.78
+lmdb-rkv = 0.14
+lmdb-rkv-sys = 0.11
+log = 0.4
+
+*** Indirect
+bitflags = 1.2.1
+byteorder = 1.3
+cc = 1.0.60
+cfg-if = 0.1.10
+pkg-config = 0.3.18
+
+*** Unstable
+cfg-if = "0.1.10"
+libc = "0.2.78"
+lmdb-rkv = "0.14.0"
+lmdb-rkv-sys = "0.11.0"
+log = "0.4.13"
+pkg-config = "0.3.18"
+
+*** License
+pkg-config     MIT/Apache-2.0
+bitflags       MIT/Apache-2.0
+byteorder      MIT
+cc             MIT/Apache-2.0
+cfg-if         MIT/Apache-2.0
+libc           MIT OR Apache-2.0
+lmdb-rkv       Apache-2.0
+lmdb-rkv-sys   Apache-2.0
+log            MIT OR Apache-2.0
+
+* Testing
+
+Look in the =testing= directory beside this file for more information.
+
+- =tests/imap/test-live= exercises as much of the IMAP functionality as possible
+  against any supported server.  Because we run it against all servers, any test
+  that can live here should live here.
+
+- New tests using the common nmh test suite, run under =make check=.
+  These tests use the classic Panda IMAP server.  =make check= also includes
+  =tests/imap/test-live=, against Panda only.  Add test cases at this level only
+  if they can't be written for =test-live=.
+
+- Rust "integration tests", which go in the =tests= directory of each crate.
+  Add test cases at this level only if they can't be written for the common nmh
+  test suite.
+
+- Rust "unit tests", which go in =tests= sub-modules under the =src= directory
+  of each crate.  Add test cases at this level only if they can't be written as
+  Rust "integration tests".
+
+* Supported servers somewhere
+
+** Free implementations
+
+- Cyrus
+- Dovecot
+- Panda
+
+** Service providers
+
+- Gandi (they seem to run Dovecot)
+- GMail
diff --git a/docs/imap/testing/cyrus/cyrus.conf b/docs/imap/testing/cyrus/cyrus.conf
new file mode 100644 (file)
index 0000000..a437c2e
--- /dev/null
@@ -0,0 +1,18 @@
+START {
+  recover       cmd="ctl_cyrusdb -r"
+}
+
+SERVICES {
+  imap          cmd="imapd" listen="127.0.0.1:imap" prefork=0
+}
+
+EVENTS {
+  checkpoint    cmd="ctl_cyrusdb -c" period=30
+  delprune      cmd="cyr_expire -E 3" at=0400
+  deleteprune   cmd="cyr_expire -E 4 -D 28" at=0430
+  expungeprune  cmd="cyr_expire -E 4 -X 28" at=0445
+  tlsprune      cmd="tls_prune" at=0400
+}
+
+DAEMON {
+}
diff --git a/docs/imap/testing/cyrus/imapd.conf b/docs/imap/testing/cyrus/imapd.conf
new file mode 100644 (file)
index 0000000..1ece338
--- /dev/null
@@ -0,0 +1,14 @@
+configdirectory: /var/imap
+partition-default: /var/spool/imap
+
+unixhierarchysep: yes
+
+allowplaintext: yes
+
+autocreate_quota: 0
+
+sasl_pwcheck_method: auxprop
+
+# TODO unneeded right?
+sieveusehomedir: false
+sievedir: /var/imap/sieve
diff --git a/docs/imap/testing/dovecot.conf b/docs/imap/testing/dovecot.conf
new file mode 100644 (file)
index 0000000..6f5b630
--- /dev/null
@@ -0,0 +1,35 @@
+base_dir = /home/epg/work/nmh/.o/std/dovecot
+state_dir = /home/epg/work/nmh/.o/std/dovecot/db
+log_path = /home/epg/work/nmh/.o/std/dovecot/log
+
+default_internal_user = epg
+default_login_user = epg
+default_internal_group = epg
+
+mail_location = mbox:/home/epg/work/nmh/.o/std/dovecot/db/mail/%d/%1n/%n:INDEX=/home/epg/work/nmh/.o/std/dovecot/db/indexes/%d/%1n/%n
+
+protocols = imap
+listen = 127.0.0.1
+
+passdb {
+  driver = static
+  args = password=testpw
+}
+
+namespace inbox {
+  inbox = yes
+}
+
+service anvil {
+  chroot =
+}
+
+service imap-login {
+  chroot =
+  inet_listener imap {
+    port = 1043
+  }
+  inet_listener imaps {
+    port = 1044
+  }
+}
diff --git a/docs/imap/testing/notes b/docs/imap/testing/notes
new file mode 100644 (file)
index 0000000..269da42
--- /dev/null
@@ -0,0 +1,7 @@
+* cyrus
+
+~sudo saslpasswd2 -c test~
+
+* panda
+
+All IMAP tests run against panda with a simple ~make check~.
diff --git a/docs/imap/testing/run b/docs/imap/testing/run
new file mode 100755 (executable)
index 0000000..b679735
--- /dev/null
@@ -0,0 +1,254 @@
+#!/usr/local/bin/zsh
+
+# Quick-n-dirty script to ease running the IMAP tests in various configurations.
+# Also generates coverage reports (requires Rust nightly).
+# TODO redo in more standard fashion under `make check`.
+
+# Configuration:
+# export MH_TEST_IMAP_LOG_LEVEL=trace
+# export MH_TEST_NOCLEANUP=1
+# export MH_TEST_STUNNEL_DEBUG=7
+# export MH_TEST_STUNNEL_OUTPUT=/dev/stderr
+# export MH_TEST_VERBOSE=1
+# export RUST_BACKTRACE=1
+# abs_srcdir=$HOME/work/nmh
+# abs_builddir=$abs_srcdir/.o/clang13
+# LLVM_PROFDATA=llvm-profdata13
+# LLVM_COV=llvm-cov13
+# MAKE=make
+# MAKE_JOBS=-j4
+# PANDA_IMAPD=/usr/local/libexec/imapd
+# # optional:
+# gandi_user='example@example.com'
+# gandi_pw='...'
+#gmail_user='example@example.com'
+#gmail_oauth=/path/to/oauth-gmail
+
+set -ex
+
+abs_builddir=$PWD
+
+cf=$1
+if ! [[ -n $cf ]]; then
+    print -u2 "usage imaptest CONFIGURATION ACTION"
+    exit 2
+fi
+shift
+
+. $cf
+
+for v in \
+        abs_srcdir \
+        LLVM_PROFDATA \
+        LLVM_COV \
+        MAKE \
+        PANDA_IMAPD \
+        ; do
+    if ! eval "[[ -n \$$v ]]"; then
+        print -u2 "$v must be set in $cf"
+        exit 2
+    fi
+done
+
+testdir=$abs_builddir/test/testdir
+
+export CARGO_TARGET_DIR=$abs_builddir/target
+
+# Coverage documentation
+# https://doc.rust-lang.org/beta/unstable-book/compiler-flags/source-based-code-coverage.html
+# https://clang.llvm.org/docs/SourceBasedCodeCoverage.html
+# https://llvm.org/docs/CommandGuide/llvm-profdata.html
+# https://llvm.org/docs/CommandGuide/llvm-cov.html
+# Bugs
+# https://github.com/rust-lang/rust/issues/79121 Tracking Issue for LLVM InstrProf code coverage
+# https://github.com/rust-lang/rust/issues/85902 Add a cargo option to generate coverage reports using -Z instrument-coverage
+# https://github.com/rust-lang/rust/issues/79649 Add support for branch coverage in source-based coverage
+report-coverage() {
+    typeset profile=$1
+    typeset profile_dir=$2
+    typeset profdata=$abs_builddir/covdata/$profile.profdata
+    find $abs_builddir/covdata/$profile_dir -name '*.profraw' | $nop xargs $LLVM_PROFDATA merge --sparse \
+                                                                      -o $profdata
+    $nop $LLVM_COV show -Xdemangler=$HOME/.cargo/bin/rustfilt '--ignore-filename-regex=/tests/' '--ignore-filename-regex=/tests\.rs' '--ignore-filename-regex=\.cargo' --format=html \
+               -o $abs_builddir/covdata/html/$profile --instr-profile=$profdata \
+               ${=${(j: --object :)test_objects}}
+}
+
+check-litter() {
+    setopt LOCAL_OPTIONS NULL_GLOB
+    typeset litter=($testdir/*.actual)
+    (( $#litter > 0 )) || return 0
+    print -u2 "$1 littered $litter"
+    false
+}
+
+report_only=false
+coverage=false
+typeset -a actions profiles
+while [[ $# -gt 0 ]]; do
+    word=$1
+    shift
+    case $word in
+        -n)
+            nop=(print --)
+            ;;
+
+        -cover*)
+            coverage=:
+            if [[ $word = -cover-report ]]; then
+                report_only=:
+            else
+                $nop rm -rf $abs_builddir/covdata
+            fi
+            . ~/.cargo/env
+            export RUSTFLAGS="-Zinstrument-coverage $RUSTFLAGS"
+            ;;
+
+        all)
+            # We test implicit TLS with Gmail and explicit with Gandi; any reason to test both ways both places?
+            set -- cargot test live/panda live/dovecot live/cyrus live/gandi-starttls live/gmail "$@"
+            ;;
+
+        build|clean)
+            actions+=$word
+            ;;
+
+        cargot|test|live/*)
+            actions+=$word
+            profiles+=$word
+            ;;
+
+        test/*)
+            actions+=$word
+            profiles+=test
+            ;;
+
+        *)
+            print -u2 "get outta here $word"
+            exit 3
+            ;;
+    esac
+done
+
+if ! $report_only; then
+for action in $actions; do
+    case $action in
+        build)
+            (cd $abs_builddir && $nop $MAKE $MAKE_JOBS all cargo)
+            ;;
+
+        cargot)
+            $nop env LLVM_PROFILE_FILE="$abs_builddir/covdata/cargot/%m-%p.profraw" cargo t -- --test-threads=1
+            ;;
+
+        test)
+            tests=(
+                test/imap/test-astring
+                test/imap/test-bad
+                test/imap/test-bad-fetch-body
+                test/imap/test-folder-create-disallowed
+                # Downloads all mail from your test account; better be sure you want that!
+                # test/imap/test-gmail-all
+                test/imap/test-inc
+                test/imap/test-inc-missing-msg
+                test/imap/test-inc-missing-msg-case-2
+                # Handled specially below.
+                # test/imap/test-live
+                test/imap/test-login
+                test/imap/test-mark
+                test/imap/test-quirks-gmail
+                # TODO fix the bitrot
+                # test/imap/test-refile
+                test/imap/test-rmm
+                test/imap/test-scan
+                test/imap/test-server-drops
+                test/imap/test-uidvalidity
+                test/imap/test-xoauth
+            )
+            (cd $abs_builddir && for test in $tests; do
+                 $nop env PANDA_IMAPD=$PANDA_IMAPD MH_COVERAGE_PROFILE=test $MAKE check TESTS=$test
+                 check-litter $test
+             done)
+        ;;
+
+        test/*)
+            (cd $abs_builddir && $nop env PANDA_IMAPD=$PANDA_IMAPD MH_COVERAGE_PROFILE=test $MAKE check TESTS=$action)
+            check-litter $action
+        ;;
+
+        live/panda)
+            (cd $abs_builddir && $nop env PANDA_IMAPD=$PANDA_IMAPD MH_COVERAGE_PROFILE=live/panda MH_IMAP_TEST_SERVER=panda \
+                 $MAKE check TESTS=test/imap/test-live)
+            check-litter $action
+            ;;
+
+        live/dovecot)
+            (cd $abs_builddir && $nop env MH_COVERAGE_PROFILE=live/dovecot MH_IMAP_TEST_SERVER=127.0.0.1 MH_IMAP_TEST_PORT=1043 \
+                 MH_IMAP_TEST_USER=test MH_IMAP_TEST_PASSWORD=testpw MH_IMAP_TEST_EMAIL_ADDRESS=test MH_IMAP_TEST_TLS=no \
+                 MH_IMAP_SERVER_DRAFT_BORN_UNSEEN=' ' MH_IMAP_SERVER_EATS_TRAILING_EOL=' ' \
+                 $MAKE check TESTS=test/imap/test-live)
+            check-litter $action
+            ;;
+
+        live/cyrus)
+            (cd $abs_builddir && $nop env MH_COVERAGE_PROFILE=live/cyrus MH_IMAP_TEST_SERVER=127.0.0.1 MH_IMAP_TEST_PORT=143 MH_IMAP_TEST_USER=test \
+                 MH_IMAP_TEST_PASSWORD=test MH_IMAP_TEST_EMAIL_ADDRESS=test MH_IMAP_TEST_TLS=no MH_IMAP_TEST_MAILBOX_ROOT=test/ \
+                 MH_IMAP_SERVER_DRAFT_BORN_UNSEEN='U' \
+                 $MAKE check TESTS=test/imap/test-live)
+            check-litter $action
+            ;;
+
+        # Looks like Gandi use Dovecot
+        live/gandi)
+            (cd $abs_builddir && $nop env MH_COVERAGE_PROFILE=live/gandi MH_IMAP_TEST_SERVER=mail.gandi.net MH_IMAP_TEST_PORT=imaps \
+                 MH_IMAP_TEST_USER=$gandi_user MH_IMAP_TEST_PASSWORD=$gandi_pw MH_IMAP_TEST_EMAIL_ADDRESS=$gandi_user \
+                 MH_IMAP_TEST_TLS=implicit MH_IMAP_TEST_MAILBOX_ROOT=test/ \
+                 MH_IMAP_SERVER_DRAFT_BORN_UNSEEN=' '  \
+                 $MAKE check TESTS=test/imap/test-live)
+            check-litter $action
+            ;;
+
+        live/gandi-starttls)
+            (cd $abs_builddir && $nop env MH_COVERAGE_PROFILE=live/gandi-starttls MH_IMAP_TEST_SERVER=mail.gandi.net MH_IMAP_TEST_PORT=imap \
+                 MH_IMAP_TEST_USER=$gandi_user MH_IMAP_TEST_PASSWORD=$gandi_pw MH_IMAP_TEST_EMAIL_ADDRESS=$gandi_user \
+                 MH_IMAP_TEST_TLS=explicit MH_IMAP_TEST_MAILBOX_ROOT=test/ \
+                 MH_IMAP_SERVER_DRAFT_BORN_UNSEEN=' '  \
+                 $MAKE check TESTS=test/imap/test-live)
+            check-litter $action
+            ;;
+
+        live/gmail)
+            (cd $abs_builddir && $nop env MH_COVERAGE_PROFILE=live/gmail MH_IMAP_TEST_SERVER=gmail MH_IMAP_TEST_PORT=imaps MH_IMAP_TEST_USER=$gmail_user \
+                 MH_IMAP_TEST_OAUTH_FILE=$gmail_oauth MH_IMAP_TEST_EMAIL_ADDRESS=$gmail_user MH_IMAP_TEST_MAILBOX_ROOT=test/ \
+                 MH_IMAP_SERVER_DRAFT_BORN_UNSEEN='U'  \
+                 $MAKE check TESTS=test/imap/test-live)
+            check-litter $action
+            ;;
+
+        report-coverage)
+            ;;
+
+        clean)
+            $nop rm -rf $abs_builddir/target
+            $nop rm -rf $testdir
+            ;;
+    esac
+done
+fi
+
+if $coverage; then
+    test_objects=(
+        $abs_builddir/target/debug/imap-folder-manager
+        $($nop env LLVM_PROFILE_FILE="$abs_builddir/covdata/cargot/%m-%p.profraw" cargo t --tests --no-run --message-format=json 2>/dev/null \
+                | $nop perl -MJSON::PP -lne 'eval { $item = decode_json($_) }; if ($@) { @all=(); die($@) } $fns = $item->{profile}->{test} && $item->{filenames}; $fns && push(@all, @$fns); END { print join("\n", @all) }')
+    )
+    print -l $test_objects
+    cd $abs_srcdir
+    report-coverage all
+    (( ${profiles[(i)live/*]} <= ${#profiles} )) && report-coverage live live
+    for profile in $profiles; do
+        report-coverage $profile $profile
+    done
+    $nop chmod -R a+rX $abs_builddir/covdata/html
+    exit
+fi
diff --git a/guide b/guide
deleted file mode 100644 (file)
index b930430..0000000
--- a/guide
+++ /dev/null
@@ -1,9 +0,0 @@
-# guide file for acme(1)
-
-make clean     make ctags 'CTAGS=atags'
-sh autogen.sh
-./configure  '--prefix=/usr/local'
-
-tags           grep -rn    .
-
-git diff       git status      git pull
diff --git a/h/folder.h b/h/folder.h
new file mode 100644 (file)
index 0000000..bae4ae1
--- /dev/null
@@ -0,0 +1,40 @@
+/* folder.h -- mh_folder and associated operations..
+ *
+ * TODO - Replace most (all?) direct use of folder_read, m_convert, etc. with
+ * common operations.  To start with, move each command's own version in here.
+ * Once they're all factored out, we can factor out commonalities and reduce to
+ * the smallest set of common operations.
+ *
+ * Operations are:
+ * - Open
+ * - Read
+ * - Select
+ * - ???
+ * - Close
+ */
+
+typedef struct mh_folder mh_folder;
+
+bool
+mh_folder_open(mh_folder **result, char const *name, mh_managed_folders *mmfs);
+
+char const *
+mh_folder_get_err_string(mh_folder const *mhf);
+
+void
+mh_folder_close(mh_folder *mhf);
+
+/* TODO Factor just a few common message selection routines out of all these
+ * after message selection has been factored out of all uip programs. */
+
+// TODO Do we need to chdir?
+struct msgs const *
+mh_folder_select_and_chdir_for_scan(mh_folder *mhf, struct msgs_array msgs);
+bool
+mh_folder_message_is_unseen_for_scan(mh_folder *mhf, int msgnum);
+
+struct msgs const *
+mh_folder_select_and_chdir_for_send(mh_folder *mhf, char *const *msgs, size_t msg_count);
+
+struct msgs const *
+mh_folder_select_and_chdir_and_mark_seen(struct msgs_array *vec, mh_folder *mhf, struct msgs_array msgs);
diff --git a/h/folder_manager_protocol.h b/h/folder_manager_protocol.h
new file mode 100644 (file)
index 0000000..2f59f4c
--- /dev/null
@@ -0,0 +1,16 @@
+
+// TODO opaque (or just replace with the rust implementation)
+typedef struct {
+    char name[NAMESZ], value_buf[BUFSIZ];
+    m_getfld_state_t getfld_ctx;
+} mh_folder_manager_protocol_read_message_t;
+
+bool
+mh_folder_manager_protocol_read_message(svector_t, mh_folder_manager_protocol_read_message_t *);
+
+bool
+mh_folder_manager_protocol_write_message(FILE *fp, char const *message, size_t partial_len);
+
+/* TODO out of place */
+void
+free_svector_and_elements(svector_t *);
diff --git a/h/managed_folders.h b/h/managed_folders.h
new file mode 100644 (file)
index 0000000..a20fd4b
--- /dev/null
@@ -0,0 +1,133 @@
+typedef struct mh_managed_folders mh_managed_folders;
+
+typedef struct mh_managed_folder mh_managed_folder;
+
+// TODO separate .h / .c file for mh_folder_manager_*?
+typedef struct mh_folder_manager_client mh_folder_manager_client;
+typedef struct mh_folder_manager_inc_context mh_folder_manager_inc_context;
+
+/*
+ * On error, return false and set an error in mmfs; mmfs is always allocated.
+ */
+bool
+mh_managed_folders_open(mh_managed_folders **mmfs);
+
+/*
+ * Return null-terminated error message after some function indicated an error.
+ *
+ * Never returns NULL, but must not be called if an error was not indicated.
+ * Return value must be freed ONLY via mh_managed_folders_free.
+ */
+char const *
+mh_managed_folders_get_err_string(mh_managed_folders *);
+
+char const *
+mh_managed_folder_get_err_string(mh_managed_folder *);
+
+/*
+ * Return managed folder information if folder is managed, else NULL.
+ */
+mh_managed_folder *
+mh_managed_folder_lookup(mh_managed_folders const *mmfs, char const *name, size_t name_len);
+
+char const *
+mh_managed_folder_manager_path(mh_managed_folder const *mmf);
+
+char const *
+mh_managed_folder_socket_path(mh_managed_folder const *mmf);
+
+void
+mh_managed_folders_free(mh_managed_folders *mmfs);
+
+char const *
+mh_managed_folder_name(mh_managed_folder const *mmf);
+
+bool
+mh_folder_manager_connect(mh_folder_manager_client **mfmc, mh_managed_folder *mmf);
+
+bool
+mh_folder_manager_close(mh_folder_manager_client *mfmc);
+
+int
+mh_folder_manager_create_draft(
+    mh_folder_manager_client *mfmc,
+    char const *draft_folder);
+
+bool
+mh_folder_manager_make_folder(
+    mh_folder_manager_client *mfmc,
+    char const *folder);
+
+bool
+mh_folder_manager_update_draft(
+    mh_folder_manager_client *mfmc,
+    char const *draft_folder,
+    int msgnum);
+
+/*
+ * Returns NULL on error.
+ */
+mh_folder_manager_inc_context *
+mh_folder_manager_inc(mh_folder_manager_client *mfmc, char const *folder);
+
+bool
+mh_folder_manager_folder_read(mh_folder_manager_client *mfmc, char const *dfolder);
+
+bool
+mh_folder_manager_refile_move(
+    mh_folder_manager_client *mfmc,
+    char const *src_folder,
+    struct msgs_array msgs,
+    char const *dst_folder);
+
+bool
+mh_folder_manager_refile_file(
+    mh_folder_manager_client *mfmc,
+    char const *src_file,
+    char const *dst_folder);
+
+typedef struct {
+    bool done;
+    char *folder;
+    int msg;
+    bool unseen;
+} mh_managed_folder_inc_page;
+
+/*
+ * Return NULL on error.
+ */
+mh_managed_folder_inc_page *
+mh_folder_manager_inc_next(mh_folder_manager_inc_context *mfinc);
+
+/* Ask folder manager to pick messages specified by `msgs`.
+ *
+ * Folder manager writes picked messages into the special sequence 'all'.
+ *
+ * Ask the folder manager to set the unseen sequence if `need_unseen` is 1 and to delete it if 0.
+ * Any value other than 0 or 1 is an error.
+ */
+bool
+mh_folder_manager_pick(mh_folder_manager_client *mfmc, char const *folder, struct msgs_array msgs, int need_unseen);
+
+bool
+mh_folder_manager_rmf(mh_folder_manager_client *mfmc, char const *folder);
+
+bool
+mh_folder_manager_rmm(mh_folder_manager_client *mfmc, char const *folder, struct msgs_array msgs);
+
+bool
+mh_folder_manager_mark_add(
+    mh_folder_manager_client *mfmc,
+    char const *folder,
+    struct msgs_array msgs,
+    struct svector const *sequences);
+
+bool
+mh_folder_manager_mark_del(
+    mh_folder_manager_client *mfmc,
+    char const *folder,
+    struct msgs_array msgs,
+    struct svector const *sequences);
+
+void
+mh_managed_folder_set_err(mh_managed_folder *mmf, char const *error_message);
index 942e375..9432e67 100644 (file)
--- a/h/oauth.h
+++ b/h/oauth.h
@@ -105,11 +105,8 @@ struct mh_oauth_service_info {
  * If refreshing, writes freshened tokens to disk.
  *
  * Exits via adios on any error.
- *
- * Always returns OK for now, but in the future could return NOTOK on error.
  */
-
-int
+void
 mh_oauth_do_xoauth(const char *user, const char *svc, unsigned char **oauth_res,
                   size_t *oauth_res_len, FILE *log);
 
diff --git a/imap/folder-manager/Cargo.toml b/imap/folder-manager/Cargo.toml
new file mode 100644 (file)
index 0000000..4422c65
--- /dev/null
@@ -0,0 +1,14 @@
+[package]
+name = "imap-folder-manager"
+# TODO derive from nmh version
+version = "0.0.0"
+edition = "2018"
+
+[dependencies]
+libc = "0.2.78"
+log = { version = "0.4", features = ["std"] }
+# nmh
+managed-folders = { path = "../../managedfolders" }
+nmh = { path = "../../sbr/rust" }
+nmhimapstore = { path = "../store" }
+nmhimaptypes = { path = "../types" }
diff --git a/imap/folder-manager/src/cf.rs b/imap/folder-manager/src/cf.rs
new file mode 100644 (file)
index 0000000..b632081
--- /dev/null
@@ -0,0 +1,140 @@
+use std::num::NonZeroU32;
+use std::path::{Path, PathBuf};
+
+use nmh::{netsec, profile, Netsec};
+
+use crate::mailbox_rule::MailboxRules;
+use crate::regex::Regex;
+
+/// Short-lived configuration only needed for startup.
+pub struct Init<'a> {
+    pub socket_path: &'a Path,
+    pub auth: Auth,
+}
+
+impl<'a> Init<'a> {
+    pub fn new(prefixed: &'a profile::Prefixed) -> Result<Self, String> {
+        Ok(Self {
+            socket_path: socket_path(prefixed.get_or_err("socket")?)?,
+            auth: Auth::new(prefixed)?,
+        })
+    }
+}
+
+/// [crate::manager::Manager]'s configuration.
+#[derive(Debug)]
+pub struct Manager {
+    pub db_path: PathBuf,
+    pub mailbox_root: String,
+    pub max_fetch_messages_in_flight: NonZeroU32,
+    pub rules: crate::mailbox_rule::MailboxRules,
+}
+
+impl Manager {
+    pub fn new(prefixed: &profile::Prefixed) -> Result<Self, String> {
+        Ok(Self {
+            db_path: Path::new(prefixed.get_or_err("db")?).to_owned(),
+            mailbox_root: prefixed.get("mailbox-root").unwrap_or("").trim().to_owned(),
+            // TODO Reasonable defaults?  Or make the user pick?
+            max_fetch_messages_in_flight: match prefixed.get("max-fetch-messages-in-flight") {
+                Some(s) => s.trim().parse::<NonZeroU32>().map_err(|_| {
+                    "{}-max-fetch-messages-in-flight must be non-zero unsigned 32-bit integer"
+                })?,
+                None => NonZeroU32::new(100).expect("100 is non-zero"),
+            },
+            rules: mailbox_rules(prefixed.iter()),
+        })
+    }
+}
+
+pub enum Auth {
+    PREAUTH(netsec::Builder, nmh::client::Connector),
+    SASL(netsec::Builder, TLS, nmh::client::Connector),
+}
+
+impl Auth {
+    pub fn new(prefixed: &profile::Prefixed) -> Result<Self, String> {
+        let connector = nmh::client::Connector::new()
+            .server(prefixed.get_or_err("host")?)?
+            .service(prefixed.get_or_err("port")?)?;
+        let tls_type: TLS = prefixed.get_or_err("tls")?.parse()?;
+        let auth_type = prefixed.get_or_err("auth")?.trim();
+        Ok(match auth_type {
+            "PREAUTH" => {
+                match tls_type {
+                    TLS::Nah => {}
+                    _ => todo!("PREAUTH only implemented for tests; good enough?"),
+                }
+                Self::PREAUTH(Netsec::builder().snoop(), connector)
+            }
+            "SASL" => Self::SASL(
+                {
+                    let mut builder = Netsec::builder();
+                    if let Some(oauth_service) = prefixed.get("authservice") {
+                        builder.sasl_oauth_service(oauth_service.trim())?
+                    }
+                    if let Some(mech) = prefixed.get("saslmech") {
+                        builder.sasl_prefer_mechanism(mech.trim())?
+                    }
+                    builder
+                        .sasl_service("imap")?
+                        .sasl_userid(prefixed.get_or_err("user")?)?
+                        .host(connector.get_server())
+                        .snoop()
+                },
+                tls_type,
+                connector,
+            ),
+            _ => todo!("unknown auth parameter: {}", auth_type),
+        })
+    }
+}
+
+pub enum TLS {
+    Explicit,
+    Implicit,
+    Nah,
+    Yolo,
+    // TODO skipping certificate verification is orthogonal to explicit vs. implicit
+    ExplicitYolo,
+}
+
+impl std::str::FromStr for TLS {
+    type Err = String;
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        match s {
+            "explicit" => Ok(Self::Explicit),
+            "implicit" => Ok(Self::Implicit),
+            "don't protect my data i know what i'm doing" => Ok(Self::Nah),
+            "implicit but don't verify the cert i already did out of band" => Ok(Self::Yolo),
+            "explicit but don't verify the cert i already did out of band" => {
+                Ok(Self::ExplicitYolo)
+            }
+            _ => Err(format!("unknown TLS configuration {}", s)),
+        }
+    }
+}
+
+fn socket_path(s: &str) -> Result<&Path, String> {
+    // TODO(rust-lang#62061): const std::mem::zeroed
+    let sun: libc::sockaddr_un = unsafe { std::mem::zeroed() };
+    let len = sun.sun_path.len() - 1;
+    if s.len() > len {
+        Err(format!("socket path {} > system limit of {} bytes", s, len))
+    } else {
+        Ok(Path::new(s))
+    }
+}
+
+fn mailbox_rules(iter: profile::PrefixedIterator) -> MailboxRules {
+    let mut rules = MailboxRules::new();
+    for (key, value) in iter {
+        if key == "mailbox-include" {
+            rules.include(Regex::compile(value))
+        }
+        if key == "mailbox-exclude" {
+            rules.exclude(Regex::compile(value))
+        }
+    }
+    rules
+}
diff --git a/imap/folder-manager/src/error.rs b/imap/folder-manager/src/error.rs
new file mode 100644 (file)
index 0000000..a81fc55
--- /dev/null
@@ -0,0 +1,128 @@
+//! Support for recovering from errors.
+//!
+//! We use methods consuming the `self` parameter for safety, but we want to
+//! recover from some errors.  Such methods can return
+//! `Result<(Data, T), RecoverableError<T>>`.
+
+pub struct RecoverableError<T: std::fmt::Debug, E: 'static + std::error::Error> {
+    recoverable: Option<T>,
+    source: Box<E>,
+}
+
+impl<T: std::fmt::Debug, E: 'static + std::error::Error> RecoverableError<T, E> {
+    /// Return an `Error` wrapping `source` and holding `recoverable`, which may be [RecoverableError::recover]ed.
+    pub fn recoverable(source: E, recoverable: T) -> Self {
+        Self {
+            recoverable: Some(recoverable),
+            source: Box::new(source),
+        }
+    }
+
+    /// Return an `Error` wrapping `source` from which nothing may be [RecoverableError::recover]ed.
+    pub fn unrecoverable(source: E) -> Self {
+        Self {
+            recoverable: None,
+            source: Box::new(source),
+        }
+    }
+
+    /// Return a tuple of the `(recovered, source)` parameters of [RecoverableError::recoverable] or
+    /// the `source` parameter of [RecoverableError::unrecoverable].  Callers who must return their
+    /// own [RecoverableError] may benefit from [RecoverMapper].
+    pub fn recover(self) -> Result<(T, E), E> {
+        match self.recoverable {
+            None => Err(*self.source),
+            Some(recovered) => Ok((recovered, *self.source)),
+        }
+    }
+}
+
+impl<T: std::fmt::Debug, E: 'static + std::error::Error> std::fmt::Debug
+    for RecoverableError<T, E>
+{
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(
+            f,
+            "{}recoverable:  {:?}",
+            if self.recoverable.is_none() { "un" } else { "" },
+            self.source
+        )
+    }
+}
+
+impl<T: std::fmt::Debug, E: 'static + std::error::Error> std::fmt::Display
+    for RecoverableError<T, E>
+{
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        self.source.fmt(f)
+    }
+}
+
+impl<T: std::fmt::Debug, E: 'static + std::error::Error> std::error::Error
+    for RecoverableError<T, E>
+{
+    fn source(&self) -> Option<&(dyn 'static + std::error::Error)> {
+        Some(self.source.as_ref())
+    }
+}
+
+/// Helps consumers of [RecoverableError] of one recovered type map to their own recovered type.
+///
+/// Only good for one use.
+/// Either [RecoverMapper::map] or [RecoverMapper::ok] may be called, and only once.
+///
+/// ```
+/// rm = RecoverMapper::new(&|foo, foo_holder| foo_holder.rebuild(foo), foo_holder);
+/// // on error, map RecoverableError<Foo, E> to RecoverableError<FooHolder, E>
+/// foo.fallible().map_err(|e| rm.map(e))?;
+/// // success case
+/// foo_holder = rm.ok();
+/// ```
+pub struct RecoverMapper<'a, From, To, Context>
+where
+    From: std::fmt::Debug,
+    To: std::fmt::Debug,
+{
+    context: Option<Context>,
+    mapper: &'a dyn Fn(From, Context) -> To,
+    _p1: std::marker::PhantomData<From>,
+    _p2: std::marker::PhantomData<To>,
+}
+
+impl<'a, From, To, Context> RecoverMapper<'a, From, To, Context>
+where
+    From: std::fmt::Debug,
+    To: std::fmt::Debug,
+{
+    /// Create a `RecoverMapper` whose [RecoverMapper::map] method calls `mapper(e, context)` where
+    /// `e` is the parameter to [RecoverMapper::map] and `context` is the parameter here.
+    pub fn new(mapper: &'a dyn Fn(From, Context) -> To, context: Context) -> Self {
+        Self {
+            context: Some(context),
+            mapper,
+            _p1: std::marker::PhantomData,
+            _p2: std::marker::PhantomData,
+        }
+    }
+
+    /// Map one RecoverableError to another using `mapper` supplied to [RecoverMapper::new].
+    /// Panic if called twice, or after a call to [RecoverMapper::ok].
+    pub fn map<E: 'static + std::error::Error>(
+        &mut self,
+        from: RecoverableError<From, E>,
+    ) -> RecoverableError<To, E> {
+        match from.recoverable {
+            None => RecoverableError::unrecoverable(*from.source),
+            Some(recovered) => RecoverableError::recoverable(
+                *from.source,
+                (self.mapper)(recovered, self.context.take().expect("RecoverMapper reuse")),
+            ),
+        }
+    }
+
+    /// Take back the `context` passed to [RecoverMapper::new].
+    /// Panic if called twice, or after a call to [RecoverMapper::map].
+    pub fn ok(self) -> Context {
+        self.context.expect("RecoverMapper::ok called after error")
+    }
+}
diff --git a/imap/folder-manager/src/imap.rs b/imap/folder-manager/src/imap.rs
new file mode 100644 (file)
index 0000000..6c55a94
--- /dev/null
@@ -0,0 +1,49 @@
+//! IMAP
+//!
+//! RFC 3501 defines these states:
+//! - 3.1.    `Not Authenticated State`
+//!   We start here.  Use [auth::login] to move to...
+//! - 3.2.    `Authenticated State`
+//!   Modeled by [Session].
+//! - 3.3.    `Selected State`
+//!   Modeled by [mailbox::Session].
+//!   Entered via [Session::select].
+//!   Return to `Authenticated State` with [mailbox::Session::close].
+//! - 3.4.    `Logout State`
+//!   Entered when we say `LOGOUT` in `Drop` or server says `BYE`.  Either way, we
+//!   close the connection in `Drop`.
+
+pub mod auth;
+pub mod mailbox;
+mod protocol;
+mod session;
+
+pub use session::{Error, FetchResult, MessageReader, MessageResult, Session};
+
+pub enum Flag {
+    /// `\Deleted`
+    Deleted,
+    /// `\Draft`
+    Draft,
+    /// `\Seen`
+    Seen,
+    // Custom(String) ?
+}
+
+impl std::fmt::Display for Flag {
+    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        formatter.write_str(match self {
+            Self::Deleted => "\\Deleted",
+            Self::Draft => "\\Draft",
+            Self::Seen => "\\Seen",
+        })
+    }
+}
+
+/// Returned by [Session::list].
+#[derive(Eq, Ord, PartialEq, PartialOrd)]
+pub struct Mailbox {
+    pub name: mailbox::Name,
+    /// Whether [Session::select] and [mailbox::Session::select] will work with this mailbox.
+    pub selectable: bool,
+}
diff --git a/imap/folder-manager/src/imap/auth.rs b/imap/folder-manager/src/imap/auth.rs
new file mode 100644 (file)
index 0000000..e18bc40
--- /dev/null
@@ -0,0 +1,187 @@
+use nmh::{netsec, Error, Netsec};
+
+use crate::cf;
+use crate::imap::protocol::{
+    read_greeting, read_response, Capabilities, Capability, Greeting, ReadResult, Response,
+    ResponseCode, ServerData, Status,
+};
+use crate::imap::Session;
+
+type Result<T> = std::result::Result<T, Error>;
+
+// TODO Instead have Netsec and netsec::Authenticator implement a trait?
+macro_rules! maybe_read_capabilities_response {
+    ($name:expr, $netsec:expr) => {{
+        let mut capabilities = None;
+        loop {
+            let response: &str = $netsec.read_line()?;
+            match read_response(response)? {
+                ReadResult::Response(response) => match response {
+                    Response::ServerData(ServerData::Capability(c)) => capabilities = Some(c),
+                    Response::TaggedStatus(_, status) => match status {
+                        Status::Ok(code) => {
+                            if let ResponseCode::Capability(c) = code {
+                                capabilities = Some(c)
+                            }
+                            break;
+                        }
+                        other => todo!("only handling OK: {:?}", other),
+                    },
+                    _ => {}
+                },
+                _ => todo!("unexpected response to {}: {}", $name, response),
+            }
+        }
+        capabilities
+    }};
+}
+macro_rules! request_capabilities_if_none {
+    ($opt_capabilities:expr, $netsec:expr) => {
+        match $opt_capabilities {
+            Some(capabilities) => capabilities,
+            None => {
+                $netsec.write_and_flush(b"1 CAPABILITY\r\n")?;
+                maybe_read_capabilities_response!("CAPABILITY", $netsec)
+                    .ok_or_else(|| Error::new("server claims no capabilities"))?
+            }
+        }
+    };
+}
+
+// TODO allow config to override capabilities in case of broken server?
+pub fn login(context: &nmh::Context, auth_type: cf::Auth) -> Result<Session> {
+    let (mut netsec, opt_capabilities) = match auth_type {
+        cf::Auth::PREAUTH(builder, connector) => preauth(builder, connector)?,
+        cf::Auth::SASL(builder, tls, connector) => sasl(context, builder, tls, connector)?,
+    };
+    let capabilities = request_capabilities_if_none!(opt_capabilities, &mut netsec);
+    Session::new(netsec, capabilities)
+}
+
+fn preauth(
+    builder: netsec::Builder,
+    connector: nmh::client::Connector,
+) -> Result<(Netsec, Option<Capabilities>)> {
+    let mut netsec: Netsec = builder.connect(connector)?.skip_tls().skip_sasl();
+    let capabilities;
+    let greeting: &mut str = netsec.read_line()?;
+    match read_greeting(greeting)? {
+        Greeting::PreAuth(code) => match code {
+            ResponseCode::Capability(c) => capabilities = Some(c),
+            _ => capabilities = None,
+        },
+        _ => todo!("PREAUTH requested but server greeting is {}", greeting),
+    }
+    Ok((netsec, capabilities))
+}
+
+fn sasl(
+    context: &nmh::Context,
+    mut builder: netsec::Builder,
+    tls: crate::cf::TLS,
+    connector: nmh::client::Connector,
+) -> Result<(Netsec, Option<Capabilities>)> {
+    let unauthenticated_capabilities;
+    let mut netsec_auth: netsec::sasl::Authenticator = match tls {
+        cf::TLS::Explicit | cf::TLS::ExplicitYolo => {
+            if let cf::TLS::ExplicitYolo = tls {
+                builder = builder.skip_certificate_checks()
+            }
+            let mut nc: netsec::tls::Connector = builder.connect(connector)?;
+            let pre_tls_capabilities =
+                request_capabilities_if_none!(read_ok_greeting(nc.read_line()?)?, &mut nc);
+            if !pre_tls_capabilities.has(Capability::StartTLS) {
+                return Err(Error::new("server doesn't offer STARTTLS"));
+            }
+            nc.write_and_flush(b"1 STARTTLS\r\n")?;
+            unauthenticated_capabilities = maybe_read_capabilities_response!("STARTTLS", nc);
+            nc.negotiate_tls()?
+        }
+        cf::TLS::Implicit | cf::TLS::Yolo => {
+            if let cf::TLS::Yolo = tls {
+                builder = builder.skip_certificate_checks()
+            }
+            let mut netsec_auth: netsec::sasl::Authenticator =
+                builder.connect(connector)?.negotiate_tls()?;
+            unauthenticated_capabilities = read_ok_greeting(netsec_auth.read_line()?)?;
+            netsec_auth
+        }
+        cf::TLS::Nah => {
+            let mut netsec_auth: netsec::sasl::Authenticator =
+                builder.connect(connector)?.skip_tls();
+            unauthenticated_capabilities = read_ok_greeting(netsec_auth.read_line()?)?;
+            netsec_auth
+        }
+    };
+    let unauthenticated_capabilities =
+        request_capabilities_if_none!(unauthenticated_capabilities, &mut netsec_auth);
+    if !unauthenticated_capabilities.has(Capability::SASLIR) {
+        return Err(Error::new(
+            "server doesn't offer SASL-IR; let us know and we'll support this configuration",
+        ));
+    }
+    let mut callback = Callback(None);
+    let netsec = netsec_auth
+        .server_supported_sasl_mechanisms(unauthenticated_capabilities.iter().filter_map(|cap| {
+            match cap {
+                Capability::Auth(mech) => Some(*mech),
+                _ => None,
+            }
+        }))
+        .negotiate_sasl(context, &mut callback)?;
+    Ok((netsec, callback.0))
+}
+
+struct Callback(Option<Capabilities>);
+
+impl netsec::sasl::Callback<Error> for Callback {
+    fn start(
+        &mut self,
+        netsec: &mut Netsec,
+        selected_mechanism: &str,
+        initial_client_response: &[u8],
+    ) -> netsec::sasl::Result<(), Error> {
+        if initial_client_response.is_empty() {
+            todo!("empty sasl start initial_client_response not implemented")
+        }
+        let b64data: nmh::str::NString = nmh::base64::encode_raw(initial_client_response);
+        // TODO does anything NOT have SASL-IR capability?  https://www.imapwiki.org/Specs says maybe Courier
+        Ok(netsec.write_with_base64_at_offset_then_crlf_then_flush(
+            &format!("1 AUTHENTICATE {} {}\r\n", selected_mechanism, b64data),
+            // TODO 16 is len("1 AUTHENTICATE ")
+            16 + selected_mechanism.len() as std::os::raw::c_int,
+        )?)
+    }
+
+    fn read(&mut self, _: &mut Netsec) -> netsec::sasl::Result<Vec<u8>, Error> {
+        todo!("NETSEC_SASL_READ")
+    }
+
+    fn write(&mut self, _netsec: &mut Netsec, _message: &[u8]) -> netsec::sasl::Result<(), Error> {
+        todo!("NETSEC_SASL_WRITE")
+        // if !message.is_empty() {
+        //     todo!("NETSEC_SASL_WRITE");
+        // }
+        // netsec.write(b"\r\n")?;
+        // Ok(netsec.flush()?)
+    }
+
+    fn finish(&mut self, netsec: &mut Netsec) -> netsec::sasl::Result<(), Error> {
+        self.0 = maybe_read_capabilities_response!("AUTHENTICATE", netsec);
+        Ok(())
+    }
+
+    fn cancel(&mut self) -> netsec::sasl::Result<(), Error> {
+        todo!("NETSEC_SASL_CANCEL not implemented")
+    }
+}
+
+fn read_ok_greeting(greeting: &mut str) -> Result<Option<Capabilities>> {
+    Ok(match read_greeting(greeting)? {
+        Greeting::Ok(code) => match code {
+            ResponseCode::Capability(capabilities) => Some(capabilities),
+            _ => None,
+        },
+        _ => todo!("unexpected greeting {}", greeting),
+    })
+}
diff --git a/imap/folder-manager/src/imap/mailbox.rs b/imap/folder-manager/src/imap/mailbox.rs
new file mode 100644 (file)
index 0000000..1350ff6
--- /dev/null
@@ -0,0 +1,23 @@
+pub use super::session::mailbox::{Error, Selected, Session};
+
+// TODO Support hierarchy separator other than '/'?  Cyrus defaults to '.'
+#[derive(Debug, Eq, Ord, PartialEq, PartialOrd)]
+pub struct Name(String);
+
+impl Name {
+    pub fn as_str(&self) -> &str {
+        self.0.as_str()
+    }
+}
+
+impl std::fmt::Display for Name {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        self.0.fmt(f)
+    }
+}
+
+impl From<String> for Name {
+    fn from(s: String) -> Self {
+        Name(s)
+    }
+}
diff --git a/imap/folder-manager/src/imap/protocol.rs b/imap/folder-manager/src/imap/protocol.rs
new file mode 100644 (file)
index 0000000..1f33a8e
--- /dev/null
@@ -0,0 +1,936 @@
+//! IMAP protocol handling used by the high-level [crate::imap::Session].
+
+use log::{debug, trace, warn};
+
+use nmh::Error;
+use nmhimaptypes::{uid, Uid, UidValidity};
+
+mod response;
+pub use response::{
+    Capabilities, Capability, Fetch, Greeting, Response, ResponseCode, ServerData, Status,
+};
+
+type Result<T> = std::result::Result<T, Error>;
+
+// greeting        = "*" SP ("OK" / "PREAUTH" / "BYE" SP resp-text) CRLF
+pub fn read_greeting(line: &mut str) -> Result<Greeting> {
+    trace!("read_greeting({})", line);
+    // TODO de-duplicate with read_response / read_untagged
+    let (first_word, index_of_rest): (&str, usize) = {
+        let first_space: usize = line
+            .find(' ')
+            .ok_or_else(|| Error::new("protocol error:  no space in response"))?;
+        (&line[..first_space], first_space + 1)
+    };
+    match first_word {
+        "*" => {}
+        _ => {
+            return Err(Error::new(format!(
+                "protocol error:  no leading * in {}",
+                line
+            )))
+        }
+    }
+    let mut index = line[index_of_rest..]
+        .find(' ')
+        .ok_or_else(|| Error::new("protocol error:  resp-text missing from resp-cond-auth"))?;
+    index += index_of_rest;
+    line[index_of_rest..index].make_ascii_uppercase();
+    let word = &line[index_of_rest..index];
+    let code: ResponseCode = read_resp_text(&line[index + 1..])?.0;
+    match word {
+        "OK" => Ok(Greeting::Ok(code)),
+        "PREAUTH" => Ok(Greeting::PreAuth(code)),
+        "BYE" => todo!("BYE in greeting"),
+        _ => todo!("protocol error:  unexpected greeting {} in {}", word, line),
+    }
+}
+
+pub fn read_response(line: &str) -> Result<ReadResult> {
+    let (first_word, index_of_rest): (&str, usize) = {
+        let first_space: usize = line
+            .find(' ')
+            .ok_or_else(|| Error::new("protocol error:  no space in response"))?;
+        (&line[..first_space], first_space + 1)
+    };
+    match first_word {
+        "+" => Ok(ReadResult::CommandContinuationRequest),
+        "*" => read_untagged(&line[index_of_rest..]),
+        _ => Ok(ReadResult::Response(Response::TaggedStatus(
+            first_word.to_owned(),
+            read_resp_cond_state(&line[index_of_rest..])?,
+        ))),
+    }
+}
+
+/// The result of any IMAP read operation, such as [read_response].
+pub enum ReadResult {
+    /// Server is awaiting client to send a continuation request.
+    CommandContinuationRequest,
+    /// Server has provided a response.
+    Response(Response),
+    // TODO Should Vec<Fetch> show up here?.  Better would be to stash it inside the LineReader?
+    //  we're not returning anything out of that in this case right now anyway.
+    /// Server has this many bytes for client to read.  Caller must read them and then read a line and pass to `LineReader`.
+    FetchBytes(Vec<Fetch>, usize, LineReader),
+    /// Server has sent a partial response and caller must read this many bytes and pass bo `ByteReader` in order to continue.
+    NeedBytes(usize, ByteReader),
+    /// Server has sent a partial response and caller must read one line and pass bo `LineReader` in order to continue.
+    NeedLine(LineReader),
+}
+
+impl std::fmt::Debug for ReadResult {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            ReadResult::CommandContinuationRequest => {
+                f.debug_tuple("CommandContinuationRequest").finish()
+            }
+            ReadResult::Response(r) => f
+                .debug_tuple("Response")
+                .field(&format!("{:?}", r))
+                .finish(),
+            ReadResult::FetchBytes(_, n, reader) => f
+                .debug_tuple("FetchBytes")
+                .field(&"Vec<Fetch>")
+                .field(n)
+                .field(&format!("{:?}", reader))
+                .finish(),
+            ReadResult::NeedBytes(n, reader) => f
+                .debug_tuple("NeedBytes")
+                .field(n)
+                .field(&format!("{:?}", reader))
+                .finish(),
+            ReadResult::NeedLine(reader) => f
+                .debug_tuple("NeedLine")
+                .field(&format!("{:?}", reader))
+                .finish(),
+        }
+    }
+}
+
+pub struct ByteReader(Option<ByteReading>);
+
+enum ByteReading {
+    MailboxList(MailboxListReader),
+}
+
+impl ByteReader {
+    fn new(reading: ByteReading) -> Self {
+        Self(Some(reading))
+    }
+
+    pub fn read_bytes(mut self, bytes: &[u8]) -> Result<ReadResult> {
+        match self.0.take().expect("ByteReader not reusable") {
+            ByteReading::MailboxList(reader) => reader.read_bytes(bytes),
+        }
+    }
+
+    // TODO implement this for use when caller decides to LOGOUT rather than continue
+    // pub fn abort(mut self) {
+    //     self.0 = None;
+    // }
+}
+
+impl Drop for ByteReader {
+    fn drop(&mut self) {
+        assert!(self.0.is_none());
+    }
+}
+
+impl std::fmt::Debug for ByteReader {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("ByteReader").finish()
+    }
+}
+
+pub struct LineReader(Option<LineReading>);
+
+enum LineReading {
+    Fetch(FetchReader),
+    MailboxList(MailboxListReader),
+}
+
+impl LineReader {
+    fn new(reading: LineReading) -> Self {
+        Self(Some(reading))
+    }
+
+    pub fn read_line(mut self, line: &str) -> Result<ReadResult> {
+        match self.0.take().expect("LineReader not reusable") {
+            LineReading::Fetch(reader) => reader.read_line(line),
+            LineReading::MailboxList(reader) => reader.read_line(line),
+        }
+    }
+
+    // TODO implement this for use when caller decides to LOGOUT rather than continue
+    // pub fn abort(mut self) {
+    //     self.0 = None;
+    // }
+}
+
+impl Drop for LineReader {
+    fn drop(&mut self) {
+        assert!(self.0.is_none());
+    }
+}
+
+impl std::fmt::Debug for LineReader {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.debug_tuple("LineReader")
+            .field(match &self.0 {
+                None => &"None",
+                Some(LineReading::Fetch(_)) => &"Fetch",
+                Some(LineReading::MailboxList(_)) => &"MailboxList",
+            })
+            .finish()
+    }
+}
+
+// msg-att         = "(" (msg-att-dynamic / msg-att-static) *(SP (msg-att-dynamic / msg-att-static)) ")"
+struct FetchReader {
+    parens_open: u32,
+}
+
+impl FetchReader {
+    fn start(line: &str) -> Result<ReadResult> {
+        trace!("FetchReader::new({})", line);
+        if !(line.starts_with('(') || line.starts_with(')')) {
+            return Err(Error::new(format!(
+                "protocol error:  msg-att doesn't start with '(' or ')':  {}",
+                line
+            )));
+        }
+        Self { parens_open: 0 }.read_line(line)
+    }
+
+    fn read_line(mut self, line: &str) -> Result<ReadResult> {
+        let mut results: Vec<Fetch> = vec![];
+        let mut start = 0;
+        loop {
+            trace!("FetchReader::read_line loop looking at");
+            crate::logging::log_string_parts(log::Level::Trace, line, start, line.len());
+            let msg_att = &line[start..];
+            if msg_att.is_empty() {
+                return Err(Error::new(
+                    "protocol error:  FETCH response line ends without ')'",
+                ));
+            }
+            match &msg_att[..1] {
+                "(" => {
+                    self.parens_open += 1;
+                    start += 1;
+                }
+                ")" => {
+                    trace!("FetchReader::next Done");
+                    if self.parens_open > 1 {
+                        return Err(Error::new(format!(
+                            "protocol error:  {} more parentheses opened than closed",
+                            self.parens_open
+                        )));
+                    }
+                    return Ok(ReadResult::Response(Response::ServerData(
+                        ServerData::Fetch(results),
+                    )));
+                }
+                " " => {
+                    start += 1;
+                }
+                unexpected => {
+                    return Err(Error::new(format!(
+                        "protocol error:  expected [ ()] not {}",
+                        unexpected
+                    )))
+                }
+            }
+            crate::logging::log_string_parts(log::Level::Trace, line, start, line.len());
+            // TODO why did i set this and then reset it
+            let msg_att: &str = &line[start..];
+            let mut iterator = msg_att.match_indices(&[' ', ')'][..]);
+            let (end_of_word, matched_delimiter): (usize, &str) =
+                iterator.next().ok_or_else(|| {
+                    Error::new("protocol error:  missing next ' ' or ')' in FETCH response")
+                })?;
+            let word: &str = &msg_att[..end_of_word];
+            trace!(
+                "FetchReader::next end_of_word={} delimiter={}:  {}",
+                end_of_word,
+                matched_delimiter,
+                word
+            );
+            match word {
+                // TODO we get one of these two
+                //  * 1 FETCH (FLAGS (\Seen \Deleted \Draft) UID 1)
+                //  * 1 FETCH (UID 1 FLAGS (\Seen))
+                //  * 1 FETCH (UID 1 BODY[] {181}
+                // TODO in response to UID STORE we get the same data twice!
+                //  => 1 UID STORE 1 -FLAGS (\Seen)
+                //  <= * 1 FETCH (FLAGS () UID 1)
+                //  <= * 1 FETCH (UID 1 FLAGS ())
+                "UID" => {
+                    if matched_delimiter == ")" {
+                        return Err(Error::new("protocol error:  'UID' not followed by a uid"));
+                    }
+                    let (end_of_uid, _) = iterator
+                        .next()
+                        .ok_or_else(|| Error::new("protocol error:  missing next ' ' or ')'"))?;
+                    start += end_of_uid;
+                    let digits = &msg_att[end_of_word + 1..end_of_uid];
+                    results.push(Fetch::Uid(Uid::from(read_nz_number(digits)?)));
+                }
+                "FLAGS" => {
+                    if matched_delimiter == ")" {
+                        return Err(Error::new("protocol error:  'FLAGS' not followed by flags"));
+                    }
+                    let flags_start = end_of_word + 1;
+                    let flag_start = flags_start + 1;
+                    if msg_att.len() < flag_start {
+                        return Err(Error::new(format!(
+                            "protocol error:  msg-att too short:  {}",
+                            msg_att
+                        )));
+                    }
+                    if &msg_att[flags_start..flag_start] != "(" {
+                        return Err(Error::new("protocol error:  flags not start with '('"));
+                    }
+                    self.parens_open += 1;
+                    let mut seen = false;
+                    loop {
+                        let (flag_end, matched_delimiter): (usize, &str) = iterator
+                            .next()
+                            .ok_or_else(|| Error::new("protocol error:  FLAGS end without ')'"))?;
+                        trace!(
+                            "FetchReader::read_line FLAGS loop matched_delim={} looking at",
+                            matched_delimiter
+                        );
+                        crate::logging::log_string_parts(
+                            log::Level::Trace,
+                            msg_att,
+                            flag_start,
+                            msg_att.len(),
+                        );
+                        let flag: &str = &msg_att[flag_start..flag_end];
+                        // TODO case
+                        if flag == "\\Seen" {
+                            seen = true;
+                        }
+                        if matched_delimiter == ")" {
+                            self.parens_open -= 1;
+                            start += flag_end + 1;
+                            results.push(Fetch::Flags(seen));
+                            break;
+                        }
+                    }
+                }
+                "BODY[]" => {
+                    // RFC3501 doesn't seem to make any promise about ordering, so we might see the UID
+                    // first and then have the bytes to save, or we might have to save the bytes first
+                    // and then keep reading for the UID.
+                    // That would be quite painful.
+                    // All implementations I've encountered so far send UID before BODY.
+                    // Let's see how far we can get without it?
+                    let size_spec: &str = &msg_att[end_of_word + 1..];
+                    let (size, one_past_end): (usize, usize) = read_literal_size(size_spec)?;
+                    if one_past_end != size_spec.len() {
+                        return Err(Error::new(format!(
+                            "protocol error:  characters after size:  {}",
+                            &size_spec[one_past_end..]
+                        )));
+                    }
+                    if self.parens_open > 1 {
+                        return Err(Error::new(format!(
+                                    "protocol error:  {} parentheses opened when BODY[] received; should be 1",
+                                    self.parens_open
+                                )));
+                    }
+                    return Ok(ReadResult::FetchBytes(
+                        results,
+                        size,
+                        LineReader::new(LineReading::Fetch(self)),
+                    ));
+                }
+                "" => {
+                    if matched_delimiter == ")" {
+                        debug!("msg-att = ()?  okay...");
+                    } else {
+                        todo!("what is msg-att here: {}", msg_att);
+                    }
+                }
+                r => todo!("what '{:?}'", r),
+            }
+        }
+    }
+}
+
+// mailbox-list = "(" [mbx-list-flags] ")" SP (DQUOTE QUOTED-CHAR DQUOTE / nil) SP "INBOX" / astring
+struct MailboxListReader {
+    flags: String,
+    // TODO bug-prone having mutually exclusive Options but it's so internal...
+    name: Option<String>,
+    reader: Option<AStringReader>,
+}
+
+impl MailboxListReader {
+    fn start(line: &str) -> Result<ReadResult> {
+        trace!("read_mailbox_list({})", line);
+        if !line.starts_with('(') {
+            return Err(Error::new(
+                "protocol error:  mailbox-list doesn't start with '('",
+            ));
+        }
+        let mbx_list_flags_close: usize = line
+            .find(')')
+            .ok_or_else(|| Error::new("protocol error:  mailbox-list missing ')'"))?;
+        // mbx_list_flags_close = 6 is minimum length:
+        // 0123456
+        // ) "/" f
+        if line.len() < mbx_list_flags_close + 6 {
+            return Err(Error::new("protocol error:  mailbox-list ends at ')'"));
+        }
+        // TODO handle separator.  Also it might be /nil/i
+        let rest = if &line[mbx_list_flags_close + 2..mbx_list_flags_close + 3] == "\"" {
+            let (separator, rest) = unquote(&line[mbx_list_flags_close + 2..])?;
+            warn!("TODO handle mailbox separator {}", separator);
+            warn!(
+                "TODO after mailbox separator '{}'",
+                &line[rest + mbx_list_flags_close + 2..]
+            );
+            rest + mbx_list_flags_close + 2
+        } else {
+            todo!("NIL separator not yet supported");
+        };
+        if line.len() - rest < 2 {
+            return Err(Error::new(
+                "protocol error:  mailbox-list missing mailbox name",
+            ));
+        }
+        if &line[rest..rest + 1] != " " {
+            return Err(Error::new(
+                "protocol error:  mailbox-list missing space before mailbox name",
+            ));
+        }
+        let flags = line[1..mbx_list_flags_close].to_lowercase();
+        trace!("mbx-list-flags is '{}'", flags);
+        Ok(match AStringReader::start(&line[rest + 1..])? {
+            AStringReaderResult::NeedBytes(n, reader) => ReadResult::NeedBytes(
+                n,
+                ByteReader::new(ByteReading::MailboxList(Self {
+                    flags,
+                    name: None,
+                    reader: Some(reader),
+                })),
+            ),
+            AStringReaderResult::Done(value, _) => {
+                trace!("mailbox is '{}'", value);
+                ReadResult::Response(Response::ServerData(ServerData::List(value, flags)))
+            }
+        })
+    }
+
+    // TODO bug-prone having both but it's so internal...
+    fn read_bytes(self, bytes: &[u8]) -> Result<ReadResult> {
+        Ok(ReadResult::NeedLine(LineReader::new(
+            LineReading::MailboxList(Self {
+                flags: self.flags,
+                name: Some(self.reader.expect("AStringReader to be here").read(bytes)?),
+                reader: None,
+            }),
+        )))
+    }
+    fn read_line(self, line: &str) -> Result<ReadResult> {
+        if !line.is_empty() {
+            return Err(Error::new(format!(
+                "protocol error:  extraneous bytes after mailbox-list:  {}",
+                line
+            )));
+        }
+        Ok(ReadResult::Response(Response::ServerData(
+            ServerData::List(self.name.expect("mailbox name to be here"), self.flags),
+        )))
+    }
+}
+
+fn read_untagged(line: &str) -> Result<ReadResult> {
+    trace!("read_untagged({})", line);
+    if line.is_empty() {
+        return Err(Error::new("protocol error:  empty untagged response"));
+    }
+    let (word, rest) = match line.find(' ') {
+        Some(index) => (line[..index].to_ascii_uppercase(), Some(index + 1)),
+        None => (line.to_ascii_uppercase(), None),
+    };
+    match word.as_str() {
+        "OK" => {
+            // TODO NO and BAD may appear here too!
+            let index_of_resp_text = rest.ok_or_else(|| {
+                Error::new("protocol error:  resp-text missing from resp-cond-state")
+            })?;
+            let (code, _) = read_resp_text(&line[index_of_resp_text..])?;
+            Ok(ReadResult::Response(Response::UntaggedStatus(Status::Ok(
+                code,
+            ))))
+        }
+        // "LIST" SP mailbox-list
+        "LIST" => rest
+            .map(|index| MailboxListReader::start(&line[index..]))
+            .ok_or_else(|| Error::new("protocol error:  LIST response missing mailbox-list"))?,
+        "ESEARCH" => {
+            let messages = match rest {
+                None => &line[..0],
+                Some(rest) => &line[rest..],
+            };
+            trace!("captured messages from search: '{}'", messages);
+            Ok(ReadResult::Response(Response::ServerData(
+                ServerData::ESearch(read_esearch_response(messages)?),
+            )))
+        }
+        "SEARCH" => {
+            let messages = match rest {
+                None => &line[..0],
+                Some(rest) => &line[rest..],
+            };
+            trace!("captured messages from search: '{}'", messages);
+            Ok(ReadResult::Response(Response::ServerData(
+                ServerData::Search(read_search_response(messages)?),
+            )))
+        }
+        "CAPABILITY" => {
+            let capabilities = match rest {
+                None => &line[..0],
+                Some(rest) => &line[rest..],
+            };
+            Ok(ReadResult::Response(Response::ServerData(
+                ServerData::Capability(read_capability_data(capabilities)),
+            )))
+        }
+        "BYE" => todo!("handle BYE: {}", line),
+        _ => {
+            match rest {
+                None => {
+                    trace!("ignoring {}", word);
+                    Ok(ReadResult::Response(Response::ServerData(
+                        ServerData::Unknown,
+                    )))
+                }
+                Some(rest_index) => {
+                    // We have some cases where `word` is a number and there's a second word (`word2`) we need to look at.
+                    let (word2, word2_rest) = match line[rest_index..].find(' ') {
+                        None => (line[rest_index..].to_ascii_uppercase(), None),
+                        Some(index) => (
+                            line[rest_index..rest_index + index].to_ascii_uppercase(),
+                            Some(rest_index + index + 1),
+                        ),
+                    };
+                    match word2.as_str() {
+                        "EXISTS" => match word.parse::<u32>() {
+                            Ok(exists) => Ok(ReadResult::Response(Response::ServerData(
+                                ServerData::Exists(exists),
+                            ))),
+                            _ => Ok(ReadResult::Response(Response::ServerData(
+                                ServerData::Unknown,
+                            ))),
+                        },
+                        // nz-number SP "FETCH" SP msg-att
+                        // nz-number is the message sequence number:  we don't care.
+                        "FETCH" => word2_rest
+                            .map(|rest_index| FetchReader::start(&line[rest_index..]))
+                            .ok_or_else(|| {
+                                Error::new("protocol error:  FETCH response missing msg-att")
+                            })?,
+                        _ => {
+                            trace!("ignoring {} and {}", word, word2);
+                            Ok(ReadResult::Response(Response::ServerData(
+                                ServerData::Unknown,
+                            )))
+                        }
+                    }
+                }
+            }
+        }
+    }
+}
+
+fn unquote(quoted: &str) -> Result<(String, usize)> {
+    let mut result = String::new();
+    let mut escaped = false;
+    // Assume first byte is " and skip that.
+    for (i, c) in quoted[1..].char_indices() {
+        if escaped {
+            escaped = false;
+            result.push(c);
+        } else if c == '\\' {
+            escaped = true;
+        } else if c == '"' {
+            return Ok((result, i + 2));
+        } else {
+            result.push(c);
+        }
+    }
+    Err(Error::new("protocol error:  str missing close quote"))
+}
+
+// resp-text       = ["[" resp-text-code "]" SP] text
+fn read_resp_text(resp_text: &str) -> Result<(ResponseCode, &str)> {
+    if resp_text.is_empty() {
+        return Err(Error::new("protocol error: resp-text is empty"));
+    }
+    if &resp_text[0..1] != "[" {
+        return Ok((ResponseCode::Unknown, resp_text));
+    }
+    let close_bracket: usize = resp_text
+        .find(']')
+        .ok_or_else(|| Error::new("protocol error:  no close bracket in resp-text"))?;
+    let text = if close_bracket + 1 == resp_text.len() {
+        debug!("GMAIL QUIRK resp-text:  {}", resp_text);
+        // GMail responds with
+        //    * OK [HIGHESTMODSEQ 8869]
+        // RFC 3501 defines it as
+        //    resp-text       = ["[" resp-text-code "]" SP] text
+        //    text            = 1*TEXT-CHAR
+        // The text is not optional, and if the optional bracketed text MUST be followed by a space.
+        &resp_text[..0]
+    } else {
+        if resp_text.len() < close_bracket + 2 {
+            return Err(Error::new(format!(
+                "protocol error:  resp-text too short:  {}",
+                resp_text
+            )));
+        }
+        if &resp_text[close_bracket + 1..close_bracket + 2] != " " {
+            return Err(Error::new(
+                "protocol error:  after resp-text-code is something not a space",
+            ));
+        }
+        &resp_text[close_bracket + 2..]
+    };
+    let (status, resp_text_code) = match resp_text[1..close_bracket].find(' ') {
+        Some(index) => {
+            if index + 2 >= close_bracket {
+                return Err(Error::new(format!(
+                    "protocol error:  resp-text too short:  {}",
+                    resp_text
+                )));
+            }
+            (
+                resp_text[1..index + 1].to_ascii_uppercase(),
+                &resp_text[index + 2..close_bracket],
+            )
+        }
+        None => (
+            resp_text[1..close_bracket].to_ascii_uppercase(),
+            &resp_text[..1],
+        ),
+    };
+    Ok(match status.as_str() {
+        //   resp-code-apnd  = "APPENDUID" SP nz-number SP append-uid
+        "APPENDUID" => (read_appenduid(resp_text_code)?, text),
+        "CAPABILITY" => (
+            ResponseCode::Capability(read_capability_data(resp_text_code)),
+            text,
+        ),
+        //   resp-code-copy  = "COPYUID" SP nz-number SP uid-set SP uid-set
+        "COPYUID" => (read_copyuid(resp_text_code)?, text),
+        // "UIDNEXT" SP nz-number
+        "UIDNEXT" => {
+            let result = (
+                ResponseCode::UidNext(Uid::from(read_nz_number(resp_text_code)?)),
+                text,
+            );
+            trace!("{:?}", result);
+            result
+        }
+        "UIDVALIDITY" => (
+            ResponseCode::UidValidity(UidValidity::from(read_nz_number(resp_text_code)?)),
+            text,
+        ),
+        _ => {
+            trace!("Unknown response code {} {}", status, text);
+            (ResponseCode::Unknown, text)
+        }
+    })
+}
+
+fn read_resp_cond_state(resp_cond_state: &str) -> Result<Status> {
+    let index: usize = resp_cond_state.find(' ').ok_or_else(|| {
+        Error::new(format!(
+            "protocol error:  can't find ' ' in resp-cond-state:  {}",
+            resp_cond_state
+        ))
+    })?;
+    if index + 1 >= resp_cond_state.len() {
+        return Err(Error::new(format!(
+            "protocol error:  resp-cond-state too short:  {}",
+            resp_cond_state
+        )));
+    }
+    match &resp_cond_state[..index] {
+        "OK" => {
+            let (code, _) = read_resp_text(&resp_cond_state[index + 1..])?;
+            Ok(Status::Ok(code))
+        }
+        "NO" => {
+            let (code, text) = read_resp_text(&resp_cond_state[index + 1..])?;
+            Ok(Status::No(code, text.to_owned()))
+        }
+        "BAD" => {
+            let (code, text) = read_resp_text(&resp_cond_state[index + 1..])?;
+            Ok(Status::Bad(code, text.to_owned()))
+        }
+        trash => Err(Error::new(format!(
+            "protocol error:  resp-cond-state must start with (OK | NO | BAD) not {}",
+            trash
+        ))),
+    }
+}
+
+// atom-specials   = "(" / ")" / "{" / SP / CTL / list-wildcards /
+//                   quoted-specials / resp-specials
+// list-wildcards  = "%" / "*"
+// quoted-specials = DQUOTE / "\"
+// resp-specials   = "]"
+const ATOM_SPECIALS: &[char] = &['(', ')', '{', ' ', '%', '*', '"', '\\', ']'];
+
+// Read one 'astring', reading additional bytes from the server if it's a 'literal'.
+// Returns (slice of the fully-read astring, slice of the astring parameter not processed herein).
+// The full specification of 'astring' follows, but all we need care about it:
+// - non-double-quoted-and-no-whitespace-word
+// - "double-quoted text with backslash-escaping"
+// - {N}\r\n where N is non-zero unsigned 32-bit number of bytes to read
+// astring              = 1*ASTRING-CHAR / string
+// ASTRING-CHAR         = ATOM-CHAR / resp-specials
+// ATOM-CHAR            = <any CHAR except atom-specials>
+// atom-specials        = "(" / ")" / "{" / SP / CTL / list-wildcards / quoted-specials / resp-specials
+// list-wildcards       = "%" / "*"
+// literal              = "{" number "}" CRLF *CHAR8
+//                        ; Number represents the number of CHAR8s
+// number               = 1*DIGIT
+//                        ; Unsigned 32-bit integer
+// quoted               = DQUOTE *QUOTED-CHAR DQUOTE
+// QUOTED-CHAR          = <any TEXT-CHAR except quoted-specials> / "\" quoted-specials
+// quoted-specials      = DQUOTE / "\"
+// resp-specials        = "]"
+// string               = quoted / literal
+enum AStringReaderResult {
+    NeedBytes(usize, AStringReader),
+    Done(String, usize),
+}
+struct AStringReader(usize);
+
+impl AStringReader {
+    pub fn start(line: &str) -> Result<AStringReaderResult> {
+        if line.is_empty() {
+            return Err(Error::new("protocol error:  empty astring"));
+        }
+        Ok(match &line[..1] {
+            "{" => {
+                let (n, one_past_end) = read_literal_size(line)?;
+                if one_past_end != line.len() {
+                    return Err(Error::new(format!(
+                        "protocol error: extraneous bytes after {{N}}:  {}",
+                        &line[one_past_end..]
+                    )));
+                }
+                AStringReaderResult::NeedBytes(n, Self(n))
+            }
+            "\"" => {
+                let (value, rest_index) = unquote(line)?;
+                AStringReaderResult::Done(value, rest_index)
+            }
+            _ => {
+                let end_of_atom = line.find(ATOM_SPECIALS).unwrap_or(line.len());
+                // TODO end_of_atom not tested!  Only use of AStringReader is MailboxListReader,
+                //  whose astring comes at the end of the line so it doesn't care.
+                AStringReaderResult::Done(line[..end_of_atom].to_string(), end_of_atom)
+            }
+        })
+    }
+
+    pub fn read(self, bytes: &[u8]) -> Result<String> {
+        if bytes.len() != self.0 {
+            Err(Error::new(format!(
+                "INTERNAL ERROR:  AStringReader expected {} bytes, got {}",
+                self.0,
+                bytes.len()
+            )))
+        } else {
+            Ok(std::str::from_utf8(bytes)
+                .map_err(|e| {
+                    Error::describe(
+                        e,
+                        format_args!("protocol error:  AStringReader requires UTF-8"),
+                    )
+                })?
+                .to_owned())
+        }
+    }
+}
+
+/// Returns (the literal size, one past the close brace).
+fn read_literal_size(literal: &str) -> Result<(usize, usize)> {
+    let end: usize = literal.find('}').ok_or_else(|| {
+        Error::new(format!(
+            "protocol error:  close brace not found in literal {}",
+            literal
+        ))
+    })?;
+    let size_str = &literal[1..end];
+    trace!("read_literal_size({}[1..{}]) -> {}", literal, end, size_str);
+    Ok((
+        size_str.parse().map_err(|e| {
+            Error::describe(
+                e,
+                format_args!("protocol error decoding literal size {}", size_str),
+            )
+        })?,
+        end + 1,
+    ))
+}
+
+// nz-number SP append-uid
+fn read_appenduid(appenduid: &str) -> Result<ResponseCode> {
+    match appenduid.split(' ').collect::<Vec<_>>().as_slice() {
+        [uidvalidity, uid] => Ok(ResponseCode::AppendUid(
+            UidValidity::from(read_nz_number(uidvalidity)?),
+            Uid::from(read_nz_number(uid)?),
+        )),
+        _ => Err(Error::new(format!(
+            "protocol error:  APPENDUID response too small:  {}",
+            appenduid
+        ))),
+    }
+}
+
+// nz-number SP uid-set SP uid-set
+fn read_copyuid(copyuid: &str) -> Result<ResponseCode> {
+    match copyuid.split(' ').collect::<Vec<_>>().as_slice() {
+        [uid_validity, _, uid] => Ok(ResponseCode::CopyUid(
+            UidValidity::from(read_nz_number(uid_validity)?),
+            Uid::from(read_nz_number(uid)?),
+        )),
+        _ => Err(Error::new(format!(
+            "protocol error:  COPYUID response too small:  {}",
+            copyuid
+        ))),
+    }
+}
+
+// nz-number       = digit-nz *DIGIT
+//                     ; Non-zero unsigned 32-bit integer
+//                     ; (0 < n < 4,294,967,296)
+fn read_nz_number(s: &str) -> Result<std::num::NonZeroU32> {
+    s.parse()
+        .map_err(|e| Error::describe(e, format_args!("protocol error:  nz-number")))
+}
+
+fn read_capability_data(s: &str) -> Capabilities {
+    let result = Capabilities::from(
+        nmh::hacks::split(s)
+            .map(read_capability)
+            .collect::<std::collections::BTreeSet<_>>(),
+    );
+    debug!("read {:?} from {}", result, s);
+    result
+}
+
+fn read_capability(s: &str) -> Capability {
+    let upper: String = s.to_ascii_uppercase();
+    match upper.split('=').collect::<Vec<_>>().as_slice() {
+        ["AUTH", "PLAIN"] => return Capability::Auth(nmh::netsec::sasl::Mechanism::Plain),
+        ["AUTH", "XOAUTH2"] => return Capability::Auth(nmh::netsec::sasl::Mechanism::XOAuth2),
+        _ => {}
+    }
+    match upper.as_str() {
+        "ESEARCH" => Capability::ESearch,
+        "IMAP4REV1" => Capability::IMAP4Rev1,
+        "SASL-IR" => Capability::SASLIR,
+        "STARTTLS" => Capability::StartTLS,
+        "UIDPLUS" => Capability::UIDPlus,
+        _ => Capability::Unknown(upper),
+    }
+}
+
+// mailbox-data    =  "SEARCH" *(SP nz-number)
+// <= * SEARCH 2 3 4 5 7 8 9 10 11
+fn read_search_response(s: &str) -> Result<uid::Set> {
+    let mut uids = uid::Set::new();
+    for uid_result in nmh::hacks::split(s).map(read_nz_number) {
+        uids.insert(uid_result?.into());
+    }
+    Ok(uids)
+}
+
+// esearch-response   = "ESEARCH" [search-correlator] [SP "UID"] *(SP search-return-data)
+// <= * ESEARCH (TAG "A283") UID ALL 2,10:11
+fn read_esearch_response(s: &str) -> Result<uid::Set> {
+    let uid_indicator = discard_search_correlator(s)?;
+    let return_data = discard_search_uid(uid_indicator)?;
+    if return_data.is_empty() {
+        // When no messages are returned, Dovecot legally omits search-return-data; Panda does not.
+        Ok(uid::Set::new())
+    } else {
+        let sequence_set = discard_search_all(return_data)?;
+        read_sequence_set(sequence_set)
+    }
+}
+
+// TODO multi-line literals are allowed or tag-string.  We don't use tags that
+//  would REQUIRE multi-line literals, but nevertheless it is perfectly legal
+//  for a server to return them to us that way.
+// search-correlator  = SP "(" "TAG" SP tag-string ")"
+// tag-string         = string
+fn discard_search_correlator(s: &str) -> Result<&str> {
+    // TODO this was supposed to start with a space.
+    match s.find('(') {
+        None => Ok(s),
+        Some(open) => match s.find(')') {
+            None => todo!(
+                "protocol error:  search-correlator open paren without close paren: {}",
+                s
+            ),
+            Some(close) => Ok(&s[open + close + 1..]),
+        },
+    }
+}
+
+fn discard_search_uid(s: &str) -> Result<&str> {
+    // RFC 4731 3.1.  New SEARCH/UID SEARCH Result Options says this is required since we UID ESEARCH:
+    //  "An extended UID SEARCH command MUST cause an ESEARCH response with the UID indicator present."
+    if !s.starts_with(" UID") {
+        todo!("protocol error:  search-response missing UID indicator")
+    }
+    Ok(&s[4..])
+}
+
+// search-return-data = search-modifier-name SP search-return-value
+// search-return-data = "ALL" SP sequence-set
+fn discard_search_all(s: &str) -> Result<&str> {
+    if !s.starts_with(" ALL ") {
+        todo!("protocol error:  search-modifier-name ALL required")
+    }
+    Ok(&s[5..])
+}
+
+// sequence-set    = (seq-number / seq-range) *("," sequence-set)
+fn read_sequence_set(s: &str) -> Result<uid::Set> {
+    let mut uids = uid::Set::new();
+    for seq_set in s.split(',') {
+        match seq_set.split(':').collect::<Vec<_>>().as_slice() {
+            [uid] => {
+                uids.insert(read_nz_number(uid)?.into());
+            }
+            [beg, end] => {
+                let beg_u32: u32 = read_nz_number(beg)?.into();
+                let end_u32: u32 = read_nz_number(end)?.into();
+                for uid in beg_u32..end_u32 + 1 {
+                    uids.insert(uid);
+                }
+            }
+            _ => {
+                todo!("protocol error:  > 2 colons in {}", seq_set);
+            }
+        }
+    }
+    Ok(uids)
+}
+
+#[cfg(test)]
+mod tests;
diff --git a/imap/folder-manager/src/imap/protocol/response.rs b/imap/folder-manager/src/imap/protocol/response.rs
new file mode 100644 (file)
index 0000000..184d3fc
--- /dev/null
@@ -0,0 +1,143 @@
+//! Models of IMAP response data used by the high-level [crate::imap::Session].
+
+use std::collections::BTreeSet;
+
+use nmhimaptypes::{uid, Uid, UidValidity};
+
+/// A server greeting returned by [super::read_greeting].
+#[derive(Debug)]
+pub enum Greeting {
+    Ok(ResponseCode),
+    PreAuth(ResponseCode),
+}
+
+/// RFC 3501 7. Server Responses says server responses are in three forms:
+/// - status responses
+/// - server data
+/// - command continuation request
+///   Status responses can be tagged or untagged.
+///   Tagged status responses indicate the completion result (OK, NO, or BAD
+///   status) of a client command, and have a tag matching the command.
+#[derive(Debug)]
+pub enum Response {
+    ServerData(ServerData),
+    UntaggedStatus(Status /*, parsed response bits */),
+    // (tag, Status)
+    TaggedStatus(String, Status),
+    //Bye(&str),
+}
+
+//   Server data are untagged.
+pub enum ServerData {
+    Unknown,
+    /// the server's current capabilities, which may vary from state to state
+    Capability(Capabilities),
+    /// the number of messages existing in the mailbox the response pertains to
+    Exists(u32),
+    Fetch(Vec<Fetch>),
+    /// (mailbox name, flags)
+    List(String, String),
+    ESearch(uid::Set),
+    Search(uid::Set),
+}
+
+impl std::fmt::Debug for ServerData {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            ServerData::Unknown => f.debug_tuple("Unknown").finish(),
+            ServerData::Capability(cap) => f.debug_tuple("Capability").field(cap).finish(),
+            ServerData::Exists(n) => f.debug_tuple("ServerData::Exists").field(n).finish(),
+            ServerData::Fetch(_) => f.debug_tuple("ServerData::Fetch").finish(),
+            ServerData::List(mailbox_name, flags) => f
+                .debug_tuple("List")
+                .field(mailbox_name)
+                .field(flags)
+                .finish(),
+            ServerData::ESearch(set) => f.debug_tuple("ESearch").field(set).finish(),
+            ServerData::Search(set) => f.debug_tuple("Search").field(set).finish(),
+        }
+    }
+}
+
+/// A capability offered by a server.
+#[derive(Debug, Eq, PartialEq, Hash, Ord, PartialOrd)]
+pub enum Capability {
+    /// RFC 3501 - IMAP4rev1
+    Auth(nmh::netsec::sasl::Mechanism),
+    /// RFC 4731 - SEARCH results in sequence-set syntax
+    ESearch,
+    /// RFC 3501 - IMAP4rev1
+    IMAP4Rev1,
+    /// RFC 3501 - IMAP4rev1
+    StartTLS,
+    /// RFC 4959 - SASL Initial Client Response
+    SASLIR,
+    /// RFC 4315 - UIDPLUS
+    UIDPlus,
+    Unknown(String),
+}
+
+/// Part of a [crate::imap::FetchResult] operation.
+#[derive(Debug)]
+pub enum Fetch {
+    /// Whether this message has the `\Seen` flag (only flag we care about right now).
+    Flags(bool),
+    Uid(Uid),
+}
+
+#[derive(Debug)]
+pub enum Status {
+    Ok(ResponseCode),
+    No(ResponseCode, String),
+    Bad(ResponseCode, String),
+}
+
+// TODO
+// Status responses we care about:
+// [ALERT text...] - display 'text...' to user
+// [BADCHARSET ...] - internal error; crash and log
+// [PARSE text...] - display 'text...' and skip this message (TODO can we test this?)
+// [PERMANENTFLAGS (... \* ...)] - look for '\*' to for keywords
+// [READ-ONLY]
+// [READ-WRITE]
+#[derive(Debug)]
+pub enum ResponseCode {
+    /// used for both unknown response code and no response code at all because we don't need to distinguish those cases.
+    Unknown,
+    AppendUid(UidValidity, Uid),
+    Capability(Capabilities),
+    CopyUid(UidValidity, Uid),
+    UidNext(Uid),
+    UidValidity(UidValidity),
+}
+
+/// Set of [Capability]s a server offers.
+pub struct Capabilities(BTreeSet<Capability>);
+
+impl Capabilities {
+    /// Return true if `capability` is in this set.
+    pub fn has(&self, capability: Capability) -> bool {
+        self.0.contains(&capability)
+    }
+
+    pub fn iter(&self) -> std::collections::btree_set::Iter<Capability> {
+        self.0.iter()
+    }
+}
+
+impl From<BTreeSet<Capability>> for Capabilities {
+    fn from(set: BTreeSet<Capability>) -> Self {
+        Self(set)
+    }
+}
+
+impl std::fmt::Debug for Capabilities {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.debug_list()
+            .entries(self.0.iter().filter(|c| {
+                // Only including known capabilities for less verbose debug output.
+                !matches!(c, Capability::Unknown(_))
+            }))
+            .finish()
+    }
+}
diff --git a/imap/folder-manager/src/imap/protocol/tests.rs b/imap/folder-manager/src/imap/protocol/tests.rs
new file mode 100644 (file)
index 0000000..8233dab
--- /dev/null
@@ -0,0 +1,201 @@
+use std::num::NonZeroU32;
+
+use super::*;
+
+mod capability {
+    use super::*;
+
+    mod greeting {
+        use super::*;
+
+        fn read_greeting_as_debug_string(s: &str) -> String {
+            let mut owned = s.to_owned();
+            format!("{:?}", read_greeting(&mut owned).unwrap())
+        }
+
+        #[test]
+        fn panda_preauth() {
+            assert_eq!(
+                "PreAuth(Capability([IMAP4Rev1, UIDPlus]))",
+                read_greeting_as_debug_string("* PREAUTH [CAPABILITY IMAP4REV1 I18NLEVEL=1 LITERAL+ IDLE UIDPLUS NAMESPACE CHILDREN MAILBOX-REFERRALS BINARY UNSELECT WITHIN SORT THREAD=REFERENCES THREAD=ORDEREDSUBJECT MULTIAPPEND SCAN] Pre-authenticated")
+            )
+        }
+
+        #[test]
+        fn dovecot() {
+            assert_eq!(
+                "Ok(Capability([Auth(Plain), IMAP4Rev1, StartTLS, SASLIR]))",
+                read_greeting_as_debug_string("* OK [CAPABILITY IMAP4rev1 SASL-IR LOGIN-REFERRALS ID ENABLE IDLE LITERAL+ STARTTLS AUTH=PLAIN] Dovecot ready.")
+            )
+        }
+
+        #[test]
+        fn cyrus() {
+            assert_eq!(
+                "Ok(Capability([Auth(Plain), IMAP4Rev1, SASLIR]))",
+                read_greeting_as_debug_string("* OK [CAPABILITY IMAP4rev1 LITERAL+ ID ENABLE AUTH=SCRAM-SHA-1 AUTH=SCRAM-SHA-256 AUTH=DIGEST-MD5 AUTH=CRAM-MD5 AUTH=NTLM AUTH=PLAIN AUTH=LOGIN SASL-IR] Cyrus IMAP 3.2.3 server ready")
+            )
+        }
+
+        #[test]
+        fn gandi() {
+            assert_eq!(
+                "Ok(Capability([Auth(Plain), IMAP4Rev1, StartTLS, SASLIR]))",
+                read_greeting_as_debug_string("* OK [CAPABILITY IMAP4rev1 SASL-IR LOGIN-REFERRALS ID ENABLE IDLE LITERAL+ STARTTLS AUTH=PLAIN] Dovecot ready.")
+            )
+        }
+
+        #[test]
+        fn gmail_omits_capabilities() {
+            assert_eq!(
+                "Ok(Unknown)",
+                read_greeting_as_debug_string(
+                    "* OK Gimap ready for requests from 70.237.130.101 z4mb18213485oce"
+                )
+            )
+        }
+    }
+
+    mod auth_response {
+        use super::*;
+
+        fn read_response_as_debug_string(s: &str) -> String {
+            match read_response(s).unwrap() {
+                ReadResult::Response(response) => format!("{:?}", response),
+                ReadResult::CommandContinuationRequest => {
+                    panic!("don't want CommandContinuationRequest")
+                }
+                ReadResult::FetchBytes(_, _, _) => panic!("don't want FetchBytes"),
+                ReadResult::NeedBytes(_, _) => panic!("don't want NeedBytes"),
+                ReadResult::NeedLine(_) => panic!("don't want NeedLine"),
+            }
+        }
+
+        #[test]
+        fn authenticate_dovecot() {
+            assert_eq!(
+                "TaggedStatus(\"1\", Ok(Capability([ESearch, IMAP4Rev1, SASLIR, UIDPlus])))",
+                read_response_as_debug_string("1 OK [CAPABILITY IMAP4rev1 SASL-IR LOGIN-REFERRALS ID ENABLE IDLE SORT SORT=DISPLAY THREAD=REFERENCES THREAD=REFS THREAD=ORDEREDSUBJECT MULTIAPPEND URL-PARTIAL CATENATE UNSELECT CHILDREN NAMESPACE UIDPLUS LIST-EXTENDED I18NLEVEL=1 CONDSTORE QRESYNC ESEARCH ESORT SEARCHRES WITHIN CONTEXT=SEARCH LIST-STATUS BINARY MOVE SNIPPET=FUZZY PREVIEW=FUZZY STATUS=SIZE SAVEDATE LITERAL+ NOTIFY] Logged in")
+            )
+        }
+
+        #[test]
+        fn authenticate_cyrus() {
+            assert_eq!(
+                "TaggedStatus(\"1\", Ok(Capability([ESearch, IMAP4Rev1, UIDPlus])))",
+                read_response_as_debug_string("1 OK [CAPABILITY IMAP4rev1 LITERAL+ ID ENABLE ACL RIGHTS=kxten QUOTA MAILBOX-REFERRALS NAMESPACE UIDPLUS NO_ATOMIC_RENAME UNSELECT CHILDREN MULTIAPPEND BINARY CATENATE CONDSTORE ESEARCH SEARCH=FUZZY SORT SORT=MODSEQ SORT=DISPLAY SORT=UID THREAD=ORDEREDSUBJECT THREAD=REFERENCES THREAD=REFS ANNOTATE-EXPERIMENT-1 METADATA LIST-EXTENDED LIST-STATUS LIST-MYRIGHTS LIST-METADATA WITHIN QRESYNC SCAN XLIST XMOVE MOVE SPECIAL-USE CREATE-SPECIAL-USE DIGEST=SHA1 X-REPLICATION STATUS=SIZE OBJECTID SAVEDATE X-CREATEDMODSEQ PREVIEW=FUZZY URLAUTH URLAUTH=BINARY LOGINDISABLED COMPRESS=DEFLATE X-QUOTA=STORAGE X-QUOTA=MESSAGE X-QUOTA=X-ANNOTATION-STORAGE X-QUOTA=X-NUM-FOLDERS IDLE] Success (no protection)")
+            )
+        }
+
+        #[test]
+        fn authenticate_gandi() {
+            assert_eq!(
+                "TaggedStatus(\"1\", Ok(Capability([ESearch, IMAP4Rev1, SASLIR, UIDPlus])))",
+                read_response_as_debug_string("1 OK [CAPABILITY IMAP4rev1 SASL-IR LOGIN-REFERRALS ID ENABLE IDLE SORT SORT=DISPLAY THREAD=REFERENCES THREAD=REFS THREAD=ORDEREDSUBJECT MULTIAPPEND URL-PARTIAL CATENATE UNSELECT CHILDREN NAMESPACE UIDPLUS LIST-EXTENDED I18NLEVEL=1 CONDSTORE QRESYNC ESEARCH ESORT SEARCHRES WITHIN CONTEXT=SEARCH LIST-STATUS BINARY MOVE SNIPPET=FUZZY PREVIEW=FUZZY PREVIEW STATUS=SIZE SAVEDATE LITERAL+ NOTIFY QUOTA] Logged in")
+            )
+        }
+
+        #[test]
+        fn capability_unauthenticated_gmail() {
+            assert_eq!(
+                "ServerData(Capability([Auth(Plain), Auth(XOAuth2), IMAP4Rev1, SASLIR]))",
+                read_response_as_debug_string("* CAPABILITY IMAP4rev1 UNSELECT IDLE NAMESPACE QUOTA ID XLIST CHILDREN X-GM-EXT-1 XYZZY SASL-IR AUTH=XOAUTH2 AUTH=PLAIN AUTH=PLAIN-CLIENTTOKEN AUTH=OAUTHBEARER AUTH=XOAUTH")
+            )
+        }
+
+        #[test]
+        fn capability_authenticated_gmail() {
+            assert_eq!(
+                "ServerData(Capability([ESearch, IMAP4Rev1, UIDPlus]))",
+                read_response_as_debug_string("* CAPABILITY IMAP4rev1 UNSELECT IDLE NAMESPACE QUOTA ID XLIST CHILDREN X-GM-EXT-1 UIDPLUS COMPRESS=DEFLATE ENABLE MOVE CONDSTORE ESEARCH UTF8=ACCEPT LIST-EXTENDED LIST-STATUS LITERAL- SPECIAL-USE APPENDLIMIT=35651584")
+            )
+        }
+    }
+}
+
+mod unquote {
+    use super::*;
+
+    #[test]
+    fn simple() {
+        let quoted = r#""simple" rest"#;
+        let (unquoted, rest) = unquote(quoted).unwrap();
+        assert_eq!((unquoted, &quoted[rest..]), ("simple".to_string(), " rest"));
+    }
+
+    #[test]
+    fn less_simple() {
+        let quoted = r#""not \\ \"so simple" rest"#;
+        let (unquoted, rest) = unquote(quoted).unwrap();
+        assert_eq!(
+            (unquoted, &quoted[rest..]),
+            (r#"not \ "so simple"#.to_string(), " rest")
+        );
+    }
+
+    #[test]
+    fn starts_with_wrong_character() {
+        // TODO This shouldn't work but this is an internal function and we only call it when we detect a double-quote so...
+        let quoted = " oops\"";
+        let (unquoted, rest) = unquote(quoted).unwrap();
+        assert_eq!((unquoted, &quoted[rest..]), ("oops".to_string(), ""));
+    }
+
+    #[test]
+    #[should_panic(expected = "byte index 1 is out of bounds")]
+    fn empty() {
+        unquote("").unwrap();
+    }
+
+    #[test]
+    fn missing_closing_quote() {
+        assert_eq!(
+            unquote("\"foo").unwrap_err().to_string(),
+            "protocol error:  str missing close quote"
+        );
+    }
+}
+
+mod read_resp_text {
+    use super::*;
+
+    #[test]
+    fn copyuid() {
+        let (code, text) = read_resp_text("[COPYUID 1613513882 3 2] UID COPY completed").unwrap();
+        assert_eq!(
+            code,
+            ResponseCode::CopyUid(
+                UidValidity::from(NonZeroU32::new(1613513882).unwrap()),
+                Uid::from(NonZeroU32::new(2).unwrap())
+            )
+        );
+        assert_eq!(text, "UID COPY completed");
+    }
+
+    // TODO should this be ProtocolError?
+    #[test]
+    fn missing_leading_bracket() {
+        let (code, text) = read_resp_text("COPYUID 1613513882 3 2] UID COPY completed").unwrap();
+        assert_eq!(code, ResponseCode::Unknown);
+        assert_eq!(text, "COPYUID 1613513882 3 2] UID COPY completed");
+    }
+}
+
+impl std::cmp::PartialEq for ResponseCode {
+    fn eq(&self, other: &Self) -> bool {
+        if std::mem::discriminant(self) != std::mem::discriminant(other) {
+            return false;
+        }
+        match self {
+            Self::Unknown => true,
+            Self::CopyUid(uv, uid) => {
+                if let Self::CopyUid(other_uv, other_uid) = other {
+                    uv == other_uv && uid == other_uid
+                } else {
+                    unreachable!("already checked discriminant")
+                }
+            }
+            _ => todo!("minimal test implementation"),
+        }
+    }
+}
diff --git a/imap/folder-manager/src/imap/session.rs b/imap/folder-manager/src/imap/session.rs
new file mode 100644 (file)
index 0000000..243988f
--- /dev/null
@@ -0,0 +1,333 @@
+//! See [crate::imap] for overview.
+
+use log::trace;
+
+use nmh::Netsec;
+use nmhimaptypes::{Uid, UidValidity};
+
+use crate::imap;
+use imap::protocol::{
+    read_response, ByteReader, Capabilities, Capability, ReadResult, Response, ResponseCode,
+    ServerData, Status,
+};
+use imap::{Flag, Mailbox};
+
+mod fetch;
+pub mod mailbox;
+
+pub use fetch::{FetchResult, MessageReader, MessageResult};
+
+pub type Error = crate::error::RecoverableError<Session, nmh::Error>;
+
+/// Manages the "authenticated" state of an IMAP session.
+/// See [crate::imap] for overview.
+/// Some errors are recoverable; for other errors, this sends a "BYE" command to the server and
+/// closes the connection without reading a response.
+/// To close the connection, simply [Drop] this.
+pub struct Session {
+    netsec: Netsec,
+    buf: Vec<u8>,
+    esearch: bool,
+}
+
+impl Session {
+    /// Exists for [crate::imap::auth::login]; use that.
+    pub fn new(netsec: Netsec, capabilities: Capabilities) -> Result<Self, nmh::Error> {
+        if !capabilities.has(Capability::UIDPlus) {
+            Err(nmh::Error::new(
+                "server doesn't offer required capability UIDPLUS",
+            ))
+        } else {
+            Ok(Self {
+                netsec,
+                buf: vec![0; 8192],
+                esearch: capabilities.has(Capability::ESearch),
+            })
+        }
+    }
+
+    /// Delete the named `mailbox`.
+    pub fn delete(mut self, mailbox: &imap::mailbox::Name) -> Result<Self, Error> {
+        // TODO derive tag from request-in-flight counter?  We don't need these tags, and it wasn't
+        //  clear to my eyes whether it's actually legal to reuse a tag like this.  So far I've
+        //  gotten away with it, but best not to make assumptions.
+        self.write_and_flush(&format!("1 DELETE {}\r\n", mailbox.as_str()))?;
+        loop {
+            match self.read_response()? {
+                Response::TaggedStatus(_, Status::Ok(_)) => {
+                    break;
+                }
+                Response::TaggedStatus(_, _) => todo!("non-OK status"),
+                ignored => {
+                    trace!("ignoring {:?}", ignored);
+                }
+            }
+        }
+        Ok(self)
+    }
+
+    /// Append a new message to the named `mailbox`, with specified `contents` and `flags`.
+    /// Return the message's UID and the mailbox's UIDVALIDITY, which may have changed.
+    pub fn append(
+        mut self,
+        mailbox: &imap::mailbox::Name,
+        flags: &[Flag],
+        contents: &str,
+    ) -> Result<((UidValidity, Uid), Self), Error> {
+        let flags_str = if flags.is_empty() {
+            String::new()
+        } else {
+            format!(" ({})", to_string_join(flags, " "))
+        };
+        self.write_and_flush(&format!(
+            "1 APPEND {}{} {{{}}}\r\n",
+            mailbox,
+            flags_str,
+            contents.len()
+        ))?;
+        match read_response(self.read_line()?)? {
+            ReadResult::CommandContinuationRequest => {}
+            // It's not clear from RFC3501, but I don't think anything else can come before the continuation request.
+            other => todo!("expecting continue-req not {:?}", other),
+        }
+        self.write(contents)?;
+        self.write_and_flush("\r\n")?;
+        loop {
+            match self.read_response()? {
+                // TODO Bail out if UIDVALIDITY changed; tell user to fix with inc.
+                Response::TaggedStatus(
+                    _,
+                    Status::Ok(ResponseCode::AppendUid(uidvalidity, uid)),
+                ) => {
+                    break Ok(((uidvalidity, uid), self));
+                }
+                Response::TaggedStatus(_, Status::Ok(_)) => {
+                    todo!("APPENDUID response too small");
+                }
+                ignored => {
+                    trace!("ignoring {:?}", ignored);
+                }
+            }
+        }
+    }
+
+    /// Create the named `mailbox`.
+    pub fn create(mut self, mailbox: &str) -> Result<Self, Error> {
+        self.write_and_flush(&format!("1 CREATE \"{}\"\r\n", mailbox))?;
+        let status: Status = loop {
+            match self.read_response()? {
+                Response::TaggedStatus(_tag, status) => break status,
+                ignored => {
+                    trace!("ignoring {:?}", ignored);
+                }
+            }
+        };
+        match status {
+            Status::No(_, message) => Err(Error::recoverable(nmh::Error::new(message), self)),
+            // TODO does BAD always mean our bug or can it be user error?
+            Status::Bad(code, message) => todo!("BAD {:?} {}", code, message),
+            Status::Ok(_) => Ok(self),
+        }
+    }
+
+    /// Select the named `mailbox`, entering the Selected state.  See [crate::imap] for overview.
+    pub fn select(mut self, mailbox: &imap::mailbox::Name) -> Result<mailbox::Selected, Error> {
+        self.write_and_flush(&format!("1 SELECT {}\r\n", quote(mailbox.as_str())))?;
+        let mut message_count = 0;
+        let mut uid_next: Option<Uid> = None;
+        let mut uid_validity: Option<UidValidity> = None;
+        loop {
+            match self.read_response()? {
+                Response::TaggedStatus(_, Status::No(_, message)) => {
+                    return Err(Error::recoverable(nmh::Error::new(message), self));
+                }
+                Response::ServerData(ServerData::Exists(value)) => {
+                    message_count = value;
+                }
+                Response::UntaggedStatus(Status::Ok(ResponseCode::UidNext(value))) => {
+                    uid_next = Some(value);
+                }
+                Response::UntaggedStatus(Status::Ok(ResponseCode::UidValidity(value))) => {
+                    uid_validity = Some(value);
+                }
+                Response::TaggedStatus(_, Status::Ok(_)) => {
+                    let uid_next = uid_next.ok_or_else(|| {
+                        Error::unrecoverable(nmh::Error::new(
+                            "expected UIDNEXT missing from response",
+                        ))
+                    })?;
+                    let uid_validity = uid_validity.ok_or_else(|| {
+                        Error::unrecoverable(nmh::Error::new(
+                            "expected UIDVALIDITY missing from response",
+                        ))
+                    })?;
+                    return Ok(mailbox::Selected {
+                        message_count,
+                        uid_next,
+                        uid_validity,
+                        mailbox_session: mailbox::Session::new(
+                            {
+                                let session_uidnext: u32 = uid_next.into();
+                                session_uidnext - 1
+                            },
+                            self,
+                        ),
+                    });
+                }
+                ignored => {
+                    trace!("ignoring {:?}", ignored);
+                }
+            }
+        }
+    }
+
+    /// Return the list of mailboxes under `mailbox_root`, which may be empty.
+    /// Returned in server order, which may or may not be sorted.
+    pub fn list(mut self, mailbox_root: &str) -> Result<(Vec<Mailbox>, Self), Error> {
+        self.write_and_flush(&format!("1 LIST \"\" {}*\r\n", mailbox_root))?;
+        let mut mailboxes = Vec::<Mailbox>::new();
+        loop {
+            match self.read_response()? {
+                Response::TaggedStatus(_, Status::No(code, message)) => {
+                    log::error!("NO {:?} {}", code, message);
+                    return Err(Error::recoverable(
+                        // TODO pretty this up; can `code` be interpreted?
+                        nmh::Error::new(format!("NO response from LIST:  {:?} {}", code, message)),
+                        self,
+                    ));
+                }
+                Response::TaggedStatus(_, Status::Bad(code, message)) => {
+                    // TODO does BAD always mean our bug or can it be user error?
+                    return Err(Error::unrecoverable(nmh::Error::new(format!(
+                        "BAD response from LIST:  {:?} {}",
+                        code, message
+                    ))));
+                }
+                Response::TaggedStatus(_, Status::Ok(_)) => {
+                    break;
+                }
+                Response::ServerData(ServerData::List(mailbox_name, flags)) => {
+                    mailboxes.push(Mailbox {
+                        name: imap::mailbox::Name::from(mailbox_name),
+                        selectable: !flags.contains("\\noselect"),
+                    });
+                }
+                ignored => {
+                    trace!("ignoring {:?}", ignored);
+                }
+            }
+        }
+        Ok((mailboxes, self))
+    }
+
+    fn close(mut self) -> Result<Self, nmh::Error> {
+        self.write_and_flush("close CLOSE\r\n")?;
+        loop {
+            match self.read_response()? {
+                Response::TaggedStatus(_, Status::Ok(_)) => {
+                    break;
+                }
+                Response::TaggedStatus(_, status) => {
+                    todo!("only handling OK from CLOSE not {:?}", status)
+                }
+                ignored => {
+                    trace!("ignoring {:?}", ignored);
+                }
+            }
+        }
+        Ok(self)
+    }
+
+    fn feed_bytes(&mut self, n: usize, reader: ByteReader) -> Result<ReadResult, nmh::Error> {
+        if n > self.buf.len() {
+            // We have 8K buffer and this is used not for reading messages (which may be large) but for
+            // reading other literals such as mailbox names (which should be small).  If there's some
+            // reason to support reading >8K here, imap::protocol::ByteReader can be made to loop.
+            todo!("cowardly refusing to read {} bytes", n);
+        }
+        let mut bytes_read = 0;
+        while bytes_read < n {
+            bytes_read += self
+                .netsec
+                .read(&mut self.buf[bytes_read..n - bytes_read])?;
+        }
+        reader.read_bytes(&self.buf[..n])
+    }
+
+    fn read_response(&mut self) -> Result<Response, nmh::Error> {
+        let mut result: ReadResult = read_response(self.read_line()?)?;
+        loop {
+            match result {
+                ReadResult::CommandContinuationRequest => {
+                    return Err(nmh::Error::new("unexpected continue-req"))
+                }
+                ReadResult::FetchBytes(_, _, _) => {
+                    return Err(nmh::Error::new("unexpected FETCH BODY[] response"))
+                }
+                ReadResult::NeedBytes(n, reader) => result = self.feed_bytes(n, reader)?,
+                ReadResult::NeedLine(reader) => result = reader.read_line(self.read_line()?)?,
+                ReadResult::Response(response) => return Ok(response),
+            }
+        }
+    }
+
+    fn read_line(&mut self) -> Result<&mut str, nmh::Error> {
+        self.netsec.read_line()
+    }
+
+    fn write_and_flush(&mut self, s: &str) -> Result<(), nmh::Error> {
+        self.write(s)?;
+        self.netsec.flush()
+    }
+
+    fn write(&mut self, s: &str) -> Result<(), nmh::Error> {
+        self.netsec.write(s.as_bytes())
+    }
+}
+
+impl std::fmt::Debug for Session {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("Session").finish()
+    }
+}
+
+impl Drop for Session {
+    fn drop(&mut self) {
+        self.netsec
+            .write("logout LOGOUT\r\n".as_bytes())
+            .unwrap_or(());
+        self.netsec.flush().unwrap_or(());
+    }
+}
+
+fn quote(s: &str) -> String {
+    let mut result = String::new();
+    result.push('"');
+    for (_, c) in s.char_indices() {
+        if c == '"' {
+            result += "\\\"";
+        } else {
+            result.push(c);
+        }
+    }
+    result.push('"');
+    result
+}
+
+impl From<nmh::Error> for Error {
+    fn from(e: nmh::Error) -> Self {
+        Self::unrecoverable(e)
+    }
+}
+
+// TODO odd home for this
+fn to_string_join<T: ToString>(values: &[T], s: &str) -> String {
+    values
+        .iter()
+        .map(|v| v.to_string())
+        .collect::<Vec<_>>()
+        .join(s)
+}
+
+#[cfg(test)]
+mod tests;
diff --git a/imap/folder-manager/src/imap/session/fetch.rs b/imap/folder-manager/src/imap/session/fetch.rs
new file mode 100644 (file)
index 0000000..9cff1fe
--- /dev/null
@@ -0,0 +1,256 @@
+use log::trace;
+
+use nmh::Error;
+use nmhimaptypes::Uid;
+
+use crate::imap;
+use imap::mailbox;
+use imap::protocol::{read_response, Fetch, LineReader, ReadResult, Response, ServerData, Status};
+
+/// This is the center of the message fetching system.  Use it to consume FETCH respones, receiving
+/// message payloads with [MessageReader], like so:
+///
+/// ```
+/// let session: mailbox::Session;
+/// let mut f: FetchResult = session.fetch(...);
+/// let session: mailbox::Session = loop {
+///     match f {
+///         Done(session) => break session,
+///         Message(uid, seen, mut reader) => {
+///             let writer = open(uid);
+///             f = loop {
+///                 match reader.read(...) {
+///                     Done(f) => break f,
+///                     Reading(bytes, next_reader) => {
+///                         writer.write_all(bytes);
+///                         reader = next_reader;
+///                     }
+///                 }
+///             }
+///         }
+///     }
+/// }
+/// ```
+pub enum FetchResult<'a> {
+    Message(Uid, bool, MessageReader<'a>),
+    Done(mailbox::Session),
+}
+
+/// See [FetchResult] for overview.
+pub struct MessageReader<'session> {
+    session: FetchSession<'session>,
+    bytes_remaining: usize,
+    line_reader: LineReader,
+}
+
+impl<'session> MessageReader<'session> {
+    /// Return [MessageResult::Reading] when message bytes have been received from the server,
+    /// [MessageResult::Done] with the [FetchResult] for the next message, if any remain.
+    pub fn read<'buffer>(
+        mut self,
+        buf: &'buffer mut [u8],
+    ) -> Result<MessageResult<'session, 'buffer>, Error> {
+        Ok(if self.bytes_remaining == 0 {
+            MessageResult::Done(self.session.finish_untagged_response(self.line_reader)?)
+        } else {
+            let bytes_to_read = std::cmp::min(buf.len(), self.bytes_remaining);
+            let bytes_read = self
+                .session
+                .session
+                .netsec
+                .read(&mut buf[..bytes_to_read])?;
+            self.bytes_remaining -= bytes_read;
+            MessageResult::Reading(&buf[..bytes_read], self)
+        })
+    }
+}
+
+pub enum MessageResult<'session, 'buffer> {
+    Reading(&'buffer [u8], MessageReader<'session>),
+    Done(FetchResult<'session>),
+}
+
+/// Internal state of [FetchResult].
+pub(super) struct FetchSession<'a> {
+    session: imap::Session,
+    highest_uid: u32,
+    uids_iter: &'a mut dyn std::iter::Iterator<Item = &'a u32>,
+    requests_in_flight: usize,
+}
+
+impl<'a> FetchSession<'a> {
+    pub fn start(
+        session: imap::Session,
+        highest_uid: u32,
+        uids_iter: &'a mut dyn std::iter::Iterator<Item = &'a u32>,
+        max_fetch_messages_in_flight: u32,
+    ) -> Result<FetchResult<'a>, Error> {
+        let mut fetch = Self {
+            session,
+            highest_uid,
+            uids_iter,
+            requests_in_flight: 0,
+        };
+        trace!("fetch start");
+        for _ in 0..max_fetch_messages_in_flight {
+            match fetch.uids_iter.next() {
+                None => break,
+                Some(uid) => fetch.fetch_body(*uid)?,
+            }
+        }
+        fetch.next()
+    }
+
+    fn next(mut self) -> Result<FetchResult<'a>, Error> {
+        trace!("requests_in_flight={}", self.requests_in_flight);
+        while self.requests_in_flight > 0 {
+            let mut result: ReadResult = read_response(self.session.read_line()?)?;
+            let response: Response = loop {
+                match result {
+                    ReadResult::Response(r) => break r,
+                    ReadResult::CommandContinuationRequest => {
+                        return Err(Error::new("unexpected continue-req"))
+                    }
+                    ReadResult::FetchBytes(fetch_parts, bytes_to_read, line_reader) => {
+                        return self.receive_body(fetch_parts, bytes_to_read, line_reader);
+                    }
+                    ReadResult::NeedBytes(n, reader) => {
+                        result = self.session.feed_bytes(n, reader)?
+                    }
+                    ReadResult::NeedLine(reader) => {
+                        result = reader.read_line(self.session.read_line()?)?
+                    }
+                }
+            };
+            match response {
+                Response::TaggedStatus(_, Status::No(code, message)) => {
+                    todo!("NO {:?} {}", code, message)
+                }
+                Response::TaggedStatus(_, Status::Bad(code, message)) => {
+                    todo!("BAD {:?} {}", code, message)
+                }
+                Response::TaggedStatus(_, Status::Ok(_)) => {
+                    trace!("FETCH OK requests_in_flight={}", self.requests_in_flight);
+                    self.handle_tagged_response()?
+                }
+                ignored => {
+                    trace!("ignoring {:?}", ignored);
+                }
+            }
+        }
+        Ok(FetchResult::Done(mailbox::Session::new(
+            self.highest_uid,
+            self.session,
+        )))
+    }
+
+    fn receive_body(
+        self,
+        fetch_parts: Vec<Fetch>,
+        bytes_remaining: usize,
+        line_reader: LineReader,
+    ) -> Result<FetchResult<'a>, Error> {
+        trace!(
+            "receive body fetch_parts={:?} bytes_to_read={} line_reader={:?}",
+            fetch_parts,
+            bytes_remaining,
+            line_reader
+        );
+        let mut opt_uid: Option<Uid> = None;
+        let mut opt_seen: Option<bool> = None;
+        for fetch_part in fetch_parts {
+            match fetch_part {
+                Fetch::Uid(uid) => match opt_uid {
+                    Some(_) => todo!("UID twice!"),
+                    _ => {
+                        opt_uid = Some(uid);
+                    }
+                },
+                Fetch::Flags(seen) => match opt_seen {
+                    Some(_) => todo!("FLAGS twice!"),
+                    _ => {
+                        opt_seen = Some(seen);
+                    }
+                },
+            }
+        }
+        let uid = match opt_uid {
+            None => todo!("assume UID comes before BODY"),
+            Some(uid) => uid,
+        };
+        let seen = match opt_seen {
+            None => todo!("assume FLAGS comes before BODY"),
+            Some(seen) => seen,
+        };
+        Ok(FetchResult::Message(
+            uid,
+            seen,
+            MessageReader {
+                session: self,
+                bytes_remaining,
+                line_reader,
+            },
+        ))
+    }
+
+    /// Finish reading the untagged "* FETCH" response; we're just ignoring anything else that comes in here.
+    fn finish_untagged_response(
+        mut self,
+        line_reader: LineReader,
+    ) -> Result<FetchResult<'a>, Error> {
+        let response: Response = {
+            let mut result: ReadResult = line_reader.read_line(self.session.read_line()?)?;
+            loop {
+                match result {
+                    ReadResult::CommandContinuationRequest => {
+                        return Err(Error::new("unexpected continue-req"))
+                    }
+                    ReadResult::FetchBytes(_, _, _) => {
+                        return Err(Error::new("unexpected second FETCH BODY[] response"))
+                    }
+                    ReadResult::NeedBytes(n, reader) => {
+                        result = self.session.feed_bytes(n, reader)?;
+                    }
+                    ReadResult::NeedLine(reader) => {
+                        result = reader.read_line(self.session.read_line()?)?;
+                    }
+                    ReadResult::Response(r) => break r,
+                }
+            }
+        };
+        match response {
+            Response::ServerData(ServerData::Fetch(fetch_parts)) => {
+                if !fetch_parts.is_empty() {
+                    todo!("extra parts in untagged FETCH response {:?}", fetch_parts)
+                }
+            }
+            response => todo!(
+                "supposed to be seeing the end of the FETCH response here not {:?}",
+                response
+            ),
+        }
+        self.next()
+    }
+
+    fn handle_tagged_response(&mut self) -> Result<(), Error> {
+        self.requests_in_flight -= 1;
+        if let Some(uid) = self.uids_iter.next() {
+            self.fetch_body(*uid)?
+        }
+        Ok(())
+    }
+
+    fn fetch_body(&mut self, uid: u32) -> Result<(), Error> {
+        assert_ne!(0, uid, "refusing to FETCH zero UID");
+        self.requests_in_flight += 1;
+        trace!(
+            "FETCH BODY uid={} requests_in_flight={}",
+            uid,
+            self.requests_in_flight
+        );
+        self.session.write_and_flush(&format!(
+            "f{} UID FETCH {} (UID FLAGS BODY.PEEK[])\r\n",
+            uid, uid
+        ))
+    }
+}
diff --git a/imap/folder-manager/src/imap/session/mailbox.rs b/imap/folder-manager/src/imap/session/mailbox.rs
new file mode 100644 (file)
index 0000000..6ebc8b7
--- /dev/null
@@ -0,0 +1,263 @@
+use std::num::NonZeroU32;
+
+use log::trace;
+
+use nmhimaptypes::{uid, Uid, UidValidity};
+
+use crate::error::RecoverMapper;
+use crate::imap;
+use imap::protocol::{Response, ResponseCode, ServerData, Status};
+use imap::{mailbox, Flag, Mailbox};
+
+pub type Error = crate::error::RecoverableError<Session, nmh::Error>;
+
+/// Returned by [imap::Session::select].
+#[derive(Debug)]
+pub struct Selected {
+    /// Number of messages in the mailbox at SELECT time; may be zero.
+    pub message_count: u32,
+    /// Projected UID of next message to be appended to the mailbox.
+    pub uid_next: Uid,
+    /// UIDVALIDITY of mailbox at SELECT time.
+    pub uid_validity: UidValidity,
+    pub mailbox_session: Session,
+}
+
+/// See [imap::Session::select].
+// TODO  Commands from imap::Session should be supported here too:
+//  6.4.    Client Commands - Selected State
+//    In addition to the universal commands:
+//    - CAPABILITY
+//    - NOOP
+//    - LOGOUT
+//    and the authenticated state commands:
+//    - CREATE
+//    - DELETE
+//    - RENAME
+//    - LIST
+//    - APPEND
+//  the following commands are valid in the selected state:
+//    - CLOSE
+//    - EXPUNGE
+//    - SEARCH
+//    - FETCH
+//    - STORE
+//    - COPY
+#[derive(Debug)]
+pub struct Session {
+    highest_uid: u32,
+    session: imap::Session,
+}
+
+impl Session {
+    /// Same as [imap::Session::list].
+    pub fn list(self, mailbox_root: &str) -> Result<(Vec<Mailbox>, Self)> {
+        Ok({
+            let (session, mut rm) = self.recover_mapper();
+            let (mailboxes, session) = session.list(mailbox_root).map_err(|e| rm.map(e))?;
+            (
+                mailboxes,
+                Self {
+                    highest_uid: rm.ok(),
+                    session,
+                },
+            )
+        })
+    }
+
+    /// Return the set of all UIDs in the mailbox.
+    pub fn search_all(self) -> Result<(uid::Set, Self)> {
+        search(self, "ALL")
+    }
+
+    /// Return set of UIDs of messages lacking [Flag::Seen].
+    pub fn search_unseen(self) -> Result<(uid::Set, Self)> {
+        search(self, "NOT SEEN")
+    }
+
+    /// Close this session and open one for the named `mailbox`.
+    pub fn select(self, mailbox: &mailbox::Name) -> Result<Selected> {
+        let (session, mut rm) = self.recover_mapper();
+        session.select(mailbox).map_err(|e| rm.map(e))
+    }
+
+    /// Close this session, returning to the authenticated state.
+    pub fn close(self) -> Result<imap::Session> {
+        // TODO Can we test this failing?  RFC 3501 says CLOSE may return BAD but it isn't clear why.
+        Ok(self.session.close()?)
+    }
+
+    // => UID COPY 2:4 dest
+    // <= 1 OK [COPYUID 1613513882 3 2] UID COPY completed
+    /// Copy the message in this mailbox identified by `uid` to the mailbox named `dst_mailbox`.
+    /// Return the new message's UID and the destination mailbox's UIDVALIDITY, which may have changed.
+    pub fn copy(
+        mut self,
+        uid: Uid,
+        dst_mailbox: &mailbox::Name,
+    ) -> Result<((UidValidity, Uid), Self)> {
+        self.session
+            .write_and_flush(&format!("1 UID COPY {} {}\r\n", uid, dst_mailbox))?;
+        loop {
+            match self.session.read_response()? {
+                // TODO Bail out (unrecoverable) if UIDVALIDITY changed; tell user to fix with inc.
+                Response::TaggedStatus(_, Status::Ok(ResponseCode::CopyUid(uidvalidity, uid))) => {
+                    break Ok(((uidvalidity, uid), self));
+                }
+                Response::TaggedStatus(_, Status::Ok(_)) => {
+                    todo!("COPYUID response too small")
+                }
+                Response::TaggedStatus(_, _) => todo!("non-OK status"),
+                ignored => {
+                    trace!("ignoring {:?}", ignored);
+                }
+            }
+        }
+    }
+
+    // TODO `uids` should be &[Uid] or &uid::Set
+    // TODO returning silly unit to ease usage of error recovery macros; make them smarter
+    /// Add `flags` on messages listed in `uids`.  May not be persisted until
+    /// this session is closed by [Session::close] or [Session::select].
+    pub fn add_flags(mut self, uids: &str, flags: &[Flag]) -> Result<((), Self)> {
+        self.session.write_and_flush(&format!(
+            "1 UID STORE {} +FLAGS.SILENT ({})\r\n",
+            uids,
+            super::to_string_join(flags, " ")
+        ))?;
+        loop {
+            match self.session.read_response()? {
+                Response::TaggedStatus(_, Status::Ok(_)) => break Ok(((), self)),
+                Response::TaggedStatus(_, _) => todo!("non-OK status"),
+                ignored => {
+                    trace!("ignoring {:?}", ignored);
+                }
+            }
+        }
+    }
+
+    // TODO `uids` should be &[Uid] or &uid::Set
+    // TODO returning silly unit to ease usage of error recovery macros; make them smarter
+    /// Delete `flags` on messages listed in `uids`.  May not be persisted
+    /// until this session is closed by [Session::close] or [Session::select].
+    pub fn del_flags(mut self, uids: &str, flags: &[Flag]) -> Result<((), Self)> {
+        self.session.write_and_flush(&format!(
+            "1 UID STORE {} -FLAGS.SILENT ({})\r\n",
+            uids,
+            super::to_string_join(flags, " ")
+        ))?;
+        loop {
+            match self.session.read_response()? {
+                Response::TaggedStatus(_, Status::Ok(_)) => break Ok(((), self)),
+                Response::TaggedStatus(_, _) => todo!("non-OK status"),
+                ignored => {
+                    trace!("ignoring {:?}", ignored);
+                }
+            }
+        }
+    }
+
+    // TODO currently unrecoverable because we might have an error with further response coming down
+    // that would need processing and it's easier just to abandon that connection.  If we really cared,
+    // we could recover in the case where no further requests are in flight but is it worth the complexity...
+    /// Send up to `max_fetch_messages_in_flight` message FETCH requests, and keep that many FETCH
+    /// requests in flight at a time, as long as `uids_iter` does not run out.
+    /// See [imap::FetchResult] for information on fetching messages.
+    pub fn fetch<'a>(
+        self,
+        max_fetch_messages_in_flight: NonZeroU32,
+        uids_iter: &'a mut dyn std::iter::Iterator<Item = &'a u32>,
+    ) -> std::result::Result<imap::FetchResult<'a>, nmh::Error> {
+        super::fetch::FetchSession::start(
+            self.session,
+            self.highest_uid,
+            uids_iter,
+            max_fetch_messages_in_flight.into(),
+        )
+    }
+
+    pub(super) fn new(highest_uid: u32, session: imap::Session) -> Self {
+        Self {
+            highest_uid,
+            session,
+        }
+    }
+
+    fn recover_mapper<'a>(self) -> (imap::Session, RecoverMapper<'a, imap::Session, Self, u32>) {
+        (
+            self.session,
+            RecoverMapper::new(
+                &|session, highest_uid| Self {
+                    highest_uid,
+                    session,
+                },
+                self.highest_uid,
+            ),
+        )
+    }
+}
+
+type Result<T> = std::result::Result<T, Error>;
+
+fn search(mut mailbox_session: Session, search: &str) -> Result<(uid::Set, Session)> {
+    mailbox_session
+        .session
+        .write_and_flush(&format!(
+            "1 UID SEARCH{} {}\r\n",
+            if mailbox_session.session.esearch {
+                " RETURN ()"
+            } else {
+                ""
+            },
+            search
+        ))
+        .map_err(Error::unrecoverable)?;
+    let mut result = uid::Set::new();
+    let status: Status = {
+        // https://www.imapwiki.org/ClientImplementation/Search says we need to handle
+        // the SEARCH results being split across multiple untagged responses.
+        loop {
+            match mailbox_session
+                .session
+                .read_response()
+                .map_err(Error::unrecoverable)?
+            {
+                Response::ServerData(ServerData::ESearch(uids)) => {
+                    result.append(uids);
+                }
+                Response::ServerData(ServerData::Search(uids)) => {
+                    result.append(uids);
+                }
+                // <= $tag OK UID SEARCH completed
+                Response::TaggedStatus(_, status) => break status,
+                ignored => {
+                    log::trace!("ignoring {:?}", ignored);
+                }
+            }
+        }
+    };
+
+    match status {
+        Status::No(code, message) => {
+            // TODO pretty this up; can `code` be interpreted?
+            Err(Error::recoverable(
+                nmh::Error::new(format!("{:?} {}", code, message)),
+                mailbox_session,
+            ))
+        }
+        Status::Bad(code, message) => {
+            // TODO does BAD always mean our bug or can it be user error?
+            Err(Error::unrecoverable(nmh::Error::new(format!(
+                "{:?} {}",
+                code, message
+            ))))
+        }
+        Status::Ok(_) => Ok((result, mailbox_session)),
+    }
+}
+
+impl From<nmh::Error> for Error {
+    fn from(e: nmh::Error) -> Self {
+        Self::unrecoverable(e)
+    }
+}
diff --git a/imap/folder-manager/src/imap/session/tests.rs b/imap/folder-manager/src/imap/session/tests.rs
new file mode 100644 (file)
index 0000000..6324198
--- /dev/null
@@ -0,0 +1,8 @@
+use super::*;
+
+#[test]
+fn embedded_quote() {
+    let unquoted = r#"a "b" c"#;
+    let quoted = r#""a \"b\" c""#;
+    assert_eq!(quoted, quote(unquoted));
+}
diff --git a/imap/folder-manager/src/logging.rs b/imap/folder-manager/src/logging.rs
new file mode 100644 (file)
index 0000000..e32457a
--- /dev/null
@@ -0,0 +1,181 @@
+use std::io::Write;
+use std::os::unix::ffi::OsStrExt;
+
+// TODO make this a macro so we capture the line/file where this is called?
+/// Log at `level` parts of `s`.
+///
+/// First logs all of `s` and then logs only the parts between `start`
+/// and `one_past_end`, like so:
+///
+/// ```text
+///  (UID 6 FLAGS (\Seen) BODY[] {91}
+///       ' FLAGS (\Seen) BODY[] {91}'
+/// ```
+pub fn log_string_parts(level: log::Level, s: &str, start: usize, one_past_end: usize) {
+    if log::log_enabled!(level) {
+        log::log!(level, " {}", s);
+        log::log!(
+            level,
+            "{}'{}{}'",
+            std::str::from_utf8(&vec![32; start]).expect("32 (SPC) is valid UTF-8"),
+            &s[start..one_past_end],
+            std::str::from_utf8(&vec![32; s.len() - one_past_end])
+                .expect("32 (SPC) is valid UTF-8"),
+        );
+    }
+}
+
+/// Log at [log::Level::Error] `e`, and its [std::error::Error::source] if any, and its... and so on.
+pub fn error(mut e: &dyn std::error::Error) {
+    let mut i = 0;
+    log::error!("{}:  {:?}", i, e);
+    loop {
+        i += 1;
+        match e.source() {
+            Some(source) => {
+                e = source;
+                log::error!("{}:  {:?}", i, e);
+            }
+            None => break,
+        }
+    }
+}
+
+/// Redirect the standard error (file descriptor 2) to the file named `path`.
+pub fn redirect_stderr_to(path: std::path::PathBuf) -> Result<(), String> {
+    let fd = {
+        let cs = std::ffi::CString::new(path.as_os_str().as_bytes())
+            .expect("Path not to contain null byte");
+        let cp = cs.as_bytes_with_nul().as_ptr() as *const libc::c_char;
+        unsafe { libc::open(cp, libc::O_APPEND | libc::O_CREAT | libc::O_WRONLY, 0o600) }
+    };
+    if fd < 0 {
+        return Err(format!(
+            "open({}):  {}",
+            path.display(),
+            std::io::Error::last_os_error()
+        ));
+    }
+    if unsafe { libc::dup2(fd, 2) } < 0 {
+        return Err(format!(
+            "dup2({}, stderr):  {}",
+            path.display(),
+            std::io::Error::last_os_error()
+        ));
+    }
+    if unsafe { libc::close(fd) } < 0 {
+        return Err(format!(
+            "close({}):  {}",
+            path.display(),
+            std::io::Error::last_os_error()
+        ));
+    }
+    Ok(())
+}
+
+/// Install a [log::Log] implementation ([Logger]) based on the `mh-profile(5)` and which
+/// Panics if it cannot persist logs to configured files.
+pub fn install(prefixed: &nmh::profile::Prefixed) -> Result<(), String> {
+    let default = prefixed
+        .get("log-level")
+        .map(|level_s| level_s.parse::<log::LevelFilter>())
+        .unwrap_or(Ok(log::LevelFilter::Off))
+        .map_err(|e| e.to_string())?;
+    let mut max = default;
+    let mut module_to_level = std::collections::HashMap::new();
+    if let Some(modules) = prefixed.get("log-modules") {
+        for module in nmh::hacks::split(modules) {
+            if let Some(level_s) = prefixed.get(&format!("log-module-{}", module)) {
+                let level = level_s
+                    .parse::<log::LevelFilter>()
+                    .map_err(|e| e.to_string())?;
+                max = std::cmp::max(level, max);
+                module_to_level.insert(module.to_owned(), level);
+            }
+        }
+    }
+
+    let mut level_to_path = std::collections::HashMap::new();
+    let mut last: Option<std::path::PathBuf> = None;
+    for level in &[
+        log::Level::Trace,
+        log::Level::Debug,
+        log::Level::Info,
+        log::Level::Warn,
+        log::Level::Error,
+    ] {
+        // TODO need to support relative and absolute in all paths read from profile
+        match prefixed.get(&format!("log-{}-to", level)) {
+            Some(path_s) => {
+                let buf = std::path::Path::new(path_s).to_owned();
+                level_to_path.insert(*level, buf.clone());
+                last = Some(buf)
+            }
+            _ => {
+                if let Some(path) = &last {
+                    level_to_path.insert(*level, path.to_owned());
+                }
+            }
+        }
+    }
+
+    if default > log::LevelFilter::Off || !module_to_level.is_empty() {
+        if level_to_path.is_empty() {
+            return Err("logging requested but no log files specified".to_owned());
+        }
+        log::set_boxed_logger(Box::new(Logger {
+            default,
+            module_to_level,
+            level_to_path,
+            pid: std::process::id(),
+        }))
+        .map_err(|e| e.to_string())?;
+        log::set_max_level(max);
+    }
+
+    Ok(())
+}
+
+struct Logger {
+    default: log::LevelFilter,
+    module_to_level: std::collections::HashMap<String, log::LevelFilter>,
+    level_to_path: std::collections::HashMap<log::Level, std::path::PathBuf>,
+    pid: u32,
+}
+
+impl log::Log for Logger {
+    fn enabled(&self, m: &log::Metadata) -> bool {
+        m.level() <= self.default
+            || self
+                .module_to_level
+                .get(m.target())
+                .map(|level| m.level() <= *level)
+                .unwrap_or(false)
+    }
+
+    fn log(&self, r: &log::Record) {
+        if !self.enabled(r.metadata()) {
+            return;
+        }
+        if let Some(path) = &self.level_to_path.get(&r.metadata().level()) {
+            let mut file: std::fs::File = std::fs::OpenOptions::new()
+                .create(true)
+                .append(true)
+                .open(path)
+                .unwrap();
+            file.write_fmt(format_args!(
+                "{} {} {} {}:{} {}\n",
+                unsafe { nmh::dtimenow() }.to_str().unwrap(),
+                self.pid,
+                r.level(),
+                r.file().unwrap_or("UNKNOWN"),
+                r.line().unwrap_or(0),
+                r.args()
+            ))
+            .unwrap();
+            file.sync_all().unwrap()
+        }
+    }
+
+    fn flush(&self) {}
+}
diff --git a/imap/folder-manager/src/mailbox_rule.rs b/imap/folder-manager/src/mailbox_rule.rs
new file mode 100644 (file)
index 0000000..1162211
--- /dev/null
@@ -0,0 +1,59 @@
+use log::trace;
+
+use crate::regex::Regex;
+
+#[derive(Debug)]
+pub struct MailboxRules(Vec<Rule>);
+
+/// These rules determine whether to include or exclude mailboxes from `inc(1)`.
+impl MailboxRules {
+    pub fn new() -> Self {
+        Self(vec![])
+    }
+
+    /// Mark mailboxes whose names match `pattern` for inclusion.
+    pub fn include(&mut self, pattern: Regex) {
+        self.0.push(Rule {
+            pattern,
+            include: true,
+        })
+    }
+
+    /// Mark mailboxes whose names match `pattern` for exclusion.
+    pub fn exclude(&mut self, pattern: Regex) {
+        self.0.push(Rule {
+            pattern,
+            include: false,
+        })
+    }
+
+    /// Return whether the mailbox named `mailbox` should be be included.
+    pub fn included(&self, mailbox: &str) -> bool {
+        let mut include = true;
+        for rule in &self.0 {
+            if rule.pattern.matches(mailbox) {
+                trace!(
+                    "MailboxRules::included was {} now {}",
+                    include,
+                    rule.include
+                );
+                include = rule.include;
+            } else {
+                trace!("MailboxRules::included leaving it {}", include);
+            }
+        }
+        trace!(
+            "MailboxRules::included({}, {}) -> {}",
+            mailbox,
+            self.0.len(),
+            include
+        );
+        include
+    }
+}
+
+#[derive(Debug)]
+struct Rule {
+    pattern: Regex,
+    include: bool,
+}
diff --git a/imap/folder-manager/src/main.rs b/imap/folder-manager/src/main.rs
new file mode 100644 (file)
index 0000000..f510e01
--- /dev/null
@@ -0,0 +1,40 @@
+mod cf;
+mod error;
+mod logging;
+mod mailbox_rule;
+mod regex;
+mod run;
+
+// pub just to hush cargo doc ¯\_(ツ)_/¯
+pub mod imap;
+pub mod manager;
+
+fn main() {
+    if std::mem::size_of::<i32>() != std::mem::size_of::<std::os::raw::c_int>() {
+        todo!("i32 != c_int not yet supported");
+    }
+
+    // Create a new session and close stdin.
+    unsafe {
+        libc::setsid();
+        if libc::close(0) < 0 {
+            libc::perror("close(0)".as_ptr() as *const libc::c_char);
+            std::process::exit(2)
+        }
+    }
+    // Leaving stderr open until we have accepted a connection through which we can instead report errors.
+    // Leaving stdout open so we can tell the calling program we're listening.
+
+    let (context, name) = parse_args(std::env::args());
+    if let Err(e) = run::configure_and_run(&context, name) {
+        eprintln!("{}", e);
+        std::process::exit(2)
+    }
+}
+
+fn parse_args(mut args: std::env::Args) -> (nmh::Context, String) {
+    (
+        nmh::init(std::ffi::CString::new(args.next().unwrap()).unwrap()),
+        args.next().unwrap(),
+    )
+}
diff --git a/imap/folder-manager/src/manager.rs b/imap/folder-manager/src/manager.rs
new file mode 100644 (file)
index 0000000..1722770
--- /dev/null
@@ -0,0 +1,663 @@
+//! Where everything is tied together and IMAP mailboxes become MH folders.
+//!
+//! [Manager] delegates much of its work to various sub-modules, and most of
+//! what you see in here is for their usage.
+//!
+//! # Error recovery
+//! The following macros are available to facilitate recovering from errors.
+//! - [recover_authenticated]
+//! - [recover_selected]
+
+use std::io::Read as _;
+
+use log::trace;
+
+use managed_folders as nmf;
+use nmh::{folder, message};
+use nmhimapstore::{
+    FolderPathMapper, FolderStore, ReadOnlyTransaction, ReadTransaction, WriteTransaction,
+};
+use nmhimaptypes::{Uid, UidValidity};
+
+use crate::imap;
+use imap::{mailbox, Flag, Session};
+
+mod command;
+mod mapper;
+mod pick;
+
+pub type Error = crate::error::RecoverableError<Manager, nmh::Error>;
+
+pub use mapper::FolderMailboxMapper;
+
+/// The best manager of folders and IMAP mailboxes nmh has ever seen.
+#[derive(Debug)]
+pub struct Manager {
+    fmm: FolderMailboxMapper,
+    fpm: FolderPathMapper,
+    state: State,
+    cf: crate::cf::Manager,
+    buf: Vec<u8>,
+}
+
+/// Use this to recover from errors in the authenticated state.  When passed a [Result] of:
+/// - [Ok] - passes it along
+/// - [imap::Error] with a recoverable [imap::Session] - reconstructs the [Manager] with the session
+/// - [imap::Error] but not recoverable - passes the error along
+///
+/// Parameters
+/// - `$mparts` is [ManagerParts] whose [ManagerParts::recover_authenticated] to use to recover
+///   [imap::Session] from [imap::Error] into the [Manager] of an [Error].
+/// - `$expression` evaluates to [imap::Error]
+#[macro_export]
+macro_rules! recover_authenticated {
+    ($mparts:ident, $expression:expr) => {{
+        let mut rm = $mparts.recover_authenticated();
+        ($expression.map_err(|e| rm.map(e))?, rm.ok())
+    }};
+}
+
+/// Use this to recover from errors in the selected state.  When passed a [Result] of:
+/// - [Ok] - passes it along
+/// - [imap::mailbox::Error] with a recoverable [imap::mailbox::Session] - reconstructs the [Manager] with the session
+/// - [imap::mailbox::Error] but not recoverable - passes the error along
+///
+/// Parameters
+/// - `$mparts` is [ManagerParts] whose [ManagerParts::recover_selected] to use along with `$selected` to
+/// recover [imap::Session] from [imap::Error] into the [Manager] of an [Error].
+/// - `$mailbox_name` is the name of the mailbox to go into [State::Selected]; used with `$mparts`
+/// - `$selected` is the [SelectedParts] from the [imap::mailbox::Selected]; used with `$mparts`
+/// - `$expression` evaluates to [imap::mailbox::Error]
+#[macro_export]
+macro_rules! recover_selected {
+    ($mparts:ident, $mailbox_name:ident, $selected:ident, $expression:expr) => {{
+        let mut rm = $mparts.recover_selected($mailbox_name, $selected);
+        let (result, mailbox_session) = $expression.map_err(|e| rm.map(e))?;
+        let (mparts, mailbox_name, selected_parts) = rm.ok();
+        (
+            result,
+            mailbox_name,
+            selected_parts.construct(mailbox_session),
+            mparts,
+        )
+    }};
+}
+
+impl Manager {
+    pub fn new(
+        fmm: FolderMailboxMapper,
+        fpm: FolderPathMapper,
+        session: imap::Session,
+        cf: crate::cf::Manager,
+    ) -> Self {
+        Self {
+            fmm,
+            fpm,
+            state: State::Authenticated(session),
+            cf,
+            buf: vec![0; 8192],
+        }
+    }
+
+    pub fn run(
+        mut self,
+        command: nmf::Command,
+    ) -> Result<(Self, nmf::FolderManagerProtocol), String> {
+        Ok(match command {
+            nmf::Command::Quit => todo!(),
+
+            nmf::Command::Inc(mut response, command) => {
+                match command::inc::inc(self, &mut response, &command.folder) {
+                    Err(recoverable) => {
+                        crate::logging::error(&recoverable);
+                        let message =
+                            format!("failed to inc folder {}:  {}", command.folder, recoverable);
+                        match recoverable.recover() {
+                            Ok((recovered, _)) => (recovered, response.error(&message)?),
+                            Err(e) => {
+                                response.error(&message)?;
+                                return Err(e.to_string());
+                            }
+                        }
+                    }
+                    Ok(new_self) => (new_self, response.ok()?),
+                }
+            }
+
+            nmf::Command::MakeDraft(response, command) => {
+                let (msgnum, new_self) = self.make_draft(command).unwrap();
+                (new_self, response.ok(msgnum)?)
+            }
+
+            // All the rest use EmptyResponse.
+            nmf::Command::No(response) => (self, response.ok()?),
+            nmf::Command::Folder(_, _) => todo!(),
+            nmf::Command::MakeFolder(response, command) => {
+                match self.make_folder(&command.folder) {
+                    Err(recoverable) => {
+                        crate::logging::error(&recoverable);
+                        let message = format!(
+                            "failed to create folder {}:  {}",
+                            command.folder, recoverable
+                        );
+                        match recoverable.recover() {
+                            Ok((recovered, _)) => (recovered, response.error(&message)?),
+                            Err(e) => {
+                                response.error(&message)?;
+                                return Err(e.to_string());
+                            }
+                        }
+                    }
+                    Ok(new_self) => (new_self, response.ok()?),
+                }
+            }
+            nmf::Command::MarkAdd(response, command) => {
+                self = command::mark::add(self, command).unwrap();
+                (self, response.ok()?)
+            }
+            nmf::Command::MarkDel(response, command) => {
+                self = command::mark::delete(self, command).unwrap();
+                (self, response.ok()?)
+            }
+            nmf::Command::Pick(response, command) => {
+                self = command::pick::pick(self, command).unwrap();
+                (self, response.ok()?)
+            }
+            nmf::Command::RefileFile(response, command) => {
+                (self.refile_file(command).unwrap(), response.ok()?)
+            }
+            nmf::Command::RefileMove(response, command) => {
+                (self.refile_move(command).unwrap(), response.ok()?)
+            }
+            nmf::Command::Rmf(response, command) => (self.rmf(command).unwrap(), response.ok()?),
+            nmf::Command::Rmm(response, command) => (self.rmm(command).unwrap(), response.ok()?),
+            nmf::Command::UpdateDraft(response, command) => {
+                (self.update_draft(command).unwrap(), response.ok()?)
+            }
+        })
+    }
+
+    /// Destruct into session `State` and everything else - used for error recovery.
+    pub fn destruct(self) -> (State, ManagerParts) {
+        (
+            self.state,
+            ManagerParts {
+                fmm: self.fmm,
+                fpm: self.fpm,
+                cf: self.cf,
+                buf: self.buf,
+            },
+        )
+    }
+
+    // TODO the rest of these methods are some of the oldest and most in need of work
+    // TODO move these out
+
+    fn make_draft(
+        mut self,
+        command: nmf::command::Folder,
+    ) -> Result<(message::Number, Self), Error> {
+        // TODO if drafts folder doesn't exist:
+        // <= * OK [TRYCREATE] Must create mailbox before append
+        // <= 1 NO [TRYCREATE] Must create mailbox before append
+        // TODO folder is nmh folder!  map to imap mailbox name
+        let mailbox: mailbox::Name = self.fmm.folder_name_to_mailbox(&command.folder)?;
+        let (state, mparts) = self.destruct();
+        let session = match state {
+            State::Authenticated(session) => session,
+            State::Selected(_, selected) => match selected.mailbox_session.close() {
+                Err(e) => return Err(Error::unrecoverable(nmh::Error::wrap(e))),
+                Ok(session) => {
+                    trace!("comp CLOSEd into Authenticated state for APPEND (TODO don't)");
+                    session
+                }
+            },
+        };
+        let (((uidvalidity, uid), session), mparts) = recover_authenticated!(
+            mparts,
+            session.append(
+                &mailbox,
+                &[Flag::Draft, Flag::Seen,],
+                // TODO put something else here.  It's not that important; the
+                //  user is composing a draft and we'll upload that as soon as
+                //  they're done.  But if they should walk away, it would be good
+                //  not to leave a weird message like this behind for them to be
+                //  surprised by.  Maybe we shouldn't be appending a placeholder
+                //  message at all, but should instead be able to reserve a
+                //  pending message number which doesn't yet have a UID.
+                "From:me\r\nTo:you\r\n\r\n\r\n",
+            )
+        );
+        self = mparts.authenticated(session);
+
+        let store: FolderStore = store::open(&self.fpm, &command.folder, uidvalidity)?;
+        let mut txn: WriteTransaction = store.write_transaction()?;
+        let msgnum: message::Number = txn.store_message(uid)?;
+        txn.commit()?;
+        Ok((msgnum, self))
+    }
+
+    fn make_folder(self, folder_name: &folder::Name) -> Result<Self, Error> {
+        let mailbox: mailbox::Name = self.fmm.folder_name_to_mailbox(folder_name)?;
+        let (state, mparts) = self.destruct();
+        let session = match state {
+            State::Authenticated(session) => session,
+            State::Selected(_, selected) => match selected.mailbox_session.close() {
+                Err(e) => return Err(Error::unrecoverable(nmh::Error::wrap(e))),
+                Ok(session) => {
+                    trace!("CLOSEd into Authenticated state for CREATE (TODO don't)");
+                    session
+                }
+            },
+        };
+        let (session, mparts) = recover_authenticated!(mparts, session.create(mailbox.as_str()));
+        let (selected, mparts): (mailbox::Selected, ManagerParts) =
+            recover_authenticated!(mparts, session.select(&mailbox));
+        FolderStore::create(
+            &mparts.fpm,
+            folder_name,
+            selected.uid_next,
+            selected.uid_validity,
+        )?;
+        // TODO don't CLOSE
+        Ok(mparts.authenticated(selected.mailbox_session.close().map_err(nmh::Error::wrap)?))
+    }
+
+    fn delete_message(
+        mut self,
+        folder: &folder::Name,
+        msgnum: message::Number,
+    ) -> Result<Self, Error> {
+        // 0. Open mailbox
+        let (state, mparts) = self.destruct();
+        let session = match state {
+            State::Authenticated(session) => session,
+            State::Selected(_, selected) => match selected.mailbox_session.close() {
+                Err(e) => return Err(Error::unrecoverable(nmh::Error::wrap(e))),
+                Ok(session) => {
+                    trace!("rmm CLOSEd into Authenticated state for STORE (TODO check first)");
+                    session
+                }
+            },
+        };
+
+        let mailbox_name = mparts.fmm.folder_name_to_mailbox(folder)?;
+        let (selected, mparts): (mailbox::Selected, ManagerParts) =
+            recover_authenticated!(mparts, session.select(&mailbox_name));
+
+        // 1. Resolve message ranges to UID ranges
+        let store: FolderStore = store::open(&mparts.fpm, folder, selected.uid_validity)?;
+        let uid: Uid = store
+            .read_transaction()?
+            .lookup_uid(msgnum)?
+            .ok_or_else(|| {
+                todo!("recoverable error:  user-specified message does not exist");
+            })
+            .unwrap();
+
+        // 2. STORE + EXPUNGE on deleting all UIDs
+        let (mailbox_session, selected_parts) = SelectedParts::destruct(selected);
+        let (_, _, selected, mparts) = recover_selected!(
+            mparts,
+            mailbox_name,
+            selected_parts,
+            mailbox_session.add_flags(&uid.to_string(), &[Flag::Deleted])
+        );
+        // TODO EXPUNGE rather than CLOSE
+        self = mparts.authenticated(selected.mailbox_session.close().map_err(nmh::Error::wrap)?);
+
+        // 3. Delete all msgnum/UID pairs from db in one transaction
+        store.delete_message(msgnum, uid)?;
+
+        Ok(self)
+    }
+
+    fn copy_message(
+        mut self,
+        src_folder: &folder::Name,
+        src: message::Number,
+        dst_folder: &folder::Name,
+    ) -> Result<Self, Error> {
+        let (state, mparts) = self.destruct();
+        let session = match state {
+            State::Authenticated(session) => session,
+            State::Selected(_, selected) => match selected.mailbox_session.close() {
+                Err(e) => return Err(Error::unrecoverable(nmh::Error::wrap(e))),
+                Ok(session) => {
+                    trace!("rmm CLOSEd into Authenticated state for COPY (TODO check first)");
+                    session
+                }
+            },
+        };
+        let src_mailbox_name: mailbox::Name = mparts.fmm.folder_name_to_mailbox(src_folder)?;
+        let (src_selected, mparts): (mailbox::Selected, ManagerParts) =
+            recover_authenticated!(mparts, session.select(&src_mailbox_name));
+
+        let src_uid: Uid = {
+            let src_store: FolderStore =
+                store::open(&mparts.fpm, src_folder, src_selected.uid_validity)?;
+            let txn: ReadOnlyTransaction = src_store.read_transaction()?;
+            match txn.lookup_uid(src)? {
+                None => {
+                    return Err(Error::recoverable(
+                        nmh::Error::new(format!("message {} doesn't exist", src)),
+                        mparts.selected(src_mailbox_name, src_selected),
+                    ));
+                }
+                Some(v) => v,
+            }
+        };
+
+        // 1. COPY
+        let dst_mailbox_name: mailbox::Name = mparts.fmm.folder_name_to_mailbox(dst_folder)?;
+        let (src_mailbox_session, src_selected_parts) = SelectedParts::destruct(src_selected);
+        let ((dst_uidvalidity, dst_uid), _, src_selected, mparts) = recover_selected!(
+            mparts,
+            src_mailbox_name,
+            src_selected_parts,
+            src_mailbox_session.copy(src_uid, &dst_mailbox_name)
+        );
+        self = mparts.authenticated(
+            // TODO need to CLOSE?
+            src_selected
+                .mailbox_session
+                .close()
+                .map_err(nmh::Error::wrap)?,
+        );
+
+        // 2. Find latest msgnum from target folder msg2uid table
+        // TODO check UIDVALIDITY later when we get it
+        // TODO move to FolderStore
+        let dst_store: FolderStore = store::open(&self.fpm, dst_folder, dst_uidvalidity)?;
+        let mut dst_txn: WriteTransaction = dst_store.write_transaction()?;
+        // TODO don't want highest UID; worth having msgnum-only form?
+        let dst_msgnum: message::Number = match dst_txn.highest()? {
+            None => message::Number::one(),
+            Some((_, mut msgnum)) => {
+                msgnum.increment();
+                msgnum
+            }
+        };
+
+        // 3. Link msg file into target folder
+        // TODO std::fs::remove_file(dst_msg_path)
+        std::fs::hard_link(
+            self.fpm.msg_path(src_folder, src),
+            self.fpm.msg_path(dst_folder, dst_msgnum),
+        )
+        .unwrap();
+
+        // TODO check dst_store UIDVALIDITY against that returned by mailbox::Session::copy.
+
+        // 4. Update tables
+        // Tested by test-live:'mark unseen after refile'.
+        dst_txn.set_uid_msgnum(dst_uid, dst_msgnum)?;
+
+        // TODO make WriteTransaction & PendingDelete panic in Drop when not committed
+        dst_txn.commit()?;
+
+        Ok(self)
+    }
+
+    fn rmf(mut self, command: nmf::command::Folder) -> Result<Self, Error> {
+        // 1. Attempt mailbox deletion first so we don't delete local cache pointlessly.
+        let session = match self.state {
+            State::Authenticated(session) => session,
+            State::Selected(_, selected) => match selected.mailbox_session.close() {
+                Err(e) => return Err(Error::unrecoverable(nmh::Error::wrap(e))),
+                Ok(session) => {
+                    trace!("rmf CLOSEd into Authenticated state for DELETE (TODO don't)");
+                    session
+                }
+            },
+        };
+        self.state = State::Authenticated(
+            session
+                .delete(&self.fmm.folder_name_to_mailbox(&command.folder)?)
+                .map_err(nmh::Error::wrap)?,
+        );
+        // 2. Delete local files.  If any foreign objects in folder, leave them.
+        nmhimapstore::delete(&self.fpm, &command.folder)?;
+        Ok(self)
+    }
+
+    fn rmm(self, command: nmf::command::Rmm) -> Result<Self, Error> {
+        // TODO message range parsing for removing more than one message
+        let msgnum: message::Number = command.msgs[0].parse()?;
+        self.delete_message(&command.folder, msgnum)
+    }
+
+    fn refile_file(self, command: nmf::command::RefileFile) -> Result<Self, Error> {
+        // 1. read file
+        let contents: String = {
+            let mut file = std::fs::File::open(command.file_path).unwrap();
+            let mut contents = String::new();
+            file.read_to_string(&mut contents).unwrap();
+            contents
+        };
+
+        // 2. APPEND to mailbox.
+        let (state, mparts) = self.destruct();
+        let session = match state {
+            State::Authenticated(session) => session,
+            State::Selected(_, selected) => match selected.mailbox_session.close() {
+                Err(e) => return Err(Error::unrecoverable(nmh::Error::wrap(e))),
+                Ok(session) => {
+                    trace!("refile CLOSEd into Authenticated state for APPEND (TODO don't)");
+                    session
+                }
+            },
+        };
+        let mailbox_name: mailbox::Name = mparts.fmm.folder_name_to_mailbox(&command.dst_folder)?;
+        let (((dst_uidvalidity, dst_uid), session), mparts) = recover_authenticated!(
+            mparts,
+            session.append(
+                &mailbox_name,
+                &[Flag::Seen],
+                &contents.lines().collect::<Vec<_>>().join("\r\n"),
+            )
+        );
+
+        // 3. Append to folder.
+        let mut writer: nmhimapstore::MessageWriter =
+            store::open(&mparts.fpm, &command.dst_folder, dst_uidvalidity)?
+                .append(&mparts.fpm, &command.dst_folder)?
+                .append(dst_uid)?;
+        writer.write_all(contents.as_bytes())?;
+        let (_, new_appender) = writer.close()?;
+        new_appender.close();
+
+        Ok(mparts.authenticated(session))
+    }
+
+    fn refile_move(self, command: nmf::command::RefileMove) -> Result<Self, Error> {
+        // TODO message range parsing for refiling more than one message
+        let msgnum: message::Number = command.msgs[0].parse()?;
+        self.copy_message(&command.src_folder, msgnum, &command.dst_folder)?
+            .delete_message(&command.src_folder, msgnum)
+    }
+
+    fn update_draft(self, command: nmf::command::UpdateDraft) -> Result<Self, Error> {
+        // TODO Return error to client rather than let Number::from panic.
+        let contents: String =
+            std::fs::read_to_string(self.fpm.msg_path(&command.folder, command.msgnum))
+                .unwrap()
+                .lines()
+                .collect::<Vec<_>>()
+                .join("\r\n");
+
+        // 1. let new_uid = APPEND new contents
+        let mailbox: mailbox::Name = self.fmm.folder_name_to_mailbox(&command.folder)?;
+        let (state, mparts) = self.destruct();
+        let session = match state {
+            State::Authenticated(session) => session,
+            State::Selected(_, selected) => match selected.mailbox_session.close() {
+                Err(e) => return Err(Error::unrecoverable(nmh::Error::wrap(e))),
+                Ok(session) => {
+                    trace!("update-draft CLOSEd into Authenticated state for APPEND (TODO don't)");
+                    session
+                }
+            },
+        };
+
+        let (((uidvalidity, new_uid), session), mparts) = recover_authenticated!(
+            mparts,
+            session.append(&mailbox, &[Flag::Draft, Flag::Seen,], &contents)
+        );
+
+        // If we crash here, the next inc will pull in the updated draft as a
+        // new message.  Locally, old and new will be identical, though the
+        // old draft won't match what's on the server.
+        // TODO test this
+
+        // 2. let old_uid = lookup(msg)
+        // TODO check UIDVALIDITY later when we get it
+        let store: FolderStore = store::open(&mparts.fpm, &command.folder, uidvalidity)?;
+        let mut txn: WriteTransaction = store.write_transaction()?;
+
+        // 3. Update msg->uid & uid->msg tables for new_uid
+        let old_uid = txn.replace_uid_msgnum(new_uid, command.msgnum)?;
+        txn.commit()?;
+
+        // If we crash here, the next inc will pull in a copy of the OLD draft as a new message.
+        // TODO test this
+
+        // 4. Delete the old message.
+        let (selected, mparts): (mailbox::Selected, ManagerParts) =
+            recover_authenticated!(mparts, session.select(&mailbox));
+        let (mailbox_session, selected_parts) = SelectedParts::destruct(selected);
+        let (_, _, selected, mparts) = recover_selected!(
+            mparts,
+            mailbox,
+            selected_parts,
+            mailbox_session.add_flags(&old_uid.to_string(), &[Flag::Deleted])
+        );
+        // TODO EXPUNGE rather than CLOSE
+        Ok(mparts.authenticated(selected.mailbox_session.close().map_err(nmh::Error::wrap)?))
+    }
+}
+
+/// [mailbox::Selected] without [mailbox::Session] - used for error recovery.
+pub struct SelectedParts {
+    message_count: u32,
+    uid_next: Uid,
+    uid_validity: UidValidity,
+}
+impl SelectedParts {
+    /// Destruct `selected` into session and everything else.
+    fn destruct(selected: mailbox::Selected) -> (mailbox::Session, Self) {
+        (
+            selected.mailbox_session,
+            Self {
+                message_count: selected.message_count,
+                uid_next: selected.uid_next,
+                uid_validity: selected.uid_validity,
+            },
+        )
+    }
+
+    /// Put it back together.
+    fn construct(self, mailbox_session: mailbox::Session) -> mailbox::Selected {
+        mailbox::Selected {
+            mailbox_session,
+            message_count: self.message_count,
+            uid_next: self.uid_next,
+            uid_validity: self.uid_validity,
+        }
+    }
+}
+
+/// [Manager] without [State] - used for error recovery.  Get one from [Manager::destruct].
+pub struct ManagerParts {
+    fmm: FolderMailboxMapper,
+    fpm: FolderPathMapper,
+    cf: crate::cf::Manager,
+    buf: Vec<u8>,
+}
+
+impl ManagerParts {
+    /// Return `RecoverMapper` for use in recovering [Session].  See [recover_authenticated].
+    pub fn recover_authenticated<'a>(
+        self,
+    ) -> crate::error::RecoverMapper<'a, Session, Manager, Self> {
+        crate::error::RecoverMapper::new(&|session, mparts| mparts.authenticated(session), self)
+    }
+
+    /// Return `RecoverMapper` for use in recovering [mailbox::Session].  See [recover_selected].
+    pub fn recover_selected<'a>(
+        self,
+        mailbox_name: mailbox::Name,
+        selected_parts: SelectedParts,
+    ) -> crate::error::RecoverMapper<
+        'a,
+        mailbox::Session,
+        Manager,
+        (Self, mailbox::Name, SelectedParts),
+    > {
+        crate::error::RecoverMapper::new(
+            &|mailbox_session, (mparts, name, selected)| {
+                mparts.selected(name, selected.construct(mailbox_session))
+            },
+            (self, mailbox_name, selected_parts),
+        )
+    }
+
+    /// Return `Manager` in the authenticated state.
+    fn authenticated(self, session: Session) -> Manager {
+        self.construct(State::Authenticated(session))
+    }
+
+    /// Return `Manager` in the selected state.
+    fn selected(self, name: mailbox::Name, selected: mailbox::Selected) -> Manager {
+        self.construct(State::Selected(name, selected))
+    }
+
+    fn construct(self, state: State) -> Manager {
+        Manager {
+            fmm: self.fmm,
+            fpm: self.fpm,
+            cf: self.cf,
+            buf: self.buf,
+            state,
+        }
+    }
+}
+
+/// See [imap] regarding authenticated and selected state.
+#[derive(Debug)]
+pub enum State {
+    Authenticated(Session),
+    // TODO Expand Selected with a little cache mapping message arguments to the result, so we
+    //  don't have to hit the server repeatedly when the user runs the same pick over and over, or
+    //  something like `show unseen:next` over and over, etc.
+    Selected(mailbox::Name, mailbox::Selected),
+}
+
+/// internal [FolderStore] wrappers
+mod store {
+    use super::*;
+
+    pub fn open(
+        fpm: &FolderPathMapper,
+        folder_name: &folder::Name,
+        uid_validity: UidValidity,
+    ) -> Result<FolderStore, nmh::Error> {
+        FolderStore::open(fpm, folder_name, uid_validity)
+            .map_err(|e| nmh::Error::describe(e, format_args!("open {}", folder_name)))
+    }
+
+    pub fn open_or_create(
+        fpm: &FolderPathMapper,
+        folder_name: &folder::Name,
+        uid_validity: UidValidity,
+    ) -> Result<FolderStore, nmh::Error> {
+        FolderStore::open_or_create(fpm, folder_name, uid_validity)
+            .map_err(|e| nmh::Error::describe(e, format_args!("open {}", folder_name)))
+    }
+}
+
+impl From<nmh::Error> for Error {
+    fn from(e: nmh::Error) -> Self {
+        Self::unrecoverable(e)
+    }
+}
diff --git a/imap/folder-manager/src/manager/command.rs b/imap/folder-manager/src/manager/command.rs
new file mode 100644 (file)
index 0000000..8a08fa2
--- /dev/null
@@ -0,0 +1,5 @@
+//! Folder manager command implementations live here.
+
+pub mod inc;
+pub mod mark;
+pub mod pick;
diff --git a/imap/folder-manager/src/manager/command/inc.rs b/imap/folder-manager/src/manager/command/inc.rs
new file mode 100644 (file)
index 0000000..9845f8d
--- /dev/null
@@ -0,0 +1,278 @@
+//! [inc] implements the `inc` command.
+
+use log::{debug, trace};
+
+use managed_folders as nmf;
+use nmh::folder;
+use nmhimapstore::{FolderPathMapper, FolderStore, ReadTransaction};
+use nmhimaptypes::{uid, Uid};
+
+use crate::imap::{mailbox, FetchResult, Mailbox, MessageResult};
+use crate::manager::{store, Error, Manager, SelectedParts, State};
+use crate::recover_selected;
+
+/// Incorporate new mail and delete messages gone from server.
+/// If `folder` maps to the root of the mailbox hierarchy we synchronize, inc it and all child mailboxes.
+/// Else, inc only the specified mailbox.
+pub fn inc(
+    mut manager: Manager,
+    response: &mut nmf::IncResponse,
+    folder: &folder::Name,
+) -> Result<Manager, Error> {
+    let mailbox: mailbox::Name = manager.fmm.folder_name_to_mailbox(folder)?;
+    trace!(
+        "inc({}) root={} mb={}",
+        folder,
+        manager.fmm.mailbox_root,
+        mailbox
+    );
+    if mailbox.as_str() != manager.fmm.mailbox_root {
+        trace!("inc requested specific mailbox {}", mailbox);
+        manager = inc_mailbox(manager, response, mailbox)?;
+        return Ok(manager);
+    }
+    let mut mailboxes: Vec<Mailbox>;
+    match manager.state {
+        State::Authenticated(session) => {
+            let (value, session) = match session.list(&manager.fmm.mailbox_root) {
+                Err(e) => match e.recover() {
+                    Err(e) => return Err(Error::unrecoverable(e)),
+                    Ok((session, e)) => {
+                        manager.state = State::Authenticated(session);
+                        return Err(Error::recoverable(e, manager));
+                    }
+                },
+                Ok(v) => v,
+            };
+            manager.state = State::Authenticated(session);
+            mailboxes = value
+        }
+        State::Selected(mailbox_name, mut selected) => {
+            match selected.mailbox_session.list(&manager.fmm.mailbox_root) {
+                Err(recoverable) => match recoverable.recover() {
+                    Err(e) => return Err(Error::unrecoverable(e)),
+                    Ok((mailbox_session, e)) => {
+                        selected.mailbox_session = mailbox_session;
+                        manager.state = State::Selected(mailbox_name, selected);
+                        return Err(Error::recoverable(e, manager));
+                    }
+                },
+                Ok((value, mailbox_session)) => {
+                    selected.mailbox_session = mailbox_session;
+                    manager.state = State::Selected(mailbox_name, selected);
+                    mailboxes = value
+                }
+            }
+        }
+    }
+    mailboxes.sort();
+    for mailbox in mailboxes {
+        if !mailbox.selectable {
+            trace!("inc {} not selectable", mailbox.name);
+            // TODO if this is the mailbox named by `folder`, report error here.
+            continue;
+        }
+        if !manager.cf.rules.included(mailbox.name.as_str()) {
+            trace!("inc {} excluded by user", mailbox.name);
+            continue;
+        }
+        trace!("inc {}", mailbox.name);
+        manager = inc_mailbox(manager, response, mailbox.name)?;
+    }
+    Ok(manager)
+}
+
+fn inc_mailbox(
+    manager: Manager,
+    response: &mut nmf::IncResponse,
+    mailbox_name: mailbox::Name,
+) -> Result<Manager, Error> {
+    let (state, mparts) = manager.destruct();
+    let selected: mailbox::Selected = match state {
+        State::Authenticated(session) => match session.select(&mailbox_name) {
+            Err(recoverable) => match recoverable.recover() {
+                Err(e) => return Err(Error::unrecoverable(e)),
+                Ok((session, e)) => {
+                    return Err(Error::recoverable(e, mparts.authenticated(session)));
+                }
+            },
+            Ok(selected) => {
+                trace!("inc SELECT {} from Authenticated", mailbox_name);
+                selected
+            }
+        },
+        State::Selected(selected_name, mut selected) => {
+            if selected_name == mailbox_name {
+                trace!("inc {} already Selected", mailbox_name);
+                selected
+            } else {
+                match selected.mailbox_session.select(&mailbox_name) {
+                    Err(recoverable) => match recoverable.recover() {
+                        Err(e) => return Err(Error::unrecoverable(e)),
+                        Ok((session, e)) => {
+                            selected.mailbox_session = session;
+                            return Err(Error::recoverable(
+                                e,
+                                mparts.selected(selected_name, selected),
+                            ));
+                        }
+                    },
+                    Ok(selected) => {
+                        trace!(
+                            "inc SELECT {} from Selected({})",
+                            mailbox_name,
+                            selected_name
+                        );
+                        selected
+                    }
+                }
+            }
+        }
+    };
+
+    let folder_name: folder::Name = mparts.fmm.mailbox_name_to_folder(mailbox_name.as_str())?;
+    let mut store: FolderStore =
+        store::open_or_create(&mparts.fpm, &folder_name, selected.uid_validity)?;
+
+    // SEARCH all messages into uid_set.
+    let (mailbox_session, selected_parts) = SelectedParts::destruct(selected);
+    let (uid_set, mailbox_name, mut selected, mut mparts) = recover_selected!(
+        mparts,
+        mailbox_name,
+        selected_parts,
+        mailbox_session.search_all()
+    );
+
+    if selected.message_count > 0 {
+        // TODO(rust-lang#71126): (selected, store) = fetch(...
+        let result = fetch(
+            store,
+            response,
+            selected,
+            &mut mparts.buf,
+            &uid_set,
+            &mparts.cf,
+            &mparts.fpm,
+            &folder_name,
+        )?;
+        selected = result.0;
+        store = result.1;
+    }
+    // TODO make deletion optional?  maybe have some option like inc -pick-delete that picks the
+    //  messages to be deleted so you can scan, show, maybe even copy before deleting?
+    delete(uid_set, store, selected.uid_next).map_err(|e| {
+        nmh::Error::describe(
+            e,
+            format_args!("inc looking for messages to delete in {}", folder_name),
+        )
+    })?;
+    Ok(mparts.selected(mailbox_name, selected))
+}
+
+/// Use [mailbox::Session::fetch] to fetch messages into `store`.
+fn fetch(
+    mut store: FolderStore,
+    response: &mut nmf::IncResponse,
+    mut selected: mailbox::Selected,
+    buf: &mut [u8],
+    uid_set: &uid::Set,
+    cf: &crate::cf::Manager,
+    fpm: &FolderPathMapper,
+    folder_name: &folder::Name,
+) -> Result<(mailbox::Selected, FolderStore), nmh::Error> {
+    let mut appender = store
+        .append(fpm, folder_name)
+        .map_err(|e| nmh::Error::describe(e, format_args!("inc open {}", folder_name)))?;
+    // This is not the same as appender.highest_uid as a
+    // UID greater than that may have been allocated and then deleted before we
+    // got here, and we don't want to waste resources attempting to
+    // FETCH nothing from such a folder on every sync.
+    if let Some(uidnext) = appender.uidnext {
+        if uidnext == selected.uid_next {
+            // covered by test-inc:'inc after inc finds no new mail'
+            debug!("UIDNEXT hasn't changed from {}", uidnext);
+            store = appender.close();
+            return Ok((selected, store));
+        }
+    }
+
+    let highest_uid_or_zero = appender.highest_uid.map(u32::from).unwrap_or(0);
+    let mut uids_to_fetch = uid_set
+        .iter()
+        .skip_while(|uid| **uid <= highest_uid_or_zero);
+
+    let mut result: FetchResult = selected
+        .mailbox_session
+        .fetch(cf.max_fetch_messages_in_flight, &mut uids_to_fetch)?;
+    let mailbox_session: mailbox::Session = loop {
+        match result {
+            FetchResult::Message(uid, seen, mut message_reader) => {
+                trace!("inc receiving uid={} seen={}", uid, seen,);
+                let mut writer = appender.append(uid).map_err(|e| {
+                    nmh::Error::describe(e, format_args!("inc append message to {}", folder_name))
+                })?;
+                result = loop {
+                    match message_reader.read(buf) {
+                        Err(e) => {
+                            return Err(e);
+                        }
+                        Ok(MessageResult::Reading(bytes, new_reader)) => {
+                            writer.write_all(bytes).map_err(|e| {
+                                nmh::Error::describe(
+                                    e,
+                                    format_args!("inc write to message file in {}", folder_name),
+                                )
+                            })?;
+                            message_reader = new_reader;
+                        }
+                        Ok(MessageResult::Done(r)) => break r,
+                    }
+                };
+                let (msgnum, new_appender) = writer.close().map_err(|e| {
+                    nmh::Error::describe(
+                        e,
+                        format_args!("inc close message file in {}", folder_name),
+                    )
+                })?;
+                appender = new_appender;
+                response
+                    .message(folder_name, msgnum, !seen)
+                    .map_err(|e| nmh::Error::new(e.to_string()))?;
+            }
+            FetchResult::Done(session) => break session,
+        }
+    };
+    // TODO Don't write uidnext unless it changed.
+    store = appender.close();
+    let uidnext = selected.uid_next;
+    store.write_uidnext(uidnext).map_err(|e| {
+        nmh::Error::describe(
+            e,
+            format_args!("inc store UIDNEXT={} in {}", uidnext, folder_name),
+        )
+    })?;
+    selected.mailbox_session = mailbox_session;
+    Ok((selected, store))
+}
+
+/// Delete `uid_set` from `store`, as they are gone from the server.
+fn delete(uid_set: uid::Set, store: FolderStore, uidnext: Uid) -> Result<(), nmh::Error> {
+    // TODO write_transaction and add a delete_message to it?
+    let txn = store.read_transaction()?;
+    let uid2msg_iter = txn.uid_to_message_number_cursor()?;
+    for result in uid2msg_iter {
+        let (uid, msgnum) = result?;
+        let uid_u32: u32 = uid.into();
+        if uid_u32 >= uidnext.into() {
+            break;
+        }
+        if uid_set.contains(uid_u32) {
+            trace!("inc NOT deleting msg={} uid={}", msgnum, uid);
+        } else {
+            trace!("inc deleting msg={} uid={}", msgnum, uid);
+            // TODO add WriteTransaction::delete ?
+            store.delete_message(msgnum, uid)?;
+        }
+    }
+    Ok(())
+}
diff --git a/imap/folder-manager/src/manager/command/mark.rs b/imap/folder-manager/src/manager/command/mark.rs
new file mode 100644 (file)
index 0000000..4268baa
--- /dev/null
@@ -0,0 +1,200 @@
+//! [add::add] implements `mark -add` and [delete::delete] `mark -del`
+
+use managed_folders as nmf;
+use nmf::command;
+use nmh::{folder, message};
+
+use crate::imap::{mailbox, Flag};
+use crate::manager::pick::{
+    check_for_all_or_unseen, pick_messages, write_sequences, LocalPicker, RemotePicker,
+};
+use crate::manager::{Error, Manager, ManagerParts, SelectedParts, State};
+use crate::{recover_authenticated, recover_selected};
+
+mod add;
+pub use add::add;
+
+mod delete;
+pub use delete::delete;
+
+// TODO Read Unseen-Sequence.
+
+fn mark<MarkServer, MarkLocal>(
+    mark_server: MarkServer,
+    mark_local: MarkLocal,
+    manager: Manager,
+    mut command: command::Mark,
+) -> Result<Manager, Error>
+where
+    MarkServer:
+        Fn(mailbox::Session, &str, &[Flag]) -> Result<((), mailbox::Session), mailbox::Error>,
+    MarkLocal: Fn(
+        std::slice::Iter<(&str, &str)>,
+        message::Set,
+        message::Set,
+        Vec<&str>,
+    ) -> Result<Vec<(String, String)>, nmh::Error>,
+{
+    // TODO Support other sequences; map them to flags?  Optionally?
+    let need_unseen = check_for_all_or_unseen(&mut command.msgs);
+    if need_unseen || command.sequences.contains(&"unseen") {
+        mark_with_unseen(
+            mark_server,
+            mark_local,
+            manager,
+            command.folder,
+            command.msgs,
+            command.sequences,
+        )
+    } else {
+        Ok(mark_without_unseen(
+            mark_local,
+            manager,
+            command.folder,
+            command.msgs,
+            command.sequences,
+        )?)
+    }
+}
+
+fn mark_with_unseen<MarkServer, MarkLocal>(
+    mark_server: MarkServer,
+    mark_local: MarkLocal,
+    mut manager: Manager,
+    folder_name: folder::Name,
+    specs: Vec<&str>,
+    mark_sequences: Vec<&str>,
+) -> Result<Manager, Error>
+where
+    MarkServer:
+        Fn(mailbox::Session, &str, &[Flag]) -> Result<((), mailbox::Session), mailbox::Error>,
+    MarkLocal: Fn(
+        std::slice::Iter<(&str, &str)>,
+        message::Set,
+        message::Set,
+        Vec<&str>,
+    ) -> Result<Vec<(String, String)>, nmh::Error>,
+{
+    let (state, mparts) = manager.destruct();
+    let session = match state {
+        State::Authenticated(session) => session,
+        State::Selected(_, selected) => match selected.mailbox_session.close() {
+            Err(e) => return Err(Error::unrecoverable(nmh::Error::wrap(e))),
+            Ok(session) => {
+                log::trace!("rmm CLOSEd into Authenticated state for STORE (TODO check first)");
+                session
+            }
+        },
+    };
+
+    let mailbox_name: mailbox::Name = mparts.fmm.folder_name_to_mailbox(&folder_name)?;
+    let (selected, mparts): (mailbox::Selected, ManagerParts) =
+        recover_authenticated!(mparts, session.select(&mailbox_name));
+
+    let picker = RemotePicker::new(&mparts.fpm, selected.uid_validity, &folder_name)?;
+
+    // SEARCH UNSEEN messages into unseen_uids.
+    let (mailbox_session, selected_parts) = SelectedParts::destruct(selected);
+    let (unseen_uids, mailbox_name, selected, mparts) = recover_selected!(
+        mparts,
+        mailbox_name,
+        selected_parts,
+        mailbox_session.search_unseen()
+    );
+
+    let picker_txn = picker.txn()?;
+    let mut picked_messages = message::Set::new();
+    let mut unseen_messages: message::Set = picker_txn.to_message_numbers(unseen_uids)?;
+    let mut uids_to_add = Vec::<nmhimaptypes::Uid>::new();
+    let sequences = picker.parse_sequences()?;
+    pick_messages(
+        specs,
+        &sequences,
+        // TODO Rather than clone it, move our unseen_messages in and then take it back out later?
+        unseen_messages.clone(),
+        picker_txn.cursor()?,
+        |(msgnum, uid)| {
+            let msgnum_i32: i32 = msgnum.into();
+            picked_messages.insert(msgnum_i32);
+            // TODO move this check out
+            if mark_sequences.contains(&"unseen") {
+                unseen_messages.remove(msgnum_i32);
+                uids_to_add.push(uid);
+            }
+        },
+    )?;
+
+    if uids_to_add.is_empty() {
+        manager = mparts.selected(mailbox_name, selected);
+    } else {
+        // TODO Should this be a UidSet, and this below its Display impl?
+        // TODO use UID ranges
+        let s: String = uids_to_add
+            .iter()
+            .map(|u| u.to_string())
+            .collect::<Vec<String>>()
+            .join(",");
+        let (mailbox_session, selected_parts) = SelectedParts::destruct(selected);
+        let (_, _, selected, mparts) = recover_selected!(
+            mparts,
+            mailbox_name,
+            selected_parts,
+            mark_server(mailbox_session, &s, &[Flag::Seen])
+        );
+        // CLOSE to persist the flag changes.
+        manager = mparts.authenticated(selected.mailbox_session.close().map_err(nmh::Error::wrap)?);
+    }
+
+    write_sequences(
+        picker.seq_path(),
+        mark_local(
+            sequences.iter(),
+            picked_messages,
+            unseen_messages,
+            mark_sequences,
+        )?,
+    )?;
+    Ok(manager)
+}
+
+fn mark_without_unseen<F>(
+    mark_sequences: F,
+    manager: Manager,
+    folder_name: folder::Name,
+    specs: Vec<&str>,
+    sequences_to_mark: Vec<&str>,
+) -> Result<Manager, nmh::Error>
+where
+    F: Fn(
+        std::slice::Iter<(&str, &str)>,
+        message::Set,
+        message::Set,
+        Vec<&str>,
+    ) -> Result<Vec<(String, String)>, nmh::Error>,
+{
+    let picker = LocalPicker::new(&manager.fpm, &folder_name)?;
+    let sequences: nmf::Components = picker.parse_sequences()?;
+
+    let mut picked_messages = message::Set::new();
+    pick_messages(
+        specs,
+        &sequences,
+        message::Set::new(),
+        picker.txn()?.cursor()?,
+        |(msgnum, _)| {
+            picked_messages.insert(i32::from(msgnum));
+        },
+    )?;
+
+    write_sequences(
+        picker.seq_path(),
+        mark_sequences(
+            sequences.iter(),
+            picked_messages,
+            message::Set::new(),
+            sequences_to_mark,
+        )?,
+    )?;
+
+    Ok(manager)
+}
diff --git a/imap/folder-manager/src/manager/command/mark/add.rs b/imap/folder-manager/src/manager/command/mark/add.rs
new file mode 100644 (file)
index 0000000..48e220f
--- /dev/null
@@ -0,0 +1,53 @@
+//! Implements `mark -add`.
+
+use crate::manager::pick::parse_msgs;
+use crate::manager::{Error, Manager};
+
+pub fn add(manager: Manager, command: managed_folders::command::Mark) -> Result<Manager, Error> {
+    super::mark(
+        |mailbox_session, s, flags| mailbox_session.del_flags(s, flags),
+        update_sequences_adding_marked,
+        manager,
+        command,
+    )
+}
+
+fn update_sequences_adding_marked(
+    sequences: std::slice::Iter<(&str, &str)>,
+    picked_messages: nmh::message::Set,
+    unseen_messages: nmh::message::Set,
+    mut mark_sequences: Vec<&str>,
+) -> Result<Vec<(String, String)>, nmh::Error> {
+    // TODO make nmh-picked configurable.  if user already configured Previous-Sequence, use that?
+    let mut result = vec![("nmh-picked".to_string(), picked_messages.to_string())];
+    if !unseen_messages.is_empty() {
+        result.push(("unseen".to_string(), unseen_messages.to_string()));
+    }
+    for (name, value) in sequences {
+        if *name == "nmh-picked" || *name == "unseen" {
+            continue;
+        }
+        let mut marked = None;
+        for i in 0..mark_sequences.len() {
+            if mark_sequences[i] == *name {
+                marked = Some(i);
+                let mut ranges = parse_msgs(value)?;
+                ranges.append(&mut picked_messages.clone());
+                if !ranges.is_empty() {
+                    result.push((name.to_string(), ranges.to_string()));
+                }
+                break;
+            }
+        }
+        match marked {
+            None => result.push((name.to_string(), value.to_string())),
+            Some(i) => {
+                mark_sequences.remove(i);
+            }
+        }
+    }
+    for new_sequence in mark_sequences.iter() {
+        result.push((new_sequence.to_string(), picked_messages.to_string()))
+    }
+    Ok(result)
+}
diff --git a/imap/folder-manager/src/manager/command/mark/delete.rs b/imap/folder-manager/src/manager/command/mark/delete.rs
new file mode 100644 (file)
index 0000000..8b337db
--- /dev/null
@@ -0,0 +1,47 @@
+//! Implements `mark -del`.
+
+use crate::manager::pick::parse_msgs;
+use crate::manager::{Error, Manager};
+
+pub fn delete(manager: Manager, command: managed_folders::command::Mark) -> Result<Manager, Error> {
+    super::mark(
+        |mailbox_session, s, flags| mailbox_session.add_flags(s, flags),
+        update_sequences_deleting_marked,
+        manager,
+        command,
+    )
+}
+
+fn update_sequences_deleting_marked(
+    sequences: std::slice::Iter<(&str, &str)>,
+    picked_messages: nmh::message::Set,
+    unseen_messages: nmh::message::Set,
+    mark_sequences: Vec<&str>,
+) -> Result<Vec<(String, String)>, nmh::Error> {
+    // TODO make nmh-picked configurable.  if user already configured Previous-Sequence, use that?
+    let mut result = vec![("nmh-picked".to_string(), picked_messages.to_string())];
+    if !unseen_messages.is_empty() {
+        result.push(("unseen".to_string(), unseen_messages.to_string()));
+    }
+    for (name, value) in sequences {
+        if *name == "nmh-picked" || *name == "unseen" {
+            continue;
+        }
+        let mut marked = false;
+        for mark_seq in &mark_sequences {
+            if name == mark_seq {
+                let mut ranges = parse_msgs(value)?;
+                ranges = ranges.clone_without(&picked_messages);
+                if !ranges.is_empty() {
+                    result.push((name.to_string(), ranges.to_string()));
+                }
+                marked = true;
+                break;
+            }
+        }
+        if !marked {
+            result.push((name.to_string(), value.to_string()));
+        }
+    }
+    Ok(result)
+}
diff --git a/imap/folder-manager/src/manager/command/pick.rs b/imap/folder-manager/src/manager/command/pick.rs
new file mode 100644 (file)
index 0000000..283761c
--- /dev/null
@@ -0,0 +1,130 @@
+//! [pick] implements the message picking.
+
+use managed_folders as nmf;
+use nmf::command;
+use nmh::{folder, message};
+
+use crate::imap::mailbox;
+use crate::manager::pick::{
+    check_for_all_or_unseen, pick_messages, write_sequences, LocalPicker, RemotePicker,
+};
+use crate::manager::{Error, Manager, ManagerParts, SelectedParts, State};
+use crate::{recover_authenticated, recover_selected};
+
+// TODO Read Unseen-Sequence.
+
+pub fn pick(manager: Manager, mut command: command::Pick) -> Result<Manager, Error> {
+    let need_unseen = check_for_all_or_unseen(&mut command.msgs);
+    if need_unseen || command.need_unseen {
+        pick_with_unseen(manager, command.folder, command.msgs)
+    } else {
+        Ok(pick_without_unseen(manager, command.folder, command.msgs)?)
+    }
+}
+
+fn pick_without_unseen(
+    manager: Manager,
+    folder_name: folder::Name,
+    specs: Vec<&str>,
+) -> Result<Manager, nmh::Error> {
+    let picker = LocalPicker::new(&manager.fpm, &folder_name)?;
+    let sequences: nmf::Components = picker.parse_sequences()?;
+
+    let mut picked_messages = message::Set::new();
+    pick_messages(
+        specs,
+        &sequences,
+        message::Set::new(),
+        picker.txn()?.cursor()?,
+        |(msgnum, _)| {
+            picked_messages.insert(i32::from(msgnum));
+        },
+    )?;
+
+    write_sequences(
+        picker.seq_path(),
+        update_sequences(sequences.iter(), picked_messages, message::Set::new()),
+    )?;
+
+    Ok(manager)
+}
+
+// TODO dedup with mark_with_unseen
+fn pick_with_unseen(
+    mut manager: Manager,
+    folder_name: folder::Name,
+    specs: Vec<&str>,
+) -> Result<Manager, Error> {
+    // TODO Support other sequences; map them to flags?  Optionally?
+    // 0. SEARCH UNSEEN if need_unseen
+    let (state, mparts) = manager.destruct();
+    let session = match state {
+        State::Authenticated(session) => session,
+        State::Selected(_, selected) => match selected.mailbox_session.close() {
+            Err(e) => return Err(Error::unrecoverable(nmh::Error::wrap(e))),
+            Ok(session) => {
+                log::trace!("rmm CLOSEd into Authenticated state for SEARCH (TODO check first)");
+                session
+            }
+        },
+    };
+
+    let mailbox_name: mailbox::Name = mparts.fmm.folder_name_to_mailbox(&folder_name)?;
+    let (selected, mparts): (mailbox::Selected, ManagerParts) =
+        recover_authenticated!(mparts, session.select(&mailbox_name));
+
+    let picker = RemotePicker::new(&mparts.fpm, selected.uid_validity, &folder_name)?;
+
+    // SEARCH UNSEEN messages into unseen_uids.
+    let (mailbox_session, selected_parts) = SelectedParts::destruct(selected);
+    let (unseen_uids, _, selected, mparts) = recover_selected!(
+        mparts,
+        mailbox_name,
+        selected_parts,
+        mailbox_session.search_unseen()
+    );
+
+    let mut picked_messages = message::Set::new();
+    let sequences = picker.parse_sequences()?;
+    let picker_txn = picker.txn()?;
+    let unseen_messages: message::Set = picker_txn.to_message_numbers(unseen_uids)?;
+    pick_messages(
+        specs,
+        &sequences,
+        // TODO Rather than clone it, move our unseen_messages in and then take it back out later?
+        unseen_messages.clone(),
+        picker_txn.cursor()?,
+        |(msgnum, _)| {
+            picked_messages.insert(i32::from(msgnum));
+        },
+    )?;
+
+    // CLOSE to persist the flag changes.
+    manager = mparts.authenticated(selected.mailbox_session.close().map_err(nmh::Error::wrap)?);
+
+    write_sequences(
+        picker.seq_path(),
+        update_sequences(sequences.iter(), picked_messages, unseen_messages),
+    )?;
+
+    Ok(manager)
+}
+
+fn update_sequences<'a>(
+    sequences: std::slice::Iter<(&'a str, &'a str)>,
+    picked_messages: message::Set,
+    unseen_messages: message::Set,
+) -> Vec<(String, String)> {
+    // TODO make nmh-picked configurable.  if user already configured Previous-Sequence, use that?
+    let mut result = vec![("nmh-picked".to_string(), picked_messages.to_string())];
+    if !unseen_messages.is_empty() {
+        result.push(("unseen".to_string(), unseen_messages.to_string()));
+    }
+    for (name, value) in sequences {
+        if *name == "nmh-picked" || *name == "unseen" {
+            continue;
+        }
+        result.push((name.to_string(), value.to_string()));
+    }
+    result
+}
diff --git a/imap/folder-manager/src/manager/mapper.rs b/imap/folder-manager/src/manager/mapper.rs
new file mode 100644 (file)
index 0000000..4f9b61d
--- /dev/null
@@ -0,0 +1,41 @@
+//! [FolderMailboxMapper] maps between folder paths and mailbox names.
+
+// TODO This assumes mailbox hierarchy separator is '/' which is not true.
+//  We have to check the LIST "" % response to find it.
+//  This is also just messy.  What do we really need?
+#[derive(Debug)]
+pub struct FolderMailboxMapper {
+    name: String,
+    // TODO
+    pub mailbox_root: String,
+}
+
+impl FolderMailboxMapper {
+    pub fn new(name: String, mailbox_root: String) -> Self {
+        Self { name, mailbox_root }
+    }
+
+    pub fn folder_name_to_mailbox(
+        &self,
+        folder: &nmh::folder::Name,
+    ) -> Result<crate::imap::mailbox::Name, nmh::Error> {
+        let stripped = match folder.as_path().strip_prefix(&self.name) {
+            Ok(value) => value,
+            Err(e) => todo!("handle wrong folder {}", e),
+        };
+        match stripped.to_str() {
+            Some(value) => Ok(crate::imap::mailbox::Name::from(format!(
+                "{}{}",
+                self.mailbox_root, value
+            ))),
+            None => todo!("mailbox name conversion"),
+        }
+    }
+
+    pub fn mailbox_name_to_folder(&self, mailbox: &str) -> Result<nmh::folder::Name, nmh::Error> {
+        nmh::folder::Name::from_utf8(
+            std::path::Path::new(&self.name)
+                .join(mailbox.strip_prefix(&self.mailbox_root).unwrap_or(mailbox)),
+        )
+    }
+}
diff --git a/imap/folder-manager/src/manager/pick.rs b/imap/folder-manager/src/manager/pick.rs
new file mode 100644 (file)
index 0000000..dcd4c2a
--- /dev/null
@@ -0,0 +1,329 @@
+//! Local and remote message picking
+//!
+//! The names here could use some work but hopefully it all makes sense.  Various commands take
+//! "message specifications" which describe the set of messages on which to operate.  See the
+//! `mh-sequence(5)` man page for details.
+//!
+//! Most of these specifications can be resolved locally.  Only one case requires contacting the
+//! server: resolving the unseen sequence.
+//!
+//! To avoid hitting the server unnecessarily we have [LocalPicker] for local-only message
+//! resolution, and [RemotePicker] for local and remote (maybe we should call it
+//! `LocalAndRemotePicker` or something).
+//!
+//! To start with, use [check_for_all_or_unseen] to determine whether assistance from the server is
+//! required.  This function also helps special-case "all" and "first-last" specifications.  If,
+//! after passing a [Vec] to [check_for_all_or_unseen], element 0 is "all", the caller knows the
+//! operation will be purely local.
+//!
+//! Next, use [LocalPicker] or [RemotePicker] with [pick_messages] to pick local messages.
+//! With [RemotePicker], the caller also gets the [Uid]s.
+//!
+//! [parse_msgs] and [write_sequences] are used to update sequences files.
+
+use std::num::{NonZeroI32, NonZeroU32};
+
+use managed_folders as nmf;
+use nmh::{folder, message};
+use nmhimapstore::{
+    FolderPathMapper, FolderStore, MessageCursor, MessageNumber2UidCursor, MessageNumberCursor,
+    MessageNumberTransaction, MessageNumberView, ReadOnlyTransaction, ReadTransaction,
+};
+use nmhimaptypes::{uid, Uid, UidValidity};
+
+use crate::manager::store;
+use nmh::Error;
+
+// TODO Unseen-Sequence
+/// Set `msgs[0]` to `"all"` (leaving all other elements untouched) if any element is "all" or
+/// "first-last" and return true if the Unseen-Sequence is referenced.
+pub fn check_for_all_or_unseen(msgs: &mut Vec<&str>) -> bool {
+    let mut need_unseen = false;
+    for i in 0..msgs.len() {
+        let spec = msgs[i];
+        if spec == "all" || spec == "first-last" {
+            msgs[0] = "all";
+            return false;
+        }
+        if spec == "unseen" {
+            need_unseen = true;
+        }
+    }
+    need_unseen
+}
+
+pub struct LocalPicker {
+    msg_only: MessageNumberView,
+    seq_path: std::path::PathBuf,
+    seq_contents: String,
+}
+
+impl LocalPicker {
+    pub fn new(fpm: &FolderPathMapper, folder_name: &folder::Name) -> Result<Self, Error> {
+        let seq_path = fpm.seq_path(folder_name);
+        // TODO locking
+        let seq_contents: String = file_contents_or_empty(&seq_path)?;
+        Ok(Self {
+            msg_only: MessageNumberView::open(fpm, folder_name)
+                .map_err(|e| Error::describe(e, format_args!("{}", folder_name)))?,
+            seq_path,
+            seq_contents,
+        })
+    }
+
+    pub fn parse_sequences(&self) -> Result<nmf::Components, Error> {
+        nmf::parse_components(&self.seq_contents)
+            .map_err(|e| Error::describe(e, format_args!("{}", self.seq_path.display())))
+    }
+
+    pub fn seq_path(&self) -> &std::path::Path {
+        &self.seq_path
+    }
+
+    pub fn txn(&self) -> Result<LocalPickerTransaction, Error> {
+        Ok(LocalPickerTransaction {
+            txn: self.msg_only.read_transaction()?,
+        })
+    }
+}
+
+pub struct LocalPickerTransaction<'a> {
+    txn: MessageNumberTransaction<'a>,
+}
+
+impl<'a> LocalPickerTransaction<'a> {
+    pub fn cursor(&self) -> Result<MessageNumberCursor, Error> {
+        self.txn.cursor()
+    }
+}
+
+pub struct RemotePicker {
+    store: FolderStore,
+    seq_path: std::path::PathBuf,
+    seq_contents: String,
+}
+
+impl RemotePicker {
+    pub fn new(
+        fpm: &FolderPathMapper,
+        uid_validity: UidValidity,
+        folder_name: &folder::Name,
+    ) -> Result<Self, Error> {
+        let seq_path = fpm.seq_path(folder_name);
+        // TODO locking
+        let seq_contents: String = file_contents_or_empty(&seq_path)?;
+        Ok(Self {
+            store: store::open(fpm, folder_name, uid_validity)?,
+            seq_path,
+            seq_contents,
+        })
+    }
+
+    pub fn parse_sequences(&self) -> Result<nmf::Components, Error> {
+        nmf::parse_components(&self.seq_contents)
+            .map_err(|e| Error::describe(e, format_args!("{}", self.seq_path.display())))
+    }
+
+    pub fn seq_path(&self) -> &std::path::Path {
+        &self.seq_path
+    }
+
+    pub fn txn(&self) -> Result<RemotePickerTransaction, Error> {
+        Ok(RemotePickerTransaction {
+            txn: self.store.read_transaction()?,
+        })
+    }
+}
+
+pub struct RemotePickerTransaction<'a> {
+    txn: ReadOnlyTransaction<'a>,
+}
+
+impl<'a> RemotePickerTransaction<'a> {
+    pub fn to_message_numbers(&self, uids: uid::Set) -> Result<message::Set, Error> {
+        let mut msgnums = message::Set::new();
+        for uid in uids.iter() {
+            // TODO is this faster or would it be faster to iterate
+            // lowest-highest unseen UID and pick what matches?
+            msgnums.insert(
+                self.txn
+                    .lookup_message_number(Uid::from(
+                        NonZeroU32::new(*uid)
+                            .ok_or_else(|| Error::new(format!("Uid <= 0:  {}", uid)))?,
+                    ))?
+                    .ok_or_else(|| {
+                        // TODO Add test for this and for the fix, don't even do the db lookup.
+                        // UID increases monotonically, so any UID not appearing in local db
+                        // MUST be greater than the highest UID we've seen before.  We can check
+                        // that and skip the lookup.
+                        todo!("UID {} in search results we haven't inc'd yet", uid)
+                    })
+                    .unwrap()
+                    .into(),
+            );
+        }
+        Ok(msgnums)
+    }
+
+    pub fn cursor(&self) -> Result<MessageNumber2UidCursor, Error> {
+        self.txn.message_number_to_uid_cursor()
+    }
+}
+
+pub fn parse_msgs(msgs: &str) -> Result<message::Set, Error> {
+    let mut ranges = message::Set::new();
+    for spec in msgs.split_ascii_whitespace() {
+        let pieces: Vec<&str> = spec.split('-').collect();
+        if pieces.len() == 1 {
+            // no '-'
+            match pieces[0].parse() {
+                Err(_) => todo!(
+                    "check for cur etc. else return error 'bad message list {}'",
+                    spec
+                ),
+                Ok(msgnum) => {
+                    ranges.insert(msgnum);
+                }
+            }
+        } else if pieces.len() == 2 {
+            // one '-'
+            let beg: i32 = match pieces[0].parse() {
+                Err(_) => todo!(
+                    "check for cur etc. else return error 'bad message list {}'",
+                    spec
+                ),
+                Ok(value) => value,
+            };
+            let end: i32 = match pieces[1].parse() {
+                Err(_) => todo!(
+                    "check for cur etc. else return error 'bad message list {}'",
+                    spec
+                ),
+                Ok(value) => value,
+            };
+            for msgnum in beg..end + 1 {
+                ranges.insert(msgnum);
+            }
+        } else {
+            todo!("you can't have two dashes in here")
+        }
+    }
+    Ok(ranges)
+}
+
+/// Call `func` with picked messages.
+///
+/// `specs` is a list of specifications according to `mh-sequenc(5)`.
+/// Any references to sequences therein are resolved in `sequences`, or
+/// `unseen_messages` for the unseen sequence.
+///
+/// If `cursor` is a [MessageNumber2UidCursor], `func` also receives corresponding UIDs.
+pub fn pick_messages<T, C: MessageCursor<T>, F: FnMut((message::Number, T))>(
+    specs: Vec<&str>,
+    sequences: &nmf::Components,
+    unseen_messages: message::Set,
+    cursor: C,
+    mut func: F,
+) -> Result<(), Error> {
+    let ranges_to_pick: message::Set = specs_to_ranges(specs, sequences, unseen_messages)?;
+    if let Some((beg, end)) = ranges_to_pick.ends() {
+        let mut item: Option<(message::Number, T)> = cursor
+            .get(message::Number::from(NonZeroI32::new(beg).ok_or_else(
+                || Error::new(format!("message number <= 0:  {}", beg)),
+            )?))?;
+        loop {
+            match item {
+                None => return Ok(()),
+                Some(pair) => {
+                    let msgnum: i32 = pair.0.into();
+                    if msgnum > end {
+                        break;
+                    }
+                    if ranges_to_pick.contains(msgnum) {
+                        func(pair);
+                    }
+                }
+            }
+            item = cursor.next()?;
+        }
+    }
+    Ok(())
+}
+
+pub fn write_sequences(
+    seq_path: &std::path::Path,
+    sequences: Vec<(String, String)>,
+) -> Result<(), Error> {
+    write_sequences_unwrapped(seq_path, sequences)
+        .map_err(|e| Error::describe(e, format_args!("write sequences to {}", seq_path.display())))
+}
+
+fn write_sequences_unwrapped(
+    seq_path: &std::path::Path,
+    sequences: Vec<(String, String)>,
+) -> std::io::Result<()> {
+    // TODO locking
+    let mut file = std::fs::File::create(seq_path)?;
+    use std::io::Write;
+    for (name, value) in sequences {
+        file.write_all(format!("{}: {}\n", name, value).as_bytes())?
+    }
+    file.sync_all()
+}
+
+// TODO not a great home for this
+fn file_contents_or_empty(path: &std::path::Path) -> Result<String, Error> {
+    match std::fs::read_to_string(&path) {
+        Err(e) => match e.kind() {
+            std::io::ErrorKind::NotFound => Ok("".to_owned()),
+            _ => Err(Error::describe(e, format_args!("read {}", path.display()))),
+        },
+        Ok(contents) => Ok(contents),
+    }
+}
+
+/// Return one [message::Set] from the user-provided vector of message specifications.
+/// If any of the specifications reference sequences, look them up in `sequences`, except for the
+/// Unseen-Sequence, for which `unseen_messages` is used.
+fn specs_to_ranges(
+    specs: Vec<&str>,
+    sequences: &nmf::Components,
+    unseen_messages: message::Set,
+) -> Result<message::Set, Error> {
+    let mut unseen_messages = Some(unseen_messages);
+    let mut ranges_to_pick = message::Set::new();
+    for spec in &specs {
+        if *spec == "all" {
+            ranges_to_pick = message::Set::all();
+            break;
+        }
+        if spec.contains('=') || spec.contains(':') {
+            todo!("= and : not yet supported")
+        }
+        // may be '1', '1-2', or 'seq'
+        // TODO while sequences cannot occur in ranges, special words like 'first', 'cur', 'prev', 'next' can
+        let expanded_specs: &str = if spec.contains('-') {
+            spec
+        } else {
+            match spec.parse::<u32>() {
+                Err(_) => {
+                    if *spec == "unseen" {
+                        if let Some(mut taken) = unseen_messages.take() {
+                            ranges_to_pick.append(&mut taken)
+                        }
+                        // TODO this is poorly structured
+                        ""
+                    } else {
+                        sequences.get(spec).unwrap_or(
+                            // TODO no such sequence; return "bad message list {spec}"?
+                            "",
+                        )
+                    }
+                }
+                Ok(_) => spec,
+            }
+        };
+        ranges_to_pick.append(&mut parse_msgs(expanded_specs)?);
+    }
+    Ok(ranges_to_pick)
+}
diff --git a/imap/folder-manager/src/regex.rs b/imap/folder-manager/src/regex.rs
new file mode 100644 (file)
index 0000000..e0d4471
--- /dev/null
@@ -0,0 +1,52 @@
+//! This is a bare-bones regex.h wrapper.  It simply panics if anything goes
+//! wrong.  At some point we should polish this.
+
+use std::ffi::CStr;
+use std::mem::MaybeUninit;
+use std::os::raw::{c_char, c_int};
+
+use libc::{regcomp, regex_t, regexec, regfree, regmatch_t, REG_NOMATCH, REG_NOSUB};
+
+#[derive(Debug)]
+pub struct Regex(MaybeUninit<regex_t>);
+
+impl Regex {
+    pub fn compile(pattern: &CStr) -> Self {
+        let mut this = Self(MaybeUninit::uninit());
+        let preg: *mut regex_t = this.0.as_mut_ptr();
+        let pat_cstr: *const c_char = pattern.as_ptr();
+        let cflags: c_int = REG_NOSUB;
+        if unsafe { regcomp(preg, pat_cstr, cflags) } != 0 {
+            todo!("regcomp error")
+        }
+        this
+    }
+
+    pub fn matches(&self, s: &str) -> bool {
+        let cstring = match std::ffi::CString::new(s) {
+            // This only returns error on embedded null byte and it's fair to call that not a match.
+            Err(_) => return false,
+            Ok(value) => value,
+        };
+        let preg: *const regex_t = self.0.as_ptr();
+        let string: *const c_char = cstring.as_c_str().as_ptr();
+        let nmatch = 0;
+        let pmatch: *mut regmatch_t = std::ptr::null_mut();
+        let eflags = 0;
+        match unsafe { regexec(preg, string, nmatch, pmatch, eflags) } {
+            0 => true,
+            REG_NOMATCH => false,
+            _ => todo!("regexec error"),
+        }
+    }
+}
+
+impl Drop for Regex {
+    fn drop(&mut self) {
+        let preg: *mut regex_t = self.0.as_mut_ptr();
+        unsafe { regfree(preg) }
+    }
+}
+
+#[cfg(test)]
+mod tests;
diff --git a/imap/folder-manager/src/regex/tests.rs b/imap/folder-manager/src/regex/tests.rs
new file mode 100644 (file)
index 0000000..d8145db
--- /dev/null
@@ -0,0 +1,15 @@
+use std::ffi::CString;
+
+use super::*;
+
+#[test]
+fn matched() {
+    let re = Regex::compile(CString::new(".").unwrap().as_c_str());
+    assert!(re.matches("."));
+}
+
+#[test]
+fn not() {
+    let re = Regex::compile(CString::new("nah").unwrap().as_c_str());
+    assert!(!re.matches("."));
+}
diff --git a/imap/folder-manager/src/run.rs b/imap/folder-manager/src/run.rs
new file mode 100644 (file)
index 0000000..cfe339a
--- /dev/null
@@ -0,0 +1,127 @@
+use managed_folders as nmf;
+
+use crate::cf;
+use crate::imap;
+use crate::logging;
+use crate::manager::Manager;
+
+pub fn configure_and_run(context: &nmh::Context, name: String) -> Result<(), String> {
+    let profile = context.profile();
+    let prefix = format!("{}-", name);
+    let prefixed = profile.prefixed(&prefix);
+    let mut message_buffer = vec![0; 8192];
+    match configure(&mut message_buffer, context, &profile, name, &prefixed)? {
+        None => Ok(()),
+        Some((listener, manager)) => run(listener, &mut message_buffer, manager),
+    }
+}
+
+fn configure(
+    message_buffer: &mut [u8],
+    context: &nmh::Context,
+    profile: &nmh::Profile,
+    name: String,
+    prefixed: &nmh::profile::Prefixed,
+) -> Result<Option<(std::os::unix::net::UnixListener, Manager)>, String> {
+    // Install logging as soon as possible.
+    logging::install(prefixed)?;
+
+    // Fully process configuration before attempting any work.
+    let init = cf::Init::new(prefixed)?;
+    let cf = cf::Manager::new(prefixed)?;
+
+    std::fs::create_dir_all(&cf.db_path)
+        .map_err(|e| format!("{}:  {}", cf.db_path.display(), e))?;
+
+    // As soon as possible, we bind the listening socket and tell the calling nmh we are ready.
+    let listener = std::os::unix::net::UnixListener::bind(init.socket_path)
+        .map_err(|e| format!("{}:  {}", init.socket_path.display(), e))?;
+    // Tell caller that we are ready.
+    if unsafe {
+        let lf = '\n';
+        libc::write(1, &lf as *const char as *const libc::c_void, 1)
+    } < 0
+    {
+        return Err(format!(
+            "write(1, \"\\n\", 1):  {}",
+            std::io::Error::last_os_error()
+        ));
+    }
+    if unsafe { libc::close(1) } < 0 {
+        return Err(format!("close(1):  {}", std::io::Error::last_os_error()));
+    }
+
+    let (stream, _sockaddr) = listener.accept().map_err(log_and_convert)?;
+
+    // Now that we're listening, we have a place to send errors, so setup the stderr log.
+    logging::redirect_stderr_to(cf.db_path.join("stderr"))?;
+
+    let mail_path: &std::path::Path = profile.path()?;
+    match make_manager(context, name, cf, init, mail_path) {
+        Err(e) => {
+            nmf::close_with_error(stream, &e).map_err(log_and_convert)?;
+            Err(e)
+        }
+        Ok(manager) => match handle(message_buffer, stream, manager)? {
+            None => Ok(None),
+            Some(manager) => Ok(Some((listener, manager))),
+        },
+    }
+}
+
+fn make_manager(
+    context: &nmh::Context,
+    name: String,
+    cf: cf::Manager,
+    init: cf::Init,
+    mail_path: &std::path::Path,
+) -> Result<Manager, String> {
+    Ok(Manager::new(
+        crate::manager::FolderMailboxMapper::new(name, cf.mailbox_root.to_string()),
+        nmhimapstore::FolderPathMapper::new(mail_path.to_path_buf(), cf.db_path.to_path_buf()),
+        imap::auth::login(context, init.auth).map_err(log_and_convert)?,
+        cf,
+    ))
+}
+
+fn run(
+    listener: std::os::unix::net::UnixListener,
+    message_buffer: &mut [u8],
+    mut manager: Manager,
+) -> Result<(), String> {
+    loop {
+        let (stream, _sockaddr) = listener.accept().map_err(log_and_convert)?;
+        match handle(message_buffer, stream, manager)? {
+            None => return Ok(()),
+            Some(m) => manager = m,
+        }
+        // TODO actually i'm pretty sure that hang was my bug which i fixed
+        // TODO m_getfld2 hangs :(
+        // temporary hack is to reconnect for every command
+    }
+}
+
+fn handle(
+    message_buffer: &mut [u8],
+    stream: std::os::unix::net::UnixStream,
+    manager: Manager,
+) -> Result<Option<Manager>, String> {
+    let reader = nmf::FolderManagerProtocol::new(stream);
+    let (message, reader): (nmf::Message, nmf::FolderManagerProtocol) = reader
+        .read_message(message_buffer)
+        .map_err(|e| e.to_string())?;
+    let command = reader.read_command(&message).map_err(|e| e.to_string())?;
+    Ok(match command {
+        nmf::Command::Quit => None,
+        _ => {
+            let (new_manager, reader) = manager.run(command)?;
+            reader.close().map_err(log_and_convert)?;
+            Some(new_manager)
+        }
+    })
+}
+
+fn log_and_convert<T: std::error::Error>(e: T) -> String {
+    logging::error(&e);
+    e.to_string()
+}
diff --git a/imap/mhimap/Cargo.toml b/imap/mhimap/Cargo.toml
new file mode 100644 (file)
index 0000000..eeea469
--- /dev/null
@@ -0,0 +1,9 @@
+[package]
+name = "mhimap"
+# TODO derive from nmh version
+version = "0.0.0"
+edition = "2018"
+
+[dependencies]
+nmh = { path = "../../sbr/rust" }
+nmhimapstore = { path = "../store" }
diff --git a/imap/mhimap/src/main.rs b/imap/mhimap/src/main.rs
new file mode 100644 (file)
index 0000000..64dbe2f
--- /dev/null
@@ -0,0 +1,92 @@
+use std::io::{Read, Write};
+use std::os::unix::net::UnixStream;
+use std::path::{Path, PathBuf};
+
+fn main() -> std::io::Result<()> {
+    // TODO mh-style switches
+    let mut args = std::env::args();
+    let (context, name): (nmh::Context, String) = parse_args(&mut args);
+    let profile: nmh::Profile = context.profile();
+    let prefix = format!("{}-", name);
+    let prefixed: nmh::profile::Prefixed = profile.prefixed(&prefix);
+
+    let command = args.next().unwrap();
+    match command.as_str() {
+        "start" => {
+            let mut stream: UnixStream = connect(&name, &prefixed)?;
+            stream.write_all(b"len:13\nop:no\n")?;
+            stream.flush()?;
+            let mut response = String::new();
+            stream.read_to_string(&mut response)?;
+            println!("{}", response);
+        }
+        "noop" | "shutdown" => {
+            // TODO separate shutdown; starting it up just to kill it is daft...
+            let mut stream: UnixStream = connect(&name, &prefixed)?;
+            if command == "shutdown" {
+                stream.write_all(b"len:15\nop:quit\n")?;
+            } else if command == "noop" {
+                stream.write_all(b"len:13\nop:no\n")?;
+            }
+            let mut response = String::new();
+            stream.read_to_string(&mut response)?;
+            println!("{}", response);
+        }
+        "check" => {
+            check(PathBuf::from(args.next().unwrap()), &profile, &prefixed);
+        }
+        _ => todo!("only check, noop, shutdown"),
+    }
+    Ok(())
+}
+
+fn parse_args(args: &mut std::env::Args) -> (nmh::Context, String) {
+    (
+        nmh::init(std::ffi::CString::new(args.next().unwrap()).unwrap()),
+        args.next().unwrap(),
+    )
+}
+
+fn connect(name: &str, prefixed: &nmh::profile::Prefixed) -> std::io::Result<UnixStream> {
+    // 1. Try to connect to running manager.
+    let socket_path = Path::new(prefixed.get_or_err("socket").unwrap());
+    match UnixStream::connect(socket_path) {
+        Err(e) => {
+            match e.kind() {
+                std::io::ErrorKind::NotFound => {}
+                // 2. If not ENOENT, unlink the stale socket name.
+                _ => std::fs::remove_file(&socket_path)?,
+            }
+            // 3. make pipe
+            // 4. fork
+            // TODO if $name-manager is a relative path, look in libexecdir
+            let run_path = Path::new(prefixed.get_or_err("manager").unwrap());
+            // 5.child. folder-manager; prints to stdout if successfully listening
+            let command = std::process::Command::new(run_path)
+                .arg(name)
+                .stdout(std::process::Stdio::piped())
+                .spawn()?;
+            let mut stdout: std::process::ChildStdout = command.stdout.unwrap();
+            // 5.parent read from pipe to child
+            // 6.parent if error, die
+            let mut buf = [0_u8; 1];
+            if stdout.read(&mut buf)? != 1 {
+                todo!("imap-folder-manager didn't report ready")
+            }
+            // 7.parent else, time to connect again!  this time, die if error
+            UnixStream::connect(socket_path)
+        }
+        ok => ok,
+    }
+}
+
+fn check(folder_path: PathBuf, profile: &nmh::Profile, prefixed: &nmh::profile::Prefixed) {
+    assert!(folder_path.is_relative());
+
+    let db_path = Path::new(prefixed.get("db").unwrap().trim()).to_path_buf();
+    let fpm = nmhimapstore::FolderPathMapper::new(
+        Path::new(profile.get("Path").unwrap().trim()).to_path_buf(),
+        db_path,
+    );
+    nmhimapstore::check(&nmh::folder::Name::from_utf8(folder_path).unwrap(), fpm).unwrap()
+}
diff --git a/imap/store/Cargo.toml b/imap/store/Cargo.toml
new file mode 100644 (file)
index 0000000..136fb63
--- /dev/null
@@ -0,0 +1,13 @@
+[package]
+name = "nmhimapstore"
+# TODO derive from nmh version
+version = "0.0.0"
+edition = "2018"
+
+[dependencies]
+lmdb-rkv = "0.14"
+lmdb-rkv-sys = "0.11"
+log = "0.4"
+# nmh
+nmh = { path = "../../sbr/rust" }
+nmhimaptypes = { path = "../types" }
diff --git a/imap/store/src/append.rs b/imap/store/src/append.rs
new file mode 100644 (file)
index 0000000..10304ac
--- /dev/null
@@ -0,0 +1,118 @@
+use std::io::Write as _;
+
+use nmh::{folder, message};
+use nmhimaptypes::Uid;
+
+use crate::{Error, FolderPathMapper, FolderStore, ReadTransaction, Result, WriteTransaction};
+
+/// For appending messages into a folder.
+/// Start with [FolderStore::append].
+pub struct FolderAppender<'a> {
+    pub highest_uid: Option<Uid>,
+    pub uidnext: Option<Uid>,
+
+    next_msgnum: message::Number,
+    store: FolderStore,
+
+    fpm: &'a FolderPathMapper,
+    folder_name: &'a folder::Name,
+}
+
+impl<'a> FolderAppender<'a> {
+    /// Open a [MessageWriter] to begin writing a a new message in the folder.  Until successful
+    /// [MessageWriter::close], the message is not stored.  Any message file left behind after
+    /// failure to append a message will be overwritten by the next message append.
+    pub fn append(mut self, uid: Uid) -> Result<MessageWriter<'a>> {
+        let msgnum = self.next_msgnum;
+        self.next_msgnum.increment();
+        let path: std::path::PathBuf = self.fpm.msg_path(self.folder_name, msgnum);
+        MessageWriter {
+            msgnum,
+            uid,
+            appender: self,
+            path,
+            file: None,
+        }
+        .open()
+    }
+
+    pub fn close(self) -> FolderStore {
+        self.store
+    }
+
+    pub(super) fn new(
+        store: FolderStore,
+        fpm: &'a FolderPathMapper,
+        folder_name: &'a folder::Name,
+    ) -> Result<Self> {
+        let highest_uid: Option<Uid>;
+        let mut next_msgnum: message::Number;
+        match store.read_transaction()?.highest()? {
+            Some((uid, msgnum)) => {
+                highest_uid = Some(uid);
+                next_msgnum = msgnum;
+                next_msgnum.increment();
+            }
+            None => {
+                highest_uid = None;
+                next_msgnum = message::Number::one();
+            }
+        }
+        let uidnext: Option<Uid> = crate::read_nonzero_u32(&store.uidnext_path)?.map(Uid::from);
+        Ok(Self {
+            highest_uid,
+            fpm,
+            folder_name,
+            next_msgnum,
+            store,
+            uidnext,
+        })
+    }
+}
+
+/// For writing to new messages in a folder.
+/// Start with [FolderAppender::append].
+pub struct MessageWriter<'a> {
+    appender: FolderAppender<'a>,
+    path: std::path::PathBuf,
+    file: Option<std::fs::File>,
+    msgnum: message::Number,
+    uid: Uid,
+}
+
+impl<'a> MessageWriter<'a> {
+    /// Sink bytes into the message file.
+    pub fn write_all(&mut self, bytes: &[u8]) -> Result<()> {
+        self.file
+            .as_ref()
+            .expect("opened")
+            .write_all(bytes)
+            .map_err(|e| Error::describe(e, format_args!("{:?}", &self.path)))
+    }
+
+    /// See [FolderAppender::append].
+    pub fn close(self) -> Result<(message::Number, FolderAppender<'a>)> {
+        self.file
+            .as_ref()
+            .expect("opened")
+            .sync_all()
+            .map_err(|e| Error::describe(e, format_args!("{:?}", &self.path)))?;
+        let mut txn: WriteTransaction = self.appender.store.write_transaction()?;
+        txn.set_uid_msgnum(self.uid, self.msgnum)?;
+        txn.commit()?;
+        Ok((self.msgnum, self.appender))
+    }
+
+    pub fn abort(self) -> Result<()> {
+        // TODO attempt to unlink file, warn but ignore if fail; we'll clobber it later
+        Ok(())
+    }
+
+    fn open(mut self) -> Result<Self> {
+        self.file = Some(
+            std::fs::File::create(&self.path)
+                .map_err(|e| Error::describe(e, format_args!("{:?}", &self.path)))?,
+        );
+        Ok(self)
+    }
+}
diff --git a/imap/store/src/check.rs b/imap/store/src/check.rs
new file mode 100644 (file)
index 0000000..374640f
--- /dev/null
@@ -0,0 +1,62 @@
+use nmh::{folder, message};
+use nmhimaptypes::Uid;
+
+use crate::{ReadOnlyTransaction, ReadTransaction};
+
+/// Check folder integrity.
+/// Spams stderr with report and panics on error.  TODO: stop that
+pub fn check(folder_name: &folder::Name, fpm: crate::FolderPathMapper) -> crate::Result<()> {
+    let mut errors = vec![];
+    let store = crate::FolderStore::do_open(fpm.folder_path(folder_name), fpm.db_path(folder_name))
+        .unwrap();
+    let txn: ReadOnlyTransaction = store.read_transaction()?;
+    for result in txn.message_number_to_uid_cursor()? {
+        let (msgnum, uid): (message::Number, Uid) = result?;
+        // Lookup uid and ensure that results in msgnum.
+        match txn.lookup_message_number(uid)? {
+            None => errors.push(format!("msg {} missing from uid table", msgnum)),
+            Some(msgnum_from_uid) => {
+                if msgnum_from_uid != msgnum {
+                    errors.push(format!(
+                        "msg table has {}={}, uid has {}={}",
+                        msgnum, uid, uid, msgnum_from_uid
+                    ))
+                }
+            }
+        }
+        // Ensure msgnum file exists and is not empty.
+        match std::fs::metadata(fpm.msg_path(folder_name, msgnum)) {
+            Err(e) => match e.kind() {
+                std::io::ErrorKind::NotFound => {
+                    errors.push(format!("message file {} missing", msgnum))
+                }
+                _ => todo!("stat({:?}): {}", msgnum, e),
+            },
+            Ok(st) => {
+                if st.len() == 0 {
+                    errors.push(format!("message file {} empty", msgnum))
+                }
+            }
+        }
+    }
+    for result in txn.uid_to_message_number_cursor()? {
+        let (uid, msgnum): (Uid, message::Number) = result?;
+        // Lookup msgnum and ensure that results in uid.
+        match txn.lookup_uid(msgnum)? {
+            None => errors.push(format!("uid {} missing from msg table", uid)),
+            Some(uid_from_msgnum) => {
+                if uid_from_msgnum != uid {
+                    errors.push(format!(
+                        "uid table has {}={}, msg has {}={}",
+                        uid, msgnum, msgnum, uid_from_msgnum
+                    ))
+                }
+            }
+        }
+    }
+    if errors.len() > 0 {
+        Err(crate::Error::new(errors.join("\n")))
+    } else {
+        Ok(())
+    }
+}
diff --git a/imap/store/src/lib.rs b/imap/store/src/lib.rs
new file mode 100644 (file)
index 0000000..57959af
--- /dev/null
@@ -0,0 +1,335 @@
+//! Storing IMAP mailboxes in nmh folders.
+//!
+//! Start with [MessageNumberView] for a read-only, message-number-only view of an existing folder.
+//! Everything else is in [FolderStore], opening of which requires a server connection in order to
+//! ensure UIDVALIDITY is not violated.
+
+// trait imports
+use std::convert::TryInto as _;
+use std::io::Write as _;
+
+use std::path::{Path, PathBuf};
+
+use nmh::{folder, message, Error};
+use nmhimaptypes::{Uid, UidValidity};
+
+mod append;
+mod check;
+mod mapper;
+mod transaction;
+
+pub use append::{FolderAppender, MessageWriter};
+pub use check::check;
+pub use mapper::FolderPathMapper;
+pub use transaction::{
+    MessageCursor, MessageNumber2UidCursor, MessageNumberCursor, MessageNumberTransaction,
+    ReadOnlyTransaction, ReadTransaction, WriteTransaction,
+};
+
+/// Offers the full capabilities of the folder store, including mapping between local message
+/// numbers and server UIDs.  Because that mapping is unstable, UIDVALIDITY is required to open
+/// this.  If the server's value differs from the local value, the folder cannot be opened.
+/// In cases where only message numbers are needed and a server connection is undesired,
+/// use [MessageNumberView] instead.
+pub struct FolderStore {
+    lmdb_path: PathBuf,
+    folder_path: PathBuf,
+    environment: lmdb::Environment,
+    msg2uid: lmdb::Database,
+    uid2msg: lmdb::Database,
+    uidnext_path: PathBuf,
+}
+
+impl FolderStore {
+    pub fn create(
+        fpm: &FolderPathMapper,
+        folder_name: &folder::Name,
+        uid_next: Uid,
+        uid_validity: UidValidity,
+    ) -> Result<Self> {
+        let folder_path: PathBuf = fpm.folder_path(folder_name);
+        let db_path: PathBuf = fpm.db_path(folder_name);
+        if db_path.exists() {
+            return Err(Error::new(format!("{:?} exists", db_path)));
+        }
+        Self::do_create(folder_path, db_path, uid_next.into(), uid_validity)
+    }
+
+    pub fn open_or_create(
+        fpm: &FolderPathMapper,
+        folder_name: &folder::Name,
+        uidvalidity: UidValidity,
+    ) -> Result<Self> {
+        let db_path: PathBuf = fpm.db_path(folder_name);
+        if db_path.exists() {
+            Self::open(fpm, folder_name, uidvalidity)
+        } else {
+            let folder_path = fpm.folder_path(folder_name);
+            Self::do_create(folder_path, db_path, 0, uidvalidity)
+        }
+    }
+
+    pub fn open(
+        fpm: &FolderPathMapper,
+        folder_name: &folder::Name,
+        uidvalidity: UidValidity,
+    ) -> Result<Self> {
+        let folder_path: PathBuf = fpm.folder_path(folder_name);
+        let db_path: PathBuf = fpm.db_path(folder_name);
+        if read_uidvalidity(&db_path)? != uidvalidity {
+            todo!("UIDVALIDITY changed");
+        }
+        Self::do_open(folder_path, db_path)
+    }
+
+    /// Returns [nmh::Error] when message number or UID are not in the database.
+    /// Caller is expected to have looked it up first.
+    pub fn delete_message(&self, msgnum: message::Number, uid: Uid) -> Result<()> {
+        let msg_bytes: [u8; 4] = msgnum_to_bytes(msgnum);
+        let uid_bytes: [u8; 4] = uid_to_bytes(uid);
+        let mut txn = self.write_transaction()?;
+        txn.txn
+            .del(self.msg2uid, &msg_bytes, None)
+            .map_err(|e| Error::describe(e, format_args!("Transaction::del")))?;
+        txn.txn
+            .del(self.uid2msg, &uid_bytes, None)
+            .map_err(|e| Error::describe(e, format_args!("Transaction::del")))?;
+        txn.commit()?;
+
+        let path: PathBuf = self.folder_path.join(msgnum.to_string());
+        if let Err(e) = std::fs::remove_file(&path) {
+            match e.kind() {
+                std::io::ErrorKind::NotFound => {}
+                _ => return Err(Error::describe(e, format_args!("unlink({:?})", path))),
+            }
+        }
+        Ok(())
+    }
+
+    /// Open a [FolderAppender] for appending messages to the folder.
+    pub fn append<'a>(
+        self,
+        fpm: &'a FolderPathMapper,
+        folder_name: &'a folder::Name,
+    ) -> Result<FolderAppender<'a>> {
+        FolderAppender::new(self, fpm, folder_name)
+    }
+
+    // TODO move self into ReadOnlyTransaction so opening two transactions fails at compilation rather than runtime
+    pub fn read_transaction(&self) -> Result<ReadOnlyTransaction> {
+        Ok(ReadOnlyTransaction {
+            txn: self.environment.begin_ro_txn().map_err(|e| {
+                Error::describe(
+                    e,
+                    format_args!("begin read-only transaction in {:?}", self.lmdb_path),
+                )
+            })?,
+            msg2uid: self.msg2uid,
+            uid2msg: self.uid2msg,
+            lmdb_path: &self.lmdb_path,
+        })
+    }
+
+    pub fn write_transaction(&self) -> Result<WriteTransaction> {
+        Ok(WriteTransaction {
+            txn: self
+                .environment
+                .begin_rw_txn()
+                .map_err(|e| Error::describe(e, format_args!("{:?}", self.lmdb_path)))?,
+            msg2uid: self.msg2uid,
+            uid2msg: self.uid2msg,
+            lmdb_path: &self.lmdb_path,
+        })
+    }
+
+    pub fn write_uidnext(&self, uidnext: Uid) -> Result<()> {
+        write_file(&self.uidnext_path, &uidnext.to_string())
+    }
+
+    fn do_open<P: AsRef<Path>>(folder_path: PathBuf, db_path: P) -> Result<Self> {
+        let lmdb_path = db_path.as_ref().join("db");
+        let environment: lmdb::Environment = open_environment(&lmdb_path)?;
+        let msg2uid: lmdb::Database = open_db(&environment, "m2u")?;
+        let uid2msg: lmdb::Database = open_db(&environment, "u2m")?;
+        Ok(Self {
+            lmdb_path,
+            folder_path,
+            environment,
+            msg2uid,
+            uid2msg,
+            uidnext_path: db_path.as_ref().join("uidnext"),
+        })
+    }
+
+    fn do_create(
+        folder_path: PathBuf,
+        db_path: PathBuf,
+        uid_next: u32,
+        uid_validity: UidValidity,
+    ) -> Result<Self> {
+        let lmdb_path = db_path.join("db");
+        std::fs::create_dir_all(&lmdb_path)
+            .map_err(|e| Error::describe(e, format_args!("{:?}", lmdb_path)))?;
+        std::fs::create_dir_all(&folder_path)
+            .map_err(|e| Error::describe(e, format_args!("{:?}", folder_path)))?;
+
+        let uidnext_path = db_path.join("uidnext");
+        write_file(&uidnext_path, &uid_next.to_string())?;
+        write_uidvalidity(&db_path, uid_validity)?;
+
+        let environment: lmdb::Environment = open_environment(&lmdb_path)?;
+        let msg2uid: lmdb::Database = create_db(&environment, "m2u")?;
+        let uid2msg: lmdb::Database = create_db(&environment, "u2m")?;
+
+        Ok(Self {
+            lmdb_path,
+            folder_path,
+            environment,
+            msg2uid,
+            uid2msg,
+            uidnext_path,
+        })
+    }
+}
+
+/// Delete a folder.
+pub fn delete(fpm: &FolderPathMapper, folder_name: &folder::Name) -> Result<()> {
+    let folder_path: PathBuf = fpm.folder_path(folder_name);
+    let db_path: PathBuf = fpm.db_path(folder_name);
+    {
+        let store = FolderStore::do_open(folder_path.clone(), &db_path)?;
+        let txn = store.read_transaction()?;
+        for result in txn.message_number_to_uid_cursor()? {
+            let msgnum: message::Number = result?.0;
+            let m = store.folder_path.join(Path::new(&msgnum.to_string()));
+            if let Err(error) = std::fs::remove_file(&m) {
+                match error.kind() {
+                    std::io::ErrorKind::NotFound => {}
+                    _ => todo!("{:?}", error),
+                }
+            }
+        }
+    }
+    // TODO ignore ENOENT
+    let seq = fpm.seq_path(folder_name);
+    if let Err(error) = std::fs::remove_file(&seq) {
+        match error.kind() {
+            std::io::ErrorKind::NotFound => {}
+            _ => todo!("{:?}", error),
+        }
+    };
+    // TODO report failure back to rmf which already has messaging for other files in the folder.
+    //  and keep going on ENOENT
+    std::fs::remove_dir(folder_path).unwrap();
+    // Nobody better be in the db path so be relentless.
+    std::fs::remove_dir_all(&db_path).unwrap();
+
+    Ok(())
+}
+
+// TODO Use FolderPathMapper to prevent opening FolderStore and then this?
+/// Offers a subset of [FolderStore] operations allowing access only to local
+/// message numbers.  Useful when a server connection is undesired.
+pub struct MessageNumberView(FolderStore);
+
+impl MessageNumberView {
+    pub fn open(fpm: &FolderPathMapper, folder_name: &folder::Name) -> Result<Self> {
+        let folder_path: PathBuf = fpm.folder_path(folder_name);
+        let db_path: PathBuf = fpm.db_path(folder_name);
+        Ok(Self(FolderStore::do_open(folder_path, db_path)?))
+    }
+
+    pub fn read_transaction(&self) -> Result<MessageNumberTransaction> {
+        Ok(MessageNumberTransaction {
+            txn: self.0.environment.begin_ro_txn().map_err(|e| {
+                Error::describe(
+                    e,
+                    format_args!("begin read-only transaction in {:?}", self.0.lmdb_path),
+                )
+            })?,
+            msg2uid: self.0.msg2uid,
+        })
+    }
+}
+
+type Result<T> = std::result::Result<T, Error>;
+
+fn write_uidvalidity(db_path: &Path, uidvalidity: UidValidity) -> Result<()> {
+    write_file(&db_path.join("uidvalidity"), &uidvalidity.to_string())
+}
+
+fn write_file(path: &Path, contents: &str) -> Result<()> {
+    let mut file = std::fs::File::create(&path)
+        .map_err(|e| Error::describe(e, format_args!("{}", path.display())))?;
+    file.write_all(contents.as_bytes())
+        .map_err(|e| Error::describe(e, format_args!("{}", path.display())))?;
+    file.sync_all()
+        .map_err(|e| Error::describe(e, format_args!("{}", path.display())))
+}
+
+fn read_uidvalidity(db_path: &Path) -> Result<UidValidity> {
+    read_nonzero_u32(&db_path.join("uidvalidity"))?
+        .ok_or_else(|| {
+            Error::new(format!(
+                "{} has illegal UIDVALIDITY zero",
+                db_path.display()
+            ))
+        })
+        .map(UidValidity::from)
+}
+
+fn read_nonzero_u32(path: &Path) -> Result<Option<std::num::NonZeroU32>> {
+    Ok(std::num::NonZeroU32::new(
+        std::fs::read_to_string(&path)
+            .map_err(|e| Error::describe(e, format_args!("{}", path.display())))?
+            .parse::<u32>()
+            .map_err(|e| Error::describe(e, format_args!("{}", path.display())))?,
+    ))
+}
+
+fn open_environment(path: &Path) -> Result<lmdb::Environment> {
+    lmdb::Environment::new()
+        .set_max_dbs(2)
+        .set_map_size(1073741824) // TODO
+        .open_with_permissions(path, 0o600)
+        .map_err(|e| Error::describe(e, format_args!("{}", path.display())))
+}
+
+fn open_db(environment: &lmdb::Environment, name: &str) -> Result<lmdb::Database> {
+    environment
+        .open_db(Some(name))
+        .map_err(|e| Error::describe(e, format_args!("{}", name)))
+}
+
+fn create_db(environment: &lmdb::Environment, name: &str) -> Result<lmdb::Database> {
+    environment
+        .create_db(Some(name), lmdb::DatabaseFlags::INTEGER_KEY)
+        .map_err(|e| Error::describe(e, format_args!("{}", name)))
+}
+
+fn msgnum_to_bytes(msgnum: message::Number) -> [u8; 4] {
+    i32::from(msgnum).to_ne_bytes()
+}
+
+fn uid_to_bytes(uid: Uid) -> [u8; 4] {
+    u32::from(uid).to_ne_bytes()
+}
+
+fn bytes_to_uid(uid_bytes: &[u8]) -> Uid {
+    std::num::NonZeroU32::new(u32::from_ne_bytes(uid_bytes.try_into().expect("4 bytes")))
+        .expect("UID > 0")
+        .into()
+}
+
+fn bytes_to_msgnum(msg_bytes: &[u8]) -> message::Number {
+    std::num::NonZeroI32::new(bytes_to_i32(msg_bytes))
+        .expect("message number > 0")
+        .into()
+}
+
+fn bytes_to_i32(msg_bytes: &[u8]) -> i32 {
+    i32::from_ne_bytes(msg_bytes.try_into().expect("4 bytes"))
+}
+
+#[cfg(test)]
+mod tests;
diff --git a/imap/store/src/mapper.rs b/imap/store/src/mapper.rs
new file mode 100644 (file)
index 0000000..9601ca4
--- /dev/null
@@ -0,0 +1,36 @@
+use nmh::{folder, message};
+
+#[derive(Debug)]
+pub struct FolderPathMapper {
+    // E.g. ~/Mail
+    mail_path: std::path::PathBuf,
+    // E.g. ~/Mail/managed/imap
+    //folder_path: std::path::PathBuf,
+    // E.g. ~/.mhimapdata
+    db_path: std::path::PathBuf,
+}
+
+impl FolderPathMapper {
+    pub fn new(mail_path: std::path::PathBuf, db_path: std::path::PathBuf) -> Self {
+        Self { mail_path, db_path }
+    }
+
+    pub fn seq_path(&self, folder: &folder::Name) -> std::path::PathBuf {
+        // TODO .mh_sequences name is configurable
+        self.mail_path.join(folder.as_path()).join(".mh_sequences")
+    }
+
+    pub fn msg_path(&self, folder: &folder::Name, msgnum: message::Number) -> std::path::PathBuf {
+        self.mail_path
+            .join(folder.as_path())
+            .join(msgnum.to_string())
+    }
+
+    pub(super) fn folder_path(&self, folder: &folder::Name) -> std::path::PathBuf {
+        self.mail_path.join(folder.as_path())
+    }
+
+    pub(super) fn db_path(&self, folder: &folder::Name) -> std::path::PathBuf {
+        self.db_path.join(folder.as_path())
+    }
+}
diff --git a/imap/store/src/tests.rs b/imap/store/src/tests.rs
new file mode 100644 (file)
index 0000000..5eb3f34
--- /dev/null
@@ -0,0 +1,94 @@
+use nmh::message;
+use nmhimaptypes::Uid;
+
+use super::*;
+
+#[test]
+fn msgnum_to_bytes_1() {
+    assert_eq!(ONE_BYTES, msgnum_to_bytes(message::Number::one()));
+}
+
+#[test]
+fn uid_to_bytes_1() {
+    assert_eq!(ONE_BYTES, uid_to_bytes(uid()));
+}
+
+mod bytes_to {
+    use super::*;
+
+    mod uid {
+        use super::*;
+
+        #[test]
+        fn one() {
+            assert_eq!(uid(), bytes_to_uid(&ONE_BYTES));
+        }
+
+        #[test]
+        #[should_panic(expected = "4 bytes: TryFromSliceError(())")]
+        fn zero_bytes() {
+            bytes_to_uid(&[]);
+        }
+
+        #[test]
+        #[should_panic(expected = "4 bytes: TryFromSliceError(())")]
+        fn too_few_bytes() {
+            bytes_to_uid(&[1]);
+        }
+
+        #[test]
+        #[should_panic(expected = "4 bytes: TryFromSliceError(())")]
+        fn too_many_bytes() {
+            bytes_to_uid(&[1, 0, 0, 0, 0]);
+        }
+
+        #[test]
+        #[should_panic(expected = "UID > 0")]
+        fn zero() {
+            bytes_to_uid(&[0, 0, 0, 0]);
+        }
+    }
+
+    mod msgnum {
+        use super::*;
+
+        #[test]
+        fn one() {
+            assert_eq!(message::Number::one(), bytes_to_msgnum(&ONE_BYTES));
+        }
+
+        #[test]
+        #[should_panic(expected = "4 bytes: TryFromSliceError(())")]
+        fn zero_bytes() {
+            bytes_to_msgnum(&[]);
+        }
+
+        #[test]
+        #[should_panic(expected = "4 bytes: TryFromSliceError(())")]
+        fn too_few_bytes() {
+            bytes_to_msgnum(&[1]);
+        }
+
+        #[test]
+        #[should_panic(expected = "4 bytes: TryFromSliceError(())")]
+        fn too_many_bytes() {
+            bytes_to_msgnum(&[1, 0, 0, 0, 0]);
+        }
+
+        #[test]
+        #[should_panic(expected = "message number > 0")]
+        fn zero() {
+            bytes_to_msgnum(&[0, 0, 0, 0]);
+        }
+    }
+}
+
+#[cfg(target_endian = "little")]
+const ONE_BYTES: [u8; 4] = [1, 0, 0, 0];
+// XXX UNTESTED
+#[cfg(target_endian = "big")]
+const ONE_BYTES: [u8; 4] = [0, 0, 0, 1];
+
+fn uid() -> Uid {
+    Uid::from(std::num::NonZeroU32::new(1).unwrap())
+}
diff --git a/imap/store/src/transaction.rs b/imap/store/src/transaction.rs
new file mode 100644 (file)
index 0000000..898549d
--- /dev/null
@@ -0,0 +1,290 @@
+use std::num::NonZeroI32;
+
+// trait imports
+use lmdb::Cursor as _;
+use lmdb::Transaction as _;
+
+use nmh::message;
+use nmhimaptypes::Uid;
+
+use crate::{
+    bytes_to_i32, bytes_to_msgnum, bytes_to_uid, msgnum_to_bytes, uid_to_bytes, Error, Result,
+};
+
+mod cursor;
+pub use cursor::{
+    MessageCursor, MessageNumber2UidCursor, MessageNumberCursor, Uid2MessageNumberCursor,
+};
+
+pub struct MessageNumberTransaction<'a> {
+    pub(super) txn: lmdb::RoTransaction<'a>,
+    pub(super) msg2uid: lmdb::Database,
+}
+
+impl<'a> MessageNumberTransaction<'a> {
+    pub fn cursor(&self) -> Result<MessageNumberCursor> {
+        Ok(MessageNumberCursor::new(
+            self.txn
+                .open_ro_cursor(self.msg2uid)
+                .map_err(|e| Error::describe(e, format_args!("open cursor in msg2uid database")))?,
+        ))
+    }
+}
+
+/// Read operations available to perform on read or write transactions.
+pub trait ReadTransaction<'a, T: lmdb::Transaction> {
+    // TODO copy_message only wants highest msgnum; do we need a variant?
+    /// Return the highest [Uid] and [message::Number] in the folder.  These probably do not correspond to each other.
+    fn highest(&self) -> Result<Option<(Uid, message::Number)>>;
+
+    fn lookup_message_number(&self, uid: Uid) -> Result<Option<message::Number>>;
+    fn lookup_uid(&self, msgnum: message::Number) -> Result<Option<Uid>>;
+    fn message_number_to_uid_cursor(&self) -> Result<MessageNumber2UidCursor>;
+    fn uid_to_message_number_cursor(&self) -> Result<Uid2MessageNumberCursor>;
+}
+
+pub struct ReadOnlyTransaction<'a> {
+    pub(super) lmdb_path: &'a std::path::Path,
+    pub(super) msg2uid: lmdb::Database,
+    pub(super) uid2msg: lmdb::Database,
+    pub(super) txn: lmdb::RoTransaction<'a>,
+}
+
+impl<'a> ReadTransaction<'a, lmdb::RoTransaction<'a>> for ReadOnlyTransaction<'a> {
+    fn highest(&self) -> Result<Option<(Uid, message::Number)>> {
+        highest(&self.txn, self.msg2uid, self.uid2msg, self.lmdb_path)
+    }
+
+    fn lookup_message_number(&self, uid: Uid) -> Result<Option<message::Number>> {
+        lookup_message_number(&self.txn, self.uid2msg, self.lmdb_path, uid)
+    }
+
+    fn lookup_uid(&self, msgnum: message::Number) -> Result<Option<Uid>> {
+        lookup_uid(&self.txn, self.msg2uid, self.lmdb_path, msgnum)
+    }
+
+    fn message_number_to_uid_cursor(&self) -> Result<MessageNumber2UidCursor> {
+        message_number_to_uid_cursor(&self.txn, self.msg2uid)
+    }
+
+    fn uid_to_message_number_cursor(&self) -> Result<Uid2MessageNumberCursor> {
+        uid_to_message_number_cursor(&self.txn, self.uid2msg)
+    }
+}
+
+pub struct WriteTransaction<'a> {
+    pub(super) lmdb_path: &'a std::path::Path,
+    pub(super) msg2uid: lmdb::Database,
+    pub(super) uid2msg: lmdb::Database,
+    pub(super) txn: lmdb::RwTransaction<'a>,
+}
+
+impl<'a> WriteTransaction<'a> {
+    pub fn set_uid_msgnum(&mut self, uid: Uid, msgnum: message::Number) -> Result<()> {
+        let uid_bytes: [u8; 4] = uid_to_bytes(uid);
+        let msg_bytes: [u8; 4] = msgnum_to_bytes(msgnum);
+        self.txn
+            .put(
+                self.uid2msg,
+                &uid_bytes,
+                &msg_bytes,
+                lmdb::WriteFlags::NO_OVERWRITE,
+            )
+            .map_err(|e| {
+                Error::describe(
+                    e,
+                    format_args!("set {}={} in uid2msg database", uid, msgnum),
+                )
+            })?;
+        self.txn
+            .put(
+                self.msg2uid,
+                &msg_bytes,
+                &uid_bytes,
+                lmdb::WriteFlags::NO_OVERWRITE,
+            )
+            .map_err(|e| Error::describe(e, format_args!("put in msg2uid database")))
+    }
+
+    pub fn store_message(&mut self, uid: Uid) -> Result<message::Number> {
+        let one_past_highest_msgnum_in_folder = message::Number::from(
+            NonZeroI32::new(1 + highest_msgnum_or_zero(&self.txn, self.msg2uid)?)
+                .expect("1 + anything to be non-zero"),
+        );
+
+        self.set_uid_msgnum(uid, one_past_highest_msgnum_in_folder)?;
+
+        Ok(one_past_highest_msgnum_in_folder)
+    }
+
+    /// Lookup old UID of `msgnum` and delete it, storing a new relation between `new_uid` and `msgnum`.
+    /// Return old UID.
+    pub fn replace_uid_msgnum(&mut self, new_uid: Uid, msgnum: message::Number) -> Result<Uid> {
+        // Lookup old UID.
+        let msg_bytes: [u8; 4] = msgnum_to_bytes(msgnum);
+        let old_uid_bytes: Vec<u8> = self
+            .txn
+            .get(self.msg2uid, &msg_bytes)
+            .map_err(|e| Error::describe(e, format_args!("{:?}", self.lmdb_path)))?
+            .to_owned();
+        let old_uid: Uid = bytes_to_uid(&old_uid_bytes);
+
+        // Delete old UID / msgnum.
+        self.txn
+            .del(self.uid2msg, &old_uid_bytes, None)
+            .map_err(|e| Error::describe(e, format_args!("{:?}", self.lmdb_path)))?;
+        self.txn
+            .del(self.msg2uid, &msg_bytes, None)
+            .map_err(|e| Error::describe(e, format_args!("{:?}", self.lmdb_path)))?;
+
+        // Put new UID / msgnum.
+        self.set_uid_msgnum(new_uid, msgnum)?;
+
+        Ok(old_uid)
+    }
+
+    pub fn commit(self) -> Result<()> {
+        let path = self.lmdb_path;
+        self.txn
+            .commit()
+            .map_err(|e| Error::describe(e, format_args!("commit {:?}", path)))?;
+        Ok(())
+    }
+}
+
+impl<'a> ReadTransaction<'a, lmdb::RwTransaction<'a>> for WriteTransaction<'a> {
+    fn highest(&self) -> Result<Option<(Uid, message::Number)>> {
+        highest(&self.txn, self.msg2uid, self.uid2msg, self.lmdb_path)
+    }
+
+    fn lookup_message_number(&self, uid: Uid) -> Result<Option<message::Number>> {
+        lookup_message_number(&self.txn, self.uid2msg, self.lmdb_path, uid)
+    }
+
+    fn lookup_uid(&self, msgnum: message::Number) -> Result<Option<Uid>> {
+        lookup_uid(&self.txn, self.msg2uid, self.lmdb_path, msgnum)
+    }
+
+    fn message_number_to_uid_cursor(&self) -> Result<MessageNumber2UidCursor> {
+        message_number_to_uid_cursor(&self.txn, self.msg2uid)
+    }
+
+    fn uid_to_message_number_cursor(&self) -> Result<Uid2MessageNumberCursor> {
+        uid_to_message_number_cursor(&self.txn, self.uid2msg)
+    }
+}
+
+fn highest_msgnum_or_zero<T: lmdb::Transaction>(txn: &T, msg2uid: lmdb::Database) -> Result<i32> {
+    let msg2uid_cursor: lmdb::RoCursor = txn
+        .open_ro_cursor(msg2uid)
+        .map_err(|e| Error::describe(e, format_args!("open cursor in msg2uid database")))?;
+    Ok(match msg2uid_cursor.get(None, None, lmdb_sys::MDB_LAST) {
+        Err(error) => {
+            if error != lmdb::Error::NotFound {
+                todo!("cursor.get returned Err {}", error)
+            }
+            0
+        }
+        Ok((opt_key, _)) => match opt_key {
+            None => todo!("key not returned for MDB_LAST"),
+            Some(key) => bytes_to_i32(key),
+        },
+    })
+}
+
+fn highest<T: lmdb::Transaction>(
+    txn: &T,
+    msg2uid: lmdb::Database,
+    uid2msg: lmdb::Database,
+    db_path: &std::path::Path,
+) -> Result<Option<(Uid, message::Number)>> {
+    let msgnum = match highest_msgnum_or_zero(txn, msg2uid)? {
+        0 => return Ok(None),
+        n => message::Number::from(
+            NonZeroI32::new(n).ok_or_else(|| Error::new("illegal message number zero"))?,
+        ),
+    };
+    // TODO basically the same as highest_msgnum_or_zero; factor out
+    let uid: Uid = {
+        let uid2msg_cursor: lmdb::RoCursor = txn
+            .open_ro_cursor(uid2msg)
+            .map_err(|e| Error::describe(e, format_args!("{:?}", db_path)))?;
+        match uid2msg_cursor.get(None, None, lmdb_sys::MDB_LAST) {
+            Ok((opt_key, _value)) => match opt_key {
+                Some(key) => bytes_to_uid(key),
+                None => todo!("key not returned for MDB_LAST"),
+            },
+            Err(error) => {
+                if error != lmdb::Error::NotFound {
+                    todo!("cursor.get returned Err {}", error)
+                }
+                todo!("db corrupt:  found msgnum but not uid")
+            }
+        }
+    };
+    Ok(Some((uid, msgnum)))
+}
+
+fn lookup_message_number<T: lmdb::Transaction>(
+    txn: &T,
+    db: lmdb::Database,
+    db_path: &std::path::Path,
+    uid: Uid,
+) -> Result<Option<message::Number>> {
+    match txn.get(db, &uid_to_bytes(uid)) {
+        Err(e) => {
+            if e != lmdb::Error::NotFound {
+                Err(Error::describe(
+                    e,
+                    format_args!("{} in {}", uid, db_path.display()),
+                ))
+            } else {
+                Ok(None)
+            }
+        }
+        Ok(value) => Ok(Some(bytes_to_msgnum(value))),
+    }
+}
+
+fn lookup_uid<T: lmdb::Transaction>(
+    txn: &T,
+    db: lmdb::Database,
+    db_path: &std::path::Path,
+    msgnum: message::Number,
+) -> Result<Option<Uid>> {
+    match txn.get(db, &msgnum_to_bytes(msgnum)) {
+        Err(e) => {
+            if e != lmdb::Error::NotFound {
+                Err(Error::describe(
+                    e,
+                    format_args!("{} in {}", msgnum, db_path.display()),
+                ))
+            } else {
+                Ok(None)
+            }
+        }
+        Ok(value) => Ok(Some(bytes_to_uid(value))),
+    }
+}
+
+fn message_number_to_uid_cursor<T: lmdb::Transaction>(
+    txn: &T,
+    db: lmdb::Database,
+) -> Result<MessageNumber2UidCursor> {
+    // XXX lmdb-rkv bug: The iterator cannot outlive the txn but that is not expressed in the types.
+    Ok(MessageNumber2UidCursor::new(
+        txn.open_ro_cursor(db)
+            .map_err(|e| Error::describe(e, format_args!("Transaction::open_ro_cursor")))?,
+    ))
+}
+
+fn uid_to_message_number_cursor<T: lmdb::Transaction>(
+    txn: &T,
+    db: lmdb::Database,
+) -> Result<Uid2MessageNumberCursor> {
+    // XXX lmdb-rkv bug: The iterator cannot outlive the txn but that is not expressed in the types.
+    Ok(Uid2MessageNumberCursor::new(
+        txn.open_ro_cursor(db)
+            .map_err(|e| Error::describe(e, format_args!("Transaction::open_ro_cursor")))?,
+    ))
+}
diff --git a/imap/store/src/transaction/cursor.rs b/imap/store/src/transaction/cursor.rs
new file mode 100644 (file)
index 0000000..b79b175
--- /dev/null
@@ -0,0 +1,131 @@
+use lmdb::Cursor;
+use lmdb_sys::{MDB_NEXT, MDB_SET_RANGE};
+
+use nmh::message;
+use nmhimaptypes::Uid;
+
+use crate::{bytes_to_msgnum, bytes_to_uid, msgnum_to_bytes, Result};
+
+/// Cursor over [message::Number] and something else (unit or [Uid]).  Use to write generic
+/// functions which may operate on [crate::FolderStore] or [crate::MessageNumberView].
+pub trait MessageCursor<T> {
+    /// Return the lowest message number in the database greater than or equal to msgnum if any, or
+    /// None if no more remain.
+    /// Set the position for subsequent next calls.
+    fn get(&self, msgnum: message::Number) -> Result<Option<(message::Number, T)>>;
+
+    // TODO Is this really carrying its own weight?  Why not just use our Iterator::next?
+    /// Return the next lowest message number greater than that returned by the previous next or get
+    /// call, or None if no more remain.
+    fn next(&self) -> Result<Option<(message::Number, T)>>;
+}
+
+/// This [MessageCursor] provides access to message numbers and their corresponding UIDs.  This can
+/// be used as an [Iterator], even after calling [MessageCursor::get].
+pub struct MessageNumber2UidCursor<'a>(lmdb::RoCursor<'a>);
+
+impl<'a> MessageNumber2UidCursor<'a> {
+    pub fn new(cursor: lmdb::RoCursor<'a>) -> Self {
+        Self(cursor)
+    }
+}
+
+type Num2Uid = (message::Number, Uid);
+impl<'a> MessageCursor<Uid> for MessageNumber2UidCursor<'a> {
+    fn get(&self, msgnum: message::Number) -> Result<Option<Num2Uid>> {
+        process_msg2uid_cursor(
+            self.0
+                .get(Some(&msgnum_to_bytes(msgnum)), None, MDB_SET_RANGE),
+        )
+    }
+
+    fn next(&self) -> Result<Option<Num2Uid>> {
+        process_msg2uid_cursor(self.0.get(None, None, MDB_NEXT))
+    }
+}
+
+impl Iterator for MessageNumber2UidCursor<'_> {
+    type Item = Result<(message::Number, Uid)>;
+    fn next(&mut self) -> Option<Self::Item> {
+        to_iter(process_msg2uid_cursor(self.0.get(None, None, MDB_NEXT)))
+    }
+}
+
+/// Provides access to UIDs and their corresponding message numbers.  Its purpose is to be an [Iterator].
+pub struct Uid2MessageNumberCursor<'a>(lmdb::RoCursor<'a>);
+
+impl<'a> Uid2MessageNumberCursor<'a> {
+    pub fn new(cursor: lmdb::RoCursor<'a>) -> Self {
+        Self(cursor)
+    }
+}
+
+impl Iterator for Uid2MessageNumberCursor<'_> {
+    type Item = Result<(Uid, message::Number)>;
+    fn next(&mut self) -> Option<Self::Item> {
+        to_iter(process(
+            self.0.get(None, None, MDB_NEXT),
+            bytes_to_uid,
+            bytes_to_msgnum,
+        ))
+    }
+}
+
+/// This [MessageCursor] only provides access to message numbers.  This can be used as an
+/// [Iterator], even after calling [MessageCursor::get].
+pub struct MessageNumberCursor<'a>(lmdb::RoCursor<'a>);
+
+impl<'a> MessageNumberCursor<'a> {
+    pub fn new(cursor: lmdb::RoCursor<'a>) -> Self {
+        Self(cursor)
+    }
+}
+
+type OnlyNumber = (message::Number, ());
+impl<'a> MessageCursor<()> for MessageNumberCursor<'a> {
+    fn get(&self, msgnum: message::Number) -> Result<Option<OnlyNumber>> {
+        process_msgnum_cursor_get(
+            self.0
+                .get(Some(&msgnum_to_bytes(msgnum)), None, MDB_SET_RANGE),
+        )
+    }
+
+    fn next(&self) -> Result<Option<OnlyNumber>> {
+        process_msgnum_cursor_get(self.0.get(None, None, MDB_NEXT))
+    }
+}
+
+fn process_msg2uid_cursor(result: lmdb::Result<(Option<&[u8]>, &[u8])>) -> Result<Option<Num2Uid>> {
+    process(result, bytes_to_msgnum, bytes_to_uid)
+}
+
+fn process_msgnum_cursor_get(
+    result: lmdb::Result<(Option<&[u8]>, &[u8])>,
+) -> Result<Option<OnlyNumber>> {
+    process(result, bytes_to_msgnum, |_| ())
+}
+
+fn process<K, V, KF: Fn(&[u8]) -> K, VF: Fn(&[u8]) -> V>(
+    result: lmdb::Result<(Option<&[u8]>, &[u8])>,
+    convert_key: KF,
+    convert_value: VF,
+) -> Result<Option<(K, V)>> {
+    Ok(match result {
+        Err(error) => {
+            if error != lmdb::Error::NotFound {
+                todo!("cursor.get returned Err {}", error)
+            }
+            None
+        }
+        Ok((Some(key), value)) => Some((convert_key(key), convert_value(value))),
+        unexpected => todo!("shouldn't happen {:?}", unexpected),
+    })
+}
+
+fn to_iter<T>(result: Result<Option<T>>) -> Option<Result<T>> {
+    match result {
+        Err(e) => Some(Err(e)),
+        Ok(None) => None,
+        Ok(Some(v)) => Some(Ok(v)),
+    }
+}
diff --git a/imap/types/Cargo.toml b/imap/types/Cargo.toml
new file mode 100644 (file)
index 0000000..6c65ab3
--- /dev/null
@@ -0,0 +1,9 @@
+[package]
+name = "nmhimaptypes"
+# TODO derive from nmh version
+version = "0.0.0"
+edition = "2018"
+
+[dependencies]
+# nmh
+nmh = { path = "../../sbr/rust" }
diff --git a/imap/types/src/lib.rs b/imap/types/src/lib.rs
new file mode 100644 (file)
index 0000000..d4904c0
--- /dev/null
@@ -0,0 +1,37 @@
+pub mod uid;
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
+pub struct Uid(std::num::NonZeroU32);
+
+impl std::fmt::Display for Uid {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        self.0.fmt(f)
+    }
+}
+
+impl From<std::num::NonZeroU32> for Uid {
+    fn from(n: std::num::NonZeroU32) -> Self {
+        Self(n)
+    }
+}
+
+impl From<Uid> for u32 {
+    fn from(uid: Uid) -> Self {
+        uid.0.get()
+    }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct UidValidity(std::num::NonZeroU32);
+
+impl std::fmt::Display for UidValidity {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        self.0.fmt(f)
+    }
+}
+
+impl From<std::num::NonZeroU32> for UidValidity {
+    fn from(n: std::num::NonZeroU32) -> Self {
+        Self(n)
+    }
+}
diff --git a/imap/types/src/uid.rs b/imap/types/src/uid.rs
new file mode 100644 (file)
index 0000000..1f8f1ce
--- /dev/null
@@ -0,0 +1,25 @@
+#[derive(Clone, Debug)]
+pub struct Set(std::collections::BTreeSet<u32>);
+
+// TODO dedup with nmh::message::Set?  Better just to wrap sbr structures.
+impl Set {
+    pub fn new() -> Self {
+        Self(std::collections::BTreeSet::new())
+    }
+
+    pub fn append(&mut self, mut other: Self) {
+        self.0.append(&mut other.0)
+    }
+
+    pub fn insert(&mut self, msgnum: u32) -> bool {
+        self.0.insert(msgnum)
+    }
+
+    pub fn contains(&self, msgnum: u32) -> bool {
+        self.0.contains(&msgnum)
+    }
+
+    pub fn iter(&self) -> std::collections::btree_set::Iter<u32> {
+        self.0.iter()
+    }
+}
diff --git a/imap/types/tests/tests.rs b/imap/types/tests/tests.rs
new file mode 100644 (file)
index 0000000..1ed58e6
--- /dev/null
@@ -0,0 +1 @@
+mod uid;
diff --git a/imap/types/tests/uid/mod.rs b/imap/types/tests/uid/mod.rs
new file mode 100644 (file)
index 0000000..7eb9f32
--- /dev/null
@@ -0,0 +1,19 @@
+mod uid {
+    #[test]
+    fn option_no_overhead() {
+        assert_eq!(
+            std::mem::size_of::<u32>(),
+            std::mem::size_of::<Option<nmhimaptypes::Uid>>()
+        );
+    }
+}
+
+mod uid_validity {
+    #[test]
+    fn option_no_overhead() {
+        assert_eq!(
+            std::mem::size_of::<u32>(),
+            std::mem::size_of::<Option<nmhimaptypes::UidValidity>>()
+        );
+    }
+}
diff --git a/managedfolders/Cargo.toml b/managedfolders/Cargo.toml
new file mode 100644 (file)
index 0000000..2a50f72
--- /dev/null
@@ -0,0 +1,10 @@
+[package]
+name = "managed-folders"
+# TODO derive from nmh version
+version = "0.0.0"
+edition = "2018"
+
+[dependencies]
+log = "0.4"
+# nmh
+nmh = { path = "../sbr/rust" }
diff --git a/managedfolders/src/command.rs b/managedfolders/src/command.rs
new file mode 100644 (file)
index 0000000..7150ddb
--- /dev/null
@@ -0,0 +1,159 @@
+use nmh::{folder, message};
+
+use crate::{Error, Message};
+
+#[derive(Debug)]
+pub struct UpdateDraft {
+    pub folder: folder::Name,
+    pub msgnum: message::Number,
+}
+
+impl UpdateDraft {
+    pub fn new(message: &Message) -> Result<Self, Error> {
+        Ok(Self {
+            folder: get_as_folder_name(message, "folder")?,
+            msgnum: message
+                .get("msg")
+                .ok_or_else(|| Error::new("msg component missing from request"))?
+                .parse()?,
+        })
+    }
+}
+
+#[derive(Debug)]
+pub struct Folder {
+    pub folder: folder::Name,
+}
+
+impl Folder {
+    pub fn new(message: &Message) -> Result<Self, Error> {
+        Ok(Self {
+            folder: get_as_folder_name(message, "folder")?,
+        })
+    }
+}
+
+#[derive(Debug)]
+pub struct Pick<'a> {
+    pub folder: folder::Name,
+    pub msgs: Vec<&'a str>,
+    pub need_unseen: bool,
+}
+
+impl<'a> Pick<'a> {
+    pub fn new(message: &'a Message<'a>) -> Result<Self, Error> {
+        Ok(Self {
+            folder: get_as_folder_name(message, "folder")?,
+            msgs: match message.get("msgs") {
+                None => todo!("no msgs component"),
+                Some(value) => non_empty_str_vec(value.trim()),
+            },
+            need_unseen: match message.get("needunseen") {
+                None => todo!("no needunseen component"),
+                Some(value) => match value {
+                    "0" => false,
+                    "1" => true,
+                    _ => todo!("needunseen must be 0 or 1"),
+                },
+            },
+        })
+    }
+}
+
+#[derive(Debug)]
+pub struct RefileFile {
+    pub file_path: std::path::PathBuf,
+    pub dst_folder: folder::Name,
+}
+
+impl RefileFile {
+    pub fn new(message: &Message) -> Result<Self, Error> {
+        Ok(Self {
+            file_path: match message.get("file") {
+                None => todo!("no file component"),
+                Some(value) => std::path::Path::new(value).to_path_buf(),
+            },
+            dst_folder: get_as_folder_name(message, "dest")?,
+        })
+    }
+}
+
+#[derive(Debug)]
+pub struct Mark<'a> {
+    pub folder: folder::Name,
+    pub msgs: Vec<&'a str>,
+    pub sequences: Vec<&'a str>,
+}
+
+impl<'a> Mark<'a> {
+    pub fn new(message: &'a Message<'a>) -> Result<Self, Error> {
+        Ok(Self {
+            folder: get_as_folder_name(message, "folder")?,
+            msgs: match message.get("msgs") {
+                None => todo!("no msgs component"),
+                Some(value) => non_empty_str_vec(value.trim()),
+            },
+            sequences: match message.get("seqs") {
+                None => todo!("no seqs component"),
+                Some(value) => non_empty_str_vec(value.trim()),
+            },
+        })
+    }
+}
+
+#[derive(Debug)]
+pub struct RefileMove<'a> {
+    pub src_folder: folder::Name,
+    pub msgs: Vec<&'a str>,
+    pub dst_folder: folder::Name,
+}
+
+impl<'a> RefileMove<'a> {
+    pub fn new(message: &'a Message<'a>) -> Result<Self, Error> {
+        Ok(Self {
+            src_folder: get_as_folder_name(message, "folder")?,
+            msgs: match message.get("msgs") {
+                None => todo!("no msgs component"),
+                Some(value) => non_empty_str_vec(value.trim()),
+            },
+            dst_folder: get_as_folder_name(message, "dest")?,
+        })
+    }
+}
+
+#[derive(Debug)]
+pub struct Rmm<'a> {
+    pub folder: folder::Name,
+    pub msgs: Vec<&'a str>,
+}
+
+impl<'a> Rmm<'a> {
+    pub fn new(message: &'a Message<'a>) -> Result<Self, Error> {
+        Ok(Self {
+            folder: get_as_folder_name(message, "folder")?,
+            msgs: match message.get("msgs") {
+                None => todo!("no msgs component"),
+                Some(value) => non_empty_str_vec(value.trim()),
+            },
+        })
+    }
+}
+
+fn get_as_folder_name(message: &Message, name: &str) -> Result<folder::Name, Error> {
+    folder::Name::from_utf8(
+        std::path::Path::new(
+            message
+                .get(name)
+                .ok_or_else(|| Error::new(format!("{} component missing from request", name)))?,
+        )
+        .to_path_buf(),
+    )
+}
+
+fn non_empty_str_vec(s: &str) -> Vec<&str> {
+    let result: Vec<&str> = nmh::hacks::split(s).collect();
+    if result.is_empty() {
+        todo!("missing required strs")
+    }
+    result
+}
diff --git a/managedfolders/src/components.rs b/managedfolders/src/components.rs
new file mode 100644 (file)
index 0000000..de482ad
--- /dev/null
@@ -0,0 +1,36 @@
+// TODO this poor man's component parser is used two ways:
+//  1. managed_folders::protocol::read::read_message
+//      instead use mh_folder_manager_protocol_read_message
+//  2. imap-folder-manager reading .mh_sequences
+//      instead use seq_read (but need to stop it from readdir(3) first)
+//  Or just use m_getfld2 directly.
+#[derive(Debug, PartialEq, Eq)]
+pub struct Components<'a> {
+    // TODO this is super lazy because i currently have single-value fields and all multiple-value
+    //  fields are ORDERED but this is currently bug-prone.
+    //  We don't want BTreeMap; want to preserve insertion order.
+    map: std::collections::HashMap<&'a str, &'a str>,
+    vec: Vec<(&'a str, &'a str)>,
+}
+
+impl<'a> Components<'a> {
+    pub fn new() -> Self {
+        Self {
+            map: std::collections::HashMap::new(),
+            vec: Vec::new(),
+        }
+    }
+
+    pub fn push(&mut self, component: (&'a str, &'a str)) {
+        self.map.insert(component.0, component.1);
+        self.vec.push(component)
+    }
+
+    pub fn get(&self, name: &str) -> Option<&str> {
+        self.map.get(name).copied()
+    }
+
+    pub fn iter(&self) -> std::slice::Iter<(&str, &str)> {
+        self.vec.iter()
+    }
+}
diff --git a/managedfolders/src/lib.rs b/managedfolders/src/lib.rs
new file mode 100644 (file)
index 0000000..b02e073
--- /dev/null
@@ -0,0 +1,34 @@
+use nmh::Error;
+
+pub mod command;
+
+mod components;
+mod protocol;
+
+pub use components::Components;
+pub use protocol::message::Message;
+pub use protocol::{close_with_error, Command, EmptyResponse, FolderManagerProtocol, IncResponse};
+
+// see TODO on Components
+/// Primitive m_getfld2 replacement.  Doesn't even support line folding or missing final newline.
+pub fn parse_components(message: &str) -> Result<Components> {
+    let mut result = Components::new();
+    let mut i = 0;
+    while i < message.len() {
+        let name_end = i + match message[i..].find(':') {
+            None => todo!("error missing colon in component name starting at {}", i),
+            Some(index) => index,
+        };
+        let value_end = name_end
+            + 1
+            + match &message[name_end + 1..].find('\n') {
+                None => todo!("error missing newline in {}", &message[i..]),
+                Some(index) => index,
+            };
+        result.push((&message[i..name_end], &message[name_end + 1..value_end]));
+        i = value_end + 1;
+    }
+    Ok(result)
+}
+
+type Result<T> = std::result::Result<T, Error>;
diff --git a/managedfolders/src/protocol.rs b/managedfolders/src/protocol.rs
new file mode 100644 (file)
index 0000000..2f8e8cb
--- /dev/null
@@ -0,0 +1,249 @@
+use std::io::Write;
+use std::os::unix::net::UnixStream;
+
+use nmh::folder;
+
+use crate::{command, Message, Result};
+
+pub mod message;
+mod read;
+mod write;
+
+#[derive(Debug)]
+pub struct FolderManagerProtocol {
+    stream: UnixStream,
+}
+
+impl FolderManagerProtocol {
+    pub fn new(stream: UnixStream) -> Self {
+        FolderManagerProtocol { stream }
+    }
+
+    fn error(mut self, message: &str) -> Result<Self> {
+        self.write_all(&write::write(&Message::from(
+            &[(KEY_STATUS, STATUS_NOTOK), (KEY_ERROR, message)][..],
+        )))?;
+        Ok(self)
+    }
+
+    fn write_all(&mut self, bytes: &[u8]) -> Result<()> {
+        self.stream.write_all(bytes).map_err(nmh::Error::wrap)
+    }
+}
+
+impl FolderManagerProtocol {
+    pub fn read_message(mut self, message_buffer: &mut [u8]) -> Result<(Message, Self)> {
+        match read::read_message(message_buffer, &mut self.stream) {
+            Err(e) => {
+                self.stream
+                    .shutdown(std::net::Shutdown::Both)
+                    .map_err(crate::Error::wrap)?;
+                Err(e)
+            }
+            Ok(message) => Ok((message, self)),
+        }
+    }
+
+    pub fn read_command<'a>(self, message: &'a Message) -> Result<Command<'a>> {
+        match message.get("op") {
+            None => todo!("no 'op' component"),
+            Some(value) => match value {
+                "no" => Ok(Command::No(EmptyResponse::new(self))),
+                "quit" => {
+                    self.close().unwrap();
+                    Ok(Command::Quit)
+                }
+                "folder" => Ok(Command::Folder(
+                    EmptyResponse::new(self),
+                    command::Folder::new(message)?,
+                )),
+                "comp" => Ok(Command::MakeDraft(
+                    MessageNumberResponse::new(self),
+                    command::Folder::new(message)?,
+                )),
+                "comp-update" => Ok(Command::UpdateDraft(
+                    EmptyResponse::new(self),
+                    command::UpdateDraft::new(message)?,
+                )),
+                "inc" => Ok(Command::Inc(
+                    IncResponse::new(self),
+                    command::Folder::new(message)?,
+                )),
+                "make-folder" => Ok(Command::MakeFolder(
+                    EmptyResponse::new(self),
+                    command::Folder::new(message)?,
+                )),
+                "mark-add" => Ok(Command::MarkAdd(
+                    EmptyResponse::new(self),
+                    command::Mark::new(message)?,
+                )),
+                "mark-del" => Ok(Command::MarkDel(
+                    EmptyResponse::new(self),
+                    command::Mark::new(message)?,
+                )),
+                "pick" => Ok(Command::Pick(
+                    EmptyResponse::new(self),
+                    command::Pick::new(message)?,
+                )),
+                "refile-file" => Ok(Command::RefileFile(
+                    EmptyResponse::new(self),
+                    command::RefileFile::new(message)?,
+                )),
+                "refile-move" => Ok(Command::RefileMove(
+                    EmptyResponse::new(self),
+                    command::RefileMove::new(message)?,
+                )),
+                "rmf" => Ok(Command::Rmf(
+                    EmptyResponse::new(self),
+                    command::Folder::new(message)?,
+                )),
+                "rmm" => Ok(Command::Rmm(
+                    EmptyResponse::new(self),
+                    command::Rmm::new(message)?,
+                )),
+                _ => todo!("unknown 'op' {}", value),
+            },
+        }
+    }
+
+    pub fn close(self) -> std::io::Result<()> {
+        self.stream.shutdown(std::net::Shutdown::Both)
+    }
+}
+
+#[derive(Debug)]
+pub struct EmptyResponse {
+    protocol: FolderManagerProtocol,
+}
+
+impl EmptyResponse {
+    pub fn ok(mut self) -> Result<FolderManagerProtocol> {
+        self.protocol
+            .stream
+            .write_all(MESSAGE_OK.as_bytes())
+            .unwrap();
+        self.protocol.stream.flush().unwrap();
+        Ok(self.protocol)
+    }
+
+    pub fn error(self, message: &str) -> Result<FolderManagerProtocol> {
+        self.protocol.error(message)
+    }
+
+    fn new(protocol: FolderManagerProtocol) -> Self {
+        Self { protocol }
+    }
+}
+#[derive(Debug)]
+pub struct MessageNumberResponse {
+    protocol: FolderManagerProtocol,
+}
+
+impl MessageNumberResponse {
+    pub fn ok(mut self, msgnum: nmh::message::Number) -> Result<FolderManagerProtocol> {
+        self.protocol
+            .stream
+            .write_all(&write::write(&Message::from(
+                &[(KEY_STATUS, STATUS_OK), (KEY_MSG, &msgnum.to_string())][..],
+            )))
+            .unwrap();
+        self.protocol.stream.flush().unwrap();
+        Ok(self.protocol)
+    }
+
+    pub fn error(self, message: &str) -> Result<FolderManagerProtocol> {
+        self.protocol.error(message)
+    }
+
+    fn new(protocol: FolderManagerProtocol) -> Self {
+        Self { protocol }
+    }
+}
+
+#[derive(Debug)]
+pub struct IncResponse {
+    protocol: FolderManagerProtocol,
+    folder: folder::Name,
+}
+
+impl IncResponse {
+    pub fn message(
+        &mut self,
+        folder: &folder::Name,
+        msgnum: nmh::message::Number,
+        unseen: bool,
+    ) -> Result<()> {
+        let mut m = Message::new();
+        if *folder != self.folder {
+            self.folder = folder.clone();
+            m.push((KEY_STATUS, STATUS_INC_FOLDER));
+            m.push((KEY_FOLDER, folder.to_str()));
+        }
+        let msgnum_s = msgnum.to_string();
+        m.push((KEY_MSG, &msgnum_s));
+        m.push((KEY_UNSEEN, if unseen { "1" } else { "0" }));
+        self.protocol.stream.write_all(&write::write(&m)).unwrap();
+        self.protocol.stream.flush().unwrap();
+        Ok(())
+    }
+
+    pub fn ok(mut self) -> Result<FolderManagerProtocol> {
+        self.protocol
+            .stream
+            .write_all(MESSAGE_OK.as_bytes())
+            .unwrap();
+        Ok(self.protocol)
+    }
+
+    pub fn error(self, message: &str) -> Result<FolderManagerProtocol> {
+        self.protocol.error(message)
+    }
+
+    fn new(protocol: FolderManagerProtocol) -> Self {
+        Self {
+            protocol,
+            folder: folder::Name::from_utf8(std::path::PathBuf::new()).unwrap(),
+        }
+    }
+}
+
+#[derive(Debug)]
+pub enum Command<'a> {
+    Quit,
+
+    Inc(IncResponse, command::Folder),
+    MakeDraft(MessageNumberResponse, command::Folder),
+
+    // All the rest use EmptyResponse.
+    No(EmptyResponse),
+    Folder(EmptyResponse, command::Folder),
+    MakeFolder(EmptyResponse, command::Folder),
+    MarkAdd(EmptyResponse, command::Mark<'a>),
+    MarkDel(EmptyResponse, command::Mark<'a>),
+    Pick(EmptyResponse, command::Pick<'a>),
+    RefileFile(EmptyResponse, command::RefileFile),
+    RefileMove(EmptyResponse, command::RefileMove<'a>),
+    Rmf(EmptyResponse, command::Folder),
+    Rmm(EmptyResponse, command::Rmm<'a>),
+    UpdateDraft(EmptyResponse, command::UpdateDraft),
+}
+
+pub fn close_with_error(mut stream: UnixStream, message: &str) -> std::io::Result<()> {
+    stream.write_all(&write::write(&Message::from(
+        &[(KEY_STATUS, STATUS_NOTOK), (KEY_ERROR, message)][..],
+    )))?;
+    stream.flush()?;
+    FolderManagerProtocol { stream }.close()
+}
+
+const MESSAGE_OK: &str = "len:17\nstatus:OK\n";
+
+const KEY_ERROR: &str = "error";
+const KEY_FOLDER: &str = "folder";
+const KEY_MSG: &str = "msg";
+const KEY_STATUS: &str = "status";
+const KEY_UNSEEN: &str = "unseen";
+
+const STATUS_NOTOK: &str = "NOTOK";
+const STATUS_OK: &str = "OK";
+const STATUS_INC_FOLDER: &str = "INC_FOLDER";
diff --git a/managedfolders/src/protocol/message.rs b/managedfolders/src/protocol/message.rs
new file mode 100644 (file)
index 0000000..de05b61
--- /dev/null
@@ -0,0 +1,44 @@
+use crate::Components;
+
+#[derive(Debug, PartialEq, Eq)]
+pub struct Message<'a>(Components<'a>);
+
+impl<'a> Message<'a> {
+    pub fn new() -> Self {
+        Self(Components::new())
+    }
+
+    pub fn push(&mut self, component: (&'a str, &'a str)) {
+        self.0.push(component)
+    }
+
+    pub fn get(&self, name: &str) -> Option<&str> {
+        self.0.get(name)
+    }
+
+    pub fn iter(&self) -> std::slice::Iter<(&str, &str)> {
+        self.0.iter()
+    }
+}
+
+impl Default for Message<'_> {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+impl<'a> From<&[(&'a str, &'a str)]> for Message<'a> {
+    fn from(components: &[(&'a str, &'a str)]) -> Self {
+        let mut m = Self::new();
+        for c in components {
+            m.push(*c);
+        }
+        m
+    }
+}
+
+impl<'a> From<Components<'a>> for Message<'a> {
+    fn from(components: Components<'a>) -> Self {
+        Self(components)
+    }
+}
diff --git a/managedfolders/src/protocol/read.rs b/managedfolders/src/protocol/read.rs
new file mode 100644 (file)
index 0000000..7ed7097
--- /dev/null
@@ -0,0 +1,71 @@
+use crate::{parse_components, Error, Message, Result};
+
+pub fn read_message<'a, T: std::io::Read>(
+    message_buffer: &'a mut [u8],
+    stream: &mut T,
+) -> Result<Message<'a>> {
+    let (end_of_len, mut bytes_read) = read_line(message_buffer, stream)?;
+    let read_str = std::str::from_utf8(&message_buffer[..bytes_read])?;
+    if bytes_read < 6 {
+        return Err(Error::new(format!(
+            "protocol error:  shortest possible message is 6 bytes not {}:  {}",
+            bytes_read, read_str
+        )));
+    }
+    if !read_str.starts_with("len:") {
+        return Err(Error::new(format!(
+            "protocol error:  must start with len not {}",
+            read_str
+        )));
+    }
+    let len: usize = read_str[4..end_of_len].parse().map_err(|e| {
+        Error::describe(
+            e,
+            format_args!(
+                "protocol error:  parse len component of message:  {}",
+                read_str
+            ),
+        )
+    })?;
+    while bytes_read < len {
+        bytes_read += read(&mut message_buffer[bytes_read..len], stream)?;
+    }
+    Ok(Message::from(parse_components(std::str::from_utf8(
+        &message_buffer[end_of_len + 1..len],
+    )?)?))
+}
+
+// return (end_of_line, bytes_read)
+fn read_line<T: std::io::Read>(buf: &mut [u8], stream: &mut T) -> Result<(usize, usize)> {
+    let max = std::cmp::min(15, buf.len());
+    let mut bytes_read = 0;
+    // TODO timeout
+    loop {
+        let this_read = read(&mut buf[bytes_read..max], stream)?;
+        if this_read == 0 {
+            return Err(Error::new("protocol error:  zero bytes read".to_owned()));
+        }
+        let previous_read = bytes_read;
+        bytes_read += this_read;
+        for i in previous_read..bytes_read {
+            if buf[i] == 10 {
+                return Ok((i, bytes_read));
+            }
+        }
+        if bytes_read == max {
+            return Err(Error::new(format!(
+                "protocol error:  can't find newline within {} bytes",
+                max
+            )));
+        }
+    }
+}
+
+fn read<T: std::io::Read>(buf: &mut [u8], stream: &mut T) -> Result<usize> {
+    stream
+        .read(buf)
+        .map_err(|e| Error::describe(e, format_args!("read")))
+}
+
+#[cfg(test)]
+mod tests;
diff --git a/managedfolders/src/protocol/read/tests.rs b/managedfolders/src/protocol/read/tests.rs
new file mode 100644 (file)
index 0000000..e1b9d4a
--- /dev/null
@@ -0,0 +1,85 @@
+use super::*;
+
+mod read_message {
+    use super::*;
+
+    mod read_buffer {
+        use super::*;
+
+        #[test]
+        fn too_small() {
+            let mut buf = [0 as u8; 1];
+            let s: Vec<u8> = b"len:11\na:b\n".to_vec();
+            assert_eq!(
+                "protocol error:  can't find newline within 1 bytes",
+                read_message(&mut buf, &mut std::io::Cursor::new(s))
+                    .unwrap_err()
+                    .to_string()
+            )
+        }
+
+        #[test]
+        fn zero_sized() {
+            let mut buf = [0 as u8; 0];
+            let s: Vec<u8> = b"len:11\na:b\n".to_vec();
+            assert_eq!(
+                "protocol error:  zero bytes read",
+                read_message(&mut buf, &mut std::io::Cursor::new(s))
+                    .unwrap_err()
+                    .to_string()
+            )
+        }
+    }
+
+    mod message {
+        use super::*;
+
+        #[test]
+        fn empty() {
+            let mut buf = [0 as u8; 64];
+            let s: Vec<u8> = b"".to_vec();
+            assert_eq!(
+                "protocol error:  zero bytes read",
+                read_message(&mut buf, &mut std::io::Cursor::new(s))
+                    .unwrap_err()
+                    .to_string()
+            )
+        }
+
+        #[test]
+        fn too_short() {
+            let mut buf = [0 as u8; 64];
+            let s: Vec<u8> = b"bad\n".to_vec();
+            assert_eq!(
+                "protocol error:  shortest possible message is 6 bytes not 4:  bad\n",
+                read_message(&mut buf, &mut std::io::Cursor::new(s))
+                    .unwrap_err()
+                    .to_string()
+            )
+        }
+
+        #[test]
+        fn bad() {
+            let mut buf = [0 as u8; 64];
+            let s: Vec<u8> = b"bad:things\n".to_vec();
+            assert_eq!(
+                "protocol error:  must start with len not bad:things\n",
+                read_message(&mut buf, &mut std::io::Cursor::new(s))
+                    .unwrap_err()
+                    .to_string()
+            )
+        }
+
+        #[test]
+        fn bad_len() {
+            let mut buf = [0 as u8; 64];
+            let s: Vec<u8> = b"len:what\n".to_vec();
+            assert_eq!(
+                "protocol error:  parse len component of message:  len:what\n:  invalid digit found in string",
+                read_message(&mut buf, &mut std::io::Cursor::new(s))
+                    .unwrap_err()
+                    .to_string()
+            )
+        }
+    }
+}
diff --git a/managedfolders/src/protocol/write.rs b/managedfolders/src/protocol/write.rs
new file mode 100644 (file)
index 0000000..266e9f4
--- /dev/null
@@ -0,0 +1,29 @@
+use std::convert::TryInto;
+
+use crate::Message;
+
+// enough to hold u32::MAX - 4294967295 ought to be enough for anybody
+const LEN_BASE: &str = "len:          \n";
+
+pub fn write(message: &Message) -> Vec<u8> {
+    let mut len = LEN_BASE.len();
+    let mut s = LEN_BASE.to_owned();
+    for (name, value) in message.iter() {
+        // + 2 for ':' and '\n'
+        len += name.len() + value.len() + 2;
+        s.push_str(name);
+        s.push_str(":");
+        s.push_str(value);
+        s.push_str("\n");
+    }
+    let len_u32: u32 = len.try_into().expect("4294967295 ought to be enough for anybody");
+    // We already had a beautifully-sized buffer into which to write this but oh well.
+    // If profiling shows a problem, we'll look at it.
+    let len_string = len_u32.to_string();
+    let mut bytes: Vec<u8> = s.into();
+    bytes[4..4 + len_string.len()].clone_from_slice(len_string.as_bytes());
+    bytes
+}
+
+#[cfg(test)]
+mod tests;
diff --git a/managedfolders/src/protocol/write/tests.rs b/managedfolders/src/protocol/write/tests.rs
new file mode 100644 (file)
index 0000000..1a53d13
--- /dev/null
@@ -0,0 +1,50 @@
+use super::*;
+
+#[test]
+fn zero_components() {
+    assert_eq!(
+        "len:15        \n",
+        std::str::from_utf8(&write(&Message::from(&[][..])))
+            .unwrap()
+            .to_owned()
+    );
+}
+
+#[test]
+fn one_component() {
+    assert_eq!(
+        "len:21        \nmsg:1\n",
+        std::str::from_utf8(&write(&Message::from(&[("msg", "1")][..])))
+            .unwrap()
+            .to_owned()
+    );
+}
+
+#[test]
+fn two_components() {
+    assert_eq!(
+        "len:48        \nstatus:NOTOK\nerror:error message\n",
+        std::str::from_utf8(&write(&Message::from(
+            &[("status", "NOTOK"), ("error", "error message")][..]
+        )))
+        .unwrap()
+        .to_owned()
+    );
+}
+
+#[test]
+fn four_components() {
+    assert_eq!(
+        "len:56        \nstatus:INC_FOLDER\nfolder:\nmsg:1\nunseen:1\n",
+        std::str::from_utf8(&write(&Message::from(
+            &[
+                ("status", "INC_FOLDER"),
+                ("folder", ""),
+                ("msg", "1"),
+                ("unseen", "1"),
+            ][..]
+        )))
+        .unwrap()
+        .to_owned()
+    );
+}
diff --git a/managedfolders/tests/tests.rs b/managedfolders/tests/tests.rs
new file mode 100644 (file)
index 0000000..7fc67f1
--- /dev/null
@@ -0,0 +1,12 @@
+use managed_folders::{parse_components, Message};
+
+#[test]
+fn simple_error() {
+    let mut m = Message::new();
+    m.push(("status", "NOTOK"));
+    m.push(("error", "error message"));
+    assert_eq!(
+        &m,
+        &Message::from(parse_components("status:NOTOK\nerror:error message\n").unwrap())
+    );
+}
diff --git a/rls.in b/rls.in
new file mode 100644 (file)
index 0000000..e39378a
--- /dev/null
+++ b/rls.in
@@ -0,0 +1,2 @@
+#!/bin/sh
+CARGO_TARGET_DIR='@CARGO_TARGET_DIR@' exec '@RLS@' "$@"
index f771155..b0471d9 100644 (file)
@@ -77,9 +77,8 @@ argsplit(char *command, char **file, int *argp)
      */
 
     if (space && !metachar) {
-       char **split;
        p = mh_xstrdup(command);
-       split = brkstring(p, " \t", NULL);
+       char *const *const split = brkstring(p, " \t", NULL);
        if (split[0] == NULL) {
            die("Invalid blank command found");
        }
index 8f1c297..bf26269 100644 (file)
@@ -19,11 +19,11 @@ static int len = 0;         /* current size of "broken"           */
 /*
  * static prototypes
  */
-static int brkany (char, char *);
+static int brkany (char, char const *);
 
 
-char **
-brkstring (char *str, char *brksep, char *brkterm)
+char *const *
+brkstring (char *const str, char const *const brksep, char const *const brkterm)
 {
     int i;
     char c, *s;
@@ -78,7 +78,7 @@ brkstring (char *str, char *brksep, char *brkterm)
  */
 
 static int
-brkany (char c, char *str)
+brkany (char const c, char const *const str)
 {
     return str && c && strchr(str, c);
 }
index f727179..55d47b2 100644 (file)
@@ -5,4 +5,9 @@
  * COPYRIGHT file in the root directory of the nmh distribution for
  * complete copyright information. */
 
-char **brkstring(char *, char *, char *) NONNULL(1);
+/* Break up str on characters in brksep, terminated by brkterm.
+ * Each character in str that exists in brksep is replace with a null byte.
+ * Processing stops at the first character in str that exists in brkterm.
+ * Directly mutates str.
+ * Returns pointer to private, static array of pointers into str. */
+char *const *brkstring(char *str, char const *brksep, char const *brkterm) NONNULL(1);
index 8d390a8..ee6987e 100644 (file)
@@ -6,8 +6,6 @@
  */
 
 #include "h/mh.h"
-#include "client.h"
-#include "h/mts.h"
 #include "h/utils.h"
 #include <fcntl.h>
 #include <sys/socket.h>
@@ -18,8 +16,8 @@
 
 
 int
-client (char *server, char *service, char *response, int len_response,
-       int debug)
+client (char const *const server, char const *const service, char *const response, int const len_response,
+       int const debug)
 {
     int sd, rc;
     struct addrinfo hints, *res, *ai;
index e339cc9..80a21a9 100644 (file)
@@ -4,4 +4,4 @@
  * COPYRIGHT file in the root directory of the nmh distribution for
  * complete copyright information. */
 
-int client(char *, char *, char *, int, int);
+int client(char const *, char const *, char *, int, int);
diff --git a/sbr/folder_manager_client.c b/sbr/folder_manager_client.c
new file mode 100644 (file)
index 0000000..3422aae
--- /dev/null
@@ -0,0 +1,1296 @@
+/* folder_manager_client.c
+ *
+ * This code is Copyright (c) 2020, by the authors of nmh.  See the
+ * COPYRIGHT file in the root directory of the nmh distribution for
+ * complete copyright information.
+ */
+
+#include "h/mh.h"
+
+#include <sys/socket.h>
+#include <sys/types.h>
+#include <sys/un.h>
+
+#include <errno.h>
+#include <stdio.h>
+#include <string.h>
+#include <unistd.h>
+
+#include "h/folder_manager_protocol.h"
+#include "h/managed_folders.h"
+#include "h/utils.h"
+#include "sbr/error.h"
+#include "sbr/getcpy.h"
+#include "sbr/m_getfld.h"
+#include "sbr/trimcpy.h"
+
+struct mh_folder_manager_client {
+    int skt;
+    FILE *readf, *writef;
+    svector_t response;
+    char *message_buf;
+    size_t message_buf_len;
+    mh_managed_folder *mmf;
+};
+
+struct mh_folder_manager_inc_context {
+    mh_managed_folder_inc_page page;
+    mh_folder_manager_client *mfmc;
+    mh_folder_manager_protocol_read_message_t rmt;
+};
+
+static char const OP_MAKE_DRAFT[] = "op:comp\n";
+static char const OP_FOLDER[] = "op:folder\n";
+static char const OP_MAKE_FOLDER[] = "op:make-folder\n";
+static char const OP_MARK_ADD[] = "op:mark-add\n";
+static char const OP_MARK_DEL[] = "op:mark-del\n";
+static char const OP_RMF[] = "op:rmf\n";
+static char const OP_RMM[] = "op:rmm\n";
+static char const OP_UPDATE_DRAFT[] = "op:comp-update\n";
+static char const OP_INC[] = "op:inc\n";
+static char const OP_PICK[] = "op:pick\n";
+static char const OP_REFILE_FILE[] = "op:refile-file\n";
+static char const OP_REFILE_MOVE[] = "op:refile-move\n";
+static char const KEY_FILE[] = "file:";
+static char const KEY_FOLDER[] = "folder:";
+static char const KEY_DEST[] = "dest:";
+static char const KEY_MSG[] = "msg:";
+static char const KEY_MSGS[] = "msgs:";
+static char const KEY_NEED_UNSEEN[] = "needunseen:";
+static char const KEY_SEQS[] = "seqs:";
+
+static bool
+read_message(mh_folder_manager_client *const mfmc);
+
+static bool
+write_message(mh_folder_manager_client *const mfmc, char const *const name1, ...) ENDNULL;
+
+static char *
+msgs_str(struct msgs_array const msgs);
+
+static char *
+seqs_str(struct svector const *const sequences);
+
+bool
+mh_folder_manager_connect(
+    mh_folder_manager_client **const result,
+    mh_managed_folder *const mmf)
+{
+    struct sockaddr_un sun = { 0 };
+    mh_folder_manager_client* mfmc;
+    // TODO hang this off the mmfs so we can free everything in one bang?
+    NEW(mfmc);
+    mfmc->skt = -1;
+    mfmc->readf = mfmc->writef = NULL;
+    mfmc->response = NULL;
+    mfmc->message_buf = NULL;
+    mfmc->message_buf_len = 0;
+    mfmc->mmf = mmf;
+    *result = mfmc;
+
+    char const *socket_path = mh_managed_folder_socket_path(mmf);
+    sun.sun_family = AF_UNIX;
+    // TODO ensure strlen(socket_path) < sizeof sun.sun_path at configuration load time
+    strncpy(sun.sun_path, socket_path, strlen(socket_path));
+    mfmc->skt = socket(AF_UNIX, SOCK_STREAM, 0);
+    if (mfmc->skt == -1) {
+       // TODO return errors rather than abort
+        adios(socket_path, "socket");
+    }
+    // 1. Try to connect to running manager.
+    if (connect(mfmc->skt, (struct sockaddr*)&sun, sizeof sun) == -1) {
+        if (errno != ENOENT) {
+           // 2. If not ENOENT, unlink the stale socket name.
+           if (unlink(socket_path) < 0)
+               adios(socket_path, "unable to unlink");
+        }
+       // 3. make pipe
+       int fds[2];
+       if (pipe(fds) < 0)
+           adios ("pipe", "unable to");
+       // 4. fork
+       char const *path = mh_managed_folder_manager_path(mmf);
+       switch (fork()) {
+       case -1:
+           adios("fork", "unable to");
+       case 0:
+           // 5.child. folder-manager; prints to stdout if successfully listening
+           if (dup2(fds[1], 1) < 0)
+               adios("pipe to child stdout", "dup2");
+           if (execlp(path, "imap-folder-manager", mh_managed_folder_name(mmf), NULL) < 0)
+               adios(path, "unable to exec");
+           break;
+       default: {
+           if (close(fds[1]) < 0)
+               adios("pipe", "close");
+           // 5. parent read from pipe to child
+           char buf[1];
+           int status = read(fds[0], buf, 1);
+           // 6.parent if error, die
+           if (status < 0)
+               adios("folder manager", "unable to read from");
+           if (status < 1)
+               adios("folder manager", "read too small from");
+           // 7.parent else, time to connect again!  this time, die if error
+           if (connect(mfmc->skt, (struct sockaddr*)&sun, sizeof sun) == -1)
+               adios(socket_path, "unable to connect");
+           break; }
+       }
+    }
+
+    mfmc->readf = fdopen(mfmc->skt, "r");
+    if (mfmc->readf == NULL) {
+        adios(socket_path, "fdopen for reading");
+    }
+
+    mfmc->writef = fdopen(mfmc->skt, "w");
+    if (mfmc->writef == NULL) {
+        adios(socket_path, "fdopen for writing");
+    }
+
+    return true;
+}
+
+bool
+mh_folder_manager_close(mh_folder_manager_client *const mfmc)
+{
+    bool success = true;
+    free(mfmc->message_buf);
+    if (fclose(mfmc->readf) < 0) {
+        perror("TODO fclose");
+        //set_err(mfmc->mmfs, TODO);
+        success = false;
+    }
+    free(mfmc);
+    return success;
+}
+
+int
+mh_folder_manager_create_draft(
+    mh_folder_manager_client *const mfmc,
+    char const *const draft_folder)
+{
+    if (!write_message(
+           mfmc,
+           OP_MAKE_DRAFT,
+           KEY_FOLDER, draft_folder, "\n",
+           NULL)) {
+        return 0;
+    }
+    if (!read_message(mfmc)) {
+        return 0;
+    }
+    int msgnum = 0;
+    for (size_t i = 0; i < svector_size(mfmc->response); i++) {
+        char const *const name = svector_read_at(mfmc->response, i++);
+        char *const value = svector_read_at(mfmc->response, i);
+        if (strcmp(name, "msg") == 0) {
+            errno = 0;
+            msgnum = strtol(value, NULL, 10);
+            if (errno != 0) {
+                fprintf(stderr, "TODO failed to convert msgnum from %s\n", value);
+                return 0;
+            }
+            if (msgnum == 0) {
+                fputs("TODO msgnum mustn't be 0\n", stderr);
+                return 0;
+            }
+            break;
+        }
+    }
+    if (msgnum == 0) {
+        // error - manager didn't return "msg" component
+        return 0;
+    }
+    return msgnum;
+}
+
+bool
+mh_folder_manager_update_draft(
+    mh_folder_manager_client *const mfmc,
+    char const *const draft_folder,
+    int const msgnum)
+{
+    char msgnum_buf[32];
+    sprintf(msgnum_buf, "%d\n", msgnum);
+    if (!write_message(
+           mfmc,
+           OP_UPDATE_DRAFT,
+           KEY_FOLDER, draft_folder, "\n",
+           KEY_MSG, msgnum_buf, /* msgnum_buf has final newline */
+           NULL)) {
+        return false;
+    }
+
+    free_svector_and_elements(&mfmc->response);
+    mfmc->response = svector_create(16);
+
+    mh_folder_manager_protocol_read_message_t rmt;
+    rmt.getfld_ctx = m_getfld_state_init(mfmc->readf);
+    if (!mh_folder_manager_protocol_read_message(mfmc->response, &rmt)) {
+        /* Not adding details for LENERR/FMTERR because m_getfld2 already wrote
+         * advise message to stderr. */
+       mh_managed_folder_set_err(mfmc->mmf, "see above");
+        return false;
+    }
+
+    for (size_t i = 0; i < svector_size(mfmc->response); i++) {
+        if (strcmp(svector_read_at(mfmc->response, i++), "status") == 0) {
+            if (strcmp(svector_read_at(mfmc->response, i), "OK") != 0) {
+                fprintf(stderr, "TODO handle error message\n");
+                return false;
+            }
+            break;
+        }
+    }
+    return true;
+}
+
+bool
+mh_folder_manager_make_folder(
+    mh_folder_manager_client *const mfmc,
+    char const *const folder)
+{
+    if (!write_message(
+           mfmc,
+           OP_MAKE_FOLDER,
+           KEY_FOLDER, folder, "\n",
+           NULL)) {
+        return false;
+    }
+    if (!read_message(mfmc)) {
+        return false;
+    }
+    int status = NOTOK;
+    char const *error = NULL;
+    for (size_t i = 0; i < svector_size(mfmc->response); i++) {
+        char const *key = svector_read_at(mfmc->response, i++);
+        char const *val = svector_read_at(mfmc->response, i);
+        if (strcmp(key, "status") == 0) {
+            if (strcmp(val, "OK") == 0) {
+               status = OK;
+            }
+        } else if (strcmp(key, "error") == 0) {
+           error = val;
+        }
+    }
+    if (status == OK) {
+       if (error != NULL) {
+           // TODO don't spam stderr
+           fprintf(stderr, "folder manager said OK but also gave error message: %s\n", error);
+           mh_managed_folder_set_err(mfmc->mmf, "folder manager said OK but also gave error message");
+           return false;
+       }
+       return true;
+    }
+    if (error == NULL) {
+       mh_managed_folder_set_err(mfmc->mmf, "unspecified error from folder manager");
+    } else {
+       mh_managed_folder_set_err(mfmc->mmf, error);
+    }
+    return false;
+}
+
+static mh_folder_manager_inc_context *
+new_inc_page(mh_folder_manager_client *const mfmc)
+{
+    mh_folder_manager_inc_context *result;
+    NEW(result);
+    result->page.done = false;
+    result->page.folder = NULL;
+    result->mfmc = mfmc;
+    result->rmt.getfld_ctx = m_getfld_state_init(mfmc->readf);
+    return result;
+}
+
+mh_folder_manager_inc_context *
+mh_folder_manager_inc(mh_folder_manager_client *const mfmc, char const *const folder)
+{
+    if (!write_message(
+           mfmc,
+           OP_INC,
+           KEY_FOLDER, folder, "\n",
+           NULL)) {
+        return NULL;
+    }
+    return new_inc_page(mfmc);
+}
+
+mh_managed_folder_inc_page *
+mh_folder_manager_inc_next(mh_folder_manager_inc_context *const mfinc)
+{
+    if (mfinc->page.done) {
+        fprintf(stderr, "TODO can't call after done!\n");
+        return NULL;
+    }
+
+    free_svector_and_elements(&mfinc->mfmc->response);
+    mfinc->mfmc->response = svector_create(16);
+
+    if (!mh_folder_manager_protocol_read_message(mfinc->mfmc->response, &mfinc->rmt)) {
+        /* Not adding details for LENERR/FMTERR because m_getfld2 already wrote
+         * advise message to stderr. */
+        mh_managed_folder_set_err(mfinc->mfmc->mmf, "see above");
+        return NULL;
+    }
+
+    int status = OK;
+    char const *error = NULL;
+    for (size_t i = 0; i < svector_size(mfinc->mfmc->response); i++) {
+        char const *const name = svector_read_at(mfinc->mfmc->response, i++);
+        char *const value = svector_read_at(mfinc->mfmc->response, i);
+        if (strcmp(name, "status") == 0) {
+            if (strcmp(value, "OK") == 0) {
+                mfinc->page.done = true;
+                m_getfld_state_destroy(&mfinc->rmt.getfld_ctx);
+           } else if (strcmp(value, "NOTOK") == 0) {
+               status = NOTOK;
+            } else if (strcmp(value, "INC_FOLDER") != 0) {
+               inform("folder manager status=%s", value);
+               mh_managed_folder_set_err(mfinc->mfmc->mmf, "illegal folder manager status");
+                return NULL;
+            }
+        } else if (strcmp(name, "folder") == 0) {
+            free(mfinc->page.folder);
+            mfinc->page.folder = mh_xstrdup(value);
+        } else if (strcmp(name, "msg") == 0) {
+            errno = 0;
+            mfinc->page.msg = strtol(value, NULL, 10);
+            if (errno != 0) {
+                fprintf(stderr, "TODO failed to convert msgnum from %s\n", value);
+                return NULL;
+            }
+        } else if (strcmp(name, "unseen") == 0) {
+            mfinc->page.unseen = *value == '1';
+        } else if (strcmp(name, "error") == 0) {
+           error = value;
+        } else if (strcmp(name, "len") != 0) {
+            fprintf(stderr, "TODO unknown response component %s\n", name);
+            return NULL;
+        }
+    }
+
+    if (status == NOTOK) {
+       mh_managed_folder_set_err(mfinc->mfmc->mmf,
+                                 error == NULL ? "NOTOK status without error message" : error);
+       return NULL;
+    } else if (error != NULL) {
+       // TODO complain about error status:OK
+    }
+
+    return &mfinc->page;
+}
+
+bool
+mh_folder_manager_folder_read(mh_folder_manager_client *const mfmc, char const *const folder)
+{
+    if (!write_message(
+           mfmc,
+           OP_FOLDER,
+           KEY_FOLDER, folder, "\n",
+           NULL)) {
+        return false;
+    }
+    free_svector_and_elements(&mfmc->response);
+    mfmc->response = svector_create(16);
+    mh_folder_manager_protocol_read_message_t rmt;
+    rmt.getfld_ctx = m_getfld_state_init(mfmc->readf);
+    if (!mh_folder_manager_protocol_read_message(mfmc->response, &rmt)) {
+        /* Not adding details for LENERR/FMTERR because m_getfld2 already wrote
+         * advise message to stderr. */
+        mh_managed_folder_set_err(mfmc->mmf, "see above");
+        return false;
+    }
+
+    for (size_t i = 0; i < svector_size(mfmc->response); i++) {
+        if (strcmp(svector_read_at(mfmc->response, i++), "status") == 0) {
+            if (strcmp(svector_read_at(mfmc->response, i), "OK") != 0) {
+                fprintf(stderr, "TODO handle error message\n");
+                return false;
+            }
+            break;
+        }
+    }
+    return true;
+}
+
+bool
+mh_folder_manager_refile_move(
+    mh_folder_manager_client *const mfmc,
+    char const *const src_folder,
+    struct msgs_array const msgs,
+    char const *const dst_folder)
+{
+    char *msgs_buf = msgs_str(msgs);
+    if (!write_message(
+           mfmc,
+           OP_REFILE_MOVE,
+           KEY_FOLDER, src_folder, "\n",
+           KEY_MSGS, msgs_buf, /* msgs_buf has final newline */
+           KEY_DEST, dst_folder, "\n",
+           NULL)) {
+       free(msgs_buf);
+        return false;
+    }
+    free(msgs_buf);
+
+    free_svector_and_elements(&mfmc->response);
+    mfmc->response = svector_create(16);
+    mh_folder_manager_protocol_read_message_t rmt;
+    rmt.getfld_ctx = m_getfld_state_init(mfmc->readf);
+    if (!mh_folder_manager_protocol_read_message(mfmc->response, &rmt)) {
+        /* Not adding details for LENERR/FMTERR because m_getfld2 already wrote
+         * advise message to stderr. */
+        mh_managed_folder_set_err(mfmc->mmf, "see above");
+        return false;
+    }
+
+    for (size_t i = 0; i < svector_size(mfmc->response); i++) {
+        if (strcmp(svector_read_at(mfmc->response, i++), "status") == 0) {
+            if (strcmp(svector_read_at(mfmc->response, i), "OK") != 0) {
+                fprintf(stderr, "TODO handle error message\n");
+                return false;
+            }
+            break;
+        }
+    }
+    return true;
+}
+
+bool
+mh_folder_manager_refile_file(
+    mh_folder_manager_client *const mfmc,
+    char const *const src_file,
+    char const *const dst_folder)
+{
+    if (!write_message(
+           mfmc,
+           OP_REFILE_FILE,
+           KEY_FILE, src_file, "\n",
+           KEY_DEST, dst_folder, "\n",
+           NULL)) {
+        return false;
+    }
+
+    free_svector_and_elements(&mfmc->response);
+    mfmc->response = svector_create(16);
+    mh_folder_manager_protocol_read_message_t rmt;
+    rmt.getfld_ctx = m_getfld_state_init(mfmc->readf);
+    if (!mh_folder_manager_protocol_read_message(mfmc->response, &rmt)) {
+        /* Not adding details for LENERR/FMTERR because m_getfld2 already wrote
+         * advise message to stderr. */
+        mh_managed_folder_set_err(mfmc->mmf, "see above");
+        return false;
+    }
+
+    for (size_t i = 0; i < svector_size(mfmc->response); i++) {
+        if (strcmp(svector_read_at(mfmc->response, i++), "status") == 0) {
+            if (strcmp(svector_read_at(mfmc->response, i), "OK") != 0) {
+                fprintf(stderr, "TODO handle error message\n");
+                return false;
+            }
+            break;
+        }
+    }
+    return true;
+}
+
+bool
+mh_folder_manager_pick(
+    mh_folder_manager_client *const mfmc,
+    char const *const folder,
+    struct msgs_array const msgs,
+    int const need_unseen)
+{
+    if (need_unseen < 0 || need_unseen > 1) {
+       die("mh_folder_manager_pick need_unseen must be 0 or 1");
+    }
+    char *msgs_buf = msgs_str(msgs);
+    if (!write_message(
+           mfmc,
+           OP_PICK,
+           KEY_FOLDER, folder, "\n",
+           KEY_MSGS, msgs_buf, /* msgs_buf has final newline */
+           KEY_NEED_UNSEEN, (need_unseen ? "1\n" : "0\n"),
+           NULL)) {
+       free(msgs_buf);
+        return false;
+    }
+    free(msgs_buf);
+
+    free_svector_and_elements(&mfmc->response);
+    mfmc->response = svector_create(16);
+    mh_folder_manager_protocol_read_message_t rmt;
+    rmt.getfld_ctx = m_getfld_state_init(mfmc->readf);
+    if (!mh_folder_manager_protocol_read_message(mfmc->response, &rmt)) {
+        /* Not adding details for LENERR/FMTERR because m_getfld2 already wrote
+         * advise message to stderr. */
+        mh_managed_folder_set_err(mfmc->mmf, "see above");
+        return false;
+    }
+
+    for (size_t i = 0; i < svector_size(mfmc->response); i++) {
+        if (strcmp(svector_read_at(mfmc->response, i++), "status") == 0) {
+            if (strcmp(svector_read_at(mfmc->response, i), "OK") != 0) {
+                fprintf(stderr, "TODO handle error message\n");
+                return false;
+            }
+            break;
+        }
+    }
+    return true;
+}
+
+bool
+mh_folder_manager_rmf(mh_folder_manager_client *const mfmc, char const *const folder)
+{
+    if (!write_message(
+           mfmc,
+           OP_RMF,
+           KEY_FOLDER, folder, "\n",
+           NULL)) {
+        return false;
+    }
+
+    free_svector_and_elements(&mfmc->response);
+    mfmc->response = svector_create(16);
+    mh_folder_manager_protocol_read_message_t rmt;
+    rmt.getfld_ctx = m_getfld_state_init(mfmc->readf);
+    if (!mh_folder_manager_protocol_read_message(mfmc->response, &rmt)) {
+        /* Not adding details for LENERR/FMTERR because m_getfld2 already wrote
+         * advise message to stderr. */
+        mh_managed_folder_set_err(mfmc->mmf, "see above");
+        return false;
+    }
+
+    for (size_t i = 0; i < svector_size(mfmc->response); i++) {
+        if (strcmp(svector_read_at(mfmc->response, i++), "status") == 0) {
+            if (strcmp(svector_read_at(mfmc->response, i), "OK") != 0) {
+                fprintf(stderr, "TODO handle error message\n");
+                return false;
+            }
+            break;
+        }
+    }
+    return true;
+}
+
+bool
+mh_folder_manager_rmm(mh_folder_manager_client *const mfmc, char const *const folder, struct msgs_array const msgs)
+{
+    char *msgs_buf = msgs_str(msgs);
+    if (!write_message(
+           mfmc,
+           OP_RMM,
+           KEY_FOLDER, folder, "\n",
+           KEY_MSGS, msgs_buf, /* msgs_buf has final newline */
+           NULL)) {
+       free(msgs_buf);
+        return false;
+    }
+    free(msgs_buf);
+
+    free_svector_and_elements(&mfmc->response);
+    mfmc->response = svector_create(16);
+    mh_folder_manager_protocol_read_message_t rmt;
+    rmt.getfld_ctx = m_getfld_state_init(mfmc->readf);
+    if (!mh_folder_manager_protocol_read_message(mfmc->response, &rmt)) {
+        /* Not adding details for LENERR/FMTERR because m_getfld2 already wrote
+         * advise message to stderr. */
+        mh_managed_folder_set_err(mfmc->mmf, "see above");
+        return false;
+    }
+
+    for (size_t i = 0; i < svector_size(mfmc->response); i++) {
+        if (strcmp(svector_read_at(mfmc->response, i++), "status") == 0) {
+            if (strcmp(svector_read_at(mfmc->response, i), "OK") != 0) {
+                fprintf(stderr, "TODO handle error message\n");
+                return false;
+            }
+            break;
+        }
+    }
+    return true;
+}
+
+bool
+mh_folder_manager_mark_add(
+    mh_folder_manager_client *const mfmc,
+    char const *const folder,
+    struct msgs_array const msgs,
+    struct svector const *const sequences)
+{
+    char *msgs_buf = msgs_str(msgs);
+    char *seqs_buf = seqs_str(sequences);
+    if (!write_message(
+           mfmc,
+           OP_MARK_ADD,
+           KEY_FOLDER, folder, "\n",
+           KEY_MSGS, msgs_buf, /* msgs_buf has final newline */
+           KEY_SEQS, seqs_buf, /* seqs_buf has final newline */
+           NULL)) {
+       free(msgs_buf);
+       free(seqs_buf);
+        return false;
+    }
+    free(msgs_buf);
+    free(seqs_buf);
+
+    free_svector_and_elements(&mfmc->response);
+    mfmc->response = svector_create(16);
+    mh_folder_manager_protocol_read_message_t rmt;
+    rmt.getfld_ctx = m_getfld_state_init(mfmc->readf);
+    if (!mh_folder_manager_protocol_read_message(mfmc->response, &rmt)) {
+        /* Not adding details for LENERR/FMTERR because m_getfld2 already wrote
+         * advise message to stderr. */
+        mh_managed_folder_set_err(mfmc->mmf, "see above");
+        return false;
+    }
+
+    for (size_t i = 0; i < svector_size(mfmc->response); i++) {
+        if (strcmp(svector_read_at(mfmc->response, i++), "status") == 0) {
+            if (strcmp(svector_read_at(mfmc->response, i), "OK") != 0) {
+                fprintf(stderr, "TODO handle error message\n");
+                return false;
+            }
+            break;
+        }
+    }
+    return true;
+}
+
+bool
+mh_folder_manager_mark_del(
+    mh_folder_manager_client *const mfmc,
+    char const *const folder,
+    struct msgs_array const msgs,
+    struct svector const *const sequences)
+{
+    char *msgs_buf = msgs_str(msgs);
+    char *seqs_buf = seqs_str(sequences);
+    if (!write_message(
+           mfmc,
+           OP_MARK_DEL,
+           KEY_FOLDER, folder, "\n",
+           KEY_MSGS, msgs_buf, /* msgs_buf has final newline */
+           KEY_SEQS, seqs_buf, /* seqs_buf has final newline */
+           NULL)) {
+       free(msgs_buf);
+       free(seqs_buf);
+        return false;
+    }
+    free(msgs_buf);
+    free(seqs_buf);
+
+    free_svector_and_elements(&mfmc->response);
+    mfmc->response = svector_create(16);
+    mh_folder_manager_protocol_read_message_t rmt;
+    rmt.getfld_ctx = m_getfld_state_init(mfmc->readf);
+    if (!mh_folder_manager_protocol_read_message(mfmc->response, &rmt)) {
+        /* Not adding details for LENERR/FMTERR because m_getfld2 already wrote
+         * advise message to stderr. */
+        mh_managed_folder_set_err(mfmc->mmf, "see above");
+        return false;
+    }
+
+    for (size_t i = 0; i < svector_size(mfmc->response); i++) {
+        if (strcmp(svector_read_at(mfmc->response, i++), "status") == 0) {
+            if (strcmp(svector_read_at(mfmc->response, i), "OK") != 0) {
+                fprintf(stderr, "TODO handle error message\n");
+                return false;
+            }
+            break;
+        }
+    }
+    return true;
+}
+
+// TODO why is this not all inside mh_folder_manager_protocol_read_message
+static bool
+read_message(mh_folder_manager_client *const mfmc)
+{
+    free_svector_and_elements(&mfmc->response);
+    mfmc->response = svector_create(16);
+
+    mh_folder_manager_protocol_read_message_t rmt;
+    rmt.getfld_ctx = m_getfld_state_init(mfmc->readf);
+    const bool success = mh_folder_manager_protocol_read_message(mfmc->response, &rmt);
+    m_getfld_state_destroy(&rmt.getfld_ctx);
+    return success;
+}
+
+static void
+allocate_buffer(mh_folder_manager_client *const mfmc, size_t const len)
+{
+    if (mfmc->message_buf_len < len) {
+        if (mfmc->message_buf_len == 0)
+            mfmc->message_buf_len = 1024;
+        for (; mfmc->message_buf_len < len; mfmc->message_buf_len *= 2);
+        free(mfmc->message_buf);
+        mfmc->message_buf = mh_xmalloc(mfmc->message_buf_len);
+    }
+}
+
+static bool
+write_message(mh_folder_manager_client *const mfmc, char const *const name1, ...)
+{
+    // Iterate over parameters the first time adding up the buffer size.
+    va_list list;
+    size_t len = 0;
+    {
+       va_start(list, name1);
+       char const *cp = name1;
+       while (cp != NULL) {
+           // Recomputing lengths of all the names which we already knew.
+           // Not hard to fix that, but we're just going to replace this
+           // with calls into the Rust implementation anyway.
+           len += strlen(cp);
+           cp = va_arg(list, char const *);
+       }
+       va_end(list);
+    }
+
+    allocate_buffer(mfmc, len);
+
+    // Copy.  (TODO good fit for writev(2) instead)
+    {
+       char *mp = mfmc->message_buf;
+       va_start(list, name1);
+       char const *cp = name1;
+       while (cp != NULL) {
+           mp = stpcpy(mp, cp);
+           cp = va_arg(list, char const *);
+       }
+       va_end(list);
+    }
+    return mh_folder_manager_protocol_write_message(mfmc->writef, mfmc->message_buf, len);
+}
+
+static char *
+msgs_str(struct msgs_array const msgs)
+{
+    // Start with the number of bytes we add:  one space after each msg but the last, which gets LF.
+    size_t msgs_len = msgs.size;
+    for (int i = 0; i < msgs.size; i++) {
+        msgs_len += strlen(msgs.msgs[i]);
+    }
+    char *const result = mh_xmalloc(msgs_len);
+    char *mp = result;
+    for (int i = 0; i < msgs.size; i++) {
+        mp = stpcpy(mp, msgs.msgs[i]);
+       mp = stpcpy(mp, " ");
+    }
+    if (mp == result) {
+       stpcpy(result, "\n");
+    } else {
+       *(mp - 1) = '\n';
+    }
+    return result;
+}
+
+static char *
+seqs_str(struct svector const *const sequences)
+{
+    // Start with the number of bytes we add:  one space after each seq but the last, which gets LF.
+    size_t seqs_len = svector_size(sequences);
+    for (size_t i = 0; i < svector_size(sequences); i++) {
+        seqs_len += strlen(svector_read_at(sequences, i));
+    }
+    char *const result = mh_xmalloc(seqs_len);
+    char *mp = result;
+    for (size_t i = 0; i < svector_size(sequences); i++) {
+        mp = stpcpy(mp, svector_read_at(sequences, i));
+       mp = stpcpy(mp, " ");
+    }
+    if (mp == result) {
+       stpcpy(result, "\n");
+    } else {
+       *(mp - 1) = '\n';
+    }
+    return result;
+}
+
+
+
+
+
+
+
+
+
+
+
+
+/* copied from seq_read.c
+ * TODO put them back together
+ */
+
+#include "ssequal.h"
+#include "trimcpy.h"
+#include "m_atoi.h"
+#include "brkstring.h"
+#include "lock_file.h"
+
+/*
+ * static prototypes
+ */
+static int seq_init (struct msgs *, char *, char *);
+static void seq_private (struct msgs *);
+
+
+
+/*
+ * read folder's sequences file for public sequences
+ */
+
+typedef struct {
+    // outputs
+    bool done;
+    int failed_to_lock;
+    char name[NAMESZ], *value;
+
+    // inputs
+    struct msgs *mp;
+    int lockflag;
+
+    // internal state
+    char seqfile[PATH_MAX];
+    FILE *fp;
+    m_getfld_state_t gstate;
+    char valuebuf[NMH_BUFSIZ];
+} seq_public_state_t;
+static int
+seq_public_open(seq_public_state_t *const sps)
+{
+    /*
+     * If mh_seq == NULL or if *mh_seq == 0 (the user has defined
+     * the "mh-sequences" profile entry, but left it empty),
+     * then just return, and do not initialize any public sequences.
+     */
+    if (mh_seq == NULL || *mh_seq == 0) {
+        sps->done = true;
+       return OK;
+    }
+    sps->done = false;
+
+    /* get filename of sequence file */
+    snprintf(sps->seqfile, sizeof(sps->seqfile), "%s/%s", sps->mp->foldpath, mh_seq);
+    sps->fp = lkfopendata(sps->seqfile, sps->lockflag ? "r+" : "r", &sps->failed_to_lock);
+    if (sps->fp == NULL) {
+        sps->done = true;
+       return NOTOK;
+    }
+
+    /* Use m_getfld2 to scan sequence file */
+    sps->gstate = m_getfld_state_init(sps->fp);
+    return OK;
+}
+static void
+seq_public_next(seq_public_state_t *const sps)
+{
+    if (sps->done) {
+        adios(sps->seqfile, "attempt to keep reading after sequence file is done");
+    }
+    {
+       int state, fieldsz = sizeof sps->valuebuf;
+       switch (state = m_getfld2(&sps->gstate, sps->name, sps->valuebuf, &fieldsz)) {
+           case FLD:
+           case FLDPLUS:
+               if (state == FLDPLUS) {
+                   char *cp = mh_xstrdup(sps->valuebuf);
+                   while (state == FLDPLUS) {
+                       fieldsz = sizeof sps->valuebuf;
+                       state = m_getfld2(&sps->gstate, sps->name, sps->valuebuf, &fieldsz);
+                       cp = add (sps->valuebuf, cp);
+                   }
+                    sps->value = trimcpy(cp);
+                   free(cp);
+               } else {
+                    sps->value = trimcpy(sps->valuebuf);
+               }
+               return;
+
+           case BODY:
+               lkfclosedata (sps->fp, sps->seqfile);
+               die("no blank lines are permitted in %s", sps->seqfile);
+               break;
+
+           case FILEEOF:
+               break;
+
+           default: 
+               lkfclosedata (sps->fp, sps->seqfile);
+               die("%s is poorly formatted", sps->seqfile);
+       }
+    }
+    sps->done = true;
+    m_getfld_state_destroy (&sps->gstate);
+
+    if (sps->lockflag) {
+       sps->mp->seqhandle = sps->fp;
+       sps->mp->seqname = mh_xstrdup(sps->seqfile);
+    } else {
+       lkfclosedata (sps->fp, sps->seqfile);
+    }
+}
+
+
+/*
+ * Scan profile/context list for private sequences.
+ *
+ * We search the context list for all keys that look like
+ * "atr-seqname-folderpath", and add them as private sequences.
+ */
+
+static void
+seq_private (struct msgs *mp)
+{
+    int i, j, alen, plen;
+    char *cp;
+    struct node *np;
+
+    alen = LEN("atr-");
+    plen = strlen (mp->foldpath) + 1;
+
+    for (np = m_defs; np; np = np->n_next) {
+       if (ssequal ("atr-", np->n_name)
+               && (j = strlen (np->n_name) - plen) > alen
+               && *(np->n_name + j) == '-'
+               && strcmp (mp->foldpath, np->n_name + j + 1) == 0) {
+           cp = mh_xstrdup(np->n_name + alen);
+           *(cp + j - alen) = 0;
+           if ((i = seq_init (mp, cp, getcpy (np->n_field))) != -1)
+               make_seq_private (mp, i);
+       }
+    }
+}
+
+
+/*
+ * Split up the different message ranges at whitespace
+ */
+typedef struct {
+    bool done;
+    int start;
+    int end;
+    char *const *piece;
+    char *field;
+} parse_range_t;
+static void
+parse_range(parse_range_t *const state)
+{
+    if (state->done) {
+        die("attempt to keep reading after sequence range is done");
+    }
+    if (state->piece == NULL) {
+        state->piece = brkstring(state->field, " ", "\n");
+        state->done = *state->piece == NULL;
+    } else {
+        state->piece++;
+    }
+    if ((state->done = *state->piece == NULL)) {
+        return;
+    }
+    char *const dash_if_any = strchr(*state->piece, '-');
+    if (dash_if_any != NULL)
+        *dash_if_any = 0;
+    state->start = m_atoi (*state->piece);
+    if (state->start > 0) {
+        state->end = dash_if_any == NULL ? state->start : m_atoi(dash_if_any+1);
+    }
+}
+
+/*
+ * Add the name of sequence to the list of folder sequences.
+ * Then parse the list of message ranges for this
+ * sequence, and setup the various bit flags for each
+ * message in the sequence.
+ *
+ * Return internal index for the sequence if successful.
+ * Return -1 on error.
+ */
+
+static int
+seq_init (struct msgs *mp, char *name, char *field)
+{
+    unsigned int i;
+    int is_current;
+
+    /*
+     * Check if this is "cur" sequence,
+     * so we can do some special things.
+     */
+    is_current = !strcmp (current, name);
+
+    /*
+     * Search for this sequence name to see if we've seen
+     * it already.  If we've seen this sequence before,
+     * then clear the bit for this sequence from all the
+     * messages in this folder.
+     */
+    for (i = 0; i < svector_size (mp->msgattrs); i++) {
+       if (!strcmp (svector_read_at (mp->msgattrs, i), name)) {
+           for (int j = mp->lowmsg; j <= mp->hghmsg; j++)
+               clear_sequence (mp, i, j);
+           break;
+       }
+    }
+
+    /*
+     * If we've already seen this sequence name, just free the
+     * name string.  Else add it to the list of sequence names.
+     */
+    if (svector_read_at (mp->msgattrs, i)) {
+       free (name);
+    } else {
+       svector_push_back (mp->msgattrs, name);
+    }
+
+    /*
+     * Split up the different message ranges at whitespace
+     */
+    parse_range_t state;
+    state.done = false;
+    state.piece = NULL;
+    state.field = field;
+    for (parse_range(&state); !state.done; parse_range(&state)) {
+       if (state.start > 0) {
+           /*
+            * Keep mp->curmsg and "cur" sequence in synch.  Unlike
+            * other sequences, this message doesn't need to exist.
+            * Think about the series of command (rmm; next) to
+            * understand why this can be the case.  But if it does
+            * exist, we will still set the bit flag for it like
+            * other sequences.
+            */
+           if (is_current)
+               mp->curmsg = state.start;
+           /*
+            * We iterate through messages in this range
+            * and flip on bit for this sequence.
+            */
+           for (int j = state.start; j <= state.end; j++) {
+               if (j >= mp->lowmsg && j <= mp->hghmsg && does_exist(mp, j))
+                   add_sequence (mp, i, j);
+           }
+       }
+    }
+
+    free (field);      /* free string containing message ranges */
+    return i;
+}
+
+
+
+
+
+
+
+
+
+
+
+
+/* copied from folder_read.c
+ * TODO put them back together
+
+ * The original folder_read uses readdir to find messages, but managed folders
+ * have a different source of truth known only to the folder manager.
+ * We never ask the folder manager for ALL messages.  Instead, we pass it the
+ * user's message specifications and expect it to write all matching message
+ * numbers into the nmh-picked sequence.
+ */
+
+#include "m_maildir.h"
+
+/* We allocate the `mi' array 1024 elements at a time */
+#define        NUMMSGS  1024
+
+/*
+ * 1) Create the folder/message structure
+ * 2) Read the directory (folder) and temporarily
+ *    record the numbers of the messages we have seen.
+ * 3) Then allocate the array for message attributes and
+ *    set the initial flags for all messages we've seen.
+ * 4) Read and initialize the sequence information.
+ */
+
+struct msgs *
+mh_managed_folder_read (char *in_name, int lockflag)
+{
+    struct msgs *mp;
+    struct bvector *v;
+    size_t i;
+    char *const name = m_mailpath (in_name);
+
+    /* Allocate the main structure for folder information */
+    NEW(mp);
+    clear_folder_flags (mp);
+    mp->foldpath = name;
+    mp->lowmsg = 0;
+    mp->hghmsg = 0;
+    mp->curmsg = 0;
+    mp->lowsel = 0;
+    mp->hghsel = 0;
+    mp->numsel = 0;
+    mp->nummsg = 0;
+    mp->seqhandle = NULL;
+    mp->seqname = NULL;
+
+    if (access (name, W_OK) == -1)
+       set_readonly (mp);
+
+    mp->msgattrs = svector_create (0);
+
+    // inlined fromseq_read
+    /*
+     * Initialize the list of sequence names.  Go ahead and
+     * add the "cur" sequence to the list of sequences.
+     */
+    svector_push_back (mp->msgattrs, getcpy (current));
+    make_all_public (mp);      /* initially, make all public */
+
+    seq_public_state_t sps;
+    sps.mp = mp;
+    sps.lockflag = lockflag;
+    if (seq_public_open(&sps) != OK) {
+        if (sps.failed_to_lock) {
+            advise (sps.seqfile, "failed to lock");
+            // TODO leaks!
+            return NULL;
+        }
+        perror("TODO is errno even useful or did lkfopendata clobber it?");
+        adios(sps.seqfile, "TODO pre-existing bug was to continue on all other failures");
+        // TODO leaks!
+        return NULL;
+    }
+
+    {
+    /*
+     * Allocate a temporary place to record the
+     * name of the messages in this folder.
+     */
+      int len = NUMMSGS;
+      int *mi = mh_xmalloc ((size_t) (len * sizeof(*mi)));
+
+      seq_public_next(&sps);
+      // TODO make nmh-picked configurable.  if user already configured Previous-Sequence, use that?
+      if (!sps.done && strcmp(sps.name, "nmh-picked") == 0) {
+        parse_range_t state;
+        state.done = false;
+        state.piece = NULL;
+        state.field = sps.value;
+        for (parse_range(&state); !state.done; parse_range(&state)) {
+            if (state.start <= 0) {
+                adios(sps.seqfile, "folder manager put invalid message number %d into `nmh-picked` sequence", state.start);
+            }
+            if (state.end < state.start) {
+                adios(sps.seqfile, "folder manager put invalid message range %d-%d into `nmh-picked` sequence", state.start, state.end);
+            }
+            for (int msgnum = state.start; msgnum <= state.end; msgnum++) {
+                /*
+                 * Check if we need to allocate more
+                 * temporary elements for message names.
+                 */
+                if (mp->nummsg >= len) {
+                    len += NUMMSGS;
+                    mi = mh_xrealloc (mi, (size_t) (len * sizeof(*mi)));
+                }
+
+                /* Check if this is the first message we've seen */
+                if (mp->nummsg == 0) {
+                    mp->lowmsg = msgnum;
+                    mp->hghmsg = msgnum;
+                } else {
+                    /* Check if this is it the highest or lowest we've seen? */
+                    if (msgnum < mp->lowmsg)
+                        mp->lowmsg = msgnum;
+                    if (msgnum > mp->hghmsg)
+                        mp->hghmsg = msgnum;
+                }
+
+                /*
+                 * Now increment count, and record message
+                 * number in a temporary place for now.
+                 */
+                mi[mp->nummsg++] = msgnum;
+
+                /* TODO Original readdir-based version would set this when
+                 * encountering a file type it didn't know about.  But this is
+                 * only used by `folder -r` to determine whether this folder
+                 * has subfolders to process.
+                * Instead we'll have `folder -r` ask the folder manager to
+                 * enumerate subfolders. */
+                //set_other_files (mp);
+            }
+        }
+
+        free(sps.value);
+        seq_public_next(&sps);
+    }
+
+        mp->lowoff = max (mp->lowmsg, 1);
+
+        /* Go ahead and allocate space for 100 additional messages. */
+        mp->hghoff = mp->hghmsg + 100;
+
+        /* for testing, allocate minimal necessary space */
+        /* mp->hghoff = max (mp->hghmsg, 1); */
+
+        /*
+         * If for some reason hghoff < lowoff (like we got an integer overflow)
+         * the complain about this now.
+         */
+
+        if (mp->hghoff < mp->lowoff) {
+            die("Internal failure: high message limit < low message "
+                "limit; possible overflow?");
+        }
+
+        /*
+         * Allocate space for status of each message.
+         */
+        mp->num_msgstats = MSGSTATNUM (mp->lowoff, mp->hghoff);
+        mp->msgstats = mh_xmalloc (MSGSTATSIZE(mp));
+        for (i = 0, v = mp->msgstats; i < mp->num_msgstats; ++i, ++v) {
+            bvector_init(v);
+        }
+
+        /*
+         * Scan through the array of messages we've seen and
+         * setup the initial flags for those messages in the
+         * newly allocated mp->msgstats area.
+         */
+        for (int msgnum = 0; msgnum < mp->nummsg; msgnum++) {
+            set_exists (mp, mi[msgnum]);
+        }
+
+        free (mi);             /* We don't need this anymore    */
+    }
+
+    /*
+     * Read and initialize the sequence information.
+     */
+    // inlined seq_read
+/*
+ * Get the sequence information for this folder from
+ * .mh_sequences (or equivalent specified in .mh_profile)
+ * or context file (for private sequences).
+ */
+
+
+    /* If folder is empty, don't scan for sequence information */
+    if (mp->nummsg == 0) {
+       return mp;
+    }
+
+    /* Initialize the public sequences */
+    for (; !sps.done; seq_public_next(&sps)) {
+        seq_init(mp, mh_xstrdup(sps.name), sps.value);
+    }
+
+    /* Initialize the private sequences */
+    seq_private (mp);
+
+    return mp;
+}
diff --git a/sbr/folder_manager_protocol.c b/sbr/folder_manager_protocol.c
new file mode 100644 (file)
index 0000000..b1b9ed0
--- /dev/null
@@ -0,0 +1,164 @@
+#include "h/mh.h"
+
+#include <errno.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "h/folder_manager_protocol.h"
+#include "h/utils.h"
+#include "sbr/error.h"
+#include "sbr/getcpy.h"
+#include "sbr/trimcpy.h"
+#include "sbr/m_getfld.h"
+
+// TODO replace with calling the Rust protocol implementation.
+
+void
+free_svector_and_elements(svector_t *svector)
+{
+    size_t i;
+    if (*svector == NULL) return;
+    size_t const len = svector_size(*svector);
+    for (i = 0; i < len; i++) {
+        free(svector_at(*svector, i));
+    }
+    svector_free(*svector);
+    *svector = NULL;
+}
+
+bool
+mh_folder_manager_protocol_read_message(svector_t message, mh_folder_manager_protocol_read_message_t *const rms)
+{
+    size_t len = 0, bytes_read = 0;
+    for (;;) {
+        int size = sizeof rms->value_buf;
+       int state = m_getfld2(&rms->getfld_ctx, rms->name, rms->value_buf, &size);
+       switch (state) {
+        case FLD:
+        case FLDPLUS: {
+            char *value, *trimmed;
+            if (state == FLD) {
+                value = trimcpy(rms->value_buf);
+            } else {
+                char *tmp = getcpy(rms->value_buf);
+                while (state == FLDPLUS) {
+                    size = sizeof rms->value_buf;
+                    state = m_getfld2(&rms->getfld_ctx, rms->name, rms->value_buf, &size);
+                    tmp = add(rms->value_buf, tmp);
+                }
+                value = trimcpy(tmp);
+                free(tmp);
+            }
+            bytes_read += size;
+            trimmed = trimcpy(rms->name);
+
+            if (strcmp(trimmed, "len") == 0) {
+                if (len > 0) {
+                    /* m_getfld2 already spews to stderr so why not. */
+                    fprintf(stderr, "len component repeated\n");
+                    return false;
+                }
+                errno = 0;
+                len = strtol(value, NULL, 10);
+                if (errno != 0) {
+                    /* m_getfld2 already spews to stderr so why not. */
+                    perror("len");
+                    return false;
+                }
+                if (len == 0) {
+                    /* m_getfld2 already spews to stderr so why not. */
+                    fprintf(stderr, "len component repeated\n");
+                    return false;
+                }
+            }
+
+            if (len == 0) {
+               /* m_getfld2 already spews to stderr so why not. */
+               fprintf(stderr, "len must be first not %s:%s\n", trimmed, value);
+               return false;
+           }
+
+            svector_push_back(message, trimmed);
+            svector_push_back(message, value);
+
+           if (bytes_read > len) {
+               /* m_getfld2 already spews to stderr so why not. */
+               fprintf(stderr, "we read %zu instead of %zu\n", bytes_read, len);
+               return false;
+           }
+           if (bytes_read == len) {
+               return true;
+            }
+           break;
+        }
+
+        case BODY:
+           fprintf(stderr, "should never see body: %s\n", rms->value_buf);
+           return false;
+
+        case FILEEOF:
+           if (len == 0) {
+               fprintf(stderr, "EOF before we saw anything?  size=%d\n", size);
+           } else {
+               fprintf(stderr, "EOF with %zu bytes missing\n", len - bytes_read);
+           }
+           return false;
+
+        default:
+           die("unexpected m_getfld2 state %d\n", state);
+       }
+    }
+    die("UNREACHABLE");
+}
+
+typedef struct {
+    char b[16];
+    size_t len;
+} len_buf_t;
+static len_buf_t
+compute_len(size_t const partial_len)
+{
+    len_buf_t len_buf;
+    *len_buf.b = 0;
+    size_t len_len = 1;
+    for (;;) {
+        sprintf(len_buf.b, "%zu", partial_len + len_len);
+        size_t const new_len_len = strlen(len_buf.b);
+        if (new_len_len == len_len)
+            break;
+        len_len = new_len_len;
+    }
+    len_buf.len = len_len;
+    return len_buf;
+}
+
+bool
+mh_folder_manager_protocol_write_message(FILE *const fp, char const *const message, size_t partial_len)
+{
+    len_buf_t const len_buf = compute_len(partial_len + 5);
+
+    if (fputs("len:", fp) == EOF) {
+        perror("TODO fputs");
+        return false;
+    }
+    if (fwrite(len_buf.b, 1, len_buf.len, fp) < len_buf.len) {
+        perror("TODO fwrite");
+        return false;
+    }
+    if (fputs("\n", fp) == EOF) {
+        perror("TODO fputs");
+        return false;
+    }
+    if (fwrite(message, 1, partial_len, fp) < partial_len) {
+        perror("TODO fwrite");
+        return false;
+    }
+
+    if (fflush(fp) == EOF) {
+        perror("TODO fflush");
+        return false;
+    }
+
+    return true;
+}
diff --git a/sbr/folder_operations.c b/sbr/folder_operations.c
new file mode 100644 (file)
index 0000000..1c560e3
--- /dev/null
@@ -0,0 +1,296 @@
+/* folder_operations.c -- mh_folder and associated operations.
+ *
+ * This code is Copyright (c) 2020, by the authors of nmh.  See the
+ * COPYRIGHT file in the root directory of the nmh distribution for
+ * complete copyright information.
+ */
+
+#include "h/mh.h"
+
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <unistd.h>
+
+#include "h/done.h"
+#include "h/managed_folders.h"
+#include "h/folder.h"
+#include "h/utils.h"
+#include "sbr/brkstring.h"
+#include "sbr/context_find.h"
+#include "sbr/context_replace.h"
+#include "sbr/context_save.h"
+#include "sbr/error.h"
+#include "sbr/folder_free.h"
+#include "sbr/folder_read.h"
+#include "sbr/m_convert.h"
+#include "sbr/m_maildir.h"
+#include "sbr/m_name.h"
+#include "sbr/seq_getnum.h"
+#include "sbr/seq_save.h"
+#include "sbr/seq_setcur.h"
+#include "sbr/seq_setprev.h"
+#include "sbr/seq_setunseen.h"
+
+struct mh_folder {
+    char *name, *path;
+    struct msgs *mp;
+
+    mh_managed_folder *mmf;
+
+    // for scan
+    int num_unseen_seq;
+    ivector_t seqnum;
+};
+
+static mh_folder *
+new_mhf()
+{
+    mh_folder *mhf;
+    NEW(mhf);
+    mhf->name = mhf->path = NULL;
+    mhf->mp = NULL;
+    mhf->seqnum = ivector_create (0);
+    return mhf;
+}
+
+bool
+mh_folder_open(mh_folder **const result, char const *const name, mh_managed_folders *const mmfs)
+{
+    mh_folder *const mhf = *result = new_mhf();
+    mhf->name = mh_xstrdup(name);
+    mhf->path = mh_xstrdup(m_maildir(mhf->name));
+
+    mhf->mmf = mh_managed_folder_lookup(mmfs, name, strlen(name));
+
+    return true;
+}
+
+char const *
+mh_folder_get_err_string(const mh_folder *const mhf)
+{
+    if (mhf->mmf != NULL)
+       return mh_managed_folder_get_err_string(mhf->mmf);
+    adios(mhf->path, "mh_folder_get_err_string not implemented because everything just dies");
+    return NULL;
+}
+
+void
+mh_folder_close(mh_folder *const mhf)
+{
+    free(mhf->name);
+    free(mhf->path);
+    ivector_free(mhf->seqnum);
+    folder_free(mhf->mp);
+
+    free(mhf);
+}
+
+/* TODO Factor just a few common message selection routines out of all these
+ * after message selection has been factored out of all uip programs. */
+
+static void
+load_unseen_sequences_for_scan(mh_folder *const mhf)
+{
+    /*
+     * Get the sequence number for each sequence
+     * specified by Unseen-Sequence
+     */
+    char const *const cp = context_find (usequence);
+    if (cp != NULL && *cp != 0) {
+        char *const dp = mh_xstrdup(cp);
+        int i = 0;
+        for (char *const *ap = brkstring (dp, " ", "\n"); ap && *ap; i++, ap++) {
+            ivector_push_back (mhf->seqnum, seq_getnum (mhf->mp, *ap));
+        }
+        mhf->num_unseen_seq = i;
+        free(dp);
+    }
+}
+
+struct msgs *
+mh_managed_folder_read (char *, int);
+
+static struct msgs *
+managed_select(mh_folder *const mhf, struct msgs_array const msgs, int const need_unseen)
+{
+    mh_folder_manager_client *mfmc;
+    if (!mh_folder_manager_connect(&mfmc, mhf->mmf))
+        die("TODO: mh_folder_manager_connect");
+    if (!mh_folder_manager_pick(mfmc, mhf->name, msgs, need_unseen))
+        die("TODO mh_folder_manager_pick");
+    if (!mh_folder_manager_close(mfmc))
+        die("TODO mh_folder_manager_close");
+    struct msgs *mp = mh_managed_folder_read(mhf->name, 1);
+    if (mp == NULL)
+        adios(mhf->name, "TODO mh_managed_folder_read");
+    // See comments above mh_managed_folder_read for explanation.
+    if (!m_convert(mp, "all")) {
+       done(1);
+    }
+    return mp;
+}
+
+static struct msgs *
+managed_select_and_mark_seen(mh_folder *const mhf, struct msgs_array const msgs)
+{
+    mh_folder_manager_client *mfmc;
+    if (!mh_folder_manager_connect(&mfmc, mhf->mmf))
+        die("TODO: mh_folder_manager_connect");
+    svector_t sequences = svector_create (0);
+    // TODO Unseen-Sequence
+    svector_push_back(sequences, "unseen");
+    bool ok = mh_folder_manager_mark_del(mfmc, mhf->name, msgs, sequences);
+    svector_free(sequences);
+    if (!ok)
+        die("TODO mh_folder_manager_mark_del");
+    if (!mh_folder_manager_close(mfmc))
+        die("TODO mh_folder_manager_close");
+    struct msgs *mp = mh_managed_folder_read(mhf->name, 1);
+    if (mp == NULL)
+        adios(mhf->name, "TODO mh_managed_folder_read");
+    // See comments above mh_managed_folder_read for explanation.
+    if (!m_convert(mp, "all")) {
+       done(1);
+    }
+    return mp;
+}
+
+struct msgs const *
+mh_folder_select_and_chdir_for_scan(mh_folder *const mhf, struct msgs_array const msgs)
+{
+    if (chdir (mhf->path) == NOTOK)
+        adios (mhf->path, "unable to change directory to");
+
+    if (mhf->mmf == NULL) {
+        /* read folder and create message structure */
+        if (!(mhf->mp = folder_read (mhf->name, 1)))
+            die("unable to read folder %s", mhf->name);
+
+        /* check for empty folder */
+        if (mhf->mp->nummsg == 0)
+            die("no messages in %s", mhf->name);
+
+        /* parse all the message ranges/sequences and set SELECTED */
+        for (int i = 0; i < msgs.size; i++) {
+            if (!m_convert (mhf->mp, msgs.msgs[i]))
+                done(1);
+        }
+    } else {
+        mhf->mp = managed_select(mhf, msgs, /* need_unseen */ 1);
+    }
+
+    /* set the Previous-Sequence */
+    seq_setprev (mhf->mp);
+
+    /* update current folder         */
+    context_replace (pfolder, mhf->name);
+    /* synchronize message sequences */
+    seq_save (mhf->mp);
+    /* save the context file         */
+    context_save ();
+
+    // TODO Should be part of Open or Read?
+    load_unseen_sequences_for_scan(mhf);
+
+    return mhf->mp;
+}
+
+struct msgs const *
+mh_folder_select_and_chdir_and_mark_seen(struct msgs_array *const vec, mh_folder *const mhf, struct msgs_array const msgs)
+{
+    if (chdir (mhf->path) == NOTOK)
+        adios (mhf->path, "unable to change directory to");
+
+    if (mhf->mmf == NULL) {
+        /* read folder and create message structure */
+        if (!(mhf->mp = folder_read (mhf->name, 1)))
+            die("unable to read folder %s", mhf->name);
+
+        /* check for empty folder */
+        if (mhf->mp->nummsg == 0)
+            die("no messages in %s", mhf->name);
+
+        /* parse all the message ranges/sequences and set SELECTED */
+        for (int i = 0; i < msgs.size; i++) {
+            if (!m_convert (mhf->mp, msgs.msgs[i]))
+                done(1);
+        }
+
+        /*
+         * Set the SELECT_UNSEEN bit for all the SELECTED messages,
+         * since we will use that as a tag to know which messages
+         * to remove from the "unseen" sequence.
+         */
+        for (int msgnum = mhf->mp->lowsel; msgnum <= mhf->mp->hghsel; msgnum++) {
+            if (is_selected(mhf->mp, msgnum))
+                set_unseen (mhf->mp, msgnum);
+        }
+
+        seq_setprev (mhf->mp);      /* set the Previous-Sequence */
+        seq_setunseen (mhf->mp, 1); /* unset the Unseen-Sequence */
+
+        // TODO Can we combine this with the previous loop?  Tests passed...
+        for (int msgnum = mhf->mp->lowsel; msgnum <= mhf->mp->hghsel; msgnum++)
+            if (is_selected(mhf->mp, msgnum))
+                app_msgarg(vec, mh_xstrdup(m_name (msgnum)));
+
+        seq_setcur (mhf->mp, mhf->mp->hghsel);      /* update current message  */
+        seq_save (mhf->mp);                         /* synchronize sequences   */
+    } else {
+        mhf->mp = managed_select_and_mark_seen(mhf, msgs);
+        for (int msgnum = mhf->mp->lowsel; msgnum <= mhf->mp->hghsel; msgnum++)
+            if (is_selected(mhf->mp, msgnum))
+                app_msgarg(vec, mh_xstrdup(m_name (msgnum)));
+        seq_setcur (mhf->mp, mhf->mp->hghsel);      /* update current message  */
+        seq_save (mhf->mp);                         /* synchronize sequences   */
+    }
+
+    return mhf->mp;
+}
+
+bool
+mh_folder_message_is_unseen_for_scan(mh_folder *const mhf, int const msgnum)
+{
+    for (int i = 0; i < mhf->num_unseen_seq; i++) {
+        if (in_sequence(mhf->mp, ivector_at (mhf->seqnum, i), msgnum)) {
+            return true;
+        }
+    }
+    return false;
+}
+
+/* Mostly identical to scan version.  Why can't this use msgs_array? */
+struct msgs const *
+mh_folder_select_and_chdir_for_send(mh_folder *const mhf, char *const *const msgs, size_t const msg_count)
+{
+    if (chdir (mhf->path) == NOTOK)
+        adios (mhf->path, "unable to change directory to");
+
+    if (mhf->mmf != NULL) {
+        // TODO Leaks msgs_array.msgs.  Have send own the msgs_array.
+        struct msgs_array msgsa = { 0, 0, NULL };
+        for (size_t i = 0; i < msg_count; i++) {
+            app_msgarg(&msgsa, msgs[i]);
+        }
+        return managed_select(mhf, msgsa, /* need_unseen */ 0);
+    }
+
+    /* read folder and create message structure */
+    if (!(mhf->mp = folder_read (mhf->name, 1)))
+        die("unable to read folder %s", mhf->name);
+
+    /* check for empty folder */
+    if (mhf->mp->nummsg == 0)
+        die("no messages in %s", mhf->name);
+
+    /* parse all the message ranges/sequences and set SELECTED */
+    for (size_t i = 0; i < msg_count; i++) {
+        if (!m_convert (mhf->mp, msgs[i]))
+            done(1);
+    }
+    /* set the Previous-Sequence */
+    seq_setprev (mhf->mp);
+
+    return mhf->mp;
+}
index 18a9ec1..76dad51 100644 (file)
@@ -26,7 +26,7 @@
  */
 
 struct msgs *
-folder_read (char *name, int lockflag)
+folder_read (char *in_name, int lockflag)
 {
     int msgnum, len, *mi;
     struct msgs *mp;
@@ -35,7 +35,7 @@ folder_read (char *name, int lockflag)
     struct bvector *v;
     size_t i;
 
-    name = m_mailpath (name);
+    char *const name = m_mailpath (in_name);
     if (!(dd = opendir (name))) {
        free (name);
        return NULL;
index 1ab1163..d9e7a8f 100644 (file)
@@ -14,7 +14,8 @@
 char **
 getarguments (char *invo_name, int argc, char **argv, int check_context)
 {
-    char *cp = NULL, **ap = NULL, **bp = NULL, **arguments = NULL;
+    char *cp = NULL, **bp = NULL, **arguments = NULL;
+    char *const *ap = NULL;
     int n = 0;
 
     /*
@@ -25,8 +26,8 @@ getarguments (char *invo_name, int argc, char **argv, int check_context)
        ap = brkstring (cp, " ", "\n"); /* split string */
 
        /* Count number of arguments split */
-       bp = ap;
-       while (*bp++)
+       char *const *counter = ap;
+       while (*counter++)
            n++;
     }
 
index be12e50..8a6e3bf 100644 (file)
@@ -40,8 +40,8 @@ static int m_conv (struct msgs *, char *, int);
 static int attr (struct msgs *, char *);
 
 
-int
-m_convert (struct msgs *mp, char *name)
+static int
+convert (struct msgs *mp, char *const name, const bool quiet)
 {
     int first, last, found, count, err;
     char *bp, *cp;
@@ -103,7 +103,7 @@ badmsg:
 
            case BADLST: 
 badlist:
-               inform("bad message list %s", name);
+               if (!quiet) inform("bad message list %s", name);
                break;
 
            case BADNEW:
@@ -249,6 +249,12 @@ single:
     return 1;
 }
 
+int
+m_convert (struct msgs *mp, char *name)
+{
+    return convert (mp, name, false);
+}
+
 /*
  * Convert the various message names to
  * their numeric values.
diff --git a/sbr/managed_folders.c b/sbr/managed_folders.c
new file mode 100644 (file)
index 0000000..5bff3a5
--- /dev/null
@@ -0,0 +1,146 @@
+/* managed_folders.c
+ *
+ * This code is Copyright (c) 2020, by the authors of nmh.  See the
+ * COPYRIGHT file in the root directory of the nmh distribution for
+ * complete copyright information.
+ */
+
+#include "h/mh.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "h/managed_folders.h"
+#include "h/utils.h"
+#include "sbr/arglist.h"
+#include "sbr/concat.h"
+#include "sbr/context_find.h"
+#include "sbr/error.h"
+
+struct mh_managed_folders {
+    int status;
+    const char *error_message;
+
+    int count;
+    char **names;
+    mh_managed_folder **folders;
+};
+
+struct mh_managed_folder {
+    char const *name;
+    size_t name_len;
+    char *manager_path;
+    char *socket_path;
+    mh_managed_folders *mmfs;
+};
+
+void
+mh_managed_folder_set_err(mh_managed_folder *const mmf, const char *const error_message)
+{
+    if (error_message == NULL)
+       die("mh_managed_folder_set_err called with NULL error message");
+    mmf->mmfs->status = NOTOK;
+    mmf->mmfs->error_message = error_message;
+}
+
+bool
+mh_managed_folders_open(mh_managed_folders **const result)
+{
+    mh_managed_folders *mmfs;
+    NEW(mmfs);
+    *result = mmfs;
+
+    char *const managed_folders = context_find("Managed-Folders");
+    if (managed_folders == NULL) {
+        mmfs->count = 0;
+    } else {
+        // TODO argsplit is a poor choice here
+        char *unused;
+        mmfs->names = argsplit(managed_folders, &unused, &(mmfs->count));
+        mmfs->folders = mh_xmalloc(mmfs->count * sizeof *mmfs->folders);
+        for (int i = 0; i < mmfs->count; i++) {
+            NEW(mmfs->folders[i]);
+            mmfs->folders[i]->name_len = strlen(mmfs->names[i]);
+           char *key = concat(mmfs->names[i], "-manager", NULL);
+            // TODO free these
+            mmfs->folders[i]->name = mmfs->names[i];
+            mmfs->folders[i]->manager_path= context_find(key);
+           free(key);
+           key = concat(mmfs->names[i], "-socket", NULL);
+            mmfs->folders[i]->socket_path= context_find(key);
+           free(key);
+            mmfs->folders[i]->mmfs = mmfs;
+        }
+        free(unused);
+    }
+
+    mmfs->status = OK;
+    mmfs->error_message = NULL;
+    return true;
+}
+
+char const *
+mh_managed_folders_get_err_string(mh_managed_folders *const mmfs)
+{
+    if (mmfs->status == OK) {
+       return "mh_managed_folders_get_err_string called without error";
+    }
+    if (mmfs->error_message == NULL) {
+       return "TODO mh_managed_folders_get_err_string require non-OK and error_message";
+    }
+    return mmfs->error_message;
+}
+
+char const *
+mh_managed_folder_get_err_string(mh_managed_folder *const mmf)
+{
+    return mh_managed_folders_get_err_string(mmf->mmfs);
+}
+
+mh_managed_folder *
+mh_managed_folder_lookup(
+    mh_managed_folders const *const mmfs, char const *const name, size_t const name_len)
+{
+    for (int i = 0; i < mmfs->count; i++) {
+        mh_managed_folder *const folder = mmfs->folders[i];
+        if (folder->name_len <= name_len
+            && strncmp(mmfs->names[i], name, folder->name_len) == 0
+            && (folder->name_len == name_len
+                || name[folder->name_len] == '/')) {
+            return folder;
+        }
+    }
+    return NULL;
+}
+
+char const *
+mh_managed_folder_manager_path(const mh_managed_folder *mmf)
+{
+    return mmf->manager_path;
+}
+
+char const *
+mh_managed_folder_socket_path(const mh_managed_folder *mmf)
+{
+    return mmf->socket_path;
+}
+
+void
+mh_managed_folders_free(mh_managed_folders *const mmfs)
+{
+    int i;
+    for (i = 0; i < mmfs->count; i++) {
+        free(mmfs->names[i]);
+        free(mmfs->folders[i]);
+    }
+    free(mmfs->names);
+    free(mmfs->folders);
+    free(mmfs);
+}
+
+char const *
+mh_managed_folder_name(mh_managed_folder const *mmf)
+{
+    return mmf->name;
+}
index 2d70b37..0c19bcd 100644 (file)
@@ -415,7 +415,7 @@ netsec_read(netsec_context *nsc, void *buffer, size_t size, char **errstr)
        nsc->ns_inbuflen -= size;
     }
 
-    return OK;
+    return retlen;
 }
 
 /*
@@ -1110,7 +1110,7 @@ netsec_set_sasl_params(netsec_context *nsc, const char *service,
  * library when asked.
  */
 
-int
+static int
 netsec_get_user(void *context, int id, const char **result,
                    unsigned int *len)
 {
@@ -1177,7 +1177,7 @@ netsec_negotiate_sasl(netsec_context *nsc, const char *mechlist, char **errstr)
 #ifdef CYRUS_SASL
     sasl_security_properties_t secprops;
     const char *chosen_mech;
-    const unsigned char *saslbuf;
+    unsigned char *saslbuf;
     unsigned char *outbuf;
     unsigned int saslbuflen, outbuflen;
     sasl_ssf_t *ssf;
@@ -1213,11 +1213,10 @@ netsec_negotiate_sasl(netsec_context *nsc, const char *mechlist, char **errstr)
      */
 
     if (nsc->sasl_mech) {
-       char **str, *mlist = getcpy(mechlist);
+       char *mlist = getcpy(mechlist);
+        char *const *const str = brkstring(mlist, " ", NULL);
        int i;
 
-       str = brkstring(mlist, " ", NULL);
-
        for (i = 0; str[i] != NULL; i++) {
            if (strcasecmp(nsc->sasl_mech, str[i]) == 0) {
                break;
@@ -1256,16 +1255,12 @@ netsec_negotiate_sasl(netsec_context *nsc, const char *mechlist, char **errstr)
 
        nsc->sasl_chosen_mech = mh_xstrdup(nsc->sasl_mech);
 
-       if (mh_oauth_do_xoauth(nsc->ns_userid, nsc->oauth_service,
-                              &xoauth_client_res, &xoauth_client_res_len,
-                              nsc->ns_snoop ? stderr : NULL) != OK) {
-           netsec_err(errstr, "Internal error: Unable to get OAuth2 "
-                      "bearer token");
-           return NOTOK;
-       }
+       mh_oauth_do_xoauth(nsc->ns_userid, nsc->oauth_service,
+                           &xoauth_client_res, &xoauth_client_res_len,
+                           nsc->ns_snoop ? stderr : NULL);
 
        rc = nsc->sasl_proto_cb(NETSEC_SASL_START, xoauth_client_res,
-                               xoauth_client_res_len, NULL, 0,
+                               xoauth_client_res_len, NULL, NULL,
                                nsc->sasl_proto_context, errstr);
        free(xoauth_client_res);
 
@@ -1279,7 +1274,7 @@ netsec_negotiate_sasl(netsec_context *nsc, const char *mechlist, char **errstr)
         * error.
         */
 
-       rc = nsc->sasl_proto_cb(NETSEC_SASL_FINISH, NULL, 0, NULL, 0,
+       rc = nsc->sasl_proto_cb(NETSEC_SASL_FINISH, NULL, 0, NULL, NULL,
                                nsc->sasl_proto_context, errstr);
 
        if (rc != OK) {
@@ -1290,9 +1285,9 @@ netsec_negotiate_sasl(netsec_context *nsc, const char *mechlist, char **errstr)
             * NOT get a success message at this point.
             */
            free(*errstr);
-           nsc->sasl_proto_cb(NETSEC_SASL_WRITE, NULL, 0, NULL, 0,
+           nsc->sasl_proto_cb(NETSEC_SASL_WRITE, NULL, 0, NULL, NULL,
                               nsc->sasl_proto_context, NULL);
-           rc = nsc->sasl_proto_cb(NETSEC_SASL_FINISH, NULL, 0, NULL, 0,
+           rc = nsc->sasl_proto_cb(NETSEC_SASL_FINISH, NULL, 0, NULL, NULL,
                                    nsc->sasl_proto_context, errstr);
            if (rc == 0) {
                netsec_err(errstr, "Unexpected success after OAuth failure!");
@@ -1374,7 +1369,7 @@ netsec_negotiate_sasl(netsec_context *nsc, const char *mechlist, char **errstr)
     if (nsc->ns_snoop)
        fprintf(stderr, "Chosen sasl mechanism: %s\n", chosen_mech);
 
-    if (nsc->sasl_proto_cb(NETSEC_SASL_START, saslbuf, saslbuflen, NULL, 0,
+    if (nsc->sasl_proto_cb(NETSEC_SASL_START, saslbuf, saslbuflen, NULL, NULL,
                           nsc->sasl_proto_context, errstr) != OK)
        return NOTOK;
 
@@ -1390,7 +1385,7 @@ netsec_negotiate_sasl(netsec_context *nsc, const char *mechlist, char **errstr)
 
        if (nsc->sasl_proto_cb(NETSEC_SASL_READ, NULL, 0, &outbuf, &outbuflen,
                               nsc->sasl_proto_context, errstr) != OK) {
-           nsc->sasl_proto_cb(NETSEC_SASL_CANCEL, NULL, 0, NULL, 0,
+           nsc->sasl_proto_cb(NETSEC_SASL_CANCEL, NULL, 0, NULL, NULL,
                               nsc->sasl_proto_context, NULL);
            return NOTOK;
        }
@@ -1403,15 +1398,15 @@ netsec_negotiate_sasl(netsec_context *nsc, const char *mechlist, char **errstr)
        if (rc != SASL_OK && rc != SASL_CONTINUE) {
            netsec_err(errstr, "SASL client negotiation failed: %s",
                       sasl_errdetail(nsc->sasl_conn));
-           nsc->sasl_proto_cb(NETSEC_SASL_CANCEL, NULL, 0, NULL, 0,
+           nsc->sasl_proto_cb(NETSEC_SASL_CANCEL, NULL, 0, NULL, NULL,
                               nsc->sasl_proto_context, NULL);
            return NOTOK;
        }
 
        if (nsc->sasl_proto_cb(NETSEC_SASL_WRITE, saslbuf, saslbuflen,
-                              NULL, 0, nsc->sasl_proto_context,
+                              NULL, NULL, nsc->sasl_proto_context,
                               errstr) != OK) {
-           nsc->sasl_proto_cb(NETSEC_SASL_CANCEL, NULL, 0, NULL, 0,
+           nsc->sasl_proto_cb(NETSEC_SASL_CANCEL, NULL, 0, NULL, NULL,
                               nsc->sasl_proto_context, NULL);
            return NOTOK;
        }
@@ -1422,7 +1417,7 @@ netsec_negotiate_sasl(netsec_context *nsc, const char *mechlist, char **errstr)
      * from the server.
      */
 
-    if (nsc->sasl_proto_cb(NETSEC_SASL_FINISH, NULL, 0, NULL, 0,
+    if (nsc->sasl_proto_cb(NETSEC_SASL_FINISH, NULL, 0, NULL, NULL,
                           nsc->sasl_proto_context, errstr) != OK) {
        /*
         * At this point we can't really send an abort since the SASL dialog
index c4509fd..776332f 100644 (file)
@@ -134,7 +134,7 @@ static bool get_json_strings(const char *, size_t, FILE *, ...) ENDNULL;
 static bool make_query_url(char *, size_t, CURL *, const char *, ...) ENDNULL;
 static bool post(struct curl_ctx *, const char *, const char *);
 
-int
+void
 mh_oauth_do_xoauth(const char *user, const char *svc, unsigned char **oauth_res,
                   size_t *oauth_res_len, FILE *log)
 {
@@ -154,7 +154,7 @@ mh_oauth_do_xoauth(const char *user, const char *svc, unsigned char **oauth_res,
     fp = lkfopendata(fn, "r+", &failed_to_lock);
     if (fp == NULL) {
         if (errno == ENOENT) {
-            die("no credentials -- run mhlogin -saslmech xoauth2 -authservice %s", svc);
+            adios(fn, "credential file not found -- run mhlogin -saslmech xoauth2 -authservice %s", svc);
         }
         adios(fn, "failed to open");
     }
@@ -196,8 +196,6 @@ mh_oauth_do_xoauth(const char *user, const char *svc, unsigned char **oauth_res,
     mh_oauth_free(ctx);
 
     *oauth_res = (unsigned char *) client_res;
-
-    return OK;
 }
 
 static bool
index 9c12367..4d84325 100644 (file)
@@ -23,12 +23,12 @@ static sigjmp_buf sigenv;
 static void intrser (int);
 
 
-char **
+char *const *
 read_switch_multiword (const char *prompt, const struct swit *ansp)
 {
     int i;
     SIGNAL_HANDLER istat = NULL;
-    char *cp, **cpp;
+    char *cp;
 
     if (sigsetjmp(sigenv, 1)) {
        SIGNAL (SIGINT, istat);
@@ -77,7 +77,7 @@ read_switch_multiword (const char *prompt, const struct swit *ansp)
            print_sw (ALL, ansp, "", stdout);
            continue;
        }
-       cpp = brkstring (ansbuf, " ", NULL);
+       char *const *cpp = brkstring (ansbuf, " ", NULL);
        switch (smatch (*cpp, ansp)) {
            case AMBIGSW: 
                ambigsw (*cpp, ansp);
index 98205e9..1f3ea6e 100644 (file)
@@ -10,4 +10,4 @@
  * When one matches, return a pointer to an array of pointers to the words.
  * Return NULL on EOF, interrupt, or other error.
  */
-char **read_switch_multiword(const char *, const struct swit *);
+char *const *read_switch_multiword(const char *, const struct swit *);
index 67d405c..2e0e66f 100644 (file)
@@ -28,10 +28,10 @@ static char ansbuf[BUFSIZ];
  * getans, but with readline support
  */
 
-char **
+char *const *
 read_switch_multiword_via_readline(char *prompt, struct swit *ansp)
 {
-    char *ans, **cpp;
+    char *ans;
 
     initialize_readline();
     rl_cmds = ansp;
@@ -56,7 +56,7 @@ read_switch_multiword_via_readline(char *prompt, struct swit *ansp)
        ansbuf[sizeof(ansbuf) - 1] = '\0';
         free(ans);
 
-       cpp = brkstring(ansbuf, " ", NULL);
+       char *const *cpp = brkstring(ansbuf, " ", NULL);
        switch (smatch(*cpp, ansp)) {
            case AMBIGSW:
                ambigsw(*cpp, ansp);
@@ -92,7 +92,7 @@ static char *
 nmh_command_generator(const char *text, int state)
 {
     static int list_index, len;
-    char *name, *p;
+    char *name;
     char buf[256];
 
     if (!state) {
@@ -104,7 +104,7 @@ nmh_command_generator(const char *text, int state)
        list_index++;
        strncpy(buf, name, sizeof(buf));
        buf[sizeof(buf) - 1] = '\0';
-       p = *brkstring(buf, " ", NULL);
+       const char *p = *brkstring(buf, " ", NULL);
        if (strncmp(p, text, len) == 0)
             return strdup(p);
     }
index e1ac82d..0b7a693 100644 (file)
@@ -8,5 +8,5 @@
  * Same as read_switch_multiword but using readline(3) for input.
  */
 #ifdef READLINE_SUPPORT
-char **read_switch_multiword_via_readline(char *, struct swit *);
+char *const *read_switch_multiword_via_readline(char *, struct swit *);
 #endif /* READLINE_SUPPORT */
index b4442f1..2975500 100644 (file)
@@ -16,7 +16,7 @@
 
 
 int
-refile (char **arg, char *file)
+refile (char *const *arg, char *const file)
 {
     pid_t pid;
     int vecp;
index 3930a85..6d58fb4 100644 (file)
@@ -5,4 +5,4 @@
  * COPYRIGHT file in the root directory of the nmh distribution for
  * complete copyright information. */
 
-int refile(char **, char *);
+int refile(char *const *, char *);
diff --git a/sbr/rust/Cargo.toml b/sbr/rust/Cargo.toml
new file mode 100644 (file)
index 0000000..de10706
--- /dev/null
@@ -0,0 +1,8 @@
+[package]
+name = "nmh"
+# TODO derive from nmh version - or not; does it even matter?  these are all internal crates
+version = "0.0.0"
+edition = "2018"
+
+[dependencies]
+libc = "0.2.78"
diff --git a/sbr/rust/bindgen.h b/sbr/rust/bindgen.h
new file mode 100644 (file)
index 0000000..fa6bcf2
--- /dev/null
@@ -0,0 +1,8 @@
+#include "h/mh.h"
+#include "h/netsec.h"
+#include "h/utils.h"
+#include "sbr/base64.h"
+#include "sbr/client.h"
+#include "sbr/context_find.h"
+#include "sbr/dtime.h"
+#include "sbr/m_getfld.h"
diff --git a/sbr/rust/build.rs.in b/sbr/rust/build.rs.in
new file mode 100644 (file)
index 0000000..6f83917
--- /dev/null
@@ -0,0 +1,10 @@
+fn main() {
+    println!("cargo:rustc-flags=@LDFLAGS@");
+    println!("cargo:rustc-link-search=native=@abs_top_builddir@/sbr");
+
+    println!("cargo:rustc-link-lib=static=mh");
+    println!("cargo:rustc-link-lib=sasl2");
+    println!("cargo:rustc-link-lib=curl");
+    println!("cargo:rustc-link-lib=ssl");
+    println!("cargo:rustc-link-lib=crypto");
+}
diff --git a/sbr/rust/src/base64.rs b/sbr/rust/src/base64.rs
new file mode 100644 (file)
index 0000000..b641b92
--- /dev/null
@@ -0,0 +1,23 @@
+//! routines for converting to base64 (base64.h wrapper)
+
+use crate::sbr;
+use crate::str::{NString, UnsafeNString};
+
+pub fn encode_raw(bytes: &[u8]) -> NString {
+    let len = base64_size(bytes.len());
+    let mut b64_raw: UnsafeNString = NString::with_capacity(len).into_unsafe();
+    let b64_raw_unowned_ptr: *mut std::os::raw::c_uchar = b64_raw.mut_ptr();
+    unsafe {
+        sbr::writeBase64raw(
+            bytes.as_ptr(),
+            bytes.len() as sbr::size_t,
+            b64_raw_unowned_ptr,
+        );
+    }
+    unsafe { b64_raw.to_nstring(len) }.expect("writeBase64raw writes ASCII")
+}
+
+// copied from base64.h
+fn base64_size(x: usize) -> usize {
+    ((x + 2) / 3) * 4
+}
diff --git a/sbr/rust/src/client.rs b/sbr/rust/src/client.rs
new file mode 100644 (file)
index 0000000..390960a
--- /dev/null
@@ -0,0 +1,86 @@
+//! connect to a server (client.h wrapper)
+
+use std::ffi::{CStr, CString};
+use std::os::raw::{c_char, c_int};
+
+use crate::sbr;
+use crate::str::NString;
+use crate::{Error, Result};
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct Connector {
+    server: Option<CString>,
+    service: Option<CString>,
+}
+
+impl Connector {
+    pub fn new() -> Self {
+        Self {
+            server: None,
+            service: None,
+        }
+    }
+
+    /// Must be valid UTF-8.  All other error-checking happens in [Connector::connect].
+    pub fn server(mut self, s: &str) -> Result<Self> {
+        self.server = Some(CString::new(s)?);
+        Ok(self)
+    }
+
+    /// Must be valid UTF-8.  All other error-checking happens in [Connector::connect].
+    pub fn service(mut self, s: &str) -> Result<Self> {
+        self.service = Some(CString::new(s)?);
+        Ok(self)
+    }
+
+    /// Panics if called without setting server.  Don't do that.
+    pub fn get_server(&self) -> &CStr {
+        self.server
+            .as_ref()
+            .expect("get_server called without server")
+    }
+
+    /// `connect(2)` to specified [Connector::server] and [Connector::service] and return the socket.
+    /// Panics if called without setting server and service
+    pub fn connect(&self) -> Result<std::os::unix::io::RawFd> {
+        let server_ptr = self
+            .server
+            .as_ref()
+            .expect("connect called without server")
+            .as_ptr() as *const c_char;
+        let service_ptr = self
+            .service
+            .as_ref()
+            .expect("connect called without service")
+            .as_ptr() as *const c_char;
+        // popsbr.c uses BUFSIZ which is 1024 (at least on FreeBSD 12.2).
+        let error_message_size: c_int = 1024;
+        let mut error_message = NString::with_capacity(error_message_size as usize);
+        error_message.push_ascii_str("client() provided no error message")?;
+        let mut error_message_raw = error_message.into_unsafe();
+        let socket = {
+            let error_message_raw_ptr = error_message_raw.mut_ptr() as *mut c_char;
+            unsafe {
+                sbr::client(
+                    server_ptr,
+                    service_ptr,
+                    error_message_raw_ptr,
+                    error_message_size,
+                    1,
+                )
+            }
+        };
+        if socket < 0 {
+            let error_message = unsafe { error_message_raw.to_nstring_sized_by_null_byte()? };
+            Err(Error::from(error_message))
+        } else {
+            Ok(socket)
+        }
+    }
+}
+
+impl Default for Connector {
+    fn default() -> Self {
+        Self::new()
+    }
+}
diff --git a/sbr/rust/src/error.rs b/sbr/rust/src/error.rs
new file mode 100644 (file)
index 0000000..2eb20db
--- /dev/null
@@ -0,0 +1,132 @@
+use std::convert::TryFrom as _;
+
+use crate::str::{NString, NStringWithFree, OrString};
+
+/// [std::error::Error] that supports three kinds of error:
+/// - [Error::new] - a direct, base `Error` containing only an error message
+/// - [Error::wrap] - a wrapper around any other `Error` without additional information
+/// - [Error::describe] - a wrapper around any other `Error` with a description prefixed to the wrapped error's message
+///
+/// See below for examples and details on the debug and display formats of each kind.
+#[derive(Debug)]
+pub struct Error(Kind);
+
+#[derive(Debug)]
+enum Kind {
+    Direct(OrString),
+    Wrapper(Box<dyn std::error::Error>),
+    Described(String, Box<dyn std::error::Error>),
+}
+
+impl Error {
+    /// ```
+    /// assert_eq!("direct error", format!("{}", nmh::Error::new("direct error")));
+    /// assert_eq!(
+    ///     "Error(Direct(String(\"direct error\")))",
+    ///     format!("{:?}", nmh::Error::new("direct error"))
+    /// );
+    /// ```
+    pub fn new<T: Into<OrString>>(message: T) -> Self {
+        Self(Kind::Direct(message.into()))
+    }
+
+    /// ```
+    /// let error = "one".parse::<i32>().unwrap_err();
+    /// assert_eq!(
+    ///     "invalid digit found in string",
+    ///     format!("{}", nmh::Error::wrap(error.clone()))
+    /// );
+    /// assert_eq!(
+    ///     "Error(Wrapper(ParseIntError { kind: InvalidDigit }))",
+    ///     format!("{:?}", nmh::Error::wrap(error.clone()))
+    /// );
+    /// ```
+    pub fn wrap<E: 'static + std::error::Error>(e: E) -> Self {
+        Self(Kind::Wrapper(Box::new(e)))
+    }
+
+    /// ```
+    /// let error = "one".parse::<i32>().unwrap_err();
+    /// assert_eq!(
+    ///     "parse(one):  invalid digit found in string",
+    ///     format!(
+    ///         "{}",
+    ///         nmh::Error::describe(error.clone(), format_args!("parse(one)"))
+    ///     )
+    /// );
+    /// assert_eq!(
+    ///     "Error(Described(\"parse(one):  invalid digit found in string\", ParseIntError { kind: InvalidDigit }))",
+    ///     format!(
+    ///         "{:?}",
+    ///         nmh::Error::describe(error.clone(), format_args!("parse(one)"))
+    ///     )
+    /// );
+    /// ```
+    pub fn describe<E: 'static + std::error::Error>(e: E, fmt: std::fmt::Arguments) -> Self {
+        Self(Kind::Described(format!("{}:  {}", fmt, e), Box::new(e)))
+    }
+}
+
+impl std::fmt::Display for Error {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match &self.0 {
+            Kind::Direct(message) => message.fmt(f),
+            Kind::Wrapper(source) => source.fmt(f),
+            Kind::Described(description, _) => description.fmt(f),
+        }
+    }
+}
+
+impl std::error::Error for Error {
+    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+        match &self.0 {
+            Kind::Direct(_) => None,
+            Kind::Wrapper(source) => Some(source.as_ref()),
+            Kind::Described(_, source) => Some(source.as_ref()),
+        }
+    }
+}
+
+mod convert {
+    use super::*;
+
+    impl From<std::ffi::NulError> for Error {
+        fn from(e: std::ffi::NulError) -> Self {
+            Self::wrap(e)
+        }
+    }
+
+    impl From<std::num::TryFromIntError> for Error {
+        fn from(e: std::num::TryFromIntError) -> Self {
+            Self::wrap(e)
+        }
+    }
+
+    impl From<std::str::Utf8Error> for Error {
+        fn from(e: std::str::Utf8Error) -> Self {
+            Self::wrap(e)
+        }
+    }
+
+    impl From<NString> for Error {
+        fn from(ns: NString) -> Self {
+            Self(Kind::Direct(OrString::N(ns)))
+        }
+    }
+
+    impl From<NStringWithFree> for Error {
+        fn from(s: NStringWithFree) -> Self {
+            // TODO Expand OrString rather than copying.
+            match String::try_from(s) {
+                Err(e) => e,
+                Ok(s) => Self::new(s),
+            }
+        }
+    }
+
+    impl From<Error> for String {
+        fn from(e: Error) -> String {
+            e.to_string()
+        }
+    }
+}
diff --git a/sbr/rust/src/folder.rs b/sbr/rust/src/folder.rs
new file mode 100644 (file)
index 0000000..8db9df4
--- /dev/null
@@ -0,0 +1,33 @@
+use std::path::{Path, PathBuf};
+
+use crate::{Error, Result};
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct Name(PathBuf);
+
+impl Name {
+    /// Construct from a [PathBuf] which MUST be a relative path encoded in UTF-8, else return [Error].
+    pub fn from_utf8(name: PathBuf) -> Result<Self> {
+        if name.is_absolute() {
+            Err(Error::new("folder name must be relative".to_owned()))
+        } else {
+            name.to_str()
+                .ok_or_else(|| Error::new("folder name must be in UTF-8"))?;
+            Ok(Self(name))
+        }
+    }
+
+    pub fn as_path(&self) -> &Path {
+        self.0.as_path()
+    }
+
+    pub fn to_str(&self) -> &str {
+        self.0.to_str().expect("checked in constructor")
+    }
+}
+
+impl std::fmt::Display for Name {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        self.0.display().fmt(f)
+    }
+}
diff --git a/sbr/rust/src/hacks.rs b/sbr/rust/src/hacks.rs
new file mode 100644 (file)
index 0000000..5649f89
--- /dev/null
@@ -0,0 +1,17 @@
+// TODO other splits have this problem too; generalize
+/// `"".split(' ')` returns [""] lol wut (ノಠ益ಠ)ノ彡┻━┻
+/// ```
+/// let empty = "";
+/// assert_eq!(vec![""], empty.split(' ').collect::<Vec<&str>>());
+///
+/// // ( ͡° ͜ ʖ ͡°)
+/// let expected_split: Vec<&str> = vec![];
+/// assert_eq!(expected_split, nmh::hacks::split(empty).collect::<Vec<&str>>());
+/// ```
+pub fn split<'a>(s: &'a str) -> Box<dyn std::iter::Iterator<Item = &str> + 'a> {
+    if s.is_empty() {
+        Box::new(std::iter::empty())
+    } else {
+        Box::new(s.split(' '))
+    }
+}
diff --git a/sbr/rust/src/lib.rs b/sbr/rust/src/lib.rs
new file mode 100644 (file)
index 0000000..be87dfc
--- /dev/null
@@ -0,0 +1,61 @@
+pub mod base64;
+pub mod client;
+pub mod folder;
+pub mod hacks;
+pub mod message;
+pub mod netsec;
+pub mod profile;
+pub mod str;
+
+mod error;
+pub use error::Error;
+
+pub use netsec::netsec::Netsec;
+pub use profile::p::Profile;
+
+/// on error, nmh_init writes to stderr and this panics
+pub fn init(argv0: std::ffi::CString) -> Context {
+    let csptr = argv0.as_ptr();
+    // The only caller (imap-folder-manager) always wants the context.
+    let read_context = true;
+    // And also doesn't need to check version because it already happened.
+    let check_version = false;
+    if unsafe { sbr::nmh_init(csptr, read_context as i32, check_version as i32) } < 0 {
+        // nmh_init already spewed to stderr
+        panic!("nmh_init")
+    }
+    Context { _argv0: argv0 }
+}
+
+// Conceptually, things like netsec depend on this.  But it's currently all
+// globals and you just have to call things in the right magical order or things
+// break.  We use this to at least enforce things in Rust.
+pub struct Context {
+    _argv0: std::ffi::CString,
+}
+
+impl Context {
+    pub fn profile(&self) -> Profile {
+        Profile(self)
+    }
+}
+
+// Suppress all these warnings on bindgen's output.
+// TODO Yeesh.  Are they going to clean this up?
+#[allow(dead_code)]
+#[allow(deref_nullptr)]
+#[allow(improper_ctypes)]
+#[allow(non_camel_case_types)]
+#[allow(non_snake_case)]
+#[allow(non_upper_case_globals)]
+mod bindgen;
+pub(crate) mod sbr;
+
+/// Return a reasonably-human-readable description of the current time.
+/// # Safety
+/// As safe as `dtimenow`.
+pub unsafe fn dtimenow() -> &'static std::ffi::CStr {
+    std::ffi::CStr::from_ptr(bindgen::dtimenow(0))
+}
+
+type Result<T> = std::result::Result<T, Error>;
diff --git a/sbr/rust/src/message.rs b/sbr/rust/src/message.rs
new file mode 100644 (file)
index 0000000..a78195e
--- /dev/null
@@ -0,0 +1,59 @@
+use std::num::NonZeroI32;
+
+mod set;
+pub use set::Set;
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
+pub struct Number(NonZeroI32);
+
+impl Number {
+    pub fn one() -> Self {
+        Self(NonZeroI32::new(1).expect("1 is not 0"))
+    }
+
+    pub fn increment(&mut self) {
+        self.0 = NonZeroI32::new(self.0.get() + 1).expect("non-zero + 1 is not 0");
+    }
+}
+
+impl std::fmt::Display for Number {
+    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        self.0.fmt(formatter)
+    }
+}
+
+mod convert {
+    use super::*;
+
+    impl From<NonZeroI32> for Number {
+        fn from(n: NonZeroI32) -> Self {
+            Self(n)
+        }
+    }
+
+    impl From<Number> for i32 {
+        fn from(n: Number) -> i32 {
+            n.0.get()
+        }
+    }
+
+    impl From<Number> for NonZeroI32 {
+        fn from(n: Number) -> NonZeroI32 {
+            n.0
+        }
+    }
+
+    impl std::str::FromStr for Number {
+        type Err = crate::Error;
+        fn from_str(s: &str) -> Result<Self, Self::Err> {
+            let n: NonZeroI32 = s
+                .parse()
+                .map_err(|e| crate::Error::describe(e, format_args!("message number")))?;
+            if n.get() < 0 {
+                Err(crate::Error::new(format!("illegal message number {}", n)))
+            } else {
+                Ok(Number::from(n))
+            }
+        }
+    }
+}
diff --git a/sbr/rust/src/message/set.rs b/sbr/rust/src/message/set.rs
new file mode 100644 (file)
index 0000000..8f8e43b
--- /dev/null
@@ -0,0 +1,119 @@
+// TODO wrap sbr structures
+
+#[derive(Clone)]
+pub struct Set(Has);
+
+impl Set {
+    pub fn new() -> Self {
+        Self(Has::Set(std::collections::BTreeSet::new()))
+    }
+
+    pub fn all() -> Self {
+        Self(Has::All)
+    }
+
+    pub fn append(&mut self, other: &mut Self) {
+        if let Has::Set(set) = &mut self.0 {
+            match &mut other.0 {
+                Has::All => self.0 = Has::All,
+                Has::Set(other_set) => set.append(other_set),
+            }
+        }
+    }
+
+    pub fn insert(&mut self, msgnum: i32) -> bool {
+        match &mut self.0 {
+            Has::All => false,
+            Has::Set(set) => set.insert(msgnum),
+        }
+    }
+
+    pub fn remove(&mut self, msgnum: i32) -> bool {
+        match &mut self.0 {
+            Has::All => todo!(),
+            Has::Set(set) => set.remove(&msgnum),
+        }
+    }
+
+    pub fn clone_without(&self, other: &Self) -> Self {
+        match &self.0 {
+            Has::All => todo!(),
+            Has::Set(set) => match &other.0 {
+                Has::All => Self::new(),
+                Has::Set(other_set) => Self(Has::Set(set.difference(other_set).cloned().collect())),
+            },
+        }
+    }
+
+    pub fn ends(&self) -> Option<(i32, i32)> {
+        match &self.0 {
+            Has::All => Some((1, i32::MAX)),
+            Has::Set(set) => {
+                let mut i = set.iter();
+                match i.next() {
+                    None => None,
+                    Some(beg) => match i.next_back() {
+                        None => Some((*beg, *beg)),
+                        Some(end) => Some((*beg, *end)),
+                    },
+                }
+            }
+        }
+    }
+
+    pub fn contains(&self, msgnum: i32) -> bool {
+        match &self.0 {
+            Has::All => true,
+            Has::Set(set) => set.contains(&msgnum),
+        }
+    }
+
+    pub fn is_empty(&self) -> bool {
+        match &self.0 {
+            Has::All => false,
+            Has::Set(set) => set.is_empty(),
+        }
+    }
+}
+
+impl std::fmt::Display for Set {
+    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
+        match &self.0 {
+            Has::All => write!(f, "1-{}", i32::MAX),
+            Has::Set(set) => {
+                let mut space = "";
+                let (mut beg, mut end) = (-1, -1);
+                for msgnum in set.iter() {
+                    if beg == -1 && end == -1 {
+                        beg = *msgnum;
+                    } else if *msgnum != end + 1 {
+                        assert!(end > 0);
+                        if beg == end {
+                            write!(f, "{}{}", space, beg)?
+                        } else {
+                            write!(f, "{}{}-{}", space, beg, end)?
+                        }
+                        space = " ";
+                        beg = *msgnum;
+                    }
+                    end = *msgnum;
+                }
+                if beg > 0 {
+                    if beg == end {
+                        write!(f, "{}{}", space, beg)
+                    } else {
+                        write!(f, "{}{}-{}", space, beg, end)
+                    }
+                } else {
+                    Ok(())
+                }
+            }
+        }
+    }
+}
+
+#[derive(Clone)]
+enum Has {
+    All,
+    Set(std::collections::BTreeSet<i32>),
+}
diff --git a/sbr/rust/src/netsec.rs b/sbr/rust/src/netsec.rs
new file mode 100644 (file)
index 0000000..d7a1a76
--- /dev/null
@@ -0,0 +1,31 @@
+//! network-security library routines (netsec.h wrapper)
+//!
+//! This is a wrapper around netsec, a common set of routines to handle network security for things
+//! like SASL and OpenSSL.  See netsec.h for more information.
+//!
+//! Start with [crate::Netsec::builder].
+//!
+//! # Examples
+//!
+//! ```ignore
+//! use nmh::{client, netsec, Netsec};
+//!
+//! let mut builder: netsec::Builder = Netsec::builder()
+//!     .host(&std::ffi::CString::new("hostname")?);
+//! let mut connector: netsec::tls::Connector = builder.connect(client::Connector::new().server("...")?.build());
+//! let mut authenticator: netsec::sasl::Authenticator = connector.negotiate_tls()?;
+//! let mut nsc: Netsec = authenticator.negotiate_sasl(&context, &mut my_callback)?;
+//! ```
+
+mod build;
+pub(crate) mod netsec;
+pub mod sasl;
+pub mod tls;
+
+pub use build::Builder;
+
+type ErrorString = crate::str::NStringWithFree;
+
+fn empty_cstring() -> std::ffi::CString {
+    std::ffi::CString::new("").expect("null byte in empty string")
+}
diff --git a/sbr/rust/src/netsec/build.rs b/sbr/rust/src/netsec/build.rs
new file mode 100644 (file)
index 0000000..e4fb63f
--- /dev/null
@@ -0,0 +1,106 @@
+use std::convert::TryInto;
+use std::ffi::{CStr, CString};
+use std::os::raw::c_int;
+
+use crate::{sbr, Result};
+
+use super::*;
+
+// We could use state transitions to prevent callers from bugs such as
+// attempting to negotiate TLS without first setting a host, but this is not
+// some published API with goals of stability, but internal, so we know the
+// cost of Holding It Wrong is not a subtle crash mid-operation, but merely an
+// obvious crash at startup.
+
+/// This is fragile.
+#[derive(Debug)]
+pub struct Builder {
+    pub(super) nsc: crate::Netsec,
+    pub(super) skip_cert_checks: bool,
+    pub(super) sasl: sasl::AuthenticatorBuilder,
+}
+
+impl Builder {
+    /// Must be valid UTF-8.  All other error-checking happens after [Builder::connect].
+    pub fn host(self, host: &CStr) -> Self {
+        // The CString doesn't need to live past this scope because netsec_set_hostname makes its own copy.
+        let csptr = host.as_ptr();
+        unsafe {
+            sbr::netsec_set_hostname(self.nsc.nsc, csptr);
+        }
+        self
+    }
+
+    /// `timeout` must be representable as a 32-bit integer of seconds.
+    pub fn timeout(self, timeout: std::time::Duration) -> Result<Self> {
+        let timeout_c: c_int = timeout.as_secs().try_into()?;
+        unsafe { sbr::netsec_set_timeout(self.nsc.nsc, timeout_c) }
+        Ok(self)
+    }
+
+    /// Enable logging of traffic to the standard error.
+    pub fn snoop(self) -> Self {
+        unsafe { sbr::netsec_set_snoop(self.nsc.nsc, 1) }
+        self
+    }
+
+    /// I wouldn't.
+    pub fn skip_certificate_checks(mut self) -> Self {
+        self.skip_cert_checks = true;
+        self
+    }
+
+    /// Must be valid UTF-8.  All other error-checking happens after [crate::netsec::sasl::Authenticator::negotiate_sasl].
+    pub fn sasl_service(mut self, value: &str) -> Result<Self> {
+        self.sasl.service = CString::new(value)?;
+        Ok(self)
+    }
+
+    /// Must be valid UTF-8.  All other error-checking happens after [crate::netsec::sasl::Authenticator::negotiate_sasl].
+    pub fn sasl_userid(self, userid: &str) -> Result<Self> {
+        // The CString doesn't need to live past this scope because netsec_set_userid makes its own copy.
+        let cs = CString::new(userid)?;
+        let csptr = cs.as_ptr();
+        unsafe {
+            sbr::netsec_set_userid(self.nsc.nsc, csptr);
+        }
+        Ok(self)
+    }
+
+    /// Must be valid UTF-8.  All other error-checking happens after [crate::netsec::sasl::Authenticator::negotiate_sasl].
+    pub fn sasl_prefer_mechanism(&mut self, value: &str) -> Result<()> {
+        self.sasl.preferred_mech = CString::new(value)?;
+        Ok(())
+    }
+
+    /// Must be valid UTF-8.  All other error-checking happens after [crate::netsec::sasl::Authenticator::negotiate_sasl].
+    pub fn sasl_oauth_service(&mut self, value: &str) -> Result<()> {
+        // The CString doesn't need to live past this scope because netsec_set_userid makes its own copy.
+        let cs = CString::new(value)?;
+        let csptr = cs.as_ptr();
+        let status = unsafe { sbr::netsec_set_oauth_service(self.nsc.nsc, csptr) };
+        if status < 0 {
+            todo!("nmh not built with OAuth support");
+        }
+        Ok(())
+    }
+
+    /// `connect(2)` via the [crate::client::Connector] and return
+    /// [tls::Connector] for negotiating TLS.
+    pub fn connect(self, connector: crate::client::Connector) -> Result<tls::Connector> {
+        let socket: std::os::unix::io::RawFd = connector.connect()?;
+        unsafe { sbr::netsec_set_fd(self.nsc.nsc, socket, socket) }
+        Ok(tls::Connector { builder: self })
+    }
+
+    pub(super) fn new() -> Self {
+        Builder {
+            nsc: crate::Netsec::new(),
+            skip_cert_checks: false,
+            sasl: sasl::AuthenticatorBuilder {
+                preferred_mech: empty_cstring(),
+                service: empty_cstring(),
+            },
+        }
+    }
+}
diff --git a/sbr/rust/src/netsec/netsec.rs b/sbr/rust/src/netsec/netsec.rs
new file mode 100644 (file)
index 0000000..259a4bf
--- /dev/null
@@ -0,0 +1,139 @@
+use std::os::raw::{c_char, c_int, c_void};
+
+use crate::sbr;
+
+use super::{Builder, ErrorString};
+
+#[derive(Debug)]
+pub struct Netsec {
+    pub(super) nsc: *mut sbr::netsec_context,
+    last_write_ends_with_crlf: bool,
+}
+
+impl Netsec {
+    pub fn builder() -> Builder {
+        Builder::new()
+    }
+
+    /// Safe due to mutable borrow: you can't call this a second time and
+    /// invalidate a previously stored &str reference.
+    pub fn read_line(&mut self) -> crate::Result<&mut str> {
+        let as_char_ptr: *mut c_char = self.read_line_raw()?;
+        let as_u8_ptr = as_char_ptr as *mut u8;
+        // TODO underlying netsec knows len and should return it
+        let as_u8_slice: &mut [u8] =
+            unsafe { std::slice::from_raw_parts_mut(as_u8_ptr, libc::strlen(as_char_ptr)) };
+        // Skip the valid UTF-8 enforcement, as we're going to instead enforce valid ASCII.
+        let as_str: &mut str = unsafe { std::str::from_utf8_unchecked_mut(as_u8_slice) };
+        if !as_str.is_ascii() {
+            Err(crate::Error::new(format!("not ASCII:  {}", as_str)))
+        } else {
+            Ok(as_str)
+        }
+    }
+
+    pub fn read(&mut self, buffer: &mut [u8]) -> crate::Result<usize> {
+        let mut errstr = ErrorString::new();
+        let bytes_read: sbr::ssize_t = {
+            let buffer_c = buffer as *mut _ as *mut c_void;
+            let buffer_len = buffer.len() as sbr::size_t;
+            unsafe { sbr::netsec_read(self.nsc, buffer_c, buffer_len, errstr.pointer()) }
+        };
+        if bytes_read < 0 {
+            return Err(errstr.into());
+        }
+        Ok(bytes_read as usize)
+    }
+
+    pub fn write(&mut self, data: &[u8]) -> crate::Result<()> {
+        self.last_write_ends_with_crlf = data.ends_with(b"\r\n");
+        let mut errstr = ErrorString::new();
+        let status = unsafe {
+            sbr::netsec_write(
+                self.nsc,
+                data.as_ptr() as *const c_void,
+                data.len() as sbr::size_t,
+                errstr.pointer(),
+            )
+        };
+        if status != 0 {
+            Err(errstr.into())
+        } else {
+            Ok(())
+        }
+    }
+
+    // TODO Tried to do this cleanly but the base64-decoding in netsec snoop is all tangled up.
+    //  There's this generic callback mechanism but it's actually only used for base64-decoding.
+    //  The snoop only happens in flush so we have to flush before clearing the decoding snoop callback.
+    //  The snoop only happens anyway if there's a CRLF in the buffer.
+    //  The "offset at which base64 data starts" is also computed from the start of a line.
+    pub fn write_with_base64_at_offset_then_crlf_then_flush(
+        &mut self,
+        line: &str,
+        mut offset: c_int,
+    ) -> crate::Result<()> {
+        assert!(line.ends_with("\r\n"));
+        let offset_c = &mut offset as *mut _ as *mut c_void;
+        unsafe {
+            sbr::netsec_set_snoop_callback(self.nsc, SNOOP_CALLBACK, offset_c);
+        }
+        // Hold onto the success or failure while we clear the callback.
+        let result = self.write(line.as_bytes()).and_then(|_| self.flush());
+        unsafe {
+            sbr::netsec_set_snoop_callback(self.nsc, None, std::ptr::null_mut());
+        }
+        result
+    }
+
+    pub fn flush(&mut self) -> crate::Result<()> {
+        if !self.last_write_ends_with_crlf {
+            // I reliably triggered a runaway memory allocation when testing
+            // refile -file on Gandi, but only on Gandi.  Why?
+            // TODO Come up with reliable test and fix the underlying bug.
+            todo!("flush may call snoop which may crash if the buffer doesn't end with CRLF");
+        }
+
+        let mut errstr = ErrorString::new();
+        let status = { unsafe { sbr::netsec_flush(self.nsc, errstr.pointer()) } };
+        if status != 0 {
+            return Err(errstr.into());
+        }
+        Ok(())
+    }
+
+    pub fn write_and_flush(&mut self, data: &[u8]) -> crate::Result<()> {
+        self.write(data)?;
+        self.flush()
+    }
+
+    pub(super) fn new() -> Self {
+        Self {
+            nsc: unsafe { sbr::netsec_init() },
+            last_write_ends_with_crlf: false,
+        }
+    }
+
+    fn read_line_raw(&mut self) -> crate::Result<*mut c_char> {
+        let mut len: sbr::size_t = 0;
+        let mut errstr = ErrorString::new();
+        let line: *mut c_char = {
+            let len_c = &mut len as *mut _;
+            unsafe { sbr::netsec_readline(self.nsc, len_c, errstr.pointer()) }
+        };
+        if line.is_null() {
+            return Err(errstr.into());
+        }
+        Ok(line)
+    }
+}
+
+impl Drop for Netsec {
+    fn drop(&mut self) {
+        unsafe {
+            sbr::netsec_shutdown(self.nsc);
+        }
+    }
+}
+
+const SNOOP_CALLBACK: sbr::netsec_snoop_callback = Some(sbr::netsec_b64_snoop_decoder);
diff --git a/sbr/rust/src/netsec/sasl.rs b/sbr/rust/src/netsec/sasl.rs
new file mode 100644 (file)
index 0000000..43ff489
--- /dev/null
@@ -0,0 +1,264 @@
+use std::ffi::{CStr, CString};
+use std::os::raw::{c_char, c_int, c_uchar, c_uint, c_void};
+
+use crate::{sbr, Netsec};
+
+use super::*;
+
+pub type Result<T, E> = std::result::Result<T, Error<E>>;
+pub enum Error<E> {
+    /// an error you returned from a [Callback] method
+    Callback(E),
+    /// an error [Authenticator::negotiate_sasl] encountered
+    Internal(crate::Error),
+}
+
+impl<E> From<crate::Error> for Error<E> {
+    fn from(e: crate::Error) -> Self {
+        Error::Internal(e)
+    }
+}
+
+impl From<Error<crate::Error>> for crate::Error {
+    fn from(e: Error<crate::Error>) -> Self {
+        match e {
+            Error::Internal(e) => e,
+            Error::Callback(e) => e,
+        }
+    }
+}
+
+/// Reading netsec.h is required at this point.
+///
+/// You can pass errors back out to yourself by returning [Error::Callback] from any of these
+/// methods and [Authenticator::negotiate_sasl] will return it to you.
+pub trait Callback<E> {
+    /// See `NETSEC_SASL_START`.
+    fn start(
+        &mut self,
+        nsc: &mut Netsec,
+        selected_mechanism: &str,
+        message: &[u8],
+    ) -> Result<(), E>;
+    /// See `NETSEC_SASL_READ`.
+    fn read(&mut self, nsc: &mut Netsec) -> Result<Vec<u8>, E>;
+    /// See `NETSEC_SASL_WRITE`.
+    fn write(&mut self, nsc: &mut Netsec, mesage: &[u8]) -> Result<(), E>;
+    /// See `NETSEC_SASL_FINISH`.
+    fn finish(&mut self, nsc: &mut Netsec) -> Result<(), E>;
+    /// See `NETSEC_SASL_CANCEL`.
+    fn cancel(&mut self) -> Result<(), E>;
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)]
+pub enum Mechanism {
+    Plain,
+    // TODO replace with OAuthBearer
+    XOAuth2,
+}
+
+impl std::fmt::Display for Mechanism {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.write_str(match self {
+            Self::Plain => "PLAIN",
+            Self::XOAuth2 => "XOAUTH2",
+        })
+    }
+}
+
+/// Use in conjunction with your [Callback] impl to negotiate authentication with SASL.
+#[derive(Debug)]
+pub struct Authenticator {
+    nsc: Netsec,
+    preferred_mech: CString,
+    server_mechlist: CString,
+    service: CString,
+}
+
+impl Authenticator {
+    /// Return a line of text from the server.  Useful with [Authenticator::write_and_flush] for SASL.
+    pub fn read_line(&mut self) -> crate::Result<&mut str> {
+        self.nsc.read_line()
+    }
+
+    /// Return a line of text from the server.  Useful with [Authenticator::read_line] for SASL.
+    pub fn write_and_flush(&mut self, data: &[u8]) -> crate::Result<()> {
+        self.nsc.write_and_flush(data)
+    }
+
+    pub fn server_supported_sasl_mechanisms<T: std::iter::Iterator<Item = Mechanism>>(
+        mut self,
+        value: T,
+    ) -> Self {
+        self.server_mechlist = CString::new(
+            value
+                .map(|mech| mech.to_string())
+                .collect::<Vec<_>>()
+                .join(" "),
+        )
+        .expect("sasl::Mechanism::to_string never to return null bytes");
+        self
+    }
+
+    /// Drive [Callback] to negotiate SASL.
+    /// If any of your [Callback] methods return [Error::Callback], return that error to you.
+    pub fn negotiate_sasl<E, T: Callback<E>>(
+        mut self,
+        // Used to lookup credentials, but under the hood, through globals.
+        _nmh: &crate::Context,
+        sasl_callback: &mut T,
+    ) -> Result<Netsec, E> {
+        let nsc = self.nsc.nsc;
+        let mut callback_data = CallbackData {
+            netsec: &mut self.nsc,
+            callback: sasl_callback,
+            error: None,
+        };
+        let mut errstr = ErrorString::new();
+        let mut status = {
+            let service = self.service.as_ptr();
+            let preferred_mech = if self.preferred_mech.as_bytes().is_empty() {
+                std::ptr::null()
+            } else {
+                self.preferred_mech.as_ptr()
+            };
+            let context = &mut callback_data as *mut _ as *mut c_void;
+            unsafe {
+                sbr::netsec_set_sasl_params(
+                    nsc,
+                    service,
+                    preferred_mech,
+                    Some(netsec_sasl_callback::<E, T>),
+                    context,
+                    errstr.pointer(),
+                )
+            }
+        };
+        if status != 0 {
+            return Err(Error::Internal(errstr.into()));
+        }
+        let mut errstr = ErrorString::new();
+        status = unsafe {
+            sbr::netsec_negotiate_sasl(nsc, self.server_mechlist.as_ptr(), errstr.pointer())
+        };
+        if let Some(e) = callback_data.error {
+            Err(e)
+        } else if status != 0 {
+            Err(Error::Internal(errstr.into()))
+        } else {
+            Ok(self.nsc)
+        }
+    }
+
+    pub fn skip_sasl(self) -> Netsec {
+        self.nsc
+    }
+}
+
+#[derive(Debug)]
+pub(super) struct AuthenticatorBuilder {
+    pub(super) preferred_mech: CString,
+    pub(super) service: CString,
+}
+
+impl AuthenticatorBuilder {
+    pub fn build(self, nsc: Netsec) -> Authenticator {
+        Authenticator {
+            nsc,
+            preferred_mech: self.preferred_mech,
+            server_mechlist: empty_cstring(),
+            service: self.service,
+        }
+    }
+}
+
+struct CallbackData<'a, E, T: Callback<E>> {
+    netsec: &'a mut Netsec,
+    callback: &'a mut T,
+    error: Option<Error<E>>,
+}
+
+unsafe extern "C" fn netsec_sasl_callback<E, T: Callback<E>>(
+    mtype: sbr::sasl_message_type,
+    indata: *const c_uchar,
+    indatasize: c_uint,
+    outdata: *mut *mut c_uchar,
+    outdatasize: *mut c_uint,
+    context: *mut c_void,
+    _: *mut *mut c_char,
+) -> c_int {
+    let nsc_callback = &mut *(context as *mut CallbackData<E, T>);
+    match dispatch_netsec_sasl_callback(
+        nsc_callback.netsec,
+        nsc_callback.callback,
+        mtype,
+        indata,
+        indatasize,
+        outdata,
+        outdatasize,
+    ) {
+        Err(e) => {
+            nsc_callback.error = Some(e);
+            -1
+        }
+        _ => 0,
+    }
+}
+
+fn u8_slice_from_cuchar_cuint<'a>(data: *const c_uchar, len: c_uint) -> &'a [u8] {
+    if len > 0 {
+        let len_usize = len as usize;
+        unsafe { std::slice::from_raw_parts(data, len_usize) }
+    } else {
+        &[]
+    }
+}
+
+fn dispatch_netsec_sasl_callback<E, T: Callback<E>>(
+    nsc: &mut Netsec,
+    callback: &mut T,
+    mtype: sbr::sasl_message_type,
+    indata: *const c_uchar,
+    indatasize: c_uint,
+    _outdata: *mut *mut c_uchar,
+    _outdatasize: *mut c_uint,
+) -> Result<(), E> {
+    match mtype {
+        sbr::sasl_message_type_NETSEC_SASL_START => {
+            let cstr = unsafe { CStr::from_ptr(sbr::netsec_get_sasl_mechanism(nsc.nsc)) };
+            let selected_mech: &str = std::str::from_utf8(cstr.to_bytes())
+                .expect("SASL mechanism never to return null bytes");
+            callback.start(
+                nsc,
+                selected_mech,
+                u8_slice_from_cuchar_cuint(indata, indatasize),
+            )
+        }
+        sbr::sasl_message_type_NETSEC_SASL_READ => {
+            todo!("NETSEC_SASL_READ")
+            // let result: Result<Vec<u8>, E> = callback.read(nsc);
+            // match result {
+            //     Ok(mut bytes) => {
+            //         let len_c: c_uint = bytes.len().try_into()?;
+            //         let bytes_ptr = bytes.as_mut_ptr();
+            //         unsafe {
+            //             *outdatasize = len_c;
+            //             *outdata = bytes_ptr;
+            //         }
+            //         Ok(())
+            //     }
+            //     Err(e) => Err(e),
+            // }
+        }
+        sbr::sasl_message_type_NETSEC_SASL_WRITE => {
+            todo!("NETSEC_SASL_WRITE")
+            // callback.write(nsc, u8_slice_from_cuchar_cuint(indata, indatasize))
+        }
+        sbr::sasl_message_type_NETSEC_SASL_FINISH => callback.finish(nsc),
+        sbr::sasl_message_type_NETSEC_SASL_CANCEL => todo!("NETSEC_SASL_CANCEL"),
+        _ => Err(Error::Internal(crate::Error::new(format!(
+            "illegal sasl_message_type {}",
+            mtype,
+        )))),
+    }
+}
diff --git a/sbr/rust/src/netsec/tls.rs b/sbr/rust/src/netsec/tls.rs
new file mode 100644 (file)
index 0000000..971cd9a
--- /dev/null
@@ -0,0 +1,49 @@
+use crate::sbr;
+
+use super::*;
+
+#[derive(Debug)]
+pub struct Connector {
+    pub(super) builder: Builder,
+}
+
+impl Connector {
+    /// Return a line of text from the server.  Useful with [Connector::write_and_flush] for STARTTLS.
+    pub fn read_line(&mut self) -> crate::Result<&mut str> {
+        self.builder.nsc.read_line()
+    }
+
+    /// Return a line of text from the server.  Useful with [Connector::read_line] for STARTTLS.
+    pub fn write_and_flush(&mut self, data: &[u8]) -> crate::Result<()> {
+        self.builder.nsc.write_and_flush(data)
+    }
+
+    /// I wouldn't.
+    pub fn skip_tls(self) -> sasl::Authenticator {
+        self.builder.sasl.build(self.builder.nsc)
+    }
+
+    /// Negotiate TLS and prepare for authentication.
+    pub fn negotiate_tls(self) -> crate::Result<sasl::Authenticator> {
+        let mut errstr = ErrorString::new();
+        let mut status = {
+            unsafe {
+                sbr::netsec_set_tls(
+                    self.builder.nsc.nsc,
+                    1,
+                    if self.builder.skip_cert_checks { 1 } else { 0 },
+                    errstr.pointer(),
+                )
+            }
+        };
+        if status != 0 {
+            return Err(errstr.into());
+        }
+        let mut errstr = ErrorString::new();
+        status = unsafe { sbr::netsec_negotiate_tls(self.builder.nsc.nsc, errstr.pointer()) };
+        if status != 0 {
+            return Err(errstr.into());
+        }
+        Ok(self.builder.sasl.build(self.builder.nsc))
+    }
+}
diff --git a/sbr/rust/src/profile.rs b/sbr/rust/src/profile.rs
new file mode 100644 (file)
index 0000000..3f931a6
--- /dev/null
@@ -0,0 +1,126 @@
+//! facilities related to [crate::Profile]
+
+use std::os::raw::c_char;
+use std::path::Path;
+
+use crate::sbr;
+
+pub(crate) mod p {
+    use super::*;
+    pub struct Profile<'a>(pub(crate) &'a crate::Context);
+    impl<'a> Profile<'a> {
+        /// Return Some if the profile has a value for `name`, else None.  Panic on any error.
+        pub fn get(&self, name: &str) -> Option<&str> {
+            get(name)
+        }
+
+        /// Return Ok if the profile has a value for `name`, else Err.   Panic on error.
+        pub fn get_or_err(&self, name: &str) -> Result<&str, String> {
+            get_or_err(name)
+        }
+
+        /// Return view into the profile that only exposes components whose names begin with `prefix`.
+        pub fn prefixed(&self, prefix: &'a str) -> Prefixed<'a> {
+            Prefixed { prefix }
+        }
+
+        ////////////////////////////////////////////////////////////////////////////////
+        // common parameters
+        // See mh-profile(5).
+
+        pub fn path(&self) -> Result<&Path, String> {
+            self.get_or_err("path").map(Path::new)
+        }
+
+        pub fn previous_sequence(&self) -> Result<&Path, String> {
+            self.get_or_err("previous-sequence").map(Path::new)
+        }
+
+        pub fn unseen_sequence(&self) -> Result<&Path, String> {
+            self.get_or_err("unseen-sequence").map(Path::new)
+        }
+    }
+}
+
+/// Prefixed view of profile; only sees components whose name starts with the prefix.
+pub struct Prefixed<'a> {
+    prefix: &'a str,
+}
+
+impl<'a> Prefixed<'a> {
+    /// Prefixed form of [crate::Profile::get].
+    pub fn get(&self, name: &str) -> Option<&str> {
+        get(&format!("{}{}", self.prefix, name))
+    }
+
+    /// Prefixed form of [crate::Profile::get_or_err].
+    pub fn get_or_err(&self, name: &str) -> Result<&str, String> {
+        get_or_err(&format!("{}{}", self.prefix, name))
+    }
+
+    pub fn iter(&self) -> PrefixedIterator<'a> {
+        PrefixedIterator {
+            prefix: self.prefix,
+            np: unsafe { sbr::m_defs },
+        }
+    }
+}
+
+pub struct PrefixedIterator<'a> {
+    prefix: &'a str,
+    np: *mut sbr::node,
+}
+
+impl<'a> Iterator for PrefixedIterator<'a> {
+    type Item = (&'a str, &'a std::ffi::CStr);
+    fn next(&mut self) -> Option<Self::Item> {
+        while !self.np.is_null() {
+            let node = self.np;
+            self.np = unsafe { (*node).n_next };
+            let name_p: *mut c_char = unsafe { (*node).n_name };
+            assert!(!name_p.is_null(), "illegal NULL component name");
+            let name_cstr = unsafe { std::ffi::CStr::from_ptr(name_p) };
+            let name: &str = match name_cstr.to_str() {
+                Err(e) => panic!(
+                    "{} decoding component name {}",
+                    e,
+                    name_cstr.to_string_lossy()
+                ),
+                Ok(value) => value,
+            };
+            if name.len() < self.prefix.len() {
+                continue;
+            }
+            if !name[..self.prefix.len()].eq_ignore_ascii_case(self.prefix) {
+                continue;
+            }
+
+            let value_p: *mut c_char = unsafe { (*node).n_field };
+            assert!(!value_p.is_null(), "illegal NULL component value");
+            let value_cstr = unsafe { std::ffi::CStr::from_ptr(value_p) };
+            return Some((&name[self.prefix.len()..], value_cstr));
+        }
+        None
+    }
+}
+
+fn get(name: &str) -> Option<&'static str> {
+    let mut name_bytes: Vec<u8> = match std::ffi::CString::new(name) {
+        Err(_) => return None,
+        Ok(value) => value.into_bytes_with_nul(),
+    };
+    let name_c = name_bytes.as_mut_ptr() as *mut c_char;
+    let result: *mut c_char = unsafe { sbr::context_find(name_c) };
+    if result.is_null() {
+        return None;
+    }
+    let result_c = unsafe { std::ffi::CStr::from_ptr(result) };
+    match result_c.to_str() {
+        Err(e) => panic!("{} decoding {}", e, result_c.to_string_lossy()),
+        Ok(value) => Some(value),
+    }
+}
+
+fn get_or_err(name: &str) -> Result<&'static str, String> {
+    get(name).ok_or_else(|| format!("required component {} absent from profile", name))
+}
diff --git a/sbr/rust/src/sbr.rs b/sbr/rust/src/sbr.rs
new file mode 100644 (file)
index 0000000..06fc094
--- /dev/null
@@ -0,0 +1,61 @@
+use crate::bindgen;
+
+pub use bindgen::context_find;
+pub use bindgen::m_defs;
+pub use bindgen::nmh_init;
+pub use bindgen::node;
+
+pub use bindgen::m_getfld2;
+pub use bindgen::m_getfld_state_destroy;
+pub use bindgen::m_getfld_state_init;
+pub use bindgen::m_getfld_state_t;
+pub use bindgen::FMTERR;
+pub use bindgen::LENERR;
+pub use bindgen::NAMESZ;
+
+pub use bindgen::netsec_context;
+pub use bindgen::netsec_err;
+pub use bindgen::netsec_flush;
+pub use bindgen::netsec_get_sasl_mechanism;
+pub use bindgen::netsec_get_sasl_ssf;
+pub use bindgen::netsec_init;
+pub use bindgen::netsec_negotiate_sasl;
+pub use bindgen::netsec_negotiate_tls;
+pub use bindgen::netsec_printf;
+pub use bindgen::netsec_read;
+pub use bindgen::netsec_readline;
+pub use bindgen::netsec_sasl_callback;
+pub use bindgen::netsec_set_fd;
+pub use bindgen::netsec_set_hostname;
+pub use bindgen::netsec_set_oauth_service;
+pub use bindgen::netsec_set_sasl_params;
+pub use bindgen::netsec_set_snoop;
+pub use bindgen::netsec_set_snoop_callback;
+pub use bindgen::netsec_set_timeout;
+pub use bindgen::netsec_set_tls;
+pub use bindgen::netsec_set_userid;
+pub use bindgen::netsec_shutdown;
+pub use bindgen::netsec_snoop_callback;
+pub use bindgen::netsec_write;
+
+pub use bindgen::netsec_b64_snoop_decoder;
+
+pub use bindgen::sasl_message_type;
+pub use bindgen::sasl_message_type_NETSEC_SASL_CANCEL;
+pub use bindgen::sasl_message_type_NETSEC_SASL_FINISH;
+pub use bindgen::sasl_message_type_NETSEC_SASL_READ;
+pub use bindgen::sasl_message_type_NETSEC_SASL_START;
+pub use bindgen::sasl_message_type_NETSEC_SASL_WRITE;
+
+pub use bindgen::decodeBase64;
+pub use bindgen::writeBase64raw;
+
+pub use bindgen::client;
+
+// TODO take from libc crate?  But those are weird and require conversions?!
+pub use bindgen::fclose;
+pub use bindgen::fdopen;
+pub use bindgen::free;
+pub use bindgen::size_t;
+pub use bindgen::ssize_t;
+pub use bindgen::FILE;
diff --git a/sbr/rust/src/str.rs b/sbr/rust/src/str.rs
new file mode 100644 (file)
index 0000000..c6c636a
--- /dev/null
@@ -0,0 +1,254 @@
+//! [String]-like types (and when to use them)
+
+//! - Use [String] and [str] in pure Rust
+//! - Use [std::ffi::CStr] to accept a reference to a null-terminated string owned in C.
+//! - Use [std::ffi::CString] to pass a reference to a [str] to C, but note that this makes a copy.  If this
+//!   should become a problem, [NString] is ready to support this case.
+//! - Use [NString] to pass a mutable reference to a buffer into which C may write a null-terminated string.
+//! - Use [NStringWithFree] to accept ownership of a null-terminated string from C, to be freed with sbr::free
+
+use crate::Error;
+
+// TODO implement more String-like behaviors as needed, for individual types and for OrString.
+//  Consider putting those behaviors into a StringLike trait.
+
+// TODO could easily split into ASCIIString and UTF8String if needed.
+/// Like String but always with a final null byte for no-copy use with C-style functions, and always containing only ASCII.
+pub struct NString {
+    // INVARIANT: data.len() > len
+    // While this starts out all zero, it is possible for bytes between len and data.len() to be non-zero, for example:
+    //  sprintf(nstring.mut_ptr(), b"this many bytes\0");
+    //  sprintf(nstring.mut_ptr(), b"fewer bytes\0");
+    // At this point, data is b"fewer bytes\0tes\0" and nstring.as_str() is "fewer bytes".
+    data: Vec<u8>,
+    len: usize,
+}
+
+impl NString {
+    pub fn new() -> Self {
+        Self::with_capacity(0)
+    }
+
+    pub fn with_capacity(capacity: usize) -> Self {
+        Self {
+            data: vec![0; capacity + 1],
+            len: 0,
+        }
+    }
+
+    pub fn push_ascii_str(&mut self, s: &str) -> Result<(), Error> {
+        check_ascii(s.as_bytes())?;
+        let new_len = self.len + s.len();
+        // Resize, but only up.
+        self.data
+            .resize(std::cmp::max(self.data.len(), new_len + 1), 0);
+        self.data[self.len..new_len].clone_from_slice(s.as_bytes());
+        self.len = new_len;
+        Ok(())
+    }
+
+    #[inline]
+    pub fn as_str(&self) -> &str {
+        unsafe { std::str::from_utf8_unchecked(&self.data[..self.len]) }
+    }
+
+    /// Convert to [UnsafeNString] for ues with C.
+    pub fn into_unsafe(self) -> UnsafeNString {
+        UnsafeNString(self)
+    }
+}
+
+impl Default for NString {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+impl std::fmt::Debug for NString {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        self.as_str().fmt(f)
+    }
+}
+
+impl std::ops::Deref for NString {
+    type Target = str;
+    #[inline]
+    fn deref(&self) -> &str {
+        self.as_str()
+    }
+}
+
+impl std::ops::DerefMut for NString {
+    #[inline]
+    fn deref_mut(&mut self) -> &mut str {
+        unsafe { std::str::from_utf8_unchecked_mut(&mut self.data[..self.len]) }
+    }
+}
+
+impl std::fmt::Display for NString {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        self.as_str().fmt(f)
+    }
+}
+
+/// Form of [NString] for use with C.
+pub struct UnsafeNString(NString);
+
+impl UnsafeNString {
+    /// Return the mutable pointer to unsigned char.
+    pub fn mut_ptr(&mut self) -> *mut std::os::raw::c_uchar {
+        self.0.data.as_mut_ptr() as *mut std::os::raw::c_uchar
+    }
+
+    /// Convert back to [NString].  Unsafe because we can't know what whether your C library stomped
+    /// all over everything.  `len` is the length of the string excluding the null byte.  `len`
+    /// obviously MUST be less than the capacity of the underlying [NString], otherwise you've
+    /// overrun the buffer!
+    /// # Safety
+    /// Safe only if caller avoids writing past the end of the buffer.
+    pub unsafe fn to_nstring(self, len: usize) -> Result<NString, Error> {
+        to_nstring(self, len)
+    }
+
+    /// Convert back to [NString].  Unsafe because we can't know what whether your C library stomped
+    /// all over everything.  In this case, you promise to have placed a null byte after your
+    /// string, and we will find it and use its offset as the string length.
+    ///
+    /// Doing this is discouraged; you probably already knew the length and lost it, and would be
+    /// better off fixing that lossage.
+    /// # Safety
+    /// Safe only if caller includes a null byte and avoids writing past the end of the buffer.
+    pub unsafe fn to_nstring_sized_by_null_byte(self) -> Result<NString, Error> {
+        to_nstring_size_by_null_byte(self)
+    }
+}
+
+pub enum OrString {
+    N(NString),
+    S(String),
+}
+
+impl std::ops::Deref for OrString {
+    type Target = str;
+    #[inline]
+    fn deref(&self) -> &str {
+        match self {
+            Self::N(ns) => ns,
+            Self::S(s) => s,
+        }
+    }
+}
+
+impl std::fmt::Debug for OrString {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            Self::N(ns) => f.debug_tuple("NString").field(ns).finish(),
+            Self::S(s) => f.debug_tuple("String").field(s).finish(),
+        }
+    }
+}
+
+impl std::fmt::Display for OrString {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.write_str(self)
+    }
+}
+
+impl From<String> for OrString {
+    fn from(s: String) -> Self {
+        Self::S(s)
+    }
+}
+
+impl From<&str> for OrString {
+    fn from(s: &str) -> Self {
+        Self::S(s.to_owned())
+    }
+}
+
+/// See module documentation.
+/// WARNING: This frees the buffer, so only use when caller owns!
+/// Don't reuse in order to avoid memory leak; in fact, this panics if you attempt reuse.
+pub struct NStringWithFree {
+    pointer: *mut std::os::raw::c_char,
+    accessed: bool,
+}
+
+impl NStringWithFree {
+    pub fn new() -> Self {
+        Self {
+            pointer: std::ptr::null::<*mut std::os::raw::c_char>() as *mut std::os::raw::c_char,
+            accessed: false,
+        }
+    }
+
+    pub fn pointer(&mut self) -> *mut *mut std::os::raw::c_char {
+        assert!(
+            !self.accessed,
+            "reusing NStringWithFree risks leaking the buffer"
+        );
+        self.accessed = true;
+        &mut self.pointer as *mut *mut _ as *mut *mut std::os::raw::c_char
+    }
+}
+
+impl Default for NStringWithFree {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+impl std::convert::TryFrom<NStringWithFree> for String {
+    type Error = Error;
+    fn try_from(value: NStringWithFree) -> Result<Self, Self::Error> {
+        if value.pointer.is_null() {
+            return Err(Error::new(
+                "BUG: TryFrom<NStringWithFree> for String buffer pointer is null".to_owned(),
+            ));
+        }
+        unsafe { std::ffi::CStr::from_ptr(value.pointer) }
+            .to_str()
+            .map_err(Error::wrap)
+            .map(|v| v.to_owned())
+    }
+}
+
+impl Drop for NStringWithFree {
+    fn drop(&mut self) {
+        let void = self.pointer as *mut std::os::raw::c_void;
+        unsafe { crate::sbr::free(void) }
+    }
+}
+
+fn check_ascii(bytes: &[u8]) -> Result<(), Error> {
+    let as_str: &str = unsafe { std::str::from_utf8_unchecked(bytes) };
+    if !as_str.is_ascii() {
+        Err(Error::new(format!("not ASCII:  {}", as_str)))
+    } else {
+        Ok(())
+    }
+}
+
+fn to_nstring(mut rs: UnsafeNString, len: usize) -> Result<NString, Error> {
+    assert!(
+        len < rs.0.data.len(),
+        "{} >= {}; buffer overrun likely",
+        len,
+        rs.0.data.len()
+    );
+    rs.0.len = len;
+    check_ascii(&rs.0.data[..rs.0.len])?;
+    Ok(rs.0)
+}
+
+fn to_nstring_size_by_null_byte(rs: UnsafeNString) -> Result<NString, Error> {
+    let len: usize =
+        rs.0.data
+            .iter()
+            .position(|c| *c == 0)
+            .expect("UnsafeNString lacks null byte; buffer overrun likely");
+    to_nstring(rs, len)
+}
+
+#[cfg(test)]
+mod tests;
diff --git a/sbr/rust/src/str/tests.rs b/sbr/rust/src/str/tests.rs
new file mode 100644 (file)
index 0000000..818b318
--- /dev/null
@@ -0,0 +1,28 @@
+use super::*;
+
+mod nstring {
+    use super::*;
+
+    /// Test [NString] starting out holding N bytes, and then being shortened to fewer bytes.
+    #[test]
+    fn long_then_short() {
+        let mut ns = NString::new();
+        ns.push_ascii_str("this many bytes").unwrap();
+        let mut raw = ns.into_unsafe();
+        sprintf(raw.mut_ptr(), b"fewer bytes\0");
+        ns = unsafe { raw.to_nstring_sized_by_null_byte() }.unwrap();
+        // The reachable part of the NString is short.
+        assert_eq!("fewer bytes", ns.as_str());
+        // The stale bytes remain untouched but unaccessed in the underlying buffer.
+        assert_eq!(b"fewer bytes\0tes\0", &ns.data[..]);
+    }
+}
+
+fn sprintf(format: *mut std::os::raw::c_uchar, s: &[u8]) {
+    unsafe {
+        libc::sprintf(
+            format as *mut std::os::raw::c_char,
+            s.as_ptr() as *const std::os::raw::c_char,
+        )
+    };
+}
diff --git a/sbr/rust/tests/base64/mod.rs b/sbr/rust/tests/base64/mod.rs
new file mode 100644 (file)
index 0000000..9c7caa1
--- /dev/null
@@ -0,0 +1,21 @@
+use nmh::base64::encode_raw;
+
+const DECODED: &'static [u8] = b"some bytes\nand lines\r\nbut mixed up";
+const DECODED_EMBEDDED_NULL: &'static [u8] =
+    b"some bytes\nand lines\r\nbut mixed up\0was that a null";
+static ENCODED: &str = "c29tZSBieXRlcwphbmQgbGluZXMNCmJ1dCBtaXhlZCB1cA==";
+static ENCODED_EMBEDDED_NULL: &str =
+    "c29tZSBieXRlcwphbmQgbGluZXMNCmJ1dCBtaXhlZCB1cAB3YXMgdGhhdCBhIG51bGw=";
+
+#[test]
+fn round_trip_encode_raw() {
+    assert_eq!(encode_raw(DECODED).as_str(), ENCODED);
+}
+
+#[test]
+fn round_trip_embedded_null() {
+    assert_eq!(
+        encode_raw(DECODED_EMBEDDED_NULL).as_str(),
+        ENCODED_EMBEDDED_NULL
+    );
+}
diff --git a/sbr/rust/tests/client/mod.rs b/sbr/rust/tests/client/mod.rs
new file mode 100644 (file)
index 0000000..471b596
--- /dev/null
@@ -0,0 +1,52 @@
+use std::error::Error;
+
+use nmh::client::*;
+
+#[test]
+fn server_null_byte() {
+    let e = Connector::new()
+        .service("imap")
+        .unwrap()
+        .server("\0")
+        .expect_err("need error");
+    let source = e.source().expect(&format!("need source in {:?}", e));
+    assert_eq!(
+        0,
+        (*source)
+            .downcast_ref::<std::ffi::NulError>()
+            .expect(&format!("need NulError:  {:?}", source))
+            .nul_position()
+    )
+}
+
+#[test]
+fn service_null_byte() {
+    let e = Connector::new()
+        .server("localhost")
+        .unwrap()
+        .service("\0")
+        .expect_err("need error");
+    let source = e.source().expect(&format!("need source in {:?}", e));
+    assert_eq!(
+        0,
+        (*source)
+            .downcast_ref::<std::ffi::NulError>()
+            .expect(&format!("need NulError:  {:?}", source))
+            .nul_position()
+    )
+}
+
+#[test]
+fn empty_strings() {
+    assert_eq!(
+        "Internal error: NULL server name passed to client()",
+        Connector::new()
+            .server("")
+            .unwrap()
+            .service("")
+            .unwrap()
+            .connect()
+            .expect_err("need error")
+            .to_string()
+    )
+}
diff --git a/sbr/rust/tests/message/mod.rs b/sbr/rust/tests/message/mod.rs
new file mode 100644 (file)
index 0000000..d1ae0d7
--- /dev/null
@@ -0,0 +1,47 @@
+#[test]
+fn option_no_overhead() {
+    assert_eq!(
+        std::mem::size_of::<i32>(),
+        std::mem::size_of::<Option<nmh::message::Number>>()
+    );
+}
+
+#[test]
+fn max() {
+    nmh::message::Number::from(std::num::NonZeroI32::new(i32::MAX).unwrap());
+}
+
+#[test]
+#[should_panic(expected = "attempt to add with overflow")]
+fn increment_max() {
+    let mut n = nmh::message::Number::from(std::num::NonZeroI32::new(i32::MAX).unwrap());
+    n.increment();
+}
+
+#[test]
+fn clone() {
+    let n = nmh::message::Number::one();
+    assert_eq!(n, n.clone());
+}
+
+#[test]
+fn display() {
+    assert_eq!("1", format!("{}", nmh::message::Number::one()));
+}
+
+#[test]
+fn debug() {
+    assert_eq!("Number(1)", format!("{:?}", nmh::message::Number::one()));
+}
+
+#[test]
+fn eq() {
+    let a = nmh::message::Number::one();
+    let b = nmh::message::Number::from(std::num::NonZeroI32::new(2).unwrap());
+    assert!(a == a);
+    assert!(!(a == b));
+    assert!(!(a != a));
+    assert!(a != b);
+}
+
+mod set;
diff --git a/sbr/rust/tests/message/set.rs b/sbr/rust/tests/message/set.rs
new file mode 100644 (file)
index 0000000..b55e529
--- /dev/null
@@ -0,0 +1,71 @@
+use nmh::message::Set;
+
+mod display {
+    use super::*;
+
+    #[test]
+    fn one() {
+        let mut set = Set::new();
+        set.insert(1);
+        assert_eq!("1", &format!("{}", set))
+    }
+
+    #[test]
+    fn some() {
+        let mut set = Set::new();
+        set.insert(1);
+        set.insert(2);
+        set.insert(3);
+        set.insert(10);
+        set.insert(15);
+        set.insert(16);
+        set.insert(17);
+        assert_eq!("1-3 10 15-17", &format!("{}", set))
+    }
+
+    #[test]
+    fn all() {
+        assert_eq!("1-2147483647", &format!("{}", Set::all()))
+    }
+}
+
+mod all {
+    use super::*;
+
+    #[test]
+    fn append_some_to_all() {
+        let mut all = Set::all();
+        let mut one = Set::new();
+        one.insert(1);
+        all.append(&mut one);
+        assert_eq!("1-2147483647", &format!("{}", all))
+    }
+
+    #[test]
+    fn append_all_to_some() {
+        let mut all = Set::all();
+        let mut one = Set::new();
+        one.insert(1);
+        one.append(&mut all);
+        assert_eq!("1-2147483647", &format!("{}", one))
+    }
+
+    #[test]
+    fn insert_to_all() {
+        let mut all = Set::all();
+        all.insert(1);
+        assert_eq!("1-2147483647", &format!("{}", all))
+    }
+
+    #[test]
+    fn clone_without() {
+        let mut some = Set::new();
+        some.insert(1);
+        assert!(some.clone_without(&Set::all()).is_empty())
+    }
+
+    #[test]
+    fn is_empty() {
+        assert!(!Set::all().is_empty())
+    }
+}
diff --git a/sbr/rust/tests/netsec/mod.rs b/sbr/rust/tests/netsec/mod.rs
new file mode 100644 (file)
index 0000000..0944b84
--- /dev/null
@@ -0,0 +1,71 @@
+use std::error::Error;
+use std::ffi::{CString, NulError};
+
+use nmh::Netsec;
+
+mod builder {
+    use super::*;
+
+    #[test]
+    fn timeout_out_of_range() {
+        let e = Netsec::builder()
+            .timeout(std::time::Duration::from_secs(u64::MAX))
+            .expect_err("need error");
+        let source = e.source().expect(&format!("need source in {:?}", e));
+        assert!(
+            (*source)
+                .downcast_ref::<std::num::TryFromIntError>()
+                .is_some(),
+            "need TryFromIntError:  {:?}",
+            e
+        )
+    }
+
+    #[test]
+    fn userid_with_nulls() {
+        let e = Netsec::builder()
+            .host(&CString::new("a hostname").unwrap())
+            .sasl_userid("oh\0null")
+            .expect_err("need error");
+        let source = e.source().expect(&format!("need source in {:?}", e));
+        assert_eq!(
+            2,
+            (*source)
+                .downcast_ref::<NulError>()
+                .expect(&format!("need NulError:  {:?}", source))
+                .nul_position()
+        )
+    }
+
+    #[test]
+    fn sasl_service_with_nulls() {
+        let e = Netsec::builder()
+            .host(&CString::new("a hostname").unwrap())
+            .sasl_service("oh\0null")
+            .expect_err("need error");
+        let source = e.source().expect(&format!("need source in {:?}", e));
+        assert_eq!(
+            2,
+            (*source)
+                .downcast_ref::<NulError>()
+                .expect(&format!("need NulError:  {:?}", source))
+                .nul_position()
+        )
+    }
+
+    #[test]
+    fn user_preferred_sasl_mechanism_with_nulls() {
+        let e = Netsec::builder()
+            .host(&CString::new("a hostname").unwrap())
+            .sasl_prefer_mechanism("oh\0null")
+            .expect_err("need error");
+        let source = e.source().expect(&format!("need source in {:?}", e));
+        assert_eq!(
+            2,
+            (*source)
+                .downcast_ref::<NulError>()
+                .expect(&format!("need NulError:  {:?}", source))
+                .nul_position()
+        )
+    }
+}
diff --git a/sbr/rust/tests/str/mod.rs b/sbr/rust/tests/str/mod.rs
new file mode 100644 (file)
index 0000000..d936bea
--- /dev/null
@@ -0,0 +1,94 @@
+use std::convert::TryFrom as _;
+
+use nmh::str::*;
+
+mod nstring {
+    use super::*;
+
+    #[test]
+    fn empty() {
+        assert!(NString::with_capacity(1).as_str().is_empty());
+    }
+
+    #[test]
+    fn capacity_zero_is_empty() {
+        assert!(NString::with_capacity(0).as_str().is_empty());
+    }
+
+    #[test]
+    fn push_str_non_ascii() {
+        let mut ns = NString::new();
+        assert_eq!(
+            "not ASCII:  ¯\\_(ツ)_/¯",
+            ns.push_ascii_str(SHRUG).unwrap_err().to_string()
+        );
+    }
+
+    #[test]
+    fn sprintf_non_ascii() {
+        let mut shrug = SHRUG.as_bytes().to_vec();
+        shrug.push(0);
+        let ns = NString::with_capacity(32);
+        let mut raw = ns.into_unsafe();
+        sprintf(raw.mut_ptr(), &shrug);
+        assert_eq!(
+            "not ASCII:  ¯\\_(ツ)_/¯",
+            unsafe { raw.to_nstring_sized_by_null_byte() }
+                .unwrap_err()
+                .to_string()
+        );
+    }
+
+    #[test]
+    #[should_panic(expected = "UnsafeNString lacks null byte; buffer overrun likely")]
+    fn missing_null_byte() {
+        let ns = NString::with_capacity(0);
+        let mut raw = ns.into_unsafe();
+        unsafe {
+            *(raw.mut_ptr()) = 1;
+            raw.to_nstring_sized_by_null_byte().unwrap();
+        }
+    }
+
+    #[test]
+    #[should_panic(expected = "10 >= 1; buffer overrun likely")]
+    fn too_many_bytes() {
+        let ns = NString::with_capacity(0);
+        let raw = ns.into_unsafe();
+        unsafe { raw.to_nstring(10).unwrap() };
+    }
+
+    #[test]
+    fn deref_mut() {
+        let mut ns = NString::new();
+        ns.push_ascii_str("foo").unwrap();
+        let mut_str: &mut str = &mut ns;
+        mut_str.make_ascii_uppercase();
+        assert_eq!("FOO", ns.as_str());
+    }
+
+    mod with_free {
+        use super::*;
+
+        #[test]
+        fn to_string_with_null() {
+            assert_eq!(
+                "BUG: TryFrom<NStringWithFree> for String buffer pointer is null",
+                String::try_from(NStringWithFree::new())
+                    .unwrap_err()
+                    .to_string(),
+            );
+        }
+    }
+}
+
+const SHRUG: &str = "¯\\_(ツ)_/¯";
+
+fn sprintf(format: *mut std::os::raw::c_uchar, s: &[u8]) {
+    unsafe {
+        libc::sprintf(
+            format as *mut std::os::raw::c_char,
+            s.as_ptr() as *const std::os::raw::c_char,
+        )
+    };
+}
diff --git a/sbr/rust/tests/tests.rs b/sbr/rust/tests/tests.rs
new file mode 100644 (file)
index 0000000..508f394
--- /dev/null
@@ -0,0 +1,5 @@
+mod base64;
+mod client;
+mod message;
+mod netsec;
+mod str;
index a3c4e0d..3563926 100644 (file)
@@ -182,7 +182,7 @@ seq_init (struct msgs *mp, char *name, char *field)
 {
     unsigned int i;
     int j, k, is_current;
-    char *cp, **ap;
+    char *cp;
 
     /*
      * Check if this is "cur" sequence,
@@ -217,7 +217,7 @@ seq_init (struct msgs *mp, char *name, char *field)
     /*
      * Split up the different message ranges at whitespace
      */
-    for (ap = brkstring (field, " ", "\n"); *ap; ap++) {
+    for (char *const *ap = brkstring (field, " ", "\n"); *ap; ap++) {
        if ((cp = strchr(*ap, '-')))
            *cp++ = '\0';
        if ((j = m_atoi (*ap)) > 0) {
index 89b052e..60c01fa 100644 (file)
  */
 
 void
-seq_setprev (struct msgs *mp)
+seq_setprev (struct msgs *const mp)
 {
-    char **ap, *cp, *dp;
-
     /*
      * Get the list of sequences for Previous-Sequence
      * and split them.
      */
-    if (!(cp = context_find(psequence)))
+    const char *const maybe_previous_sequences = context_find(psequence);
+    if (maybe_previous_sequences == NULL)
         return;
-    dp = mh_xstrdup(cp);
-    if (!(ap = brkstring(dp, " ", "\n")) || !*ap) {
-        free(dp);
+    char *const previous_sequences = mh_xstrdup(maybe_previous_sequences);
+    char *const *broken = brkstring(previous_sequences, " ", "\n");
+    if (broken == NULL || *broken == NULL) {
+        free(previous_sequences);
         return;
     }
 
     /* Now add all SELECTED messages to each sequence */
-    for (; *ap; ap++)
-       seq_addsel (mp, *ap, -1, 1);
+    for (; *broken; broken++)
+       seq_addsel (mp, *broken, -1, 1);
 
-    free (dp);
+    free (previous_sequences);
 }
index bcad3f1..c5ce6e7 100644 (file)
  */
 
 void
-seq_setunseen (struct msgs *mp, int seen)
+seq_setunseen (struct msgs *const mp, const int seen)
 {
-    int msgnum;
-    char **ap, *cp, *dp;
-
     /*
      * Get the list of sequences for Unseen-Sequence
      * and split them.
      */
-    if (!(cp = context_find (usequence)))
+    const char*const maybe_unseen_sequences = context_find (usequence);
+    if (maybe_unseen_sequences == NULL)
         return;
-    dp = mh_xstrdup(cp);
-    if (!(ap = brkstring (dp, " ", "\n")) || !*ap) {
-        free (dp);
+    char *const unseen_sequences = mh_xstrdup(maybe_unseen_sequences);
+    char *const *broken = brkstring (unseen_sequences, " ", "\n");
+    if (broken == NULL || *broken == NULL) {
+        free (unseen_sequences);
         return;
     }
 
@@ -45,19 +44,19 @@ seq_setunseen (struct msgs *mp, int seen)
      * Now add/delete each message which has the SELECT_UNSEEN
      * bit set to/from each of these sequences.
      */
-    for (; *ap; ap++) {
+    for (; *broken; broken++) {
        if (seen) {
            /* make sure sequence exists first */
-           if (seq_getnum(mp, *ap) != -1)
-               for (msgnum = mp->lowsel; msgnum <= mp->hghsel; msgnum++)
+           if (seq_getnum(mp, *broken) != -1)
+               for (int msgnum = mp->lowsel; msgnum <= mp->hghsel; msgnum++)
                    if (is_unseen (mp, msgnum))
-                       seq_delmsg (mp, *ap, msgnum);
+                       seq_delmsg (mp, *broken, msgnum);
        } else {
-           for (msgnum = mp->lowmsg; msgnum <= mp->hghmsg; msgnum++)
+           for (int msgnum = mp->lowmsg; msgnum <= mp->hghmsg; msgnum++)
                if (is_unseen (mp, msgnum))
-                   seq_addmsg (mp, *ap, msgnum, -1, 0);
+                   seq_addmsg (mp, *broken, msgnum, -1, 0);
        }
     }
 
-    free (dp);
+    free (unseen_sequences);
 }
index 7daadae..fb5d9f4 100644 (file)
@@ -14,7 +14,7 @@
 
 
 int
-showfile (char **arg, char *file)
+showfile (char *const *arg, char *const file)
 {
     pid_t pid;
     int vecp;
index 8ff12b8..08b08cd 100644 (file)
@@ -4,4 +4,4 @@
  * COPYRIGHT file in the root directory of the nmh distribution for
  * complete copyright information. */
 
-int showfile(char **, char *);
+int showfile(char *const *, char *);
index d0ebc33..85b5a83 100644 (file)
@@ -211,12 +211,20 @@ svector_at (svector_t vec, size_t i)
     return vec->strs[i];
 }
 
+char *
+svector_read_at (const struct svector *const vec, size_t i)
+{
+    if (i >= vec->maxsize)
+        return NULL;
+    return vec->strs[i];
+}
+
 /*
  * Return address of first element that stringwise matches s.
  * The caller can replace the contents of the return address.
  */
 char **
-svector_find (svector_t vec, const char *s)
+svector_find (const struct svector *const vec, const char *s)
 {
     size_t i;
     char **str = vec->strs;
@@ -231,13 +239,13 @@ svector_find (svector_t vec, const char *s)
 }
 
 char **
-svector_strs (svector_t vec)
+svector_strs (const struct svector *const vec)
 {
     return vec->strs;
 }
 
 size_t
-svector_size (svector_t vec)
+svector_size (const struct svector *const vec)
 {
     return vec->size;
 }
index fc03a2d..e0afd0b 100644 (file)
@@ -32,9 +32,10 @@ svector_t svector_create(size_t);
 void svector_free(svector_t) NONNULL(1);
 char *svector_push_back(svector_t, char *) NONNULL(1);
 char *svector_at(svector_t, size_t) NONNULL(1);
-char **svector_find(svector_t, const char *) NONNULL(1) PURE;
-char **svector_strs(svector_t) NONNULL(1) PURE;
-size_t svector_size(svector_t) NONNULL(1) PURE;
+char *svector_read_at(const struct svector *, size_t) NONNULL(1);
+char **svector_find(const struct svector *, const char *) NONNULL(1) PURE;
+char **svector_strs(const struct svector *) NONNULL(1) PURE;
+size_t svector_size(const struct svector *) NONNULL(1) PURE;
 
 typedef struct ivector *ivector_t;
 
index d3875a3..cd5cb05 100644 (file)
@@ -1,6 +1,7 @@
 # Common helper routines for test shell scripts -- to be sourced by them
 # @configure_input@
 
+exec < /dev/null
 
 #### The following variables are set by "make check".  Ensure
 #### that they are set here so that individual tests can be run
@@ -34,6 +35,7 @@ test -z "$CURL_USER_AGENT"  &&  CURL_USER_AGENT="@CURL_USER_AGENT@"
 HOME="$MH_TEST_DIR"
 export HOME
 
+unset EDITOR
 unset MAILDROP MHBUILD MHCONTEXT MHMTSUSERCONF MHN MHSHOW MHSTORE
 unset MHLDEBUG MHWDEBUG PAGER XOAUTH SMTPUTF8
 #### Set LC_ALL in individual tests as needed.  Unset these so
@@ -189,6 +191,16 @@ prepare_space() {
   sed 's/'"`printf '\\302\\240'`"'/ /g'
 }
 
+#### fail increments the global variable "failed" and return non-zero.
+#### Any parameters are passed to echo.
+fail() {
+    if [ x"$1" != x ]; then
+        echo "$@"
+    fi
+    failed=`expr ${failed:-0} + 1`
+    false
+}
+
 #### check() requires two arguments, each the name of a file to be
 #### diff'ed.
 #### If the contents are same, the second file is removed.  If different,
@@ -232,10 +244,7 @@ check() {
         #### POSIX diff should support -c.
         diff -c "$first" "$second" || true
         echo
-        echo "$0: $label failed, outputs are in $first and $second."
-        failed=`expr ${failed:-0} + 1`
-        #### Set return status of the function.
-        [ $failed -eq 0 ]
+        fail "$0: $label failed, outputs are in $first and $second."
     fi
 }
 
@@ -341,6 +350,7 @@ cleanup() {
     #### cd to $MH_TEST_DIR before trying to remove its Mail
     #### subdirectory.  rm on Solaris won't remove it if it's in the
     #### path of the current working directory.
+    # TODO multi-profile?
     test -z "$MH_TEST_NOCLEANUP" && (cd "$MH_TEST_DIR" && rm -rf "$MH_TEST_DIR"/Mail)
 
     #### Report test name if set, which indicates failure.
@@ -364,6 +374,7 @@ cleanup() {
 
 #### Function to set the test name, and whatever the future brings.
 start_test() {
+    [ -n "$MH_TEST_VERBOSE" ] && echo "START TEST $1"
     #### run_test disables exit on non-zero status, but does increment
     #### failed.  Don't overwrite nmh_tests_testname if there was a
     #### failure; remember the first test that failed.
@@ -378,14 +389,21 @@ finish_test() {
     [ ${failed:-0} -eq 0 ]  &&  unset nmh_tests_testname
 }
 
-setup_test ()
+set_mh_profile()
 {
-  set -e
+    profile="$1"
+    MH="${MH_TEST_DIR}/$profile/.mh_profile"
+    MHMTSCONF="${MH_INST_DIR}${nmhetcdir}/mts.conf"
+    MHTMPDIR="$MH_TEST_DIR/$profile"
+    MH_LIBEXEC_DIR="${MH_INST_DIR}${nmhlibexecdir}"
+    export MH MHMTSCONF MHTMPDIR MH_LIBEXEC_DIR
+}
 
-  MH="${MH_TEST_DIR}/Mail/.mh_profile"
-  MHMTSCONF="${MH_INST_DIR}${nmhetcdir}/mts.conf"
-  MH_LIBEXEC_DIR="${MH_INST_DIR}${nmhlibexecdir}"
-  export MH MHMTSCONF MH_LIBEXEC_DIR
+setup_test_without_data ()
+{
+  set -e
+  set_mh_profile "$1"
+  profiles="$profiles $profile"
 
   #
   # Only install once
@@ -394,8 +412,7 @@ setup_test ()
     :
   else
     (cd "${MH_OBJ_DIR}" &&
-      make DESTDIR="${MH_INST_DIR}" SETGID_MAIL= install) ||
-      exit 1
+      make DESTDIR="${MH_INST_DIR}" SETGID_MAIL= install)
 
     #### Don't test with sendmail because it would really send the
     #### mail.  If configured to use sendmail, change to smtp instead
@@ -405,11 +422,12 @@ setup_test ()
     #### test-slocal needs to look at the original mts.conf, so save it.
     mv -f "${MHMTSCONF}" "${MHMTSCONF}.old"
     sed -e 's/mts: *.*/mts: smtp/' \
-        -e "s%mmdfldir: *.*%mmdfldir: ${MH_TEST_DIR}/Mail%" \
+        -e "s%mmdfldir: *.*%mmdfldir: ${MH_TEST_DIR}/$1%" \
         -e 's%mmdflfil: *.*%mmdflfil: maildrop%' \
         "${MHMTSCONF}.old" >"${MHMTSCONF}"
   fi
 
+  # TODO we prepend it again for every new profile...
   #### On Solaris, must set PATH after the install!
   PATH="${MH_INST_DIR}${bindir}:${PATH}"
   export PATH
@@ -417,10 +435,12 @@ setup_test ()
   # clean old test data on exit
   trap cleanup 0
 
-  # setup test data
-  mkdir -p "$MH_TEST_DIR/Mail" || exit 1
-  cat > "$MH" <<EOF || exit 1
-Path: ${MH_TEST_DIR}/Mail
+  # But crashes happen; cleanup here too.
+  rm -rf "$MH_TEST_DIR/$profile"
+
+  mkdir -p "$MH_TEST_DIR/$1"
+  cat > "$MH" <<EOF
+Path: ${MH_TEST_DIR}/$1
 buildmimeproc: ${MH_INST_DIR}${bindir}/mhbuild
 fileproc: ${MH_INST_DIR}${bindir}/refile
 libexecdir: ${MH_LIBEXEC_DIR}
@@ -446,18 +466,23 @@ EOF
     #### installation.
     #### posh doesn't like "${MH_INST_DIR}${nmhetcdir}"/*, so cd to
     #### the directory and provide an argument without quotes to cp.
-    (cd "${MH_INST_DIR}${nmhetcdir}/"  &&  cp * "${MH_TEST_DIR}/Mail")
+    (cd "${MH_INST_DIR}${nmhetcdir}/"  &&  cp * "${MH_TEST_DIR}/$1")
   fi
+}
+
+setup_test_data ()
+{
+  set -e
 
   folder -create +inbox > /dev/null
   #### Suppress warnings about a new version.
   #### mhparam version includes the nmh- prefix, so be consistent with that.
-  printf 'Version: nmh-%s\n' "${MH_VERSION}" >> "${MH_TEST_DIR}"/Mail/context
+  printf 'Version: nmh-%s\n' "${MH_VERSION}" >> "${MH_TEST_DIR}/$1/context"
 
   # create 10 basic messages
   for i in 1 2 3 4 5 6 7 8 9 10;
   do
-    cat > $MH_TEST_DIR/Mail/inbox/$i <<EOF || exit 1
+    cat > "$MH_TEST_DIR/$1/inbox/$i" <<EOF || exit 1
 From: Test$i <test$i@example.com>
 To: Some User <user@example.com>
 Date: Fri, 29 Sep 2006 00:00:00
@@ -468,3 +493,49 @@ This is message number $i
 EOF
   done
 }
+
+setup_test ()
+{
+  set -e
+
+  setup_test_without_data Mail
+  setup_test_data Mail
+}
+
+### Start fakeinetd.
+### All arguments are passed as-is to fakeinetd.
+### If successul, returns 0 and sets parameters fakeinetd_pid and fakeinetd_port.
+start_fakeinetd() {
+    fakeinetd_pid_port=$("$MH_OBJ_DIR/test/fakeinetd" "$@") || return
+    set -- $fakeinetd_pid_port
+    unset fakeinetd_pid_port
+    [ $# -eq 2 ] || return
+    fakeinetd_pid=$1
+    fakeinetd_port=$2
+}
+
+# TODO autoconf
+STUNNEL=stunnel
+
+### Start stunnel under fakeinetd.
+### start_stunnel NAME EXEC
+### See start_fakeinetd above.
+start_stunnel() {
+    cat > "$MH_TEST_DIR/$1.stunnel.cf" <<EOF
+foreground = yes
+output = ${MH_TEST_STUNNEL_OUTPUT:-/dev/null}
+syslog = no
+cert = $MH_OBJ_DIR/fake.pem
+key = $MH_OBJ_DIR/fake.pem
+exec = $2
+EOF
+    if [ -n "$MH_TEST_STUNNEL_DEBUG" ]; then
+        echo "debug = $MH_TEST_STUNNEL_DEBUG" >> "$MH_TEST_DIR/$1.stunnel.cf"
+    fi
+    cat > "$MH_TEST_DIR/$1.stunnel.sh" <<EOF
+#!/bin/sh
+exec $STUNNEL "$MH_TEST_DIR/$1.stunnel.cf" 2> ${MH_TEST_STUNNEL_OUTPUT:-/dev/null}
+EOF
+    chmod 755 "$MH_TEST_DIR/$1.stunnel.sh"
+    start_fakeinetd "$MH_TEST_DIR/$1.stunnel.sh"
+}
diff --git a/test/comp/test-draft-fcc b/test/comp/test-draft-fcc
new file mode 100755 (executable)
index 0000000..1702917
--- /dev/null
@@ -0,0 +1,205 @@
+#!/bin/sh
+
+if test -z "${MH_OBJ_DIR}"; then
+    srcdir=`dirname "$0"`/../..
+    MH_OBJ_DIR=`cd "$srcdir" && pwd`; export MH_OBJ_DIR
+fi
+
+. "${MH_OBJ_DIR}/test/common.sh"
+
+setup_test
+
+cat >> "$MH" <<EOF
+Draft-Folder: drafts
+Local-Mailbox: norm@example.com
+EOF
+
+# Set this for the EHLO command
+echo "clientname: nosuchhost.example.com" >> ${MHMTSCONF}
+
+cat > "$MH_TEST_DIR/Mail/scan.dateless" <<EOF
+%4(msg)%<(cur)+%| %>%<(unseen)U%| %>%<{replied}-%| %>\
+%<(mymbox{from})%<{to}To:%(unquote(decode(friendly{to})))%>%>\
+%<(zero)%(unquote(decode(friendly{from})))%>  \
+%(decode{subject})%<{body}<<%{body}>>%>
+EOF
+
+sbackup="`mhparam sbackup`"
+
+arith_eval 64000 + $$ % 1000
+smtp_port=$arith_val
+
+expected="$MH_TEST_DIR/$$.expected"
+actual="$MH_TEST_DIR/$$.actual"
+
+################################################################################
+start_test 'comp with Draft-Folder'
+
+check_exit '-eq 0' comp -noedit -to phyl@example.com -fcc sent -subject test -nowhatnowproc < /dev/null
+
+################################################################################
+start_test 'scan +drafts shows the draft'
+
+cat > "$expected" <<EOF
+   1+  To:phyl@example.com  test
+EOF
+
+run_prog scan -form scan.dateless +drafts > "$actual"
+check "$actual" "$expected"
+
+################################################################################
+start_test 'send with Draft-Folder'
+
+pid=`"${MH_OBJ_DIR}/test/fakesmtp" /dev/null $smtp_port`
+
+if ! (echo y | check_exit '-eq 0' send -server 127.0.0.1 -port $smtp_port); then
+    failed=`expr ${failed:-0} + 1`
+fi
+kill $pid 2> /dev/null || :
+[ ${failed:-0} -eq 0 ]
+
+################################################################################
+start_test 'drafts/.mh_sequences'
+
+cat > "$expected" <<EOF
+cur: 1
+EOF
+check "`mhpath +drafts`/.mh_sequences" "$expected"
+
+################################################################################
+start_test 'scan +drafts shows no messages'
+
+cat > "$expected" <<EOF
+scan: no messages in drafts
+EOF
+
+run_prog scan -form scan.dateless +drafts > "$actual" 2>&1 || :
+check "$actual" "$expected"
+
+################################################################################
+start_test 'scan +sent shows the sent message'
+
+cat > "$expected" <<EOF
+   1   To:phyl@example.com  test
+EOF
+
+run_prog scan -form scan.dateless +sent > "$actual"
+check "$actual" "$expected"
+
+################################################################################
+start_test 'drafts/,1.orig is the original draft'
+
+cat > "$expected" <<EOF
+From: norm@example.com
+To: phyl@example.com
+cc:
+Fcc: sent
+Subject: test
+--------
+EOF
+check "`mhpath +drafts`/,1.orig" "$expected"
+
+################################################################################
+start_test 'drafts/,1 is the post-mhbuild draft'
+
+cat > "$expected" <<EOF
+From: norm@example.com
+To: phyl@example.com
+cc:
+Fcc: sent
+Subject: test
+MIME-Version: 1.0
+Content-Type: text/plain
+EOF
+check "`mhpath +drafts`/,1" "$expected"
+
+################################################################################
+# Now test with -draftfolder.
+# TODO Why are these different?!
+################################################################################
+
+################################################################################
+start_test 'comp with Draft-Folder now for send -draftfolder'
+
+check_exit '-eq 0' comp -noedit -to phyl@example.com -fcc sent -subject test2 -nowhatnowproc < /dev/null
+
+################################################################################
+start_test 'scan +drafts shows the draft now for send -draftfolder'
+
+cat > "$expected" <<EOF
+   1+  To:phyl@example.com  test2
+EOF
+
+run_prog scan -form scan.dateless +drafts > "$actual"
+check "$actual" "$expected"
+
+################################################################################
+start_test 'send -draftfolder'
+
+pid=`"${MH_OBJ_DIR}/test/fakesmtp" /dev/null $smtp_port`
+
+if ! check_exit '-eq 0' send -draftfolder drafts -server 127.0.0.1 -port $smtp_port < /dev/null; then
+    failed=`expr ${failed:-0} + 1`
+fi
+kill $pid 2> /dev/null || :
+[ ${failed:-0} -eq 0 ]
+
+################################################################################
+start_test 'drafts/.mh_sequences'
+
+cat > "$expected" <<EOF
+cur: 1
+EOF
+check "`mhpath +drafts`/.mh_sequences" "$expected"
+
+################################################################################
+start_test 'scan +drafts shows no messages now for send -draftfolder'
+
+cat > "$expected" <<EOF
+scan: no messages in drafts
+EOF
+
+run_prog scan -form scan.dateless +drafts > "$actual" 2>&1 || :
+check "$actual" "$expected"
+
+################################################################################
+start_test 'scan +sent shows the sent message now for send -draftfolder'
+
+cat > "$expected" <<EOF
+   1   To:phyl@example.com  test
+   2   To:phyl@example.com  test2
+EOF
+
+run_prog scan -form scan.dateless +sent > "$actual"
+check "$actual" "$expected"
+
+################################################################################
+start_test "drafts/${sbackup}1.orig is the original draft now for send -draftfolder"
+
+cat > "$expected" <<EOF
+From: norm@example.com
+To: phyl@example.com
+cc:
+Fcc: sent
+Subject: test2
+--------
+EOF
+check "`mhpath +drafts`/${sbackup}1.orig" "$expected"
+
+################################################################################
+start_test "drafts/${sbackup}1 is the post-mhbuild draft now for send -draftfolder"
+
+cat > "$expected" <<EOF
+From: norm@example.com
+To: phyl@example.com
+cc:
+Fcc: sent
+Subject: test2
+MIME-Version: 1.0
+Content-Type: text/plain
+EOF
+check "`mhpath +drafts`/${sbackup}1" "$expected"
+
+finish_test
+
+exit ${failed:-0}
diff --git a/test/fakeinetd.c b/test/fakeinetd.c
new file mode 100644 (file)
index 0000000..7284bca
--- /dev/null
@@ -0,0 +1,135 @@
+/* fakeinetd - a simple inetd-alike (more like tcpserver-alike) for tests
+ *
+ * This code is Copyright (c) 2021, by the authors of nmh.  See the
+ * COPYRIGHT file in the root directory of the nmh distribution for
+ * complete copyright information.
+ *
+ * fakeinetd listens for TCP connections to a system-assigned port on 127.0.0.1,
+ * handing accepted connections to a child process for processing.  The first
+ * argument to fakeinetd is the name of the handler program to run and all other
+ * arguments are passed to it.  Its standard input and output will be the
+ * accepted socket with standard error inherited.
+ *
+ * If fakeinetd is able to begin accepting connections, it forks itself into
+ * the background, prints a single line to its standard output, then closes
+ * its standard output.
+ *
+ * That single line consists of the PID, a space, and the listening port.
+ */
+
+// TODO duplicated from server.c
+//  Why not take the listening code out of fake*.c and run under this?
+
+#include <sys/types.h>
+#include <sys/socket.h>
+#include <netinet/in.h>
+#include <arpa/inet.h>
+
+#include <errno.h>
+#include <signal.h>
+#include <stdio.h>
+#include <unistd.h>
+
+int
+main(int argc, char *argv[])
+{
+    if (close(0) < 0) {
+       perror("close(0)");
+       return 1;
+    }
+
+    // We're not interested in children; ignore this to avoid zombies.
+    signal(SIGCHLD, SIG_IGN);
+
+    int const listening = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
+    if (listening < 0) {
+       perror("socket");
+       return 1;
+    }
+
+    {
+       struct sockaddr_in sin = { 0 };
+       int status = inet_pton(AF_INET, "127.0.0.1", &(sin.sin_addr));
+       switch (status) {
+       case 1:
+           break;
+       case -1:
+           perror("inet_pton(127.0.0.1)");
+           return 1;
+       case 0:
+           fputs("inet_pton rejected hard-coded 127.0.0.1?!\n", stderr);
+           return 1;
+       default:
+           fprintf(stderr, "inet_pton(127.0.0.1) nonstandard return %d\n", status);
+           return 1;
+       }
+       sin.sin_family = AF_INET;
+       if (bind(listening, (struct sockaddr *)&sin, sizeof sin) < 0) {
+           perror("bind");
+           return 1;
+       }
+    }
+
+    if (listen(listening, SOMAXCONN) < 0) {
+       perror("listen");
+       return 1;
+    }
+
+    setsid();
+    switch (fork()) {
+    case -1:
+       perror("fork");
+       return 1;
+    case 0:
+       break;
+    default:
+       return 0;
+    }
+
+    {
+       struct sockaddr_in sin = { 0 };
+       socklen_t slen = sizeof sin;
+       if (getsockname(listening, (struct sockaddr *)&sin, &slen) < 0) {
+           perror("getsockname");
+           return 1;
+       }
+       if (printf("%llu %llu\n", (unsigned long long)getpid(), (unsigned long long)ntohs(sin.sin_port)) < 0) {
+           perror("printf");
+           return 1;
+       }
+       if (fclose(stdout) != 0) {
+           perror("fclose(1)");
+           return 1;
+       }
+    }
+
+    argv++;
+    for (;;) {
+       int const connected = accept(listening, NULL, NULL);
+       if (connected < 0) {
+           if (errno == EINTR)
+               continue;
+           perror("accept");
+           return 1;
+       }
+       switch (fork()) {
+       case -1:
+           perror("fork");
+           return 1;
+       case 0:
+           if (dup2(connected, 0) < 0 || dup2(connected, 1) < 0) {
+               perror("dup2");
+               return 1;
+           }
+           if (execvp(argv[0], argv) < 0) {
+               perror("execvp");
+               return 1;
+           }
+       default:
+           if (close(connected) < 0) {
+               perror("close");
+               return 1;
+           }
+       }
+    }
+}
diff --git a/test/imap/test-astring b/test/imap/test-astring
new file mode 100755 (executable)
index 0000000..670ab54
--- /dev/null
@@ -0,0 +1,121 @@
+#!/bin/sh
+
+# Test astring handling.
+# For now only in LIST response.
+# Gmail encodes all mailbox names in quotes and embedded quotes like "foo \" bar"
+# Panda encodes them using literals.
+
+if test -z "$MH_OBJ_DIR"; then
+    srcdir=`dirname "$0"`/../..
+    MH_OBJ_DIR=`cd "$srcdir" && pwd`; export MH_OBJ_DIR
+fi
+
+# TODO no need for xoauth in this test
+. "${srcdir}/test/oauth/common.sh"
+
+# common.sh sets this to something crazy; did i do that?!
+testname=${0#$srcdir/}
+
+# coverage notes
+# (
+#     set -ex
+#     . ~/.cargo/env
+#     covdir="$builddir/test/covdata/${MH_COVERAGE_PROFILE:-default}/test/cargo"
+#     install -d "$covdir"
+#     export LLVM_PROFILE_FILE="$covdir/%m-%p.profraw"
+#     export CARGO_TARGET_DIR=$builddir/target
+#     export RUSTC_BOOTSTRAP=1
+#     export RUSTFLAGS="-Zinstrument-coverage"
+#     cargo t -- --test-threads=1
+#     make cargo
+#     MH_IMAP_TEST_SERVER=panda make check 'TESTS=test/imap/test-astring'
+# )
+#grcov .o/clang10 -s . --binary-path .o/clang10/target/debug/ -t html --branch --ignore-not-existing -o /coverage/default > covlog 2>&1
+
+setup_test_without_data Mail
+
+setup_service
+fake_creds <<EOF
+access-testuser: test-access
+refresh-testuser: test-refresh
+expire-testuser: 2000000000
+EOF
+
+imap_cleanup() {
+    #### Save exit status to use as status for this program.
+    status=$?
+    set +e
+
+    rm -f "$imapd"
+
+    [ -n "$imapd_pid" ] && kill $imapd_pid
+    "$MH_OBJ_DIR/target/debug/mhimap" imap shutdown
+
+    # TODO have cleanup allow additional cleanup registration?
+    #  Or just allow it to accept status as argument.
+    (exit $status)
+    cleanup
+}
+trap imap_cleanup 0
+
+covdir="$MH_OBJ_DIR/covdata/${MH_COVERAGE_PROFILE:-default}/$testname"
+install -d "$covdir"
+export LLVM_PROFILE_FILE="$covdir/$profile-%m.profraw"
+
+imapd="$MH_TEST_DIR/$$.imapd"
+start_fakeinetd "$imapd"
+imapd_pid=$fakeinetd_pid
+
+cat >> "$MH" <<EOF
+Managed-Folders: imap
+imap-manager: $MH_OBJ_DIR/target/debug/imap-folder-manager
+imap-socket: $MH_TEST_DIR/Mail/imap.d/socket
+imap-db: $MH_TEST_DIR/Mail/imap.d
+imap-host: 127.0.0.1
+imap-port: $fakeinetd_port
+imap-tls: don't protect my data i know what i'm doing
+imap-auth: SASL
+imap-user: testuser
+imap-saslmech: xoauth2
+imap-authservice: test
+EOF
+if [ -n "$MH_TEST_IMAP_LOG_LEVEL" ]; then
+    echo "imap-log-level: $MH_TEST_IMAP_LOG_LEVEL" >> "$MH"
+    echo "imap-log-$MH_TEST_IMAP_LOG_LEVEL-to: $MH_TEST_DIR/Mail/imap.d/log.$MH_TEST_IMAP_LOG_LEVEL" >> "$MH"
+fi
+
+# Test astring and nstring handling with some faked up but legal responses.
+# I don't know whether any implementations return nil or quoted in these FETCH
+# respones, but it is legal, so it's a fair test.
+# TODO removed nstring testing for now
+
+cat > "$imapd" <<EOSCRIPT
+#!/bin/sh
+cat <<EOF
+* OK [CAPABILITY IMAP4REV1 SASL-IR AUTH=PLAIN AUTH=XOAUTH2] greeting\r
+1 OK [CAPABILITY IMAP4REV1 UIDPLUS] authenticated\r
+* LIST () "/" "embedded quote \" gmail style"\r
+* LIST () "/" {28}\r
+embedded quote " panda style\r
+1 OK LIST completed\r
+* 0 EXISTS\r
+* OK [UIDVALIDITY 1603585238] UID validity status\r
+* OK [UIDNEXT 1] Predicted next UID\r
+1 OK [READ-WRITE] SELECT completed\r
+1 OK ESEARCH\r
+* 0 EXISTS\r
+* OK [UIDVALIDITY 1603585238] UID validity status\r
+* OK [UIDNEXT 1] Predicted next UID\r
+* 7 FETCH ()\r
+* 7 FETCH (UID 1 FLAGS (\Seen))\r
+1 OK [READ-WRITE] SELECT completed\r
+1 OK ESEARCH\r
+EOF
+while read line; do :; done
+EOSCRIPT
+chmod 755 "$imapd"
+
+check_exit '-eq 0' inc +imap
+
+finish_test
+exit ${failed:-0}
diff --git a/test/imap/test-bad b/test/imap/test-bad
new file mode 100755 (executable)
index 0000000..d829e78
--- /dev/null
@@ -0,0 +1,83 @@
+#!/bin/sh
+
+# TODO rename this file test-bad-list
+# TODO figure out how to get a server to send a BAD response so we have an accurate fake
+
+if test -z "$MH_OBJ_DIR"; then
+    srcdir=`dirname "$0"`/../..
+    MH_OBJ_DIR=`cd "$srcdir" && pwd`; export MH_OBJ_DIR
+fi
+
+. "$MH_OBJ_DIR/test/common.sh"
+
+# common.sh sets this to something crazy; did i do that?!
+testname=${0#$srcdir/}
+
+setup_test_without_data Mail
+
+imap_cleanup() {
+    #### Save exit status to use as status for this program.
+    status=$?
+    set +e
+
+    rm -f "$imapd"
+
+    [ -n "$imapd_pid" ] && kill $imapd_pid
+    "$MH_OBJ_DIR/target/debug/mhimap" imap shutdown
+
+    # TODO have cleanup allow additional cleanup registration?
+    #  Or just allow it to accept status as argument.
+    (exit $status)
+    cleanup
+}
+trap imap_cleanup 0
+
+covdir="$MH_OBJ_DIR/covdata/${MH_COVERAGE_PROFILE:-default}/$testname"
+install -d "$covdir"
+export LLVM_PROFILE_FILE="$covdir/$profile-%m.profraw"
+
+imapd="$MH_TEST_DIR/$$.imapd"
+start_fakeinetd "$imapd"
+imapd_pid=$fakeinetd_pid
+
+cat >> "$MH" <<EOF
+Managed-Folders: imap
+imap-manager: $MH_OBJ_DIR/target/debug/imap-folder-manager
+imap-socket: $MH_TEST_DIR/Mail/imap.d/socket
+imap-db: $MH_TEST_DIR/Mail/imap.d
+imap-host: 127.0.0.1
+imap-port: $fakeinetd_port
+imap-tls: don't protect my data i know what i'm doing
+imap-auth: PREAUTH
+imap-mailbox-root: test/
+EOF
+if [ -n "$MH_TEST_IMAP_LOG_LEVEL" ]; then
+    echo "imap-log-level: $MH_TEST_IMAP_LOG_LEVEL" >> "$MH"
+    echo "imap-log-$MH_TEST_IMAP_LOG_LEVEL-to: $MH_TEST_DIR/$profile/imap.d/log.$MH_TEST_IMAP_LOG_LEVEL" >> "$MH"
+fi
+
+expected="$MH_TEST_DIR/$$.expected"
+actual="$MH_TEST_DIR/$$.actual"
+
+start_test 'unrecoverable error:  BAD LIST'
+
+cat > "$imapd" <<EOSCRIPT
+#!/bin/sh
+cat <<EOF
+* PREAUTH [CAPABILITY IMAP4REV1 UIDPLUS] authenticated\r
+* LIST (\NoInferiors \UnMarked) "/" test/fold\r
+1 BAD fake fake fakey fake\r
+EOF
+while read line; do :; done
+EOSCRIPT
+chmod 755 "$imapd"
+
+cat > "$expected" <<EOF
+inc: failed to inc folder imap:  BAD response from LIST:  Unknown fake fake fakey fake
+EOF
+inc +imap > "$actual" 2>&1 || status=$?
+check "$actual" "$expected"
+(exit $status) && fail 'inc exited 0 on error'
+
+finish_test
+exit ${failed:-0}
diff --git a/test/imap/test-bad-fetch-body b/test/imap/test-bad-fetch-body
new file mode 100755 (executable)
index 0000000..ecb85be
--- /dev/null
@@ -0,0 +1,90 @@
+#!/bin/sh
+
+# TODO FIX uh oh this and the inc missing msg cases passed by accident after inc uses ESEARCH
+#  and now switching inc to leave mailbox open has broken it completely!
+
+# TODO figure out how to get a server to send a BAD response so we have an accurate fake
+
+if test -z "$MH_OBJ_DIR"; then
+    srcdir=`dirname "$0"`/../..
+    MH_OBJ_DIR=`cd "$srcdir" && pwd`; export MH_OBJ_DIR
+fi
+
+. "$MH_OBJ_DIR/test/common.sh"
+
+# common.sh sets this to something crazy; did i do that?!
+testname=${0#$srcdir/}
+
+setup_test_without_data Mail
+
+imap_cleanup() {
+    #### Save exit status to use as status for this program.
+    status=$?
+    set +e
+
+    rm -f "$imapd"
+
+    [ -n "$imapd_pid" ] && kill $imapd_pid
+    "$MH_OBJ_DIR/target/debug/mhimap" imap shutdown
+
+    # TODO have cleanup allow additional cleanup registration?
+    #  Or just allow it to accept status as argument.
+    (exit $status)
+    cleanup
+}
+trap imap_cleanup 0
+
+covdir="$MH_OBJ_DIR/covdata/${MH_COVERAGE_PROFILE:-default}/$testname"
+install -d "$covdir"
+export LLVM_PROFILE_FILE="$covdir/$profile-%m.profraw"
+
+imapd="$MH_TEST_DIR/$$.imapd"
+start_fakeinetd "$imapd"
+imapd_pid=$fakeinetd_pid
+
+cat >> "$MH" <<EOF
+Managed-Folders: imap
+imap-manager: $MH_OBJ_DIR/target/debug/imap-folder-manager
+imap-socket: $MH_TEST_DIR/Mail/imap.d/socket
+imap-db: $MH_TEST_DIR/Mail/imap.d
+imap-host: 127.0.0.1
+imap-port: $fakeinetd_port
+imap-tls: don't protect my data i know what i'm doing
+imap-auth: PREAUTH
+imap-mailbox-root: test/
+EOF
+if [ -n "$MH_TEST_IMAP_LOG_LEVEL" ]; then
+    echo "imap-log-level: $MH_TEST_IMAP_LOG_LEVEL" >> "$MH"
+    echo "imap-log-$MH_TEST_IMAP_LOG_LEVEL-to: $MH_TEST_DIR/$profile/imap.d/log.$MH_TEST_IMAP_LOG_LEVEL" >> "$MH"
+fi
+
+start_test 'unrecoverable error:  BAD FETCH'
+
+cat > "$imapd" <<EOSCRIPT
+#!/bin/sh
+cat <<EOF
+* PREAUTH [CAPABILITY IMAP4REV1 UIDPLUS] authenticated\r
+* LIST (\NoInferiors \UnMarked) "/" test/fold\r
+1 OK LIST completed\r
+* 1 EXISTS\r
+* OK [UIDVALIDITY 1603585238] UID validity status\r
+* OK [UIDNEXT 12] Predicted next UID\r
+1 OK [READ-WRITE] SELECT completed\r
+* SEARCH 11\r
+1 OK UID SEARCH completed\r
+fetch11 BAD fake fake fakey fake\r
+EOF
+while read line; do :; done
+EOSCRIPT
+chmod 755 "$imapd"
+
+# TODO plumb this error out to inc
+
+check_exit '-eq 1' inc +imap
+if ! grep -q 'BAD.*fake fake fakey fake' "$MH_TEST_DIR/$profile/imap.d/stderr"; then
+    cat "$MH_TEST_DIR/$profile/imap.d/stderr" >&2
+    fail "BAD response not in $MH_TEST_DIR/$profile/imap.d/stderr"
+fi
+
+finish_test
+exit ${failed:-0}
diff --git a/test/imap/test-folder-create-disallowed b/test/imap/test-folder-create-disallowed
new file mode 100755 (executable)
index 0000000..ae241c4
--- /dev/null
@@ -0,0 +1,159 @@
+#!/bin/sh
+
+# Errors:  can't live with 'em can't live without 'em
+#   - Priorities:
+#     0. db integrity
+#     1. report comprehensible error to user
+#     2. comprehensible log (good enough for a quality bug report)
+#     3. clean close of IMAP connection
+#   - If db open:
+#     1. clean close if at all possible
+#     2. if not, report failure along with the original failure
+#   - If nmh connection open:
+#     1. write blind error
+#     2. if that write failed log but ignore
+#     3. Else close the socket, log+ignore error here too
+#   - If IMAP connection is open:
+#     1. write a blind LOGOUT
+#     2. if that write failed log but ignore
+#     3. Else close the socket, log+ignore error here too
+#   - If error is detected at layer that has both nmh and IMAP connection
+#     available, handle nmh first; we fight for the users
+#   - In any case, finally error!(it)
+# Types:
+# Environmental
+#   - read/write error
+#   - open error (except ENOENT)
+#   Action: report, log, clean shutdown
+# Protocol
+#   - either the bytes received were bytes expected or they weren't;
+#     context for logging is useful, but we don't need fine-grained types
+#   - this goes for nmh-folder-manager and IMAP protocols
+#   Action: report, log, clean shutdown
+# Invalid request
+#   A request is valid or not regardless of the state of the system.
+#   - E.g.
+#     - UID FETCH without mailbox open - protocol violation:  EXAMINE first
+#     - UID FETCH fred - `fred` is not a valid UID
+#   - In theory, this maps to IMAP `BAD response code, but can we rely on it?
+#   Action:  Internal error, below
+# Illegal request
+#   A request must be valid before its legality may be judged.
+#   - E.g.
+#     - FETCH 10 on mailbox with 9 messages - NO such message
+#     - EXAMINE nosuchmailbox - NO mailbox by that name exists
+#   - In theory, this maps to IMAP `NO response code, but can we rely on it?
+#   - These SHOULD only be caused by user action, e.g. create folder that already exists.
+#   Action: report, log, NO SHUTDOWN
+# Internal
+#   Action: report, log, clean shutdown
+
+testname=${0#$srcdir/}
+
+if test -z "$MH_OBJ_DIR"; then
+    srcdir=`dirname "$0"`/../..
+    MH_OBJ_DIR=`cd "$srcdir" && pwd`; export MH_OBJ_DIR
+fi
+
+. "$MH_OBJ_DIR/test/common.sh"
+
+rm -rf "$MH_TEST_DIR/panda"
+
+imap_cleanup() {
+    #### Save exit status to use as status for this program.
+    status=$?
+    set +e
+
+    [ -n "$imapd_pid" ] && kill $imapd_pid
+
+    # TODO uh oops don't need two profiles for this one
+    for i in 1 2; do
+        set_mh_profile profile$i
+        "$MH_OBJ_DIR/target/debug/mhimap" imap shutdown
+    done
+
+    # TODO have cleanup allow additional cleanup registration?
+    #  Or just allow it to accept status as argument.
+    (exit $status)
+    cleanup
+}
+
+covdir="$MH_OBJ_DIR/covdata/${MH_COVERAGE_PROFILE:-default}/$testname"
+install -d "$covdir"
+
+mkdir "$MH_TEST_DIR/panda"
+mkdir "$MH_TEST_DIR/panda/test"
+
+expected="$MH_TEST_DIR/$$.expected"
+actual="$MH_TEST_DIR/$$.actual"
+
+start_fakeinetd env "HOME=$MH_TEST_DIR/panda" "$PANDA_IMAPD"
+imapd_pid=$fakeinetd_pid
+
+start() {
+    rm -f "$MH_TEST_DIR/$profile/imap.d/socket"
+    status=0
+    LLVM_PROFILE_FILE="$covdir/$profile-%m.profraw" "$MH_OBJ_DIR/target/debug/mhimap" imap start > "$actual" 2>&1 || status=$?
+}
+
+# setup profile 2 and 1, leaving 1 as active profile
+for i in 2 1; do
+    setup_test_without_data profile$i
+    trap imap_cleanup 0
+    cat >> "$MH" <<EOF
+Managed-Folders: imap
+imap-manager: $MH_OBJ_DIR/target/debug/imap-folder-manager
+imap-socket: $MH_TEST_DIR/profile$i/imap.d/socket
+imap-db: $MH_TEST_DIR/profile$i/imap.d
+imap-host: 127.0.0.1
+imap-port: $fakeinetd_port
+imap-tls: don't protect my data i know what i'm doing
+imap-auth: PREAUTH
+imap-mailbox-root: test/
+EOF
+    if [ -n "$MH_TEST_IMAP_LOG_LEVEL" ]; then
+        echo "imap-log-level: $MH_TEST_IMAP_LOG_LEVEL" >> "$MH"
+        echo "imap-log-$MH_TEST_IMAP_LOG_LEVEL-to: $MH_TEST_DIR/$profile/imap.d/log.$MH_TEST_IMAP_LOG_LEVEL" >> "$MH"
+    fi
+    start
+done
+
+################################################################################
+start_test 'profile1: folder +imap/fold -create'
+check_exit '-eq 0' folder +imap/fold -create
+
+################################################################################
+start_test 'profile2: folder +imap/fold -create'
+
+set_mh_profile profile2
+
+# TODO Oops folder -create when folder is already known LOCALLY to exist tries to create over IMAP!
+
+cat > "$expected" <<EOF
+folder: failed to create folder imap/fold:  Can't create mailbox node $MH_TEST_DIR/panda/test/fold: File exists
+EOF
+
+exitcode=1
+folder +imap/fold -create > "$actual" 2>&1 || exitcode=$?
+check "$actual" "$expected"
+if [ $exitcode -ne 1 ]; then
+    fail "folder exited $exitcode rather than 1"
+fi
+
+################################################################################
+start_test 'profile2: inc +imap leaves imap/fold SELECTed'
+inc +imap
+
+################################################################################
+start_test 'profile1: rmf +imap/fold fails because profile1 has it SELECTED'
+
+set_mh_profile profile1
+
+rmf +imap/fold -nointeractive || status=$?
+if ! grep -q 'NO Mailbox test/fold is in use by another process' "$MH_TEST_DIR/$profile/imap.d/stderr"; then
+    cat "$MH_TEST_DIR/$profile/imap.d/stderr" >&2
+    fail 'mailbox deletion not disallowed'
+fi
+
+finish_test
+exit ${failed:-0}
diff --git a/test/imap/test-gmail-all b/test/imap/test-gmail-all
new file mode 100755 (executable)
index 0000000..5a5fedf
--- /dev/null
@@ -0,0 +1,61 @@
+#!/bin/sh
+
+# TODO this must have bitrotted to oblivion...
+
+testname=${0#$srcdir/}
+
+if test -z "$MH_OBJ_DIR"; then
+    srcdir=`dirname "$0"`/../..
+    MH_OBJ_DIR=`cd "$srcdir" && pwd`; export MH_OBJ_DIR
+fi
+
+. "$MH_OBJ_DIR/test/common.sh"
+
+setup_test_without_data Mail
+
+imap_cleanup() {
+    #### Save exit status to use as status for this program.
+    status=$?
+    set +e
+
+    "$MH_OBJ_DIR/target/debug/mhimap" imap shutdown
+
+    # TODO have cleanup allow additional cleanup registration?
+    #  Or just allow it to accept status as argument.
+    (exit $status)
+    cleanup
+}
+trap imap_cleanup 0
+
+# * LIST (\All \HasNoChildren) "/" "[Gmail]/All Mail"
+cat >> "$MH" <<EOF
+oauth-gmail-credential-file: $MH_IMAP_TEST_OAUTH_FILE
+imap-host: imap.gmail.com
+imap-port: imaps
+imap-user: $MH_IMAP_TEST_USER
+imap-tls: implicit
+imap-auth: SASL
+imap-saslmech: xoauth2
+imap-authservice: gmail
+imap-mailbox-root: [Gmail]
+imap-mailbox-exclude: .
+imap-mailbox-include: All Mail
+Unseen-Sequence: unseen
+Managed-Folders: imap
+imap-manager: $MH_OBJ_DIR/target/debug/imap-folder-manager
+imap-socket: $MH_TEST_DIR/Mail/imap.d/socket
+imap-db: $MH_TEST_DIR/Mail/imap.d
+EOF
+if [ -n "$MH_TEST_IMAP_LOG_LEVEL" ]; then
+    echo "imap-log-level: $MH_TEST_IMAP_LOG_LEVEL" >> "$MH"
+    echo "imap-log-$MH_TEST_IMAP_LOG_LEVEL-to: $MH_TEST_DIR/$profile/imap.d/log.$MH_TEST_IMAP_LOG_LEVEL" >> "$MH"
+fi
+
+covdir="$MH_OBJ_DIR/covdata/${MH_COVERAGE_PROFILE:-default}/$testname"
+install -d "$covdir"
+export LLVM_PROFILE_FILE="$covdir/$profile-%m.profraw"
+
+start_test 'inc All Mail'
+inc +imap
+finish_test
+exit ${failed:-0}
diff --git a/test/imap/test-inc b/test/imap/test-inc
new file mode 100755 (executable)
index 0000000..ce6adc9
--- /dev/null
@@ -0,0 +1,476 @@
+#!/bin/sh
+
+testname=${0#$srcdir/}
+
+if test -z "$MH_OBJ_DIR"; then
+    srcdir=`dirname "$0"`/../..
+    MH_OBJ_DIR=`cd "$srcdir" && pwd`; export MH_OBJ_DIR
+fi
+
+. "$MH_OBJ_DIR/test/common.sh"
+
+rm -rf "$MH_TEST_DIR/panda"
+
+imap_cleanup() {
+    #### Save exit status to use as status for this program.
+    status=$?
+    set +e
+
+    [ -n "$imapd_pid" ] && kill $imapd_pid
+
+    for profile in $profiles; do
+        set_mh_profile $profile
+        "$MH_OBJ_DIR/target/debug/mhimap" imap shutdown
+    done
+
+    # TODO have cleanup allow additional cleanup registration?
+    #  Or just allow it to accept status as argument.
+    (exit $status)
+    cleanup
+}
+
+mkdir "$MH_TEST_DIR/panda"
+mkdir "$MH_TEST_DIR/panda/test"
+
+expected="$MH_TEST_DIR/$$.expected"
+actual="$MH_TEST_DIR/$$.actual"
+
+# TODO duplicated with test-scan
+arith_val=1
+message_count=11
+cat > "$MH_TEST_DIR/panda/test/fold" <<EOF
+From nmh@example.com Sat Oct 24 19:20:38 2020 -0500
+Date:Sat, 24 Oct 2020 19:20:38 -0500
+from:me
+to:you
+X-IMAPbase: 1603585238 $message_count testseq
+Status: RO
+X-Status: 
+X-Keywords:          
+X-UID: $arith_val
+
+body
+
+EOF
+
+start_fakeinetd env "HOME=$MH_TEST_DIR/panda" "$PANDA_IMAPD"
+imapd_pid=$fakeinetd_pid
+
+start() {
+    rm -f "$MH_TEST_DIR/$profile/imap.d/socket"
+    status=0
+    LLVM_PROFILE_FILE="$covdir/$profile-%m.profraw" "$MH_OBJ_DIR/target/debug/mhimap" imap start > "$actual" 2>&1 || status=$?
+}
+
+covdir="$MH_OBJ_DIR/covdata/${MH_COVERAGE_PROFILE:-default}/$testname"
+install -d "$covdir"
+
+# setup profile 2 and 1, leaving 1 as active profile
+for i in 3 2 1; do
+    setup_test_without_data profile$i
+    trap imap_cleanup 0
+
+    # TODO Stop using mailbox-root in all but live tests once we have a local
+    # panda hacked never to look at real mailboxes.
+    cat >> "$MH" <<EOF
+Managed-Folders: imap
+imap-manager: $MH_OBJ_DIR/target/debug/imap-folder-manager
+imap-socket: $MH_TEST_DIR/$profile/imap.d/socket
+imap-db: $MH_TEST_DIR/$profile/imap.d
+imap-host: 127.0.0.1
+imap-port: $fakeinetd_port
+imap-tls: don't protect my data i know what i'm doing
+imap-auth: PREAUTH
+imap-mailbox-root: test/
+imap-mailbox-exclude: excluded
+imap-mailbox-include: excluded/re-included
+EOF
+    if [ -n "$MH_TEST_IMAP_LOG_LEVEL" ]; then
+        echo "imap-log-level: $MH_TEST_IMAP_LOG_LEVEL" >> "$MH"
+        echo "imap-log-$MH_TEST_IMAP_LOG_LEVEL-to: $MH_TEST_DIR/$profile/imap.d/log.$MH_TEST_IMAP_LOG_LEVEL" >> "$MH"
+    fi
+done
+
+# 4 messages in profile 1
+echo 'imap-max-fetch-messages-in-flight: 4' >> "$MH"
+start
+
+################################################################################
+start_test 'inc'
+
+echo 'Incorporating new mail into imap/fold...' > "$expected.inc.full"
+echo >> "$expected.inc.full"
+echo "   $arith_val   10/24 me                 <<body >>" >> "$expected.inc.full"
+
+while [ $arith_val -lt $message_count ]; do
+    arith_eval $arith_val + 1
+    printf '  %2d U 10/24 me                 <<body >>\n' $arith_val >> "$expected.inc.full"
+    cat >> $MH_TEST_DIR/panda/test/fold <<EOF
+From nmh@example.com Sat Oct 24 19:20:38 2020 -0500
+Date:Sat, 24 Oct 2020 19:20:38 -0500
+from:me
+to:you
+Status: O
+X-Status: 
+X-Keywords:          
+X-UID: $arith_val
+
+body
+
+EOF
+    arith_eval $arith_val + 1
+    printf '  %2d U 10/24 me                 <<body >>\n' $arith_val >> "$expected.inc.full"
+    cat >> $MH_TEST_DIR/panda/test/fold <<EOF
+From nmh@example.com Sat Oct 24 19:20:38 2020 -0500
+Date:Sat, 24 Oct 2020 19:20:38 -0500
+from:me
+to:you
+Status: O
+X-Status: 
+X-Keywords:          
+X-UID: $arith_val
+
+body
+
+EOF
+done
+
+cp "$expected.inc.full" "$expected.inc"
+
+inc +imap -form scan.unseen -width 80 > "$actual"
+check "$actual" "$expected.inc"
+
+################################################################################
+start_test 'inc after inc finds no new mail'
+
+cat /dev/null > "$expected.inc"
+inc +imap -form scan.unseen -width 80 > "$actual"
+check "$actual" "$expected.inc"
+
+################################################################################
+start_test 'inc after inc finds new mail'
+
+arith_eval $arith_val + 1
+    cat >> $MH_TEST_DIR/panda/test/fold <<EOF
+From nmh@example.com Sat Oct 24 19:20:38 2020 -0500
+Date:Sat, 24 Oct 2020 19:20:38 -0500
+from:me
+to:you
+Status: O
+X-Status: 
+X-Keywords:          
+X-UID: $arith_val
+
+body
+
+EOF
+echo 'Incorporating new mail into imap/fold...' > "$expected.inc"
+echo >> "$expected.inc"
+printf '  %2d U 10/24 me                 <<body >>\n' $arith_val >> "$expected.inc"
+
+# Kill the folder manager so inc will cause a re-SELECT.
+"$MH_OBJ_DIR/target/debug/mhimap" imap shutdown
+
+inc +imap -form scan.unseen -width 80 > "$actual"
+check "$actual" "$expected.inc"
+
+################################################################################
+start_test 'child of excluded mailbox excluded'
+
+mkdir -p "$MH_TEST_DIR/panda/test/excluded"
+cat > "$MH_TEST_DIR/panda/test/excluded/fold" <<EOF
+From nmh@example.com Sat Oct 24 19:20:38 2020 -0500
+Date:Sat, 24 Oct 2020 19:20:38 -0500
+from:me
+to:you
+X-IMAPbase: 1603585238 1 testseq
+Status: RO
+X-Status: 
+X-Keywords:          
+X-UID: 1
+
+body
+EOF
+
+cat /dev/null > "$expected"
+inc +imap > "$actual"
+check "$actual" "$expected"
+
+################################################################################
+start_test 'explicitly included child of excluded mailbox included'
+
+cp "$MH_TEST_DIR/panda/test/excluded/fold" "$MH_TEST_DIR/panda/test/excluded/re-included"
+
+cat > "$expected" <<EOF
+Incorporating new mail into imap/excluded/re-included...
+
+   1  10/24 me                 <<body >>
+EOF
+inc +imap > "$actual"
+check "$actual" "$expected"
+
+################################################################################
+start_test 'explicitly requested child of excluded mailbox included'
+
+cp "$MH_TEST_DIR/panda/test/excluded/fold" "$MH_TEST_DIR/panda/test/excluded/still-excluded"
+
+cat > "$expected" <<EOF
+Incorporating new mail into imap/excluded/still-excluded...
+
+   1  10/24 me                 <<body >>
+EOF
+inc +imap/excluded/still-excluded > "$actual"
+check "$actual" "$expected"
+
+################################################################################
+start_test 'fetch messages all fit in first FETCH UID'
+
+set_mh_profile profile2
+
+# 12 messages so go for 11 per fetch.
+# This hits the '1d. no more UIDs to FETCH but we are still expecting FETCH UID responses from that initial fetch' case in do_inc.
+echo 'imap-max-fetch-messages-in-flight: 11' >> "$MH"
+start
+
+(
+    sed 's/ U /  /; s| 1   10/24 me| 1  10/24 me|' "$expected.inc.full"
+    echo '  12  10/24 me                 <<body >>'
+) > "$expected.inc"
+
+inc +imap/fold > "$actual"
+check "$actual" "$expected.inc"
+
+################################################################################
+start_test '1d. just one more UID to FETCH'
+
+set_mh_profile profile3
+
+cat >> $MH_TEST_DIR/panda/test/fold <<EOF
+From nmh@example.com Sat Oct 24 19:20:38 2020 -0500
+Date:Sat, 24 Oct 2020 19:20:38 -0500
+from:me
+to:you
+Status: O
+X-Status: 
+X-Keywords:          
+X-UID: $arith_val
+
+body
+
+EOF
+
+# max:6 means the first FETCH UID requests 12 leaving just one more for the 2nd FETCH UID.
+echo 'imap-max-fetch-messages-in-flight: 6' >> "$MH"
+start
+
+(
+    sed 's/ U /  /; s| 1   10/24 me| 1  10/24 me|' "$expected.inc.full"
+    echo '  12  10/24 me                 <<body >>'
+    echo '  13  10/24 me                 <<body >>'
+) > "$expected.inc"
+rm -f "$expected.inc.full"
+
+inc +imap/fold > "$actual"
+check "$actual" "$expected.inc"
+
+################################################################################
+start_test 'one message in page 2'
+
+set_mh_profile profile1
+
+# Still using max-fetch-messages-in-flight:4
+# The first FETCH UID requests 1:8 (pages 1 and 2).
+# We provide a mailbox with messages 1-9.
+
+genmailbox() {
+    cat <<EOF
+From nmh@example.com Sat Oct 24 19:20:38 2020 -0500
+Date:Sat, 24 Oct 2020 19:20:38 -0500
+from:me
+to:you
+X-IMAPbase: 1626040083 $1
+Status: O
+X-Status: 
+X-Keywords:                       
+X-UID: 1
+
+body
+
+EOF
+    arith_val=2
+    while [ $arith_val -le $1 ]; do
+        cat <<EOF
+From nmh@example.com Sat Oct 24 19:20:38 2020 -0500
+Date:Sat, 24 Oct 2020 19:20:38 -0500
+from:me
+to:you
+Status: O
+X-Status: 
+X-Keywords:                  
+X-UID: $arith_val
+
+body
+
+EOF
+        arith_eval $arith_val + 1
+    done
+}
+
+genmailbox 13 > $MH_TEST_DIR/panda/test/omp2
+
+cat > "$expected.inc" <<EOF
+Incorporating new mail into imap/omp2...
+
+   1 U 10/24 me                 <<body >>
+   2 U 10/24 me                 <<body >>
+   3 U 10/24 me                 <<body >>
+   4 U 10/24 me                 <<body >>
+   5 U 10/24 me                 <<body >>
+   6 U 10/24 me                 <<body >>
+   7 U 10/24 me                 <<body >>
+   8 U 10/24 me                 <<body >>
+   9 U 10/24 me                 <<body >>
+  10 U 10/24 me                 <<body >>
+  11 U 10/24 me                 <<body >>
+  12 U 10/24 me                 <<body >>
+  13 U 10/24 me                 <<body >>
+EOF
+
+inc +imap/omp2 -form scan.unseen -width 80 > "$actual" || :
+check "$actual" "$expected.inc"
+
+################################################################################
+start_test 'UID gap with one remaining'
+
+set_mh_profile profile3
+
+# Still using max-fetch-messages-in-flight:6
+# The first FETCH UID requests 1:12.
+# We provide a mailbox with a UID gap though; only these are present:
+# 1, 3, 13
+
+cat > $MH_TEST_DIR/panda/test/uidgap1 <<EOF
+From nmh@example.com Sat Oct 24 19:20:38 2020 -0500
+Date:Sat, 24 Oct 2020 19:20:38 -0500
+from:me
+to:you
+X-IMAPbase: 1626040083 13
+Status: O
+X-Status: 
+X-Keywords:                       
+X-UID: 1
+
+body
+
+From nmh@example.com Sat Oct 24 19:20:38 2020 -0500
+Date:Sat, 24 Oct 2020 19:20:38 -0500
+from:me
+to:you
+Status: O
+X-Status: 
+X-Keywords:                  
+X-UID: 3
+
+body
+
+From nmh@example.com Sat Oct 24 19:20:38 2020 -0500
+Date:Sat, 24 Oct 2020 19:20:38 -0500
+from:me
+to:you
+Status: O
+X-Status: 
+X-Keywords:                  
+X-UID: 13
+
+body
+
+EOF
+
+cat > "$expected.inc" <<EOF
+Incorporating new mail into imap/uidgap1...
+
+   1 U 10/24 me                 <<body >>
+   2 U 10/24 me                 <<body >>
+   3 U 10/24 me                 <<body >>
+EOF
+
+inc +imap/uidgap1 -form scan.unseen -width 80 > "$actual" || :
+check "$actual" "$expected.inc"
+
+################################################################################
+start_test 'UID gap with two remaining'
+
+set_mh_profile profile3
+
+# Still using max-fetch-messages-in-flight:6
+# The first FETCH UID requests 1:12.
+# We provide a mailbox with many UID gaps though; only these are present:
+# 1, 3, 20, 30
+# oh hey!  what happens when there's HUGE gaps?!
+# we'll keep making dumb requests for sequences like 13:18 for no gain, as this test demonstrates
+# is there a better way to discover UIDs?
+# even if there is, these tests are worth having
+# nothing obvious.  we can move the FLAGS request to the FETCH BODY though
+
+cat > $MH_TEST_DIR/panda/test/uidgap2 <<EOF
+From nmh@example.com Sat Oct 24 19:20:38 2020 -0500
+Date:Sat, 24 Oct 2020 19:20:38 -0500
+from:me
+to:you
+X-IMAPbase: 1626040083 30
+Status: O
+X-Status: 
+X-Keywords:                       
+X-UID: 1
+
+body
+
+From nmh@example.com Sat Oct 24 19:20:38 2020 -0500
+Date:Sat, 24 Oct 2020 19:20:38 -0500
+from:me
+to:you
+Status: O
+X-Status: 
+X-Keywords:                  
+X-UID: 3
+
+body
+
+From nmh@example.com Sat Oct 24 19:20:38 2020 -0500
+Date:Sat, 24 Oct 2020 19:20:38 -0500
+from:me
+to:you
+Status: O
+X-Status: 
+X-Keywords:                  
+X-UID: 20
+
+body
+
+From nmh@example.com Sat Oct 24 19:20:38 2020 -0500
+Date:Sat, 24 Oct 2020 19:20:38 -0500
+from:me
+to:you
+Status: O
+X-Status: 
+X-Keywords:                  
+X-UID: 30
+
+body
+
+EOF
+
+cat > "$expected.inc" <<EOF
+Incorporating new mail into imap/uidgap2...
+
+   1 U 10/24 me                 <<body >>
+   2 U 10/24 me                 <<body >>
+   3 U 10/24 me                 <<body >>
+   4 U 10/24 me                 <<body >>
+EOF
+
+inc +imap/uidgap2 -form scan.unseen -width 80 > "$actual" || :
+check "$actual" "$expected.inc"
+
+finish_test
+exit ${failed:-0}
diff --git a/test/imap/test-inc-missing-msg b/test/imap/test-inc-missing-msg
new file mode 100755 (executable)
index 0000000..b695ea6
--- /dev/null
@@ -0,0 +1,130 @@
+#!/bin/sh
+
+# TODO FIX uh oh this and the other missing msg cases passed by accident after inc uses ESEARCH
+#  and now switching inc to leave mailbox open has broken it completely!
+
+if test -z "$MH_OBJ_DIR"; then
+    srcdir=`dirname "$0"`/../..
+    MH_OBJ_DIR=`cd "$srcdir" && pwd`; export MH_OBJ_DIR
+fi
+
+. "$MH_OBJ_DIR/test/common.sh"
+
+# common.sh sets this to something crazy; did i do that?!
+testname=${0#$srcdir/}
+
+setup_test_without_data Mail
+
+imap_cleanup() {
+    #### Save exit status to use as status for this program.
+    status=$?
+    set +e
+
+    rm -f "$imapd"
+
+    [ -n "$imapd_pid" ] && kill $imapd_pid
+    "$MH_OBJ_DIR/target/debug/mhimap" imap shutdown
+
+    # TODO have cleanup allow additional cleanup registration?
+    #  Or just allow it to accept status as argument.
+    (exit $status)
+    cleanup
+}
+trap imap_cleanup 0
+
+covdir="$MH_OBJ_DIR/covdata/${MH_COVERAGE_PROFILE:-default}/$testname"
+install -d "$covdir"
+export LLVM_PROFILE_FILE="$covdir/$profile-%m.profraw"
+
+imapd="$MH_TEST_DIR/$$.imapd"
+start_fakeinetd "$imapd"
+imapd_pid=$fakeinetd_pid
+
+cat >> "$MH" <<EOF
+Managed-Folders: imap
+imap-manager: $MH_OBJ_DIR/target/debug/imap-folder-manager
+imap-socket: $MH_TEST_DIR/Mail/imap.d/socket
+imap-db: $MH_TEST_DIR/Mail/imap.d
+imap-host: 127.0.0.1
+imap-port: $fakeinetd_port
+imap-tls: don't protect my data i know what i'm doing
+imap-auth: PREAUTH
+imap-mailbox-root: test/
+EOF
+if [ -n "$MH_TEST_IMAP_LOG_LEVEL" ]; then
+    echo "imap-log-level: $MH_TEST_IMAP_LOG_LEVEL" >> "$MH"
+    echo "imap-log-$MH_TEST_IMAP_LOG_LEVEL-to: $MH_TEST_DIR/$profile/imap.d/log.$MH_TEST_IMAP_LOG_LEVEL" >> "$MH"
+fi
+
+start_test 'message disappears between FETCH UID and FETCH BODY'
+# test the case where a message goes missing between the time inc first
+# notices it and when it goes to download it, e.g. inc starts, sees a message,
+# and then another client deletes it before inc can fetch it.
+
+# I'm kinda surprised this just worked on the first try...  hmm or not.
+# I see.  The ServerData::Fetch loop doesn't know if it's procesinsg a
+# response from our request for a range of UIDS or from our request of a
+# message body.
+# TODO Teach it the difference so we can log when a message goes missing.
+
+# Documented behaviors:
+# https://datatracker.ietf.org/doc/html/rfc3501#section-6.4.8 says:
+#       A non-existent unique identifier is ignored without any error
+#       message generated.  Thus, it is possible for a UID FETCH command
+#       to return an OK without any data or a UID COPY or UID STORE to
+#       return an OK without performing any operations.
+# This seems to match the Panda behavior I see.
+
+# https://datatracker.ietf.org/doc/html/rfc2180#section-4.1.1 has an example:
+#                => B003 FETCH 4:7 RFC822
+#                <= B003 NO Messages 4:7 are no longer available.
+#  and in 4.1.2:
+#                => B001 FETCH 3:5 ENVELOPE
+#                <= * 3 FETCH ENVELOPE . . . (ENVELOPE info returned)
+#                <= B001 NO Some of the requested messages no longer exist
+# and in 4.1.3:
+#    If all of the messages in the subsequent FETCH command have been
+#    expunged, the server SHOULD return only a tagged NO.
+# and mayb it can return NIL or empty string for BODY[] ?
+
+# Observed behaviors
+# Panda:  no data but just an OK!
+#   (tls-encrypted) => fetch1 UID FETCH 101 BODY.PEEK[]
+#   (tls-decrypted) <= fetch1 OK UID FETCH completed
+# Dovecot:  same
+#   => fetch1 UID FETCH 101 BODY.PEEK[]
+#   <= fetch1 OK Fetch completed (0.001 + 0.000 secs).
+# Cyrus:  same
+#   => fetch1 UID FETCH 101 BODY.PEEK[]
+#   <= fetch1 OK Completed (0.000 sec)
+# Gandi:  same
+#   (tls-encrypted) => fetch1 UID FETCH 101 BODY.PEEK[]
+#   (tls-decrypted) <= fetch1 OK Fetch completed (0.001 + 0.000 secs).
+# Gmail
+#   (tls-encrypted) => fetch1 UID FETCH 101 BODY.PEEK[]
+#   (tls-decrypted) <= fetch1 OK Success
+
+cat > "$imapd" <<EOSCRIPT
+#!/bin/sh
+cat <<EOF
+* PREAUTH [CAPABILITY IMAP4REV1 UIDPLUS] authenticated\r
+* LIST (\NoInferiors \UnMarked) "/" test/fold\r
+1 OK LIST completed\r
+* 1 EXISTS\r
+* OK [UIDVALIDITY 1603585238] UID validity status\r
+* OK [UIDNEXT 12] Predicted next UID\r
+1 OK [READ-WRITE] SELECT completed\r
+* 1 FETCH (UID 1 FLAGS (\Seen))\r
+1 OK UID FETCH completed\r
+fetch11 OK UID FETCH completed\r
+1 OK SEARCH ALL completed
+1 OK CLOSE completed\r
+EOF
+while read line; do :; done
+EOSCRIPT
+chmod 755 "$imapd"
+
+check_exit '-eq 0' inc +imap
+
+finish_test
+exit ${failed:-0}
diff --git a/test/imap/test-inc-missing-msg-case-2 b/test/imap/test-inc-missing-msg-case-2
new file mode 100755 (executable)
index 0000000..859e619
--- /dev/null
@@ -0,0 +1,83 @@
+#!/bin/sh
+
+# TODO FIX uh oh this and the other missing msg cases passed by accident after inc uses ESEARCH
+#  and now switching inc to leave mailbox open has broken it completely!
+
+if test -z "$MH_OBJ_DIR"; then
+    srcdir=`dirname "$0"`/../..
+    MH_OBJ_DIR=`cd "$srcdir" && pwd`; export MH_OBJ_DIR
+fi
+
+. "$MH_OBJ_DIR/test/common.sh"
+
+# common.sh sets this to something crazy; did i do that?!
+testname=${0#$srcdir/}
+
+setup_test_without_data Mail
+
+imap_cleanup() {
+    #### Save exit status to use as status for this program.
+    status=$?
+    set +e
+
+    rm -f "$imapd"
+
+    [ -n "$imapd_pid" ] && kill $imapd_pid
+    "$MH_OBJ_DIR/target/debug/mhimap" imap shutdown
+
+    # TODO have cleanup allow additional cleanup registration?
+    #  Or just allow it to accept status as argument.
+    (exit $status)
+    cleanup
+}
+trap imap_cleanup 0
+
+covdir="$MH_OBJ_DIR/covdata/${MH_COVERAGE_PROFILE:-default}/$testname"
+install -d "$covdir"
+export LLVM_PROFILE_FILE="$covdir/$profile-%m.profraw"
+
+imapd="$MH_TEST_DIR/$$.imapd"
+start_fakeinetd "$imapd"
+imapd_pid=$fakeinetd_pid
+
+cat >> "$MH" <<EOF
+Managed-Folders: imap
+imap-manager: $MH_OBJ_DIR/target/debug/imap-folder-manager
+imap-socket: $MH_TEST_DIR/Mail/imap.d/socket
+imap-db: $MH_TEST_DIR/Mail/imap.d
+imap-host: 127.0.0.1
+imap-port: $fakeinetd_port
+imap-tls: don't protect my data i know what i'm doing
+imap-auth: PREAUTH
+imap-mailbox-root: test/
+EOF
+if [ -n "$MH_TEST_IMAP_LOG_LEVEL" ]; then
+    echo "imap-log-level: $MH_TEST_IMAP_LOG_LEVEL" >> "$MH"
+    echo "imap-log-$MH_TEST_IMAP_LOG_LEVEL-to: $MH_TEST_DIR/$profile/imap.d/log.$MH_TEST_IMAP_LOG_LEVEL" >> "$MH"
+fi
+
+# TODO handle this case next:
+# UIDNEXT 12 so FETCH 1:11 but nothing is returned; 11 was deleted but never seen.
+
+cat > "$imapd" <<EOSCRIPT
+#!/bin/sh
+cat <<EOF
+* PREAUTH [CAPABILITY IMAP4REV1 UIDPLUS] authenticated\r
+* LIST (\NoInferiors \UnMarked) "/" test/fold\r
+1 OK LIST completed\r
+* 1 EXISTS\r
+* OK [UIDVALIDITY 1603585238] UID validity status\r
+* OK [UIDNEXT 12] Predicted next UID\r
+1 OK [READ-WRITE] SELECT completed\r
+1 OK UID FETCH completed\r
+1 OK SEARCH ALL completed
+1 OK CLOSE completed\r
+EOF
+while read line; do :; done
+EOSCRIPT
+chmod 755 "$imapd"
+
+check_exit '-eq 0' inc +imap
+
+finish_test
+exit ${failed:-0}
diff --git a/test/imap/test-live b/test/imap/test-live
new file mode 100755 (executable)
index 0000000..1acab60
--- /dev/null
@@ -0,0 +1,775 @@
+#!/bin/sh
+
+# Cyrus
+# MH_IMAP_TEST_SERVER=127.0.0.1 MH_IMAP_TEST_PORT=143 MH_IMAP_TEST_USER=test MH_IMAP_TEST_PASSWORD=test MH_IMAP_TEST_EMAIL_ADDRESS=test MH_IMAP_TEST_TLS=no MH_IMAP_TEST_MAILBOX_ROOT=test/ make check TESTS=test/imap/test-live
+# Dovecot
+# MH_IMAP_TEST_SERVER=127.0.0.1 MH_IMAP_TEST_PORT=1043 MH_IMAP_TEST_USER=test MH_IMAP_TEST_PASSWORD=testpw MH_IMAP_TEST_EMAIL_ADDRESS=test MH_IMAP_TEST_TLS=no MH_IMAP_SERVER_EATS_TRAILING_EOL=' ' make check TESTS=test/imap/test-live
+# Panda
+# MH_IMAP_TEST_SERVER=panda make check TESTS=test/imap/test-live
+# Gandi (looks like they use Dovecot)
+# MH_IMAP_TEST_SERVER=mail.gandi.net MH_IMAP_TEST_PORT=imaps MH_IMAP_TEST_USER=... MH_IMAP_TEST_PASSWORD='...' MH_IMAP_TEST_EMAIL_ADDRESS=test@pretzelnet.org MH_IMAP_TEST_TLS=implicit MH_IMAP_TEST_MAILBOX_ROOT=test/ make check TESTS=test/imap/test-live
+# Gmail
+# MH_IMAP_TEST_SERVER=gmail MH_IMAP_TEST_PORT=imaps MH_IMAP_TEST_USER=... MH_IMAP_TEST_OAUTH_FILE="/path/to/oauth-gmail" MH_IMAP_TEST_EMAIL_ADDRESS=... MH_IMAP_TEST_MAILBOX_ROOT=test/ make check TESTS=test/imap/test-live
+
+testname=${0#$srcdir/}
+
+if test -z "$MH_OBJ_DIR"; then
+    srcdir=`dirname "$0"`/../..
+    MH_OBJ_DIR=`cd "$srcdir" && pwd`; export MH_OBJ_DIR
+fi
+
+. "$MH_OBJ_DIR/test/common.sh"
+
+if [ -z "$MH_IMAP_TEST_SERVER" ]; then
+    test_skip 'no IMAP test server selected'
+fi
+
+start() {
+    rm -f "$MH_TEST_DIR/$profile/imap.d/socket"
+    status=0
+    LLVM_PROFILE_FILE="$covdir/$profile-%m.profraw" "$MH_OBJ_DIR/target/debug/mhimap" imap start > "$actual" 2>&1 || status=$?
+}
+
+MH_template="$MH_TEST_DIR/template.mh_profile"
+components_template="$MH_TEST_DIR/template.components"
+scan_template="$MH_TEST_DIR/template.scan.nodate"
+
+rm -f "$MH_template" "$components_template" "$scan_template"
+rm -rf "$MH_TEST_DIR/dovecot" "$MH_TEST_DIR/panda"
+
+rm -rf "$MH_TEST_DIR"/*/imap.d
+
+imap_cleanup() {
+    #### Save exit status to use as status for this program.
+    status=$?
+    set +e
+
+    if [ -z "$MH_TEST_NOCLEANUP" ]; then
+        for i in $test_folders; do
+            rmf +$i -nointeractive
+        done
+    fi
+
+    [ -n "$imapd_pid" ] && kill $imapd_pid
+
+    for profile in $profiles; do
+        set_mh_profile $profile
+        "$MH_OBJ_DIR/target/debug/mhimap" imap shutdown
+    done
+
+    # TODO have cleanup allow additional cleanup registration?
+    #  Or just allow it to accept status as argument.
+    (exit $status)
+    cleanup
+}
+
+if [ "$MH_IMAP_TEST_SERVER" = 'panda' ]; then
+    # TODO It seems drafts are born unseen on Cyrus and GMail.
+    MH_IMAP_SERVER_DRAFT_BORN_UNSEEN=' '
+    # TODO Why is Cyrus eating the trailing CRLF?
+    #  I'm planning to use refile -file instead of comp anyway; will that help?
+    MH_IMAP_SERVER_EATS_TRAILING_EOL=' '
+    [ -z "$LOGNAME" ] && fail 'LOGNAME not set'
+    MH_IMAP_TEST_EMAIL_ADDRESS="$LOGNAME"
+    mkdir "$MH_TEST_DIR/panda"
+    # Thought creating this stopped Panda from looking at the developer's real
+    # inbox, but it doesn't stop it from mangling /var/mail!
+    # TODO Ship customized Panda for testing.
+    touch "$MH_TEST_DIR/panda/mbox"
+    start_fakeinetd env "HOME=$MH_TEST_DIR/panda" "$PANDA_IMAPD"
+    imapd_pid=$fakeinetd_pid
+    cat > "$MH_template" <<EOF
+imap-host: 127.0.0.1
+imap-port: $fakeinetd_port
+imap-auth: PREAUTH
+imap-tls: don't protect my data i know what i'm doing
+imap-mailbox-root: test/
+EOF
+elif [ "$MH_IMAP_TEST_SERVER" = dovecot ]; then
+    # TODO dovecot "-Fc$MH_TEST_DIR/dovecot/cf"
+    # currently starting manually before running tests:
+    #   rm -rf .o/std/dovecot && mkdir .o/std/dovecot && dovecot -Fcdocs/imap/testing/dovecot.conf &
+    cat > "$MH_TEST_DIR/dovecot/cf" <<EOF
+base_dir = $MH_TEST_DIR/dovecot
+state_dir = $MH_TEST_DIR/dovecot/db
+log_path = $MH_TEST_DIR/dovecot/log
+
+default_internal_user = $LOGNAME
+default_login_user = $LOGNAME
+default_internal_group = $LOGNAME
+
+mail_location = mbox:$MH_TEST_DIR/dovecot/db/mail/%d/%1n/%n:INDEX=$MH_TEST_DIR/dovecot/db/indexes/%d/%1n/%n
+
+protocols = imap
+listen = 127.0.0.1
+
+passdb {
+  driver = static
+  args = password=test
+}
+
+namespace inbox {
+  inbox = yes
+}
+
+service anvil {
+  chroot =
+}
+
+service imap-login {
+  chroot =
+  inet_listener imap {
+    port = 1043
+  }
+  inet_listener imaps {
+    port = 1044
+  }
+}
+EOF
+elif [ "$MH_IMAP_TEST_SERVER" = 'gmail' ]; then
+    # * LIST (\All \HasNoChildren) "/" "[Gmail]/All Mail"
+    cat > "$MH_template" <<EOF
+oauth-gmail-credential-file: $MH_IMAP_TEST_OAUTH_FILE
+imap-host: imap.gmail.com
+imap-port: imaps
+imap-user: $MH_IMAP_TEST_USER
+imap-tls: implicit
+imap-auth: SASL
+imap-saslmech: xoauth2
+imap-authservice: gmail
+EOF
+    if [ -n "$MH_IMAP_TEST_MAILBOX_ROOT" ]; then
+        echo "imap-mailbox-root: $MH_IMAP_TEST_MAILBOX_ROOT" >> "$MH_template"
+    fi
+elif [ -z "$MH_IMAP_TEST_PORT" ] \
+        || [ -z "$MH_IMAP_TEST_TLS" ] \
+        || [ -z "$MH_IMAP_TEST_USER" ] \
+        || [ -z "$MH_IMAP_TEST_PASSWORD" ] \
+        || [ -z "$MH_IMAP_TEST_EMAIL_ADDRESS" ]; then
+    fail 'MH_IMAP_TEST_PORT MH_IMAP_TEST_USER MH_IMAP_TEST_PASSWORD MH_IMAP_TEST_EMAIL_ADDRESS must all be set'
+else
+    case $MH_IMAP_TEST_TLS in
+        no)
+            MH_IMAP_TEST_TLS="don't protect my data i know what i'm doing"
+            ;;
+        unverified-implicit)
+            MH_IMAP_TEST_TLS="implicit but don't verify the cert i already did out of band"
+            ;;
+    esac
+    cat > "$MH_TEST_DIR/netrc" <<EOF
+machine $MH_IMAP_TEST_SERVER login $MH_IMAP_TEST_USER password $MH_IMAP_TEST_PASSWORD
+EOF
+    cat >> "$MH_template" <<EOF
+credentials: file-nopermcheck:$MH_TEST_DIR/netrc
+imap-host: $MH_IMAP_TEST_SERVER
+imap-port: $MH_IMAP_TEST_PORT
+imap-user: $MH_IMAP_TEST_USER
+imap-auth: SASL
+imap-tls: $MH_IMAP_TEST_TLS
+EOF
+    if [ -n "$MH_IMAP_TEST_MAILBOX_ROOT" ]; then
+        echo "imap-mailbox-root: $MH_IMAP_TEST_MAILBOX_ROOT" >> "$MH_template"
+    fi
+fi
+
+# it seems with cyrus everything has to go under INBOX?  i probably
+# messed something up but whatever, let's roll with it.
+# * LIST (\HasChildren) "/" INBOX
+#* LIST (\HasNoChildren) "/" INBOX/foo
+
+imap_folder=imap/imap$(date '+%j%H%M%S')
+
+cat >> "$MH_template" <<EOF
+Unseen-Sequence: unseen
+Local-Mailbox: $MH_IMAP_TEST_EMAIL_ADDRESS
+Draft-Folder: $imap_folder/drafts
+Managed-Folders: imap
+rmm: -unlink
+imap-manager: $MH_OBJ_DIR/target/debug/imap-folder-manager
+EOF
+
+cat > "$scan_template" <<EOF
+%4(msg)%<(cur)+%| %>%<(unseen)U%| %>%<{replied}-%| %>\
+%<(mymbox{from})%<{to}To:%(unquote(decode(friendly{to})))%>%>\
+%<(zero)%(unquote(decode(friendly{from})))%>  \
+%(decode{subject})%<{body}<<%{body}>>%>
+EOF
+
+# TODO default components use '--------' to close header...
+cat > "$components_template" <<EOF
+%<{from}%|%(void(localmbox))%>%(void(width))%(putaddr From: )
+%<{to}%(void(width))%(putaddr To: )%|To:%>
+%<{cc}%(void(width))%(putaddr cc: )%|cc:%>
+Fcc: %<{fcc}%(putstr)%|+outbox%>
+Subject:%<{subject} %(putstr)%>
+
+body
+EOF
+
+expected="$MH_TEST_DIR/$$.expected"
+actual="$MH_TEST_DIR/$$.actual"
+
+################################################################################
+profile=profile1
+rm -rf "$MH_TEST_DIR/$profile"
+setup_test_without_data $profile
+trap imap_cleanup 0
+
+cat "$MH_template" >> "$MH"
+cp "$components_template" "$MH_TEST_DIR/$profile/components"
+cp "$scan_template" "$MH_TEST_DIR/$profile/scan.nodate"
+if [ -n "$MH_TEST_IMAP_LOG_LEVEL" ]; then
+    echo "imap-log-level: $MH_TEST_IMAP_LOG_LEVEL" >> "$MH"
+    echo "imap-log-$MH_TEST_IMAP_LOG_LEVEL-to: $MH_TEST_DIR/$profile/imap.d/log.$MH_TEST_IMAP_LOG_LEVEL" >> "$MH"
+fi
+
+# Set this for the EHLO command
+echo "clientname: nosuchhost.example.com" >> "$MHMTSCONF"
+
+cat >> "$MH" <<EOF
+imap-socket: $MH_TEST_DIR/$profile/imap.d/socket
+imap-db: $MH_TEST_DIR/$profile/imap.d
+EOF
+
+covdir="$MH_OBJ_DIR/covdata/${MH_COVERAGE_PROFILE:-default}/$testname"
+install -d "$covdir"
+
+start
+################################################################################
+
+################################################################################
+start_test 'folder -create'
+
+cat > "$expected" <<EOF
+$imap_folder/drafts+ has no messages.
+$imap_folder/sent+ has no messages.
+EOF
+test_folders="$imap_folder/drafts $imap_folder/sent"
+(run_prog folder +$imap_folder/drafts -create \
+     && run_prog folder +$imap_folder/sent -create) > "$actual"
+check "$actual" "$expected"
+
+check_exit '-eq 0' "$MH_OBJ_DIR/target/debug/mhimap" imap check $imap_folder/drafts
+check_exit '-eq 0' "$MH_OBJ_DIR/target/debug/mhimap" imap check $imap_folder/sent
+
+################################################################################
+start_test 'inc when nothing to fetch'
+
+cat /dev/null > "$expected"
+check_exit '-eq 0' inc +imap -width 80
+
+################################################################################
+start_test 'comp creates draft in empty draft folder'
+
+check_exit '-eq 0' comp -noedit -to "$MH_IMAP_TEST_EMAIL_ADDRESS" -fcc $imap_folder/sent -subject 'testing imap' -nowhatnowproc
+
+# comp creates a message, stores a UID/msgnum pair, runs the user's editor,
+# creates a new message with the user's edits, and replaces the UID/msgnum pair.
+# So verify the integrity of the database after that.
+# TODO any other operations worth having this integrity check on?
+check_exit '-eq 0' "$MH_OBJ_DIR/target/debug/mhimap" imap check $imap_folder/drafts
+
+start_test 'scan drafts after comp'
+cat > "$expected" <<EOF
+   1   To:$MH_IMAP_TEST_EMAIL_ADDRESS  testing imap<<body >>
+EOF
+run_prog scan -form scan.nodate +$imap_folder/drafts -width 80 > "$actual"
+check "$actual" "$expected"
+
+start_test 'show draft after comp'
+cat > "$expected" <<EOF
+(Message $imap_folder/drafts:1)
+
+To:      $MH_IMAP_TEST_EMAIL_ADDRESS
+cc:      
+From:    $MH_IMAP_TEST_EMAIL_ADDRESS
+Subject: testing imap
+
+Fcc:     $imap_folder/sent
+
+body
+EOF
+run_prog show +$imap_folder/drafts 1 > "$actual"
+check "$actual" "$expected"
+
+start_test 'check .mh_sequences after show after comp'
+cat > "$expected" <<EOF
+cur: 1
+EOF
+check "$MH_TEST_DIR/$profile/$imap_folder/drafts/.mh_sequences" "$expected" 'keep first'
+
+################################################################################
+start_test 'comp creates draft in non-empty draft folder'
+
+check_exit '-eq 0' comp -noedit -to "$MH_IMAP_TEST_EMAIL_ADDRESS" -fcc $imap_folder/sent -subject 'testing imap 2' -nowhatnowproc
+
+check_exit '-eq 0' "$MH_OBJ_DIR/target/debug/mhimap" imap check $imap_folder/drafts
+
+start_test 'scan non-empty drafts after comp'
+cat > "$expected" <<EOF
+   1+  To:$MH_IMAP_TEST_EMAIL_ADDRESS  testing imap<<body >>
+   2   To:$MH_IMAP_TEST_EMAIL_ADDRESS  testing imap 2<<body >>
+EOF
+run_prog scan -form scan.nodate +$imap_folder/drafts -width 80 > "$actual"
+check "$actual" "$expected"
+
+start_test 'check .mh_sequences after non-empty comp'
+cat > "$expected" <<EOF
+nmh-picked: 1-2
+cur:  1
+EOF
+check "$MH_TEST_DIR/$profile/$imap_folder/drafts/.mh_sequences" "$expected" 'keep first'
+
+################################################################################
+start_test 'send refiles draft to fcc folder'
+
+arith_eval 64000 + $$ % 1000
+localport=$arith_val
+"$MH_OBJ_DIR/test/fakesmtp" /dev/null $localport > /dev/null
+check_exit '-eq 0' send -server 127.0.0.1 -port $localport -draftfolder $imap_folder/drafts
+rm -f "$actual"
+
+check_exit '-eq 0' "$MH_OBJ_DIR/target/debug/mhimap" imap check $imap_folder/drafts
+check_exit '-eq 0' "$MH_OBJ_DIR/target/debug/mhimap" imap check $imap_folder/sent
+
+# TODO what?!  this test started out as this and evolved into something else?
+start_test 'scan drafts finds no messages after send'
+
+# Check draft moved to FCC folder
+cat > "$expected" <<EOF
+   2   To:$MH_IMAP_TEST_EMAIL_ADDRESS  testing imap 2<<body >>
+EOF
+run_prog scan -form scan.nodate +$imap_folder/drafts -width 80 > "$actual"
+check "$actual" "$expected"
+
+start_test 'scan sent finds the sent messages after send'
+
+cat > "$expected" <<EOF
+   1   To:$MH_IMAP_TEST_EMAIL_ADDRESS  testing imap<<body >>
+EOF
+run_prog scan -form scan.nodate +$imap_folder/sent -width 80 > "$actual"
+check "$actual" "$expected"
+
+################################################################################
+profile=profile2
+rm -rf "$MH_TEST_DIR/$profile"
+setup_test_without_data $profile
+trap imap_cleanup 0
+
+cat "$MH_template" >> "$MH"
+cp "$components_template" "$MH_TEST_DIR/$profile/components"
+cp "$scan_template" "$MH_TEST_DIR/$profile/scan.nodate"
+if [ -n "$MH_TEST_IMAP_LOG_LEVEL" ]; then
+    echo "imap-log-level: $MH_TEST_IMAP_LOG_LEVEL" >> "$MH"
+    echo "imap-log-$MH_TEST_IMAP_LOG_LEVEL-to: $MH_TEST_DIR/$profile/imap.d/log.$MH_TEST_IMAP_LOG_LEVEL" >> "$MH"
+fi
+
+cat >> "$MH" <<EOF
+imap-socket: $MH_TEST_DIR/$profile/imap.d/socket
+imap-db: $MH_TEST_DIR/$profile/imap.d
+EOF
+
+start
+################################################################################
+
+################################################################################
+start_test 'inc on empty managed folder fetches all mail in that folder'
+
+cat > "$expected" <<EOF
+Incorporating new mail into $imap_folder/drafts...
+
+   1 $MH_IMAP_SERVER_DRAFT_BORN_UNSEEN To:$MH_IMAP_TEST_EMAIL_ADDRESS  testing imap 2<<body$MH_IMAP_SERVER_EATS_TRAILING_EOL>>
+Incorporating new mail into $imap_folder/sent...
+
+   1   To:$MH_IMAP_TEST_EMAIL_ADDRESS  testing imap<<body$MH_IMAP_SERVER_EATS_TRAILING_EOL>>
+EOF
+
+inc +imap -form scan.nodate -width 80 > "$actual"
+check "$actual" "$expected"
+
+check_exit '-eq 0' "$MH_OBJ_DIR/target/debug/mhimap" imap check $imap_folder/drafts
+check_exit '-eq 0' "$MH_OBJ_DIR/target/debug/mhimap" imap check $imap_folder/sent
+
+################################################################################
+start_test 'show the sent message after inc'
+
+cat > "$expected" <<EOF
+(Message $imap_folder/sent:1)
+
+Date:
+To:      $MH_IMAP_TEST_EMAIL_ADDRESS 
+From:    $MH_IMAP_TEST_EMAIL_ADDRESS 
+Subject: testing imap 
+
+MIME-Version: 1.0\r
+
+\r
+EOF
+if [ -n "$MH_IMAP_SERVER_EATS_TRAILING_EOL" ]; then
+    echo 'body\r' >> "$expected"
+else
+    echo -n body >> "$expected"
+fi
+
+run_prog show +$imap_folder/sent 1 > "$actual"
+sed -e 's/^Date:.*/Date:/' "$actual" > "$actual".nodate
+rm -f "$actual"
+check "$actual.nodate" "$expected"
+
+################################################################################
+start_test 'mark the sent message unseen'
+
+check_exit '-eq 0' mark -add -seq unseen +$imap_folder/sent 1
+
+start_test 'scan after mark unseen'
+
+cat > "$expected" <<EOF
+   1+U To:$MH_IMAP_TEST_EMAIL_ADDRESS  testing imap<<body$MH_IMAP_SERVER_EATS_TRAILING_EOL>>
+EOF
+run_prog scan -form scan.nodate -width 80 > "$actual"
+check "$actual" "$expected"
+
+################################################################################
+start_test 'show the now-unseen message'
+
+check_exit '-eq 0' show all
+
+start_test 'scan after show'
+
+cat > "$expected" <<EOF
+   1+  To:$MH_IMAP_TEST_EMAIL_ADDRESS  testing imap<<body$MH_IMAP_SERVER_EATS_TRAILING_EOL>>
+EOF
+run_prog scan -form scan.nodate -width 80 > "$actual"
+check "$actual" "$expected"
+
+################################################################################
+start_test 'refile -file'
+
+refiled_file="$MH_TEST_DIR/$$.refiled-file"
+cat > "$refiled_file" <<EOF
+Date:Sat, 24 Oct 2020 19:20:38 -0500
+from:me
+to:you
+
+refile-test
+EOF
+check_exit '-eq 0' refile -file "$refiled_file" +$imap_folder/drafts
+if [ -e "$refiled_file" ]; then
+    rm -f "$refiled_file" || :
+    fail "$refiled_file missing after refile"
+fi
+
+start_test 'scan dst folder after refile -file finds refiled message'
+
+cat > "$expected" <<EOF
+   1   To:$MH_IMAP_TEST_EMAIL_ADDRESS  testing imap 2<<body$MH_IMAP_SERVER_EATS_TRAILING_EOL>>
+   2   me  <<refile-test >>
+EOF
+scan -form scan.nodate -width 80 +$imap_folder/drafts > "$actual"
+check "$actual" "$expected"
+
+check_exit '-eq 0' "$MH_OBJ_DIR/target/debug/mhimap" imap check $imap_folder/drafts
+
+################################################################################
+start_test 'refile'
+
+check_exit '-eq 0' refile -src +$imap_folder/drafts 2 +$imap_folder/sent
+
+# TODO what?!  this test started out as this and evolved into something else?
+start_test 'scan src folder after refile finds no messages'
+
+cat > "$expected" <<EOF
+   1   To:$MH_IMAP_TEST_EMAIL_ADDRESS  testing imap 2<<body$MH_IMAP_SERVER_EATS_TRAILING_EOL>>
+EOF
+scan -form scan.nodate -width 80 +$imap_folder/drafts > "$actual"
+check "$actual" "$expected"
+
+start_test 'scan dst folder after refile finds refiled message'
+
+cat > "$expected" <<EOF
+   1+  To:$MH_IMAP_TEST_EMAIL_ADDRESS  testing imap<<body$MH_IMAP_SERVER_EATS_TRAILING_EOL>>
+   2   me  <<refile-test >>
+EOF
+scan -form scan.nodate -width 80 +$imap_folder/sent > "$actual"
+check "$actual" "$expected"
+
+check_exit '-eq 0' "$MH_OBJ_DIR/target/debug/mhimap" imap check $imap_folder/drafts
+check_exit '-eq 0' "$MH_OBJ_DIR/target/debug/mhimap" imap check $imap_folder/sent
+
+################################################################################
+start_test 'mark unseen after refile'
+
+# This tests the UID update in the local database.
+
+check_exit '-eq 0' mark -add -seq unseen 2
+cat > "$expected" <<EOF
+   1+  To:$MH_IMAP_TEST_EMAIL_ADDRESS  testing imap<<body$MH_IMAP_SERVER_EATS_TRAILING_EOL>>
+   2 U me  <<refile-test >>
+EOF
+scan -form scan.nodate -width 80 +$imap_folder/sent > "$actual"
+check "$actual" "$expected"
+
+################################################################################
+start_test 'show all before rmm'
+
+cat > "$expected" <<EOF
+>>> $imap_folder/sent:1
+
+
+Date:
+To:      $MH_IMAP_TEST_EMAIL_ADDRESS 
+From:    $MH_IMAP_TEST_EMAIL_ADDRESS 
+Subject: testing imap 
+
+MIME-Version: 1.0\r
+
+\r
+EOF
+if [ -n "$MH_IMAP_SERVER_EATS_TRAILING_EOL" ]; then
+    echo 'body\r' >> "$expected"
+else
+    echo -n body >> "$expected"
+fi
+cat >> "$expected" <<EOF
+
+
+
+>>> $imap_folder/sent:2
+
+
+Date:
+To:      you
+From:    me
+
+
+EOF
+# TODO Why is this one unaffected?!  Really need to get to the bottom of this...
+if [ -n "$MH_IMAP_SERVER_EATS_TRAILING_EOL" ]; then
+    echo refile-test >> "$expected"
+else
+    echo refile-test >> "$expected"
+fi
+
+run_prog show +$imap_folder/sent all > "$actual"
+sed -e 's/^Date:.*/Date:/' "$actual" > "$actual.nodate"
+rm -f "$actual"
+check "$actual.nodate" "$expected"
+
+start_test 'check .mh_sequences after show before rmm'
+cat > "$expected" <<EOF
+cur: 2
+EOF
+check "$MH_TEST_DIR/$profile/$imap_folder/sent/.mh_sequences" "$expected" 'keep first'
+
+################################################################################
+start_test 'show cur shows 2 after show all'
+
+cat > "$expected" <<EOF
+(Message $imap_folder/sent:2)
+
+Date:    Sat, 24 Oct 2020 19:20:38 -0500
+To:      you
+From:    me
+
+
+EOF
+# TODO Why is this one unaffected?!  Really need to get to the bottom of this...
+if [ -n "$MH_IMAP_SERVER_EATS_TRAILING_EOL" ]; then
+    echo refile-test >> "$expected"
+else
+    echo refile-test >> "$expected"
+fi
+
+run_prog show +$imap_folder/sent > "$actual"
+check "$actual" "$expected"
+
+start_test 'check .mh_sequences after show cur shows 2 after show all'
+cat > "$expected" <<EOF
+cur: 2
+EOF
+check "$MH_TEST_DIR/$profile/$imap_folder/sent/.mh_sequences" "$expected" 'keep first'
+
+################################################################################
+start_test 'show 1 before rmm'
+
+cat > "$expected" <<EOF
+(Message $imap_folder/sent:1)
+
+Date:
+To:      $MH_IMAP_TEST_EMAIL_ADDRESS 
+From:    $MH_IMAP_TEST_EMAIL_ADDRESS 
+Subject: testing imap 
+
+MIME-Version: 1.0\r
+
+\r
+EOF
+if [ -n "$MH_IMAP_SERVER_EATS_TRAILING_EOL" ]; then
+    echo 'body\r' >> "$expected"
+else
+    echo -n body >> "$expected"
+fi
+
+run_prog show +$imap_folder/sent 1 > "$actual"
+sed -e 's/^Date:.*/Date:/' "$actual" > "$actual".nodate
+rm -f "$actual"
+check "$actual.nodate" "$expected"
+
+start_test 'check .mh_sequences after show 1 before rmm'
+cat > "$expected" <<EOF
+cur: 1
+EOF
+check "$MH_TEST_DIR/$profile/$imap_folder/sent/.mh_sequences" "$expected" 'keep first'
+
+################################################################################
+start_test 'show cur shows 1 after show 1'
+
+cat > "$expected" <<EOF
+(Message $imap_folder/sent:1)
+
+Date:
+To:      $MH_IMAP_TEST_EMAIL_ADDRESS 
+From:    $MH_IMAP_TEST_EMAIL_ADDRESS 
+Subject: testing imap 
+
+MIME-Version: 1.0\r
+
+\r
+EOF
+if [ -n "$MH_IMAP_SERVER_EATS_TRAILING_EOL" ]; then
+    echo 'body\r' >> "$expected"
+else
+    echo -n body >> "$expected"
+fi
+
+run_prog show +$imap_folder/sent > "$actual"
+sed -e 's/^Date:.*/Date:/' "$actual" > "$actual".nodate
+rm -f "$actual"
+check "$actual.nodate" "$expected"
+
+start_test 'check .mh_sequences after show cur shows 1 after show 1'
+cat > "$expected" <<EOF
+cur: 1
+EOF
+check "$MH_TEST_DIR/$profile/$imap_folder/sent/.mh_sequences" "$expected" 'keep first'
+
+################################################################################
+start_test 'rmm'
+
+check_exit '-eq 0' rmm -unlink 2
+
+start_test 'scan after rmm'
+
+cat > "$expected" <<EOF
+   1+  To:$MH_IMAP_TEST_EMAIL_ADDRESS  testing imap<<body$MH_IMAP_SERVER_EATS_TRAILING_EOL>>
+EOF
+scan -form scan.nodate -width 80 +$imap_folder/sent > "$actual"
+check "$actual" "$expected"
+
+check_exit '-eq 0' "$MH_OBJ_DIR/target/debug/mhimap" imap check $imap_folder/sent
+
+################################################################################
+start_test 'inc finds messages to delete 1. create messages in profile2'
+
+for i in 2 3 4 5 6; do
+    refiled_file="$MH_TEST_DIR/$$.refiled-file"
+    cat > "$refiled_file" <<EOF
+Date:Sat, 24 Oct 2020 19:20:38 -0500
+from:me
+to:you
+subject: refile $i
+
+refile-test $i
+EOF
+    check_exit '-eq 0' refile -file "$refiled_file" +$imap_folder/drafts
+    if [ -e "$refiled_file" ]; then
+        rm -f "$refiled_file" || :
+        fail "$refiled_file missing after refile"
+    fi
+done
+
+cat > "$expected" <<EOF
+   1   To:$MH_IMAP_TEST_EMAIL_ADDRESS  testing imap 2<<body$MH_IMAP_SERVER_EATS_TRAILING_EOL>>
+   2   me  refile 2<<refile-test 2 >>
+   3   me  refile 3<<refile-test 3 >>
+   4   me  refile 4<<refile-test 4 >>
+   5   me  refile 5<<refile-test 5 >>
+   6   me  refile 6<<refile-test 6 >>
+EOF
+scan -form scan.nodate -width 80 +$imap_folder/drafts > "$actual"
+check "$actual" "$expected"
+
+for i in $test_folders; do
+    check_exit '-eq 0' "$MH_OBJ_DIR/target/debug/mhimap" imap check $i
+done
+
+################################################################################
+start_test 'inc finds messages to delete 2. inc new messages into profile1'
+
+set_mh_profile profile1
+
+# Message numbers are one greater in profile1 than in profile2.
+cat > "$expected" <<EOF
+Incorporating new mail into $imap_folder/drafts...
+
+   3   me  refile 2<<refile-test 2$MH_IMAP_SERVER_EATS_TRAILING_EOL>>
+   4   me  refile 3<<refile-test 3$MH_IMAP_SERVER_EATS_TRAILING_EOL>>
+   5   me  refile 4<<refile-test 4$MH_IMAP_SERVER_EATS_TRAILING_EOL>>
+   6   me  refile 5<<refile-test 5$MH_IMAP_SERVER_EATS_TRAILING_EOL>>
+   7   me  refile 6<<refile-test 6$MH_IMAP_SERVER_EATS_TRAILING_EOL>>
+EOF
+inc +imap -form scan.nodate -width 80 > "$actual" || :
+check "$actual" "$expected"
+
+################################################################################
+start_test 'inc finds messages to delete 3. rmm messages out of profile2'
+
+set_mh_profile profile2
+
+check_exit '-eq 0' rmm -unlink 2
+check_exit '-eq 0' rmm -unlink 4
+check_exit '-eq 0' rmm -unlink 5
+
+cat > "$expected" <<EOF
+   1   To:$MH_IMAP_TEST_EMAIL_ADDRESS  testing imap 2<<body$MH_IMAP_SERVER_EATS_TRAILING_EOL>>
+   3   me  refile 3<<refile-test 3 >>
+   6   me  refile 6<<refile-test 6 >>
+EOF
+scan -form scan.nodate -width 80 +$imap_folder/drafts > "$actual"
+check "$actual" "$expected"
+
+################################################################################
+start_test 'inc finds messages to delete 4. inc deletes from profile1'
+
+set_mh_profile profile1
+
+inc +imap -form scan.nodate -width 80 > "$actual" || :
+check /dev/null "$actual" 'keep first'
+
+################################################################################
+start_test 'inc finds messages to delete 5. scan profile1 to see messages gone'
+
+# Message numbers are one greater in profile1 than in profile2.
+cat > "$expected" <<EOF
+   2   To:$MH_IMAP_TEST_EMAIL_ADDRESS  testing imap 2<<body >>
+   4   me  refile 3<<refile-test 3$MH_IMAP_SERVER_EATS_TRAILING_EOL>>
+   7   me  refile 6<<refile-test 6$MH_IMAP_SERVER_EATS_TRAILING_EOL>>
+EOF
+scan -form scan.nodate -width 80 +$imap_folder/drafts > "$actual"
+check "$actual" "$expected"
+
+# TODO tests:
+# - refile old message into a folder with newer messages:  UID validity preserved?
+
+################################################################################
+start_test 'check all folder databases one last time'
+
+for profile in $profiles; do
+    set_mh_profile $profile
+    for i in $test_folders; do
+        check_exit '-eq 0' "$MH_OBJ_DIR/target/debug/mhimap" imap check $i
+    done
+done
+
+finish_test
+exit ${failed:-0}
diff --git a/test/imap/test-login b/test/imap/test-login
new file mode 100755 (executable)
index 0000000..b4a8d0c
--- /dev/null
@@ -0,0 +1,374 @@
+#!/bin/sh
+
+testname=${0#$srcdir/}
+
+if test -z "$MH_OBJ_DIR"; then
+    srcdir=`dirname "$0"`/../..
+    MH_OBJ_DIR=`cd "$srcdir" && pwd`; export MH_OBJ_DIR
+fi
+
+. "$MH_OBJ_DIR/test/common.sh"
+
+if [ $(uname) != FreeBSD ]; then
+    test_skip 'imap/test-login only working on FreeBSD for now'
+fi
+
+# common.sh sets this to something crazy; did i do that?!
+testname=${0#$srcdir/}
+
+imap_cleanup() {
+    #### Save exit status to use as status for this program.
+    status=$?
+    set +e
+
+    rm -f "$imapd" "$actual"
+    [ -n "$imapd_pid" ] && kill $imapd_pid
+
+    # TODO have cleanup allow additional cleanup registration?
+    #  Or just allow it to accept status as argument.
+    (exit $status)
+    cleanup
+}
+trap imap_cleanup 0
+
+covdir="$MH_OBJ_DIR/covdata/${MH_COVERAGE_PROFILE:-default}/$testname"
+install -d "$covdir"
+
+cat > "$MH_TEST_DIR/netrc" <<EOF
+machine 127.0.0.1 login testuser password password
+EOF
+
+actual="$MH_TEST_DIR/$$.actual"
+
+imapd="$MH_TEST_DIR/$$.imapd"
+
+arith_val=0
+
+start() {
+    rm -f "$MH_TEST_DIR/$profile/imap.d/socket"
+    status=0
+    LLVM_PROFILE_FILE="$covdir/$profile-%m.profraw" "$MH_OBJ_DIR/target/debug/mhimap" imap start > "$actual" 2>&1 || status=$?
+}
+
+################################################################################
+# Tests with nothing even listening.
+
+start_test 'connection refused, explicit TLS'
+
+arith_eval $arith_val + 1
+setup_test_without_data profile$arith_val
+trap imap_cleanup 0
+
+cat >> "$MH" <<EOF
+credentials: file-nopermcheck:$MH_TEST_DIR/netrc
+Managed-Folders: imap
+imap-log-level: error
+imap-log-error-to: $MH_TEST_DIR/error
+imap-manager: $MH_OBJ_DIR/target/debug/imap-folder-manager
+imap-socket: $MH_TEST_DIR/$profile/imap.d/socket
+imap-db: $MH_TEST_DIR/$profile/imap.d
+imap-host: 127.0.0.1
+imap-mailbox-root: test/
+imap-port: 255
+imap-tls: explicit but don't verify the cert i already did out of band
+imap-auth: SASL
+imap-user: testuser
+EOF
+if [ -n "$MH_TEST_IMAP_LOG_LEVEL" ]; then
+    echo "imap-log-level: $MH_TEST_IMAP_LOG_LEVEL" >> "$MH"
+    echo "imap-log-$MH_TEST_IMAP_LOG_LEVEL-to: $MH_TEST_DIR/$profile/imap.d/log.$MH_TEST_IMAP_LOG_LEVEL" >> "$MH"
+fi
+
+start
+if ! grep -q 'Connection to "127.0.0.1:255" failed: Connection refused' "$actual"; then
+    cat "$actual" >&2
+    fail 'ECONN not reported'
+fi
+
+# Use fakeinetd for tests of the parts of explicit TLS handling before TLS negotiation.
+start_fakeinetd "$imapd"
+imapd_pid=$fakeinetd_pid
+
+arith_eval $arith_val + 1
+setup_test_without_data profile$arith_val
+trap imap_cleanup 0
+
+cat >> "$MH" <<EOF
+credentials: file-nopermcheck:$MH_TEST_DIR/netrc
+Managed-Folders: imap
+imap-log-level: error
+imap-log-error-to: $MH_TEST_DIR/error
+imap-manager: $MH_OBJ_DIR/target/debug/imap-folder-manager
+imap-socket: $MH_TEST_DIR/$profile/imap.d/socket
+imap-db: $MH_TEST_DIR/$profile/imap.d
+imap-host: 127.0.0.1
+imap-mailbox-root: test/
+imap-port: $fakeinetd_port
+imap-tls: explicit but don't verify the cert i already did out of band
+imap-auth: SASL
+imap-user: testuser
+EOF
+if [ -n "$MH_TEST_IMAP_LOG_LEVEL" ]; then
+    echo "imap-log-level: $MH_TEST_IMAP_LOG_LEVEL" >> "$MH"
+    echo "imap-log-$MH_TEST_IMAP_LOG_LEVEL-to: $MH_TEST_DIR/$profile/imap.d/log.$MH_TEST_IMAP_LOG_LEVEL" >> "$MH"
+fi
+
+################################################################################
+start_test 'rudely close pre-greeting, explicit TLS'
+
+cat > "$imapd" <<EOSCRIPT
+#!/bin/sh
+exit 1
+EOSCRIPT
+chmod 755 "$imapd"
+
+start
+if ! grep -q 'Received EOF on network read' "$actual"; then
+    cat "$actual" >&2
+    fail 'EOF error not reported'
+fi
+
+################################################################################
+start_test 'protocol violation in greeting, explicit TLS'
+
+# Greets with simply "trash\r\n".
+
+cat > "$imapd" <<EOSCRIPT
+#!/bin/sh
+cat <<EOF
+trash\r
+EOF
+while read line; do :; done
+EOSCRIPT
+
+start
+if ! grep -q 'protocol error:  no space in response' "$actual"; then
+    cat "$actual" >&2
+    fail 'protocol error not reported'
+fi
+
+################################################################################
+start_test 'STARTTLS capability not offered'
+
+cat > "$imapd" <<EOSCRIPT
+#!/bin/sh
+cat <<EOF
+* OK [CAPABILITY IMAP4REV1 AUTH=PLAIN UIDPLUS]\r
+EOF
+while read line; do :; done
+EOSCRIPT
+
+start
+if ! grep -q "server doesn't offer STARTTLS" "$actual"; then
+    cat "$actual" >&2
+    fail 'missing STARTTLS not reported'
+fi
+
+################################################################################
+start_test 'STARTTLS capability not offered after capabilities requested'
+
+cat > "$imapd" <<EOSCRIPT
+#!/bin/sh
+cat <<EOF
+* OK greeting
+1 OK [CAPABILITY IMAP4REV1 AUTH=PLAIN UIDPLUS] CAPABILITY response\r
+EOF
+while read line; do :; done
+EOSCRIPT
+
+start
+if ! grep -q "server doesn't offer STARTTLS" "$actual"; then
+    cat "$actual" >&2
+    fail 'missing STARTTLS not reported'
+fi
+
+################################################################################
+start_test 'rudely closed after STARTTLS'
+
+cat > "$imapd" <<EOSCRIPT
+#!/bin/sh
+cat <<EOF
+* OK [CAPABILITY IMAP4REV1 STARTTLS] greeting\r
+EOF
+read line
+EOSCRIPT
+
+start
+if ! grep -q 'Received EOF on network read' "$actual"; then
+    cat "$actual" >&2
+    fail 'EOF not reported'
+fi
+
+################################################################################
+start_test 'trash response to STARTTLS'
+
+cat > "$imapd" <<EOSCRIPT
+#!/bin/sh
+cat <<EOF
+* OK [CAPABILITY IMAP4REV1 STARTTLS] greeting\r
+trash\r
+EOF
+while read line; do :; done
+EOSCRIPT
+
+start
+if ! grep -q 'protocol error:  no space in response' "$actual"; then
+    cat "$actual" >&2
+    fail 'protocol error not reported'
+fi
+
+kill $imapd_pid
+imapd_pid=
+
+################################################################################
+start_test 'connection refused, implicit TLS'
+
+arith_eval $arith_val + 1
+setup_test_without_data profile$arith_val
+trap imap_cleanup 0
+
+cat >> "$MH" <<EOF
+credentials: file-nopermcheck:$MH_TEST_DIR/netrc
+Managed-Folders: imap
+imap-log-level: error
+imap-log-error-to: $MH_TEST_DIR/error
+imap-manager: $MH_OBJ_DIR/target/debug/imap-folder-manager
+imap-socket: $MH_TEST_DIR/$profile/imap.d/socket
+imap-db: $MH_TEST_DIR/$profile/imap.d
+imap-host: 127.0.0.1
+imap-mailbox-root: test/
+imap-port: 255
+imap-tls: implicit but don't verify the cert i already did out of band
+imap-auth: SASL
+imap-user: testuser
+EOF
+if [ -n "$MH_TEST_IMAP_LOG_LEVEL" ]; then
+    echo "imap-log-level: $MH_TEST_IMAP_LOG_LEVEL" >> "$MH"
+    echo "imap-log-$MH_TEST_IMAP_LOG_LEVEL-to: $MH_TEST_DIR/$profile/imap.d/log.$MH_TEST_IMAP_LOG_LEVEL" >> "$MH"
+fi
+
+start
+if ! grep -q 'Connection to "127.0.0.1:255" failed: Connection refused' "$actual"; then
+    cat "$actual" >&2
+    fail 'ECONN not reported'
+fi
+
+################################################################################
+# Start the server for the rest.
+
+start_stunnel imap "$imapd"
+
+arith_eval $arith_val + 1
+setup_test_without_data profile$arith_val
+trap imap_cleanup 0
+
+cat >> "$MH" <<EOF
+credentials: file-nopermcheck:$MH_TEST_DIR/netrc
+Managed-Folders: imap
+imap-log-level: error
+imap-log-error-to: $MH_TEST_DIR/error
+imap-manager: $MH_OBJ_DIR/target/debug/imap-folder-manager
+imap-socket: $MH_TEST_DIR/$profile/imap.d/socket
+imap-db: $MH_TEST_DIR/$profile/imap.d
+imap-host: 127.0.0.1
+imap-mailbox-root: test/
+imap-port: $fakeinetd_port
+imap-tls: implicit but don't verify the cert i already did out of band
+imap-auth: SASL
+imap-user: testuser
+EOF
+if [ -n "$MH_TEST_IMAP_LOG_LEVEL" ]; then
+    echo "imap-log-level: $MH_TEST_IMAP_LOG_LEVEL" >> "$MH"
+    echo "imap-log-$MH_TEST_IMAP_LOG_LEVEL-to: $MH_TEST_DIR/$profile/imap.d/log.$MH_TEST_IMAP_LOG_LEVEL" >> "$MH"
+fi
+
+################################################################################
+start_test 'rudely close pre-greeting, implicit TLS'
+
+cat > "$imapd" <<EOSCRIPT
+#!/bin/sh
+exit 1
+EOSCRIPT
+
+start
+if ! grep -q 'TLS peer closed remote connection' "$actual"; then
+    cat "$actual" >&2
+    fail 'EOF error not reported'
+fi
+
+################################################################################
+start_test 'no SASL-IR capability'
+
+cat > "$imapd" <<EOSCRIPT
+#!/bin/sh
+cat <<EOF
+* OK [CAPABILITY IMAP4REV1 AUTH=PLAIN]\r
+EOF
+while read line; do :; done
+EOSCRIPT
+
+start
+if ! grep -q "server doesn't offer SASL-IR; let us know and we'll support this configuration" "$actual"; then
+    cat "$actual" >&2
+    fail 'missing SASL-IR not reported'
+fi
+
+################################################################################
+start_test 'no UIDPLUS capability'
+
+cat > "$imapd" <<EOSCRIPT
+#!/bin/sh
+cat <<EOF
+* OK [CAPABILITY IMAP4REV1 SASL-IR AUTH=PLAIN] greeting\r
+1 OK [CAPABILITY IMAP4REV1] AUTENTICATE response
+EOF
+while read line; do :; done
+EOSCRIPT
+
+start
+if ! grep -q "server doesn't offer required capability UIDPLUS" "$actual"; then
+    cat "$actual" >&2
+    fail 'missing UIDPLUS not reported'
+fi
+
+################################################################################
+start_test 'protocol violation in greeting, implicit TLS'
+
+# Greets with simply "trash\r\n".
+
+cat > "$imapd" <<EOSCRIPT
+#!/bin/sh
+cat <<EOF
+trash\r
+EOF
+while read line; do :; done
+EOSCRIPT
+
+start
+if ! grep -q 'protocol error:  no space in response' "$actual"; then
+    cat "$actual" >&2
+    fail 'protocol error not reported'
+fi
+
+################################################################################
+start_test 'protocol violation in AUTHENTICATE response'
+
+# Responds to the AUTHENTICATE command with simply "trash\r\n".
+
+cat > "$imapd" <<EOSCRIPT
+#!/bin/sh
+cat <<EOF
+* OK [CAPABILITY IMAP4REV1 SASL-IR AUTH=PLAIN] greeting\r
+trash\r
+EOF
+while read line; do :; done
+EOSCRIPT
+
+start
+if ! grep -q 'protocol error:  no space in response' "$actual"; then
+    cat "$actual" >&2
+    fail 'protocol error not reported'
+fi
+
+finish_test
+exit $failed
diff --git a/test/imap/test-mark b/test/imap/test-mark
new file mode 100755 (executable)
index 0000000..3092d59
--- /dev/null
@@ -0,0 +1,105 @@
+#!/bin/sh
+
+testname=${0#$srcdir/}
+
+# setup copied from test-scan
+
+if test -z "$MH_OBJ_DIR"; then
+    srcdir=`dirname "$0"`/../..
+    MH_OBJ_DIR=`cd "$srcdir" && pwd`; export MH_OBJ_DIR
+fi
+
+. "$MH_OBJ_DIR/test/common.sh"
+
+setup_test_without_data Mail
+
+rm -rf "$MH_TEST_DIR/panda"
+
+imap_cleanup() {
+    #### Save exit status to use as status for this program.
+    status=$?
+    set +e
+
+    [ -n "$imapd_pid" ] && kill $imapd_pid
+    "$MH_OBJ_DIR/target/debug/mhimap" imap shutdown
+
+    # TODO have cleanup allow additional cleanup registration?
+    #  Or just allow it to accept status as argument.
+    (exit $status)
+    cleanup
+}
+trap imap_cleanup 0
+
+covdir="$MH_OBJ_DIR/covdata/${MH_COVERAGE_PROFILE:-default}/$testname"
+install -d "$covdir"
+export LLVM_PROFILE_FILE="$covdir/$profile-%m.profraw"
+
+start_fakeinetd env "HOME=$MH_TEST_DIR/panda" "$PANDA_IMAPD"
+imapd_pid=$fakeinetd_pid
+
+# TODO imap-manager: $MH_LIBEXEC_DIR/folder-managers/imap
+# TODO imap-socket: relative path
+# TODO can we come up with reasonable default of socket path?
+cat >> "$MH" <<EOF
+Managed-Folders: imap
+imap-manager: $MH_OBJ_DIR/target/debug/imap-folder-manager
+imap-socket: $MH_TEST_DIR/Mail/imap.d/socket
+imap-db: $MH_TEST_DIR/Mail/imap.d
+imap-host: 127.0.0.1
+imap-port: $fakeinetd_port
+imap-auth: PREAUTH
+imap-tls: don't protect my data i know what i'm doing
+imap-mailbox-root: test/
+EOF
+if [ -n "$MH_TEST_IMAP_LOG_LEVEL" ]; then
+    echo "imap-log-level: $MH_TEST_IMAP_LOG_LEVEL" >> "$MH"
+    echo "imap-log-$MH_TEST_IMAP_LOG_LEVEL-to: $MH_TEST_DIR/$profile/imap.d/log.$MH_TEST_IMAP_LOG_LEVEL" >> "$MH"
+fi
+
+mkdir "$MH_TEST_DIR/panda"
+mkdir "$MH_TEST_DIR/panda/test"
+
+expected="$MH_TEST_DIR/$$.expected"
+actual="$MH_TEST_DIR/$$.actual"
+
+cat > "$MH_TEST_DIR/panda/test/folder" <<EOF
+From nmh@example.com Sat Oct 24 19:20:38 2020 -0500
+Date:Sat, 24 Oct 2020 19:20:38 -0500
+from:me
+to:you
+X-IMAPbase: 1603585238 11
+Status: RO
+X-Status: 
+X-Keywords:          
+X-UID: 11
+
+body
+
+EOF
+
+start_test 'inc'
+check_exit '-eq 0' inc +imap
+
+################################################################################
+start_test 'mark -seq unseen'
+
+cat > "$expected" <<EOF
+From nmh@example.com Sat Oct 24 19:20:38 2020 -0500
+Date:Sat, 24 Oct 2020 19:20:38 -0500
+from:me
+to:you
+X-IMAPbase: 1603585238 11
+Status: O
+X-Status: 
+X-Keywords:                      
+X-UID: 11
+
+body
+
+EOF
+
+check_exit '-eq 0' mark -add -seq unseen +imap/folder 1
+check "$MH_TEST_DIR/panda/test/folder" "$expected"
+
+finish_test
+exit ${failed:-0}
diff --git a/test/imap/test-quirks-gmail b/test/imap/test-quirks-gmail
new file mode 100755 (executable)
index 0000000..741b04c
--- /dev/null
@@ -0,0 +1,94 @@
+#!/bin/sh
+
+# Test illegal or questionable behaviors of GMail.
+
+if test -z "$MH_OBJ_DIR"; then
+    srcdir=`dirname "$0"`/../..
+    MH_OBJ_DIR=`cd "$srcdir" && pwd`; export MH_OBJ_DIR
+fi
+
+. "${srcdir}/test/oauth/common.sh"
+
+# common.sh sets this to something crazy; did i do that?!
+testname=${0#$srcdir/}
+
+setup_test_without_data Mail
+
+setup_service
+fake_creds <<EOF
+access-testuser: test-access
+refresh-testuser: test-refresh
+expire-testuser: 2000000000
+EOF
+
+imap_cleanup() {
+    #### Save exit status to use as status for this program.
+    status=$?
+    set +e
+
+    rm -f "$imapd"
+
+    [ -n "$imapd_pid" ] && kill $imapd_pid
+    "$MH_OBJ_DIR/target/debug/mhimap" imap shutdown
+
+    # TODO have cleanup allow additional cleanup registration?
+    #  Or just allow it to accept status as argument.
+    (exit $status)
+    cleanup
+}
+trap imap_cleanup 0
+
+covdir="$MH_OBJ_DIR/covdata/${MH_COVERAGE_PROFILE:-default}/$testname"
+install -d "$covdir"
+export LLVM_PROFILE_FILE="$covdir/$profile-%m.profraw"
+
+imapd="$MH_TEST_DIR/$$.imapd"
+start_fakeinetd "$imapd"
+imapd_pid=$fakeinetd_pid
+
+cat >> "$MH" <<EOF
+Managed-Folders: imap
+imap-manager: $MH_OBJ_DIR/target/debug/imap-folder-manager
+imap-socket: $MH_TEST_DIR/Mail/imap.d/socket
+imap-db: $MH_TEST_DIR/Mail/imap.d
+imap-host: 127.0.0.1
+imap-port: $fakeinetd_port
+imap-tls: don't protect my data i know what i'm doing
+imap-auth: SASL
+imap-user: testuser
+imap-saslmech: xoauth2
+imap-authservice: test
+EOF
+if [ -n "$MH_TEST_IMAP_LOG_LEVEL" ]; then
+    echo "imap-log-level: $MH_TEST_IMAP_LOG_LEVEL" >> "$MH"
+    echo "imap-log-$MH_TEST_IMAP_LOG_LEVEL-to: $MH_TEST_DIR/$profile/imap.d/log.$MH_TEST_IMAP_LOG_LEVEL" >> "$MH"
+fi
+
+# It's the "* OK [HIGHESTMODSEQ 8869]" that's illegal:  a space is required after the close bracket.
+cat > "$imapd" <<EOSCRIPT
+#!/bin/sh
+cat <<EOF
+* OK Gimap ready for requests\r
+* CAPABILITY IMAP4rev1 UNSELECT IDLE NAMESPACE QUOTA ID XLIST CHILDREN X-GM-EXT-1 XYZZY SASL-IR AUTH=XOAUTH2 AUTH=PLAIN AUTH=PLAIN-CLIENTTOKEN AUTH=OAUTHBEARER AUTH=XOAUTH\r
+1 OK capability response\r
+* CAPABILITY IMAP4rev1 UNSELECT IDLE NAMESPACE QUOTA ID XLIST CHILDREN X-GM-EXT-1 UIDPLUS COMPRESS=DEFLATE ENABLE MOVE CONDSTORE ESEARCH UTF8=ACCEPT LIST-EXTENDED LIST-STATUS LITERAL- SPECIAL-USE APPENDLIMIT=35651584\r
+1 OK authenticate response\r
+1 OK Success\r
+* FLAGS (\Answered \Flagged \Draft \Deleted \Seen $NotPhishing $Phishing)\r
+* OK [PERMANENTFLAGS (\Answered \Flagged \Draft \Deleted \Seen $NotPhishing $Phishing \*)] Flags permitted.\r
+* OK [UIDVALIDITY 62] UIDs valid.\r
+* 0 EXISTS\r
+* 0 RECENT\r
+* OK [UIDNEXT 1] Predicted next UID.\r
+* OK [HIGHESTMODSEQ 8869]\r
+1 OK [READ-WRITE] imap/foo selected. (Success)\r
+1 OK Returned to authenticated state. (Success)\r
+EOF
+while read line; do :; done
+EOSCRIPT
+chmod 755 "$imapd"
+
+folder -create +imap/foo
+
+finish_test
+exit ${failed:-0}
diff --git a/test/imap/test-refile b/test/imap/test-refile
new file mode 100755 (executable)
index 0000000..95e3f9c
--- /dev/null
@@ -0,0 +1,198 @@
+#!/bin/sh
+
+# TODO this must have bitrotted to oblivion...
+
+testname=${0#$srcdir/}
+
+# setup copied from test-scan
+
+if test -z "$MH_OBJ_DIR"; then
+    srcdir=`dirname "$0"`/../..
+    MH_OBJ_DIR=`cd "$srcdir" && pwd`; export MH_OBJ_DIR
+fi
+
+. "$MH_OBJ_DIR/test/common.sh"
+
+setup_test_without_data Mail
+
+rm -rf "$MH_TEST_DIR/panda"
+
+imap_cleanup() {
+    #### Save exit status to use as status for this program.
+    status=$?
+    set +e
+
+    [ -n "$imapd_pid" ] && kill $imapd_pid
+    "$MH_OBJ_DIR/target/debug/mhimap" imap shutdown
+
+    # TODO have cleanup allow additional cleanup registration?
+    #  Or just allow it to accept status as argument.
+    (exit $status)
+    cleanup
+}
+trap imap_cleanup 0
+
+covdir="$MH_OBJ_DIR/covdata/${MH_COVERAGE_PROFILE:-default}/$testname"
+install -d "$covdir"
+export LLVM_PROFILE_FILE="$covdir/$profile-%m.profraw"
+
+start_fakeinetd env "HOME=$MH_TEST_DIR/panda" "$PANDA_IMAPD"
+imapd_pid=$fakeinetd_pid
+
+# TODO imap-manager: $MH_LIBEXEC_DIR/folder-managers/imap
+# TODO imap-socket: relative path
+# TODO can we come up with reasonable default of socket path?
+cat >> "$MH" <<EOF
+Managed-Folders: imap
+imap-manager: $MH_OBJ_DIR/target/debug/imap-folder-manager
+imap-socket: $MH_TEST_DIR/Mail/imap.d/socket
+imap-db: $MH_TEST_DIR/Mail/imap.d
+imap-host: 127.0.0.1
+imap-port: $fakeinetd_port
+imap-auth: PREAUTH
+imap-tls: don't protect my data i know what i'm doing
+imap-mailbox-root: test/
+EOF
+if [ -n "$MH_TEST_IMAP_LOG_LEVEL" ]; then
+    echo "imap-log-level: $MH_TEST_IMAP_LOG_LEVEL" >> "$MH"
+    echo "imap-log-$MH_TEST_IMAP_LOG_LEVEL-to: $MH_TEST_DIR/$profile/imap.d/log.$MH_TEST_IMAP_LOG_LEVEL" >> "$MH"
+fi
+
+mkdir "$MH_TEST_DIR/panda"
+mkdir "$MH_TEST_DIR/panda/test"
+
+expected="$MH_TEST_DIR/$$.expected"
+actual="$MH_TEST_DIR/$$.actual"
+
+for i in 1 2 3; do
+    cat > "$MH_TEST_DIR/panda/test/fold$i" <<EOF
+From nmh@example.com Sat Oct 24 19:20:38 2020 -0500
+Date:Sat, 24 Oct 2020 19:20:38 -0500
+from:me
+to:you
+X-IMAPbase: 1603585238 1
+Status: RO
+X-Status: 
+X-Keywords:          
+X-UID: 1
+
+initial message in fold$i
+
+EOF
+done
+
+start_test 'initial inc'
+
+cat >  "$expected.inc" <<EOF
+Incorporating new mail into imap/fold1...
+
+   1  10/24 me                 <<initial message in fold1 >>
+Incorporating new mail into imap/fold2...
+
+   1  10/24 me                 <<initial message in fold2 >>
+Incorporating new mail into imap/fold3...
+
+   1  10/24 me                 <<initial message in fold3 >>
+EOF
+
+inc +imap > "$actual"
+check "$actual" "$expected.inc"
+
+################################################################################
+start_test 'refile'
+
+check_exit '-eq 0' refile -src +imap/fold1 1 +imap/fold2
+
+start_test 'scan src folder after refile finds no messages'
+
+cat > "$expected" <<EOF
+scan: no messages match specification
+EOF
+scan -width 80 +imap/fold1 > "$actual" 2>&1 || :
+check "$actual" "$expected"
+
+start_test 'scan dst folder after refile finds refiled message'
+
+cat > "$expected" <<EOF
+   1  10/24 me                 <<initial message in fold2 >>
+   2+ 10/24 me                 <<initial message in fold1 >>
+EOF
+scan -width 80 +imap/fold2 > "$actual"
+check "$actual" "$expected"
+
+################################################################################
+start_test 'refile -link -file'
+
+refiled_file="$MH_TEST_DIR/$$.refiled-file"
+cat > "$refiled_file" <<EOF
+Date:Sat, 24 Oct 2020 19:20:38 -0500
+from:me
+to:you
+
+refile-link file
+EOF
+check_exit '-eq 0' refile -link -file "$refiled_file" +imap/fold1
+if [ -e "$refiled_file" ]; then
+    rm "$refiled_file"
+else
+    fail "$refiled_file missing after refile -link"
+fi
+
+start_test 'scan dst folder after refile -link -file finds refiled message'
+
+cat > "$expected" <<EOF
+   1+ 10/24 me                 <<refile-link file >>
+EOF
+scan -width 80 +imap/fold1 > "$actual"
+check "$actual" "$expected"
+
+################################################################################
+start_test 'refile -file'
+
+refiled_file="$MH_TEST_DIR/$$.refiled-file"
+cat > "$refiled_file" <<EOF
+Date:Sat, 24 Oct 2020 19:20:38 -0500
+from:me
+to:you
+
+refiled file
+EOF
+check_exit '-eq 0' refile -file "$refiled_file" +imap/fold3
+if [ -e "$refiled_file" ]; then
+    rm -f "$refiled_file" || :
+    fail "$refiled_file missing after refile"
+fi
+
+start_test 'scan dst folder after refile -file finds refiled message'
+
+cat > "$expected" <<EOF
+   1  10/24 me                 <<initial message in fold3 >>
+   2+ 10/24 me                 <<refiled file >>
+EOF
+scan -width 80 +imap/fold3 > "$actual"
+check "$actual" "$expected"
+
+################################################################################
+start_test 'rebuild local from scratch'
+
+rm -rf "$MH_TEST_DIR/Mail/imap" "$MH_TEST_DIR/Mail/imap.d/imap"
+
+cat >  "$expected.inc" <<EOF
+Incorporating new mail into imap/fold1...
+
+   1  10/24 me                 <<refile-link file >>
+Incorporating new mail into imap/fold2...
+
+   1  10/24 me                 <<initial message in fold2 >>
+   2  10/24 me                 <<initial message in fold1 >>
+Incorporating new mail into imap/fold3...
+
+   1  10/24 me                 <<initial message in fold3 >>
+   2  10/24 me                 <<refiled file >>
+EOF
+
+inc +imap > "$actual"
+check "$actual" "$expected.inc"
+
+finish_test
+exit ${failed:-0}
diff --git a/test/imap/test-rmm b/test/imap/test-rmm
new file mode 100755 (executable)
index 0000000..7a9f1e9
--- /dev/null
@@ -0,0 +1,98 @@
+#!/bin/sh
+
+testname=${0#$srcdir/}
+
+# setup copied from test-scan
+
+if test -z "$MH_OBJ_DIR"; then
+    srcdir=`dirname "$0"`/../..
+    MH_OBJ_DIR=`cd "$srcdir" && pwd`; export MH_OBJ_DIR
+fi
+
+. "$MH_OBJ_DIR/test/common.sh"
+
+setup_test_without_data Mail
+
+rm -rf "$MH_TEST_DIR/panda"
+
+imap_cleanup() {
+    #### Save exit status to use as status for this program.
+    status=$?
+    set +e
+
+    [ -n "$imapd_pid" ] && kill $imapd_pid
+    "$MH_OBJ_DIR/target/debug/mhimap" imap shutdown
+
+    # TODO have cleanup allow additional cleanup registration?
+    #  Or just allow it to accept status as argument.
+    (exit $status)
+    cleanup
+}
+trap imap_cleanup 0
+
+covdir="$MH_OBJ_DIR/covdata/${MH_COVERAGE_PROFILE:-default}/$testname"
+install -d "$covdir"
+export LLVM_PROFILE_FILE="$covdir/$profile-%m.profraw"
+
+start_fakeinetd env "HOME=$MH_TEST_DIR/panda" "$PANDA_IMAPD"
+imapd_pid=$fakeinetd_pid
+
+# TODO imap-manager: $MH_LIBEXEC_DIR/folder-managers/imap
+# TODO imap-socket: relative path
+# TODO can we come up with reasonable default of socket path?
+cat >> "$MH" <<EOF
+Managed-Folders: imap
+imap-manager: $MH_OBJ_DIR/target/debug/imap-folder-manager
+imap-socket: $MH_TEST_DIR/Mail/imap.d/socket
+imap-db: $MH_TEST_DIR/Mail/imap.d
+imap-host: 127.0.0.1
+imap-port: $fakeinetd_port
+imap-auth: PREAUTH
+imap-tls: don't protect my data i know what i'm doing
+imap-mailbox-root: test/
+EOF
+if [ -n "$MH_TEST_IMAP_LOG_LEVEL" ]; then
+    echo "imap-log-level: $MH_TEST_IMAP_LOG_LEVEL" >> "$MH"
+    echo "imap-log-$MH_TEST_IMAP_LOG_LEVEL-to: $MH_TEST_DIR/$profile/imap.d/log.$MH_TEST_IMAP_LOG_LEVEL" >> "$MH"
+fi
+
+mkdir "$MH_TEST_DIR/panda"
+mkdir "$MH_TEST_DIR/panda/test"
+
+expected="$MH_TEST_DIR/$$.expected"
+actual="$MH_TEST_DIR/$$.actual"
+
+cat > "$MH_TEST_DIR/panda/test/fold1" <<EOF
+From nmh@example.com Sat Oct 24 19:20:38 2020 -0500
+Date:Sat, 24 Oct 2020 19:20:38 -0500
+from:me
+to:you
+X-IMAPbase: 1603585238 1
+Status: RO
+X-Status: 
+X-Keywords:          
+X-UID: 1
+
+body
+
+EOF
+
+start_test 'inc'
+check_exit '-eq 0' inc +imap
+
+################################################################################
+start_test 'rmm over IMAP'
+
+check_exit '-eq 0' rmm -unlink +imap/fold1 1
+
+################################################################################
+start_test 'scan shows no messages'
+
+cat > "$expected" <<EOF
+scan: no messages match specification
+EOF
+scan -width 80 +imap/fold1 > "$actual" 2>&1 || :
+check "$actual" "$expected"
+
+finish_test
+exit ${failed:-0}
diff --git a/test/imap/test-scan b/test/imap/test-scan
new file mode 100755 (executable)
index 0000000..81669a3
--- /dev/null
@@ -0,0 +1,259 @@
+#!/bin/sh
+
+testname=${0#$srcdir/}
+
+if test -z "$MH_OBJ_DIR"; then
+    srcdir=`dirname "$0"`/../..
+    MH_OBJ_DIR=`cd "$srcdir" && pwd`; export MH_OBJ_DIR
+fi
+
+. "$MH_OBJ_DIR/test/common.sh"
+
+setup_test_without_data Mail
+
+rm -rf "$MH_TEST_DIR/panda"
+
+imap_cleanup() {
+    #### Save exit status to use as status for this program.
+    status=$?
+    set +e
+
+    [ -n "$imapd_pid" ] && kill $imapd_pid
+    "$MH_OBJ_DIR/target/debug/mhimap" imap shutdown
+
+    # TODO have cleanup allow additional cleanup registration?
+    #  Or just allow it to accept status as argument.
+    (exit $status)
+    cleanup
+}
+trap imap_cleanup 0
+
+covdir="$MH_OBJ_DIR/covdata/${MH_COVERAGE_PROFILE:-default}/$testname"
+install -d "$covdir"
+export LLVM_PROFILE_FILE="$covdir/$profile-%m.profraw"
+
+start_fakeinetd env "HOME=$MH_TEST_DIR/panda" "$PANDA_IMAPD"
+imapd_pid=$fakeinetd_pid
+
+cat >> "$MH" <<EOF
+Unseen-Sequence: unseen
+Managed-Folders: imap
+imap-manager: $MH_OBJ_DIR/target/debug/imap-folder-manager
+imap-socket: $MH_TEST_DIR/Mail/imap.d/socket
+imap-db: $MH_TEST_DIR/Mail/imap.d
+imap-host: 127.0.0.1
+imap-port: $fakeinetd_port
+imap-auth: PREAUTH
+imap-tls: don't protect my data i know what i'm doing
+imap-mailbox-root: test/
+EOF
+if [ -n "$MH_TEST_IMAP_LOG_LEVEL" ]; then
+    echo "imap-log-level: $MH_TEST_IMAP_LOG_LEVEL" >> "$MH"
+    echo "imap-log-$MH_TEST_IMAP_LOG_LEVEL-to: $MH_TEST_DIR/$profile/imap.d/log.$MH_TEST_IMAP_LOG_LEVEL" >> "$MH"
+fi
+
+mkdir "$MH_TEST_DIR/panda"
+mkdir "$MH_TEST_DIR/panda/test"
+
+expected="$MH_TEST_DIR/$$.expected"
+actual="$MH_TEST_DIR/$$.actual"
+
+# TODO duplicated with test-inc
+removed_message=5
+arith_val=1
+message_count=11
+cat > $MH_TEST_DIR/panda/test/fold <<EOF
+From nmh@example.com Sat Oct 24 19:20:38 2020 -0500
+Date:Sat, 24 Oct 2020 19:20:38 -0500
+from:me
+to:you
+X-IMAPbase: 1603585238 $message_count testseq
+Status: RO
+X-Status: 
+X-Keywords:          
+X-UID: $arith_val
+
+body
+
+EOF
+echo "   $arith_val  10/24 me                 <<body >>" > "$expected.scan-all"
+
+while [ $arith_val -lt $message_count ]; do
+    arith_eval $arith_val + 1
+    printf '  %2d  10/24 me                 <<body >>\n' $arith_val >> "$expected.scan-all"
+    printf '  %2d U 10/24 me                 <<body >>\n' $arith_val >> "$expected.scan-unseen"
+    cat >> $MH_TEST_DIR/panda/test/fold <<EOF
+From nmh@example.com Sat Oct 24 19:20:38 2020 -0500
+Date:Sat, 24 Oct 2020 19:20:38 -0500
+from:me
+to:you
+Status: O
+X-Status: 
+X-Keywords:          
+X-UID: $arith_val
+
+body
+
+EOF
+    arith_eval $arith_val + 1
+    if [ $arith_val -ne $removed_message ]; then
+        printf '  %2d U 10/24 me                 <<body >>\n' $arith_val >> "$expected.scan-unseen"
+        printf '  %2d  10/24 me                 <<body >>\n' $arith_val >> "$expected.scan-all"
+    fi
+    cat >> $MH_TEST_DIR/panda/test/fold <<EOF
+From nmh@example.com Sat Oct 24 19:20:38 2020 -0500
+Date:Sat, 24 Oct 2020 19:20:38 -0500
+from:me
+to:you
+Status: O
+X-Status: 
+X-Keywords:          
+X-UID: $arith_val
+
+body
+
+EOF
+done
+
+start_test 'inc then remove mesage 5'
+check_exit '-eq 0' inc +imap
+check_exit '-eq 0' rmm -unlink +imap/fold $removed_message
+
+################################################################################
+start_test 'scan all implicitly'
+
+run_prog scan +imap/fold -width 80 > "$actual"
+check "$expected.scan-all" "$actual" 'keep first'
+
+################################################################################
+start_test 'scan all explicitly'
+
+run_prog scan +imap/fold -width 80 all > "$actual"
+check "$expected.scan-all" "$actual" 'keep first'
+
+################################################################################
+start_test 'scan unseen'
+
+run_prog scan +imap/fold -form scan.unseen -width 80 unseen > "$actual"
+check "$expected.scan-unseen" "$actual"
+
+cat > "$expected" <<EOF
+nmh-picked: 2-4 6-11
+unseen: 2-4 6-11
+EOF
+check "$MH_TEST_DIR/Mail/imap/fold/.mh_sequences" "$expected" 'keep first'
+
+################################################################################
+start_test 'scan some messages'
+
+cat > "$expected" <<EOF
+   2 U 10/24 me                 <<body >>
+   3 U 10/24 me                 <<body >>
+   4 U 10/24 me                 <<body >>
+   6 U 10/24 me                 <<body >>
+   8 U 10/24 me                 <<body >>
+   9 U 10/24 me                 <<body >>
+  10 U 10/24 me                 <<body >>
+EOF
+run_prog scan +imap/fold -form scan.unseen -width 80 8-9 9-10 2-6 > "$actual"
+check "$expected" "$actual"
+
+cat > "$expected" <<EOF
+nmh-picked: 2-4 6 8-10
+unseen: 2-4 6-11
+EOF
+check "$MH_TEST_DIR/Mail/imap/fold/.mh_sequences" "$expected" 'keep first'
+
+################################################################################
+start_test 'scan non-existent message'
+
+cat > "$expected" <<EOF
+scan: no messages match specification
+EOF
+
+run_prog scan +imap/fold -form scan.unseen -width 80 $removed_message > "$actual" 2>&1 || :
+check "$expected" "$actual"
+
+################################################################################
+start_test 'mark -del testseq unseen'
+
+check_exit '-eq 0' mark -add -seq testseq 6 8 10
+# TODO check output
+check_exit '-eq 0' scan testseq
+
+check_exit '-eq 0' mark -del -seq testseq unseen
+
+# TODO should be 'scan: bad message list testseq'
+cat > "$expected" <<EOF
+scan: no messages match specification
+EOF
+
+run_prog scan +imap/fold -form scan.unseen -width 80 testseq > "$actual" 2>&1 || :
+check "$expected" "$actual"
+
+################################################################################
+start_test 'show unseen twice'
+
+sed 's/^  11 /  11+/' "$expected.scan-all" > "$expected"
+
+check_exit '-eq 0' show unseen unseen
+
+run_prog scan +imap/fold -width 80 > "$actual"
+check "$expected" "$actual"
+
+################################################################################
+start_test 'show 1-11'
+
+sed 's/^  11 /  11+/' "$expected.scan-all" > "$expected"
+rm "$expected.scan-all"
+
+check_exit '-eq 0' show 1-11
+
+################################################################################
+start_test 'scan testseq'
+
+check_exit '-eq 0' mark -add -seq testseq 2-3 6-8 11
+cat > "$expected" <<EOF
+   2   10/24 me                 <<body >>
+   3   10/24 me                 <<body >>
+   6   10/24 me                 <<body >>
+   7   10/24 me                 <<body >>
+   8   10/24 me                 <<body >>
+  11+  10/24 me                 <<body >>
+EOF
+run_prog scan +imap/fold -form scan.unseen -width 80 testseq > "$actual"
+check "$expected" "$actual"
+
+################################################################################
+start_test 'mark -del testseq 1'
+
+check_exit '-eq 0' mark -del -seq testseq 2
+
+cat > "$expected" <<EOF
+   3   10/24 me                 <<body >>
+   6   10/24 me                 <<body >>
+   7   10/24 me                 <<body >>
+   8   10/24 me                 <<body >>
+  11+  10/24 me                 <<body >>
+EOF
+run_prog scan +imap/fold -form scan.unseen -width 80 testseq > "$actual"
+check "$expected" "$actual"
+
+################################################################################
+start_test 'mark -del testseq all'
+
+check_exit '-eq 0' mark -del -seq testseq testseq
+
+################################################################################
+start_test 'scan testseq after mark -del'
+
+# TODO should be 'scan: bad message list testseq'
+cat > "$expected" <<EOF
+scan: no messages match specification
+EOF
+
+run_prog scan +imap/fold -form scan.unseen -width 80 testseq > "$actual" 2>&1 || :
+check "$expected" "$actual"
+
+finish_test
+exit ${failed:-0}
diff --git a/test/imap/test-search b/test/imap/test-search
new file mode 100755 (executable)
index 0000000..252d3c2
--- /dev/null
@@ -0,0 +1,132 @@
+#!/bin/sh
+
+if test -z "$MH_OBJ_DIR"; then
+    srcdir=`dirname "$0"`/../..
+    MH_OBJ_DIR=`cd "$srcdir" && pwd`; export MH_OBJ_DIR
+fi
+
+. "$MH_OBJ_DIR/test/common.sh"
+
+# common.sh sets this to something crazy; did i do that?!
+testname=${0#$srcdir/}
+
+setup_test_without_data Mail
+
+imap_cleanup() {
+    #### Save exit status to use as status for this program.
+    status=$?
+    set +e
+
+    rm -f "$imapd"
+
+    [ -n "$imapd_pid" ] && kill $imapd_pid
+    "$MH_OBJ_DIR/target/debug/mhimap" imap shutdown
+
+    # TODO have cleanup allow additional cleanup registration?
+    #  Or just allow it to accept status as argument.
+    (exit $status)
+    cleanup
+}
+trap imap_cleanup 0
+
+covdir="$MH_OBJ_DIR/covdata/${MH_COVERAGE_PROFILE:-default}/$testname"
+install -d "$covdir"
+export LLVM_PROFILE_FILE="$covdir/$profile-%m.profraw"
+
+imapd="$MH_TEST_DIR/$$.imapd"
+start_fakeinetd "$imapd"
+imapd_pid=$fakeinetd_pid
+
+cat >> "$MH" <<EOF
+Managed-Folders: imap
+imap-manager: $MH_OBJ_DIR/target/debug/imap-folder-manager
+imap-socket: $MH_TEST_DIR/Mail/imap.d/socket
+imap-db: $MH_TEST_DIR/Mail/imap.d
+imap-host: 127.0.0.1
+imap-port: $fakeinetd_port
+imap-tls: don't protect my data i know what i'm doing
+imap-auth: PREAUTH
+imap-mailbox-root: test/
+EOF
+if [ -n "$MH_TEST_IMAP_LOG_LEVEL" ]; then
+    echo "imap-log-level: $MH_TEST_IMAP_LOG_LEVEL" >> "$MH"
+    echo "imap-log-$MH_TEST_IMAP_LOG_LEVEL-to: $MH_TEST_DIR/$profile/imap.d/log.$MH_TEST_IMAP_LOG_LEVEL" >> "$MH"
+fi
+
+expected="$MH_TEST_DIR/$$.expected"
+actual="$MH_TEST_DIR/$$.actual"
+
+cat > "$imapd" <<EOSCRIPT
+#!/bin/sh
+cat <<EOF
+* PREAUTH [CAPABILITY IMAP4REV1 UIDPLUS] authenticated\r
+* LIST (\NoInferiors \UnMarked) "/" test/fold\r
+1 OK LIST completed\r
+* 6 EXISTS\r
+* OK [UIDVALIDITY 1633920336] UID validity status\r
+* OK [UIDNEXT 11] Predicted next UID\r
+1 OK [READ-WRITE] SELECT for inc completed\r
+* 2 FETCH (UID 6)\r
+* 3 FETCH (UID 7)\r
+* 4 FETCH (UID 8)\r
+* 5 FETCH (UID 9)\r
+* 6 FETCH (UID 10)\r
+1 OK UID FETCH completed\r
+* 2 FETCH (UID 6 FLAGS (\Seen) BODY[] {9}\r
+from:me\r
+)\r
+f6 OK UID FETCH completed\r
+* 3 FETCH (UID 7 FLAGS (\Seen) BODY[] {9}\r
+from:me\r
+)\r
+f7 OK UID FETCH completed\r
+* 4 FETCH (UID 8 FLAGS (\Seen) BODY[] {9}\r
+from:me\r
+)\r
+f8 OK UID FETCH completed\r
+* 5 FETCH (UID 9 FLAGS (\Seen) BODY[] {9}\r
+from:me\r
+)\r
+f9 OK UID FETCH completed\r
+* 6 FETCH (UID 10 FLAGS (\Seen) BODY[] {9}\r
+from:me\r
+)\r
+f10 OK UID FETCH completed\r
+* SEARCH 9\r
+* SEARCH 7 6 10\r
+* SEARCH 8\r
+1 OK UID SEARCH completed\r
+close OK CLOSE for inc completed\r
+* 6 EXISTS\r
+* OK [UIDVALIDITY 1633920336] UID validity status\r
+* OK [UIDNEXT 11] Predicted next UID\r
+1 OK [READ-WRITE] SELECT for scan completed\r
+1 OK nothing for SEARCH NOT SEEN
+close OK CLOSE for scan completed\r
+EOF
+while read line; do :; done
+EOSCRIPT
+chmod 755 "$imapd"
+
+################################################################################
+start_test 'out of order SEARCH response - inc'
+
+check_exit '-eq 0' inc +imap
+
+check_exit '-eq 0' "$MH_OBJ_DIR/target/debug/mhimap" imap check imap/fold
+
+################################################################################
+start_test 'out of order SEARCH response - scan finds all 5 messages'
+
+cat > "$expected" <<EOF
+   1  10/11*me                 
+   2  10/11*me                 
+   3  10/11*me                 
+   4  10/11*me                 
+   5  10/11*me                 
+EOF
+scan +imap/fold > "$actual"
+check "$actual" "$expected"
+
+finish_test
+exit ${failed:-0}
diff --git a/test/imap/test-server-drops b/test/imap/test-server-drops
new file mode 100755 (executable)
index 0000000..0c569e4
--- /dev/null
@@ -0,0 +1,81 @@
+#!/bin/sh
+
+if test -z "$MH_OBJ_DIR"; then
+    srcdir=`dirname "$0"`/../..
+    MH_OBJ_DIR=`cd "$srcdir" && pwd`; export MH_OBJ_DIR
+fi
+
+. "$MH_OBJ_DIR/test/common.sh"
+
+# common.sh sets this to something crazy; did i do that?!
+testname=${0#$srcdir/}
+
+setup_test_without_data Mail
+
+imap_cleanup() {
+    #### Save exit status to use as status for this program.
+    status=$?
+    set +e
+
+    rm -f "$imapd"
+
+    [ -n "$imapd_pid" ] && kill $imapd_pid
+    "$MH_OBJ_DIR/target/debug/mhimap" imap shutdown
+
+    # TODO have cleanup allow additional cleanup registration?
+    #  Or just allow it to accept status as argument.
+    (exit $status)
+    cleanup
+}
+trap imap_cleanup 0
+
+covdir="$MH_OBJ_DIR/covdata/${MH_COVERAGE_PROFILE:-default}/$testname"
+install -d "$covdir"
+export LLVM_PROFILE_FILE="$covdir/$profile-%m.profraw"
+
+imapd="$MH_TEST_DIR/$$.imapd"
+start_fakeinetd "$imapd"
+imapd_pid=$fakeinetd_pid
+
+cat >> "$MH" <<EOF
+Managed-Folders: imap
+imap-manager: $MH_OBJ_DIR/target/debug/imap-folder-manager
+imap-socket: $MH_TEST_DIR/Mail/imap.d/socket
+imap-db: $MH_TEST_DIR/Mail/imap.d
+imap-host: 127.0.0.1
+imap-port: $fakeinetd_port
+imap-tls: don't protect my data i know what i'm doing
+imap-auth: PREAUTH
+imap-mailbox-root: test/
+EOF
+if [ -n "$MH_TEST_IMAP_LOG_LEVEL" ]; then
+    echo "imap-log-level: $MH_TEST_IMAP_LOG_LEVEL" >> "$MH"
+    echo "imap-log-$MH_TEST_IMAP_LOG_LEVEL-to: $MH_TEST_DIR/$profile/imap.d/log.$MH_TEST_IMAP_LOG_LEVEL" >> "$MH"
+fi
+
+expected="$MH_TEST_DIR/$$.expected"
+actual="$MH_TEST_DIR/$$.actual"
+
+start_test 'unrecoverable error:  server closes connection mid-LIST-response'
+
+cat > "$imapd" <<EOSCRIPT
+#!/bin/sh
+cat <<EOF
+* PREAUTH [CAPABILITY IMAP4REV1 UIDPLUS] authenticated\r
+* LIST (\NoInferiors \UnMarked) "/" test/fold\r
+EOF
+read line
+EOSCRIPT
+chmod 755 "$imapd"
+
+# This is similar to but NOT THE SAME as EOF tests in test-login; those are during authentication, these are after.
+
+cat > "$expected" <<EOF
+inc: failed to inc folder imap:  Received EOF on network read
+EOF
+inc +imap > "$actual" 2>&1 || status=$?
+check "$actual" "$expected"
+(exit $status) && fail 'inc exited 0 on error'
+
+finish_test
+exit ${failed:-0}
diff --git a/test/imap/test-uidvalidity b/test/imap/test-uidvalidity
new file mode 100755 (executable)
index 0000000..3cb4308
--- /dev/null
@@ -0,0 +1,139 @@
+#!/bin/sh
+
+testname=${0#$srcdir/}
+
+if test -z "$MH_OBJ_DIR"; then
+    srcdir=`dirname "$0"`/../..
+    MH_OBJ_DIR=`cd "$srcdir" && pwd`; export MH_OBJ_DIR
+fi
+
+. "$MH_OBJ_DIR/test/common.sh"
+
+rm -rf "$MH_TEST_DIR/panda"
+
+imap_cleanup() {
+    #### Save exit status to use as status for this program.
+    status=$?
+    set +e
+
+    [ -n "$imapd_pid" ] && kill $imapd_pid
+
+    # TODO uh oops don't need two profiles for this one
+    for i in 1 2; do
+        set_mh_profile profile$i
+        "$MH_OBJ_DIR/target/debug/mhimap" imap shutdown
+    done
+
+    if [ -z "$MH_TEST_NOCLEANUP" ]; then
+        rm -rf "$MH_TEST_DIR/panda"
+        for i in 1 2; do
+            rm -rf "$MH_TEST_DIR/profile$i"
+        done
+    fi
+
+    # TODO have cleanup allow additional cleanup registration?
+    #  Or just allow it to accept status as argument.
+    (exit $status)
+    cleanup
+}
+
+covdir="$MH_OBJ_DIR/covdata/${MH_COVERAGE_PROFILE:-default}/$testname"
+install -d "$covdir"
+
+mkdir "$MH_TEST_DIR/panda"
+mkdir "$MH_TEST_DIR/panda/test"
+
+expected="$MH_TEST_DIR/$$.expected"
+actual="$MH_TEST_DIR/$$.actual"
+
+uidvalidity=1503585238
+cat > $MH_TEST_DIR/panda/test/fold <<EOF
+From nmh@example.com Sat Oct 24 19:20:38 2020 -0500
+Date:Sat, 24 Oct 2020 19:20:38 -0500
+from:me
+to:you
+X-IMAPbase: $uidvalidity 1 testseq
+Status: RO
+X-Status: 
+X-Keywords:          
+X-UID: 1
+
+body
+
+EOF
+
+start_fakeinetd env "HOME=$MH_TEST_DIR/panda" "$PANDA_IMAPD"
+imapd_pid=$fakeinetd_pid
+
+start() {
+    rm -f "$MH_TEST_DIR/$profile/imap.d/socket"
+    status=0
+    LLVM_PROFILE_FILE="$covdir/$profile-%m.profraw" "$MH_OBJ_DIR/target/debug/mhimap" imap start > "$actual" 2>&1 || status=$?
+}
+
+# setup profile 2 and 1, leaving 1 as active profile
+for i in 2 1; do
+    setup_test_without_data profile$i
+    trap imap_cleanup 0
+    cat >> "$MH" <<EOF
+Managed-Folders: imap
+imap-manager: $MH_OBJ_DIR/target/debug/imap-folder-manager
+imap-socket: $MH_TEST_DIR/profile$i/imap.d/socket
+imap-db: $MH_TEST_DIR/profile$i/imap.d
+imap-host: 127.0.0.1
+imap-port: $fakeinetd_port
+imap-tls: don't protect my data i know what i'm doing
+imap-auth: PREAUTH
+imap-mailbox-root: test/
+EOF
+    if [ -n "$MH_TEST_IMAP_LOG_LEVEL" ]; then
+        echo "imap-log-level: $MH_TEST_IMAP_LOG_LEVEL" >> "$MH"
+        echo "imap-log-$MH_TEST_IMAP_LOG_LEVEL-to: $MH_TEST_DIR/$profile/imap.d/log.$MH_TEST_IMAP_LOG_LEVEL" >> "$MH"
+    fi
+    start
+done
+
+################################################################################
+start_test '1. inc from profile1'
+
+echo 'Incorporating new mail into imap/fold...' > "$expected.inc"
+echo >> "$expected.inc"
+printf '   1   10/24 me                 <<body >>\n' >> "$expected.inc"
+
+inc +imap -form scan.unseen -width 80 > "$actual"
+check "$actual" "$expected.inc"
+
+################################################################################
+# 2. rebuild mailbox
+uidvalidity=1603585238
+cat > $MH_TEST_DIR/panda/test/fold <<EOF
+From nmh@example.com Sat Oct 25 19:20:38 2020 -0500
+Date:Sat, 25 Oct 2020 19:20:38 -0500
+from:me
+to:you
+X-IMAPbase: $uidvalidity 1 testseq
+Status: RO
+X-Status: 
+X-Keywords:          
+X-UID: 1
+
+body
+
+EOF
+
+################################################################################
+start_test '3. inc after rebuilt mailbox goes BOOM'
+
+# Kill the folder manager so inc will cause a re-SELECT.
+"$MH_OBJ_DIR/target/debug/mhimap" imap shutdown
+
+# TODO plumb this error out to inc
+
+check_exit '-eq 1' inc +imap
+if ! grep -q 'UIDVALIDITY changed' "$MH_TEST_DIR/profile$i/imap.d/stderr"; then
+    cat "$MH_TEST_DIR/profile$i/imap.d/stderr" >&2
+    fail "'UIDVALIDITY changed' not in $MH_TEST_DIR/profile$i/imap.d/stderr"
+fi
+
+finish_test
+exit ${failed:-0}
diff --git a/test/imap/test-xoauth b/test/imap/test-xoauth
new file mode 100755 (executable)
index 0000000..21bda35
--- /dev/null
@@ -0,0 +1,89 @@
+#!/bin/sh
+
+# TODO fold into test-login?
+
+if test -z "$MH_OBJ_DIR"; then
+    srcdir=`dirname "$0"`/../..
+    MH_OBJ_DIR=`cd "$srcdir" && pwd`; export MH_OBJ_DIR
+fi
+
+. "${srcdir}/test/oauth/common.sh"
+
+# common.sh sets this to something crazy; did i do that?!
+testname=${0#$srcdir/}
+
+setup_test_without_data Mail
+
+setup_service
+fake_creds <<EOF
+access-testuser: test-access
+refresh-testuser: test-refresh
+expire-testuser: 2000000000
+EOF
+
+imap_cleanup() {
+    #### Save exit status to use as status for this program.
+    status=$?
+    set +e
+
+    rm -f "$imapd"
+
+    [ -n "$imapd_pid" ] && kill $imapd_pid
+    "$MH_OBJ_DIR/target/debug/mhimap" imap shutdown
+
+    # TODO have cleanup allow additional cleanup registration?
+    #  Or just allow it to accept status as argument.
+    (exit $status)
+    cleanup
+}
+trap imap_cleanup 0
+
+covdir="$MH_OBJ_DIR/covdata/${MH_COVERAGE_PROFILE:-default}/$testname"
+install -d "$covdir"
+export LLVM_PROFILE_FILE="$covdir/$profile-%m.profraw"
+
+imapd="$MH_TEST_DIR/$$.imapd"
+start_fakeinetd "$imapd"
+imapd_pid=$fakeinetd_pid
+
+cat >> "$MH" <<EOF
+Managed-Folders: imap
+imap-manager: $MH_OBJ_DIR/target/debug/imap-folder-manager
+imap-socket: $MH_TEST_DIR/Mail/imap.d/socket
+imap-db: $MH_TEST_DIR/Mail/imap.d
+imap-host: 127.0.0.1
+imap-port: $fakeinetd_port
+imap-tls: don't protect my data i know what i'm doing
+imap-auth: SASL
+imap-user: testuser
+imap-saslmech: xoauth2
+imap-authservice: test
+EOF
+if [ -n "$MH_TEST_IMAP_LOG_LEVEL" ]; then
+    echo "imap-log-level: $MH_TEST_IMAP_LOG_LEVEL" >> "$MH"
+    echo "imap-log-$MH_TEST_IMAP_LOG_LEVEL-to: $MH_TEST_DIR/$profile/imap.d/log.$MH_TEST_IMAP_LOG_LEVEL" >> "$MH"
+fi
+
+# TODO OAUTHBEARER?  oooooh
+# TODO when imlementing capability put this after the greeting
+# * CAPABILITY IMAP4rev1 UNSELECT IDLE NAMESPACE QUOTA ID XLIST CHILDREN X-GM-EXT-1 XYZZY SASL-IR AUTH=XOAUTH2 AUTH=PLAIN AUTH=PLAIN-CLIENTTOKEN AUTH=OAUTHBEARER AUTH=XOAUTH
+# 1 OK
+cat > "$imapd" <<EOSCRIPT
+#!/bin/sh
+cat <<EOF
+* OK Gimap ready for requests\r
+* CAPABILITY IMAP4rev1 UNSELECT IDLE NAMESPACE QUOTA ID XLIST CHILDREN X-GM-EXT-1 XYZZY SASL-IR AUTH=XOAUTH2 AUTH=PLAIN AUTH=PLAIN-CLIENTTOKEN AUTH=OAUTHBEARER AUTH=XOAUTH\r
+1 OK capability response\r
+* CAPABILITY IMAP4rev1 UNSELECT IDLE NAMESPACE QUOTA ID XLIST CHILDREN X-GM-EXT-1 UIDPLUS COMPRESS=DEFLATE ENABLE MOVE CONDSTORE ESEARCH UTF8=ACCEPT LIST-EXTENDED LIST-STATUS LITERAL- SPECIAL-USE APPENDLIMIT=35651584\r
+1 OK authenticate response\r
+1 OK LIST completed\r
+EOF
+while read line; do :; done
+EOSCRIPT
+chmod 755 "$imapd"
+
+# TODO lighter-weight command?  Just trying to show auth worked.
+check_exit '-eq 0' inc +imap
+
+finish_test
+exit ${failed:-0}
diff --git a/test/managed-folders/protocol-tester.c b/test/managed-folders/protocol-tester.c
new file mode 100644 (file)
index 0000000..a1743a1
--- /dev/null
@@ -0,0 +1,116 @@
+#include "h/mh.h"
+
+#include <errno.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include "h/folder_manager_protocol.h"
+#include "sbr/m_getfld.h"
+
+static int
+run_message_len(char const *const *const argv)
+{
+    // goto out to return this status and free these allocations
+    char *msg = NULL;
+    int status = 1;
+
+    errno = 0;
+    size_t const size = strtol(*argv, NULL, 10);
+    if (errno != 0) {
+        perror(*argv);
+        goto out;
+    }
+
+    msg = malloc(size);
+    if (read(0, msg, size) != (ssize_t)size) {
+        fprintf(stderr, "failed to read %ld bytes from stdin\n", size);
+        goto out;
+    }
+
+    status = mh_folder_manager_protocol_write_message(stdout, msg, size) ? 0 : 1;
+
+  out:
+    free(msg);
+    return status;
+}
+
+static int
+run_read_message(char const *const *argv)
+{
+    long size = 0, message_count = 0;
+    for (; *argv != NULL; argv++) {
+        if (strcmp(*argv, "-initial-vector-size") == 0) {
+            if (size > 0) {
+                fputs("1 protocol-tester -initial-vector-size N -message-count N\n", stderr);
+                return 1;
+            }
+            errno = 0;
+            size = strtol(*(++argv), NULL, 10);
+            if (errno != 0) {
+                perror(*argv);
+                return 1;
+            }
+        } else if (strcmp(*argv, "-message-count") == 0) {
+            if (message_count > 0) {
+                fputs("2 protocol-tester -initial-vector-size N -message-count N\n", stderr);
+                return 1;
+            }
+            errno = 0;
+            message_count = strtol(*(++argv), NULL, 10);
+            if (errno != 0) {
+                perror(*argv);
+                return 1;
+            }
+        } else {
+            fprintf(stderr, "what is this %s\n", *argv);
+            fputs("3 protocol-tester -initial-vector-size N -message-count N\n", stderr);
+            return 1;
+        }
+    }
+    if (size < 1 || message_count < 1) {
+        fputs("4 protocol-tester -initial-vector-size N -message-count N\n", stderr);
+        return 1;
+    }
+
+    mh_folder_manager_protocol_read_message_t state;
+    state.getfld_ctx = m_getfld_state_init(stdin);
+
+    svector_t message = NULL;
+    for (int i = 0; i < message_count; i++) {
+        free_svector_and_elements(&message);
+        message = svector_create(size);
+        if (!mh_folder_manager_protocol_read_message(message, &state)) {
+            printf("msg%d size=%zu mh_folder_manager_protocol_read_message failed\n", i, svector_size(message));
+            return 2;
+        }
+        printf("msg%d size=%zu\n", i, svector_size(message));
+        for (size_t j = 0; j < svector_size(message); j++) {
+            char *name = svector_at(message, j++);
+            char *value = svector_at(message, j);
+            printf("%s = %s\n", name, value);
+        }
+    }
+
+    m_getfld_state_destroy(&state.getfld_ctx);
+
+    return 0;
+}
+
+int
+main(int const argc, char const *const *const argv)
+{
+    if (argc < 2) {
+        fputs("too few arguments\n", stderr);
+        return 1;
+    }
+    if (strcmp(argv[1], "message-len") == 0) {
+        return run_message_len(argv + 2);
+    }
+    if (strcmp(argv[1], "read-message") == 0) {
+        return run_read_message(argv + 2);
+    }
+    fprintf(stderr, "unknown imap/protocol-tester command %s\n", argv[1]);
+    return 1;
+}
diff --git a/test/managed-folders/test-message-len b/test/managed-folders/test-message-len
new file mode 100755 (executable)
index 0000000..f0657ad
--- /dev/null
@@ -0,0 +1,318 @@
+#!/bin/sh
+
+if [ -z "$MH_OBJ_DIR" ]; then
+    srcdir=`dirname "$0"`/../..
+    MH_OBJ_DIR=`cd "$srcdir" && pwd`; export MH_OBJ_DIR
+fi
+
+. "$MH_OBJ_DIR/test/common.sh"
+
+setup_test_without_data
+
+expected="$MH_TEST_DIR/$$.expected"
+actual="$MH_TEST_DIR/$$.actual"
+
+################################################################################
+start_test 'ask protocol-tester to read too much'
+
+check_exit '-eq 1' "$MH_OBJ_DIR/test/managed-folders/protocol-tester" message-len 1 < /dev/null
+
+################################################################################
+start_test 'len-only empty message'
+
+echo 'len:6' > "$expected"
+
+"$MH_OBJ_DIR/test/managed-folders/protocol-tester" message-len 0 < /dev/null > "$actual"
+check "$expected" "$actual"
+
+################################################################################
+start_test '7 characters'
+
+cat > "$expected" <<EOF
+len:7
+
+EOF
+
+"$MH_OBJ_DIR/test/managed-folders/protocol-tester" message-len 1 > "$actual" <<EOF
+
+EOF
+check "$expected" "$actual"
+
+################################################################################
+start_test '8 characters'
+
+cat > "$expected" <<EOF
+len:8
+.
+EOF
+
+"$MH_OBJ_DIR/test/managed-folders/protocol-tester" message-len 2 > "$actual" <<EOF
+.
+EOF
+check "$expected" "$actual"
+
+################################################################################
+start_test '9 characters'
+
+cat > "$expected" <<EOF
+len:9
+..
+EOF
+
+"$MH_OBJ_DIR/test/managed-folders/protocol-tester" message-len 3 > "$actual" <<EOF
+..
+EOF
+check "$expected" "$actual"
+
+################################################################################
+start_test '7 characters'
+
+cat > "$expected" <<EOF
+len:11
+...
+EOF
+
+"$MH_OBJ_DIR/test/managed-folders/protocol-tester" message-len 4 > "$actual" <<EOF
+...
+EOF
+check "$expected" "$actual"
+
+################################################################################
+start_test '99 characters'
+
+cat > "$expected" <<EOF
+len:98
+..........................................................................................
+EOF
+
+"$MH_OBJ_DIR/test/managed-folders/protocol-tester" message-len 91 > "$actual" <<EOF
+..........................................................................................
+EOF
+check "$expected" "$actual"
+
+################################################################################
+start_test '100 characters'
+
+cat > "$expected" <<EOF
+len:99
+...........................................................................................
+EOF
+
+"$MH_OBJ_DIR/test/managed-folders/protocol-tester" message-len 92 > "$actual" <<EOF
+...........................................................................................
+EOF
+check "$expected" "$actual"
+
+################################################################################
+start_test '101 characters'
+
+cat > "$expected" <<EOF
+len:101
+............................................................................................
+EOF
+
+"$MH_OBJ_DIR/test/managed-folders/protocol-tester" message-len 93 > "$actual" <<EOF
+............................................................................................
+EOF
+check "$expected" "$actual"
+
+################################################################################
+start_test '102 characters'
+
+cat > "$expected" <<EOF
+len:102
+.............................................................................................
+EOF
+
+"$MH_OBJ_DIR/test/managed-folders/protocol-tester" message-len 94 > "$actual" <<EOF
+.............................................................................................
+EOF
+check "$expected" "$actual"
+
+################################################################################
+start_test '103 characters'
+
+cat > "$expected" <<EOF
+len:103
+..............................................................................................
+EOF
+
+"$MH_OBJ_DIR/test/managed-folders/protocol-tester" message-len 95 > "$actual" <<EOF
+..............................................................................................
+EOF
+check "$expected" "$actual"
+
+################################################################################
+start_test '104 characters'
+
+cat > "$expected" <<EOF
+len:104
+...............................................................................................
+EOF
+
+"$MH_OBJ_DIR/test/managed-folders/protocol-tester" message-len 96 > "$actual" <<EOF
+...............................................................................................
+EOF
+check "$expected" "$actual"
+
+# TODO this is just a bunch of captured messages; don't need to keep all these
+
+cat > $expected <<EOF
+len:26
+op:inc
+folder:imap
+EOF
+$MH_OBJ_DIR/test/managed-folders/protocol-tester message-len 19 > $actual <<EOF
+op:inc
+folder:imap
+EOF
+check $expected $actual
+
+cat > $expected <<EOF
+len:42
+op:pick
+folder:imap/fold2
+msgs:all
+EOF
+$MH_OBJ_DIR/test/managed-folders/protocol-tester message-len 35 > $actual <<EOF
+op:pick
+folder:imap/fold2
+msgs:all
+EOF
+check $expected $actual
+
+cat > $expected <<EOF
+len:55
+op:make-folder
+folder:imap/imap356135249/drafts
+EOF
+$MH_OBJ_DIR/test/managed-folders/protocol-tester message-len 48 > $actual <<EOF
+op:make-folder
+folder:imap/imap356135249/drafts
+EOF
+check $expected $actual
+
+cat > $expected <<EOF
+len:53
+op:make-folder
+folder:imap/imap356135249/sent
+EOF
+$MH_OBJ_DIR/test/managed-folders/protocol-tester message-len 46 > $actual <<EOF
+op:make-folder
+folder:imap/imap356135249/sent
+EOF
+check $expected $actual
+
+cat > $expected <<EOF
+len:40
+op:inc
+folder:imap/imap356135249
+EOF
+$MH_OBJ_DIR/test/managed-folders/protocol-tester message-len 33 > $actual <<EOF
+op:inc
+folder:imap/imap356135249
+EOF
+check $expected $actual
+
+cat > $expected <<EOF
+len:57
+op:pick
+folder:imap/imap356135249/drafts
+msgs:all
+EOF
+$MH_OBJ_DIR/test/managed-folders/protocol-tester message-len 50 > $actual <<EOF
+op:pick
+folder:imap/imap356135249/drafts
+msgs:all
+EOF
+check $expected $actual
+
+cat > $expected <<EOF
+len:55
+op:show
+folder:imap/imap356135249/drafts
+msgs:1
+EOF
+$MH_OBJ_DIR/test/managed-folders/protocol-tester message-len 48 > $actual <<EOF
+op:show
+folder:imap/imap356135249/drafts
+msgs:1
+EOF
+check $expected $actual
+
+cat > $expected <<EOF
+len:55
+op:pick
+folder:imap/imap356135249/sent
+msgs:all
+EOF
+$MH_OBJ_DIR/test/managed-folders/protocol-tester message-len 48 > $actual <<EOF
+op:pick
+folder:imap/imap356135249/sent
+msgs:all
+EOF
+check $expected $actual
+
+cat > $expected <<EOF
+len:40
+op:inc
+folder:imap/imap356135249
+EOF
+$MH_OBJ_DIR/test/managed-folders/protocol-tester message-len 33 > $actual <<EOF
+op:inc
+folder:imap/imap356135249
+EOF
+check $expected $actual
+
+cat > $expected <<EOF
+len:53
+op:show
+folder:imap/imap356135249/sent
+msgs:1
+EOF
+$MH_OBJ_DIR/test/managed-folders/protocol-tester message-len 46 > $actual <<EOF
+op:show
+folder:imap/imap356135249/sent
+msgs:1
+EOF
+check $expected $actual
+
+cat > $expected <<EOF
+len:55
+op:pick
+folder:imap/imap356135249/sent
+msgs:all
+EOF
+$MH_OBJ_DIR/test/managed-folders/protocol-tester message-len 48 > $actual <<EOF
+op:pick
+folder:imap/imap356135249/sent
+msgs:all
+EOF
+check $expected $actual
+
+cat > $expected <<EOF
+len:55
+op:pick
+folder:imap/imap356135249/sent
+msgs:all
+EOF
+$MH_OBJ_DIR/test/managed-folders/protocol-tester message-len 48 > $actual <<EOF
+op:pick
+folder:imap/imap356135249/sent
+msgs:all
+EOF
+check $expected $actual
+
+cat > $expected <<EOF
+len:47
+op:rmf
+folder:imap/imap356135249/drafts
+EOF
+$MH_OBJ_DIR/test/managed-folders/protocol-tester message-len 40 > $actual <<EOF
+op:rmf
+folder:imap/imap356135249/drafts
+EOF
+check $expected $actual
+
+finish_test
+exit ${failed:-0}
diff --git a/test/managed-folders/test-read-message b/test/managed-folders/test-read-message
new file mode 100755 (executable)
index 0000000..627f004
--- /dev/null
@@ -0,0 +1,123 @@
+#!/bin/sh
+
+if [ -z "$MH_OBJ_DIR" ]; then
+    srcdir=`dirname "$0"`/../..
+    MH_OBJ_DIR=`cd "$srcdir" && pwd`; export MH_OBJ_DIR
+fi
+
+. "$MH_OBJ_DIR/test/common.sh"
+
+setup_test_without_data
+
+expected="$MH_TEST_DIR/$$.expected"
+actual="$MH_TEST_DIR/$$.actual"
+
+start_test 'one 32-component message with initial vector size of 1'
+cat > "$expected" <<EOF
+msg0 size=66
+len = 471
+comp1 = value1
+comp2 = value2
+comp3 = value3
+comp4 = value4
+comp5 = value5
+comp6 = value6
+comp7 = value7
+comp8 = value8
+comp9 = value9
+comp10 = value10
+comp11 = value11
+comp12 = value12
+comp13 = value13
+comp14 = value14
+comp15 = value15
+comp16 = value16
+comp17 = value17
+comp18 = value18
+comp19 = value19
+comp20 = value20
+comp21 = value21
+comp22 = value22
+comp23 = value23
+comp24 = value24
+comp25 = value25
+comp26 = value26
+comp27 = value27
+comp28 = value28
+comp29 = value29
+comp30 = value30
+comp31 = value31
+comp32 = value32
+EOF
+"$MH_OBJ_DIR/test/managed-folders/protocol-tester" read-message -initial-vector-size 1 -message-count 1 > "$actual" <<EOF
+len:471
+comp1:value1
+comp2:value2
+comp3:value3
+comp4:value4
+comp5:value5
+comp6:value6
+comp7:value7
+comp8:value8
+comp9:value9
+comp10:value10
+comp11:value11
+comp12:value12
+comp13:value13
+comp14:value14
+comp15:value15
+comp16:value16
+comp17:value17
+comp18:value18
+comp19:value19
+comp20:value20
+comp21:value21
+comp22:value22
+comp23:value23
+comp24:value24
+comp25:value25
+comp26:value26
+comp27:value27
+comp28:value28
+comp29:value29
+comp30:value30
+comp31:value31
+comp32:value32
+EOF
+check "$expected" "$actual"
+
+start_test 'two messages'
+cat > "$expected" <<EOF
+msg0 size=4
+len = 19
+cmp1 = value
+msg1 size=4
+len = 19
+cmp2 = value
+EOF
+"$MH_OBJ_DIR/test/managed-folders/protocol-tester" read-message -initial-vector-size 16 -message-count 2 > "$actual" <<EOF
+len:19
+cmp1:value
+len:19
+cmp2:value
+EOF
+check "$expected" "$actual"
+
+# TODO This is a limitation of using m_getfld2 to parse messages.
+#  Not sure we can fix it as long as we're using m_getfld2.
+#  But reuse of m_getfld2 is the entire reason for the protocol taking this particular form.
+#  The protocol is entirely internal so it doesn't really matter...
+start_test 'two messages with no newline between them does not work'
+cat > "$expected" <<EOF
+we read 24 instead of 18
+msg0 size=4 mh_folder_manager_protocol_read_message failed
+EOF
+("$MH_OBJ_DIR/test/managed-folders/protocol-tester" read-message -initial-vector-size 16 -message-count 2 > "$actual" 2>&1 || :) <<EOF
+len:18
+cmp1:valuelen:19
+cmp2:value
+EOF
+check "$expected" "$actual"
+
+finish_test
+exit $failed
index 342e578..fcc0d7c 100644 (file)
@@ -2,7 +2,8 @@
 
 . "${MH_OBJ_DIR}/test/common.sh"
 
-setup_test
+# TODO move to oauth test scripts and make them call setup_service
+#setup_test
 
 if [ "${OAUTH_SUPPORT}" -eq 0 ]; then
     test_skip 'no oauth support'
@@ -19,7 +20,8 @@ pop_port=${arith_val}
 arith_eval ${pop_port} + 1
 smtp_port=${arith_val}
 
-cat >> ${MH} <<EOF
+setup_service() {
+    cat >> ${MH} <<EOF
 oauth-test-scope: test-scope
 oauth-test-client_id: test-id
 oauth-test-client_secret: test-secret
@@ -27,6 +29,7 @@ oauth-test-auth_endpoint: http://127.0.0.1:${http_port}/oauth/auth
 oauth-test-token_endpoint: http://127.0.0.1:${http_port}/oauth/token
 oauth-test-redirect_uri: urn:ietf:wg:oauth:2.0:oob
 EOF
+}
 
 setup_pop() {
     pop_message=${MHTMPDIR}/testmessage
index 8d5e021..0a2191f 100644 (file)
@@ -57,7 +57,7 @@ serve(const char *pidfn, const char *port)
 
        /*
         * If there is a pid file already around, kill the previously running
-        * fakesmtp process.  Hopefully this will reduce the race conditions
+        * server process.  Hopefully this will reduce the race conditions
         * that crop up when running the test suite.
         */
 
index d14b38a..7a0106d 100644 (file)
--- a/uip/ali.c
+++ b/uip/ali.c
@@ -55,7 +55,7 @@ main (int argc, char **argv)
 {
     int i, vecp = 0;
     bool inverted, list, noalias;
-    char *cp, **ap, **argp, buf[BUFSIZ];
+    char *cp, **argp, buf[BUFSIZ];
     /* Really only need to allocate for argc-1, but must allocate at least 1,
        so go ahead and allocate for argc char pointers. */
     char **vec = mh_xmalloc (argc * sizeof (char *)), **arguments;
@@ -126,7 +126,7 @@ main (int argc, char **argv)
        if ((cp = context_find ("Aliasfile"))) {
            char *dp = NULL;
 
-           for (ap = brkstring(dp = mh_xstrdup(cp), " ", "\n"); ap && *ap; ap++)
+           for (char *const *ap = brkstring(dp = mh_xstrdup(cp), " ", "\n"); ap && *ap; ap++)
                if ((i = alias (*ap)) != AK_OK)
                    die("aliasing error in %s - %s", *ap, akerror (i));
             free(dp);
index c16e89d..345b547 100644 (file)
@@ -17,6 +17,7 @@
 #include "sbr/showfile.h"
 #include "sbr/smatch.h"
 #include "sbr/refile.h"
+#include "sbr/concat.h"
 #include "sbr/cpydata.h"
 #include "sbr/m_draft.h"
 #include "sbr/m_convert.h"
@@ -33,6 +34,7 @@
 #include "h/utils.h"
 #include "h/fmt_scan.h"
 #include "h/done.h"
+#include "h/managed_folders.h"
 #include "sbr/m_maildir.h"
 #include <fcntl.h>
 
@@ -100,15 +102,20 @@ main (int argc, char **argv)
     bool build = false;
     int i, in = NOTOK, isdf = 0, out, dat[5], format_len = 0;
     int outputlinelen = OUTPUTLINELEN;
-    char *cp, *cwd, *maildir, *dfolder = NULL;
+    char *cp, *cwd, *maildir = NULL, *dfolder = NULL;
     char *ed = NULL, *file = NULL, *form = NULL;
     char *folder = NULL, *msg = NULL, buf[BUFSIZ];
     char *to = NULL, *from = NULL, *cc = NULL, *fcc = NULL, *dp;
     char *subject = NULL;
-    char drft[BUFSIZ], **argp, **arguments;
+    char *drft;
+    int drft_msgnum;
+    char draft_fn_buf[BUFSIZ], **argp, **arguments;
     struct msgs *mp = NULL;
     struct format *fmt;
     struct stat st;
+    mh_managed_folders *mmfs = NULL;
+    mh_managed_folder *mmf;
+    mh_folder_manager_client *mfmc = NULL;
 
     if (nmh_init(argv[0], true, true)) { return 1; }
 
@@ -254,12 +261,44 @@ main (int argc, char **argv)
 
     /*
      * Check if we are using a draft folder
-     * and have specified a message in it.
      */
-    if ((dfolder || context_find ("Draft-Folder")) && !folder && msg && !file) {
-       file = msg;
-       msg = NULL;
+    if (dfolder == NULL ) {
+        // TODO m_draft redoes this check; stop it.
+        dfolder = context_find("Draft-Folder");
     }
+    bool draft_folder_is_managed = false;
+    {
+        if (dfolder != NULL) {
+            if (!mh_managed_folders_open(&mmfs)) {
+                die("%s", mh_managed_folders_get_err_string(mmfs));
+            }
+           mmf = mh_managed_folder_lookup(mmfs, dfolder, strlen(dfolder));
+            if (mmf == NULL) {
+                /* and have specified a message in it. */
+                if (!folder && msg && !file) {
+                    file = msg;
+                    msg = NULL;
+                }
+            } else {
+                draft_folder_is_managed = true;
+                if (!nwhat || build || folder || msg || file) {
+                    die("only simple comp supports managed folder");
+                }
+               maildir = m_maildir (dfolder);
+               if (chdir (maildir) == NOTOK)
+                   adios (maildir, "unable to change directory to");
+                if (!mh_folder_manager_connect(&mfmc, mmf)) {
+                    die("%s", mh_managed_folders_get_err_string(mmfs));
+                }
+               drft_msgnum = mh_folder_manager_create_draft(mfmc, dfolder);
+                if (drft_msgnum == 0) {
+                   die("%s", mh_managed_folders_get_err_string(mmfs));
+                }
+               drft = m_name(drft_msgnum);
+            }
+        }
+    }
+
     if (form && (folder || msg))
            die("can't mix forms and folders/msgs");
 
@@ -338,9 +377,12 @@ main (int argc, char **argv)
        }
     }
 
+    if (!draft_folder_is_managed) {
 try_it_again:
-    strncpy (drft, build ? m_maildir ("draft")
+    strncpy (draft_fn_buf, build ? m_maildir ("draft")
                        : m_draft (dfolder, file, use, &isdf), sizeof(drft) - 1);
+    drft = draft_fn_buf;
+    }
 
     /*
      * Check if we have an existing draft
@@ -361,10 +403,10 @@ try_it_again:
            adios (drft, "unable to stat");
        printf ("Draft \"%s\" exists (%ld bytes).", drft, (long) st.st_size);
        for (i = LISTDSW; i != YESW;) {
-           if (!(argp = read_switch_multiword ("\nDisposition? ",
-                                               isdf ? aqrunl : aqrul)))
+            char *const *words = read_switch_multiword ("\nDisposition? ", isdf ? aqrunl : aqrul);
+           if (words == NULL)
                done (1);
-           switch (i = smatch (*argp, isdf ? aqrunl : aqrul)) {
+           switch (i = smatch (*words, isdf ? aqrunl : aqrul)) {
                case NOSW: 
                    done (0);
                case NEWSW: 
@@ -377,10 +419,10 @@ try_it_again:
                    use++;
                    goto edit_it;
                case LISTDSW: 
-                   showfile (++argp, drft);
+                   showfile (++words, drft);
                    break;
                case REFILSW: 
-                   if (refile (++argp, drft) == 0)
+                   if (refile (++words, drft) == 0)
                        i = YESW;
                    break;
                default: 
@@ -420,8 +462,21 @@ try_it_again:
 edit_it:
     context_save ();   /* save the context file */
 
-    if (nwhat)
+    if (nwhat) {
+        if (draft_folder_is_managed) {
+                if (!mh_folder_manager_connect(&mfmc, mmf)) {
+                    die("%s", mh_managed_folders_get_err_string(mmfs));
+                }
+        if (!mh_folder_manager_update_draft(
+                mfmc,
+                dfolder,
+                drft_msgnum)) {
+                    die("in trouble updating draft %s", mh_managed_folders_get_err_string(mmfs));
+        }
+        }
        done (0);
+    }
+
     what_now (ed, nedit, use, drft, NULL, 0, NULL, NULL, 0, cwd, 0);
     done (1);
     return 1;
index 1c2b596..becffb8 100644 (file)
@@ -255,12 +255,13 @@ main (int argc, char **argv)
 try_it_again:
     strncpy (drft, m_draft (dfolder, dmsg, NOUSE, &isdf), sizeof(drft) - 1);
 
+    // TODO(epg): Duplicated from comp.
     /* Check if draft already exists */
     if (stat (drft, &st) != NOTOK) {
        printf ("Draft \"%s\" exists (%ld bytes).", drft, (long) st.st_size);
        for (i = LISTDSW; i != YESW;) {
-           if (!(argp = read_switch_multiword ("\nDisposition? ",
-                                               isdf ? aqrnl : aqrl)))
+            char *const *const words = read_switch_multiword ("\nDisposition? ", isdf ? aqrnl : aqrl);
+           if (words == NULL)
                done (1);
            switch (i = smatch (*argp, isdf ? aqrnl : aqrl)) {
                case NOSW: 
index 17dd672..b1d6c3e 100644 (file)
@@ -245,11 +245,10 @@ main(int argc, char **argv)
      * all the sequences defined there.
      */
     if (svector_size (sequencesToDo) == 0) {
+        // TODO(epg): How many times is this duplicated?
        if ((cp = context_find(usequence)) && *cp) {
-           char **ap, *dp;
-
-           dp = mh_xstrdup(cp);
-           ap = brkstring (dp, " ", "\n");
+           char *const dp = mh_xstrdup(cp);
+           char *const *ap = brkstring (dp, " ", "\n");
            for (; ap && *ap; ap++)
                svector_push_back (sequencesToDo, *ap);
        } else {
index 50804c9..aaefb9e 100644 (file)
@@ -550,10 +550,8 @@ process_messages(struct format *fmt, struct msgs_array *comps,
 
     if (dat[4] == -1) {
        if ((cp = context_find(usequence)) && *cp) {
-           char **ap, *dp;
-
-           dp = mh_xstrdup(cp);
-           ap = brkstring(dp, " ", "\n");
+           char *const dp = mh_xstrdup(cp);
+           char *const *ap = brkstring(dp, " ", "\n");
            for (i = 0; ap && *ap; i++, ap++)
                ivector_push_back (seqnum, seq_getnum(mp, *ap));
                
index 6706ef8..e2bf3b7 100644 (file)
@@ -8,6 +8,10 @@
  */
 
 #include "h/mh.h"
+
+#include <string.h>
+
+#include "h/managed_folders.h"
 #include "sbr/getarguments.h"
 #include "sbr/concat.h"
 #include "sbr/seq_setprev.h"
@@ -135,7 +139,7 @@ main (int argc, char **argv)
     bool pushsw = false;
     bool popsw = false;
     char *cp, *dp, *msg = NULL, *argfolder = NULL;
-    char **ap, **argp, buf[BUFSIZ], **arguments;
+    char *const *ap, **argp, buf[BUFSIZ], **arguments;
 
     if (nmh_init(argv[0], true, true)) { return 1; }
 
@@ -379,6 +383,11 @@ main (int argc, char **argv)
                crawl_folders (folder, get_folder_info_callback, NULL);
        }
     } else {
+        mh_managed_folders *mmfs;
+        if (!mh_managed_folders_open(&mmfs)) {
+            die("%s", mh_managed_folders_get_err_string(mmfs));
+        }
+
        strncpy (folder, argfolder ? argfolder : getfolder (1),
                 sizeof(folder) - 1);
 
@@ -386,7 +395,19 @@ main (int argc, char **argv)
         * Check if folder exists.  If not, then see if
         * we should create it, or just exit.
         */
-        create_folder (m_maildir (folder), fcreat, nonexistent_folder);
+
+        mh_folder_manager_client *mfmc = NULL;
+       mh_managed_folder *const mmf = mh_managed_folder_lookup(mmfs, folder, strlen(folder));
+        if (mmf == NULL || !fcreat) {
+            create_folder (m_maildir (folder), fcreat, nonexistent_folder);
+        } else {
+            if (!mh_folder_manager_connect(&mfmc, mmf)) {
+                die("%s", mh_managed_folders_get_err_string(mmfs));
+            }
+            if (!mh_folder_manager_make_folder(mfmc, folder)) {
+                die("%s", mh_managed_folders_get_err_string(mmfs));
+            }
+        }
 
        if (get_folder_info (folder, msg) && argfolder) {
            /* update current folder */
index 530e61c..9af71e7 100644 (file)
@@ -341,12 +341,13 @@ try_it_again:
     strncpy (drft, buildsw ? m_maildir ("draft")
                  : m_draft (dfolder, NULL, NOUSE, &isdf), sizeof(drft) - 1);
 
+    // TODO(epg): Duplicated from comp.
     /* Check if a draft already exists */
     if (!buildsw && stat (drft, &st) != NOTOK) {
        printf ("Draft \"%s\" exists (%ld bytes).", drft, (long) st.st_size);
        for (i = LISTDSW; i != YESW;) {
-           if (!(argp = read_switch_multiword ("\nDisposition? ",
-                                               isdf ? aqrnl : aqrl)))
+            char *const *const words = read_switch_multiword ("\nDisposition? ", isdf ? aqrnl : aqrl);
+           if (words == NULL)
                done (1);
            switch (i = smatch (*argp, isdf ? aqrnl : aqrl)) {
                case NOSW: 
index 9ad2234..a631367 100644 (file)
@@ -490,11 +490,10 @@ static void
 parse_capability(const char *cap, unsigned int len)
 {
     char *str = mh_xmalloc(len + 1);
-    char **caplist;
     int i;
 
     trunccpy(str, cap, len + 1);
-    caplist = brkstring(str, " ", NULL);
+    char *const *const caplist = brkstring(str, " ", NULL);
 
     if (imap_capabilities) {
        svector_free(imap_capabilities);
index 7ebb058..802da32 100644 (file)
--- a/uip/inc.c
+++ b/uip/inc.c
@@ -31,6 +31,9 @@
 #endif
 
 #include "h/mh.h"
+
+#include <stdio.h>
+
 #include "sbr/fmt_new.h"
 #include "sbr/dtime.h"
 #include "scansbr.h"
@@ -60,6 +63,7 @@
 #include "h/dropsbr.h"
 #include "popsbr.h"
 #include "h/fmt_scan.h"
+#include "h/managed_folders.h"
 #include "h/signals.h"
 #include "h/tws.h"
 #include "h/mts.h"
@@ -116,8 +120,11 @@ DEFINE_SWITCH_ARRAY(INC, switches);
 /*
  * flags for the mail source
  */
-#define INC_FILE  0
-#define INC_POP   1
+typedef enum IncType {
+    INC_FILE,
+    INC_POP,
+    INC_MANAGED_FOLDER,
+} IncType;
 
 static struct Maildir_entry *Maildir;
 static int num_maildir_entries;
@@ -185,27 +192,23 @@ static int pop_action(void *closure, char *);
 int
 main (int argc, char **argv)
 {
-    static int inc_type;
+    IncType inc_type;
     bool chgflag;
     int trnflag = 1;
     bool noisy;
     int width = -1;
     int hghnum = 0, msgnum = 0;
-    FILE *pf = NULL;
     bool sasl, noverify;
     int tls = 0;
-    int incerr = 0; /* <0 if inc hits an error which means it should not truncate mailspool */
-    char *cp, *maildir = NULL, *folder = NULL;
+    char *cp, *folder = NULL;
     char *format = NULL, *form = NULL;
     char *host = NULL, *port = NULL, *user = NULL, *proxy = NULL;
     char *audfile = NULL, *from = NULL, *saslmech = NULL, *auth_svc = NULL;
-    char buf[BUFSIZ], **argp, *nfs, **arguments;
-    struct msgs *mp = NULL;
-    struct stat st, s1;
-    FILE *aud = NULL;
-    char b[PATH_MAX + 1];
-    char *maildir_copy = NULL; /* copy of mail directory because the static gets overwritten */
-    charstring_t scanl = NULL;
+    char buf[BUFSIZ], **argp, **arguments;
+    struct stat s1;
+    mh_managed_folders *mmfs;
+    mh_folder_manager_client *mfmc;
+    mh_folder_manager_inc_context *mfinc;
 
     int nmsgs, nbytes;
     char *MAILHOST_env_variable;
@@ -409,8 +412,19 @@ main (int argc, char **argv)
     /* guarantee dropping group privileges; we might not have done so earlier */
     DROPGROUPPRIVS();
 
-    /* Source of mail;  -from overrides any -host. */
-    inc_type = host && !from ? INC_POP : INC_FILE;
+    if (!mh_managed_folders_open(&mmfs)) {
+            die("%s", mh_managed_folders_get_err_string(mmfs));
+    }
+    mh_managed_folder *const mmf = mh_managed_folder_lookup(mmfs, folder, strlen(folder));
+    if (mmf == NULL) {
+        /* Source of mail;  -from overrides any -host. */
+        inc_type = host && !from ? INC_POP : INC_FILE;
+    } else {
+        inc_type = INC_MANAGED_FOLDER;
+        if (!mh_folder_manager_connect(&mfmc, mmf)) {
+            die("%s", mh_managed_folders_get_err_string(mmfs));
+        }
+    }
 
     if (inc_type == INC_POP) {
         /* Mail from a POP server. */
@@ -474,10 +488,33 @@ main (int argc, char **argv)
     if (!context_find ("path"))
        free (path ("./", TFOLDER));
     if (!folder)
-       folder = getfolder (0);
-    maildir = m_maildir (folder);
-    maildir_copy = mh_xstrdup(maildir);
+       folder = mh_xstrdup (getfolder (0));
+
+    // TODO the whole inc loop needs work; this is the least of the problems
+    int hack_loop_count = 0;
+    if (inc_type == INC_MANAGED_FOLDER) {
+        mfinc = mh_folder_manager_inc(mfmc, folder);
+        if (mfinc == NULL) {
+            die("%s", mh_managed_folders_get_err_string(mmfs));
+        }
+    }
 
+    /* We loop for INC_MANAGED_FOLDER only. */
+    for (;;) {
+        FILE *pf = NULL;
+        int incerr = 0; /* <0 if inc hits an error which means it should not truncate mailspool */
+        char *maildir = m_maildir (folder);
+        // TODO do we really need both?
+        /* copy of mail directory because the static gets overwritten */
+        char *maildir_copy = mh_xstrdup(maildir);
+        char *nfs;
+        struct msgs *mp = NULL;
+        FILE *aud = NULL;
+        char b[PATH_MAX + 1];
+        charstring_t scanl = NULL;
+
+
+    if (inc_type != INC_MANAGED_FOLDER) {
     if (!folder_exists(maildir)) {
         /* If the folder doesn't exist, and we're given the -silent flag,
          * just fail.
@@ -494,6 +531,7 @@ main (int argc, char **argv)
     /* read folder and create message structure */
     if (!(mp = folder_read (folder, 0)))
        die("unable to read folder %s", folder);
+    } /* inc_type != INC_MANAGED_FOLDER */
 
     if (inc_type == INC_FILE && Maildir == NULL) {
         /* Mail from a spool file. */
@@ -525,6 +563,7 @@ main (int argc, char **argv)
 
     if (audfile) {
        int i;
+       struct stat st;
        if ((i = stat (audfile, &st)) == NOTOK)
            inform("Creating Receive-Audit: %s", audfile);
        if ((aud = fopen (audfile, "a")) == NULL)
@@ -546,7 +585,7 @@ main (int argc, char **argv)
     /* Get new format string */
     nfs = new_fs (form, format, FORMAT);
 
-    if (noisy) {
+    if (inc_type != INC_MANAGED_FOLDER && noisy) {
        printf ("Incorporating new mail into %s...\n\n", folder);
        fflush (stdout);
     }
@@ -627,7 +666,10 @@ main (int argc, char **argv)
        if (pop_quit () == NOTOK)
            die("%s", response);
 
-    } else if (inc_type == INC_FILE && Maildir == NULL) {
+        /* Only loop for INC_MANAGED_FOLDER. */
+        break;
+    } else if (inc_type == INC_FILE) {
+      if (Maildir == NULL) {
         /* Mail from a spool file. */
 
        scan_detect_mbox_style (in);            /* the MAGIC invocation... */
@@ -772,9 +814,92 @@ main (int argc, char **argv)
        charstring_free (scanl);
        scanl = NULL;
     }
+        /* Only loop for INC_MANAGED_FOLDER. */
+        break;
+    } else {
+        const mh_managed_folder_inc_page *const page = mh_folder_manager_inc_next(mfinc);
+        if (page == NULL) {
+           die("%s", mh_managed_folders_get_err_string(mmfs));
+        }
+        if (page->done) {
+            break;
+        }
+
+       // TODO this is the problem.  I wrote this for this case:
+       //    inc +imap
+       //      Incorporating new mail into imap/child1...
+       //      Incorporating new mail into imap/child2...
+       //  not for this case:
+       //    inc +imap/child1
+       //      Incorporating new mail into imap/child1...
+       //  Because here I thought I was comparing 'imap' to 'imap/child1' on
+       //  the first iteration, but in this case we are comparing
+       //  'imap/child1' to 'imap/child1' on the first iteration!  I think
+       //  the mistake was in reusing the 'folder' variable
+        if (++hack_loop_count == 1 || strcmp(page->folder, folder) != 0) {
+            free (folder);
+            folder = mh_xstrdup (page->folder);
+            printf ("Incorporating new mail into %s...\n\n", folder);
+            fflush (stdout);
+            maildir = m_maildir(folder);
+            free(maildir_copy);
+            maildir_copy = mh_xstrdup(maildir);
+            if (chdir (maildir) == NOTOK) {
+                adios(maildir, "unable to change directory to");
+            }
+        }
+
+        {
+            long TODO_FILE_SIZE = 32;
+
+            msgnum = page->msg;
+
+            cp = mh_xstrdup(m_name(msgnum));
+            if ((pf = fopen (cp, "r")) == NULL)
+                adios (cp, "unable to scan");
+           switch (incerr = scan (pf, msgnum, 0, nfs, width, 0,
+                             page->unseen, NULL, TODO_FILE_SIZE, noisy, &scanl)) {
+           case SCNEOF:
+               printf ("%*d  empty\n", DMAXFOLDER, msgnum);
+               break;
+
+           case SCNFAT:
+               trnflag = 0;
+               noisy = true;
+               /* advise (cp, "unable to read"); already advised */
+               break;
+
+           case SCNERR:
+           case SCNNUM:
+               break;
+
+           case SCNMSG:
+           default:
+               if (aud)
+                   fputs (charstring_buffer (scanl), aud);
+               if (noisy)
+                   fflush (stdout);
+               break;
+           }
+
+           if (scanl)
+               charstring_clear (scanl);
+
+            if (ferror(pf) || fclose (pf)) {
+                perror("TODO error during scan... do we really care?");
+            }
+            free (cp);
+
+           scan_finished();
+       }
+
+       charstring_free (scanl);
+       scanl = NULL;
+    }
 
     scan_finished ();
 
+    if (inc_type != INC_MANAGED_FOLDER) {
     if (incerr < 0) {          /* error */
        if (locked) {
             GETGROUPPRIVS();      /* Be sure we can unlock mail file */
@@ -785,6 +910,7 @@ main (int argc, char **argv)
        }
        die("failed");
     }
+    }
 
     if (aud)
        fclose (aud);
@@ -792,10 +918,13 @@ main (int argc, char **argv)
     if (noisy)
        fflush (stdout);
 
+    } /* loop for INC_MANAGED_FOLDER only */
+
     if (inc_type == INC_FILE && Maildir == NULL) {
         /* Mail from a spool file;  truncate it. */
 
        if (trnflag) {
+            struct stat st;
            if (stat (newmail, &st) != NOTOK && s1.st_mtime != st.st_mtime)
                inform("new messages have arrived!\007");
            else {
@@ -811,6 +940,7 @@ main (int argc, char **argv)
        }
     }
 
+    if (inc_type != INC_MANAGED_FOLDER) {
     if (msgnum == hghnum) {
        inform("no messages incorporated, continuing...");
     } else {
@@ -858,6 +988,7 @@ main (int argc, char **argv)
        seq_save(mp2);          /* Save the sequence file */
        folder_free(mp2);
     }
+    }
 
 skip:
     if (inc_type == INC_FILE && Maildir == NULL) {
index 72328ba..a5c79cf 100644 (file)
@@ -8,6 +8,7 @@
  */
 
 #include "h/mh.h"
+#include "h/managed_folders.h"
 #include "sbr/getarguments.h"
 #include "sbr/seq_save.h"
 #include "sbr/smatch.h"
@@ -172,6 +173,33 @@ main (int argc, char **argv)
        app_msgarg(&msgs, listsw ? "all" :"cur");
     if (!folder)
        folder = getfolder (1);
+
+    {
+        mh_managed_folders *mmfs;
+        if (!mh_managed_folders_open(&mmfs))
+            die("%s", mh_managed_folders_get_err_string(mmfs));
+       mh_managed_folder *const mmf = mh_managed_folder_lookup(mmfs, folder, strlen(folder));
+        if (mmf != NULL) {
+            if (listsw || publicsw > 0 || zerosw)
+                die("unsupported switches for mark on managed folder");
+            mh_folder_manager_client *mfmc;
+            if (!mh_folder_manager_connect(&mfmc, mmf))
+                die("%s", mh_managed_folders_get_err_string(mmfs));
+            if (addsw) {
+                if (!mh_folder_manager_mark_add(mfmc, folder, msgs, seqs))
+                   die("%s", mh_managed_folders_get_err_string(mmfs));
+            }
+            if (deletesw) {
+                if (!mh_folder_manager_mark_del(mfmc, folder, msgs, seqs))
+                   die("%s", mh_managed_folders_get_err_string(mmfs));
+            }
+            if (!mh_folder_manager_close(mfmc))
+                die("%s", mh_managed_folders_get_err_string(mmfs));
+            done (0);
+            return 1;
+        }
+    }
+
     maildir = m_maildir (folder);
 
     if (chdir (maildir) == NOTOK)
index 6795025..6670e71 100644 (file)
@@ -674,7 +674,7 @@ static int
 user_content (FILE *in, char *buf, CT *ctp, const char *infilename)
 {
     int        extrnal, vrsn;
-    char *cp, **ap;
+    char *cp, *const *ap;
     char buffer[NMH_BUFSIZ];
     struct multipart *m;
     struct part **pp;
index 5e6d161..9a42760 100644 (file)
 #include <string.h>
 
 #include "h/mh.h"
-#include "sbr/getarguments.h"
-#include "sbr/concat.h"
-#include "sbr/smatch.h"
-#include "sbr/ambigsw.h"
-#include "sbr/print_version.h"
-#include "sbr/print_help.h"
-#include "sbr/error.h"
+
 #include "h/done.h"
-#include "h/utils.h"
 #include "h/oauth.h"
+#include "h/utils.h"
+#include "sbr/ambigsw.h"
+#include "sbr/base64.h"
+#include "sbr/concat.h"
+#include "sbr/error.h"
+#include "sbr/getarguments.h"
 #include "sbr/lock_file.h"
+#include "sbr/print_help.h"
+#include "sbr/print_version.h"
+#include "sbr/smatch.h"
 
 #define MHLOGIN_SWITCHES \
-    X("user username", 0, USERSW) \
-    X("saslmech", 0, SASLMECHSW) \
     X("authservice", 0, AUTHSERVICESW) \
     X("browser", 0, BROWSERSW) \
-    X("snoop", 0, SNOOPSW) \
     X("help", 0, HELPSW) \
+    X("print-client-response", 0, PRINT_CLIENT_RES_SW) \
+    X("saslmech", 0, SASLMECHSW) \
+    X("snoop", 0, SNOOPSW) \
+    X("user username", 0, USERSW) \
     X("version", 0, VERSIONSW) \
 
 #define X(sw, minchars, id) id,
@@ -141,11 +144,25 @@ do_login(const char *svc, const char *user, const char *browser, int snoop)
 
     return 0;
 }
+
+static int
+do_print_client_response(const char *svc, const char *user, const int snoop)
+{
+    unsigned char *oauth_res, *b64data;
+    size_t oauth_res_len;
+    mh_oauth_do_xoauth(user, svc, &oauth_res, &oauth_res_len, snoop ? stderr : NULL);
+    fwrite(oauth_res, 1, oauth_res_len, stdout);
+    b64data = mh_xmalloc(BASE64SIZE(oauth_res_len));
+    writeBase64raw(oauth_res, oauth_res_len, b64data);
+    printf("\n%s\n", b64data);
+    return 0;
+}
 #endif
 
 int
 main(int argc, char **argv)
 {
+    bool print_client_response = false;
     char *cp, **argp, **arguments;
     const char *user = NULL, *saslmech = NULL, *svc = NULL, *browser = NULL;
     int snoop = 0;
@@ -194,6 +211,9 @@ main(int argc, char **argv)
                     die("missing argument to %s", argp[-2]);
                 continue;
 
+            case PRINT_CLIENT_RES_SW:
+                print_client_response = true;
+                continue;
             case SNOOPSW:
                 snoop++;
                 continue;
@@ -209,6 +229,9 @@ main(int argc, char **argv)
     free(arguments);
 
 #ifdef OAUTH_SUPPORT
+    if (print_client_response)
+        return do_print_client_response(svc, user, snoop);
+
     return do_login(svc, user, browser, snoop);
 #else
     NMH_UNUSED(svc);
index ade7e1c..1e78f2f 100644 (file)
@@ -620,14 +620,13 @@ mhl_format (char *file, int length, int width)
                 * it to the end of the current "ignores" list.
                 */
                if (!strcasecmp (name, "ignores")) {
-                   char **tmparray, **p;
                    int n = 0;
 
                    /* split the fields */
-                   tmparray = brkstring (mh_xstrdup(++parptr), ",", NULL);
+                   char *const *const tmparray = brkstring (mh_xstrdup(++parptr), ",", NULL);
 
                    /* count number of fields split */
-                   p = tmparray;
+                   char *const *p = tmparray;
                    while (*p++)
                        n++;
 
index 64b69bc..1362519 100644 (file)
@@ -1012,7 +1012,7 @@ clobber_check (char *original_file, mhstoreinfo_t info)
             { "no", 0, NMH_NO },
             { "rename", 0, NMH_RENAME },
             { NULL, 0, 0 } };
-          char **ans;
+          char *const *ans;
 
           if (isatty (fileno (stdin))) {
             char *prompt =
index 8e61e77..0dee3c3 100644 (file)
--- a/uip/new.c
+++ b/uip/new.c
@@ -73,13 +73,13 @@ count_messages(char *field)
 {
     int total = 0;
     int j, k;
-    char *cp, **ap;
 
     field = getcpy(field);
 
     /* copied from seq_read.c:seq_init */
-    for (ap = brkstring (field, " ", "\n"); *ap; ap++) {
-        if ((cp = strchr(*ap, '-')))
+    for (char *const *ap = brkstring (field, " ", "\n"); *ap; ap++) {
+        char *cp = strchr(*ap, '-');
+        if (cp != NULL)
             *cp++ = '\0';
         if ((j = m_atoi (*ap)) > 0) {
             k = cp ? m_atoi (cp) : j;
@@ -453,7 +453,7 @@ doit(char *cur, char *folders, char *sequences[])
 int
 main(int argc, char **argv)
 {
-    char **ap, *cp, **argp, **arguments;
+    char *cp, **argp, **arguments;
     char help[BUFSIZ];
     char *folders = NULL;
     svector_t sequences = svector_create (0);
@@ -527,7 +527,7 @@ main(int argc, char **argv)
        if (unseen == NULL || unseen[0] == '\0') {
            die("must specify sequences or set %s", usequence);
        }
-       for (ap = brkstring(unseen, " ", "\n"); *ap; ap++) {
+       for (char *const *ap = brkstring(unseen, " ", "\n"); *ap; ap++) {
            svector_push_back (sequences, *ap);
            ++i;
        }
index ab4cf9e..0bb9ac0 100644 (file)
@@ -32,6 +32,7 @@
 #include "sbr/arglist.h"
 #include "sbr/error.h"
 #include "h/done.h"
+#include "h/managed_folders.h"
 #include "h/utils.h"
 #include "sbr/m_maildir.h"
 #include "sbr/m_mktemp.h"
@@ -82,11 +83,12 @@ static void copy_seqs (struct msgs *, int, struct msgs *, int);
 int
 main (int argc, char **argv)
 {
+    bool draft = false;
     bool linkf = false;
     bool preserve = false;
     bool retainseqs = false;
     int filep = 0;
-    int foldp = 0, isdf = 0;
+    int foldp = 0;
     bool unlink_msgs = false;
     int i, msgnum;
     char *cp, *folder = NULL, buf[BUFSIZ];
@@ -159,12 +161,14 @@ main (int argc, char **argv)
                folder = path (*cp == '+' || *cp == '@' ? cp + 1 : cp,
                               *cp != '@' ? TFOLDER : TSUBCWF);
                continue;
-           case DRAFTSW:
+           case DRAFTSW: {
                if (filep > NFOLDERS)
                    die("only %d files allowed!", NFOLDERS);
-               isdf = 0;
+                draft = true;
+               int isdf = 0;
                files[filep++] = mh_xstrdup(m_draft(NULL, NULL, 1, &isdf));
                continue;
+            }
            case FILESW:
                if (filep > NFOLDERS)
                    die("only %d files allowed!", NFOLDERS);
@@ -196,6 +200,47 @@ main (int argc, char **argv)
     if (foldp == 0)
        die("no folder specified");
 
+    mh_managed_folders *mmfs;
+    if (!mh_managed_folders_open(&mmfs))
+        die("%s", mh_managed_folders_get_err_string(mmfs));
+    mh_managed_folder **const mmf = mh_xmalloc(foldp * sizeof *mmf);
+    *mmf = mh_managed_folder_lookup(mmfs, folders[0].f_name, strlen(folders[0].f_name));
+    if (*mmf != NULL) {
+        if (foldp > 1)
+            die("TODO multiple destination folders not yet supported");
+        if (draft || retainseqs)
+            die("TODO flags not yet supported");
+        if (preserve)
+            die("-preserve not supported by managed folder");
+        if (unlink_msgs)
+            die("-unlink not supported by managed folder");
+        mh_folder_manager_client *mfmc;
+        if (filep > 0) {
+            if (filep > 1)
+                die("TODO multiple files not yet supported");
+            if (!mh_folder_manager_connect(&mfmc, *mmf))
+                die("%s", mh_managed_folders_get_err_string(mmfs));
+            if (!mh_folder_manager_refile_file(mfmc, files[0], folders[0].f_name))
+                die("%s", mh_managed_folders_get_err_string(mmfs));
+            /* If -nolink, then "remove" files */
+            if (!linkf)
+                remove_files (filep, filevec);
+        } else {
+            if (linkf)
+                die("TODO -link not yet supported for folder-to-folder refile");
+            if (mh_managed_folder_lookup(mmfs, folder, strlen(folder)) != *mmf)
+                die("TODO refile only supported on one folder manager for now");
+            if (!mh_folder_manager_connect(&mfmc, *mmf))
+                die("%s", mh_managed_folders_get_err_string(mmfs));
+            if (!mh_folder_manager_refile_move(mfmc, folder, msgs, folders[0].f_name))
+                die("%s", mh_managed_folders_get_err_string(mmfs));
+        }
+        if (!mh_folder_manager_close(mfmc))
+            die("TODO mh_folder_manager_close");
+        done (0);
+        return 1;
+    }
+
     /*
      * We are refiling a file to the folders
      */
@@ -216,6 +261,7 @@ main (int argc, char **argv)
        app_msgarg(&msgs, "cur");
     if (!folder)
        folder = getfolder (1);
+
     strncpy (maildir, m_maildir (folder), sizeof(maildir) - 1);
 
     if (chdir (maildir) == NOTOK)
index 0563c0a..36cffaf 100644 (file)
@@ -385,14 +385,15 @@ try_it_again:
     strncpy (drft, buildsw ? m_maildir ("reply")
              : m_draft (dfolder, NULL, NOUSE, &isdf), sizeof(drft) - 1);
 
+    // TODO(epg): Duplicated from comp.
     /* Check if a draft exists */
     if (!buildsw && stat (drft, &st) != NOTOK) {
        printf ("Draft \"%s\" exists (%ld bytes).", drft, (long) st.st_size);
        for (i = LISTDSW; i != YESW;) {
-           if (!(argp = read_switch_multiword ("\nDisposition? ",
-                                               isdf ? aqrnl : aqrl)))
+            char *const *words = read_switch_multiword ("\nDisposition? ", isdf ? aqrnl : aqrl);
+           if (words == NULL)
                done (1);
-           switch (i = smatch (*argp, isdf ? aqrnl : aqrl)) {
+           switch (i = smatch (*words, isdf ? aqrnl : aqrl)) {
                case NOSW: 
                    done (0);
                case NEWSW: 
@@ -401,10 +402,10 @@ try_it_again:
                case YESW: 
                    break;
                case LISTDSW: 
-                   showfile (++argp, drft);
+                   showfile (++words, drft);
                    break;
                case REFILSW: 
-                   if (refile (++argp, drft) == 0)
+                   if (refile (++words, drft) == 0)
                        i = YESW;
                    break;
                default: 
index ee4abae..dda77a8 100644 (file)
--- a/uip/rmf.c
+++ b/uip/rmf.c
@@ -6,6 +6,7 @@
  */
 
 #include "h/mh.h"
+#include "h/managed_folders.h"
 #include "sbr/getarguments.h"
 #include "sbr/read_yes_or_no_if_tty.h"
 #include "sbr/concat.h"
@@ -49,6 +50,40 @@ DEFINE_SWITCH_ARRAY(RMF, switches);
 static int rmf(char *);
 static void rma (char *);
 
+static int
+managed_rmf (mh_managed_folder *const mmf, char *const folder)
+{
+    char *const maildir = m_maildir (folder);
+
+    /* chdir is not required for managed folders but may be expected
+     * by external delete hook. */
+    if (chdir (maildir) < 0)
+       adios (maildir, "chdir");
+
+    mh_folder_manager_client *mfmc;
+    if (!mh_folder_manager_connect(&mfmc, mmf))
+        die("TODO: mh_folder_manager_connect");
+
+    /* Run the external delete hook program. */
+    (void)ext_hook("del-hook", maildir, NULL);
+
+    if (!mh_folder_manager_rmf(mfmc, folder))
+        die("TODO mh_folder_manager_rmf");
+    if (!mh_folder_manager_close(mfmc))
+        die("TODO mh_folder_manager_close");
+
+    /*
+     * Remove any relevant private sequences
+     * or attributes from context file.
+     */
+    rma (folder);
+
+    if (chdir ("..") < 0) {
+       advise ("..", "chdir");
+    }
+
+    return OK;
+}
 
 int
 main (int argc, char **argv)
@@ -131,7 +166,13 @@ main (int argc, char **argv)
        free (cp);
     }
 
-    if (rmf (folder) == OK) {
+    mh_managed_folders *mmfs;
+    if (!mh_managed_folders_open(&mmfs))
+        die("%s", mh_managed_folders_get_err_string(mmfs));
+    mh_managed_folder *const mmf = mh_managed_folder_lookup(mmfs, folder, strlen(folder));
+
+    const int status = mmf == NULL ? rmf (folder) : managed_rmf (mmf, folder);
+    if (status == OK) {
        char *cfolder = context_find(pfolder);
        if (cfolder && strcmp (cfolder, newfolder)) {
            printf ("[+%s now current]\n", newfolder);
index df28926..2f383f4 100644 (file)
--- a/uip/rmm.c
+++ b/uip/rmm.c
@@ -22,6 +22,7 @@
 #include "sbr/print_help.h"
 #include "sbr/error.h"
 #include "h/done.h"
+#include "h/managed_folders.h"
 #include "h/utils.h"
 #include "sbr/m_maildir.h"
 
@@ -107,6 +108,25 @@ main (int argc, char **argv)
        app_msgarg(&msgs, "cur");
     if (!folder)
        folder = getfolder (1);
+
+    mh_managed_folders *mmfs;
+    if (!mh_managed_folders_open(&mmfs))
+        die("%s", mh_managed_folders_get_err_string(mmfs));
+    mh_managed_folder *const mmf = mh_managed_folder_lookup(mmfs, folder, strlen(folder));
+    if (mmf != NULL) {
+        if (!unlink_msgs)
+            die("%s", "managed folder only implemented for -unlink currently");
+        if (rmmproc != NULL)
+            die("%s", "managed folder only implemented without rmmproc currently");
+        mh_folder_manager_client *mfmc;
+        if (!mh_folder_manager_connect(&mfmc, mmf))
+            die("%s", mh_managed_folders_get_err_string(mmfs));
+        if (!mh_folder_manager_rmm(mfmc, folder, msgs))
+            die("%s", mh_managed_folders_get_err_string(mmfs));
+        done (0);
+        return 1;
+    }
+
     maildir = m_maildir (folder);
 
     if (chdir (maildir) == NOTOK)
index c1f2719..16efca1 100644 (file)
@@ -21,7 +21,6 @@
 #include "sbr/context_save.h"
 #include "sbr/context_replace.h"
 #include "sbr/context_find.h"
-#include "sbr/brkstring.h"
 #include "sbr/ambigsw.h"
 #include "sbr/path.h"
 #include "sbr/print_version.h"
@@ -29,6 +28,8 @@
 #include "sbr/seq_getnum.h"
 #include "sbr/error.h"
 #include "h/fmt_scan.h"
+#include "h/managed_folders.h"
+#include "h/folder.h"
 #include "h/tws.h"
 #include "h/mts.h"
 #include "h/done.h"
@@ -68,15 +69,13 @@ main (int argc, char **argv)
     int ontty = 0;
     int width = -1;
     bool revflag = false;
-    int i, state, msgnum;
-    ivector_t seqnum = ivector_create (0);
+    int state, msgnum;
     bool unseen;
-    int num_unseen_seq = 0;
-    char *cp, *maildir, *file = NULL, *folder = NULL;
+    char *cp, *file = NULL, *folder = NULL;
     char *form = NULL, *format = NULL, buf[BUFSIZ];
     char **argp, *nfs, **arguments;
     struct msgs_array msgs = { 0, 0, NULL };
-    struct msgs *mp;
+    const struct msgs *mp;
     charstring_t scanl = NULL;
     FILE *in;
 
@@ -266,44 +265,15 @@ main (int argc, char **argv)
        app_msgarg(&msgs, "all");
     if (!folder)
        folder = getfolder (1);
-    maildir = m_maildir (folder);
-
-    if (chdir (maildir) == NOTOK)
-       adios (maildir, "unable to change directory to");
-
-    /* read folder and create message structure */
-    if (!(mp = folder_read (folder, 1)))
-       die("unable to read folder %s", folder);
-
-    /* check for empty folder */
-    if (mp->nummsg == 0)
-       die("no messages in %s", folder);
-
-    /* parse all the message ranges/sequences and set SELECTED */
-    for (msgnum = 0; msgnum < msgs.size; msgnum++)
-       if (!m_convert (mp, msgs.msgs[msgnum]))
-           done(1);
-    seq_setprev (mp);                  /* set the Previous-Sequence */
-
-    context_replace (pfolder, folder); /* update current folder         */
-    seq_save (mp);                     /* synchronize message sequences */
-    context_save ();                   /* save the context file         */
-
-    /*
-     * Get the sequence number for each sequence
-     * specified by Unseen-Sequence
-     */
-    if ((cp = context_find (usequence)) && *cp) {
-       char **ap, *dp;
-
-       dp = mh_xstrdup(cp);
-       ap = brkstring (dp, " ", "\n");
-       for (i = 0; ap && *ap; i++, ap++)
-           ivector_push_back (seqnum, seq_getnum (mp, *ap));
-
-       num_unseen_seq = i;
-        free(dp);
+    mh_managed_folders *mmfs;
+    if (!mh_managed_folders_open(&mmfs)) {
+            die("%s", mh_managed_folders_get_err_string(mmfs));
     }
+    mh_folder *mhf;
+    if (!mh_folder_open(&mhf, folder, mmfs))
+        die("%s", mh_folder_get_err_string(mhf));
+    if (!(mp = mh_folder_select_and_chdir_for_scan(mhf, msgs)))
+        die("%s", mh_folder_get_err_string(mhf));
 
     ontty = isatty (fileno (stdout));
 
@@ -325,13 +295,7 @@ main (int argc, char **argv)
             * Check if message is in any sequence given
             * by Unseen-Sequence profile entry.
             */
-           unseen = false;
-           for (i = 0; i < num_unseen_seq; i++) {
-               if (in_sequence(mp, ivector_at (seqnum, i), msgnum)) {
-                   unseen = true;
-                   break;
-               }
-           }
+           unseen = mh_folder_message_is_unseen_for_scan(mhf, msgnum);
 
            switch (state = scan (in, msgnum, 0, nfs, width,
                        msgnum == mp->curmsg, unseen,
@@ -358,8 +322,7 @@ main (int argc, char **argv)
     }
     charstring_free (scanl);
 
-    ivector_free (seqnum);
-    folder_free (mp);  /* free folder/message structure */
+    mh_folder_close (mhf);
     if (clearflag)
        nmh_clear_screen ();
 
index c12908f..eec9102 100644 (file)
@@ -31,6 +31,8 @@
 #include "sbr/error.h"
 #include <fcntl.h>
 #include "h/done.h"
+#include "h/managed_folders.h"
+#include "h/folder.h"
 #include "h/utils.h"
 #ifdef OAUTH_SUPPORT
 #include "h/oauth.h"
@@ -137,11 +139,10 @@ main (int argc, char **argv)
     int msgp = 0, vecp;
     int isdf = 0;
     int msgnum, status;
-    char *cp, *dfolder = NULL, *maildir = NULL;
-    char buf[BUFSIZ], **ap, **argp, **arguments, *program;
+    char *cp, *dfolder = NULL;
+    char buf[BUFSIZ], **argp, **arguments, *program;
     char *msgs[MAXARGS], **vec;
     const char *user = NULL, *saslmech = NULL;
-    struct msgs *mp;
     struct stat st;
     char *auth_svc = NULL;
 
@@ -321,12 +322,15 @@ main (int argc, char **argv)
     if ((cp = context_find ("Aliasfile"))) {
        char *dp = NULL;
 
-       for (ap = brkstring(dp = mh_xstrdup(cp), " ", "\n"); ap && *ap; ap++) {
+       for (char *const *ap = brkstring(dp = mh_xstrdup(cp), " ", "\n"); ap && *ap; ap++) {
            vec[vecp++] = "-alias";
            vec[vecp++] = *ap;
        }
     }
 
+    /* TODO WHY treat -draftfolder different from profile Draft-Folder?!  And
+     *  one branch uses m_draft and one doesn't?  comp seems to duplicate some
+     *  of m_draft too... draft management clearly needs a lot of work. */
     if (dfolder == NULL) {
        if (msgp == 0) {
            msgs[msgp++] = mh_xstrdup(m_draft(NULL, NULL, 1, &isdf));
@@ -334,15 +338,16 @@ main (int argc, char **argv)
                adios (msgs[0], "unable to stat draft file");
            cp = concat ("Use \"", msgs[0], "\"? ", NULL);
            for (status = LISTDSW; status != YESW;) {
-               if (!(argp = read_switch_multiword (cp, anyl)))
-                   done (1);
-               switch (status = smatch (*argp, anyl)) {
+                char *const *words = read_switch_multiword (cp, anyl);
+                if (words == NULL)
+                    done (1);
+               switch (status = smatch (*words, anyl)) {
                    case NOSW: 
                        done (0);
                    case YESW: 
                        break;
                    case LISTDSW: 
-                       showfile (++argp, msgs[0]);
+                       showfile (++words, msgs[0]);
                        break;
                    default:
                        inform("say what?");
@@ -359,40 +364,25 @@ main (int argc, char **argv)
 
        if (!msgp)
            msgs[msgp++] = "cur";
-       maildir = m_maildir (dfolder);
-
-       if (chdir (maildir) == NOTOK)
-           adios (maildir, "unable to change directory to");
-
-       /* read folder and create message structure */
-       if (!(mp = folder_read (dfolder, 1)))
-           die("unable to read folder %s", dfolder);
-
-       /* check for empty folder */
-       if (mp->nummsg == 0)
-           die("no messages in %s", dfolder);
-
-       /* parse all the message ranges/sequences and set SELECTED */
-       for (msgnum = 0; msgnum < msgp; msgnum++)
-           if (!m_convert (mp, msgs[msgnum]))
-               done (1);
-       seq_setprev (mp);       /* set the previous-sequence */
+        mh_managed_folders *mmfs;
+        if (!mh_managed_folders_open(&mmfs))
+            die("%s", mh_managed_folders_get_err_string(mmfs));
+        mh_folder *mhf;
+        if (!mh_folder_open(&mhf, dfolder, mmfs))
+            die("%s", mh_folder_get_err_string(mhf));
+        const struct msgs *const mp = mh_folder_select_and_chdir_for_send(mhf, msgs, msgp);
+        if (mp == NULL)
+            die("%s", mh_folder_get_err_string(mhf));
 
        for (msgp = 0, msgnum = mp->lowsel; msgnum <= mp->hghsel; msgnum++) {
            if (is_selected (mp, msgnum)) {
                msgs[msgp++] = mh_xstrdup(m_name (msgnum));
-               unset_exists (mp, msgnum);
            }
        }
 
-       mp->msgflags |= SEQMOD;
-       seq_save (mp);
+        mh_folder_close (mhf);
     }
 
-#ifdef WHATNOW
-go_to_it:
-#endif /* WHATNOW */
-
     if ((cp = getenv ("SIGNATURE")) == NULL || *cp == 0)
        if ((cp = context_find ("signature")) && *cp)
            setenv("SIGNATURE", cp, 1);
@@ -476,15 +466,24 @@ go_to_it:
 
     status = 0;
 
-    for (msgnum = 0; msgnum < msgp; msgnum++) {
-        switch (sendsbr (vec, vecp, program, msgs[msgnum], &st, 1, auth_svc)) {
+    for (int i = 0; i < msgp; i++) {
+        char *const msgnum = msgs[i];
+        switch (sendsbr (vec, vecp, program, msgnum, &st, dfolder == NULL, auth_svc)) {
            case DONE: 
                done (++status);
-               /* FALLTHRU */
+                break;
            case NOTOK: 
                status++;
-               /* FALLTHRU */
+                break;
            case OK:
+                if (dfolder != NULL) {
+                    char *const rmm_command = concat ("rmm +", dfolder, " ", msgnum, NULL);
+                    const int status = system (rmm_command);
+                    free (rmm_command);
+                    if (!WIFEXITED (status) || WEXITSTATUS (status) != 0) {
+                        adios (msgnum, "failed to rmm after send");
+                    }
+                }
                break;
        }
     }
index 628b99d..d0f324b 100644 (file)
@@ -434,7 +434,7 @@ annoaux (int fd)
 {
     int        fd2, fd3, msgnum;
     char *cp, *folder, *maildir;
-    char buffer[BUFSIZ], **ap;
+    char buffer[BUFSIZ];
     FILE *fp;
     struct msgs *mp;
 
@@ -476,7 +476,7 @@ annoaux (int fd)
     }
     else
        fd2 = NOTOK;
-    for (ap = brkstring (cp = mh_xstrdup(cp), " ", NULL); *ap; ap++)
+    for (char *const *ap = brkstring (cp = mh_xstrdup(cp), " ", NULL); *ap; ap++)
        m_convert (mp, *ap);
     free (cp);
     if (fd2 != NOTOK)
index 699b77b..7aedb5f 100644 (file)
@@ -6,6 +6,8 @@
  */
 
 #include "h/mh.h"
+#include "h/managed_folders.h"
+#include "h/folder.h"
 #include "mhlsbr.h"
 #include "sbr/m_name.h"
 #include "sbr/m_getfld.h"
@@ -97,9 +99,8 @@ main (int argc, char **argv)
     bool checkmime = true;
     bool mime = false;
     int isdf = 0, mode = SHOW, msgnum;
-    char *cp, *maildir, *file = NULL, *folder = NULL, *proc, *program;
+    char *cp, *file = NULL, *folder = NULL, *proc, *program;
     char buf[BUFSIZ], **argp, **arguments;
-    struct msgs *mp = NULL;
     struct msgs_array msgs = { 0, 0, NULL };
     struct msgs_array vec = { 0, 0, NULL }, non_mhl_vec = { 0, 0, NULL };
 
@@ -254,42 +255,16 @@ usage:
 
     if (!folder)
        folder = getfolder (1);
-    maildir = m_maildir (folder);
+    mh_managed_folders *mmfs;
+    if (!mh_managed_folders_open(&mmfs))
+        die("%s", mh_managed_folders_get_err_string(mmfs));
+    mh_folder *mhf;
+    if (!mh_folder_open(&mhf, folder, mmfs))
+        die("%s", mh_folder_get_err_string(mhf));
+    const struct msgs *const mp = mh_folder_select_and_chdir_and_mark_seen(&vec, mhf, msgs);
+    if (mp == NULL)
+        die("%s", mh_folder_get_err_string(mhf));
 
-    if (chdir (maildir) == NOTOK)
-       adios (maildir, "unable to change directory to");
-
-    /* read folder and create message structure */
-    if (!(mp = folder_read (folder, 1)))
-       die("unable to read folder %s", folder);
-
-    /* check for empty folder */
-    if (mp->nummsg == 0)
-       die("no messages in %s", folder);
-
-    /* parse all the message ranges/sequences and set SELECTED */
-    for (msgnum = 0; msgnum < msgs.size; msgnum++)
-       if (!m_convert (mp, msgs.msgs[msgnum]))
-           done (1);
-
-    /*
-     * Set the SELECT_UNSEEN bit for all the SELECTED messages,
-     * since we will use that as a tag to know which messages
-     * to remove from the "unseen" sequence.
-     */
-    for (msgnum = mp->lowsel; msgnum <= mp->hghsel; msgnum++)
-       if (is_selected(mp, msgnum))
-           set_unseen (mp, msgnum);
-
-    seq_setprev (mp);          /* set the Previous-Sequence */
-    seq_setunseen (mp, 1);     /* unset the Unseen-Sequence */
-
-    for (msgnum = mp->lowsel; msgnum <= mp->hghsel; msgnum++)
-       if (is_selected(mp, msgnum))
-           app_msgarg(&vec, mh_xstrdup(m_name (msgnum)));
-
-    seq_setcur (mp, mp->hghsel);       /* update current message  */
-    seq_save (mp);                     /* synchronize sequences   */
     context_replace (pfolder, folder); /* update current folder   */
     context_save ();                   /* save the context file   */
 
index 7f28f34..897525c 100644 (file)
@@ -256,10 +256,13 @@ WhatNow (int argc, char **argv)
        done (1);
 
     for (;;) {
+        // read_switch_multiword(_via_readline) return pointer to brkstring's
+        // private data.  Writing into it is rude, but as long as we don't free
+        // it, there's no practical harm.  This is the least of our problems...
 #ifdef READLINE_SUPPORT
-       if (!(argp = read_switch_multiword_via_readline(myprompt, aleqs))) {
+       if (!(argp = (char**)read_switch_multiword_via_readline(myprompt, aleqs))) {
 #else /* ! READLINE_SUPPORT */
-       if (!(argp = read_switch_multiword(myprompt, aleqs))) {
+        if (!(argp = (char**)read_switch_multiword(myprompt, aleqs))) {
 #endif /* READLINE_SUPPORT */
            (void) m_unlink (LINK);
            done (1);
@@ -1202,10 +1205,8 @@ sendit (char *sp, char **arg, char *file, int pushed)
 
     /* allow Aliasfile: profile entry */
     if ((cp = context_find ("Aliasfile"))) {
-       char **ap, *dp;
-
-       dp = mh_xstrdup(cp);
-       for (ap = brkstring (dp, " ", "\n"); ap && *ap; ap++) {
+       char *const dp = mh_xstrdup(cp);
+       for (char *const *ap = brkstring (dp, " ", "\n"); ap && *ap; ap++) {
            vec[vecp++] = "-alias";
            vec[vecp++] = *ap;
        }
index 7a74cf2..de5f573 100644 (file)
@@ -74,7 +74,7 @@ main (int argc, char **argv)
     int vecp = 0;
     bool distsw;
     char *cp, *dfolder = NULL, *dmsg = NULL;
-    char *msg = NULL, **ap, **argp, backup[BUFSIZ];
+    char *msg = NULL, **argp, backup[BUFSIZ];
     char buf[BUFSIZ], **arguments, *vec[MAXARGS];
 
     if (nmh_init(argv[0], true, false)) { return 1; }
@@ -169,7 +169,7 @@ main (int argc, char **argv)
     if ((cp = context_find ("Aliasfile"))) {
        char *dp = NULL;
 
-       for (ap = brkstring(dp = mh_xstrdup(cp), " ", "\n"); ap && *ap; ap++) {
+       for (char *const *ap = brkstring(dp = mh_xstrdup(cp), " ", "\n"); ap && *ap; ap++) {
            vec[vecp++] = "-alias";
            vec[vecp++] = *ap;
        }