StalwartにおけるJMAP over WebSocketを用いたメールクライアントとメールサーバー間のデータ同期

at
    Tags:
  • Mail
  • JMAP
  • WebSocket
  • プロダクト開発グループ

こんにちは!研究開発エンジニアの森田(@tascript)です。健康づくりの一環として、1か月に1回ほど歯科健診に行っています。歯がきれいだと気持ちも明るくなりますし、そろそろ最強の歯磨きセットを見つけたいなと思っています!

さて、前回のブログでもお伝えしたとおり、さくらインターネット研究所ではプロダクト開発グループを設けており、現在メールに関する研究およびWebメールを開発しています。

Webメールは、ユーザーがメールの状態を正確かつ迅速に認識できるようメールサーバーとメールクライアント間のデータ同期が重要です。例えば、新着メッセージをメールサーバーが受け取ったら、即座にメールクライアントに反映することで、ユーザーは新着メッセージが届いたことを認識できます。メールサーバーとメールクライアント間のデータ同期を実現するプロトコルとしてJMAP(Json Meta Application Protocol)を使用しており、メールサーバーにはJMAPをサポートしているStalwartを採用しました。

Stalwartはメールサーバーにおける状態変更をメールクライアントに通知する方法として、JMAP over WebSocketによる双方向通信、Push SubscriptionsおよびServer Sent Eventによるプッシュ通知を提供しています。私たちのプロジェクトは通知だけではなく、メールクライアントとメールサーバー間のデータ同期がオールインワンで可能なJMAP over WebSocketを採用しましたが、Stalwartのドキュメントには接続方法およびデータ同期方法に関する記載がありません。そこで今回は、JMAP関連のRFCを調査しつつ、JMAP over WebSocketを利用したメールクライアントとメールサーバー間のデータ同期する機能を実装しましたので、その実装方法について紹介します。

JMAPとは

JMAPはRFC8620にて定義されているクライアントとサーバー間でメール、カレンダー、連絡先などを同期するための汎用プロトコルです。JMAPを利用した電子メールデータの同期に必要なデータモデル(JMAP for Mail)についてはRFC8621にて定義されており、メッセージ、スレッドおよびメールボックスなどをJSONオブジェクトとして体系化することができ、メールの検索、読み取り、整理および送信を可能にします。IMAPと比較すると、必要なデータをクライアント側で組み立てるのではなく、メールサーバー側で構造化データとして取り扱うことができるので、より高速かつ容易にデータフェッチが可能です。

JMAP over WebSocketとは

JMAP over WebSocketはRFC 8887にて定義されているWebSocketサブプロトコルであり、WebSocket上でJMAP APIへのリクエスト、レスポンスおよびプッシュ通知を実施します。JMAP over WebSocketを利用すれば、リアルタイムでのメールクライアントとメールサーバー間の同期が実現可能です。

メールクライアントとメールサーバーの同期

この章では、JMAP over WebSocketを利用したメールクライアントとメールサーバーの同期を実現するために必要なフローおよび実装例について記載します。

シーケンス図

JMAP over WebSocketの接続からメールクライアントとメールサーバー間のデータ同期までのフローをシーケンス図にまとめます。

sequenceDiagram
    participant Client as Mail Client
    participant Proxy as Reverse Proxy
    participant Server as Stalwart

    Client->>Proxy: WebSocket 接続 (/jmap/ws/)
    Proxy->>Server: Authorization Header + Sec-WebSocket-Protocol Header (/jmap/ws/)
    Server->>Client: WebSocket 接続完了

    Note over Client,Server: 以降はJMAP over WebSocket

    Client->>Server: WebSocketPushEnable

    Note right of Server: プッシュ通知の有効化

    Client->>Server: Email/get { ids: [] }
    Server-->>Client: Email/get { state: 'a' }

    Client->>Client: 現在のサーバーの状態を記憶(a)

    Server->>Server: 新着メールによる状態変更(b)

    Server-->>Client: StateChange { Email: 'b', Mailbox, 'b' }

    Note left of Client: メールの変更を検知

    Client->>Server: Email/changes { sinceState: 'a' }
    Server-->>Client: Email/changes { created: [email-1] oldState: 'a' newState: 'b' }

    Client->>Client: 最新のサーバー状態を記憶(b)

    Note over Client, Server: 「StateChange を受け取ったら Email/changes にリクエスト」を繰り返す

ハンドシェイク

JMAP over WebSocketを実現するためにメールクライアントはHTTP上で接続ハンドシェイクを開始し、メールサーバーに対してWebSocketの接続を試みます。

また、StalwartはBasic認証とBearer認証の2つをサポートしています。しかし、ブラウザ上のWebSocketオブジェクトからはAuthorizaion Headerを設定することはできません。そのため、メールサーバーの前段にリバースプロキシを用意し、メールクライアントから送信された認証用トークンをAuthorizaion Headerに含める必要があります。認証用トークンの送信はURLクエリパラメータもしくはSec-WebSocket-Protocol Headerに含める方法があります。URLクエリパラメータに含める場合は、リバースプロキシのアクセスログおよびブラウザの履歴等に記録されてしまうリスクがあります。よって、Sec-WebSocket-Protocol Headerからサブプロトコルと認証トークンを指定し、リバースプロキシで認証トークンをSec-WebSocket-Protocol Headerから取得後、Authorization Headerを作成してメールサーバーにリクエストします。

ハンドシェイク用のリクエストは次のようなコードで実現します。

const authToken = '....'
const socket = new WebSocket('wss://mail.test/jmap/ws/', ['jmap', `token.{$authToken}`])

メールサーバーの前段にリバースプロキシを設置し、Sec-WebSocket-Protocol Headerに含めたtokenを取得してAuthorization Headerを生成します。今回はリバースプロキシとして、Caddyを採用したので、以下のようなCaddyfileを用意します。

https://mail.test {
    tls <cert_file> <key_file>
    
    @auth_token {
        header_regexp token Sec-WebSocket-Protocol token.(.+)
        path /jmap/ws/*
    }
    
    handle @auth_token {
        reverse_proxy [対象のメールサーバー] {
            header_up Authorization "Bearer {re.token.1}"
            header_up Sec-WebSocket-Protocol "jmap"
        }
    }
}

以上により、メールサーバーへのリクエストに対してSec-WebSocket-ProtocolにてサブプロトコルをJMAPに再設定し、Authorization HeaderにBearer Tokenを指定することができます。認証が成功すれば、通信はHTTPからWebSocketに切り替わります。なお、認証情報はWebSocketの接続期間中であれば再度認証を実施する必要はありません。

プッシュ通知の有効化

WebSocket接続が完了後、クライアントがメールサーバーからのプッシュ通知を受け取るためには、WebSocketPushEnableオブジェクトをメールサーバーに送信する必要があります。

プッシュ通知有効化のリクエストは以下のようなコードで実現します。

...

const webSocketPushEnableObject = {
  '@type': 'WebSocketPushEnable', 
  dataTypes: ['Mailbox', 'Email'] // プッシュ通知を受け取りたいデータ型一覧
}

// プッシュ通知有効化のリクエスト
socket.send(JSON.stringify(webSocketPushEnableObject))

WebSocket.send()メソッドを利用してメールボックスとメールに関する変更をプッシュ通知できるようになります。

クライアントの現在の状態(sinceState)を取得

JMAPではメールに関する状態をstateプロパティでバージョン管理システムのように管理します。stateプロパティはメールに関するJMAP APIのレスポンスから取得可能であり、不特定多数のメールクライアントからメールサーバーの状態を確認することが出来ます。つまりメールクライアントとメールサーバのデータ同期は、メールクライアントとメールサーバーでstateを共有することで達成します。

プッシュ通知を有効化したことで、以降はメールサーバーで発生した変更を受け取ることができるようになりました。メールサーバーの変更はstateChangeオブジェクトをWebSocket上でクライアントが受け取ることで検知可能です。このとき、メールサーバーはクライアントがどの時点以降の変更を通知してほしいのかわかりません。そのため、メールクライアントは現時点と比較したメールサーバーにおける状態変更を通知してほしいことをメールサーバーにリクエストする必要があります。その際に必要なパラメータがsinceStateです。

sinceStateは、stateChangeによって変更されたターゲットを確認する/changesメソッドで利用するプロパティであり、指定した状態以降の変化を受け取るために必要です。sinceStateは/getメソッドのレスポンスであるstateから取得することができるので、プッシュ通知の有効化されたら、Email/getメソッドを実施し、stateを取得してsinceStateにバインドします。これにより、メールクライアントが持っているstateをメールサーバに共有することができます。

また、Email/getメソッドはidsをnullで指定すると、メールサーバーに存在する全てのメールを取得しますが、これまでに一度もメールサーバーのデータをクライアントに同期してない場合に有効なので、sinceStateを取得する前に一度リクエストしておくとよいでしょう。全件のメールがメールクライアントに同期済みの場合かつsinceStateがない場合は、特定のメール情報を取得する必要がないので、Email/getメソッドに対してidsを空配列でリクエストします。

Email/getメソッドへのリクエストは以下のようなコードで実現します。

...

const pendingRequest = new MAP()
let sinceState = ''

const sendRequest = (ws, name, params, callId) => {
  return new Promise((resolve, reject) => {
    pendingRequest.set(callId, {resolve, reject})
    
    socket.send(JSON.stringify({
      '@type': 'Request',
      using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail']
      methodCalls: [name, params, callId]
    })

    setTimeoout(() => {
      if (pendingRequest.has(callId)) {
        pendingRequest.delete(callId)
        reject(new Error('JMAP Request timeout'))
      }
    }, 10000)
  })
}

const handleResponse = (message) => {
  for (const [name, data, callId] of message.methodResponses) {
    const pr = pendingRequest.get(callId)
    if (!pr) {
      continue
    }
    const {resolve} = pr
    pendingRequest.delete(callId)
    resolve(response)
  }
}

socket.onmessage = (event) => {
  const message = JSON.parse(event.data)

  switch(message['@type']) {
    case 'Response':
      handleReponse(message)
      break
    ...
  }
}

// Email/getメソッドへのリクエスト
const response = await sendRequest(socket, 'Email/get', {accountId: 'test', ids: []} 'email-get-1')
sinceState = response.state

メールサーバーにEmail/getメソッドのリクエストを送る関数にて、PromiseコンストラクターのresolveFuncMapオブジェクトに保存します。メールサーバーはリクエストに対してWebSocketを通じて、メールクライアントにレスポンスを返します。メールクライアントでは、WebSocketのmessageイベントにてメールサーバーからレスポンスを受け取り、resolveにラップして返すことで、Email/getメソッドの実行結果を取得します。

また、Email/getメソッドのレスポンスは以下のようになります。

[
  "Email/get",
  {
    "accountId": "test",
    "state": "a",
    "list": [],
    "notFound": []
  },
  "email-get-1"
]

stateプロパティは現在のメールサーバーの状態を表現する文字列です。stateプロパティはメールサーバーにて対象データの変更があると変化します。これをsessionStateに保存し、メールサーバーの状態変更を確認する/changesメソッドのリクエストに含めることで、メールクライアントとメールサーバーの状態を同期します。

メールサーバーの状態変更を検知する

メールサーバーにて状態変更が発生すると、StateChnageオブジェクトをメールクライアントに送信します。メールクライアントでは、WebSocketのmessageイベントにてこれを受け取ります。例えば、メールサーバーにてメールを受信した際には以下のようなStateChangeオブジェクトをメールクライントに送信します。

{
  "@type":"StateChange",
  "changed": {
    "test": {
      "Mailbox": "b",
      "Email": "b"
    }
  }
}

アカウント単位で対象データにおける最新のstateプロパティを返しており、Email/getリクエストで取得した際の値と異なる(aからbに変化)ことから、メールボックスとメールに対する変更が確認できます。メールクライアントでは、StateChangeオブジェクトをWebSocketのmessageイベントにて受け取ることができますので、続けてメールクライアントからEmail/changesメソッドに対してリクエストを実施し、どのメールに対してどのような変更があったかを確認します。

一連のフローは以下のようなコードで実現します。

...

const handleStateChange = (message) => {
  const response = await sendRequest(
    socket,
    'Email/changes',
    {
      accountId: 'test',
      sinceState // sinceStateは'a'
    },
    'email-changes-1'
  )
  ...
}

socket.onmessage = (event) => {
  const message = JSON.parse(event.data)

  switch(message['@type']) {
    case 'Response':
      handleReponse(message)
      break
    case 'StateChange':
      handleStateChange(message)
      break
    ...
  }
}

また、Email/changes APIは以下のようなレスポンスを返します。

{
  "@type":"Response",
  "methodResponses": [
    [
      "Email/changes",
      {
        "accountId": "test",
        "oldState": "a",
        "newState": "b",
        "hasMoreChanges": false,
        "created": ["email-1"],
        "updated": [],
        "destroyed": []
      },
      "email-changes-1"
    ]
  ],
  "sessionState": "session-state"
}

oldStateとnewStateの値がそれぞれEmail/getメソッドのレスポンスのstateプロパティ(a)とStateChangeオブジェクトにおける各データのstate(b)に一致していることから、メールに関してaからbまでの状態変更を検知したことが確認できます。また、createdプロパティにメールのidが含まれていることから、idがemail-1のメールを受信したことが確認できます。あとは必要に応じて、対象IDのメールをEmail/get APIから取得するなどしてメールクライアントとメールサーバー間でデータを同期します。

Stalwartが抱える課題

Stalwartの認証時、ブラウザ上のWebSocketオブジェクトでは、Authorization Headerを指定できないのでメールサーバーの前段にリバースプロキシを置いて対応しましたが、2026年1月にStalwartのdiscussionsにて対策が提案されています。

JMAP WebSocket Ticket-Based Authentication for Browser Clients · stalwartlabs/stalwart · Discussion #2680 · GitHub
Summary Browser-based JMAP clients cannot use WebSocket push notifications because browsers do not allow setting custom HTTP headers (like Authorization: Bearer) on WebSocket connections. This is a...
https://github.com/stalwartlabs/stalwart/discussions/2680

このdiscussionsでは先にAuthorizaion Header付きのHTTPリクエストで有効期限の短いチケットを受け取るエンドポイントを用意して、受け取ったチケットをWebSocketサーバーのURLにクエリパラメータとして含めて認証するという方法が提案されています。フォークされたリポジトリには実装済みであり、この方法だとリバースプロキシは不要になるのでより便利になるかもしれません。

JMAPは認証スキームに関して、IANA公開されているHTTP Authentication Scheme Registryを参考にするよう明示しており、上記の認証方法は含まれていないのでStalwartの独自実装になることが望ましいと考えています。

最後に

JMAPはIMAPと比較してパフォーマンスの観点において非常に優れたプロトコルですが、2026年4月現在、JMAPに関する実装は充足しているとは言えません。よって基本はRFCを読みながら実装することになります。もちろんRFCを読むことは大事ですが、より多くの実装や知見を増やすことで技術は汎用的になっていくと考えています。今後もJMAPおよびメールに関する情報共有を進めていきますので、ご覧いただけますと幸いです。

著者

森田 亘
森田 亘
研究開発エンジニア

2024年1月入社。Web系企業にてフロントエンドからバックエンドの開発に携わり、新規開発およびレガシーなプロダクトの改善を経験。

フロントエンドのパフォーマンス改善や、様々なJavaScriptのランタイムに興味がある。

入社後はフロントエンドを中心に新規プロダクトの研究、開発を担当。