Отправка письма без опасного повтора#
Композер отвечает одним из трёх способов: показывает подтверждение, открывает диалог об отклонённом адресе или не показывает ничего. Последний случай не означает ошибку доставки. Клиент не повторяет письмо, а ищет его в «Отправленных» по получателю и теме.
Ответ сайта |
Правило HTTP или признак страницы |
Что получает вызывающий |
|---|---|---|
|
успешный JSON или видимый |
|
|
типизированная ошибка или открытый |
|
|
JSON без подтверждения на странице |
|
→ Request · POST /api/v1/messages/send
Authorization: Bearer session-1
Content-Type: application/json
X-Mail-Signature: 4a0a5ef967590a6eeec04612bc5a0066c0826b0c3d36af223ab541bc2055c815
{"recipient":"grace@mail.example","subject":"Проверка доставки","body":"Встречаемся в пятницу."}
← Response · 200 OK
{
"status": "ok",
"body": {"outcome": "sent", "message_id": 100}
}
Подпись строится после сериализации тела. body_digest("sha256") даёт hex-строку хеша,
hmac_sha256(...) подписывает её учебным ключом, а header_output(...) записывает результат
в X-Mail-Signature.
# examples/mail/http/send.py
MAIL_BEARER = BearerScheme("mail-bearer")
MAIL_KEY = SigningKeyRequirement("mail-send")
MAIL_HMAC = hmac_sha256(
key=MAIL_KEY,
base=body_digest("sha256"),
output=header_output("X-Mail-Signature"),
)
def signing_key(requirement: SigningKeyRequirement) -> SigningKey:
if requirement == MAIL_KEY:
return SigningKey(b"mail-demo-secret")
raise LookupError(requirement.name)
SendMessage отличает успешный конверт от отклонённого адреса по Const. Поиск в
«Отправленных» остаётся отдельной идемпотентной GET-операцией.
# examples/mail/http/send.py
@dataclass(frozen=True, slots=True, kw_only=True)
class SendMessage(HttpOperation[SendResult]):
__http__ = Http.post(
"/api/v1/messages/send",
success={200: Json(SendEnvelope)},
errors={200: (Json(RejectedEnvelope), RecipientRejected)},
security=MAIL_BEARER,
signing=MAIL_HMAC,
)
body: Annotated[NewMessage, JsonBody()]
@dataclass(frozen=True, slots=True, kw_only=True)
class FindSent(HttpOperation[SentMessages]):
__http__ = Http.get(
"/api/v1/messages/sent",
success={200: Json(SentMessagesEnvelope)},
security=MAIL_BEARER,
)
recipient: Query[str]
subject: Query[str]
class MailApi(SyncApi):
base_url = "https://mail.example"
send = op(SendMessage)
sent = op(FindSent)
После silent код не вызывает send второй раз. Он ищет письмо с теми же получателем и темой;
найденный идентификатор 101 доказывает, что первый запрос уже сработал.
# examples/mail/http/send.py
def send_three_messages() -> tuple[int, str, str, int]:
identity = Identity(
auth=(MAIL_BEARER.static("session-1"),),
key_provider=signing_key,
)
with mail_client() as client:
api = MailApi(client, identity=identity)
sent = api.send(
body=NewMessage(
recipient="grace@mail.example",
subject="Проверка доставки",
body="Встречаемся в пятницу.",
)
)
try:
api.send(
body=NewMessage(
recipient="rejected@mail.example",
subject="Проверка адреса",
body="Это письмо отклонят.",
)
)
except RecipientRejected as error:
rejected = type(error).__name__
silent = api.send(
body=NewMessage(
recipient="quiet@mail.example",
subject="Тихая доставка",
body="Проверим папку отправленных.",
)
)
found = api.sent(
recipient="quiet@mail.example",
subject="Тихая доставка",
)
return sent.message_id or 0, rejected, silent.outcome, found.items[0].id
Файл примера целиком
"""Sign a mail send request and handle sent, rejected and silent outcomes."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Annotated
from pydantic import BaseModel
from eazy_sdk import Http, HttpOperation, Identity, Query, SyncApi, op
from eazy_sdk.auth import BearerScheme
from eazy_sdk.request import (
SigningKey,
SigningKeyRequirement,
body_digest,
header_output,
hmac_sha256,
)
from eazy_sdk.request.markers import JsonBody
from eazy_sdk.response import ApiError, Const, Json, Payload
from examples.mail.http._client import mail_client
class NewMessage(BaseModel):
recipient: str
subject: str
body: str
class SendResult(BaseModel):
outcome: str
message_id: int | None = None
class SendEnvelope(BaseModel):
status: Annotated[str, Const("ok")]
body: Payload[SendResult]
class RejectedDetails(BaseModel):
code: Annotated[str, Const("recipient_rejected")]
message: str
class RejectedEnvelope(BaseModel):
status: Annotated[str, Const("recipient_rejected")]
body: RejectedDetails
class RecipientRejected(ApiError[RejectedEnvelope]):
pass
class SentMessage(BaseModel):
id: int
sender: str
recipient: str
subject: str
class SentMessages(BaseModel):
items: list[SentMessage]
class SentMessagesEnvelope(BaseModel):
status: Annotated[str, Const("ok")]
body: Payload[SentMessages]
# region docs: http-send-signature
# examples/mail/http/send.py
MAIL_BEARER = BearerScheme("mail-bearer")
MAIL_KEY = SigningKeyRequirement("mail-send")
MAIL_HMAC = hmac_sha256(
key=MAIL_KEY,
base=body_digest("sha256"),
output=header_output("X-Mail-Signature"),
)
def signing_key(requirement: SigningKeyRequirement) -> SigningKey:
if requirement == MAIL_KEY:
return SigningKey(b"mail-demo-secret")
raise LookupError(requirement.name)
# endregion docs: http-send-signature
# region docs: http-send-operations
# examples/mail/http/send.py
@dataclass(frozen=True, slots=True, kw_only=True)
class SendMessage(HttpOperation[SendResult]):
__http__ = Http.post(
"/api/v1/messages/send",
success={200: Json(SendEnvelope)},
errors={200: (Json(RejectedEnvelope), RecipientRejected)},
security=MAIL_BEARER,
signing=MAIL_HMAC,
)
body: Annotated[NewMessage, JsonBody()]
@dataclass(frozen=True, slots=True, kw_only=True)
class FindSent(HttpOperation[SentMessages]):
__http__ = Http.get(
"/api/v1/messages/sent",
success={200: Json(SentMessagesEnvelope)},
security=MAIL_BEARER,
)
recipient: Query[str]
subject: Query[str]
class MailApi(SyncApi):
base_url = "https://mail.example"
send = op(SendMessage)
sent = op(FindSent)
# endregion docs: http-send-operations
# region docs: http-send-flow
# examples/mail/http/send.py
def send_three_messages() -> tuple[int, str, str, int]:
identity = Identity(
auth=(MAIL_BEARER.static("session-1"),),
key_provider=signing_key,
)
with mail_client() as client:
api = MailApi(client, identity=identity)
sent = api.send(
body=NewMessage(
recipient="grace@mail.example",
subject="Проверка доставки",
body="Встречаемся в пятницу.",
)
)
try:
api.send(
body=NewMessage(
recipient="rejected@mail.example",
subject="Проверка адреса",
body="Это письмо отклонят.",
)
)
except RecipientRejected as error:
rejected = type(error).__name__
silent = api.send(
body=NewMessage(
recipient="quiet@mail.example",
subject="Тихая доставка",
body="Проверим папку отправленных.",
)
)
found = api.sent(
recipient="quiet@mail.example",
subject="Тихая доставка",
)
return sent.message_id or 0, rejected, silent.outcome, found.items[0].id
# endregion docs: http-send-flow
def main() -> None:
sent, rejected, silent, found = send_three_messages()
print(f"sent: {sent}")
print(f"rejected: {rejected}")
print(f"silent response: {silent}")
print(f"found in Sent: {found}")
if __name__ == "__main__":
main()
Запуск: uv run python -m examples.mail.http.send.
sent: 100
rejected: RecipientRejected
silent response: silent
found in Sent: 101
Страница /compose/ содержит форму и два наблюдаемых признака. Третий исход определяется по
истечению общего срока ожидания, когда ни подтверждение, ни диалог не появились.
<main data-page="compose">
<form data-composer>
<input name="recipient" type="email"><input name="subject">
<textarea name="body"></textarea><button type="submit">Отправить</button>
</form>
<div role="status" hidden>Отправлено</div>
<dialog data-outcome="rejected">Адрес отклонён</dialog>
</main>
HMAC здесь вычисляет JavaScript самой учебной страницы перед fetch. Браузерная операция
отвечает только за поля формы, клик и распознавание результата через outcomes(...).
# examples/mail/browser/send.py
class ComposerPage:
recipient: Annotated[Element, css('input[name="recipient"]')]
subject: Annotated[Element, css('input[name="subject"]')]
body: Annotated[Element, css('textarea[name="body"]')]
submit: Annotated[Element, css('button[type="submit"]')]
rejected: Annotated[Element, css('[data-outcome="rejected"]')]
@dataclass(frozen=True, slots=True)
class Sent:
pass
@dataclass(frozen=True, slots=True)
class Rejected:
pass
@dataclass(frozen=True, slots=True)
class Silent:
pass
async def _sent(content: ComposerPage) -> Sent:
_ = content
return Sent()
async def _rejected(content: ComposerPage) -> Rejected:
_ = content
return Rejected()
async def _silent(content: ComposerPage) -> Silent:
_ = content
return Silent()
@dataclass(frozen=True, slots=True, kw_only=True)
class OpenComposer(BrowserOperation[None, None]):
__browser__ = Browser.goto("/compose/", at=css("[data-composer]"))
@dataclass(frozen=True, slots=True, kw_only=True)
class SubmitMessage(BrowserOperation[ComposerPage, Sent | Rejected | Silent]):
__browser__ = Browser.act(
ComposerPage,
at=css("[data-composer]"),
outcomes=outcomes(
when(visible(css('[role="status"]')), then=_sent),
when(visible(css('[data-outcome="rejected"]')), then=_rejected),
otherwise=_silent,
),
)
recipient: str
subject: str
body: str
async def act(self, content: ComposerPage) -> None:
if await content.rejected.visible():
await content.rejected.press("Escape")
await content.recipient.fill(self.recipient)
await content.subject.fill(self.subject)
await content.body.fill(self.body)
await content.submit.click()
class ComposerApi(AsyncBrowserApi):
open = op(OpenComposer)
send = op(SubmitMessage)
Три последовательных вызова идут через настоящий Chromium и один MailSite. Перед новым
вызовом операция закрывает диалог предыдущего отказа, если он ещё открыт.
# examples/mail/browser/send.py
async def send_three_messages() -> tuple[
Sent | Rejected | Silent,
Sent | Rejected | Silent,
Sent | Rejected | Silent,
]:
async with playwright_mail() as runtime:
composer = ComposerApi(runtime.client)
await composer.open(options=OPTIONS)
sent = await composer.send(
recipient="grace@mail.example",
subject="Проверка доставки",
body="Встречаемся в пятницу.",
options=OPTIONS,
)
rejected = await composer.send(
recipient="rejected@mail.example",
subject="Проверка адреса",
body="Это письмо отклонят.",
options=OPTIONS,
)
silent = await composer.send(
recipient="quiet@mail.example",
subject="Тихая доставка",
body="Проверим папку отправленных.",
options=OPTIONS,
)
return sent, rejected, silent
Файл примера целиком
"""Submit the mail composer and distinguish its three visible outcomes."""
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from typing import Annotated
from eazy_sdk import op
from eazy_sdk_browser import (
AsyncBrowserApi,
Browser,
BrowserCallOptions,
BrowserOperation,
Element,
css,
outcomes,
visible,
when,
)
from examples.mail.site._playwright import playwright_mail
OPTIONS = BrowserCallOptions(timeout=1.0, element_timeout=5.0)
# region docs: browser-send-operation
# examples/mail/browser/send.py
class ComposerPage:
recipient: Annotated[Element, css('input[name="recipient"]')]
subject: Annotated[Element, css('input[name="subject"]')]
body: Annotated[Element, css('textarea[name="body"]')]
submit: Annotated[Element, css('button[type="submit"]')]
rejected: Annotated[Element, css('[data-outcome="rejected"]')]
@dataclass(frozen=True, slots=True)
class Sent:
pass
@dataclass(frozen=True, slots=True)
class Rejected:
pass
@dataclass(frozen=True, slots=True)
class Silent:
pass
async def _sent(content: ComposerPage) -> Sent:
_ = content
return Sent()
async def _rejected(content: ComposerPage) -> Rejected:
_ = content
return Rejected()
async def _silent(content: ComposerPage) -> Silent:
_ = content
return Silent()
@dataclass(frozen=True, slots=True, kw_only=True)
class OpenComposer(BrowserOperation[None, None]):
__browser__ = Browser.goto("/compose/", at=css("[data-composer]"))
@dataclass(frozen=True, slots=True, kw_only=True)
class SubmitMessage(BrowserOperation[ComposerPage, Sent | Rejected | Silent]):
__browser__ = Browser.act(
ComposerPage,
at=css("[data-composer]"),
outcomes=outcomes(
when(visible(css('[role="status"]')), then=_sent),
when(visible(css('[data-outcome="rejected"]')), then=_rejected),
otherwise=_silent,
),
)
recipient: str
subject: str
body: str
async def act(self, content: ComposerPage) -> None:
if await content.rejected.visible():
await content.rejected.press("Escape")
await content.recipient.fill(self.recipient)
await content.subject.fill(self.subject)
await content.body.fill(self.body)
await content.submit.click()
class ComposerApi(AsyncBrowserApi):
open = op(OpenComposer)
send = op(SubmitMessage)
# endregion docs: browser-send-operation
# region docs: browser-send-flow
# examples/mail/browser/send.py
async def send_three_messages() -> tuple[
Sent | Rejected | Silent,
Sent | Rejected | Silent,
Sent | Rejected | Silent,
]:
async with playwright_mail() as runtime:
composer = ComposerApi(runtime.client)
await composer.open(options=OPTIONS)
sent = await composer.send(
recipient="grace@mail.example",
subject="Проверка доставки",
body="Встречаемся в пятницу.",
options=OPTIONS,
)
rejected = await composer.send(
recipient="rejected@mail.example",
subject="Проверка адреса",
body="Это письмо отклонят.",
options=OPTIONS,
)
silent = await composer.send(
recipient="quiet@mail.example",
subject="Тихая доставка",
body="Проверим папку отправленных.",
options=OPTIONS,
)
return sent, rejected, silent
# endregion docs: browser-send-flow
async def run() -> None:
sent, rejected, silent = await send_three_messages()
print(f"sent: {type(sent).__name__}")
print(f"rejected: {type(rejected).__name__}")
print(f"silent: {type(silent).__name__}")
def main() -> None:
asyncio.run(run())
if __name__ == "__main__":
main()
Запуск: uv run python -m examples.mail.browser.send.
sent: Sent
rejected: Rejected
silent: Silent
Предупреждение
Не повторяйте отправку после тишины в интерфейсе: запрос мог завершиться успешно. Сначала найдите письмо в «Отправленных» по устойчивым полям.