Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820//! 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 <mailto:…> and <https://…>; 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);}