Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279//! Messages to send, as RFC 5322 text with MIME parts, which Gmail and//! a JMAP server both take whole.
const std = @import("std");const Compose = @import("Compose.zig");const ascii = std.ascii;const mem = std.mem;const testing = std.testing;const Allocator = std.mem.Allocator;const ArenaAllocator = std.heap.ArenaAllocator;const Io = std.Io;const Writer = Io.Writer;
/// What a message carries besides what the user wrote: Gmail adds it/// itself, and a JMAP server wants it in the message.pub const Envelope = struct { /// The From header's value; empty leaves it out. from: []const u8, /// The Date, in seconds since the epoch. date: ?i64, /// The Message-ID, angle brackets and all; empty leaves it out. message_id: []const u8,
pub const none: Envelope = .{ .from = "", .date = null, .message_id = "" };};
/// `outgoing` as RFC 5322 text, as a draft is uploaded: its body in/// base64 as UTF-8 text, or as multipart/alternative, the text then the/// HTML, when it has HTML; a subject with more than ASCII as an encoded/// word; and with attachments, multipart/mixed: the body, then each file/// in base64. Line breaks in a header are dropped, so none can/// add one, nor a line that ends a part.pub fn message(arena: Allocator, outgoing: Compose.Outgoing, envelope: Envelope) ![]const u8 { var out: Writer.Allocating = .init(arena); const w = &out.writer; if (envelope.from.len > 0) try headerLine(w, "From", envelope.from); if (envelope.date) |date| try w.print("Date: {f}\r\n", .{RfcDate{ .seconds = date }}); if (envelope.message_id.len > 0) try headerLine(w, "Message-ID", envelope.message_id); try headerLine(w, "To", outgoing.to); if (outgoing.cc.len > 0) try headerLine(w, "Cc", outgoing.cc); const plain = for (outgoing.subject) |c| { if (c >= 0x80) break false; } else true; if (plain) { try headerLine(w, "Subject", outgoing.subject); } else { try w.writeAll("Subject: =?UTF-8?B?"); try std.base64.standard.Encoder.encodeWriter(w, oneLine(outgoing.subject)); try w.writeAll("?=\r\n"); } if (outgoing.in_reply_to.len > 0) try headerLine(w, "In-Reply-To", outgoing.in_reply_to); if (outgoing.references.len > 0) try headerLine(w, "References", outgoing.references); try w.writeAll("MIME-Version: 1.0\r\n"); if (outgoing.attachments.len == 0) { try body(w, outgoing); return out.written(); } const dash = "--" ++ part_boundary; try w.writeAll("Content-Type: multipart/mixed; boundary=\"" ++ part_boundary ++ "\"\r\n\r\n"); try w.writeAll(dash ++ "\r\n"); try body(w, outgoing); for (outgoing.attachments) |a| { try w.print(dash ++ "\r\nContent-Type: {s}; ", .{mimeToken(a.mime)}); try parameter(w, "name", a.name); try w.writeAll("\r\nContent-Disposition: attachment; "); try parameter(w, "filename", a.name); try w.writeAll("\r\nContent-Transfer-Encoding: base64\r\n\r\n"); try base64Lines(w, a.data); } try w.writeAll(dash ++ "--\r\n"); return out.written();}
/// What divides a message's parts, and its body's text and HTML: "=_" is/// in no base64 line, and no header starts with it.const part_boundary = "=_parcel_part";const alternative_boundary = "=_parcel_alt";
/// The body's part: the text, or the text and the HTML as alternatives.fn body(w: *Writer, outgoing: Compose.Outgoing) !void { const text = "Content-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: base64\r\n\r\n"; if (outgoing.html.len == 0) { try w.writeAll(text); return base64Lines(w, outgoing.body); } const dash = "--" ++ alternative_boundary; try w.writeAll("Content-Type: multipart/alternative; boundary=\"" ++ alternative_boundary ++ "\"\r\n\r\n"); try w.writeAll(dash ++ "\r\n" ++ text); try base64Lines(w, outgoing.body); try w.writeAll(dash ++ "\r\nContent-Type: text/html; charset=UTF-8\r\nContent-Transfer-Encoding: base64\r\n\r\n"); try base64Lines(w, outgoing.html); try w.writeAll(dash ++ "--\r\n");}
/// `bytes` in base64, 76 characters a line, as MIME has it.fn base64Lines(w: *Writer, bytes: []const u8) !void { const encoder = std.base64.standard.Encoder; var rest = bytes; while (true) { const n = @min(rest.len, 57); var line: [76]u8 = undefined; try w.print("{s}\r\n", .{encoder.encode(&line, rest[0..n])}); rest = rest[n..]; if (rest.len == 0) return; }}
/// A MIME type that can go in a header as it is, else bytes.fn mimeToken(mime: []const u8) []const u8 { for (mime) |c| { if (!ascii.isAlphanumeric(c) and mem.findScalar(u8, "/.+-_", c) == null) return "application/octet-stream"; } return if (mime.len > 0) mime else "application/octet-stream";}
/// A header's parameter, such as a file's name: `name="plan.pdf"`, or as/// RFC 2231 has it when it holds more than plain ASCII, or a quote:/// `name*=UTF-8''%C3%A9t%C3%A9.pdf`.fn parameter(w: *Writer, key: []const u8, value: []const u8) !void { const plain = for (value) |c| { if (c < ' ' or c >= 0x7f or c == '"' or c == '\\') break false; } else true; if (plain) return w.print("{s}=\"{s}\"", .{ key, value }); try w.print("{s}*=UTF-8''", .{key}); for (value) |c| { if (ascii.isAlphanumeric(c) or mem.findScalar(u8, "!#$&+-.^_`|~", c) != null) try w.writeByte(c) else try w.print("%{X:0>2}", .{c}); }}
fn headerLine(w: *Writer, name: []const u8, value: []const u8) !void { try w.print("{s}: ", .{name}); for (value) |c| if (c != '\r' and c != '\n') try w.writeByte(c); try w.writeAll("\r\n");}
/// A date as RFC 5322 has it, in UTC: "Sun, 08 Nov 2026 19:41:00 +0000".const RfcDate = struct { seconds: i64,
pub fn format(self: RfcDate, w: *Writer) Writer.Error!void { const epoch: std.time.epoch.EpochSeconds = .{ .secs = @intCast(@max(self.seconds, 0)) }; const day = epoch.getEpochDay(); const year_day = day.calculateYearDay(); const month_day = year_day.calculateMonthDay(); const time = epoch.getDaySeconds(); // 1 January 1970 was a Thursday. const weekdays = [_][]const u8{ "Thu", "Fri", "Sat", "Sun", "Mon", "Tue", "Wed" }; const months = [_][]const u8{ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", }; try w.print("{s}, {d:0>2} {s} {d} {d:0>2}:{d:0>2}:{d:0>2} +0000", .{ weekdays[day.day % 7], month_day.day_index + 1, months[@backingInt(month_day.month) - 1], year_day.year, time.getHoursIntoDay(), time.getMinutesIntoHour(), time.getSecondsIntoMinute(), }); }};
/// Text that can go in a header: no line breaks.fn oneLine(text: []const u8) []const u8 { return text[0 .. mem.findAny(u8, text, "\r\n") orelse text.len];}
test "a message to send is RFC 5322, with no way to add a header" { var arena_state: ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const text = try message(arena, .{ .to = "Ana <ana@example.com>\r\nBcc: eve@example.com", .cc = "bo@example.com", .subject = "Café", .body = "Hi Ana,\nyes.", .html = "", .thread = "t1", .in_reply_to = "<m2@example.com>", .references = "<m1@example.com> <m2@example.com>", .attachments = &.{}, }, .none); try testing.expectEqualStrings( "To: Ana <ana@example.com>Bcc: eve@example.com\r\n" ++ "Cc: bo@example.com\r\n" ++ "Subject: =?UTF-8?B?Q2Fmw6k=?=\r\n" ++ "In-Reply-To: <m2@example.com>\r\n" ++ "References: <m1@example.com> <m2@example.com>\r\n" ++ "MIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\n" ++ "Content-Transfer-Encoding: base64\r\n\r\n" ++ "SGkgQW5hLAp5ZXMu\r\n", text, );
// With files: the text, then each file, its name as RFC 2231 has it // when it is more than ASCII, and its type bytes when it is no type. var bytes: [60]u8 = undefined; for (&bytes, 0..) |*b, i| b.* = @intCast(i); const mixed = try message(arena, .{ .to = "bo@example.com", .cc = "", .subject = "Files", .body = "Hi", .html = "", .thread = "", .in_reply_to = "", .references = "", .attachments = &.{ .{ .name = "plan.pdf", .mime = "application/pdf", .data = "%PDF" }, .{ .name = "caf\u{e9} \"1\".bin", .mime = "text/plain\r\nX-Evil: 1", .data = &bytes }, }, }, .none); try testing.expectEqualStrings( "To: bo@example.com\r\nSubject: Files\r\nMIME-Version: 1.0\r\n" ++ "Content-Type: multipart/mixed; boundary=\"=_parcel_part\"\r\n\r\n" ++ "--=_parcel_part\r\nContent-Type: text/plain; charset=UTF-8\r\n" ++ "Content-Transfer-Encoding: base64\r\n\r\nSGk=\r\n" ++ "--=_parcel_part\r\nContent-Type: application/pdf; name=\"plan.pdf\"\r\n" ++ "Content-Disposition: attachment; filename=\"plan.pdf\"\r\n" ++ "Content-Transfer-Encoding: base64\r\n\r\nJVBERg==\r\n" ++ "--=_parcel_part\r\nContent-Type: application/octet-stream; name*=UTF-8''caf%C3%A9%20%221%22.bin\r\n" ++ "Content-Disposition: attachment; filename*=UTF-8''caf%C3%A9%20%221%22.bin\r\n" ++ "Content-Transfer-Encoding: base64\r\n\r\n" ++ "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4\r\n" ++ "OTo7\r\n--=_parcel_part--\r\n", mixed, );
// Written as rich text, the body is the text and the HTML, as // alternatives, inside the mixed part when files go with it. const rich = try message(arena, .{ .to = "bo@example.com", .cc = "", .subject = "Rich", .body = "Hi", .html = "<b>Hi</b>", .thread = "", .in_reply_to = "", .references = "", .attachments = &.{.{ .name = "a.txt", .mime = "text/plain", .data = "a" }}, }, .none); try testing.expectEqualStrings( "To: bo@example.com\r\nSubject: Rich\r\nMIME-Version: 1.0\r\n" ++ "Content-Type: multipart/mixed; boundary=\"=_parcel_part\"\r\n\r\n" ++ "--=_parcel_part\r\nContent-Type: multipart/alternative; boundary=\"=_parcel_alt\"\r\n\r\n" ++ "--=_parcel_alt\r\nContent-Type: text/plain; charset=UTF-8\r\n" ++ "Content-Transfer-Encoding: base64\r\n\r\nSGk=\r\n" ++ "--=_parcel_alt\r\nContent-Type: text/html; charset=UTF-8\r\n" ++ "Content-Transfer-Encoding: base64\r\n\r\nPGI+SGk8L2I+\r\n--=_parcel_alt--\r\n" ++ "--=_parcel_part\r\nContent-Type: text/plain; name=\"a.txt\"\r\n" ++ "Content-Disposition: attachment; filename=\"a.txt\"\r\n" ++ "Content-Transfer-Encoding: base64\r\n\r\nYQ==\r\n--=_parcel_part--\r\n", rich, );}
test "a message for a JMAP server says who sent it, when, and its Message-ID" { var arena_state: ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); const text = try message(arena_state.allocator(), .{ .to = "ana@example.com", .cc = "", .subject = "Hi", .body = "Hi", .html = "", .thread = "", .in_reply_to = "", .references = "", .attachments = &.{}, }, .{ .from = "Tim <tim@example.com>", .date = 1_794_166_860, .message_id = "<a1@example.com>" }); try testing.expect(mem.startsWith(u8, text, "From: Tim <tim@example.com>\r\n" ++ "Date: Sun, 08 Nov 2026 19:41:00 +0000\r\n" ++ "Message-ID: <a1@example.com>\r\nTo: ana@example.com\r\n"));}