Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635//! 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: <m1@x>\r\nReferences: <m0@x> <m1@x>\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 <ana@example.com>", 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("<l1@x>", back.in_reply_to); try testing.expectEqualStrings("<l0@x> <l1@x>", 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 <ana@example.com>"}, \\ {"name":"Subject","value":"Re: Lunch"},{"name":"In-Reply-To","value":"<l1@x>"}, \\ {"name":"References","value":"<l0@x> <l1@x>"}],"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 <m1@x> in thread t1, about `subject`.fn reply(comptime subject: []const u8) Compose.Outgoing { return .{ .to = "Bo <bo@example.com>", .cc = "", .subject = "Re: " ++ subject, .body = "Yes.", .html = "", .thread = "t1", .in_reply_to = "<m1@x>", .references = "<m0@x> <m1@x>", .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; }};