# Aurinko
[](https://hex.pm/packages/aurinko)
[](https://hexdocs.pm/aurinko)
[](https://github.com/yourusername/aurinko/actions)
[](https://coveralls.io/github/yourusername/aurinko)
[](LICENSE)
A production-grade Elixir client for the [Aurinko Unified Mailbox API](https://docs.aurinko.io) — covering **Email, Calendar, Contacts, Tasks, Webhooks, and Booking** across Google Workspace, Office 365, Outlook, MS Exchange, Zoho Mail, iCloud, and IMAP.
---
## Features
- ✅ Full coverage of Aurinko's Unified APIs (Email, Calendar, Contacts, Tasks, Webhooks, Booking)
- ✅ Type-safe structs for all response objects
- ✅ Delta/incremental sync model for all data categories
- ✅ ETS-backed response cache with TTL and LRU eviction
- ✅ Token-bucket rate limiter (per-account + global) with automatic backoff
- ✅ Per-endpoint circuit breaker (closed → open → half-open)
- ✅ Automatic retry with exponential backoff and jitter
- ✅ `Retry-After` header parsing for 429 responses
- ✅ Lazy `Stream`-based pagination — never load all pages into memory
- ✅ High-level sync orchestrator for email, calendar, and contacts
- ✅ HMAC-SHA256 webhook signature verification (no extra dependencies)
- ✅ 7 `:telemetry` events covering requests, retries, circuit breaker, and sync
- ✅ Structured JSON log formatter for Datadog, Loki, and GCP
- ✅ Structured, tagged error types (`{:error, %Aurinko.Error{}}`)
- ✅ Config validation with `NimbleOptions`
- ✅ Full typespecs, `@spec`, and `@doc` coverage
---
## Installation
Add `aurinko` to your dependencies in `mix.exs`:
```elixir
def deps do
[
{:aurinko, "~> 0.2.1"}
]
end
```
```bash
mix deps.get
```
---
## Configuration
### Minimum required
```elixir
# config/runtime.exs
config :aurinko,
client_id: System.fetch_env!("AURINKO_CLIENT_ID"),
client_secret: System.fetch_env!("AURINKO_CLIENT_SECRET")
```
### Full configuration reference
```elixir
# 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
```elixir
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
```elixir
{: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
```elixir
{:ok, %{token: new_token}} = Aurinko.Auth.refresh_token(refresh_token)
```
---
## Email API
```elixir
# 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
```elixir
# 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
```elixir
{: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
```elixir
{: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
```elixir
{: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)
```elixir
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
```elixir
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:
```elixir
# 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:
```elixir
{: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_time` | `method`, `path` |
| `[:aurinko, :request, :stop]` | `duration` | `method`, `path`, `result`, `cached` |
| `[:aurinko, :request, :retry]` | `count` | `method`, `path`, `reason` |
| `[:aurinko, :circuit_breaker, :opened]` | `count` | `circuit`, `reason` |
| `[:aurinko, :circuit_breaker, :closed]` | `count` | `circuit` |
| `[:aurinko, :circuit_breaker, :rejected]` | `count` | `circuit` |
| `[:aurinko, :sync, :complete]` | `updated`, `deleted`, `duration_ms` | `resource` |
### Zero-config structured logging
```elixir
# 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
```elixir
: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
```elixir
def metrics do
[...your_metrics..., Aurinko.Telemetry.metrics()]
|> List.flatten()
end
```
---
## Error Handling
All functions return `{:ok, result}` or `{:error, %Aurinko.Error{}}`.
```elixir
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
```bash
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
- [Getting Started Guide](https://hexdocs.pm/aurinko/getting_started.html)
- [Advanced Guide](https://hexdocs.pm/aurinko/advanced.html)
- [API Reference](https://hexdocs.pm/aurinko)
- [Aurinko Docs](https://docs.aurinko.io)
- [Changelog](CHANGELOG.md)
---
## License
Apache 2.0 — see [LICENSE](LICENSE).