Slide 1

Slide 1 text

Scala でクロスプラットフォームな SMTP クライアント を書く Lightning Talk@2025/03/14 Scalaわいわい勉強会 / by110416

Slide 2

Slide 2 text

WHO AM I GitHub: https://github.com/i10416 SpeakersDeck: https://speakerdeck.com/i10416

Slide 3

Slide 3 text

本日の資料はこちら https://speakerdeck.com/i10416/scala -dekurosupuratutohuomuna-smtp- kuraiantowoshu-ku

Slide 4

Slide 4 text

SMTP とは SMTP(Simple Mail Transfer Protocol)とは、メールの送受信や転送を行う通信プロト コルです。インターネットなどのTCP/IPネットワークで標準的に利用されています。 例えば gmail では、 smtp.gmail.com を使って SMTP クライアントからメールを送信でき ます。

Slide 5

Slide 5 text

SMTP のリクエストはコマンドとデータから構成されます。 HELO smtp.example.com MAIL FROM: RCPT TO: サーバーはリクエストに対して 250 OK のようにステータスコードとメッセージを含むレス ポンスで応答します。

Slide 6

Slide 6 text

SMTP の実装 (TLSを使う) SMTP クライアントは次のような手順でサーバーに接続してメールを送信しま す。 1. サーバーとソケット通信を開始 2. HELO コマンドでサーバー側がサポートする機能を確認 3. STARTTLS コマンドを送信し upgrade 4. HELO コマンドでサーバー側がサポートする機能を確認 5. [optional] AUTH LOGIN コマンドで認証 6. SMTP の仕様に従ってメールをシリアライズして送信 7. QUIT コマンドで接続を切る

Slide 7

Slide 7 text

NOTE SMTP は 7 bits のプロトコルなので、7 bits で表現できない特殊文字、日本語やバイナリファ イルなどのデータのシリアライゼーションには工夫が必要です。7 bits の制約を回避するには base64 でデータをエンコードする方法があります。 今回は時間の都合上この説明は省きます

Slide 8

Slide 8 text

Typelevel Stack でクロスプラットフォームなアプリケーションを書く 非同期ランタイム cats-effect と関数型ストリーミングライブラリ fs2(https://github.com/typelevel/fs2) を使うと簡単にクロスプラットフォーム対応した Scala コードを書けます。 また、バイナリコーデックのscodec や scodec-bits もクロスプラットフォームに対応してい るのでバイナリ形式でデータをやり取りするプロトコルを簡単に実装できます。

Slide 9

Slide 9 text

利用例 SMTP は行単位でテキストをやり取りするプロトコルですが、生のソケットに対して文字列 やバイナリ操作を行うと不正な操作の余地が生まれてしまうので、次の API でメールを送信 できるようにインターフェースを設計します。 val conn = SMTPConnection[IO]( host"smtp.gmail.com", Some(Credential(username, password)) ) conn.connect.use: mailer => mailer.send(Message.text(...))

Slide 10

Slide 10 text

SMTPConnection.scala object SMTPConnection: def apply[F[_]](...): SMTPConnection[F] = ??? trait SMTPConnection[F[_]]: def connect: Resource[F, Mailer[F]] Mailer.scala trait Mailer[F[_]]: def send(message: Message): F[Unit]

Slide 11

Slide 11 text

実装例 上のインターフェースの実装例です。 mkTransport はメールを受け付ける準備ができたソ ケットを返します。 object SMTPConnection extends TLSParametersPlatform { def apply[F[_]: Network: Concurrent]( smtpServer: Host, credential: Option[Credential] = None, port: Port = port"587" ): SMTPConnection[F] = new SMTPConnection[F] { def connect: Resource[F, Mailer[F]] = mkTransport(smtpServer, credential, port).flatMap(socket => // ...

Slide 12

Slide 12 text

mkTransport fs2.io.net.Network インスタンスを使ってソケット通信をはじめます。 ネットワーク通 信のように I/O を伴う処理はプラットフォーム依存になりがちですが、fs2 は JVM・JS・ Native の違いをうまく吸収してくれます。 SMTPSocket は Socket 型を扱いやすくするヘルパーです。 for { socket <- Network[F] .client(SocketAddress(smtpServer, port)) smtpSocket = SMTPSocket.from(socket) // ...

Slide 13

Slide 13 text

SMTPSocket fs2.io.net.Socket には Byte や fs2.Chunk[Byte] が書き込まれます。そのままでは 扱いにくいのでより型安全なヘルパーとして SMTPSocket を定義しています。 trait SMTPSocket[F[_]] { def check: F[Unit] def helo(domain: Host): F[Unit] def startTLS: F[Unit] def authn(credential: Credential.Password): F[Unit] // ...

Slide 14

Slide 14 text

mkTransport まずTLS で SMTPでメールを送信するために、サーバーに接続し TLS へ upgrade するように 要求します。 // ... _ <- ( // サーバーが SMTP をサポートしている場合は // グリーティング(code 220) を返します。 smtpSocket.check // HELO コマンドをサーバーに送ります *> smtpSocket.helo(smtpServer) // サーバーにTLS 接続を要求します *> smtpSocket.startTLS ).toResource // ...

Slide 15

Slide 15 text

mkTransport TLS に upgrade します。 TLS には暗号処理が必要なのでプラットフォームごとに実装が変わりますが、ここでも fs2 がよしなに抽象化してくれます。 // upgrade to TLS socket <- for { tlsCtx <- Network[F].tlsContext.system.toResource tlsSocket <- tlsCtx .clientBuilder(socket) .withParameters(startTLSParameters(smtpServer)) .build .map(SMTPSocket.from[F])

Slide 16

Slide 16 text

mkTransport TLS 前後でサーバーがサポートする機能が変わることがあるので再度 HELO コマンドを送り ます。また、必要に応じて認証をします。 // greet again _ <- tlsSocket.helo(smtpServer).toResource } yield tlsSocket _ <- (credential match { case None => F.unit case Some(credential: Credential.Password) => socket.authn(credential) case Some(credential: Credential.OAuth2Token) => socket.xoauth2(credential) }).toResource } yield socket

Slide 17

Slide 17 text

呼び出し側から send が並列に呼び出された際にソケットに送るデータが混線しないよう Mutex でガードします。 メールを送り終えたら QUIT コマンドで接続を切ります。 mkTransport(smtpServer, credential, port).flatMap(socket => Resource.make( for { mu <- Mutex[F] } yield new Mailer[F] { def send(message: Message): F[Unit] = mu.lock.surround { for { _ <- socket.mail(message.from.email) _ <- message.to.map(_.email).traverse(socket.rcpt) _ <- socket.data _ <- socket.send(message) } yield () } } )(_ => socket.quit.as(()))

Slide 18

Slide 18 text

Random Thought: クロスプラットフォームの何が嬉しいか クラウド環境だと JVM の制約が色々と辛いが、JS ランタイムをサポートできれば全てのク ラウドベンダーがサポートしていると言っても過言ではない。 小規模な API、Slack ボットやちょっとしたタスクの自動化など、小さなところから導入する ことができて嬉しい。

Slide 19

Slide 19 text

Random Thought Typelevel Stack を使うとクロスプラットフォームな実装を手軽にできるが、エコシステムは まだまだ発展途上 あるにはあるが... 例 http4s-grpc http4s-googleapis Smithy-based AWS SDK otel4s

Slide 20

Slide 20 text

Key Takeaways Typelevel Stack(e.g. cats-effect, fs2, scodec) を使うとクロスプラットフォームなコー ドを(ほとんど)意識しないで書ける F[_] で 副作用を分離して書くとシグニチャに副作用が明示的に現れるので嬉しい Pure Scala でクロスプラットフォームなエコシステムはまだまだ発展途上 コントリビューションチャンス

Slide 21

Slide 21 text

不完全ながら今回解説したコードは ↓ の gist にあります。 完全版はしばしお待ちを https://gist.github.com/i10416/fbbb1c0ad47f9c87f0084e1c74cf7fa0