Current section

Files

Jump to
jmap lib jmap client.ex
Raw

lib/jmap/client.ex

defmodule Jmap.Client do
@moduledoc """
JMAP client implementation that handles the core protocol communication.
"""
defstruct [
:api_token,
:provider,
:api_url,
:session_url,
:account_id,
:inbox_id,
:archive_id,
:download_url
]
@fastmail_api_url "https://api.fastmail.com/jmap/session"
@doc """
Creates a new JMAP client using configuration values.
## Returns
* `{:ok, client}` - on success
* `{:error, reason}` - on failure
## Examples
iex> Jmap.new()
{:ok, %Jmap.Client{}}
"""
def new do
case {Application.get_env(:jmap, :api_token), Application.get_env(:jmap, :provider)} do
{nil, _} -> {:error, "API token not configured"}
{_, nil} -> {:error, "Provider not configured"}
{api_token, provider} -> new(api_token, provider)
end
end
@doc """
Creates a new JMAP client with the given configuration.
## Parameters
- api_token: The JMAP API token (for Fastmail, generate this in Settings -> Password & Security -> App Passwords)
- provider: The JMAP provider (e.g., :fastmail)
- options: Additional options (optional)
- api_url: Custom API URL (defaults to provider's default)
- timeout: Request timeout in milliseconds (default: 30_000)
## Returns
* `{:ok, client}` - on success
* `{:error, reason}` - on failure
## Examples
iex> Jmap.new("your-api-token", :fastmail)
{:ok, %Jmap.Client{}}
"""
def new(api_token, provider, options \\ [])
def new(api_token, provider, options) when is_atom(provider) do
api_url =
case provider do
:fastmail ->
Keyword.get(options, :api_url, @fastmail_api_url)
_ ->
Keyword.get(options, :api_url) ||
{:error, "API URL must be provided for provider: #{inspect(provider)}"}
end
case api_url do
{:error, reason} ->
{:error, reason}
url ->
client = %__MODULE__{
api_token: api_token,
provider: provider,
api_url: url
}
case authenticate(client) do
{:ok, authenticated_client} -> {:ok, authenticated_client}
{:error, reason} -> {:error, reason}
end
end
end
def new(_api_token, provider, _options) do
{:error, "Invalid provider: #{inspect(provider)}. Expected an atom."}
end
@doc """
Fetches emails from the Inbox.
## Options
- limit: Maximum number of emails to fetch (default: 50)
- offset: Number of emails to skip (default: 0)
- sort: Custom sort order (default: [%{"isAscending" => true, "property" => "receivedAt"}])
## Returns
* `{:ok, result}` - on success, where result contains:
- "ids": List of email IDs
- "total": Total number of emails
- "position": Current position in the result set
* `{:error, reason}` - on failure
"""
def fetch_emails(client, options \\ []) do
limit = Keyword.get(options, :limit, 50)
offset = Keyword.get(options, :offset, 0)
sort = Keyword.get(options, :sort, [%{"isAscending" => true, "property" => "receivedAt"}])
request = %{
"using" => ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
"methodCalls" => [
[
"Email/query",
%{
"accountId" => client.account_id,
"filter" => %{
"inMailbox" => client.inbox_id
},
"sort" => sort,
"position" => offset,
"limit" => limit
},
"a"
]
]
}
case make_request(client, request) do
{:ok, %{"accountId" => _account_id} = result} ->
{:ok, result}
{:ok, %{status: status, body: body}} ->
{:error, "Request failed with status #{status}: #{inspect(body)}"}
{:error, reason} ->
{:error, reason}
end
end
@doc """
Archives the specified email by moving it to the Archive folder.
"""
def archive_email(client, email_id) do
request = %{
"using" => ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
"methodCalls" => [
[
"Email/set",
%{
"accountId" => client.account_id,
"update" => %{
email_id => %{
"mailboxIds" => %{
client.archive_id => true
}
}
}
},
"a"
]
]
}
case make_request(client, request) do
{:ok, result} ->
{:ok, result}
{:error, reason} ->
{:error, reason}
end
end
@doc """
Fetches the full content of a specific email.
## Parameters
- client: The JMAP client struct
- email_id: The ID of the email to fetch
## Returns
* `{:ok, email}` - on success, where email contains:
- id: The email ID
- subject: The email subject
- from: List of sender email addresses
- to: List of recipient email addresses
- receivedAt: Timestamp when the email was received
- textBody: List of text/plain body parts
- htmlBody: List of text/html body parts
- attachments: List of attachments
- threadId: The thread ID this email belongs to
* `{:error, reason}` - on failure
## Examples
iex> Jmap.Client.fetch_email(client, "email123")
{:ok, %{
"id" => "email123",
"subject" => "Hello",
"from" => [%{"email" => "sender@example.com", "name" => "Sender"}],
"to" => [%{"email" => "recipient@example.com", "name" => "Recipient"}],
"receivedAt" => "2024-01-01T00:00:00Z",
"textBody" => [
%{"blobId" => "blob123", "type" => "text/plain"},
%{"blobId" => "blob124", "type" => "text/plain"}
],
"htmlBody" => [
%{"blobId" => "blob125", "type" => "text/html"}
],
"attachments" => [],
"threadId" => "thread123"
}}
"""
def fetch_email(client, email_id) do
request = %{
"using" => ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
"methodCalls" => [
[
"Email/get",
%{
"accountId" => client.account_id,
"ids" => [email_id],
"properties" => [
"id",
"subject",
"from",
"to",
"receivedAt",
"textBody",
"htmlBody",
"attachments",
"threadId"
]
},
"a"
]
]
}
case make_request(client, request) do
{:ok, %{"list" => [email | _]}} -> {:ok, email}
{:ok, %{"list" => []}} -> {:error, "Email not found"}
{:error, reason} -> {:error, reason}
end
end
@doc """
Fetches the content of a blob by its ID.
## Parameters
- client: The JMAP client struct
- blob_id: The ID of the blob to fetch
- type: The expected content type (optional)
- "text/html" or "text/plain" will return a string
- "application/octet-stream" or other types will return binary
## Returns
* `{:ok, content}` - on success
- For text types: {:ok, string}
- For binary types: {:ok, binary}
* `{:error, reason}` - on failure
## Examples
iex> Jmap.Client.fetch_blob(client, "blob123", "text/html")
{:ok, "<html>...</html>"}
iex> Jmap.Client.fetch_blob(client, "blob123", "application/octet-stream")
{:ok, <<...>>}
"""
def fetch_blob(client, blob_id, type \\ "application/octet-stream") do
# Replace placeholders in the download URL from the session response
download_url =
client.download_url
|> String.replace("{accountId}", client.account_id)
|> String.replace("{blobId}", blob_id)
|> String.replace("{name}", "content")
|> String.replace("{type}", type)
headers = [
{"Authorization", "Bearer #{client.api_token}"}
]
http_client = get_http_client()
case http_client.get(download_url, headers: headers) do
{:ok, %{status: 200, body: content}} ->
case type do
"text/html" -> {:ok, content |> to_string()}
"text/plain" -> {:ok, content |> to_string()}
_ -> {:ok, content}
end
{:ok, %{status: status, body: body}} ->
{:error, "Failed to fetch blob with status #{status}: #{inspect(body)}"}
{:error, reason} ->
{:error, reason}
end
end
@doc """
Fetches a thread by its ID.
## Parameters
- client: The JMAP client struct
- thread_id: The ID of the thread to fetch
## Returns
* `{:ok, thread}` - on success, containing thread ID and associated email IDs
* `{:error, reason}` - on failure
## Examples
iex> Jmap.Client.fetch_thread(client, "thread123")
{:ok, %Jmap.Thread{id: "thread123", emailIds: ["email1", "email2"]}}
"""
def fetch_thread(client, thread_id) do
request = %{
"using" => ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
"methodCalls" => [
[
"Thread/get",
%{
"accountId" => client.account_id,
"ids" => [thread_id]
},
"a"
]
]
}
case make_request(client, request) do
{:ok, %{"list" => [thread | _]}} ->
{:ok,
%Jmap.Thread{
id: thread["id"],
emailIds: thread["emailIds"]
}}
{:ok, %{"list" => []}} ->
{:error, "Thread not found"}
{:error, reason} ->
{:error, reason}
end
end
# Private functions
defp authenticate(client) do
headers = [{"Authorization", "Bearer #{client.api_token}"}]
http_client = get_http_client()
case http_client.get(client.api_url, headers: headers) do
{:ok, %{status: 200, body: session}} ->
# Get the first account ID from primaryAccounts
account_id = session["primaryAccounts"]["urn:ietf:params:jmap:mail"]
# Get the account details
account = get_in(session, ["accounts", account_id])
if is_nil(account) do
{:error, "Account not found in session response"}
else
with {:ok, mailboxes} <-
fetch_mailboxes(session["apiUrl"], client.api_token, account_id),
{:ok, inbox_id} <- find_mailbox_id(mailboxes, "inbox"),
{:ok, archive_id} <- find_mailbox_id(mailboxes, "archive") do
{:ok,
%{
client
| session_url: session["apiUrl"],
account_id: account_id,
inbox_id: inbox_id,
archive_id: archive_id,
download_url: session["downloadUrl"]
}}
else
{:error, reason} -> {:error, reason}
end
end
{:ok, %{status: status, body: body}} ->
{:error, "Authentication failed with status #{status}: #{inspect(body)}"}
{:error, reason} ->
{:error, "Authentication request failed: #{inspect(reason)}"}
end
end
defp fetch_mailboxes(api_url, api_token, account_id) do
request = %{
"using" => ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
"methodCalls" => [
[
"Mailbox/get",
%{
"accountId" => account_id
},
"a"
]
]
}
headers = [
{"Authorization", "Bearer #{api_token}"},
{"Content-Type", "application/json"}
]
http_client = get_http_client()
case http_client.post(api_url, request, headers: headers) do
{:ok, %{status: 200, body: %{"methodResponses" => [["Mailbox/get", result, "a"]]}}} ->
{:ok, result["list"]}
{:ok, %{status: status, body: body}} ->
{:error, "Failed to fetch mailboxes with status #{status}: #{inspect(body)}"}
{:error, reason} ->
{:error, "Failed to fetch mailboxes: #{inspect(reason)}"}
end
end
defp find_mailbox_id(mailboxes, role) do
case mailboxes do
nil ->
{:error, "No mailboxes found in response"}
mailboxes when is_list(mailboxes) ->
case Enum.find(mailboxes, fn mailbox -> mailbox["role"] == role end) do
nil -> {:error, "No mailbox found with role: #{role}"}
mailbox -> {:ok, mailbox["id"]}
end
_ ->
{:error, "Invalid mailboxes format in response"}
end
end
defp make_request(client, request) do
headers = [
{"Authorization", "Bearer #{client.api_token}"},
{"Content-Type", "application/json"}
]
http_client = get_http_client()
case http_client.post(client.session_url, request, headers: headers) do
{:ok, %{status: 200, body: %{"methodResponses" => [[_method_name, result, _id]]}}} ->
{:ok, result}
{:ok, %{status: status, body: body}} ->
{:error, "Request failed with status #{status}: #{inspect(body)}"}
{:error, reason} ->
{:error, reason}
end
end
defp get_http_client do
Application.get_env(:jmap, :http_client) ||
if Code.ensure_loaded?(Req) do
Jmap.HttpClient.Req
else
Jmap.HttpClient.Web
end
end
end