defmodule Jmap do
@moduledoc ~S"""
Basic JMAP client for Elixir.
This module provides a high-level interface for interacting with JMAP email servers.
It handles all the complexity of fetching emails, downloading blobs, and sanitizing content.
It was purpose built for fetching a single email at a time from FastMail. However, it may
serve for others as a useful starting point for building a more feature rich client.
## Configuration
In order to use JMAP, you'll need to configure a JMAP provider and token.
```elixir
config :jmap,
provider: "fastmail",
api_token: "your_api_token"
```
### HTTP Client
The library uses Erlang's built-in `:httpc` module by default. If you want to use the more feature-rich `req` package instead, simply add it as a dependency:
```elixir
defp deps do
[
{:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0"}
]
end
```
The library will automatically use `req` if it's available, falling back to `:httpc` if it's not.
## Creating a client
Once you have a provider and token, you can create a client.
```elixir
{:ok, client} = Jmap.new()
```
This returns an authenticated client struct or fails if the provider or token are invalid.
## Getting email
```elixir
{:ok, email} = Jmap.get_next_mail(client)
#=> %Jmap.Email{
id: "Md45jz",
subject: "Meeting Tomorrow",
from: [
%Jmap.Email.EmailAddress{
name: "John Smith",
email: "john.smith@example.com"
}
],
to: [
%Jmap.Email.EmailAddress{
name: "Jane Doe",
email: "jane.doe@example.com"
}
],
received_at: "2024-03-20T15:30:00Z",
text_body: %Jmap.Email.EmailBody{
blob_id: "blob_123",
type: "text/plain",
charset: "utf-8",
part_id: "part1",
size: 423,
contents: "Hi Jane,\n\nJust confirming our meeting tomorrow at 2pm.\n\nBest regards,\nJohn"
},
html_body: %Jmap.Email.EmailBody{
blob_id: "blob_124",
type: "text/html",
charset: "utf-8",
part_id: "part2",
size: 628,
contents: "
Hi Jane,
Just confirming our meeting tomorrow at 2pm.
Best regards,
John
"
},
attachments: [
%Jmap.Email.Attachment{
blob_id: "blob_125",
type: "application/pdf",
name: "agenda.pdf",
size: 125_840,
disposition: "attachment",
contents: <<...>> # Binary content
}
],
thread_id: "thread_789"
}
```
This returns the next email from the inbox. By default, it will return the oldest email first.
You can pass a `limit` and `offset` to paginate through the emails.
```elixir
{:ok, email} = Jmap.get_next_mail(client, limit: 10, offset: 0)
```
You can also change the default sort order by passing a `sort` option.
```elixir
{:ok, email} = Jmap.get_next_mail(client,
sort: [%{"isAscending" => false, "property" => "receivedAt"}])
```
## Get an email by ID.
This works exactly like `Jmap.get_next_mail/1` but it will return an email by ID.
```elixir
{:ok, email} = Jmap.get_email(client, "email_id")
```
This returns an email by ID.
"""
alias Jmap.Email
alias Jmap.Helpers.String, as: StringHelper
defdelegate new(), to: Jmap.Client
defdelegate new(api_token, provider, options \\ []), to: Jmap.Client
defdelegate fetch_emails(client, options \\ []), to: Jmap.Client
defdelegate archive_email(client, email_id), to: Jmap.Client
defdelegate fetch_email(client, email_id), to: Jmap.Client
defdelegate fetch_blob(client, blob_id), to: Jmap.Client
@doc """
Fetches the oldest email from the inbox, including its full contents.
This function handles all the complexity of:
- Fetching the email metadata
- Downloading the email body contents
- Downloading attachment contents
- Sanitizing HTML content
- Inlining embedded images
- Converting the data into a structured format
## Parameters
- client: The JMAP client struct
## Examples
iex> Jmap.get_next_mail(client)
{:ok, %Jmap.Email{subject: "Hello", text_body: %{contents: "Hello world"}}}
iex> Jmap.get_next_mail(client)
{:error, "Inbox empty"}
"""
def get_next_mail(client) do
with {:ok, %{"ids" => []}} <- fetch_emails(client, limit: 1, offset: 0) do
{:error, "Inbox empty"}
else
{:ok, %{"ids" => [email_id | _]}} ->
with {:ok, email_data} <- fetch_email(client, email_id),
email = Email.new(email_data),
{:ok, email_with_contents} <- populate_email_contents(email, client) do
{:ok, email_with_contents}
end
{:error, reason} ->
{:error, reason}
end
end
@doc """
Fetches an email by its ID, including its full contents.
This function handles all the complexity of:
- Fetching the email metadata
- Downloading the email body contents
- Downloading attachment contents
- Sanitizing HTML content
- Inlining embedded images
- Converting the data into a structured format
## Parameters
- client: The JMAP client struct
- email_id: The ID of the email to fetch
## Examples
iex> Jmap.get_email(client, "email123")
{:ok, %Jmap.Email{subject: "Hello", text_body: %{contents: "Hello world"}}}
iex> Jmap.get_email(client, "email123")
{:error, "Inbox empty"}
"""
def get_email(client, email_id) do
with {:ok, email_data} <- fetch_email(client, email_id),
email = Email.new(email_data),
{:ok, email_with_contents} <- populate_email_contents(email, client) do
{:ok, email_with_contents}
end
end
defp populate_email_contents(email, client) do
with {:ok, text_body} <- fetch_text_body(email.text_body, client),
{:ok, html_body} <- fetch_html_body(email.html_body, client),
{:ok, attachments} <- fetch_attachment_contents(email.attachments, client),
{:ok, html_body_with_images} <- inline_images(html_body, attachments) do
{:ok,
%{email | text_body: text_body, html_body: html_body_with_images, attachments: attachments}}
end
end
defp fetch_text_body(nil, _client), do: {:ok, nil}
defp fetch_text_body(body, client), do: fetch_body_content(body, client)
defp fetch_html_body(nil, _client), do: {:ok, nil}
defp fetch_html_body(body, client), do: fetch_body_content(body, client)
defp fetch_body_content(body, client) do
case fetch_blob(client, body.blob_id) do
{:ok, content} ->
content =
if body.type == "text/html" do
StringHelper.sanitize_printable(content)
else
content
end
{:ok, %{body | contents: content}}
{:error, reason} ->
{:error, reason}
end
end
defp fetch_attachment_contents(attachments, client) do
Enum.reduce_while(attachments, {:ok, []}, fn attachment, {:ok, acc} ->
case fetch_attachment_content(attachment, client) do
{:ok, attachment_with_content} -> {:cont, {:ok, [attachment_with_content | acc]}}
{:error, reason} -> {:halt, {:error, reason}}
end
end)
|> case do
{:ok, acc} -> {:ok, Enum.reverse(acc)}
error -> error
end
end
defp fetch_attachment_content(attachment, client) do
case fetch_blob(client, attachment.blob_id) do
{:ok, content} ->
{:ok, %{attachment | contents: content}}
{:error, reason} ->
{:error, reason}
end
end
defp inline_images(nil, _attachments), do: {:ok, nil}
defp inline_images(html_body, attachments) do
inline_attachments = Enum.filter(attachments, &(&1.disposition == "inline" && &1.cid))
content = html_body.contents
content_with_images =
Enum.reduce(inline_attachments, content, fn attachment, acc ->
cid = attachment.cid
base64 = Base.encode64(attachment.contents)
mime_type = attachment.type
replacement = "data:#{mime_type};base64,#{base64}"
String.replace(acc, "cid:#{cid}", replacement)
end)
{:ok, %{html_body | contents: content_with_images}}
end
end