Upyo Documentation

repository·main·Indexed 19 days ago

https://github.com/dahlia/upyo

A cross-runtime email library providing a unified, type-safe API for sending emails across Node.js, Deno, Bun, and edge functions. Upyo allows developers to switch between SMTP and HTTP-based providers without changing application code. It includes a core package (@upyo/core) for message construction and transport interfaces, as well as specialized transports for JMAP (@upyo/jmap), Mailgun (@upyo/mailgun), LogTape (@upyo/logtape), and a mock transport for testing (@upyo/mock).

Tokens
110.6K
Snippets
317
Records
380
Agent score
64%

What's inside Upyo

  1. Overview of Upyo packages

    main

    Upyo is a monorepo consisting of a core package and several specialized transport packages.

    • @upyo/core: The foundation package providing shared types and common interfaces for email messages.
    • Transports: Individual packages for different delivery methods, including SMTP, JMAP, and various HTTP-based providers (e.g., Mailgun, SendGrid, Amazon SES).
    • Observability & Utilities: Packages for logging (@upyo/logtape), OpenTelemetry (@upyo/opentelemetry), retries (@upyo/retry), and testing (@upyo/mock).
  2. Handle email delivery receipts

    main

    All transport operations return a Receipt object. Use a discriminated union to check if the operation was successful.

    If successful is false, you can inspect errorMessages (an array of strings) or the structured errors array for programmatic handling. The retryable boolean indicates if the operation can be attempted again.

    Common error categories include:

    • auth
    • rate-limit
    • network
    • timeout
    • validation
    • rejected
    • server-error
    • service-unavailable
    • configuration
    • unknown
    import type { Receipt } from "@upyo/core";
    
    function handleReceipt(receipt: Receipt) {
      if (receipt.successful) {
        console.log("Message sent with ID:", receipt.messageId);
      } else {
        console.error("Send failed:", receipt.errorMessages.join(", "));
        console.error("Retryable:", receipt.retryable ?? false);
    
        for (const error of receipt.errors ?? []) {
          console.error(error.category, error.code, error.provider);
        }
      }
    }
  3. Configure backoff, jitter, and Retry-After

    main

    The retry transport uses exponential backoff to calculate delays between attempts.

    Backoff Calculation

    baseDelayMilliseconds * factor ^ (attempt - 1), capped by maxDelayMilliseconds.

    Configuration Options

    • backoff: An object containing baseDelayMilliseconds, maxDelayMilliseconds, and factor.
    • jitter: Controls randomness in delays. Options are "full" (default), false, or "none" for deterministic delays.
    • Retry-After: If a structured receipt error includes retryAfterMilliseconds, the transport uses that provider-supplied delay before applying the computed backoff (still subject to maxDelayMilliseconds).
    • wait: A custom function to replace the default waiting mechanism. Useful for testing or environments where you want to control the delay execution. It receives (context, signal) where context.delayMilliseconds is the calculated delay.

    Example: Custom wait for testing

    import { MockTransport } from "@upyo/mock";
    import { RetryTransport } from "@upyo/retry";
    
    const delays: number[] = [];
    const baseTransport = new MockTransport();
    
    const transport = new RetryTransport(baseTransport, {
      jitter: false,
      wait(context, signal) {
        signal?.throwIfAborted();
        delays.push(context.delayMilliseconds);
        return Promise.resolve();
      },
    });
  4. Configure SesTransport authentication

    main

    The SesTransport requires an authentication object. It supports two mutually exclusive types:

    1. Credentials Authentication: For long-term AWS credentials.
    2. Session Token Authentication: For temporary credentials (e.g., from an AssumeRole operation).

    For IAM role-based authentication, you must perform the AssumeRole operation externally and provide the resulting temporary credentials using the session type.

    // Credentials
    const auth = {
      type: "credentials",
      accessKeyId: "ACCESS_KEY",
      secretAccessKey: "SECRET_KEY",
    };
    
    // Session (Temporary)
    const auth = {
      type: "session",
      accessKeyId: "ACCESS_KEY",
      secretAccessKey: "SECRET_KEY",
      sessionToken: "SESSION_TOKEN",
    };
  5. Core principles for custom transports

    main

    When building a custom transport, follow these three core principles to ensure compatibility with the Upyo ecosystem:

    1. Never throw exceptions from transport methods: Instead of throwing, always return a Receipt object. Use createFailedReceipt to return failures. This ensures predictable error handling for the consumer.
    2. Support cancellation via AbortSignal: Check options?.signal?.throwIfAborted() before expensive operations and pass the signal to network calls (like fetch) to allow operations to be cancelled promptly.
    3. Return descriptive receipts: Success receipts should include a messageId. Failure receipts should include specific errorMessages and, if possible, metadata like statusCode or provider ID.
  6. Load balancing strategies in PoolTransport

    main

    The PoolTransport supports four built-in strategies for distributing traffic across providers:

    1. round-robin: Cycles through transports in order, ensuring perfectly even distribution.
    2. weighted: Distributes traffic proportionally based on a weight property assigned to each transport entry. A weight of 0 effectively disables a transport.
    3. priority: Always attempts the highest priority transport first. It only falls back to lower priority transports if the higher ones fail. Use maxRetries to control how many transports are attempted.
    4. selector-based: Routes messages based on custom logic via a selector function. The selector receives the message and returns true if that transport should handle it. If no selector matches, the entry without a selector acts as a catch-all default.
  7. How PoolTransport works with different strategies

    main

    The PoolTransport class allows you to combine multiple email providers (transports) using different selection strategies. It implements the standard Transport interface, making it a drop-in replacement for single transports.

    Supported strategies include:

    • Round-robin: Cycles through transports in order for even distribution.
    • Weighted: Selects transports based on a weight value for proportional traffic distribution.
    • Priority: Attempts the highest priority transport first, falling back to lower priorities only on failure.
    • Selector-based: Routes messages based on custom logic defined by selector functions on each TransportEntry.
    • Custom Strategy: You can provide a class implementing the Strategy interface for complex routing logic.
    import { PoolTransport } from "@upyo/pool";
    
    const transport = new PoolTransport({
      strategy: "round-robin", // or "weighted", "priority", "selector-based", or a custom Strategy instance
      transports: [
        { transport: transport1 },
        { transport: transport2 },
      ],
    });
  8. How the OpenTelemetry transport works

    main

    The OpenTelemetry transport is a decorator that wraps any existing Upyo transport (such as MailgunTransport, SmtpTransport, etc.). It adds automatic tracing and metrics collection without requiring changes to your base transport's logic. It preserves all existing functionality while generating telemetry data for every email operation.

    import { trace, metrics } from "@opentelemetry/api";
    import { createMessage } from "@upyo/core";
    import { MailgunTransport } from "@upyo/mailgun";
    import { OpenTelemetryTransport } from "@upyo/opentelemetry";
    
    // Create your base transport
    const baseTransport = new MailgunTransport({
      apiKey: "your-mailgun-api-key",
      domain: "mg.example.com",
      region: "us",
    });
    
    // Wrap with OpenTelemetry observability
    const transport = new OpenTelemetryTransport(baseTransport, {
      tracerProvider: trace.getTracerProvider(),
      meterProvider: metrics.getMeterProvider(),
      metrics: { enabled: true },
      tracing: { enabled: true },
    });
    
    const message = createMessage({
      from: "system@example.com",
      to: "user@example.com",
      subject: "Account Created",
      content: { text: "Welcome to our platform!" },
    });
    
    const receipt = await transport.send(message);
    
    // Clean up resources when done
    await transport[Symbol.asyncDispose]();
  9. Set message priority and idempotency

    main

    The Resend transport provides built-in idempotency by automatically generating keys for each request to prevent duplicates during retries. You can also set a priority level in createMessage (e.g., priority: "high"), which the transport converts into appropriate email headers (like X-Priority) for better inbox placement.

    import { ResendTransport } from "@upyo/resend";
    import { createMessage } from "@upyo/core";
    
    const transport = new ResendTransport({
      apiKey: "re_1234567890abcdef_1234567890abcdef1234567890",
      retries: 3,
      timeout: 30000,
    });
    
    const message = createMessage({
      from: "alerts@example.com",
      to: "admin@example.com",
      subject: "🚨 System Alert: High CPU Usage",
      content: {
        text: "Server CPU usage has exceeded 90% for the past 5 minutes.",
      },
      priority: "high",
      tags: ["alert", "system"],
    });
    
    const receipt = await transport.send(message);
  10. Switch between Mailtrap Email API and Email Sandbox

    main

    Mailtrap uses a single API token for both production and testing. To capture messages in a virtual inbox instead of sending them to real recipients, set sandbox: true and provide a valid inboxId.

    import { createMessage } from "@upyo/core";
    import { MailtrapTransport } from "@upyo/mailtrap";
    
    // Sandbox mode (captures in a virtual inbox)
    const sandboxTransport = new MailtrapTransport({
      apiToken: "your-mailtrap-api-token",
      sandbox: true,
      inboxId: 12345,
    });
    
    // Production mode (sends via Email API)
    const productionTransport = new MailtrapTransport({
      apiToken: "your-mailtrap-api-token",
    });
  11. Ensure reliability with idempotency and retries

    main

    To prevent duplicate emails during network retries, use Message.idempotencyKey. The transport maps this value to the Idempotency-Key HTTP header.

    Reliability Features

    • Retries: The transport automatically retries temporary failures using exponential backoff. You can configure the number of retries and the timeout in the LettermintTransport constructor.
    • Error Handling: Client errors (e.g., invalid requests) are returned as failed receipts immediately without retrying.
    • Cancellation: Supports AbortSignal for cancelling ongoing requests.
    import { createMessage } from "@upyo/core";
    import { LettermintTransport } from "@upyo/lettermint";
    
    const transport = new LettermintTransport({
      apiToken: "lm_project_1234567890abcdef",
      retries: 3,
      timeout: 30000,
    });
    
    const message = createMessage({
      from: "alerts@example.com",
      to: "admin@example.com",
      subject: "System alert",
      content: { text: "CPU usage has exceeded 90%." },
      priority: "high",
      idempotencyKey: "alert-cpu-2026-05-17T10:00Z",
    });
    
    await transport.send(message);
  12. Participate in distributed tracing

    main

    The OpenTelemetry transport automatically participates in distributed traces. When you start an active span in your application, the email sending operation (e.g., transport.send()) will automatically create a child span that inherits the current trace context. This allows you to visualize the relationship between a high-level business operation and the resulting email delivery.

    import { trace } from "@opentelemetry/api";
    import { createMessage } from "@upyo/core";
    import { MailgunTransport } from "@upyo/mailgun";
    import { createOpenTelemetryTransport } from "@upyo/opentelemetry";
    
    const transport = createOpenTelemetryTransport(
      new MailgunTransport({
        apiKey: "your-api-key",
        domain: "mg.example.com",
      }),
      {
        serviceName: "user-service",
        tracing: {
          enabled: true,
          recordSensitiveData: false,
        },
      }
    );
    
    const tracer = trace.getTracer("user-registration");
    
    await tracer.startActiveSpan("user-registration", async (span) => {
      try {
        span.setAttributes({
          "user.id": "12345",
          "user.email": "newuser@example.com",
        });
    
        const message = createMessage({
          from: "welcome@example.com",
          to: "newuser@example.com",
          subject: "Welcome to our platform",
          content: { text: "Thank you for joining us!" },
        });
    
        // This automatically becomes a child span
        await transport.send(message);
    
        span.setStatus({ code: 1 }); // OK
      } catch (error) {
        if (error instanceof Error) {
          span.recordException(error);
        }
        span.setStatus({ code: 2, message: String(error) }); // ERROR
        throw error;
      } finally {
        span.end();
      }
    });