symbiont_ex/lib/symbiont/telepathy/jmap.ex
Claude Opus 4.6 fe5896be2d feat: initial Telepathy Elixir port — GenServer + JMAP fetch loop
Adds the Telepathy email communication layer as an OTP subtree:

- Telepathy.JMAP: GenServer that discovers Fastmail JMAP session,
  periodically polls inbox for new emails, and provides send_email/3.
  Uses :httpc (no external HTTP dep). Deduplicates via seen_ids MapSet
  seeded from existing MessageStore entries.

- Telepathy.MessageStore: JSONL-backed message persistence, compatible
  with the existing Python messages.jsonl format. Supports store, unread,
  mark_read, list, count operations.

- Telepathy.Supervisor: rest_for_one supervisor (MessageStore then JMAP).

- Conditionally enabled via config (:telepathy, :enabled). Disabled in
  test env. Can be disabled at runtime via TELEPATHY_ENABLED=false.

Validated against live Fastmail: session discovery, inbox fetch (20
emails), message storage all working.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-19 06:11:13 +00:00

485 lines
12 KiB
Elixir

defmodule Symbiont.Telepathy.JMAP do
@moduledoc """
JMAP client GenServer for Fastmail (RFC 8620/8621).
Discovers the JMAP session, then periodically polls for new inbound emails.
New emails are stored in the MessageStore. Also provides send_email/3 for
outbound mail.
Uses :httpc from :inets (no external HTTP dependency).
"""
use GenServer
require Logger
@jmap_session_url "https://api.fastmail.com/jmap/session"
@core_cap "urn:ietf:params:jmap:core"
@mail_cap "urn:ietf:params:jmap:mail"
@submit_cap "urn:ietf:params:jmap:submission"
@default_poll_interval_ms 60_000
@config_path "/data/telepathy/config.json"
defstruct [
:token,
:api_url,
:account_id,
:identity_id,
:from_email,
:drafts_id,
:inbox_id,
:poll_interval_ms,
seen_ids: MapSet.new()
]
# -- Client API --
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
def send_email(to, subject, body) do
GenServer.call(__MODULE__, {:send_email, to, subject, body}, 30_000)
end
def session_info do
GenServer.call(__MODULE__, :session_info)
end
def fetch_now do
send(__MODULE__, :poll)
:ok
end
# -- Server --
@impl true
def init(opts) do
poll_ms = Keyword.get(opts, :poll_interval_ms, @default_poll_interval_ms)
token = load_token()
state = %__MODULE__{
token: token,
poll_interval_ms: poll_ms
}
send(self(), :discover_session)
{:ok, state}
end
@impl true
def handle_info(:discover_session, state) do
case discover_session(state.token) do
{:ok, session} ->
Logger.info("Telepathy JMAP session discovered: account=#{session.account_id}")
new_state = %{
state
| api_url: session.api_url,
account_id: session.account_id,
identity_id: session.identity_id,
from_email: session.from_email,
drafts_id: session.drafts_id,
inbox_id: session.inbox_id
}
seen = seed_seen_ids()
schedule_poll(new_state.poll_interval_ms)
{:noreply, %{new_state | seen_ids: seen}}
{:error, reason} ->
Logger.error("Telepathy JMAP session discovery failed: #{inspect(reason)}")
Process.send_after(self(), :discover_session, 10_000)
{:noreply, state}
end
end
@impl true
def handle_info(:poll, %{api_url: nil} = state) do
schedule_poll(state.poll_interval_ms)
{:noreply, state}
end
@impl true
def handle_info(:poll, state) do
new_state =
case fetch_inbox_emails(state) do
{:ok, emails} ->
{new_count, updated_seen} = store_new_emails(emails, state.seen_ids)
if new_count > 0,
do: Logger.info("Telepathy: fetched #{new_count} new email(s)")
%{state | seen_ids: updated_seen}
{:error, reason} ->
Logger.warning("Telepathy JMAP poll failed: #{inspect(reason)}")
state
end
schedule_poll(new_state.poll_interval_ms)
{:noreply, new_state}
end
@impl true
def handle_call({:send_email, to, subject, body}, _from, state) do
result = do_send_email(state, to, subject, body)
{:reply, result, state}
end
@impl true
def handle_call(:session_info, _from, state) do
info = %{
api_url: state.api_url,
account_id: state.account_id,
identity_id: state.identity_id,
from_email: state.from_email,
inbox_id: state.inbox_id,
seen_count: MapSet.size(state.seen_ids)
}
{:reply, info, state}
end
# -- Private: Session Discovery --
defp discover_session(token) do
with {:ok, session_resp} <- jmap_get(@jmap_session_url, token),
api_url <- session_resp["apiUrl"],
account_id <- get_in(session_resp, ["primaryAccounts", @mail_cap]),
{:ok, details} <- fetch_identity_and_mailboxes(api_url, account_id, token) do
{:ok, Map.merge(%{api_url: api_url, account_id: account_id}, details)}
end
end
defp fetch_identity_and_mailboxes(api_url, account_id, token) do
method_calls = [
["Identity/get", %{"accountId" => account_id, "ids" => nil}, "id0"],
["Mailbox/get", %{"accountId" => account_id, "ids" => nil}, "mb0"]
]
case jmap_call(api_url, token, method_calls) do
{:ok, responses} ->
identity = extract_identity(responses)
{drafts_id, inbox_id} = extract_mailbox_ids(responses)
{:ok,
%{
identity_id: identity[:id],
from_email: identity[:email] || "mdwyer@michaelmdwyer.com",
drafts_id: drafts_id,
inbox_id: inbox_id
}}
error ->
error
end
end
defp extract_identity(responses) do
target = "mdwyer@michaelmdwyer.com"
Enum.find_value(responses, %{id: nil, email: nil}, fn
["Identity/get", data, _] ->
identities = data["list"] || []
match =
Enum.find(identities, fn i ->
String.downcase(i["email"] || "") == target
end) || List.first(identities)
if match, do: %{id: match["id"], email: match["email"]}, else: %{id: nil, email: nil}
_ ->
nil
end)
end
defp extract_mailbox_ids(responses) do
Enum.find_value(responses, {nil, nil}, fn
["Mailbox/get", data, _] ->
mailboxes = data["list"] || []
drafts = Enum.find(mailboxes, &(&1["role"] == "drafts"))
inbox = Enum.find(mailboxes, &(&1["role"] == "inbox"))
{drafts && drafts["id"], inbox && inbox["id"]}
_ ->
nil
end)
end
# -- Private: Fetch Loop --
defp fetch_inbox_emails(state) do
method_calls = [
[
"Email/query",
%{
"accountId" => state.account_id,
"filter" => %{"inMailbox" => state.inbox_id},
"sort" => [%{"property" => "receivedAt", "isAscending" => false}],
"limit" => 20
},
"q0"
],
[
"Email/get",
%{
"accountId" => state.account_id,
"#ids" => %{
"resultOf" => "q0",
"name" => "Email/query",
"path" => "/ids"
},
"properties" => [
"id",
"from",
"to",
"subject",
"receivedAt",
"textBody",
"bodyValues",
"preview"
],
"fetchTextBodyValues" => true
},
"g0"
]
]
case jmap_call(state.api_url, state.token, method_calls) do
{:ok, responses} ->
emails =
Enum.find_value(responses, [], fn
["Email/get", data, _] -> data["list"] || []
_ -> nil
end) || []
{:ok, emails}
error ->
error
end
end
defp store_new_emails(emails, seen_ids) do
new_emails = Enum.reject(emails, &MapSet.member?(seen_ids, &1["id"]))
Enum.each(new_emails, fn email ->
body = extract_body(email)
from = extract_from(email)
subject = email["subject"] || "(no subject)"
msg = %{
"source" => "email-in",
"subject" => subject,
"content" => "[From: #{from}]\n#{body}",
"jmap_id" => email["id"],
"received_at" => email["receivedAt"]
}
Symbiont.Telepathy.MessageStore.store(msg)
end)
new_seen =
new_emails
|> Enum.map(& &1["id"])
|> Enum.reduce(seen_ids, &MapSet.put(&2, &1))
{length(new_emails), new_seen}
end
defp extract_body(email) do
body_values = email["bodyValues"] || %{}
text_body = email["textBody"] || []
case text_body do
[%{"partId" => part_id} | _] ->
get_in(body_values, [part_id, "value"]) || email["preview"] || ""
_ ->
email["preview"] || ""
end
end
defp extract_from(email) do
case email["from"] do
[%{"name" => name, "email" => addr} | _] when is_binary(name) and name != "" ->
"#{name} <#{addr}>"
[%{"email" => addr} | _] ->
addr
_ ->
"unknown"
end
end
# -- Private: Send Email --
defp do_send_email(state, to, subject, body) do
method_calls = [
[
"Email/set",
%{
"accountId" => state.account_id,
"create" => %{
"e1" => %{
"mailboxIds" => %{state.drafts_id => true},
"from" => [%{"name" => "Muse", "email" => state.from_email}],
"to" => [%{"email" => to}],
"subject" => subject,
"replyTo" => [%{"name" => "Muse", "email" => "muse@hydrascale.net"}],
"keywords" => %{"$draft" => true},
"bodyStructure" => %{"type" => "text/plain", "partId" => "body"},
"bodyValues" => %{"body" => %{"value" => body, "charset" => "utf-8"}}
}
}
},
"create-email"
],
[
"EmailSubmission/set",
%{
"accountId" => state.account_id,
"create" => %{
"s1" => %{
"identityId" => state.identity_id,
"emailId" => "#e1"
}
}
},
"send"
]
]
case jmap_call(state.api_url, state.token, method_calls) do
{:ok, responses} ->
parse_send_response(responses)
{:error, reason} ->
{:error, reason}
end
end
defp parse_send_response(responses) do
Enum.reduce(responses, %{email_id: nil, sent: false, errors: []}, fn
["Email/set", data, _], acc ->
case get_in(data, ["created", "e1", "id"]) do
nil ->
error = get_in(data, ["notCreated", "e1"])
%{acc | errors: ["Email create: #{inspect(error)}" | acc.errors]}
id ->
%{acc | email_id: id}
end
["EmailSubmission/set", data, _], acc ->
if get_in(data, ["created", "s1"]) do
%{acc | sent: true}
else
error = get_in(data, ["notCreated", "s1"])
%{acc | errors: ["Submission: #{inspect(error)}" | acc.errors]}
end
_, acc ->
acc
end)
|> then(fn
%{sent: true, email_id: id} -> {:ok, id}
%{errors: errors} -> {:error, Enum.join(errors, "; ")}
end)
end
# -- Private: HTTP Helpers (using :httpc) --
defp jmap_get(url, token) do
headers = [{~c"Authorization", ~c"Bearer #{token}"}]
case :httpc.request(:get, {String.to_charlist(url), headers}, [{:timeout, 15_000}], []) do
{:ok, {{_, 200, _}, _, body}} ->
Jason.decode(IO.iodata_to_binary(body))
{:ok, {{_, status, _}, _, body}} ->
{:error, {:http, status, IO.iodata_to_binary(body)}}
{:error, reason} ->
{:error, reason}
end
end
defp jmap_call(api_url, token, method_calls) do
payload =
Jason.encode!(%{
"using" => [@core_cap, @mail_cap, @submit_cap],
"methodCalls" => method_calls
})
headers = [
{~c"Authorization", ~c"Bearer #{token}"},
{~c"Content-Type", ~c"application/json"}
]
case :httpc.request(
:post,
{String.to_charlist(api_url), headers, ~c"application/json", payload},
[{:timeout, 30_000}],
[]
) do
{:ok, {{_, 200, _}, _, body}} ->
case Jason.decode(IO.iodata_to_binary(body)) do
{:ok, %{"methodResponses" => responses}} -> {:ok, responses}
{:ok, other} -> {:error, {:unexpected_response, other}}
{:error, _} = err -> err
end
{:ok, {{_, status, _}, _, body}} ->
{:error, {:http, status, IO.iodata_to_binary(body)}}
{:error, reason} ->
{:error, reason}
end
end
defp schedule_poll(interval_ms) do
Process.send_after(self(), :poll, interval_ms)
end
defp load_token do
case System.get_env("FASTMAIL_API_TOKEN") do
nil -> load_token_from_config()
"" -> load_token_from_config()
token -> token
end
end
defp load_token_from_config do
case File.read(@config_path) do
{:ok, content} ->
case Jason.decode(content) do
{:ok, %{"fastmail_token" => token}}
when is_binary(token) and token != "" and token != "PLACEHOLDER" ->
token
_ ->
raise "No valid fastmail_token in #{@config_path}"
end
{:error, _} ->
raise "Cannot read #{@config_path} — set FASTMAIL_API_TOKEN env var"
end
end
defp seed_seen_ids do
case Symbiont.Telepathy.MessageStore.list(limit: 10_000) do
messages when is_list(messages) ->
messages
|> Enum.map(& &1["jmap_id"])
|> Enum.reject(&is_nil/1)
|> MapSet.new()
_ ->
MapSet.new()
end
end
end