Phase 7: harden config validation and docs
ober
c076f1bfd0a9f9806117e95e6b277be4ca81bcbe
--- 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 --- 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. --- 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) --- 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 --- 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)]) --- 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 "")))