outlooks 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. outlooks-0.2.0/.gitignore +22 -0
  2. outlooks-0.2.0/CHANGELOG.md +147 -0
  3. outlooks-0.2.0/LICENSE +21 -0
  4. outlooks-0.2.0/PKG-INFO +327 -0
  5. outlooks-0.2.0/README.md +314 -0
  6. outlooks-0.2.0/outlooks/__init__.py +1 -0
  7. outlooks-0.2.0/outlooks/capture.py +454 -0
  8. outlooks-0.2.0/outlooks/classify.py +158 -0
  9. outlooks-0.2.0/outlooks/cli.py +688 -0
  10. outlooks-0.2.0/outlooks/config.py +274 -0
  11. outlooks-0.2.0/outlooks/coverage.py +417 -0
  12. outlooks-0.2.0/outlooks/detail.py +88 -0
  13. outlooks-0.2.0/outlooks/doctor.py +184 -0
  14. outlooks-0.2.0/outlooks/hook.py +246 -0
  15. outlooks-0.2.0/outlooks/host.py +110 -0
  16. outlooks-0.2.0/outlooks/lookup.py +317 -0
  17. outlooks-0.2.0/outlooks/prompts/__init__.py +1 -0
  18. outlooks-0.2.0/outlooks/prompts/references/connector.md +245 -0
  19. outlooks-0.2.0/outlooks/prompts/references/profile-template.md +104 -0
  20. outlooks-0.2.0/outlooks/prompts/references/queries.md +296 -0
  21. outlooks-0.2.0/outlooks/prompts/skills/lookup/SKILL.md +315 -0
  22. outlooks-0.2.0/outlooks/prompts/skills/lookup/references/reader-brief.md +72 -0
  23. outlooks-0.2.0/outlooks/prompts/skills/lookup/references/searcher-brief.md +59 -0
  24. outlooks-0.2.0/outlooks/prompts/skills/lookup/references/timeline.md +56 -0
  25. outlooks-0.2.0/outlooks/prompts/skills/sweep/SKILL.md +228 -0
  26. outlooks-0.2.0/outlooks/prompts/skills/window/SKILL.md +334 -0
  27. outlooks-0.2.0/outlooks/prompts/skills/window/references/bulk-reader-brief.md +68 -0
  28. outlooks-0.2.0/outlooks/prompts/skills/window/references/multi-pager.md +12 -0
  29. outlooks-0.2.0/outlooks/prompts/skills/window/references/pager-brief.md +44 -0
  30. outlooks-0.2.0/outlooks/py.typed +0 -0
  31. outlooks-0.2.0/outlooks/render.py +328 -0
  32. outlooks-0.2.0/outlooks/senders.py +70 -0
  33. outlooks-0.2.0/outlooks/sizing.py +103 -0
  34. outlooks-0.2.0/outlooks/store.py +321 -0
  35. outlooks-0.2.0/outlooks/window_plan.py +317 -0
  36. outlooks-0.2.0/outlooks/worklist.py +267 -0
  37. outlooks-0.2.0/pyproject.toml +107 -0
@@ -0,0 +1,22 @@
1
+ .archive/
2
+ .claude/
3
+ .hypothesis/
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ .venv/
7
+ .vscode/
8
+ .worktrees/
9
+
10
+ __pycache__/
11
+ build/
12
+ dist/
13
+ *.egg-info/
14
+
15
+ .DS_Store
16
+
17
+ .coverage
18
+ .coverage.*
19
+
20
+ .env
21
+ .env.*
22
+ !.env.example
@@ -0,0 +1,147 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/).
7
+
8
+ A repo that builds on this package depends on more than its commands. A
9
+ change to any of the following is listed under *Changed* or *Removed*, with
10
+ what a consuming repo has to do about it:
11
+
12
+ - a public Python name or one of its parameters (the README's "Python API");
13
+ - a settings key or its default. A default counts because a repo that leaves
14
+ a key unset still has the default's path in its hook script and its ignore
15
+ file;
16
+ - the archive's file format;
17
+ - a command's name or exit code;
18
+ - a line another tool is told to read: the `coverage` footer (`ledger
19
+ high-water mark`, `coverage ends`, `next sweep from`), and `COVERED`,
20
+ `INCOMPLETE`, and `DIFFERS` from `import`;
21
+ - a skill step's name;
22
+ - a key of a lookup's timeline file.
23
+
24
+ ## [Unreleased]
25
+
26
+ ## [0.2.0] - 2026-09-29
27
+
28
+ ### Added
29
+
30
+ - A declared Python API: `store`, `classify`, `detail`, and `config` each
31
+ name what a consuming repo may import in `__all__`, listed in the README's
32
+ "Python API" section with a reading example and an example of refining a
33
+ `system` role in the repo's own wrapper. Every other module says in its
34
+ docstring that it is internal. A contract test pins the names, and each
35
+ function's parameter names, which of them have a default, and how each
36
+ may be passed.
37
+ - `outlooks coverage --ledger <path>` reads a ledger kept somewhere other
38
+ than `ledger.csv` in `archive_dir`. A path that is not a file is an error
39
+ (exit 1), never an empty ledger.
40
+ - `outlooks lookup-reset <name>` moves a lookup's page and timeline files
41
+ to `earlier/{timestamp}/` under `scratch_dir` and prints what it moved, so
42
+ a repeat lookup of a name never merges the last one's pages. It deletes
43
+ nothing, and the lookup skill runs it before launching searchers.
44
+ - `outlooks doc lookup/timeline` lists every key of a lookup's timeline
45
+ file, which of them `check` reads, and which are required.
46
+ - `outlooks doctor` checks in one read-only pass that a repo is wired
47
+ correctly, one line per check, and exits 1 if any fails: the settings
48
+ (the table is there, every key is a setting, and each parses), the
49
+ profile (the file exists when `profile` is set), the archive
50
+ (`coverage.csv` and every hits file parse), the hook script, the hook's
51
+ settings entry, and the skill stub. It makes no connector call, so a
52
+ consuming repo's CI can run it.
53
+ - `outlooks senders [--since YYYY-MM-DD]` counts the archived inbound hits
54
+ by sender address, most frequent first, and marks each `own`, `system`, or
55
+ `unlisted`, so a notifier missing from `system_senders` is visible.
56
+ Read-only. The profile template's *Who we are* points at it.
57
+ - Every step of the three modes has a fixed name, given where the step
58
+ starts (the sweep's are `setup`, `sweep`, `classify`, `match`, `worklist`,
59
+ `file`, `ledger`, and `report`). A repo's own skill or profile cites a step
60
+ by name, which a renumbering does not change; `outlooks doc
61
+ profile-template` says so. A test pins the names and their order per mode.
62
+ - The README gives one install recipe for a repo that only runs the
63
+ commands and one for a repo that imports the package, an upgrade recipe
64
+ that ends in `outlooks doctor`, a CI example, how to run any command
65
+ against a copy of the archive, and the fixture a consuming repo's tests
66
+ pin the settings with.
67
+
68
+ ### Changed
69
+
70
+ - `outlooks coverage` ends with one more line, `next sweep from`, giving the
71
+ instant the next sweep passes as `afterDateTime`, in UTC: the earlier of
72
+ the ledger's high-water mark less five minutes and the end of recorded
73
+ coverage. The sweep reads it, where it used to compute it. With an empty
74
+ ledger the line is the end of recorded coverage, and the sweep still asks
75
+ the operator where to start, because the archived mail before that instant
76
+ has never been classified. A tool that reads the footer should find each
77
+ line by its label, not its position.
78
+ - `outlooks import` names the fields that differ on a `DIFFERS` line (`DIFFERS
79
+ from the stored copy in body.content, attachments[].uri: <path>`) and no
80
+ longer says `(--replace rewrites)`. A tool that matches the line should
81
+ match on `DIFFERS from the stored copy` and take the path after the last
82
+ `: `. The line names no field, `DIFFERS from the stored copy: <path>`,
83
+ when the stored copy is not JSON. The README says when `--replace` is the
84
+ fix.
85
+ - `outlooks check` prints why a row is `BAD` on a line of its own under the
86
+ row: `missing key date_local` (or `internet_message_id`), `not archived`,
87
+ or the two dates that disagree. A timeline file with no
88
+ `internet_message_id` used to stop the command with a traceback, and one
89
+ with no `date_local` read as a date mismatch. The exit code is unchanged.
90
+
91
+ ### Fixed
92
+
93
+ - `outlooks split <name>` no longer merges the page files of another lookup
94
+ whose name starts with `<name>-` (`smith` and `smith-jones`). A page file
95
+ is the name, one of the searchers' kinds (`sender`, `title`, `sent`) or
96
+ none, and `page-{n}.json`.
97
+
98
+ ## [0.1.0] - 2026-09-27
99
+
100
+ ### Added
101
+
102
+ - The mailbox archive: the two-tier, write-once store (`store`), the capture
103
+ import (`capture`), window coverage (`coverage`), window sizing and the
104
+ item-id proof (`sizing`), the backfill planner (`window_plan`), message
105
+ classification (`classify`), the message detail view (`detail`), and the
106
+ `.docx` transcription (`render`).
107
+ - `[tool.outlooks]` configuration with `OUTLOOKS_*` overrides, printed by
108
+ `outlooks config`: `mailbox`, `own_addresses`, `system_senders`,
109
+ `decision_tag`, `archive_dir`, `captured_dir`, `scratch_dir`, `timezone`,
110
+ `render_label` (the label `render` gives a file when `--label` is not
111
+ passed, default `Message`), and `profile`.
112
+ - The signed-in account's own mailbox: with `mailbox` unset the archive files
113
+ under the first of `own_addresses`, and `import` takes the captures that
114
+ name no mailbox or name any own address. Its pulls report no total, so a
115
+ window is covered once its last page is captured.
116
+ - The skill helpers as commands: `split`, `check`, `worklist`, `totals`,
117
+ `audit`, `windows`, and `hash`; `hook` writes and checks the capture hook,
118
+ whose script writes to the repo's `captured_dir`.
119
+ - The `outlooks` skill (lookup, sweep, window) and its reference documents,
120
+ installed as a version-stamped stub through pkgskills. The sweep takes the
121
+ ledger's classification column name and outcome words from the profile
122
+ when it gives them, and skipping replies is a default the profile can
123
+ override.
124
+ - Message roles from `classify`: `arrival`, `followup`, `ours`, `decision`
125
+ (our mail with the `decision_tag` in its subject, reported with the label
126
+ after the tag), and `system` (mail from one of `system_senders`).
127
+ - Mass sends: a hand-owned `mass-sends.csv` beside the archive (`sender`,
128
+ `subject`, `after`, `before`, `exemplar`) lists the bulk sends whose copies
129
+ differ only in the recipient. `worklist` reads the exemplar and skips the
130
+ other copies; `coverage` counts them on their own line rather than as
131
+ covered mail with no full read.
132
+ - `worklist` refuses, writing nothing, while the newest capture was written
133
+ under 90 seconds ago, so a reader still running never has its batch files
134
+ replaced.
135
+ - `outlooks hook` reads a script written by a pre-release build as `stale`,
136
+ so `--apply` rewrites it; for a script with a local change it says that
137
+ `--apply --force` overwrites it.
138
+
139
+ ### Security
140
+
141
+ - The capture hook and `outlooks import` follow the saved-file path in an
142
+ oversized-result note only into Claude Code's projects directory
143
+ (`~/.claude/projects/`, or under `CLAUDE_CONFIG_DIR`), comparing folders as
144
+ the filesystem resolves them and refusing a file that is itself a link, so
145
+ neither a `..` nor a link leads out of it. A test or tool that feeds the
146
+ hook a spill note must put the saved file, as a regular file, under that
147
+ directory; anywhere else, nothing is copied.
outlooks-0.2.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ronald E. Robertson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,327 @@
1
+ Metadata-Version: 2.5
2
+ Name: outlooks
3
+ Version: 0.2.0
4
+ Summary: Archive and read an Outlook mailbox through the Microsoft 365 connector, with the Claude Code skill that drives it.
5
+ Project-URL: repository, https://github.com/gitronald/outlooks
6
+ Author-email: gitronald <gitronald@users.noreply.github.com>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Requires-Python: >=3.11
10
+ Requires-Dist: pkgskills<0.6.0,>=0.5.1
11
+ Requires-Dist: typer>=0.15.0
12
+ Description-Content-Type: text/markdown
13
+
14
+ # outlooks
15
+
16
+ Archive and read an Outlook mailbox through the claude.ai Microsoft 365
17
+ connector, and ship the Claude Code skill that drives it.
18
+
19
+ The connector is only reachable from an interactive Claude Code session, so
20
+ this package never talks to Microsoft itself. A PostToolUse hook writes every
21
+ `outlook_email_search` and `read_resource` result verbatim to a captures
22
+ directory; `outlooks import` files those captures into a committed, write-once
23
+ archive, and records every date window whose pages were all captured as
24
+ covered. The skill (`/outlooks`) tells an agent how to search, page, and read
25
+ the mailbox so that the archive fills itself.
26
+
27
+ ## Install
28
+
29
+ Python 3.11 or later. `render` needs [pandoc](https://pandoc.org) on the PATH.
30
+
31
+ There are two recipes, one for each way a repo uses the package. Both pin a
32
+ release.
33
+
34
+ ### Operator only
35
+
36
+ The repo runs the commands, the skill, and the capture hook from an
37
+ interactive session, and none of its own code imports `outlooks`. Put the
38
+ package in a dependency group of its own, so it is installed for the
39
+ operator and the repo's package cannot come to depend on it by accident:
40
+
41
+ ```toml
42
+ [dependency-groups]
43
+ mailbox = ["outlooks==X.Y.Z"]
44
+
45
+ [tool.uv]
46
+ default-groups = ["dev", "mailbox"]
47
+ ```
48
+
49
+ ### Importing
50
+
51
+ The repo's own code or tests `import outlooks` (see [Python API](#python-api)).
52
+ It is a plain dependency:
53
+
54
+ ```bash
55
+ uv add "outlooks==X.Y.Z"
56
+ ```
57
+
58
+ ### Either way
59
+
60
+ ```bash
61
+ uv sync
62
+ uv run outlooks install # the /outlooks skill stub, under .claude/skills/
63
+ uv run outlooks hook --apply # the capture hook script and its settings entry
64
+ uv run outlooks doctor # every check reads ok
65
+ ```
66
+
67
+ Where the package index cannot be used, the same release installs from its
68
+ tag. Keep the dependency as above and add a source:
69
+
70
+ ```toml
71
+ [tool.uv.sources]
72
+ outlooks = { git = "https://github.com/gitronald/outlooks", tag = "vX.Y.Z" }
73
+ ```
74
+
75
+ A git source cannot be resolved on a network that allows only the package
76
+ index, and it pins one tag, not a version range, so the index is the first
77
+ choice.
78
+
79
+ ### In CI
80
+
81
+ `outlooks doctor` makes no connector call, so CI can run it. It checks what
82
+ the repo commits: the `[tool.outlooks]` table, the profile, the archive, and,
83
+ under `.claude/`, the hook script, `settings.json`, and the skill stub.
84
+
85
+ ```yaml
86
+ - run: uv sync --frozen
87
+ - run: uv run outlooks doctor
88
+ ```
89
+
90
+ ## Upgrade
91
+
92
+ ```bash
93
+ # 1. change the version (or the tag) in pyproject.toml, then
94
+ uv lock --upgrade-package outlooks
95
+ uv sync
96
+ uv run outlooks hook --apply # rewrites a hook script an earlier release wrote
97
+ uv run outlooks install # rewrites the skill stub for the new version
98
+ uv run outlooks doctor
99
+ ```
100
+
101
+ Read the changelog's *Changed* and *Removed* entries for every release
102
+ between the two versions first: they say what a consuming repo has to do.
103
+
104
+ The lock file is the pin that holds. For a release from the index it records
105
+ the version and the hashes of its files, and for a git source the commit the
106
+ tag resolved to, so `uv sync --frozen` installs the same code whatever
107
+ happens upstream.
108
+
109
+ ## Configure
110
+
111
+ Everything repo-specific comes from `[tool.outlooks]` in the nearest
112
+ `pyproject.toml` holding the table, each key overridable by an
113
+ `OUTLOOKS_{KEY}` environment variable. Paths are relative to that file.
114
+
115
+ ```toml
116
+ [tool.outlooks]
117
+ mailbox = "team@example.org" # unset: the signed-in account's own mailbox
118
+ own_addresses = ["team@example.org"] # default: [mailbox]; required when mailbox is unset
119
+ system_senders = ["notices@system.example.org"]
120
+ decision_tag = "[Decision]"
121
+ archive_dir = "data/outlook"
122
+ captured_dir = "temp/outlook/captured"
123
+ scratch_dir = "temp/outlook"
124
+ timezone = "America/Los_Angeles"
125
+ render_label = "Message" # render's --label when none is passed
126
+ profile = "docs/outlook-profile.md"
127
+ ```
128
+
129
+ For the signed-in account's own mailbox, leave `mailbox` unset and list the
130
+ account's addresses in `own_addresses`: the archive files under the first of
131
+ them. Its searches report no totals, so a window counts as covered once its
132
+ last page is captured, and the window planner does not apply.
133
+
134
+ `uv run outlooks config` prints the effective values and where each came from.
135
+ The `profile` is repo-owned prose the skill reads for what this package cannot
136
+ know: where a name resolves, how the sweep classifies, what filing a candidate
137
+ means, and which of the repo's own records a message can contradict. `uv run
138
+ outlooks doc profile-template` prints the headings it should answer.
139
+
140
+ ## Trying it without touching the archive
141
+
142
+ Every path is a setting, and every setting has an `OUTLOOKS_*` override, so
143
+ any command runs against a copy. To see what `import --replace` would
144
+ rewrite before it rewrites anything:
145
+
146
+ ```bash
147
+ cp -r data/outlook temp/outlook-trial
148
+ OUTLOOKS_ARCHIVE_DIR=temp/outlook-trial \
149
+ OUTLOOKS_SCRATCH_DIR=temp/outlook-trial-scratch \
150
+ uv run outlooks import --replace
151
+ git diff --no-index data/outlook temp/outlook-trial
152
+ ```
153
+
154
+ The captures are only read, so `captured_dir` can stay as it is.
155
+ `outlooks config` run with the same variables shows each path and that it
156
+ came from the environment.
157
+
158
+ ## The archive
159
+
160
+ | Path | Holds |
161
+ |---|---|
162
+ | `hits/{mailbox}/{YYYY-MM}.jsonl` | every search hit ever listed, deduped by `internetMessageId` |
163
+ | `messages/{id-hash}.json` | full `read_resource` payloads, bodies raw |
164
+ | `messages/{id-hash}.{n}.txt` | attachment `n`'s extracted text |
165
+ | `coverage.csv` | one row per window pull that ran to completion |
166
+ | `mass-sends.csv` | optional, hand-owned: bulk sends whose copies are read once |
167
+
168
+ `{id-hash}` is the first 16 hex digits of the SHA-256 of the
169
+ `internetMessageId` (`outlooks hash <id>`). Both tiers are write-once: a second
170
+ import of an unchanged message is a no-op, and one that differs is listed, not
171
+ rewritten:
172
+
173
+ ```
174
+ DIFFERS from the stored copy in body.content, attachments[].uri: temp/outlook/captured/20260105T101500-4242.json
175
+ ```
176
+
177
+ The line names the fields that differ, so the two copies need no diffing by
178
+ hand. It names none when the stored copy is not JSON. Which copy is right
179
+ decides what to do:
180
+
181
+ - **The stored copy was not written from a capture** (it was saved by hand
182
+ with `outlooks save`, or typed out): the capture is the connector's own
183
+ output, and `outlooks import --replace` rewrites the stored copy from it.
184
+ - **The capture is the older of the two** (the message changed after the
185
+ capture was written, and the stored copy came from a later read): the
186
+ stored copy is right. Leave it, and do not pass `--replace`.
187
+
188
+ `outlooks import <paths> --replace` limits the rewrite to the captures named,
189
+ so one repair never rewrites anything else that differs.
190
+
191
+ ## Commands
192
+
193
+ | Command | Does |
194
+ |---|---|
195
+ | `outlooks import [paths] [--replace]` | file the hook's captures; record complete windows |
196
+ | `outlooks coverage [--since] [--ledger]` | covered windows, gaps, unread counts, ledger lag, and the next sweep's start |
197
+ | `outlooks senders [--since]` | inbound hits counted by sender, each marked `own`, `system`, or `unlisted` |
198
+ | `outlooks split <name> --match ...` | a lookup's hits: archived (timeline written) vs to-read |
199
+ | `outlooks check <name>` | a lookup's timeline files against the archive |
200
+ | `outlooks lookup-reset <name>` | move a lookup's page and timeline files aside before a repeat lookup |
201
+ | `outlooks worklist --after --before --out` | reader worklists for a full read of a range |
202
+ | `outlooks totals [prefix ...]` / `--check AFTER BEFORE` | window sizes; the item-id proof |
203
+ | `outlooks windows init/fill/split/batches/remaining` | the backfill planner |
204
+ | `outlooks audit <prefix> [--sweep]` | attachment-text audit for a period |
205
+ | `outlooks render <message.json>` | a message as `{Surname} - {render_label}.docx` (pandoc, sandboxed) |
206
+ | `outlooks save <payload>` | archive a hand-saved payload (sessions use `import`) |
207
+ | `outlooks hash <id>` | a message's archive file stem |
208
+ | `outlooks hook [--apply]` | check or wire the capture hook |
209
+ | `outlooks config` | the effective settings |
210
+ | `outlooks doctor` | settings, profile, archive, hook, and skill stub in one read-only pass; exit 1 if any check fails |
211
+ | `outlooks skill`, `outlooks doc`, `outlooks install`, `outlooks permissions` | the skill, its documents, the stub, and its permission profile ([pkgskills](https://github.com/gitronald/pkgskills)) |
212
+
213
+ ## Python API
214
+
215
+ A repo that imports this package may import the names below, and nothing
216
+ else. Every other module is internal, and the commands are its interface. A
217
+ change to one of these names, or to a parameter of one, is listed in the
218
+ changelog under *Changed* or *Removed*.
219
+
220
+ | Module | Public names |
221
+ |---|---|
222
+ | `outlooks.store` | `archive_root`, `id_hash`, `stable`, `load_hits`, `load_messages`, `newest_hit`, `StoreError` |
223
+ | `outlooks.classify` | `Mail`, `Facts`, `view`, `classify`, `is_reply`, `thread_subject` |
224
+ | `outlooks.detail` | `detail`, `details_by_id`, `body_text` |
225
+ | `outlooks.config` | `Settings`, `load`, `settings`, `KEYS`, `DEFAULTS`, `ConfigError`, `mailbox`, `archive_dir`, `captured_dir`, `scratch_dir`, `zone`, `zone_label` |
226
+
227
+ The API reads the archive and never writes it: a repo fills the archive
228
+ through `outlooks import`.
229
+
230
+ Reading the archive:
231
+
232
+ ```python
233
+ from outlooks import classify, detail, store
234
+
235
+ hits = store.load_hits() # every message ever listed, by internetMessageId
236
+ messages = store.load_messages() # the ones read in full
237
+
238
+ for mid, hit in hits.items():
239
+ mail = classify.view(messages.get(mid, hit))
240
+ role = classify.classify(mail).role
241
+ print(mail.day, role, mail.sender, mail.subject)
242
+
243
+ for mid, message in messages.items():
244
+ fields = detail.detail(message) # from, to, cc, bcc, attachments, body
245
+ ```
246
+
247
+ `classify` reports the role a message plays (`arrival`, `followup`, `ours`,
248
+ `decision`, `system`) and stops there. What a system sender's notification
249
+ says is the repo's own business, and the place for it is a thin wrapper in
250
+ the repo. For a ticketing system whose notices read "Ticket #12 was opened
251
+ by ...":
252
+
253
+ ```python
254
+ import re
255
+
256
+ from outlooks import classify as cl
257
+
258
+ OPENED = re.compile(r"Ticket #(\d+) was opened by (.+?)\.")
259
+
260
+
261
+ def facts(payload):
262
+ mail = cl.view(payload)
263
+ base = cl.classify(mail)
264
+ if base.role != "system":
265
+ return base.role, None
266
+ found = OPENED.search(mail.text)
267
+ return ("system-opened", int(found[1])) if found else ("system-other", None)
268
+ ```
269
+
270
+ `Mail` carries what such a wrapper reads: `sender`, `recipients`, `subject`,
271
+ and `text` (the body as plain text for a message read in full, the summary
272
+ for a search hit).
273
+
274
+ ## Testing against it
275
+
276
+ A consuming repo's tests pin every setting through `OUTLOOKS_*` in a
277
+ fixture, so they never read the repo's own `[tool.outlooks]` table, its
278
+ archive, or the zone of the machine they run on:
279
+
280
+ ```python
281
+ import pytest
282
+
283
+
284
+ @pytest.fixture(autouse=True)
285
+ def outlooks_settings(monkeypatch, tmp_path):
286
+ monkeypatch.chdir(tmp_path)
287
+ monkeypatch.setenv("OUTLOOKS_MAILBOX", "desk@example.org")
288
+ monkeypatch.setenv("OUTLOOKS_OWN_ADDRESSES", "desk@example.org")
289
+ monkeypatch.setenv("OUTLOOKS_SYSTEM_SENDERS", "notices@system.example.org")
290
+ monkeypatch.setenv("OUTLOOKS_DECISION_TAG", "[Decision]")
291
+ monkeypatch.setenv("OUTLOOKS_TIMEZONE", "America/Los_Angeles")
292
+ monkeypatch.setenv("OUTLOOKS_ARCHIVE_DIR", str(tmp_path / "archive"))
293
+ monkeypatch.setenv("OUTLOOKS_CAPTURED_DIR", str(tmp_path / "captured"))
294
+ monkeypatch.setenv("OUTLOOKS_SCRATCH_DIR", str(tmp_path / "scratch"))
295
+ monkeypatch.delenv("OUTLOOKS_PROFILE", raising=False)
296
+ monkeypatch.delenv("OUTLOOKS_RENDER_LABEL", raising=False)
297
+ ```
298
+
299
+ The settings are resolved again whenever the working directory or an
300
+ `OUTLOOKS_*` variable changes, so a test that sets one more variable gets
301
+ it. A test that needs an archive writes its own messages under `tmp_path`
302
+ in the connector's shape, with invented senders.
303
+
304
+ ## Development
305
+
306
+ ```bash
307
+ uv sync --all-groups
308
+ uv run pytest
309
+ uv run ruff check .
310
+ uv run ruff format --check .
311
+ uv run pyrefly check
312
+ ```
313
+
314
+ The tests use synthetic fixtures only (`example.org` addresses and an invented
315
+ cast), and every test runs in its own temporary directory with its settings
316
+ pinned through `OUTLOOKS_*`, so the suite never reads a real archive.
317
+
318
+ ### Releases
319
+
320
+ A published tag never moves. A fix ships as the next version, under its own
321
+ changelog heading, because a consuming repo's lock file names the commit a
322
+ tag resolved to, and a tag that moved would leave two repos on the same
323
+ version running different code.
324
+
325
+ The changelog's preamble lists what counts as a change a consuming repo has
326
+ to hear about. Each such change is entered under *Changed* or *Removed*,
327
+ with what to do.