Errors and retries
Handle an EmailError by reason, read acceptance to know if a send is safe to repeat, and retry on your own terms.
email.send fails with an EmailError. Its reason says what went wrong, and its acceptance says whether the provider may already have the message. The package never retries for you.
Example (Handling a Rate Limit by Reason)
import { } from "effect";
import { } from "@effect-kit/emails";
const = .(function* () {
const = yield* ;
return yield* .({
: "hello@example.com",
: "ada@example.com",
: "Welcome",
: "Your account is ready.",
});
});
const handled = .( .("EmailError", "RateLimitedError", () =>
.(.),
),
);Effect.catchReason unwraps the nested reason. Reasons you do not handle stay in the error channel as EmailError.
Reading an EmailError
Every failure carries the same fields. Only reason varies by case.
| Field | Type | Meaning |
|---|---|---|
provider | string | Provider name, such as resend or ses. |
operation | "send" | The only operation. |
acceptance | "notSubmitted" | "rejected" | "unknown" | What is known about submission. |
reason | tagged union | The cause, with its own fields. |
statusCode | number (optional) | HTTP status from the provider, 100 to 599. |
providerCode | string (optional) | A provider code, kept only if the package recognizes it. |
references | SubmissionReference[] (optional) | Provider identifiers, when the failure carries any. |
Example (Listing Every Reason)
import type { } from "@effect-kit/emails";
type ReasonTag = ["reason"]["_tag"];The reasons most code handles:
| Reason | Handle it by |
|---|---|
InvalidMessageError | Fixing the message. paths lists the offending fields. |
UnsupportedFeatureError | Dropping the field or choosing another provider. See Capabilities. |
AuthenticationError | Checking the credential. |
RateLimitedError | Waiting. retryAfter is a hint when the provider sends one. |
RecipientRejectedError | Removing the recipients in recipients, each a { role, index }. |
IdempotencyConflictError | Treating the key as already used. See Idempotency. |
The full list, with every field, is in Errors.
What acceptance says
acceptance is the field that decides whether a failed send can be repeated.
Example (Reading the Three Values)
import type { } from "@effect-kit/emails";
type Acceptance = ["acceptance"];| Value | Meaning | Set when |
|---|---|---|
notSubmitted | Nothing was sent to the provider. | The message failed validation, a capability check, or an adapter pre-flight check. |
rejected | The provider refused the submission. | The provider answered with an HTTP 4xx status other than 409. |
unknown | The provider may have accepted the message. | Everything else: 409, 5xx, timeouts, transport errors, an unreadable response. |
The package sees acceptance only. It never sees delivery, so there is no "delivered" or "bounced" value.
Retrying
The package makes no retry and has no failover. The SES client is built with maxAttempts: 1 and the Nodemailer transport with maxRequeues: 0, so neither SDK retries. A client you pass to layerClient keeps its own retry policy.
Retry yourself, and decide by acceptance:
notSubmittedandrejected: the provider has no copy of the message, so a repeat cannot duplicate it. It helps only when the cause can change, such as a rate limit.unknown: the first attempt may have gone through.
Example (Retrying Only a Rate Limit That Was Refused)
import { , } from "effect";
import { } from "@effect-kit/emails";
const = .(function* () {
const = yield* ;
return yield* .({
: "hello@example.com",
: "ada@example.com",
: "Welcome",
: "Your account is ready.",
});
});
const withRetry = .( .({
: 3,
: .("500 millis"),
: () => . === "rejected" && .. === "RateLimitedError",
}),
);To honor the provider's wait, read retryAfter. RateLimitedError, QuotaExceededError and ProviderUnavailableError may carry it as a Duration.
Example (Waiting for retryAfter Once)
import { Duration, Effect } from "effect";
import { Email } from "@effect-kit/emails";
const send = Effect.gen(function* () {
const email = yield* Email;
return yield* email.send({
from: "hello@example.com",
to: "ada@example.com",
subject: "Welcome",
text: "Your account is ready.",
});
});
const sendAfterLimit = send.pipe(
Effect.catchReason("EmailError", "RateLimitedError", (reason) =>
Effect.sleep(reason.retryAfter ?? Duration.seconds(5)).pipe(Effect.andThen(send)),
),
);Do not retry unknown blindly
When acceptance is "unknown", the provider may have the message already, and a second send
delivers it twice. Pass an idempotencyKey on a provider that supports one, or check with the
provider before you resend. See Idempotency.