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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mailflat
3
- Version: 0.6.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` — send a
108
- DKIM-signed email from this inbox. Pass `in_reply_to` (a `Message-ID`) to stay in a thread.
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` — send a
73
- DKIM-signed email from this inbox. Pass `in_reply_to` (a `Message-ID`) to stay in a thread.
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`, `EncryptedInboxError`
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.6.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()`, `.mark_read()`, `.burn()`, `.download_attachment()`
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 .errors import EncryptedInboxError, MailFlatError, OTPTimeoutError
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) -> dict[str, Any]:
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
- if not self.sender:
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
- self.sender,
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: dict[str, Any] = {"to": to, "subject": subject, "body": body}
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(to, subject=subject, body=body, html=html or None))
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 = "") -> dict:
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