Phase 7: harden config validation and docs

ober

c076f1bfd0a9f9806117e95e6b277be4ca81bcbe

diff --git a/Makefile b/Makefile
index 7a6889d..c20844e 100644
--- a/Makefile
+++ b/Makefile
@@ -4,7 +4,7 @@ JERBOA_SSL_DIR ?= $(realpath $(CURDIR)/../jerboa-ssl)
 JERBOA_MAIL_DIR ?= $(realpath $(CURDIR)/../jerboa-mail)
 LIBDIRS := $(CURDIR):$(JERBOA_MAIL_DIR):$(JERBOA_SSL_DIR)/lib:$(JERBOA_HOME)/lib
 
-.PHONY: help run test doctor clean
+.PHONY: help run test integration-test doctor clean
 .DEFAULT_GOAL := help
 
 help:
@@ -15,6 +15,7 @@ help:
 	@echo "  make run ARGS='--help'   Run the CLI under the interpreter"
 	@echo "  make doctor              Run the placeholder doctor command"
 	@echo "  make test                Run smoke tests"
+	@echo "  make integration-test    Run Bridge doctor when PROTON_BRIDGE_INTEGRATION=1"
 	@echo "  make clean               Remove generated local artifacts"
 	@echo ""
 	@echo "Environment:"
@@ -36,6 +37,13 @@ test:
 		$(SCHEME) -q --libdirs $(LIBDIRS) \
 			--script test/test-all.ss
 
+integration-test:
+	@if [ "$$PROTON_BRIDGE_INTEGRATION" != "1" ]; then \
+		echo "Skipping integration test; set PROTON_BRIDGE_INTEGRATION=1"; \
+	else \
+		bin/protonmail-read doctor; \
+	fi
+
 clean:
 	rm -f protonmail-read protonmail-read-bin
 	rm -rf exports cache tmp
diff --git a/README.md b/README.md
index de94b53..d1df277 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,7 @@ first implementation path.
 
 ## Current Status
 
-Phase 6 is in progress:
+Phase 7 is in progress:
 
 - CLI entry point.
 - Environment config helper.
@@ -24,6 +24,7 @@ Phase 6 is in progress:
 - `.eml` export.
 - Decoded `show` output through `jerboa-mail`.
 - Search by from, subject, and since.
+- Config validation and opt-in integration checks.
 - Smoke tests.
 - Makefile.
 - Project plan.
@@ -36,6 +37,7 @@ Live Proton Bridge checks are attempted only when Bridge credentials are set.
 make run ARGS='--help'
 make doctor
 make test
+make integration-test
 bin/protonmail-read doctor
 ```
 
@@ -70,3 +72,26 @@ export PROTON_BRIDGE_TLS=auto
 ```
 
 Use Bridge-generated IMAP credentials, not your Proton account password.
+
+## Commands
+
+```sh
+bin/protonmail-read doctor
+bin/protonmail-read folders
+bin/protonmail-read list --folder INBOX --limit 20
+bin/protonmail-read show --folder INBOX --uid 123
+bin/protonmail-read raw --folder INBOX --uid 123
+bin/protonmail-read export-eml --folder INBOX --uid 123 --output message.eml
+bin/protonmail-read search --folder INBOX --from person@example.com
+bin/protonmail-read search --folder INBOX --subject invoice
+bin/protonmail-read search --folder INBOX --since 2026-01-01
+```
+
+## Safety
+
+The implemented IMAP fetches use `BODY.PEEK[...]` so reads should not mark
+messages as read. The tool does not issue `STORE`, `EXPUNGE`, `COPY`, `MOVE`,
+`APPEND`, `CREATE`, or `DELETE`.
+
+Exported `.eml` files contain plaintext mail after Bridge has decrypted it
+locally. Treat them as sensitive.
diff --git a/protonmail/cli.ss b/protonmail/cli.ss
index 2a04958..f3e10ba 100644
--- a/protonmail/cli.ss
+++ b/protonmail/cli.ss
@@ -52,9 +52,20 @@
     (println version))
 
   (define (require-complete-config cfg)
-    (unless (config-complete? cfg)
-      (die 2
-           "Bridge config is incomplete; set PROTON_BRIDGE_USER and PROTON_BRIDGE_PASSWORD")))
+    (let ([errors (config-errors cfg)])
+      (unless (null? errors)
+        (die 2
+             (string-append
+               "Bridge config is incomplete: "
+               (string-join errors "; "))))))
+
+  (define (string-join items sep)
+    (if (null? items)
+        ""
+        (let loop ([xs (cdr items)] [acc (car items)])
+          (if (null? xs)
+              acc
+              (loop (cdr xs) (string-append acc sep (car xs)))))))
 
   (define (run-network-command thunk)
     (let ([result (guard (e [#t e])
@@ -217,10 +228,13 @@
   (define (cmd-doctor)
     (let ([cfg (config-from-environment)])
       (println "protonmail-read doctor")
-      (println "phase: 1")
-      (println "status: CLI, config loader, and IMAP probe are installed")
+      (println "phase: 7")
+      (println "status: CLI, config loader, IMAP commands, decoding, and hardening are installed")
       (println (string-append "bridge host: " (config-host cfg)))
-      (println (string-append "bridge port: " (number->string (config-port cfg))))
+      (println (string-append "bridge port: "
+                              (if (number? (config-port cfg))
+                                  (number->string (config-port cfg))
+                                  "<invalid>")))
       (println (string-append "bridge tls: " (config-tls cfg)))
       (println (string-append "bridge user: " (redact-secret (config-user cfg))))
       (println (string-append "bridge password: " (redact-secret (config-password cfg))))
@@ -243,6 +257,10 @@
                                               (assoc-value 'mailbox-count result 0))))))))
           (begin
             (println "config: incomplete")
+            (for-each
+              (lambda (err)
+                (println (string-append "config error: " err)))
+              (config-errors cfg))
             (println "network: skipped")))))
 
   (define (assoc-value key alist default)
diff --git a/protonmail/config.ss b/protonmail/config.ss
index 3c8d015..cdbc4ca 100644
--- a/protonmail/config.ss
+++ b/protonmail/config.ss
@@ -11,6 +11,7 @@
     config-tls
     config-from-environment
     config-complete?
+    config-errors
     redact-secret)
 
   (import (except (chezscheme)
@@ -32,35 +33,56 @@
   (define (config-password cfg) (vector-ref cfg 3))
   (define (config-tls cfg) (vector-ref cfg 4))
 
-  (define (env/default name default)
+  (define (env name)
     (let ([value (getenv name)])
-      (if (and value (not (string=? value "")))
-          value
-          default)))
+      (if (and value (not (string=? value ""))) value #f)))
 
-  (define (string->port s)
+  (define (valid-port-string->number s)
     (let ([n (string->number s)])
       (if (and n (integer? n) (> n 0) (< n 65536))
           n
-          1143)))
+          #f)))
 
   (define (config-from-environment)
-    (make-config
-      (env/default "PROTON_BRIDGE_HOST" "127.0.0.1")
-      (string->port (env/default "PROTON_BRIDGE_PORT" "1143"))
-      (env/default "PROTON_BRIDGE_USER" "")
-      (env/default "PROTON_BRIDGE_PASSWORD" "")
-      (env/default "PROTON_BRIDGE_TLS" "auto")))
+    (let ([port-env (env "PROTON_BRIDGE_PORT")])
+      (make-config
+        (or (env "PROTON_BRIDGE_HOST") "127.0.0.1")
+        (if port-env (valid-port-string->number port-env) 1143)
+        (or (env "PROTON_BRIDGE_USER") "")
+        (or (env "PROTON_BRIDGE_PASSWORD") "")
+        (or (env "PROTON_BRIDGE_TLS") "auto"))))
 
   (define (nonempty? s)
     (and (string? s) (> (string-length s) 0)))
 
+  (define (valid-tls-mode? mode)
+    (and (string? mode)
+         (or (string-ci=? mode "auto")
+             (string-ci=? mode "tls")
+             (string-ci=? mode "ssl")
+             (string-ci=? mode "plain")
+             (string-ci=? mode "true")
+             (string-ci=? mode "yes")
+             (string-ci=? mode "false")
+             (string-ci=? mode "no")
+             (string-ci=? mode "off"))))
+
+  (define (config-errors cfg)
+    (let ([errors '()])
+      (unless (nonempty? (config-host cfg))
+        (set! errors (cons "PROTON_BRIDGE_HOST is empty" errors)))
+      (unless (number? (config-port cfg))
+        (set! errors (cons "PROTON_BRIDGE_PORT must be an integer from 1 to 65535" errors)))
+      (unless (nonempty? (config-user cfg))
+        (set! errors (cons "PROTON_BRIDGE_USER is unset" errors)))
+      (unless (nonempty? (config-password cfg))
+        (set! errors (cons "PROTON_BRIDGE_PASSWORD is unset" errors)))
+      (unless (valid-tls-mode? (config-tls cfg))
+        (set! errors (cons "PROTON_BRIDGE_TLS must be auto, tls, or plain" errors)))
+      (reverse errors)))
+
   (define (config-complete? cfg)
-    (and (nonempty? (config-host cfg))
-         (number? (config-port cfg))
-         (nonempty? (config-user cfg))
-         (nonempty? (config-password cfg))
-         (nonempty? (config-tls cfg))))
+    (null? (config-errors cfg)))
 
   (define (redact-secret s)
     (cond
diff --git a/protonmail/imap/client.ss b/protonmail/imap/client.ss
index d5e5115..1f5f024 100644
--- a/protonmail/imap/client.ss
+++ b/protonmail/imap/client.ss
@@ -113,7 +113,8 @@
       [(or (string=? mode "plain") (string=? mode "false")
            (string=? mode "no") (string=? mode "off"))
        "plain"]
-      [else "auto"]))
+      [(string=? mode "auto") "auto"]
+      [else (error 'imap-connect "invalid TLS mode")]))
 
   (define (open-mode cfg mode)
     (let ([tr (transport-connect (config-host cfg) (config-port cfg) mode)])
diff --git a/test/test-all.ss b/test/test-all.ss
index 9aeb339..597bb61 100644
--- a/test/test-all.ss
+++ b/test/test-all.ss
@@ -80,6 +80,14 @@
        (config-complete?
          (make-config "127.0.0.1" 1143 "user" "password" "auto")))
 
+(check "invalid port makes config incomplete"
+       (not (config-complete?
+              (make-config "127.0.0.1" #f "user" "password" "auto"))))
+
+(check "invalid tls mode makes config incomplete"
+       (not (config-complete?
+              (make-config "127.0.0.1" 1143 "user" "password" "starttls"))))
+
 (check "redaction hides unset secret"
        (string=? "<unset>" (redact-secret "")))