Skip to content

feat: add jaro serve localhost IMAP bridge via dovecot - #42

Open
jaromil wants to merge 2 commits into
masterfrom
jaro-serve
Open

feat: add jaro serve localhost IMAP bridge via dovecot#42
jaromil wants to merge 2 commits into
masterfrom
jaro-serve

Conversation

@jaromil

@jaromil jaromil commented May 1, 2026

Copy link
Copy Markdown
Member

Summary

This PR adds a new jaro serve command that runs a localhost-only Dovecot IMAP bridge over Jaromail local Maildirs with generated runtime configuration.

What was added

  • New command route: serve in src/jaro
  • New module: src/zlibs/serve
    • Request parsing from JARO_SERVE_* env vars
    • Localhost-only host validation
    • Default port 61443
    • Generated runtime under $MAILDIRS/.imap
    • Generated dovecot.conf and passwd-file auth
    • Port preflight checks (lsof/ss/netstat fallback)
    • Foreground Dovecot launch (dovecot -F -c ...)
    • Config-only mode for tests (JARO_SERVE_CONFIG_ONLY=1)
  • Dependency gating in src/zlibs/bootstrap
  • Shell completion updates (bash and zsh)
  • Documentation updates (README.md, doc/command-contracts.md)
  • New tests:
    • extras/test/test-serve-config.sh
    • extras/test/test-serve-dovecot.sh (optional smoke, skip when dovecot is missing)

Validation

Executed:

  • extras/test/test-serve-config.sh
  • extras/test/test-serve-dovecot.sh
  • Full available sweep: extras/test/test-*.sh and extras/test/run-source.sh

In this environment, tests passed or skipped due to missing optional external runtime tools (existing suite behavior).

Notes

  • Existing unrelated local changes in doc/jaro.1 and doc/jaromail.1 were intentionally not included in this PR.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/serve implementing cmd_serve with env-driven request parsing, runtime/config generation, optional config-only mode, port preflight, and foreground dovecot execution.
  • Wires the new serve route into src/jaro and adds dependency gating in src/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.

Comment thread src/zlibs/serve Outdated
Comment on lines +190 to +209
# 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

Copilot AI May 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
# 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}'
)

Copilot uses AI. Check for mistakes.
Comment thread src/zlibs/serve
cat <<EOF
protocols = imap
listen = ${serve_host}
ssl = no

Copilot AI May 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
ssl = no
ssl = no
disable_plaintext_auth = no

Copilot uses AI. Check for mistakes.
Comment thread src/zlibs/serve Outdated
Comment on lines +131 to +136
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}

Copilot AI May 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread src/zlibs/serve
Comment on lines +162 to +166

# Write generated runtime files.
serve_write_runtime_files() {
fn serve_write_runtime_files

Copilot AI May 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
# 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

Copilot uses AI. Check for mistakes.
Comment on lines +48 to +49
assert_contains "${conf_body}" "auth_mechanisms = plain" "serve auth mechanism"
assert_contains "${conf_body}" "mail_location = maildir:${mail_root}:LAYOUT=fs" "serve mail location"

Copilot AI May 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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

Copilot uses AI. Check for mistakes.
@jaromil

jaromil commented May 1, 2026

Copy link
Copy Markdown
Member Author

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)

@jaromil
jaromil marked this pull request as ready for review May 1, 2026 17:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants