Documentation for agents: an index at /llms.txt, every docs page in one file at /llms-full.txt, and Markdown for any docs page by adding .md to its URL.

Effect KitEmails

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 = .(
const handled: Effect<Duration | {
    readonly provider: string;
    readonly status: "accepted" | "partiallyAccepted";
    readonly references: readonly {
        readonly kind: "operationId" | "providerMessageId" | "smtpMessageId";
        readonly value: string;
    }[];
    readonly requestId?: string | undefined;
    readonly recipients?: ... | undefined;
} | undefined, EmailError, Email>
.("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.

FieldTypeMeaning
providerstringProvider name, such as resend or ses.
operation"send"The only operation.
acceptance"notSubmitted" | "rejected" | "unknown"What is known about submission.
reasontagged unionThe cause, with its own fields.
statusCodenumber (optional)HTTP status from the provider, 100 to 599.
providerCodestring (optional)A provider code, kept only if the package recognizes it.
referencesSubmissionReference[] (optional)Provider identifiers, when the failure carries any.

Example (Listing Every Reason)

import type {  } from "@effect-kit/emails";

type ReasonTag = ["reason"]["_tag"];
type ReasonTag = "AuthenticationError" | "IdempotencyConflictError" | "InvalidMessageError" | "MessageRejectedError" | "PermissionDeniedError" | "ProviderError" | "ProviderUnavailableError" | "QuotaExceededError" | "RateLimitedError" | "RecipientRejectedError" | "TimeoutError" | "TransportError" | "UnexpectedResponseError" | "UnsupportedFeatureError"

The reasons most code handles:

ReasonHandle it by
InvalidMessageErrorFixing the message. paths lists the offending fields.
UnsupportedFeatureErrorDropping the field or choosing another provider. See Capabilities.
AuthenticationErrorChecking the credential.
RateLimitedErrorWaiting. retryAfter is a hint when the provider sends one.
RecipientRejectedErrorRemoving the recipients in recipients, each a { role, index }.
IdempotencyConflictErrorTreating 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"];
type Acceptance = "notSubmitted" | "rejected" | "unknown"
ValueMeaningSet when
notSubmittedNothing was sent to the provider.The message failed validation, a capability check, or an adapter pre-flight check.
rejectedThe provider refused the submission.The provider answered with an HTTP 4xx status other than 409.
unknownThe 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:

  • notSubmitted and rejected: 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 = .(
const withRetry: Effect<{
    readonly provider: string;
    readonly status: "accepted" | "partiallyAccepted";
    readonly references: readonly {
        readonly kind: "operationId" | "providerMessageId" | "smtpMessageId";
        readonly value: string;
    }[];
    readonly requestId?: string | undefined;
    readonly recipients?: ... | undefined;
}, EmailError, Email>
.({ : 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.