Skip to content

Instantly share code, notes, and snippets.

@e-volusian
Last active August 10, 2026 00:42
Show Gist options
  • Select an option

  • Save e-volusian/59acd6269c3738e2349cd14338a9069a to your computer and use it in GitHub Desktop.

Select an option

Save e-volusian/59acd6269c3738e2349cd14338a9069a to your computer and use it in GitHub Desktop.
Fastmail userscript to add events to calendar
// ==UserScript==
// @name Fastmail Smart Schedule
// @namespace fastmail-improver
// @version 1.13.0
// @description Detect appointment phrases in the open email and add them to your calendar
// @match https://app.fastmail.com/*
// @match https://betaapp.fastmail.com/*
// @run-at document-idle
// @grant none
// @noframes
// @downloadURL https://gist.githubusercontent.com/e-volusian/59acd6269c3738e2349cd14338a9069a/raw/fastmail-smart-schedule.user.js
// @updateURL https://gist.githubusercontent.com/e-volusian/59acd6269c3738e2349cd14338a9069a/raw/fastmail-smart-schedule.user.js
// @supportURL https://gist.github.com/e-volusian/59acd6269c3738e2349cd14338a9069a
// ==/UserScript==
(function () {
'use strict';
// Shared JMAP transport for both userscripts.
//
// Two backends, tried in order: Fastmail's own in-page client when the script
// runs in the page world, and a direct fetch using the session in localStorage
// when it runs in a sandboxed userscript world.
// Only these hosts may receive the session bearer token. Fastmail serves the
// JMAP endpoint from a sibling host, so same-origin alone is too strict.
const JMAP_ALLOWED_HOSTS = ['fastmail.com', 'fastmail.fm', 'messagingengine.com'];
/**
* Whether a URL is safe to send the session access token to.
*
* `apiUrl` comes from localStorage, which any script on the page can write.
* Without this check a poisoned session entry would exfiltrate a live token.
*
* @param {string} url Absolute or relative URL to test.
* @param {string} [pageHost] Host to treat as trusted; defaults to the page's.
* @returns {boolean} True when the URL is https and on an allowed host.
*/
function jmapUrlAllowed(url, pageHost) {
if (!String(url == null ? '' : url).trim()) return false;
let parsed;
try { parsed = new URL(url, 'https://' + (pageHost || 'invalid.example')); }
catch { return false; }
if (parsed.protocol !== 'https:') return false;
if (pageHost && parsed.hostname === pageHost) return true;
return JMAP_ALLOWED_HOSTS.some(h =>
parsed.hostname === h || parsed.hostname.endsWith('.' + h));
}
/**
* @param {object} accounts JMAP account map, id -> account.
* @param {string} capability Capability URN the account must advertise.
* @returns {string|null} The first matching account id, or null.
*/
function jmapFindAccountId(accounts, capability) {
for (const [id, acct] of Object.entries(accounts || {})) {
if ((acct.accountCapabilities || {})[capability]) return id;
}
return null;
}
/** @returns {string} The `u=` account param from the page URL, or ''. */
function jmapUParam() {
return new URLSearchParams(location.search).get('u') || '';
}
/**
* @param {Array<object>} sessions Parsed `sessions` localStorage value.
* @param {string} uParam Account param to match on.
* @returns {object|null} The matching session, the first one, or null.
*/
function jmapPickSession(sessions, uParam) {
if (!Array.isArray(sessions) || sessions.length === 0) return null;
return sessions.find(s => s.accounts && s.accounts['u' + uParam]) || sessions[0];
}
/**
* Read the current Fastmail session from localStorage.
*
* @returns {{apiUrl: string, accessToken: string, accounts: object}|null}
* Null when absent, unparseable, or missing credentials.
*/
function jmapReadSession() {
let sessions;
try { sessions = JSON.parse(localStorage.getItem('sessions')); }
catch { return null; }
if (!Array.isArray(sessions) || sessions.length === 0) return null;
const s = jmapPickSession(sessions, jmapUParam());
return (s && s.apiUrl && s.accessToken) ? s : null;
}
/**
* @param {object} data Parsed JMAP response body.
* @returns {object} The first method response's arguments.
* @throws {Error} When the response is empty or the method errored.
*/
function jmapUnwrapResponse(data) {
const [name, resp] = (data.methodResponses || [])[0] || [];
if (!name) throw new Error('empty JMAP response');
if (name === 'error') throw new Error('JMAP error: ' + ((resp || {}).type || 'unknown'));
return resp || {};
}
/**
* POST a single JMAP method call. Needs the session cookies AND the bearer
* token, plus the `u=` param.
*
* @param {object} session Session from {@link jmapReadSession}.
* @param {string} capability Capability URN to declare in `using`.
* @param {string} method JMAP method name, e.g. `Note/get`.
* @param {object} args Method arguments.
* @returns {Promise<object>} The unwrapped method response.
* @throws {Error} On a disallowed apiUrl, a non-2xx status, or a JMAP error.
*/
async function jmapFetch(session, capability, method, args) {
if (!jmapUrlAllowed(session.apiUrl, location.hostname)) {
throw new Error('refusing to send credentials to ' + session.apiUrl);
}
const sep = session.apiUrl.includes('?') ? '&' : '?';
const res = await fetch(session.apiUrl + sep + 'u=' + jmapUParam(), {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + session.accessToken,
},
body: JSON.stringify({
using: ['urn:ietf:params:jmap:core', capability],
methodCalls: [[method, args, '0']],
}),
});
if (!res.ok) throw new Error('JMAP HTTP ' + res.status);
return jmapUnwrapResponse(await res.json());
}
/**
* Direct page access; only works when the script runs in the page world.
*
* @returns {{accounts: object, call: function}|null} Null when unavailable.
*/
function jmapPageBackend() {
const FM = window.FastMail;
if (!FM || !FM.callJMAPMethod || !FM.auth) return null;
const auth = FM.auth;
const accounts = (auth.get ? auth.get('accounts') : auth.accounts) || {};
return {
accounts,
call: (method, args) => Promise.resolve(FM.callJMAPMethod(method, args)),
};
}
/**
* Fetch-based fallback. Works from a sandboxed userscript world, since
* localStorage is shared with the page.
*
* @param {string} capability Capability URN to declare on every call.
* @returns {{accounts: object, call: function}|null} Null with no session.
*/
function jmapSessionBackend(capability) {
const session = jmapReadSession();
if (!session) return null;
return {
accounts: session.accounts || {},
// Re-read the session per call so a rotated token is picked up.
call: (method, args) =>
jmapFetch(jmapReadSession() || session, capability, method, args),
};
}
/**
* @param {string} capability Capability URN the account must advertise.
* @returns {{accounts: object, call: function}|null} The first backend with a
* capable account, or null.
*/
function jmapBackendFor(capability) {
const backends = [jmapPageBackend(), jmapSessionBackend(capability)].filter(Boolean);
return backends.find(b => jmapFindAccountId(b.accounts, capability)) || null;
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
jmapUrlAllowed, jmapFindAccountId, jmapPickSession,
jmapUnwrapResponse, jmapBackendFor,
};
}
const FMS_NUM_WORDS = {
a: 1, an: 1, one: 1, two: 2, three: 3, four: 4, five: 5, six: 6,
seven: 7, eight: 8, nine: 9, ten: 10, eleven: 11, twelve: 12,
};
const FMS_RELATIVE_RE =
/\bin\s+(\d{1,3}|a|an|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)\s+(day|week|month|year)s?\b/gi;
/**
* @param {string} word A digit run or an English number word.
* @returns {number} The amount, or 0 when unrecognised.
*/
function fmsParseAmount(word) {
const w = String(word).toLowerCase();
return FMS_NUM_WORDS[w] || parseInt(w, 10) || 0;
}
/**
* Add months in place, clamping to the last day (Aug 31 + 1 month -> Sep 30).
*
* @param {Date} d Date to shift; mutated.
* @param {number} n Months to add, may be negative.
*/
function fmsAddMonths(d, n) {
const day = d.getDate();
d.setDate(1);
d.setMonth(d.getMonth() + n);
const last = new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate();
d.setDate(Math.min(day, last));
}
/**
* @param {Date} base Starting point; not mutated.
* @param {number} amount How many units to add.
* @param {string} unit One of `day`, `week`, `month`, `year`.
* @returns {Date} A new date.
*/
function fmsAddToDate(base, amount, unit) {
const d = new Date(base.getTime());
if (unit === 'day') d.setDate(d.getDate() + amount);
else if (unit === 'week') d.setDate(d.getDate() + amount * 7);
else if (unit === 'month') fmsAddMonths(d, amount);
else if (unit === 'year') fmsAddMonths(d, amount * 12);
return d;
}
/**
* Find "in 3 months" / "in three weeks" style phrases.
*
* @param {string} text Text to scan.
* @param {Date} baseDate The email's sent date.
* @returns {Array<object>} Hits, deduped by amount and unit.
*/
function fmsFindRelativeDates(text, baseDate) {
const found = [];
const seen = new Set();
for (const m of String(text).matchAll(FMS_RELATIVE_RE)) {
const amount = fmsParseAmount(m[1]);
const unit = m[2].toLowerCase();
const key = amount + unit;
if (!amount || seen.has(key)) continue;
seen.add(key);
found.push({
phrase: m[0], amount, unit, kind: 'relative',
date: fmsAddToDate(baseDate, amount, unit), index: m.index,
});
}
return found;
}
/**
* @param {string} title The `title` attribute on `.v-MessageCard-time`, e.g.
* "Tuesday, August 04, 2026 15:24".
* @returns {?Date} The parsed date, or null when unparseable.
*/
function fmsParseSentDate(title) {
const d = new Date(String(title || '').replace(/^[A-Za-z]+,\s*/, ''));
return isNaN(d.getTime()) ? null : d;
}
/**
* @param {Date} d Date to render.
* @returns {string} A locale-formatted date, e.g. "Wed, Oct 28, 2026".
*/
function fmsFormatDate(d) {
return d.toLocaleDateString(undefined,
{ weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' });
}
/**
* @param {Date} d Date to render.
* @returns {string} A JSCalendar LocalDateTime at midnight, no zone suffix.
*/
function fmsToLocalIso(d) {
const p = n => String(n).padStart(2, '0');
return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + 'T00:00:00';
}
/**
* Fastmail day-view URL for a date, keeping the account (`u=`) param. With an
* event id appended, Fastmail opens that event's detail panel.
*
* @param {Location} loc The page location.
* @param {Date} d Day to open.
* @param {string} [eventId] Event to select.
* @returns {string} An absolute URL.
*/
function fmsCalendarDayUrl(loc, d, eventId) {
const u = new URLSearchParams(loc.search).get('u');
return loc.origin + '/calendar/day/' + fmsToLocalIso(d).slice(0, 10) +
(eventId ? '/' + eventId : '') + (u ? '?u=' + u : '');
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
fmsParseAmount, fmsAddToDate, fmsFindRelativeDates,
fmsParseSentDate, fmsToLocalIso, fmsFormatDate, fmsCalendarDayUrl,
};
}
const FMS_MONTH_NUMS = {
jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6,
jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12,
};
const FMS_MONTH_SRC =
'jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?' +
'|jul(?:y)?|aug(?:ust)?|sep(?:t|tember)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?';
// How to read "10/11": month-first (US) or day-first (most of the world).
const FMS_DATE_ORDER_MDY = 'MDY';
const FMS_DATE_ORDER_DMY = 'DMY';
/**
* @param {string} name Month name or three-letter prefix.
* @returns {number} 1-12, or 0 when unrecognised.
*/
function fmsMonthNum(name) {
return FMS_MONTH_NUMS[String(name).slice(0, 3).toLowerCase()] || 0;
}
/**
* @param {number} y Full year.
* @param {number} m1 Month, 1-12.
* @param {number} day Day of month.
* @returns {?Date} The date, or null when the combination is not real.
*/
function fmsMakeDate(y, m1, day) {
const last = new Date(y, m1, 0).getDate();
if (m1 < 1 || m1 > 12 || day < 1 || day > last) return null;
return new Date(y, m1 - 1, day);
}
// How far behind the base date an undated month/day still reads as the date
// just gone rather than next year's.
const FMS_RECENT_PAST_DAYS = 30;
/**
* A month/day with no year lands on its next occurrence after the base date,
* except when it fell within the last {@link FMS_RECENT_PAST_DAYS} days.
*
* Rolling every past date forward turns a delivery estimate that slipped by a
* day -- "Previously expected July 17" read on the 21st -- into a suggestion
* twelve months out. A date that recently passed is that date; one months
* behind is far likelier to mean the year ahead, which is what an undated
* "Jan 2" read in August means. The previous year is worth reading for the
* same reason: a January mail naming a December date means the one just gone.
*
* @param {number} m1 Month, 1-12.
* @param {number} day Day of month.
* @param {Date} base Date to resolve against.
* @returns {number} The year to use.
*/
function fmsYearFor(m1, day, base) {
const y = base.getFullYear();
for (const candidate of [y, y - 1]) {
const behind = base - new Date(candidate, m1 - 1, day);
if (behind > 0 && behind <= FMS_RECENT_PAST_DAYS * 86400000) return candidate;
}
return new Date(y, m1 - 1, day) < base ? y + 1 : y;
}
/**
* @param {string} text Text to scan.
* @returns {Array<object>} Hits for unambiguous `2026-10-28` dates.
*/
function fmsFindIsoDates(text) {
const hits = [];
for (const m of text.matchAll(/\b(20\d{2})-(\d{1,2})-(\d{1,2})\b/g)) {
const d = fmsMakeDate(+m[1], +m[2], +m[3]);
if (d) hits.push({ phrase: m[0], date: d, kind: 'absolute', index: m.index });
}
return hits;
}
/**
* Slash-separated dates, whose field order is genuinely ambiguous: `10/11`
* is October 11 to a US sender and 10 November to most others. The caller
* supplies the reading; there is no way to infer it from the text.
*
* @param {string} text Text to scan.
* @param {Date} base Date to resolve bare month/day against.
* @param {string} [order] `MDY` (default) or `DMY`.
* @returns {Array<object>} Hits, each carrying the matched phrase.
*/
function fmsFindNumericDates(text, base, order = FMS_DATE_ORDER_MDY) {
const hits = [];
for (const m of text.matchAll(/\b(\d{1,2})\/(\d{1,2})(?:\/(\d{2}|20\d{2}))?\b/g)) {
// A component above 12 cannot be a month, so it settles the order by
// itself: 24/07 is the 24th whatever the reader's convention. The setting
// only decides genuinely ambiguous pairs like 10/11.
const dayFirst = +m[1] > 12 ? true
: +m[2] > 12 ? false
: order === FMS_DATE_ORDER_DMY;
const mo = +m[dayFirst ? 2 : 1], day = +m[dayFirst ? 1 : 2];
let y = m[3] ? +m[3] : fmsYearFor(mo, day, base);
if (y < 100) y += 2000;
const d = fmsMakeDate(y, mo, day);
if (d) hits.push({ phrase: m[0], date: d, kind: 'absolute', index: m.index });
}
return hits;
}
/**
* @param {string} text Text to scan.
* @param {Date} base Date to resolve a missing year against.
* @returns {Array<object>} Hits for `Oct 28`, `October 28th, 2026`, etc.
*/
function fmsFindNamedDates(text, base) {
const hits = [];
const add = (m, mo, day, year) => {
const d = fmsMakeDate(year || fmsYearFor(mo, day, base), mo, day);
if (d) hits.push({ phrase: m[0], date: d, kind: 'absolute', index: m.index });
};
// "July 24", "Jul 24, 2026"
const monthFirst = new RegExp(
'\\b(' + FMS_MONTH_SRC + ')\\.?\\s+(\\d{1,2})(?:st|nd|rd|th)?(?:,?\\s+(20\\d{2}))?\\b', 'gi');
for (const m of text.matchAll(monthFirst)) {
add(m, fmsMonthNum(m[1]), +m[2], m[3] && +m[3]);
}
// "24 July", "24th Jul 2026" -- how most of the world writes it, and how a
// day range reads once its start is dashed off: "24-26 Jul".
const dayFirst = new RegExp(
'\\b(\\d{1,2})(?:st|nd|rd|th)?\\s+(' + FMS_MONTH_SRC + ')\\.?(?:,?\\s+(20\\d{2}))?\\b', 'gi');
for (const m of text.matchAll(dayFirst)) {
add(m, fmsMonthNum(m[2]), +m[1], m[3] && +m[3]);
}
return hits;
}
/**
* @param {string} text Text to scan.
* @param {Date} base Date to resolve against.
* @param {string} [order] Field order for slash dates; see
* {@link fmsFindNumericDates}.
* @returns {Array<object>} All absolute-date hits, most specific format first.
*/
function fmsFindAbsoluteDates(text, base, order) {
return [
...fmsFindIsoDates(text),
...fmsFindNumericDates(text, base, order),
...fmsFindNamedDates(text, base),
];
}
/**
* "end of 10/2026", "end of October", "end of the month" -> last day of month.
*
* @param {string} text Text to scan.
* @param {Date} base Date to resolve a missing year or month against.
* @returns {Array<object>} Month-end hits.
*/
function fmsFindMonthEnds(text, base) {
const hits = [];
const re = new RegExp(
'\\bend\\s+of\\s+(?:(\\d{1,2})\\/(20\\d{2})|(' + FMS_MONTH_SRC +
')\\.?(?:\\s+(20\\d{2}))?|the\\s+month)\\b', 'gi');
for (const m of text.matchAll(re)) {
let mo, y;
if (m[1]) { mo = +m[1]; y = +m[2]; }
else if (m[3]) {
mo = fmsMonthNum(m[3]);
y = m[4] ? +m[4] : base.getFullYear();
if (!m[4] && new Date(y, mo, 0) < base) y += 1;
} else { mo = base.getMonth() + 1; y = base.getFullYear(); }
if (mo < 1 || mo > 12) continue;
hits.push({ phrase: m[0], date: new Date(y, mo, 0), kind: 'month-end', index: m.index });
}
return hits;
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
fmsMonthNum, fmsMakeDate, fmsYearFor, fmsFindAbsoluteDates, fmsFindMonthEnds,
fmsFindNumericDates, FMS_DATE_ORDER_MDY, FMS_DATE_ORDER_DMY,
};
}
const FMS_WEEKDAYS = {
sunday: 0, monday: 1, tuesday: 2, wednesday: 3,
thursday: 4, friday: 5, saturday: 6,
// Carriers abbreviate: "Arriving by Thu, Jul 24", "Scheduled delivery: Fri".
sun: 0, mon: 1, tue: 2, tues: 2, wed: 3, weds: 3,
thu: 4, thur: 4, thurs: 4, fri: 5, sat: 6,
};
// Longest first, so "thursday" is never truncated to "thu".
const FMS_WEEKDAY_SRC = Object.keys(FMS_WEEKDAYS)
.sort((a, b) => b.length - a.length).join('|');
// How far after a date phrase to look for a clock time, and how far before it
// to look for deadline wording.
const FMS_TIME_LOOKAHEAD_CHARS = 40;
const FMS_DEADLINE_LOOKBEHIND_CHARS = 30;
/**
* "this X" = soonest X after base; "next X" = one week beyond that.
*
* @param {Date} base Date to count from.
* @param {number} target Weekday index, 0 = Sunday.
* @param {boolean} extraWeek Add a week, for "next".
* @returns {Date} Midnight on the resolved day.
*/
function fmsNextWeekday(base, target, extraWeek) {
const d = new Date(base.getFullYear(), base.getMonth(), base.getDate());
let diff = (target - d.getDay() + 7) % 7;
if (diff === 0) diff = 7;
d.setDate(d.getDate() + diff + (extraWeek ? 7 : 0));
return d;
}
/**
* @param {string} text Text to scan.
* @param {Date} base Date to resolve against.
* @returns {Array<object>} Hits for "this Tuesday", "next Friday", "on Monday".
*/
function fmsFindWeekdays(text, base) {
const hits = [];
const re = new RegExp(
'\\b(this|next|on)\\s+(' + FMS_WEEKDAY_SRC + ')\\b', 'gi');
for (const m of text.matchAll(re)) {
const day = FMS_WEEKDAYS[m[2].toLowerCase()];
const extra = m[1].toLowerCase() === 'next';
hits.push({
phrase: m[0], date: fmsNextWeekday(base, day, extra),
kind: 'weekday', index: m.index,
});
}
return hits;
}
/**
* @param {string} text Text to scan.
* @param {Date} base Date to resolve against.
* @returns {Array<object>} Hits for "tomorrow", "next week", "next month".
*/
function fmsFindSimpleRelatives(text, base) {
const specs = [
[/\btomorrow\b/gi, b => fmsAddToDate(b, 1, 'day')],
[/\bnext\s+week\b/gi, b => fmsAddToDate(b, 1, 'week')],
[/\bnext\s+month\b/gi, b => fmsAddToDate(b, 1, 'month')],
];
const hits = [];
for (const [re, calc] of specs) {
for (const m of text.matchAll(re)) {
hits.push({ phrase: m[0], date: calc(base), kind: 'relative', index: m.index });
}
}
return hits;
}
/**
* Find a clock time like "at 3pm" or "15:00" shortly after a date phrase.
*
* @param {string} text Full text being scanned.
* @param {number} index Where the date phrase starts.
* @param {number} phraseLen Length of the date phrase.
* @param {number} [range] How many characters past the phrase to search.
* @returns {?{hours: number, minutes: number}} The time, or null.
*/
function fmsTimeNear(text, index, phraseLen, range = FMS_TIME_LOOKAHEAD_CHARS) {
const seg = text.slice(index, index + phraseLen + range);
const m = seg.match(/\b(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)\b/i) ||
seg.match(/\b(\d{1,2}):(\d{2})\b/);
if (!m) return null;
let h = +m[1];
const minutes = +(m[2] || 0);
const ap = (m[3] || '').toLowerCase();
if (ap === 'pm' && h < 12) h += 12;
if (ap === 'am' && h === 12) h = 0;
return (h > 23 || minutes > 59) ? null : { hours: h, minutes };
}
const FMS_DEADLINE_RE =
/(due\s+(?:by|on)|expir(?:es?|ing|ation)|renew(?:al)?|no\s+later\s+than|deadline|before)[^.\n]{0,15}$/i;
/**
* Deadline-flavored wording just before the phrase enables the reminder offer.
*
* @param {string} text Full text being scanned.
* @param {number} index Where the date phrase starts.
* @returns {boolean} Whether the run-up reads like a deadline.
*/
function fmsIsDeadline(text, index) {
return FMS_DEADLINE_RE.test(
text.slice(Math.max(0, index - FMS_DEADLINE_LOOKBEHIND_CHARS), index));
}
/**
* @param {Date} d Any date.
* @returns {Date} Midnight at the start of that day.
*/
function fmsDayOf(d) {
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
}
// Delivery mail rarely says "on Tuesday" -- it says "Arriving Tuesday". A bare
// weekday is far too common to detect on its own, so it only counts when one
// of these leads into it.
const FMS_ARRIVAL_SRC = [
// "Arriving Friday", "Arrives on Friday", "Estimated to arrive by Thu"
'arriv(?:ing|es|ed|al|e)(?:\\s+(?:on|by))?',
'estimated\\s+to\\s+arriv(?:e|ing)(?:\\s+(?:on|by))?',
// "Estimated delivery", "Expected delivery date", "Scheduled delivery"
'(?:estimated|expected|scheduled)\\s+delivery(?:\\s+date)?',
'delivery\\s+(?:date|expected|scheduled|by)',
'expected\\s+(?:by|on)',
// "Delivered on Friday", "will be delivered by Friday"
'deliver(?:s|ed|y)?\\s+(?:on|by)',
'scheduled\\s+(?:for|on)',
// Amazon and eBay both promise this way.
'get\\s+it\\s+by',
// A missed attempt needs chasing as much as an arrival needs meeting.
'delivery\\s+attempted|attempted\\s+delivery',
].join('|');
/**
* "Arriving Tuesday", "Estimated delivery: Aug 12", "Out for delivery".
*
* @param {string} text Text to scan.
* @param {Date} base Date to resolve against.
* @returns {Array<object>} Arrival hits, one per phrase.
*/
function fmsFindArrivals(text, base) {
const hits = [];
const re = new RegExp(
'\\b(?:' + FMS_ARRIVAL_SRC + ')\\b[\\s:,.-]*' +
'(today|tomorrow|' + FMS_WEEKDAY_SRC + ')\\b',
'gi');
for (const m of text.matchAll(re)) {
const word = m[1].toLowerCase();
let date;
if (word === 'today') date = fmsDayOf(base);
else if (word === 'tomorrow') date = fmsAddToDate(fmsDayOf(base), 1, 'day');
else date = fmsNextWeekday(base, FMS_WEEKDAYS[word], false);
hits.push({ phrase: m[0], date, kind: 'arrival', index: m.index });
}
// No date at all: the carrier means today.
for (const m of text.matchAll(/\bout\s+for\s+delivery\b/gi)) {
hits.push({
phrase: m[0], date: fmsDayOf(base), kind: 'arrival', index: m.index,
});
}
return hits;
}
const FMS_IN_HOURS_RE =
/\bin\s+(\d{1,2}|an?|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)\s+hours?\b/gi;
// Sub-day anchors. 8/13/18 match the clock times Fastmail's own snooze
// preferences offer, so a suggestion lands where the native options would.
const FMS_HOUR_MORNING = 8;
const FMS_HOUR_AFTERNOON = 13;
const FMS_HOUR_EOD = 17;
const FMS_HOUR_EVENING = 18;
/**
* Phrases that name a part of a day. Unlike the calendar detectors these can
* resolve to the send day itself, because a snooze can be same-day.
*
* @param {string} text Text to scan.
* @param {Date} base Date to resolve against.
* @returns {Array<object>} Hits carrying an explicit `time`.
*/
function fmsFindSubDay(text, base) {
const day = fmsDayOf(base);
const next = fmsAddToDate(day, 1, 'day');
const at = hours => ({ hours, minutes: 0 });
const specs = [
[/\bthis\s+afternoon\b/gi, day, at(FMS_HOUR_AFTERNOON)],
[/\b(?:this\s+evening|tonight)\b/gi, day, at(FMS_HOUR_EVENING)],
[/\b(?:by\s+)?(?:the\s+)?end\s+of\s+(?:the\s+)?day\b|\bEOD\b/g, day, at(FMS_HOUR_EOD)],
[/\b(?:first\s+thing\s+)?tomorrow\s+morning\b/gi, next, at(FMS_HOUR_MORNING)],
[/\btomorrow\s+afternoon\b/gi, next, at(FMS_HOUR_AFTERNOON)],
[/\btomorrow\s+(?:evening|night)\b/gi, next, at(FMS_HOUR_EVENING)],
];
const hits = [];
for (const [re, date, time] of specs) {
for (const m of text.matchAll(re)) {
hits.push({
phrase: m[0], date: new Date(date.getTime()), kind: 'subday',
index: m.index, time, fixedTime: true,
});
}
}
for (const m of text.matchAll(FMS_IN_HOURS_RE)) {
const amount = fmsParseAmount(m[1]);
if (!amount || amount > 48) continue;
const when = new Date(base.getTime() + amount * 3600000);
hits.push({
phrase: m[0], date: fmsDayOf(when), kind: 'subday', index: m.index,
time: { hours: when.getHours(), minutes: when.getMinutes() },
fixedTime: true,
});
}
return hits;
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
fmsNextWeekday, fmsFindWeekdays, fmsFindSimpleRelatives, fmsTimeNear, fmsIsDeadline,
fmsFindArrivals, fmsFindSubDay, fmsDayOf,
};
}
const FMS_MAX_HITS = 5;
/**
* Keep the first hit for each calendar day. Detectors run most-specific first,
* so an explicit date beats a vaguer phrase resolving to the same day.
*
* @param {Array<object>} hits Detected hits.
* @returns {Array<object>} One hit per distinct day, in input order.
*/
function fmsDedupeByDay(hits) {
const seen = new Map();
for (const h of hits) {
const key = h.date.toDateString();
if (!seen.has(key)) seen.set(key, h);
}
return [...seen.values()];
}
/**
* Find every date phrase in an email body.
*
* @param {string} text Subject plus body text.
* @param {Date} base The email's sent date; all relative phrases resolve
* against it.
* @param {{dateOrder?: string}} [opts] `dateOrder` picks how `10/11` reads;
* see {@link fmsFindNumericDates}.
* @returns {Array<object>} Up to {@link FMS_MAX_HITS} hits sorted by date,
* each with `phrase`, `date`, `kind`, `index`, `time` and `deadline`.
*/
function fmsDetectAll(text, base, opts = {}) {
const t = String(text);
const all = [
...fmsFindAbsoluteDates(t, base, opts.dateOrder),
...fmsFindMonthEnds(t, base),
...fmsFindWeekdays(t, base),
...fmsFindSimpleRelatives(t, base),
...fmsFindRelativeDates(t, base),
];
// Strictly after the send day: a hit on the send date itself is almost
// always the message's own Date header echoed in the body.
const dayStart = new Date(base.getFullYear(), base.getMonth(), base.getDate());
const future = all.filter(h => h.date > dayStart);
for (const h of future) {
h.time = fmsTimeNear(t, h.index || 0, h.phrase.length);
h.deadline = fmsIsDeadline(t, h.index || 0);
}
return fmsDedupeByDay(future)
.sort((a, b) => a.date - b.date)
.slice(0, FMS_MAX_HITS);
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = { fmsDetectAll, fmsDedupeByDay };
}
// Snooze suggestions: the dates already found in a message, turned into
// wake-up times worth offering in Fastmail's snooze menu.
// Fastmail's own picker refuses anything further out than this.
const FMS_SNOOZE_MAX_DAYS = 512;
// How many dates to build groups for, at most.
const FMS_SNOOZE_MAX = 3;
// A suggestion this close to one of Fastmail's built-in options is that
// option, so it is dropped rather than duplicated.
const FMS_SNOOZE_NEAR_MS = 30 * 60 * 1000;
// Nothing in a one-time-code mail is worth waking up for, and such mail is
// full of times ("expires in 10 minutes") that would otherwise be detected.
const FMS_SNOOZE_SKIP_RE =
/\b(?:verification|security|access)\s+code\b|\bone[-\s]?time\s+(?:code|password|passcode|pin|link)\b|\bexpires?\s+in\s+\d+\s+minutes?\b|\bOTP\b|\btwo[-\s]?factor\b/i;
/**
* Every date phrase in a message that a snooze could target.
*
* The calendar path ({@link fmsDetectAll}) drops anything on or before the
* send day and forgets the time of day. A snooze needs both: "this evening"
* and "arriving today" are the most useful targets a message can offer.
*
* @param {string} text Subject plus body text.
* @param {Date} base The message's sent date.
* @param {{dateOrder?: string}} [opts] `dateOrder` picks how `10/11` reads.
* @returns {Array<object>} Hits sorted by date, one per calendar day.
*/
function fmsSnoozeHits(text, base, opts = {}) {
const t = String(text);
// Most specific first: fmsDedupeByDay keeps the first hit for each day, and
// a phrase naming a time of day beats a bare date on the same day.
const all = [
...fmsFindSubDay(t, base),
...fmsFindArrivals(t, base),
...fmsFindAbsoluteDates(t, base, opts.dateOrder),
...fmsFindMonthEnds(t, base),
...fmsFindWeekdays(t, base),
...fmsFindSimpleRelatives(t, base),
...fmsFindRelativeDates(t, base),
];
for (const h of all) {
if (!h.fixedTime) h.time = fmsTimeNear(t, h.index || 0, h.phrase.length);
h.deadline = fmsIsDeadline(t, h.index || 0);
}
return fmsDedupeByDay(all).sort((a, b) => a.date - b.date);
}
/**
* @param {Date} day Day to land on.
* @param {number} minutes Clock time, in minutes since midnight.
* @returns {Date} That day at that time.
*/
function fmsSnoozeAt(day, minutes) {
const d = new Date(day.getFullYear(), day.getMonth(), day.getDate());
d.setMinutes(minutes);
return d;
}
/**
* @param {Date} d Date to render.
* @returns {string} A short day label, e.g. "Oct 31".
*/
function fmsSnoozeDayLabel(d) {
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
/**
* @param {Date} d Date to render.
* @returns {string} The date written out, e.g. "October 31".
*/
function fmsSnoozeDateLabel(d) {
return d.toLocaleDateString(undefined, { month: 'long', day: 'numeric' });
}
/**
* The wake-up times one detected date is worth offering.
*
* Each option lands at the configured morning hour, so the row only has to
* name which day it means: the date itself, the days either side of it, and
* the lead time before it. The date they are all relative to is named once,
* by the group heading above them, so it is left out of the labels here.
*
* @param {object} hit A hit from {@link fmsSnoozeHits}.
* @param {object} opts Resolved settings; see {@link fmsSnoozeOptions}.
* @returns {Array<{date: Date, why: string}>} Candidates, in menu order.
*/
function fmsSnoozeTargets(hit, opts) {
// A phrase that names its own time ("tomorrow morning", "in 3 hours") is
// already exact. Padding it with a day-before or morning-of option would
// only add noise.
if (hit.fixedTime) {
return [{
date: fmsSnoozeAt(hit.date, hit.time.hours * 60 + hit.time.minutes),
why: fmsSnoozeSentenceCase(hit.phrase),
}];
}
const back = new Date(hit.date.getTime() - opts.lead * 60000);
const out = [];
// A message that names a time for the date means that time; waking at the
// usual morning hour instead would miss it.
if (hit.time) {
out.push({
date: fmsSnoozeAt(hit.date, hit.time.hours * 60 + hit.time.minutes),
why: 'At the time',
});
}
return out.concat([
{ date: fmsSnoozeAt(hit.date, opts.morning), why: 'Morning of' },
{
date: fmsSnoozeAt(fmsAddToDate(hit.date, -1, 'day'), opts.morning),
why: 'Day before',
},
{
date: fmsSnoozeAt(fmsAddToDate(hit.date, 1, 'day'), opts.morning),
why: 'Day after',
},
{
// A lead of whole days lands at the usual morning hour; a shorter one
// is meant literally, so keep the offset it names.
date: opts.lead % 1440 === 0 ? fmsSnoozeAt(back, opts.morning) : back,
why: fmsSnoozeLeadLabel(opts.lead) + ' before',
},
]);
}
/** @param {string} s Any phrase. @returns {string} The phrase, capitalized. */
function fmsSnoozeSentenceCase(s) {
const t = String(s).trim().replace(/\s+/g, ' ');
return t.charAt(0).toUpperCase() + t.slice(1).toLowerCase();
}
/**
* @param {number} minutes A lead time in minutes.
* @returns {string} How to name it, e.g. "1 week".
*/
function fmsSnoozeLeadLabel(minutes) {
for (const [unit, size] of [['week', 10080], ['day', 1440], ['hour', 60]]) {
if (minutes >= size && minutes % size === 0) {
const n = minutes / size;
return n + ' ' + unit + (n === 1 ? '' : 's');
}
}
return minutes + ' minutes';
}
/**
* The wake-up times Fastmail's snooze menu already offers, so suggestions can
* avoid repeating one. Mirrors `FutureTimeMenuView.drawOptions`; the menu's
* remembered "last custom" entry is read from Fastmail's own preferences.
*
* @param {Date} now Current time.
* @param {?number} lastCustomDelta Fastmail's `lastUsedFutureDelta`, if known.
* @returns {Array<Date>} The times the menu shows.
*/
function fmsSnoozeNativeTimes(now, lastCustomDelta) {
const hour = new Date(now.getTime());
hour.setMinutes(0, 0, 0);
const day = now.getDay();
const at = (offsetDays, hours) => {
const d = new Date(hour.getTime());
d.setHours(hours, 0, 0, 0);
d.setDate(d.getDate() + offsetDays);
return d;
};
const times = [
new Date(hour.getTime() + 3 * 3600000), // later today
at(0, 18), // this evening
at(1, 8), // tomorrow
at(7 - ((day + 1) % 7), 8), // this weekend (Saturday)
at(7 - ((day + 6) % 7), 8), // next week (Monday)
];
if (typeof lastCustomDelta === 'number') {
const endOfToday = new Date(now.getTime());
endOfToday.setHours(24, 0, 0, 0);
times.push(new Date(endOfToday.getTime() + lastCustomDelta));
}
return times;
}
/**
* Resolve the snooze settings into the numbers the engine works in.
*
* @returns {{morning: number, evening: number, lead: number, max: number}}
* Clock times and lead time in minutes.
*/
function fmsSnoozeOptions() {
return {
morning: fmsParseClock(fmsReadSetting('snoozeMorning')) || 8 * 60,
evening: fmsParseClock(fmsReadSetting('snoozeEvening')) || 18 * 60,
lead: fmsParseOffset(fmsReadSetting('snoozeLead')) || 10080,
max: FMS_SNOOZE_MAX,
};
}
/**
* What to offer in the snooze menu for one message, one group per date found.
*
* Each group names its date once and lists the wake-up times built from it, so
* a message mentioning several dates stays readable.
*
* @param {string} text Subject plus body text.
* @param {Date} sent The message's sent date; relative phrases resolve
* against it, so a stale "this evening" correctly falls in the past.
* @param {Date} now Current time.
* @param {object} [opts] Settings from {@link fmsSnoozeOptions}, plus
* `dateOrder`, `native` (times to avoid) and `max` (how many dates).
* @returns {Array<{date: Date, label: string, options: Array<object>}>} Groups
* in the order their dates appear, soonest first.
*/
function fmsSnoozeGroups(text, sent, now, opts = {}) {
const t = String(text);
if (FMS_SNOOZE_SKIP_RE.test(t)) return [];
const settings = Object.assign(
{ morning: 8 * 60, evening: 18 * 60, lead: 10080, max: FMS_SNOOZE_MAX },
opts);
const native = opts.native || [];
const latest = now.getTime() + FMS_SNOOZE_MAX_DAYS * 86400000;
const groups = [];
const seen = new Set();
for (const hit of fmsSnoozeHits(t, sent, settings)) {
// A day that has already gone by has nothing left to offer. Today still
// does -- a time later today, or the morning after -- so the comparison
// is by day, and the per-option check below drops the times that passed.
if (fmsDayOf(hit.date).getTime() < fmsDayOf(now).getTime()) continue;
const options = [];
for (const target of fmsSnoozeTargets(hit, settings)) {
const ms = target.date.getTime();
if (ms <= now.getTime() || ms > latest) continue;
if (seen.has(ms)) continue;
if (native.some(n => Math.abs(n - ms) < FMS_SNOOZE_NEAR_MS)) continue;
seen.add(ms);
options.push(target);
}
if (!options.length) continue;
groups.push({ date: hit.date, label: fmsSnoozeDateLabel(hit.date), options });
if (groups.length >= settings.max) break;
}
return groups;
}
/**
* The same suggestions as {@link fmsSnoozeGroups}, flattened.
*
* @param {string} text Subject plus body text.
* @param {Date} sent The message's sent date.
* @param {Date} now Current time.
* @param {object} [opts] As {@link fmsSnoozeGroups}.
* @returns {Array<{date: Date, why: string}>} Every option, grouping dropped.
*/
function fmsSnoozeSuggestions(text, sent, now, opts = {}) {
return fmsSnoozeGroups(text, sent, now, opts)
.reduce((all, g) => all.concat(g.options), []);
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
fmsSnoozeHits, fmsSnoozeTargets, fmsSnoozeNativeTimes, fmsSnoozeSuggestions,
fmsSnoozeGroups, fmsSnoozeDayLabel, fmsSnoozeDateLabel, fmsSnoozeLeadLabel,
};
}
const FMS_CAL_CAP = 'urn:ietf:params:jmap:calendars';
// The account and its default calendar effectively never change during a
// session, but fmsFindEventByUid runs on every banner render -- so cache them
// rather than re-fetching the whole calendar list each time.
const FMS_CONTEXT_TTL_MS = 5 * 60 * 1000;
let fmsContextCache = null;
/**
* @param {object} accounts JMAP account map.
* @returns {string|null} Id of the account holding calendars, or null.
*/
function fmsFindCalendarAccountId(accounts) {
return jmapFindAccountId(accounts, FMS_CAL_CAP);
}
/**
* @returns {{accounts: object, call: function}} A calendar-capable backend.
* @throws {Error} When no Fastmail session exposes calendars.
*/
function fmsGetBackend() {
const backend = jmapBackendFor(FMS_CAL_CAP);
if (!backend) throw new Error('no calendar-capable Fastmail session found');
return backend;
}
/**
* @param {{call: function}} backend Calendar backend.
* @param {string} accountId Account to query.
* @returns {Promise<object>} The default calendar, or the first writable one.
* @throws {Error} When nothing writable exists.
*/
async function fmsDefaultCalendar(backend, accountId) {
const res = await backend.call('Calendar/get', { accountId, ids: null });
const list = (res.list || []).filter(c =>
!c.myRights || c.myRights.mayWriteAll || c.myRights.mayWriteOwn);
const cal = list.find(c => c.isDefault) || list[0];
if (!cal) throw new Error('no writable calendar found');
return cal;
}
/** @returns {Promise<{backend: object, accountId: string, calendar: object}>} */
async function fmsBuildContext() {
const backend = fmsGetBackend();
const accountId = fmsFindCalendarAccountId(backend.accounts);
const calendar = await fmsDefaultCalendar(backend, accountId);
return { backend, accountId, calendar };
}
/**
* Resolve the target account and calendar, memoized for {@link
* FMS_CONTEXT_TTL_MS}. A failed lookup is not cached.
*
* @param {boolean} [force] Bypass and replace the cached value.
* @returns {Promise<{backend: object, accountId: string, calendar: object}>}
*/
function fmsEventContext(force) {
const now = Date.now();
if (!force && fmsContextCache && now - fmsContextCache.at < FMS_CONTEXT_TTL_MS) {
return fmsContextCache.promise;
}
const promise = fmsBuildContext();
fmsContextCache = { at: now, promise };
promise.catch(() => {
if (fmsContextCache && fmsContextCache.promise === promise) fmsContextCache = null;
});
return promise;
}
/**
* @param {Date} date Event day.
* @param {?{hours: number, minutes: number}} time Clock time, or null for all-day.
* @returns {string} A JSCalendar LocalDateTime.
*/
function fmsEventStart(date, time) {
if (!time) return fmsToLocalIso(date);
const p = n => String(n).padStart(2, '0');
return fmsToLocalIso(date).slice(0, 11) + p(time.hours) + ':' + p(time.minutes) + ':00';
}
/**
* Build the JSCalendar event to create. All-day unless a time is given; then a
* timed event of the configured length in the local zone.
*
* @param {{title: string, description: string, date: Date,
* time: ?object, url: ?string, uid: ?string}} spec What the banner detected.
* @param {string} calendarId Target calendar.
* @returns {object} A JSCalendar Event ready for `CalendarEvent/set`.
*/
function fmsBuildEvent({ title, description, date, time, url, uid }, calendarId) {
const event = {
'@type': 'Event',
// Fastmail's jscalendarbis rejects creates without this exact version.
version: '2.0',
// Never rendered by any client UI, so it doubles as our hidden marker.
...(uid ? { uid } : {}),
calendarIds: { [calendarId]: true },
title,
description,
start: fmsEventStart(date, time),
showWithoutTime: !time,
duration: time ? fmsTimedDurationIso() : 'P1D',
freeBusyStatus: time ? 'busy' : 'free',
};
if (time) event.timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
if (url) event.links = { l1: { '@type': 'Link', href: url, title: 'Original email' } };
// Trigger only -- Fastmail decides how the alert is delivered.
const alerts = fmsAlertsFor(!time);
if (alerts) event.alerts = alerts;
return event;
}
/**
* Test mode: resolve the target calendar and build the event, writing nothing.
*
* @param {object} spec Event spec.
* @returns {Promise<{event: object, calendarName: string}>}
*/
async function fmsPreviewEvent(spec) {
const { calendar } = await fmsEventContext();
return {
event: fmsBuildEvent(spec, calendar.id),
calendarName: calendar.name || '(unnamed calendar)',
};
}
/**
* @param {Date} date A local calendar day.
* @param {number} offsetDays Days to shift.
* @returns {string} That day's midnight as a zoneless LocalDateTime — the only
* form Fastmail's query filter accepts (a `Z` suffix is invalidArguments).
*/
function fmsDayIso(date, offsetDays) {
return fmsToLocalIso(new Date(date.getFullYear(), date.getMonth(),
date.getDate() + offsetDays));
}
/**
* Find the event our marker uid produced for a given day, if it still exists.
*
* The query window is the day padded by one day each side: the server compares
* in the calendar's zone, which may differ from the browser's.
*
* @param {string} uid Marker uid from `fmsHitUid`.
* @param {Date} date The event's expected day.
* @returns {Promise<?string>} The event id, or null when no event carries the
* uid. Rejects when the lookup itself fails, so callers can tell "definitely
* absent" from "unknown".
*/
async function fmsFindEventByUid(uid, date) {
const { backend, accountId } = await fmsEventContext();
const q = await backend.call('CalendarEvent/query', { accountId,
filter: { after: fmsDayIso(date, -1), before: fmsDayIso(date, 2) } });
if (!q.ids || !q.ids.length) return null;
const res = await backend.call('CalendarEvent/get',
{ accountId, ids: q.ids, properties: ['id', 'uid'] });
const hit = (res.list || []).find(e => e.uid === uid);
return hit ? hit.id : null;
}
/**
* @param {object} spec Event spec.
* @returns {Promise<string>} The created event's id.
* @throws {Error} When the server rejects the create.
*/
async function fmsCreateEvent(spec) {
const { backend, accountId, calendar } = await fmsEventContext();
const res = await backend.call('CalendarEvent/set',
{ accountId, create: { e1: fmsBuildEvent(spec, calendar.id) } });
if (!res.created || !res.created.e1) {
const why = res.notCreated && res.notCreated.e1;
throw new Error('event creation failed' + (why ? ': ' + (why.type || '') : ''));
}
return res.created.e1.id;
}
// Test mode: action buttons preview what would be created instead of creating it.
const FMS_TEST_KEY = 'fms.testMode';
/** @returns {boolean} Whether action buttons only preview. */
function fmsTestModeEnabled() {
try { return localStorage.getItem(FMS_TEST_KEY) === '1'; }
catch { return false; }
}
/** @param {boolean} on Turn test mode on or off; persisted per browser. */
function fmsSetTestMode(on) {
try { localStorage.setItem(FMS_TEST_KEY, on ? '1' : '0'); }
catch { /* storage unavailable; the toggle just won't persist */ }
}
/**
* @param {object} event A built JSCalendar event.
* @param {string} calendarName Target calendar's name.
* @returns {Array<[string, string]>} [label, value] pairs for the preview modal.
*/
function fmsPreviewLines(event, calendarName) {
const allDay = !!event.showWithoutTime;
const lines = [
['Title', event.title],
['Date', fmsFormatDate(new Date(event.start))],
];
if (!allDay) lines.push(['Time', event.start.slice(11, 16)]);
lines.push(
['Duration', allDay ? '1 day' : fmsReadSetting('timedDur') + ' h'],
['Type', allDay ? 'All-day' : 'Timed'],
['Alert', fmsAlertLine(event)],
['Calendar', calendarName],
['Description', event.description],
);
return lines;
}
// User-configurable settings, persisted per-browser in localStorage.
const FMS_SET_KEYS = {
alertsOn: 'fms.alertsOn',
alertAllDay: 'fms.alertAllDay',
alertTimed: 'fms.alertTimed',
alarmTime: 'fms.alarmTime',
timedDur: 'fms.timedDur',
viewNewTab: 'fms.viewNewTab',
dateOrder: 'fms.dateOrder',
snoozeOn: 'fms.snoozeOn',
snoozeMorning: 'fms.snoozeMorning',
snoozeEvening: 'fms.snoozeEvening',
snoozeLead: 'fms.snoozeLead',
};
const FMS_SET_DEFAULTS = {
alertsOn: '1',
alertAllDay: '1D',
alertTimed: '1H',
alarmTime: '8:00',
timedDur: '1',
viewNewTab: '1',
dateOrder: FMS_DATE_ORDER_MDY,
snoozeOn: '1',
snoozeMorning: '8:00',
snoozeEvening: '18:00',
snoozeLead: '1W',
};
// Remembered-event map entries kept before the oldest are dropped.
const FMS_CREATED_MAX = 200;
// Fallback when the configured all-day alert clock time is unreadable.
const FMS_DEFAULT_ALARM_MINUTES = 480;
const FMS_UNIT_MINUTES = { m: 1, h: 60, d: 1440, w: 10080, mo: 43200 };
// "1.5H" -> 90 minutes; null when not a valid time text.
function fmsParseOffset(text) {
const m = String(text).trim().match(/^(\d+(?:\.\d+)?)\s*(mo|[mhdw])$/i);
if (!m) return null;
const minutes = Math.round(parseFloat(m[1]) * FMS_UNIT_MINUTES[m[2].toLowerCase()]);
return minutes >= 1 ? minutes : null;
}
// "8:00" (24h clock) -> minutes since midnight; null when invalid.
function fmsParseClock(text) {
const m = String(text).trim().match(/^([01]?\d|2[0-3]):([0-5]\d)$/);
return m ? (+m[1]) * 60 + (+m[2]) : null;
}
// "0.25" hours -> 15 minutes; null when not a positive number.
function fmsParseHours(text) {
if (!/^\d+(\.\d+)?$/.test(String(text).trim())) return null;
const minutes = Math.round(parseFloat(text) * 60);
return minutes >= 1 ? minutes : null;
}
function fmsMinutesToIso(total) {
const d = Math.floor(total / 1440);
const h = Math.floor((total % 1440) / 60);
const mi = total % 60;
let s = 'P' + (d ? d + 'D' : '');
if (h || mi || !d) s += 'T' + (h ? h + 'H' : '') + (mi || !h ? mi + 'M' : '');
return s;
}
/**
* @param {string} name Setting key.
* @param {string} v Candidate value.
* @returns {boolean} Whether the value is usable for this setting.
*/
function fmsValidSetting(name, v) {
if (name === 'alertsOn' || name === 'viewNewTab' || name === 'snoozeOn') {
return v === '0' || v === '1';
}
if (name === 'dateOrder') return v === FMS_DATE_ORDER_MDY || v === FMS_DATE_ORDER_DMY;
if (name === 'timedDur') return fmsParseHours(v) !== null;
if (name === 'alarmTime' || name === 'snoozeMorning' || name === 'snoozeEvening') {
return fmsParseClock(v) !== null;
}
if (name === 'snoozeLead') return fmsParseOffset(v) !== null;
return v === '' || fmsParseOffset(v) !== null;
}
function fmsReadSetting(name) {
let v;
try { v = localStorage.getItem(FMS_SET_KEYS[name]); }
catch { v = null; }
if (v === null || !fmsValidSetting(name, v)) return FMS_SET_DEFAULTS[name];
return v;
}
function fmsWriteSetting(name, value) {
try { localStorage.setItem(FMS_SET_KEYS[name], value); }
catch { /* storage unavailable; the setting just won't persist */ }
}
function fmsAlertsEnabled() {
return fmsReadSetting('alertsOn') !== '0';
}
function fmsSnoozeEnabled() {
return fmsReadSetting('snoozeOn') !== '0';
}
function fmsViewInNewTab() {
return fmsReadSetting('viewNewTab') !== '0';
}
// Raw user text for the alert offset; '' means no alert.
function fmsAlertText(allDay) {
if (!fmsAlertsEnabled()) return '';
return fmsReadSetting(allDay ? 'alertAllDay' : 'alertTimed');
}
function fmsAlarmMinutes() {
return fmsParseClock(fmsReadSetting('alarmTime')) || FMS_DEFAULT_ALARM_MINUTES;
}
/**
* @returns {string} How to read slash dates: `MDY` or `DMY`.
*/
function fmsDateOrder() {
return fmsReadSetting('dateOrder');
}
// ISO offset for JMAP, or '' when no alert applies. All-day events start at
// midnight, so their offset is shifted to fire at the configured clock time.
function fmsAlertOffset(allDay) {
const minutes = fmsParseOffset(fmsAlertText(allDay));
if (!minutes) return '';
if (!allDay) return '-' + fmsMinutesToIso(minutes);
const eff = minutes - fmsAlarmMinutes();
return eff > 0 ? '-' + fmsMinutesToIso(eff) : fmsMinutesToIso(-eff);
}
// JSCalendar alerts map for a new event, or null when alerts are off.
// Only the trigger is set; Fastmail decides how the alert is delivered.
function fmsAlertsFor(allDay) {
const offset = fmsAlertOffset(allDay);
if (!offset) return null;
return { a1: { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset } } };
}
// ISO duration for timed events from the configured hours.
function fmsTimedDurationIso() {
return fmsMinutesToIso(fmsParseHours(fmsReadSetting('timedDur')) || 60);
}
// Human text for the preview modal's Alert line, e.g. "1D before at 8:00".
function fmsAlertLine(event) {
if (!event.alerts) return 'none';
const allDay = !!event.showWithoutTime;
const base = fmsAlertText(allDay) + ' before';
return allDay ? base + ' at ' + fmsReadSetting('alarmTime') : base;
}
// Created-event memory so a re-rendered banner still shows Added/View.
const FMS_CREATED_KEY = 'fms.createdEvents';
function fmsReadCreatedMap() {
try { return JSON.parse(localStorage.getItem(FMS_CREATED_KEY)) || {}; }
catch { return {}; }
}
function fmsRememberCreated(key, eventId) {
const map = fmsReadCreatedMap();
map[key] = eventId;
const keys = Object.keys(map);
for (let i = 0; i < keys.length - FMS_CREATED_MAX; i++) delete map[keys[i]];
try { localStorage.setItem(FMS_CREATED_KEY, JSON.stringify(map)); }
catch { /* storage unavailable; the memory just won't persist */ }
}
function fmsCreatedId(key) {
return fmsReadCreatedMap()[key] || null;
}
function fmsForgetCreated(key) {
const map = fmsReadCreatedMap();
delete map[key];
try { localStorage.setItem(FMS_CREATED_KEY, JSON.stringify(map)); }
catch { /* storage unavailable */ }
}
function fmsCheckboxRow(labelText, checked, onchange) {
const row = document.createElement('label');
row.className = 'fms-set-row';
const span = document.createElement('span');
span.textContent = labelText;
const box = document.createElement('input');
box.type = 'checkbox';
box.checked = checked;
box.addEventListener('change', () => onchange(box.checked));
row.append(span, box);
return row;
}
// Valid entries persist immediately; invalid ones only mark the field.
function fmsInputRow(labelText, name, suffix, allowEmpty, parse) {
const row = document.createElement('label');
row.className = 'fms-set-row';
const span = document.createElement('span');
span.textContent = labelText;
const input = document.createElement('input');
input.type = 'text';
input.className = 'fms-input';
input.value = fmsReadSetting(name);
input.oninput = () => {
const v = input.value.trim();
const ok = (allowEmpty && v === '') || parse(v) !== null;
input.classList.toggle('fms-invalid', !ok);
input.classList.toggle('fms-valid', ok && v !== '');
if (ok) fmsWriteSetting(name, v);
};
const unit = document.createElement('span');
unit.className = 'fms-unit';
unit.textContent = suffix;
const wrap = document.createElement('span');
wrap.className = 'fms-input-wrap';
wrap.append(input, unit);
row.append(span, wrap);
return row;
}
/**
* A labelled dropdown row that persists on change.
*
* @param {string} labelText Row label.
* @param {string} name Setting key.
* @param {Array<[string, string]>} options [value, label] pairs.
* @returns {HTMLLabelElement} The row.
*/
function fmsSelectRow(labelText, name, options) {
const row = document.createElement('label');
row.className = 'fms-set-row';
const span = document.createElement('span');
span.textContent = labelText;
const select = document.createElement('select');
select.className = 'fms-select fms-set-select';
for (const [value, text] of options) select.append(new Option(text, value));
select.value = fmsReadSetting(name);
select.addEventListener('change', () => fmsWriteSetting(name, select.value));
row.append(span, select);
return row;
}
function fmsSectionHead(text) {
const head = document.createElement('div');
head.className = 'fms-settings-head';
head.textContent = text;
return head;
}
function fmsHr() {
const hr = document.createElement('hr');
hr.className = 'fms-hr';
return hr;
}
function fmsLegend() {
const div = document.createElement('div');
div.className = 'fms-legend';
div.textContent = 'Times are a number plus a unit: M minutes, H hours, D days, ' +
'W weeks, MO months. Fractions work (0.25H, 1.5D). Duration is plain hours. ' +
'Leave an alert blank for none. Alert time is a 24-hour clock (8:00) and ' +
'sets when all-day alerts fire. Slash dates are ambiguous, so pick the ' +
'reading your correspondents use. Snooze suggestions read the open message '
+ 'and add rows to Fastmail\'s own snooze menu, grouped under each date '
+ 'found: the morning of that date, the days either side of it, and the '
+ 'deadline lead time before it. Morning at sets where all of those land.';
return div;
}
/**
* The legend, behind a disclosure so the panel opens short.
*
* @returns {HTMLDetailsElement} A collapsed section holding the legend.
*/
function fmsHelpDetails() {
const details = document.createElement('details');
details.className = 'fms-help';
const summary = document.createElement('summary');
summary.textContent = 'Help / Details';
details.append(summary, fmsLegend());
return details;
}
// Comments on the public gist are the feedback channel. Kept in sync with
// GISTS in scripts/build.py.
const FMS_FEEDBACK_URL =
'https://gist.github.com/e-volusian/59acd6269c3738e2349cd14338a9069a';
function fmsFeedbackLink() {
const a = document.createElement('a');
a.className = 'fms-feedback';
a.href = FMS_FEEDBACK_URL;
a.target = '_blank';
a.rel = 'noopener noreferrer';
a.textContent = 'Feedback? Leave a gist comment';
return a;
}
function fmsSettingsPanel() {
const panel = document.createElement('div');
panel.className = 'fms-settings';
panel.append(
fmsSectionHead('Settings'), fmsHr(),
fmsCheckboxRow('Test mode', fmsTestModeEnabled(), fmsSetTestMode),
fmsCheckboxRow('Open View in new tab', fmsViewInNewTab(),
on => fmsWriteSetting('viewNewTab', on ? '1' : '0')),
fmsInputRow('Timed event duration', 'timedDur', 'hours', false, fmsParseHours),
fmsSelectRow('Read 10/11 as', 'dateOrder', [
[FMS_DATE_ORDER_MDY, 'Oct 11 (M/D)'],
[FMS_DATE_ORDER_DMY, '10 Nov (D/M)'],
]),
fmsSectionHead('Snooze'), fmsHr(),
fmsCheckboxRow('Suggest snooze times', fmsSnoozeEnabled(),
on => fmsWriteSetting('snoozeOn', on ? '1' : '0')),
fmsInputRow('Morning at', 'snoozeMorning', '24h', false, fmsParseClock),
fmsInputRow('Deadline lead time', 'snoozeLead', 'before', false, fmsParseOffset),
fmsSectionHead('Reminder'), fmsHr(),
fmsCheckboxRow('Alerts', fmsAlertsEnabled(),
on => fmsWriteSetting('alertsOn', on ? '1' : '0')),
fmsInputRow('All-day alert', 'alertAllDay', 'before', true, fmsParseOffset),
fmsInputRow('Timed alert', 'alertTimed', 'before', true, fmsParseOffset),
fmsInputRow('All-day alert time', 'alarmTime', '24h', false, fmsParseClock),
fmsHr(), fmsHelpDetails(), fmsFeedbackLink());
return panel;
}
const FMS_GEAR_SVG =
'<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">' +
'<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 ' +
'0 0 0 .12-.61l-1.92-3.32a.488.488 0 0 0-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36' +
'-2.54a.484.484 0 0 0-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62' +
'.94l-2.39-.96a.488.488 0 0 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09' +
'.63-.09.94s.02.64.07.94l-2.03 1.58a.49.49 0 0 0-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39' +
'-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c' +
'.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32a.49.49 0 0 0-.12-.61l-2.01' +
'-1.58zM12 15.6a3.6 3.6 0 1 1 0-7.2 3.6 3.6 0 0 1 0 7.2z"/></svg>';
// The gear stays visibly "pressed" while the panel is open: same button closes it.
function fmsSettingsButton() {
const wrap = document.createElement('span');
wrap.className = 'fms-settings-wrap';
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'fms-gear';
btn.title = 'Settings';
btn.setAttribute('aria-label', 'Settings');
btn.setAttribute('aria-expanded', 'false');
btn.innerHTML = FMS_GEAR_SVG;
let panel = null;
btn.addEventListener('click', () => {
if (panel) { panel.remove(); panel = null; }
else { panel = fmsSettingsPanel(); wrap.append(panel); }
btn.classList.toggle('is-open', !!panel);
btn.title = panel ? 'Click again to close settings' : 'Settings';
btn.setAttribute('aria-expanded', String(!!panel));
});
wrap.append(btn);
return wrap;
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
fmsParseOffset, fmsParseHours, fmsParseClock, fmsMinutesToIso,
fmsReadSetting, fmsWriteSetting, fmsAlertsEnabled, fmsViewInNewTab, fmsDateOrder,
fmsSnoozeEnabled,
fmsAlertOffset, fmsAlertsFor, fmsTimedDurationIso, fmsAlertLine,
fmsRememberCreated, fmsCreatedId, fmsForgetCreated,
};
}
const FMS_CSS = `
.fms-banner {
display: flex; flex-direction: column; gap: 6px;
margin: 8px 16px 10px; padding: 10px 14px;
border: 1px solid #c9d4e8; border-radius: 10px;
background: #eef3fc; color: #1a2b49; font-size: 13px;
}
.fms-row { display: flex; align-items: center; gap: 10px; flex-wrap: nowrap; }
.fms-cal { flex: 0 0 auto; display: inline-flex; }
.fms-cal svg { width: 34px; height: 34px; display: block; }
.fms-cal-body { fill: #fff; stroke: #b9c8e4; }
.fms-cal-day { fill: #1a2b49; }
.fms-date-label { font-weight: 600; flex: 1 1 auto; min-width: 0; }
/* A date heading inside Fastmail's snooze menu, on the menu's own row height
so the whole list keeps one rhythm. Qualified with li.v-MenuOption because
Fastmail sets "padding: 0 4px" on that class: a bare .fms-snooze-date ties
on specificity and loses, squeezing the row down to its icon. */
li.v-MenuOption.fms-snooze-date {
position: relative;
display: flex; align-items: center; gap: 7px;
padding: 9px 4px 5px; font-weight: 600;
}
/* Fastmail draws its section divider as a block ::after. In a flex row that
becomes a flex item sitting after the caret and eating its margins, which
knocks the caret out of line with the row above. Take it out of flow; it
still draws across the bottom of the row. */
li.v-MenuOption.fms-snooze-date.v-MenuOption--lastOfSection::after {
position: absolute; left: 0; right: 0; bottom: 0; margin: 0 12px;
}
.fms-snooze-date .fms-cal { flex: 0 0 auto; }
.fms-snooze-date .fms-cal svg { width: 17px; height: 17px; }
/* An expander is a row you click, so it takes the full row height. */
li.v-MenuOption.fms-snooze-more { cursor: pointer; padding: 7px 4px; }
/* A transparent overlay, so the whole row is the target without wrapping the
icon and label in a control. */
.fms-snooze-hit {
position: absolute; inset: 0; width: 100%;
border: 0; background: none; padding: 0; cursor: pointer;
}
.fms-snooze-hit:hover { background: currentColor; opacity: 0.06; }
.fms-snooze-caret {
margin-left: auto; flex: 0 0 auto;
border-top: 5px solid currentColor;
border-left: 4px solid transparent; border-right: 4px solid transparent;
opacity: 0.6; transition: transform 0.15s ease;
}
.fms-snooze-more.is-open .fms-snooze-caret { transform: rotate(180deg); }
.fms-select.fms-dates { flex: 1 1 auto; min-width: 0; width: auto; }
.fms-badge {
min-height: 24px; padding: 3px 10px;
display: inline-flex; align-items: center; justify-content: center;
border-radius: 999px; background: #ffdf70; color: #574400;
font-size: 11px; font-weight: 600; letter-spacing: 0.02em;
text-transform: uppercase; text-align: center; line-height: 1.15;
max-width: 90px; flex: 0 0 auto;
}
.fms-actions { display: flex; gap: 0; align-items: center; flex: 0 0 auto; }
.fms-btn {
height: 32px; padding: 0 14px; border: 1px solid #2f5bb7;
border-radius: 8px; background: #2f5bb7; color: #fff; cursor: pointer;
font-size: 13px; font-weight: 500; white-space: nowrap;
display: inline-flex; align-items: center; justify-content: center;
transition: background 0.15s ease;
}
.fms-btn:hover:not(:disabled) { background: #264d9f; }
.fms-btn--ghost { background: none; color: #2f5bb7; }
.fms-btn--ghost:hover:not(:disabled) { background: rgba(47, 91, 183, 0.08); }
.fms-btn:disabled { opacity: 0.55; cursor: default; }
.fms-apply { gap: 8px; font-weight: 600; }
.fms-apply .fms-arrow { font-size: 16px; line-height: 1; }
.fms-gear, .fms-close {
width: 36px; height: 36px;
display: inline-flex; align-items: center; justify-content: center;
background: none; border: none; border-radius: 10px;
color: #55658a; cursor: pointer; font-size: 22px; line-height: 1;
transition: background 0.15s ease, color 0.15s ease;
}
.fms-gear svg { width: 32px; height: 32px; display: block; transition: transform 0.2s ease; }
.fms-gear:hover, .fms-close:hover { background: rgba(47, 91, 183, 0.1); color: #1a2b49; }
.fms-gear.is-open { background: #2f5bb7; color: #fff; }
.fms-gear.is-open:hover { background: #264d9f; color: #fff; }
.fms-gear.is-open svg { transform: rotate(45deg); }
.fms-select {
height: 32px; padding: 0 30px 0 12px; font-size: 13px; color: #1a2b49;
border: 1px solid #b9c8e4; border-radius: 8px; background-color: #fff;
cursor: pointer; -webkit-appearance: none; appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%2355658a' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 10px center;
transition: border-color 0.15s ease;
}
.fms-select:hover { border-color: #2f5bb7; }
.fms-select:focus-visible, .fms-btn:focus-visible,
.fms-gear:focus-visible, .fms-close:focus-visible {
outline: 2px solid #2f5bb7; outline-offset: 1px;
}
.fms-settings-wrap { position: relative; display: inline-flex; }
.fms-settings {
position: absolute; top: 40px; right: 0; z-index: 2147483000;
display: flex; flex-direction: column; gap: 8px; width: 250px;
padding: 12px 14px; border: 1px solid #c9d4e8; border-radius: 10px;
background: #fff; color: #1a2b49;
box-shadow: 0 6px 20px rgba(15, 23, 42, 0.2);
}
.fms-settings-head { font-weight: 600; font-size: 12px; }
.fms-set-select { height: 28px; font-size: 12px; padding: 0 24px 0 8px; }
.fms-hr { border: none; border-top: 1px solid #dfe6f2; margin: 0; }
.fms-set-row {
display: flex; align-items: center; justify-content: space-between;
gap: 8px; font-size: 12px; cursor: pointer;
}
.fms-input-wrap { display: inline-flex; align-items: center; gap: 6px; }
.fms-input {
height: 28px; width: 60px; padding: 0 8px; font-size: 12px; color: #1a2b49;
border: 1px solid #b9c8e4; border-radius: 8px; background: #fff;
text-align: right; transition: border-color 0.15s ease, background 0.15s ease;
}
.fms-input:focus-visible { outline: 2px solid #2f5bb7; outline-offset: 1px; }
.fms-input.fms-valid { border-color: #2e8b57; }
.fms-input.fms-invalid { border-color: #c0392b; background: #fdecea; }
.fms-unit { font-size: 12px; color: #55658a; }
.fms-legend { font-size: 11px; color: #55658a; line-height: 1.5; }
/* The legend, folded away behind a disclosure. The triangle is drawn rather
than left to the browser so the summary reads as expandable everywhere. */
.fms-help > summary {
display: flex; align-items: center; gap: 6px;
padding: 2px 0; cursor: pointer; user-select: none;
font-size: 11px; font-weight: 600; color: #2f5bb7; list-style: none;
}
.fms-help > summary::-webkit-details-marker { display: none; }
.fms-help > summary::before {
content: ''; flex: 0 0 auto;
border-left: 5px solid currentColor;
border-top: 4px solid transparent; border-bottom: 4px solid transparent;
transition: transform 0.15s ease;
}
.fms-help[open] > summary::before { transform: rotate(90deg); }
.fms-help > summary:hover { text-decoration: underline; }
.fms-help > .fms-legend { margin-top: 5px; }
.fms-feedback { font-size: 11px; color: #2f5bb7; text-decoration: none; }
.fms-feedback:hover { text-decoration: underline; }
.fms-modal-overlay {
position: fixed; inset: 0; z-index: 2147483000;
background: rgba(15, 23, 42, 0.45);
display: flex; align-items: center; justify-content: center;
}
.fms-modal {
background: #fff; color: #1a2b49; border: 1px solid #c9d4e8;
border-radius: 10px; padding: 16px 20px; width: 90%; max-width: 420px;
max-height: 80vh; overflow: auto; font-size: 13px;
box-shadow: 0 8px 30px rgba(15, 23, 42, 0.3);
}
.fms-modal h3 { margin: 0 0 10px; font-size: 14px; }
.fms-modal-fields {
display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; margin: 0 0 12px;
}
.fms-modal-fields dt { font-weight: 600; }
.fms-modal-fields dd { margin: 0; overflow-wrap: anywhere; }
@media (prefers-color-scheme: dark) {
.fms-banner { background: #1e2738; border-color: #38445c; color: #dbe4f5; }
.fms-cal-body { fill: #2a3550; stroke: #38445c; }
.fms-cal-day { fill: #dbe4f5; }
.fms-badge { background: #d9b23c; color: #241d00; }
.fms-btn--ghost { color: #9db8e8; border-color: #4a68a8; }
.fms-btn--ghost:hover:not(:disabled) { background: rgba(157, 184, 232, 0.12); }
.fms-gear, .fms-close { color: #94a3c4; }
.fms-gear:hover, .fms-close:hover { background: rgba(157, 184, 232, 0.12); color: #dbe4f5; }
.fms-gear.is-open, .fms-gear.is-open:hover { background: #9db8e8; color: #1e2738; }
.fms-hr { border-top-color: #38445c; }
.fms-input { background: #2a3550; border-color: #38445c; color: #dbe4f5; }
.fms-input.fms-invalid { background: #472a2e; border-color: #d9756b; }
.fms-input.fms-valid { border-color: #58b58b; }
.fms-unit, .fms-legend { color: #94a3c4; }
.fms-feedback, .fms-help > summary { color: #9db8e8; }
.fms-settings { background: #1e2738; border-color: #38445c; color: #dbe4f5; }
.fms-select {
background-color: #2a3550; border-color: #38445c; color: #dbe4f5;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%2394a3c4' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
}
.fms-select:hover { border-color: #9db8e8; }
.fms-modal { background: #1e2738; border-color: #38445c; color: #dbe4f5; }
}
`;
/**
* Inject the banner stylesheet, once.
*
* Side effect: appends a `<style id="fms-styles">` to `document.head`.
*/
function fmsInjectStyles() {
if (document.querySelector('#fms-styles')) return;
const style = document.createElement('style');
style.id = 'fms-styles';
style.textContent = FMS_CSS;
document.head.appendChild(style);
}
/**
* @param {{hours: number, minutes: number}} time Clock time.
* @returns {string} The time in the viewer's locale format.
*/
function fmsFormatTime(time) {
return new Date(2000, 0, 1, time.hours, time.minutes)
.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
}
/**
* Dropdown text: only the resolved date, never the source phrase -- the phrase
* is exposed as a tooltip instead, so a misread date can still be spotted.
*
* @param {object} hit A detected hit.
* @returns {string} Human-readable date, plus time when the hit has one.
*/
function fmsWhenText(hit) {
return fmsFormatDate(hit.date) + (hit.time ? ', ' + fmsFormatTime(hit.time) : '');
}
/**
* @param {object} hit A detected hit.
* @returns {string} Tooltip text naming the phrase the date came from.
*/
function fmsWhyText(hit) {
return 'Matched "' + hit.phrase + '" in this message';
}
/**
* @param {object} hit A detected hit.
* @param {string} subject The message subject, used as the event title.
* @param {string} messageKey Identity from `fmsMessageKey`.
* @returns {object} An event spec for `fmsBuildEvent`.
*/
function fmsAddSpec(hit, subject, messageKey) {
return {
title: subject,
description: 'Created from email "' + subject + '" (matched "' + hit.phrase +
'")\n\n' + location.href,
url: location.href,
uid: fmsHitUid(hit, messageKey),
date: hit.date, time: hit.time,
};
}
/**
* Deep-links to the created event; the `#fms-edit` marker makes the script on
* the calendar page open the event's Edit dialog directly.
*
* @param {Date} date The event's day.
* @param {string} eventId The created event's id.
* @returns {HTMLButtonElement} The View button.
*/
function fmsViewButton(date, eventId) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'fms-btn fms-btn--ghost fms-view';
btn.textContent = 'View';
btn.title = 'Open in Calendar';
btn.addEventListener('click', () => {
const url = fmsCalendarDayUrl(location, date, eventId) + '#fms-edit';
// window.name survives the SPA's URL rewriting; the hash is a fallback.
if (fmsViewInNewTab()) window.open(url, 'fms-edit');
else { window.name = 'fms-edit'; location.href = url; }
});
return btn;
}
/**
* A stable key for "this message already produced this event".
*
* @param {object} hit A detected hit.
* @param {string} messageKey Identity from `fmsMessageKey`.
* @returns {string} The lookup key for the created-event memory.
*/
function fmsHitKey(hit, messageKey) {
return messageKey + '|' + fmsToLocalIso(hit.date) +
(hit.time ? '@' + hit.time.hours + ':' + hit.time.minutes : '');
}
/**
* The marker uid stamped on events we create, from the same identity as
* {@link fmsHitKey}. Changing this derivation orphans every event already
* created with the old form — they would all show "Add" again.
*
* @param {object} hit A detected hit.
* @param {string} messageKey Identity from `fmsMessageKey`.
* @returns {string} A JSCalendar uid; kept to iCalendar-safe characters.
*/
function fmsHitUid(hit, messageKey) {
return 'fms-' + fmsHitKey(hit, messageKey).replace(/[^A-Za-z0-9@:.|-]/g, '_');
}
/**
* Draw the Add/Added state for a known event id (or its absence).
*
* @param {HTMLButtonElement} btn The Add button.
* @param {object} hit The currently selected hit.
* @param {?string} id The created event's id, or null for none.
*/
function fmsPaintState(btn, hit, id) {
const old = btn.parentElement && btn.parentElement.querySelector('.fms-view');
if (old) old.remove();
if (!id) { fmsResetApply(btn); return; }
btn.disabled = true;
fmsApplyLabel(btn, 'Added ✓', false);
btn.title = 'Already added';
btn.after(fmsViewButton(hit.date, id));
}
/**
* Reflect whether the selected date already has a created event.
*
* The per-browser memory paints immediately; the calendar is the source of
* truth, so the day is then queried for our marker uid and the state (and the
* memory) corrected to match. A failed lookup keeps the painted state.
*
* @param {HTMLButtonElement} btn The Add button.
* @param {object} hit The currently selected hit.
* @param {string} messageKey Identity from `fmsMessageKey`.
*/
function fmsApplyState(btn, hit, messageKey) {
const key = fmsHitKey(hit, messageKey);
btn.dataset.fmsKey = key;
fmsPaintState(btn, hit, fmsCreatedId(key));
fmsFindEventByUid(fmsHitUid(hit, messageKey), hit.date).then(id => {
// The user may have switched dates while the query ran.
if (btn.dataset.fmsKey !== key) return;
if (id) fmsRememberCreated(key, id);
else fmsForgetCreated(key);
fmsPaintState(btn, hit, id);
}).catch(() => {});
}
/**
* @param {Array<object>} hits Detected hits, in date order.
* @returns {HTMLSelectElement} A labelled picker; each option carries the
* phrase it came from as a tooltip.
*/
function fmsDateSelect(hits) {
const sel = document.createElement('select');
sel.className = 'fms-select fms-dates';
sel.setAttribute('aria-label', 'Detected date');
hits.forEach((h, i) => {
const opt = new Option(fmsWhenText(h), String(i));
opt.title = fmsWhyText(h);
sel.append(opt);
});
return sel;
}
/**
* @param {HTMLButtonElement} btn Button to relabel.
* @param {string} text Visible label.
* @param {boolean} arrow Append the decorative arrow.
*/
function fmsApplyLabel(btn, text, arrow) {
btn.replaceChildren(document.createTextNode(text));
if (arrow) {
const span = document.createElement('span');
span.className = 'fms-arrow';
span.setAttribute('aria-hidden', 'true');
span.textContent = '→';
btn.append(span);
}
}
/** @param {HTMLButtonElement} btn Button to return to its unused state. */
function fmsResetApply(btn) {
btn.disabled = false;
fmsApplyLabel(btn, 'Add', true);
btn.title = 'Add to calendar';
}
/**
* Create the event and reflect the result on the button.
*
* @param {HTMLButtonElement} btn The Add button.
* @param {object} spec Event spec.
* @param {string} key Created-event memory key.
* @returns {Promise<void>} Resolves once the button reflects the outcome.
*/
async function fmsApplyCreate(btn, spec, key) {
btn.disabled = true;
fmsApplyLabel(btn, '…', false);
const old = btn.parentElement.querySelector('.fms-view');
if (old) old.remove();
try {
const id = await fmsCreateEvent(spec);
fmsRememberCreated(key, id);
fmsApplyLabel(btn, 'Added ✓', false);
btn.title = 'Added';
btn.after(fmsViewButton(spec.date, id));
} catch (e) {
fmsResetApply(btn);
btn.title = 'Failed — retry';
btn.setAttribute('aria-label', 'Add to calendar, previous attempt failed');
console.info('[fms] add failed:', e.message);
}
}
// Test mode: build the event and show it in a modal; nothing is created.
/**
* @param {HTMLButtonElement} btn The Add button.
* @param {function(): object} makeEvent Builds the spec to preview.
* @returns {Promise<void>}
*/
async function fmsPreviewAction(btn, makeEvent) {
btn.disabled = true;
fmsApplyLabel(btn, '…', false);
try {
const { event, calendarName } = await fmsPreviewEvent(makeEvent());
document.body.appendChild(fmsPreviewModal(event, calendarName));
} catch (e) {
console.info('[fms] preview failed:', e.message);
}
fmsResetApply(btn);
}
/**
* @param {function(): object} getHit Reads the currently selected hit.
* @param {string} subject Message subject, used as the event title.
* @param {string} messageKey Identity from `fmsMessageKey`.
* @returns {HTMLButtonElement} The Add button.
*/
function fmsApplyButton(getHit, subject, messageKey) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'fms-btn fms-apply';
fmsResetApply(btn);
btn.addEventListener('click', () => {
const hit = getHit();
const spec = fmsAddSpec(hit, subject, messageKey);
if (fmsTestModeEnabled()) fmsPreviewAction(btn, () => spec);
else fmsApplyCreate(btn, spec, fmsHitKey(hit, messageKey));
});
return btn;
}
/**
* @param {object} event The built JSCalendar event.
* @param {string} calendarName Target calendar's name.
* @returns {HTMLDListElement} The preview field list.
*/
function fmsModalFields(event, calendarName) {
const dl = document.createElement('dl');
dl.className = 'fms-modal-fields';
for (const [name, value] of fmsPreviewLines(event, calendarName)) {
const dt = document.createElement('dt');
dt.textContent = name;
const dd = document.createElement('dd');
dd.textContent = value;
dl.append(dt, dd);
}
return dl;
}
/**
* Build the test-mode preview dialog.
*
* Focus moves into the dialog on open and returns to the trigger on close;
* Escape and a backdrop click both dismiss it.
*
* @param {object} event The built JSCalendar event.
* @param {string} calendarName Target calendar's name.
* @returns {HTMLElement} The overlay, ready to append.
*/
function fmsPreviewModal(event, calendarName) {
const previousFocus = document.activeElement;
const overlay = document.createElement('div');
overlay.className = 'fms-modal-overlay';
const box = document.createElement('div');
box.className = 'fms-modal';
box.setAttribute('role', 'dialog');
box.setAttribute('aria-modal', 'true');
box.setAttribute('aria-label', 'Test mode preview');
const head = document.createElement('h3');
head.textContent = 'Test mode — would have created:';
const close = document.createElement('button');
close.type = 'button';
close.className = 'fms-btn';
close.textContent = 'Close';
const dismiss = () => {
overlay.remove();
if (previousFocus && previousFocus.isConnected) previousFocus.focus();
};
close.addEventListener('click', dismiss);
overlay.addEventListener('click', e => { if (e.target === overlay) dismiss(); });
overlay.addEventListener('keydown', e => {
if (e.key === 'Escape') { e.stopPropagation(); dismiss(); }
// Only one control, so trapping Tab means keeping focus on it.
else if (e.key === 'Tab') { e.preventDefault(); close.focus(); }
});
box.append(head, fmsModalFields(event, calendarName), close);
overlay.append(box);
// Deferred so the caller can append before focus moves.
setTimeout(() => close.focus(), 0);
return overlay;
}
/** @returns {HTMLElement} The settings gear and dismiss button. */
function fmsRightControls() {
const actions = document.createElement('span');
actions.className = 'fms-actions';
const close = document.createElement('button');
close.type = 'button';
close.className = 'fms-close';
close.title = 'Dismiss';
close.setAttribute('aria-label', 'Dismiss this suggestion');
close.textContent = '✕';
close.addEventListener('click', () => close.closest('.fms-banner').remove());
actions.append(fmsSettingsButton(), close);
return actions;
}
/**
* A little tear-off calendar showing the target date itself.
*
* @param {Date} date Date to draw.
* @returns {HTMLElement} A decorative, aria-hidden icon.
*/
function fmsCalIcon(date) {
const month = date.toLocaleDateString(undefined, { month: 'short' })
.toUpperCase().replace(/[<&]/g, '');
const span = document.createElement('span');
span.className = 'fms-cal';
span.innerHTML =
'<svg viewBox="0 0 32 32" aria-hidden="true">' +
'<rect x="1" y="3" width="30" height="28" rx="6" class="fms-cal-body" stroke-width="1.5"/>' +
'<path d="M1 13 V9 q0-6 6-6 h18 q6 0 6 6 v4 z" fill="#d5453c"/>' +
'<text x="16" y="10.5" text-anchor="middle" font-size="7.5" font-weight="700" ' +
'fill="#fff" font-family="system-ui, sans-serif" letter-spacing="0.5">' + month + '</text>' +
'<text x="16" y="27" text-anchor="middle" font-size="13.5" font-weight="700" ' +
'class="fms-cal-day" font-family="system-ui, sans-serif">' + date.getDate() + '</text>' +
'</svg>';
return span;
}
/**
* @param {object} hit A detected hit.
* @returns {HTMLElement} A badge reading "Timed event" or "All day".
*/
function fmsTypeBadge(hit) {
const badge = document.createElement('span');
badge.className = 'fms-badge';
badge.textContent = hit.time ? 'Timed event' : 'All day';
return badge;
}
/**
* A dropdown only when there is actually something to choose between.
*
* @param {Array<object>} hits Detected hits.
* @returns {HTMLElement} A `<select>`, or a label for a single hit.
*/
function fmsDateControl(hits) {
if (hits.length > 1) return fmsDateSelect(hits);
const span = document.createElement('span');
span.className = 'fms-date-label';
span.textContent = fmsWhenText(hits[0]);
span.title = fmsWhyText(hits[0]);
return span;
}
/**
* Build the banner: one condensed row of 📅 [date] [badge] [Add →] [⚙][✕].
*
* @param {Array<object>} hits Detected hits, in date order.
* @param {string} subject Message subject, used as the event title.
* @param {string} messageKey Identity from `fmsMessageKey`, so two messages in
* one thread do not share created-event state.
* @returns {HTMLElement} The banner element.
*/
function fmsBanner(hits, subject, messageKey) {
const el = document.createElement('div');
el.className = 'fms-banner';
const row = document.createElement('div');
row.className = 'fms-row';
const dateCtl = fmsDateControl(hits);
const getHit = () => (hits.length > 1 ? hits[+dateCtl.value] : hits[0]);
let icon = fmsCalIcon(hits[0].date);
let badge = fmsTypeBadge(hits[0]);
const apply = fmsApplyButton(getHit, subject, messageKey);
if (hits.length > 1) {
dateCtl.addEventListener('change', () => {
const hit = getHit();
const nextIcon = fmsCalIcon(hit.date);
icon.replaceWith(nextIcon);
icon = nextIcon;
const next = fmsTypeBadge(hit);
badge.replaceWith(next);
badge = next;
fmsApplyState(apply, hit, messageKey);
});
}
row.append(icon, dateCtl, badge, apply, fmsRightControls());
el.append(row);
fmsApplyState(apply, hits[0], messageKey);
return el;
}
/** @returns {string} The open message's subject, from the DOM or the title. */
function fmsFindSubject() {
const el = document.querySelector('.v-Thread .u-text-xl');
if (el) return el.textContent.trim();
return document.title.replace(/ \| Fastmail$/, '').replace(/^.* \u2013 /, '');
}
/**
* @param {Element} card A `.v-MessageCard` element.
* @returns {Date} The message's sent date, or now when unreadable.
*/
function fmsSentDate(card) {
const timeEl = card.querySelector('.v-MessageCard-time');
return fmsParseSentDate(timeEl && timeEl.getAttribute('title')) || new Date();
}
/**
* A stable identity for one message.
*
* The URL only identifies the thread, so a thread with several expanded cards
* would otherwise share one identity and cross-contaminate their "Added"
* state. The sent date separates them.
*
* @param {Date} sent The message's sent date.
* @returns {string} An identity string for {@link fmsHitKey}.
*/
function fmsMessageKey(sent) {
const seg = location.pathname.split('/').filter(Boolean).pop() || '';
return seg + '@' + sent.getTime();
}
/**
* Add a banner to one message card, at most once.
*
* Side effects: marks the card with `data-fmsScanned` and inserts the banner.
* A re-render clears the marker along with the node.
*
* @param {Element} card A `.v-MessageCard` element.
*/
function fmsScanCard(card) {
if (card.dataset.fmsScanned) return;
const bodyEl = card.querySelector('.v-Message');
if (!bodyEl) return;
card.dataset.fmsScanned = '1';
const subject = fmsFindSubject();
const sent = fmsSentDate(card);
const text = subject + '\n' + bodyEl.innerText;
const hits = fmsDetectAll(text, sent, { dateOrder: fmsDateOrder() });
if (hits.length) {
card.insertBefore(fmsBanner(hits, subject, fmsMessageKey(sent)), bodyEl);
}
}
/** Scan every expanded message card on the page. */
function fmsScanAll() {
document.querySelectorAll('.v-MessageCard').forEach(fmsScanCard);
}
// How many times, and how often, to look for the Edit button after a View
// deep-link lands on the calendar page.
const FMS_AUTO_EDIT_TRIES = 40;
const FMS_AUTO_EDIT_INTERVAL_MS = 300;
/**
* View's `#fms-edit` marker: on the calendar page, open the event's Edit
* dialog as soon as its detail popup has rendered.
*
* @param {number} tries Attempts remaining.
*/
function fmsAutoEditTick(tries) {
const btn = [...document.querySelectorAll('button')]
.find(b => b.textContent.trim() === 'Edit');
if (btn) {
btn.click();
history.replaceState(null, '', location.pathname + location.search);
return;
}
if (tries > 0) setTimeout(() => fmsAutoEditTick(tries - 1), FMS_AUTO_EDIT_INTERVAL_MS);
}
/** Start the auto-edit poll when the page was opened by a View button. */
function fmsMaybeAutoEdit() {
if (window.name !== 'fms-edit' && location.hash !== '#fms-edit') return;
window.name = '';
fmsAutoEditTick(FMS_AUTO_EDIT_TRIES);
}
// Extra rows in Fastmail's snooze menu, built from the open message.
//
// The menu is one of Fastmail's own views. Rather than reimplement snoozing --
// which spans threads, memos, labels-vs-folders mode, unread keywords and
// creating the Snoozed folder -- an injected row calls the very method the
// native rows call, `didSelect(date)`, on that same view. Everything after
// that is Fastmail's code, unchanged.
// Fastmail's class names, all load-bearing. If a redesign renames one, the
// rows stop appearing and the native menu is left exactly as it was.
const FMS_MENU_SEL = '.v-FutureTimeMenu';
const FMS_MENU_OPTION_SEL = 'li.v-MenuOption';
const FMS_MENU_SECTION_CLASS = 'v-MenuOption--lastOfSection';
// The flex row inside a native option's button, holding its label and time.
// Only used when a native row cannot be copied; see {@link fmsPlainRowLabel}.
const FMS_MENU_LABEL_CLASS = 'u-flex u-space-x-2 u-whitespace-nowrap';
// Marks rows this script added, so a re-render is not decorated twice.
const FMS_MENU_MARK = 'data-fms-snooze';
// Our own class, so a group heading can be styled without a native one to copy.
const FMS_MENU_HEADING_CLASS = 'fms-snooze-date';
// A date heading that expands, its caret, and the transparent button that
// takes the click. Ours, because no native option behaves this way.
const FMS_MENU_EXPANDER_CLASS = 'fms-snooze-more';
const FMS_MENU_CARET_CLASS = 'fms-snooze-caret';
const FMS_MENU_EXPANDER_HIT_CLASS = 'fms-snooze-hit';
const FMS_MENU_OPEN_CLASS = 'is-open';
// The same view type backs compose's "Schedule send". Only the snooze menu
// leaves `lastCustomKey` at its default.
const FMS_SNOOZE_CUSTOM_KEY = 'lastUsedFutureDelta';
// How many frames to wait for Fastmail to register the view for a menu that
// has only just been inserted.
const FMS_MENU_VIEW_TRIES = 3;
/**
* The page's own `window`, which is not always the one this script sees.
*
* Fastmail's CSP allows no inline script, no `blob:` and no `eval`, so on
* Firefox a userscript manager cannot inject into the page context and runs
* the script in a content-script sandbox instead. There `window.FastMail` is
* absent and the page's globals live behind `wrappedJSObject`. Chrome's
* managers inject through an extension API that page CSP does not govern, so
* the first branch is the only one they ever take.
*
* Tested by capability rather than by browser, so this follows either engine
* if its injection changes.
*
* @returns {Window} The window whose globals Fastmail defined.
*/
function fmsPageWindow() {
const usable = w => !!(w && w.FastMail
&& typeof w.FastMail.getViewFromNode === 'function');
if (usable(window)) return window;
const unwrapped = window.wrappedJSObject;
return usable(unwrapped) ? unwrapped : window;
}
/**
* Fastmail's in-page handle.
*
* @returns {?object} The handle, or null when the page has not exposed one.
*/
function fmsPageApi() {
const FM = fmsPageWindow().FastMail;
return (FM && typeof FM.getViewFromNode === 'function') ? FM : null;
}
/**
* A date the page will accept.
*
* `didSelect` is Fastmail's own code, so from a sandbox it must be handed a
* `Date` built by the page's constructor; one built here crosses the boundary
* as a foreign object. In the page context this is the same constructor and
* the copy costs nothing.
*
* @param {Date} date A wake-up time.
* @returns {Date} The same instant, owned by the page.
*/
function fmsPageDate(date) {
const PageDate = fmsPageWindow().Date;
if (PageDate === Date) return date;
try { return new PageDate(date.getTime()); }
catch { return date; }
}
/**
* @param {Element} el A menu element.
* @returns {?object} The view that owns it, or null.
*/
function fmsViewFor(el) {
const FM = fmsPageApi();
if (!FM) return null;
try { return FM.getViewFromNode(el) || null; }
catch { return null; }
}
/**
* A snooze menu is a future-time menu that is not compose's schedule-send.
*
* @param {?object} view The view behind the menu element.
* @returns {boolean} Whether to decorate it.
*/
function fmsIsSnoozeMenu(view) {
if (!view || typeof view.didSelect !== 'function') return false;
if (typeof view.customDateChosen !== 'function') return false;
let key;
try { key = view.get('lastCustomKey'); }
catch { return false; }
return key === FMS_SNOOZE_CUSTOM_KEY;
}
/**
* Fastmail's remembered custom snooze offset, so a suggestion landing on the
* menu's "last custom" row can be dropped instead of duplicating it.
*
* @returns {?number} The offset in ms from the end of today, or null.
*/
function fmsLastCustomDelta() {
const FM = fmsPageApi();
if (!FM || !FM.localPrefs || typeof FM.localPrefs.get !== 'function') return null;
let v;
try { v = FM.localPrefs.get(FMS_SNOOZE_CUSTOM_KEY); }
catch { return null; }
return typeof v === 'number' ? v : null;
}
/**
* The message a snooze would apply to: the one on screen.
*
* @returns {?{text: string, sent: Date}} Its text and sent date, or null when
* no single message is open to read.
*/
function fmsOpenMessage() {
const cards = [...document.querySelectorAll('.v-MessageCard')]
.filter(card => card.querySelector('.v-Message'));
if (cards.length !== 1) return null;
const card = cards[0];
return {
text: fmsFindSubject() + '\n' + card.querySelector('.v-Message').innerText,
sent: fmsSentDate(card),
};
}
/**
* Whether the menu is writing 12- or 24-hour times, read off its own rows.
*
* The choice is Fastmail's setting, not the browser's locale, and Fastmail
* does not expose it. Its built-in options are already rendered in it, so the
* rows we sit beside are the reliable place to learn it from.
*
* @param {Array<Element>} options The menu's native `li.v-MenuOption`s.
* @returns {?boolean} True for 12-hour, false for 24-hour, null if no row
* shows a time and the browser's own locale should decide.
*/
function fmsMenuHour12(options) {
for (const li of options) {
const p = [...li.querySelectorAll('p')].pop();
const text = p && p.textContent;
if (!text || !/\d:\d{2}/.test(text)) continue;
return /[ap]\.?\s?m\.?/i.test(text);
}
return null;
}
/**
* The right-hand label, in whichever clock the menu's own rows use.
*
* @param {Date} d A wake-up time.
* @param {Date} now Current time.
* @param {?boolean} [hour12] From {@link fmsMenuHour12}.
* @returns {string} e.g. "Sat, Oct 24 08:00" or "Sat, Oct 24 8:00 AM".
*/
function fmsSnoozeWhenText(d, now, hour12) {
const shape = { hour: hour12 === false ? '2-digit' : 'numeric', minute: '2-digit' };
if (hour12 === true || hour12 === false) shape.hour12 = hour12;
const time = d.toLocaleTimeString(undefined, shape);
const sameWeek = d - now < 7 * 86400000;
const day = d.toLocaleDateString(undefined,
sameWeek ? { weekday: 'short' } : { weekday: 'short', month: 'short', day: 'numeric' });
return day + ' ' + time;
}
/**
* Copy a native row's label markup, filled in with this suggestion's text.
*
* Copying rather than rebuilding keeps every wrapper Fastmail nests inside the
* button. One of them is load-bearing: the label sits in a stretching
* `<span class="label">`, and without it the right-hand time sits against the
* label text rather than at the menu's edge, so the row fails to line up with
* the native ones above it.
*
* @param {?Element} modelBtn A native row's button, or null.
* @param {string} why Left-hand text.
* @param {string} when Right-hand text.
* @returns {?Element} The filled copy, or null when the model is shaped
* differently and so has nothing to copy.
*/
function fmsCloneRowLabel(modelBtn, why, when) {
const modelLabel = modelBtn && modelBtn.firstElementChild;
if (!modelLabel || modelLabel.querySelectorAll('p').length !== 2) return null;
const label = modelLabel.cloneNode(true);
const [left, right] = label.querySelectorAll('p');
left.textContent = why;
right.textContent = when;
return label;
}
/**
* The label to fall back on when no native row can be copied: the same shape,
* with the classes Fastmail used when this was written.
*
* @param {string} why Left-hand text.
* @param {string} when Right-hand text.
* @returns {HTMLDivElement} The label row.
*/
function fmsPlainRowLabel(why, when) {
const label = document.createElement('div');
label.className = FMS_MENU_LABEL_CLASS;
const left = document.createElement('p');
left.className = 'u-flex-grow';
left.textContent = why;
const right = document.createElement('p');
right.className = 'u-flex-none u-color-unimportant';
right.textContent = when;
label.append(left, right);
return label;
}
/**
* Build one menu row, borrowing the native row's classes so it inherits the
* menu's styling, spacing and dark mode.
*
* @param {Element} model A native `li.v-MenuOption` to copy classes from.
* @param {{date: Date, why: string}} hit The suggestion.
* @param {Date} now Current time.
* @param {function} onPick Called with the date when the row is chosen.
* @param {?boolean} [hour12] From {@link fmsMenuHour12}.
* @returns {HTMLLIElement} The row.
*/
function fmsSnoozeRow(model, hit, now, onPick, hour12) {
const li = document.createElement('li');
li.className = model.className.replace(FMS_MENU_SECTION_CLASS, '').trim();
li.setAttribute(FMS_MENU_MARK, '1');
const btn = document.createElement('button');
const modelBtn = model.querySelector('button');
btn.type = 'button';
if (modelBtn) btn.className = modelBtn.className;
btn.title = hit.why;
const when = fmsSnoozeWhenText(hit.date, now, hour12);
btn.append(fmsCloneRowLabel(modelBtn, hit.why, when)
|| fmsPlainRowLabel(hit.why, when));
btn.addEventListener('click', event => {
event.preventDefault();
event.stopPropagation();
onPick(hit.date);
});
li.append(btn);
return li;
}
/**
* Hand a date to the menu's own selection handler and close the popover, the
* same two steps a native row performs.
*
* @param {object} view The snooze menu's view.
* @param {Date} date The chosen wake-up time.
*/
function fmsSnoozePick(view, date) {
try {
view.didSelect(fmsPageDate(date));
} catch (e) {
console.warn('[fms] snooze failed', e);
return;
}
try { view.hide(); }
catch { /* the menu closes itself on the next interaction */ }
}
/**
* A group's heading: the date its options are built from, written out beside
* the same tear-off calendar the Smart Schedule banner uses.
*
* @param {Element} model A native `li.v-MenuOption` to copy classes from.
* @param {{date: Date, label: string}} group The group being introduced.
* @returns {HTMLLIElement} A non-interactive row.
*/
function fmsSnoozeHeadingRow(model, group) {
const li = document.createElement('li');
li.className = model.className.replace(FMS_MENU_SECTION_CLASS, '').trim();
li.classList.add(FMS_MENU_HEADING_CLASS);
li.setAttribute(FMS_MENU_MARK, '1');
const text = document.createElement('span');
text.textContent = group.label;
li.append(fmsCalIcon(group.date), text);
return li;
}
/**
* Close our block with Fastmail's section divider, below whichever of our rows
* is currently last. Expanding a date inserts rows after it, so which row ends
* the block changes as the menu is used.
*
* @param {Element} menu The snooze menu.
*/
function fmsMarkSectionEnd(menu) {
const ours = [...menu.querySelectorAll('li[' + FMS_MENU_MARK + ']')]
.filter(li => !li.hidden);
for (const li of ours) li.classList.remove(FMS_MENU_SECTION_CLASS);
if (ours.length) ours[ours.length - 1].classList.add(FMS_MENU_SECTION_CLASS);
}
/**
* A collapsed row for a date, which expands its options in place.
*
* Fastmail's own submenu would be the natural fit, but building one means
* handing `ButtonView` a `{target, method}` pair for Fastmail to call back
* into. A content-script function cannot be called from the page context, so
* on Firefox that is unreachable. Our own rows and our own listeners work in
* both, so the options are revealed in the list instead of in a flyout.
*
* @param {Element} model A native `li.v-MenuOption` to copy classes from.
* @param {{date: Date, label: string}} group The date being folded.
* @param {function(): Array<HTMLLIElement>} buildRows Makes the option rows,
* called once, the first time the row is expanded.
* @returns {HTMLLIElement} The collapsed row.
*/
function fmsSnoozeExpanderRow(model, group, buildRows) {
const li = fmsSnoozeHeadingRow(model, group);
li.classList.add(FMS_MENU_EXPANDER_CLASS);
const caret = document.createElement('span');
caret.className = FMS_MENU_CARET_CLASS;
caret.setAttribute('aria-hidden', 'true');
li.append(caret);
const button = document.createElement('button');
button.type = 'button';
button.className = FMS_MENU_EXPANDER_HIT_CLASS;
button.setAttribute('aria-expanded', 'false');
button.setAttribute('aria-label', group.label);
li.append(button);
let rows = null;
button.addEventListener('click', event => {
// Fastmail's menu closes on a click it considers a selection; this is a
// disclosure, so the event stops here.
event.preventDefault();
event.stopPropagation();
if (!rows) {
rows = buildRows();
li.after(...rows);
}
const open = li.classList.toggle(FMS_MENU_OPEN_CLASS);
button.setAttribute('aria-expanded', open ? 'true' : 'false');
for (const row of rows) row.hidden = !open;
const menu = li.closest(FMS_MENU_SEL);
if (menu) fmsMarkSectionEnd(menu);
});
return li;
}
/**
* Add suggestions to one snooze menu, at most once.
*
* Each date found becomes a heading followed by its wake-up times, and every
* group is closed with Fastmail's own section divider so the groups read
* apart from each other and from the menu's built-in options above them.
*
* @param {Element} menu A `.v-FutureTimeMenu` element.
* @param {object} view Its view.
*/
function fmsDecorateSnoozeMenu(menu, view) {
const options = [...menu.querySelectorAll(FMS_MENU_OPTION_SEL)];
// The last option is "Choose a date and time"; ours go above it, and the
// one before it is the model for their styling.
const custom = options[options.length - 1];
const model = options.find(o => o !== custom) || custom;
if (!custom || !model) return;
const message = fmsOpenMessage();
if (!message) return;
const now = new Date();
const groups = fmsSnoozeGroups(message.text, message.sent, now,
Object.assign(fmsSnoozeOptions(), {
dateOrder: fmsDateOrder(),
native: fmsSnoozeNativeTimes(now, fmsLastCustomDelta()),
}));
if (!groups.length) return;
const hour12 = fmsMenuHour12(options);
const pick = date => fmsSnoozePick(view, date);
const optionRows = group => group.options.map(hit =>
fmsSnoozeRow(model, hit, now, pick, hour12));
// The soonest date is worth the room. Any others collapse to a single row
// each, so one wordy message cannot push the custom picker off the screen.
const rows = [fmsSnoozeHeadingRow(model, groups[0]), ...optionRows(groups[0])];
for (const group of groups.slice(1)) {
rows.push(fmsSnoozeExpanderRow(model, group, () => {
const built = optionRows(group);
for (const row of built) row.hidden = true;
return built;
}));
}
// Divide the first group from Fastmail's own options above it.
const lastNative = options[options.length - 2];
if (lastNative) lastNative.classList.add(FMS_MENU_SECTION_CLASS);
custom.before(...rows);
fmsMarkSectionEnd(menu);
}
/**
* Decorate a menu once Fastmail has registered its view. A menu inserted this
* frame may not be registered yet, so retry for a few frames.
*
* @param {Element} menu A `.v-FutureTimeMenu` element.
* @param {number} tries Attempts remaining.
*/
function fmsTrySnoozeMenu(menu, tries) {
if (!menu.isConnected || menu.querySelector('[' + FMS_MENU_MARK + ']')) return;
if (!fmsSnoozeEnabled()) return;
const view = fmsViewFor(menu);
if (fmsIsSnoozeMenu(view)) {
fmsDecorateSnoozeMenu(menu, view);
return;
}
// A view that resolved but is not the snooze menu is compose's schedule
// send: leave it alone. Only an unresolved view is worth retrying.
if (view || tries <= 0) return;
requestAnimationFrame(() => fmsTrySnoozeMenu(menu, tries - 1));
}
/**
* Watch for the snooze menu opening. Separate from the message-card observer
* in {@link fmsMain}, which is debounced: a popover has to be decorated in
* the frame it appears, not 400ms later.
*
* The observer is installed unconditionally. Fastmail attaches
* `getViewFromNode` when its app module finishes loading, which is after this
* script runs at `document-idle` -- testing for it here would mean never
* watching at all. {@link fmsTrySnoozeMenu} checks for it when a menu opens,
* by which point the app is up.
*/
function fmsWatchSnoozeMenus() {
new MutationObserver(records => {
for (const record of records) {
for (const node of record.addedNodes) {
if (node.nodeType !== 1) continue;
const menu = node.matches(FMS_MENU_SEL)
? node : node.querySelector(FMS_MENU_SEL);
if (menu) fmsTrySnoozeMenu(menu, FMS_MENU_VIEW_TRIES);
}
}
}).observe(document.body, { childList: true, subtree: true });
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
fmsIsSnoozeMenu, fmsSnoozeRow, fmsSnoozeWhenText, fmsSnoozePick,
fmsSnoozeHeadingRow, fmsCloneRowLabel, fmsPlainRowLabel, fmsMenuHour12,
fmsSnoozeExpanderRow, fmsMarkSectionEnd, fmsPageWindow, fmsPageDate,
fmsDecorateSnoozeMenu, fmsTrySnoozeMenu, fmsWatchSnoozeMenus,
};
}
// How long to let Fastmail's DOM settle before re-scanning for message cards.
const FMS_SCAN_DEBOUNCE_MS = 400;
// Entry point. Installs a body-wide observer for the page's lifetime; message
// cards arrive and re-render as the SPA navigates.
(function fmsMain() {
fmsInjectStyles();
fmsMaybeAutoEdit();
fmsWatchSnoozeMenus();
fmsScanAll();
let timer = null;
new MutationObserver(() => {
clearTimeout(timer);
timer = setTimeout(fmsScanAll, FMS_SCAN_DEBOUNCE_MS);
}).observe(document.body, { childList: true, subtree: true });
})();
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment