Skip to main content

outlooks

Archive and read an Outlook mailbox through the claude.ai Microsoft 365 connector, and ship the Claude Code skill that drives it.

The connector is only reachable from an interactive Claude Code session, so this package never talks to Microsoft itself. A PostToolUse hook writes every outlook_email_search and read_resource result verbatim to a captures directory; outlooks import files those captures into a committed, write-once archive, and records every date window whose pages were all captured as covered. The skill (/outlooks) tells an agent how to search, page, and read the mailbox so that the archive fills itself.

Install

Python 3.11 or later. render needs pandoc on the PATH.

There are two recipes, one for each way a repo uses the package. Both pin a release.

Operator only

The repo runs the commands, the skill, and the capture hook from an interactive session, and none of its own code imports outlooks. Put the package in a dependency group of its own, so it is installed for the operator and the repo's package cannot come to depend on it by accident:

[dependency-groups]
mailbox = ["outlooks==X.Y.Z"]

[tool.uv]
default-groups = ["dev", "mailbox"]

Importing

The repo's own code or tests import outlooks (see Python API). It is a plain dependency:

uv add "outlooks==X.Y.Z"

Either way

uv sync
uv run outlooks install       # the /outlooks skill stub, under .claude/skills/
uv run outlooks hook --apply  # the capture hook script and its settings entry
uv run outlooks doctor        # every check reads ok

Where the package index cannot be used, the same release installs from its tag. Keep the dependency as above and add a source:

[tool.uv.sources]
outlooks = { git = "https://github.com/gitronald/outlooks", tag = "vX.Y.Z" }

A git source cannot be resolved on a network that allows only the package index, and it pins one tag, not a version range, so the index is the first choice.

In CI

outlooks doctor makes no connector call, so CI can run it. It checks what the repo commits: the [tool.outlooks] table, the profile, the archive, and, under .claude/, the hook script, settings.json, and the skill stub. Run locally, it also reads .claude/settings.local.json and the user's own settings.json, and fails on any other hook there that captures the connector tools, since Claude Code runs every one.

- run: uv sync --frozen
- run: uv run outlooks doctor

Upgrade

# 1. change the version (or the tag) in pyproject.toml, then
uv lock --upgrade-package outlooks
uv sync
uv run outlooks hook --apply   # rewrites a hook script an earlier release wrote
uv run outlooks install        # rewrites the skill stub, or only its stamp
uv run outlooks doctor

install rewrites the stub's content when the new release changed it. When it did not, only the stamp line changes, to name the new version.

Read the changelog's Changed and Removed entries for every release between the two versions first: they say what a consuming repo has to do.

The lock file is the pin that holds. For a release from the index it records the version and the hashes of its files, and for a git source the commit the tag resolved to, so uv sync --frozen installs the same code whatever happens upstream.

Configure

Everything repo-specific comes from [tool.outlooks] in the nearest pyproject.toml holding the table, each key overridable by an OUTLOOKS_{KEY} environment variable. Paths are relative to that file.

[tool.outlooks]
mailbox = "team@example.org"            # unset: the signed-in account's own mailbox
own_addresses = ["team@example.org"]    # default: [mailbox]; required when mailbox is unset
system_senders = ["notices@system.example.org"]  # automated, speaks for us
notice_senders = ["postmaster@*"]       # automated, writes to us: a bounce, say
decision_tag = "[Decision]"
archive_dir = "data/outlook"
captured_dir = "temp/outlook/captured"
scratch_dir = "temp/outlook"
timezone = "America/Los_Angeles"
render_label = "Message"                # render's --label when none is passed
profile = "docs/outlook-profile.md"

For the signed-in account's own mailbox, leave mailbox unset and list the account's addresses in own_addresses: the archive files under the first of them. Its searches report no totals, so a window counts as covered once its last page is captured, and the window planner does not apply.

The two sender lists differ in direction. Mail from one of system_senders is ours (out) unless one of the own addresses is among its recipients. Mail from one of notice_senders is a notice to us (in) whoever it is addressed to: a bounce names the address that failed, not ours. An entry of either list is an address, or a pattern in which * stands for any run of characters, for a sender that writes from many addresses. An address belongs to one list: one of own_addresses is ours whatever a pattern matches, and an address that both sender lists match is a notice sender.

uv run outlooks config prints the effective values and where each came from. The profile is repo-owned prose the skill reads for what this package cannot know: where a name resolves, how the sweep classifies, what filing a candidate means, and which of the repo's own records a message can contradict. uv run outlooks doc profile-template prints the headings it should answer.

Trying it without touching the archive

Every path is a setting, and every setting has an OUTLOOKS_* override, so any command runs against a copy. To see what import --replace would rewrite before it rewrites anything:

cp -r data/outlook temp/outlook-trial
OUTLOOKS_ARCHIVE_DIR=temp/outlook-trial \
OUTLOOKS_SCRATCH_DIR=temp/outlook-trial-scratch \
  uv run outlooks import --replace
git diff --no-index data/outlook temp/outlook-trial

The captures are only read, so captured_dir can stay as it is. outlooks config run with the same variables shows each path and that it came from the environment.

The archive

Path Holds
hits/{mailbox}/{YYYY-MM}.jsonl every search hit ever listed, deduped by internetMessageId
messages/{id-hash}.json full read_resource payloads, bodies raw
messages/{id-hash}.{n}.txt attachment n's extracted text
coverage.csv one row per window pull that ran to completion
mass-sends.csv optional, hand-owned: bulk sends whose copies are read once

{id-hash} is the first 16 hex digits of the SHA-256 of the internetMessageId (outlooks hash <id>). Both tiers are write-once: a second import of an unchanged message is a no-op, and one that differs is listed, not rewritten:

  DIFFERS from the stored copy in body.content, attachments[].uri: temp/outlook/captured/20260105T101500-4242.json

The line names the fields that differ, so the two copies need no diffing by hand. It names none when the stored copy is not JSON. Which copy is right decides what to do:

  • The stored copy was not written from a capture (it was saved by hand with outlooks save, or typed out): the capture is the connector's own output, and outlooks import --replace rewrites the stored copy from it.
  • The capture is the older of the two (the message changed after the capture was written, and the stored copy came from a later read): the stored copy is right. Leave it, and do not pass --replace.

outlooks import <paths> --replace limits the rewrite to the captures named, so one repair never rewrites anything else that differs.

Commands

Command Does
outlooks import [paths] [--replace] file the hook's captures; record complete windows
outlooks coverage [--since] [--ledger] covered windows, gaps, unread counts, ledger lag, and the next sweep's start
outlooks senders [--since] inbound hits counted by sender, each marked own, system, notice, or unlisted (none for the hits with no sender)
outlooks split <name> --match ... a lookup's hits: archived (timeline written) vs to-read
outlooks check <name> a lookup's timeline files against the archive
outlooks lookup-reset <name> move a lookup's page and timeline files aside before a repeat lookup
outlooks worklist --after --before --out reader worklists for a full read of a range
outlooks totals [prefix ...] / --check AFTER BEFORE window sizes; the item-id proof
outlooks windows init/fill/split/batches/remaining the backfill planner
outlooks audit <prefix> [--sweep] attachment-text audit for a period
outlooks render <message.json> a message as {Surname} - {render_label}.docx (pandoc, sandboxed)
outlooks save <payload> archive a hand-saved payload (sessions use import)
outlooks hash <id> a message's archive file stem
outlooks hook [--apply] check or wire the capture hook
outlooks config the effective settings
outlooks doctor settings, profile, archive, hook, and skill stub in one read-only pass; exit 1 if any check fails
outlooks skill, outlooks doc, outlooks install, outlooks permissions the skill, its documents, the stub, and its permission profile (pkgskills)

Python API

A repo that imports this package may import the names below, and nothing else. Every other module is internal, and the commands are its interface. A change to one of these names, or to a parameter of one, is listed in the changelog under Changed or Removed.

Module Public names
outlooks.store archive_root, id_hash, stable, load_hits, load_messages, newest_hit, StoreError
outlooks.classify Mail, Facts, view, classify, is_reply, is_auto_reply, thread_subject
outlooks.detail detail, details_by_id, body_text
outlooks.config Settings, load, settings, KEYS, DEFAULTS, ConfigError, is_system_sender, is_notice_sender, mailbox, archive_dir, captured_dir, scratch_dir, zone, zone_label

The API reads the archive and never writes it: a repo fills the archive through outlooks import.

Reading the archive:

from outlooks import classify, detail, store

hits = store.load_hits()  # every message ever listed, by internetMessageId
messages = store.load_messages()  # the ones read in full

for mid, hit in hits.items():
    mail = classify.view(messages.get(mid, hit))
    role = classify.classify(mail).role
    print(mail.day, role, mail.sender, mail.subject)

for mid, message in messages.items():
    fields = detail.detail(message)  # from, to, cc, bcc, attachments, body

classify reports the role a message plays (arrival, followup, ours, decision, system) and stops there. What a system sender's notification says is the repo's own business, and the place for it is a thin wrapper in the repo. For a ticketing system whose notices read "Ticket #12 was opened by ...":

import re

from outlooks import classify as cl

OPENED = re.compile(r"Ticket #(\d+) was opened by (.+?)\.")


def facts(payload):
    mail = cl.view(payload)
    base = cl.classify(mail)
    if base.role != "system":
        return base.role, None
    found = OPENED.search(mail.text)
    return ("system-opened", int(found[1])) if found else ("system-other", None)

A followup carries extra["auto"], true for an automatic reply. is_auto_reply(subject) is the same test for a repo that has only the subject in hand. A system message from one of notice_senders carries extra["notice"], true. config.is_system_sender(address) and config.is_notice_sender(address) say which kind of sender an address is, patterns included, which address in settings().system_senders does not. At most one of them is true of an address, and neither of an own address.

Mail carries what such a wrapper reads: sender, recipients, subject, and text (the body as plain text for a message read in full, the summary for a search hit).

Testing against it

A consuming repo's tests pin every setting through OUTLOOKS_* in a fixture, so they never read the repo's own [tool.outlooks] table, its archive, or the zone of the machine they run on:

import pytest


@pytest.fixture(autouse=True)
def outlooks_settings(monkeypatch, tmp_path):
    monkeypatch.chdir(tmp_path)
    monkeypatch.setenv("OUTLOOKS_MAILBOX", "desk@example.org")
    monkeypatch.setenv("OUTLOOKS_OWN_ADDRESSES", "desk@example.org")
    monkeypatch.setenv("OUTLOOKS_SYSTEM_SENDERS", "notices@system.example.org")
    monkeypatch.setenv("OUTLOOKS_NOTICE_SENDERS", "postmaster@*")
    monkeypatch.setenv("OUTLOOKS_DECISION_TAG", "[Decision]")
    monkeypatch.setenv("OUTLOOKS_TIMEZONE", "America/Los_Angeles")
    monkeypatch.setenv("OUTLOOKS_ARCHIVE_DIR", str(tmp_path / "archive"))
    monkeypatch.setenv("OUTLOOKS_CAPTURED_DIR", str(tmp_path / "captured"))
    monkeypatch.setenv("OUTLOOKS_SCRATCH_DIR", str(tmp_path / "scratch"))
    monkeypatch.delenv("OUTLOOKS_PROFILE", raising=False)
    monkeypatch.delenv("OUTLOOKS_RENDER_LABEL", raising=False)

The settings are resolved again whenever the working directory or an OUTLOOKS_* variable changes, so a test that sets one more variable gets it. A test that needs an archive writes its own messages under tmp_path in the connector's shape, with invented senders.

Development

uv sync --all-groups
uv run pytest
uv run ruff check .
uv run ruff format --check .
uv run pyrefly check

The tests use synthetic fixtures only (example.org addresses and an invented cast), and every test runs in its own temporary directory with its settings pinned through OUTLOOKS_*, so the suite never reads a real archive.

Releases

A published tag never moves. A fix ships as the next version, under its own changelog heading, because a consuming repo's lock file names the commit a tag resolved to, and a tag that moved would leave two repos on the same version running different code.

The changelog's preamble lists what counts as a change a consuming repo has to hear about. Each such change is entered under Changed or Removed, with what to do.

Metadata

Release files for outlooks 0.4.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for outlooks 0.4.0
File Size Uploaded
outlooks-0.4.0.tar.gz 99.6 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for outlooks 0.4.0
File Interpreter ABI Platform
outlooks-0.4.0-py3-none-any.whl Python 3 none any Details

Total release size: 215.7 kB

Release files / outlooks-0.4.0.tar.gz

Download URL outlooks-0.4.0.tar.gz
Size 99.6 kB
Tags Source
SHA-256 checksum
How to use checksums
08f0be36bcff089d988322bb1c5f4962225bccab9961ddf5edefec3edcc93254
BLAKE2b-256 checksum
How to use checksums
7bd9dd5a10f37e41ee58c6697d094599ea8feed061b89c4be94abcd42516c9d0
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 30, 2026.

Transparency log

Release files / outlooks-0.4.0-py3-none-any.whl

Download URL outlooks-0.4.0-py3-none-any.whl
Size 116.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
b4e5df61ad1614cd610d4f3c06f8625f9eac00201b4358cec4a3966dbe83670e
BLAKE2b-256 checksum
How to use checksums
3781b813b487323ce08428c8d5a557012d63a191a8c043a6a82a0ad8ce15dd90
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 30, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.4.0 This release

2 release files

0.3.0

2 release files

0.2.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page