//! JMAP (RFC 8620 and RFC 8621), as Fastmail serves it: the session //! that names the account and where requests go, the requests parcel //! sends, and what it makes of the replies. JMAP's mailboxes become the //! mailbox's labels: one with a role takes the place of Gmail's system //! label for it, the rest are user labels by their ids, and the Archive //! mailbox is no label, so mail only there is in All Mail. The $flagged //! keyword is Starred, and a thread is unread while one of its messages //! lacks $seen. Requests go through Http, on the event loop. const std = @import("std"); const Compose = @import("Compose.zig"); const Contacts = @import("Contacts.zig"); const Corpus = @import("Corpus.zig"); const Http = @import("Http.zig"); const Mailbox = @import("Mailbox.zig"); const cards = @import("cards.zig"); const html = @import("html.zig"); const ascii = std.ascii; const json = std.json; const mem = std.mem; const testing = std.testing; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const ArrayList = std.ArrayList; const Io = std.Io; const Label = Mailbox.Label; const Stringify = json.Stringify; const Writer = Io.Writer; const log = std.log.scoped(.jmap); const Jmap = @This(); http: *Http, /// The header each request carries: "authorization: Bearer …" for an /// API token, or Basic for a server that takes a password. authorization: []const u8, /// What the session resource names; empty until it is read. api_url: []const u8, download_url: []const u8, upload_url: []const u8, /// Where the server pushes what changes; empty if it does not. event_source_url: []const u8, account_id: []const u8, /// The most objects one get may name, as the server says. max_objects: u32, /// Whether the session offers submission, which sending needs. A /// read-only token's does not, and a request naming it is refused whole. can_submit: bool, /// Fastmail's session resource. pub const fastmail = "https://api.fastmail.com/jmap/session"; const core = "urn:ietf:params:jmap:core"; const mail_capability = "urn:ietf:params:jmap:mail"; const submission = "urn:ietf:params:jmap:submission"; const using = [_][]const u8{ core, mail_capability, submission }; /// What a thread's line in the list needs of each of its messages. pub const summary_properties = [_][]const u8{ "id", "threadId", "mailboxIds", "keywords", "from", "to", "cc", "subject", "preview", "receivedAt", }; /// What the reader needs of each message besides. RFC 8621 parses /// List-Unsubscribe, an RFC 2369 header, only as URLs, and Fastmail /// refuses it as text; List-Unsubscribe-Post goes raw, which any server /// takes. pub const full_properties = summary_properties ++ [_][]const u8{ "replyTo", "messageId", "references", "header:List-Unsubscribe:asURLs", "header:List-Unsubscribe-Post:asRaw", "bodyValues", "htmlBody", "textBody", "attachments", }; /// What search keeps of each message (`readCorpus`), its text parts' values /// asked for with `fetchTextBodyValues`. List-Id goes raw: Stalwart gives /// no text form of it, and `list:` wants only the id in its angles. pub const corpus_properties = [_][]const u8{ "id", "threadId", "receivedAt", "size", "from", "to", "cc", "bcc", "subject", "messageId", "textBody", "bodyValues", "header:List-Id", "header:Delivered-To:asText", "attachments", }; /// What compose needs of a draft kept on an earlier run to open it again. pub const draft_properties = [_][]const u8{ "threadId", "keywords", "to", "cc", "subject", "inReplyTo", "references", "bodyValues", "textBody", "attachments", }; pub fn init(http: *Http, authorization: []const u8) Jmap { return .{ .http = http, .authorization = authorization, .api_url = "", .download_url = "", .upload_url = "", .event_source_url = "", .account_id = "", .max_objects = 500, .can_submit = false, }; } /// The authorization header for `token`: a password, user:password, /// goes as Basic, anything else as a bearer token. pub fn authorizationFor(arena: Allocator, token: []const u8) ![]const u8 { if (mem.findScalar(u8, token, ':') == null) return mem.concat(arena, u8, &.{ "authorization: Bearer ", token }); const encoder = std.base64.standard.Encoder; const out = try arena.alloc(u8, "authorization: Basic ".len + encoder.calcSize(token.len)); @memcpy(out[0.."authorization: Basic ".len], "authorization: Basic "); _ = encoder.encode(out["authorization: Basic ".len..], token); return out; } /// Asks for the session resource at `url`; `readSession` reads it. pub fn askSession(self: Jmap, arena: Allocator, url: []const u8, context: *anyopaque, done: Http.Done) !void { const headers = try arena.dupe([]const u8, &.{self.authorization}); try self.http.send(.{ .method = .get, .url = url, .headers = headers, .body = null }, context, done); } /// Takes where requests go and the mail account from the session /// resource; `arena` keeps them. pub fn readSession(self: *Jmap, arena: Allocator, body: []const u8) !void { const Session = struct { apiUrl: []const u8, downloadUrl: []const u8, uploadUrl: []const u8, eventSourceUrl: []const u8 = "", primaryAccounts: json.ArrayHashMap([]const u8), capabilities: struct { @"urn:ietf:params:jmap:core": struct { maxObjectsInGet: u32 = 500 } = .{}, @"urn:ietf:params:jmap:submission": ?struct {} = null, } = .{}, }; const s = try parse(Session, arena, body); self.account_id = s.primaryAccounts.map.get(mail_capability) orelse return error.NoMailAccount; self.api_url = s.apiUrl; self.download_url = s.downloadUrl; self.upload_url = s.uploadUrl; self.event_source_url = s.eventSourceUrl; self.max_objects = @max(s.capabilities.@"urn:ietf:params:jmap:core".maxObjectsInGet, 10); self.can_submit = s.capabilities.@"urn:ietf:params:jmap:submission" != null; } /// A request being written: method calls, each with its arguments, the /// account first. `finish` sends it. pub const Request = struct { jmap: *const Jmap, out: Writer.Allocating, s: Stringify, pub fn init(r: *Request, jmap: *const Jmap, arena: Allocator) !void { r.* = .{ .jmap = jmap, .out = .init(arena), .s = undefined }; r.s = .{ .writer = &r.out.writer }; try r.s.beginObject(); try r.s.objectField("using"); try r.s.write(if (jmap.can_submit) &using else using[0..2]); try r.s.objectField("methodCalls"); try r.s.beginArray(); } /// Starts a call to `method`; its arguments follow, as fields, until /// `end` names the call, which its reply is known by. pub fn call(r: *Request, method: []const u8) !void { try r.s.beginArray(); try r.s.write(method); try r.s.beginObject(); try r.s.objectField("accountId"); try r.s.write(r.jmap.account_id); } pub fn end(r: *Request, id: []const u8) !void { try r.s.endObject(); try r.s.write(id); try r.s.endArray(); } pub fn field(r: *Request, name: []const u8, value: anytype) !void { try r.s.objectField(name); try r.s.write(value); } /// An argument taken from an earlier call's reply: `#name`, the /// values at `path` in the reply to `id`, a call of `method`. pub fn ref(r: *Request, name: []const u8, id: []const u8, method: []const u8, path: []const u8) !void { var buf: [32]u8 = undefined; try r.s.objectField(try mem.print(&buf, "#{s}", .{name})); try r.s.write(.{ .resultOf = id, .name = method, .path = path }); } /// The JSON written so far, closed. pub fn body(r: *Request) ![]const u8 { try r.s.endArray(); try r.s.endObject(); return r.out.written(); } pub fn send(r: *Request, context: *anyopaque, done: Http.Done) !void { const arena = r.out.allocator; const headers = try arena.dupe([]const u8, &.{ r.jmap.authorization, "content-type: application/json" }); const b = try r.body(); try r.jmap.http.send(.{ .method = .post, .url = r.jmap.api_url, .headers = headers, .body = b }, context, done); } }; /// Asks for a blob, such as an attachment, by its id; the reply is its /// bytes. pub fn download(self: Jmap, arena: Allocator, blob: []const u8, context: *anyopaque, done: Http.Done) !void { const url = try expand(arena, self.download_url, &.{ .{ "accountId", self.account_id }, .{ "blobId", blob }, .{ "type", "application/octet-stream" }, .{ "name", "file" }, }); const headers = try arena.dupe([]const u8, &.{self.authorization}); try self.http.send(.{ .method = .get, .url = url, .headers = headers, .body = null }, context, done); } /// How long the server may go without a word on the event stream: it /// pings every minute, and one silent for three is gone. const ping_s = 60; const silent_s = 3 * ping_s; /// Opens the event stream, on which the server says when the mail, the /// mailboxes, or the threads change; `heard` hears it as it comes, for /// `nextEvent` to read, and `done` when it ends. pub fn listen(self: Jmap, arena: Allocator, context: *anyopaque, heard: Http.Heard, done: Http.Done) !void { const url = try expand(arena, self.event_source_url, &.{ .{ "types", "Email,Mailbox,Thread" }, .{ "closeafter", "no" }, .{ "ping", std.fmt.comptimePrint("{d}", .{ping_s}) }, }); const headers = try arena.dupe([]const u8, &.{ self.authorization, "accept: text/event-stream" }); const request: Http.Request = .{ .method = .get, .url = url, .headers = headers, .body = null }; try self.http.listen(request, silent_s, context, heard, done); } /// An event of a stream of server-sent events: its name, and its data, /// lines joined. pub const Event = struct { name: []const u8, data: []const u8 }; /// The first whole event in `bytes`, and how many bytes it took; null /// until a blank line ends one. Comments and other fields are left out. pub fn nextEvent(arena: Allocator, bytes: []const u8) !?struct { event: Event, len: usize } { var name: []const u8 = "message"; var data: Writer.Allocating = .init(arena); var any_data = false; var at: usize = 0; while (mem.findScalarPos(u8, bytes, at, '\n')) |end| { const text = mem.trimEnd(u8, bytes[at..end], "\r"); at = end + 1; if (text.len == 0) return .{ .event = .{ .name = name, .data = data.written() }, .len = at }; const colon = mem.findScalar(u8, text, ':') orelse text.len; const field = text[0..colon]; var value = if (colon < text.len) text[colon + 1 ..] else ""; if (value.len > 0 and value[0] == ' ') value = value[1..]; if (mem.eql(u8, field, "event")) { name = value; } else if (mem.eql(u8, field, "data")) { if (any_data) try data.writer.writeByte('\n'); try data.writer.writeAll(value); any_data = true; } } return null; } /// Whether an event says this account's mail changed: a StateChange /// that names it. pub fn changed(self: Jmap, arena: Allocator, event: Event) bool { if (!mem.eql(u8, event.name, "state")) return false; const StateChange = struct { changed: json.ArrayHashMap(json.Value) = .{} }; const got = parse(StateChange, arena, event.data) catch |err| { log.warn("an event: {t}", .{err}); return false; }; return got.changed.map.contains(self.account_id); } /// A URL template filled in, RFC 6570 level 1: each {name} replaced by /// its value, escaped for a URL, or by nothing. fn expand(arena: Allocator, template: []const u8, values: []const [2][]const u8) ![]const u8 { var url: Writer.Allocating = .init(arena); var rest = template; while (mem.findScalar(u8, rest, '{')) |open| { const close = mem.findScalarPos(u8, rest, open, '}') orelse break; try url.writer.writeAll(rest[0..open]); const name = rest[open + 1 .. close]; for (values) |v| { if (mem.eql(u8, v[0], name)) try escape(&url.writer, v[1]); } rest = rest[close + 1 ..]; } try url.writer.writeAll(rest); return url.written(); } /// Sends `bytes` as a blob of type `mime`; `blobId` reads the reply. pub fn upload( self: Jmap, arena: Allocator, bytes: []const u8, mime: []const u8, context: *anyopaque, done: Http.Done, ) !void { const at = mem.find(u8, self.upload_url, "{accountId}") orelse return error.BadUploadUrl; const rest = self.upload_url[at + "{accountId}".len ..]; const url = try mem.concat(arena, u8, &.{ self.upload_url[0..at], self.account_id, rest }); const content_type = try mem.concat(arena, u8, &.{ "content-type: ", mime }); const headers = try arena.dupe([]const u8, &.{ self.authorization, content_type }); try self.http.send(.{ .method = .post, .url = url, .headers = headers, .body = bytes }, context, done); } /// The id of a blob `upload` sent. pub fn blobId(arena: Allocator, body: []const u8) ![]const u8 { return (try parse(struct { blobId: []const u8 }, arena, body)).blobId; } fn escape(w: *Writer, value: []const u8) !void { for (value) |c| { if (ascii.isAlphanumeric(c) or mem.findScalar(u8, "-._~", c) != null) try w.writeByte(c) else try w.print("%{X:0>2}", .{c}); } } /// One method's reply. pub const Reply = struct { name: []const u8, args: json.Value, id: []const u8 }; /// The replies to a request, or why there are none: Unauthorized when /// the token is refused. pub fn replies(what: []const u8, arena: Allocator, response: Http.Error!Http.Response) ![]const Reply { const r = response catch |err| { log.err("{s}: {t}", .{ what, err }); return err; }; if (r.status == 401 or r.status == 403) { log.info("{s}: the token was refused", .{what}); return error.Unauthorized; } if (r.status / 100 != 2) { log.err("{s}: {d} {s}", .{ what, r.status, r.body[0..@min(r.body.len, 200)] }); return error.JmapFailed; } const value = try json.parseFromSliceLeaky(json.Value, arena, r.body, .{ .allocate = .alloc_always }); const list = switch (value) { .object => |o| o.get("methodResponses") orelse return error.JmapFailed, else => return error.JmapFailed, }; if (list != .array) return error.JmapFailed; const out = try arena.alloc(Reply, list.array.items.len); for (list.array.items, out) |item, *one| { if (item != .array or item.array.items.len != 3) return error.JmapFailed; const parts = item.array.items; if (parts[0] != .string or parts[2] != .string) return error.JmapFailed; one.* = .{ .name = parts[0].string, .args = parts[1], .id = parts[2].string }; } return out; } /// The arguments of the reply to the call `id` as `T`. A method's error /// is returned: CannotCalculateChanges when the state is too old. pub fn reply(comptime T: type, arena: Allocator, list: []const Reply, id: []const u8) !T { const r = for (list) |r| { if (mem.eql(u8, r.id, id) and !mem.eql(u8, r.name, "error")) break r; if (mem.eql(u8, r.id, id)) { const kind = if (r.args == .object) r.args.object.get("type") else null; const name = if (kind) |k| if (k == .string) k.string else "" else ""; // The whole error, which says what was wrong, such as the // argument the server refused. log.warn("{s}: {f}", .{ id, json.fmt(r.args, .{}) }); if (mem.eql(u8, name, "cannotCalculateChanges")) return error.CannotCalculateChanges; return error.MethodFailed; } } else return error.NoReply; return json.parseFromValueLeaky(T, arena, r.args, .{ .ignore_unknown_fields = true }); } /// The account's mailboxes, from the Mailbox/get `id`. pub fn readFolders(arena: Allocator, list: []const Reply, id: []const u8) !Folders { return folders(arena, (try reply(GetOf(MailboxJson), arena, list, id)).list); } /// What the Thread/get "threads" of a request and the Email/get "emails" /// of their messages read, for `lines`. pub const Read = struct { threads: []const Thread, emails: []const Email, /// The threads asked for that are gone. gone: []const []const u8, /// The two gets' states, as `Mailbox.state` keeps them. state: []const u8, pub const empty: Read = .{ .threads = &.{}, .emails = &.{}, .gone = &.{}, .state = "" }; }; pub fn readThreads(arena: Allocator, list: []const Reply) !Read { const threads = try reply(GetOf(Thread), arena, list, "threads"); const emails = try reply(GetOf(Email), arena, list, "emails"); return .{ .threads = threads.list, .emails = emails.list, .gone = threads.notFound, .state = try mem.concat(arena, u8, &.{ emails.state, " ", threads.state }), }; } /// What the Email/changes "emailChanges" and Thread/changes /// "threadChanges" of a request found, with the Email/gets "created" and /// "updated" of the messages they name. pub const Changed = struct { /// The new states, as `Mailbox.state` keeps them. state: []const u8, /// The threads to read again. threads: []const []const u8, gone: []const []const u8, /// The messages destroyed, which search drops. deleted: []const []const u8, /// Whether there were more changes than one reply holds. more: bool, }; pub fn readChanges(arena: Allocator, list: []const Reply) !Changed { const emails = try reply(Changes, arena, list, "emailChanges"); const threads = try reply(Changes, arena, list, "threadChanges"); var ids: std.array_hash_map.String(void) = .empty; for ([_][]const []const u8{ threads.created, threads.updated }) |l| { for (l) |id| try ids.put(arena, id, {}); } for ([_][]const u8{ "created", "updated" }) |which| { const got = try reply(GetOf(Email), arena, list, which); for (got.list) |e| if (e.threadId.len > 0) try ids.put(arena, e.threadId, {}); } return .{ .state = try mem.concat(arena, u8, &.{ emails.newState, " ", threads.newState }), .threads = ids.keys(), .gone = threads.destroyed, .deleted = emails.destroyed, .more = emails.hasMoreChanges or threads.hasMoreChanges, }; } /// The `update` of an Email/set for `change` (see `patches`), from the /// thread's messages that the Email/get "emails" read. pub fn readPatches( s: *Stringify, arena: Allocator, list: []const Reply, mail: *const Mailbox, roles: Roles, change: Mailbox.Change, ) !void { const emails = try arena.dupe(Email, (try reply(GetOf(Email), arena, list, "emails")).list); mem.sort(Email, emails, {}, struct { fn older(_: void, a: Email, b: Email) bool { return a.date() < b.date(); } }.older); try patches(s, mail, roles, change, emails); } /// The conversation the Email/get "emails" read, for the reader. pub fn readMessages(arena: Allocator, list: []const Reply, roles: Roles, me: []const u8) ![]const Mailbox.Message { return messages(arena, roles, (try reply(GetOf(Email), arena, list, "emails")).list, me); } /// The ids the Email/query `id` found. pub fn readIds(arena: Allocator, list: []const Reply, id: []const u8) ![]const []const u8 { return (try reply(Query, arena, list, id)).ids; } /// What search keeps of the messages the Email/get `id` read, asked /// for with `corpus_properties`. pub fn readCorpus(arena: Allocator, list: []const Reply, id: []const u8) ![]const Corpus.Record { const emails = (try reply(GetOf(Email), arena, list, id)).list; const out = try arena.alloc(Corpus.Record, emails.len); for (emails, out) |*e, *r| { const files = try arena.alloc(Corpus.Attachment, e.attachments.len); for (e.attachments, files) |p, *f| f.* = .{ .name = p.name orelse "", .mime = p.type, .size = p.size }; const text = try Corpus.split(arena, try bodyOf(arena, e, .text)); const from = first(e.from); r.* = .{ .id = e.id, .thread = e.threadId, .message_id = if (e.messageId) |ids| if (ids.len > 0) ids[0] else "" else "", .subject = e.subject orelse "", .list_id = mem.trim(u8, e.@"header:List-Id" orelse "", " \t\r\n"), .delivered_to = mem.trim(u8, e.@"header:Delivered-To:asText" orelse "", " "), .date = e.date(), .size = e.size, .from = .{ .name = from.name orelse "", .address = from.email }, .to = try corpusPeople(arena, e.to), .cc = try corpusPeople(arena, e.cc), .bcc = try corpusPeople(arena, e.bcc), .attachments = files, .text = text.said, .quoted = text.quoted, }; } return out; } fn corpusPeople(arena: Allocator, list: ?[]const Address) ![]const Corpus.Person { const in = list orelse return &.{}; const out = try arena.alloc(Corpus.Person, in.len); for (in, out) |a, *p| p.* = .{ .name = a.name orelse "", .address = a.email }; return out; } /// A page of a list: the Email/query "query" and the Email/get "heads" /// of the messages it found. pub const Page = struct { /// How many messages the query found, one a thread. count: usize, /// The threads of the messages, in the query's order. threads: []const []const u8, }; pub fn readPage(arena: Allocator, list: []const Reply) !Page { const query = try reply(Query, arena, list, "query"); const heads = try reply(GetOf(Email), arena, list, "heads"); const ids = try arena.alloc([]const u8, heads.list.len); for (heads.list, ids) |e, *id| id.* = e.threadId; return .{ .count = query.ids.len, .threads = ids }; } /// Who the account sends as. pub const Identity = struct { id: []const u8, email: []const u8, /// The From header: "Name
". from: []const u8, /// Its signature, as text: the server's text one, else its HTML one /// made text; empty for none. signature: []const u8, }; /// The first identity the Identity/get "identities" read; null when the /// account has none. pub fn readIdentity(arena: Allocator, list: []const Reply) !?Identity { const got = try reply(GetOf(IdentityJson), arena, list, "identities"); if (got.list.len == 0) return null; const i = got.list[0]; return .{ .id = i.id, .email = i.email, .from = try header(arena, &.{.{ .name = i.name, .email = i.email }}), .signature = if (mem.trim(u8, i.textSignature, " \r\n").len > 0) i.textSignature else try html.toText(arena, i.htmlSignature), }; } /// A draft kept on an earlier run, read back: its message for compose, /// whose attachments' bytes are read by their blobs, one for each. pub const Draft = struct { message: Compose.Outgoing, attachments: []Compose.Attachment, blobs: []const []const u8, }; /// The draft the Email/get "draft" of `draft_properties`, with its text /// values, read; null once it is gone or no longer a draft, as when it /// was sent from elsewhere. pub fn readDraft(arena: Allocator, list: []const Reply) !?Draft { const got = try reply(GetOf(Email), arena, list, "draft"); if (got.list.len == 0) return null; const e = &got.list[0]; if (!e.has("$draft")) return null; // As typed: a reply starts with blank lines over its quote, which // bodyOf would trim. var text: Writer.Allocating = .init(arena); for (e.textBody) |p| { const id = p.partId orelse continue; if (e.bodyValues.map.get(id)) |v| try text.writer.writeAll(v.value); } const files = try arena.alloc(Compose.Attachment, e.attachments.len); const blobs = try arena.alloc([]const u8, e.attachments.len); for (e.attachments, files, blobs) |p, *file, *blob| { file.* = .{ .name = p.name orelse "attachment", .mime = p.type, .data = "" }; blob.* = p.blobId orelse ""; } const in_reply_to = try angled(arena, e.inReplyTo, " "); return .{ .attachments = files, .blobs = blobs, .message = .{ .to = if (e.to) |l| try header(arena, l) else "", .cc = if (e.cc) |l| try header(arena, l) else "", .subject = e.subject orelse "", .body = text.written(), .html = "", // A new message is a thread of its own on the server, but no // reply in compose. .thread = if (in_reply_to.len > 0) e.threadId else "", .in_reply_to = in_reply_to, .references = try angled(arena, e.references, " "), .attachments = files, }, }; } // What replies hold, read with std.json, seen only here: a property a // call did not ask for is absent, so each has a default. const Address = struct { name: ?[]const u8 = null, email: []const u8 = "" }; /// A message, with what the list needs; the reader's fields are empty /// unless asked for. const Email = struct { id: []const u8, threadId: []const u8 = "", mailboxIds: json.ArrayHashMap(bool) = .{}, keywords: json.ArrayHashMap(bool) = .{}, from: ?[]const Address = null, to: ?[]const Address = null, cc: ?[]const Address = null, bcc: ?[]const Address = null, replyTo: ?[]const Address = null, subject: ?[]const u8 = null, preview: []const u8 = "", receivedAt: []const u8 = "", size: u32 = 0, messageId: ?[]const []const u8 = null, inReplyTo: ?[]const []const u8 = null, references: ?[]const []const u8 = null, @"header:List-Unsubscribe:asURLs": ?[]const []const u8 = null, @"header:List-Unsubscribe-Post:asRaw": ?[]const u8 = null, @"header:List-Id": ?[]const u8 = null, @"header:Delivered-To:asText": ?[]const u8 = null, bodyValues: json.ArrayHashMap(struct { value: []const u8 = "" }) = .{}, htmlBody: []const Part = &.{}, textBody: []const Part = &.{}, attachments: []const Part = &.{}, fn has(e: *const Email, keyword: []const u8) bool { return e.keywords.map.get(keyword) orelse false; } fn in(e: *const Email, mailbox: []const u8) bool { return mailbox.len > 0 and (e.mailboxIds.map.get(mailbox) orelse false); } fn date(e: *const Email) i64 { return parseDate(e.receivedAt) orelse 0; } }; const Part = struct { partId: ?[]const u8 = null, blobId: ?[]const u8 = null, type: []const u8 = "", name: ?[]const u8 = null, cid: ?[]const u8 = null, disposition: ?[]const u8 = null, size: u32 = 0, }; const MailboxJson = struct { id: []const u8, name: []const u8, parentId: ?[]const u8 = null, role: ?[]const u8 = null, }; const Thread = struct { id: []const u8, emailIds: []const []const u8 = &.{} }; fn GetOf(comptime T: type) type { return struct { state: []const u8 = "", list: []const T = &.{}, notFound: []const []const u8 = &.{} }; } const Changes = struct { newState: []const u8 = "", hasMoreChanges: bool = false, created: []const []const u8 = &.{}, updated: []const []const u8 = &.{}, destroyed: []const []const u8 = &.{}, }; const Query = struct { ids: []const []const u8 = &.{}, position: u32 = 0, total: ?u32 = null }; const IdentityJson = struct { id: []const u8, name: []const u8 = "", email: []const u8 = "", textSignature: []const u8 = "", htmlSignature: []const u8 = "", }; /// The account's mailboxes by role, for what the user does: each the /// JMAP id of the mailbox with that role, or empty when there is none. pub const Roles = struct { inbox: []const u8, sent: []const u8, drafts: []const u8, junk: []const u8, trash: []const u8, archive: []const u8, pub const none: Roles = .{ .inbox = "", .sent = "", .drafts = "", .junk = "", .trash = "", .archive = "" }; /// The mailbox that `label`, a system one, stands for; empty for /// Starred, a keyword. pub fn of(roles: Roles, label: Label) []const u8 { return switch (label) { .inbox => roles.inbox, .sent => roles.sent, .drafts => roles.drafts, .spam => roles.junk, .trash => roles.trash, else => "", }; } }; /// The account's mailboxes: by role, and the rest as user labels, each /// by its id and its own name, "Child", or with its parents', /// "Parent/Child", where another has the same name; sorted by path, so /// a child follows its parent. pub const Folders = struct { roles: Roles, labels: []const Mailbox.LabelInfo }; fn folders(arena: Allocator, mailboxes: []const MailboxJson) !Folders { var roles: Roles = .none; var labels: ArrayList(Mailbox.LabelInfo) = .empty; // Each label's path, for sorting, in step with `labels`. var paths: ArrayList([]const u8) = .empty; for (mailboxes) |m| { const role = m.role orelse ""; const place: ?*[]const u8 = if (mem.eql(u8, role, "inbox")) &roles.inbox else if (mem.eql(u8, role, "sent")) &roles.sent else if (mem.eql(u8, role, "drafts")) &roles.drafts else if (mem.eql(u8, role, "junk")) &roles.junk else if (mem.eql(u8, role, "trash")) &roles.trash else if (mem.eql(u8, role, "archive")) &roles.archive else null; if (place) |p| { if (p.len == 0) p.* = m.id; continue; } try labels.append(arena, .{ .id = m.id, .name = m.name, .kind = .user, .color = .{ .r = 0x6b, .g = 0x72, .b = 0x80 }, }); try paths.append(arena, try folderPath(arena, mailboxes, m)); } // Which names two labels share, found before any becomes a path. const shared = try arena.alloc(bool, labels.items.len); for (labels.items, shared, 0..) |l, *s, i| { s.* = for (labels.items, 0..) |other, j| { if (i != j and ascii.eqlIgnoreCase(l.name, other.name)) break true; } else false; } for (labels.items, paths.items, shared) |*l, path, s| { if (s) l.name = path; } const Order = struct { labels: []Mailbox.LabelInfo, paths: [][]const u8, const Self = @This(); pub fn lessThan(o: Self, a: usize, b: usize) bool { return ascii.lessThanIgnoreCase(o.paths[a], o.paths[b]); } pub fn swap(o: Self, a: usize, b: usize) void { mem.swap(Mailbox.LabelInfo, &o.labels[a], &o.labels[b]); mem.swap([]const u8, &o.paths[a], &o.paths[b]); } }; std.sort.pdqContext(0, labels.items.len, Order{ .labels = labels.items, .paths = paths.items }); return .{ .roles = roles, .labels = labels.items }; } /// A mailbox's name under its parents', "Parent/Child". fn folderPath(arena: Allocator, mailboxes: []const MailboxJson, m: MailboxJson) ![]const u8 { var name = m.name; var parent = m.parentId; var depth: u32 = 0; while (parent) |id| : (depth += 1) { if (depth == 16) break; const p = for (mailboxes) |c| { if (mem.eql(u8, c.id, id)) break c; } else break; name = try mem.concat(arena, u8, &.{ p.name, "/", name }); parent = p.parentId; } return name; } /// Whether `mail` has `labels` as its user labels, in order, so threads /// keep their places. pub fn sameLabels(mail: *const Mailbox, labels: []const Mailbox.LabelInfo) bool { const have = mail.userLabels(); if (have.len != labels.len) return false; for (have, labels) |a, b| { if (!mem.eql(u8, a.id, b.id) or !mem.eql(u8, a.name, b.name)) return false; } return true; } /// The label of `mail` that a JMAP mailbox is, if any. fn labelOf(mail: *const Mailbox, roles: Roles, mailbox: []const u8) ?Label { for ([_]Label{ .inbox, .sent, .drafts, .spam, .trash }) |l| { if (mem.eql(u8, roles.of(l), mailbox)) return l; } return mail.findLabel(mailbox); } /// The JMAP mailbox a label of `mail` is; empty for Starred and a label /// the account has no mailbox for. pub fn mailboxOf(mail: *const Mailbox, roles: Roles, label: Label) []const u8 { if (mail.label(label).kind == .system) return roles.of(label); return mail.label(label).id; } /// A thread as the list shows it, and who is on it, for the contacts. pub const Line = struct { thread: Mailbox.NewThread, people: []const People, pub const People = struct { how: Contacts.How, list: []const u8, ms: i64 }; }; /// A thread's line from its messages: the first's subject, the newest's /// preview and date, every message's labels, Starred if one is flagged, /// and unread if one is not seen. A draft counts for none of it but its /// labels. `me` is the user's address, to name them "me". fn line( arena: Allocator, mail: *const Mailbox, roles: Roles, id: []const u8, emails: []const Email, me: []const u8, ) !Line { const sorted = try arena.dupe(Email, emails); mem.sort(Email, sorted, {}, struct { fn older(_: void, a: Email, b: Email) bool { return a.date() < b.date(); } }.older); var set: ArrayList(Label) = .empty; var notes: ArrayList(Mailbox.NewNote) = .empty; var people: ArrayList(Line.People) = .empty; var names: std.array_hash_map.String(void) = .empty; var unread = false; var flagged = false; var newest: ?*const Email = null; for (sorted) |*e| { var it = e.mailboxIds.map.iterator(); while (it.next()) |entry| { if (!entry.value_ptr.*) continue; const l = labelOf(mail, roles, entry.key_ptr.*) orelse continue; if (mem.findScalar(Label, set.items, l) == null) try set.append(arena, l); } if (e.has("$draft") or e.in(roles.drafts)) continue; newest = e; if (!e.has("$seen") and !e.in(roles.trash)) unread = true; if (e.has("$flagged")) flagged = true; const sent = e.in(roles.sent) or fromMe(e, me); const from = first(e.from); try names.put(arena, if (sent) "me" else displayName(from), {}); const date = e.date(); try notes.append(arena, .{ .date = date, .from = person(from), .to = try persons(arena, e.to), .cc = try persons(arena, e.cc), }); const ms = date * 1000; if (sent) { for ([_]?[]const Address{ e.to, e.cc }) |list| if (list) |l| { try people.append(arena, .{ .how = .wrote, .list = try header(arena, l), .ms = ms }); }; } else { if (e.from) |l| try people.append(arena, .{ .how = .heard, .list = try header(arena, l), .ms = ms }); for ([_]?[]const Address{ e.to, e.cc }) |list| if (list) |l| { try people.append(arena, .{ .how = .copied, .list = try header(arena, l), .ms = ms }); }; } } if (flagged) try set.append(arena, .starred); const last = newest orelse &sorted[sorted.len - 1]; const subject = for (sorted) |e| { if (e.subject) |s| if (s.len > 0) break s; } else "(no subject)"; return .{ .thread = .{ .id = id, .participants = try participants(arena, names.keys()), .subject = subject, .snippet = try oneLine(arena, last.preview), .date = last.date(), .messages = @intCast(notes.items.len), .labels = set.items, .unread = unread, .notes = notes.items, }, .people = people.items, }; } /// Puts what was read of threads in `mail`, in place of what it had of /// them; their people join `contacts`, when there are some. pub fn put(mail: *Mailbox, read: []const Line, contacts: ?*Contacts) !void { for (read) |l| { _ = try mail.put(l.thread, 0); const c = contacts orelse continue; for (l.people) |p| try c.note(p.how, p.list, p.ms); } } /// The threads' lines, from the messages that a Thread/get and an /// Email/get read; a thread with no message read is left out. pub fn lines(arena: Allocator, mail: *const Mailbox, roles: Roles, read: Read, me: []const u8) ![]const Line { var by_id: std.array_hash_map.String(*const Email) = .empty; for (read.emails) |*e| try by_id.put(arena, e.id, e); var out: ArrayList(Line) = try .initCapacity(arena, read.threads.len); for (read.threads) |t| { var mine: ArrayList(Email) = .empty; for (t.emailIds) |id| if (by_id.get(id)) |e| try mine.append(arena, e.*); if (mine.items.len == 0) continue; out.appendAssumeCapacity(try line(arena, mail, roles, t.id, mine.items, me)); } return out.items; } fn fromMe(e: *const Email, me: []const u8) bool { return me.len > 0 and ascii.eqlIgnoreCase(first(e.from).email, me); } fn first(list: ?[]const Address) Address { const l = list orelse return .{}; return if (l.len > 0) l[0] else .{}; } fn displayName(a: Address) []const u8 { const name = mem.trim(u8, a.name orelse "", " \t\"'"); return if (name.len > 0) name else a.email; } fn person(a: Address) Mailbox.Person { return .{ .name = mem.trim(u8, a.name orelse "", " \t\"'"), .address = a.email }; } fn persons(arena: Allocator, list: ?[]const Address) ![]const Mailbox.Person { const l = list orelse return &.{}; const out = try arena.alloc(Mailbox.Person, l.len); for (l, out) |a, *p| p.* = person(a); return out; } /// An address list as a header has it: `"Maya Okafor" , …`. fn header(arena: Allocator, list: []const Address) ![]const u8 { var out: Writer.Allocating = .init(arena); for (list, 0..) |a, i| { if (i > 0) try out.writer.writeAll(", "); const name = mem.trim(u8, a.name orelse "", " \t"); if (name.len == 0) { try out.writer.writeAll(a.email); } else if (mem.findAny(u8, name, ",;<>@\"()[]:") != null) { try out.writer.writeByte('"'); for (name) |c| if (c != '"' and c != '\\') try out.writer.writeByte(c); try out.writer.print("\" <{s}>", .{a.email}); } else { try out.writer.print("{s} <{s}>", .{ name, a.email }); } } return out.written(); } /// Who wrote in a thread, in the order they first did, as Gmail's list /// names them: one by their whole name, several by first names, and past /// three the first and the last two. /// A preview as a line, as Gmail's snippets come: JMAP's keeps the /// body's line breaks. fn oneLine(arena: Allocator, text: []const u8) ![]const u8 { var out: ArrayList(u8) = try .initCapacity(arena, text.len); var words = mem.tokenizeAny(u8, text, " \t\r\n"); while (words.next()) |w| { if (out.items.len > 0) out.appendAssumeCapacity(' '); out.appendSliceAssumeCapacity(w); } return out.items; } fn participants(arena: Allocator, names: []const []const u8) ![]const u8 { if (names.len <= 1) return if (names.len == 1) names[0] else ""; var out: Writer.Allocating = .init(arena); try out.writer.writeAll(firstName(names[0])); const tail = if (names.len > 3) names.len - 2 else 1; for (names[tail..], tail..) |name, i| { try out.writer.writeAll(if (i == tail and tail > 1) " … " else ", "); try out.writer.writeAll(firstName(name)); } return out.written(); } fn firstName(name: []const u8) []const u8 { return name[0 .. mem.findScalar(u8, name, ' ') orelse name.len]; } /// The messages of a thread the reader shows, oldest first, from an /// Email/get of `full_properties` with every text part's value. fn messages(arena: Allocator, roles: Roles, emails: []const Email, me: []const u8) ![]const Mailbox.Message { const sorted = try arena.dupe(Email, emails); mem.sort(Email, sorted, {}, struct { fn older(_: void, a: Email, b: Email) bool { return a.date() < b.date(); } }.older); var out: ArrayList(Mailbox.Message) = try .initCapacity(arena, sorted.len); for (sorted) |*e| { if (e.has("$draft")) continue; const rich = try bodyOf(arena, e, .html); var files: ArrayList(Mailbox.Attachment) = .empty; var calendar: []const u8 = ""; for (e.attachments) |p| { const blob = p.blobId orelse continue; if (ascii.eqlIgnoreCase(p.type, "text/calendar") and calendar.len == 0) { if (p.partId) |id| if (e.bodyValues.map.get(id)) |v| { calendar = v.value; }; } const cid = mem.trim(u8, p.cid orelse "", " <>"); const ref = try mem.concat(arena, u8, &.{ "cid:", cid }); const shown = cid.len > 0 and ascii.findIgnoreCase(rich, ref) != null; try files.append(arena, .{ .name = p.name orelse "", .mime = p.type, .content_id = cid, .id = blob, .data = "", .inline_shown = shown, }); } const from = first(e.from); const from_header = if (e.from) |l| try header(arena, l) else ""; out.appendAssumeCapacity(.{ .id = e.id, .from = displayName(from), .address = from.email, .date = e.date(), .html = rich, .text = try bodyOf(arena, e, .text), .snippet = try oneLine(arena, e.preview), .attachments = files.items, .unread = !e.has("$seen"), .message_id = try angled(arena, e.messageId, " "), .references = try angled(arena, e.references, " "), .reply_to = if (e.replyTo) |l| try header(arena, l) else from_header, .to = if (e.to) |l| try header(arena, l) else "", .cc = if (e.cc) |l| try header(arena, l) else "", .sent = e.in(roles.sent) or fromMe(e, me), .unsubscribe = try angled(arena, e.@"header:List-Unsubscribe:asURLs", ", "), .one_click = ascii.findIgnoreCase(e.@"header:List-Unsubscribe-Post:asRaw" orelse "", "One-Click") != null, .action = cards.action(arena, rich), .invite = if (calendar.len > 0) try cards.invite(arena, calendar) else null, }); } return out.items; } /// Message-IDs as a header has them: " ". fn angled(arena: Allocator, ids: ?[]const []const u8, separator: []const u8) ![]const u8 { const list = ids orelse return ""; var out: Writer.Allocating = .init(arena); for (list, 0..) |id, i| try out.writer.print("{s}<{s}>", .{ if (i > 0) separator else "", id }); return out.written(); } /// A message's body as HTML, its HTML parts else its text escaped, or as /// text to quote, its text parts else its HTML's text. The server gives /// the values in UTF-8. fn bodyOf(arena: Allocator, e: *const Email, as: enum { html, text }) ![]const u8 { var out: Writer.Allocating = .init(arena); const w = &out.writer; const parts = if (as == .html) e.htmlBody else e.textBody; if (as == .html) try w.writeAll(""); var any = false; for (parts) |p| { const id = p.partId orelse continue; const v = e.bodyValues.map.get(id) orelse continue; any = true; const is_html = ascii.eqlIgnoreCase(p.type, "text/html"); switch (as) { .html => if (is_html) try w.writeAll(v.value) else try html.fromText(w, v.value), .text => try w.writeAll(if (is_html) try html.toText(arena, v.value) else v.value), } } if (!any and as == .text) return ""; return if (as == .text) mem.trim(u8, out.written(), " \r\n") else out.written(); } /// What a change the user made to a thread does to its messages, as the /// `update` of an Email/set: a patch per message that changes. `emails` /// are the thread's, with their mailboxes and keywords; `mail` has the /// change made already. fn patches( s: *Stringify, mail: *const Mailbox, roles: Roles, change: Mailbox.Change, emails: []const Email, ) !void { try s.beginObject(); switch (change) { .label => |c| if (c.label == .starred) { // A star goes on the newest message, and off all of them. for (emails, 0..) |e, i| { if (c.on and i + 1 != emails.len) continue; if (e.has("$flagged") == c.on) continue; try s.objectField(e.id); try s.write(.{ .@"keywords/$flagged" = if (c.on) true else null }); } } else { const box = mailboxOf(mail, roles, c.label); if (box.len > 0) for (emails) |e| try labelPatch(s, roles, e, box, c.label == .inbox, c.on); }, .unread => |c| for (emails, 0..) |e, i| { // Unread marks the newest message; read marks them all. if (c.unread and i + 1 != emails.len) continue; if (e.has("$seen") == !c.unread) continue; try s.objectField(e.id); try s.write(.{ .@"keywords/$seen" = if (c.unread) null else true }); }, .trash => for (emails) |e| { if (roles.trash.len == 0) break; try s.objectField(e.id); try s.beginObject(); try s.objectField("mailboxIds"); try s.beginObject(); try s.objectField(roles.trash); try s.write(true); try s.endObject(); try s.endObject(); }, .untrash => |c| { try untrashPatch(s, mail, roles, c.thread, c.inbox, emails); }, } try s.endObject(); } /// A label put on or taken off a message: its mailbox added or removed. /// One left in no mailbox goes to the archive; the Inbox put back takes /// it out of the archive. Sent copies and drafts stay out of the Inbox. fn labelPatch(s: *Stringify, roles: Roles, e: Email, box: []const u8, inbox: bool, on: bool) !void { if (e.in(box) == on) return; if (on and inbox and (e.in(roles.sent) or e.in(roles.drafts))) return; var left: u32 = 0; var it = e.mailboxIds.map.iterator(); while (it.next()) |entry| { if (entry.value_ptr.* and !mem.eql(u8, entry.key_ptr.*, box)) left += 1; } // Every message must be in some mailbox. if (!on and left == 0 and roles.archive.len == 0) return; try s.objectField(e.id); try s.beginObject(); var buf: [128]u8 = undefined; try s.objectField(try mem.print(&buf, "mailboxIds/{s}", .{box})); try s.write(if (on) true else null); if (!on and left == 0) { try s.objectField(try mem.print(&buf, "mailboxIds/{s}", .{roles.archive})); try s.write(true); } if (on and inbox and e.in(roles.archive)) { try s.objectField(try mem.print(&buf, "mailboxIds/{s}", .{roles.archive})); try s.write(null); } try s.endObject(); } /// A thread out of the trash: each message in the mailboxes of the /// thread's labels, the Inbox too when `inbox` says, else the archive. fn untrashPatch( s: *Stringify, mail: *const Mailbox, roles: Roles, thread: Mailbox.Id, inbox: bool, emails: []const Email, ) !void { var boxes: [64][]const u8 = undefined; var n: usize = 0; var it = mail.get(thread).labels.iterator(.{}); while (it.next()) |i| { const l: Label = @fromBackingInt(@intCast(i)); if (l == .trash or l == .starred or l == .inbox) continue; const box = mailboxOf(mail, roles, l); if (box.len == 0 or n == boxes.len) continue; boxes[n] = box; n += 1; } if (inbox and roles.inbox.len > 0 and n < boxes.len) { boxes[n] = roles.inbox; n += 1; } if (n == 0) { boxes[0] = if (roles.archive.len > 0) roles.archive else roles.inbox; n = 1; } for (emails) |e| { try s.objectField(e.id); try s.beginObject(); try s.objectField("mailboxIds"); try s.beginObject(); for (boxes[0..n]) |box| { try s.objectField(box); try s.write(true); } try s.endObject(); try s.endObject(); } } /// An Email/query filter for the view of `labels` (their ids in `mail`) /// and `search`, in Gmail's syntax: each label's mailbox, Starred as /// $flagged, none as everything but spam and the trash; and from:, to:, /// cc:, subject:, has:attachment, is:unread, is:read, is:starred, and /// in: or label: a label by name, the rest as text. pub fn filter(s: *Stringify, mail: *const Mailbox, roles: Roles, labels: []const []const u8, search: []const u8) !void { try s.beginObject(); try s.objectField("operator"); try s.write("AND"); try s.objectField("conditions"); try s.beginArray(); var spam_or_trash = false; for (labels) |id| { const l = mail.findLabel(id) orelse continue; if (l == .spam or l == .trash) spam_or_trash = true; try condition(s, mail, roles, l); } var text: [512]u8 = undefined; var text_len: usize = 0; var it = mem.tokenizeScalar(u8, search, ' '); while (it.next()) |word| { const colon = mem.findScalar(u8, word, ':') orelse 0; const key = word[0..colon]; const value = if (colon > 0) mem.trim(u8, word[colon + 1 ..], "\"") else ""; if (colon > 0 and value.len > 0) { if (try operator(s, mail, roles, key, value, &spam_or_trash)) continue; } if (text_len + word.len + 1 > text.len) continue; if (text_len > 0) { text[text_len] = ' '; text_len += 1; } @memcpy(text[text_len..][0..word.len], word); text_len += word.len; } if (text_len > 0) { try s.write(.{ .text = text[0..text_len] }); } if (!spam_or_trash) { var out: [2][]const u8 = undefined; var n: usize = 0; for ([_][]const u8{ roles.junk, roles.trash }) |box| if (box.len > 0) { out[n] = box; n += 1; }; if (n > 0) try s.write(.{ .inMailboxOtherThan = out[0..n] }); } try s.endArray(); try s.endObject(); } fn condition(s: *Stringify, mail: *const Mailbox, roles: Roles, l: Label) !void { if (l == .starred) return s.write(.{ .hasKeyword = "$flagged" }); const box = mailboxOf(mail, roles, l); // A label the account has no mailbox for matches nothing. try s.write(.{ .inMailbox = if (box.len > 0) box else "none" }); } /// One of Gmail's operators as a condition; false when it is none parcel /// knows, so it goes as text. fn operator( s: *Stringify, mail: *const Mailbox, roles: Roles, key: []const u8, value: []const u8, spam_or_trash: *bool, ) !bool { if (ascii.eqlIgnoreCase(key, "from")) { try s.write(.{ .from = value }); } else if (ascii.eqlIgnoreCase(key, "to")) { try s.write(.{ .to = value }); } else if (ascii.eqlIgnoreCase(key, "cc")) { try s.write(.{ .cc = value }); } else if (ascii.eqlIgnoreCase(key, "subject")) { try s.write(.{ .subject = value }); } else if (ascii.eqlIgnoreCase(key, "has") and ascii.eqlIgnoreCase(value, "attachment")) { try s.write(.{ .hasAttachment = true }); } else if (ascii.eqlIgnoreCase(key, "is") and ascii.eqlIgnoreCase(value, "unread")) { try s.write(.{ .notKeyword = "$seen" }); } else if (ascii.eqlIgnoreCase(key, "is") and ascii.eqlIgnoreCase(value, "read")) { try s.write(.{ .hasKeyword = "$seen" }); } else if (ascii.eqlIgnoreCase(key, "is") and ascii.eqlIgnoreCase(value, "starred")) { try s.write(.{ .hasKeyword = "$flagged" }); } else if (ascii.eqlIgnoreCase(key, "in") or ascii.eqlIgnoreCase(key, "label")) { for (mail.labels.items, 0..) |info, i| { if (!ascii.eqlIgnoreCase(info.name, value) and !ascii.eqlIgnoreCase(info.id, value)) continue; const l: Label = @fromBackingInt(@intCast(i)); if (l == .spam or l == .trash) spam_or_trash.* = true; try condition(s, mail, roles, l); return true; } return false; } else return false; return true; } /// A UTCDate, such as "2026-11-08T19:41:00Z", in seconds since the /// epoch; a fraction of a second and an offset are allowed. pub fn parseDate(text: []const u8) ?i64 { if (text.len < 19) return null; if (text[4] != '-' or text[7] != '-' or text[10] != 'T' or text[13] != ':' or text[16] != ':') return null; const year = std.fmt.parseInt(i64, text[0..4], 10) catch return null; const month = std.fmt.parseInt(i64, text[5..7], 10) catch return null; const day = std.fmt.parseInt(i64, text[8..10], 10) catch return null; const hour = std.fmt.parseInt(i64, text[11..13], 10) catch return null; const minute = std.fmt.parseInt(i64, text[14..16], 10) catch return null; const second = std.fmt.parseInt(i64, text[17..19], 10) catch return null; if (month < 1 or month > 12 or day < 1 or day > 31) return null; var rest = text[19..]; if (rest.len > 0 and rest[0] == '.') { const end = mem.findAny(u8, rest, "Z+-") orelse rest.len; rest = rest[end..]; } var offset: i64 = 0; if (rest.len >= 6 and (rest[0] == '+' or rest[0] == '-')) { const h = std.fmt.parseInt(i64, rest[1..3], 10) catch return null; const m = std.fmt.parseInt(i64, rest[4..6], 10) catch return null; offset = (h * 60 + m) * 60 * @as(i64, if (rest[0] == '-') -1 else 1); } // Days from the civil date, Howard Hinnant's algorithm. const y = if (month <= 2) year - 1 else year; const era = @divFloor(y, 400); const yoe = y - era * 400; const mp = @mod(month + 9, 12); const doy = @divFloor(153 * mp + 2, 5) + day - 1; const doe = yoe * 365 + @divFloor(yoe, 4) - @divFloor(yoe, 100) + doy; const days = era * 146097 + doe - 719468; return days * 86400 + hour * 3600 + minute * 60 + second - offset; } /// JSON as `T`, which names only the fields the app reads. fn parse(comptime T: type, arena: Allocator, bytes: []const u8) !T { return json.parseFromSliceLeaky(T, arena, bytes, .{ .ignore_unknown_fields = true, .allocate = .alloc_always }); } /// Emails as an Email/get lists them, for the tests. fn testEmails(arena: Allocator, text: []const u8) ![]const Email { return parse([]const Email, arena, text); } const test_roles: Roles = .{ .inbox = "in", .sent = "sent", .drafts = "dr", .junk = "junk", .trash = "bin", .archive = "arch", }; test "a thread's line: roles are system labels, the archive none, a flag is Starred, a draft counts for nothing" { var arena_state: ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); var mail: Mailbox = try .init(testing.allocator); defer mail.deinit(); const work = try mail.addLabel(.{ .id = "w", .name = "Work", .kind = .user, .color = .{ .r = 0, .g = 0, .b = 0 } }); const emails = try testEmails(arena, \\[{"id":"e2","mailboxIds":{"sent":true},"keywords":{"$seen":true}, \\ "from":[{"name":"Tim","email":"tim@example.com"}],"to":[{"name":"Maya Okafor","email":"maya@example.com"}], \\ "subject":"Re: Plan","preview":"Sounds\r\n\r\n good ","receivedAt":"2026-11-08T10:00:00Z"}, \\ {"id":"e1","mailboxIds":{"in":true,"w":true,"arch":true},"keywords":{"$flagged":true}, \\ "from":[{"name":"Maya Okafor","email":"maya@example.com"}],"to":[{"email":"tim@example.com"}], \\ "subject":"Plan","preview":"Here it is","receivedAt":"2026-11-08T09:00:00Z"}, \\ {"id":"e3","mailboxIds":{"dr":true},"keywords":{"$draft":true}, \\ "from":[{"name":"Tim","email":"tim@example.com"}],"subject":"Re: Plan","preview":"draft", \\ "receivedAt":"2026-11-08T11:00:00Z"}] ); const l = try line(arena, &mail, test_roles, "t1", emails, "tim@example.com"); const t = l.thread; try testing.expectEqualStrings("Plan", t.subject); try testing.expectEqualStrings("Sounds good", t.snippet); try testing.expectEqualStrings("Maya, me", t.participants); try testing.expectEqual(2, t.messages); try testing.expectEqual(parseDate("2026-11-08T10:00:00Z").?, t.date); // e1 is not seen, but e3, a draft, does not make the thread unread on its own. try testing.expect(t.unread); for ([_]Label{ .inbox, work, .sent, .drafts, .starred }) |want| { try testing.expect(mem.findScalar(Label, t.labels, want) != null); } try testing.expectEqual(5, t.labels.len); // Who wrote to the user, oldest first, then whom the user wrote to. try testing.expectEqual(Contacts.How.heard, l.people[0].how); try testing.expectEqualStrings("Maya Okafor ", l.people[0].list); const last = l.people[l.people.len - 1]; try testing.expectEqual(Contacts.How.wrote, last.how); try testing.expectEqualStrings("Maya Okafor ", last.list); } /// The update an Email/set would send for `change`. fn testPatches(arena: Allocator, mail: *const Mailbox, change: Mailbox.Change, emails: []const Email) ![]const u8 { var out: Writer.Allocating = .init(arena); var s: Stringify = .{ .writer = &out.writer }; try patches(&s, mail, test_roles, change, emails); return out.written(); } test "a change becomes patches: Done archives what it leaves in no mailbox, a star and unread go on the newest" { var arena_state: ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); var mail: Mailbox = try .init(testing.allocator); defer mail.deinit(); const work = try mail.addLabel(.{ .id = "w", .name = "Work", .kind = .user, .color = .{ .r = 0, .g = 0, .b = 0 } }); const t = try mail.addThread(.{ .id = "t1", .participants = "", .subject = "", .snippet = "", .date = 0, .messages = 3, .labels = &.{ .inbox, work }, .unread = true, .notes = &.{}, }); const emails = try testEmails(arena, \\[{"id":"a","mailboxIds":{"in":true}}, \\ {"id":"b","mailboxIds":{"in":true,"w":true},"keywords":{"$seen":true}}, \\ {"id":"c","mailboxIds":{"sent":true},"keywords":{"$seen":true}}] ); try testing.expectEqualStrings( \\{"a":{"mailboxIds/in":null,"mailboxIds/arch":true},"b":{"mailboxIds/in":null}} , try testPatches(arena, &mail, .{ .label = .{ .thread = t, .label = .inbox, .on = false } }, emails)); // Back to the Inbox: out of the archive, and a sent copy stays out. const archived = try testEmails(arena, \\[{"id":"a","mailboxIds":{"arch":true}},{"id":"c","mailboxIds":{"sent":true}}] ); try testing.expectEqualStrings( \\{"a":{"mailboxIds/in":true,"mailboxIds/arch":null}} , try testPatches(arena, &mail, .{ .label = .{ .thread = t, .label = .inbox, .on = true } }, archived)); try testing.expectEqualStrings( \\{"c":{"keywords/$flagged":true}} , try testPatches(arena, &mail, .{ .label = .{ .thread = t, .label = .starred, .on = true } }, emails)); try testing.expectEqualStrings( \\{"c":{"keywords/$seen":null}} , try testPatches(arena, &mail, .{ .unread = .{ .thread = t, .unread = true } }, emails)); try testing.expectEqualStrings( \\{"a":{"keywords/$seen":true}} , try testPatches(arena, &mail, .{ .unread = .{ .thread = t, .unread = false } }, emails)); try testing.expectEqualStrings( \\{"a":{"mailboxIds":{"bin":true}},"b":{"mailboxIds":{"bin":true}},"c":{"mailboxIds":{"bin":true}}} , try testPatches(arena, &mail, .{ .trash = t }, emails)); // Out of the trash, with the labels it had and the Inbox. try testing.expectEqualStrings( \\{"a":{"mailboxIds":{"w":true,"in":true}}} , try testPatches(arena, &mail, .{ .untrash = .{ .thread = t, .inbox = true } }, emails[0..1])); } fn testFilter(arena: Allocator, mail: *const Mailbox, labels: []const []const u8, search: []const u8) ![]const u8 { var out: Writer.Allocating = .init(arena); var s: Stringify = .{ .writer = &out.writer }; try filter(&s, mail, test_roles, labels, search); return out.written(); } test "a view becomes a query: labels as mailboxes, Starred as a keyword, Gmail's operators, spam and trash left out" { var arena_state: ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); var mail: Mailbox = try .init(testing.allocator); defer mail.deinit(); _ = try mail.addLabel(.{ .id = "w", .name = "Work", .kind = .user, .color = .{ .r = 0, .g = 0, .b = 0 } }); try testing.expectEqualStrings( \\{"operator":"AND","conditions":[{"inMailbox":"in"},{"hasKeyword":"$flagged"},{"from":"maya"}, ++ \\{"hasAttachment":true},{"text":"lisbon trip"},{"inMailboxOtherThan":["junk","bin"]}]} , try testFilter(arena, &mail, &.{ "INBOX", "STARRED" }, "from:maya lisbon has:attachment trip")); try testing.expectEqualStrings( \\{"operator":"AND","conditions":[{"inMailbox":"w"},{"inMailbox":"bin"},{"notKeyword":"$seen"}]} , try testFilter(arena, &mail, &.{"w"}, "in:trash is:unread")); } test "a UTCDate is seconds since the epoch, with a fraction or an offset" { try testing.expectEqual(1_794_166_860, parseDate("2026-11-08T19:41:00Z").?); try testing.expectEqual(1_794_166_860, parseDate("2026-11-08T19:41:00.123Z").?); try testing.expectEqual(1_794_166_860, parseDate("2026-11-08T13:41:00-06:00").?); try testing.expectEqual(951_782_400, parseDate("2000-02-29T00:00:00Z").?); try testing.expect(parseDate("2026-11-08") == null); } test "mailboxes become labels by their own names, by path where two share one, sorted by path" { var arena_state: ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const boxes = try parse([]const MailboxJson, arena, \\[{"id":"1","name":"Inbox","role":"inbox"},{"id":"2","name":"Work"}, \\ {"id":"3","name":"Zeta","parentId":"2"},{"id":"4","name":"Archive","role":"archive"}, \\ {"id":"5","name":"family"},{"id":"6","name":"Notes","parentId":"2"}, \\ {"id":"7","name":"Home"},{"id":"8","name":"notes","parentId":"7"}] ); const f = try folders(arena, boxes); try testing.expectEqualStrings("1", f.roles.inbox); try testing.expectEqualStrings("4", f.roles.archive); const want = [_][2][]const u8{ .{ "5", "family" }, .{ "7", "Home" }, .{ "8", "Home/notes" }, .{ "2", "Work" }, .{ "6", "Work/Notes" }, .{ "3", "Zeta" }, }; try testing.expectEqual(want.len, f.labels.len); for (want, f.labels) |w, l| { try testing.expectEqualStrings(w[0], l.id); try testing.expectEqualStrings(w[1], l.name); } } test "a message for the reader: its HTML, else its text made HTML; files with an image the body shows; an invitation" { var arena_state: ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const emails = try testEmails(arena, \\[{"id":"e1","mailboxIds":{"in":true},"from":[{"name":"Ana","email":"ana@example.com"}], \\ "to":[{"name":"Tim, the boss","email":"tim@example.com"}],"receivedAt":"2026-11-08T09:00:00Z", \\ "messageId":["m1@example.com"],"references":["m0@example.com"], \\ "header:List-Unsubscribe:asURLs":["mailto:leave@example.com","https://example.com/u"], \\ "header:List-Unsubscribe-Post:asRaw":" List-Unsubscribe=One-Click", \\ "htmlBody":[{"partId":"1","type":"text/html"}],"textBody":[{"partId":"2","type":"text/plain"}], \\ "attachments":[{"partId":"3","blobId":"b3","type":"image/png","cid":""}, \\ {"partId":"4","blobId":"b4","type":"application/pdf","name":"plan.pdf"}, \\ {"partId":"5","blobId":"b5","type":"text/calendar","name":"invite.ics"}], \\ "bodyValues":{"1":{"value":"

Hi

"},"2":{"value":"Hi\n"}, \\ "5":{"value":"BEGIN:VEVENT\r\nSUMMARY:Lunch\r\nDTSTART:20261112T120000Z\r\nEND:VEVENT\r\n"}}}, \\ {"id":"e2","mailboxIds":{"sent":true},"keywords":{"$seen":true},"from":[{"email":"tim@example.com"}], \\ "receivedAt":"2026-11-08T10:00:00Z","textBody":[{"partId":"1","type":"text/plain"}], \\ "htmlBody":[{"partId":"1","type":"text/plain"}],"bodyValues":{"1":{"value":"a & c"}}}] ); const got = try messages(arena, test_roles, emails, "tim@example.com"); try testing.expectEqual(2, got.len); const m = got[0]; try testing.expectEqualStrings("

Hi

", m.html); try testing.expectEqualStrings("Hi", m.text); try testing.expectEqualStrings("", m.message_id); try testing.expectEqualStrings("", m.references); try testing.expectEqualStrings("\"Tim, the boss\" ", m.to); try testing.expectEqualStrings("Ana ", m.reply_to); try testing.expect(m.unread and !m.sent and m.one_click); try testing.expectEqualStrings(", ", m.unsubscribe); try testing.expectEqual(3, m.attachments.len); try testing.expect(m.attachments[0].inline_shown and !m.attachments[1].inline_shown); try testing.expectEqualStrings("b4", m.attachments[1].id); try testing.expectEqualStrings("Lunch", m.invite.?.summary); try testing.expect(got[1].sent and !got[1].unread); try testing.expectEqualStrings( "
a <b> & c
", got[1].html, ); } test "what search keeps of a message: who, the list it came by, its files, its text apart from what it quotes" { var arena_state: ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const list = try replies("", arena, .{ .status = 200, .url = "", .body = \\{"methodResponses":[["Email/get",{"list":[{"id":"e1","threadId":"t1","size":5120, \\ "receivedAt":"2026-11-08T09:00:00Z","subject":"Re: Plan","messageId":["m1@example.com"], \\ "from":[{"name":"Ana","email":"ana@example.com"}],"to":[{"name":null,"email":"tim@example.com"}], \\ "cc":null,"bcc":[{"name":"Bo","email":"bo@example.com"}], \\ "header:List-Id":" Dev ","header:Delivered-To:asText":" tim+dev@example.com", \\ "textBody":[{"partId":"1","type":"text/html"}], \\ "bodyValues":{"1":{"value":"

Yes

On Monday, Tim wrote:

> Plan?

"}}, \\ "attachments":[{"partId":"2","blobId":"b2","type":"application/pdf","name":"plan.pdf","size":900}]}, \\ {"id":"e2","threadId":"t2"}]},"corpus"]]} }); const got = try readCorpus(arena, list, "corpus"); try testing.expectEqual(2, got.len); const r = got[0]; try testing.expectEqualStrings("t1", r.thread); try testing.expectEqualStrings("m1@example.com", r.message_id); try testing.expectEqualStrings("Re: Plan", r.subject); try testing.expectEqualStrings("Dev ", r.list_id); try testing.expectEqualStrings("tim+dev@example.com", r.delivered_to); try testing.expectEqual(1_794_128_400, r.date); try testing.expectEqual(5120, r.size); try testing.expectEqualStrings("Ana", r.from.name); try testing.expectEqualStrings("", r.to[0].name); try testing.expectEqualStrings("tim@example.com", r.to[0].address); try testing.expectEqual(0, r.cc.len); try testing.expectEqualStrings("bo@example.com", r.bcc[0].address); try testing.expectEqualStrings("plan.pdf", r.attachments[0].name); try testing.expectEqual(900, r.attachments[0].size); try testing.expectEqualStrings("Yes", r.text); try testing.expectEqualStrings("On Monday, Tim wrote:\nPlan?", r.quoted); // A message with nothing to say is kept all the same. try testing.expectEqualStrings("e2", got[1].id); try testing.expectEqualStrings("", got[1].text); } test "a draft read back: its text as typed, a reply's thread but a new message none, its files; a sent one is none" { var arena_state: ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const reply_draft = try replies("", arena, .{ .status = 200, .url = "", .body = \\{"methodResponses":[["Email/get",{"list":[{"id":"d1","threadId":"t1","keywords":{"$draft":true}, \\ "to":[{"name":"Ana","email":"ana@example.com"}],"subject":"Re: Plan", \\ "inReplyTo":["m1@example.com"],"references":["m0@example.com","m1@example.com"], \\ "textBody":[{"partId":"1","type":"text/plain"}], \\ "bodyValues":{"1":{"value":"\n\nOn Monday, Ana wrote:\n> Hi\n"}}, \\ "attachments":[{"partId":"2","blobId":"b2","type":"application/pdf","name":"plan.pdf"}]}]},"draft"]]} }); const d = (try readDraft(arena, reply_draft)).?; try testing.expectEqualStrings("Ana ", d.message.to); try testing.expectEqualStrings("\n\nOn Monday, Ana wrote:\n> Hi\n", d.message.body); try testing.expectEqualStrings("t1", d.message.thread); try testing.expectEqualStrings("", d.message.in_reply_to); try testing.expectEqualStrings(" ", d.message.references); try testing.expectEqualStrings("plan.pdf", d.message.attachments[0].name); try testing.expectEqualStrings("b2", d.blobs[0]); const new = try replies("", arena, .{ .status = 200, .url = "", .body = \\{"methodResponses":[["Email/get",{"list":[{"id":"d2","threadId":"t2","keywords":{"$draft":true}, \\ "subject":"Lunch"}]},"draft"]]} }); try testing.expectEqualStrings("", (try readDraft(arena, new)).?.message.thread); const sent = try replies("", arena, .{ .status = 200, .url = "", .body = \\{"methodResponses":[["Email/get",{"list":[{"id":"d3","threadId":"t3","keywords":{"$seen":true}}]},"draft"]]} }); try testing.expect(try readDraft(arena, sent) == null); } test "events end at a blank line, in any line ending; a change to this account's mail is told, another's is not" { var arena_state: ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); var jmap: Jmap = .init(undefined, ""); jmap.account_id = "u1"; const stream = ": hello\r\nevent: ping\r\ndata: {\"interval\":60}\r\n\r\n" ++ "event: state\ndata: {\"@type\":\"StateChange\",\n" ++ "data:\"changed\":{\"u1\":{\"Email\":\"s1\"}}}\n\n" ++ "event: state\ndata: {\"changed\":{\"u2\":{\"Email\":\"s9\"}}}\n\nevent: sta"; var rest: []const u8 = stream; var told: [3]bool = undefined; for (&told) |*t| { const next = (try nextEvent(arena, rest)).?; t.* = jmap.changed(arena, next.event); rest = rest[next.len..]; } try testing.expectEqualSlices(bool, &.{ false, true, false }, &told); try testing.expectEqualStrings("event: sta", rest); try testing.expect(try nextEvent(arena, rest) == null); const ping = (try nextEvent(arena, stream)).?.event; try testing.expectEqualStrings("ping", ping.name); try testing.expectEqualStrings("{\"interval\":60}", ping.data); } test "a token is a bearer token, and user:password goes as Basic" { var arena_state: ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); try testing.expectEqualStrings("authorization: Bearer fmu1-abc", try authorizationFor(arena, "fmu1-abc")); try testing.expectEqualStrings("authorization: Basic dGltOnB3", try authorizationFor(arena, "tim:pw")); } test "requests name submission only when the session offers it, as a read-only token's does not" { var arena_state: ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const session = \\{"apiUrl":"a","downloadUrl":"d","uploadUrl":"u","primaryAccounts":{"urn:ietf:params:jmap:mail":"A1"}, \\"capabilities":{"urn:ietf:params:jmap:core":{},"urn:ietf:params:jmap:mail":{}{s}}} ; for ([_][]const u8{ "", ",\"urn:ietf:params:jmap:submission\":{\"maxDelayedSend\":0}" }) |more| { var jmap: Jmap = .init(undefined, ""); try jmap.readSession(arena, try mem.replaceOwned(u8, arena, session, "{s}", more)); var r: Request = undefined; try r.init(&jmap, arena); const offered = more.len > 0; try testing.expectEqual(offered, jmap.can_submit); try testing.expectEqual(offered, mem.find(u8, try r.body(), submission) != null); } } test "an identity's signature is its text one, else its HTML one made text" { var arena_state: ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const html_only = try replies("", arena, .{ .status = 200, .url = "", .body = \\{"methodResponses":[["Identity/get",{"list":[{"id":"i1","name":"Tim","email":"tim@example.com", \\ "textSignature":"","htmlSignature":""}]}, \\ "identities"]]} }); const i = (try readIdentity(arena, html_only)).?; try testing.expectEqualStrings("Tim\nexample.org", mem.trim(u8, i.signature, "\n")); const both = try replies("", arena, .{ .status = 200, .url = "", .body = \\{"methodResponses":[["Identity/get",{"list":[{"id":"i1","email":"tim@example.com", \\ "textSignature":"-- Tim","htmlSignature":"Timothy"}]},"identities"]]} }); try testing.expectEqualStrings("-- Tim", (try readIdentity(arena, both)).?.signature); }