pcode 0.1.0__py3-none-any.whl
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.
- pcode/__init__.py +7 -0
- pcode/_job_supervisor.py +65 -0
- pcode/agent.py +592 -0
- pcode/anthropic_oauth.py +469 -0
- pcode/app.py +3165 -0
- pcode/appearance.py +181 -0
- pcode/aside.py +541 -0
- pcode/aside_guard.py +39 -0
- pcode/aside_ui.py +683 -0
- pcode/auth.py +113 -0
- pcode/block.py +39 -0
- pcode/browser.py +393 -0
- pcode/cache_diagnostics.py +336 -0
- pcode/cache_settings.py +58 -0
- pcode/cache_warnings.py +139 -0
- pcode/claude_sdk/__init__.py +96 -0
- pcode/claude_sdk/cli.py +52 -0
- pcode/claude_sdk/errors.py +61 -0
- pcode/claude_sdk/messages.py +154 -0
- pcode/claude_sdk/model.py +179 -0
- pcode/claude_sdk/resume.py +114 -0
- pcode/claude_sdk/session.py +547 -0
- pcode/claude_sdk/session_pool.py +251 -0
- pcode/claude_sdk/workspace.py +34 -0
- pcode/cli.py +73 -0
- pcode/clipboard.py +76 -0
- pcode/code_mode.py +43 -0
- pcode/codex_login.py +189 -0
- pcode/command_transcript.py +71 -0
- pcode/commands.py +145 -0
- pcode/compaction.py +457 -0
- pcode/completion.py +263 -0
- pcode/config.py +223 -0
- pcode/context_breakdown.py +178 -0
- pcode/context_usage.py +52 -0
- pcode/controller.py +3496 -0
- pcode/conversation_ids.py +27 -0
- pcode/conversation_tree.py +160 -0
- pcode/copy_ui.py +172 -0
- pcode/delegation.py +236 -0
- pcode/delta.py +295 -0
- pcode/diagnostics.py +226 -0
- pcode/edit_preview.py +158 -0
- pcode/edit_transcript.py +287 -0
- pcode/edit_ui.py +850 -0
- pcode/edits.py +162 -0
- pcode/email_remote/__init__.py +14 -0
- pcode/email_remote/cli.py +133 -0
- pcode/email_remote/listener.py +695 -0
- pcode/email_remote/mailbox.py +336 -0
- pcode/email_remote/outbound.py +75 -0
- pcode/email_remote/parsing.py +226 -0
- pcode/email_remote/render.py +175 -0
- pcode/email_remote/state.py +79 -0
- pcode/error_report.py +38 -0
- pcode/ext.py +602 -0
- pcode/extension_guide.md +311 -0
- pcode/extensions/browser.py +284 -0
- pcode/extensions/ponytail.py +217 -0
- pcode/extensions/sandbox.py +138 -0
- pcode/extensions/session_history.py +170 -0
- pcode/extensions/web_research.py +89 -0
- pcode/file_refs.py +265 -0
- pcode/filesystem.py +138 -0
- pcode/frame.py +149 -0
- pcode/gateway.py +465 -0
- pcode/git_diff.py +497 -0
- pcode/history.py +589 -0
- pcode/history_cursor.py +147 -0
- pcode/history_embeddings.py +102 -0
- pcode/host.py +941 -0
- pcode/host_protocol.py +291 -0
- pcode/host_ui.py +225 -0
- pcode/input_keys.py +35 -0
- pcode/inspection.py +289 -0
- pcode/inspector_ui.py +448 -0
- pcode/isolated_delegation.py +425 -0
- pcode/job_notices.py +54 -0
- pcode/jobs.py +584 -0
- pcode/jobs_ui.py +265 -0
- pcode/keymap.py +215 -0
- pcode/layout_speed.py +103 -0
- pcode/links.py +90 -0
- pcode/links_ui.py +200 -0
- pcode/live.py +1301 -0
- pcode/llm_proxy.py +44 -0
- pcode/logins.py +233 -0
- pcode/mcp.py +683 -0
- pcode/mcp_notice.py +91 -0
- pcode/mcp_oauth.py +381 -0
- pcode/meridian.py +233 -0
- pcode/meridian_process.py +337 -0
- pcode/meridian_reminders.py +107 -0
- pcode/meridian_setup.py +356 -0
- pcode/model_metadata.py +451 -0
- pcode/model_ui.py +168 -0
- pcode/models.py +209 -0
- pcode/native_results.py +119 -0
- pcode/oauth_pages.py +66 -0
- pcode/output_limits.py +41 -0
- pcode/paste.py +45 -0
- pcode/path_completion.py +66 -0
- pcode/plan_preview.py +131 -0
- pcode/plan_sizes.py +35 -0
- pcode/planning.py +161 -0
- pcode/popup_ui.py +873 -0
- pcode/preferences.py +1176 -0
- pcode/prefix_keys.py +490 -0
- pcode/profile_benchmark.py +332 -0
- pcode/profiling.py +566 -0
- pcode/project_trust.py +94 -0
- pcode/prompt_keys.py +286 -0
- pcode/remote.py +717 -0
- pcode/remote_print.py +125 -0
- pcode/remote_profile.py +237 -0
- pcode/replay_benchmark.py +319 -0
- pcode/repo_context.py +158 -0
- pcode/retries.py +28 -0
- pcode/rpc.py +280 -0
- pcode/runtime.py +244 -0
- pcode/sandbox.py +454 -0
- pcode/session_naming.py +82 -0
- pcode/session_ui.py +550 -0
- pcode/sessions.py +892 -0
- pcode/shell.py +113 -0
- pcode/shell_mode.py +172 -0
- pcode/shell_tools.py +459 -0
- pcode/skills.py +161 -0
- pcode/stall_log.py +187 -0
- pcode/startup_input.py +96 -0
- pcode/steering.py +27 -0
- pcode/stream_display.py +188 -0
- pcode/stream_events.py +437 -0
- pcode/strict_tools.py +130 -0
- pcode/syntax.py +43 -0
- pcode/syntax_colors.py +206 -0
- pcode/task_prompt.py +28 -0
- pcode/task_worktrees.py +482 -0
- pcode/terminal_notify.py +383 -0
- pcode/theme.py +135 -0
- pcode/theme_gallery.py +88 -0
- pcode/thinking_markdown.py +41 -0
- pcode/token_accounting.py +67 -0
- pcode/tool_display.py +784 -0
- pcode/tool_output_limits.py +108 -0
- pcode/tool_panel.py +377 -0
- pcode/transcript_log.py +127 -0
- pcode/transcript_notice.py +103 -0
- pcode/tree_ui.py +288 -0
- pcode/turn.py +44 -0
- pcode/ui.py +3745 -0
- pcode/usage.py +293 -0
- pcode/word_wrap.py +107 -0
- pcode/worker_ui.py +235 -0
- pcode/workers.py +136 -0
- pcode/workspace.py +57 -0
- pcode/workspace_filesystem.py +72 -0
- pcode/worktree.py +650 -0
- pcode-0.1.0.dist-info/METADATA +145 -0
- pcode-0.1.0.dist-info/RECORD +163 -0
- pcode-0.1.0.dist-info/WHEEL +4 -0
- pcode-0.1.0.dist-info/entry_points.txt +3 -0
- pcode-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"""Gmail over IMAP and SMTP, and the app password in the macOS keychain.
|
|
2
|
+
|
|
3
|
+
Gmail's IMAP extensions give what acceptance needs without the Gmail API's
|
|
4
|
+
OAuth project: `X-GM-LABELS` carries the message-level `\\Sent` label,
|
|
5
|
+
`X-GM-MSGID` a stable message id, and `X-GM-RAW` Gmail's own search. The
|
|
6
|
+
listener reads only: the All Mail folder is opened read-only, and bodies are
|
|
7
|
+
fetched with `BODY.PEEK`, so nothing is marked read or relabelled.
|
|
8
|
+
|
|
9
|
+
The credential is a Gmail app password. It is read from the keychain by the
|
|
10
|
+
listener alone; a remote host's sandbox cannot reach the keychain, and its
|
|
11
|
+
environment never holds the password. There is no plaintext fallback.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import imaplib
|
|
17
|
+
import re
|
|
18
|
+
import shutil
|
|
19
|
+
import smtplib
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from email.message import EmailMessage
|
|
24
|
+
from typing import Protocol, TypeAlias
|
|
25
|
+
|
|
26
|
+
IMAP_HOST = "imap.gmail.com"
|
|
27
|
+
SMTP_HOST = "smtp.gmail.com"
|
|
28
|
+
KEYCHAIN_SERVICE = "pcode-email"
|
|
29
|
+
TIMEOUT = 60
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SetupError(RuntimeError):
|
|
33
|
+
"""Something `pcode --email-setup` fixes; the message says what."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
BAD_LOGIN = (
|
|
37
|
+
"Gmail rejected the app password for {owner}. Check that it was created while "
|
|
38
|
+
"signed in as {owner} (the app-passwords page opens in the browser's current "
|
|
39
|
+
"Google account), that 2-Step Verification is on, and that it was copied whole; "
|
|
40
|
+
"then run pcode --email-setup again."
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def app_password(text: str) -> str:
|
|
45
|
+
"""Google shows app passwords as `abcd efgh ijkl mnop`; pastes also bring
|
|
46
|
+
bracketed-paste markers. Neither belongs in the password."""
|
|
47
|
+
return re.sub(r"\x1b\[20[01]~|\s", "", text)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class Meta:
|
|
52
|
+
"""What the mailbox says about a message before its content is read."""
|
|
53
|
+
|
|
54
|
+
gmail_id: str
|
|
55
|
+
labels: frozenset[str]
|
|
56
|
+
size: int
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def sent(self) -> bool:
|
|
60
|
+
"""The message itself was sent from the account (`\\Sent`), not delivered to it."""
|
|
61
|
+
return "\\Sent" in self.labels
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Mailbox(Protocol):
|
|
65
|
+
"""The listener's view of the account; `GmailMailbox` is the real one."""
|
|
66
|
+
|
|
67
|
+
def search(self, alias: str) -> list[str]: ...
|
|
68
|
+
def meta(self, handle: str) -> Meta: ...
|
|
69
|
+
def headers(self, handle: str) -> bytes: ...
|
|
70
|
+
def fetch(self, handle: str) -> bytes: ...
|
|
71
|
+
def send(self, message: EmailMessage) -> None: ...
|
|
72
|
+
def close(self) -> None: ...
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _keychain() -> str:
|
|
76
|
+
if sys.platform != "darwin" or (tool := shutil.which("security")) is None:
|
|
77
|
+
raise SetupError(
|
|
78
|
+
"The email remote keeps its app password in the macOS keychain, which "
|
|
79
|
+
"this machine does not have. There is no plaintext fallback."
|
|
80
|
+
)
|
|
81
|
+
return tool
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def read_password(owner: str) -> str:
|
|
85
|
+
result = subprocess.run(
|
|
86
|
+
[_keychain(), "find-generic-password", "-s", KEYCHAIN_SERVICE, "-a", owner, "-w"],
|
|
87
|
+
capture_output=True,
|
|
88
|
+
text=True,
|
|
89
|
+
)
|
|
90
|
+
if result.returncode or not result.stdout.strip():
|
|
91
|
+
raise SetupError(f"No app password for {owner} in the keychain; run pcode --email-setup.")
|
|
92
|
+
return result.stdout.rstrip("\n")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def store_password(owner: str) -> None:
|
|
96
|
+
"""Ask for the app password on the terminal (`security` prompts; pcode never sees it)."""
|
|
97
|
+
subprocess.run(
|
|
98
|
+
[
|
|
99
|
+
_keychain(),
|
|
100
|
+
"add-generic-password",
|
|
101
|
+
"-U",
|
|
102
|
+
"-s",
|
|
103
|
+
KEYCHAIN_SERVICE,
|
|
104
|
+
"-a",
|
|
105
|
+
owner,
|
|
106
|
+
"-l",
|
|
107
|
+
"pcode email remote",
|
|
108
|
+
# Trust no application: every read, the listener's included, asks
|
|
109
|
+
# macOS first, so nothing else running as you reads it silently.
|
|
110
|
+
"-T",
|
|
111
|
+
"",
|
|
112
|
+
# Last, with no value: `security` prompts for it.
|
|
113
|
+
"-w",
|
|
114
|
+
],
|
|
115
|
+
check=True,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# Keep string values (quoted or literal bytes) distinct from protocol atoms
|
|
120
|
+
# (str). In particular, label contents must never become FETCH field names.
|
|
121
|
+
_TOKEN = re.compile(rb'\s+|[()]|"(?:[^"\\]|\\.)*"|[^\s()"{}]+')
|
|
122
|
+
_Value: TypeAlias = str | bytes | list["_Value"]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _fetch_records(data: list) -> list[dict[str, _Value]]:
|
|
126
|
+
"""Parse imaplib's tuple/literal fragments and continuations as records."""
|
|
127
|
+
records = []
|
|
128
|
+
root: list[_Value] = []
|
|
129
|
+
stack = [root]
|
|
130
|
+
for part in data:
|
|
131
|
+
if part is None:
|
|
132
|
+
continue
|
|
133
|
+
literal = None
|
|
134
|
+
if isinstance(part, tuple) and len(part) == 2:
|
|
135
|
+
fragment, literal = part
|
|
136
|
+
if not isinstance(fragment, bytes) or not isinstance(literal, bytes):
|
|
137
|
+
raise imaplib.IMAP4.error("Invalid FETCH literal")
|
|
138
|
+
marker = re.search(rb"\{([0-9]+)\}$", fragment)
|
|
139
|
+
if marker is None or int(marker[1]) != len(literal):
|
|
140
|
+
raise imaplib.IMAP4.error("Invalid FETCH literal length")
|
|
141
|
+
fragment = fragment[: marker.start()]
|
|
142
|
+
elif isinstance(part, bytes):
|
|
143
|
+
fragment = part
|
|
144
|
+
else:
|
|
145
|
+
raise imaplib.IMAP4.error("Invalid FETCH fragment")
|
|
146
|
+
index = 0
|
|
147
|
+
while index < len(fragment):
|
|
148
|
+
match = _TOKEN.match(fragment, index)
|
|
149
|
+
if match is None:
|
|
150
|
+
raise imaplib.IMAP4.error("Invalid FETCH syntax")
|
|
151
|
+
token = match[0]
|
|
152
|
+
index = match.end()
|
|
153
|
+
if token.isspace():
|
|
154
|
+
continue
|
|
155
|
+
if token == b"(":
|
|
156
|
+
child: list[_Value] = []
|
|
157
|
+
stack[-1].append(child)
|
|
158
|
+
stack.append(child)
|
|
159
|
+
elif token == b")":
|
|
160
|
+
if len(stack) == 1:
|
|
161
|
+
raise imaplib.IMAP4.error("Unbalanced FETCH response")
|
|
162
|
+
stack.pop()
|
|
163
|
+
if len(stack) == 1:
|
|
164
|
+
if (
|
|
165
|
+
len(root) != 2
|
|
166
|
+
or not isinstance(root[0], str)
|
|
167
|
+
or not root[0].isascii()
|
|
168
|
+
or not root[0].isdigit()
|
|
169
|
+
or not isinstance(root[1], list)
|
|
170
|
+
):
|
|
171
|
+
raise imaplib.IMAP4.error("Invalid FETCH record")
|
|
172
|
+
values = root[1]
|
|
173
|
+
if len(values) % 2:
|
|
174
|
+
raise imaplib.IMAP4.error("Unpaired FETCH field")
|
|
175
|
+
fields: dict[str, _Value] = {}
|
|
176
|
+
for key, value in zip(values[::2], values[1::2]):
|
|
177
|
+
if not isinstance(key, str) or key.upper() in fields:
|
|
178
|
+
raise imaplib.IMAP4.error("Invalid or duplicate FETCH field")
|
|
179
|
+
fields[key.upper()] = value
|
|
180
|
+
records.append(fields)
|
|
181
|
+
root.clear()
|
|
182
|
+
elif token.startswith(b'"'):
|
|
183
|
+
stack[-1].append(re.sub(rb"\\(.)", rb"\1", token[1:-1]))
|
|
184
|
+
else:
|
|
185
|
+
stack[-1].append(token.decode("ascii"))
|
|
186
|
+
if literal is not None:
|
|
187
|
+
if len(stack) == 1:
|
|
188
|
+
raise imaplib.IMAP4.error("FETCH literal outside a record")
|
|
189
|
+
stack[-1].append(literal)
|
|
190
|
+
if len(stack) != 1 or root:
|
|
191
|
+
raise imaplib.IMAP4.error("Incomplete FETCH response")
|
|
192
|
+
return records
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _labels(fields: dict[str, _Value]) -> frozenset[str]:
|
|
196
|
+
values = fields.get("X-GM-LABELS", [])
|
|
197
|
+
if not isinstance(values, list):
|
|
198
|
+
raise imaplib.IMAP4.error("Invalid FETCH labels")
|
|
199
|
+
labels = []
|
|
200
|
+
for value in values:
|
|
201
|
+
if isinstance(value, list):
|
|
202
|
+
raise imaplib.IMAP4.error("Invalid FETCH label")
|
|
203
|
+
labels.append(value.decode() if isinstance(value, bytes) else value)
|
|
204
|
+
return frozenset(labels)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def parse_labels(line: str) -> frozenset[str]:
|
|
208
|
+
"""The `X-GM-LABELS (...)` list of a complete FETCH response."""
|
|
209
|
+
records = _fetch_records([line.encode()])
|
|
210
|
+
return _labels(records[0]) if records else frozenset()
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _number(name: str, fields: dict[str, _Value]) -> str:
|
|
214
|
+
value = fields.get(name)
|
|
215
|
+
if not isinstance(value, str) or not value.isascii() or not value.isdigit():
|
|
216
|
+
raise imaplib.IMAP4.error(f"FETCH response without valid {name}")
|
|
217
|
+
return value
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _message_records(data: list, handle: str) -> list[dict[str, _Value]]:
|
|
221
|
+
return [fields for fields in _fetch_records(data) if fields.get("UID") == handle]
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _all_mail(imap: imaplib.IMAP4) -> str:
|
|
225
|
+
"""The All Mail folder, found by its `\\All` flag (its name is localized)."""
|
|
226
|
+
status, lines = imap.list()
|
|
227
|
+
if status != "OK":
|
|
228
|
+
raise imaplib.IMAP4.error("LIST failed")
|
|
229
|
+
for raw in lines:
|
|
230
|
+
line = raw.decode() if isinstance(raw, bytes) else str(raw)
|
|
231
|
+
match = re.match(r'\((?P<flags>[^)]*)\) (?:"[^"]*"|NIL) (?P<name>.+)$', line)
|
|
232
|
+
if match and "\\All" in match.group("flags").split():
|
|
233
|
+
return match.group("name")
|
|
234
|
+
raise SetupError("This account has no All Mail folder over IMAP; is it Gmail?")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class GmailMailbox:
|
|
238
|
+
"""One owner's Gmail: an IMAP connection kept open, and SMTP per message sent."""
|
|
239
|
+
|
|
240
|
+
def __init__(self, owner: str, password: str) -> None:
|
|
241
|
+
self.owner = owner
|
|
242
|
+
self._password = app_password(password)
|
|
243
|
+
self._imap: imaplib.IMAP4 | None = None
|
|
244
|
+
|
|
245
|
+
def _connection(self) -> imaplib.IMAP4:
|
|
246
|
+
if self._imap is None:
|
|
247
|
+
imap = imaplib.IMAP4_SSL(IMAP_HOST, timeout=TIMEOUT)
|
|
248
|
+
try:
|
|
249
|
+
try:
|
|
250
|
+
imap.login(self.owner, self._password)
|
|
251
|
+
except imaplib.IMAP4.error as error:
|
|
252
|
+
if "AUTHENTICATIONFAILED" in str(error):
|
|
253
|
+
raise SetupError(BAD_LOGIN.format(owner=self.owner)) from None
|
|
254
|
+
raise
|
|
255
|
+
status, _ = imap.select(_all_mail(imap), readonly=True)
|
|
256
|
+
if status != "OK":
|
|
257
|
+
raise imaplib.IMAP4.error("Could not open All Mail")
|
|
258
|
+
except BaseException:
|
|
259
|
+
imap.shutdown()
|
|
260
|
+
raise
|
|
261
|
+
self._imap = imap
|
|
262
|
+
return self._imap
|
|
263
|
+
|
|
264
|
+
def _command(self, name: str, *args) -> list:
|
|
265
|
+
"""One IMAP command, reconnecting once if the connection dropped."""
|
|
266
|
+
for attempt in (1, 2):
|
|
267
|
+
try:
|
|
268
|
+
status, data = getattr(self._connection(), name)(*args)
|
|
269
|
+
except (imaplib.IMAP4.abort, OSError):
|
|
270
|
+
self.close()
|
|
271
|
+
if attempt == 2:
|
|
272
|
+
raise
|
|
273
|
+
continue
|
|
274
|
+
if status != "OK":
|
|
275
|
+
raise imaplib.IMAP4.error(f"{name.upper()} {args[0] if args else ''} failed")
|
|
276
|
+
return data
|
|
277
|
+
raise AssertionError("unreachable")
|
|
278
|
+
|
|
279
|
+
def _uid(self, *args) -> list:
|
|
280
|
+
return self._command("uid", *args)
|
|
281
|
+
|
|
282
|
+
def verify(self) -> None:
|
|
283
|
+
"""Log in and open All Mail, so setup fails now rather than at the first poll."""
|
|
284
|
+
self._connection()
|
|
285
|
+
|
|
286
|
+
def search(self, alias: str) -> list[str]:
|
|
287
|
+
# A selected mailbox is a snapshot: Gmail only adds mail that arrived
|
|
288
|
+
# since SELECT once the client polls, so without NOOP a long-lived
|
|
289
|
+
# connection searches the mailbox as it was at login.
|
|
290
|
+
self._command("noop")
|
|
291
|
+
data = self._uid("SEARCH", "X-GM-RAW", f'"to:{alias} newer_than:1d"')
|
|
292
|
+
return [uid.decode() for uid in (data[0] or b"").split()]
|
|
293
|
+
|
|
294
|
+
def meta(self, handle: str) -> Meta:
|
|
295
|
+
data = self._uid("FETCH", handle, "(X-GM-MSGID X-GM-LABELS RFC822.SIZE)")
|
|
296
|
+
for fields in _message_records(data, handle):
|
|
297
|
+
# An unsolicited FLAGS update for this UID is not our result.
|
|
298
|
+
if not {"X-GM-MSGID", "X-GM-LABELS", "RFC822.SIZE"} <= fields.keys():
|
|
299
|
+
continue
|
|
300
|
+
return Meta(
|
|
301
|
+
gmail_id=_number("X-GM-MSGID", fields),
|
|
302
|
+
labels=_labels(fields),
|
|
303
|
+
size=int(_number("RFC822.SIZE", fields)),
|
|
304
|
+
)
|
|
305
|
+
raise imaplib.IMAP4.error(f"No metadata for message {handle}")
|
|
306
|
+
|
|
307
|
+
def _literal(self, handle: str, item: str) -> bytes:
|
|
308
|
+
data = self._uid("FETCH", handle, f"({item})")
|
|
309
|
+
# PEEK is a request modifier, not part of the returned BODY field.
|
|
310
|
+
field = item.replace("BODY.PEEK[", "BODY[")
|
|
311
|
+
for fields in _message_records(data, handle):
|
|
312
|
+
value = fields.get(field)
|
|
313
|
+
if isinstance(value, bytes):
|
|
314
|
+
return value
|
|
315
|
+
raise imaplib.IMAP4.error(f"No content for message {handle}")
|
|
316
|
+
|
|
317
|
+
def headers(self, handle: str) -> bytes:
|
|
318
|
+
return self._literal(handle, "BODY.PEEK[HEADER]")
|
|
319
|
+
|
|
320
|
+
def fetch(self, handle: str) -> bytes:
|
|
321
|
+
return self._literal(handle, "BODY.PEEK[]")
|
|
322
|
+
|
|
323
|
+
def send(self, message: EmailMessage) -> None:
|
|
324
|
+
with smtplib.SMTP_SSL(SMTP_HOST, 465, timeout=TIMEOUT) as smtp:
|
|
325
|
+
smtp.login(self.owner, self._password)
|
|
326
|
+
# Only ever to the owner: never a recipient read from mail or a model.
|
|
327
|
+
smtp.send_message(message, from_addr=self.owner, to_addrs=[self.owner])
|
|
328
|
+
|
|
329
|
+
def close(self) -> None:
|
|
330
|
+
imap, self._imap = self._imap, None
|
|
331
|
+
if imap is None:
|
|
332
|
+
return
|
|
333
|
+
try:
|
|
334
|
+
imap.logout()
|
|
335
|
+
except (imaplib.IMAP4.error, OSError):
|
|
336
|
+
pass
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""The messages pcode sends: always from and to the owner, replies going to the alias.
|
|
2
|
+
|
|
3
|
+
Recipients are fixed here and nowhere else: `From` and `To` are the owner,
|
|
4
|
+
`Reply-To` the live alias. Nothing from an incoming message's headers or from
|
|
5
|
+
model output can change them. Each message carries the marker header and
|
|
6
|
+
`Auto-Submitted`, so neither pcode nor a well-behaved client loops on it, and a
|
|
7
|
+
"Start new task" `mailto:` to the alias with no reply headers, which is how a
|
|
8
|
+
second independent session starts.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from email.message import EmailMessage
|
|
14
|
+
from email.utils import formataddr, make_msgid
|
|
15
|
+
from urllib.parse import quote
|
|
16
|
+
|
|
17
|
+
from pcode.email_remote.parsing import MARKER_HEADER
|
|
18
|
+
from pcode.email_remote.render import document
|
|
19
|
+
|
|
20
|
+
NEW_TASK_SUBJECT = "New pcode task"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def new_task_link(alias: str) -> str:
|
|
24
|
+
return f"mailto:{alias}?subject={quote(NEW_TASK_SUBJECT)}"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def new_message_id(owner: str) -> str:
|
|
28
|
+
return make_msgid("pcode", domain=owner.rpartition("@")[2] or None)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def reply_subject(subject: str) -> str:
|
|
32
|
+
subject = subject.strip() or "pcode"
|
|
33
|
+
return subject if subject.lower().startswith("re:") else f"Re: {subject}"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def compose(
|
|
37
|
+
*,
|
|
38
|
+
owner: str,
|
|
39
|
+
alias: str,
|
|
40
|
+
subject: str,
|
|
41
|
+
body: str,
|
|
42
|
+
trailer: str = "",
|
|
43
|
+
in_reply_to: str = "",
|
|
44
|
+
references: list[str] | None = None,
|
|
45
|
+
auto: str = "auto-replied",
|
|
46
|
+
message_id: str = "",
|
|
47
|
+
) -> EmailMessage:
|
|
48
|
+
"""Plain text (the Markdown as written) with a rendered HTML copy.
|
|
49
|
+
|
|
50
|
+
`body` is Markdown; `trailer` is preformatted session detail shown small
|
|
51
|
+
under it. The footer is added here. `message_id` reuses one planned
|
|
52
|
+
earlier (a retry); otherwise one is made.
|
|
53
|
+
"""
|
|
54
|
+
message = EmailMessage()
|
|
55
|
+
message["From"] = formataddr(("pcode", owner))
|
|
56
|
+
message["To"] = owner
|
|
57
|
+
message["Reply-To"] = alias
|
|
58
|
+
message["Subject"] = subject
|
|
59
|
+
message["Message-ID"] = message_id or new_message_id(owner)
|
|
60
|
+
if in_reply_to:
|
|
61
|
+
message["In-Reply-To"] = in_reply_to
|
|
62
|
+
message["References"] = " ".join([*(references or []), in_reply_to])
|
|
63
|
+
message["Auto-Submitted"] = auto
|
|
64
|
+
message[MARKER_HEADER] = "1"
|
|
65
|
+
link = new_task_link(alias)
|
|
66
|
+
footer = (
|
|
67
|
+
"Reply to this email to continue. To start a separate session, "
|
|
68
|
+
f"write to the address this email replies to, or open: {link}"
|
|
69
|
+
)
|
|
70
|
+
text = body.rstrip()
|
|
71
|
+
if trailer.strip():
|
|
72
|
+
text += f"\n\n---\n{trailer.strip()}"
|
|
73
|
+
message.set_content(f"{text}\n\n--\n{footer}\n")
|
|
74
|
+
message.add_alternative(document(body, trailer, link), subtype="html")
|
|
75
|
+
return message
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""One raw email, parsed: the headers acceptance needs, and the new text of its body.
|
|
2
|
+
|
|
3
|
+
Only the standard library's parser reads the message. Headers are taken as
|
|
4
|
+
parsed, never from the body. The body's new text is what the owner wrote above
|
|
5
|
+
Gmail's quoted copy of the thread; anything doubtful (no text part, quoted
|
|
6
|
+
text only, too long) yields a reason instead of text, and the listener asks
|
|
7
|
+
for a clean resend rather than guessing.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from email import policy
|
|
15
|
+
from email.message import EmailMessage
|
|
16
|
+
from email.parser import BytesParser
|
|
17
|
+
from email.utils import getaddresses
|
|
18
|
+
from html.parser import HTMLParser
|
|
19
|
+
|
|
20
|
+
# The new text a message may carry, and the whole message pcode will fetch.
|
|
21
|
+
MAX_TEXT_BYTES = 64 * 1024
|
|
22
|
+
MAX_MESSAGE_BYTES = 2 * 1024 * 1024
|
|
23
|
+
|
|
24
|
+
# Marks every message pcode sends, so it never takes one of its own as input.
|
|
25
|
+
MARKER_HEADER = "X-Pcode-Remote"
|
|
26
|
+
|
|
27
|
+
# Why a message has no usable text; the listener answers each with a resend request.
|
|
28
|
+
EMPTY, MALFORMED, TOO_LONG = "empty", "malformed", "too-long"
|
|
29
|
+
|
|
30
|
+
_LIST_HEADERS = ("List-Id", "List-Unsubscribe", "List-Post", "Mailing-List")
|
|
31
|
+
_BULK = {"bulk", "list", "junk"}
|
|
32
|
+
# A Gmail attribution: "On Mon, 1 Jan 2026 at 10:00, Name <a@b.c> wrote:", which
|
|
33
|
+
# Gmail wraps onto two lines when long.
|
|
34
|
+
_ATTRIBUTION = re.compile(r"^On .+wrote:\s*$", re.DOTALL)
|
|
35
|
+
# Lines mobile clients add, dropped only as the very last line of the new text.
|
|
36
|
+
_SENT_FROM = re.compile(r"^(Sent from my \w+|Sent from Gmail Mobile|Get Outlook for \w+)$")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class Incoming:
|
|
41
|
+
"""What the listener reads from one message."""
|
|
42
|
+
|
|
43
|
+
message_id: str
|
|
44
|
+
subject: str
|
|
45
|
+
to: list[str]
|
|
46
|
+
senders: list[str]
|
|
47
|
+
# Ancestry: References, then In-Reply-To, without repeats.
|
|
48
|
+
ancestry: list[str]
|
|
49
|
+
marker: bool
|
|
50
|
+
auto_submitted: str
|
|
51
|
+
resent: bool
|
|
52
|
+
sender_header: list[str]
|
|
53
|
+
list_mail: bool
|
|
54
|
+
report: bool
|
|
55
|
+
# The new text, or "" with `problem` saying why there is none.
|
|
56
|
+
text: str = ""
|
|
57
|
+
problem: str = ""
|
|
58
|
+
attachments: list[str] = field(default_factory=list)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def parse(raw: bytes) -> Incoming:
|
|
62
|
+
"""The headers of `raw`; the body is left for `read_body`, once accepted."""
|
|
63
|
+
message = BytesParser(policy=policy.default).parsebytes(raw, headersonly=True)
|
|
64
|
+
senders = _addresses(message, "From")
|
|
65
|
+
if len(message.get_all("From") or []) != 1:
|
|
66
|
+
# Two From headers are as conflicting as two mailboxes in one.
|
|
67
|
+
senders.append("(conflicting)")
|
|
68
|
+
content_type = message.get_content_type()
|
|
69
|
+
return Incoming(
|
|
70
|
+
message_id=str(message.get("Message-ID", "") or "").strip(),
|
|
71
|
+
subject=" ".join(str(message.get("Subject", "") or "").split()),
|
|
72
|
+
to=_addresses(message, "To"),
|
|
73
|
+
senders=senders,
|
|
74
|
+
ancestry=_ancestry(message),
|
|
75
|
+
marker=MARKER_HEADER in message,
|
|
76
|
+
auto_submitted=str(message.get("Auto-Submitted", "") or "").strip().lower(),
|
|
77
|
+
resent=any(key.lower().startswith("resent-") for key in message.keys()),
|
|
78
|
+
sender_header=_addresses(message, "Sender"),
|
|
79
|
+
list_mail=any(key in message for key in _LIST_HEADERS)
|
|
80
|
+
or str(message.get("Precedence", "")).strip().lower() in _BULK,
|
|
81
|
+
report=content_type in ("multipart/report", "message/delivery-status"),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def read_body(raw: bytes, incoming: Incoming) -> Incoming:
|
|
86
|
+
"""Fill in the new text (or why there is none) and the attachment names."""
|
|
87
|
+
try:
|
|
88
|
+
message = BytesParser(policy=policy.default).parsebytes(raw)
|
|
89
|
+
text, attachments = _body(message)
|
|
90
|
+
except (LookupError, ValueError, UnicodeError, AttributeError):
|
|
91
|
+
incoming.problem = MALFORMED
|
|
92
|
+
return incoming
|
|
93
|
+
incoming.attachments = attachments
|
|
94
|
+
if text is None:
|
|
95
|
+
incoming.problem = EMPTY if attachments else MALFORMED
|
|
96
|
+
return incoming
|
|
97
|
+
new = new_text(text)
|
|
98
|
+
if not new:
|
|
99
|
+
incoming.problem = EMPTY
|
|
100
|
+
elif len(new.encode()) > MAX_TEXT_BYTES:
|
|
101
|
+
incoming.problem = TOO_LONG
|
|
102
|
+
else:
|
|
103
|
+
incoming.text = new
|
|
104
|
+
return incoming
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _addresses(message: EmailMessage, name: str) -> list[str]:
|
|
108
|
+
values = [str(value) for value in message.get_all(name) or []]
|
|
109
|
+
return [address.strip().lower() for _, address in getaddresses(values) if address.strip()]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _ancestry(message: EmailMessage) -> list[str]:
|
|
113
|
+
ids: list[str] = []
|
|
114
|
+
for name in ("References", "In-Reply-To"):
|
|
115
|
+
for value in message.get_all(name) or []:
|
|
116
|
+
for found in re.findall(r"<[^<>\s]+>", str(value)):
|
|
117
|
+
if found not in ids:
|
|
118
|
+
ids.append(found)
|
|
119
|
+
return ids
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _body(message: EmailMessage) -> tuple[str | None, list[str]]:
|
|
123
|
+
"""The best text part (plain preferred, else HTML as text), and attachment names."""
|
|
124
|
+
attachments = []
|
|
125
|
+
plain = rich = None
|
|
126
|
+
for part in message.walk():
|
|
127
|
+
if part.is_multipart():
|
|
128
|
+
continue
|
|
129
|
+
kind = part.get_content_type()
|
|
130
|
+
if part.get_content_disposition() == "attachment" or kind not in (
|
|
131
|
+
"text/plain",
|
|
132
|
+
"text/html",
|
|
133
|
+
):
|
|
134
|
+
attachments.append(part.get_filename() or kind)
|
|
135
|
+
elif kind == "text/plain" and plain is None:
|
|
136
|
+
plain = part
|
|
137
|
+
elif kind == "text/html" and rich is None:
|
|
138
|
+
rich = part
|
|
139
|
+
if plain is not None:
|
|
140
|
+
return str(plain.get_content()), attachments
|
|
141
|
+
if rich is not None:
|
|
142
|
+
return html_text(str(rich.get_content())), attachments
|
|
143
|
+
return None, attachments
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class _TextExtractor(HTMLParser):
|
|
147
|
+
"""Visible text with line breaks, minus Gmail's quoted thread and anything active."""
|
|
148
|
+
|
|
149
|
+
BLOCKS = {"p", "div", "br", "li", "tr", "h1", "h2", "h3", "h4", "h5", "h6", "pre"}
|
|
150
|
+
SKIPPED = {"script", "style", "head", "title", "blockquote"}
|
|
151
|
+
VOID = {"br", "img", "hr", "meta", "link", "input", "wbr", "col", "source"}
|
|
152
|
+
|
|
153
|
+
def __init__(self) -> None:
|
|
154
|
+
super().__init__(convert_charrefs=True)
|
|
155
|
+
self.parts: list[str] = []
|
|
156
|
+
# Open elements, and the depth at which a skipped subtree began.
|
|
157
|
+
self.stack: list[str] = []
|
|
158
|
+
self.skip_from: int | None = None
|
|
159
|
+
|
|
160
|
+
def handle_starttag(self, tag, attrs):
|
|
161
|
+
classes = (dict(attrs).get("class") or "").split()
|
|
162
|
+
if self.skip_from is None and (
|
|
163
|
+
tag in self.SKIPPED or "gmail_quote" in classes or "gmail_signature" in classes
|
|
164
|
+
):
|
|
165
|
+
self.skip_from = len(self.stack)
|
|
166
|
+
if tag in self.BLOCKS and self.skip_from is None:
|
|
167
|
+
self.parts.append("\n")
|
|
168
|
+
if tag not in self.VOID:
|
|
169
|
+
self.stack.append(tag)
|
|
170
|
+
|
|
171
|
+
def handle_endtag(self, tag):
|
|
172
|
+
if tag not in self.stack:
|
|
173
|
+
return
|
|
174
|
+
while self.stack and self.stack.pop() != tag:
|
|
175
|
+
pass
|
|
176
|
+
if self.skip_from is not None and len(self.stack) <= self.skip_from:
|
|
177
|
+
self.skip_from = None
|
|
178
|
+
return
|
|
179
|
+
if tag in self.BLOCKS and self.skip_from is None:
|
|
180
|
+
self.parts.append("\n")
|
|
181
|
+
|
|
182
|
+
def handle_data(self, data):
|
|
183
|
+
if self.skip_from is None:
|
|
184
|
+
self.parts.append(data)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def html_text(markup: str) -> str:
|
|
188
|
+
"""Text from HTML, fetching nothing and dropping the quoted thread."""
|
|
189
|
+
extractor = _TextExtractor()
|
|
190
|
+
extractor.feed(markup)
|
|
191
|
+
extractor.close()
|
|
192
|
+
text = "".join(extractor.parts).replace("\xa0", " ")
|
|
193
|
+
lines = [line.rstrip() for line in text.splitlines()]
|
|
194
|
+
return re.sub(r"\n{3,}", "\n\n", "\n".join(lines)).strip()
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def new_text(text: str) -> str:
|
|
198
|
+
"""What the owner wrote: above Gmail's attribution and quote, above a `-- ` signature.
|
|
199
|
+
|
|
200
|
+
Conservative: a quote is cut only where an attribution line ("On ... wrote:")
|
|
201
|
+
is followed by `>` lines, so quoted code or a `>` the owner typed stays. A
|
|
202
|
+
signature is cut only at the standard `-- ` delimiter, not at any `--` line.
|
|
203
|
+
"""
|
|
204
|
+
lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
|
205
|
+
lines = _before_quote(lines)
|
|
206
|
+
if "-- " in lines:
|
|
207
|
+
lines = lines[: lines.index("-- ")]
|
|
208
|
+
while lines and not lines[-1].strip():
|
|
209
|
+
lines.pop()
|
|
210
|
+
if lines and _SENT_FROM.match(lines[-1].strip()):
|
|
211
|
+
lines.pop()
|
|
212
|
+
return "\n".join(lines).strip()
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _before_quote(lines: list[str]) -> list[str]:
|
|
216
|
+
for index, line in enumerate(lines):
|
|
217
|
+
if not line.startswith("On "):
|
|
218
|
+
continue
|
|
219
|
+
# The attribution may wrap onto the next line.
|
|
220
|
+
for span in (1, 2):
|
|
221
|
+
if not _ATTRIBUTION.match("\n".join(lines[index : index + span])):
|
|
222
|
+
continue
|
|
223
|
+
rest = [later for later in lines[index + span :] if later.strip()]
|
|
224
|
+
if rest and rest[0].startswith(">"):
|
|
225
|
+
return lines[:index]
|
|
226
|
+
return lines
|