This guide covers the bounded operator-managed multi-user model shipped in Day Captain.
- One deployment serves one Microsoft 365 tenant.
- Users are explicit targets, not auto-discovered recipients.
- Each digest run executes for exactly one
target_user_id. - Persistence is partitioned by
tenant_idanduser_id.
DAY_CAPTAIN_ENV=productionDAY_CAPTAIN_DATABASE_URL=postgresql://...DAY_CAPTAIN_JOB_SECRET=...DAY_CAPTAIN_GRAPH_AUTH_MODE=app_onlyDAY_CAPTAIN_GRAPH_TENANT_ID=...DAY_CAPTAIN_GRAPH_CLIENT_ID=...DAY_CAPTAIN_GRAPH_CLIENT_SECRET=...DAY_CAPTAIN_TARGET_USERS=["alice@example.com","bob@example.com"]is not used directly by the app. Keep the app env var as CSV:DAY_CAPTAIN_TARGET_USERS=alice@example.com,bob@example.com
- optional
DAY_CAPTAIN_GRAPH_SENDER_USER_ID=daycaptain@example.comwhen delivery should come from a dedicated shared mailbox - optional
DAY_CAPTAIN_EMAIL_COMMAND_ALLOWED_SENDERS=assistant@example.com=alice@example.comwhen inbound mail commands should be accepted from a bounded helper sender set DAY_CAPTAIN_GRAPH_SEND_ENABLED=true
- Confirm the user mailbox is inside the configured tenant.
- Add the mailbox identifier to
DAY_CAPTAIN_TARGET_USERS. - Ensure the Graph application permissions still cover the mailbox read and send operations.
- Trigger a single-user validation run for the new target before adding it to automated schedules.
- Use
DAY_CAPTAIN_GRAPH_SENDER_USER_ID=daycaptain@example.comwhen the digest should be sent from a shared mailbox such asdaycaptain@.... - In that mode, Day Captain still reads Outlook mail and calendar data from the selected
target_user_id. - Delivery is routed through
/users/{sender}/sendMail, so confirm the dedicated mailbox exists and the app-only Graph permissions cover it. - Keep the target user explicit in hosted runs; otherwise the sender mailbox is not enough to infer which mailbox should be analyzed.
- The shipped command surface is bounded to
recall,recall-today, andrecall-week. recallandrecall-todaygenerate a digest for the current day inDAY_CAPTAIN_DISPLAY_TIMEZONE.recall-weekgenerates a digest from Monday00:00through now inDAY_CAPTAIN_DISPLAY_TIMEZONE.- Sender resolution is strict:
- the feature is disabled unless
DAY_CAPTAIN_EMAIL_COMMAND_ALLOWED_SENDERSis configured - single-user deployments must explicitly list allowed senders through
DAY_CAPTAIN_EMAIL_COMMAND_ALLOWED_SENDERS - multi-user deployments may authorize helper senders only through explicit
sender=targetmappings inDAY_CAPTAIN_EMAIL_COMMAND_ALLOWED_SENDERS
- the feature is disabled unless
- Duplicate suppression is keyed by inbound
command_message_id, so replaying the same inbound message should not regenerate a second digest. - The first recommended transport bridge is Power Automate on top of the shared mailbox trigger. See
power_automate_shared_mailbox_recall_setup.md.
Validate the hosted env contract before triggering runs:
PYTHONPATH=src python3 -m day_captain validate-config
PYTHONPATH=src python3 -m day_captain validate-config --target-user alice@example.comRun a single target through the CLI:
PYTHONPATH=src python3 -m day_captain morning-digest --force --target-user alice@example.comRecall the latest digest for one target:
PYTHONPATH=src python3 -m day_captain recall-digest --target-user alice@example.comTrigger the hosted job directly:
curl -X POST "$DAY_CAPTAIN_SERVICE_URL/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"}'Run the hosted validation flow:
DAY_CAPTAIN_SERVICE_URL=... \
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 postgresRun the hosted validation flow including inbound email-command recall:
DAY_CAPTAIN_SERVICE_URL=... \
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 postgres \
--check-email-command \
--email-command-sender alice@example.com \
--email-command-text recall-weekIf the hosted service can sleep between runs, use --wake-service instead of assuming the first scheduler call will execute immediately.
For several target users, prefer a separate readiness pass before fan-out:
DAY_CAPTAIN_SERVICE_URL=... \
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 postgresThen use trigger-only calls for the routine schedule:
DAY_CAPTAIN_SERVICE_URL=... \
DAY_CAPTAIN_JOB_SECRET=... \
PYTHONPATH=src python3 -m day_captain trigger-hosted-job \
--job morning-digest \
--target-user alice@example.com \
--timeout-seconds 90Trigger a bounded email-command recall manually:
DAY_CAPTAIN_SERVICE_URL=... \
DAY_CAPTAIN_JOB_SECRET=... \
PYTHONPATH=src python3 -m day_captain trigger-hosted-job \
--job email-command-recall \
--message-id inbound-001 \
--sender-address alice@example.com \
--command-text recall-week \
--timeout-seconds 90Production scheduling should use Power Automate recurrence. It calls the hosted Day Captain job endpoints directly, using Europe/Paris schedule semantics without GitHub cron jitter, checkout, Python setup, or package installation.
Use power_automate_scheduler_setup.md as the scheduler runbook.
The production Power Automate scheduler has two flows:
Day Captain - Morning Digest: Monday-Friday at08:45 Europe/Paris, calling/jobs/morning-digest.Day Captain - Weekly Digest: Sunday at20:30 Europe/Paris, calling/jobs/weekly-digest.
Each flow:
- warms the hosted service with authenticated
GET /healthz - fans out over explicit target users from a tenant-managed JSON array
- sends one content-free POST per target user
- keeps concurrency at one run
- leaves digest generation, Graph access, storage, and delivery inside Day Captain
Keep the private GitHub Actions workflows as manual fallback only:
workflow_dispatchcan trigger a one-off target user override.schedule:blocks should be removed only after successful Power Automate morning and weekly run evidence is recorded.- The copy-ready GitHub scheduler templates remain useful for rollback and debugging, not as the primary production scheduler.
If the hosted web service is on a plan that can sleep:
- use Power Automate to call authenticated
GET /healthzas a warm-up step before the real trigger - for multi-user schedules, do that warm-up once before the per-user fan-out rather than once per user
- use trigger-only job calls for the weekday and Sunday schedules, and keep full
validate-hosted-serviceruns for manual validation or rollout checks - allow bounded retries before
POST /jobs/morning-digest - allow bounded retries before
POST /jobs/weekly-digest - use longer timeout settings than for an always-on deployment
- keep this as a fallback mode only; prefer a paid always-on service for routine production delivery
Use private_ops_repo_bootstrap.md as the starting point for private ops fallback docs.
After adding or changing users, validate:
- each scheduled or manual run returns
200 - each user receives only their own digest
- recall for
alice@example.comnever returnsbob@example.com's latest run - if a dedicated sender mailbox is configured, delivery still arrives for the target user while the visible sender is
daycaptain@... - if inbound email-command recall is enabled, only authorized senders can trigger it and replaying the same inbound
command_message_idis deduplicated - if inbound email-command recall uses helper senders in multi-user mode, each helper sender must map to exactly one target user and ambiguous mappings must fail explicitly
- a run left in
delivery_pendingstill means delivery may already have happened and requires reconciliation before another send - a run marked
delivery_failedmeans Graph prerequisites or delivery failed before acceptance was likely, so a later retry is expected to be safe - feedback recorded against one user changes only that user's preferences
- persisted rows for messages, meetings, runs, feedback, and preferences stay partitioned by
tenant_idanduser_id
- Treat configured target users and the dedicated sender as the complete authorized mailbox set; requests for any other target must fail before Graph collection or delivery.
- Authentication codes, password-reset messages, magic links, and equivalent authentication content are dropped before storage or model processing.
- Do not copy mailbox subjects, previews, bodies, identities, URLs, tokens, or secrets into logs, tickets, fixtures, screenshots, or telemetry.
- Keep mailbox-derived audit exports only for the duration of the audit, restrict them to the operator performing it, and delete them immediately after aggregate content-free metrics are recorded.
- Microsoft 365 retention for the sender mailbox remains an administrator policy; Day Captain does not create a second content archive.
Use this model for bounded operator-managed deployments. It is not a self-service tenant administration system.