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>
127 lines
3.1 KiB
Elixir
127 lines
3.1 KiB
Elixir
defmodule Symbiont.Telepathy.MessageStore do
|
|
@moduledoc """
|
|
Persistent message store backed by JSONL, compatible with the Python
|
|
Telepathy messages.jsonl format.
|
|
|
|
Message schema:
|
|
%{"id" => hex12, "timestamp" => iso8601, "source" => string,
|
|
"subject" => string, "content" => string, "read" => boolean}
|
|
"""
|
|
use GenServer
|
|
require Logger
|
|
|
|
defstruct [:path, messages: []]
|
|
|
|
def start_link(opts) do
|
|
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
|
end
|
|
|
|
def store(message) do
|
|
GenServer.call(__MODULE__, {:store, message})
|
|
end
|
|
|
|
def unread do
|
|
GenServer.call(__MODULE__, :unread)
|
|
end
|
|
|
|
def mark_read(msg_id) do
|
|
GenServer.call(__MODULE__, {:mark_read, msg_id})
|
|
end
|
|
|
|
def list(opts \\ []) do
|
|
GenServer.call(__MODULE__, {:list, opts})
|
|
end
|
|
|
|
def count do
|
|
GenServer.call(__MODULE__, :count)
|
|
end
|
|
|
|
# -- Server --
|
|
|
|
@impl true
|
|
def init(opts) do
|
|
path = Keyword.get(opts, :path, "/data/telepathy/messages.jsonl")
|
|
File.mkdir_p!(Path.dirname(path))
|
|
unless File.exists?(path), do: File.write!(path, "")
|
|
|
|
messages = load(path)
|
|
Logger.info("Telepathy MessageStore loaded: #{length(messages)} messages")
|
|
{:ok, %__MODULE__{path: path, messages: messages}}
|
|
end
|
|
|
|
@impl true
|
|
def handle_call({:store, msg}, _from, state) do
|
|
entry =
|
|
Map.merge(
|
|
%{
|
|
"id" => generate_id(),
|
|
"timestamp" => DateTime.utc_now() |> DateTime.to_iso8601(),
|
|
"read" => false
|
|
},
|
|
msg
|
|
)
|
|
|
|
new_messages = state.messages ++ [entry]
|
|
append!(state.path, entry)
|
|
Logger.info("Telepathy message stored: #{entry["id"]} from #{entry["source"]}")
|
|
{:reply, {:ok, entry["id"]}, %{state | messages: new_messages}}
|
|
end
|
|
|
|
@impl true
|
|
def handle_call(:unread, _from, state) do
|
|
unread = Enum.filter(state.messages, &(not &1["read"]))
|
|
{:reply, unread, state}
|
|
end
|
|
|
|
@impl true
|
|
def handle_call({:mark_read, msg_id}, _from, state) do
|
|
new_messages =
|
|
Enum.map(state.messages, fn msg ->
|
|
if msg["id"] == msg_id, do: Map.put(msg, "read", true), else: msg
|
|
end)
|
|
|
|
persist!(state.path, new_messages)
|
|
{:reply, :ok, %{state | messages: new_messages}}
|
|
end
|
|
|
|
@impl true
|
|
def handle_call({:list, opts}, _from, state) do
|
|
limit = Keyword.get(opts, :limit, 50)
|
|
msgs = Enum.take(state.messages, -limit)
|
|
{:reply, msgs, state}
|
|
end
|
|
|
|
@impl true
|
|
def handle_call(:count, _from, state) do
|
|
{:reply, length(state.messages), state}
|
|
end
|
|
|
|
# -- Private --
|
|
|
|
defp load(path) do
|
|
path
|
|
|> File.stream!()
|
|
|> Stream.reject(&(&1 in ["", "\n"]))
|
|
|> Enum.map(fn line ->
|
|
case Jason.decode(String.trim(line)) do
|
|
{:ok, msg} -> msg
|
|
{:error, _} -> nil
|
|
end
|
|
end)
|
|
|> Enum.reject(&is_nil/1)
|
|
end
|
|
|
|
defp append!(path, entry) do
|
|
File.write!(path, Jason.encode!(entry) <> "\n", [:append])
|
|
end
|
|
|
|
defp persist!(path, messages) do
|
|
content = Enum.map_join(messages, fn m -> Jason.encode!(m) <> "\n" end)
|
|
File.write!(path, content)
|
|
end
|
|
|
|
defp generate_id do
|
|
:crypto.strong_rand_bytes(6) |> Base.encode16(case: :lower)
|
|
end
|
|
end
|