Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 36 additions & 21 deletions modules/template-insert.js
Original file line number Diff line number Diff line change
Expand Up @@ -122,21 +122,14 @@ export function applyVariables(text, vars, isHtml = false) {
}

/**
* Replace template variables in text.
*
* Supported tokens: {DATE}, {TIME}, {DATETIME}, {YEAR}, {WEEKDAY},
* {SENDER_NAME}, {SENDER_EMAIL}, {ACCOUNT_NAME}, {ACCOUNT_EMAIL}.
*
* @param {string} text - Text containing placeholders
* @param {number} tabId - The compose tab ID (used to resolve sender identity)
* @param {boolean} isHtml - Pass true when substituting into HTML to HTML-encode identity values.
* @returns {Promise<string>} Text with placeholders replaced
* @see applyVariables
* Resolve the sender identity variables for a compose tab by calling the
* three Thunderbird messenger APIs that require live tab/account context.
* Separating this from `replaceVariables` keeps the substitution logic pure
* and independently testable.
* @param {number} tabId
* @returns {Promise<{senderName: string, senderEmail: string, accountName: string, accountEmail: string}>}
*/
export async function replaceVariables(text, tabId, isHtml = false) {
if (!text) return text;

const now = new Date();
export async function resolveIdentityVars(tabId) {
let senderName = "";
let senderEmail = "";
let accountName = "";
Expand Down Expand Up @@ -168,6 +161,26 @@ export async function replaceVariables(text, tabId, isHtml = false) {
console.warn("TemplateWing: could not resolve sender identity", err);
}

return { senderName, senderEmail, accountName, accountEmail };
}

/**
* Replace template variables in text.
*
* Supported tokens: {DATE}, {TIME}, {DATETIME}, {YEAR}, {WEEKDAY},
* {SENDER_NAME}, {SENDER_EMAIL}, {ACCOUNT_NAME}, {ACCOUNT_EMAIL}.
*
* @param {string} text - Text containing placeholders
* @param {object} vars - Pre-resolved identity vars: { senderName, senderEmail, accountName, accountEmail }.
* Obtain via {@link resolveIdentityVars} before calling this function.
* @param {boolean} isHtml - Pass true when substituting into HTML to HTML-encode identity values.
* @returns {string} Text with placeholders replaced
* @see applyVariables
* @see resolveIdentityVars
*/
export function replaceVariables(text, vars, isHtml = false) {
if (!text) return text;
const now = new Date();
return applyVariables(
text,
{
Expand All @@ -176,10 +189,11 @@ export async function replaceVariables(text, tabId, isHtml = false) {
datetime: now.toLocaleDateString() + " " + now.toLocaleTimeString(),
year: now.getFullYear(),
weekday: WEEKDAY_NAMES[now.getDay()],
senderName,
senderEmail,
accountName,
accountEmail,
senderName: "",
senderEmail: "",
accountName: "",
accountEmail: "",
...vars,
},
isHtml
);
Expand Down Expand Up @@ -405,13 +419,14 @@ export async function insertTemplateIntoTab(tabId, template) {
// "cursor" mode is delivered via a compose script message rather than by
// rewriting the whole body, so the signature and any text the user has
// already typed stay intact.
const identityVars = await resolveIdentityVars(tabId);
if (resolvedBody && mode === INSERT_MODES.CURSOR) {
const body = await replaceVariables(resolvedBody, tabId, true);
const body = replaceVariables(resolvedBody, identityVars, true);
const existing = await messenger.compose.getComposeDetails(tabId);
const fallbackBody = await tryCursorInsert(tabId, body, existing);
if (fallbackBody !== null) details.body = fallbackBody;
} else if (resolvedBody) {
const body = await replaceVariables(resolvedBody, tabId, true);
const body = replaceVariables(resolvedBody, identityVars, true);
if (mode === INSERT_MODES.REPLACE) {
details.body = body;
} else if (mode === INSERT_MODES.PREPEND) {
Expand All @@ -432,7 +447,7 @@ export async function insertTemplateIntoTab(tabId, template) {
}

if (template.subject) {
details.subject = await replaceVariables(template.subject, tabId);
details.subject = replaceVariables(template.subject, identityVars);
}

if (template.to && template.to.length > 0) {
Expand Down
76 changes: 74 additions & 2 deletions tests/template-insert.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,13 @@ import { installMessengerMock, uninstallMessengerMock } from "./_mock-messenger.
// The module installs a storage listener at import time; install the mock first.
installMessengerMock();

const { TEMPLATE_INCLUDE_REGEX, WEEKDAY_NAMES, applyVariables, resolveNestedTemplates } =
await import("../modules/template-insert.js");
const {
TEMPLATE_INCLUDE_REGEX,
WEEKDAY_NAMES,
applyVariables,
replaceVariables,
resolveNestedTemplates,
} = await import("../modules/template-insert.js");

after(() => uninstallMessengerMock());

Expand Down Expand Up @@ -118,6 +123,73 @@ describe("applyVariables", () => {
});
});

describe("replaceVariables", () => {
it("replaces {SENDER_NAME} and {SENDER_EMAIL} from provided vars", () => {
const result = replaceVariables("From: {SENDER_NAME} <{SENDER_EMAIL}>", {
senderName: "Alice",
senderEmail: "alice@example.com",
});
assert.strictEqual(result, "From: Alice <alice@example.com>");
});

it("replaces {ACCOUNT_NAME} and {ACCOUNT_EMAIL} from provided vars", () => {
const result = replaceVariables("{ACCOUNT_NAME} / {ACCOUNT_EMAIL}", {
accountName: "Work",
accountEmail: "alice.work@example.com",
});
assert.strictEqual(result, "Work / alice.work@example.com");
});

it("HTML-encodes identity values when isHtml is true", () => {
const result = replaceVariables("{SENDER_NAME}", { senderName: "<b>Alice & Bob</b>" }, true);
assert.strictEqual(result, "&lt;b&gt;Alice &amp; Bob&lt;/b&gt;");
});

it("does not HTML-encode when isHtml is false", () => {
const result = replaceVariables("{SENDER_NAME}", { senderName: "<Alice>" }, false);
assert.strictEqual(result, "<Alice>");
});

it("defaults identity vars to empty strings when vars is empty", () => {
const result = replaceVariables("{SENDER_NAME} <{SENDER_EMAIL}>", {});
assert.strictEqual(result, " <>");
});

it("replaces {DATE} with a non-empty string", () => {
const result = replaceVariables("{DATE}", {});
assert.ok(
result.length > 0 && !result.includes("{DATE}"),
`{DATE} should be replaced, got: ${result}`
);
});

it("replaces {TIME} with a non-empty string", () => {
const result = replaceVariables("{TIME}", {});
assert.ok(
result.length > 0 && !result.includes("{TIME}"),
`{TIME} should be replaced, got: ${result}`
);
});

it("replaces {YEAR} with a 4-digit year", () => {
const result = replaceVariables("{YEAR}", {});
assert.match(result, /^\d{4}$/);
});

it("replaces {WEEKDAY} with a day name", () => {
const result = replaceVariables("{WEEKDAY}", {});
assert.ok(WEEKDAY_NAMES.includes(result), `expected a weekday name, got: ${result}`);
});

it("returns null unchanged for null input", () => {
assert.strictEqual(replaceVariables(null, {}), null);
});

it("returns undefined unchanged for undefined input", () => {
assert.strictEqual(replaceVariables(undefined, {}), undefined);
});
});

describe("WEEKDAY_NAMES", () => {
it("has seven English weekday names starting with Sunday", () => {
assert.strictEqual(WEEKDAY_NAMES.length, 7);
Expand Down