Aurinko

Hex.pmDocsCICoverage StatusLicense

A production-grade Elixir client for the Aurinko Unified Mailbox API — covering Email, Calendar, Contacts, Tasks, Webhooks, and Booking across Google Workspace, Office 365, Outlook, MS Exchange, Zoho Mail, iCloud, and IMAP.


Features


Installation

Add aurinko to your dependencies in mix.exs:

def deps do
  [
    {:aurinko, "~> 0.2.1"}
  ]
end
mix deps.get

Configuration

Minimum required

# config/runtime.exs
config :aurinko,
  client_id:     System.fetch_env!("AURINKO_CLIENT_ID"),
  client_secret: System.fetch_env!("AURINKO_CLIENT_SECRET")

Full configuration reference

# config/runtime.exs
config :aurinko,
  # Required
  client_id:     System.fetch_env!("AURINKO_CLIENT_ID"),
  client_secret: System.fetch_env!("AURINKO_CLIENT_SECRET"),

  # HTTP
  base_url:        "https://api.aurinko.io/v1",  # default
  timeout:         30_000,                        # ms (default: 30 s)
  retry_attempts:  3,                             # default
  retry_delay:     500,                           # ms base for exponential backoff

  # Cache
  cache_enabled:          true,
  cache_ttl:              60_000,    # ms per entry (default: 60 s)
  cache_max_size:         5_000,     # entries before LRU eviction
  cache_cleanup_interval: 30_000,    # expired-entry sweep interval in ms

  # Rate limiter
  rate_limiter_enabled:  true,
  rate_limit_per_token:  10,         # req/sec per account token
  rate_limit_global:     100,        # req/sec across all tokens
  rate_limit_burst:      5,          # burst headroom above steady-state

  # Circuit breaker
  circuit_breaker_enabled:   true,
  circuit_breaker_threshold: 5,      # consecutive failures before opening
  circuit_breaker_timeout:   30_000, # ms before half-open probe

  # Observability
  attach_default_telemetry: false,   # auto-attach logger on start
  log_level:                :info,
  webhook_secret:           System.get_env("AURINKO_WEBHOOK_SECRET")

Authentication

Step 1 — Build the authorization URL

url = Aurinko.authorize_url(
  service_type: "Google",   # "Office365" | "Zoho" | "EWS" | "IMAP" | "Outlook" | ...
  scopes: ["Mail.Read", "Mail.Send", "Calendars.ReadWrite", "Contacts.Read"],
  return_url: "https://yourapp.com/auth/callback",
  state: "csrf_token_here"
)

# Redirect the user's browser to `url`

Step 2 — Exchange the code for a token

{:ok, %{token: token, account_id: id, email: email}} =
  Aurinko.Auth.exchange_code(params["code"])

# Store `token` securely — pass it to every subsequent API call

Refresh a token

{:ok, %{token: new_token}} = Aurinko.Auth.refresh_token(refresh_token)

Email API

# List messages with optional search
{:ok, page} = Aurinko.list_messages(token,
  limit: 25,
  q: "from:[email protected] is:unread"
)

Enum.each(page.records, fn raw ->
  msg = Aurinko.Types.Email.from_response(raw)
  IO.puts("#{msg.subject} — from #{msg.from.address}")
end)

# Get a single message
{:ok, msg} = Aurinko.get_message(token, "msg_id_123", body_type: "html")

# Send a message with open/reply tracking
{:ok, sent} = Aurinko.send_message(token, %{
  to: [%{address: "[email protected]", name: "Recipient"}],
  subject: "Hello from Elixir!",
  body: "<h1>Hello!</h1>",
  body_type: "html",
  tracking: %{opens: true, thread_replies: true}
})

# Create a draft
{:ok, draft} = Aurinko.APIs.Email.create_draft(token, %{
  to: [%{address: "[email protected]"}],
  subject: "Draft subject"
})

# Delta sync — start or resume
{:ok, sync} = Aurinko.APIs.Email.start_sync(token, days_within: 30)

# Drain updated messages (handles pagination automatically)
{:ok, page} = Aurinko.APIs.Email.sync_updated(token, sync.sync_updated_token)

# Continue paginating if needed
if page.next_page_token do
  {:ok, next} = Aurinko.APIs.Email.sync_updated(token, page.next_page_token)
end

# Store page.next_delta_token — use it next time for incremental sync
{:ok, incremental} = Aurinko.APIs.Email.sync_updated(token, page.next_delta_token)

# List attachments
{:ok, attachments} = Aurinko.APIs.Email.list_attachments(token, msg.id)

Calendar API

# List all calendars for the account
{:ok, page} = Aurinko.list_calendars(token)

# Get a specific calendar
{:ok, cal} = Aurinko.APIs.Calendar.get_calendar(token, "primary")

# List events in a date range
{:ok, page} = Aurinko.list_events(token, "primary",
  time_min: ~U[2024-01-01 00:00:00Z],
  time_max: ~U[2024-12-31 23:59:59Z]
)

# Create an event
{:ok, event} = Aurinko.create_event(token, "primary", %{
  subject: "Product Review",
  start: %{date_time: ~U[2024-06-15 14:00:00Z], timezone: "America/New_York"},
  end:   %{date_time: ~U[2024-06-15 15:00:00Z], timezone: "America/New_York"},
  location: "Zoom",
  attendees: [
    %{email: "[email protected]", name: "Alice"},
    %{email: "[email protected]", name: "Bob"}
  ],
  body: "Please review the Q2 metrics."
})

# Update an event
{:ok, updated} = Aurinko.APIs.Calendar.update_event(token, "primary", event.id, %{
  subject: "Product Review — Updated",
  location: "Google Meet"
}, notify_attendees: true)

# Delete an event
:ok = Aurinko.APIs.Calendar.delete_event(token, "primary", event.id)

# Check free/busy availability
{:ok, schedule} = Aurinko.APIs.Calendar.free_busy(token, "primary", %{
  time_min: ~U[2024-06-15 09:00:00Z],
  time_max: ~U[2024-06-15 18:00:00Z]
})

# Delta sync
{:ok, sync} = Aurinko.APIs.Calendar.start_sync(token, "primary",
  time_min: ~U[2024-01-01 00:00:00Z],
  time_max: ~U[2024-12-31 23:59:59Z]
)
{:ok, page} = Aurinko.APIs.Calendar.sync_updated(token, "primary", sync.sync_updated_token)

Contacts API

{:ok, page}    = Aurinko.list_contacts(token, limit: 50)
{:ok, contact} = Aurinko.APIs.Contacts.get_contact(token, "contact_id")

{:ok, new_contact} = Aurinko.create_contact(token, %{
  given_name: "Jane",
  surname: "Doe",
  email_addresses: [%{address: "[email protected]"}],
  company: "Acme Corp"
})

:ok = Aurinko.APIs.Contacts.delete_contact(token, contact.id)

# Delta sync
{:ok, sync} = Aurinko.APIs.Contacts.start_sync(token)
{:ok, page} = Aurinko.APIs.Contacts.sync_updated(token, sync.sync_updated_token)

Tasks API

{:ok, lists} = Aurinko.list_task_lists(token)
{:ok, page}  = Aurinko.list_tasks(token, "task_list_id")

{:ok, task} = Aurinko.create_task(token, "task_list_id", %{
  title: "Review PR #42",
  importance: "high",
  due: ~U[2024-06-20 17:00:00Z]
})

:ok = Aurinko.APIs.Tasks.delete_task(token, "task_list_id", task.id)

Webhooks

Subscription management

{:ok, sub} = Aurinko.create_subscription(token, %{
  resource: "email",
  notification_url: "https://yourapp.com/webhooks/aurinko"
})

{:ok, subs} = Aurinko.APIs.Webhooks.list_subscriptions(token)
:ok         = Aurinko.APIs.Webhooks.delete_subscription(token, sub["id"])

Signature verification (Phoenix controller)

defmodule MyAppWeb.WebhookController do
  use MyAppWeb, :controller

  def receive(conn, _params) do
    signature = get_req_header(conn, "x-aurinko-signature") |> List.first()
    raw_body  = conn.assigns[:raw_body]

    case Aurinko.Webhook.Verifier.verify(raw_body, signature) do
      :ok ->
        Aurinko.Webhook.Handler.dispatch(MyApp.WebhookHandler, raw_body, signature)
        send_resp(conn, 200, "ok")

      {:error, :invalid_signature} ->
        send_resp(conn, 401, "invalid signature")
    end
  end
end

Handler behaviour

defmodule MyApp.WebhookHandler do
  @behaviour Aurinko.Webhook.Handler

  @impl true
  def handle_event("email.new", %{"data" => data}, _meta),
    do: MyApp.Mailbox.process_incoming(data)

  def handle_event("calendar.event.updated", payload, _meta),
    do: MyApp.Calendar.handle_change(payload)

  def handle_event(_event, _payload, _meta), do: :ok
end

Streaming Pagination

Never manually track next_page_token again:

# Lazy stream — fetches pages only as consumed
Aurinko.Paginator.stream(token, &Aurinko.APIs.Email.list_messages/2, q: "is:unread")
|> Stream.take(100)
|> Enum.to_list()

# Process in batches without loading everything into memory
Aurinko.Paginator.stream(token, &Aurinko.APIs.Contacts.list_contacts/2)
|> Stream.chunk_every(50)
|> Stream.each(fn batch -> MyApp.Contacts.upsert_batch(batch) end)
|> Stream.run()

# Collect all pages into a list
{:ok, all_events} = Aurinko.Paginator.collect_all(
  token,
  fn t, opts -> Aurinko.APIs.Calendar.list_events(t, "primary", opts) end,
  time_min: ~U[2024-01-01 00:00:00Z],
  time_max: ~U[2024-12-31 23:59:59Z]
)

High-level Sync Orchestration

Aurinko.Sync.Orchestrator manages token resolution, pagination, batching, and token persistence end-to-end:

{:ok, result} = Aurinko.Sync.Orchestrator.sync_email(token,
  days_within: 30,
  on_updated:  fn records -> MyApp.Mailbox.upsert_many(records) end,
  on_deleted:  fn ids     -> MyApp.Mailbox.delete_by_ids(ids) end,
  get_tokens:  fn         -> MyApp.Store.get_delta_tokens("email") end,
  save_tokens: fn toks    -> MyApp.Store.save_delta_tokens("email", toks) end
)

Logger.info("Sync complete: #{result.updated} updated, #{result.deleted} deleted in #{result.duration_ms}ms")

Calendar and contacts variants are also available — see Aurinko.Sync.Orchestrator.


Telemetry

Aurinko emits 7 telemetry events covering the full request lifecycle:

Event Measurements Metadata
[:aurinko, :request, :start]system_timemethod, path
[:aurinko, :request, :stop]durationmethod, path, result, cached
[:aurinko, :request, :retry]countmethod, path, reason
[:aurinko, :circuit_breaker, :opened]countcircuit, reason
[:aurinko, :circuit_breaker, :closed]countcircuit
[:aurinko, :circuit_breaker, :rejected]countcircuit
[:aurinko, :sync, :complete]updated, deleted, duration_msresource

Zero-config structured logging

# Attach the default logger at runtime:
Aurinko.Telemetry.attach_default_logger(:info)

# Or enable at startup via config:
config :aurinko, attach_default_telemetry: true

Custom handler

:telemetry.attach(
  "my-metrics",
  [:aurinko, :request, :stop],
  fn _event, %{duration: d}, %{method: m, path: p, result: r}, _cfg ->
    ms = System.convert_time_unit(d, :native, :millisecond)
    Logger.info("Aurinko #{m} #{p}#{r} (#{ms}ms)")
  end,
  nil
)

Phoenix LiveDashboard / Prometheus

def metrics do
  [...your_metrics..., Aurinko.Telemetry.metrics()]
  |> List.flatten()
end

Error Handling

All functions return {:ok, result} or {:error, %Aurinko.Error{}}.

case Aurinko.get_message(token, "msg_123") do
  {:ok, message} ->
    IO.inspect(message)

  {:error, %Aurinko.Error{type: :not_found}} ->
    Logger.warning("Message not found")

  {:error, %Aurinko.Error{type: :auth_error, message: msg}} ->
    Logger.error("Auth failure: #{msg}")

  {:error, %Aurinko.Error{type: :rate_limited}} ->
    Logger.warning("Rate limited — all retries exhausted")

  {:error, %Aurinko.Error{type: :circuit_open}} ->
    Logger.warning("Circuit open — endpoint temporarily unavailable")

  {:error, %Aurinko.Error{type: :network_error}} ->
    Logger.error("Network failure")

  {:error, %Aurinko.Error{type: t, message: msg, status: status}} ->
    Logger.error("Aurinko #{t} (HTTP #{status}): #{msg}")
end

Error types: :auth_error · :not_found · :rate_limited · :server_error · :network_error · :timeout · :circuit_open · :invalid_params · :config_error · :unknown


Development

mix setup           # Install deps
mix lint            # Format check + Credo strict + Dialyzer
mix test            # Run tests
mix test.all        # Tests with coverage (generates coverage/index.html)
mix docs            # Generate ExDoc

Links


License

Apache 2.0 — see LICENSE.