//! Keeps the draft being written in Gmail's drafts, so a quit or a crash //! does not lose it, and sends it from there. Compose hands it the draft //! to keep (a Compose.Keeper): the first time Gmail makes a draft of it //! (10 quota units), and after that replaces the draft's message (15). //! A draft has one request out at a time; what changes meanwhile goes as //! one request when it comes back, as Sync does a thread's labels. //! Sending sends the draft (100 units), as last given; one thrown away is //! deleted (10), once Gmail has named it. Requests go through Http, on //! the event loop. //! //! The id of the draft being written is saved between runs, and the next //! run reads it back whole (60 units), and its files (20 each), for //! compose, which c then opens. const std = @import("std"); const siv = @import("siv"); const Compose = @import("Compose.zig"); const Gmail = @import("Gmail.zig"); const Http = @import("Http.zig"); const Settings = @import("Settings.zig"); const Sync = @import("Sync.zig"); const mime = @import("mime.zig"); const mem = std.mem; const testing = std.testing; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const ArrayList = std.ArrayList; const log = std.log.scoped(.drafts); const Drafts = @This(); /// Hears what came of the drafts, on the app's thread. pub const Host = struct { context: *anyopaque, /// Whether the draft sent went. sent: *const fn (context: *anyopaque, ok: bool) void, /// Offers compose the draft kept on an earlier run; true when it /// takes it. restored: *const fn (context: *anyopaque, message: Compose.Outgoing) bool, trouble: *const fn (context: *anyopaque, trouble: Sync.Trouble) void, }; /// Where the id of the draft being written is saved. pub const file_name = "draft"; gpa: Allocator, /// The session's; its authorization changes when the token is refreshed. gmail: *const Gmail, settings: ?Settings, host: Host, /// The drafts with something to do: the one being written, and those /// thrown away or kept from an earlier run that Gmail has not answered /// for yet. drafts: ArrayList(*Draft), /// The one compose is writing, once it has given it. current: ?*Draft, /// Set until there is a token, and from a 401 until a new one: nothing /// is sent meanwhile. paused: bool, /// A draft, and what Gmail has of it. const Draft = struct { /// Gmail's id for it; empty until Gmail names it. id: ArrayList(u8), /// Its message as last given, as mime.message makes it, and the /// thread it answers. raw: ArrayList(u8), thread: ArrayList(u8), /// Whether Gmail lacks `raw`. dirty: bool, goal: Goal, /// The request out for it, if any. out: ?Request, /// What was read back of it, while its files are read. kept: ?*Kept, drafts: *Drafts, fn deinit(d: *Draft, gpa: Allocator) void { d.dropKept(gpa); d.id.deinit(gpa); d.raw.deinit(gpa); d.thread.deinit(gpa); d.* = undefined; } fn dropKept(d: *Draft, gpa: Allocator) void { const k = d.kept orelse return; k.arena.deinit(); gpa.destroy(k); d.kept = null; } }; /// A draft read back from Gmail. const Kept = struct { arena: ArenaAllocator, draft: Gmail.Draft, /// The next of its files to read. next: u32, }; /// What is to become of a draft. const Goal = enum { keep, send, delete, restore }; const Request = enum { create, update, send, delete, read, file }; /// Starts paused, as there is no token yet: drafts wait for `proceed`. pub fn init(gpa: Allocator, gmail: *const Gmail, settings: ?Settings, host: Host) Drafts { return .{ .gpa = gpa, .gmail = gmail, .settings = settings, .host = host, .drafts = .empty, .current = null, .paused = true, }; } /// Drops what is unsent. Deinit the Http first, so no reply comes after. pub fn deinit(self: *Drafts) void { for (self.drafts.items) |d| { d.deinit(self.gpa); self.gpa.destroy(d); } self.drafts.deinit(self.gpa); self.* = undefined; } /// What to set as compose's keeper. pub fn keeper(self: *Drafts) Compose.Keeper { return .{ .context = self, .save = onSave, .send = onSend, .discard = onDiscard }; } /// Whether no request is out. pub fn idle(self: *const Drafts) bool { for (self.drafts.items) |d| { if (d.out != null) return false; } return true; } /// Sends what waited for a new token. pub fn proceed(self: *Drafts) void { self.paused = false; // A step may finish a draft, and take it off the list. var i = self.drafts.items.len; while (i > 0) { i -= 1; self.step(self.drafts.items[i]); } } /// Reads back the draft whose id was saved on an earlier run, for /// compose; it is read once there is a token. pub fn restore(self: *Drafts) !void { const settings = self.settings orelse return; var buf: [256]u8 = undefined; const id = settings.load(file_name, &buf) orelse return; if (id.len == 0) return; const d = try self.add(.restore); errdefer self.finish(d); try d.id.appendSlice(self.gpa, id); self.step(d); } fn onSave(context: *anyopaque, message: Compose.Outgoing) void { const self: *Drafts = @ptrCast(@alignCast(context)); self.save(message, .keep) catch |err| { log.err("keeping the draft: {t}", .{err}); self.host.trouble(self.host.context, .failed); }; } fn onSend(context: *anyopaque, message: Compose.Outgoing) void { const self: *Drafts = @ptrCast(@alignCast(context)); self.save(message, .send) catch |err| { log.err("sending the draft: {t}", .{err}); self.host.sent(self.host.context, false); }; } /// Deletes the draft being written, once Gmail has named it. fn onDiscard(context: *anyopaque) void { const self: *Drafts = @ptrCast(@alignCast(context)); const d = self.current orelse return; self.current = null; d.goal = .delete; self.step(d); } /// Gives the draft being written its message as it is now, to keep or /// to send. fn save(self: *Drafts, message: Compose.Outgoing, goal: Goal) !void { var arena_state: ArenaAllocator = .init(self.gpa); defer arena_state.deinit(); const raw = try mime.message(arena_state.allocator(), message, .none); const d = self.current orelse try self.add(.keep); self.current = d; d.raw.clearRetainingCapacity(); try d.raw.appendSlice(self.gpa, raw); d.thread.clearRetainingCapacity(); try d.thread.appendSlice(self.gpa, message.thread); d.dirty = true; d.goal = goal; self.step(d); } fn add(self: *Drafts, goal: Goal) !*Draft { try self.drafts.ensureUnusedCapacity(self.gpa, 1); const d = try self.gpa.create(Draft); d.* = .{ .id = .empty, .raw = .empty, .thread = .empty, .dirty = false, .goal = goal, .out = null, .kept = null, .drafts = self, }; self.drafts.appendAssumeCapacity(d); return d; } /// The request that takes a draft toward its goal, if any is needed. fn next(d: *const Draft) ?Request { const named = d.id.items.len > 0; return switch (d.goal) { .keep => if (!d.dirty) null else if (named) .update else .create, // Made first if Gmail has none; then sent as given. .send => if (named) .send else .create, .delete => if (named) .delete else null, .restore => if (d.kept == null) .read else .file, }; } /// Sends a draft's next request, unless one is out; one with nothing /// left to do goes, unless it is the one being written. fn step(self: *Drafts, d: *Draft) void { if (d.out != null or self.paused) return; const request = next(d) orelse { if (d != self.current) self.finish(d); return; }; self.ask(d, request) catch |err| { log.err("{t} a draft: {t}", .{ request, err }); self.refused(d, request, err); }; } fn ask(self: *Drafts, d: *Draft, request: Request) !void { var arena_state: ArenaAllocator = .init(self.gpa); defer arena_state.deinit(); const arena = arena_state.allocator(); const g = self.gmail; const id = d.id.items; const raw = d.raw.items; const thread = d.thread.items; switch (request) { .create => try g.createDraft(arena, raw, thread, d, onReply), .update => try g.updateDraft(arena, id, raw, thread, d, onReply), .send => try g.sendDraft(arena, id, if (d.dirty) raw else null, thread, d, onReply), .delete => try g.deleteDraft(arena, id, d, onReply), .read => try g.readDraft(arena, id, d, onReply), .file => { const k = d.kept.?; try g.readAttachment(arena, k.draft.message_id, k.draft.file_ids[k.next], d, onReply); }, } d.out = request; // What it carries Gmail has once it is answered; a change meanwhile // goes after. if (carries(request)) d.dirty = false; } fn onReply(context: *anyopaque, response: Http.Error!Http.Response) void { const d: *Draft = @ptrCast(@alignCast(context)); const self = d.drafts; const request = d.out.?; d.out = null; var arena_state: ArenaAllocator = .init(self.gpa); defer arena_state.deinit(); const arena = arena_state.allocator(); const what = switch (request) { .create => "POST /drafts", .update => "PUT /drafts/…", .send => "POST /drafts/send", .delete => "DELETE /drafts/…", .read => "GET /drafts/…", .file => "GET /messages/…/attachments/…", }; const body = Gmail.check(what, arena, response) catch |err| return self.refused(d, request, err); self.answered(d, request, arena, body) catch |err| { log.err("{s}: {t}", .{ what, err }); self.refused(d, request, err); }; } fn answered(self: *Drafts, d: *Draft, request: Request, arena: Allocator, body: []const u8) !void { switch (request) { .create => { const id = try Gmail.draftId(arena, body); try d.id.appendSlice(self.gpa, id); if (d == self.current) if (self.settings) |s| s.save(file_name, id); }, .update => {}, .send => { self.forget(d); self.finish(d); return self.host.sent(self.host.context, true); }, .delete => { self.forget(d); return self.finish(d); }, .read => { const k = try self.gpa.create(Kept); k.* = .{ .arena = .init(self.gpa), .draft = undefined, .next = 0 }; d.kept = k; // Its strings point into the body, which goes with `arena`. const ka = k.arena.allocator(); k.draft = try Gmail.draft(ka, try ka.dupe(u8, body)); return self.gather(d); }, .file => { const k = d.kept.?; k.draft.attachments[k.next].data = try Gmail.attachmentData(k.arena.allocator(), body); k.next += 1; return self.gather(d); }, } self.step(d); } /// Reads the next file of the draft read back that Gmail did not give /// with it, or offers it to compose once it has them all. fn gather(self: *Drafts, d: *Draft) !void { const k = d.kept.?; const ids = k.draft.file_ids; while (k.next < ids.len and ids[k.next].len == 0) k.next += 1; if (k.next < ids.len) return self.step(d); try self.offer(d, k.draft.message); } /// Offers compose the draft read back. One it does not take, as it has /// a draft already, stays in Gmail's drafts. fn offer(self: *Drafts, d: *Draft, message: Compose.Outgoing) !void { if (!self.host.restored(self.host.context, message)) return self.finish(d); var arena_state: ArenaAllocator = .init(self.gpa); defer arena_state.deinit(); d.goal = .keep; try d.raw.appendSlice(self.gpa, try mime.message(arena_state.allocator(), message, .none)); try d.thread.appendSlice(self.gpa, message.thread); d.dropKept(self.gpa); self.current = d; } /// Whether a request carries the draft's message. fn carries(request: Request) bool { return switch (request) { .create, .update, .send => true, .delete, .read, .file => false, }; } /// A request that failed, or could not be made. A 401 waits for a new /// token; a draft Gmail no longer has is made again, unless it was to /// go; anything else waits for the next change, with what it carried. fn refused(self: *Drafts, d: *Draft, request: Request, err: anyerror) void { if (carries(request)) d.dirty = true; if (err == error.Unauthorized) { if (!self.paused) { self.paused = true; self.host.trouble(self.host.context, .unauthorized); } return; } if (err == error.NotFound) switch (request) { // Deleted in Gmail meanwhile, or sent from elsewhere. .update, .send => { d.id.clearRetainingCapacity(); return self.step(d); }, .delete, .read => { self.forget(d); return self.finish(d); }, .create, .file => {}, }; switch (request) { .create, .update => self.host.trouble(self.host.context, .failed), .send => { d.goal = .keep; self.host.sent(self.host.context, false); }, // It stays in Gmail's drafts; a read is tried again next run. .delete, .read, .file => self.finish(d), } } /// Forgets the saved id, if it is this draft's. fn forget(self: *Drafts, d: *const Draft) void { const settings = self.settings orelse return; var buf: [256]u8 = undefined; const saved = settings.load(file_name, &buf) orelse return; if (mem.eql(u8, saved, d.id.items)) settings.remove(file_name); } fn finish(self: *Drafts, d: *Draft) void { if (self.current == d) self.current = null; if (mem.findScalar(*Draft, self.drafts.items, d)) |i| _ = self.drafts.orderedRemove(i); d.deinit(self.gpa); self.gpa.destroy(d); } test "a draft is made once, replaced as it changes, and sent as last given; its id is saved until then" { var fake: Gmail.Fake = undefined; try fake.start(&routes); defer fake.stop(); var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); const settings: Settings = .{ .io = testing.io, .dir = tmp.dir }; var host: TestHost = .init(); defer host.deinit(); var drafts: Drafts = .init(testing.allocator, &fake.gmail, settings, host.host()); defer drafts.deinit(); const k = drafts.keeper(); // Nothing goes before there is a token. k.save(k.context, reply("one")); try testing.expect(drafts.idle()); drafts.proceed(); try settle(&fake, &drafts); var buf: [64]u8 = undefined; try testing.expectEqualStrings("d1", settings.load(file_name, &buf).?); // Changes while one is out go as one, the last. k.save(k.context, reply("two")); k.save(k.context, reply("three")); k.save(k.context, reply("four")); try settle(&fake, &drafts); k.send(k.context, reply("five")); try settle(&fake, &drafts); try testing.expectEqual(true, host.sent); try testing.expect(settings.load(file_name, &buf) == null); try testing.expectEqual(0, drafts.drafts.items.len); const seen = try fake.asked(); const create = "{\"message\":{\"threadId\":\"t1\"}}"; const named = "{\"id\":\"d1\",\"message\":{\"threadId\":\"t1\"}}"; const expected = [_]struct { std.http.Method, []const u8, []const u8, []const u8 }{ .{ .POST, "/upload/drafts?uploadType=multipart", create, "one" }, .{ .PUT, "/upload/drafts/d1?uploadType=multipart", named, "two" }, .{ .PUT, "/upload/drafts/d1?uploadType=multipart", named, "four" }, .{ .POST, "/upload/drafts/send?uploadType=multipart", named, "five" }, }; try testing.expectEqual(expected.len, seen.len); for (expected, seen) |want, got| { try testing.expectEqual(want[0], got.method); try testing.expectEqualStrings(want[1], got.target); // The Draft resource, then the message, with what it answers. const metadata = "--=_parcel_upload\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n"; try testing.expect(mem.startsWith(u8, got.body, metadata)); try testing.expect(mem.startsWith(u8, got.body[metadata.len..], want[2])); const subject = try host.arena.allocator().print("Subject: Re: {s}\r\n", .{want[3]}); try testing.expect(mem.find(u8, got.body, subject) != null); try testing.expect(mem.find(u8, got.body, "In-Reply-To: \r\nReferences: \r\n") != null); } } test "a draft thrown away is deleted once named; the kept one comes back with its files, made again if Gmail lost it" { var fake: Gmail.Fake = undefined; try fake.start(&routes); defer fake.stop(); var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); const settings: Settings = .{ .io = testing.io, .dir = tmp.dir }; var host: TestHost = .init(); defer host.deinit(); var drafts: Drafts = .init(testing.allocator, &fake.gmail, settings, host.host()); defer drafts.deinit(); drafts.proceed(); const k = drafts.keeper(); k.save(k.context, reply("one")); k.discard(k.context); try settle(&fake, &drafts); var buf: [64]u8 = undefined; try testing.expect(settings.load(file_name, &buf) == null); try testing.expectEqual(0, drafts.drafts.items.len); // Saved on an earlier run: read back for compose, as it was typed, // and still a reply in its thread. settings.save(file_name, "r1"); try drafts.restore(); try settle(&fake, &drafts); const back = host.restored.?; try testing.expectEqualStrings("Ana ", back.to); try testing.expectEqualStrings("Re: Lunch", back.subject); try testing.expectEqualStrings("\n\nOn Thu, 5 Nov 2026, Ana wrote:\n> Lunch?\n", back.body); try testing.expectEqualStrings("t7", back.thread); try testing.expectEqualStrings("", back.in_reply_to); try testing.expectEqualStrings(" ", back.references); try testing.expectEqual(1, back.attachments.len); try testing.expectEqualStrings("plan.pdf", back.attachments[0].name); try testing.expectEqualStrings("application/pdf", back.attachments[0].mime); try testing.expectEqualStrings("%PDF", back.attachments[0].data); // Deleted in Gmail since: changed, it is made again, in its thread. k.save(k.context, back); try settle(&fake, &drafts); try testing.expectEqualStrings("d1", settings.load(file_name, &buf).?); try testing.expectEqual(0, host.troubles); const seen = try fake.asked(); const expected = [_]struct { std.http.Method, []const u8 }{ .{ .POST, "/upload/drafts?uploadType=multipart" }, .{ .DELETE, "/drafts/d1" }, .{ .GET, "/drafts/r1?format=full" }, .{ .GET, "/messages/m7/attachments/a1?fields=data" }, .{ .PUT, "/upload/drafts/r1?uploadType=multipart" }, .{ .POST, "/upload/drafts?uploadType=multipart" }, }; try testing.expectEqual(expected.len, seen.len); for (expected, seen) |want, got| { try testing.expectEqual(want[0], got.method); try testing.expectEqualStrings(want[1], got.target); } // Its file goes with it again. const made = seen[5].body; try testing.expect(mem.find(u8, made, "{\"message\":{\"threadId\":\"t7\"}}") != null); const file = "Content-Disposition: attachment; filename=\"plan.pdf\"\r\n" ++ "Content-Transfer-Encoding: base64\r\n\r\nJVBERg==\r\n"; try testing.expect(mem.find(u8, made, file) != null); } /// What the fake Gmail answers to the tests above. const routes = [_]Gmail.Fake.Route{ .ok("/upload/drafts/send?", "{\"id\":\"m9\",\"threadId\":\"t1\"}"), .{ .path = "/upload/drafts/r1?", .body = "{}", .status = .not_found }, .ok("/upload/drafts/d1?", "{\"id\":\"d1\"}"), .ok("/upload/drafts?", "{\"id\":\"d1\",\"message\":{\"id\":\"m1\"}}"), .ok("/drafts/r1?format=full", \\{"id":"r1","message":{"id":"m7","threadId":"t7","labelIds":["DRAFT"],"payload":{ \\ "mimeType":"multipart/mixed","headers":[{"name":"To","value":"Ana "}, \\ {"name":"Subject","value":"Re: Lunch"},{"name":"In-Reply-To","value":""}, \\ {"name":"References","value":" "}],"parts":[ \\ {"mimeType":"text/plain","body":{"data":"CgpPbiBUaHUsIDUgTm92IDIwMjYsIEFuYSB3cm90ZToKPiBMdW5jaD8K"}}, \\ {"mimeType":"application/pdf","filename":"plan.pdf","body":{"attachmentId":"a1","size":4}}]}}} ), .ok("/messages/m7/attachments/a1?", "{\"data\":\"JVBERg\"}"), .ok("/drafts/d1", ""), }; /// A reply to in thread t1, about `subject`. fn reply(comptime subject: []const u8) Compose.Outgoing { return .{ .to = "Bo ", .cc = "", .subject = "Re: " ++ subject, .body = "Yes.", .html = "", .thread = "t1", .in_reply_to = "", .references = " ", .attachments = &.{}, }; } /// Runs the event loop until no request is out. fn settle(fake: *Gmail.Fake, drafts: *const Drafts) !void { var waited: u32 = 0; while (!drafts.idle() and waited < 100) : (waited += 1) try siv.appDispatch(fake.app, 100); try testing.expect(drafts.idle()); } /// Hears what the drafts tell, and keeps a copy of the draft restored. const TestHost = struct { arena: ArenaAllocator, sent: ?bool, restored: ?Compose.Outgoing, troubles: u32, fn init() TestHost { return .{ .arena = .init(testing.allocator), .sent = null, .restored = null, .troubles = 0 }; } fn deinit(t: *TestHost) void { t.arena.deinit(); t.* = undefined; } fn host(t: *TestHost) Host { return .{ .context = t, .sent = onSent, .restored = onRestored, .trouble = onTrouble }; } fn onSent(context: *anyopaque, ok: bool) void { const t: *TestHost = @ptrCast(@alignCast(context)); t.sent = ok; } fn onRestored(context: *anyopaque, m: Compose.Outgoing) bool { const t: *TestHost = @ptrCast(@alignCast(context)); const a = t.arena.allocator(); t.restored = .{ .to = a.dupe(u8, m.to) catch return false, .cc = a.dupe(u8, m.cc) catch return false, .subject = a.dupe(u8, m.subject) catch return false, .body = a.dupe(u8, m.body) catch return false, .html = "", .thread = a.dupe(u8, m.thread) catch return false, .in_reply_to = a.dupe(u8, m.in_reply_to) catch return false, .references = a.dupe(u8, m.references) catch return false, .attachments = copy(a, m.attachments) catch return false, }; return true; } fn copy(a: Allocator, attachments: []const Compose.Attachment) ![]const Compose.Attachment { const out = try a.alloc(Compose.Attachment, attachments.len); for (attachments, out) |from, *to| to.* = .{ .name = try a.dupe(u8, from.name), .mime = try a.dupe(u8, from.mime), .data = try a.dupe(u8, from.data), }; return out; } fn onTrouble(context: *anyopaque, _: Sync.Trouble) void { const t: *TestHost = @ptrCast(@alignCast(context)); t.troubles += 1; } };