Skip to content

Commit 48bc01d

Browse files
web-flowclaude
andcommitted
feat(EmailingEssay): 送信の成功に声を与え、本文をファイルから渡せるようにする(v1.4.0)
同じ日に 6 通送って、ログは 213 バイト 3 行のまま動かなかった。3 行とも 返信取り込み由来で、送信は台帳と sent/ には残っているのにログには一行も 無い。原因は独立した二つだった。成功は print で告げられていてログレコード を発せず、台帳へ書けたことを告げる行は水準 DEBUG で INFO の閾値の下に 沈んでいた。二つは別々のメッセージを黙らせているので、どちらか一方を 直すだけでも沈黙は終わる。 これを設計思想の論争にはしなかった。SKILL.md の File Locations は既に "Every run's log lines" と約束しており、返信路は成功を INFO で書いている。 プラグインが自分自身と整合していない状態の解消であって、設計の変更ではない。 より深い原因も同時に潰した。実運用の送信路は usecases.factories を直 import する使い捨てランナーで、文書化された CLI を通っていなかった。 Windows cp932 と、空行で区切られた複数段落の本文が、シェルの引数行に 載らないためである。send --body-file / --subject-file の新設でランナーが 不要になり、ドキュメント乖離とデータディレクトリの散乱と誤診の温床が 同時に消える。ファイルは utf-8-sig で読み、改行を LF へ正規化してから Domain の検証に掛ける。空本文と空行を含む本文を拒む規則は発明した閾値 ではなく、各ランナーが私的に持っていたチェックの昇格である。 返信路の事実の住所も定めた。「返事は受信箱に来ない」は v1.2.0 の返信 取り込み導入で失効した文で、失効した後も同じ顔で置かれ続けていた。原因は プラグインの機構を外の文書が二重記述したことなので、日付入りの注記では なく住所を一つに定めて解く。機構は SKILL.md の Correspondence Paths、 変更はこの CHANGELOG、思想は CONCEPT.md、解釈は人格層——この分掌表を CONCEPT.md に置いた。 第 5 の原則を新設した。The Address Is the Safeguard。頼まれずに送る 装置を安全にするのは内容の検閲ではない——書いた判断と送る判断が同じで ある以上、自己検閲は自分の答案を自分で採点することにしかならない。 安全を担保しているのは宛先の側で、答えられる一人が訂正を握っていること である。宛先を広げた瞬間、担保は弱まるのではなく無くなる。 ruff 0 / ruff format clean (75 files) / mypy Success (75 files) / pytest 567 passed Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 79a7e05 commit 48bc01d

21 files changed

Lines changed: 696 additions & 18 deletions

File tree

.claude-plugin/marketplace.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
"name": "EmailingEssay",
3434
"source": "./EmailingEssay",
3535
"description": "Autonomous essay writing and emailing from your AI",
36-
"version": "1.3.1",
36+
"version": "1.4.0",
3737
"author": {
3838
"name": "Weave @ EmailingEssay"
3939
},

EmailingEssay/.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "EmailingEssay",
3-
"version": "1.3.1",
3+
"version": "1.4.0",
44
"description": "Autonomous essay writing and emailing from your AI",
55
"author": {
66
"name": "Weave @ EmailingEssay"

EmailingEssay/CHANGELOG.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,65 @@ All notable changes to EmailingEssay will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [1.4.0] - 2026-08-29
9+
10+
A multi-paragraph essay can now be sent from `main.py` itself, and the send path says on the
11+
record that it sent. Between them, the throwaway runner that had grown up beside the
12+
documented CLI has nothing left to do.
13+
14+
### Added
15+
- **`send --body-file` / `--subject-file`.** `python main.py send --subject-file s.txt
16+
--body-file b.txt` sends a body that will not fit on a shell argument line — which is why
17+
the send path in actual use had drifted onto throwaway runners under
18+
`~/.claude/plugins/.emailingessay/`, Windows cp932 and blank-line-separated paragraphs not
19+
surviving the command line. `send "Subject" --body-file b.txt` mixes the two forms. Files
20+
are read `utf-8-sig`, so a BOM never reaches the mail, and `\r\n` / `\r` is normalized to
21+
`\n` before validation. A positional argument and its file form are mutually exclusive
22+
(`argparse`, exit 2). `--to-self` combines with either
23+
- **`validate_essay_body` / `validate_essay_subject`** (`domain/validators.py`). Pure
24+
functions with no external dependency: an empty or whitespace-only body, a body carrying a
25+
blank line (the HTML conversion turns one into an empty `<p>`), and an empty or
26+
whitespace-only subject are refused. The rules are not invented thresholds — they are the
27+
checks each throwaway runner had been keeping privately, promoted to the Domain. A send
28+
that fails them does not go out: exit 1, with the reason on both `logger.error` and stderr
29+
- **A fifth principle, *The Address Is the Safeguard*** (`CONCEPT.md`). What makes unprompted
30+
sending safe is not that the content was vetted — written and sent by the same judgment, a
31+
self-check is the sender grading its own work — but that the address answers and holds the
32+
correction. It states what the standing advice to keep `ESSAY_RECIPIENT_EMAIL` as your own
33+
address was already resting on, and what breaks when the list is widened: not a weaker
34+
safeguard, but none
35+
36+
### Fixed
37+
- **A successful send left no trace in the log.** `adapters/mail/yagmail_adapter.py`
38+
announced it with `print`, which a scheduled run discards. It is `logger.info` now, so
39+
`emailingessay.log` holds what `SKILL.md` has been promising it holds
40+
- **The ledger recorded silently.** The line written when a send is recorded
41+
(`adapters/storage/ledger_storage.py`) was `logger.debug`, below the INFO threshold in
42+
actual use; it is `logger.info` now. The two duplicate-detection DEBUG lines stay as they
43+
are — those are noise suppression working as designed, not the record of a send
44+
- **A failed run said nothing to the log either.** `main.py` reported `ValueError`,
45+
`FileNotFoundError` and `PermissionError` to stdout alone; each now also writes
46+
`logger.error`. The `print` stays for the reader who is present
47+
- **`replies fetch` reported itself twice.** `adapters/cli/handlers.py` printed
48+
`Ingested replies: ...` beside the INFO line `usecases/ingest_replies.py` already wrote.
49+
The handler's copy is gone
50+
51+
### Changed
52+
- **Two intended behavior changes on `send`.** A bare `python main.py send` now exits 1 from
53+
validation rather than 2 from `argparse` — both positional arguments had to become optional
54+
for the file forms to take their place — and the positional route is validated like the
55+
file route, so `send "S" ""` is refused instead of sending an empty body
56+
- **The reply path's facts have one address.** Reply ingestion arrived in [1.2.0]; "replies
57+
do not come back to the inbox" has been false ever since.
58+
`skills/send-email/SKILL.md`**Correspondence Paths** states the round trip in the
59+
present tense and is where that fact is kept from here on. `CONCEPT.md`**Where Each Kind
60+
of Fact Lives** names the division it belongs to: mechanism in `SKILL.md`, change in this
61+
file, rationale in `CONCEPT.md`, and what any of it means to the writer in the persona
62+
layer, which the plugin does not ship
63+
64+
### Verification
65+
ruff 0 / ruff format clean (75 files) / mypy Success (75 files) / pytest 567 passed
66+
867
## [1.3.1] - 2026-08-29
968

1069
The concept document catches up with what [1.2.0][1.3.0] built. Documentation only; no code,

EmailingEssay/CONCEPT.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ Why does EmailingEssay exist? What problems does it solve?
88
- [The Solution: Proactive AI Communication](#the-solution-proactive-ai-communication)
99
- [Core Principles](#core-principles)
1010
- [Relationship with EpisodicRAG](#relationship-with-episodicrag)
11+
- [Where Each Kind of Fact Lives](#where-each-kind-of-fact-lives)
1112
- [Design Decisions](#design-decisions)
1213
- [Summary](#summary)
1314

@@ -89,6 +90,26 @@ than by habit, so it accrues whether or not anyone remembers to keep it. It is a
8990
it holds the correspondence and nothing else. But it gives the AI somewhere of its own to
9091
remember from, and not only somewhere to speak into.
9192

93+
What remains includes the act of sending, not only what was sent: the send path now leaves
94+
its own line in the log, so a delivery that happened can be shown to have happened.
95+
96+
### 5. The Address Is the Safeguard
97+
98+
An agent that sends without being asked needs something to make that safe. The usual answer is
99+
to vet the content — a review step, a filter, a rule about what may be said. That answer does
100+
not hold here. The essay is written by the same judgment that decides to send it, so a content
101+
check performed by the sender is the sender grading its own work.
102+
103+
What makes it safe is the address. One reader, who can answer, and who holds the correction.
104+
Something sent in error does not stay in the world uncorrected; it comes back on the return
105+
leg and is answered. That property belongs to the recipient, not to the message — which is
106+
why `ESSAY_RECIPIENT_EMAIL` is meant to be an address that answers, and why there is no
107+
broadcast mode here.
108+
109+
Widen the address list and the safeguard is not weakened but gone. The channel becomes
110+
publishing, and nothing on the return leg can reach what was already sent. Every scheduling
111+
feature in this plugin rests on that assumption.
112+
92113
---
93114

94115
## Relationship with EpisodicRAG
@@ -127,6 +148,27 @@ operator's side. That line is drawn on purpose: what deserves to become memory i
127148

128149
---
129150

151+
## Where Each Kind of Fact Lives
152+
153+
That was one line. There is a second, and it runs between the documents rather than around the
154+
plugin: a fact written in two places goes stale in one of them, and the stale copy is the one
155+
still being read. So each kind of fact is given a single address.
156+
157+
| Kind of fact | Canonical place | Example |
158+
|---|---|---|
159+
| **Mechanism** — what exists, and how it is connected now | `skills/send-email/SKILL.md` | Replies are pulled from the inbox over IMAP through four gates; the round trip, in **Correspondence Paths** |
160+
| **Change** — when something changed, and what it made false | `CHANGELOG.md` | Reply ingestion arrived in v1.2.0, and "replies do not come back" stopped being true then |
161+
| **Rationale** — why it was built this way | `CONCEPT.md` | The fourth principle, *The Correspondence Is Itself a Memory* |
162+
| **Interpretation** — what any of it means to the one writing | `Identities/IntentionPad.md` — the persona layer, outside the plugin (not shipped with it) | What it is to be answered; on what condition an unprompted send is allowed |
163+
164+
The failure this prevents is specific. A document that mixes two of these keeps the half that
165+
ages well and carries the half that has already expired — mechanism written into a note about
166+
meaning is the usual case, where the meaning stays true while the mechanism quietly stops
167+
being. The repair is not a dated footnote. It is to move the mechanism to its own address and
168+
leave a pointer where it was.
169+
170+
---
171+
130172
## Design Decisions
131173

132174
### Why Email?

EmailingEssay/README.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,14 @@ python main.py replies fetch # Ingest replies to sent essays
8585
python main.py replies list # List ingested replies
8686
```
8787

88-
See [skills/send-email/SKILL.md](skills/send-email/SKILL.md) for file locations.
88+
A body of more than one paragraph is sent from a file, not from the command line:
89+
90+
```bash
91+
python main.py send --subject-file s.txt --body-file b.txt
92+
```
93+
94+
See [skills/send-email/SKILL.md](skills/send-email/SKILL.md) for the round trip
95+
(**Correspondence Paths**) and file locations.
8996

9097
---
9198

EmailingEssay/SETUP.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,10 @@ SMTPAuthenticationError: Username and Password not accepted
187187

188188
**Solution**:
189189
1. Verify recipient email is correct
190-
2. Look for the send in the ledger (`essay_ledger.jsonl` + `sent/`) — every send is recorded there
190+
2. Look for the send in the ledger (`essay_ledger.jsonl` + `sent/`) — every send is recorded
191+
there, and each successful send also writes INFO lines to
192+
`~/.claude/plugins/.emailingessay/emailingessay.log`, one for the delivery and one for the
193+
ledger record
191194
3. For a `wait` run, `essay_wait.log` also holds the return code; a registered `schedule` never
192195
writes it, so an old timestamp there is not evidence that nothing was sent
193196
4. Wait and retry (Gmail has daily sending limits)

EmailingEssay/commands/essay.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,20 @@ python main.py ledger import-legacy --dry-run # Preview a retroactive import
305305
Only mail whose `In-Reply-To` matches a ledger entry **and** whose `From` matches
306306
`ESSAY_RECIPIENT_EMAIL` is taken in — the inbox is not searched across.
307307

308+
### Sending a body from a file
309+
310+
An essay of more than one paragraph does not fit on a shell argument line. It is passed by
311+
file instead:
312+
313+
```bash
314+
python main.py send --subject-file subject.txt --body-file body.txt
315+
python main.py send "Subject" --body-file body.txt # the two forms mix
316+
```
317+
318+
This is what a throwaway sending script used to be written for; one is no longer needed.
319+
Files are read `utf-8-sig` and their newlines normalized, and a body that is empty or
320+
contains a blank line is refused before anything is sent.
321+
308322
### Notes to self
309323

310324
On a day with no essay to send, a note can still be left — addressed to

EmailingEssay/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "emailingessay"
7-
version = "1.3.1"
7+
version = "1.4.0"
88
description = "EmailingEssay - AI essay delivery via email"
99
requires-python = ">=3.10"
1010
license = {text = "MIT"}

EmailingEssay/skills/reflect/SKILL.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,10 @@ Output: **Chat display only** (no email)
9898

9999
Output: **Email** (user is not present)
100100

101-
- **Delivering**: Use `skills/send-email` to deliver
101+
- **Delivering**: Use `skills/send-email` to deliver. Write the essay to a file and send it
102+
with `python main.py send --subject-file … --body-file …` — the default route, since a body
103+
of more than one paragraph does not fit on a shell argument line. Do not write a throwaway
104+
sending script
102105
- **Not Delivering**: Exit silently. Nothing records the silence — scheduled runs invoke
103106
`claude -p` directly and never reach the wrapper that wrote `essay_wait.log`. A `--to-self`
104107
note, if one is written, lands in the ledger like any other send.

EmailingEssay/skills/send-email/SKILL.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Send emails via Gmail SMTP. Frugal design with yagmail as the only dependency.
1111

1212
- [Invocation](#invocation)
1313
- [Implementation](#implementation)
14+
- [Correspondence Paths](#correspondence-paths)
1415
- [File Locations](#file-locations)
1516
- [Security Considerations](#security-considerations)
1617

@@ -48,6 +49,8 @@ yagmail
4849
|-----------|-----|-------------|
4950
| test | `python main.py test` | Send test email |
5051
| send | `python main.py send "Subject" "Body"` | Send custom email |
52+
| send | `python main.py send --subject-file s.txt --body-file b.txt` | Send with the subject and body read from files (`utf-8-sig`, newlines normalized) — the way to send a multi-paragraph body, which does not fit on a shell argument line |
53+
| send | `python main.py send "Subject" --body-file b.txt` | The two forms mix; a positional argument and its file form do not (mutually exclusive) |
5154
| send | `python main.py send "Subject" "Body" --to-self` | Send a note to the AI's own address (`ESSAY_SENDER_EMAIL`); the ledger records it under that address |
5255
| wait | `python main.py wait TIME [OPTIONS]` | One-time schedule |
5356
| schedule | `python main.py schedule FREQ TIME [OPTIONS]` | Recurring schedule |
@@ -66,6 +69,43 @@ For full options and examples, see `commands/essay.md` → **Command Structure**
6669

6770
---
6871

72+
## Correspondence Paths
73+
74+
The mail goes both ways. This is the current shape of the round trip — what happens, where it
75+
lands, and what has to be run by hand.
76+
77+
```text
78+
essay written
79+
↓ python main.py send --subject-file … --body-file … (or /essay --send)
80+
sent over Gmail SMTP
81+
→ essay_ledger.jsonl one line, keyed by the Message-ID minted for the send
82+
→ sent/YYYYMMDD_HHMM.md the body, with YAML frontmatter
83+
→ emailingessay.log one INFO line for the send, one for the ledger record
84+
85+
the reader's inbox → the reader replies
86+
↓ python main.py replies fetch (by hand, or by an OS scheduler entry — there is
87+
↓ no /essay subcommand for it)
88+
IMAP over imap.gmail.com, four gates (see Security Considerations)
89+
→ essay_replies.jsonl one line, linked to the ledger by in_reply_to
90+
91+
at hand for the next reflection (skills/reflect/SKILL.md → Load Context)
92+
```
93+
94+
Three things this makes explicit:
95+
96+
- **Replies do come back.** They are pulled from the inbox over IMAP and land on disk; the
97+
plugin no longer sends into a channel with nothing on the return leg
98+
- **Nothing polls.** `replies fetch` runs when something runs it — a reflection, or an OS
99+
scheduler entry (`SETUP.md`**Scheduling Reply Ingestion**). Between runs, a reply sits in
100+
the inbox unread by the plugin
101+
- **Only replies to this plugin's own mail are taken.** Matching is by `In-Reply-To` against
102+
the ledger, so a reply to an essay sent before the ledger existed — or to a migrated row,
103+
whose Message-ID is synthetic — can never be matched
104+
105+
Where each kind of fact about this is kept: `CONCEPT.md`**Where Each Kind of Fact Lives**.
106+
107+
---
108+
69109
## File Locations
70110

71111
| File | Location | Description |

0 commit comments

Comments
 (0)