Handbook
Outlook inbox list strategies
Use canonical governed task ids (outlook_*). List harvest must not click inbox rows (no list_row clicks) — read-state mutation is limited to the conversation open task; see OUTLOOK-READ-STRATEGIES.md.
Updated
Quick decision
Inbox list strategy router
Pick a governed, read-only list task from Graph REST or DOM harvest based on session and scale.
- Need inbox message list / folder counts?Entry decision for harvesting inbox rows or folder counts.
- M365 cookies on Playwright page? → outlook_list_inbox_rest (or outlook_list_inbox_chunk list_strategy=auto)Prefer Graph REST when the Playwright page carries M365 session cookies.
- REST blocked, inbox loaded? → outlook_list_inbox_chunk (DOM harvest_list_rows + checkpoint)Fall back to DOM chunk harvest when Graph REST is blocked but OWA inbox is loaded.
- Need more rows than one chunk? → outlook_list_messages_scroll (DOM scroll rounds)Scroll the inbox DOM when a single chunk cannot collect enough rows.
- Smoke / visible rows only? → outlook_list_messages_viewportRead viewport-visible rows only for quick connectivity smoke checks.
- outlook_probe_mail_health (+ optional REST folder stats → folder_total_count, unread_count)Probe mail health before listing to confirm OWA is ready.
Need inbox message list / folder counts?
├─ M365 cookies on Playwright page? → outlook_list_inbox_rest (or outlook_list_inbox_chunk list_strategy=auto)
├─ REST blocked, inbox loaded? → outlook_list_inbox_chunk (DOM harvest_list_rows + checkpoint)
├─ Need more rows than one chunk? → outlook_list_messages_scroll (DOM scroll rounds)
└─ Smoke / visible rows only? → outlook_list_messages_viewport
Before list:
outlook_probe_mail_health (+ optional REST folder stats → folder_total_count, unread_count)Governed tasks (list population)
| task_id | Produces | Mutates OWA read? | Typical use |
|---|---|---|---|
outlook_list_inbox_rest |
items[], extra.via=outlook_rest, folder_total_count |
No | Accurate counts + is_unread when Graph session works |
outlook_list_inbox_chunk |
items[], next_cursor, chunk.has_more |
No | REST fallback, resume checkpoints |
outlook_list_messages_scroll |
items[] |
No | Large folder DOM backfill |
outlook_list_messages_viewport |
items[] |
No | Health smoke |
outlook_read_conversation_thread |
bodies | Yes (list_row click) |
Open pass only; use preserve_unread |
Strategy router (list_strategy)
outlook_list_inbox_chunk accepts query.list_strategy:
| Value | Behavior |
|---|---|
auto (default) |
Try Graph REST once; on auth/403/parse failure → DOM chunk with ConnectorWarning list_strategy_fallback |
rest |
REST only (fail if unavailable) |
dom |
DOM harvest_list_rows only |
Environment: FORGE_LCDL_OUTLOOK_LIST_STRATEGY (auto | rest | dom).
REST page size: FORGE_LCDL_OUTLOOK_REST_PAGE_SIZE (default 100, max 200). Each chunk may follow @odata.nextLink until chunk_size rows are collected.
Cockpit passes COCKPIT_OUTLOOK_LIST_STRATEGY into the bridge (same values). Guided harvest defaults: 100 rows per chunk request, 1000 messages per pass (max_messages).
Count signals (do not confuse)
| Field | Source | Meaning |
|---|---|---|
folder_total_count |
Graph inbox folder stats (REST) | Mailbox size in folder |
unread_count |
Graph (REST) | Unread in folder |
list_row_count_estimate |
DOM probe | Rows visible in viewport now |
seen (consumer) |
Rows processed in this sync run | Not folder size |
Output shape (REST items)
REST list items set source_kind to mail_rest, extra.is_unread from Graph isRead, and stable item_id from message id. DOM chunk items use extra.list_row + extra.is_unread from evaluate heuristics.
Pagination: REST uses @odata.nextLink stored in checkpoint rest_next_link; DOM uses scroll position + seen_item_ids.
SharePoint pattern
SharePoint uses browser-session page.context.request.get with SharePoint cookies. OWA on Edge often does not attach a Graph bearer to Playwright context.request (401 Access token is empty); list_strategy=auto then falls back to DOM harvest_list_rows + scroll until up to chunk_size rows (default 100) per chunk. When Graph works (some tenants/profiles), REST is used automatically.
DOM list harvest and tab visibility
DOM harvest_list_rows and search-day navigation (received:M/D/YYYY in the OWA search box) only populate rows when the tab reports visible. A background Edge tab (document.hidden === true) often leaves the search query typed but returns zero rows.
Default: LCDL enables CDP focus emulation on attach when OS focus steal is off — the tab thinks it is active without Page.bringToFront. See EDGE-CDP-ATTACH.md and OUTLOOK-READ-STRATEGIES.md.
List row fields (DOM harvest)
harvest_list_rows / list_rows evaluate scripts emit one object per visible OWA row:
| Wire field | DOM source (priority) | Notes |
|---|---|---|
from_email |
span[title] matching email |
e.g. vstrut@softserveinc.com |
from |
email span text or avatar aria-label |
Display name; skip 1-char avatar initials |
subject |
Longest substantive innerText line |
Full subject line — not sender name alone |
received_raw |
span[title] full datetime → <time datetime> → short time label |
Transient; normalized in Python |
received_at |
parse_list_received_at_to_utc |
UTC ISO …Z; browser TZ for time-only |
preview |
Remaining body lines | Optional; no trailing time duplicate |
thread_id / item_id |
data-convid |
Stable OWA item id for Pass 2 re-find |
Received: full hint (Sun 6/7/2026 9:27 AM) wins over short label (9:27 AM). Subject: store the complete line (e.g. …shared the folder "Projects" with you), not the sender display name.
Message identity (Exchange item id)
Primary: exchange_item_id (AAkALg…) from DOM harvest (data-itemid, href, parent walk in outlook_readonly.py harvest_list_rows). Wire: UnifiedItem.extra.exchange_item_id and list_row.exchange_item_id.
Cockpit derives message_identity_key = owa_item_message_key(exchange_item_id) for queue/dedupe. See outlook-message-identity.md.
DOM-only capture hits ~35% of rows at list time; menu intercept + scripts/backfill-outlook-exchange-item-ids.py cover the rest without RFC822/EML ingest.
Legacy content ui_hash (deprecated)
When Exchange id is unavailable on legacy rows only, consumers may still read compute_message_ui_hash (outlook_ui_hash.py):
ui_hash = SHA256( normalize(from_email or from) | normalize(subject) | received_at_utc_minute )
New LCDL harvest does not emit extra.ui_hash. Do not use content-hash for new catalog or disk paths.
EML artifact: governed task outlook_download_message_eml writes consumer dest_path (.eml only; .msg fails). OWA exports to Edge Downloads (subject filename); LCDL adopt_download_file moves into staging; Cockpit finalizes Message-ID basename. Dual matrix: OUTLOOK-EML-EXPORT-DUAL-ARCHITECTURE.md. When graph_id is supplied (from REST list), Cockpit/LCDL uses Graph GET /me/messages/{id}/$value first (outlook_download_message_mime_graph). Without graph_id, OWA export uses list-row context menu (right-click → Download) by default; reading-pane toolbar is fallback (FORGE_LCDL_OUTLOOK_EML_DOWNLOAD_MODE). Row resolve: FORGE_LCDL_OUTLOOK_RESOLVE_ROW_FUZZY=0 default (exact conv / row index). See OUTLOOK-TWO-PASS-PLAN.md.
Consumer integration
forge-cockpit-web must not implement REST URLs or list evaluate scripts in connectors/outlook.py. Set COCKPIT_OUTLOOK_LIST_STRATEGY=auto for guided harvest; map extra.is_unread → unified_message.is_read in lcdl_outlook_bridge.wire_item_to_cockpit_fields.
For download-then-analyze ingest, set COCKPIT_OUTLOOK_INGEST_MODE=download_then_analyze.
References
- OUTLOOK-READ-STRATEGIES.md — read/unread mutation and
preserve_unread - MESSAGING-CONSUMER-BOUNDARY.md
- READONLY_POLICY.md