AoAH Day 17: OCaml JMAP to plaster my painful email papercuts / Dec 2025

After building a JSON Pointer library yesterday, I proceeded to complete my OCaml JMAP library today so that I could wrestle my overflowing email inbox under control. Email is central to our digital lives and yet we have mostly ceded control to third-party services for something that unlocks access to almost any service we use.

Luckily, I've been self-hosting my own email for some time, so I do have full local access to about three decades worth of messages. However, I've been hampered by existing email clients which are mostly geared towards a temporal view and not towards easy programmability. So today's exercise has been to build an ocaml-jmap that lets me write little agentic programs to help me manage my ever overflowing inbox!

Shriram vibes a fair criticism of my current email strategy
Shriram vibes a fair criticism of my current email strategy

Why JMAP for email?

Historically, the protocol of choice for accessing email has been IMAP, and for years I used the Lwt ocaml-imap library that Nicolás Ojeda Bär wrote back when he was here in Cambridge. However, IMAP is a pretty convoluted protocol that hasn't evolved much over time, and requires a number of accreted extensions that are patchily implemented.

The JSON Meta Application Protocol (or JMAP) was developed in response to this. The developers wrote:

JMAP is the result of efforts to address shortcomings [in existing protocols], providing a modern, efficient, easy-to-use API, built on many years of experience and field testing. -- JMAP, a modern open email protocol, Gondwana and Jenkins, May 2019

JMAP's exciting because it allows for the reuse of a number of other protocol implementations I've already built these past few weeks. I can use ocaml-requests and conpool for HTTPS connections, and my ever-growing dependence on jsont means that I can get reasonable error messages while debugging the implementation.

Servers that support JMAP are still thin on the ground, but all my email endpoints do now support it. At work in the Cambridge Computer Lab, the enlightened sysadmins gave us Fastmail accounts as an alternative to having Exchange inflicted on us by the central University. Over at Recoil, my personal mail is switching to Stalwart JMAP. While there's still no good JMAP client that replaces my day-to-day email, all these services also expose IMAP for that purpose. This clears the way for me to vibecode up a JSON library in OCaml for the programmatic fragments that I so crave!

Building^H^H^Hvibing OCaml JMAP

Interestingly, I had also tried to build an OCaml JMAP waaay back when Claude Code was first released, and the difference in nine months of model development is massive; browse my first attempt here and see what a mess it was.

But now that I've got a bunch of examples under my belt, the latest round of vibing of ocaml-jmap was extremely fast. The agent had examples of jsont use from jsonfeed and the Karakeep REST client, and ocaml-requests provides direct-style HTTP requests with authentication bearer support. I vibesplained a Javascript tutorial to help me view messages.

Building a browser-based JMAP client to test

However, I wanted to go a little further than just notebook style tutorials. In addition to compiling OCaml code to JavaScript, there's also really good support for programming browser API directly through libraries like Brr. These expose an OCaml interface that allows us to build up browser-specific logic to better integrate within it.

Connecting to fastmail from a browser OCaml JMAP compiled to JavaScript
Connecting to fastmail from a browser OCaml JMAP compiled to JavaScript

So I took the notebook idea one step further and prompted up an entire JMAP client that runs in the browser (albeit a very simple one!). This Json_brr library looks just like normal OCaml, except that it uses libraries designed to run in the browser as its dependencies. The interface shows some differences

type connection
val api_url : connection -> Jstr.t
(** [api_url conn] returns the API URL for requests. *)

(** {1 Session Establishment} *)

val get_session :
  url:Jstr.t ->
  token:Jstr.t ->
  (connection, Jv.Error.t) result Fut.t
(** [get_session ~url ~token] establishes a JMAP session.

Instead of string, this uses Jstr.t for JavaScript strings. Instead of Eio, this also uses Fut.t to encode future promises. But aside from those things, it's just plain OCaml! The implementation makes Websocket requests to connect to a remote JMAP server in the browser.

let fetch_json ~url ~meth ~headers ?body () =
  Console.(log [str ">>> Request:"; str (Jstr.to_string meth); str (Jstr.to_string url)]);
  (match body with
   | Some b -> Console.(log [str ">>> Body:"; b])
   | None -> Console.(log [str ">>> No body"]));
  let init = Brr_io.Fetch.Request.init
    ~method':meth
    ~headers
    ?body
    ()
  in
  let req = Brr_io.Fetch.Request.v ~init url in
  let* response = Brr_io.Fetch.request req in

To use jmap.html, I obtained a (read only!!) API key from my live email server, and then managed to connect to my live email and get a protocol debugger, all from my browser.

The client also dumps the JMAP protocol messages so I can learn more about how it works.
The client also dumps the JMAP protocol messages so I can learn more about how it works.

One important performance point about how this works is that some of the base libraries I'm using take advantage of OCaml's modularity in order to expose browser specific backends. For example, Jsont_brr uses the native browser JSON parser while still working with Jsont codecs. This sort of casual modularity is an extremely nice and undersung feature of OCaml, and was also key in MirageOS being portable to so many embedded devices.

Fixing my notifications with programmable email

So now we have a working library, I wanted to go beyond the conventional email clients. With agentic coding, I should be able to construct hundreds of domain-specific bits of code that help me manipulate my email the way I want it done. This was the dream of #selfhosting and open source in the first place, but it was too much work to write all that glue code... until now.

The sad state of my 10000s of GitHub notifications, which I mostly ignore these days
The sad state of my 10000s of GitHub notifications, which I mostly ignore these days

One papercut that bugs both me and Mark Elvers is the flood of email notifications we get that are rapidly out of date. I get thousands a day from GitHub, and ideally they would be marked as read and filed away automatically when I finish with it on the remote service, and not stay in my Inbox. This is now easily solveable using OCaml JMAP, so I built a proof of concept to filter away my Zulip notifications nicely.

JMAP keywords and CLI fragments

I built a domain-specific CLI called jmapq which exists solely to implement specialist workflows. I'll break this out into a private repo for my own use later, this is just a prototype!

Zulip sends a nicely structured email with a subject like "#Blogs > My 2025 Advent of Agentic Humps: a new library daily [Cambridge Energy & Environment Group]" every so often which I instructed the agent to parse into an OCaml data structure:

(** Parsed information from a Zulip notification email subject.
    Subject format: "#Channel > topic [Server Name]" *)
module Zulip_message = struct
  type t = {
    id : string;
    date : Ptime.t;
    thread_id : string;
    channel : string;
    topic : string;
    server : string;
    is_read : bool;
    labels : string list;
  }

  (** Parse a Zulip subject line of the form "#Channel > topic [Server Name]" *)
  let parse_subject subject =
    (* Pattern: #<channel> > <topic> [<server>] *)
    let channel_re = Re.Pcre.regexp {|^#(.+?)\s*>\s*(.+?)\s*\[(.+?)\]$|} in
    match Re.exec_opt channel_re subject with
    | Some groups ->
        let channel = Re.Group.get groups 1 in
        let topic = Re.Group.get groups 2 in
        let server = Re.Group.get groups 3 in
        Some (channel, topic, server)
    | None -> None

Once we have this, I then added another CLI command to list all the Zulip notifications in my email, which it outputs using nicely structured JSON output that is parseable by jq.

Given a read only API key, this parses out just what I need from Zulip
Given a read only API key, this parses out just what I need from Zulip

Then I instructed the CLI to give me more grouped views, so I can see notifications in my email by topic and by server (I now also get notifications from other Zulips I'm on like the OCaml or Lean).

The vibe coded CLI can show really specific queries like my Zulip channels
The vibe coded CLI can show really specific queries like my Zulip channels

And then the really sketchy bit. I swapped API keys to have a read/write one (it could delete all my email in theory), and then -- while only mildly sweating -- ran the timeout command. I did add a dry-run mode so I could see the query first before I let it rip.

Running AI against my live email with a vibe coded client is not how I imagined 2025 to go.
Running AI against my live email with a vibe coded client is not how I imagined 2025 to go.

And et voila, it added the right keywords and marked things as unread, and my live email view in Fastmail now allows for keyword searches that show me exactly what I want.

I'm not sure what the limit on keywords is, but Fastmails search interface is great
I'm not sure what the limit on keywords is, but Fastmails search interface is great

Reflections

Today's been great; I managed to find a papercut that's been bugging me for years, and code up something quickly that genuinely makes my personal workflows a little bit better. I used to do this a lot before the pandemic, but somehow the shift to online services has really damaged my ability to program my own digital life.

I feel back in control again; although ocaml-jmap should be used by anyone else with extreme caution, I'm rigging up some ZFS snapshots of my own email so I can code up a few hundred custom agents over things and see how it goes. But mostly, I'm happy to have found a glimmer of the Resonant Computing manifesto through the medium of strong OCaml types, self hosted services and agentic coding glue:

Dedicated: Software should work exclusively for you, ensuring contextual integrity where data use aligns with your expectations. You must be able to trust there are no hidden agendas or conflicting interests. -- Resonant Computing Manifesto, 2025

I'm going to work on wrestling GitHub and Netdata under control next. And what about the other side of the Zulip notifications? Well, I'll knock up a Zulip bot in OCaml tomorrow, so stay tuned for that!


Loading recent items...