Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290//! What Gmail shows of a message beyond its body, read from the message://! a go-to action from its schema.org markup (JSON-LD), such as GitHub's//! "View Pull Request", and the event an invitation's text/calendar part//! describes. AMP for Email runs script, so it is not shown.
const std = @import("std");const Mailbox = @import("Mailbox.zig");const ascii = std.ascii;const json = std.json;const mem = std.mem;const testing = std.testing;const Allocator = std.mem.Allocator;const ArenaAllocator = std.heap.ArenaAllocator;
/// The first ViewAction the HTML's JSON-LD offers that leads to an https/// page; null when it offers none.pub fn action(arena: Allocator, html: []const u8) ?Mailbox.Action { var at: usize = 0; while (ascii.findIgnoreCasePos(html, at, "application/ld+json")) |tag| { const start = (mem.findScalarPos(u8, html, tag, '>') orelse return null) + 1; const end = ascii.findIgnoreCasePos(html, start, "</script") orelse return null; at = end; const value = json.parseFromSliceLeaky(json.Value, arena, html[start..end], .{}) catch continue; if (find(value)) |a| return a; } return null;}
/// Looks through the items of a list, a @graph, and the potentialAction/// of each thing.fn find(value: json.Value) ?Mailbox.Action { switch (value) { .array => |list| for (list.items) |item| { if (find(item)) |a| return a; }, .object => |object| { if (viewAction(object)) |a| return a; for ([_][]const u8{ "potentialAction", "@graph" }) |key| { if (find(object.get(key) orelse continue)) |a| return a; } }, else => {}, } return null;}
fn viewAction(object: json.ObjectMap) ?Mailbox.Action { const kind = string(object.get("@type")) orelse return null; if (!ascii.endsWithIgnoreCase(kind, "ViewAction")) return null; const target = object.get("target"); const url = string(object.get("url")) orelse string(target) orelse if (target) |t| switch (t) { .object => |o| string(o.get("url")) orelse string(o.get("urlTemplate")), else => null, } else null; const link = url orelse return null; if (!ascii.startsWithIgnoreCase(link, "https://")) return null; return .{ .name = string(object.get("name")) orelse "Open", .url = link };}
fn string(value: ?json.Value) ?[]const u8 { const v = value orelse return null; return switch (v) { .string => |s| if (s.len > 0) s else null, else => null, };}
/// The first event of an iCalendar invitation; null when it holds none./// `ics` must outlast it.pub fn invite(arena: Allocator, ics: []const u8) !?Mailbox.Invite { var out: Mailbox.Invite = .{ .summary = "", .when = "", .location = "", .organizer = "", .cancelled = false, .ics = ics, }; var in_event = false; var start: ?Property = null; var end: ?Property = null; var lines: Lines = .{ .rest = ics }; while (try lines.next(arena)) |line| { const p = Property.of(line) orelse continue; if (eql(p.name, "BEGIN") and eql(p.value, "VEVENT")) { in_event = true; } else if (eql(p.name, "END") and eql(p.value, "VEVENT")) { break; } else if (eql(p.name, "METHOD")) { out.cancelled = out.cancelled or eql(p.value, "CANCEL"); } else if (in_event) { if (eql(p.name, "SUMMARY")) out.summary = try unescape(arena, p.value); if (eql(p.name, "LOCATION")) out.location = try unescape(arena, p.value); if (eql(p.name, "STATUS")) out.cancelled = out.cancelled or eql(p.value, "CANCELLED"); if (eql(p.name, "DTSTART")) start = p; if (eql(p.name, "DTEND")) end = p; if (eql(p.name, "ORGANIZER")) out.organizer = organizer(p); } } if (!in_event) return null; if (start) |s| out.when = try when(arena, s, end); if (out.summary.len == 0) out.summary = "Event"; return out;}
/// An iCalendar content line: NAME;PARAM=…:VALUE.const Property = struct { name: []const u8, params: []const u8, value: []const u8,
fn of(line: []const u8) ?Property { // A colon in a quoted parameter, as in CN="A: B", is not the end. var quoted = false; const colon = for (line, 0..) |c, i| { if (c == '"') quoted = !quoted; if (c == ':' and !quoted) break i; } else return null; const head = line[0..colon]; const semi = mem.findScalar(u8, head, ';') orelse head.len; return .{ .name = head[0..semi], .params = if (semi < head.len) head[semi + 1 ..] else "", .value = line[colon + 1 ..], }; }
/// A parameter's value, unquoted; null when it is not given. fn param(p: Property, name: []const u8) ?[]const u8 { var it = mem.tokenizeScalar(u8, p.params, ';'); while (it.next()) |kv| { const eq = mem.findScalar(u8, kv, '=') orelse continue; if (ascii.eqlIgnoreCase(kv[0..eq], name)) return mem.trim(u8, kv[eq + 1 ..], "\""); } return null; }};
/// Content lines, unfolded: a line that starts with a space or a tab/// goes on the one before.const Lines = struct { rest: []const u8,
fn next(self: *Lines, arena: Allocator) !?[]const u8 { if (self.rest.len == 0) return null; var line: std.ArrayList(u8) = .empty; var first = true; while (self.rest.len > 0) { const nl = mem.findScalar(u8, self.rest, '\n') orelse self.rest.len; const raw = mem.trimEnd(u8, self.rest[0..nl], "\r"); if (!first and (raw.len == 0 or (raw[0] != ' ' and raw[0] != '\t'))) break; try line.appendSlice(arena, if (first) raw else raw[1..]); first = false; self.rest = if (nl < self.rest.len) self.rest[nl + 1 ..] else ""; } return line.items; }};
/// Who invites: their name, else their address.fn organizer(p: Property) []const u8 { if (p.param("CN")) |name| if (name.len > 0) return name; return if (ascii.startsWithIgnoreCase(p.value, "mailto:")) p.value["mailto:".len..] else p.value;}
/// "Thu 12 Nov 2026, 15:00 – 16:00 UTC", in the zone the invitation/// names; a day's event is the day alone.fn when(arena: Allocator, start: Property, end: ?Property) ![]const u8 { const s = mem.trim(u8, start.value, " "); if (s.len < 8) return arena.dupe(u8, s); const year = std.fmt.parseInt(i32, s[0..4], 10) catch return arena.dupe(u8, s); const month = std.fmt.parseInt(u8, s[4..6], 10) catch return arena.dupe(u8, s); const day = std.fmt.parseInt(u8, s[6..8], 10) catch return arena.dupe(u8, s); if (month < 1 or month > 12) return arena.dupe(u8, s); 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" }; const weekday = weekdays[@intCast(@mod(daysFromCivil(year, month, day), 7))]; const date = try arena.print("{s} {d} {s} {d}", .{ weekday, day, months[month - 1], year }); if (s.len < 13 or s[8] != 'T') return date; const zone = if (s[s.len - 1] == 'Z') "UTC" else start.param("TZID") orelse ""; var until: []const u8 = ""; if (end) |e| { const v = mem.trim(u8, e.value, " "); // The end's time when it falls on the same day. if (v.len >= 13 and mem.eql(u8, v[0..8], s[0..8])) { until = try arena.print(" – {s}:{s}", .{ v[9..11], v[11..13] }); } } return arena.print("{s}, {s}:{s}{s}{s}{s}", .{ date, s[9..11], s[11..13], until, if (zone.len > 0) " " else "", zone, });}
/// Days since 1970-01-01, by Howard Hinnant's days_from_civil.fn daysFromCivil(year: i32, month: u8, day: u8) i64 { const y: i64 = if (month <= 2) year - 1 else year; const era = @divFloor(y, 400); const yoe = y - era * 400; const m: i64 = month; const doy = @divFloor(153 * (if (m > 2) m - 3 else m + 9) + 2, 5) + day - 1; const doe = yoe * 365 + @divFloor(yoe, 4) - @divFloor(yoe, 100) + doy; return era * 146097 + doe - 719468;}
/// Text with iCalendar's escapes undone: \n, \, \; and \\.fn unescape(arena: Allocator, text: []const u8) ![]const u8 { if (mem.findScalar(u8, text, '\\') == null) return text; var out: std.ArrayList(u8) = .empty; var i: u32 = 0; while (i < text.len) : (i += 1) { if (text[i] == '\\' and i + 1 < text.len) { i += 1; try out.append(arena, if (text[i] == 'n' or text[i] == 'N') ' ' else text[i]); } else { try out.append(arena, text[i]); } } return out.items;}
fn eql(a: []const u8, b: []const u8) bool { return ascii.eqlIgnoreCase(mem.trim(u8, a, " "), b);}
test "a go-to action comes from the JSON-LD, inside a list or not, and only to https" { var arena_state: ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const github = \\<p>Hi</p><SCRIPT type="application/ld+json">[{"@context":"http://schema.org","@type":"EmailMessage", \\"potentialAction":{"@type":"ViewAction","target":"https://github.com/a/b/pull/1", \\"url":"https://github.com/a/b/pull/1","name":"View Pull Request"}}]</script> ; const a = action(arena, github).?; try testing.expectEqualStrings("View Pull Request", a.name); try testing.expectEqualStrings("https://github.com/a/b/pull/1", a.url);
const target = \\<script type="application/ld+json">{"@type":"EmailMessage","potentialAction":[ \\{"@type":"ConfirmAction","handler":{"url":"https://x.example/c"}}, \\{"@type":"ViewAction","target":{"urlTemplate":"https://x.example/v"}}]}</script> ; const b = action(arena, target).?; try testing.expectEqualStrings("Open", b.name); try testing.expectEqualStrings("https://x.example/v", b.url);
try testing.expectEqual(null, action(arena, \\<script type="application/ld+json">{"potentialAction":{"@type":"ViewAction","url":"javascript:x"}}</script> )); try testing.expectEqual(null, action(arena, "<script type=\"application/ld+json\">{not json</script>")); try testing.expectEqual(null, action(arena, "<p>none</p>"));}
test "an invitation's event: what, when in its zone, where, and who asks" { var arena_state: ArenaAllocator = .init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const ics = "BEGIN:VCALENDAR\r\nMETHOD:REQUEST\r\nBEGIN:VTIMEZONE\r\nTZID:America/Chicago\r\n" ++ "END:VTIMEZONE\r\nBEGIN:VEVENT\r\nSUMMARY:Lunch\\, then a walk\r\n" ++ "DTSTART;TZID=America/Chicago:20261112T120000\r\nDTEND;TZID=America/Chicago:20261112T133000\r\n" ++ "LOCATION:Taco place on Lake St\\nMinneapolis\r\nORGANIZER;CN=\"Torres: Sam\":mailto:sam@example.com\r\n" ++ "DESCRIPTION:a long line that goes \r\n on\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"; const event = (try invite(arena, ics)).?; try testing.expectEqualStrings("Lunch, then a walk", event.summary); // 12 November 2026 is a Thursday. try testing.expectEqualStrings("Thu 12 Nov 2026, 12:00 – 13:30 America/Chicago", event.when); try testing.expectEqualStrings("Taco place on Lake St Minneapolis", event.location); try testing.expectEqualStrings("Torres: Sam", event.organizer); try testing.expect(!event.cancelled);
const cancel = "BEGIN:VCALENDAR\nMETHOD:CANCEL\nBEGIN:VEVENT\nDTSTART:20240229T235900Z\n" ++ "DTEND:20240301T010000Z\nORGANIZER:mailto:bo@example.com\nEND:VEVENT\nEND:VCALENDAR\n"; const gone = (try invite(arena, cancel)).?; try testing.expect(gone.cancelled); try testing.expectEqualStrings("Event", gone.summary); // A leap day; the end is the next day, so it is left out. try testing.expectEqualStrings("Thu 29 Feb 2024, 23:59 UTC", gone.when); try testing.expectEqualStrings("bo@example.com", gone.organizer);
const day = (try invite(arena, "BEGIN:VEVENT\nDTSTART;VALUE=DATE:19700101\nEND:VEVENT\n")).?; try testing.expectEqualStrings("Thu 1 Jan 1970", day.when); try testing.expectEqual(null, try invite(arena, "BEGIN:VCALENDAR\nEND:VCALENDAR\n"));}