Imported from yusufaltunbicak/outlook-cli (
AGENTS.md). Install upstream withnpx skills add yusufaltunbicak/outlook-cli. Copyright stays with the author.
AGENTS.md
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
What This Is
A Python CLI tool for Outlook 365 that uses OWA bearer token authentication via Playwright browser interception — no Azure app registration, admin consent, or API keys required. Entry point: outlook command.
Build & Run
pip install -e . # editable install (hatchling build system)
playwright install chromium # required for auth
outlook login # first-time: opens browser, captures OWA bearer token
outlook inbox # verify it works
pytest # run the full test suite
pytest -m smoke # run only smoke tests (require live token)
Architecture
Two API layers
- Outlook REST v2 (
outlook.office.com/api/v2.0/me) — standard mail, calendar, contacts, folders, per-message categories. Used byOutlookClientinclient.py. - OWA service.svc (
outlook.cloud.microsoft/owa/service.svc) — reverse-engineered endpoint for master category list operations (create/delete/rename/recolor) and message pinning (UpdateItemwithRenewTime). Uses a non-standard pattern: JSON payload goes in thex-owa-urlpostdataheader, body is empty. Used bycategory_manager.pyandclient.py(pin_message).
Module responsibilities
cli.py— Click group definition + command registration hub only (~92 lines). Imports fromcommands/modules.account.py— Account profile registry, active-profile resolution, path derivation, mailbox binding checks, and per-profile config loading.commands/— All CLI commands split into modules:_common.py— shared helpers: active-account resolution, runtime config proxy, per-profile_get_client,_handle_api_error,_wants_jsonaccount.py—account add/list/current/switch/removeauth.py—login,whoamimail.py—inbox,read,thread,send,draft,draft-send,reply,reply-draft,forwardschedule.py—schedule,schedule-list,schedule-cancel,schedule-draftsearch.py—searchfolders.py—folders,foldercategories.py—categories,categorize,uncategorize,category-create/rename/clear/deletesignatures.py—signature-pull,signature-list,signature-show,signature-deletemanage.py—mark-read,move,delete,flag,pinattachments.py—attachmentscalendar.py—calendar,event,event-create/update/delete/instances/respond,calendars,free-busy,people-searchcontacts.py—contacts
exceptions.py— Structured exception hierarchy:OutlookCliError→TokenExpiredError,RateLimitError,ResourceNotFoundError,AuthRequiredError,AccountError. Includeserror_code_for_exception()mapping.client.py—OutlookClientwraps httpx for REST v2 API. Manages per-profile display-number-to-real-ID mapping (short#1, #2numbers → long Outlook IDs). Handles rate limiting (429 retry) and token expiry (401).get_thread()fetches conversation chains.auth.py— Playwright-based token capture. Intercepts bearer tokens from OWA network requests. Picks the best token by testing against multiple endpoints. Enforces strict mailbox binding per account profile and stores token + browser SSO state in profile-scoped paths. Also supports--with-tokenfor direct token input (skips browser, validates JWT format and mailbox binding).category_manager.py— Standalone module for OWA master category operations. Has its own_owa_requesthelper (separate fromclient.py's_owa_action).rename_categoryandclear_categorydo bulk message propagation via REST v2.signature_manager.py— Signature management: pull from SentItems, save as HTML files in the selected profile's signature directory, append to outgoing emails. Handles plain text → HTML conversion when signature is used.models.py— Dataclasses (Email,Folder,Attachment,Event,Attendee,Contact,EmailAddress) withfrom_api()class methods that parse Outlook REST v2 JSON.Emailincludescategories: list[str],flag_status("notFlagged"/"flagged"/"complete"),flag_due: datetime | None.Eventincludesattendees: list[Attendee],recurrence,event_type(SingleInstance/Occurrence/Exception/SeriesMaster),series_master_id,display_num.formatter.py— Rich table output.Console(stderr=True)so JSON piping stays clean on stdout.print_thread()for conversation view. Inbox flags column shows*(unread),@(attachment),!(flagged),v(flag complete). Email detail view shows flag status with due date.serialization.py—to_json_envelope()wraps data in{ok, schema_version, data}for stdout.error_json()for structured errors.to_json()/save_json()for raw file export. Accepts optionaltzparameter to convert datetimes to a target timezone (outputs single ISO 8601 string with offset).config.py— Global YAML config loader with deep-merge defaults; per-profile config overlays are resolved viaaccount.py.constants.py— URLs and root cache/config paths.
Key patterns
- Multi-account support: Named profiles are selected by
--account,OUTLOOK_ACCOUNT, persisted current account, then implicitdefault.outlook account add/list/current/switch/removemanages profile lifecycle. - Display number ID mapping: Messages and events get short
#1, #2...numbers stored in the selected profile'sid_map.json. Users reference items by these numbers. The map is capped at 500 entries with LRU eviction. Events share the same ID map as messages. - Multi-ID commands:
delete,move,mark-read,categorize,uncategorize,flag,pinaccept multiple message IDs via Click'snargs=-1. The variadic argument comes first, fixed argument (destination/category) last. - Send confirmation:
send,reply,forward,draft-send,schedule,schedule-draft,event-createshow details and require confirmation before action. All accept-yto skip. Draft-creation commands (draft,reply-draft) do NOT require confirmation since nothing is sent.event-deletealso confirms unless-y. - Draft reply:
reply-draftusescreateReply/createReplyAllREST v2 endpoints to create reply drafts with original recipients pre-filled. Body argument is optional (default empty). - Scheduled send: Uses
PidTagDeferredSendTime(0x3FEF) extended property.scheduleuses/sendmailwith the property inline.schedule-draftPATCHes an existing draft then sends it. Tracked locally in the selected profile'sscheduled.json(REST v2 doesn't support$filter/$expandon extended properties).schedule-listcross-references local tracking with Drafts folder by subject to find matching draft IDs.schedule-canceldeletes both local tracking and the server draft when found. Time formats:+30m,+1h,tomorrow 09:00,2024-03-15T10:00. $filtervs$searchsplit: REST v2 can't combine$filterand$search. Text filters (from/subject/hasattachments) use KQL$search(no$orderby). Date/read/category filters use$filter(supports$orderby). See_build_query_paramsinclient.py.--no-categoryclient-side filtering: REST v2 can't filter for emptyCategoriesarray.get_messagesover-fetches in pages (3x batch, max 5 pages) and filters locally to guarantee--maxcount.- Signature extraction:
signature_manager.pyparses SentItems HTML to find the outermost<table>containingmailto:links. Signatures are stored as plain HTML files in the selected profile's config directory — no API dependency. - Conversation thread:
threadcommand fetches all messages with the sameConversationId. REST v2 doesn't support$filteronConversationId, soget_thread()searches by base subject (strips Re:/Fwd:/İlt:/Ynt: prefixes) then filters client-side byConversationId. Results sorted oldest-first. - Structured JSON envelope: All
--jsonoutput wraps data in{ok: true, schema_version: "1", data: [...]}. Errors return{ok: false, error: {code, message}}. Error codes:session_expired,rate_limited,not_found,not_authenticated,unknown_error. File export (-oflag) stays raw (no envelope). - Auto-JSON on pipe: When stdout is not a TTY (piped to
jq,grep, etc.), commands automatically output JSON envelope — no--jsonflag needed. Controlled by_is_piped()/_wants_json()incommands/_common.py. - Token flow: env var
OUTLOOK_TOKEN→ cached profile token → interactive Playwright login (or--with-tokenstdin). Bound profiles reject tokens for the wrong mailbox. Auto re-login on 401 via_handle_api_errordecorator incommands/_common.pyand retries the same profile. - Calendar timezone conversion:
--timezoneflag ortimezoneconfig key._resolve_output_tz()incalendar.pychecks flag first, then config, defaults to None (UTC output). Conversion happens inserialization.pyvia_encoder_cls(tz)which outputs ISO 8601 strings with offset. Only calendar commands passtz=to serializer; mail/search/contacts are unaffected. - Negative --days:
calendar --days -7shows past events. Positive days use midnight-today to midnight+N (full calendar days). Negative days use midnight+N (negative) to midnight-today. Boundaries are local-timezone-aware. - Pin messages:
pinuses OWAservice.svcUpdateItemaction withRenewTimefield (not REST v2). Pin setsRenewTimeto far-future date (4500-09-01), unpin deletes the field. Message IDs must be converted from URL-safe base64 (-,_) to standard base64 (/,+) for OWA compatibility. - File attachments:
send,draft,reply,reply-draft,forward, andscheduleaccept--attach/-a(repeatable). When attachments are present, commands use a draft flow: create draft → attach files → send. Small files (<3 MB) use inline base64 viaPOST /messages/{id}/attachments. Large files (>=3 MB) use upload sessions viacreateuploadsession+ chunked PUT.create_forward_draftusesPOST /messages/{id}/createforward. Click'stype=click.Path(exists=True)validates files before execution. - Dual OWA helpers:
client.pyhas_owa_actionandcategory_manager.pyhas_owa_request— both call OWA service.svc with slightly different base URLs (outlook.office365.comvsoutlook.cloud.microsoft). - Calendar CRUD: Full event lifecycle via REST v2:
POST /events(create),GET /events/{id}(read),PATCH /events/{id}(update),DELETE /events/{id}(delete). Attendee management viaadd_event_attendees/remove_event_attendees(GET existing + PATCH merged list). Meeting responses viaPOST /events/{id}/{accept|decline|tentativelyaccept}. - Shared calendars:
--calendar "Name"resolves display name → ID via_resolve_calendar(exact match first, then partial). Queries/me/calendars/{id}/calendarviewinstead of/me/calendarview. - Recurrence:
event-create --repeat daily|weekly|monthlybuildsRecurrencepayload with Pattern (Type, Interval, DaysOfWeek, DayOfMonth) + Range (Numbered/EndDate).event-instanceslists occurrences via/events/{master_id}/instances— auto-resolves occurrence → series master viaSeriesMasterId.event-delete --seriesdeletes via series master ID. - Free/busy:
findMeetingTimesendpoint with attendees, time constraints, duration. Returns MeetingTimeSuggestions with confidence scores. - People search:
/me/people?$search=queryfor attendee autocomplete. ReturnsScoredEmailAddresses.
Cache & config locations
- Account registry:
~/.config/outlook-cli/accounts.json - Global config:
~/.config/outlook-cli/config.yaml - Per-profile cache:
~/.cache/outlook-cli/accounts/<profile>/ - Per-profile config:
~/.config/outlook-cli/accounts/<profile>/ - Legacy implicit
defaultprofile can still use root cache files until a profile-specificdefault/directory exists. - Overridable via
OUTLOOK_CLI_CACHEandOUTLOOK_CLI_CONFIGenv vars
Dependencies
click, rich, httpx, playwright, PyYAML, beautifulsoup4. Python >=3.10. Build: hatchling.
