//! 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(); }