thunderbird-patch-review
Simple email patch review tool for Thunderbird
git clone git://mccd.space/thunderbird-patch-review| Log | Files | Refs | README | LICENSE | Mail | Artifacts |
background.js (9847B)
1 // Background script: patch detection on displayed messages, opening the
2 // review tab, and servicing requests from it (series data, compose replies,
3 // apply/edit through the patchHost experiment).
4 //
5 // MV2 background pages can't be ES modules, so shared code is pulled in
6 // with dynamic import().
7
8 "use strict";
9
10 const MBOX_FROM = "From nobody Mon Sep 17 00:00:00 2001\n";
11
12 let modulesPromise = null;
13 function mods() {
14 if (!modulesPromise) {
15 modulesPromise = Promise.all([
16 import(messenger.runtime.getURL("modules/patch-detect.js")),
17 import(messenger.runtime.getURL("modules/series.js")),
18 import(messenger.runtime.getURL("modules/sourcehut.js")),
19 ]).then((loaded) => Object.assign({}, ...loaded));
20 }
21 return modulesPromise;
22 }
23
24 async function getConfig() {
25 const found = await messenger.storage.local.get("config");
26 return Object.assign({ editor: "", strategy: "am3", mappings: [] }, found.config);
27 }
28
29 // Apply, the directory chooser, and the editor launch are backed by the
30 // patchHost experiment bundled under api/patchHost/. It always ships with
31 // the extension, but its privileged code can still fail to load on a
32 // Thunderbird whose internals moved; turn that into a normal error reply.
33 function host() {
34 if (!messenger.patchHost) {
35 throw new Error(
36 "the bundled patchHost helper did not load (incompatible Thunderbird version?)"
37 );
38 }
39 return messenger.patchHost;
40 }
41
42 // ---------------------------------------------------------------------------
43 // Detection: the single toolbar ("Review") button lights up when the current
44 // selection — or the displayed message, in standalone message tabs — looks
45 // like a patch. (The message-header button was dropped: the multi-message
46 // summary view has no header toolbar, so it could never cover threads.)
47
48 messenger.browserAction.disable();
49
50 async function updateAction(tabId, messages) {
51 try {
52 const { isPatchMessage, parseSubject, getPlainBody } = await mods();
53 let patch = messages.some((message) => Boolean(parseSubject(message.subject)));
54 if (!patch && messages.length === 1) {
55 // Subject gave no hint; look for a diff in the body (single message
56 // only — fetching every body of a large selection would be wasteful).
57 patch = isPatchMessage(messages[0].subject, await getPlainBody(messages[0].id));
58 }
59 await messenger.browserAction[patch ? "enable" : "disable"](tabId);
60 } catch (e) {
61 console.warn("patch-review: detection failed", e);
62 }
63 }
64
65 messenger.mailTabs.onSelectedMessagesChanged.addListener((tab, selected) =>
66 updateAction(tab.id, (selected && selected.messages) || [])
67 );
68
69 // Covers messages opened in their own tab or window, where no mail-tab
70 // selection exists.
71 messenger.messageDisplay.onMessageDisplayed.addListener((tab, message) =>
72 updateAction(tab.id, [message])
73 );
74
75 async function openReview(messageId) {
76 await messenger.tabs.create({
77 url: messenger.runtime.getURL(`review/review.html?mid=${messageId}`),
78 });
79 }
80
81 messenger.browserAction.onClicked.addListener(async (tab) => {
82 let list = [];
83 try {
84 const selected = await messenger.mailTabs.getSelectedMessages(tab.id);
85 list = (selected && selected.messages) || [];
86 } catch (e) {
87 // Not a mail tab; fall through to the displayed message.
88 }
89 if (!list.length) {
90 const displayed = await messenger.messageDisplay.getDisplayedMessage(tab.id);
91 if (displayed) {
92 list = [displayed];
93 }
94 }
95 const { parseSubject } = await mods();
96 const message = list.find((header) => parseSubject(header.subject)) || list[0];
97 if (message) {
98 await openReview(message.id);
99 }
100 });
101
102 // ---------------------------------------------------------------------------
103 // Repo resolution: match the message's List-Id (or author) against the
104 // configured mappings.
105
106 async function listKeysFor(messageId) {
107 const keys = [];
108 try {
109 const full = await messenger.messages.getFull(messageId);
110 const listId = full.headers && full.headers["list-id"];
111 if (listId && listId.length) {
112 keys.push(listId[0]);
113 }
114 } catch (e) {
115 // headers unavailable; fall through to author only
116 }
117 return keys;
118 }
119
120 function resolveRepo(config, keys) {
121 for (const mapping of config.mappings || []) {
122 if (!mapping.match || !mapping.repo) {
123 continue;
124 }
125 if (keys.some((k) => k && k.toLowerCase().includes(mapping.match.toLowerCase()))) {
126 return mapping.repo;
127 }
128 }
129 return "";
130 }
131
132 // ---------------------------------------------------------------------------
133 // Requests from the review tab.
134
135 messenger.runtime.onMessage.addListener((request, sender, sendResponse) => {
136 handle(request)
137 .then(sendResponse)
138 .catch((e) => sendResponse({ ok: false, error: e.message || String(e) }));
139 return true; // async response
140 });
141
142 async function handle(request) {
143 const m = await mods();
144
145 switch (request.type) {
146 case "get-series": {
147 const anchor = await messenger.messages.get(request.messageId);
148 const series = await m.collectSeries(anchor);
149 const keys = [...(await listKeysFor(request.messageId)), anchor.author];
150 const config = await getConfig();
151 const { lastRepo } = await messenger.storage.local.get("lastRepo");
152 return {
153 ok: true,
154 series,
155 anchorHeaderMessageId: anchor.headerMessageId,
156 // Explicit mapping first, otherwise the last path that was applied to.
157 repo: resolveRepo(config, keys) || lastRepo || "",
158 listKeys: keys,
159 isSourcehut: m.isSourcehutList([
160 ...keys,
161 ...(anchor.recipients || []),
162 ...(anchor.ccList || []),
163 ]),
164 config,
165 };
166 }
167
168 case "send-review": {
169 // mode "send": send each reply immediately (the compose window opens
170 // and closes; the WebExtension API has no headless send).
171 // mode "preview": leave the compose windows open for the user.
172 const send = request.mode === "send";
173 const status = m.SOURCEHUT_STATUSES.includes(request.status) ? request.status : null;
174 const errors = [];
175 let done = 0;
176 for (const [i, reply] of request.replies.entries()) {
177 const details = {
178 isPlainText: true,
179 plainTextBody: reply.body,
180 };
181 // One status update per patchset: only the first reply carries it.
182 if (status && i === 0) {
183 details.customHeaders = [{ name: m.STATUS_HEADER, value: status }];
184 }
185 const tab = await messenger.compose.beginReply(reply.messageId, "replyToAll", details);
186 if (!send) {
187 done++;
188 continue;
189 }
190 try {
191 await messenger.compose.sendMessage(tab.id, { mode: "sendNow" });
192 done++;
193 } catch (e) {
194 // Leave the failed compose window open so nothing is lost.
195 errors.push(`reply ${i + 1}: ${e.message}`);
196 }
197 }
198 return {
199 ok: errors.length === 0,
200 sent: send ? done : 0,
201 opened: send ? 0 : done,
202 error: errors.length ? `Some replies were not sent:\n${errors.join("\n")}` : undefined,
203 };
204 }
205
206 case "apply-series": {
207 const config = await getConfig();
208 // Remember the path for the next review, until it is changed again.
209 await messenger.storage.local.set({ lastRepo: request.repo });
210 const patches = [];
211 for (const messageId of request.messageIds) {
212 patches.push(await rawMessage(messageId));
213 }
214 return host().apply(request.repo, config.strategy || "am3", patches, Boolean(request.noHooks));
215 }
216
217 // Forwarded to patchHost.check(): classifies the series as already
218 // applied, applicable, or conflicting against the repository's HEAD.
219 case "check-series": {
220 const config = await getConfig();
221 const patches = [];
222 for (const messageId of request.messageIds) {
223 patches.push(await rawMessage(messageId));
224 }
225 return host().check(request.repo, config.strategy || "am3", patches);
226 }
227
228 case "open-editor": {
229 const config = await getConfig();
230 return host().openEditor(request.repo, config.editor || "", request.files || []);
231 }
232
233 case "download-series": {
234 const parts = [];
235 for (const messageId of request.messageIds) {
236 const raw = await rawMessage(messageId);
237 parts.push(raw.endsWith("\n") ? raw : raw + "\n");
238 }
239 const url = URL.createObjectURL(new Blob(parts, { type: "application/mbox" }));
240 try {
241 await messenger.downloads.download({
242 url,
243 filename: `${request.filename || "patchset"}.mbox`,
244 saveAs: true,
245 });
246 } catch (e) {
247 if (/cancel/i.test(e.message || "")) {
248 return { ok: true, cancelled: true };
249 }
250 throw e;
251 } finally {
252 // Give the download time to read the blob before releasing it.
253 setTimeout(() => URL.revokeObjectURL(url), 120000);
254 }
255 return { ok: true };
256 }
257
258 case "pick-repo":
259 // Native directory chooser; the repo field doubles as its start dir.
260 return host().pickDir(request.start || "");
261
262 case "describe-repo":
263 // Resolve a path to its repository name + current branch (or an error
264 // for not-a-repo). Used by the toolbar to display the target repo.
265 return host().describe(request.repo || "");
266
267 case "ping-host":
268 return host().ping();
269
270 default:
271 throw new Error(`unknown request: ${request.type}`);
272 }
273 }
274
275 /** Raw RFC822 source with an mbox From_ separator, as git am expects. */
276 async function rawMessage(messageId) {
277 let raw = await messenger.messages.getRaw(messageId);
278 if (raw && typeof raw !== "string") {
279 raw = await raw.text(); // newer TB returns a File
280 }
281 if (!raw.startsWith("From ")) {
282 raw = MBOX_FROM + raw;
283 }
284 return raw;
285 }