Day Captain is a Python service that builds a daily Microsoft 365 digest from Outlook mail and calendar data.
flowchart LR
TargetMailbox[Target User Mailbox] --> Graph[Microsoft Graph]
Calendar[Target User Calendar] --> Graph
Graph --> Auth[Entra App-Only or Delegated Auth]
Auth --> App[Day Captain App]
App --> Score[Scoring and Filtering]
Score --> LLM[Bounded LLM Layer]
LLM --> Render[Digest Renderer]
Render --> Json[JSON or CLI Output]
Render --> Delivery[Graph Delivery]
Delivery --> SenderMailbox[daycaptain Shared Mailbox]
SenderMailbox --> Recipient[Target User Inbox]
UserCommand[Email Command: recall or recall-week] --> SenderMailbox
SenderMailbox --> CommandTrigger[Power Automate Shared Mailbox Trigger]
CommandTrigger --> App
Scheduler[GitHub Actions or manual hosted trigger] --> Web[HTTP or CLI Entry]
Web --> App
App --> Storage[(SQLite or Postgres)]
Feedback[Feedback and Preferences] --> Storage
Storage --> App
It currently supports:
- delegated Microsoft Graph auth through Microsoft Entra ID device code flow
- message and meeting ingestion from Graph
- deterministic scoring and anti-noise filtering
- optional bounded LLM wording on shortlisted digest items with deterministic fallback
- optional bounded top-of-digest summary block with deterministic fallback
- digest generation with
critical_topics,actions_to_take,watch_items, andupcoming_meetings - persisted runs, feedback, and preferences
- local CLI usage
- a minimal hosted HTTP surface for Render
Current package version: 1.9.4
This repository is in active development. The core digest flow works locally and against a real Microsoft 365 mailbox. The hosted Render path is scaffolded, and a dedicated hardening track exists in Logics before treating it as production-ready.
Current operating model:
- local runs still default to one mailbox at a time
- the roadmap now explicitly targets one company tenant with multiple users, each receiving a separate digest
- tenant-scoped storage and explicit per-user execution are implemented for operator-managed multi-user hosting
- the remaining Logics work is now mainly production hardening and hosted operational proof for that model
- the current open hardening track includes hosted Graph trust-boundary enforcement and shared-secret validation tightening
src/day_captain/: application codetests/: unit and integration-style testschangelogs/: versioned changelog artifacts (CHANGELOGS_x_y_z.md)logics/: request, backlog, specs, and task trackingrender.yaml: Render deployment blueprint.github/workflows/: CI and example hosted trigger workflowsdocs/assets/: shared documentation assets such as the project logoCONTRIBUTING.md: contributor workflow and validation expectationsLICENSE: current repository license terms
Recommended repository split:
day-captain: application source codeday-captain-ops: private GitHub repository for production scheduling, deployment orchestration, and secrets
Planned operating model:
- one Microsoft 365 company tenant
- several explicitly configured users/mailboxes inside that tenant
- one digest run per target user
- strict tenant-scoped and user-scoped data isolation
config.py: environment-driven settingsapp.py: application assembly and main digest flowadapters/auth.py: Microsoft Entra device code auth and token cacheadapters/graph.py: Microsoft Graph mail/calendar adaptersadapters/storage.py:SQLiteand Postgres-backed persistenceservices.py: scoring, filtering, digest rendering, recall, and feedback logicweb.py: hosted HTTP endpoints for health, morning digest, and recallcli.py: command-line entrypoints
Day Captain now reserves repository-level changelog artifacts under changelogs/ using the pattern CHANGELOGS_x_y_z.md.
The intended workflow is:
- close delivery work first
- resolve the real current version from
pyproject.toml - generate the changelog artifact at closure time instead of guessing the filename in advance
Generate a scaffold with:
python3 scripts/generate_changelog.py --previous-version 1.4.2- Python
3.9+ - a Microsoft Entra app registration for Graph delegated auth
- Graph delegated permissions:
User.ReadMail.ReadCalendars.Read- optionally
Mail.Send
python3 -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"Start from .env.example.
Local development typically uses:
DAY_CAPTAIN_ENV=development
DAY_CAPTAIN_SQLITE_PATH=day_captain.sqlite3
DAY_CAPTAIN_DELIVERY_MODE=json
DAY_CAPTAIN_GRAPH_TENANT_ID=common
DAY_CAPTAIN_GRAPH_CLIENT_ID=your-app-client-id
DAY_CAPTAIN_GRAPH_AUTH_CACHE_PATH=.day_captain_auth.json
DAY_CAPTAIN_GRAPH_SCOPES=User.Read,Mail.Read,Calendars.Read,Mail.Send
DAY_CAPTAIN_DISPLAY_TIMEZONE=Europe/Paris
DAY_CAPTAIN_DIGEST_LANGUAGE=en
DAY_CAPTAIN_LLM_LANGUAGE=
DAY_CAPTAIN_WEATHER_LATITUDE=
DAY_CAPTAIN_WEATHER_LONGITUDE=
DAY_CAPTAIN_WEATHER_LOCATION_NAME=
DAY_CAPTAIN_LLM_PROVIDER=disabled
DAY_CAPTAIN_LLM_MODEL=
DAY_CAPTAIN_LLM_API_KEY=Hosted deployment typically uses:
DAY_CAPTAIN_ENV=production
DAY_CAPTAIN_DATABASE_URL=postgresql://...
DAY_CAPTAIN_JOB_SECRET=...
DAY_CAPTAIN_DELIVERY_MODE=graph_send
DAY_CAPTAIN_GRAPH_AUTH_MODE=app_only
DAY_CAPTAIN_GRAPH_CLIENT_ID=...
DAY_CAPTAIN_GRAPH_CLIENT_SECRET=...
DAY_CAPTAIN_GRAPH_TENANT_ID=...
DAY_CAPTAIN_TARGET_USERS=alice@example.com,bob@example.com
DAY_CAPTAIN_GRAPH_SENDER_USER_ID=daycaptain@example.com
DAY_CAPTAIN_EMAIL_COMMAND_ALLOWED_SENDERS=assistant@example.com=alice@example.com
DAY_CAPTAIN_GRAPH_SEND_ENABLED=true
DAY_CAPTAIN_DISPLAY_TIMEZONE=Europe/Paris
DAY_CAPTAIN_DIGEST_LANGUAGE=en
DAY_CAPTAIN_LLM_LANGUAGE=en
DAY_CAPTAIN_WEATHER_LATITUDE=48.8566
DAY_CAPTAIN_WEATHER_LONGITUDE=2.3522
DAY_CAPTAIN_WEATHER_LOCATION_NAME=Paris
DAY_CAPTAIN_LLM_PROVIDER=openai
DAY_CAPTAIN_LLM_MODEL=gpt-5-mini
DAY_CAPTAIN_LLM_API_KEY=...Important hosted note:
- hosted runs are now tenant-scoped and user-scoped, with one explicit target user per execution
- configure explicit recipients with
DAY_CAPTAIN_TARGET_USERS DAY_CAPTAIN_GRAPH_SENDER_USER_IDcan send mail from a dedicated mailbox such asdaycaptain@company.comwhile reads stay scoped to the selected target mailboxDAY_CAPTAIN_EMAIL_COMMAND_ALLOWED_SENDERScan allow a bounded helper sender set for inbound email-command recall; in single-target setups bare senders still work, while multi-user setups must use explicitsender=targetmappingsDAY_CAPTAIN_GRAPH_USER_IDremains supported as a single-user fallback and default target- hosted Graph auth now supports an explicit
DAY_CAPTAIN_GRAPH_AUTH_MODE=app_onlypath for unattended environments - absolute Graph pagination links are trusted only when they stay on the same origin as
DAY_CAPTAIN_GRAPH_BASE_URL; unexpected hosts fail boundedly instead of receiving the bearer token - weather is optional and enabled only when both
DAY_CAPTAIN_WEATHER_LATITUDEandDAY_CAPTAIN_WEATHER_LONGITUDEare configured;DAY_CAPTAIN_WEATHER_LOCATION_NAMEcontrols the capsule label shown in the digest
Important:
- never commit
.env - never commit Graph access or refresh tokens
- never commit LLM API keys
- local token cache and local databases are already git-ignored
The digest still uses deterministic scoring and guardrails to decide what matters.
If DAY_CAPTAIN_LLM_PROVIDER is enabled, Day Captain sends only a bounded shortlist of already-prioritized digest items to an OpenAI-compatible chat-completions endpoint to improve summary wording. If the provider is disabled, misconfigured, or fails at runtime, the app falls back to the deterministic summaries already present in the scored items.
You can constrain that wording pass with DAY_CAPTAIN_LLM_ENABLED_SECTIONS, steer the tone with DAY_CAPTAIN_LLM_STYLE_PROMPT, and force the wording language with DAY_CAPTAIN_LLM_LANGUAGE. If DAY_CAPTAIN_LLM_LANGUAGE is unset, it falls back to DAY_CAPTAIN_DIGEST_LANGUAGE.
The digest can also render a top summary block above the detailed sections. That summary is built only from the final digest content, is no longer forcibly truncated by app policy, and falls back to a deterministic overview if the LLM path is disabled or fails.
The delivered digest now supports:
- localized product copy through
DAY_CAPTAIN_DIGEST_LANGUAGEwith English default and French support - a condensed header with explicit as-of/window metadata and a more polished coverage line instead of verbose report phrasing
- a highlighted
In brief/En brefexecutive summary block above the detailed sections - a full
In brief/En brefblock that is no longer forcibly shortened by the application when the summary remains useful but long - an optional weather capsule before
In brief/En bref, including a simple warmer/cooler-than-yesterday signal when weather data is configured - stronger prominence for flagged messages through scoring promotion and a dedicated badge in text and HTML rendering
- compact meeting cards that keep time, organizer, and location easy to scan with more natural day-horizon wording
- lighter empty-state presentation even when the LLM layer is disabled
- lighter hero/card visual treatment than the first readability pass
- optional footer quick actions using
mailto:links that open a prefilled draft for recall commands, with the command repeated in subject and body when a command mailbox is known - source-open controls that keep Outlook web links as the reliable baseline and prefer an explicit desktop protocol link only when a native Outlook link is already available in the source metadata
- weekend meeting fallback to Monday and next-day meeting fallback when no meetings remain for the current day
- first-run
morning-digestmail fallback to Friday00:00inDAY_CAPTAIN_DISPLAY_TIMEZONEon Saturday, Sunday, and Monday; repeated runs stay incremental
For the current local-preview and final Outlook validation workflow, see digest_rendering_validation.md.
Local delegated workflow:
- Create an Entra app registration.
- Enable public client flows.
- Add delegated Microsoft Graph permissions.
- Export your local env vars.
- Run:
set -a
source .env
set +a
PYTHONPATH=src python3 -m day_captain auth loginUseful auth commands:
PYTHONPATH=src python3 -m day_captain auth status
PYTHONPATH=src python3 -m day_captain auth login
PYTHONPATH=src python3 -m day_captain auth logoutValidate the current runtime configuration:
PYTHONPATH=src python3 -m day_captain validate-config
PYTHONPATH=src python3 -m day_captain validate-config --target-user alice@example.comValidate a deployed hosted service end to end:
DAY_CAPTAIN_SERVICE_URL=https://your-render-service.example.com \
DAY_CAPTAIN_JOB_SECRET=... \
PYTHONPATH=src python3 -m day_captain validate-hosted-service \
--target-user alice@example.com \
--wake-service \
--wake-timeout-seconds 45 \
--wake-max-attempts 6 \
--wake-delay-seconds 10 \
--timeout-seconds 90 \
--expect-graph-auth-mode app_only \
--expect-storage-backend postgresCheck or warm the hosted service without triggering a digest:
DAY_CAPTAIN_SERVICE_URL=https://your-render-service.example.com \
DAY_CAPTAIN_JOB_SECRET=... \
PYTHONPATH=src python3 -m day_captain check-hosted-health \
--wake-service \
--wake-timeout-seconds 45 \
--wake-max-attempts 6 \
--wake-delay-seconds 10 \
--expect-graph-auth-mode app_only \
--expect-storage-backend postgresIf the hosted web service can sleep between runs, treat the first request as a wake-up step rather than assuming the backend is already warm. In that case:
- prefer
--wake-serviceso the tooling probesGET /healthzbefore the real morning trigger - if you schedule several user-specific runs, prefer one standalone
check-hosted-health --wake-servicestep before the fan-out - use longer timeouts in the private ops repo than you would on an always-on service
- treat this as a fallback operating mode, not the preferred production posture
Recommended scheduler split:
- use
check-hosted-healthfor a standalone readiness step - use
trigger-hosted-job --job morning-digestfor the routine weekday cron, with a default target time of09:00 Europe/Paris - use
trigger-hosted-job --job weekly-digestfor the Sunday-evening weekly recap cron - reserve
validate-hosted-servicefor manual checks, rollout validation, or pre-cron verification
If you add Mail.Send or change delegated scopes, rerun PYTHONPATH=src python3 -m day_captain auth login so the cached token is refreshed with the new consented scope set.
When delivery_mode=graph_send, the current local delegated flow sends through POST /me/sendMail. If the rendered message does not already include recipients, the app defaults to the authenticated mailbox address returned by the Graph profile.
Hosted delivery recovery semantics:
delivery_failedmeans Graph prerequisites or delivery failed before acceptance was likely, so a later retry is allowed.delivery_pendingis reserved for uncertain post-send reconciliation, where delivery may already have happened and duplicate sends must still be blocked.email-command-recallfollows the same rule: pre-send failures are retryable, but uncertain post-send outcomes stay deduplicated until reconciled.
Hosted app-only workflow:
- set
DAY_CAPTAIN_GRAPH_AUTH_MODE=app_only - provide
DAY_CAPTAIN_GRAPH_CLIENT_ID - provide
DAY_CAPTAIN_GRAPH_CLIENT_SECRET - provide
DAY_CAPTAIN_GRAPH_TENANT_ID - provide
DAY_CAPTAIN_TARGET_USERS - optionally provide
DAY_CAPTAIN_GRAPH_SENDER_USER_IDfor a dedicated sender mailbox - optionally provide
DAY_CAPTAIN_EMAIL_COMMAND_ALLOWED_SENDERSto enable bounded inbound command senders - grant the corresponding Graph application permissions in Entra
Hosted email-command-recall contract:
- treat the feature as enabled only when
DAY_CAPTAIN_EMAIL_COMMAND_ALLOWED_SENDERSis configured - require
DAY_CAPTAIN_GRAPH_AUTH_MODE=app_only - require
DAY_CAPTAIN_GRAPH_SEND_ENABLED=true - sender validation is deterministic:
- there is no implicit self-sender fallback when the env var is empty
- in a single-target deployment, list the allowed sender explicitly, for example
alice@company.comorassistant@company.com - in a multi-user deployment, helper senders must use explicit
sender=targetmappings such asassistant@company.com=alice@example.com - ambiguous helper mappings are rejected explicitly rather than guessed
In hosted app-only mode, Day Captain targets explicit /users/{id} routes for mailbox reads, calendar reads, and sendMail instead of relying on a permanent /me identity. When several users are configured, each run must choose one explicit target user. If DAY_CAPTAIN_GRAPH_SENDER_USER_ID is set, reads still target the selected mailbox but sendMail is routed through the dedicated sender mailbox instead.
For the first inbound email-command bridge, the recommended operator path is currently Power Automate against the shared mailbox trigger rather than a custom Graph webhook. See power_automate_shared_mailbox_recall_setup.md.
Run a digest directly:
set -a
source .env
set +a
PYTHONPATH=src python3 -m day_captain morning-digest --forceRun a digest for one configured hosted target:
PYTHONPATH=src python3 -m day_captain morning-digest --force --target-user alice@example.comExport the rendered digest locally for manual review:
PYTHONPATH=src python3 -m day_captain morning-digest \
--preview \
--force \
--output-html tmp/day-captain-preview.html \
--output-text tmp/day-captain-preview.txtThat preview flow is documented in digest_rendering_validation.md.
In development, this can also be used as a layout-only stub preview before live Graph auth is configured.
Use --preview when you want a guaranteed no-send local render; --output-html and --output-text only control file export.
Run a weekly digest directly:
PYTHONPATH=src python3 -m day_captain weekly-digest --target-user alice@example.comRecall the latest digest:
PYTHONPATH=src python3 -m day_captain recall-digestRecall a specific configured target:
PYTHONPATH=src python3 -m day_captain recall-digest --target-user alice@example.comProcess an inbound email command locally:
PYTHONPATH=src python3 -m day_captain email-command-recall \
--message-id inbound-123 \
--sender-address alice@example.com \
--subject recall-weekRecord feedback:
PYTHONPATH=src python3 -m day_captain record-feedback \
--run-id RUN_ID \
--source-kind message \
--source-id MESSAGE_ID \
--signal-type useful \
--signal-value true \
--target-user alice@example.comStart the local web service:
set -a
source .env
set +a
PYTHONPATH=src python3 -m day_captain serveHealthcheck:
curl http://127.0.0.1:8000/healthzProtected runtime summary for hosted validation:
curl http://127.0.0.1:8000/healthz \
-H "X-Day-Captain-Secret: $DAY_CAPTAIN_JOB_SECRET"Trigger a digest through the HTTP endpoint:
curl -X POST http://127.0.0.1:8000/jobs/morning-digest \
-H "Content-Type: application/json" \
-H "X-Day-Captain-Secret: $DAY_CAPTAIN_JOB_SECRET" \
-d '{"force": true}'Hosted job payloads should use real JSON booleans for fields such as force; do not send quoted strings like "false".
Trigger one configured target user explicitly:
curl -X POST http://127.0.0.1:8000/jobs/morning-digest \
-H "Content-Type: application/json" \
-H "X-Day-Captain-Secret: $DAY_CAPTAIN_JOB_SECRET" \
-d '{"force": false, "target_user_id": "alice@example.com"}'Trigger a weekly digest through the HTTP endpoint:
curl -X POST http://127.0.0.1:8000/jobs/weekly-digest \
-H "Content-Type: application/json" \
-H "X-Day-Captain-Secret: $DAY_CAPTAIN_JOB_SECRET" \
-d '{"target_user_id": "alice@example.com"}'Recall through HTTP:
curl -X POST http://127.0.0.1:8000/jobs/recall-digest \
-H "Content-Type: application/json" \
-H "X-Day-Captain-Secret: $DAY_CAPTAIN_JOB_SECRET" \
-d '{}'Process an inbound email command through HTTP:
curl -X POST http://127.0.0.1:8000/jobs/email-command-recall \
-H "Content-Type: application/json" \
-H "X-Day-Captain-Secret: $DAY_CAPTAIN_JOB_SECRET" \
-d '{"command_message_id":"inbound-123","sender_address":"alice@example.com","subject":"recall-week"}'Day Captain now supports a bounded inbound command surface intended to be fed later by a Graph webhook, a polling job, or an external Microsoft 365 automation.
Supported commands:
recallrecall-todayrecall-week
Behavior:
recallandrecall-todaygenerate a digest for the current local day.recall-weekgenerates a digest from Monday00:00through now inDAY_CAPTAIN_DISPLAY_TIMEZONE.- duplicate inbound events are suppressed by
command_message_id. - sender validation is deterministic:
- the feature is disabled unless
DAY_CAPTAIN_EMAIL_COMMAND_ALLOWED_SENDERSis configured - in a single-target deployment,
DAY_CAPTAIN_EMAIL_COMMAND_ALLOWED_SENDERSmust explicitly list the allowed sender such asalice@company.comorassistant@company.com - in a multi-user deployment, helper senders must be declared as explicit mappings such as
assistant@company.com=alice@example.com
- the feature is disabled unless
Hosted trigger tooling also supports this path:
DAY_CAPTAIN_SERVICE_URL=https://your-render-service.example.com \
DAY_CAPTAIN_JOB_SECRET=... \
PYTHONPATH=src python3 -m day_captain trigger-hosted-job \
--job email-command-recall \
--message-id inbound-123 \
--sender-address alice@example.com \
--command-text recall-weekAnd hosted validation can now include it:
DAY_CAPTAIN_SERVICE_URL=https://your-render-service.example.com \
DAY_CAPTAIN_JOB_SECRET=... \
PYTHONPATH=src python3 -m day_captain validate-hosted-service \
--target-user alice@example.com \
--check-email-command \
--email-command-sender alice@example.com \
--email-command-text recall-weekRun the full test suite:
python3 -m unittest discover -s testsRun targeted tests:
python3 -m unittest tests.test_scoring
python3 -m unittest tests.test_web
python3 -m unittest tests.test_graph_clientCurrent persistence covers tenant-scoped and user-scoped tables for:
messagesmeetingsdigest_runsdigest_itemsfeedbackpreferences
Current model:
- one deployment serves one Microsoft 365 tenant
- digest data is partitioned by
tenant_idanduser_id - only users listed in
DAY_CAPTAIN_TARGET_USERSare valid hosted recipients by default
Local mode uses SQLite.
Hosted mode is wired for Postgres through DAY_CAPTAIN_DATABASE_URL.
The repository includes render.yaml for a first hosted deployment path:
- Render web service
- Render Postgres
gunicorn --worker-class gthread --threads 4 --timeout 90 --bind 0.0.0.0:$PORT "day_captain.web:create_web_app()"/healthzhealthcheck
Expected hosted secrets/config include:
DAY_CAPTAIN_DATABASE_URLDAY_CAPTAIN_JOB_SECRET- Graph / Entra settings
- optional
DAY_CAPTAIN_GRAPH_SENDER_USER_ID - optional
DAY_CAPTAIN_EMAIL_COMMAND_ALLOWED_SENDERS
When X-Day-Captain-Secret is supplied to GET /healthz, the service also returns a runtime summary with the resolved auth mode, storage backend, target-user count, and delivery configuration. This is intended for private ops validation, not public monitoring.
The hosted API contract still uses the same X-Day-Captain-Secret header over TLS; recent hardening only tightens server-side comparison and Graph pagination trust boundaries without changing the operator-facing request shape.
This repository currently includes two workflow categories:
- CI checks
- manual example hosted triggers for the morning digest and weekly digest
The example scheduler workflows are in:
They are intentionally workflow_dispatch only in this repository. Scheduled production triggers belong in the private ops repository, not in the application repository.
They expect:
DAY_CAPTAIN_SERVICE_URLDAY_CAPTAIN_JOB_SECRET- optional
DAY_CAPTAIN_TARGET_USERS_JSONrepository variable for manual multi-user fan-out
For the operator workflow used by the bounded multi-user model, see tenant_scoped_multi_user_operator_guide.md.
For the private production scheduling repo shape, see private_ops_repo_bootstrap.md.
Recommended production setup:
- keep CI here if you want public validation
- move real scheduling and production secrets into a private
day-captain-opsrepository - keep the public example workflows manual-only in this repo
- let the private repo trigger the hosted Day Captain service over HTTPS using
scripts/trigger_hosted_digest.pyorday-captain trigger-hosted-job - keep weekday
morning-digestauto-send separate from the Sunday-eveningweekly-digestscheduler contract - for the Sunday weekly recap, use a jitter-tolerant gate in the private ops workflow instead of relying on an exact GitHub
scheduleminute match; the copy-ready weekly scheduler template already follows that model - the shipped weekly scheduler templates are expected to stay aligned with the shared
day_captain.scheduler.should_run_sunday_weekly_digestgate helper
Fallback if the hosted service sleeps:
- add
--wake-servicein the private ops workflow before the real job trigger or validation path - use bounded
--wake-max-attemptsand--wake-delay-secondsuntilGET /healthzsucceeds - use longer timeouts for the real trigger and validation path
- do not treat this as equivalent to an always-on paid service for strict production reliability
The hosted path exists, but a separate hardening track is still open in Logics:
req_001_day_captain_hosted_security_hardening.mditem_001_day_captain_hosted_security_hardening.mdtask_004_day_captain_hosted_security_hardening.md
Treat the current Render deployment path as staging-quality until that hardening task is implemented.
Main product chain:
req_000_day_captain_daily_assistant_for_microsoft_365.mditem_000_day_captain_daily_assistant_for_microsoft_365.mdtask_003_day_captain_render_deployment_and_scheduler.md
- harden the hosted security path
- validate a real Render deployment end to end
- switch the hosted scheduler to a production-safe operating mode
- continue tuning scoring and feedback behavior on real mailbox data