symbiont_ex/lib/symbiont/telepathy/message_store.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

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