Skip to content

Instantly share code, notes, and snippets.

@e-volusian
Created August 6, 2026 02:56
Show Gist options
  • Select an option

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

Select an option

Save e-volusian/b9a94eddab3f8c3ed4c6ffc1db3df404 to your computer and use it in GitHub Desktop.
Fastmail - put notes in the right sidebar
// ==UserScript==
// @name Fastmail Notes Sidebar
// @namespace fastmail-notes-sidebar
// @version 1.0.4
// @description Read/write Fastmail Notes from the mail right sidebar
// @match https://app.fastmail.com/*
// @run-at document-idle
// @grant none
// @noframes
// ==/UserScript==
(function () {
'use strict';
function sortNotes(notes) {
return [...notes].sort((a, b) =>
(b.isFlagged - a.isFlagged) ||
String(b.lastSaved).localeCompare(String(a.lastSaved))
);
}
function makeSnippet(note, maxLen = 80) {
const src = note.body == null ? '' : String(note.body);
const raw = note.isHTML ? src.replace(/<[^>]+>/g, ' ') : src;
const clean = raw
.replace(/&nbsp;/g, ' ').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
.replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/g, '&')
.replace(/\s+/g, ' ').trim();
return clean.length > maxLen ? clean.slice(0, maxLen - 1) + '…' : clean;
}
function debounce(fn, ms) {
let timer = null, lastArgs = null;
const wrapped = (...args) => {
lastArgs = args;
clearTimeout(timer);
timer = setTimeout(() => { timer = null; fn(...lastArgs); }, ms);
};
wrapped.flush = () => {
if (timer === null) return;
clearTimeout(timer); timer = null; fn(...lastArgs);
};
wrapped.pending = () => timer !== null;
return wrapped;
}
function escapeHtml(s) {
return s.replace(/[&<>"']/g, c =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = { sortNotes, makeSnippet, debounce, escapeHtml };
}
const NOTES_CAP = 'https://www.fastmail.com/dev/notes';
function findNotesAccountId(accounts) {
for (const [id, acct] of Object.entries(accounts || {})) {
if ((acct.accountCapabilities || {})[NOTES_CAP]) return id;
}
return null;
}
// Direct page access; only works when the script runs in the page world.
function pageBackend() {
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)),
};
}
function pickSession(sessions, uParam) {
if (!Array.isArray(sessions) || sessions.length === 0) return null;
return sessions.find(s => s.accounts && s.accounts['u' + uParam]) || sessions[0];
}
function currentUParam() {
return new URLSearchParams(location.search).get('u') || '';
}
function readSession() {
let sessions;
try { sessions = JSON.parse(localStorage.getItem('sessions')); }
catch (e) { return null; }
const s = pickSession(sessions, currentUParam());
return (s && s.apiUrl && s.accessToken) ? s : null;
}
function unwrapJmapResponse(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 || {};
}
// The API needs the session cookies AND the bearer token, plus the u= param.
async function jmapFetch(session, method, args) {
const sep = session.apiUrl.includes('?') ? '&' : '?';
const res = await fetch(session.apiUrl + sep + 'u=' + currentUParam(), {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + session.accessToken,
},
body: JSON.stringify({
using: ['urn:ietf:params:jmap:core', NOTES_CAP],
methodCalls: [[method, args, '0']],
}),
});
if (!res.ok) throw new Error('JMAP HTTP ' + res.status);
return unwrapJmapResponse(await res.json());
}
// Works from a sandboxed userscript world: localStorage is shared with the page.
function sessionBackend() {
const session = readSession();
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(readSession() || session, method, args),
};
}
function waitForBackend(timeoutMs = 20000) {
return new Promise((resolve, reject) => {
const started = Date.now();
(function poll() {
const backends = [pageBackend(), sessionBackend()].filter(Boolean);
const ready = backends.find(b => findNotesAccountId(b.accounts));
if (ready) return resolve(ready);
if (Date.now() - started > timeoutMs) {
return reject(new Error(backends.length
? 'no notes-capable account'
: 'no Fastmail session found'));
}
setTimeout(poll, 250);
})();
});
}
function makeNotesApi(backend, accountId) {
const call = (method, args) => backend.call(method, { accountId, ...args });
return {
async list() {
const res = await call('Note/get', { ids: null });
return res.list || [];
},
async get(id) {
const res = await call('Note/get', { ids: [id] });
return (res.list || []).find(n => n.id === id) || null;
},
async create(props) {
const withDate = Object.assign(
{ lastSaved: new Date().toISOString().replace(/\.\d+Z$/, 'Z') }, props);
const res = await call('Note/set', { create: { n1: withDate } });
if (!res.created || !res.created.n1) throw new Error('create failed');
return Object.assign({}, withDate, res.created.n1);
},
async update(id, props) {
const res = await call('Note/set', { update: { [id]: props } });
if (res.notUpdated && res.notUpdated[id]) throw new Error('update failed');
},
async destroy(id) {
const res = await call('Note/set', { destroy: [id] });
if (res.notDestroyed && res.notDestroyed[id]) throw new Error('delete failed');
},
};
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = { findNotesAccountId, pickSession, unwrapJmapResponse, makeNotesApi };
}
const FMN_CSS = `
.fmn-panel { display: flex; flex-direction: column; height: 100%;
font: inherit; color: var(--color-text, #1a1a1a);
background: var(--color-background, #fff); overflow: hidden; }
.fmn-header { display: flex; align-items: center; gap: 8px;
padding: 8px 12px; border-bottom: 1px solid var(--color-border, #ddd); }
.fmn-header .fmn-title { font-weight: 600; flex: 1; }
.fmn-btn { border: 1px solid var(--color-border, #ccc); border-radius: 4px;
background: transparent; color: inherit; padding: 4px 10px; cursor: pointer; }
.fmn-btn:hover { background: rgba(128,128,128,.12); }
.fmn-btn--primary { background: var(--color-blue, #2e6da4); color: #fff;
border-color: transparent; }
.fmn-btn--danger { color: #c0392b; border-color: #c0392b; }
.fmn-list { flex: 1; overflow-y: auto; margin: 0; padding: 0; list-style: none; }
.fmn-list-item { padding: 10px 12px; cursor: pointer;
border-bottom: 1px solid var(--color-border, #eee); }
.fmn-list-item:hover { background: rgba(128,128,128,.08); }
.fmn-list-item .fmn-item-title { font-weight: 600; margin-bottom: 2px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.fmn-list-item .fmn-item-snippet { font-size: .85em; opacity: .7;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.fmn-flag { color: #e67e22; margin-right: 4px; }
.fmn-empty { padding: 24px 12px; text-align: center; opacity: .6; }
.fmn-editor-title { border: none; outline: none; font-weight: 700;
font-size: 1.05em; padding: 10px 12px; background: transparent; color: inherit;
border-bottom: 1px solid var(--color-border, #eee); }
.fmn-toolbar { display: flex; gap: 4px; padding: 6px 10px;
border-bottom: 1px solid var(--color-border, #eee); }
.fmn-toolbar .fmn-btn { min-width: 32px; min-height: 30px; }
.fmn-toolbar.fmn-disabled .fmn-btn { opacity: .35; pointer-events: none; }
.fmn-editor-body { flex: 1; min-height: 0; overflow-y: auto; padding: 12px;
outline: none; white-space: pre-wrap; overflow-wrap: break-word; }
.fmn-editor-body ul, .fmn-editor-body ol { white-space: normal; }
.fmn-status { font-size: .8em; opacity: .6; padding: 4px 12px; min-height: 1.4em; }
.fmn-banner { background: #fdecea; color: #b71c1c; padding: 8px 12px;
display: flex; align-items: center; gap: 8px; font-size: .9em; }
.fmn-banner button { margin-left: auto; }
.fmn-fab { position: fixed; right: 16px; bottom: 16px; z-index: 2147483000;
width: 48px; height: 48px; border-radius: 50%; border: none; cursor: pointer;
background: var(--color-blue, #2e6da4); color: #fff; font-size: 22px;
box-shadow: 0 2px 8px rgba(0,0,0,.35); }
.fmn-overlay { position: fixed; inset: 0; z-index: 2147483001;
background: var(--color-background, #fff); display: flex; }
.fmn-overlay .fmn-panel { flex: 1; }
.fmn-overlay .fmn-header { padding-right: 56px; }
@media (max-width: 800px) {
.fmn-btn, .fmn-list-item { min-height: 40px; }
}`;
function injectStyles() {
if (document.getElementById('fmn-styles')) return;
const style = document.createElement('style');
style.id = 'fmn-styles';
style.textContent = FMN_CSS;
document.head.appendChild(style);
}
function sanitizeNoteHtml(html) {
const doc = new DOMParser().parseFromString(html || '', 'text/html');
doc.querySelectorAll('script,style,iframe,object,embed,link,meta,form,base')
.forEach((el) => el.remove());
for (const el of doc.body.querySelectorAll('*')) {
for (const attr of [...el.attributes]) {
const name = attr.name.toLowerCase();
const val = String(attr.value).trim().toLowerCase();
if (name.startsWith('on')) el.removeAttribute(attr.name);
else if ((name === 'href' || name === 'src' || name === 'xlink:href') &&
(val.startsWith('javascript:') || val.startsWith('data:'))) {
el.removeAttribute(attr.name);
}
}
}
return doc.body.innerHTML;
}
function createPanel(api) {
const el = document.createElement('div');
el.className = 'fmn-panel';
// contain key/mouse/focus events so Fastmail's shortcuts and focus
// manager (which reclaims focus from unmanaged editables) ignore the panel
for (const type of ['keydown', 'keypress', 'keyup', 'pointerdown',
'pointerup', 'mousedown', 'mouseup', 'click', 'focusin']) {
el.addEventListener(type, (ev) => ev.stopPropagation());
}
const state = { notes: [], current: null, saveDirty: false };
const banner = document.createElement('div');
const content = document.createElement('div');
content.style.cssText = 'display:flex;flex-direction:column;flex:1;min-height:0';
el.append(banner, content);
function showError(msg, retry, kind = 'general') {
banner.className = 'fmn-banner';
banner.dataset.kind = kind;
banner.innerHTML = `<span>${escapeHtml(msg)}</span>`;
const btn = document.createElement('button');
btn.className = 'fmn-btn';
btn.textContent = 'Retry';
btn.onclick = () => { clearError(); retry(); };
banner.appendChild(btn);
}
function clearError(kind) {
if (kind && banner.dataset.kind && banner.dataset.kind !== kind) return;
banner.className = ''; banner.innerHTML = ''; delete banner.dataset.kind;
}
function renderList() {
state.current = null;
content.innerHTML =
`<div class="fmn-header"><span class="fmn-title">Notes</span>
<button class="fmn-btn fmn-btn--primary" data-act="new">New note</button></div>
<ul class="fmn-list"></ul>`;
const ul = content.querySelector('.fmn-list');
const notes = sortNotes(state.notes);
if (!notes.length) ul.innerHTML = '<li class="fmn-empty">No notes yet</li>';
for (const note of notes) {
const li = document.createElement('li');
li.className = 'fmn-list-item';
li.innerHTML =
`<div class="fmn-item-title">${note.isFlagged ? '<span class="fmn-flag">⚑</span>' : ''}${escapeHtml(note.title || 'Untitled')}</div>
<div class="fmn-item-snippet">${escapeHtml(makeSnippet(note))}</div>`;
li.onclick = () => openEditor(note);
ul.appendChild(li);
}
content.querySelector('[data-act="new"]').onclick = createNote;
}
async function createNote() {
try {
const note = await api.create({ title: '', body: '', isHTML: true });
state.notes.push(note);
openEditor(note);
} catch (e) { showError('Could not create note', createNote); }
}
function openEditor(note) {
state.current = note;
const editable = !note.isReadOnly;
content.innerHTML =
`<div class="fmn-header">
<button class="fmn-btn" data-act="back">‹ Back</button>
<span class="fmn-title"></span>
${editable ? '<button class="fmn-btn fmn-btn--danger" data-act="del">Delete</button>' : ''}
</div>
<input class="fmn-editor-title" placeholder="Title" ${editable ? '' : 'disabled'}>
<div class="fmn-toolbar ${note.isHTML && editable ? '' : 'fmn-disabled'}"
title="${note.isHTML ? '' : 'Plain-text note — formatting disabled'}">
<button class="fmn-btn" data-cmd="bold"><b>B</b></button>
<button class="fmn-btn" data-cmd="italic"><i>I</i></button>
<button class="fmn-btn" data-cmd="insertUnorderedList">• List</button>
<button class="fmn-btn" data-cmd="insertOrderedList">1. List</button>
</div>
<div class="fmn-editor-body" contenteditable="${editable}"></div>
<div class="fmn-status"></div>`;
const titleInput = content.querySelector('.fmn-editor-title');
const body = content.querySelector('.fmn-editor-body');
titleInput.value = note.title || '';
if (note.isHTML) body.innerHTML = sanitizeNoteHtml(note.body);
else body.textContent = note.body || '';
content.querySelector('[data-act="back"]').onclick = () => { flushSave(); refreshList(); };
wireDelete(note);
wireToolbar(body);
if (editable) {
titleInput.addEventListener('input', scheduleSave);
body.addEventListener('input', scheduleSave);
titleInput.addEventListener('blur', flushSave);
body.addEventListener('blur', flushSave);
}
}
function wireToolbar(body) {
for (const btn of content.querySelectorAll('[data-cmd]')) {
// block the button's focus-stealing mousedown so the editor keeps
// its selection; execCommand needs a live selection to do anything
btn.addEventListener('mousedown', (ev) => ev.preventDefault());
btn.onclick = () => {
const sel = document.getSelection();
if (!sel.rangeCount || !body.contains(sel.anchorNode)) body.focus();
document.execCommand(btn.dataset.cmd);
scheduleSave();
};
}
}
function wireDelete(note) {
const del = content.querySelector('[data-act="del"]');
if (!del) return;
const doDelete = async () => {
try {
await api.destroy(note.id);
state.notes = state.notes.filter(n => n.id !== note.id);
refreshList();
} catch (e) { showError('Delete failed', doDelete); }
};
del.onclick = () => {
if (del.dataset.armed !== '1') {
del.dataset.armed = '1';
del.textContent = 'Really delete?';
setTimeout(() => { del.dataset.armed = ''; del.textContent = 'Delete'; }, 4000);
return;
}
doDelete();
};
}
function readEditor() {
const note = state.current;
const titleInput = content.querySelector('.fmn-editor-title');
const body = content.querySelector('.fmn-editor-body');
if (!note || !titleInput || !body) return null;
return {
title: titleInput.value,
body: note.isHTML ? body.innerHTML : body.innerText,
isHTML: note.isHTML,
};
}
function setStatus(text) {
const s = content.querySelector('.fmn-status');
if (s) s.textContent = text;
}
const doSave = async () => {
const note = state.current;
const props = readEditor();
if (!note || !props || note.isReadOnly) return;
await saveNote(note, props);
};
async function saveNote(note, props) {
setStatus('Saving…');
try {
await api.update(note.id, props);
Object.assign(note, props);
state.saveDirty = false;
setStatus('Saved');
clearError('save');
} catch (e) {
state.saveDirty = true;
showError(`Save failed: "${note.title || 'Untitled'}" — edits kept until retried`,
() => saveNote(note, props), 'save');
setStatus('');
}
}
const debouncedSave = debounce(doSave, 750);
function scheduleSave() { state.saveDirty = true; setStatus('…'); debouncedSave(); }
function flushSave() {
if (debouncedSave.pending()) debouncedSave.flush();
else if (state.saveDirty) doSave();
}
async function refreshList() {
try {
state.notes = await api.list();
clearError('load');
} catch (e) { showError('Could not load notes', refreshList, 'load'); }
renderList();
}
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') flushSave();
});
return {
el,
open() { refreshList(); },
flushSave,
};
}
function fmnFindTabStrip() {
for (const label of document.querySelectorAll('.v-PushSelect-option')) {
if (label.textContent.trim() === 'Calendar') return label.closest('.v-PushSelect');
}
return null;
}
function fmnFindSidebarContent(strip) {
const toolbar = strip.closest('.v-Toolbar');
if (!toolbar || !toolbar.parentElement) return null;
let el = toolbar.nextElementSibling;
while (el && el.classList.contains('fmn-panel')) el = el.nextElementSibling;
return el;
}
function fmnSelectNotesTab(strip, panel) {
const content = fmnFindSidebarContent(strip);
if (content) content.style.display = 'none';
strip.querySelectorAll('.v-PushSelect-option.is-selected')
.forEach(o => o.classList.remove('is-selected'));
strip.querySelector('.fmn-tab').classList.add('is-selected');
const host = strip.closest('.v-Toolbar').parentElement;
if (!panel.el.parentElement) host.appendChild(panel.el);
panel.el.style.display = '';
panel.open();
}
function fmnDeselectNotesTab(strip, panel) {
const tab = strip.querySelector('.fmn-tab');
if (tab) tab.classList.remove('is-selected');
panel.flushSave();
panel.el.style.display = 'none';
const content = fmnFindSidebarContent(strip);
if (content) content.style.display = '';
}
function fmnInjectTab(strip, panel) {
if (!strip.dataset.fmnWired) {
strip.dataset.fmnWired = '1';
strip.addEventListener('click', (ev) => {
const opt = ev.target.closest('.v-PushSelect-option');
if (opt && !opt.classList.contains('fmn-tab')) fmnDeselectNotesTab(strip, panel);
}, true);
}
if (strip.querySelector('.fmn-tab')) return;
const tab = document.createElement('label');
tab.className = 'v-PushSelect-option fmn-tab';
tab.textContent = 'Notes';
tab.addEventListener('click', (ev) => {
ev.preventDefault(); ev.stopPropagation();
fmnSelectNotesTab(strip, panel);
});
strip.appendChild(tab);
}
// Mobile FAB/overlay feature gate; off unless explicitly enabled.
function fmnMobileEnabled() {
try { return localStorage.getItem('fmn.enableMobile') === '1'; }
catch (e) { return false; }
}
function setupIntegration(panel) {
injectStyles();
const apply = () => {
const strip = fmnFindTabStrip();
if (strip) { fmnRemoveFab(); fmnInjectTab(strip, panel); }
else if (fmnMobileEnabled()) fmnEnsureFab(panel);
else fmnRemoveFab();
};
apply();
let timer = null;
new MutationObserver(() => {
clearTimeout(timer);
timer = setTimeout(apply, 500);
}).observe(document.body, { childList: true, subtree: true });
}
function fmnEnsureFab(panel) {
if (document.querySelector('.fmn-fab')) return;
const fab = document.createElement('button');
fab.className = 'fmn-fab';
fab.title = 'Notes';
fab.textContent = '✎';
fab.onclick = () => fmnOpenOverlay(panel);
document.body.appendChild(fab);
}
function fmnRemoveFab() {
const fab = document.querySelector('.fmn-fab');
if (fab) fab.remove();
fmnCloseOverlay();
}
function fmnOpenOverlay(panel) {
if (document.querySelector('.fmn-overlay')) return;
const overlay = document.createElement('div');
overlay.className = 'fmn-overlay';
const closeBtn = document.createElement('button');
closeBtn.className = 'fmn-btn';
closeBtn.style.cssText = 'position:absolute;top:8px;right:8px;z-index:1';
closeBtn.textContent = '✕';
closeBtn.onclick = () => { panel.flushSave(); fmnCloseOverlay(); };
overlay.append(panel.el, closeBtn);
panel.el.style.display = '';
document.body.appendChild(overlay);
panel.open();
}
function fmnCloseOverlay() {
const overlay = document.querySelector('.fmn-overlay');
if (!overlay) return;
const panel = overlay.querySelector('.fmn-panel');
if (panel) panel.remove();
overlay.remove();
}
(async function fmnMain() {
try {
const backend = await waitForBackend();
const accountId = findNotesAccountId(backend.accounts);
const panel = createPanel(makeNotesApi(backend, accountId));
setupIntegration(panel);
} catch (e) {
console.info('[fmn] init skipped:', e.message);
}
})();
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment