mailflat 0.6.0__tar.gz → 0.7.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.
- {mailflat-0.6.0 → mailflat-0.7.0}/PKG-INFO +50 -6
- {mailflat-0.6.0 → mailflat-0.7.0}/README.md +49 -5
- {mailflat-0.6.0 → mailflat-0.7.0}/src/mailflat/__init__.py +9 -2
- {mailflat-0.6.0 → mailflat-0.7.0}/src/mailflat/errors.py +34 -0
- {mailflat-0.6.0 → mailflat-0.7.0}/src/mailflat/inbox.py +178 -11
- {mailflat-0.6.0 → mailflat-0.7.0}/src/mailflat/langchain.py +20 -4
- {mailflat-0.6.0 → mailflat-0.7.0}/.gitignore +0 -0
- {mailflat-0.6.0 → mailflat-0.7.0}/LICENSE +0 -0
- {mailflat-0.6.0 → mailflat-0.7.0}/pyproject.toml +0 -0
- {mailflat-0.6.0 → mailflat-0.7.0}/src/mailflat/client.py +0 -0
- {mailflat-0.6.0 → mailflat-0.7.0}/src/mailflat/py.typed +0 -0
- {mailflat-0.6.0 → mailflat-0.7.0}/src/mailflat/redact.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mailflat
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
4
4
|
Summary: Official Python SDK for MailFlat — disposable, automation-friendly email inboxes with one-line OTP retrieval.
|
|
5
5
|
Project-URL: Homepage, https://mailflat.net
|
|
6
6
|
Project-URL: Documentation, https://mailflat.net
|
|
@@ -104,8 +104,14 @@ SDK in tests without a network (e.g. `httpx.MockTransport`).
|
|
|
104
104
|
- `.latest(*, direction="in") -> Message | None` — most recent message.
|
|
105
105
|
- `.wait_for_otp(*, timeout=30, poll_interval=1.0) -> str` — poll until an OTP arrives; returns the code.
|
|
106
106
|
- `.wait_for_message(*, timeout=30, poll_interval=1.0, direction="in") -> Message` — poll until a message arrives.
|
|
107
|
-
- `.send(to, *, subject="", body="", html=None, in_reply_to=None) -> dict` —
|
|
108
|
-
DKIM-signed email from this inbox.
|
|
107
|
+
- `.send(to, *, subject="", body="", html=None, in_reply_to=None, cc=None, bcc=None, attachments=None) -> dict` —
|
|
108
|
+
send a DKIM-signed email from this inbox. Returns once the mail is **accepted for
|
|
109
|
+
delivery**, not once it is delivered (see below). Pass `in_reply_to` (a `Message-ID`) to
|
|
110
|
+
stay in a thread.
|
|
111
|
+
- `.message(message_id) -> Message` — fetch one message; the way to check what happened to a send.
|
|
112
|
+
- `.wait_until_sent(message_id, *, timeout=120, poll_interval=2.0) -> Message` — block until
|
|
113
|
+
delivery finishes. Raises `SendFailedError` if it permanently failed, `SendTimeoutError`
|
|
114
|
+
if it is still queued at the deadline.
|
|
109
115
|
- `.mark_read(message_id) -> dict` — mark a message read so later polls can skip it.
|
|
110
116
|
- `.burn() -> dict` — delete every message but keep the address.
|
|
111
117
|
- `.download_attachment(message_id, attachment_id) -> bytes` — fetch an attachment's bytes.
|
|
@@ -117,12 +123,15 @@ SDK in tests without a network (e.g. `httpx.MockTransport`).
|
|
|
117
123
|
|
|
118
124
|
### `Message`
|
|
119
125
|
`.otp`, `.subject`, `.sender`, `.text`, `.html`, `.to_address`, `.direction`, `.received_at`,
|
|
120
|
-
`.links`, `.attachments`, `.spam`, `.headers`, `.is_read`, `.raw`.
|
|
126
|
+
`.links`, `.attachments`, `.spam`, `.headers`, `.is_read`, `.message_id`, `.reply_to_address`, `.raw`.
|
|
127
|
+
|
|
128
|
+
- ⚠️ `.sender` is the **SMTP envelope sender** (MAIL FROM). On transactional mail that is
|
|
129
|
+
usually a bounce address such as `bounces+abc@sendgrid.net`, so `send(to=msg.sender, …)`
|
|
130
|
+
delivers your reply to a bounce processor. Use `.reply_to_address` (or just `.reply()`),
|
|
131
|
+
which resolves `Reply-To` → `From` → envelope sender.
|
|
121
132
|
|
|
122
133
|
- `.links` — URLs found in the body (HTML hrefs first). For "click the verification link" flows.
|
|
123
134
|
- `.attachments` — metadata; `msg.attachments[0].download()` fetches the bytes.
|
|
124
|
-
⚠️ `send()` cannot attach files yet, so you cannot produce an attachment from the SDK
|
|
125
|
-
alone — send one from a normal mail client to test this path.
|
|
126
135
|
- `.spam` — `{score, required, is_spam, rules, scanner}`, or `None` when never scanned
|
|
127
136
|
(which is not the same as a score of 0). ⚠️ Spam scanning is currently **disabled** on
|
|
128
137
|
mailflat.net, so today this is `None` for every message.
|
|
@@ -135,6 +144,41 @@ SDK in tests without a network (e.g. `httpx.MockTransport`).
|
|
|
135
144
|
conversation instead, which does not look like a reply.
|
|
136
145
|
- `.mark_read()` / `.delete()` — act on this message directly.
|
|
137
146
|
|
|
147
|
+
## Attachments, cc and bcc
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
inbox.send(
|
|
151
|
+
"customer@example.com",
|
|
152
|
+
subject="Your invoice",
|
|
153
|
+
body="Attached.",
|
|
154
|
+
cc=["billing@example.com"],
|
|
155
|
+
bcc=["audit@example.com"],
|
|
156
|
+
attachments=["/tmp/invoice.pdf"], # a path — the SDK reads and encodes it
|
|
157
|
+
)
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
An attachment can be a file path, `{"filename": ..., "content": b"..."}`, or
|
|
161
|
+
`{"filename": ..., "content_b64": "..."}`. Size and count limits depend on your plan
|
|
162
|
+
(the free plan is deliberately small); going over raises with the limit spelled out
|
|
163
|
+
rather than silently dropping the file.
|
|
164
|
+
|
|
165
|
+
`bcc` recipients receive the mail but never appear in its headers — not even in their own
|
|
166
|
+
copy. `reply()` accepts all three too, so you can answer a thread with a file attached.
|
|
167
|
+
|
|
168
|
+
## Did it actually go out?
|
|
169
|
+
|
|
170
|
+
`send()` returns as soon as the mail is **accepted**; delivery runs on a queue, so nothing
|
|
171
|
+
is delivered yet when it returns:
|
|
172
|
+
|
|
173
|
+
```python
|
|
174
|
+
res = inbox.send("customer@example.com", subject="Your invoice",
|
|
175
|
+
attachments=["/tmp/invoice.pdf"])
|
|
176
|
+
inbox.wait_until_sent(res["message_id"], timeout=120) # raises if it failed
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
For anything long-running, subscribe to the `message.delivered` / `message.failed`
|
|
180
|
+
webhooks instead of polling.
|
|
181
|
+
|
|
138
182
|
## Errors
|
|
139
183
|
|
|
140
184
|
All errors subclass `MailFlatError`: `AuthenticationError` (401), `MailFlatPermissionError` (403,
|
|
@@ -69,8 +69,14 @@ SDK in tests without a network (e.g. `httpx.MockTransport`).
|
|
|
69
69
|
- `.latest(*, direction="in") -> Message | None` — most recent message.
|
|
70
70
|
- `.wait_for_otp(*, timeout=30, poll_interval=1.0) -> str` — poll until an OTP arrives; returns the code.
|
|
71
71
|
- `.wait_for_message(*, timeout=30, poll_interval=1.0, direction="in") -> Message` — poll until a message arrives.
|
|
72
|
-
- `.send(to, *, subject="", body="", html=None, in_reply_to=None) -> dict` —
|
|
73
|
-
DKIM-signed email from this inbox.
|
|
72
|
+
- `.send(to, *, subject="", body="", html=None, in_reply_to=None, cc=None, bcc=None, attachments=None) -> dict` —
|
|
73
|
+
send a DKIM-signed email from this inbox. Returns once the mail is **accepted for
|
|
74
|
+
delivery**, not once it is delivered (see below). Pass `in_reply_to` (a `Message-ID`) to
|
|
75
|
+
stay in a thread.
|
|
76
|
+
- `.message(message_id) -> Message` — fetch one message; the way to check what happened to a send.
|
|
77
|
+
- `.wait_until_sent(message_id, *, timeout=120, poll_interval=2.0) -> Message` — block until
|
|
78
|
+
delivery finishes. Raises `SendFailedError` if it permanently failed, `SendTimeoutError`
|
|
79
|
+
if it is still queued at the deadline.
|
|
74
80
|
- `.mark_read(message_id) -> dict` — mark a message read so later polls can skip it.
|
|
75
81
|
- `.burn() -> dict` — delete every message but keep the address.
|
|
76
82
|
- `.download_attachment(message_id, attachment_id) -> bytes` — fetch an attachment's bytes.
|
|
@@ -82,12 +88,15 @@ SDK in tests without a network (e.g. `httpx.MockTransport`).
|
|
|
82
88
|
|
|
83
89
|
### `Message`
|
|
84
90
|
`.otp`, `.subject`, `.sender`, `.text`, `.html`, `.to_address`, `.direction`, `.received_at`,
|
|
85
|
-
`.links`, `.attachments`, `.spam`, `.headers`, `.is_read`, `.raw`.
|
|
91
|
+
`.links`, `.attachments`, `.spam`, `.headers`, `.is_read`, `.message_id`, `.reply_to_address`, `.raw`.
|
|
92
|
+
|
|
93
|
+
- ⚠️ `.sender` is the **SMTP envelope sender** (MAIL FROM). On transactional mail that is
|
|
94
|
+
usually a bounce address such as `bounces+abc@sendgrid.net`, so `send(to=msg.sender, …)`
|
|
95
|
+
delivers your reply to a bounce processor. Use `.reply_to_address` (or just `.reply()`),
|
|
96
|
+
which resolves `Reply-To` → `From` → envelope sender.
|
|
86
97
|
|
|
87
98
|
- `.links` — URLs found in the body (HTML hrefs first). For "click the verification link" flows.
|
|
88
99
|
- `.attachments` — metadata; `msg.attachments[0].download()` fetches the bytes.
|
|
89
|
-
⚠️ `send()` cannot attach files yet, so you cannot produce an attachment from the SDK
|
|
90
|
-
alone — send one from a normal mail client to test this path.
|
|
91
100
|
- `.spam` — `{score, required, is_spam, rules, scanner}`, or `None` when never scanned
|
|
92
101
|
(which is not the same as a score of 0). ⚠️ Spam scanning is currently **disabled** on
|
|
93
102
|
mailflat.net, so today this is `None` for every message.
|
|
@@ -100,6 +109,41 @@ SDK in tests without a network (e.g. `httpx.MockTransport`).
|
|
|
100
109
|
conversation instead, which does not look like a reply.
|
|
101
110
|
- `.mark_read()` / `.delete()` — act on this message directly.
|
|
102
111
|
|
|
112
|
+
## Attachments, cc and bcc
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
inbox.send(
|
|
116
|
+
"customer@example.com",
|
|
117
|
+
subject="Your invoice",
|
|
118
|
+
body="Attached.",
|
|
119
|
+
cc=["billing@example.com"],
|
|
120
|
+
bcc=["audit@example.com"],
|
|
121
|
+
attachments=["/tmp/invoice.pdf"], # a path — the SDK reads and encodes it
|
|
122
|
+
)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
An attachment can be a file path, `{"filename": ..., "content": b"..."}`, or
|
|
126
|
+
`{"filename": ..., "content_b64": "..."}`. Size and count limits depend on your plan
|
|
127
|
+
(the free plan is deliberately small); going over raises with the limit spelled out
|
|
128
|
+
rather than silently dropping the file.
|
|
129
|
+
|
|
130
|
+
`bcc` recipients receive the mail but never appear in its headers — not even in their own
|
|
131
|
+
copy. `reply()` accepts all three too, so you can answer a thread with a file attached.
|
|
132
|
+
|
|
133
|
+
## Did it actually go out?
|
|
134
|
+
|
|
135
|
+
`send()` returns as soon as the mail is **accepted**; delivery runs on a queue, so nothing
|
|
136
|
+
is delivered yet when it returns:
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
res = inbox.send("customer@example.com", subject="Your invoice",
|
|
140
|
+
attachments=["/tmp/invoice.pdf"])
|
|
141
|
+
inbox.wait_until_sent(res["message_id"], timeout=120) # raises if it failed
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
For anything long-running, subscribe to the `message.delivered` / `message.failed`
|
|
145
|
+
webhooks instead of polling.
|
|
146
|
+
|
|
103
147
|
## Errors
|
|
104
148
|
|
|
105
149
|
All errors subclass `MailFlatError`: `AuthenticationError` (401), `MailFlatPermissionError` (403,
|
|
@@ -10,7 +10,8 @@ Key exports:
|
|
|
10
10
|
- `MailFlat` — client
|
|
11
11
|
- `Inbox`, `Message`, `Attachment` — domain types
|
|
12
12
|
- errors: `MailFlatError`, `AuthenticationError`, `MailFlatPermissionError`,
|
|
13
|
-
`NotFoundError`, `RateLimitError`, `APIError`, `OTPTimeoutError`,
|
|
13
|
+
`NotFoundError`, `RateLimitError`, `APIError`, `OTPTimeoutError`,
|
|
14
|
+
`SendFailedError`, `SendTimeoutError`, `EncryptedInboxError`
|
|
14
15
|
- `redact_secrets()` — strips secret fields from agent tool output (used by MCP/LangChain)
|
|
15
16
|
"""
|
|
16
17
|
from __future__ import annotations
|
|
@@ -26,13 +27,16 @@ from .errors import (
|
|
|
26
27
|
OTPTimeoutError,
|
|
27
28
|
PermissionError,
|
|
28
29
|
RateLimitError,
|
|
30
|
+
SendError,
|
|
31
|
+
SendFailedError,
|
|
32
|
+
SendTimeoutError,
|
|
29
33
|
)
|
|
30
34
|
from .inbox import Attachment, Inbox, Message
|
|
31
35
|
from .redact import redact_secrets
|
|
32
36
|
|
|
33
37
|
# The package's ONLY version source — pyproject.toml reads it via [tool.hatch.version].
|
|
34
38
|
# Releasing = bump this line and nothing else.
|
|
35
|
-
__version__ = "0.
|
|
39
|
+
__version__ = "0.7.0"
|
|
36
40
|
|
|
37
41
|
__all__ = [
|
|
38
42
|
"MailFlat",
|
|
@@ -47,6 +51,9 @@ __all__ = [
|
|
|
47
51
|
"RateLimitError",
|
|
48
52
|
"APIError",
|
|
49
53
|
"OTPTimeoutError",
|
|
54
|
+
"SendError",
|
|
55
|
+
"SendFailedError",
|
|
56
|
+
"SendTimeoutError",
|
|
50
57
|
"EncryptedInboxError",
|
|
51
58
|
"redact_secrets",
|
|
52
59
|
"__version__",
|
|
@@ -11,6 +11,8 @@ Key exports:
|
|
|
11
11
|
- `RateLimitError` — 429 (carries `retry_after` when the server sent it)
|
|
12
12
|
- `APIError` — any other 4xx/5xx
|
|
13
13
|
- `OTPTimeoutError` — wait_for_otp / wait_for_message timed out
|
|
14
|
+
- `SendFailedError` — a sent mail permanently failed to be delivered
|
|
15
|
+
- `SendTimeoutError` — wait_until_sent gave up while the mail was still queued
|
|
14
16
|
- `EncryptedInboxError` — end-to-end encrypted inbox cannot be read through the API
|
|
15
17
|
"""
|
|
16
18
|
from __future__ import annotations
|
|
@@ -65,6 +67,38 @@ class OTPTimeoutError(MailFlatError):
|
|
|
65
67
|
"""No OTP (or message) arrived before the timeout elapsed."""
|
|
66
68
|
|
|
67
69
|
|
|
70
|
+
class SendError(MailFlatError):
|
|
71
|
+
"""Base for the two ways `wait_until_sent()` can end badly.
|
|
72
|
+
|
|
73
|
+
Separate from `OTPTimeoutError` on purpose. That one means *"the mail I was waiting
|
|
74
|
+
for never arrived"*; these mean *"the mail I sent did not go out"*. Reusing one name
|
|
75
|
+
for both would let a single `except` clause hide two unrelated faults.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def __init__(self, message: str, *, message_id: int | None = None,
|
|
79
|
+
status: str | None = None) -> None:
|
|
80
|
+
super().__init__(message)
|
|
81
|
+
self.message_id = message_id
|
|
82
|
+
#: Last delivery status seen — `"queued"` on timeout, `"failed"` on failure.
|
|
83
|
+
self.status = status
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class SendFailedError(SendError):
|
|
87
|
+
"""Delivery permanently failed; the queue will not retry it.
|
|
88
|
+
|
|
89
|
+
`.message` carries the server's reason (a `5xx` from the recipient's MX, an unknown
|
|
90
|
+
mailbox, and so on).
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class SendTimeoutError(SendError):
|
|
95
|
+
"""Still queued when `wait_until_sent()` gave up.
|
|
96
|
+
|
|
97
|
+
NOT the same as failure: the queue keeps retrying with exponential backoff, so the
|
|
98
|
+
mail may still be delivered. Greylisting alone can hold a mail for several minutes.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
|
|
68
102
|
class EncryptedInboxError(MailFlatError):
|
|
69
103
|
"""The inbox is end-to-end encrypted, so the server cannot read its contents."""
|
|
70
104
|
|
|
@@ -9,17 +9,25 @@ Connected to:
|
|
|
9
9
|
|
|
10
10
|
Key exports:
|
|
11
11
|
- `Inbox` — `.address`, `.messages()`, `.latest()`, `.wait_for_message()`,
|
|
12
|
-
`.wait_for_otp()`, `.send()`, `.
|
|
12
|
+
`.wait_for_otp()`, `.send()`, `.wait_until_sent()`, `.mark_read()`, `.burn()`,
|
|
13
|
+
`.download_attachment()`
|
|
13
14
|
- `Message` — `.otp`, `.subject`, `.text`, `.html`, `.links`, `.attachments`, `.spam`, ...
|
|
14
15
|
- `Attachment` — `.filename`, `.size_bytes`, `.download()`
|
|
15
16
|
"""
|
|
16
17
|
from __future__ import annotations
|
|
17
18
|
|
|
19
|
+
import base64
|
|
20
|
+
import mimetypes
|
|
21
|
+
import os
|
|
18
22
|
import time
|
|
19
23
|
from dataclasses import dataclass, field
|
|
24
|
+
from pathlib import Path
|
|
20
25
|
from typing import TYPE_CHECKING, Any
|
|
21
26
|
|
|
22
|
-
from .
|
|
27
|
+
from email.utils import parseaddr
|
|
28
|
+
|
|
29
|
+
from .errors import (EncryptedInboxError, MailFlatError, OTPTimeoutError, SendFailedError,
|
|
30
|
+
SendTimeoutError)
|
|
23
31
|
|
|
24
32
|
if TYPE_CHECKING: # avoid a circular import (type hint only)
|
|
25
33
|
from .client import MailFlat
|
|
@@ -41,6 +49,76 @@ def _reply_subject(subject: str | None) -> str:
|
|
|
41
49
|
return text if text.lower().startswith("re:") else f"Re: {text}"
|
|
42
50
|
|
|
43
51
|
|
|
52
|
+
def _encode_attachment(item: Any) -> dict[str, str]:
|
|
53
|
+
"""One attachment → the wire shape `{filename, content_type, content_b64}`.
|
|
54
|
+
|
|
55
|
+
Accepts a filesystem path or a dict, because an agent should never have to base64 a
|
|
56
|
+
file by hand — hiding that is most of what an SDK is for. A path is the common case
|
|
57
|
+
(the file is on disk next to the agent) and the type is guessed from the extension so
|
|
58
|
+
the recipient's client shows a PDF as a PDF rather than a nameless blob.
|
|
59
|
+
|
|
60
|
+
The JS SDK deliberately does NOT accept paths: it can run in a browser, where there is
|
|
61
|
+
no filesystem. Same feature, different runtimes — see `docs`.
|
|
62
|
+
"""
|
|
63
|
+
if isinstance(item, (str, os.PathLike)):
|
|
64
|
+
path = Path(item)
|
|
65
|
+
try:
|
|
66
|
+
data = path.read_bytes()
|
|
67
|
+
except OSError as exc:
|
|
68
|
+
raise ValueError(f"Could not read attachment {path}: {exc}") from exc
|
|
69
|
+
ctype = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
|
|
70
|
+
return {"filename": path.name, "content_type": ctype,
|
|
71
|
+
"content_b64": base64.b64encode(data).decode("ascii")}
|
|
72
|
+
|
|
73
|
+
if not isinstance(item, dict):
|
|
74
|
+
raise TypeError(
|
|
75
|
+
"Each attachment must be a file path or a dict with 'filename' and "
|
|
76
|
+
f"'content' (bytes) or 'content_b64' (str), got {type(item).__name__}.")
|
|
77
|
+
|
|
78
|
+
filename = str(item.get("filename") or "").strip()
|
|
79
|
+
if not filename:
|
|
80
|
+
raise ValueError("Attachment dicts need a 'filename'.")
|
|
81
|
+
|
|
82
|
+
if "content_b64" in item:
|
|
83
|
+
content_b64 = item["content_b64"]
|
|
84
|
+
if not isinstance(content_b64, str):
|
|
85
|
+
raise TypeError("'content_b64' must be a base64 string.")
|
|
86
|
+
else:
|
|
87
|
+
content = item.get("content")
|
|
88
|
+
if not isinstance(content, (bytes, bytearray, memoryview)):
|
|
89
|
+
raise TypeError(
|
|
90
|
+
f"Attachment '{filename}' needs 'content' as bytes (or 'content_b64' as "
|
|
91
|
+
"a base64 string).")
|
|
92
|
+
content_b64 = base64.b64encode(bytes(content)).decode("ascii")
|
|
93
|
+
|
|
94
|
+
ctype = item.get("content_type") or mimetypes.guess_type(filename)[0] \
|
|
95
|
+
or "application/octet-stream"
|
|
96
|
+
return {"filename": filename, "content_type": ctype, "content_b64": content_b64}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _send_payload(to: str, subject: str, body: str, html: str | None,
|
|
100
|
+
in_reply_to: str | None, cc: list[str] | None, bcc: list[str] | None,
|
|
101
|
+
attachments: list[Any] | None) -> dict[str, Any]:
|
|
102
|
+
"""Shared body builder for `send()` and `reply()`.
|
|
103
|
+
|
|
104
|
+
One builder because `reply()` must accept exactly what `send()` accepts (K6): an agent
|
|
105
|
+
answering a mail with an invoice attached is the normal case, not an edge case. Two
|
|
106
|
+
builders would drift the first time a field is added to one of them.
|
|
107
|
+
"""
|
|
108
|
+
payload: dict[str, Any] = {"to": to, "subject": subject, "body": body}
|
|
109
|
+
if html is not None:
|
|
110
|
+
payload["html"] = html
|
|
111
|
+
if in_reply_to is not None:
|
|
112
|
+
payload["in_reply_to"] = in_reply_to
|
|
113
|
+
if cc:
|
|
114
|
+
payload["cc"] = list(cc)
|
|
115
|
+
if bcc:
|
|
116
|
+
payload["bcc"] = list(bcc)
|
|
117
|
+
if attachments:
|
|
118
|
+
payload["attachments"] = [_encode_attachment(a) for a in attachments]
|
|
119
|
+
return payload
|
|
120
|
+
|
|
121
|
+
|
|
44
122
|
def _check_direction(direction: str) -> str:
|
|
45
123
|
if direction not in DIRECTIONS:
|
|
46
124
|
raise ValueError(f"direction must be one of {DIRECTIONS!r}, got {direction!r}")
|
|
@@ -158,27 +236,60 @@ class Message:
|
|
|
158
236
|
"""This message's `Message-ID`, or None when headers were not stored."""
|
|
159
237
|
return self.header("message-id")
|
|
160
238
|
|
|
239
|
+
@property
|
|
240
|
+
def reply_to_address(self) -> str | None:
|
|
241
|
+
"""Where a reply should actually go: `Reply-To`, else `From`, else the envelope sender.
|
|
242
|
+
|
|
243
|
+
`.sender` is the SMTP envelope sender (MAIL FROM). For transactional mail that is
|
|
244
|
+
usually a bounce address such as `bounces+abc@sendgrid.net`, so replying to it reaches
|
|
245
|
+
a bounce processor rather than a person. RFC 5322 says a reply goes to `Reply-To` when
|
|
246
|
+
present and to `From` otherwise; the envelope address is only a last resort.
|
|
247
|
+
"""
|
|
248
|
+
for name in ("reply-to", "from"):
|
|
249
|
+
value = self.header(name)
|
|
250
|
+
if isinstance(value, list):
|
|
251
|
+
value = value[0] if value else None
|
|
252
|
+
if value:
|
|
253
|
+
addr = parseaddr(str(value))[1]
|
|
254
|
+
if addr and "@" in addr:
|
|
255
|
+
return addr
|
|
256
|
+
return self.sender
|
|
257
|
+
|
|
161
258
|
def reply(self, body: str = "", *, html: str | None = None,
|
|
162
|
-
subject: str | None = None
|
|
259
|
+
subject: str | None = None, cc: list[str] | None = None,
|
|
260
|
+
bcc: list[str] | None = None,
|
|
261
|
+
attachments: list[Any] | None = None) -> dict[str, Any]:
|
|
163
262
|
"""Reply to this message, keeping it in the same conversation.
|
|
164
263
|
|
|
165
264
|
Fills in what a reply needs and is easy to get wrong by hand: the recipient, an
|
|
166
265
|
`Re:` subject that is not doubled up, and the `In-Reply-To` / `References` headers
|
|
167
266
|
Gmail and Outlook use to thread. Without those headers a reply shows up as a separate
|
|
168
267
|
conversation, which is what a hand-rolled `send()` produces.
|
|
268
|
+
|
|
269
|
+
The recipient comes from `reply_to_address`, not from `.sender`: the envelope sender of
|
|
270
|
+
transactional mail is usually a bounce address, so `send(to=msg.sender, ...)` quietly
|
|
271
|
+
delivers the reply to a machine.
|
|
272
|
+
|
|
273
|
+
Takes everything `send()` takes, attachments included — answering a mail with a
|
|
274
|
+
file attached is the normal case, and an agent should not have to drop back to
|
|
275
|
+
`send()` (losing the threading headers) just to attach one.
|
|
169
276
|
"""
|
|
170
277
|
if self._inbox is None:
|
|
171
278
|
raise ValueError(
|
|
172
279
|
"This message isn't attached to an inbox. Use inbox.send(...) instead."
|
|
173
280
|
)
|
|
174
|
-
|
|
281
|
+
target = self.reply_to_address
|
|
282
|
+
if not target:
|
|
175
283
|
raise ValueError("This message has no sender to reply to.")
|
|
176
284
|
return self._inbox.send(
|
|
177
|
-
|
|
285
|
+
target,
|
|
178
286
|
subject=subject if subject is not None else _reply_subject(self.subject),
|
|
179
287
|
body=body,
|
|
180
288
|
html=html,
|
|
181
289
|
in_reply_to=self.message_id,
|
|
290
|
+
cc=cc,
|
|
291
|
+
bcc=bcc,
|
|
292
|
+
attachments=attachments,
|
|
182
293
|
)
|
|
183
294
|
|
|
184
295
|
def mark_read(self) -> dict[str, Any]:
|
|
@@ -259,6 +370,17 @@ class Inbox:
|
|
|
259
370
|
f"/api/v1/inboxes/{self.address}/messages?direction={direction}")
|
|
260
371
|
return [self._wrap(e) for e in (res.get("emails") or [])]
|
|
261
372
|
|
|
373
|
+
def message(self, message_id: int) -> Message:
|
|
374
|
+
"""Fetch one message by id — the way to ask "what happened to this send?".
|
|
375
|
+
|
|
376
|
+
Without it the only way to check a sent mail's status was to pull the whole
|
|
377
|
+
outbound list and find the id by hand, which for an agent that sent 100 mails
|
|
378
|
+
meant 100 messages per check.
|
|
379
|
+
"""
|
|
380
|
+
res = self._client._get(
|
|
381
|
+
f"/api/v1/inboxes/{self.address}/messages/{message_id}")
|
|
382
|
+
return self._wrap(res.get("email") or {})
|
|
383
|
+
|
|
262
384
|
def latest(self, *, direction: str = "in") -> Message | None:
|
|
263
385
|
"""Return the most recent message, or None when the inbox is empty.
|
|
264
386
|
|
|
@@ -368,23 +490,68 @@ class Inbox:
|
|
|
368
490
|
# ----------------------------------------------------------------- yazma
|
|
369
491
|
def send(
|
|
370
492
|
self, to: str, *, subject: str = "", body: str = "", html: str | None = None,
|
|
371
|
-
in_reply_to: str | None = None,
|
|
493
|
+
in_reply_to: str | None = None, cc: list[str] | None = None,
|
|
494
|
+
bcc: list[str] | None = None, attachments: list[Any] | None = None,
|
|
372
495
|
) -> dict[str, Any]:
|
|
373
496
|
"""Send mail from this inbox's address (DKIM-signed, through our own MTA).
|
|
374
497
|
|
|
498
|
+
Returns as soon as the mail is **accepted for delivery** — not once it is
|
|
499
|
+
delivered. Delivery happens on a queue, so the response carries `message_id` and
|
|
500
|
+
`queued: true`; use `wait_until_sent(message_id)` (or a `message.delivered`
|
|
501
|
+
webhook) to find out how it ended. Blocking here would put back the exact stall
|
|
502
|
+
the queue was built to remove.
|
|
503
|
+
|
|
504
|
+
`attachments` takes file paths or dicts — the base64 encoding is done here:
|
|
505
|
+
|
|
506
|
+
inbox.send(to, attachments=["/tmp/invoice.pdf"])
|
|
507
|
+
inbox.send(to, attachments=[{"filename": "a.pdf", "content": raw_bytes}])
|
|
508
|
+
|
|
509
|
+
Size and count limits depend on the plan (free is deliberately small); going over
|
|
510
|
+
raises with the limit spelled out rather than silently dropping the file.
|
|
511
|
+
|
|
512
|
+
`bcc` recipients receive the mail but never appear in its headers — not even in
|
|
513
|
+
their own copy.
|
|
514
|
+
|
|
375
515
|
Pass `in_reply_to` (a `Message-ID`) to keep the mail in an existing conversation;
|
|
376
516
|
without it the recipient's client starts a new thread. `Message.reply()` fills this
|
|
377
517
|
in for you.
|
|
378
518
|
|
|
379
519
|
Not retried on gateway errors: a retried send can deliver the same mail twice.
|
|
380
520
|
"""
|
|
381
|
-
payload
|
|
382
|
-
if html is not None:
|
|
383
|
-
payload["html"] = html
|
|
384
|
-
if in_reply_to is not None:
|
|
385
|
-
payload["in_reply_to"] = in_reply_to
|
|
521
|
+
payload = _send_payload(to, subject, body, html, in_reply_to, cc, bcc, attachments)
|
|
386
522
|
return self._client._post(f"/api/v1/inboxes/{self.address}/send", payload)
|
|
387
523
|
|
|
524
|
+
def wait_until_sent(self, message_id: int, *, timeout: float = 120.0,
|
|
525
|
+
poll_interval: float = 2.0) -> Message:
|
|
526
|
+
"""Block until a sent mail reaches a final delivery state.
|
|
527
|
+
|
|
528
|
+
`send()` is asynchronous, so "did it actually go out?" has no synchronous answer.
|
|
529
|
+
This polls the message until the server reports one, which is the pull half of the
|
|
530
|
+
contract (the push half is the `message.delivered` / `message.failed` webhook —
|
|
531
|
+
prefer that when you can receive one).
|
|
532
|
+
|
|
533
|
+
Returns the message on success. Raises `SendFailedError` if delivery permanently
|
|
534
|
+
failed and `SendTimeoutError` if it is still queued when the timeout elapses.
|
|
535
|
+
It does NOT return quietly on failure: a helper called `wait_until_sent` that
|
|
536
|
+
hands back a failed mail as if nothing happened is how mail goes missing.
|
|
537
|
+
"""
|
|
538
|
+
deadline = time.monotonic() + timeout
|
|
539
|
+
while True:
|
|
540
|
+
message = self.message(message_id)
|
|
541
|
+
status = message.send_status
|
|
542
|
+
if status in ("sent", "unsigned"):
|
|
543
|
+
return message
|
|
544
|
+
if status == "failed":
|
|
545
|
+
raise SendFailedError(
|
|
546
|
+
message.send_error or "Delivery failed",
|
|
547
|
+
message_id=message_id, status=status)
|
|
548
|
+
if time.monotonic() >= deadline:
|
|
549
|
+
raise SendTimeoutError(
|
|
550
|
+
f"Message {message_id} was still {status or 'queued'} after "
|
|
551
|
+
f"{timeout:g}s. It may still be delivered; the queue keeps retrying.",
|
|
552
|
+
message_id=message_id, status=status)
|
|
553
|
+
time.sleep(min(poll_interval, max(0.0, deadline - time.monotonic())))
|
|
554
|
+
|
|
388
555
|
def mark_read(self, message_id: int) -> dict[str, Any]:
|
|
389
556
|
"""Mark one message as read.
|
|
390
557
|
|
|
@@ -14,6 +14,11 @@ Usage:
|
|
|
14
14
|
⚠️ Tool output goes through `redact_secrets()`: a per-inbox `api_key` must never reach the
|
|
15
15
|
model's context (and from there LangSmith traces) — see B-055.
|
|
16
16
|
|
|
17
|
+
⚠️ `send_email` / `reply` take `cc` and `bcc` but NOT attachments. An address is a short
|
|
18
|
+
string; file bytes would have to travel through the model's context, which costs tokens
|
|
19
|
+
and is simply impossible for a multi-megabyte file. Attach files from the SDK
|
|
20
|
+
(`inbox.send(..., attachments=[...])`) — the code surface, not the model surface.
|
|
21
|
+
|
|
17
22
|
Connected to:
|
|
18
23
|
- imports from: mailflat.client (MailFlat), mailflat.redact, langchain_core.tools
|
|
19
24
|
- imported by: user code (LangChain agents)
|
|
@@ -146,23 +151,31 @@ class MailFlatToolkit:
|
|
|
146
151
|
return {"email": None, "error": str(e)}
|
|
147
152
|
|
|
148
153
|
def send_email(
|
|
149
|
-
address: str, to: str, subject: str = "", body: str = "", html: str = ""
|
|
154
|
+
address: str, to: str, subject: str = "", body: str = "", html: str = "",
|
|
155
|
+
cc: list[str] | None = None, bcc: list[str] | None = None,
|
|
150
156
|
) -> dict:
|
|
151
157
|
"""Send an email FROM the given inbox address (DKIM-signed via MailFlat's MTA).
|
|
152
158
|
|
|
159
|
+
Returns once the mail is ACCEPTED for delivery, not once it is delivered.
|
|
160
|
+
|
|
153
161
|
Args:
|
|
154
162
|
address: The inbox address to send from (must belong to this API key).
|
|
155
163
|
to: Recipient email address.
|
|
156
164
|
subject: Email subject line.
|
|
157
165
|
body: Plain-text body.
|
|
158
166
|
html: Optional HTML body.
|
|
167
|
+
cc: Addresses to copy. They appear in the mail's headers.
|
|
168
|
+
bcc: Addresses to copy privately. They never appear in the headers.
|
|
159
169
|
"""
|
|
160
170
|
try:
|
|
161
|
-
return redact_secrets(client.inbox(address).send(
|
|
171
|
+
return redact_secrets(client.inbox(address).send(
|
|
172
|
+
to, subject=subject, body=body, html=html or None,
|
|
173
|
+
cc=cc or None, bcc=bcc or None))
|
|
162
174
|
except MailFlatError as e:
|
|
163
175
|
return {"error": str(e)}
|
|
164
176
|
|
|
165
|
-
def reply(address: str, message_id: int, body: str = "", html: str = ""
|
|
177
|
+
def reply(address: str, message_id: int, body: str = "", html: str = "",
|
|
178
|
+
cc: list[str] | None = None, bcc: list[str] | None = None) -> dict:
|
|
166
179
|
"""Reply to a message so it stays in the SAME conversation.
|
|
167
180
|
|
|
168
181
|
Prefer this over send_email when answering: it fills in the recipient, an `Re:`
|
|
@@ -174,13 +187,16 @@ class MailFlatToolkit:
|
|
|
174
187
|
message_id: The id of the message to answer, as returned by read_messages.
|
|
175
188
|
body: Plain-text reply body.
|
|
176
189
|
html: Optional HTML body.
|
|
190
|
+
cc: Addresses to copy. They appear in the mail's headers.
|
|
191
|
+
bcc: Addresses to copy privately. They never appear in the headers.
|
|
177
192
|
"""
|
|
178
193
|
try:
|
|
179
194
|
inbox = client.inbox(address)
|
|
180
195
|
target = next((m for m in inbox.messages(direction="all") if m.id == message_id), None)
|
|
181
196
|
if target is None:
|
|
182
197
|
return {"error": f"Message {message_id} not found in {address}"}
|
|
183
|
-
return redact_secrets(target.reply(body, html=html or None
|
|
198
|
+
return redact_secrets(target.reply(body, html=html or None,
|
|
199
|
+
cc=cc or None, bcc=bcc or None))
|
|
184
200
|
except (MailFlatError, ValueError) as e:
|
|
185
201
|
return {"error": str(e)}
|
|
186
202
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|