Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [1.5.0] - 2026-09-10

Brings this client level with lettr-php: template modules, the folders endpoint, preparation status, and idempotent sends. Everything is additive - code written against 1.4.1 keeps compiling and sends identical requests.

### Added

- **`client.folders.list()`** - the folders templates are filed into, each with its `purpose` and `templates_count`. This is what `CreateTemplateOptions::with_folder_id` was missing: nothing else returned a folder id, so a caller either omitted it and accepted whichever folder the API picked, or hardcoded an integer read out of an app URL. Read-only, because deleting a folder moves or deletes the templates inside it.
- **`TemplatePurpose`** (`Transactional`, `Campaign`) on `CreateTemplateOptions::with_purpose`, on every template response, and as a `ListTemplatesOptions` filter.
- **`TemplatePreparationStatus`** (`Pending`, `Ready`, `Failed`) on every template response, with `is_settled()`.

`is_settled()` rather than `is_ready()` on purpose: it answers "is what I sent what will go out", which is not the same question as "can I send this". After an *update* the previous render stays in place, so a pending template is still sendable - it is serving the old content.

Both fields default when the API omits them - `Transactional` and `Ready` - because on a deployment that predates them every template with HTML was simply usable. Defaulting to `Pending` would make an older API look like a stalled queue. Both enums carry an `Unknown(String)` variant, so a value added server-side deserializes rather than failing.
- **`ListTemplatesOptions::folder_id`** - one `per_page(100)` call reconciles a whole bulk import instead of a detail call per template, each dragging the full HTML payload against the same rate limit. A folder outside the resolved project is a 404, not an empty list, so a typo cannot be misread as "nothing is there yet".
- **`CreateEmailOptions::with_idempotency_key`** - reuse the key when you retry and the API returns the original result instead of delivering a second email. `SendEmailResponse::replayed` says when that happened.

The key lives on the options rather than as a `send` argument, so both `send` and `send_with_quota` pick it up and neither signature changed. It is `#[serde(skip)]`, so the request body is byte-identical to what you were already sending.

You choose the key; the SDK never generates one. It only works if both attempts use the same value, and the SDK does not retry - one `send` is one HTTP request - so the retry is yours. A malformed key returns `Error::Validation` **before any request goes out**; `is_valid_idempotency_key` is public for callers deriving keys from their own ids.
- **`Error::is_idempotency_in_progress()`** and **`Error::is_idempotency_conflict()`**, because one is safe to retry and the other is not. The first should be retried with the *same* key after `Error::retry_after()` seconds; the second means that key was used with a different payload and will fail identically forever.
- **`Error::retry_after()`** and `ApiError::retry_after` - the `Retry-After` header in seconds, when the API sent one.
- Two new `ErrorCode` variants: `IdempotencyKeyConflict` and `IdempotencyInProgress`.

### Notes

- Keys are scoped per team **and** API key, so the same string through a different API key is a different key. The provider retains one for 24 hours.
- `RawErrorResponse::into_error` now takes the parsed `Retry-After`. It is `pub(crate)`, so this is not a public API change.

## [1.4.1] - 2026-08-15

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "lettr"
version = "1.4.1"
version = "1.5.0"
edition = "2021"
rust-version = "1.70"

Expand Down
5 changes: 5 additions & 0 deletions src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use crate::campaigns::CampaignsSvc;
use crate::config::Config;
use crate::domains::DomainsSvc;
use crate::emails::EmailsSvc;
use crate::folders::FoldersSvc;
use crate::projects::ProjectsSvc;
use crate::templates::TemplatesSvc;
use crate::webhooks::WebhooksSvc;
Expand Down Expand Up @@ -42,6 +43,9 @@ pub struct Lettr {
pub templates: TemplatesSvc,
/// Project listing.
pub projects: ProjectsSvc,

/// Template folder listing — where a usable `folder_id` comes from.
pub folders: FoldersSvc,
/// Audience management: lists, contacts, topics, properties, and segments.
pub audience: AudienceSvc,
/// Campaign listing, stats, engagement events, and dispatch/scheduling.
Expand Down Expand Up @@ -78,6 +82,7 @@ impl Lettr {
webhooks: WebhooksSvc(Arc::clone(&config)),
templates: TemplatesSvc(Arc::clone(&config)),
projects: ProjectsSvc(Arc::clone(&config)),
folders: FoldersSvc(Arc::clone(&config)),
audience: AudienceSvc::new(Arc::clone(&config)),
campaigns: CampaignsSvc(Arc::clone(&config)),
config,
Expand Down
10 changes: 9 additions & 1 deletion src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,10 +97,18 @@ impl Config {
if status.is_success() {
Ok(response)
} else {
// Read before `text()` consumes the response.
let retry_after = response
.headers()
.get(reqwest::header::RETRY_AFTER)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse::<u32>().ok())
.filter(|seconds| *seconds > 0);

let body = response.text().await.unwrap_or_default();

match serde_json::from_str::<crate::error::RawErrorResponse>(&body) {
Ok(raw) => Err(raw.into_error()),
Ok(raw) => Err(raw.into_error(retry_after)),
Err(_) => Err(crate::Error::Parse(format!("HTTP {status}: {body}"))),
}
}
Expand Down
115 changes: 111 additions & 4 deletions src/emails.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,10 +143,23 @@ impl EmailsSvc {
/// ```
#[maybe_async::maybe_async]
pub async fn send(&self, email: CreateEmailOptions) -> crate::Result<SendEmailResponse> {
let request = self.0.build(Method::POST, "/emails").json(&email);
// Checked here so a malformed key fails locally instead of costing a
// round trip and a 422.
let key = email.idempotency_key_header()?;

let mut request = self.0.build(Method::POST, "/emails").json(&email);
if let Some(ref key) = key {
request = request.header("Idempotency-Key", key);
}

let response = self.0.send(request).await?;
let replayed = replayed_from_headers(response.headers());
let wrapper = response.json::<SendEmailResponseWrapper>().await?;
Ok(wrapper.data)

Ok(SendEmailResponse {
replayed,
..wrapper.data
})
}

/// Send a transactional email and return quota information.
Expand Down Expand Up @@ -178,12 +191,23 @@ impl EmailsSvc {
&self,
email: CreateEmailOptions,
) -> crate::Result<SendEmailWithQuotaResponse> {
let request = self.0.build(Method::POST, "/emails").json(&email);
let key = email.idempotency_key_header()?;

let mut request = self.0.build(Method::POST, "/emails").json(&email);
if let Some(ref key) = key {
request = request.header("Idempotency-Key", key);
}

let response = self.0.send(request).await?;
let quota = QuotaInfo::from_headers(response.headers());
let replayed = replayed_from_headers(response.headers());
let wrapper = response.json::<SendEmailResponseWrapper>().await?;

Ok(SendEmailWithQuotaResponse {
response: wrapper.data,
response: SendEmailResponse {
replayed,
..wrapper.data
},
quota,
})
}
Expand Down Expand Up @@ -466,6 +490,13 @@ impl EmailsSvc {
#[must_use]
#[derive(Debug, Clone, Serialize)]
pub struct CreateEmailOptions {
/// A key identifying one logical send.
///
/// Travels as the `Idempotency-Key` header, not in the body, which is why
/// it is skipped during serialization.
#[serde(skip)]
idempotency_key: Option<String>,

/// Sender email address.
from: String,

Expand Down Expand Up @@ -545,6 +576,29 @@ pub struct CreateEmailOptions {
attachments: Option<Vec<Attachment>>,
}

/// Whether a string is a usable idempotency key.
///
/// The format the API accepts is 1-255 characters of letters, digits, periods,
/// underscores or hyphens. Exported so callers deriving keys from their own ids
/// — an order number, a job id — can check before sending rather than
/// discovering it as a 422.
#[must_use]
pub fn is_valid_idempotency_key(key: &str) -> bool {
!key.is_empty()
&& key.len() <= 255
&& key
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
}

/// Whether the response replayed an earlier send under the same key.
fn replayed_from_headers(headers: &reqwest::header::HeaderMap) -> bool {
headers
.get("Idempotency-Replayed")
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value.eq_ignore_ascii_case("true"))
}

impl CreateEmailOptions {
/// Creates a new [`CreateEmailOptions`] with a subject.
///
Expand All @@ -567,6 +621,7 @@ impl CreateEmailOptions {
A: Into<String>,
{
Self {
idempotency_key: None,
from: from.into(),
from_name: None,
subject: Some(subject.into()),
Expand Down Expand Up @@ -614,6 +669,7 @@ impl CreateEmailOptions {
A: Into<String>,
{
Self {
idempotency_key: None,
from: from.into(),
from_name: None,
subject: None,
Expand All @@ -637,6 +693,46 @@ impl CreateEmailOptions {
}
}

/// Sends this email under an idempotency key.
///
/// Reuse the key when you retry and the API returns the original result
/// instead of delivering a second email;
/// [`SendEmailResponse::replayed`] says when that happened.
///
/// **You choose the key; the SDK never generates one.** It only works if
/// both attempts use the same value, and the SDK does not retry — one
/// `send` is one HTTP request — so the retry is yours, and only you know
/// that two calls are the same logical send. A key generated inside `send`
/// would differ on every attempt and protect nothing while looking like it
/// did.
///
/// The key must be 1-255 characters of `[A-Za-z0-9._-]`; `send` returns a
/// validation error before making a request if it is not.
#[inline]
pub fn with_idempotency_key(mut self, key: impl Into<String>) -> Self {
self.idempotency_key = Some(key.into());
self
}

/// The key to send, validated, or `None` when there is none.
pub(crate) fn idempotency_key_header(&self) -> crate::Result<Option<String>> {
match self.idempotency_key {
None => Ok(None),
Some(ref key) if is_valid_idempotency_key(key) => Ok(Some(key.clone())),
Some(_) => Err(crate::Error::Validation(crate::error::ValidationError {
message: "Validation failed.".to_string(),
error_code: Some(crate::error::ErrorCode::ValidationError),
errors: std::collections::HashMap::from([(
"Idempotency-Key".to_string(),
vec![
"Use 1 to 255 letters, digits, periods, underscores or hyphens."
.to_string(),
],
)]),
})),
}
}

/// Sets the sender display name.
#[inline]
pub fn with_from_name(mut self, name: impl Into<String>) -> Self {
Expand Down Expand Up @@ -1065,6 +1161,17 @@ pub struct SendEmailResponse {
pub accepted: u32,
/// Number of rejected recipients.
pub rejected: u32,
/// Whether this response replayed an earlier send under the same
/// idempotency key — no second email went out.
///
/// A replay is a **success**, not an error: the API hands back the original
/// transmission. Always `false` for a send made without a key, since there
/// is nothing to replay.
///
/// Read from the `Idempotency-Replayed` response header rather than the
/// body, so it is skipped during deserialization.
#[serde(skip)]
pub replayed: bool,
}

/// Successful response from sending an email, including quota information.
Expand Down
72 changes: 71 additions & 1 deletion src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ pub enum ErrorCode {
DailyQuotaExceeded,
InsufficientScope,
ScheduleCancellationFailed,
IdempotencyKeyConflict,
IdempotencyInProgress,
/// An unknown error code not yet covered by this enum.
#[serde(untagged)]
Unknown(String),
Expand All @@ -40,6 +42,8 @@ impl fmt::Display for ErrorCode {
Self::DailyQuotaExceeded => write!(f, "daily_quota_exceeded"),
Self::InsufficientScope => write!(f, "insufficient_scope"),
Self::ScheduleCancellationFailed => write!(f, "schedule_cancellation_failed"),
Self::IdempotencyKeyConflict => write!(f, "idempotency_key_conflict"),
Self::IdempotencyInProgress => write!(f, "idempotency_in_progress"),
Self::Unknown(s) => write!(f, "{s}"),
}
}
Expand Down Expand Up @@ -113,6 +117,62 @@ impl Error {
pub fn is_contact_already_exists(&self) -> bool {
matches!(self.error_code(), Some(ErrorCode::ResourceAlreadyExists))
}

/// Whether this is the "key already used with a different payload" conflict
/// (HTTP 409, `idempotency_key_conflict`).
///
/// **Never retry this.** Two different emails were sent under one key,
/// which is a bug on the caller's side; the same request will fail
/// identically forever. Use a key that is unique per logical send, or send
/// the payload the key was first used with.
///
/// Keys are scoped per team **and** API key, so the same string sent
/// through a different API key is a different key and will not collide.
#[must_use]
pub fn is_idempotency_conflict(&self) -> bool {
matches!(self.error_code(), Some(ErrorCode::IdempotencyKeyConflict))
}

/// Whether the original send for this key is still processing (HTTP 409,
/// `idempotency_in_progress`).
///
/// Unlike [`is_idempotency_conflict`](Self::is_idempotency_conflict) this
/// one **is** retryable, and must be retried with the *same* key — a fresh
/// key would send a second email. Wait [`retry_after`](Self::retry_after)
/// seconds first.
///
/// ```rust,no_run
/// # use lettr::{Lettr, CreateEmailOptions};
/// # async fn run() -> lettr::Result<()> {
/// # let client = Lettr::new("your-api-key");
/// # let email = CreateEmailOptions::new("a@example.com", ["b@example.com"], "Hello")
/// .with_idempotency_key("order-12345");
/// match client.emails.send(email).await {
/// Ok(response) => println!("replayed: {}", response.replayed),
/// Err(e) if e.is_idempotency_in_progress() => {
/// // Wait e.retry_after() seconds, then retry with the SAME key.
/// }
/// Err(e) => return Err(e),
/// }
/// # Ok(())
/// # }
/// ```
#[must_use]
pub fn is_idempotency_in_progress(&self) -> bool {
matches!(self.error_code(), Some(ErrorCode::IdempotencyInProgress))
}

/// Seconds to wait before retrying, from the `Retry-After` header.
///
/// Present on the retryable failures — an in-progress idempotent send, a
/// rate limit — and absent on the ones that will never succeed.
#[must_use]
pub fn retry_after(&self) -> Option<u32> {
match self {
Self::Api(e) => e.retry_after,
_ => None,
}
}
}

/// An error response from the Lettr API.
Expand All @@ -123,6 +183,12 @@ pub struct ApiError {
/// Machine-readable error code.
#[serde(default)]
pub error_code: Option<ErrorCode>,
/// Seconds to wait before retrying, from the `Retry-After` header.
///
/// Not part of the response body — filled in from the header, so it is
/// skipped during deserialization.
#[serde(skip)]
pub retry_after: Option<u32>,
}

impl fmt::Display for ApiError {
Expand Down Expand Up @@ -176,7 +242,10 @@ pub(crate) struct RawErrorResponse {

impl RawErrorResponse {
/// Convert into the appropriate [`Error`] variant.
pub fn into_error(self) -> Error {
///
/// `retry_after` comes from the response header rather than the body, and
/// is what separates the retryable failures from the permanent ones.
pub fn into_error(self, retry_after: Option<u32>) -> Error {
if let Some(errors) = self.errors {
Error::Validation(ValidationError {
message: self.message,
Expand All @@ -187,6 +256,7 @@ impl RawErrorResponse {
Error::Api(ApiError {
message: self.message,
error_code: self.error_code,
retry_after,
})
}
}
Expand Down
Loading
Loading