├── .gitignore ├── LICENSE ├── javascript ├── hello-world.js ├── package-lock.json ├── package.json └── top-ten.js ├── lua ├── README.md ├── hello_world.lua ├── tiny_jmap.lua └── top_ten.lua ├── perl5 ├── hello-world └── top-ten └── python3 ├── hello-world.py ├── requirements.txt ├── tiny_jmap_library.py └── top-ten.py /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | __pycache__ 3 | *.pyc 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Fastmail 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /javascript/hello-world.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | // bail if we don't have our ENV set: 3 | if (!process.env.JMAP_USERNAME || !process.env.JMAP_TOKEN) { 4 | console.log("Please set your JMAP_USERNAME and JMAP_TOKEN"); 5 | console.log("JMAP_USERNAME=username JMAP_TOKEN=token node hello-world.js"); 6 | 7 | process.exit(1); 8 | } 9 | 10 | const hostname = process.env.JMAP_HOSTNAME || "api.fastmail.com"; 11 | const username = process.env.JMAP_USERNAME; 12 | 13 | const authUrl = `https://${hostname}/.well-known/jmap`; 14 | const headers = { 15 | "Content-Type": "application/json", 16 | Authorization: `Bearer ${process.env.JMAP_TOKEN}`, 17 | }; 18 | 19 | const getSession = async () => { 20 | const response = await fetch(authUrl, { 21 | method: "GET", 22 | headers, 23 | }); 24 | return response.json(); 25 | }; 26 | 27 | const mailboxQuery = async (apiUrl, accountId) => { 28 | const response = await fetch(apiUrl, { 29 | method: "POST", 30 | headers, 31 | body: JSON.stringify({ 32 | using: ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"], 33 | methodCalls: [ 34 | ["Mailbox/query", { accountId, filter: { name: "Drafts" } }, "a"], 35 | ], 36 | }), 37 | }); 38 | const data = await response.json(); 39 | 40 | return await data["methodResponses"][0][1].ids[0]; 41 | }; 42 | 43 | const identityQuery = async (apiUrl, accountId) => { 44 | const response = await fetch(apiUrl, { 45 | method: "POST", 46 | headers, 47 | body: JSON.stringify({ 48 | using: [ 49 | "urn:ietf:params:jmap:core", 50 | "urn:ietf:params:jmap:mail", 51 | "urn:ietf:params:jmap:submission", 52 | ], 53 | methodCalls: [["Identity/get", { accountId, ids: null }, "a"]], 54 | }), 55 | }); 56 | const data = await response.json(); 57 | 58 | return await data["methodResponses"][0][1].list.filter( 59 | (identity) => identity.email === username 60 | )[0].id; 61 | }; 62 | 63 | const draftResponse = async (apiUrl, accountId, draftId, identityId) => { 64 | const messageBody = 65 | "Hi! \n\n" + 66 | "This email may not look like much, but I sent it with JMAP, a protocol \n" + 67 | "designed to make it easier to manage email, contacts, calendars, and more of \n" + 68 | "your digital life in general. \n\n" + 69 | "Pretty cool, right? \n\n" + 70 | "-- \n" + 71 | "This email sent from my next-generation email system at Fastmail. \n"; 72 | 73 | const draftObject = { 74 | from: [{ email: username }], 75 | to: [{ email: username }], 76 | subject: "Hello, world!", 77 | keywords: { $draft: true }, 78 | mailboxIds: { [draftId]: true }, 79 | bodyValues: { body: { value: messageBody, charset: "utf-8" } }, 80 | textBody: [{ partId: "body", type: "text/plain" }], 81 | }; 82 | 83 | const response = await fetch(apiUrl, { 84 | method: "POST", 85 | headers, 86 | body: JSON.stringify({ 87 | using: [ 88 | "urn:ietf:params:jmap:core", 89 | "urn:ietf:params:jmap:mail", 90 | "urn:ietf:params:jmap:submission", 91 | ], 92 | methodCalls: [ 93 | ["Email/set", { accountId, create: { draft: draftObject } }, "a"], 94 | [ 95 | "EmailSubmission/set", 96 | { 97 | accountId, 98 | onSuccessDestroyEmail: ["#sendIt"], 99 | create: { sendIt: { emailId: "#draft", identityId } }, 100 | }, 101 | "b", 102 | ], 103 | ], 104 | }), 105 | }); 106 | 107 | const data = await response.json(); 108 | console.log(JSON.stringify(data, null, 2)); 109 | }; 110 | 111 | const run = async () => { 112 | const session = await getSession(); 113 | const apiUrl = session.apiUrl; 114 | const accountId = session.primaryAccounts["urn:ietf:params:jmap:mail"]; 115 | const draftId = await mailboxQuery(apiUrl, accountId); 116 | const identityId = await identityQuery(apiUrl, accountId); 117 | draftResponse(apiUrl, accountId, draftId, identityId); 118 | }; 119 | 120 | run(); 121 | -------------------------------------------------------------------------------- /javascript/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jmap-samples", 3 | "version": "1.0.0", 4 | "lockfileVersion": 2, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "jmap-samples", 9 | "version": "1.0.0" 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /javascript/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jmap-samples", 3 | "version": "1.0.0", 4 | "description": "JMAP samples in javascript", 5 | "main": "hello-world.js", 6 | "engines": { 7 | "node": ">=18.0.0" 8 | }, 9 | "scripts": { 10 | "test": "echo \"Error: no test specified\" && exit 1" 11 | }, 12 | "author": "", 13 | "dependencies": {} 14 | } 15 | -------------------------------------------------------------------------------- /javascript/top-ten.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | // bail if we don't have our ENV set: 3 | if (!process.env.JMAP_USERNAME || !process.env.JMAP_TOKEN) { 4 | console.log("Please set your JMAP_USERNAME and JMAP_TOKEN"); 5 | console.log("JMAP_USERNAME=username JMAP_TOKEN=token node hello-world.js"); 6 | 7 | process.exit(1); 8 | } 9 | 10 | const hostname = process.env.JMAP_HOSTNAME || "api.fastmail.com"; 11 | const username = process.env.JMAP_USERNAME; 12 | 13 | const authUrl = `https://${hostname}/.well-known/jmap`; 14 | const headers = { 15 | "Content-Type": "application/json", 16 | Authorization: `Bearer ${process.env.JMAP_TOKEN}`, 17 | }; 18 | 19 | const getSession = async () => { 20 | const response = await fetch(authUrl, { 21 | method: "GET", 22 | headers, 23 | }); 24 | return response.json(); 25 | }; 26 | 27 | const inboxIdQuery = async (api_url, account_id) => { 28 | const response = await fetch(api_url, { 29 | method: "POST", 30 | headers, 31 | body: JSON.stringify({ 32 | using: ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"], 33 | methodCalls: [ 34 | [ 35 | "Mailbox/query", 36 | { 37 | accountId: account_id, 38 | filter: { role: "inbox", hasAnyRole: true }, 39 | }, 40 | "a", 41 | ], 42 | ], 43 | }), 44 | }); 45 | 46 | const data = await response.json(); 47 | 48 | const inbox_id = data["methodResponses"][0][1]["ids"][0]; 49 | 50 | if (!inbox_id.length) { 51 | console.error("Could not get an inbox."); 52 | process.exit(1); 53 | } 54 | 55 | return await inbox_id; 56 | }; 57 | 58 | const mailboxQuery = async (api_url, account_id, inbox_id) => { 59 | const response = await fetch(api_url, { 60 | method: "POST", 61 | headers, 62 | body: JSON.stringify({ 63 | using: ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"], 64 | methodCalls: [ 65 | [ 66 | "Email/query", 67 | { 68 | accountId: account_id, 69 | filter: { inMailbox: inbox_id }, 70 | sort: [{ property: "receivedAt", isAscending: false }], 71 | limit: 10, 72 | }, 73 | "a", 74 | ], 75 | [ 76 | "Email/get", 77 | { 78 | accountId: account_id, 79 | properties: ["id", "subject", "receivedAt"], 80 | "#ids": { 81 | resultOf: "a", 82 | name: "Email/query", 83 | path: "/ids/*", 84 | }, 85 | }, 86 | "b", 87 | ], 88 | ], 89 | }), 90 | }); 91 | 92 | const data = await response.json(); 93 | 94 | return await data; 95 | }; 96 | 97 | getSession().then((session) => { 98 | const api_url = session.apiUrl; 99 | const account_id = session.primaryAccounts["urn:ietf:params:jmap:mail"]; 100 | inboxIdQuery(api_url, account_id).then((inbox_id) => { 101 | mailboxQuery(api_url, account_id, inbox_id).then((emails) => { 102 | emails["methodResponses"][1][1]["list"].forEach((email) => { 103 | console.log(`${email.receivedAt} — ${email.subject}`); 104 | }); 105 | }); 106 | }); 107 | }); 108 | -------------------------------------------------------------------------------- /lua/README.md: -------------------------------------------------------------------------------- 1 | Needs the following packages via luarocks: 2 | 3 | - http 4 | - lua-cjson 5 | - basexx 6 | - serpent 7 | 8 | $ JMAP_USERNAME=you@fastmail.com JMAP_TOKEN=xxx lua top_ten.lua 9 | -------------------------------------------------------------------------------- /lua/hello_world.lua: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env lua 2 | 3 | local tiny_jmap = require("tiny_jmap") 4 | 5 | -- Set up our client from the environment and set our account ID 6 | local client = tiny_jmap.new({ 7 | hostname = os.getenv("JMAP_HOSTNAME") or "api.fastmail.com", 8 | username = os.getenv("JMAP_USERNAME"), 9 | token = os.getenv("JMAP_TOKEN"), 10 | }) 11 | local account_id = client:get_account_id() 12 | 13 | -- Here, we're going to find our drafts mailbox, by calling Mailbox/query 14 | local query_res = client:make_jmap_call( 15 | { 16 | using = { 17 | "urn:ietf:params:jmap:core", 18 | "urn:ietf:params:jmap:mail", 19 | }, 20 | methodCalls = { 21 | { 22 | "Mailbox/query", 23 | { 24 | accountId = account_id, 25 | filter = { 26 | name = "Drafts", 27 | }, 28 | }, 29 | "a", 30 | }, 31 | }, 32 | } 33 | ) 34 | 35 | -- Pull out the id from the list response, and make sure we got it 36 | local draft_mailbox_id = query_res.methodResponses[1][2].ids[1] 37 | assert(draft_mailbox_id) 38 | 39 | -- Great! Now we're going to set up the data for the email we're going to send. 40 | local body = [[ 41 | Hi! 42 | 43 | This email may not look like much, but I sent it with JMAP, a protocol 44 | designed to make it easier to manage email, contacts, calendars, and more of 45 | your digital life in general. 46 | 47 | Pretty cool, right? 48 | 49 | -- 50 | This email sent from my next-generation email system at Fastmail. 51 | ]] 52 | 53 | local draft = { 54 | from = {{ email = client.username }}, 55 | to = {{ email = client.username }}, 56 | subject = "Hello, world!", 57 | keywords = {}, 58 | mailboxIds = {}, 59 | bodyValues = { body = { value = body, charset = "utf-8" } }, 60 | textBody = {{ partId = "body", type = "text/plain" }}, 61 | } 62 | draft.keywords["$draft"] = true 63 | draft.mailboxIds[draft_mailbox_id] = true 64 | 65 | -- We also need to build a message envelope, which means knowing the id of the 66 | -- identity we are sending as. So we have to ask the server for those too. 67 | local ident_res = client:make_jmap_call( 68 | { 69 | using = { 70 | "urn:ietf:params:jmap:core", 71 | "urn:ietf:params:jmap:mail", 72 | "urn:ietf:params:jmap:submission", 73 | }, 74 | methodCalls = { 75 | { 76 | "Identity/get", 77 | { 78 | accountId = account_id, 79 | ids = nil, 80 | }, 81 | "a", 82 | }, 83 | }, 84 | } 85 | ) 86 | 87 | -- Pull out the id from the list response, and make sure we got it 88 | local identity_id 89 | for _,identity in ipairs(ident_res.methodResponses[1][2].list) do 90 | if identity.email == client.username then 91 | identity_id = identity.id 92 | end 93 | end 94 | assert(identity_id) 95 | 96 | -- Here, we make two calls in a single request. The first is an Email/set, to 97 | -- set our draft in our drafts folder, and the second is an 98 | -- EmailSubmission/set, to actually send the mail to ourselves. This requires 99 | -- an additional capability for submission. 100 | local create_res = client:make_jmap_call( 101 | { 102 | using = { 103 | "urn:ietf:params:jmap:core", 104 | "urn:ietf:params:jmap:mail", 105 | "urn:ietf:params:jmap:submission", 106 | }, 107 | methodCalls = { 108 | { 109 | "Email/set", 110 | { 111 | accountId = account_id, 112 | create = { 113 | draft = draft, 114 | }, 115 | }, 116 | "a", 117 | }, 118 | { 119 | "EmailSubmission/set", 120 | { 121 | accountId = account_id, 122 | onSuccessDestroyEmail = {"#sendIt"}, 123 | create = { 124 | sendIt = { 125 | emailId = "#draft", 126 | identityId = identity_id, 127 | }, 128 | }, 129 | }, 130 | "b", 131 | }, 132 | }, 133 | } 134 | ) 135 | 136 | local serpent = require("serpent") 137 | print(serpent.block(create_res)) 138 | -------------------------------------------------------------------------------- /lua/tiny_jmap.lua: -------------------------------------------------------------------------------- 1 | local request = require("http.request") 2 | local cjson = require("cjson") 3 | 4 | local tiny_jmap = {} 5 | 6 | function tiny_jmap.new (args) 7 | local self = {} 8 | self.hostname = args.hostname 9 | self.username = args.username 10 | self.authorization = "Bearer "..args.token 11 | setmetatable(self, {__index = tiny_jmap}) 12 | return self 13 | end 14 | 15 | function tiny_jmap:get_session () 16 | if self.session then return self.session end 17 | 18 | local req = request.new_from_uri("https://"..self.hostname.."/.well-known/jmap") 19 | req.headers:append("Authorization", self.authorization) 20 | local headers, stream = assert(req:go()) 21 | local body = assert(stream:get_body_as_string()) 22 | if headers:get ":status" ~= "200" then 23 | error(body) 24 | end 25 | 26 | self.session = cjson.decode(body) 27 | return self.session 28 | end 29 | 30 | function tiny_jmap:get_account_id () 31 | if self.account_id then return self.account_id end 32 | 33 | local session = self:get_session() 34 | 35 | self.account_id = session.primaryAccounts["urn:ietf:params:jmap:mail"] 36 | return self.account_id 37 | end 38 | 39 | function tiny_jmap:make_jmap_call (call) 40 | local session = self:get_session(); 41 | 42 | local req = request.new_from_uri(session.apiUrl) 43 | req.headers:append("Authorization", self.authorization) 44 | req.headers:upsert("Content-Type", "application/json") 45 | req.headers:upsert(":method", "POST") 46 | req:set_body(cjson.encode(call)) 47 | local headers, stream = assert(req:go()) 48 | local body = assert(stream:get_body_as_string()) 49 | if headers:get ":status" ~= "200" then 50 | error(body) 51 | end 52 | return cjson.decode(body) 53 | end 54 | 55 | return tiny_jmap 56 | -------------------------------------------------------------------------------- /lua/top_ten.lua: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env lua 2 | 3 | local tiny_jmap = require("tiny_jmap") 4 | 5 | local client = tiny_jmap.new({ 6 | hostname = os.getenv("JMAP_HOSTNAME") or "api.fastmail.com", 7 | username = os.getenv("JMAP_USERNAME"), 8 | token = os.getenv("JMAP_TOKEN"), 9 | }) 10 | 11 | local account_id = client:get_account_id() 12 | 13 | local inbox_res = client:make_jmap_call( 14 | { 15 | using = { 16 | "urn:ietf:params:jmap:core", 17 | "urn:ietf:params:jmap:mail", 18 | }, 19 | methodCalls = { 20 | { 21 | "Mailbox/query", 22 | { 23 | accountId = account_id, 24 | filter = { role = "inbox", hasAnyRole = true }, 25 | }, 26 | "a", 27 | }, 28 | }, 29 | } 30 | ) 31 | local inbox_id = inbox_res.methodResponses[1][2].ids[1] 32 | assert(inbox_id) 33 | 34 | local get_res = client:make_jmap_call( 35 | { 36 | using = { 37 | "urn:ietf:params:jmap:core", 38 | "urn:ietf:params:jmap:mail", 39 | }, 40 | methodCalls = { 41 | { 42 | "Email/query", 43 | { 44 | accountId = account_id, 45 | filter = { inMailbox = inbox_id }, 46 | sort = { 47 | { 48 | property = "receivedAt", 49 | isAscending = false, 50 | }, 51 | }, 52 | limit = 10, 53 | }, 54 | "a", 55 | }, 56 | { 57 | "Email/get", 58 | { 59 | accountId = account_id, 60 | properties = {"id", "subject", "receivedAt"}, 61 | ["#ids"] = { 62 | resultOf = "a", 63 | name = "Email/query", 64 | path = "/ids/*", 65 | }, 66 | }, 67 | "b", 68 | }, 69 | }, 70 | } 71 | ) 72 | 73 | for i,email in ipairs(get_res.methodResponses[2][2].list) do 74 | print(string.format("%s - %s", email.receivedAt, email.subject)) 75 | end 76 | -------------------------------------------------------------------------------- /perl5/hello-world: -------------------------------------------------------------------------------- 1 | #!perl 2 | use v5.24.0; 3 | use warnings; 4 | 5 | use JSON::MaybeXS; 6 | use LWP::UserAgent; 7 | use MIME::Base64; 8 | 9 | binmode *STDOUT, ':encoding(UTF-8)'; 10 | 11 | my $hostname = $ENV{JMAP_HOSTNAME} // 'api.fastmail.com'; 12 | my $username = $ENV{JMAP_USERNAME} // die "no JMAP_USERNAME set!\n"; 13 | my $token = $ENV{JMAP_TOKEN} // die "no JMAP_TOKEN set!\n"; 14 | my $auth_url = "https://$hostname/.well-known/jmap"; 15 | 16 | my $www = LWP::UserAgent->new; 17 | my $JSON = JSON::MaybeXS->new->utf8; 18 | 19 | sub get_session { 20 | my $res = $www->get( 21 | $auth_url, 22 | Authorization => "Bearer $token", 23 | ); 24 | 25 | return $JSON->decode($res->decoded_content); 26 | } 27 | 28 | my $session = get_session(); 29 | 30 | my $account_id = $session->{primaryAccounts}->{"urn:ietf:params:jmap:mail"}; 31 | my $api_url = $session->{apiUrl}; 32 | 33 | my $mbox_query = $www->post( 34 | $api_url, 35 | 'Content-Type' => 'application/json', 36 | Authorization => "Bearer $token", 37 | Content => encode_json({ 38 | using => [ 39 | "urn:ietf:params:jmap:core", 40 | "urn:ietf:params:jmap:mail", 41 | ], 42 | methodCalls => [ 43 | [ 44 | 'Mailbox/query', 45 | # I should use the role filter instead, but I believe there is a bug 46 | # in that filter at the time of writing. -- rjbs, 2019-10-18 47 | { accountId => $account_id, filter => { name => 'Drafts' } }, 48 | 'a' 49 | ], 50 | ], 51 | }), 52 | ); 53 | 54 | die "failed to Mailbox/query" unless $mbox_query->is_success; 55 | my $draft_mailbox_id = $JSON->decode($mbox_query->decoded_content) 56 | ->{methodResponses}[0][1]{ids}[0]; 57 | 58 | my $body = < [ { email => $username } ], 73 | to => [ { email => $username } ], 74 | subject => "Hello, world!", 75 | keywords => { '$draft' => JSON::MaybeXS::true }, 76 | mailboxIds => { $draft_mailbox_id => JSON::MaybeXS::true }, 77 | bodyValues => { body => { value => $body, charset => 'utf-8' } }, 78 | textBody => [ { partId => 'body', type => 'text/plain' } ], 79 | }; 80 | 81 | my $ident_res = $www->post( 82 | $api_url, 83 | 'Content-Type' => 'application/json', 84 | Authorization => "Bearer $token", 85 | Content => encode_json({ 86 | using => [ 87 | "urn:ietf:params:jmap:core", 88 | "urn:ietf:params:jmap:mail", 89 | "urn:ietf:params:jmap:submission", 90 | ], 91 | methodCalls => [ 92 | [ 'Identity/get', { 93 | accountId => $account_id, 94 | ids => undef, 95 | }, 96 | 'a', 97 | ], 98 | ], 99 | }), 100 | ); 101 | 102 | my $identities = $JSON->decode($ident_res->decoded_content); 103 | my ($identity) = grep {; $_->{email} eq $username } 104 | $identities->{methodResponses}[0][1]{list}->@*; 105 | 106 | die "could not find identity to use" unless $identity; 107 | 108 | my $res = $www->post( 109 | $api_url, 110 | 'Content-Type' => 'application/json', 111 | Authorization => "Bearer $token", 112 | Content => encode_json({ 113 | using => [ 114 | "urn:ietf:params:jmap:core", 115 | "urn:ietf:params:jmap:mail", 116 | "urn:ietf:params:jmap:submission", 117 | ], 118 | methodCalls => [ 119 | [ 'Email/set', { 120 | accountId => $account_id, 121 | create => { draft => $draft } 122 | }, 123 | 'a', 124 | ], 125 | [ 'EmailSubmission/set', { 126 | accountId => $account_id, 127 | onSuccessDestroyEmail => [ '#sendIt' ], 128 | create => { 129 | sendIt => { 130 | emailId => '#draft', 131 | identityId => $identity->{id}, 132 | }, 133 | }, 134 | }, 135 | 'b', 136 | ], 137 | ], 138 | }), 139 | ); 140 | 141 | print JSON::MaybeXS->new->pretty->canonical->utf8->encode( 142 | $JSON->decode($res->decoded_content) 143 | ); 144 | -------------------------------------------------------------------------------- /perl5/top-ten: -------------------------------------------------------------------------------- 1 | #!perl 2 | use v5.24.0; 3 | use warnings; 4 | 5 | use JSON::MaybeXS; 6 | use LWP::UserAgent; 7 | use MIME::Base64; 8 | 9 | binmode *STDOUT, ':encoding(UTF-8)'; 10 | 11 | my $hostname = $ENV{JMAP_HOSTNAME} // 'api.fastmail.com'; 12 | my $token = $ENV{JMAP_TOKEN} // die "no JMAP_TOKEN set!\n"; 13 | my $auth_url = "https://$hostname/.well-known/jmap"; 14 | 15 | my $www = LWP::UserAgent->new; 16 | my $JSON = JSON::MaybeXS->new->utf8; 17 | 18 | sub get_session { 19 | my $res = $www->get( 20 | $auth_url, 21 | Authorization => "Bearer $token", 22 | ); 23 | 24 | return $JSON->decode($res->decoded_content); 25 | } 26 | 27 | my $session = get_session(); 28 | 29 | my $account_id = $session->{primaryAccounts}->{"urn:ietf:params:jmap:mail"}; 30 | my $api_url = $session->{apiUrl}; 31 | 32 | sub do_request { 33 | my (@method_calls) = @_; 34 | 35 | my $res = $www->post( 36 | $api_url, 37 | 'Content-Type' => 'application/json', 38 | Authorization => "Bearer $token", 39 | Content => encode_json({ 40 | using => [ "urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail" ], 41 | methodCalls => \@method_calls, 42 | }), 43 | ); 44 | 45 | die $res->as_string unless $res->is_success; 46 | 47 | return $JSON->decode($res->decoded_content)->{methodResponses}; 48 | } 49 | 50 | my $inbox_reply = do_request( 51 | [ 52 | 'Mailbox/query', 53 | { 54 | accountId => $account_id, 55 | filter => { role => 'inbox', hasAnyRole => \1 }, 56 | }, 57 | 'a', 58 | ] 59 | ); 60 | 61 | my $inbox_id = $inbox_reply->[0][1]{ids}[0]; 62 | 63 | die "no inbox!?" unless defined $inbox_id; 64 | 65 | my $mail_reply = do_request( 66 | [ 'Email/query', 67 | { 68 | accountId => $account_id, 69 | filter => { inMailbox => $inbox_id }, 70 | sort => [ { property => "receivedAt", isAscending => \0 } ], 71 | limit => 10, 72 | }, 73 | 'a', 74 | ], 75 | [ 'Email/get', 76 | { accountId => $account_id, 77 | properties => [ 'id', 'subject', 'receivedAt' ], 78 | '#ids' => { resultOf => 'a', name => 'Email/query', path => '/ids/*' }, 79 | }, 80 | 'b', 81 | ] 82 | ); 83 | 84 | for my $email ($mail_reply->[1][1]{list}->@*) { 85 | printf "%20s - %s\n", $email->{receivedAt}, $email->{subject}; 86 | } 87 | -------------------------------------------------------------------------------- /python3/hello-world.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import pprint 4 | import os 5 | from tiny_jmap_library import TinyJMAPClient 6 | 7 | # Set up our client from the environment and set our account ID 8 | client = TinyJMAPClient( 9 | hostname=os.environ.get("JMAP_HOSTNAME", "api.fastmail.com"), 10 | username=os.environ.get("JMAP_USERNAME"), 11 | token=os.environ.get("JMAP_TOKEN"), 12 | ) 13 | account_id = client.get_account_id() 14 | 15 | # Here, we're going to find our drafts mailbox, by calling Mailbox/query 16 | query_res = client.make_jmap_call( 17 | { 18 | "using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"], 19 | "methodCalls": [ 20 | [ 21 | "Mailbox/query", 22 | {"accountId": account_id, "filter": {"name": "Drafts"}}, 23 | "a", 24 | ] 25 | ], 26 | } 27 | ) 28 | 29 | # Pull out the id from the list response, and make sure we got it 30 | draft_mailbox_id = query_res["methodResponses"][0][1]["ids"][0] 31 | assert len(draft_mailbox_id) > 0 32 | 33 | # Great! Now we're going to set up the data for the email we're going to send. 34 | body = """ 35 | Hi! 36 | 37 | This email may not look like much, but I sent it with JMAP, a protocol 38 | designed to make it easier to manage email, contacts, calendars, and more of 39 | your digital life in general. 40 | 41 | Pretty cool, right? 42 | 43 | -- 44 | This email sent from my next-generation email system at Fastmail. 45 | """ 46 | 47 | draft = { 48 | "from": [{"email": client.username}], 49 | "to": [{"email": client.username}], 50 | "subject": "Hello, world!", 51 | "keywords": {"$draft": True}, 52 | "mailboxIds": {draft_mailbox_id: True}, 53 | "bodyValues": {"body": {"value": body, "charset": "utf-8"}}, 54 | "textBody": [{"partId": "body", "type": "text/plain"}], 55 | } 56 | 57 | identity_id = client.get_identity_id() 58 | 59 | # Here, we make two calls in a single request. The first is an Email/set, to 60 | # set our draft in our drafts folder, and the second is an 61 | # EmailSubmission/set, to actually send the mail to ourselves. This requires 62 | # an additional capability for submission. 63 | create_res = client.make_jmap_call( 64 | { 65 | "using": [ 66 | "urn:ietf:params:jmap:core", 67 | "urn:ietf:params:jmap:mail", 68 | "urn:ietf:params:jmap:submission", 69 | ], 70 | "methodCalls": [ 71 | ["Email/set", {"accountId": account_id, "create": {"draft": draft}}, "a"], 72 | [ 73 | "EmailSubmission/set", 74 | { 75 | "accountId": account_id, 76 | "onSuccessDestroyEmail": ["#sendIt"], 77 | "create": { 78 | "sendIt": { 79 | "emailId": "#draft", 80 | "identityId": identity_id, 81 | } 82 | }, 83 | }, 84 | "b", 85 | ], 86 | ], 87 | } 88 | ) 89 | 90 | pprint.pprint(create_res) 91 | -------------------------------------------------------------------------------- /python3/requirements.txt: -------------------------------------------------------------------------------- 1 | requests==2.32.4 2 | -------------------------------------------------------------------------------- /python3/tiny_jmap_library.py: -------------------------------------------------------------------------------- 1 | import json 2 | import requests 3 | 4 | 5 | class TinyJMAPClient: 6 | """The tiniest JMAP client you can imagine.""" 7 | 8 | def __init__(self, hostname, username, token): 9 | """Initialize using a hostname, username and bearer token""" 10 | assert len(hostname) > 0 11 | assert len(username) > 0 12 | assert len(token) > 0 13 | 14 | self.hostname = hostname 15 | self.username = username 16 | self.token = token 17 | self.session = None 18 | self.api_url = None 19 | self.account_id = None 20 | self.identity_id = None 21 | 22 | def get_session(self): 23 | """Return the JMAP Session Resource as a Python dict""" 24 | if self.session: 25 | return self.session 26 | r = requests.get( 27 | "https://" + self.hostname + "/.well-known/jmap", 28 | headers={ 29 | "Content-Type": "application/json", 30 | "Authorization": f"Bearer {self.token}", 31 | }, 32 | ) 33 | r.raise_for_status() 34 | self.session = session = r.json() 35 | self.api_url = session["apiUrl"] 36 | return session 37 | 38 | def get_account_id(self): 39 | """Return the accountId for the account matching self.username""" 40 | if self.account_id: 41 | return self.account_id 42 | 43 | session = self.get_session() 44 | 45 | account_id = session["primaryAccounts"]["urn:ietf:params:jmap:mail"] 46 | self.account_id = account_id 47 | return account_id 48 | 49 | def get_identity_id(self): 50 | """Return the identityId for an address matching self.username""" 51 | if self.identity_id: 52 | return self.identity_id 53 | 54 | identity_res = self.make_jmap_call( 55 | { 56 | "using": [ 57 | "urn:ietf:params:jmap:core", 58 | "urn:ietf:params:jmap:submission", 59 | ], 60 | "methodCalls": [ 61 | ["Identity/get", {"accountId": self.get_account_id()}, "i"] 62 | ], 63 | } 64 | ) 65 | 66 | identity_id = next( 67 | filter( 68 | lambda i: i["email"] == self.username, 69 | identity_res["methodResponses"][0][1]["list"], 70 | ) 71 | )["id"] 72 | 73 | self.identity_id = str(identity_id) 74 | return self.identity_id 75 | 76 | def make_jmap_call(self, call): 77 | """Make a JMAP POST request to the API, returning the response as a 78 | Python data structure.""" 79 | res = requests.post( 80 | self.api_url, 81 | headers={ 82 | "Content-Type": "application/json", 83 | "Authorization": f"Bearer {self.token}", 84 | }, 85 | data=json.dumps(call), 86 | ) 87 | res.raise_for_status() 88 | return res.json() 89 | -------------------------------------------------------------------------------- /python3/top-ten.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import json 4 | import os 5 | from tiny_jmap_library import TinyJMAPClient 6 | 7 | client = TinyJMAPClient( 8 | hostname=os.environ.get("JMAP_HOSTNAME", "api.fastmail.com"), 9 | username=os.environ.get("JMAP_USERNAME"), 10 | token=os.environ.get("JMAP_TOKEN"), 11 | ) 12 | account_id = client.get_account_id() 13 | 14 | inbox_res = client.make_jmap_call( 15 | { 16 | "using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"], 17 | "methodCalls": [ 18 | [ 19 | "Mailbox/query", 20 | { 21 | "accountId": account_id, 22 | "filter": {"role": "inbox", "hasAnyRole": True}, 23 | }, 24 | "a", 25 | ] 26 | ], 27 | } 28 | ) 29 | inbox_id = inbox_res["methodResponses"][0][1]["ids"][0] 30 | assert len(inbox_id) > 0 31 | 32 | get_res = client.make_jmap_call( 33 | { 34 | "using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"], 35 | "methodCalls": [ 36 | [ 37 | "Email/query", 38 | { 39 | "accountId": account_id, 40 | "filter": {"inMailbox": inbox_id}, 41 | "sort": [{"property": "receivedAt", "isAscending": False}], 42 | "limit": 10, 43 | }, 44 | "a", 45 | ], 46 | [ 47 | "Email/get", 48 | { 49 | "accountId": account_id, 50 | "properties": ["id", "subject", "receivedAt"], 51 | "#ids": {"resultOf": "a", "name": "Email/query", "path": "/ids/*"}, 52 | }, 53 | "b", 54 | ], 55 | ], 56 | } 57 | ) 58 | 59 | for email in get_res["methodResponses"][1][1]["list"]: 60 | print("{} - {}".format(email["receivedAt"], email["subject"])) 61 | --------------------------------------------------------------------------------