//! Gmail's REST API (https://developers.google.com/workspace/gmail/api): //! the account's labels and its newest threads, put into a Mailbox; the //! pages of any list of threads, and threads read by id, for the list's //! pages and the backfill; every message, whole, for search to keep; the //! requests that change a thread's labels or trash it; and those that //! keep, send, and delete the user's draft. //! //! Requests go through Http, on the event loop. const std = @import("std"); const siv = @import("siv"); 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 assert = std.debug.assert; const fmt = std.fmt; const json = std.json; const mem = std.mem; const meta = std.meta; const testing = std.testing; const unicode = std.unicode; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const ArrayList = std.ArrayList; const Io = std.Io; const Label = Mailbox.Label; const Rgb = Mailbox.Rgb; const Writer = Io.Writer; const log = std.log.scoped(.gmail); const Gmail = @This(); http: *Http, /// The signed-in user's root: `api`, or a local server in tests. base: []const u8, /// "authorization: Bearer " and an access token, as a header line. authorization: []const u8, pub const api = "https://gmail.googleapis.com/gmail/v1/users/me"; /// How much to read. // ast-grep-ignore: public-struct-field-default -- an options bag for load. pub const LoadOptions = struct { /// The newest threads of the Inbox, and of all mail, up to this many each. threads: u16 = 50, /// Hears who is on the messages read. Until it is seeded, an update /// reads the newest threads again for it, as a whole load would. contacts: ?*Contacts = null, }; /// Hears that a load finished, or why it did not. pub const Done = *const fn (context: *anyopaque, result: anyerror!void) void; /// Adds the account's user labels to `mail`, then its newest threads: /// the Inbox's, and all mail's, so other labels' views have some too. /// It runs on the event loop, the threads' requests side by side, and /// `done` hears the end. `arena` holds the work until then. pub fn load( self: Gmail, arena: Allocator, mail: *Mailbox, options: LoadOptions, context: *anyopaque, done: Done, ) !void { const l = try Load.create(arena, self, .{ .whole = mail }, options, context, done); try l.send("/labels", Load.onLabels); l.step(l.send("/profile?fields=historyId", Load.onProfile)); } /// What changed in the account since a mailbox's history id, as /// `update` reads it, to put in the mailbox in place. `arena` holds it. pub const Changes = struct { /// The user's labels as they are, in the order a mailbox keeps them. labels: []const Mailbox.LabelInfo, /// The threads that changed or came, read again. threads: []const Thread, /// Gmail's ids of the threads gone. gone: std.array_hash_map.String(void), /// Gmail's ids of the messages deleted, for search to forget. deleted: []const []const u8, /// Gmail's history id as of these. history: u64, pub const empty: Changes = .{ .labels = &.{}, .threads = &.{}, .gone = .empty, .deleted = &.{}, .history = 0 }; /// Whether `mail` has the user's labels as they are, in the same /// places, so the threads can go in it as they are named. pub fn sameLabels(c: *const Changes, mail: *const Mailbox) bool { const have = mail.userLabels(); if (have.len != c.labels.len) return false; for (have, c.labels) |a, b| { if (!mem.eql(u8, a.id, b.id) or !mem.eql(u8, a.name, b.name)) return false; if (a.kind != b.kind or !meta.eql(a.color, b.color)) return false; } return true; } /// A mailbox with the changes and `old`'s other threads, by Gmail's /// ids, with how far `old` was walked: for when the labels changed, /// which moves their places. pub fn rebuild(c: *const Changes, gpa: Allocator, old: *const Mailbox) !Mailbox { var mail: Mailbox = try .init(gpa); errdefer mail.deinit(); for (c.labels) |info| _ = try mail.addLabel(info); try put(&mail, c.threads); try mail.copyAbsent(old, &c.gone); try mail.setWalk(old.walk); mail.history = c.history; return mail; } }; /// Reads into `changes` what changed since `old.history`: Gmail's labels /// afresh, the threads named in the history since, read again, and /// those of them gone; `old` must outlast the work. /// error.HistoryExpired (Gmail keeps about a week) and /// error.TooManyChanges mean a whole `load` is the better way. pub fn update( self: Gmail, arena: Allocator, old: *const Mailbox, changes: *Changes, options: LoadOptions, context: *anyopaque, done: Done, ) !void { changes.* = .empty; const l = try Load.create(arena, self, .{ .changes = .{ .old = old, .out = changes } }, options, context, done); l.history = old.history; try l.send("/labels", Load.onLabels); l.step(l.sendHistory(null)); } /// A load under way. A whole one asks for the labels and the history id, /// then the two thread lists, then every thread on them; an update asks /// for the labels and the history, then the threads it names. Each reply /// counts itself off `pending`, and the last one out ends the load, so /// `done` is heard once, with nothing left to touch `arena` after. const Load = struct { arena: Allocator, gmail: Gmail, into: Into, options: LoadOptions, context: *anyopaque, done: Done, stage: enum { start, lists, threads, added }, /// Requests still out. pending: u32, /// The first thing that went wrong; the load ends once nothing is out. failure: ?anyerror, /// The threads to read, in the order they were named. ids: std.array_hash_map.String(void), results: []Result, /// Gmail's history id for the mail as read. history: u64, /// The messages the history says were deleted. deleted: ArrayList([]const u8), const Result = union(enum) { missing, gone, thread: Thread }; /// What a whole load fills; or what an update reads the changes to, /// and where it puts them. const Into = union(enum) { whole: *Mailbox, changes: struct { old: *const Mailbox, out: *Changes }, }; fn create( arena: Allocator, gmail: Gmail, into: Into, options: LoadOptions, context: *anyopaque, done: Done, ) !*Load { const l = try arena.create(Load); l.* = .{ .arena = arena, .gmail = gmail, .into = into, .options = options, .context = context, .done = done, .stage = .start, .pending = 0, .failure = null, .ids = .empty, .results = &.{}, .history = 0, .deleted = .empty, }; return l; } fn send(l: *Load, path: []const u8, done: Http.Done) !void { try l.gmail.get(l.arena, path, l, done); l.pending += 1; } fn sendHistory(l: *Load, page: ?[]const u8) !void { const path = try l.arena.print( "/history?startHistoryId={d}&maxResults=500" ++ "&fields=history(messages/threadId,messagesDeleted/message/id),historyId,nextPageToken{s}{s}", .{ l.history, if (page != null) "&pageToken=" else "", page orelse "" }, ); try l.send(path, onHistory); } fn onLabels(context: *anyopaque, response: Http.Error!Http.Response) void { const l: *Load = @ptrCast(@alignCast(context)); l.pending -= 1; l.step(l.labels(response)); } fn labels(l: *Load, response: Http.Error!Http.Response) !void { const infos = try parseLabels(l.arena, try check("GET /labels", l.arena, response)); switch (l.into) { .whole => |mail| for (infos) |info| { _ = try mail.addLabel(info); }, .changes => |c| c.out.labels = infos, } } fn onProfile(context: *anyopaque, response: Http.Error!Http.Response) void { const l: *Load = @ptrCast(@alignCast(context)); l.pending -= 1; l.step(l.profile(response)); } fn profile(l: *Load, response: Http.Error!Http.Response) !void { const Profile = struct { historyId: []const u8 = "0" }; const p = try parse(Profile, l.arena, try check("GET /profile", l.arena, response)); l.history = try fmt.parseInt(u64, p.historyId, 10); } fn onHistory(context: *anyopaque, response: Http.Error!Http.Response) void { const l: *Load = @ptrCast(@alignCast(context)); l.pending -= 1; l.step(l.historyPage(response)); } fn historyPage(l: *Load, response: Http.Error!Http.Response) !void { const History = struct { history: []const struct { messages: []const struct { threadId: []const u8 } = &.{}, messagesDeleted: []const struct { message: struct { id: []const u8 } } = &.{}, } = &.{}, historyId: []const u8 = "0", nextPageToken: ?[]const u8 = null, }; const body = check("GET /history", l.arena, response) catch |err| return if (err == error.NotFound) error.HistoryExpired else err; const h = try parse(History, l.arena, body); for (h.history) |record| { for (record.messages) |m| try l.ids.put(l.arena, m.threadId, {}); for (record.messagesDeleted) |d| try l.deleted.append(l.arena, d.message.id); } if (h.nextPageToken) |page| return l.sendHistory(page); l.history = try fmt.parseInt(u64, h.historyId, 10); } fn onList(context: *anyopaque, response: Http.Error!Http.Response) void { const l: *Load = @ptrCast(@alignCast(context)); l.pending -= 1; l.step(l.list(response)); } fn list(l: *Load, response: Http.Error!Http.Response) !void { const Json = struct { threads: []const struct { id: []const u8 } = &.{} }; const ids = try parse(Json, l.arena, try check("GET /threads", l.arena, response)); for (ids.threads) |t| try l.ids.put(l.arena, t.id, {}); } /// Notes how a reply went, starts what comes next once the step is /// done, and ends the load once nothing is out. fn step(l: *Load, result: anyerror!void) void { result catch |err| { if (l.failure == null) l.failure = err; }; while (l.pending == 0 and l.failure == null and l.stage != .added) { l.advance() catch |err| { l.failure = err; }; } if (l.pending > 0) return; l.done(l.context, if (l.failure) |err| err else {}); } fn advance(l: *Load) !void { switch (l.stage) { .start => if (l.into == .whole) { l.stage = .lists; for ([_][]const u8{ "labelIds=INBOX&", "" }) |filter| { const path = try l.arena.print( "/threads?{s}maxResults={d}&fields=threads/id", .{ filter, l.options.threads }, ); try l.send(path, onList); } } else try l.readThreads(), .lists => try l.readThreads(), .threads => try l.complete(), .added => unreachable, } } fn readThreads(l: *Load) !void { // Past this, two whole lists cost less quota than the threads. const most = 2 * @as(u32, l.options.threads); switch (l.into) { .whole => {}, .changes => |c| { if (l.ids.count() > most) return error.TooManyChanges; if (l.options.contacts) |contacts| if (!contacts.seeded) try l.seed(c.old, most); }, } l.stage = .threads; l.results = try l.arena.alloc(Result, l.ids.count()); @memset(l.results, .missing); for (l.ids.keys(), 0..) |id, i| { const one = try l.arena.create(One); one.* = .{ .load = l, .index = @intCast(i) }; const path = try l.arena.print("/threads/{s}" ++ metadata, .{id}); try l.gmail.get(l.arena, path, one, One.onThread); l.pending += 1; } } /// Names the newest of `old`'s threads too, up to `most` in all, to /// read the people on them for contacts that were never seeded. fn seed(l: *Load, old: *const Mailbox, most: u32) !void { const order = try l.arena.alloc(u32, old.threads.items.len); for (order, 0..) |*o, i| o.* = @intCast(i); mem.sort(u32, order, old, struct { fn newer(m: *const Mailbox, a: u32, b: u32) bool { return m.threads.items[a].date > m.threads.items[b].date; } }.newer); for (order) |i| { if (l.ids.count() >= most) break; try l.ids.put(l.arena, old.threads.items[i].id, {}); } } /// Puts the threads that came back, in the order they were named, in /// the mailbox, or in the changes with those gone. One that could not /// be read is in neither, so a mailbox keeps its copy. fn complete(l: *Load) !void { l.stage = .added; var missing: u32 = 0; var threads: ArrayList(Thread) = try .initCapacity(l.arena, l.results.len); var gone: std.array_hash_map.String(void) = .empty; for (l.results, l.ids.keys()) |*r, id| switch (r.*) { .thread => |t| threads.appendAssumeCapacity(t), .gone => try gone.put(l.arena, id, {}), .missing => missing += 1, }; if (l.options.contacts) |c| { try notePeople(c, threads.items); c.seeded = true; } if (missing > 0) log.warn("{d} of {d} threads could not be read", .{ missing, l.results.len }); switch (l.into) { .whole => |mail| { try put(mail, threads.items); mail.history = l.history; }, .changes => |c| { c.out.threads = threads.items; c.out.gone = gone; c.out.deleted = l.deleted.items; c.out.history = l.history; }, } } }; /// One thread's request. A thread that cannot be read is left out: one /// should not cost the rest. const One = struct { load: *Load, index: u32, fn onThread(context: *anyopaque, response: Http.Error!Http.Response) void { const one: *One = @ptrCast(@alignCast(context)); const l = one.load; l.pending -= 1; l.results[one.index] = if (check("GET /threads/…", l.arena, response)) |body| blk: { const t = parseThread(l.arena, body) catch |err| { log.warn("thread {s}: {t}", .{ l.ids.keys()[one.index], err }); break :blk .missing; }; break :blk .{ .thread = t }; } else |err| if (err == error.NotFound) .gone else .missing; l.step({}); } }; /// How a thread is asked for to show it in a list (40 quota units), with /// who is on each message for the contacts. const metadata = "?format=metadata&metadataHeaders=From&metadataHeaders=Subject" ++ "&metadataHeaders=To&metadataHeaders=Cc" ++ "&fields=id,historyId,messages(labelIds,snippet,internalDate,payload/headers)"; /// What a list of threads shows, as Gmail's threads.list takes it. pub const Query = struct { /// Gmail's ids of labels a thread must all have; none for all mail. labels: []const []const u8, /// Words in the syntax of Gmail's search box (from:, has:attachment); /// empty for none. search: []const u8, }; /// A thread as a list names it: what Gmail calls it, and its historyId, /// which grows when it changes. pub const Listed = struct { id: []const u8, version: u64 }; /// A page of a list, newest first, and the token of the next; null at /// the end. pub const List = struct { threads: []const Listed, next: ?[]const u8 }; pub const Listing = *const fn (context: *anyopaque, result: anyerror!List) void; /// Asks for a page of the threads `query` shows (10 quota units, however /// many): the first when `page` is empty, else the one it names, as /// `List.next` gave it. Spam and trash are left out, as Gmail's own /// lists leave them. `arena` holds the work and the page. pub fn list( self: Gmail, arena: Allocator, query: Query, page: []const u8, max: u16, context: *anyopaque, done: Listing, ) !void { var path: Writer.Allocating = .init(arena); const w = &path.writer; try w.print("/threads?maxResults={d}&fields=threads(id,historyId),nextPageToken", .{max}); for (query.labels) |l| { try w.writeAll("&labelIds="); try std.Uri.Component.percentEncode(w, l, isUnreserved); } if (query.search.len > 0) { try w.writeAll("&q="); try std.Uri.Component.percentEncode(w, query.search, isUnreserved); } if (page.len > 0) { try w.writeAll("&pageToken="); try std.Uri.Component.percentEncode(w, page, isUnreserved); } const call = try arena.create(ListCall); call.* = .{ .arena = arena, .context = context, .done = done }; try self.get(arena, path.written(), call, ListCall.onList); } fn isUnreserved(c: u8) bool { return ascii.isAlphanumeric(c) or c == '-' or c == '.' or c == '_' or c == '~'; } const ListCall = struct { arena: Allocator, context: *anyopaque, done: Listing, fn onList(context: *anyopaque, response: Http.Error!Http.Response) void { const call: *ListCall = @ptrCast(@alignCast(context)); call.done(call.context, call.read(response)); } fn read(call: *ListCall, response: Http.Error!Http.Response) !List { const Json = struct { threads: []const struct { id: []const u8, historyId: []const u8 = "0" } = &.{}, nextPageToken: ?[]const u8 = null, }; const got = try parse(Json, call.arena, try check("GET /threads?", call.arena, response)); const threads = try call.arena.alloc(Listed, got.threads.len); for (got.threads, threads) |t, *out| { out.* = .{ .id = t.id, .version = fmt.parseInt(u64, t.historyId, 10) catch 0 }; } return .{ .threads = threads, .next = got.nextPageToken }; } }; /// How many threads `read` asks for at once. Google turns away a user's /// requests past some number at once, and a few out keep the reads quick /// enough without crowding out the user's other requests. const reads_at_once = 4; pub const Reading = *const fn (context: *anyopaque, result: anyerror![]const Thread) void; /// Reads the threads `ids` names, as a list shows them (40 quota units /// each), a few at a time. `done` hears those read, in the order named: /// one deleted meanwhile, or one that cannot be made sense of, is left /// out. A request that failed fails the whole, which the caller can try /// again. `arena` holds the work and the threads; `ids` must not be /// empty. pub fn read(self: Gmail, arena: Allocator, ids: []const []const u8, context: *anyopaque, done: Reading) !void { assert(ids.len > 0); const call = try arena.create(ReadCall); call.* = .{ .arena = arena, .gmail = self, .ids = ids, .results = try arena.alloc(?Thread, ids.len), .sent = 0, .out = 0, .failure = null, .context = context, .done = done, }; @memset(call.results, null); while (call.out < reads_at_once and call.sent < ids.len) try call.sendNext(); } const ReadCall = struct { arena: Allocator, gmail: Gmail, ids: []const []const u8, results: []?Thread, /// How many of `ids` have been asked for. sent: u32, out: u32, failure: ?anyerror, context: *anyopaque, done: Reading, const Request = struct { call: *ReadCall, index: u32 }; fn sendNext(call: *ReadCall) !void { const one = try call.arena.create(Request); one.* = .{ .call = call, .index = call.sent }; const path = try call.arena.print("/threads/{s}" ++ metadata, .{call.ids[call.sent]}); try call.gmail.get(call.arena, path, one, onThread); call.sent += 1; call.out += 1; } fn onThread(context: *anyopaque, response: Http.Error!Http.Response) void { const one: *Request = @ptrCast(@alignCast(context)); const call = one.call; call.out -= 1; const id = call.ids[one.index]; if (check("GET /threads/…", call.arena, response)) |body| { call.results[one.index] = parseThread(call.arena, body) catch |err| blk: { log.warn("thread {s}: {t}", .{ id, err }); break :blk null; }; } else |err| switch (err) { error.NotFound => log.debug("thread {s} is gone", .{id}), else => if (call.failure == null) { call.failure = err; }, } if (call.failure == null and call.sent < call.ids.len) { call.sendNext() catch |err| { call.failure = err; }; } if (call.out > 0) return; call.done(call.context, call.finish()); } fn finish(call: *ReadCall) ![]const Thread { if (call.failure) |err| return err; var threads: ArrayList(Thread) = try .initCapacity(call.arena, call.results.len); for (call.results) |r| if (r) |t| threads.appendAssumeCapacity(t); return threads.items; } }; /// A thread as Gmail describes it, reduced to what the mailbox holds. pub const Thread = struct { id: []const u8, participants: []const u8, subject: []const u8, snippet: []const u8, date: i64, messages: u32, label_ids: []const []const u8, unread: bool, /// Who is on its messages, for the contacts. people: []const People, /// Its messages, for search. notes: []const Mailbox.NewNote, /// Its historyId as read. version: u64, /// An address list of a message, and how the user knows those on it. const People = struct { how: Contacts.How, list: []const u8, ms: i64 }; }; /// Adds who is on the threads' messages to `contacts`. pub fn notePeople(contacts: *Contacts, threads: []const Thread) Allocator.Error!void { for (threads) |*t| for (t.people) |p| try contacts.note(p.how, p.list, p.ms); } /// The JSON of a thread fetched with format=metadata. const ThreadJson = struct { id: []const u8, historyId: []const u8 = "0", messages: []const struct { labelIds: []const []const u8 = &.{}, snippet: []const u8 = "", internalDate: []const u8 = "0", payload: struct { headers: []const struct { name: []const u8, value: []const u8 } = &.{} } = .{}, }, }; /// Reduces a thread to one line of the list: who wrote in it and about /// what, as of its newest message, with every message's labels. fn parseThread(arena: Allocator, body: []const u8) !Thread { const t = try parse(ThreadJson, arena, body); if (t.messages.len == 0) return error.EmptyThread; const first = t.messages[0]; const last = t.messages[t.messages.len - 1]; var label_ids: std.array_hash_map.String(void) = .empty; var unread = false; for (t.messages) |m| { for (m.labelIds) |l| { if (mem.eql(u8, l, "UNREAD")) unread = true else try label_ids.put(arena, l, {}); } } var subject: []const u8 = ""; for (first.payload.headers) |h| { if (ascii.eqlIgnoreCase(h.name, "Subject")) subject = h.value; } const ms = fmt.parseInt(i64, last.internalDate, 10) catch 0; return .{ .id = t.id, .participants = try participants(arena, t), .subject = if (subject.len == 0) "(no subject)" else subject, .snippet = try html.decodeEntities(arena, last.snippet), .date = @divFloor(ms, 1000), .messages = @intCast(t.messages.len), .label_ids = label_ids.keys(), .unread = unread, .people = try people(arena, t), .notes = try notes(arena, t), .version = fmt.parseInt(u64, t.historyId, 10) catch 0, }; } /// Who is on a thread's messages: whom the user wrote to, in To and Cc /// of a message sent; who wrote to the user, in From of one received, /// and who else it went to. A draft is no message yet. fn people(arena: Allocator, t: ThreadJson) ![]const Thread.People { var out: ArrayList(Thread.People) = .empty; for (t.messages) |m| { if (hasLabel(m.labelIds, "DRAFT")) continue; const sent = hasLabel(m.labelIds, "SENT"); const ms = fmt.parseInt(i64, m.internalDate, 10) catch 0; for (m.payload.headers) |h| { const how: Contacts.How = if (ascii.eqlIgnoreCase(h.name, "From")) if (sent) continue else .heard else if (ascii.eqlIgnoreCase(h.name, "To") or ascii.eqlIgnoreCase(h.name, "Cc")) if (sent) .wrote else .copied else continue; try out.append(arena, .{ .how = how, .list = h.value, .ms = ms }); } } return out.items; } /// A thread's messages as search knows them: when each came and who is /// on it. A draft is no message yet. fn notes(arena: Allocator, t: ThreadJson) ![]const Mailbox.NewNote { var out: ArrayList(Mailbox.NewNote) = try .initCapacity(arena, t.messages.len); for (t.messages) |m| { if (hasLabel(m.labelIds, "DRAFT")) continue; var from: Mailbox.Person = .{ .name = "", .address = "" }; var to: []const Mailbox.Person = &.{}; var cc: []const Mailbox.Person = &.{}; for (m.payload.headers) |h| { if (ascii.eqlIgnoreCase(h.name, "From")) { var it: Contacts.Addresses = .{ .rest = h.value }; if (it.next()) |entry| from = person(entry); } else if (ascii.eqlIgnoreCase(h.name, "To")) { to = try persons(arena, h.value); } else if (ascii.eqlIgnoreCase(h.name, "Cc")) { cc = try persons(arena, h.value); } } const ms = fmt.parseInt(i64, m.internalDate, 10) catch 0; out.appendAssumeCapacity(.{ .date = @divFloor(ms, 1000), .from = from, .to = to, .cc = cc }); } return out.items; } /// The people of an address list. fn persons(arena: Allocator, addresses: []const u8) ![]const Mailbox.Person { var out: ArrayList(Mailbox.Person) = .empty; var it: Contacts.Addresses = .{ .rest = addresses }; while (it.next()) |entry| try out.append(arena, person(entry)); return out.items; } /// An entry of an address list as a name and an address: /// `"Maya Okafor" `, or a bare address with no name. fn person(entry: []const u8) Mailbox.Person { const address = mem.trim(u8, Contacts.bare(entry), " \t"); if (mem.findScalar(u8, entry, '<') == null) return .{ .name = "", .address = address }; const name = displayName(entry); return .{ .name = if (mem.eql(u8, name, address)) "" else name, .address = address }; } /// Asks for a thread whole, its messages' bodies too, for the reader. /// It costs 120 quota units, three threads' worth, so it is for a thread /// being opened. `messages` reads the reply. pub fn readThread(self: Gmail, arena: Allocator, thread: []const u8, context: *anyopaque, done: Http.Done) !void { const path = try arena.print("/threads/{s}?format=full&fields=messages(" ++ whole_fields ++ ")", .{thread}); try self.get(arena, path, context, done); } /// Asks for a message whole (60 quota units, half a thread's), for /// search to keep; `readCorpus` reads the reply. pub fn readMessage(self: Gmail, arena: Allocator, id: []const u8, context: *anyopaque, done: Http.Done) !void { try self.get(arena, try arena.print("/messages/{s}?format=full&fields=" ++ whole_fields, .{id}), context, done); } /// What a message read whole names. const whole_fields = "id,threadId,labelIds,snippet,internalDate,sizeEstimate,payload"; /// A message read whole. const WholeMessage = struct { id: []const u8 = "", threadId: []const u8 = "", labelIds: []const []const u8 = &.{}, snippet: []const u8 = "", internalDate: []const u8 = "0", sizeEstimate: u32 = 0, payload: Part = .{}, }; /// Asks for a page of every message but drafts, newest first, by its id /// and its thread's (5 quota units for up to 500): the first when `page` /// is empty, else the one it names. Spam and trash are left out, as in /// Gmail's own search. `messageIds` reads the reply. pub fn listMessages(self: Gmail, arena: Allocator, page: []const u8, context: *anyopaque, done: Http.Done) !void { var path: Writer.Allocating = .init(arena); const w = &path.writer; try w.writeAll("/messages?maxResults=500&q=-in%3Adrafts&fields=messages(id,threadId),nextPageToken"); if (page.len > 0) { try w.writeAll("&pageToken="); try std.Uri.Component.percentEncode(w, page, isUnreserved); } try self.get(arena, path.written(), context, done); } /// A page of messages `listMessages` asked for, and the next one's /// token, null after the last. pub const MessageIds = struct { messages: []const MessageId, next: ?[]const u8 }; pub const MessageId = struct { id: []const u8, threadId: []const u8 }; pub fn messageIds(arena: Allocator, body: []const u8) !MessageIds { const Json = struct { messages: []const MessageId = &.{}, nextPageToken: ?[]const u8 = null }; const page = try parse(Json, arena, body); return .{ .messages = page.messages, .next = page.nextPageToken }; } /// What search keeps of the messages a `readMessage` or `readThread` /// reply holds, but drafts, spam, and trash, which `listMessages` leaves /// out too. pub fn readCorpus(arena: Allocator, body: []const u8) ![]const Corpus.Record { const thread = try parse(struct { messages: ?[]const WholeMessage = null }, arena, body); const one: [1]WholeMessage = if (thread.messages == null) .{try parse(WholeMessage, arena, body)} else undefined; const all = thread.messages orelse &one; var out: ArrayList(Corpus.Record) = try .initCapacity(arena, all.len); for (all) |*m| { const kept = for ([_][]const u8{ "DRAFT", "SPAM", "TRASH" }) |l| { if (hasLabel(m.labelIds, l)) break false; } else true; if (!kept) continue; var content: Content = .{}; var shown: ArrayList(Mailbox.Attachment) = .empty; try content.find(arena, &m.payload, &shown); var files: ArrayList(Corpus.Attachment) = .empty; try corpusFiles(arena, &m.payload, &files); const text = try Corpus.split(arena, try content.asText(arena)); const headers = m.payload.headers; const from = person(header(headers, "From") orelse ""); out.appendAssumeCapacity(.{ .id = m.id, .thread = m.threadId, .message_id = mem.trim(u8, header(headers, "Message-ID") orelse "", " \t<>"), .subject = header(headers, "Subject") orelse "", .list_id = mem.trim(u8, header(headers, "List-Id") orelse "", " \t\r\n"), .delivered_to = mem.trim(u8, header(headers, "Delivered-To") orelse "", " \t"), .date = @divFloor(fmt.parseInt(i64, m.internalDate, 10) catch 0, 1000), .size = m.sizeEstimate, .from = .{ .name = from.name, .address = from.address }, .to = try corpusPeople(arena, header(headers, "To")), .cc = try corpusPeople(arena, header(headers, "Cc")), .bcc = try corpusPeople(arena, header(headers, "Bcc")), .attachments = files.items, .text = text.said, .quoted = text.quoted, }); } return out.items; } /// The parts of a message that are files, by their names. fn corpusFiles(arena: Allocator, part: *const Part, out: *ArrayList(Corpus.Attachment)) !void { if (part.filename.len > 0) { return out.append(arena, .{ .name = part.filename, .mime = part.mimeType, .size = part.body.size }); } for (part.parts) |*p| try corpusFiles(arena, p, out); } fn corpusPeople(arena: Allocator, addresses: ?[]const u8) ![]const Corpus.Person { const in = try persons(arena, addresses orelse return &.{}); const out = try arena.alloc(Corpus.Person, in.len); for (in, out) |p, *c| c.* = .{ .name = p.name, .address = p.address }; return out; } /// Asks for an attachment's bytes (20 quota units); `attachmentData` /// reads the reply. pub fn readAttachment( self: Gmail, arena: Allocator, message: []const u8, attachment: []const u8, context: *anyopaque, done: Http.Done, ) !void { const path = try arena.print("/messages/{s}/attachments/{s}?fields=data", .{ message, attachment }); try self.get(arena, path, context, done); } pub fn attachmentData(arena: Allocator, body: []const u8) ![]const u8 { const Body = struct { data: []const u8 = "" }; return decodeBase64(arena, (try parse(Body, arena, body)).data); } /// Keeps a message as a new draft (10 quota units), in thread `thread` /// when it is a reply; `raw` is what `mime.message` makes of it. `draftId` /// reads the reply. `arena` may go once this returns. pub fn createDraft( self: Gmail, arena: Allocator, raw: []const u8, thread: []const u8, context: *anyopaque, done: Http.Done, ) !void { try self.upload(arena, .post, "/drafts", try draftJson(arena, null, thread), raw, context, done); } /// Replaces draft `id`'s message with `raw` (15 quota units). pub fn updateDraft( self: Gmail, arena: Allocator, id: []const u8, raw: []const u8, thread: []const u8, context: *anyopaque, done: Http.Done, ) !void { const path = try arena.print("/drafts/{s}", .{id}); try self.upload(arena, .put, path, try draftJson(arena, id, thread), raw, context, done); } /// Sends draft `id` (100 quota units): `raw` in place of what it kept, /// or what it kept when null. Gmail adds From, Date, and Message-ID. pub fn sendDraft( self: Gmail, arena: Allocator, id: []const u8, raw: ?[]const u8, thread: []const u8, context: *anyopaque, done: Http.Done, ) !void { if (raw) |r| return self.upload(arena, .post, "/drafts/send", try draftJson(arena, id, thread), r, context, done); // A message here, even with no raw, would replace the one kept. try self.post(arena, "/drafts/send", try draftJson(arena, id, null), context, done); } /// Deletes draft `id` for good (10 quota units), as `check` reads. pub fn deleteDraft(self: Gmail, arena: Allocator, id: []const u8, context: *anyopaque, done: Http.Done) !void { const url = try mem.concat(arena, u8, &.{ self.base, "/drafts/", id }); const headers = try arena.dupe([]const u8, &.{self.authorization}); try self.http.send(.{ .method = .delete, .url = url, .headers = headers, .body = null }, context, done); } /// Asks for draft `id` whole (60 quota units); `draft` reads the reply. pub fn readDraft(self: Gmail, arena: Allocator, id: []const u8, context: *anyopaque, done: Http.Done) !void { try self.get(arena, try arena.print("/drafts/{s}?format=full", .{id}), context, done); } /// The id of the draft a create, an update, or a read answers with. pub fn draftId(arena: Allocator, body: []const u8) ![]const u8 { const Body = struct { id: []const u8 = "" }; const id = (try parse(Body, arena, body)).id; return if (id.len > 0) id else error.NoDraftId; } /// A draft as `readDraft` reads it, as compose writes one. pub const Draft = struct { id: []const u8, /// Its message's id, which its files are read by. message_id: []const u8, /// Its attachments are `attachments`. message: Compose.Outgoing, /// Its files: with their bytes when Gmail gave them, else empty, to /// be read by their ids in `file_ids`. attachments: []Compose.Attachment, file_ids: []const []const u8, }; /// The draft `readDraft` asked for: its fields, and its text as typed, /// or as text when it only has HTML. pub fn draft(arena: Allocator, body: []const u8) !Draft { const Json = struct { id: []const u8 = "", message: struct { id: []const u8 = "", threadId: []const u8 = "", payload: Part = .{} } = .{}, }; const d = try parse(Json, arena, body); var content: Content = .{}; var attachments: ArrayList(Mailbox.Attachment) = .empty; try content.find(arena, &d.message.payload, &attachments); const headers = d.message.payload.headers; // Not trimmed, as asText would: a reply starts with blank lines to // type in, over its quote. const text = if (content.plain) |p| try html.toUtf8(arena, try decodeBase64(arena, p.body.data), charset(p) orelse "utf-8") else try content.asText(arena); const files = try arena.alloc(Compose.Attachment, attachments.items.len); const ids = try arena.alloc([]const u8, attachments.items.len); for (attachments.items, files, ids) |a, *file, *id| { // An image shown by Content-ID may have no name. file.* = .{ .name = if (a.name.len > 0) a.name else "attachment", .mime = a.mime, .data = a.data }; id.* = a.id; } return .{ .id = d.id, .message_id = d.message.id, .attachments = files, .file_ids = ids, .message = .{ .to = header(headers, "To") orelse "", .cc = header(headers, "Cc") orelse "", .subject = header(headers, "Subject") orelse "", .body = text, .html = "", .thread = d.message.threadId, .in_reply_to = header(headers, "In-Reply-To") orelse "", .references = header(headers, "References") orelse "", .attachments = files, }, }; } /// A Draft resource as JSON, with what is not null of `id` and `thread`. fn draftJson(arena: Allocator, id: ?[]const u8, thread: ?[]const u8) ![]const u8 { const Message = struct { threadId: ?[]const u8 }; const Resource = struct { id: ?[]const u8, message: ?Message }; const t = if (thread) |th| (if (th.len > 0) th else null) else null; const message: ?Message = if (thread == null) null else .{ .threadId = t }; return json.Stringify.valueAlloc(arena, Resource{ .id = id, .message = message }, .{ .emit_null_optional_fields = false, }); } /// Asks for the account's address; `emailAddress` reads the reply. pub fn askAddress(self: Gmail, arena: Allocator, context: *anyopaque, done: Http.Done) !void { try self.get(arena, "/profile?fields=emailAddress", context, done); } pub fn emailAddress(arena: Allocator, body: []const u8) ![]const u8 { const Profile = struct { emailAddress: []const u8 = "" }; return (try parse(Profile, arena, body)).emailAddress; } /// Asks for the addresses the user sends as, with their signatures /// (1 quota unit). pub fn askSignature(self: Gmail, arena: Allocator, context: *anyopaque, done: Http.Done) !void { try self.get(arena, "/settings/sendAs?fields=sendAs(isDefault,isPrimary,signature)", context, done); } /// The signature of the address Gmail sends as by default, else of the /// account's own, as text: Gmail keeps it as HTML. Empty when it has none. pub fn signature(arena: Allocator, body: []const u8) ![]const u8 { const SendAs = struct { isDefault: bool = false, isPrimary: bool = false, signature: []const u8 = "" }; const addresses = (try parse(struct { sendAs: []const SendAs = &.{} }, arena, body)).sendAs; const chosen = for (addresses) |a| { if (a.isDefault) break a; } else for (addresses) |a| { if (a.isPrimary) break a; } else return ""; return html.toText(arena, chosen.signature); } /// A MIME part, as Gmail gives it with format=full. const Part = struct { mimeType: []const u8 = "", filename: []const u8 = "", headers: []const Header = &.{}, body: struct { data: []const u8 = "", attachmentId: []const u8 = "", size: u32 = 0 } = .{}, parts: []const Part = &.{}, }; const Header = struct { name: []const u8, value: []const u8 }; /// The messages of a thread `readThread` asked for, oldest first. pub fn messages(arena: Allocator, body: []const u8) ![]const Mailbox.Message { const t = try parse(struct { messages: []const WholeMessage = &.{} }, arena, body); const out = try arena.alloc(Mailbox.Message, t.messages.len); for (t.messages, out) |m, *message| { var content: Content = .{}; var attachments: ArrayList(Mailbox.Attachment) = .empty; try content.find(arena, &m.payload, &attachments); const headers = m.payload.headers; const from = header(headers, "From") orelse ""; const rich = try content.asHtml(arena); for (attachments.items) |*a| { if (a.content_id.len == 0) continue; const reference = try mem.concat(arena, u8, &.{ "cid:", a.content_id }); a.inline_shown = ascii.findIgnoreCase(rich, reference) != null; } message.* = .{ .id = m.id, .from = displayName(from), .address = person(from).address, .date = @divFloor(fmt.parseInt(i64, m.internalDate, 10) catch 0, 1000), .html = rich, .text = try content.asText(arena), .snippet = try html.decodeEntities(arena, m.snippet), .attachments = attachments.items, .unread = hasLabel(m.labelIds, "UNREAD"), .message_id = header(headers, "Message-ID") orelse "", .references = header(headers, "References") orelse "", .reply_to = header(headers, "Reply-To") orelse from, .to = header(headers, "To") orelse "", .cc = header(headers, "Cc") orelse "", .sent = hasLabel(m.labelIds, "SENT"), .unsubscribe = header(headers, "List-Unsubscribe") orelse "", .one_click = ascii.findIgnoreCase(header(headers, "List-Unsubscribe-Post") orelse "", "One-Click") != null, .action = if (content.rich) |p| cards.action(arena, try decodeBase64(arena, p.body.data)) else null, .invite = if (content.calendar) |p| try cards.invite(arena, try decodeBase64(arena, p.body.data)) else null, }; } return out; } /// The parts of a message that can be its body. const Content = struct { rich: ?*const Part = null, plain: ?*const Part = null, /// An invitation, when Gmail gives its bytes. calendar: ?*const Part = null, /// Walks the parts: the first HTML and plain text ones, and those /// that are files, or images the HTML shows by Content-ID. fn find(c: *Content, arena: Allocator, part: *const Part, attachments: *ArrayList(Mailbox.Attachment)) !void { const content_id = mem.trim(u8, header(part.headers, "Content-ID") orelse "", " <>"); const text = ascii.startsWithIgnoreCase(part.mimeType, "text/"); if (ascii.eqlIgnoreCase(part.mimeType, "text/calendar") and part.body.data.len > 0) { if (c.calendar == null) c.calendar = part; } if (part.filename.len > 0 or (content_id.len > 0 and !text)) { return attachments.append(arena, .{ .name = part.filename, .mime = part.mimeType, .content_id = content_id, .id = part.body.attachmentId, .data = if (part.body.attachmentId.len > 0) "" else try decodeBase64(arena, part.body.data), .inline_shown = false, }); } if (ascii.startsWithIgnoreCase(part.mimeType, "multipart/")) { for (part.parts) |*p| try c.find(arena, p, attachments); } else if (ascii.eqlIgnoreCase(part.mimeType, "text/html")) { if (c.rich == null) c.rich = part; } else if (ascii.eqlIgnoreCase(part.mimeType, "text/plain")) { if (c.plain == null) c.plain = part; } } /// The body as HTML: the message's own, else its plain text, escaped, /// keeping its line breaks. Its charset goes first in a , where /// the document view looks for it. fn asHtml(c: Content, arena: Allocator) ![]const u8 { const part = c.rich orelse c.plain orelse return ""; const bytes = try decodeBase64(arena, part.body.data); var out: Writer.Allocating = .init(arena); const w = &out.writer; if (charset(part)) |cs| try w.print("", .{cs}); if (c.rich != null) { try w.writeAll(bytes); } else { try html.fromText(w, bytes); } return out.written(); } /// The body as UTF-8 text, to quote: the plain text, else the HTML's. fn asText(c: Content, arena: Allocator) ![]const u8 { const part = c.plain orelse c.rich orelse return ""; const bytes = try html.toUtf8(arena, try decodeBase64(arena, part.body.data), charset(part) orelse "utf-8"); return if (c.plain != null) mem.trim(u8, bytes, " \r\n") else html.toText(arena, bytes); } }; /// A part's charset, from its Content-Type, when it is a plain name. fn charset(part: *const Part) ?[]const u8 { const content_type = header(part.headers, "Content-Type") orelse return null; const at = ascii.findIgnoreCase(content_type, "charset=") orelse return null; const rest = mem.trimStart(u8, content_type[at + "charset=".len ..], "\""); const end = for (rest, 0..) |ch, i| { if (!ascii.isAlphanumeric(ch) and ch != '-' and ch != '_') break i; } else rest.len; return if (end > 0) rest[0..end] else null; } fn hasLabel(ids: []const []const u8, id: []const u8) bool { for (ids) |l| { if (mem.eql(u8, l, id)) return true; } return false; } fn header(headers: []const Header, name: []const u8) ?[]const u8 { for (headers) |h| { if (ascii.eqlIgnoreCase(h.name, name)) return h.value; } return null; } /// Gmail's base64url, with or without its padding. fn decodeBase64(arena: Allocator, data: []const u8) ![]u8 { const trimmed = mem.trimEnd(u8, data, "="); const decoder = std.base64.url_safe_no_pad.Decoder; const out = try arena.alloc(u8, try decoder.calcSizeForSlice(trimmed)); try decoder.decode(out, trimmed); return out; } /// Who wrote in a thread, in the order they first did, as Gmail's list /// names them: one sender by their whole name, several by first names, /// the user as "me", and past three the first and the last two. fn participants(arena: Allocator, t: ThreadJson) ![]const u8 { var names: std.array_hash_map.String(void) = .empty; for (t.messages) |m| { const sent = for (m.labelIds) |l| { if (mem.eql(u8, l, "SENT")) break true; } else false; const name = if (sent) "me" else for (m.payload.headers) |h| { if (ascii.eqlIgnoreCase(h.name, "From")) break displayName(h.value); } else continue; try names.put(arena, name, {}); } const all = names.keys(); if (all.len <= 1) return if (all.len == 1) all[0] else ""; var out: Writer.Allocating = .init(arena); try out.writer.writeAll(firstName(all[0])); const tail = if (all.len > 3) all.len - 2 else 1; for (all[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]; } /// Puts what was read of threads in `mail`, in place of what it had of /// them. pub fn put(mail: *Mailbox, threads: []const Thread) !void { for (threads) |*t| { var labels: [256]Label = undefined; var n: u32 = 0; for (t.label_ids) |id| { const l = mail.findLabel(id) orelse continue; if (n == labels.len) break; labels[n] = l; n += 1; } _ = try mail.put(.{ .id = t.id, .participants = t.participants, .subject = t.subject, .snippet = t.snippet, .date = t.date, .messages = t.messages, .labels = labels[0..n], .unread = t.unread, .notes = t.notes, }, t.version); } } /// The user's labels, by name so a nested one follows its parent. The /// system ones a mailbox has already; the rest (UNREAD, IMPORTANT, /// CATEGORY_*) it does not show. fn parseLabels(arena: Allocator, body: []const u8) ![]const Mailbox.LabelInfo { const LabelJson = struct { id: []const u8, name: []const u8, type: []const u8 = "user", color: ?struct { backgroundColor: []const u8 = "" } = null, }; const Json = struct { labels: []LabelJson = &.{} }; const got = try parse(Json, arena, body); mem.sort(LabelJson, got.labels, {}, struct { fn lessThan(_: void, a: LabelJson, b: LabelJson) bool { return ascii.lessThanIgnoreCase(a.name, b.name); } }.lessThan); var out: ArrayList(Mailbox.LabelInfo) = try .initCapacity(arena, got.labels.len); var uncoloured: u32 = 0; for (got.labels) |l| { if (!mem.eql(u8, l.type, "user")) continue; const color = if (l.color) |c| parseColor(c.backgroundColor) else null; out.appendAssumeCapacity(.{ .id = l.id, .name = l.name, .kind = .user, .color = color orelse palette[uncoloured % palette.len], }); if (color == null) uncoloured += 1; } return out.items; } /// Colours for labels the user never coloured in Gmail. const palette = [_]Rgb{ .{ .r = 0x25, .g = 0x63, .b = 0xeb }, .{ .r = 0x16, .g = 0xa3, .b = 0x4a }, .{ .r = 0xea, .g = 0x58, .b = 0x0c }, .{ .r = 0x7c, .g = 0x3a, .b = 0xed }, .{ .r = 0x08, .g = 0x91, .b = 0xb2 }, .{ .r = 0xdb, .g = 0x27, .b = 0x77 }, .{ .r = 0xca, .g = 0x8a, .b = 0x04 }, .{ .r = 0x64, .g = 0x74, .b = 0x8b }, }; /// "#rrggbb", as Gmail writes a label's colours. fn parseColor(text: []const u8) ?Rgb { if (text.len != 7 or text[0] != '#') return null; const v = fmt.parseInt(u24, text[1..], 16) catch return null; return .{ .r = @truncate(v >> 16), .g = @truncate(v >> 8), .b = @truncate(v) }; } /// The name in a From header, or the address when it has none: /// `"Maya Okafor" ` is Maya Okafor. A name some mailer /// put in single quotes loses them, and a mailing list's "via" after /// them: `"'Yuzu Vita' via Amp Devs" ` is Yuzu Vita. fn displayName(from: []const u8) []const u8 { const lt = mem.findScalar(u8, from, '<') orelse return mem.trim(u8, from, " "); const name = mem.trim(u8, from[0..lt], " \""); if (name.len > 1 and name[0] == '\'') { if (mem.findScalarPos(u8, name, 1, '\'')) |end| return name[1..end]; } if (name.len > 0) return name; const gt = mem.findScalarPos(u8, from, lt, '>') orelse from.len; return from[lt + 1 .. gt]; } /// Puts labels on every message of a thread and takes others off, by /// Gmail's ids; UNREAD is one of them. `done` hears the response, which /// `check` reads. `arena` may go once this returns. pub fn modify( self: Gmail, arena: Allocator, thread: []const u8, put_on: []const []const u8, take_off: []const []const u8, context: *anyopaque, done: Http.Done, ) !void { const body = try json.Stringify.valueAlloc(arena, .{ .addLabelIds = put_on, .removeLabelIds = take_off }, .{}); try self.post(arena, try arena.print("/threads/{s}/modify", .{thread}), body, context, done); } /// Which way `trash` moves a thread; each is its endpoint's name. pub const Bin = enum { trash, untrash }; /// Moves a thread to the trash, or out of it, as `modify` does its /// labels. pub fn trash(self: Gmail, arena: Allocator, thread: []const u8, way: Bin, context: *anyopaque, done: Http.Done) !void { try self.post(arena, try arena.print("/threads/{s}/{t}", .{ thread, way }), "", context, done); } fn post(self: Gmail, arena: Allocator, path: []const u8, body: []const u8, context: *anyopaque, done: Http.Done) !void { const url = try mem.concat(arena, u8, &.{ self.base, path }); const headers = try arena.dupe([]const u8, &.{ self.authorization, "content-type: application/json" }); try self.http.send(.{ .method = .post, .url = url, .headers = headers, .body = body }, context, done); } /// What divides an upload's parts. Nothing in a message `mime.message` /// makes starts a line with it: its headers start with their names, and /// its bodies are base64. const upload_boundary = "=_parcel_upload"; /// Sends `resource`, JSON, and the message `raw` to `path` under Gmail's /// upload root, as one multipart/related body. The upload root takes a /// message of up to 35 MB, where the JSON one takes some 5. fn upload( self: Gmail, arena: Allocator, method: Http.Method, path: []const u8, resource: []const u8, raw: []const u8, context: *anyopaque, done: Http.Done, ) !void { // https://gmail.googleapis.com/upload/gmail/v1/users/me, for the API. const at = mem.find(u8, self.base, "/gmail/") orelse self.base.len; const url = try mem.concat(arena, u8, &.{ self.base[0..at], "/upload", self.base[at..], path, "?uploadType=multipart", }); const dash = "--" ++ upload_boundary; const body = try mem.concat(arena, u8, &.{ dash ++ "\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n", resource, "\r\n" ++ dash ++ "\r\nContent-Type: message/rfc822\r\n\r\n", raw, "\r\n" ++ dash ++ "--\r\n", }); const content_type = "content-type: multipart/related; boundary=\"" ++ upload_boundary ++ "\""; const headers = try arena.dupe([]const u8, &.{ self.authorization, content_type }); try self.http.send(.{ .method = method, .url = url, .headers = headers, .body = body }, context, done); } /// GETs `path` under the user's root; `done` hears the response. fn get(self: Gmail, arena: Allocator, path: []const u8, context: *anyopaque, done: Http.Done) !void { const url = try mem.concat(arena, u8, &.{ self.base, path }); const headers = try arena.dupe([]const u8, &.{self.authorization}); try self.http.send(.{ .method = .get, .url = url, .headers = headers, .body = null }, context, done); } /// The body of a 2xx response. Anything else is logged with Gmail's /// message, under `what` (such as "GET /labels"). A 401 means the /// access token has expired, and a 404 is told apart: a thread deleted /// since, or history too old to have. pub fn check(what: []const u8, arena: Allocator, response: Http.Error!Http.Response) ![]const u8 { const r = response catch |err| { log.err("{s}: {t}", .{ what, err }); return err; }; if (r.status == 401) { log.info("{s}: the access token has expired", .{what}); return error.Unauthorized; } if (r.status == 404) { log.info("{s}: not found", .{what}); return error.NotFound; } if (r.status / 100 != 2) { const Error = struct { @"error": struct { message: []const u8 = "" } = .{} }; const reply = parse(Error, arena, r.body) catch Error{}; log.err("{s}: {d} {s}", .{ what, r.status, reply.@"error".message }); return error.GmailFailed; } // The response is freed when its callback returns; the parse keeps // strings in the arena. return arena.dupe(u8, r.body); } /// Gmail's 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 }); } test "a thread becomes its first sender and subject, its newest snippet and date, and all its labels" { var arena_state: ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); const t = try parseThread(arena_state.allocator(), \\{"id":"18f0","messages":[ \\ {"labelIds":["INBOX","Label_3"],"snippet":"first","internalDate":"1730000000000", \\ "payload":{"headers":[{"name":"Subject","value":"Sunday dinner?"}, \\ {"name":"From","value":"\"Mom\" "}]}}, \\ {"labelIds":["UNREAD","INBOX","SENT"],"snippet":"Can you bring the pie? I'll do the rest & more", \\ "internalDate":"1730000123456", \\ "payload":{"headers":[{"name":"From","value":"me@example.com"}, \\ {"name":"Subject","value":"Re: Sunday dinner?"}]}} \\]} ); try testing.expectEqualStrings("Mom, me", t.participants); try testing.expectEqualStrings("Sunday dinner?", t.subject); try testing.expectEqualStrings("Can you bring the pie? I'll do the rest & more", t.snippet); try testing.expectEqual(1730000123, t.date); try testing.expectEqual(2, t.messages); try testing.expect(t.unread); try testing.expectEqual(3, t.label_ids.len); try testing.expectEqualStrings("SENT", t.label_ids[2]); } test "a thread's people and notes are who wrote, to whom, and who else it went to, not a draft's" { var arena_state: ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); const t = try parseThread(arena_state.allocator(), \\{"id":"1","messages":[ \\ {"internalDate":"1000","payload":{"headers":[{"name":"From","value":"Ana "}, \\ {"name":"to","value":"me@example.com"},{"name":"Subject","value":"Hi"}]}}, \\ {"labelIds":["SENT"],"internalDate":"2000","payload":{"headers":[{"name":"From","value":"me@example.com"}, \\ {"name":"To","value":"Ana "},{"name":"Cc","value":"bo@example.com"}]}}, \\ {"labelIds":["DRAFT"],"internalDate":"3000","payload":{"headers":[{"name":"To","value":"cy@example.com"}]}} \\]} ); try testing.expectEqual(4, t.people.len); const expected = [_]Thread.People{ .{ .how = .heard, .list = "Ana ", .ms = 1000 }, .{ .how = .copied, .list = "me@example.com", .ms = 1000 }, .{ .how = .wrote, .list = "Ana ", .ms = 2000 }, .{ .how = .wrote, .list = "bo@example.com", .ms = 2000 }, }; for (expected, t.people) |want, got| { try testing.expectEqual(want.how, got.how); try testing.expectEqualStrings(want.list, got.list); try testing.expectEqual(want.ms, got.ms); } try testing.expectEqual(2, t.notes.len); const ana: Mailbox.Person = .{ .name = "Ana", .address = "ana@example.com" }; const me: Mailbox.Person = .{ .name = "", .address = "me@example.com" }; const bo: Mailbox.Person = .{ .name = "", .address = "bo@example.com" }; try testing.expectEqualDeep(Mailbox.NewNote{ .date = 1, .from = ana, .to = &.{me}, .cc = &.{} }, t.notes[0]); try testing.expectEqualDeep(Mailbox.NewNote{ .date = 2, .from = me, .to = &.{ana}, .cc = &.{bo} }, t.notes[1]); } test "a long thread names its first writer and its last two" { var arena_state: ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); const t = try parseThread(arena_state.allocator(), \\{"id":"1","messages":[ \\ {"payload":{"headers":[{"name":"From","value":"Maya Okafor "}]}}, \\ {"payload":{"headers":[{"name":"From","value":"Ravi Menon "}]}}, \\ {"labelIds":["SENT"],"payload":{"headers":[{"name":"From","value":"Tim "}]}}, \\ {"payload":{"headers":[{"name":"From","value":"Maya Okafor "}]}}, \\ {"payload":{"headers":[{"name":"From","value":"lena@example.com"}]}} \\]} ); try testing.expectEqualStrings("Maya … me, lena@example.com", t.participants); try testing.expectEqual(5, t.messages); } test "a message's body is its HTML, else its text made HTML, in its charset; files are listed" { var arena_state: ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); // "Hi", "

Hi

", "PNG!" and Latin-1 // "a \nb caf\xe9", in base64url. const got = try messages(arena, \\{"messages":[ \\ {"id":"m1","labelIds":["INBOX"],"snippet":"Hi","internalDate":"1730000000000","payload":{ \\ "mimeType":"multipart/mixed","headers":[{"name":"From","value":"Ana "}, \\ {"name":"List-Unsubscribe","value":""}, \\ {"name":"List-Unsubscribe-Post","value":"List-Unsubscribe=One-Click"}],"parts":[ \\ {"mimeType":"multipart/alternative","parts":[ \\ {"mimeType":"text/plain","body":{"data":"SGk="}}, \\ {"mimeType":"text/calendar","body":{"data": \\ "QkVHSU46VkVWRU5UClNVTU1BUlk6U3RhbmR1cApEVFNUQVJUOjIwMjYxMTEyVDE1MDAwMFoKRU5EOlZFVkVOVAo="}}, \\ {"mimeType":"multipart/related","parts":[ \\ {"mimeType":"text/html","headers":[{"name":"Content-Type","value":"text/html; charset=\"UTF-8\""}], \\ "body":{"data":"PHA-SGk8aW1nIHNyYz0iQ0lEOmxvZ29AeCI-PC9wPg=="}}, \\ {"mimeType":"image/png","headers":[{"name":"Content-ID","value":""}], \\ "body":{"data":"UE5HIQ"}}]}]}, \\ {"mimeType":"application/pdf","filename":"plan.pdf","headers":[{"name":"Content-ID","value":""}], \\ "body":{"attachmentId":"A1","size":9000}}]}}, \\ {"labelIds":["UNREAD"],"internalDate":"1730000060000","payload":{ \\ "mimeType":"text/plain","headers":[{"name":"Content-Type","value":"text/plain; charset=iso-8859-1"}], \\ "body":{"data":"YSA8Yj4KYiBjYWbp"}}} \\]} ); try testing.expectEqual(2, got.len); try testing.expectEqualStrings("Ana", got[0].from); try testing.expectEqualStrings("m1", got[0].id); try testing.expectEqualStrings("

Hi

", got[0].html); try testing.expectEqualStrings("Hi", got[0].text); try testing.expectEqualStrings("a \nb café", got[1].text); // The image the body shows comes with its bytes and is not listed; the // file is read by its id. const files = got[0].attachments; try testing.expectEqual(2, files.len); try testing.expectEqualStrings("logo@x", files[0].content_id); try testing.expectEqualStrings("PNG!", files[0].data); try testing.expect(files[0].inline_shown and files[0].name.len == 0); try testing.expectEqualStrings("plan.pdf", files[1].name); try testing.expectEqualStrings("A1", files[1].id); try testing.expectEqualStrings("", files[1].data); try testing.expect(!files[1].inline_shown); try testing.expectEqual(0, got[1].attachments.len); try testing.expectEqualStrings("", got[0].unsubscribe); try testing.expectEqualStrings("Standup", got[0].invite.?.summary); try testing.expectEqualStrings("Thu 12 Nov 2026, 15:00 UTC", got[0].invite.?.when); try testing.expect(got[1].invite == null and got[0].action == null); try testing.expect(got[0].one_click and !got[1].one_click and got[1].unsubscribe.len == 0); try testing.expect(!got[0].unread and got[1].unread); try testing.expectEqual(1730000060, got[1].date); try testing.expectEqualStrings( "
a <b>\nb caf\xe9
", got[1].html, ); } test "search keeps who a message is from and to, its files, and what it says apart from what it quotes" { var arena_state: ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); // A thread: a reply quoting the message before it, with a file and its // text as HTML too; a draft and a message in the trash, left out. const got = try readCorpus(arena, \\{"messages":[{"id":"m1","threadId":"t1","labelIds":["INBOX"],"internalDate":"1730000000999", \\"sizeEstimate":4096,"payload":{"mimeType":"multipart/mixed","headers":[ \\{"name":"From","value":"Bo Lin "}, \\{"name":"To","value":"ana@example.com, \"Cy\" "}, \\{"name":"Cc","value":"Di "},{"name":"Subject","value":"Re: Lunch"}, \\{"name":"Message-ID","value":""},{"name":"List-Id","value":" Food "}, \\{"name":"Delivered-To","value":"ana@example.com"}], \\"parts":[{"mimeType":"multipart/alternative","parts":[ \\{"mimeType":"text/plain", \\"body":{"data":"U3VyZS4NCg0KT24gTW9uLCBBbmEgd3JvdGU6DQo-IEx1bmNoIGF0IG5vb24_DQo="}}, \\{"mimeType":"text/html","body":{"data":"PHA-UGxhbiBCPC9wPg=="}}]}, \\{"mimeType":"application/pdf","filename":"menu.pdf","body":{"attachmentId":"x","size":2048}}]}}, \\{"id":"m2","threadId":"t1","labelIds":["DRAFT"],"payload":{"headers":[]}}, \\{"id":"m3","threadId":"t1","labelIds":["TRASH"],"payload":{"headers":[]}}]} ); try testing.expectEqual(1, got.len); const r = got[0]; try testing.expectEqualStrings("m1", r.id); try testing.expectEqualStrings("t1", r.thread); try testing.expectEqualStrings("r1@example.com", r.message_id); try testing.expectEqualStrings("Re: Lunch", r.subject); try testing.expectEqualStrings("Food ", r.list_id); try testing.expectEqualStrings("ana@example.com", r.delivered_to); try testing.expectEqual(1730000000, r.date); try testing.expectEqual(4096, r.size); try testing.expectEqualStrings("Bo Lin", r.from.name); try testing.expectEqualStrings("bo@example.com", r.from.address); try testing.expectEqual(2, r.to.len); try testing.expectEqualStrings("", r.to[0].name); try testing.expectEqualStrings("cy@example.com", r.to[1].address); try testing.expectEqualStrings("Di", r.cc[0].name); try testing.expectEqual(0, r.bcc.len); try testing.expectEqual(1, r.attachments.len); try testing.expectEqualStrings("menu.pdf", r.attachments[0].name); try testing.expectEqualStrings("application/pdf", r.attachments[0].mime); try testing.expectEqual(2048, r.attachments[0].size); try testing.expectEqualStrings("Sure.", r.text); try testing.expectEqualStrings("On Mon, Ana wrote:\nLunch at noon?", r.quoted); // A message read alone, its text only HTML. const alone = try readCorpus(arena, \\{"id":"m4","threadId":"t2","labelIds":["SENT"],"internalDate":"2000","payload":{"mimeType":"text/html", \\"headers":[{"name":"From","value":"ana@example.com"}],"body":{"data":"PHA-UGxhbiBCPC9wPg=="}}} ); try testing.expectEqual(1, alone.len); try testing.expectEqualStrings("t2", alone[0].thread); try testing.expectEqual(2, alone[0].date); try testing.expectEqualStrings("ana@example.com", alone[0].from.address); try testing.expectEqualStrings("Plan B", mem.trim(u8, alone[0].text, "\n ")); } test "the signature is the default address's, else the account's own, made text" { var arena_state: ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const chosen = try signature(arena, \\{"sendAs":[{"isPrimary":true,"signature":"
Primary
"}, \\ {"isDefault":true,"signature":""}]} ); try testing.expectEqualStrings("Tim\nexample.org", mem.trim(u8, chosen, "\n")); const primary = try signature(arena, \\{"sendAs":[{"signature":"

Other

"},{"isPrimary":true,"signature":"

Primary

"}]} ); try testing.expectEqualStrings("Primary", mem.trim(u8, primary, "\n")); try testing.expectEqualStrings("", try signature(arena, "{\"sendAs\":[{\"isPrimary\":true}]}")); } test "a From header without a name shows the address" { try testing.expectEqualStrings("Maya Okafor", displayName("Maya Okafor ")); try testing.expectEqualStrings("maya@example.com", displayName("")); try testing.expectEqualStrings("maya@example.com", displayName("maya@example.com")); try testing.expectEqualStrings("Mads Hansen", displayName("'Mads Hansen' ")); try testing.expectEqualStrings("Yuzu Vita", displayName("\"'Yuzu Vita' via Amp Devs\" ")); try testing.expectEqualStrings("O'Brien", displayName("O'Brien ")); } test "load puts the account's labels and newest threads in the mailbox, and update reads only what changed" { var fake: Fake = undefined; try fake.start(&load_routes); defer fake.stop(); const gmail = fake.gmail; var mail: Mailbox = try .init(testing.allocator); defer mail.deinit(); var arena_state: ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); var result: ?anyerror!void = null; var contacts: Contacts = .init(testing.allocator); defer contacts.deinit(); try gmail.load(arena_state.allocator(), &mail, .{ .threads = 2, .contacts = &contacts }, &result, Fake.heard(void)); _ = try fake.wait(void, &result); try testing.expectEqual(77, mail.history); // c was sent to Bo; A wrote a and b. try testing.expect(contacts.seeded); var people_found: [4]Contacts.Match = undefined; const found_bo = contacts.suggest(&people_found, "b", &.{}); try testing.expectEqual(1, found_bo.len); try testing.expectEqualStrings("Bo", found_bo[0].name); try testing.expectEqual(1, contacts.suggest(&people_found, "a@", &.{}).len); // Work comes before Work/Hiring whatever order Gmail lists them in. const work = mail.findLabel("Label_1").?; const hiring = mail.findLabel("Label_2").?; try testing.expect(@backingInt(work) < @backingInt(hiring)); try testing.expectEqual(Rgb{ .r = 0x49, .g = 0x86, .b = 0xe7 }, mail.label(work).color); // b is both in the Inbox and among the newest: one thread, not two. try testing.expectEqual(3, mail.threads.items.len); var by_id: [3]Mailbox.Id = undefined; for (mail.threads.items, 0..) |t, i| by_id[t.id[0] - 'a'] = @intCast(i); try testing.expect(mail.has(by_id[0], .inbox) and !mail.has(by_id[2], .inbox)); try testing.expect(mail.has(by_id[1], hiring) and mail.get(by_id[1]).unread); try testing.expectEqual(5, mail.get(by_id[1]).version); // Since 77, b and d changed and e was deleted; a and c did not. _ = try mail.put(.{ .id = "e", .participants = "", .subject = "", .snippet = "", .date = 0, .messages = 1, .labels = &.{.inbox}, .unread = false, .notes = &.{}, }, 3); mail.setUnread(by_id[0], true); mail.setUnread(by_id[1], false); try mail.setWalk(.{ .page = "p9" }); var changes: Changes = undefined; result = null; const options: LoadOptions = .{ .threads = 2, .contacts = &contacts }; try gmail.update(arena_state.allocator(), &mail, &changes, options, &result, Fake.heard(void)); _ = try fake.wait(void, &result); try testing.expectEqual(90, changes.history); try testing.expectEqual(2, changes.threads.len); try testing.expectEqualStrings("b", changes.threads[0].id); try testing.expectEqualStrings("d", changes.threads[1].id); try testing.expectEqual(1, changes.gone.count()); try testing.expect(changes.gone.contains("e")); try testing.expectEqual(1, changes.deleted.len); try testing.expectEqualStrings("e1", changes.deleted[0]); // The changes go in the mail in place, as the labels are the same, or // in new mail made from it, as when they are not. Either way, what the // user did to a stays, b is Gmail's again, and how far the backfill // got goes on. var bare: Mailbox = try .init(testing.allocator); defer bare.deinit(); try testing.expect(changes.sameLabels(&mail) and !changes.sameLabels(&bare)); var rebuilt = try changes.rebuild(testing.allocator, &mail); defer rebuilt.deinit(); try put(&mail, changes.threads); mail.history = changes.history; for (changes.gone.keys()) |id| mail.remove(mail.findThread(id).?); for ([_]*const Mailbox{ &rebuilt, &mail }) |m| { try testing.expectEqual(90, m.history); try testing.expectEqualStrings("p9", m.walk.page); try testing.expectEqual(4, m.threads.items.len); try testing.expect(m.findThread("e") == null); try testing.expect(m.get(m.findThread("a").?).unread); const b = m.findThread("b").?; try testing.expect(m.get(b).unread and m.has(b, m.findLabel("Label_2").?)); try testing.expect(m.has(m.findThread("c").?, .sent)); try testing.expect(m.has(m.findThread("d").?, .inbox)); } // For contacts never seeded, the newest threads are read again, a too. var unseeded: Contacts = .init(testing.allocator); defer unseeded.deinit(); var reread: Changes = undefined; result = null; mail.history = 77; const unseeded_options: LoadOptions = .{ .threads = 3, .contacts = &unseeded }; try gmail.update(arena_state.allocator(), &mail, &reread, unseeded_options, &result, Fake.heard(void)); _ = try fake.wait(void, &result); const a = for (reread.threads) |t| { if (mem.eql(u8, t.id, "a")) break t; } else return error.TestUnexpectedResult; try testing.expect(!a.unread); try testing.expect(unseeded.seeded); mail.history = 5; var stale: Changes = undefined; result = null; try gmail.update(arena_state.allocator(), &mail, &stale, .{ .threads = 2 }, &result, Fake.heard(void)); try testing.expectError(error.HistoryExpired, fake.wait(void, &result)); } test "a list names a page of threads and the next; reading them leaves out one deleted meanwhile" { var fake: Fake = undefined; try fake.start(&load_routes); defer fake.stop(); var arena_state: ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); var listed: ?anyerror!List = null; const query: Query = .{ .labels = &.{ "INBOX", "Label_1" }, .search = "from:ana has:attachment" }; try fake.gmail.list(arena, query, "", 500, &listed, Fake.heard(List)); const page = try fake.wait(List, &listed); try testing.expectEqual(3, page.threads.len); try testing.expectEqualStrings("d", page.threads[0].id); try testing.expectEqual(12, page.threads[0].version); try testing.expectEqualStrings("p2", page.next.?); listed = null; try fake.gmail.list(arena, query, "p2", 500, &listed, Fake.heard(List)); try testing.expect((try fake.wait(List, &listed)).next == null); var got: ?anyerror![]const Thread = null; try fake.gmail.read(arena, &.{ "d", "gone", "a", "c", "b" }, &got, Fake.heard([]const Thread)); const threads = try fake.wait([]const Thread, &got); try testing.expectEqual(4, threads.len); for (threads, [_][]const u8{ "d", "a", "c", "b" }) |t, id| try testing.expectEqualStrings(id, t.id); // A few at a time, not all at once. try testing.expect(fake.most_out.load(.monotonic) <= reads_at_once); // One that fails fails the whole, to be tried again. got = null; try fake.gmail.read(arena, &.{ "a", "broken" }, &got, Fake.heard([]const Thread)); try testing.expectError(error.Unauthorized, fake.wait([]const Thread, &got)); } /// What the fake Gmail answers to the tests above. const load_routes = [_]Fake.Route{ .ok("/labels", \\{"labels":[{"id":"Label_2","name":"Work/Hiring","type":"user"}, \\{"id":"INBOX","name":"INBOX","type":"system"},{"id":"UNREAD","name":"UNREAD","type":"system"}, \\{"id":"Label_1","name":"Work","type":"user", \\"color":{"textColor":"#ffffff","backgroundColor":"#4986e7"}}]} ), .ok("/threads?labelIds=INBOX&", \\{"threads":[{"id":"a"},{"id":"b"}]} ), .ok("/threads?maxResults=500&fields=threads(id,historyId),nextPageToken" ++ "&labelIds=INBOX&labelIds=Label_1&q=from%3Aana%20has%3Aattachment&pageToken=p2", \\{"threads":[{"id":"f","historyId":"3"}]} ), .ok("/threads?maxResults=500&fields=threads(id,historyId),nextPageToken" ++ "&labelIds=INBOX&labelIds=Label_1&q=from%3Aana%20has%3Aattachment", \\{"threads":[{"id":"d","historyId":"12"},{"id":"a","historyId":"4"},{"id":"gone"}], \\"nextPageToken":"p2"} ), .ok("/threads?maxResults", \\{"threads":[{"id":"b"},{"id":"c"}]} ), .ok("/profile?", "{\"historyId\":\"77\"}"), .ok("/history?startHistoryId=77&", \\{"history":[{"messages":[{"threadId":"b"},{"threadId":"d"}]}, \\{"messages":[{"threadId":"e"},{"threadId":"b"}], \\"messagesDeleted":[{"message":{"id":"e1","threadId":"e"}}]}],"historyId":"90"} ), .ok("/threads/a?", Fake.thread("a", "INBOX", 4)), .ok("/threads/b?", Fake.thread("b", "INBOX\",\"Label_2\",\"UNREAD", 5)), .ok("/threads/c?", Fake.thread("c", "SENT", 6)), .ok("/threads/d?", Fake.thread("d", "INBOX", 12)), .{ .path = "/threads/broken?", .body = "{}", .status = .unauthorized }, }; /// A stand-in for Gmail on a loopback port, for tests: it answers a /// request by the first route that starts its path, and the rest with /// 404, and counts what it is asked. pub const Fake = struct { server: Io.net.Server, serving: Io.Future(void), routes: []const Route, app: siv.App, http: *Http, base_buf: [64]u8, gmail: Gmail, /// Requests answered so far, and the most out at once. requests: std.atomic.Value(u32), out: std.atomic.Value(u32), most_out: std.atomic.Value(u32), /// Requests whose path this starts fail with a 401, as many as /// `fail_count` says; none when it is empty. A 401 is a failure that /// logs no error. fail_path: []const u8, fail_count: std.atomic.Value(u32), /// Each request, in the order they came; `mutex` guards it. seen: ArrayList(Seen), seen_arena: ArenaAllocator, mutex: Io.Mutex, pub const Seen = struct { method: std.http.Method, target: []const u8, body: []const u8 }; pub const Route = struct { path: []const u8, body: []const u8, status: std.http.Status, pub fn ok(path: []const u8, body: []const u8) Route { return .{ .path = path, .body = body, .status = .ok }; } }; /// Listens, and makes an app and a Gmail that asks it. `self` must /// stay put until `stop`. pub fn start(self: *Fake, routes: []const Route) !void { var address: Io.net.IpAddress = .{ .ip4 = .loopback(0) }; self.server = try address.listen(testing.io, .{}); errdefer self.server.deinit(testing.io); self.routes = routes; self.requests = .init(0); self.out = .init(0); self.most_out = .init(0); self.fail_path = ""; self.fail_count = .init(0); self.seen = .empty; self.seen_arena = .init(testing.allocator); self.mutex = .init; errdefer self.seen_arena.deinit(); self.serving = try testing.io.concurrent(serve, .{ self, testing.io }); errdefer self.serving.cancel(testing.io); self.app = try siv.appCreate(.{ .backend = .headless, .flags = .{ .no_system_fonts = true, .no_system_icons = true, .reduced_motion = true }, }); errdefer siv.appDestroy(self.app); self.http = try Http.init(testing.allocator, self.app); self.gmail = .{ .http = self.http, .base = try mem.print(&self.base_buf, "http://127.0.0.1:{d}", .{self.server.socket.address.getPort()}), .authorization = "authorization: Bearer t", }; } pub fn stop(self: *Fake) void { self.http.deinit(); siv.appDestroy(self.app); self.serving.cancel(testing.io); self.server.deinit(testing.io); self.seen.deinit(testing.allocator); self.seen_arena.deinit(); self.* = undefined; } /// The requests seen so far. Wait for their answers first: no more /// may come while the slice is used. pub fn asked(self: *Fake) ![]const Seen { try self.mutex.lock(testing.io); defer self.mutex.unlock(testing.io); return self.seen.items; } /// A callback that puts what it hears in a `?anyerror!T` its context /// points at. pub fn heard(comptime T: type) *const fn (*anyopaque, anyerror!T) void { return struct { fn put(context: *anyopaque, r: anyerror!T) void { const out: *?anyerror!T = @ptrCast(@alignCast(context)); out.* = r; } }.put; } /// Runs the event loop until `result` is heard; ten seconds is /// plenty for a loopback server. pub fn wait(self: *Fake, comptime T: type, result: *?anyerror!T) !T { var waited: u32 = 0; while (result.* == null and waited < 100) : (waited += 1) try siv.appDispatch(self.app, 100); return result.* orelse error.Timeout; } /// A thread as threads.get gives it, its one message's labels the /// list `labels` quotes inside. pub fn thread(comptime id: []const u8, comptime labels: []const u8, comptime version: u64) []const u8 { return fmt.comptimePrint( "{{\"id\":\"{s}\",\"historyId\":\"{d}\",\"messages\":[{{\"labelIds\":[\"{s}\"]," ++ "\"snippet\":\"hi\",\"internalDate\":\"1730000000000\",\"payload\":{{\"headers\":" ++ "[{{\"name\":\"From\",\"value\":\"A \"}}," ++ "{{\"name\":\"To\",\"value\":\"Bo \"}}]}}}}]}}", .{ id, version, labels }, ); } /// Serves until canceled. fn serve(self: *Fake, io: Io) void { var connections: Io.Group = .init; defer connections.cancel(io); while (true) { const stream = self.server.accept(io) catch |err| switch (err) { error.Canceled => return, else => continue, }; connections.concurrent(io, connection, .{ self, stream, io }) catch stream.close(io); } } fn connection(self: *Fake, stream: Io.net.Stream, io: Io) Io.Cancelable!void { defer stream.close(io); var read_buf: [4096]u8 = undefined; var write_buf: [4096]u8 = undefined; var reader = stream.reader(io, &read_buf); var writer = stream.writer(io, &write_buf); var http: std.http.Server = .init(&reader.interface, &writer.interface); while (true) { var request = http.receiveHead() catch return; const out = self.out.fetchAdd(1, .monotonic) + 1; _ = self.most_out.fetchMax(out, .monotonic); defer _ = self.out.fetchSub(1, .monotonic); var authorized = false; var headers = request.iterateHeaders(); while (headers.next()) |h| { if (ascii.eqlIgnoreCase(h.name, "authorization")) authorized = mem.eql(u8, h.value, "Bearer t"); } const method = request.head.method; // Reading the body invalidates the head's strings. const target = testing.allocator.dupe(u8, request.head.target) catch return; defer testing.allocator.free(target); var body_buf: [4096]u8 = undefined; const body_reader = request.readerExpectContinue(&body_buf) catch return; const body = body_reader.allocRemaining(testing.allocator, .limited(4 << 20)) catch return; defer testing.allocator.free(body); self.note(io, .{ .method = method, .target = target, .body = body }) catch return; const route: Route = if (authorized) self.find(target) else .{ .path = "", .body = "{}", .status = .unauthorized, }; // Counted before the answer, so a test that heard it sees it. _ = self.requests.fetchAdd(1, .monotonic); request.respond(route.body, .{ .status = route.status }) catch return; } } fn note(self: *Fake, io: Io, request: Seen) !void { try self.mutex.lock(io); defer self.mutex.unlock(io); const a = self.seen_arena.allocator(); try self.seen.append(testing.allocator, .{ .method = request.method, .target = try a.dupe(u8, request.target), .body = try a.dupe(u8, request.body), }); } fn find(self: *Fake, target: []const u8) Route { if (self.fail_path.len > 0 and mem.startsWith(u8, target, self.fail_path)) { const left = self.fail_count.load(.monotonic); if (left > 0 and self.fail_count.cmpxchgStrong(left, left - 1, .monotonic, .monotonic) == null) { return .{ .path = "", .body = "{}", .status = .unauthorized }; } } for (self.routes) |r| { if (mem.startsWith(u8, target, r.path)) return r; } return .{ .path = "", .body = "{}", .status = .not_found }; } };