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>
485 lines
12 KiB
Elixir
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
|