//! Parcel, a mail client for the Linux desktop. //! //! parcel opens each account's mail, else samples //! under the Add account page //! parcel login [gmail [NAME]] signs in to Gmail in the browser, as //! account NAME (Gmail when none is named) //! parcel login fastmail [NAME] signs in to Fastmail with an API token //! parcel login jmap NAME URL signs in to another JMAP server //! parcel --version prints the version it was built as const std = @import("std"); const build_options = @import("build_options"); const siv = @import("siv"); const zeit = @import("zeit"); const Accounts = @import("Accounts.zig"); const AddAccount = @import("AddAccount.zig"); const Compose = @import("Compose.zig"); const Contacts = @import("Contacts.zig"); const Desk = @import("Desk.zig"); const Drafts = @import("Drafts.zig"); const Http = @import("Http.zig"); const Jmap = @import("Jmap.zig"); const JmapSession = @import("JmapSession.zig"); const LogFile = @import("LogFile.zig"); const Mailbox = @import("Mailbox.zig"); const Notifier = @import("Notifier.zig"); const Session = @import("Session.zig"); const Settings = @import("Settings.zig"); const cache = @import("cache.zig"); const oauth = @import("oauth.zig"); const sample = @import("sample.zig"); const linux = std.os.linux; const mem = std.mem; const posix = std.posix; const Io = std.Io; const testing = std.testing; const log = std.log.scoped(.parcel); /// The Wayland app_id, and the name of the desktop file that goes with it. const app_id = "dev.rockorager.parcel"; const token_file = Session.token_file; /// What an account keeps in its directory, which parcel kept in the /// state directory itself before it had accounts. const account_files = [_][]const u8{ token_file, cache.file_name, "mail.json", Contacts.file_name, Drafts.file_name, Desk.filters_file, }; /// The account the one parcel had before becomes. const first_account = "Gmail"; pub const std_options: std.Options = .{ .logFn = logFn }; /// Where log lines are kept besides stderr, from when main opens it. var log_file: ?LogFile = null; /// Writes each line to stderr in std's form, without its colors, and /// the lines worth keeping to the log file too, stamped. It touches /// neither the environment nor std's stderr lock, both of which a panic /// takes (LogFile.zig). fn logFn( comptime level: std.log.Level, comptime scope: @EnumLiteral(), comptime format: []const u8, args: anytype, ) void { var buf: [4096]u8 = undefined; const line = LogFile.line(&buf, LogFile.now(), level, scope, format, args); LogFile.writeAll(posix.STDERR_FILENO, line[LogFile.stamp_len..]); if (comptime !LogFile.keeps(level, scope)) return; if (log_file) |*f| f.write(line); } pub fn main(init: std.process.Init) !void { const args = try init.minimal.args.toSlice(init.arena.allocator()); // Before the state directory opens, so asking touches nothing. if (args.len == 2 and mem.eql(u8, args[1], "--version")) { return Io.File.stdout().writeStreamingAll(init.io, "parcel " ++ build_options.version ++ "\n"); } const settings = Settings.open(init.io, init.environ_map); defer if (settings) |s| s.close(); if (settings) |s| log_file = LogFile.open(s.dir.handle) catch |err| blk: { log.warn("{s}: {t}", .{ LogFile.name, err }); break :blk null; }; // Before the directory it is in closes. defer if (log_file) |*f| { f.close(); log_file = null; }; const arena = init.arena.allocator(); if (settings) |s| migrate(s, arena); if (args.len >= 2 and mem.eql(u8, args[1], "login")) return login(init, settings, args[2..]); if (args.len != 1) usage(init.io); // Signed in, the window opens on each account's mail as last seen, // or none the first time, and its session brings it up to date; // otherwise it opens on the samples. const names = if (settings) |s| try s.accounts(arena) else &.{}; // Gmail's accounts sign in with it, and the Add account page; JMAP's // need none. const client = try oauth.Client.load(arena, init.io, init.environ_map); if (names.len > Desk.max_accounts) { log.err("{d} accounts; parcel shows the first {d}", .{ names.len, Desk.max_accounts }); } const app = try siv.appCreate(.{ .name = "Parcel", .app_id = app_id }); defer siv.appDestroy(app); // Parcel says everything else in English, so its dates do too, not in // the locale's language, until it is translated. try siv.appSetLanguage(app, "en", .ltr); // The terminal's face, as Fontconfig's monospace names it; Omarchy // sets that to the font it gives every terminal. try siv.appSetDefaultFont(app, "monospace"); var run: Run = .{ .init = init, .app = app, .settings = settings, .client = client, .names = if (names.len == 0) &.{""} else names[0..@min(names.len, Desk.max_accounts)], .downloads = downloads(arena, init.environ_map), .avatars = avatars(arena, init.environ_map), .window = .null, .accounts = null, .samples = undefined, .adder = undefined, .adder_built = false, }; defer run.close(); // One parcel runs: launching another, or clicking a notification, // comes to it. try siv.appLaunch(app, .{ .on_activate = Run.onActivate, .user_data = &run }); quitOnSignal(app) catch |err| log.warn("watching for signals: {t}", .{err}); // Logging out can take the compositor away first; what the sessions // hold is saved then too. siv.appRun(app) catch |err| log.err("running: {t}", .{err}); if (run.accounts) |accounts| for (accounts.list) |a| { if (a.session) |s| s.finish(); if (a.jmap) |j| j.finish(); }; } /// The pipe a signal's handler writes to and siv's loop reads, and the /// app it quits. var quit_pipe: [2]linux.fd_t = .{ -1, -1 }; var quit_app: siv.App = .null; /// Ends the run on SIGTERM (logging out, shutting down), SIGINT, and /// SIGHUP the way closing the window does, so the sessions send what is /// queued and save the mail. The handler only writes to a pipe siv /// watches; blocking the signals for a signalfd instead would leave them /// blocked in the programs parcel starts, which inherit the mask. fn quitOnSignal(app: siv.App) !void { switch (linux.errno(linux.pipe2(&quit_pipe, .{ .NONBLOCK = true, .CLOEXEC = true }))) { .SUCCESS => {}, else => |err| return posix.unexpectedErrno(err), } quit_app = app; _ = try siv.appWatchFd(app, quit_pipe[0], .{ .readable = true }, onQuit, null, null); const action: posix.Sigaction = .{ .handler = .{ .handler = onSignal }, .mask = posix.sigemptyset(), .flags = posix.SA.RESTART, }; for ([_]posix.SIG{ .TERM, .INT, .HUP }) |sig| posix.sigaction(sig, &action, null); } fn onSignal(_: posix.SIG) callconv(.c) void { const byte: u8 = 0; _ = linux.write(quit_pipe[1], @ptrCast(&byte), 1); } fn onQuit(_: ?*anyopaque, _: siv.Watch, _: siv.WatchFlags) callconv(.c) void { var buf: [16]u8 = undefined; while (true) { const n = linux.read(quit_pipe[0], &buf, buf.len); if (linux.errno(n) != .SUCCESS or n == 0) break; } siv.appQuit(quit_app) catch |err| log.err("quitting: {t}", .{err}); } fn usage(io: Io) noreturn { say("usage: parcel [--version | login [gmail [NAME] | fastmail [NAME] | jmap NAME URL]]\n", io, .{}); std.process.exit(2); } /// Moves the one account parcel had before it had several, kept in the /// state directory itself, to its own directory as the first account. fn migrate(settings: Settings, arena: mem.Allocator) void { settings.dir.access(settings.io, token_file, .{}) catch return; const names = settings.accounts(arena) catch |err| return log.warn("listing the accounts: {t}", .{err}); if (names.len > 0) return; settings.moveTo(first_account, &account_files) catch |err| { return log.warn("moving the account to {s}/{s}: {t}", .{ Settings.accounts_dir, first_account, err }); }; log.info("the account is now {s}/{s}; rename its directory to rename it", .{ Settings.accounts_dir, first_account, }); } /// The window and what drives it, made at the first activation, as siv's /// single instance wants; a later one shows the window again. const Run = struct { init: std.process.Init, app: siv.App, settings: ?Settings, /// What Gmail's accounts sign in with, if parcel has one. client: ?oauth.Client, /// The accounts to open, or one with no name for the samples. names: []const []const u8, downloads: []const u8, /// Where senders' photos from Gravatar are kept. avatars: []const u8, window: siv.Window, accounts: ?*Accounts, samples: Samples, /// The Add account page, over the desks, once `adder_built`. adder: AddAccount, adder_built: bool, fn onActivate(user_data: ?*anyopaque, _: siv.App, activation: *const siv.Activation) callconv(.c) void { const self: *Run = @ptrCast(@alignCast(user_data)); const first = self.accounts == null; if (first) self.open() catch |err| { log.err("opening the window: {t}", .{err}); siv.appQuit(self.app) catch |e| log.err("quitting: {t}", .{e}); return; }; const token = if (activation.activation_token) |p| p[0..activation.activation_token_len] else ""; // A window just opened shows by itself; one open already, as a // notification's click or a second launch finds it, is brought up. if (first and token.len == 0) return; Notifier.activate(self.window, token); } /// What it makes, `close` takes down, however far it got. The mail /// loads here, not before, so a launch that only shows the running /// parcel reads none. fn open(self: *Run) !void { const init = self.init; self.window = try siv.windowCreate(self.app, .{ .title = "Parcel", .app_id = app_id, .width = 1200, .height = 760, // Narrower, the list's names and chips crowd out the subject. .min_width = 640, .min_height = 420, }); const accounts = try Accounts.create(init.gpa, init.io, self.window, self.names, self.settings); self.accounts = accounts; const now = Io.Timestamp.now(init.io, .real).toSeconds(); const samples = self.names.len == 1 and self.names[0].len == 0; if (samples) { log.info("showing sample mail until an account is added", .{}); try sample.load(&accounts.list[0].mail, now); } else for (accounts.list) |*a| { const s = a.settings orelse continue; const cached = cache.load(init.gpa, s) orelse continue; a.mail.deinit(); a.mail = cached; } var zones: [Desk.max_accounts]zeit.TimeZone = undefined; for (zones[0..accounts.list.len]) |*zone| zone.* = self.localZone(); try accounts.build(zones[0..accounts.list.len]); try self.adder.init(init.gpa, init.io, self.window, accounts.stack, self.settings, self.client, .{ .context = self, .added = added, .closed = adderClosed, }, openBrowser); self.adder_built = true; for (accounts.list) |a| self.prepare(a.desk.?); if (samples) { try self.openSamples(accounts.list[0].desk.?, now); // There is no account yet: the page asks for one. return self.adder.show(); } for (accounts.list) |*a| try self.startSession(a); } fn localZone(self: *Run) zeit.TimeZone { return zeit.local(self.init.gpa, self.init.io, .{ .tz = self.init.environ_map.get("TZ"), .tzdir = self.init.environ_map.get("TZDIR"), }) catch |err| { log.warn("reading the local time zone: {t}; dates are in UTC", .{err}); return zeit.utc; }; } /// What every desk gets from the app: where files and photos go, and /// the Add account page. fn prepare(self: *Run, desk: *Desk) void { desk.messages.downloads = self.downloads; desk.messages.avatars.dir = self.avatars; desk.adder = .{ .context = self, .open = openAdder }; } fn openAdder(context: *anyopaque) void { const self: *Run = @ptrCast(@alignCast(context)); self.adder.show() catch |err| log.err("showing the Add account page: {t}", .{err}); } /// The page signed in to a new account: it opens with its session, /// in place of the samples if they showed. fn added(context: *anyopaque, name: []const u8) !void { const self: *Run = @ptrCast(@alignCast(context)); const accounts = self.accounts.?; if (accounts.list.len == Desk.max_accounts) return error.TooManyAccounts; const a = try accounts.add(name, self.localZone(), self.adder.layer); self.prepare(a.desk.?); try self.startSession(a); } /// The page closed: the shown desk takes the keys back. fn adderClosed(context: *anyopaque) void { const self: *Run = @ptrCast(@alignCast(context)); const accounts = self.accounts orelse return; const desk = accounts.list[accounts.shown].desk orelse return; desk.show(true) catch |err| log.err("showing the desk: {t}", .{err}); } /// Starts an account's session, Gmail's or a JMAP server's, if it is /// signed in. fn startSession(self: *Run, a: *Accounts.Account) !void { const s = a.settings orelse return; var token_buf: [1024]u8 = undefined; defer std.crypto.secureZero(u8, &token_buf); const desk = a.desk.?; const many = a.accounts.list.len > 1; const account: Notifier.Account = .{ .name = if (many) a.name else "", .index = a.index }; var arena_state: std.heap.ArenaAllocator = .init(self.init.gpa); defer arena_state.deinit(); if (try jmapAuth(arena_state.allocator(), s, &token_buf)) |auth| { desk.compose.contacts.load(s); var url_buf: [1024]u8 = undefined; const url = if (s.load(JmapSession.url_file, &url_buf)) |u| mem.trim(u8, u, " \n") else Jmap.fastmail; a.jmap = try JmapSession.start(self.init.gpa, self.init.io, self.app, desk, s, url, auth, account); return; } const token = s.load(token_file, &token_buf) orelse { return log.warn("{s} is not signed in: add it again from the palette", .{a.name}); }; const client = self.client orelse { return log.warn("{s} needs Google's OAuth client: see `parcel login`", .{a.name}); }; desk.compose.contacts.load(s); const refresh = mem.trim(u8, token, " \n"); a.session = try Session.start(self.init.gpa, self.init.io, self.app, desk, s, client, refresh, account); } /// How a JMAP account signs in, if it is one: its token, in `buf`, or /// OAuth's grant, in `arena`. fn jmapAuth(arena: mem.Allocator, s: Settings, buf: []u8) !?JmapSession.Auth { if (s.load(JmapSession.token_file, buf)) |token| return .{ .token = mem.trim(u8, token, " \n") }; const bytes = s.loadAlloc(JmapSession.grant_file, arena, .limited(16 * 1024)) orelse return null; return .{ .grant = try oauth.Grant.parse(arena, bytes) }; } fn openSamples(self: *Run, desk: *Desk, now: i64) !void { self.samples = .{ .desk = desk }; desk.messages.fetcher = .{ .context = &self.samples, .fetch = Samples.fetch, .attachment = null, .web = null, }; desk.compose.keeper = .{ .context = &self.samples, .save = Samples.save, .send = Samples.send, .discard = Samples.discard, }; try sample.people(&desk.compose.contacts, now); } fn close(self: *Run) void { const accounts = self.accounts orelse { if (self.window != .null) siv.windowDestroy(self.window); return; }; for (accounts.list) |*a| { if (a.session) |s| s.destroy(); if (a.jmap) |j| j.destroy(); a.session = null; a.jmap = null; } // The window's handlers hold the desks and the page, so the // window goes first. siv.windowDestroy(self.window); if (self.adder_built) self.adder.deinit(); accounts.destroy(); } }; /// Answers the reader with made-up messages when there is no Gmail. const Samples = struct { desk: *Desk, fn fetch(context: *anyopaque, thread: []const u8) void { const self: *Samples = @ptrCast(@alignCast(context)); var arena_state: std.heap.ArenaAllocator = .init(self.desk.gpa); defer arena_state.deinit(); const id = self.desk.mail.findThread(thread) orelse return; const messages = sample.conversation(arena_state.allocator(), self.desk.mail.get(id)) catch null; self.desk.messages.deliver(thread, messages) catch |err| log.err("samples: {t}", .{err}); } /// Sends nowhere: there is no account to send from. fn send(context: *anyopaque, _: Compose.Outgoing) void { const self: *Samples = @ptrCast(@alignCast(context)); self.desk.sent(true) catch |err| log.err("samples: {t}", .{err}); } /// Keeps the draft in memory only, as compose has it. fn save(_: *anyopaque, _: Compose.Outgoing) void {} fn discard(_: *anyopaque) void {} }; /// Where opened attachments are saved: $XDG_DOWNLOAD_DIR, else /// ~/Downloads; empty when there is no home. fn downloads(arena: mem.Allocator, environ: *const std.process.Environ.Map) []const u8 { if (environ.get("XDG_DOWNLOAD_DIR")) |dir| return dir; const home = environ.get("HOME") orelse return ""; return mem.concat(arena, u8, &.{ home, "/Downloads" }) catch ""; } /// Where senders' photos are kept: $XDG_CACHE_HOME/parcel/avatars, else /// ~/.cache/parcel/avatars; empty when there is no home. fn avatars(arena: mem.Allocator, environ: *const std.process.Environ.Map) []const u8 { if (environ.get("XDG_CACHE_HOME")) |dir| return mem.concat(arena, u8, &.{ dir, "/parcel/avatars" }) catch ""; const home = environ.get("HOME") orelse return ""; return mem.concat(arena, u8, &.{ home, "/.cache/parcel/avatars" }) catch ""; } /// Signs in to `args`' account: `gmail NAME`, `fastmail NAME`, `jmap /// NAME URL`, or Gmail as the first account when none is named. fn login(init: std.process.Init, settings: ?Settings, args: []const [:0]const u8) !void { const kind: []const u8 = if (args.len > 0) args[0] else "gmail"; const jmap = mem.eql(u8, kind, "jmap"); if (args.len > @as(usize, if (jmap) 3 else 2) or (jmap and args.len != 3)) usage(init.io); const default: []const u8 = if (mem.eql(u8, kind, "fastmail")) "Fastmail" else first_account; const name = if (args.len >= 2) args[1] else default; if (name.len == 0 or name[0] == '.' or mem.findScalar(u8, name, '/') != null) { say("An account's name is its directory's: no slash, and not starting with a dot.\n", init.io, .{}); std.process.exit(2); } if (mem.eql(u8, kind, "fastmail")) return loginJmap(init, settings, name, Jmap.fastmail); if (jmap) return loginJmap(init, settings, name, args[2]); if (!mem.eql(u8, kind, "gmail")) usage(init.io); const arena = init.arena.allocator(); const client = try oauth.Client.load(arena, init.io, init.environ_map) orelse { say( \\Parcel needs an OAuth client to sign in with. In the Google Cloud console: \\ 1. Enable the Gmail API for a project. \\ 2. Under Google Auth Platform, Clients, create a client of type Desktop app. \\ 3. Download its JSON and save it as ~/.config/parcel/{s}. \\Then run `parcel login` again. \\ , init.io, .{oauth.Client.file_name}); std.process.exit(1); }; const top = settings orelse return error.NoStateDirectory; const s = top.account(name) orelse return error.NoStateDirectory; defer s.close(); const token = oauth.login(arena, init.io, client, .google, openBrowser) catch |err| switch (err) { // Logged with Google's reason. error.SignInRefused, error.TokenRefused => std.process.exit(1), else => |e| return e, }; try s.saveSecret(token_file, token.refresh orelse return error.NoRefreshToken); // They may be another account's. s.remove(cache.file_name); s.remove(Contacts.file_name); s.remove(Drafts.file_name); say("Signed in as {s}. Run `parcel` to see your mail.\n", init.io, .{name}); } /// Signs in to a JMAP server with a token typed at the terminal: an API /// token, or for a server that takes a password, user:password. It is /// tried on the session resource at `url` before it is kept. fn loginJmap(init: std.process.Init, settings: ?Settings, name: []const u8, url: []const u8) !void { const arena = init.arena.allocator(); if (mem.eql(u8, url, Jmap.fastmail)) { say("Make an API token in Fastmail's Settings, Privacy & Security, Manage API tokens,\n" ++ "with access to Email and Email submission.\n", init.io, .{}); } say("Token: ", init.io, .{}); var buf: [1024]u8 = undefined; defer std.crypto.secureZero(u8, &buf); const token = mem.trim(u8, try readSecretLine(init.io, &buf), " \r\n"); say("\n", init.io, .{}); if (token.len == 0) std.process.exit(1); const headers = try arena.dupe([]const u8, &.{try Jmap.authorizationFor(arena, token)}); const response = try Http.fetch(arena, .{ .method = .get, .url = url, .headers = headers, .body = null }); if (response.status / 100 != 2) { say("{s} refused the token ({d}).\n", init.io, .{ url, response.status }); std.process.exit(1); } const top = settings orelse return error.NoStateDirectory; const s = top.account(name) orelse return error.NoStateDirectory; defer s.close(); try s.saveSecret(JmapSession.token_file, token); if (mem.eql(u8, url, Jmap.fastmail)) s.remove(JmapSession.url_file) else s.save(JmapSession.url_file, url); // They may be another account's. s.remove(cache.file_name); s.remove(Contacts.file_name); say("Signed in as {s}. Run `parcel` to see your mail.\n", init.io, .{name}); } /// A line from stdin, not echoed when it is a terminal. fn readSecretLine(io: Io, buf: []u8) ![]const u8 { const stdin = Io.File.stdin(); const fd = stdin.handle; const old = posix.tcgetattr(fd) catch null; if (old) |o| { var quiet = o; quiet.lflag.ECHO = false; posix.tcsetattr(fd, .NOW, quiet) catch |err| log.warn("the token will show as it is typed: {t}", .{err}); } defer if (old) |o| posix.tcsetattr(fd, .NOW, o) catch {}; var reader = stdin.readerStreaming(io, buf); return (try reader.interface.takeDelimiter('\n')) orelse ""; } /// Opens the sign-in page in the browser, and prints it in case there is /// none, as over SSH. fn openBrowser(io: Io, url: []const u8) void { say("Sign in at:\n\n {s}\n\n", io, .{url}); _ = std.process.spawn(io, .{ .argv = &.{ "xdg-open", url }, .stdin = .ignore, .stdout = .ignore, .stderr = .ignore, }) catch |err| log.info("xdg-open: {t}; open the link yourself", .{err}); } /// Writes to stderr, for the terminal running `parcel login`. /// Nowhere is left to report a failure to write there. fn say(comptime format: []const u8, io: Io, args: anytype) void { var buf: [1024]u8 = undefined; const stderr = io.lockStderr(&buf, null) catch return; defer io.unlockStderr(); const w = &stderr.file_writer.interface; w.print(format, args) catch return; w.flush() catch return; } test "the one account from before accounts becomes the first, with its sign-in and mail" { var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); const top: Settings = .{ .io = testing.io, .dir = tmp.dir }; try top.saveSecret(token_file, "refresh"); top.save(Desk.filters_file, "INBOX"); top.save(LogFile.name, "kept"); var arena: std.heap.ArenaAllocator = .init(testing.allocator); defer arena.deinit(); migrate(top, arena.allocator()); const names = try top.accounts(arena.allocator()); try testing.expectEqual(1, names.len); try testing.expectEqualStrings(first_account, names[0]); const gmail = top.account(first_account).?; defer gmail.close(); var buf: [16]u8 = undefined; try testing.expectEqualStrings("refresh", gmail.load(token_file, &buf).?); try testing.expectEqualStrings("INBOX", gmail.load(Desk.filters_file, &buf).?); try testing.expect(top.load(token_file, &buf) == null); // The log is the app's, not the account's. try testing.expectEqualStrings("kept", top.load(LogFile.name, &buf).?); } test { _ = Accounts; _ = AddAccount; _ = @import("SignIn.zig"); _ = @import("Backfill.zig"); _ = @import("Harvest.zig"); _ = @import("Corpus.zig"); _ = @import("Index.zig"); _ = @import("Segment.zig"); _ = Jmap; _ = JmapSession; _ = Desk; _ = Drafts; _ = @import("Gmail.zig"); _ = @import("Http.zig"); _ = LogFile; _ = Mailbox; _ = Notifier; _ = @import("Pager.zig"); _ = @import("Query.zig"); _ = Session; _ = Settings; _ = @import("Sync.zig"); _ = @import("Reader.zig"); _ = @import("Images.zig"); _ = @import("Avatars.zig"); _ = @import("portable_text.zig"); _ = @import("Zoom.zig"); _ = Compose; _ = Contacts; _ = @import("html.zig"); _ = @import("cards.zig"); _ = cache; _ = oauth; _ = sample; _ = @import("kit.zig"); _ = @import("mime.zig"); }