From 5e53d0de864279f0dc23ee7022df065a907cd4db Mon Sep 17 00:00:00 2001 From: Thomas Faour Date: Thu, 12 Feb 2026 20:30:10 -0500 Subject: [PATCH] fixed race condition, added systemctl integration --- README.md | 37 +++++++++++++++++++++++++++++++++ imap-idle-caldav.service | 24 +++++++++++++++++++++ imap_idle_caldav/imap_client.py | 37 ++++++++++++++++++++------------- 3 files changed, 84 insertions(+), 14 deletions(-) create mode 100644 imap-idle-caldav.service diff --git a/README.md b/README.md index 93c3836..4f140aa 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,43 @@ python -m imap_idle_caldav -c config.yaml The daemon will connect to IMAP, enter IDLE mode, and process incoming calendar emails as they arrive. It re-idles every 5 minutes per RFC 2177 and automatically reconnects on disconnection. +## Systemd Service + +Create a service user and install the app: + +```bash +sudo useradd -r -s /usr/sbin/nologin imap-idle-caldav +sudo mkdir -p /opt/imap-idle-caldav +sudo python3 -m venv /opt/imap-idle-caldav/.venv +sudo /opt/imap-idle-caldav/.venv/bin/pip install . +``` + +Put your config in place: + +```bash +sudo mkdir -p /etc/imap-idle-caldav +sudo cp config.yaml /etc/imap-idle-caldav/config.yaml +sudo chmod 600 /etc/imap-idle-caldav/config.yaml +sudo chown imap-idle-caldav: /etc/imap-idle-caldav/config.yaml +``` + +Install and start the service: + +```bash +sudo cp imap-idle-caldav.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now imap-idle-caldav +``` + +Check status and logs: + +```bash +sudo systemctl status imap-idle-caldav +sudo journalctl -u imap-idle-caldav -f +``` + +Edit the `ExecStart` path and `User` in the unit file if your install location or user differs. + ## Project Structure ``` diff --git a/imap-idle-caldav.service b/imap-idle-caldav.service new file mode 100644 index 0000000..c755e04 --- /dev/null +++ b/imap-idle-caldav.service @@ -0,0 +1,24 @@ +[Unit] +Description=IMAP IDLE to CalDAV daemon +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=imap-idle-caldav +ExecStart=/opt/imap-idle-caldav/.venv/bin/imap-idle-caldav -c /etc/imap-idle-caldav/config.yaml +Restart=on-failure +RestartSec=10 + +# Hardening +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true +PrivateDevices=true +ProtectKernelTunables=true +ProtectControlGroups=true +RestrictSUIDSGID=true + +[Install] +WantedBy=multi-user.target diff --git a/imap_idle_caldav/imap_client.py b/imap_idle_caldav/imap_client.py index 79e2463..69e6a0a 100644 --- a/imap_idle_caldav/imap_client.py +++ b/imap_idle_caldav/imap_client.py @@ -10,8 +10,9 @@ from .config import ImapConfig log = logging.getLogger(__name__) -IDLE_TIMEOUT = 300 # 5 minutes, per RFC 2177 -MAX_BACKOFF = 300 # 5 minutes max backoff +IDLE_TIMEOUT = 300 # 5 minutes total IDLE before re-idle (RFC 2177) +IDLE_POLL = 30 # check shutdown every 30 seconds within an IDLE +MAX_BACKOFF = 300 # 5 minutes max backoff def run_idle_loop( @@ -60,23 +61,31 @@ def _idle_session( while not (shutdown_event and shutdown_event.is_set()): client.idle() try: - responses = client.idle_check(timeout=IDLE_TIMEOUT) + # Poll in short intervals so we can check shutdown_event + # between iterations, rather than blocking for the full + # IDLE_TIMEOUT (which causes a hang on Ctrl-C). + responses = [] + deadline = time.monotonic() + IDLE_TIMEOUT + while time.monotonic() < deadline: + if shutdown_event and shutdown_event.is_set(): + break + chunk = client.idle_check(timeout=IDLE_POLL) + if chunk: + responses.extend(chunk) + break # got activity, exit IDLE to process finally: - client.idle_done() + done_result = client.idle_done() + if done_result and done_result[0]: + responses.extend(done_result[0]) if shutdown_event and shutdown_event.is_set(): break - # Check if any EXISTS response indicates new mail - has_new = any( - isinstance(resp, tuple) and len(resp) >= 2 and resp[1] == b"EXISTS" - for resp in responses - ) - - if not has_new: - continue - - # Fetch new messages since last_uid + # Always search for new UIDs, not just when we see EXISTS. + # A message can arrive between idle_done() and the next idle() + # call — the server already sent EXISTS while we weren't + # listening, so we'd never see it. The UID SEARCH is cheap + # and eliminates this race condition entirely. new_uids = _fetch_new_uids(client, last_uid) if not new_uids: continue