Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719//! 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 <address>". 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" <maya@example.com>, …`.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: "<a@x> <b@y>".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("<meta charset=\"utf-8\">"); 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 <maya@example.com>", 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 <maya@example.com>", 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":"<logo>"}, \\ {"partId":"4","blobId":"b4","type":"application/pdf","name":"plan.pdf"}, \\ {"partId":"5","blobId":"b5","type":"text/calendar","name":"invite.ics"}], \\ "bodyValues":{"1":{"value":"<p>Hi <img src=\"cid:logo\"></p>"},"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 <b> & 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("<meta charset=\"utf-8\"><p>Hi <img src=\"cid:logo\"></p>", m.html); try testing.expectEqualStrings("Hi", m.text); try testing.expectEqualStrings("<m1@example.com>", m.message_id); try testing.expectEqualStrings("<m0@example.com>", m.references); try testing.expectEqualStrings("\"Tim, the boss\" <tim@example.com>", m.to); try testing.expectEqualStrings("Ana <ana@example.com>", m.reply_to); try testing.expect(m.unread and !m.sent and m.one_click); try testing.expectEqualStrings("<mailto:leave@example.com>, <https://example.com/u>", 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( "<meta charset=\"utf-8\"><div style=\"white-space: pre-wrap\">a <b> & c</div>", 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, .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 <dev.lists.example>","header:Delivered-To:asText":" tim+dev@example.com", \\ "textBody":[{"partId":"1","type":"text/html"}], \\ "bodyValues":{"1":{"value":"<p>Yes</p><p>On Monday, Tim wrote:</p><p>> Plan?</p>"}}, \\ "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 <dev.lists.example>", 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, .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 <ana@example.com>", 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("<m1@example.com>", d.message.in_reply_to); try testing.expectEqualStrings("<m0@example.com> <m1@example.com>", 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, .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, .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, .body = \\{"methodResponses":[["Identity/get",{"list":[{"id":"i1","name":"Tim","email":"tim@example.com", \\ "textSignature":"","htmlSignature":"<div>Tim<br><a href=\"https://example.org\">example.org</a></div>"}]}, \\ "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, .body = \\{"methodResponses":[["Identity/get",{"list":[{"id":"i1","email":"tim@example.com", \\ "textSignature":"-- Tim","htmlSignature":"<b>Timothy</b>"}]},"identities"]]} }); try testing.expectEqualStrings("-- Tim", (try readIdentity(arena, both)).?.signature);}