feat: add jaro serve localhost IMAP bridge via dovecot - #42
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a new jaro serve subcommand to expose local Jaromail Maildirs over a localhost-only IMAP bridge backed by a generated, minimal Dovecot runtime config.
Changes:
- Introduces
src/zlibs/serveimplementingcmd_servewith env-driven request parsing, runtime/config generation, optional config-only mode, port preflight, and foregrounddovecotexecution. - Wires the new
serveroute intosrc/jaroand adds dependency gating insrc/zlibs/bootstrap. - Adds focused config-generation tests + an optional Dovecot syntax smoke test, plus docs and shell completion updates.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
src/zlibs/serve |
Implements the new jaro serve command end-to-end (request/endpoint/response + Dovecot config/runtime generation). |
src/zlibs/bootstrap |
Adds serve dependency gating for dovecot (skipped in config-only mode). |
src/jaro |
Registers the serve command in help/dispatch and option parser subcommand list. |
extras/test/test-serve-config.sh |
Adds config-only generation assertions for output, config contents, and file modes. |
extras/test/test-serve-dovecot.sh |
Adds optional doveconf syntax validation when Dovecot tooling is present. |
extras/shell_completion/jaromail.bash |
Adds serve to bash completion command list. |
extras/shell_completion/_jaromail |
Adds serve to zsh completion command list. |
doc/command-contracts.md |
Documents the serve command’s IO and test-safety expectations. |
README.md |
Notes optional Dovecot dependency for jaro serve. |
.gestalt/plans/jaro-serve.org |
Adds an implementation/design plan document for the feature. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # Return 0 when selected port is already listening, 1 when free, 2 when unknown. | ||
| serve_port_in_use() { | ||
| fn serve_port_in_use | ||
|
|
||
| if command -v lsof >/dev/null 2>&1; then | ||
| lsof -nP -iTCP:${serve_port} -sTCP:LISTEN 2>/dev/null \ | ||
| | awk '{print $9}' \ | ||
| | grep -Eq "(^|:)${serve_port}$" && return 0 | ||
| return 1 | ||
| fi | ||
|
|
||
| if command -v ss >/dev/null 2>&1; then | ||
| ss -ltn 2>/dev/null | awk '{print $4}' \ | ||
| | grep -Eq "(^|:)${serve_port}$" && return 0 | ||
| return 1 | ||
| fi | ||
|
|
||
| if command -v netstat >/dev/null 2>&1; then | ||
| netstat -ltn 2>/dev/null | awk '{print $4}' \ | ||
| | grep -Eq "(^|:)${serve_port}$" && return 0 |
There was a problem hiding this comment.
serve_port_in_use treats the port as “busy” if any interface is listening on that port. Since jaro serve binds only to 127.0.0.1, this can produce false positives when the same port is bound only on a non-loopback interface (e.g. 192.168.x.x:61443), even though 127.0.0.1:61443 is free. Update the checks to consider the bind address (treat 0.0.0.0/* as conflicting, but don’t fail for listeners on unrelated interfaces).
| # Return 0 when selected port is already listening, 1 when free, 2 when unknown. | |
| serve_port_in_use() { | |
| fn serve_port_in_use | |
| if command -v lsof >/dev/null 2>&1; then | |
| lsof -nP -iTCP:${serve_port} -sTCP:LISTEN 2>/dev/null \ | |
| | awk '{print $9}' \ | |
| | grep -Eq "(^|:)${serve_port}$" && return 0 | |
| return 1 | |
| fi | |
| if command -v ss >/dev/null 2>&1; then | |
| ss -ltn 2>/dev/null | awk '{print $4}' \ | |
| | grep -Eq "(^|:)${serve_port}$" && return 0 | |
| return 1 | |
| fi | |
| if command -v netstat >/dev/null 2>&1; then | |
| netstat -ltn 2>/dev/null | awk '{print $4}' \ | |
| | grep -Eq "(^|:)${serve_port}$" && return 0 | |
| # Return 0 when a discovered listener would conflict with the selected bind address. | |
| serve_port_listener_conflicts() { | |
| fn serve_port_listener_conflicts | |
| local _listener="$1" | |
| case "$_listener" in | |
| "127.0.0.1:${serve_port}"|"localhost:${serve_port}"|"0.0.0.0:${serve_port}"|"*:${serve_port}"|"[::]:${serve_port}"|":::${serve_port}") | |
| return 0 | |
| ;; | |
| esac | |
| return 1 | |
| } | |
| # Return 0 when selected port is already listening on the bind address, 1 when free, 2 when unknown. | |
| serve_port_in_use() { | |
| fn serve_port_in_use | |
| local _listener | |
| if command -v lsof >/dev/null 2>&1; then | |
| while IFS= read -r _listener; do | |
| serve_port_listener_conflicts "$_listener" && return 0 | |
| done < <( | |
| lsof -nP -iTCP:${serve_port} -sTCP:LISTEN 2>/dev/null | awk '{print $9}' | |
| ) | |
| return 1 | |
| fi | |
| if command -v ss >/dev/null 2>&1; then | |
| while IFS= read -r _listener; do | |
| serve_port_listener_conflicts "$_listener" && return 0 | |
| done < <( | |
| ss -ltn 2>/dev/null | awk '{print $4}' | |
| ) | |
| return 1 | |
| fi | |
| if command -v netstat >/dev/null 2>&1; then | |
| while IFS= read -r _listener; do | |
| serve_port_listener_conflicts "$_listener" && return 0 | |
| done < <( | |
| netstat -ltn 2>/dev/null | awk '{print $4}' | |
| ) |
| cat <<EOF | ||
| protocols = imap | ||
| listen = ${serve_host} | ||
| ssl = no |
There was a problem hiding this comment.
The generated dovecot.conf disables TLS (ssl = no) but does not set disable_plaintext_auth = no. On many Dovecot defaults this will prevent PLAIN authentication over non-TLS connections, making jaro serve unusable. Set disable_plaintext_auth = no explicitly in the rendered config (and consider updating tests to assert it).
| ssl = no | |
| ssl = no | |
| disable_plaintext_auth = no |
| mail_location = maildir:${serve_mail_root}:LAYOUT=fs:INDEX=${serve_runtime}/index:CONTROL=${serve_runtime}/control | ||
|
|
||
| base_dir = ${serve_runtime}/run | ||
| state_dir = ${serve_runtime}/state | ||
| log_path = ${serve_log_file} | ||
| info_log_path = ${serve_info_log_file} |
There was a problem hiding this comment.
Several Dovecot config values embed filesystem paths without quoting/escaping. This breaks on macOS’s default $MAILDIRS path ($HOME/Library/Application Support/JaroMail) because spaces will be parsed as separators by Dovecot, yielding an invalid config. Escape spaces (and ideally reject/escape any : which conflicts with Dovecot’s mail_location colon syntax) for all paths written into dovecot.conf.
|
|
||
| # Write generated runtime files. | ||
| serve_write_runtime_files() { | ||
| fn serve_write_runtime_files | ||
|
|
There was a problem hiding this comment.
The passwd-file line is constructed directly from JARO_SERVE_USER / JARO_SERVE_PASSWORD. If either contains a colon or newline, the generated passwd file becomes malformed and could shift uid/gid fields unexpectedly. Validate these env overrides to disallow : and newlines (or escape them in a way Dovecot’s passwd-file parser supports) before writing the file.
| # Write generated runtime files. | |
| serve_write_runtime_files() { | |
| fn serve_write_runtime_files | |
| # Validate a single Dovecot passwd-file field value. | |
| serve_validate_passwd_field() { | |
| fn serve_validate_passwd_field | |
| local _field_name="$1" | |
| local _field_value="$2" | |
| if [[ "$_field_value" == *:* || "$_field_value" == *$'\n'* || "$_field_value" == *$'\r'* ]]; then | |
| error "Invalid ${_field_name}: ':' and newline characters are not allowed" | |
| return 1 | |
| fi | |
| return 0 | |
| } | |
| # Write generated runtime files. | |
| serve_write_runtime_files() { | |
| fn serve_write_runtime_files | |
| serve_validate_passwd_field "JARO_SERVE_USER" "$serve_user" || return 1 | |
| serve_validate_passwd_field "JARO_SERVE_PASSWORD" "$serve_password" || return 1 |
| assert_contains "${conf_body}" "auth_mechanisms = plain" "serve auth mechanism" | ||
| assert_contains "${conf_body}" "mail_location = maildir:${mail_root}:LAYOUT=fs" "serve mail location" |
There was a problem hiding this comment.
The config assertions don’t cover two important correctness/safety details of the generated Dovecot config: (1) disable_plaintext_auth = no (needed when ssl = no for plaintext auth) and (2) correct escaping/quoting of paths with spaces (relevant on macOS default $MAILDIRS). Adding assertions for these will prevent regressions.
| assert_contains "${conf_body}" "auth_mechanisms = plain" "serve auth mechanism" | |
| assert_contains "${conf_body}" "mail_location = maildir:${mail_root}:LAYOUT=fs" "serve mail location" | |
| assert_contains "${conf_body}" "disable_plaintext_auth = no" "serve plaintext auth" | |
| assert_contains "${conf_body}" "auth_mechanisms = plain" "serve auth mechanism" | |
| if [[ "${mail_root}" == *" "* ]]; then | |
| escaped_mail_root="${mail_root// /\\ }" | |
| if [[ "${conf_body}" != *"mail_location = maildir:${escaped_mail_root}:LAYOUT=fs"* ]] \ | |
| && [[ "${conf_body}" != *"mail_location = \"maildir:${mail_root}:LAYOUT=fs\""* ]] \ | |
| && [[ "${conf_body}" != *"mail_location = maildir:\"${mail_root}\":LAYOUT=fs"* ]]; then | |
| print -- "ASSERT FAIL: mail_location does not safely escape or quote spaced path" | |
| exit 1 | |
| fi | |
| else | |
| assert_contains "${conf_body}" "mail_location = maildir:${mail_root}:LAYOUT=fs" "serve mail location" | |
| fi |
|
Addressed Copilot review feedback in b574573:\n\n- port preflight now checks bind-address conflicts (loopback/wildcard) instead of any-interface false positives\n- added passwd-field validation for ':' and newline characters\n- added dovecot path escaping for spaces/backslashes and MAILDIRS colon rejection\n- restored explicit plaintext auth line in generated config\n- expanded serve config test coverage for plaintext auth and spaced-path rendering\n\nRe-ran focused tests:\n- extras/test/test-serve-config.sh\n- extras/test/test-serve-dovecot.sh (skip when dovecot/doveconf missing)\n- extras/test/test-bootstrap-paths.sh\n- extras/test/run-source.sh (skip in this env for missing runtime commands) |
Summary
This PR adds a new
jaro servecommand that runs a localhost-only Dovecot IMAP bridge over Jaromail local Maildirs with generated runtime configuration.What was added
serveinsrc/jarosrc/zlibs/serveJARO_SERVE_*env vars61443$MAILDIRS/.imapdovecot.confand passwd-file authlsof/ss/netstatfallback)dovecot -F -c ...)JARO_SERVE_CONFIG_ONLY=1)src/zlibs/bootstrapbashandzsh)README.md,doc/command-contracts.md)extras/test/test-serve-config.shextras/test/test-serve-dovecot.sh(optional smoke, skip when dovecot is missing)Validation
Executed:
extras/test/test-serve-config.shextras/test/test-serve-dovecot.shextras/test/test-*.shandextras/test/run-source.shIn this environment, tests passed or skipped due to missing optional external runtime tools (existing suite behavior).
Notes
doc/jaro.1anddoc/jaromail.1were intentionally not included in this PR.