//! The account's mail as the app holds it: its labels and its threads, //! changed in memory by what the user does. The views read and change //! only this. A backend fills it, Gmail.zig or Jmap.zig, or sample.zig //! when signed out, and a listener (Sync.zig, or JmapSession.zig) hears //! each change to send it back. //! //! Labels are Gmail's: the system ones first, at fixed places (Label's //! named values), then the user's, each with the id its backend knows it //! by. JMAP's mailboxes with a role take the system places. const std = @import("std"); const Query = @import("Query.zig"); const ascii = std.ascii; const mem = std.mem; const testing = std.testing; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const ArrayList = std.ArrayList; const Mailbox = @This(); /// A label, as its place among the account's labels. pub const Label = enum(u16) { inbox, starred, sent, drafts, spam, trash, _, }; pub const Kind = enum { system, user }; pub const Rgb = struct { r: u8, g: u8, b: u8 }; pub const LabelInfo = struct { /// What Gmail calls it: INBOX for a system label, Label_7 for a user's. id: []const u8, /// What the user sees; a slash nests it under the label before it. name: []const u8, kind: Kind, color: Rgb, }; /// A thread, as its place among the account's threads. pub const Id = u32; /// A set of labels, one bit per label. pub const Labels = std.bit_set.Dynamic; pub const Thread = struct { /// What Gmail calls it. id: []const u8, /// Who wrote in it, as Gmail lists them: "Maya, Ravi, me", or one /// sender's whole name. participants: []const u8, subject: []const u8, snippet: []const u8, /// When its newest message arrived, in seconds since the epoch. date: i64, /// How many messages it holds. messages: u32, labels: Labels, unread: bool, /// Gmail's historyId for the thread when it was read, to tell whether /// it has changed since; 0 when it did not come from Gmail. version: u64, /// Its messages, as search knows them; none when they were not read. notes: []const Note, /// Whether the walk under way (`walk`) has passed it in Gmail's list /// of all mail, or it was read from Gmail since the walk began. One /// not, once the walk ends, is gone from that list: deleted, in the /// trash, or in spam. walked: bool, }; /// Someone on a message. pub const Person = struct { /// Empty when the header gave none. name: []const u8, address: []const u8, }; /// One message of a thread, as search knows it: when it came and who is /// on it, as places in the mailbox's people (`person`). pub const Note = struct { date: i64, from: u32, /// Its To, then its Cc. people: []const u32, /// How many of `people` are in its To. to: u32, }; /// A message of a thread to add, its people as its headers name them. pub const NewNote = struct { date: i64, from: Person, to: []const Person, cc: []const Person, }; /// How far back Gmail's list of all mail has been read into a copy, so /// a later run goes on where this one stopped (Backfill.zig). pub const Walk = union(enum) { /// The page token to read on from; empty for the first page. page: []const u8, /// All of it, or as much as the copy keeps. done, }; /// One message of a thread as the reader shows it, read only when the /// thread is opened, so the mailbox does not hold it. pub const Message = struct { /// What Gmail calls it, to read its attachments with. id: []const u8, /// The sender's name, or address when it has none. from: []const u8, /// The sender's address alone; empty when it has none. address: []const u8, /// When it arrived, in seconds since the epoch. date: i64, /// Its body as HTML: its own, or its plain text made into some. html: []const u8, /// Its body as plain text, to quote. text: []const u8, snippet: []const u8, attachments: []const Attachment, unread: bool, /// What a reply needs: its Message-ID and References, where replies go /// (Reply-To, else From), and its To and Cc, whole. message_id: []const u8, references: []const u8, reply_to: []const u8, to: []const u8, cc: []const u8, /// Whether the user sent it. sent: bool, /// Its List-Unsubscribe header, how to leave the mailing list it came /// from, as and ; empty for mail from no list. unsubscribe: []const u8, /// Whether its List-Unsubscribe-Post offers RFC 8058's one click. one_click: bool, /// A page its schema.org markup offers to go to, as Gmail shows. action: ?Action, /// The event it invites to. invite: ?Invite, }; /// A button that opens a page, such as GitHub's "View Pull Request". pub const Action = struct { name: []const u8, /// Always https. url: []const u8, }; /// An event a message invites to, from its text/calendar part. pub const Invite = struct { summary: []const u8, /// When it starts, in the zone the invitation names, such as /// "Thu 12 Nov 2026, 12:00 – 13:30 America/Chicago". when: []const u8, location: []const u8, /// Who invites: a name, else an address. organizer: []const u8, /// Whether the invitation calls the event off. cancelled: bool, /// The invitation whole, to add to a calendar. ics: []const u8, }; /// A file a message carries: one the user opens, or an image its body /// shows by Content-ID (cid:). pub const Attachment = struct { /// Its file name; empty for a part only the body shows. name: []const u8, /// Its MIME type, such as image/png. mime: []const u8, /// Its Content-ID, without the angle brackets; empty when it has none. content_id: []const u8, /// What Gmail calls it, to read it with; empty when `data` holds it. id: []const u8, /// Its bytes, when the message holds them itself, as Gmail gives /// small parts. data: []const u8, /// Whether the body shows it, so it is not listed with the files. inline_shown: bool, }; /// A thread to add, with its labels as a list. pub const NewThread = struct { id: []const u8, participants: []const u8, subject: []const u8, snippet: []const u8, date: i64, messages: u32, labels: []const Label, unread: bool, notes: []const NewNote, }; /// Which threads a view shows. // ast-grep-ignore: public-struct-field-default -- an options bag for query and count. pub const Filter = struct { /// Labels a thread must all have; none is All Mail. Spam and trash /// are left out unless one of these names them. labels: []const Label = &.{}, /// What a search typed asks for; null matches all. query: ?*const Query = null, /// Only these threads, by Gmail's ids, such as a search's; null is /// any. among: ?*const std.array_hash_map.String(void) = null, }; /// What the user changed about a thread. pub const Change = union(enum) { /// A label went on (`on`) or came off. label: struct { thread: Id, label: Label, on: bool }, unread: struct { thread: Id, unread: bool }, trash: Id, /// Out of the trash, with the labels it had. `inbox` says it goes /// back to the Inbox, which Gmail's trash takes it out of and its /// untrash does not put back. untrash: struct { thread: Id, inbox: bool }, }; /// Hears each change as it is made, and only real ones: archiving a /// thread that is not in the inbox says nothing. pub const Listener = struct { context: *anyopaque, changed: *const fn (context: *anyopaque, mail: *const Mailbox, change: Change) void, }; const gray: Rgb = .{ .r = 0x6b, .g = 0x72, .b = 0x80 }; const system_labels = [_]LabelInfo{ .{ .id = "INBOX", .name = "Inbox", .kind = .system, .color = .{ .r = 0x3b, .g = 0x6e, .b = 0xd8 } }, .{ .id = "STARRED", .name = "Starred", .kind = .system, .color = .{ .r = 0xd9, .g = 0xa4, .b = 0x06 } }, .{ .id = "SENT", .name = "Sent", .kind = .system, .color = gray }, .{ .id = "DRAFT", .name = "Drafts", .kind = .system, .color = gray }, .{ .id = "SPAM", .name = "Spam", .kind = .system, .color = .{ .r = 0xb9, .g = 0x1c, .b = 0x1c } }, .{ .id = "TRASH", .name = "Trash", .kind = .system, .color = gray }, }; gpa: Allocator, /// Holds the strings of labels and threads for as long as the mailbox. strings: ArenaAllocator, labels: ArrayList(LabelInfo), threads: ArrayList(Thread), /// The threads' Gmail ids, in the threads' order, so a thread's place /// is its id's. index: std.array_hash_map.String(void), /// Everyone on the threads' messages: an address, lowercased, a zero /// byte, and a name, to the name, so a note names each by a place here /// rather than by its strings. The name is part of the key: senders such /// as GitHub send every message from one address, each under the name of /// whoever acted. people: std.array_hash_map.String([]const u8), listener: ?Listener, /// Gmail's historyId when this copy was made, to ask what has changed /// since; 0 when it was not made from Gmail. history: u64, /// What another backend asks what has changed since, as it says, such /// as JMAP's states; empty when it has none. `strings` holds it. state: []const u8, walk: Walk, /// A mailbox with Gmail's system labels and no threads. pub fn init(gpa: Allocator) Allocator.Error!Mailbox { var self: Mailbox = .{ .gpa = gpa, .strings = .init(gpa), .labels = .empty, .threads = .empty, .index = .empty, .people = .empty, .listener = null, .history = 0, .state = "", .walk = .{ .page = "" }, }; try self.labels.appendSlice(gpa, &system_labels); return self; } pub fn deinit(self: *Mailbox) void { for (self.threads.items) |*t| t.labels.deinit(self.gpa); self.threads.deinit(self.gpa); self.index.deinit(self.gpa); self.people.deinit(self.gpa); self.labels.deinit(self.gpa); self.strings.deinit(); self.* = undefined; } /// Adds a label; every thread's set grows to hold it. pub fn addLabel(self: *Mailbox, info: LabelInfo) Allocator.Error!Label { const a = self.strings.allocator(); try self.labels.append(self.gpa, .{ .id = try a.dupe(u8, info.id), .name = try a.dupe(u8, info.name), .kind = info.kind, .color = info.color, }); for (self.threads.items) |*t| try t.labels.resize(self.gpa, self.labels.items.len, false); return @fromBackingInt(@intCast(self.labels.items.len - 1)); } pub fn addThread(self: *Mailbox, new: NewThread) Allocator.Error!Id { return self.put(new, 0); } /// Adds the thread Gmail calls `new.id`, or replaces it if the mailbox /// has it, as Gmail described it at `version`, its historyId. It is /// Gmail's state, not the user's doing, so no listener hears it. pub fn put(self: *Mailbox, new: NewThread, version: u64) Allocator.Error!Id { const a = self.strings.allocator(); var places_len: u32 = 0; for (new.notes) |n| places_len += @intCast(n.to.len + n.cc.len); const notes = try a.alloc(Note, new.notes.len); const places = try a.alloc(u32, places_len); var used: u32 = 0; for (new.notes, notes) |n, *note| { const start = used; for ([_][]const Person{ n.to, n.cc }) |list| for (list) |p| { places[used] = try self.addPerson(p); used += 1; }; note.* = .{ .date = n.date, .from = try self.addPerson(n.from), .people = places[start..used], .to = @intCast(n.to.len), }; } return self.putNoted(new, notes, version); } /// As `put`, with the thread's notes already naming this mailbox's /// people; they are copied. pub fn putNoted(self: *Mailbox, new: NewThread, notes: []const Note, version: u64) Allocator.Error!Id { const a = self.strings.allocator(); const entry = try self.index.getOrPut(self.gpa, new.id); errdefer if (!entry.found_existing) self.index.swapRemoveAt(entry.index); var labels: Labels = if (entry.found_existing) self.threads.items[entry.index].labels else try .initEmpty(self.gpa, self.labels.items.len); errdefer if (!entry.found_existing) labels.deinit(self.gpa); labels.unsetAll(); for (new.labels) |l| labels.set(@backingInt(l)); const thread: Thread = .{ .id = if (entry.found_existing) entry.key_ptr.* else try a.dupe(u8, new.id), .participants = try a.dupe(u8, new.participants), .subject = try a.dupe(u8, new.subject), .snippet = try a.dupe(u8, new.snippet), .date = new.date, .messages = new.messages, .labels = labels, .unread = new.unread, .version = version, .notes = try copyNotes(a, notes), .walked = true, }; // The key is the thread's own copy of its id. entry.key_ptr.* = thread.id; if (entry.found_existing) { self.threads.items[entry.index] = thread; } else { try self.threads.append(self.gpa, thread); } return @intCast(entry.index); } /// Takes thread `id` out; the last thread takes its place. pub fn remove(self: *Mailbox, id: Id) void { var gone = self.threads.swapRemove(id); gone.labels.deinit(self.gpa); self.index.swapRemoveAt(id); } /// Adds `from`'s threads this mailbox lacks, but those `except` names, /// with the labels of theirs this mailbox has too, matched by Gmail's /// ids. pub fn copyAbsent( self: *Mailbox, from: *const Mailbox, except: ?*const std.array_hash_map.String(void), ) Allocator.Error!void { // Each of `from`'s labels as this mailbox's, found once rather than // once a thread. const map = try self.gpa.alloc(?Label, from.labels.items.len); defer self.gpa.free(map); for (from.labels.items, map) |info, *l| l.* = self.findLabel(info.id); // Likewise its people, each found when first met. const unmet = std.math.maxInt(u32); const people = try self.gpa.alloc(u32, from.people.count()); defer self.gpa.free(people); @memset(people, unmet); var notes: ArrayList(Note) = .empty; defer notes.deinit(self.gpa); var places: ArrayList(u32) = .empty; defer places.deinit(self.gpa); try self.threads.ensureUnusedCapacity(self.gpa, from.threads.items.len); try self.index.ensureUnusedCapacity(self.gpa, from.threads.items.len); for (from.threads.items) |*t| { if (self.index.contains(t.id)) continue; if (except) |set| if (set.contains(t.id)) continue; var labels: [256]Label = undefined; var n: u32 = 0; var it = t.labels.iterator(.{}); while (it.next()) |i| { const l = map[i] orelse continue; if (n == labels.len) break; labels[n] = l; n += 1; } notes.clearRetainingCapacity(); places.clearRetainingCapacity(); for (t.notes) |note| for (note.people) |p| { try places.append(self.gpa, try self.meet(from, people, p)); }; var used: u32 = 0; for (t.notes) |note| { const end: u32 = used + @as(u32, @intCast(note.people.len)); try notes.append(self.gpa, .{ .date = note.date, .from = try self.meet(from, people, note.from), .people = places.items[used..end], .to = note.to, }); used = end; } const id = try self.putNoted(.{ .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 = &.{}, }, notes.items, t.version); self.threads.items[id].walked = t.walked; } } /// `from`'s person `p` as this mailbox's, found through `people`, the /// places found so far. fn meet(self: *Mailbox, from: *const Mailbox, people: []u32, p: u32) Allocator.Error!u32 { if (people[p] == std.math.maxInt(u32)) people[p] = try self.addPerson(from.person(p)); return people[p]; } /// The place of the person with `p`'s address and name among the /// mailbox's people, added if new. pub fn addPerson(self: *Mailbox, p: Person) Allocator.Error!u32 { var buf: [512]u8 = undefined; const len = p.address.len + 1 + p.name.len; const key = if (len <= buf.len) buf[0..len] else try self.gpa.alloc(u8, len); defer if (len > buf.len) self.gpa.free(key); _ = ascii.lowerString(key, p.address); key[p.address.len] = 0; @memcpy(key[p.address.len + 1 ..], p.name); const entry = try self.people.getOrPut(self.gpa, key); if (!entry.found_existing) { const kept = self.strings.allocator().dupe(u8, key) catch |err| { self.people.swapRemoveAt(entry.index); return err; }; entry.key_ptr.* = kept; entry.value_ptr.* = kept[p.address.len + 1 ..]; } return @intCast(entry.index); } /// The person at place `p` among the mailbox's people; the address is /// lowercased. pub fn person(self: *const Mailbox, p: u32) Person { const key = self.people.keys()[p]; return .{ .name = self.people.values()[p], .address = key[0..mem.findScalar(u8, key, 0).?] }; } /// Notes and their people's places, copied together. fn copyNotes(a: Allocator, notes: []const Note) Allocator.Error![]const Note { var places_len: usize = 0; for (notes) |n| places_len += n.people.len; const out = try a.dupe(Note, notes); var places = try a.alloc(u32, places_len); for (out) |*n| { @memcpy(places[0..n.people.len], n.people); n.people = places[0..n.people.len]; places = places[n.people.len..]; } return out; } /// Keeps `state`, copied. pub fn setState(self: *Mailbox, state: []const u8) Allocator.Error!void { self.state = try self.strings.allocator().dupe(u8, state); } /// Keeps `walk`, copied. pub fn setWalk(self: *Mailbox, walk: Walk) Allocator.Error!void { self.walk = switch (walk) { .page => |token| .{ .page = try self.strings.allocator().dupe(u8, token) }, .done => .done, }; } /// Starts the walk again from the first page, which has passed no thread /// yet. pub fn startWalk(self: *Mailbox) void { self.walk = .{ .page = "" }; for (self.threads.items) |*t| t.walked = false; } /// Notes that the walk passed the thread Gmail calls `id`, if the /// mailbox has it. pub fn passed(self: *Mailbox, id: []const u8) void { if (self.findThread(id)) |i| self.threads.items[i].walked = true; } /// Adds Gmail's ids of the threads the walk has not passed to `out`. pub fn unwalked(self: *const Mailbox, gpa: Allocator, out: *ArrayList([]const u8)) Allocator.Error!void { for (self.threads.items) |*t| { if (!t.walked) try out.append(gpa, t.id); } } /// The user's labels, after Gmail's system ones. pub fn userLabels(self: *const Mailbox) []const LabelInfo { return self.labels.items[system_labels.len..]; } pub fn labelCount(self: *const Mailbox) u16 { return @intCast(self.labels.items.len); } pub fn label(self: *const Mailbox, l: Label) *const LabelInfo { return &self.labels.items[@backingInt(l)]; } /// The label Gmail calls `id`. pub fn findLabel(self: *const Mailbox, id: []const u8) ?Label { for (self.labels.items, 0..) |info, i| { if (mem.eql(u8, info.id, id)) return @fromBackingInt(@intCast(i)); } return null; } /// The thread Gmail calls `id`. pub fn findThread(self: *const Mailbox, id: []const u8) ?Id { return if (self.index.getIndex(id)) |i| @intCast(i) else null; } /// Replaces `out` with the threads `filter` selects, newest first. pub fn query(self: *const Mailbox, filter: Filter, out: *ArrayList(Id)) Allocator.Error!void { out.clearRetainingCapacity(); for (self.threads.items, 0..) |*t, i| { if (self.matches(t, filter)) try out.append(self.gpa, @intCast(i)); } // Stable, so threads of one second keep their order from run to run. mem.sort(Id, out.items, self, newerFirst); } /// How many threads `filter` selects, and how many of those are unread. pub fn count(self: *const Mailbox, filter: Filter) struct { total: u32, unread: u32 } { var total: u32 = 0; var unread: u32 = 0; for (self.threads.items) |*t| { if (!self.matches(t, filter)) continue; total += 1; if (t.unread) unread += 1; } return .{ .total = total, .unread = unread }; } /// Replaces `out` with what came to the Inbox since `before`, newest /// first: its unread threads that `before` did not have, or that have /// a newer message than they had then. pub fn arrivals(self: *const Mailbox, before: *const Mailbox, out: *ArrayList(Id)) Allocator.Error!void { out.clearRetainingCapacity(); for (self.threads.items, 0..) |*t, i| { const then = if (before.findThread(t.id)) |old| before.get(old).date else null; if (self.arrived(@intCast(i), then)) try out.append(self.gpa, @intCast(i)); } mem.sort(Id, out.items, self, newerFirst); } /// Whether thread `id` came to the Inbox since its newest message was /// from `then`, null when it was not had then: it is unread there, and /// new or has a newer message. pub fn arrived(self: *const Mailbox, id: Id, then: ?i64) bool { const t = self.get(id); if (!t.unread or !t.labels.isSet(@backingInt(Label.inbox))) return false; return if (then) |date| t.date > date else true; } pub fn get(self: *const Mailbox, id: Id) *const Thread { return &self.threads.items[id]; } pub fn has(self: *const Mailbox, id: Id, l: Label) bool { return self.threads.items[id].labels.isSet(@backingInt(l)); } /// Archiving takes a thread out of the inbox and nothing else. pub fn archive(self: *Mailbox, id: Id) void { if (!self.has(id, .inbox)) return; self.threads.items[id].labels.unset(@backingInt(Label.inbox)); self.tell(.{ .label = .{ .thread = id, .label = .inbox, .on = false } }); } /// Trash hides every other label, as Gmail's list does. pub fn trash(self: *Mailbox, id: Id) void { if (self.has(id, .trash)) return; const labels = &self.threads.items[id].labels; labels.unsetAll(); labels.set(@backingInt(Label.trash)); self.tell(.{ .trash = id }); } /// Takes a thread out of the trash with `labels`, those it had before, /// as undoing `trash` does. pub fn untrash(self: *Mailbox, id: Id, labels: *const Labels) void { if (!self.has(id, .trash)) return; const own = &self.threads.items[id].labels; own.unsetAll(); // A label added since is on neither. var it = labels.iterator(.{}); while (it.next()) |i| own.set(i); own.unset(@backingInt(Label.trash)); self.tell(.{ .untrash = .{ .thread = id, .inbox = self.has(id, .inbox) } }); } pub fn toggleLabel(self: *Mailbox, id: Id, l: Label) void { self.threads.items[id].labels.toggle(@backingInt(l)); self.tell(.{ .label = .{ .thread = id, .label = l, .on = self.has(id, l) } }); } /// Puts a label on (`on`) or takes it off; a thread that has it so /// already says nothing. pub fn setLabel(self: *Mailbox, id: Id, l: Label, on: bool) void { if (self.has(id, l) != on) self.toggleLabel(id, l); } pub fn setUnread(self: *Mailbox, id: Id, unread: bool) void { if (self.threads.items[id].unread == unread) return; self.threads.items[id].unread = unread; self.tell(.{ .unread = .{ .thread = id, .unread = unread } }); } fn tell(self: *const Mailbox, change: Change) void { if (self.listener) |l| l.changed(l.context, self, change); } fn matches(self: *const Mailbox, t: *const Thread, filter: Filter) bool { var hidden_shown = false; for (filter.labels) |l| { if (!t.labels.isSet(@backingInt(l))) return false; if (l == .spam or l == .trash) hidden_shown = true; } const hidden = t.labels.isSet(@backingInt(Label.spam)) or t.labels.isSet(@backingInt(Label.trash)); if (hidden and !hidden_shown and !(if (filter.query) |q| q.showsHidden() else false)) return false; if (filter.among) |set| if (!set.contains(t.id)) return false; const q = filter.query orelse return true; return q.matches(self, t); } /// Orders threads newest first, for `mem.sort`. pub fn newerFirst(self: *const Mailbox, a: Id, b: Id) bool { return self.threads.items[a].date > self.threads.items[b].date; } test "a label added later reaches every thread; query stacks labels, leaves out trash, and sorts newest first" { var mail: Mailbox = try .init(testing.allocator); defer mail.deinit(); const older = try mail.addThread(.{ .id = "t1", .participants = "Ana", .subject = "Older", .snippet = "", .date = 100, .messages = 1, .labels = &.{.inbox}, .unread = false, .notes = &.{}, }); const newer = try mail.addThread(.{ .id = "t2", .participants = "Bo", .subject = "Newer about Lisbon", .snippet = "", .date = 200, .messages = 1, .labels = &.{ .inbox, .starred }, .unread = true, .notes = &.{}, }); const work = try mail.addLabel(.{ .id = "Label_1", .name = "Work", .kind = .user, .color = gray }); mail.toggleLabel(older, work); try testing.expect(mail.has(older, work) and !mail.has(newer, work)); try testing.expectEqual(work, mail.findLabel("Label_1").?); var ids: ArrayList(Id) = .empty; defer ids.deinit(testing.allocator); try mail.query(.{ .labels = &.{.inbox} }, &ids); try testing.expectEqualSlices(Id, &.{ newer, older }, ids.items); var lisbon: Query = try .init(testing.allocator, "lisbon", 0, &mail); defer lisbon.deinit(testing.allocator); try mail.query(.{ .query = &lisbon }, &ids); try testing.expectEqualSlices(Id, &.{newer}, ids.items); try testing.expectEqual(1, mail.count(.{}).unread); // Labels stack: a thread must have every one. try mail.query(.{ .labels = &.{ .inbox, .starred } }, &ids); try testing.expectEqualSlices(Id, &.{newer}, ids.items); try mail.query(.{ .labels = &.{ .starred, work } }, &ids); try testing.expectEqual(0, ids.items.len); // Trash is out of every view but one that names it. mail.trash(older); try mail.query(.{ .labels = &.{work} }, &ids); try testing.expectEqual(0, ids.items.len); try mail.query(.{}, &ids); try testing.expectEqualSlices(Id, &.{newer}, ids.items); try mail.query(.{ .labels = &.{.trash} }, &ids); try testing.expectEqualSlices(Id, &.{older}, ids.items); } test "arrivals are unread Inbox threads that are new, or have a newer message" { var before: Mailbox = try .init(testing.allocator); defer before.deinit(); var after: Mailbox = try .init(testing.allocator); defer after.deinit(); const thread: NewThread = .{ .id = "", .participants = "Ana", .subject = "", .snippet = "", .date = 100, .messages = 1, .labels = &.{.inbox}, .unread = true, .notes = &.{}, }; // Unread before and unchanged: no news, though still unread. var same = thread; same.id = "same"; _ = try before.addThread(same); _ = try after.addThread(same); // A reply came. var replied_then = thread; replied_then.id = "replied"; _ = try before.addThread(replied_then); var replied_now = replied_then; replied_now.date = 300; const replied = try after.addThread(replied_now); // New, but read already (on the phone), or archived. var read = thread; read.id = "read"; read.date = 400; read.unread = false; _ = try after.addThread(read); var archived = thread; archived.id = "archived"; archived.date = 400; archived.labels = &.{}; _ = try after.addThread(archived); // New. var fresh = thread; fresh.id = "new"; fresh.date = 200; const new = try after.addThread(fresh); var ids: ArrayList(Id) = .empty; defer ids.deinit(testing.allocator); try after.arrivals(&before, &ids); try testing.expectEqualSlices(Id, &.{ replied, new }, ids.items); } test "archive leaves other labels; trash replaces them and leaves All Mail" { var mail: Mailbox = try .init(testing.allocator); defer mail.deinit(); const id = try mail.addThread(.{ .id = "t1", .participants = "Ana", .subject = "Hi", .snippet = "", .date = 1, .messages = 1, .labels = &.{ .inbox, .starred }, .unread = false, .notes = &.{}, }); mail.archive(id); try testing.expect(!mail.has(id, .inbox) and mail.has(id, .starred)); try testing.expectEqual(1, mail.count(.{}).total); mail.trash(id); try testing.expect(mail.has(id, .trash) and !mail.has(id, .starred)); try testing.expectEqual(0, mail.count(.{}).total); } test "one address under two names is two people, as GitHub sends each actor's mail" { var mail: Mailbox = try .init(testing.allocator); defer mail.deinit(); const tim = try mail.addPerson(.{ .name = "Tim Culverhouse", .address = "notifications@github.com" }); const mitchell = try mail.addPerson(.{ .name = "Mitchell Hashimoto", .address = "Notifications@GitHub.com" }); try testing.expect(tim != mitchell); try testing.expectEqualStrings("Mitchell Hashimoto", mail.person(mitchell).name); try testing.expectEqualStrings("notifications@github.com", mail.person(mitchell).address); try testing.expectEqualStrings("Tim Culverhouse", mail.person(tim).name); const again = try mail.addPerson(.{ .name = "Tim Culverhouse", .address = "NOTIFICATIONS@github.com" }); try testing.expectEqual(tim, again); }