From a3e5cf42938c3ebe8cfdc8207fe70c2cb3b678a3 Mon Sep 17 00:00:00 2001 From: Scott Wright Date: Tue, 17 Sep 2024 09:44:28 -0400 Subject: [PATCH] Ruby Implementation of JMAP-Samples --- ruby/hello_world.rb | 60 +++++++++++++++++++++++++ ruby/tiny_jmap_client.rb | 96 ++++++++++++++++++++++++++++++++++++++++ ruby/top-ten.rb | 38 ++++++++++++++++ 3 files changed, 194 insertions(+) create mode 100755 ruby/hello_world.rb create mode 100644 ruby/tiny_jmap_client.rb create mode 100755 ruby/top-ten.rb diff --git a/ruby/hello_world.rb b/ruby/hello_world.rb new file mode 100755 index 0000000..1ca9e3b --- /dev/null +++ b/ruby/hello_world.rb @@ -0,0 +1,60 @@ +#!/usr/bin/env ruby + +require "./tiny_jmap_client" + +# Set up our client from the environment and set our account ID +client = TinyJMAPClient.new(ENV["JMAP_HOSTNAME"] || "api.fastmail.com", ENV["JMAP_USERNAME"], ENV["JMAP_TOKEN"]) + +account_id = client.account_id + +# Here, we're going to find our drafts mailbox, by calling Mailbox/query +query_res = client.jmap_call( + [["Mailbox/query", {accountId: account_id, filter: {name: "Drafts"}}, "a"]], + ["urn:ietf:params:jmap:mail"] +) + +# Pull out the id from the list response, and make sure we got it + +draft_mailbox_id = query_res["methodResponses"][0][1]["ids"][0] + +# Great! Now we're going to set up the data for the email we're going to send. +body = <<~EOF + Hi! + + This email may not look like much, but I sent it with JMAP, a protocol + designed to make it easier to manage email, contacts, calendars, and more of + your digital life in general. + + Pretty cool, right? + + -- + This email sent from my next-generation email system at Fastmail. +EOF + +draft = { + from: [{email: client.username}], + to: [{email: client.username}], + subject: "Hello, world!", + keywords: {"$draft": true}, + mailboxIds: {draft_mailbox_id => true}, + bodyValues: {body: {value: body, charset: "utf-8"}}, + textBody: [{partId: "body", type: "text/plain"}] +} + +identity_id = client.identity_id + +# Here, we make two calls in a single request. The first is an Email/set, to +# set our draft in our drafts folder, and the second is an +# EmailSubmission/set, to actually send the mail to ourselves. This requires +# an additional capability for submission. +method_calls = [] +method_calls << ["Email/set", {accountId: account_id, create: {draft: draft}}, "a"] +method_calls << ["EmailSubmission/set", + { + accountId: account_id, + onSuccessDestroyEmail: ["#sendIt"], + create: {sendIt: {emailId: "#draft", identityId: identity_id}} + }, + "b"] +create_res = client.jmap_call(method_calls, ["urn:ietf:params:jmap:mail", "urn:ietf:params:jmap:submission"]) +puts(create_res) diff --git a/ruby/tiny_jmap_client.rb b/ruby/tiny_jmap_client.rb new file mode 100644 index 0000000..c2a739c --- /dev/null +++ b/ruby/tiny_jmap_client.rb @@ -0,0 +1,96 @@ +require "json" +require "net/http" +require "net/https" + +class TinyJMAPClient + attr_reader :username, :hostname, :token + def initialize(hostname, username, token) + if hostname.nil? || username.nil? || token.nil? || hostname.empty? || username.empty? || token.empty? + raise ArgumentError("hostname, username, and token must be supplied as non nil, non empty strings") + end + + @hostname = hostname + @username = username + @token = token + end + + def session + @session ||= fetch_session("https://#{@hostname}/.well-known/jmap") + end + + def api_url + session["apiUrl"] + end + + def account_id + # Return the accountId for the account matching self.username + @account_id ||= session["primaryAccounts"]["urn:ietf:params:jmap:mail"] + end + + def identity_res + @identity_res ||= jmap_call([["Identity/get", {accountId: account_id}, "i"]], ["urn:ietf:params:jmap:submission"]) + end + + def all_identity_ids + identity_res["methodResponses"][0][1]["list"].map { |user| {email: user["email"], id: user["id"]} } + end + + def identity_id(email = nil) + # Return the identityId for an address matching @username or email if supplied + if email.nil? + email = @username + end + acct = identity_res["methodResponses"][0][1]["list"].select { |user| user["email"].downcase == email.downcase } + if acct.any? + acct[0]["id"] + end + end + + def jmap_call(method_calls, using = []) + if !using.include?("urn:ietf:params:jmap:core") + using.unshift("urn:ietf:params:jmap:core") + end + uri = URI(api_url) + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = true + http.verify_mode = OpenSSL::SSL::VERIFY_PEER + body = JSON.dump({using: using, methodCalls: method_calls}) + req = Net::HTTP::Post.new(uri) + req.add_field "Authorization", "Bearer #{@token}" + req.add_field "Content-Type", "application/json" + req.body = body + resp = http.request(req) + JSON.parse(resp.body) + rescue => e + puts "#{e.class}: #{e.message}" + if defined?(resp) + puts "Response code: #{resp.code}" + puts resp.body + end + end + + private + + def fetch_session(uri_str, limit = 10) + # This was extracted to its own method because net:http doesn't have a built in mechanism for handling redirects + # There are more user friendly http clients for Ruby, but I am using net:http to minimize external dependencies + if limit < 1 + raise StandardError.new("Too many redirects") + end + uri = URI(uri_str) + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = true + http.verify_mode = OpenSSL::SSL::VERIFY_PEER + req = Net::HTTP::Get.new(uri) + req.add_field "Authorization", "Bearer #{@token}" + req.add_field "Content-Type", "application/json" + resp = http.request(req) + if resp["location"] + fetch_session(resp["location"], limit - 1) + elsif resp.code == "200" + JSON.parse(resp.body) + else + raise StandardError.new(resp.msg) + end + end +end diff --git a/ruby/top-ten.rb b/ruby/top-ten.rb new file mode 100755 index 0000000..c9abb78 --- /dev/null +++ b/ruby/top-ten.rb @@ -0,0 +1,38 @@ +#!/usr/bin/env ruby + +require "./tiny_jmap_client" + +client = TinyJMAPClient.new(ENV["JMAP_HOSTNAME"] || "api.fastmail.com", ENV["JMAP_USERNAME"], ENV["JMAP_TOKEN"]) +account_id = client.account_id + +inbox_res = client.jmap_call( + [["Mailbox/query", {accountId: account_id, filter: {role: "inbox", hasAnyRole: true}}, "a"]], + ["urn:ietf:params:jmap:mail"] +) +inbox_id = inbox_res["methodResponses"][0][1]["ids"][0] + +get_res = client.jmap_call( + [ + [ + "Email/query", + { + accountId: account_id, + filter: {inMailbox: inbox_id}, + sort: [{property: "receivedAt", isAscending: false}], limit: 10 + }, + "a" + ], + [ + "Email/get", + { + accountId: account_id, + properties: ["id", "subject", "receivedAt"], + "#ids": {resultOf: "a", name: "Email/query", path: "/ids/*"} + }, + "b" + ] + ], + ["urn:ietf:params:jmap:mail"] +) + +get_res["methodResponses"][1][1]["list"].each { |email| puts "#{email["receivedAt"]} - #{email["subject"]}" }