//! 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"Hi
Hi
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