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.
Files changed (163) hide show
  1. pcode/__init__.py +7 -0
  2. pcode/_job_supervisor.py +65 -0
  3. pcode/agent.py +592 -0
  4. pcode/anthropic_oauth.py +469 -0
  5. pcode/app.py +3165 -0
  6. pcode/appearance.py +181 -0
  7. pcode/aside.py +541 -0
  8. pcode/aside_guard.py +39 -0
  9. pcode/aside_ui.py +683 -0
  10. pcode/auth.py +113 -0
  11. pcode/block.py +39 -0
  12. pcode/browser.py +393 -0
  13. pcode/cache_diagnostics.py +336 -0
  14. pcode/cache_settings.py +58 -0
  15. pcode/cache_warnings.py +139 -0
  16. pcode/claude_sdk/__init__.py +96 -0
  17. pcode/claude_sdk/cli.py +52 -0
  18. pcode/claude_sdk/errors.py +61 -0
  19. pcode/claude_sdk/messages.py +154 -0
  20. pcode/claude_sdk/model.py +179 -0
  21. pcode/claude_sdk/resume.py +114 -0
  22. pcode/claude_sdk/session.py +547 -0
  23. pcode/claude_sdk/session_pool.py +251 -0
  24. pcode/claude_sdk/workspace.py +34 -0
  25. pcode/cli.py +73 -0
  26. pcode/clipboard.py +76 -0
  27. pcode/code_mode.py +43 -0
  28. pcode/codex_login.py +189 -0
  29. pcode/command_transcript.py +71 -0
  30. pcode/commands.py +145 -0
  31. pcode/compaction.py +457 -0
  32. pcode/completion.py +263 -0
  33. pcode/config.py +223 -0
  34. pcode/context_breakdown.py +178 -0
  35. pcode/context_usage.py +52 -0
  36. pcode/controller.py +3496 -0
  37. pcode/conversation_ids.py +27 -0
  38. pcode/conversation_tree.py +160 -0
  39. pcode/copy_ui.py +172 -0
  40. pcode/delegation.py +236 -0
  41. pcode/delta.py +295 -0
  42. pcode/diagnostics.py +226 -0
  43. pcode/edit_preview.py +158 -0
  44. pcode/edit_transcript.py +287 -0
  45. pcode/edit_ui.py +850 -0
  46. pcode/edits.py +162 -0
  47. pcode/email_remote/__init__.py +14 -0
  48. pcode/email_remote/cli.py +133 -0
  49. pcode/email_remote/listener.py +695 -0
  50. pcode/email_remote/mailbox.py +336 -0
  51. pcode/email_remote/outbound.py +75 -0
  52. pcode/email_remote/parsing.py +226 -0
  53. pcode/email_remote/render.py +175 -0
  54. pcode/email_remote/state.py +79 -0
  55. pcode/error_report.py +38 -0
  56. pcode/ext.py +602 -0
  57. pcode/extension_guide.md +311 -0
  58. pcode/extensions/browser.py +284 -0
  59. pcode/extensions/ponytail.py +217 -0
  60. pcode/extensions/sandbox.py +138 -0
  61. pcode/extensions/session_history.py +170 -0
  62. pcode/extensions/web_research.py +89 -0
  63. pcode/file_refs.py +265 -0
  64. pcode/filesystem.py +138 -0
  65. pcode/frame.py +149 -0
  66. pcode/gateway.py +465 -0
  67. pcode/git_diff.py +497 -0
  68. pcode/history.py +589 -0
  69. pcode/history_cursor.py +147 -0
  70. pcode/history_embeddings.py +102 -0
  71. pcode/host.py +941 -0
  72. pcode/host_protocol.py +291 -0
  73. pcode/host_ui.py +225 -0
  74. pcode/input_keys.py +35 -0
  75. pcode/inspection.py +289 -0
  76. pcode/inspector_ui.py +448 -0
  77. pcode/isolated_delegation.py +425 -0
  78. pcode/job_notices.py +54 -0
  79. pcode/jobs.py +584 -0
  80. pcode/jobs_ui.py +265 -0
  81. pcode/keymap.py +215 -0
  82. pcode/layout_speed.py +103 -0
  83. pcode/links.py +90 -0
  84. pcode/links_ui.py +200 -0
  85. pcode/live.py +1301 -0
  86. pcode/llm_proxy.py +44 -0
  87. pcode/logins.py +233 -0
  88. pcode/mcp.py +683 -0
  89. pcode/mcp_notice.py +91 -0
  90. pcode/mcp_oauth.py +381 -0
  91. pcode/meridian.py +233 -0
  92. pcode/meridian_process.py +337 -0
  93. pcode/meridian_reminders.py +107 -0
  94. pcode/meridian_setup.py +356 -0
  95. pcode/model_metadata.py +451 -0
  96. pcode/model_ui.py +168 -0
  97. pcode/models.py +209 -0
  98. pcode/native_results.py +119 -0
  99. pcode/oauth_pages.py +66 -0
  100. pcode/output_limits.py +41 -0
  101. pcode/paste.py +45 -0
  102. pcode/path_completion.py +66 -0
  103. pcode/plan_preview.py +131 -0
  104. pcode/plan_sizes.py +35 -0
  105. pcode/planning.py +161 -0
  106. pcode/popup_ui.py +873 -0
  107. pcode/preferences.py +1176 -0
  108. pcode/prefix_keys.py +490 -0
  109. pcode/profile_benchmark.py +332 -0
  110. pcode/profiling.py +566 -0
  111. pcode/project_trust.py +94 -0
  112. pcode/prompt_keys.py +286 -0
  113. pcode/remote.py +717 -0
  114. pcode/remote_print.py +125 -0
  115. pcode/remote_profile.py +237 -0
  116. pcode/replay_benchmark.py +319 -0
  117. pcode/repo_context.py +158 -0
  118. pcode/retries.py +28 -0
  119. pcode/rpc.py +280 -0
  120. pcode/runtime.py +244 -0
  121. pcode/sandbox.py +454 -0
  122. pcode/session_naming.py +82 -0
  123. pcode/session_ui.py +550 -0
  124. pcode/sessions.py +892 -0
  125. pcode/shell.py +113 -0
  126. pcode/shell_mode.py +172 -0
  127. pcode/shell_tools.py +459 -0
  128. pcode/skills.py +161 -0
  129. pcode/stall_log.py +187 -0
  130. pcode/startup_input.py +96 -0
  131. pcode/steering.py +27 -0
  132. pcode/stream_display.py +188 -0
  133. pcode/stream_events.py +437 -0
  134. pcode/strict_tools.py +130 -0
  135. pcode/syntax.py +43 -0
  136. pcode/syntax_colors.py +206 -0
  137. pcode/task_prompt.py +28 -0
  138. pcode/task_worktrees.py +482 -0
  139. pcode/terminal_notify.py +383 -0
  140. pcode/theme.py +135 -0
  141. pcode/theme_gallery.py +88 -0
  142. pcode/thinking_markdown.py +41 -0
  143. pcode/token_accounting.py +67 -0
  144. pcode/tool_display.py +784 -0
  145. pcode/tool_output_limits.py +108 -0
  146. pcode/tool_panel.py +377 -0
  147. pcode/transcript_log.py +127 -0
  148. pcode/transcript_notice.py +103 -0
  149. pcode/tree_ui.py +288 -0
  150. pcode/turn.py +44 -0
  151. pcode/ui.py +3745 -0
  152. pcode/usage.py +293 -0
  153. pcode/word_wrap.py +107 -0
  154. pcode/worker_ui.py +235 -0
  155. pcode/workers.py +136 -0
  156. pcode/workspace.py +57 -0
  157. pcode/workspace_filesystem.py +72 -0
  158. pcode/worktree.py +650 -0
  159. pcode-0.1.0.dist-info/METADATA +145 -0
  160. pcode-0.1.0.dist-info/RECORD +163 -0
  161. pcode-0.1.0.dist-info/WHEEL +4 -0
  162. pcode-0.1.0.dist-info/entry_points.txt +3 -0
  163. 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