Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397//! The mail as last seen, kept between runs, so the window opens on it//! and Gmail is asked only what has changed since (Gmail.update). It is//! a copy Gmail can always replace: one that cannot be read is ignored.//!//! It holds up to `kept` threads, which the backfill (Backfill.zig)//! fills over many runs, so the file is read before the window opens and//! must be quick to read: a flat binary layout, strings with their//! lengths before them, little-endian, read in one pass with no parse.
const std = @import("std");const Mailbox = @import("Mailbox.zig");const Settings = @import("Settings.zig");const mem = std.mem;const testing = std.testing;const Allocator = std.mem.Allocator;const ArenaAllocator = std.heap.ArenaAllocator;const Io = std.Io;const log = std.log.scoped(.cache);
pub const file_name = "mail.cache";
/// What an older parcel saved, as JSON; removed once the new file is/// written.const legacy_file_name = "mail.json";
/// Starts the file, and changes when its layout does; a file without it/// is ignored.const magic = "parcel mail\x00" ++ "\x04\x00\x00\x00";
/// A thread's byte of flags. A file from before `unwalked` has it clear,/// so a walk under way then drops none of its threads.const Flags = packed struct(u8) { unread: bool, /// The walk under way has not passed it (Mailbox.Thread.walked). unwalked: bool, spare: u6,};
/// The most threads kept: the newest, and past them only the Inbox's./// At about 450 bytes each, with who is on their messages, 100,000 make/// a file of some 45 MB.pub const kept = 100_000;
/// The largest file read, well past what `kept` threads take.const max_bytes = 512 << 20;
/// The mail saved last, or null when there is none to use.pub fn load(gpa: Allocator, settings: Settings) ?Mailbox { const bytes = settings.loadAlloc(file_name, gpa, .limited(max_bytes)) orelse return null; defer gpa.free(bytes); return parse(gpa, bytes) catch |err| { log.warn("{s}: {t}; reading the mail again", .{ file_name, err }); return null; };}
/// Saves `mail`, replacing what was saved, for the user's eyes alone. A/// failure is logged: the next run reads the mail from Gmail instead.pub fn save(gpa: Allocator, settings: Settings, mail: *const Mailbox) void { var arena_state: ArenaAllocator = .init(gpa); defer arena_state.deinit(); const bytes = serialize(arena_state.allocator(), mail) catch |err| { return log.warn("{s}: {t}", .{ file_name, err }); }; settings.saveSecret(file_name, bytes) catch |err| return log.warn("{s}: {t}", .{ file_name, err }); settings.remove(legacy_file_name);}
fn serialize(arena: Allocator, mail: *const Mailbox) ![]const u8 { const ids = try keep(arena, mail); var out: Io.Writer.Allocating = try .initCapacity(arena, 64 + ids.len * 320); const w = &out.writer; try w.writeAll(magic); try w.writeInt(u64, mail.history, .little); try string(w, mail.state); switch (mail.walk) { .page => |token| { try w.writeByte(0); try string(w, token); }, .done => try w.writeByte(1), } // Every label, the system ones too, so a thread names each by its // place here. try w.writeInt(u32, @intCast(mail.labels.items.len), .little); for (mail.labels.items) |l| { try string(w, l.id); try string(w, l.name); try w.writeAll(&.{ @backingInt(l.kind), l.color.r, l.color.g, l.color.b }); } // Everyone, so a note names each by their place here. try w.writeInt(u32, @intCast(mail.people.count()), .little); for (0..mail.people.count()) |i| { const p = mail.person(@intCast(i)); try string(w, p.address); try string(w, p.name); } try w.writeInt(u32, @intCast(ids.len), .little); for (ids) |id| { const t = mail.get(id); try string(w, t.id); try string(w, t.participants); try string(w, t.subject); try string(w, t.snippet); try w.writeInt(i64, t.date, .little); try w.writeInt(u32, t.messages, .little); try w.writeInt(u64, t.version, .little); try w.writeByte(@bitCast(Flags{ .unread = t.unread, .unwalked = !t.walked, .spare = 0 })); try w.writeInt(u16, @intCast(t.labels.count()), .little); var it = t.labels.iterator(.{}); while (it.next()) |i| try w.writeInt(u16, @intCast(i), .little); try w.writeInt(u32, @intCast(t.notes.len), .little); for (t.notes) |n| { try w.writeInt(i64, n.date, .little); try w.writeInt(u32, n.from, .little); try w.writeInt(u32, n.to, .little); try w.writeInt(u32, @intCast(n.people.len), .little); for (n.people) |p| try w.writeInt(u32, p, .little); } } return out.written();}
fn string(w: *Io.Writer, s: []const u8) !void { try w.writeInt(u32, @intCast(s.len), .little); try w.writeAll(s);}
/// The threads to save, newest first: the newest `kept`, and the Inbox's.fn keep(arena: Allocator, mail: *const Mailbox) ![]const Mailbox.Id { const ids = try arena.alloc(Mailbox.Id, mail.threads.items.len); for (ids, 0..) |*id, i| id.* = @intCast(i); mem.sort(Mailbox.Id, ids, mail, struct { fn newer(m: *const Mailbox, a: Mailbox.Id, b: Mailbox.Id) bool { return m.get(a).date > m.get(b).date; } }.newer); var out: std.ArrayList(Mailbox.Id) = try .initCapacity(arena, ids.len); for (ids, 0..) |id, rank| { if (rank < kept or mail.has(id, .inbox)) out.appendAssumeCapacity(id); } return out.items;}
fn parse(gpa: Allocator, bytes: []const u8) !Mailbox { var r: Io.Reader = .fixed(bytes); if (!mem.eql(u8, try r.take(magic.len), magic)) return error.OtherVersion; var mail: Mailbox = try .init(gpa); errdefer mail.deinit(); mail.history = try r.takeInt(u64, .little); try mail.setState(try take(&r)); switch (try r.takeByte()) { 0 => try mail.setWalk(.{ .page = try take(&r) }), 1 => mail.walk = .done, else => return error.Corrupt, } // The file's labels, by place, as the mailbox's. const label_count = try r.takeInt(u32, .little); const labels = try gpa.alloc(Mailbox.Label, label_count); defer gpa.free(labels); for (labels) |*l| { const id = try take(&r); const name = try take(&r); const rest = try r.takeArray(4); l.* = mail.findLabel(id) orelse try mail.addLabel(.{ .id = id, .name = name, .kind = .user, .color = .{ .r = rest[1], .g = rest[2], .b = rest[3] }, }); } // The file's people, by place, as the mailbox's. const people_count = try r.takeInt(u32, .little); if (people_count > bytes.len / 8) return error.Corrupt; const people = try gpa.alloc(u32, people_count); defer gpa.free(people); for (people) |*p| { const address = try take(&r); p.* = try mail.addPerson(.{ .address = address, .name = try take(&r) }); } const thread_count = try r.takeInt(u32, .little); // Each thread takes some 30 bytes at least: a count past that is not // to be believed, and not to allocate for. if (thread_count > bytes.len / 30) return error.Corrupt; try mail.threads.ensureTotalCapacity(gpa, thread_count); try mail.index.ensureTotalCapacity(gpa, thread_count); var on: std.ArrayList(Mailbox.Label) = .empty; defer on.deinit(gpa); var notes: std.ArrayList(Mailbox.Note) = .empty; defer notes.deinit(gpa); var places: std.ArrayList(u32) = .empty; defer places.deinit(gpa); for (0..thread_count) |_| { const id = try take(&r); const participants = try take(&r); const subject = try take(&r); const snippet = try take(&r); const date = try r.takeInt(i64, .little); const messages = try r.takeInt(u32, .little); const version = try r.takeInt(u64, .little); const flags: Flags = @bitCast(try r.takeByte()); on.clearRetainingCapacity(); for (0..try r.takeInt(u16, .little)) |_| { const i = try r.takeInt(u16, .little); if (i >= labels.len) return error.Corrupt; try on.append(gpa, labels[i]); } try takeNotes(&r, gpa, people, ¬es, &places); const place = try mail.putNoted(.{ .id = id, .participants = participants, .subject = subject, .snippet = snippet, .date = date, .messages = messages, .labels = on.items, .unread = flags.unread, .notes = &.{}, }, notes.items, version); mail.threads.items[place].walked = !flags.unwalked; } if (r.seek != bytes.len) return error.Corrupt; return mail;}
/// A thread's notes, into `notes`, their people's places into `places`/// as the mailbox's, mapped from the file's through `people`.fn takeNotes( r: *Io.Reader, gpa: Allocator, people: []const u32, notes: *std.ArrayList(Mailbox.Note), places: *std.ArrayList(u32),) !void { notes.clearRetainingCapacity(); places.clearRetainingCapacity(); const count = try r.takeInt(u32, .little); if (count > r.bufferedLen() / 20) return error.Corrupt; try notes.ensureTotalCapacity(gpa, count); for (0..count) |_| { const date = try r.takeInt(i64, .little); const from = try r.takeInt(u32, .little); const to = try r.takeInt(u32, .little); const len = try r.takeInt(u32, .little); if (from >= people.len or to > len or len > r.bufferedLen() / 4) return error.Corrupt; const start = places.items.len; for (0..len) |_| { const p = try r.takeInt(u32, .little); if (p >= people.len) return error.Corrupt; try places.append(gpa, people[p]); } // Pointed again below: `places` may move as it grows. notes.appendAssumeCapacity(.{ .date = date, .from = people[from], .people = places.items[start..][0..len], .to = to, }); } var start: usize = 0; for (notes.items) |*n| { n.people = places.items[start..][0..n.people.len]; start += n.people.len; }}
/// A string, as `string` wrote it.fn take(r: *Io.Reader) ![]const u8 { return r.take(try r.takeInt(u32, .little));}
test "saved mail reads back the same: labels, threads, their labels, the history id, the state, and the walk" { var mail: Mailbox = try .init(testing.allocator); defer mail.deinit(); mail.history = 4_000_000_123; try mail.setState("12 34 56"); const color: Mailbox.Rgb = .{ .r = 1, .g = 2, .b = 3 }; const work = try mail.addLabel(.{ .id = "Label_9", .name = "Work", .kind = .user, .color = color }); const me: Mailbox.Person = .{ .name = "", .address = "me@example.com" }; const maya: Mailbox.Person = .{ .name = "Maya", .address = "maya@example.com" }; const ravi: Mailbox.Person = .{ .name = "Ravi", .address = "ravi@example.com" }; const ana: Mailbox.Person = .{ .name = "Ana", .address = "ana@example.com" }; // Someone on no thread kept takes a place too, so places move. _ = try mail.addPerson(.{ .name = "", .address = "gone@example.com" }); _ = try mail.put(.{ .id = "t1", .participants = "Maya, me", .subject = "“Quoted” \\ subject", .snippet = "line\nbreak", .date = 1_730_000_000, .messages = 3, .labels = &.{ .inbox, work }, .unread = true, .notes = &.{ .{ .date = 1_729_000_000, .from = me, .to = &.{maya}, .cc = &.{} }, .{ .date = 1_730_000_000, .from = maya, .to = &.{ me, ravi }, .cc = &.{ana} }, }, }, 77); // The walk under way has passed t1, not t2. _ = try mail.addThread(.{ .id = "t2", .participants = "", .subject = "", .snippet = "", .date = 1_800_000_000, .messages = 1, .labels = &.{}, .unread = false, .notes = &.{}, }); mail.startWalk(); mail.passed("t1"); try mail.setWalk(.{ .page = "09876543210" }); var arena_state: ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); const bytes = try serialize(arena_state.allocator(), &mail); var back = try parse(testing.allocator, bytes); defer back.deinit();
try testing.expectEqual(mail.history, back.history); try testing.expectEqualStrings("12 34 56", back.state); try testing.expectEqualStrings("09876543210", back.walk.page); try testing.expectEqual(mail.labelCount(), back.labelCount()); const back_work = back.findLabel("Label_9").?; try testing.expectEqualStrings("Work", back.label(back_work).name); try testing.expectEqual(color, back.label(back_work).color); const t1 = back.findThread("t1").?; const t = back.get(t1); try testing.expectEqualStrings("“Quoted” \\ subject", t.subject); try testing.expectEqualStrings("line\nbreak", t.snippet); try testing.expect(t.unread and t.messages == 3 and t.date == 1_730_000_000 and t.version == 77); try testing.expect(t.walked and !back.get(back.findThread("t2").?).walked); try testing.expect(back.has(t1, .inbox) and back.has(t1, back_work) and !back.has(t1, .starred)); try testing.expectEqual(2, t.notes.len); try testing.expectEqual(1_729_000_000, t.notes[0].date); try testing.expectEqualStrings("me@example.com", back.person(t.notes[0].from).address); const second = t.notes[1]; try testing.expectEqualStrings("Maya", back.person(second.from).name); try testing.expectEqual(2, second.to); try testing.expectEqual(3, second.people.len); for (second.people, [_][]const u8{ "me@example.com", "ravi@example.com", "ana@example.com" }) |p, want| { try testing.expectEqualStrings(want, back.person(p).address); }
// Another layout, a cut-off file, and a done walk. try testing.expectError(error.OtherVersion, parse(testing.allocator, "{\"version\":1}" ++ magic)); try testing.expectError(error.Corrupt, parse(testing.allocator, bytes[0 .. bytes.len - 1])); mail.walk = .done; var done = try parse(testing.allocator, try serialize(arena_state.allocator(), &mail)); defer done.deinit(); try testing.expect(done.walk == .done);}
test "past the newest threads, only the Inbox's are saved" { var mail: Mailbox = try .init(testing.allocator); defer mail.deinit(); var id_buf: [16]u8 = undefined; // Two older than the rest: one in the Inbox, one not. for (0..kept + 2) |i| { const date: i64 = @intCast(i); _ = try mail.addThread(.{ .id = try mem.print(&id_buf, "t{d}", .{i}), .participants = "", .subject = "", .snippet = "", .date = date, .messages = 1, .labels = if (i == 0) &.{.inbox} else &.{}, .unread = false, .notes = &.{}, }); } var arena_state: ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); var back = try parse(testing.allocator, try serialize(arena_state.allocator(), &mail)); defer back.deinit(); try testing.expectEqual(kept + 1, back.threads.items.len); try testing.expect(back.findThread("t0") != null); try testing.expect(back.findThread("t1") == null); try testing.expect(back.findThread("t2") != null); // Newest first, so the list needs little sorting. try testing.expectEqualStrings("t100001", back.get(0).id);}
test "the cache is the user's alone" { var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); const settings: Settings = .{ .io = testing.io, .dir = tmp.dir }; var mail: Mailbox = try .init(testing.allocator); defer mail.deinit(); save(testing.allocator, settings, &mail); const stat = try tmp.dir.statFile(testing.io, file_name, .{}); try testing.expectEqual(0o600, @backingInt(stat.permissions) & 0o777); var back = load(testing.allocator, settings).?; defer back.deinit();}