Files
thunderbird-send-later-mirror/background.js
Jonathan Kamens 739f4386f2 Don't need an experiment to generate message IDs
We can generate message IDs ourselves in WebExtension code; we don't
need to use an experiment to do it.
2025-10-07 12:04:47 -04:00

3130 lines
103 KiB
JavaScript

// Class for abstracting the idea that a message is locked and can't be sent
// again. Uses local storage as the persistent lock cache. The key of each
// value in the cache is a message idea and a date. The value is an array
// containing a lock type and an x-send-later-at date value. Search for
// ".lock(" below to find out the lock types that are used. The lock type
// "true" is the generic "This message was sent successfully so if we see it
// again the Drafts folder is probably corrupt" lock type; others indicate
// different errors which do NOT mean that the Drafts folder is corrupt.
//
// Because we enforce not delivering messages with x-send-later-at values more
// than 180 days in the past, we can prune any lock cache entries with dates
// older than that. This prevents the lock cache from growing without bound
// forever.
class Locker {
static locks;
static newLocks;
constructor() {
if (!Locker.locks) {
return (async () => {
let changed = false;
let storage = await messenger.storage.local.get({ lock: {} });
Locker.locks = storage.lock;
// Convert old style lock cache to new one.
if (!Locker.locks["migrated"]) {
for (let lock of Object.keys(Locker.locks)) {
Locker.locks[lock] = [Locker.locks[lock], new Date()];
}
Locker.locks["migrated"] = 1;
changed = true;
}
let cutoff = new Date(Date.now() - 180 * 24 * 60 * 60 * 1000);
for (let lockId of Object.keys(Locker.locks)) {
let value = Locker.locks[lockId];
if (typeof value != "object") {
// "migrated" key
continue;
}
if (value[1] < cutoff) {
changed = true;
delete Locker.locks[lockId];
}
}
if (changed) {
await messenger.storage.local.set({ lock: Locker.locks });
}
return this;
})();
}
}
async lock(hdr, full, reason) {
let msgId = hdr.headerMessageId;
let date = hdr.date;
let id = `${msgId}/${date}`;
let sendAt = full.headers["x-send-later-at"][0];
Locker.locks[id] = [reason || true, new Date(sendAt)];
if (Locker.newLocks) {
Locker.newLocks[id] = Locker.locks[id];
}
return await messenger.storage.local.set({ lock: Locker.locks });
}
isLocked(hdr, full) {
let msgId = hdr.headerMessageId;
let date = hdr.date;
let id = `${msgId}/${date}`;
let it = Locker.locks[id];
if (it) {
if (Locker.newLocks) {
Locker.newLocks[id] = Locker.locks[id];
}
return it[0];
}
return false;
}
}
async function* getMessages(list) {
let page = await list;
for (let message of page.messages) {
yield message;
}
while (page.id) {
page = await messenger.messages.continueList(page.id);
for (let message of page.messages) {
yield message;
}
}
}
async function* getMessageIds(list) {
for await (let message of getMessages(list)) yield message.id;
}
// Set of message ID's which are scheduled
const scheduledMsgCache = new Set();
// Set of message ID's which are not scheduled
const _unscheduledMsgCache = new Set();
const unscheduledMsgCache = {
has: function (key) {
let ret = _unscheduledMsgCache.has(key);
SLTools.trace(`unscheduledMsgCache.has(${key}) = ${ret}`);
return ret;
},
add: function (key) {
let ret = _unscheduledMsgCache.add(key);
SLTools.trace(`unscheduledMsgCache.add(${key}) = ${ret}`);
return ret;
},
delete: function (key) {
let ret = _unscheduledMsgCache.delete(key);
SLTools.trace(`unscheduledMsgCache.delete(${key}) = ${ret}`);
return ret;
},
clear: function () {
let ret = _unscheduledMsgCache.clear();
SLTools.trace(`unscheduledMsgCache.clear() = ${ret}`);
return ret;
},
};
// Count draft messages containing the correct `x-send-later-uuid` header.
async function countActiveScheduledMessages() {
const preferences = await SLTools.getPrefs();
let isScheduled = await SLTools.forAllDrafts(
async (msgHdr) => {
let msgId = msgHdr.id;
SLTools.debug(`countActiveScheduledMessages: Checking message ${msgId}`);
if (scheduledMsgCache.has(msgId)) {
SLTools.debug(
`countActiveScheduledMessages: msg ${msgId} found in scheduled cache`,
msgHdr,
);
return true;
} else if (unscheduledMsgCache.has(msgId)) {
SLTools.debug(
`countActiveScheduledMessages: msg ${msgId} found in unscheduled cache`,
);
return false;
} else {
let fullMsg = await messenger.messages.getFull(msgId);
let uuid = (fullMsg.headers["x-send-later-uuid"] || [])[0];
let instanceUuid = preferences.instanceUUID;
if (uuid == instanceUuid) {
scheduledMsgCache.add(msgId);
SLTools.debug(
`countActiveScheduledMessages: msg ${msgId} added to scheduled cache`,
msgHdr,
);
return true;
} else {
SLTools.debug(
`countActiveScheduledMessages: msg ${msgId} added to unscheduled cache` +
` (uuid="${uuid}", instanceUuid="${instanceUuid}")`,
);
unscheduledMsgCache.add(msgId);
return false;
}
}
},
true, // Running sequentially seems to give slightly better performance.
undefined,
preferences,
);
return isScheduled.filter((x) => x).length;
}
async function updateShortcuts(preferences) {
for (let i = 1; i <= 3; i++) {
let shortcutName = `send-later-shortcut-${i}`;
let shortcutPref = `quickOptions${i}Key`;
let shortcutValue = preferences[shortcutPref];
let shortcutKey = shortcutValue.length ? `Ctrl+Alt+${shortcutValue}` : "";
try {
await messenger.commands.update({
name: shortcutName,
shortcut: shortcutKey,
});
SLTools.debug(`Set ${shortcutName} to ${shortcutKey}`);
} catch (ex) {
SLTools.error(
`Error binding ${shortcutName} to ${shortcutKey}: ${ex}`,
ex,
);
}
}
}
// Like messenger.messages.import, but supports IMAP folders.
// Cribbed from https://github.com/cleidigh/EditEmailSubject-MX/blob/master/
// src/content/scripts/editemailsubject.mjs, as recommended by John Bieling.
async function messageImport(file, destination, properties) {
// Operation is piped thru a local folder, since messages.import does not
// currently work with imap.
let localAccount = (await messenger.accounts.list(false)).find(
(account) => account.type == "none",
);
let isLocal = localAccount.id == destination.accountId;
let localFolder;
if (isLocal) {
localFolder = destination;
} else {
let localFolders = await messenger.folders.getSubFolders(
await SLTools.tb128(localAccount.id, localAccount),
false,
);
localFolder = localFolders.find(
(folder) => folder.name == SLTools.tempFolderName,
);
if (!localFolder) {
localFolder = await messenger.folders.create(
localAccount,
SLTools.tempFolderName,
);
}
}
let newMsgHeader = await messenger.messages.import(
file,
await SLTools.tb128(localFolder.id, localFolder),
properties,
);
if (!newMsgHeader) {
return false;
}
SLTools.debug(`Saved local message ${newMsgHeader.id}`);
if (isLocal) {
SLTools.debug("Destination folder is already local, not moving message");
return true;
}
// Move new message from temp folder to real destination.
let moved;
try {
await messenger.messages
.move([newMsgHeader.id], destination.id)
.then(() => {
moved = true;
});
} catch (ex) {
moved = false;
}
return moved;
}
// Original idea:
// https://thunderbird.topicbox.com/groups/addons/T06356567165277ee-M25e96f2d58e961d6167ad348/charset-problem-when-saving-to-eml-format
// My improvement to it:
// https://thunderbird.topicbox.com/groups/addons/T06356567165277ee-Md1002780236b2a1ad92e88bd/charset-problem-when-saving-to-eml-format
function getFileFromRaw(binaryString) {
let bytes = new Array(binaryString.length);
for (let i = 0; i < bytes.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return new File([new Uint8Array(bytes)], "message.eml", {
type: "message/rfc822",
});
}
/* Given a user function and an "unparsed" argument string,
* this function returns a common schedule object, including
* `sendAt` and `recur` members.
*/
function parseUfuncToSchedule(name, body, prev, argStr) {
let args = null;
if (argStr) {
try {
argStr = SLTools.unparseArgs(SLTools.parseArgs(argStr));
args = SLTools.parseArgs(argStr);
} catch (ex) {
SLTools.warn(ex);
let errTitle = SLTools.i18n.getMessage("InvalidArgsTitle");
let errBody = SLTools.i18n.getMessage("InvalidArgsBody");
return { err: `${errTitle}: ${errBody}` };
}
}
const { sendAt, nextspec, nextargs, error } = SLTools.evaluateUfunc(
name,
body,
prev,
args,
);
SLTools.debug("User function returned:", {
sendAt,
nextspec,
nextargs,
error,
});
if (error) {
throw new Error(error);
} else {
let recur = SLTools.parseRecurSpec(nextspec || "none") || { type: "none" };
if (recur.type !== "none") recur.args = nextargs || "";
const schedule = { sendAt, recur };
SLTools.debug("commands.onCommand received ufunc response:", schedule);
return schedule;
}
}
function customHdrToScheduleInfo(customHeaders, instanceUUID) {
let cellText = "";
let sortValue = (Math.pow(2, 31) - 5) | 0;
if (!customHeaders["content-type"]) {
SLTools.warn("Didn't receive complete headers.");
return { cellText, sortValue };
} else if (!customHeaders["x-send-later-at"]) {
// Do nothing. Leave cell properties as default
return { cellText, sortValue };
}
if (customHeaders["x-send-later-uuid"] !== instanceUUID) {
cellText = SLTools.i18n.getMessage("incorrectUUID");
sortValue = (Math.pow(2, 31) - 2) | 0;
} else {
const sendAt = new Date(customHeaders["x-send-later-at"]);
const recurSpec = customHeaders["x-send-later-recur"] || "none";
let recur = SLTools.parseRecurSpec(recurSpec);
recur.cancelOnReply = ["true", "yes"].includes(
customHeaders["x-send-later-cancel-on-reply"],
);
recur.args = customHeaders["x-send-later-args"];
cellText = SLTools.formatScheduleForUIColumn({ sendAt, recur });
// Numbers will be truncated. Be sure this fits in 32 bits
sortValue = (sendAt.getTime() / 1000) | 0;
}
return { cellText, sortValue };
}
// Pseudo-namespace encapsulation for global-ish variables.
const SendLater = {
prefCache: {},
windowCreatedResolver: null,
// Track the status of Send Later's main loop. This helps
// resolve sub-minute accuracy for very short scheduled times
// (e.g. "Send in 38 seconds" ...). Only affects UI
// elements in which a relative time is displayed.
previousLoop: null,
loopMinutes: 1,
// Time for each loop over and above the interval time
loopExcessTimes: [],
// Cause current compose window to send immediately
// (after some pre-send checks)
async checkDoSendNow(options) {
if (options.first) {
let messageArgs = [
messenger.i18n.getMessage("sendNowLabel"),
messenger.i18n.getMessage("sendAtLabel"),
];
let preferences = await SLTools.getPrefs();
if (preferences.showSendNowAlert) {
const result = await SLTools.confirmCheck(
messenger.i18n.getMessage("AreYouSure"),
messenger.i18n.getMessage("SendNowConfirmMessage", messageArgs),
messenger.i18n.getMessage("ConfirmAgain"),
true,
).catch((err) => {
SLTools.trace(err);
});
if (result.check === false) {
preferences.showSendNowAlert = false;
await messenger.storage.local.set({ preferences });
}
if (!result.ok) {
SLTools.debug(`User canceled send now.`);
return false;
}
} else if (options.changed && preferences.showChangedAlert) {
const result = await SLTools.confirmCheck(
messenger.i18n.getMessage("AreYouSure"),
messenger.i18n.getMessage("PopupChangedConfirmMessage", messageArgs),
messenger.i18n.getMessage("ConfirmAgain"),
true,
).catch((err) => {
SLTools.trace(err);
});
if (result.check === false) {
preferences.showChangedAlert = false;
await messenger.storage.local.set({ preferences });
}
if (!result.ok) {
SLTools.debug(`User canceled send now.`);
return false;
}
}
}
return true;
},
async doSendNow(tabId, options, fromMenuCommand) {
await messenger.compose.sendMessage(tabId, { mode: "sendNow" });
return true;
},
// Use built-in send later function (after some pre-send checks)
async checkDoPlaceInOutbox(options) {
if (options.first) {
let messageArgs = [
messenger.i18n.getMessage("sendlater.prompt.sendlater.label"),
messenger.i18n.getMessage("sendAtLabel"),
];
let preferences = await SLTools.getPrefs();
if (preferences.showOutboxAlert) {
const result = await SLTools.confirmCheck(
messenger.i18n.getMessage("AreYouSure"),
messenger.i18n.getMessage("OutboxConfirmMessage", messageArgs),
messenger.i18n.getMessage("ConfirmAgain"),
true,
).catch((err) => {
SLTools.trace(err);
});
if (result.check === false) {
preferences.showOutboxAlert = false;
await messenger.storage.local.set({ preferences });
}
if (!result.ok) {
SLTools.debug(`User canceled put in outbox.`);
return false;
}
} else if (options.changed && preferences.showChangedAlert) {
const result = await SLTools.confirmCheck(
messenger.i18n.getMessage("AreYouSure"),
messenger.i18n.getMessage("PopupChangedConfirmMessage", messageArgs),
messenger.i18n.getMessage("ConfirmAgain"),
true,
).catch((err) => {
SLTools.trace(err);
});
if (result.check === false) {
preferences.showChangedAlert = false;
await messenger.storage.local.set({ preferences });
}
if (!result.ok) {
SLTools.debug(`User canceled put in outbox.`);
return false;
}
}
}
return true;
},
async doPlaceInOutbox(tabId, options, fromMenuCommand) {
await messenger.compose.sendMessage(tabId, { mode: "sendLater" });
return true;
},
// Sends composed message according to user function (specified
// by name), and arguments (specified as an "unparsed" string).
async quickSendWithUfunc(funcName, funcArgs, tabId) {
if (!tabId) {
let tab = await SLTools.getActiveComposeTab();
if (tab) {
tabId = tab.id;
}
}
if (!(await SendLater.schedulePrecheck())) {
return false;
}
if (tabId) {
let { ufuncs } = await messenger.storage.local.get({ ufuncs: {} });
let funcBody = ufuncs[funcName].body;
let schedule = parseUfuncToSchedule(funcName, funcBody, null, funcArgs);
let options = {
sendAt: schedule.sendAt,
recurSpec: SLTools.unparseRecurSpec(schedule.recur),
args: schedule.recur.args,
cancelOnReply: false,
};
await SendLater.scheduleSendLater(tabId, options);
}
},
async schedulePrecheck() {
if (!(await SLTools.tb128(true, false))) {
let tab = await SLTools.getActiveComposeTab();
let composeDetails = await messenger.compose.getComposeDetails(tab.id);
if (composeDetails.deliveryStatusNotification) {
let extensionName = messenger.i18n.getMessage("extensionName");
let dsnName = messenger.i18n.getMessage("DSN");
let title = messenger.i18n.getMessage("noDsnTitle", [
dsnName,
extensionName,
]);
let text = messenger.i18n.getMessage("noDsnText", [
dsnName,
extensionName,
]);
SLTools.alert(title, text);
return false;
}
}
return true;
},
// Go through the process of handling pre-send checks, assigning custom
// header fields, and saving the message to Drafts.
async scheduleSendLater(tabId, options, fromMenuCommand) {
let now = new Date();
SLTools.debug(`Pre-send check initiated at ${now}`);
let encryptionStatus =
await messenger.SL3U.signingOrEncryptingMessage(tabId);
SLTools.telemetrySend({
event: "encryptionStatus",
encryptionStatus: encryptionStatus,
});
if (encryptionStatus.endsWith("-error")) {
let name = messenger.i18n.getMessage("extensionName");
let title = messenger.i18n.getMessage("IncompatibleEncryptionTitle", [
name,
]);
let errorKey = `IncompatibleEncryption-${encryptionStatus}-Text`;
let text = messenger.i18n.getMessage(errorKey, [name]);
SLTools.alert(title, text);
return false;
}
let check = await messenger.SL3U.GenericPreSendCheck();
if (!check) {
SLTools.warn(`Canceled via pre-send checks (check initiated at ${now})`);
return;
}
// let windowId = await messenger.tabs.get(tabId).then(
// tab => tab.windowId);
// let originalDraftMsg = await messenger.SL3U.findAssociatedDraft(
// windowId);
const preferences = await SLTools.getPrefs();
SLTools.info(`Scheduling send later: ${tabId} with options`, options);
// Expand mailing lists into individual recipients
await SLTools.expandRecipients(tabId);
let customHeaders = [
{ name: "X-Send-Later-Uuid", value: preferences.instanceUUID },
];
// Determine time at which this message should be sent
let sendAt;
if (options.sendAt !== undefined) {
sendAt = new Date(options.sendAt);
} else if (options.delay !== undefined) {
sendAt = new Date(Date.now() + options.delay * 60000);
} else {
SLTools.error("scheduleSendLater requires scheduling information");
return;
}
sendAt = SLTools.parseableDateTimeFormat(sendAt);
customHeaders.push({ name: "X-Send-Later-At", value: sendAt });
if (preferences.scheduledDateField) {
customHeaders.push({ name: "Date", value: sendAt });
}
if (options.recurSpec) {
customHeaders.push({
name: "X-Send-Later-Recur",
value: options.recurSpec,
});
if (options.cancelOnReply) {
customHeaders.push({
name: "X-Send-Later-Cancel-On-Reply",
value: "yes",
});
}
}
if (options.args) {
customHeaders.push({ name: "X-Send-Later-Args", value: options.args });
}
// When Thunderbird saves an existing draft, it preserves its message ID
// (the RFC message ID, not the internal TB message ID). This causes
// problems, especially with Gmail. Setting Message-ID to an empty value
// here forces Thunderbird to generate a new Message ID when saving the
// message.
customHeaders.push({ name: "message-id", value: "" });
let composeDetails = await messenger.compose.getComposeDetails(tabId);
// // // Merge the new custom headers into the original headers
// // // Note: this shouldn't be necessary, but it appears that
// // // `setComposeDetails` does not preserve existing headers.
// // for (let hdr of composeDetails.customHeaders) {
// // if (!hdr.name.toLowerCase().startsWith("x-send-later")) {
// // customHeaders.push(hdr);
// // }
// // }
// // composeDetails.customHeaders = customHeaders;
// // SLTools.info("Saving message with details:", composeDetails);
// // await messenger.compose.setComposeDetails(tabId, composeDetails);
//
// The setComposeDetails method seems to drop all unsupported headers
// (which is most of the headers). This breaks things like replies
// which need to retain the "in-reply-to" header (for example).
for (let hdr of customHeaders) {
await messenger.SL3U.setHeader(tabId, hdr.name, hdr.value);
}
// Save the message as a draft
let saveProperties = await messenger.compose.saveMessage(tabId, {
mode: "draft",
});
if (!saveProperties.messages.length) {
throw new Error(
"Failed to save draft, no exception thrown by Thunderbird",
);
}
// The "real" draft, as opposed to the FCC, is always first.
let msg = saveProperties.messages[0];
// Optionally mark the saved message as "read"
if (preferences.markDraftsRead) {
await messenger.messages.update(msg.id, { read: true });
}
// Some servers, most notably Gmail but perhaps others as well, don't
// refresh the content of the saved message properly when the new message
// is saved and the old one is deleted. This seems to be true even when
// we replace the Message-ID in the message as we do above. This also seems
// to be different from the bug which sometimes causes the local Thunderbird
// to display the old content for a message even though the server has the
// new content; in this case it appears that even the server doesn't show
// the new content, as evidenced by looking at the draft on mail.google.com.
// Cleaning the Drafts folder after saving the message seems to solve this.
SendLater.addToDraftsToClean(msg.folder, true);
let targetFolder = await SLTools.getTargetSubfolder(preferences, msg);
if (targetFolder) {
await messenger.messages.move([msg.id], targetFolder.id);
// Message ID has changed so we want to make sure not to use the old one!
// If we forget and try to later on in the function this should cause an
// error.
msg.id = null;
msg.folder = targetFolder;
SendLater.addToDraftsToClean(msg.folder, true);
}
SendLater.cleanDrafts();
if (preferences.ignoredAccounts && preferences.ignoredAccounts.length) {
let identity = await messenger.identities.get(composeDetails.identityId);
let accountId = identity.accountId;
if (preferences.ignoredAccounts.includes(accountId)) {
preferences.ignoredAccounts = preferences.ignoredAccounts.filter(
(a) => a != accountId,
);
await messenger.storage.local.set({ preferences });
SLTools.info(
`Reactivating ${accountId} because message scheduled in it`,
);
}
}
// Close the composition tab
await messenger.tabs.remove(tabId);
// If message was a reply or forward, update the original message
// to show that it has been replied to or forwarded.
if (!fromMenuCommand && composeDetails.relatedMessageId) {
if (composeDetails.type == "reply") {
SLTools.debug("This is a reply message. Setting original 'replied'");
await messenger.SL3U.setDispositionState(
composeDetails.relatedMessageId,
"replied",
);
} else if (composeDetails.type == "forward") {
SLTools.debug("This is a fwd message. Setting original 'forwarded'");
await messenger.SL3U.setDispositionState(
composeDetails.relatedMessageId,
"forwarded",
);
}
}
// If the message was already saved as a draft (and made it into the
// unscheduledMsgCache while being composed), then it will be ignored when
// checking for scheduled messages. We should be able to remove it from the
// unscheduledMsgCache here, but there seems to be a bug in Thunderbird
// where the message ID reported to us is not the actual saved message.
// Also, if the user is using a drafts folder then we no longer have the
// actual message ID of the draft because it changed when we moved it and
// we haven't searched and found the new ID.
// Best option right now seems to be invalidating and regenerating the
// entire unscheduledMsgCache.
if (!fromMenuCommand) {
unscheduledMsgCache.clear();
// It seems that a delay is required for messages.getFull to successfully
// access the recently saved message.
setTimeout(SendLater.updateStatusIndicator, 1000);
}
// // Different workaround:
// function touchDraftMsg(draftId) {
// unscheduledMsgCache.delete(draftId);
// scheduledMsgCache.add(draftId);
// if (preferences.markDraftsRead)
// await messenger.messages.update(draftId, { read: true });
// }
// if (originalDraftMsg)
// touchDraftMsg(originalDraftMsg.id);
// if (composeDetails.type == "draft" && composeDetails.relatedMessageId)
// touchDraftMsg(composeDetails.relatedMessageId);
// await messenger.SL3U.findAssociatedDraft(windowId).then(
// newDraftMsg => touchDraftMsg(newDraftMsg.id)
// );
return true;
},
draftsToClean: [],
addToDraftsToClean(folder, force) {
if (
!SendLater.draftsToClean.some(
(f) => f.accountId == folder.accountId && f.path == folder.path,
)
) {
SLTools.debug("Adding folder to draftsToClean:", folder);
SendLater.draftsToClean.push(folder);
} else {
SLTools.debug("Clean is already queued for:", folder);
}
if (force) SendLater.draftsToClean.slforce = true;
},
// There are two different race conditions we're concerned about here. First,
// while we're waiting for all of the drafts folders we're cleaning to become
// idle, someone else could call cleanDrafts a second time. Second, while
// we're awaiting for something in the loop of cleaning all the folders,
// someone could add another folder to the list.
// To address the first, any time cleanDrafts is invoked it needs to await
// for the prior running invocation to finish. We set this up in a loop that
// keeps awaiting until there's nothing to wait for, because if multiple
// invocations are awaiting for it to finish before they start, one of them
// could regain control before we do and start another clean cycle.
// To address the second, we make the list of drafts to clean local before we
// start the cleaning process, and reinitialize the shared list to an empty
// array, so if someone else adds a folder to the list once we've started
// cleaning it'll get picked up in the next invocation of cleanDrafts.
// This is just used so that different invocations of cleanDrafts can be
// distinguished from each other in the logs.
cdid: 0,
cleanDraftsPromise: null,
async cleanDrafts() {
let _id = SendLater.cdid++;
SLTools.trace(`cleanDrafts[${_id}]: start`);
let waited;
while (SendLater.cleanDraftsPromise) {
waited = true;
SLTools.debug(
`cleanDrafts[${_id}]: waiting for previous clean to finish`,
);
await SendLater.cleanDraftsPromise;
}
if (waited) SLTools.debug(`cleanDrafts[${_id}]: finished waiting`);
SendLater.cleanDraftsPromise = SendLater.cleanDraftsReal();
await SendLater.cleanDraftsPromise;
SendLater.cleanDraftsPromise = null;
SLTools.trace(`cleanDrafts[${_id}]: end`);
},
async cleanDraftsReal() {
draftsToClean = SendLater.draftsToClean;
SendLater.draftsToClean = [];
if (!draftsToClean.length) return;
if (draftsToClean.slforce || SendLater.prefCache.compactDrafts) {
await messenger.SL3U.waitUntilIdle(draftsToClean);
for (let folder of draftsToClean) {
SLTools.debug("Cleaning folder:", folder);
await messenger.SL3U.expungeOrCompactFolder(folder);
}
} else {
SLTools.debug("Not cleaning folders, preference is disabled");
}
},
async deleteMessage(hdr) {
SendLater.addToDraftsToClean(hdr.folder);
let account = await messenger.accounts.get(hdr.folder.accountId, false);
let accountType = account.type;
let succeeded;
try {
await messenger.messages
.delete(
[hdr.id],
await SLTools.tb137({ deletePermanently: true }, true),
)
.then(() => {
succeeded = true;
SLTools.info("Deleted message", hdr.id);
scheduledMsgCache.delete(hdr.id);
unscheduledMsgCache.delete(hdr.id);
});
} catch (ex) {
SLTools.error(`Error deleting message ${hdr.id}`, ex);
}
return succeeded;
},
checkEncryption(contentType, originalMsgId, msgHdr) {
if (/encrypted/i.test(contentType)) {
SLTools.debug(
`Message ${originalMsgId} is encrypted, and will not ` +
`be processed by Send Later.`,
);
unscheduledMsgCache.add(msgHdr.id);
return false;
}
return true;
},
async checkLocked(
preferences,
locker,
originalMsgId,
msgHdr,
msgLockId,
fullMsg,
) {
let locked = locker.isLocked(msgHdr, fullMsg);
if (!locked) return true;
const msgSubject = msgHdr.subject;
if (locked === true) {
if (preferences.optOutResendWarning === true) {
SLTools.debug(
`Encountered previously sent message ` +
`"${msgSubject}" ${msgLockId}.`,
);
} else {
SLTools.error(
`Attempted to resend message "${msgSubject}" ${msgLockId}.`,
);
const result = await SLTools.alertCheck(
null,
messenger.i18n.getMessage("CorruptFolderError", [
msgHdr.folder.path,
]) +
"\n\n" +
messenger.i18n.getMessage("CorruptFolderErrorDetails", [
msgSubject,
originalMsgId,
]),
null,
true,
);
if (result.check === false) {
preferences.optOutResendWarning = true;
await messenger.storage.local.set({ preferences });
}
}
}
return false;
},
async checkLate(preferences, locker, nextSend, msgHdr, fullMsg) {
// Respect late message blocker
// We enforce a maximum grace period of six months even when one isn't
// specified in the user's preferences, for safety.
let maxGracePeriod = 60 * 24 * 180; // minutes
let lateGracePeriod;
if (preferences.blockLateMessages) {
lateGracePeriod = Math.min(preferences.lateGracePeriod, maxGracePeriod);
} else {
lateGracePeriod = maxGracePeriod;
}
let lateness = (Date.now() - nextSend.getTime()) / 60000;
if (lateness <= lateGracePeriod) return true;
SLTools.warn(`Grace period exceeded for message ${msgHdr.id}`);
if (locker.isLocked(msgHdr, fullMsg) != "late") {
let units, newLateness;
if (lateness / 60 / 24 / 365 > 1) {
lateness = Math.floor(lateness / 60 / 24 / 365);
units = "year";
} else if (lateness / 60 / 24 / 30 > 1) {
lateness = Math.floor(lateness / 60 / 24 / 30);
units = "month";
} else if (lateness / 60 / 24 / 7 > 1) {
lateness = Math.floor(lateness / 60 / 24 / 7);
units = "week";
} else if (lateness / 60 / 24 > 1) {
lateness = Math.floor(lateness / 60 / 24);
units = lateness == 1 ? "day" : "dai"; // ugh
} else {
lateness = Math.floor(lateness);
units = "minute";
}
units = SLTools.i18n.getMessage(
lateness == 1 ? `single_${units}` : `plural_${units}ly`,
);
const msgSubject = msgHdr.subject;
const warningMsg = messenger.i18n.getMessage("BlockedLateMessage2", [
msgSubject,
msgHdr.folder.path,
lateness,
units,
]);
const warningTitle = messenger.i18n.getMessage(
"ScheduledMessagesWarningTitle",
);
SLTools.alert(warningTitle, warningMsg);
await locker.lock(msgHdr, fullMsg, "late");
}
return false;
},
async checkTimeRestrictions(
preferences,
recur,
skipping,
originalMsgId,
msgHdr,
) {
if (!preferences.enforceTimeRestrictions) return true;
// Respect "until" preference
if (recur.until) {
if (SLTools.compareTimes(Date.now(), ">", recur.until)) {
(skipping ? SLTools.error : SLTools.debug)(
`Message ${msgHdr.id} ${originalMsgId} past ` +
`"until" restriction. Skipping.`,
);
return false;
}
}
// Respect "send between" preference
if (!skipping && recur.between) {
if (
SLTools.compareTimes(Date.now(), "<", recur.between.start) ||
SLTools.compareTimes(Date.now(), ">", recur.between.end)
) {
// Skip message this time, but don't explicitly reschedule it.
SLTools.debug(
`Message ${msgHdr.id} ${originalMsgId} outside of ` +
`sendable time range. Skipping.`,
recur.between,
);
return false;
}
}
// Respect "only on days of week" preference
if (!skipping && recur.days) {
const today = new Date().getDay();
if (!recur.days.includes(today)) {
// Reschedule for next valid time.
const start_time = recur.between && recur.between.start;
const end_time = recur.between && recur.between.end;
let nextRecurAt = SLTools.adjustDateForRestrictions(
new Date(),
start_time,
end_time,
recur.days,
false,
);
while (nextRecurAt < new Date()) {
nextRecurAt = new Date(nextRecurAt.getTime() + 60000);
}
const this_wkday = new Intl.DateTimeFormat("default", {
weekday: "long",
});
SLTools.info(
`Message ${msgHdr.id} not scheduled to send on ` +
`${this_wkday.format(new Date())}. Rescheduling ` +
`for ${nextRecurAt}`,
);
let newMsgContent = await messenger.messages.getRaw(msgHdr.id);
newMsgContent = SLTools.replaceHeader(
newMsgContent,
"X-Send-Later-At",
SLTools.parseableDateTimeFormat(nextRecurAt),
false,
);
if (preferences.scheduledDateField) {
newMsgContent = SLTools.replaceHeader(
newMsgContent,
"Date",
SLTools.parseableDateTimeFormat(nextRecurAt),
false /* replaceAll */,
true /* addIfMissing */,
);
}
let file = getFileFromRaw(newMsgContent);
let success = await messageImport(file, msgHdr.folder, {
new: false,
read: preferences.markDraftsRead,
});
if (success) {
SLTools.debug(
`Rescheduled message ${originalMsgId}. Deleting original.`,
);
await SendLater.deleteMessage(msgHdr);
} else {
SLTools.error("Unable to schedule next recurrence.");
}
return false;
}
}
return true;
},
async doSendMessage(
preferences,
options,
locker,
originalMsgId,
msgHdr,
msgLockId,
fullMsg,
) {
// Initiate send from draft message
SLTools.info(`Sending message ${originalMsgId}.`);
// "Why do we have to iterate through local accounts?" you ask. "Isn't
// there just one Local Folders account?" Well, sure, that's normally the
// case, but it's apparently possible to have multiple local accounts. See,
// for example, https://addons.thunderbird.net/thunderbird/addon/
// localfolder/. So we need to find the local account that has the Outbox
// in it.
let outboxFolder;
let localAccounts = (await messenger.accounts.list(false)).filter(
(account) => account.type == "none",
);
for (let localAccount of localAccounts) {
let localFolders = await messenger.folders.getSubFolders(
await SLTools.tb128(localAccount.id, localAccount),
);
for (let localFolder of localFolders) {
if (localFolder.type == "outbox") {
outboxFolder = localFolder;
break;
}
}
if (outboxFolder) break;
}
if (!outboxFolder) {
SLTools.error("Could not find outbox folder to deliver message");
return false;
}
let content = await SLTools.prepNewMessageHeaders(
await messenger.messages.getRaw(msgHdr.id),
);
const identityId =
options.identityId ?? (await findBestIdentity(msgHdr, fullMsg)).id;
content = SLTools.replaceHeader(
content,
"X-Identity-Key",
identityId,
true,
true,
);
let file = getFileFromRaw(content);
let success = await messageImport(file, outboxFolder, {
new: false,
read: true,
});
SLTools.telemetrySend({
event: "delivery",
successful: success,
});
if (success) {
if (preferences.sendUnsentMsgs) {
setTimeout(messenger.SL3U.queueSendUnsentMessages, 1000);
}
await locker.lock(msgHdr, fullMsg, true);
SLTools.debug(`Locked message <${msgLockId}> from re-sending.`);
} else {
SLTools.error(
`Something went wrong while sending message ${originalMsgId}`,
);
}
return success;
},
async doNextRecur(
preferences,
locker,
originalMsgId,
msgHdr,
fullMsg,
recur,
nextSend,
msgRecurSpec,
args,
) {
let nextRecur;
if (recur.type !== "none") {
nextRecur = await SLTools.nextRecurDate(
nextSend,
msgRecurSpec,
new Date(),
args,
);
}
if (!nextRecur) return false;
try {
let nextRecurAt = nextRecur.sendAt;
let nextRecurSpec = nextRecur.nextspec;
let nextRecurArgs = nextRecur.nextargs;
while (nextRecurAt < new Date()) {
nextRecurAt = new Date(nextRecurAt.getTime() + 60000);
}
SLTools.info(`Scheduling next recurrence of message ${originalMsgId}`, {
nextRecurAt,
nextRecurSpec,
nextRecurArgs,
});
let newMsgContent = await messenger.messages.getRaw(msgHdr.id);
// Replace Message-ID so we don't send different messages with
// the same message ID.
let identity = await findBestIdentity(msgHdr, fullMsg);
newMsgContent = SLTools.replaceHeader(
newMsgContent,
"Message-ID",
await SLTools.generateMessageID(identity),
);
newMsgContent = SLTools.replaceHeader(
newMsgContent,
"Date",
SLTools.parseableDateTimeFormat(
SendLater.prefCache.scheduledDateField ? nextRecurAt : Date.now(),
),
false /* replaceAll */,
true /* addIfMissing */,
);
newMsgContent = SLTools.replaceHeader(
newMsgContent,
"X-Send-Later-At",
SLTools.parseableDateTimeFormat(nextRecurAt),
false,
);
if (typeof nextRecurSpec === "string") {
newMsgContent = SLTools.replaceHeader(
newMsgContent,
"X-Send-Later-Recur",
nextRecurSpec,
false,
true,
);
}
if (typeof nextRecurArgs === "object") {
newMsgContent = SLTools.replaceHeader(
newMsgContent,
"X-Send-Later-Args",
SLTools.unparseArgs(nextRecurArgs),
false,
true,
);
}
let file = getFileFromRaw(newMsgContent);
let success = await messageImport(file, msgHdr.folder, {
new: false,
read: preferences.markDraftsRead,
});
SLTools.telemetrySend({ event: "scheduleNext", successful: success });
if (success) {
SLTools.info(
`Scheduled next occurrence of message ` +
`<${originalMsgId}>. Deleting original.`,
);
} else {
throw new Error("Unable to schedule next recurrence.");
}
} catch (ex) {
await locker.lock(msgHdr, fullMsg, "rescheduling");
SLTools.error("Error scheduling next recurrence", ex);
let title = SLTools.i18n.getMessage("RescheduleErrorTitle");
const msgSubject = msgHdr.subject;
let text = SLTools.i18n.getMessage("RescheduleErrorText", [msgSubject]);
SLTools.alert(title, text);
return true;
}
await SendLater.deleteMessage(msgHdr);
return true;
},
// Given a MessageHeader object, identify whether the message is
// scheduled, and due to be sent. If so, make sure it qualifies for
// sending (not encrypted, not sent previously, not past the late
// message limit), and then send it. If it was a recurring message,
// handle rescheduling its next recurrence, otherwise just delete
// the draft copy.
// TODO: Break this up into more manageable parts. This function is
// ridiculously long.
async possiblySendMessage(msgHdr, options, locker) {
SLTools.trace("possiblySendMessage", msgHdr, options, locker);
let msgId = msgHdr.id;
let logPrefix = `possiblySendMessage(${msgId}): `;
let throttleStart = Date.now();
if (!options) {
options = {};
}
let skipping = options.skipping;
if (!locker) {
locker = await new Locker();
}
// Determines whether or not a particular draft message is due to be sent
if (unscheduledMsgCache.has(msgId)) {
SLTools.debug(`${logPrefix}unscheduledMsgCache.has returns true`);
return;
}
SLTools.debug(`Checking message ${msgId}.`);
const fullMsg =
options.messageFull || (await messenger.messages.getFull(msgId));
if (!fullMsg.headers.hasOwnProperty("x-send-later-at")) {
unscheduledMsgCache.add(msgId);
SLTools.debug(`${logPrefix}no x-send-later-at`);
return;
}
const originalMsgId = msgHdr.headerMessageId;
const contentType = fullMsg.contentType;
const msgSendAt = (fullMsg.headers["x-send-later-at"] || [])[0];
const msgUUID = (fullMsg.headers["x-send-later-uuid"] || [])[0];
const msgRecurSpec = (fullMsg.headers["x-send-later-recur"] || [])[0];
const msgRecurArgs = (fullMsg.headers["x-send-later-args"] || [])[0];
const msgLockId = `${originalMsgId}/${msgHdr.date}`;
const nextSend = new Date(msgSendAt);
if (!SendLater.checkEncryption(contentType, originalMsgId, msgHdr)) {
SLTools.debug(`${logPrefix}checkEncryption returns false`);
return;
}
let preferences = await SLTools.getPrefs();
if (!preferences.sendWhileOffline && !window.navigator.onLine) {
SLTools.debug(
`${logPrefix}Send Later is configured to disable sending while offline. Skipping.`,
);
return;
}
if (!msgUUID) {
SLTools.debug(
`${logPrefix}Message <${originalMsgId}> has no uuid header.`,
);
unscheduledMsgCache.add(msgId);
return;
}
if (msgUUID !== preferences.instanceUUID) {
(skipping ? SLTools.error : SLTools.debug)(
`${logPrefix}Message <${originalMsgId}> is scheduled by a ` +
`different Thunderbird instance.`,
);
unscheduledMsgCache.add(msgId);
return;
}
if (
!(await SendLater.checkLocked(
preferences,
locker,
originalMsgId,
msgHdr,
msgLockId,
fullMsg,
))
) {
SLTools.debug(`${logPrefix}checkLocked returns false`);
return;
}
if (!skipping && Date.now() < nextSend.getTime()) {
SLTools.debug(
`${logPrefix}Message ${msgId} not due for send until ` +
`${SLTools.humanDateTimeFormat(nextSend)}`,
);
return;
}
const recur = SLTools.parseRecurSpec(msgRecurSpec);
const args = msgRecurArgs ? SLTools.parseArgs(msgRecurArgs) : null;
if (
!(
skipping ||
(await SendLater.checkLate(
preferences,
locker,
nextSend,
msgHdr,
fullMsg,
))
)
) {
SLTools.debug(`${logPrefix}checkLate returns false`);
return;
}
if (
!(await SendLater.checkTimeRestrictions(
preferences,
recur,
skipping,
originalMsgId,
msgHdr,
))
) {
SLTools.debug(`${logPrefix}checkTimeRestrictions returns false`);
return;
}
if (
!(
skipping ||
(await SendLater.doSendMessage(
preferences,
options,
locker,
originalMsgId,
msgHdr,
msgLockId,
fullMsg,
))
)
) {
SLTools.debug(`${logPrefix}doSendMessage returns false`);
return;
}
if (
!(await SendLater.doNextRecur(
preferences,
locker,
originalMsgId,
msgHdr,
fullMsg,
recur,
nextSend,
msgRecurSpec,
args,
)) &&
!skipping
) {
SLTools.info(
`${logPrefix}No recurrences for message <${originalMsgId}>. Deleting original.`,
);
await SendLater.deleteMessage(msgHdr);
}
if (!skipping && preferences.throttleDelay) {
SLTools.debug(
`${logPrefix}Throttling send rate: ${
preferences.throttleDelay / 1000
}s`,
);
let throttleDelta = Date.now() - throttleStart;
let delay = preferences.throttleDelay - throttleDelta;
await new Promise((resolve) => setTimeout(resolve, delay));
}
SLTools.debug(`${logPrefix}returning true at end of function`);
return true;
},
async updatePreferences() {
let { preferences, ufuncs } = await messenger.storage.local.get({
preferences: {},
ufuncs: {},
});
// (Re-)load the built-in user functions
if (!ufuncs.ReadMeFirst) {
ufuncs.ReadMeFirst = {
help: messenger.i18n.getMessage("EditorReadMeHelp"),
body: messenger.i18n.getMessage("EditorReadMeCode"),
};
}
if (!ufuncs.BusinessHours) {
ufuncs.BusinessHours = {
help: messenger.i18n.getMessage("BusinessHoursHelp"),
body: messenger.i18n.getMessage("_BusinessHoursCode"),
};
}
if (!ufuncs.DaysInARow) {
ufuncs.DaysInARow = {
help: messenger.i18n.getMessage("DaysInARowHelp"),
body: messenger.i18n.getMessage("DaysInARowCode"),
};
}
if (!ufuncs.Delay) {
ufuncs.Delay = {
help: messenger.i18n.getMessage("DelayFunctionHelp"),
body: "next = new Date(Date.now() + args[0]*60000);",
};
}
let prefDefaults = await SLTools.prefDefaults();
// Pick up any new properties from defaults
for (let prefName of Object.getOwnPropertyNames(prefDefaults)) {
if (preferences[prefName] === undefined) {
const prefValue = prefDefaults[prefName][1];
SLTools.info(`Added new preference ${prefName}: ${prefValue}`);
preferences[prefName] = prefValue;
}
}
if (preferences.instanceUUID) {
SLTools.info(`This instance's UUID: ${preferences.instanceUUID}`);
} else {
let instance_uuid = SLTools.generateUUID();
SLTools.info(`Generated new UUID: ${instance_uuid}`);
preferences.instanceUUID = instance_uuid;
}
// Needed for the time being for the Mail Merge add-on
messenger.SL3U.setLegacyPref(
"instance.uuid",
"string",
preferences.instanceUUID,
);
if (preferences.checkTimePref_isMilliseconds) {
preferences.checkTimePref /= 60000;
delete preferences.checkTimePref_isMilliseconds;
}
if (typeof preferences.checkTimePref == "string") {
// 2023-09-01 Old versions of the code didn't handle this properly. We
// can presumably eventually delete this backward compatibility fix.
let value = Number(preferences.checkTimePref);
if (isNaN(value)) {
SLTools.error(
`Invalid value ${preferences.checkTimePref} for checkTimePref ` +
`preference, reverting to default value 1`,
);
value = 1;
}
preferences.checkTimePref = value;
}
await messenger.storage.local.set({ preferences, ufuncs });
},
async updateStatusIndicator(nActive, waitFor) {
SLTools.debug(`updateStatusIndicator(${nActive})`);
if (waitFor) {
SLTools.debug("updateStatusIndicator waiting");
await waitFor;
SLTools.debug("updateStatusIndicator done waiting");
}
let extName = messenger.i18n.getMessage("extensionName");
if (nActive == undefined) nActive = await countActiveScheduledMessages();
if (nActive) {
await messenger.browserAction.setTitle({
title: `${extName} [${messenger.i18n.getMessage("PendingMessage", [
nActive,
])}]`,
});
await messenger.browserAction.setBadgeText({ text: String(nActive) });
} else {
await messenger.browserAction.setTitle({
title: `${extName} [${messenger.i18n.getMessage("IdleMessage")}]`,
});
await messenger.browserAction.setBadgeText({ text: null });
}
},
// This function sets the quit notifications to enabled or disabled. If
// enabled, it checks to see if there are any active scheduled messages. If
// there are, it sets the quit requested and quit granted alerts. If there
// are no active scheduled messages, it removes the quit requested and quit
// granted observers.
async setQuitNotificationsEnabled(enabled, prefs, nActive) {
if (enabled) {
if (!prefs) prefs = await SLTools.getPrefs();
enabled =
prefs.askQuit && prefs.sendDrafts && (prefs.checkTimePref || 0) > 0;
}
if (enabled) {
if (nActive == undefined) nActive = await countActiveScheduledMessages();
enabled = nActive > 0;
}
if (!enabled) {
await messenger.quitter.removeQuitRequestedObserver();
await messenger.quitter.removeQuitGrantedObserver();
return;
}
let appName = (await messenger.runtime.getBrowserInfo()).name;
let extensionName = messenger.i18n.getMessage("extensionName");
let title =
messenger.i18n.getMessage("scheduledMessagesWarningTitle") +
" - " +
extensionName;
let requestWarning = messenger.i18n.getMessage(
"scheduledMessagesWarningQuitRequested",
appName,
);
let grantedWarning = messenger.i18n.getMessage(
"ScheduledMessagesWarningQuit",
appName,
);
await messenger.quitter.setQuitRequestedAlert(title, requestWarning);
await messenger.quitter.setQuitGrantedAlert(title, grantedWarning);
},
async init() {
SLTools.startupLogVersionInfo();
// Add listeners to the various events we care about
messenger.alarms.onAlarm.addListener(alarmsListener);
messenger.windows.onCreated.addListener(SendLater.onWindowCreatedListener);
messenger.SL3U.onKeyCode.addListener(SendLater.onUserCommandKeyListener);
messenger.runtime.onMessageExternal.addListener(
SendLater.onMessageExternalListener,
);
messenger.runtime.onMessage.addListener(
SendLater.onRuntimeMessageListenerasync,
);
messenger.messageDisplay.onMessageDisplayed.addListener(
SendLater.onMessageDisplayedListener,
);
messenger.commands.onCommand.addListener(SendLater.onCommandListener);
messenger.composeAction.onClicked.addListener(
SendLater.clickComposeListener,
);
// Perform any necessary preference updates
await this.updatePreferences();
let preferences = await SLTools.getPrefs();
SendLater.prefCache = preferences;
// Update shortcut key bindings
await updateShortcuts(preferences);
messenger.messages.onNewMailReceived.addListener(
SendLater.onNewMailReceivedListener,
);
// Set custom DB headers preference, if not already set.
try {
await messenger.SL3U.setCustomDBHeaders([
"x-send-later-at",
"x-send-later-recur",
"x-send-later-args",
"x-send-later-cancel-on-reply",
"x-send-later-uuid",
"content-type",
]);
} catch (ex) {
SLTools.error("SL3U.setCustomDBHeaders", ex);
}
// Clear the current message settings cache
await messenger.storage.local.set({ scheduleCache: {} });
try {
await messenger.SL3U.setLogConsoleLevel(preferences.logConsoleLevel);
let nActive = await countActiveScheduledMessages();
await SendLater.updateStatusIndicator(nActive);
await SendLater.setQuitNotificationsEnabled(true, preferences, nActive);
await messenger.browserAction.setLabel({
label: preferences.showStatus
? messenger.i18n.getMessage("sendlater3header.label")
: "",
});
} catch (ex) {
SLTools.error(ex);
}
// Attach to all existing msgcompose windows
try {
await messenger.SL3U.hijackComposeWindowKeyBindings();
} catch (ex) {
SLTools.error("SL3U.hijackComposeWindowKeyBindings", ex);
}
// This listener should be added *after* all of the storage-related
// initialization is complete. It ensures that subsequent changes to storage
// take effect immediately.
messenger.storage.local.onChanged.addListener(
SendLater.storageChangedListener,
);
this.scheduleMenuId = await messenger.menus.create({
contexts: ["message_list"],
title: messenger.i18n.getMessage("menuScheduleMsg"),
});
this.skipMenuId = await messenger.menus.create({
contexts: ["message_list"],
title: messenger.i18n.getMessage("menuSkipMsg"),
});
this.claimMenuId = await messenger.menus.create({
contexts: ["message_list"],
title: messenger.i18n.getMessage("menuClaimMsg"),
});
this.menuVisible = true;
messenger.menus.onClicked.addListener(async (info, tab) => {
SendLater.menuClickHandler(info, tab);
});
messenger.menus.onShown.addListener(async (info, tab) => {
SendLater.checkMenu(info, tab);
});
let slVersionString = messenger.runtime.getManifest().version;
let oldVersion = preferences.releaseNotesVersion;
if (
preferences.showBetaAlert &&
slVersionString != oldVersion &&
(await SLTools.isOnBetaChannel())
) {
let title = messenger.i18n.getMessage("betaThankYouTitle");
let extensionName = messenger.i18n.getMessage("extensionName");
let text = messenger.i18n.getMessage("betaThankYouText", [
extensionName,
]);
SLTools.alertCheck(title, text, null, true).then(async (result) => {
if (result.check === false) {
let preferences = await SLTools.getPrefs();
preferences.showBetaAlert = false;
await messenger.storage.local.set({ preferences });
}
});
}
if (preferences.releaseNotesShow) {
SLTools.debug("Checking if release notes should be displayed");
let slVersion = slVersionString.split(".").map((n) => {
return parseInt(n);
});
if (!oldVersion) {
oldVersion = "0.0.0";
}
oldVersion = oldVersion.split(".").map((n) => {
return parseInt(n);
});
if (
!oldVersion.length ||
slVersion[0] > oldVersion[0] ||
(slVersion[0] == oldVersion[0] && slVersion[1] > oldVersion[1])
) {
SLTools.debug("Displaying release notes");
await SendLater.onRuntimeMessageListenerasync(
{ action: "showReleaseNotes" },
{},
);
} else {
SLTools.debug("Release notes display not needed");
}
} else {
SLTools.debug("Release notes display not wanted");
}
preferences.releaseNotesVersion = slVersionString;
await messenger.storage.local.set({
preferences,
});
if (!preferences.telemetryAsked) {
await messenger.windows.create({
url: "ui/telemetry.html",
type: "popup",
allowScriptsToClose: true,
});
}
await SLTools.ufuncCompatibilityWarning();
},
async menuClickHandler(info, tab) {
SLTools.trace("SendLater.scheduleSelectedMessages", info, tab);
let messageIds = await Array.fromAsync(
getMessageIds(info.selectedMessages),
);
if (!messageIds.length) return;
if (info.menuItemId == this.scheduleMenuId) {
let queryString = messageIds.map((id) => `messageId=${id}`).join("&");
await messenger.windows.create({
allowScriptsToClose: true,
type: "popup",
url: `ui/popup.html?${queryString}`,
});
} else if (info.menuItemId == this.skipMenuId) {
await SendLater.handleMessageCommand(
SendLater.doSkipNextOccurrence,
{
messageChecker: SendLater.checkSkipNextOccurrence,
batchMode: true,
},
null,
messageIds,
);
} else if (info.menuItemId == this.claimMenuId) {
await SendLater.handleMessageCommand(
SendLater.doClaimMessage,
{
messageChecker: SendLater.checkClaimMessage,
batchMode: true,
},
null,
messageIds,
);
} else {
SLTools.error(`Unrecognized menu item ID ${info.menuItemId}`);
}
},
async checkMenu(info, tab) {
SLTools.trace("SendLater.checkScheduleMenu", info, tab);
if (!(info && info.displayedFolder)) return;
let visible = info.displayedFolder.type == "drafts";
if (SendLater.menuVisible != visible) {
SLTools.debug(`Making menu items ${visible ? "" : "in"}visible`);
for (let menuId of [
this.scheduleMenuId,
this.skipMenuId,
this.claimMenuId,
]) {
await messenger.menus.update(menuId, { visible: visible });
}
await messenger.menus.refresh();
SendLater.menuVisible = visible;
}
},
async storageChangedListener(changes) {
// You *must not* log anything in a storage changed listener until you've
// confirmed that the stuff you care about has actually changed, or you will
// cause an infinite logging loop when local storage logging is enabled,
// because in that case logging causes storage to change!
if (changes.preferences) {
SLTools.debug("Propagating changes from local storage");
const preferences = changes.preferences.newValue;
if (preferences.checkTimePref) {
rescheduleDeferred("mainLoop", preferences.checkTimePref * 60000);
}
SendLater.prefCache = preferences;
await messenger.SL3U.setLogConsoleLevel(preferences.logConsoleLevel);
await SendLater.setQuitNotificationsEnabled(true, preferences);
await messenger.browserAction.setLabel({
label: preferences.showStatus
? messenger.i18n.getMessage("sendlater3header.label")
: "",
});
await updateShortcuts(preferences);
}
},
// When user opens a new messagecompose window, we need to do several things
// to ensure that it behaves as they expect. namely, we need to override the
// window's send and sendlater menu items, we need to ensure the toolbar is
// visible, and we need to check whether they're editing a previously
// scheduled draft.
async onWindowCreatedListener(window) {
if (window.type != "messageCompose") {
SLTools.debug("Not a messageCompose window");
return;
}
let resolver = SendLater.windowCreatedResolver;
SendLater.windowCreatedResolver = null;
try {
await SendLater.setUpWindow(window);
if (resolver) {
resolver(true);
}
} catch (ex) {
if (resolver) {
resolver(false);
}
throw ex;
}
},
async setUpWindow(window) {
// Wait for window to fully load
window = await messenger.windows.get(window.id, { populate: true });
SLTools.info("Opened new window", window);
// Bind listeners to overlay components like File>Send,
// Send Later, and keycodes like Ctrl+enter, etc.
try {
await messenger.SL3U.hijackComposeWindowKeyBindings(window.id);
} catch (ex) {
SLTools.error("SL3U.hijackComposeWindowKeyBindings", ex);
}
let tab = window.tabs[0];
let cd = await messenger.compose.getComposeDetails(tab.id);
SLTools.debug("Opened window with composeDetails", cd);
// Check if we're editing an existing draft message
if (cd.type != "draft") {
SLTools.debug("Not editing an existing draft");
return;
}
let originalMsg = await messenger.SL3U.findAssociatedDraft(window.id);
if (originalMsg) {
let originalMsgPart = await messenger.messages.getFull(originalMsg.id);
scheduledMsgCache.delete(originalMsg.id);
unscheduledMsgCache.add(originalMsg.id);
// Check if original message has x-send-later headers
if (originalMsgPart.headers.hasOwnProperty("x-send-later-at")) {
let { preferences, scheduleCache } = await messenger.storage.local.get(
{
preferences: {},
scheduleCache: {},
},
);
// Re-save message (drops x-send-later headers by default
// because they are not loaded when editing as draft).
let { messages } = await messenger.compose.saveMessage(tab.id, {
mode: "draft",
});
let msg = messages[0];
// https://bugzilla.mozilla.org/show_bug.cgi?id=1855487
//
// This is a workaround for a TB bug. If we're editing a draft in a
// subfolder of the main Drafts folder, then when we save the draft
// above it sometimes gets saved to the server without the Send Later
// headers as expected, but TB doesn't realize that, i.e., it's still
// got the SL headers in the message database and if you view the
// message source you'll see them, even though they're not in the copy
// on the server! However, if we wait until the message is visible in
// the drafts folder and then save it again, the new save overwrites
// the old one _and_ the headers go away like they're supposed to.
if (originalMsg.folder.path != msg.folder.path) {
let found = await SLTools.waitForMessage(msg);
({ messages } = await messenger.compose.saveMessage(tab.id, {
mode: "draft",
}));
if (!found && messages[0].id != msg.id) {
SendLater.deleteMessage(msg);
}
msg = messages[0];
}
// Courtesy of https://bugzilla.mozilla.org/show_bug.cgi?id=263114, if
// the draft we're editing started in a subfolder than TB may not
// delete the original draft automatically.
if (msg.id != originalMsg.id) {
try {
SendLater.deleteMessage(originalMsg);
} catch (ex) {}
}
await SendLater.updateStatusIndicator();
// Set popup scheduler defaults based on original message
scheduleCache[window.id] = SLTools.parseHeadersForPopupUICache(
originalMsgPart.headers,
);
SLTools.debug(
`Schedule cache item added for window ${window.id}:`,
scheduleCache[window.id],
);
await messenger.storage.local.set({ scheduleCache });
// Alert the user about what just happened
if (preferences.showEditAlert) {
let draftSaveWarning = messenger.i18n.getMessage("draftSaveWarning");
let result = await SLTools.alertCheck(
null,
draftSaveWarning,
null,
true,
);
let preferences = await SLTools.getPrefs();
preferences.showEditAlert = result.check;
await messenger.storage.local.set({ preferences });
}
}
}
},
async openPopup() {
if (!(await SendLater.schedulePrecheck())) {
return false;
}
SLTools.info("Opening popup");
async function detachedPopup() {
let tab = (await browser.tabs.query({ currentWindow: true }))[0];
let params = {
allowScriptsToClose: true,
type: "popup",
url: `ui/popup.html?tabId=${tab.id}`,
};
if (
SendLater.prefCache.detachedPopupWindowWidth &&
SendLater.prefCache.detachedPopupWindowHeight
) {
params.width = SendLater.prefCache.detachedPopupWindowWidth;
params.height = SendLater.prefCache.detachedPopupWindowHeight;
}
if (!(await messenger.windows.create(params))) {
SLTools.error("standalone scheduling pop-up failed to open");
}
}
await SLTools.tb128(
async () => {
if (SendLater.prefCache.detachedPopup) return await detachedPopup();
// The onClicked event on the compose action button doesn't fire if a
// pop-up is configured, so we have to set and open the popup here and
// then immediately unset the popup so that we can catch the key binding
// if the user clicks again with a modifier.
messenger.composeAction.setPopup({ popup: "ui/popup.html" });
try {
if (!(await messenger.composeAction.openPopup())) {
SLTools.info(
"composeAction pop-up failed to open, trying standalone",
);
await detachedPopup();
}
} finally {
messenger.composeAction.setPopup({ popup: null });
}
},
async () => {
await detachedPopup();
},
);
},
// Custom events that are attached to user actions within
// composition windows. These events occur when the user activates
// the built-in send or send later using either key combinations
// (e.g. ctrl+shift+enter), or click the file menu buttons.
async onUserCommandKeyListener(keyid) {
SLTools.info(`Received keycode ${keyid}`);
switch (keyid) {
case "key_altShiftEnter": {
if (SendLater.prefCache.altBinding) {
await SendLater.openPopup();
} else {
SLTools.info(
"Ignoring Alt+Shift+Enter on account of user preferences",
);
}
break;
}
case "key_sendLater": {
// User pressed ctrl+shift+enter
SLTools.debug("Received Ctrl+Shift+Enter.");
if (SendLater.prefCache.altBinding) {
SLTools.info(
"Passing Ctrl+Shift+Enter along to builtin send " +
"later because user bound alt+shift+enter instead.",
);
let curTab = await SLTools.getActiveComposeTab();
if (curTab) {
await messenger.compose.sendMessage(curTab.id, {
mode: "sendLater",
});
}
} else {
await SendLater.openPopup();
}
break;
}
case "cmd_sendLater": {
// User clicked the "Send Later" menu item, which should always
// open the Send Later popup.
await SendLater.openPopup();
break;
}
case "cmd_sendNow":
case "cmd_sendButton":
case "key_send": {
if (
SendLater.prefCache.sendDoesSL &&
!(await SendLater.messageWhitelisted())
) {
if (!(await SendLater.schedulePrecheck())) {
return false;
}
await SendLater.openPopup();
} else if (
SendLater.prefCache.sendDoesDelay &&
!(await SendLater.messageWhitelisted())
) {
if (!(await SendLater.schedulePrecheck())) {
return false;
}
// Schedule with delay.
const sendDelay = SendLater.prefCache.sendDelay;
SLTools.info(`Scheduling Send Later ${sendDelay} minutes from now.`);
let curTab = await SLTools.getActiveComposeTab();
if (curTab) {
await SendLater.scheduleSendLater(curTab.id, { delay: sendDelay });
}
} else {
let curTab = await SLTools.getActiveComposeTab();
if (curTab) {
if (keyid == "key_send") {
await messenger.SL3U.SendMessageWithCheck(curTab.id);
} else {
await messenger.compose.sendMessage(curTab.id, {
mode: "sendNow",
});
}
}
}
break;
}
default: {
SLTools.error(`Unrecognized keycode ${keyid}`);
}
}
},
async messageWhitelisted() {
if (!SendLater.prefCache.whitelistName) return false;
let addressBook = (await messenger.addressBooks.list(false)).find(
(b) => b.name == SendLater.prefCache.whitelistName,
);
if (!addressBook) {
SLTools.warn(`Could not find address book ${addressBook}`);
return false;
}
addressBook = await messenger.addressBooks.get(addressBook.id, true);
let whitelist = SendLater.addressBookToEmails(addressBook);
let tab = await SLTools.getActiveComposeTab();
let cd = await messenger.compose.getComposeDetails(tab.id);
SLTools.debug("Recipients:", cd.bcc, cd.cc, cd.to);
return (
(await SendLater.recipientsWhitelisted(whitelist, cd.bcc)) &&
(await SendLater.recipientsWhitelisted(whitelist, cd.cc)) &&
(await SendLater.recipientsWhitelisted(whitelist, cd.to))
);
},
addressBookToEmails(addressBook) {
SLTools.debug("addressBookToEmails", addressBook);
return addressBook.contacts.map(SendLater.contactToEmails).flat();
},
contactToEmails(contact) {
let vcard = contact.properties.vCard;
vcard = new ICAL.Component(ICAL.parse(vcard));
SLTools.debug("contactToEmails: vcard=", vcard);
return vcard.getAllProperties("email").map((e) => e.jCal[3]);
},
async recipientToEmails(recipient) {
if (typeof recipient == "string") {
// Name <email> or just email
let match = /<([^<]+)>$/.exec(recipient);
if (!match) return [recipient];
return [match[1]];
} else if (typeof recipient == "object") {
// id, type == contact or mailingList
if (recipient.type == "mailingList") return false;
else if (recipient.type != "contact") return false;
let contact = await messenger.contacts.get(recipient.id);
if (!contact) return undefined;
return SendLater.contactToEmails(contact);
}
return undefined;
},
async recipientsWhitelisted(whitelist, recipients) {
if (Array.isArray(recipients)) {
for (let recipient of recipients) {
let ret = await SendLater.recipientsWhitelisted(whitelist, recipient);
if (!ret) return false;
}
return true;
}
let emails = await SendLater.recipientToEmails(recipients);
if (!emails) return false;
return emails.some((e) => whitelist.includes(e));
},
async setPreferencesMessage(new_prefs) {
let prefKeys = await SLTools.userPrefKeys(false);
let old_prefs = await SLTools.getPrefs();
for (const prop in new_prefs) {
if (!prefKeys.includes(prop)) {
throw new Error(
`Property ${prop} is not a valid Send Later preference.`,
);
}
if (
prop in old_prefs &&
typeof old_prefs[prop] != "undefined" &&
typeof new_prefs[prop] != "undefined" &&
typeof old_prefs[prop] != typeof new_prefs[prop]
) {
throw new Error(
`Type of ${prop} is invalid: new ` +
`${typeof new_prefs[prop]} vs. current ` +
`${typeof old_prefs[prop]}.`,
);
}
old_prefs[prop] = new_prefs[prop];
}
await messenger.storage.local.set({ preferences: old_prefs });
return old_prefs;
},
// Allow other extensions to access local preferences
onMessageExternalListener(message, sender, sendResponse) {
switch (message.action) {
case "getUUID": {
// Return Promise for the instanceUUID.
return SLTools.getPrefs()
.then((preferences) => preferences.instanceUUID)
.catch((ex) => SLTools.error(ex));
}
case "getPreferences": {
// Return Promise for the allowed preferences.
let prefKeysPromise = SLTools.userPrefKeys(true);
let prefsPromise = SLTools.getPrefs();
return Promise.all([prefKeysPromise, prefsPromise])
.then(([prefKeys, prefs]) => {
prefs = Object.entries(prefs);
prefs = prefs.filter(([key, value]) => prefKeys.includes(key));
prefs = Object.fromEntries(prefs);
return prefs;
})
.catch((ex) => SLTools.error(ex));
}
case "setPreferences": {
// Return Promise for updating the allowed preferences.
return SendLater.setPreferencesMessage(message.preferences).catch(
(ex) => SLTools.error(ex),
);
}
case "parseDate": {
SLTools.trace("onMessageExternalListener.parseDate");
// Return Promise for the Date. Since this is a sync operation, the
// Promise is already fulfilled. But it still has to be a Promise, or
// sendResponse() has to be used instead. Promise syntax is preferred.
try {
const date = SLTools.convertDate(message["value"]);
if (date) {
const dateStr = SLTools.parseableDateTimeFormat(date.getTime());
return Promise.resolve(dateStr);
}
} catch (ex) {
SLTools.debug("Unable to parse date/time", ex);
}
break;
}
default: {
SLTools.warn(`Unrecognized operation <${message.action}>.`);
}
}
return false;
},
async checkSkipNextOccurrence(options) {
// Is the user sure they want to do this?
if (options.first) {
let preferences = await SLTools.getPrefs();
if (preferences.showSkipAlert) {
const result = await SLTools.confirmCheck(
messenger.i18n.getMessage("AreYouSure"),
messenger.i18n.getMessage("SkipConfirmMessage"),
messenger.i18n.getMessage("ConfirmAgain"),
true,
).catch((err) => {
SLTools.trace(err);
});
if (result.check === false) {
preferences.showSkipAlert = false;
await messenger.storage.local.set({ preferences });
}
if (!result.ok) {
SLTools.debug(`User canceled occurrence skip.`);
return false;
}
}
}
// Is this a recurring message?
let recurHeader = (options.messageFull.headers["x-send-later-recur"] ||
[])[0];
let recur = SLTools.parseRecurSpec(recurHeader);
if (recur.type == "none") {
let title = messenger.i18n.getMessage("cantSkipSingletonTitle");
let text = messenger.i18n.getMessage("cantSkipSingletonText", [
options.messageHeader.subject,
]);
await SLTools.alert(title, text);
return false;
}
// Does the message have another recurrence in the future?
let sendAtHeader = (options.messageFull.headers["x-send-later-at"] ||
[])[0];
if (!sendAtHeader) {
// This should never happen so not bothering with a user message.
SLTools.error(
`Message ${options.messageId} (${options.messageHeader.subject}) ` +
`has recur header but no at header?`,
);
return false;
}
let argsHeader = (options.messageFull.headers["x-send-later-args"] ||
[])[0];
let args = argsHeader ? SLTools.parseArgs(msgRecurArgs) : null;
let sendAt = new Date(sendAtHeader);
let now = new Date();
if (now < sendAt) {
now = sendAt;
}
let nextRecur = await SLTools.nextRecurDate(
sendAt,
recurHeader,
now,
args,
);
if (!nextRecur || !nextRecur.sendAt) {
let title = messenger.i18n.getMessage("cantSkipPastLastTitle");
let text = messenger.i18n.getMessage("cantSkipPastLastText", [
options.messageHeader.subject,
]);
await SLTools.alert(title, text);
return false;
}
return true;
},
async doSkipNextOccurrence(tabId, options, fromMenuCommand) {
SLTools.trace("doSkipNextOccurrence", tabId, options, fromMenuCommand);
if (tabId || !fromMenuCommand) {
SLTools.error("Unsupported doSkipNextOccurrence call from window");
return false;
}
options.skipping = true;
return await SendLater.possiblySendMessage(options.messageHeader, options);
},
async checkClaimMessage(options) {
let preferences = await SLTools.getPrefs();
if (!preferences.sendWhileOffline && !window.navigator.onLine) {
SLTools.warn(
"Send Later is configured to disable sending while offline. Skipping.",
);
return false;
}
return true;
},
// Claim a message previously scheduled by a different instance of Send Later
// without changing anything else about it.
async doClaimMessage(tabId, options, fromMenuCommand) {
if (tabId || !fromMenuCommand) {
SLTools.error("Unsupported doClaimMessages call from window");
return false;
}
let msgHdr = options.messageHeader;
SLTools.debug(`Claiming message ${msgHdr.id}`);
let originalMsgId = msgHdr.headerMessageId;
let fullMsg = options.messageFull;
if (!fullMsg.headers.hasOwnProperty("x-send-later-at")) {
SLTools.warn(`Can't claim unscheduled message ${originalMsgId}`);
unscheduledMsgCache.add(msgHdr.id);
return false;
}
let contentType = fullMsg.contentType;
if (/encrypted/i.test(contentType)) {
SLTools.warn(
`Message ${originalMsgId} is encrypted, and will not ` +
`be processed by Send Later.`,
);
unscheduledMsgCache.add(msgHdr.id);
return false;
}
let msgUUID = (fullMsg.headers["x-send-later-uuid"] || [])[0];
if (!msgUUID) {
SLTools.warn(`Message <${originalMsgId}> has no uuid header.`);
unscheduledMsgCache.add(msgHdr.id);
return false;
}
let preferences = await SLTools.getPrefs();
if (msgUUID == preferences.instanceUUID) {
SLTools.warn(
`Message <${originalMsgId}> is already owned by this Thunderbird ` +
`instance.`,
);
return false;
}
let newMsgContent = await messenger.messages.getRaw(msgHdr.id);
newMsgContent = SLTools.replaceHeader(
newMsgContent,
"X-Send-Later-UUID",
preferences.instanceUUID,
true,
true,
);
newMsgContent = SLTools.replaceHeader(
newMsgContent,
"X-Identity-Key",
options.identityId,
true,
true,
);
let file = getFileFromRaw(newMsgContent);
let success = await messageImport(file, msgHdr.folder, {
new: false,
read: preferences.markDraftsRead,
});
if (success) {
SLTools.debug(`Claimed message ${originalMsgId}. Deleting original.`);
await SendLater.deleteMessage(msgHdr);
return true;
} else {
SLTools.error(`Unable to claim message ${originalMsgId}.`);
return;
}
},
async handleMessageCommand(command, options, tabId, messageIds) {
SLTools.trace("handleMessageCommand", command, options, tabId, messageIds);
options.first = true;
if (messageIds) {
let total = messageIds.length;
let successful = 0;
for (let messageId of messageIds) {
let message = await messenger.messages.get(messageId);
options.messageFull = await messenger.messages.getFull(messageId);
let identityId = (await findBestIdentity(message, options.messageFull))
.id;
options.identityId = identityId;
options.messageId = messageId;
options.messageHeader = message;
// The message checker, if there is one, should return true to proceed
// or false to stop processing any further messages. Individual message
// commands should return false to indicate they were unsuccessful.
if (
options.messageChecker &&
!(await options.messageChecker(options))
) {
break;
}
let tab;
if (!options.batchMode) {
let promise = new Promise((r) => {
SendLater.windowCreatedResolver = r;
});
tab = await messenger.compose.beginNew(messageId, {
identityId: identityId,
});
if (!(await promise)) {
break;
}
}
if (await command(options.batchMode ? null : tab.id, options, true)) {
if (!options.batchMode) await SendLater.deleteMessage(message);
successful++;
} else {
break;
}
options.first = false;
}
if (messageIds.length > 1) {
let title = messenger.i18n.getMessage("resultsTitle");
let text;
if (total == successful) {
text = messenger.i18n.getMessage("resultsAllSuccess");
} else {
text = messenger.i18n.getMessage("resultsPartialSuccess", [
successful,
total,
]);
}
SLTools.alert(title, text);
}
scheduledMsgCache.clear();
unscheduledMsgCache.clear();
setTimeout(SendLater.updateStatusIndicator, 1000);
return total == successful;
} else if (
!options.messageChecker ||
(await options.messageChecker(options))
) {
// N.B. In window mode the message checker doesn't get the message ID,
// message header, or full message.
return await command(tabId, options);
} else {
return false;
}
},
async getSchedule(hdr) {
let draftMsg = await messenger.messages.getFull(hdr.id);
function getHeader(name) {
return (draftMsg.headers[name] || [])[0];
}
if (getHeader("x-send-later-at")) {
let folderPath = hdr.folder.path;
let accountId = hdr.folder.accountId;
let account = await messenger.accounts.get(accountId, false);
let accountName = account.name;
let fullFolderName = accountName + folderPath;
scheduledMsgCache.add(hdr.id);
return {
sendAt: getHeader("x-send-later-at"),
recur: getHeader("x-send-later-recur"),
args: getHeader("x-send-later-args"),
cancel: getHeader("x-send-later-cancel-on-reply"),
subject: hdr.subject,
recipients: hdr.recipients,
folder: fullFolderName,
};
} else {
unscheduledMsgCache.add(hdr.id);
scheduledMsgCache.delete(hdr.id);
return null;
}
},
// Various extension components communicate with
// the background script via these runtime messages.
// e.g. the options page and the scheduler dialog.
async onRuntimeMessageListenerasync(message, sender) {
if (!message.action)
// Not intended for us!
return;
const response = {};
switch (message.action) {
case "alert": {
SLTools.alert(message.title, message.text);
break;
}
case "doSendNow": {
SLTools.debug("User requested send immediately.");
await SendLater.handleMessageCommand(
SendLater.doSendNow,
{
messageChecker: SendLater.checkDoSendNow,
changed: message.changed,
},
message.tabId,
message.messageIds,
);
break;
}
case "doPlaceInOutbox": {
SLTools.debug("User requested system send later.");
await SendLater.handleMessageCommand(
SendLater.doPlaceInOutbox,
{
messageChecker: SendLater.checkDoPlaceInOutbox,
changed: message.changed,
},
message.tabId,
message.messageIds,
);
break;
}
case "doSendLater": {
SLTools.debug("User requested send later.");
const options = {
sendAt: message.sendAt,
recurSpec: message.recurSpec,
args: message.args,
cancelOnReply: message.cancelOnReply,
};
await SendLater.handleMessageCommand(
SendLater.scheduleSendLater,
options,
message.tabId,
message.messageIds,
);
break;
}
case "getMainLoopStatus": {
response.previousLoop =
SendLater.previousLoop && SendLater.previousLoop.getTime();
response.loopMinutes = SendLater.loopMinutes;
if (SendLater.loopMinutes && SendLater.loopExcessTimes) {
let a = SendLater.loopExcessTimes;
response.averageLoopMinutes =
SendLater.loopMinutes +
a.reduce((a, b) => a + b) / a.length / 60000;
} else {
response.averageLoopTimes = null;
}
break;
}
case "getScheduleText": {
try {
const dispMsgHdr =
await messenger.messageDisplay.getDisplayedMessage(message.tabId);
const fullMsg = await messenger.messages.getFull(dispMsgHdr.id);
const preferences = await SLTools.getPrefs();
const msgSendAt = (fullMsg.headers["x-send-later-at"] || [])[0];
const msgUuid = (fullMsg.headers["x-send-later-uuid"] || [])[0];
const msgRecur = (fullMsg.headers["x-send-later-recur"] || [])[0];
const msgArgs = (fullMsg.headers["x-send-later-args"] || [])[0];
const msgCancelOnReply = (fullMsg.headers[
"x-send-later-cancel-on-reply"
] || [])[0];
if (!msgSendAt) {
response.err = "Message is not scheduled by Send Later.";
break;
} else if (msgUuid !== preferences.instanceUUID) {
response.err = messenger.i18n.getMessage("incorrectUUID");
break;
}
const sendAt = new Date(msgSendAt);
const recurSpec = msgRecur || "none";
const recur = SLTools.parseRecurSpec(recurSpec);
recur.cancelOnReply = ["true", "yes"].includes(msgCancelOnReply);
recur.args = msgArgs;
response.scheduleTxt = SLTools.formatScheduleForUI(
{ sendAt, recur },
SendLater.previousLoop,
SendLater.loopMinutes,
);
} catch (ex) {
response.err = ex.message;
}
break;
}
case "getAllSchedules": {
response.schedules = await SLTools.forAllDrafts(
async (draftHdr) => {
if (unscheduledMsgCache.has(draftHdr.id)) {
SLTools.debug(
"Ignoring unscheduled message",
draftHdr.id,
draftHdr,
);
return null;
}
return await SendLater.getSchedule(draftHdr);
},
false, // non-sequential
).then((r) => r.filter((x) => x != null));
break;
}
case "showPreferences": {
messenger.runtime.openOptionsPage();
break;
}
case "showUserGuide": {
let url = SLTools.translationURL(await SLTools.userGuideLink());
messenger.tabs.create({ url });
break;
}
case "showReleaseNotes": {
let url = SLTools.translationURL(
await SLTools.userGuideLink("release-notes"),
);
messenger.tabs.create({ url });
break;
}
case "donateLink": {
let url = SLTools.translationURL(
await SLTools.userGuideLink("#support-send-later"),
);
messenger.tabs.create({ url });
break;
}
case "logLink": {
let url = SLTools.translationURL(
await SLTools.userGuideLink("#support-send-later"),
);
messenger.tabs.create({ url: "ui/log.html" });
break;
}
default: {
SLTools.warn(`Unrecognized operation <${message.action}>.`);
}
}
SLTools.debug(`${message.action} action:`, response);
return response;
},
// Listen for incoming messages, and check if they are in response to a
// scheduled message with a 'cancel-on-reply' header.
async onNewMailReceivedListener(folder, messagelist) {
let skipFolders = [
"sent",
"trash",
"templates",
"archives",
"junk",
"outbox",
"drafts",
];
if (skipFolders.includes(folder.type)) {
SLTools.debug(
`Skipping onNewMailReceived for folder type ${folder.type}`,
);
return;
}
SLTools.debug("Received messages in folder", folder, ":", messagelist);
for await (let rcvdHdr of getMessages(messagelist)) {
let rcvdMsg = await messenger.messages.getFull(rcvdHdr.id);
SLTools.debug("Got message", rcvdHdr, rcvdMsg);
let inReplyTo = (rcvdMsg.headers["in-reply-to"] || [])[0];
if (inReplyTo) {
await SLTools.forAllDrafts(async (draftHdr) => {
if (!unscheduledMsgCache.has(draftHdr.id)) {
SLTools.debug(
"Comparing",
rcvdHdr,
"to",
draftHdr,
inReplyTo,
"?=",
`<${draftHdr.headerMessageId}>`,
);
if (inReplyTo == `<${draftHdr.headerMessageId}>`) {
let draftMsg = await messenger.messages.getFull(draftHdr.id);
let cancelOnReply = (draftMsg.headers[
"x-send-later-cancel-on-reply"
] || [])[0];
if (["true", "yes"].includes(cancelOnReply)) {
SLTools.info(
`Received response to message ${inReplyTo}.`,
`Deleting scheduled draft ${draftHdr.id}`,
);
await SendLater.deleteMessage(draftHdr);
}
}
}
});
}
}
},
// When a new message is displayed, check whether it is scheduled and
// choose whether to show the messageDisplayAction button and the header.
async onMessageDisplayedListener(tab, hdr) {
SLTools.trace("onMessageDisplayedListener");
if (!hdr) {
// No, this shouldn't happen, but it does. It looks like this happens
// if Thunderbird is in the process of displaying a message when the
// user switches to a different folder.
SLTools.debug("onMessageDisplayedListener: no hdr");
return;
}
// TODO currently only display the Send Later header on messages in the
// 3pane window. It would be nice to also display it when a draft is
// opened in a separate tab or window.
let headerName = messenger.i18n.getMessage("sendlater3header.label");
await messenger.messageDisplayAction.disable(tab.id);
if (await SLTools.isDraftsFolder(hdr.folder)) {
SLTools.debug("onMessageDisplayedListener: isDraftsFolder is true");
// Add header row
const preferences = await SLTools.getPrefs();
const instanceUUID = preferences.instanceUUID;
let msgParts = await messenger.messages.getFull(hdr.id);
let hdrs = {
"content-type": msgParts.contentType,
};
for (let hdrName in msgParts.headers) {
hdrs[hdrName] = msgParts.headers[hdrName][0];
}
const { cellText } = customHdrToScheduleInfo(hdrs, instanceUUID);
if (preferences.showHeader === true && cellText !== "") {
try {
SLTools.trace("Calling addCustomHdrRow");
await messenger.headerView.addCustomHdrRow(
tab.id,
headerName,
cellText,
);
} catch (ex) {
SLTools.error("headerView.addCustomHdrRow", ex);
}
} else {
SLTools.trace("Calling removeCustomHdrRow");
await messenger.headerView.removeCustomHdrRow(tab.id, headerName);
}
let msg = await messenger.messages.getFull(hdr.id);
if (msg.headers["x-send-later-uuid"] == instanceUUID) {
await messenger.messageDisplayAction.enable(tab.id);
}
} else {
SLTools.debug("onMessageDisplayedListener: isDraftsFolder is false");
await messenger.headerView.removeCustomHdrRow(tab.id, headerName);
}
},
// Global key shortcuts (defined in manifest) are handled here.
async onCommandListener(cmd) {
const cmdId = /send-later-shortcut-([123])/.exec(cmd)[1];
if (["1", "2", "3"].includes(cmdId)) {
const preferences = await SLTools.getPrefs();
const funcName = preferences[`quickOptions${cmdId}funcselect`];
if (!funcName) {
SLTools.info(`Can't execute empty shortcut ${cmdId}`);
return;
}
const funcArgs = preferences[`quickOptions${cmdId}Args`];
SLTools.info(`Executing shortcut ${cmdId}: ${funcName}(${funcArgs})`);
await SendLater.quickSendWithUfunc(funcName, funcArgs);
}
},
// Compose action button (emulate accelerator keys)
async clickComposeListener(tab, info) {
let mod = info.modifiers.length === 1 ? info.modifiers[0] : undefined;
if (mod === "Command")
// MacOS compatibility
mod = "Ctrl";
if (["Ctrl", "Shift"].includes(mod)) {
const preferences = await SLTools.getPrefs();
const funcName = preferences[`accel${mod}funcselect`];
const funcArgs = preferences[`accel${mod}Args`];
SLTools.info(
`Executing accelerator Click+${mod}: ${funcName}(${funcArgs})`,
);
await SendLater.quickSendWithUfunc(funcName, funcArgs, tab.id);
} else {
// The onClicked event on the compose action button doesn't fire if a
// pop-up is configured, so we have to set and open the popup here and
// then immediately unset the popup so that we can catch the key binding
// if the user clicks again with a modifier.
await SendLater.openPopup();
}
},
// Fully disable the extension without actually removing it. The UI elements
// will still be visible, but they will be disabled and show a message
// indicating that the extension is disabled. This is important for cases
// where the extension failed to fully intialize, so that the user doesn't
// get a false impression that the extension is working.
async disable() {
SLTools.warn("Disabling Send Later.");
await SLTools.nofail(clearDeferred, "mainLoop");
await SLTools.nofail(SendLater.setQuitNotificationsEnabled, false);
await SLTools.nofail(messenger.browserAction.disable);
await SLTools.nofail(messenger.browserAction.setTitle, {
title:
`${messenger.i18n.getMessage("extensionName")} ` +
`[${messenger.i18n.getMessage("DisabledMessage")}]`,
});
await SLTools.nofail(messenger.browserAction.setBadgeText, {
text: null,
});
await SLTools.nofail(messenger.composeAction.disable);
await SLTools.nofail(messenger.messageDisplayAction.disable);
await SLTools.nofail(messenger.messageDisplayAction.setPopup, {
popup: null,
});
await SLTools.nofail(
messenger.alarms.onAlarm.removeListener,
alarmsListener,
);
await SLTools.nofail(
messenger.windows.onCreated.removeListener,
SendLater.onWindowCreatedListener,
);
await SLTools.nofail(
messenger.SL3U.onKeyCode.removeListener,
SendLater.onUserCommandKeyListener,
);
await SLTools.nofail(
messenger.runtime.onMessageExternal.removeListener,
SendLater.onMessageExternalListener,
);
await SLTools.nofail(
messenger.runtime.onMessage.removeListener,
SendLater.onRuntimeMessageListenerasync,
);
await SLTools.nofail(
messenger.messages.onNewMailReceived.removeListener,
SendLater.onNewMailReceivedListener,
);
await SLTools.nofail(
messenger.messageDisplay.onMessageDisplayed.removeListener,
SendLater.onMessageDisplayedListener,
);
await SLTools.nofail(
messenger.commands.onCommand.removeListener,
SendLater.onCommandListener,
);
await SLTools.nofail(
messenger.composeAction.onClicked.removeListener,
SendLater.clickComposeListener,
);
await SLTools.nofail(
messenger.storage.local.onChanged.removeListener,
SendLater.storageChangedListener,
);
SLTools.warn("Disabled.");
},
}; // End SendLater object
async function mainLoop() {
SLTools.debug("Entering main loop.");
try {
clearDeferred("mainLoop");
} catch (ex) {
SLTools.error(ex);
}
try {
// We do this clean at both the beginning and end of the main loop
// because at the beginning there may be cleaning needed as a result of
// messages that were edited or scheduled in the interim, and at the end
// there may be cleaning needed as a result of messages sent and/or
// rescheduled during the main loop.
await SendLater.cleanDrafts();
let preferences = await SLTools.getPrefs();
let interval = preferences.checkTimePref || 0;
let now = new Date();
if (SendLater.loopMinutes && SendLater.previousLoop) {
SendLater.loopExcessTimes.push(
now - SendLater.previousLoop - SendLater.loopMinutes * 60000,
);
SendLater.loopExcessTimes = SendLater.loopExcessTimes.slice(-10);
}
SendLater.loopMinutes = interval;
if (interval > 0) {
SendLater.previousLoop = now;
// Possible refresh icon options (↻ \u8635); or (⟳ \u27F3)
// or (⌛ \u231B) (e.g. badgeText = "\u27F3")
let extName = messenger.i18n.getMessage("extensionName");
let isActiveMessage = messenger.i18n.getMessage("CheckingMessage");
// We do not await for this here because it takes an indeterminatee
// amount of time to run when the Thunderbird window is minimized.
// [noawait]
let enablePromise = messenger.browserAction.enable();
// noawait (indeterminate, see above)
let titlePromise = messenger.browserAction.setTitle({
title: `${extName} [${isActiveMessage}]`,
});
let throttleDelay = preferences.throttleDelay;
try {
if (preferences.sendDrafts) {
let locker = await new Locker();
await SLTools.forAllDrafts(
(message) => SendLater.possiblySendMessage(message, {}, locker),
throttleDelay > 0,
// If we are doing a throttle delay then forAllDrafts needs to wait
// long enough for it to elapse. The "+ 10" we're adding to the
// throttleDelay is to avoid the race condition of the forAllDrafts
// timeout finishing just a wee bit before we finish throttling.
throttleDelay ? Math.max(5000, throttleDelay + 10) : undefined,
preferences,
);
}
let nActive = await countActiveScheduledMessages();
// noawait (indeterminate, see above)
SendLater.updateStatusIndicator(
nActive,
Promise.all([enablePromise, titlePromise]),
);
await SendLater.setQuitNotificationsEnabled(
true,
preferences,
nActive,
);
await SendLater.cleanDrafts();
setDeferred("mainLoop", 60000 * interval, mainLoop);
SLTools.debug(`Next main loop iteration in ${60 * interval} seconds.`);
} catch (err) {
SLTools.error(err);
let nActive = await countActiveScheduledMessages();
await SendLater.updateStatusIndicator(nActive);
await SendLater.setQuitNotificationsEnabled(
true,
preferences,
nActive,
);
setDeferred("mainLoop", 60000, mainLoop);
SLTools.debug(`Next main loop iteration in 1 minute.`);
}
} else {
SendLater.previousLoop = null;
let extName = messenger.i18n.getMessage("extensionName");
let disabledMsg = messenger.i18n.getMessage("DisabledMessage");
await messenger.browserAction.disable();
await messenger.browserAction.setTitle({
title: `${extName} [${disabledMsg}]`,
});
await messenger.browserAction.setBadgeText({ text: null });
setDeferred("mainLoop", 60000, mainLoop);
SLTools.debug(`Next main loop iteration in 1 minute.`);
}
} catch (ex) {
SLTools.error(ex);
setDeferred("mainLoop", 60000, mainLoop);
SLTools.debug(`Next main loop iteration in 1 minute.`);
}
}
let deferredObjects = {};
function alarmsListener(alarm, checking) {
SLTools.debug(`alarmsListener: alarm=${alarm.name}, checking=${checking}`);
let func;
switch (alarm.name) {
case "mainLoop":
func = mainLoop;
break;
default:
throw new Error(`Unknown alarm: ${alarm.name}`);
}
if (checking) {
return true;
}
if (!deferredObjects[alarm.name].triggered) {
SLTools.debug(`alarms.Listener: triggered ${alarm.name}`);
deferredObjects[alarm.name].triggered = true;
func();
} else {
SLTools.debug(`alarmsListener: ${alarm.name} already triggered`);
}
}
function setDeferred(name, timeout, func) {
SLTools.debug(`setDeferred(${name}, ${timeout})`);
if (deferredObjects[name] && !deferredObjects[name].triggered) {
clearDeferred(name);
}
// Alarms' granularity is a minimum of one minute, but timeouts can run more
// frequently than that. We use a timeout to try to get the best granularity,
// and an alarm because timeouts don't run reliably when Thunderbird windows
// are minimized (ugh).
if (!alarmsListener({ name }, true)) {
throw new Error(`Unknown alarm: ${name}`);
}
let timeoutId;
let response = {
scheduledAt: new Date(),
timeout: timeout,
name: name,
func: func,
};
if (timeout >= 60000) {
response.timeoutId = undefined;
} else {
response.timeoutId = setTimeout(() => {
if (!response.triggered) {
SLTools.debug(`setDeferred timeout callback for ${name}`);
response.triggered = true;
func();
} else {
SLTools.debug(`setDeferred timeout ${name} already triggered`);
}
}, timeout);
}
messenger.alarms.create(name, { delayInMinutes: timeout / 1000 / 60 });
deferredObjects[name] = response;
SLTools.debug(`setDeferred(${name}) success`);
}
async function rescheduleDeferred(name, timeout) {
// If the specified alarm is currently scheduled, recalculate when it should
// be scheduled for based on the specified new timeout and reschedule as
// needed.
let deferred = deferredObjects[name];
if (!deferred) {
SLTools.debug(`rescheduleDeferred: unrecognized time ${name}`);
return;
}
if (deferred.triggered) {
SLTools.debug(`rescheduleDeferred: ${name} already triggered`);
return;
}
if (timeout == deferred.timeout) {
SLTools.debug(`rescheduleDeferred: ${name} no change`);
return;
}
let scheduledAt = deferred.scheduledAt;
let now = new Date();
let msSinceScheduled = now - scheduledAt;
let msLeft = timeout - msSinceScheduled;
if (msLeft < 0) {
msLeft = 1;
}
let func = deferred.func;
clearDeferred(name);
setDeferred(name, msLeft, func);
// Gross but effective
deferred = deferredObjects[name];
deferred.scheduledAt = scheduledAt;
deferred.timeout = timeout;
SLTools.debug(
`rescheduleDeferred moved ${name} to ${new Date(now.getTime() + msLeft)}`,
);
}
async function clearDeferred(name) {
SLTools.debug(`clearDeferred(${name})`);
let deferredObj = deferredObjects[name];
if (!deferredObj) {
SLTools.debug("clearDeferred: no timer to clear");
return;
}
deferredObj.triggered = true;
clearTimeout(deferredObj.timeoutId);
await messenger.alarms.clear(deferredObj.name);
}
async function findBestIdentity(message, messageFull) {
// First try to find the author of the message in the account associated
// with the folder it's in. If that fails, save the default identity for
// that account and try to find the author in the identities of all other
// accounts. If that fails, return the default identity of the account
// associated with the folder.
let author = message.author;
let keyIdentityId = messageFull?.headers["x-identity-key"]?.at(0);
if (keyIdentityId) {
let keyIdentity = await messenger.identities.get(keyIdentityId);
if (keyIdentity && exactIdentityMatch(author, keyIdentity))
return keyIdentity;
}
let account = await messenger.accounts.get(message.folder.accountId, false);
let nameMatch = null;
let emailMatch = null;
// There really should be a way to parse From lines in the TB API.
for (let identity of account.identities)
if (exactIdentityMatch(author, identity)) return identity;
else if (!nameMatch && nameIdentityMatch(author, identity))
nameMatch = identity;
else if (!emailMatch && emailIdentityMatch(author, identity))
emailMatch = identity;
if (nameMatch || emailMatch) return nameMatch || emailMatch;
let primaryIdentity = account.identities.length
? account.identities[0]
: undefined;
let primaryAccountId = account.id;
for (account of await messenger.accounts.list(false)) {
if (account.id == primaryAccountId) continue;
for (let identity of account.identities)
if (exactIdentityMatch(author, identity)) return identity;
else if (!nameMatch && nameIdentityMatch(author, identity))
nameMatch = identity;
else if (!emailMatch && emailIdentityMatch(author, identity))
emailMatch = identity;
if (nameMatch) return nameMatch;
}
if (nameMatch || emailMatch) return nameMatch || emailMatch;
return primaryIdentity;
}
function exactIdentityMatch(author, identity) {
if (identity.name && identity.email) {
return author == `${identity.name} <${identity.email}>`;
} else if (identity.email) {
return author == identity.email || author == `<${identity.email}>`;
}
return false;
}
function nameIdentityMatch(author, identity) {
if (identity.name && identity.email) {
return (
author.startsWith(identity.name) &&
author.endsWith(`<${identity.email}>`)
);
}
return false;
}
function emailIdentityMatch(author, identity) {
if (identity.email) {
return author == identity.email || author.includes(`<${identity.email}>`);
}
return false;
}
SendLater.init()
.then(mainLoop)
.catch((err) => {
SLTools.error("Error initializing Send Later", err);
SendLater.disable();
});