# Email, Calendar, Notes: The Personal-Productivity Side

> IMAP/SMTP triage with AI auto-tagging, CalDAV sync to Radicale / Nextcloud / Apple / Fastmail, ntfy-channel reminders, and cron-style tasks the agent can act on — features that lean on background pollers and per-account routing.

- Repository: pewdiepie-archdaemon/odysseus
- GitHub: https://github.com/pewdiepie-archdaemon/odysseus
- Human wiki: https://grok-wiki.com/public/wiki/pewdiepie-archdaemon-odysseus-8b8805c93124
- Complete Markdown: https://grok-wiki.com/public/wiki/pewdiepie-archdaemon-odysseus-8b8805c93124/llms-full.txt

## Source Files

- `routes/email_routes.py`
- `routes/email_pollers.py`
- `routes/email_helpers.py`
- `routes/calendar_routes.py`
- `routes/task_routes.py`
- `routes/note_routes.py`
- `src/caldav_sync.py`
- `src/task_scheduler.py`

---

<details>
<summary>Relevant source files</summary>
The following files were used as context for generating this wiki page:
- [routes/email_routes.py](routes/email_routes.py)
- [routes/email_pollers.py](routes/email_pollers.py)
- [routes/email_helpers.py](routes/email_helpers.py)
- [routes/calendar_routes.py](routes/calendar_routes.py)
- [routes/task_routes.py](routes/task_routes.py)
- [routes/note_routes.py](routes/note_routes.py)
- [src/caldav_sync.py](src/caldav_sync.py)
- [src/task_scheduler.py](src/task_scheduler.py)
- [src/builtin_actions.py](src/builtin_actions.py)
</details>

# Email, Calendar, Notes: The Personal-Productivity Side

Odysseus is a chat-first agent, but a surprising amount of its surface area is a personal-productivity OS welded to the same LLM stack. It talks IMAP/SMTP directly against multiple accounts, pulls CalDAV from Radicale / Nextcloud / Apple / Fastmail into local SQLite, fires reminders through ntfy or back through the user's own SMTP, and runs cron-style "built-in actions" the agent itself can compose with. None of it depends on a hosted service — the credentials live in the user's encrypted prefs, every poller is just an `asyncio.create_task` loop, and the LLM is whichever endpoint the user picked.

The interesting design choice is that each surface (mail, calendar, notes, tasks) is *both* a feature you can use directly in the UI and a tool the agent can call. The same `dispatch_reminder` that the note scanner fires is the same path a chat-tool reminder takes. The same `summarize_emails` action you can schedule via cron is what the AI auto-reply pass calls. That collapsing — UI feature == agent action == cron job — is what makes the productivity side feel coherent rather than four separate apps stapled together.

## What ships in the box

| Surface | Where it lives | Backed by | How the agent reaches it |
|---|---|---|---|
| IMAP inbox + AI triage | `routes/email_routes.py`, `routes/email_pollers.py` | `imaplib` against per-user `EmailAccount` rows | `summarize_emails`, `draft_email_replies`, `extract_email_events`, `check_email_urgency`, `mark_email_boundaries` built-in actions |
| SMTP send + scheduled send | `routes/email_routes.py` `/send`, `/schedule` | `scheduled_emails` SQLite + 30s poller | `_scheduled_poll_once` (also exposed as `odysseus-mail poll-scheduled` CLI) |
| Calendar | `routes/calendar_routes.py` | Local SQLite `CalendarEvent` + CalDAV one-way pull | `do_manage_calendar` tool, `extract_email_events` action |
| CalDAV sync | `src/caldav_sync.py` | `caldav` lib in a threadpool | Triggered on calendar open + periodic scheduler tick |
| Notes / todos / due-date reminders | `routes/note_routes.py` | `Note` rows + per-owner `data/note_pings_{slug}.json` cache | `action_ping_notes` scanner running every 60s |
| Scheduled tasks (cron / event / webhook) | `routes/task_routes.py`, `src/task_scheduler.py` | `ScheduledTask` rows | `TaskScheduler._loop` |

Sources: [src/builtin_actions.py:2138-2179](), [src/task_scheduler.py:186-198](), [routes/email_helpers.py:259-369]()

## Background pollers: three loops, all serial

Three asyncio loops run inside the FastAPI process by default, and all three are guarded by the same `ODYSSEUS_INPROCESS_POLLERS` switch so an operator running cron / systemd timers can opt out without a fork.

```text
                                ┌─ _scheduled_email_poller   (30s tick → SMTP deliver due rows)
 routes/email_pollers.py        │
   _start_poller() ─────────────┤
                                └─ (legacy _auto_summarize_poller — now disabled;
                                    scheduled Tasks own that work — see _launch())

 src/task_scheduler.py
   TaskScheduler.start() ───────┬─ _loop                    (≤60s, woken by next_run)
                                ├─ _note_pings_loop         (60s, owner-iterating)
                                └─ _event_pings_loop        (10min — present but unwired:
                                                            calendar reminders are emitted
                                                            as Notes, so _note_pings_loop is
                                                            the single dispatch path)
```

The scheduler loop is one of the more careful pieces of the codebase. It snapshots `_executing` under a lock so a request handler dispatching a manual run cannot race the periodic sweep ([src/task_scheduler.py:493-511]()), it serializes LLM-using tasks behind `asyncio.Semaphore(1)` while letting pure-infra actions bypass that slot ([src/task_scheduler.py:223-224, 535-540]()), and on startup it marks any leftover `running`/`queued` rows as `aborted` with the reason "Server restarted…" so a crash doesn't leave phantom rows ([src/task_scheduler.py:278-295]()). It also sleeps only until the next due `next_run` capped at 60 s, which is why a `* * * * *` cron actually fires near the minute boundary instead of up to a minute late ([src/task_scheduler.py:470-486]()).

Sources: [routes/email_pollers.py:939-1006](), [src/task_scheduler.py:269-510]()

## The auto-triage pass: one loop, five jobs

`_auto_summarize_pass_single` is the centerpiece of the email side. One IMAP connection, a single `SINCE`-bounded fetch over the last `days_back` days, and then five independent LLM jobs gated by feature flags from `data/settings.json`:

| Flag | What the LLM is asked to do | Side-effect |
|---|---|---|
| `email_auto_summarize` | 1–3 bullet summary fenced by `<<<SUMMARY>>>` markers | Row in `email_summaries` |
| `email_auto_reply` | Draft reply fenced by `<<<REPLY>>>` markers, style-matched to user's writing | Row in `email_ai_replies` |
| `email_auto_tag` / `email_auto_spam` | Classify to a fixed 13-tag set + spam verdict | Row in `email_tags`; if spam and folder detected, IMAP `MOVE` to Junk/Spam |
| `email_auto_calendar` | Decide `create` / `update` / `cancel` / `noop` against next 60 days of events | `do_manage_calendar` invocations + `email_calendar_extractions` row |
| `auto_urgent` (currently force-disabled here; lives in `check_email_urgency` task) | Verdict `critical`/`high`/`medium`/`low`/`none` + send self-alert email | Row in `email_urgency_alerts` |

Three details worth noticing:

**Fenced output, not JSON.** The summary and reply prompts demand the model put its answer between `<<<SUMMARY>>>` / `<<<REPLY>>>` and `<<<END>>>`. `_extract_reply` strips everything outside. This sidesteps the eternal "the model thought out loud before its answer" problem more cleanly than think-tag stripping, and the same extractor doubles for replies and summaries ([routes/email_helpers.py:82-113]()).

**Calendar extraction reads the existing calendar first.** Before asking the LLM to decide what to do with an itinerary email, the pass injects the next 60 days of upcoming events as `EXISTING_EVENTS` JSON so the model can return `action=update` with the existing UID instead of creating a duplicate ([routes/email_pollers.py:365-441]()). It also scans the Sent folder when calendar extraction is on, so a confirmation reply the user wrote propagates ([routes/email_pollers.py:140-148]()).

**Regex-driven detail rescue.** Even when the LLM nails the high-level event, the pass runs a second pass of compiled regexes for meeting links (`teams.microsoft.com`, `zoom.us`, `meet.google.com`, `webex.com`, `meet.jit.si`), tracking URLs (Amazon, FedEx, UPS, DHL, Japan Post), and identifier patterns (meeting IDs, passcodes, PNRs, gates, seats, flight numbers — including Japanese-language variants like `便` and `予約`), then appends them to the description so they survive verbatim ([routes/email_pollers.py:502-549]()).

The whole pass caps itself at 10 processed messages per run, sleeps 1 second between messages, and runs LLM calls via `asyncio.to_thread` so the 240-second model timeouts don't block the event loop ([routes/email_pollers.py:204, 302-305, 802]()).

Sources: [routes/email_pollers.py:114-833](), [routes/email_helpers.py:85-113]()

## Per-account routing without leaking creds

The repo had to grow a multi-account story carefully because the original code stored a single mailbox in `data/settings.json`. The current shape is: `EmailAccount` rows in the database, with passwords run through `secret_storage.encrypt`/`decrypt`. `_get_email_config(account_id, owner)` resolves the right row with a fallback order — explicit ID, then default-flagged, then first-enabled — *always* scoped by `owner` when one is given ([routes/email_helpers.py:456-557]()).

The owner-scoping is the security-relevant part. The same file calls out previous incidents in comments: `_assert_owns_account` exists because a bare `id == account_id` filter let a multi-user deploy enumerate other users' accounts ([routes/email_helpers.py:172-186]()); the IMAP pool key is `(account_id, owner)` because two users both passing `account_id=None` were silently sharing a connection ([routes/email_routes.py:443-498]()); `email_tags` had its primary key promoted from `message_id` alone to `(message_id, owner)` because Message-IDs are globally shared (a newsletter has the same ID for every recipient) and a tag-write for user A's row was clobbering B's ([routes/email_helpers.py:303-361]()).

The same `_get_email_config` is what the note reminder dispatcher uses to find an SMTP route, with a settings-level `reminder_email_account_id` letting users pick *which* account reminders go through when they have several configured ([routes/note_routes.py:274-299]()).

Sources: [routes/email_helpers.py:154-186, 456-557](), [routes/email_routes.py:419-498]()

## CalDAV: one-way pull, idempotent on URL hash

CalDAV sync is deliberately scoped down. It's a one-way pull (remote → local SQLite), it uses the `caldav` Python lib through `asyncio.to_thread` so the FastAPI loop stays free, and the local row id is `caldav-<sha256(remote_url)[:24]>` so re-syncs always target the same row ([src/caldav_sync.py:40-44]()).

The discovery handshake tries PROPFIND → principal → calendars first, then falls back to treating the URL as a direct calendar reference — which is the difference between "user pasted the server root" and "user pasted a specific calendar collection" ([src/caldav_sync.py:75-95]()). The fetch window is 90 days back to 1 year forward, which keeps the REPORT cheap; far-future recurring events still render through frontend RRULE expansion ([src/caldav_sync.py:33-37, 97-98]()). VEVENT UIDs are the upsert key, and any locally-cached CalDAV-sourced event whose UID didn't appear in the latest pull within the window gets deleted so remote deletions propagate ([src/caldav_sync.py:185-225]()).

Datetime handling is the bit that bites most CalDAV clients: tz-aware datetimes get converted to UTC and stored naive with `is_utc=True` so the serializer adds the `Z` suffix and the frontend renders in the user's local timezone correctly; all-day `date` values get widened to `datetime` and stay flag-free ([src/caldav_sync.py:47-56, 168-174]()).

The `/api/calendar/test` route is also worth a note: it issues an actual PROPFIND with a real DAV XML body and translates the HTTP codes back into user-readable strings ("Auth failed — check username/password", "Forbidden — user can't access that URL"), accepting an un-saved body so the user can validate a configuration before storing the password ([routes/calendar_routes.py:438-493]()).

Sources: [src/caldav_sync.py:1-256](), [routes/calendar_routes.py:392-499]()

## The reminder fanout: browser, email, ntfy — and dedupe

```text
 Note.due_date hits its ±90s window
              │
              ▼
   action_ping_notes(owner)            ──┐
   (src/builtin_actions.py)              │  All three paths converge on
              │                          │  dispatch_reminder(...) and
              ▼                          │  share data/note_pings_{slug}.json
   dispatch_reminder(title, body, id) ──┘
              │
   reads settings["reminder_channel"]:
              │
       ┌──────┴──────┬──────────────┐
       ▼             ▼              ▼
   "browser"     "email"          "ntfy"
   in-mem        SMTP via         POST to
   queue +       resolved         {integrations.ntfy.base_url}/{topic}
   frontend      EmailAccount     with Priority:high, Tags:bell
   Notification  + reminder_      header
                 email_to
```

A single reminder always pushes to the in-app notification queue regardless of channel — the user might be looking at the tab when the email arrives, and the toast confirms the reminder fired ([routes/note_routes.py:393-411]()). The dedupe state is a JSON file keyed per-owner (`data/note_pings_{owner_slug}.json`); both the route-level dispatcher and the background scanner write to it, and the entry remembers which channel it last fired on so a failed email send can be retried by the next scanner tick instead of being silently muted by a frontend-only entry ([routes/note_routes.py:139-174, 418-441](), [src/builtin_actions.py:1436-1452]()).

Calendar reminders deliberately go through this same path: the scheduler exposes `_event_pings_loop` but it's not started, with a comment explaining that calendar reminders are represented as Notes by the calendar UI, so the Notes scanner is the single dispatch path — running both produced duplicate emails for the same event ([src/task_scheduler.py:341-347, 411-429]()).

The ntfy integration is read out of `data/integrations.json` rather than its own settings stanza: any enabled integration with `preset == "ntfy"` and a `base_url` qualifies, and an `api_key` (if set) becomes `Authorization: Bearer …` ([routes/note_routes.py:367-391]()). Topic defaults to `"reminders"` but is configurable via `reminder_ntfy_topic`.

Sources: [routes/note_routes.py:111-450](), [src/builtin_actions.py:1421-1565]()

## The cron grammar: more than "every Wednesday at 9"

`ScheduledTask` supports five schedule shapes — `once`, `daily`, `weekly`, `monthly`, and full `cron` via `croniter` — plus three trigger types: `schedule`, `event` (fired off the in-process event bus when N matching events accumulate), and `webhook` (a tokenized POST endpoint at `/api/tasks/{id}/webhook/{token}`) ([routes/task_routes.py:21-99](), [src/task_scheduler.py:62-165]()).

The interesting wrinkle is timezone handling. `compute_next_run` takes an IANA `tz_name`, interprets `scheduled_time` as wall-clock in that zone, and converts the resulting datetime to naive UTC for storage. Without a tz the legacy naive-UTC behavior is preserved so existing tasks don't shift ([src/task_scheduler.py:62-103, 130-163]()). The timezone is sourced from the linked `CrewMember.timezone` if any — i.e., scheduling is per-persona, not per-server ([src/task_scheduler.py:168-179]()).

Three task types are dispatched:

- **`action`** — calls into `BUILTIN_ACTIONS[task.action]`. No LLM. This is how `summarize_emails`, `extract_email_events`, `classify_events`, `check_email_urgency`, and friends run on a `cron_expression` like `"0 */2 * * *"` ([src/task_scheduler.py:856-877](), [src/builtin_actions.py:2138-2159]()).
- **`research`** — drives the deep-research pipeline.
- **`llm`** — runs through the agent loop with full tool access; this is what a user-written prompt-task becomes.

`HOUSEKEEPING_DEFAULTS` seeds each owner with a canonical set of built-in tasks (Email Summary, Email AI Auto Reply, Email Calendar Events, Calendar Classify Events, Email Mark Boundaries, Email Tags, etc.). The UI flags `is_builtin` and `is_modified` so a user who tweaked a default can revert ([src/task_scheduler.py:182-198](), [routes/task_routes.py:101-117]()).

Sources: [src/task_scheduler.py:62-198, 535-877](), [routes/task_routes.py:21-117]()

## Scheduled send: the SMTP path that survives a restart

The `POST /api/email/schedule` endpoint isn't a wrapper around the task scheduler — it's a separate SQLite table (`scheduled_emails`) drained by its own 30-second poller. The handler validates `send_at` against now (with a 30s grace), refuses past timestamps to stop the poller from immediately firing `1970-01-01` mistakes, and stamps the row with the originating `account_id` and an `odysseus_kind` tag that flows through to the eventual `X-Odysseus-Kind` header ([routes/email_routes.py:1841-1895]()).

`_scheduled_poll_once` builds a multipart/alternative message (mixed if attachments are present), appends to the user's IMAP Sent folder so the message appears there, marks the row `sent` (or `failed` with the error), and is also exposed as a `odysseus-mail poll-scheduled` CLI for cron-driven deployments — set `ODYSSEUS_INPROCESS_POLLERS=0` so the two don't race on the same SQLite ([routes/email_pollers.py:848-980]()).

Sources: [routes/email_pollers.py:848-980](), [routes/email_routes.py:1841-1932]()

## What builders should notice

A few patterns are reusable beyond Odysseus:

- **Same path for "feature" and "action."** `dispatch_reminder` is what the note scanner calls, what the calendar feature ultimately reaches, and what an agent tool could call. The reminder dedupe cache lives on disk in the same shape regardless of who fired the reminder, which is why "fire from UI, then again from scanner 2 min later" can't double-send.
- **Fenced LLM output beats JSON-only.** `<<<REPLY>>>...<<<END>>>` survives chain-of-thought leakage better than asking for clean JSON, and the same extractor handles summaries and replies. Compare to the calendar-extraction prompt which *does* require JSON — and which has fallback regex matching against `r'\[\s*\{[^[\]]*?"action"…'` for when the model wraps the array in commentary anyway ([routes/email_pollers.py:443-449]()).
- **Owner-scope every fallback.** Almost every security comment in this subsystem is variations on the same theme: a "default" lookup that was fine for single-user installs ended up leaking to a second user. The fix is always passing `owner` into the resolution function and OR-matching legacy null-owner rows by their mailbox identity, not by trust ([routes/email_helpers.py:478-484]()).
- **Idempotent IDs for external state.** `caldav-<sha256(url)[:24]>` for remote calendar rows, VEVENT `UID` for events, `(folder, uid)`-hashed `<synth-…@local>` for messages missing a Message-ID ([src/caldav_sync.py:40-44](), [routes/email_pollers.py:222-228]()) — every "I might re-sync this thing" surface picks a deterministic key.
- **One semaphore makes the agent loop sane.** A single shared `Semaphore(1)` for LLM tasks plus a bypass for pure-infra actions means the scheduler can dispatch as many concurrent things as it wants without ever running two model calls at once on the same machine — the only exception is the `ping_notes` scanner, which is allowed to fire reminders out-of-band because it doesn't touch the model ([src/task_scheduler.py:223-224, 535-540]()).

What's *not* here is also instructive: there is no hosted-service dependency, no API key for "the Odysseus cloud," no per-vendor SDK. CalDAV is the protocol it actually speaks; ntfy is an HTTP POST it just makes; SMTP/IMAP are stdlib `imaplib`/`smtplib`. The LLM resolution goes through `resolve_endpoint("utility")` which lets the user point at OpenAI, an Ollama box on their LAN, or anything else with a chat-completions shape — so the whole productivity side is provider-neutral by construction.

Sources: [routes/email_pollers.py:188-194](), [src/caldav_sync.py:1-23](), [routes/note_routes.py:365-391]()
