diff --git a/.gitignore b/.gitignore
index 92671c596ab2ea98a5d070078d481df9e85048ed..0206ae2789aca52a9945beec5156b6818f44daad 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
 *~
 \#*
 .#*
-safelinks-cleaner-thunderbird.xpi
+build/
+*.xpi
diff --git a/firefox/content.js b/firefox/content.js
index 5e9a958aa5ee49a2c83fc3a550219266a03375fe..3fe7f87d02be8f47f4abf4354bed7a9ea51534ff 100644
--- a/firefox/content.js
+++ b/firefox/content.js
@@ -22,161 +22,6 @@
 // Display script
 
 
-let currentPopupTarget = null;
-let hidePopupTimeout = null;
-let mutationObserver = null;
-
-
-function enableMutationObserver() {
-    console.log('enter enableMutationObserver');
-    if (!mutationObserver) {
-	mutationObserver = new MutationObserver(withoutMutationObserver(fixAllTheLinks));
-    }
-    mutationObserver.observe(document.body, {
-	childList: true,
-	subtree: true
-    });
-    console.log('exit enableMutationObserver');
-}
-
-function disableMutationObserver() {
-    console.log('enter disableMutationObserver');
-    if (mutationObserver) {
-	mutationObserver.disconnect();
-    }
-    console.log('exit disableMutationObserver');
-}
-
-function withoutMutationObserver(func) {
-    return (...args) => {
-	try {
-	    disableMutationObserver();
-	    func(...args);
-	}
-	finally {
-	    enableMutationObserver();
-	}
-    }
-}
-
-
-
-/**
- * Return the popup div element, creating it if necessary.
- * @returns {Element} The popup element.
- */
-function getPopup() {
-    let popup = document.getElementById(safelinksPopupId);
-    if (!popup) {
-	popupElementLocked = false;
-	popup = document.createElement('div');
-	popup.id = safelinksPopupId;
-	popup.addEventListener('mouseenter', cancelHidePopup, {passive: true});
-	popup.addEventListener('mouseleave', scheduleHidePopup, {passive: true});
-	document.body.appendChild(popup);
-    }
-    return popup;
-}
-
-
-/**
- * Cancel hiding the popup (if it has been scheduled) and set
- * hidePopupTimeout to null.
- */
-function cancelHidePopup() {
-    if (hidePopupTimeout) {
-	clearTimeout(hidePopupTimeout);
-	hidePopupTimeout = null;
-    }
-}
-
-
-/**
- * Hide the current popup. If there is no popup, one will be created.
- */
-function hidePopup() {
-    cancelHidePopup();
-    getPopup().classList.remove(safelinksPopupVisibleClass);
-    currentPopupTarget = undefined;
-}
-
-
-/**
- * Schedule hiding the current popup.
- */
-function scheduleHidePopup() {
-    if (!hidePopupTimeout) {
-	hidePopupTimeout = setTimeout(withoutMutationObserver(hidePopup), 100);
-    }
-}
-
-
-/**
- * Get the absolute bounds of an element.
- * @param {Element} elem - The element for which to return bounds.
- * @returns {{top: number, left: number, right: number, bottom:
- *   number}} The top, left, right, and bottom coordinates of the
- *   element.
- */
-function getAbsoluteBoundingRect(elem) {
-    let rect = elem.getBoundingClientRect();
-    let scrollLeft = window.scrollX;
-    let scrollTop = window.scrollY;
-    return {
-	top: rect.top + window.scrollY,
-	left: rect.left + window.scrollX,
-	bottom: rect.bottom + window.scrollY,
-	right: rect.right + window.scrollX,
-    }
-}
-
-/**
- * Attempt to ensure that at least part of an element is visible. If
- * the element's right-hand coordinate is off-screen, move it
- * on-screen without moving the left-hand side off-screen. If the
- * bottom of the element is off-screen, move it on-screen.
- * @param {Element} elem - The element to show.
- */
-function clampElementToDocument(elem) {
-    let elemBounds = getAbsoluteBoundingRect(elem);
-
-    if (elemBounds.bottom > document.documentElement.scrollHeight) {
-	elem.style.removeProperty('top');
-	elem.style.bottom = 0;
-    }
-
-    if (elemBounds.right > document.documentElement.scrollWidth) {
-	elem.style.removeProperty('left');
-	elem.style.right = 0;
-	if (getAbsoluteBoundingRect(elem).left < 0) {
-	    elem.style.left = 0;
-	}
-    }
-}
-
-/**
- * Show the original URL of a link.
- * @param {MouseEvent} event - The event triggering this handler.
- */
-function showOriginalUrl(event) {
-    console.log('enter showOriginalUrl');
-    let popup = getPopup();
-    cancelHidePopup();
-    if (event.target != currentPopupTarget
-	|| !popup.classList.contains(safelinksPopupVisibleClass)) {
-	currentPopupTarget = event.target;
-	popup.textContent = untangleLink(event.target.href);
-	popup.style.removeProperty('bottom');
-	popup.style.removeProperty('right');
-	popup.style.left = event.clientX + 'px';
-	popup.style.top = event.clientY + 'px';
-	popup.classList.add(safelinksPopupVisibleClass);
-	clampElementToDocument(popup);
-    }
-    console.log('exit showOriginalUrl');
-}
-
-
 /**
  * Add event handlers to a link so it will show the original url.
  * @param {Element} link - The link to add the popup to.
@@ -187,22 +32,5 @@ function addLinkPopup(link) {
 }
 
 
-function fixAllTheLinks() {
-    console.log('enter fixAllTheLinks');
-    for (const link of document.links) {
-	// Untangle link text
-	for (const node of getTextNodes(link)) {
-	    node.textContent = untangleLink(node.textContent);
-	}
-
-	// Create popup event handlers
-	if (isTangledLink(link.href)) {
-	    addLinkPopup(link);
-	}
-    }
-    console.log('leave fixAllTheLinks');
-}
-
-
 fixAllTheLinks();
 enableMutationObserver();
diff --git a/firefox/icon.svg b/firefox/icon.svg
deleted file mode 100644
index dc540fd6026284266c72dc1d835e2060eb62c583..0000000000000000000000000000000000000000
--- a/firefox/icon.svg
+++ /dev/null
@@ -1,22 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<!-- Generator: Adobe Illustrator 24.2.3, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
-<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
-	 viewBox="0 0 283.46 283.46" style="enable-background:new 0 0 283.46 283.46;" xml:space="preserve">
-<style type="text/css">
-	.st0{fill:#231F20;}
-	.st1{fill:none;stroke:#231F20;stroke-width:36;stroke-linecap:round;stroke-miterlimit:10;}
-	.st2{fill:#ED1C24;}
-	.st3{fill:#FFFFFF;}
-</style>
-<g>
-	<path class="st0" d="M209.46,74v135.46H74V74H209.46 M283.46,0H0v283.46h283.46V0L283.46,0z"/>
-</g>
-<path class="st1" d="M15.14,199.56"/>
-<path class="st1" d="M15.14,216.86"/>
-<g>
-	<polygon class="st2" points="97.71,88.66 150.06,36.3 99.24,-14.5 297.96,-14.5 297.96,184.2 246.66,132.89 194.3,185.25 	"/>
-	<path class="st3" d="M283.46,0v149.2l-36.81-36.81l-52.36,52.36l-76.09-76.09l52.36-52.36L134.26,0H283.46 M312.46-29h-29H134.26
-		H64.23l49.52,49.51l15.8,15.79L97.71,68.15L77.2,88.66l20.51,20.51l76.09,76.09l20.51,20.51l20.51-20.51l31.85-31.85l16.3,16.3
-		l49.51,49.51V149.2V0V-29L312.46-29z"/>
-</g>
-</svg>
diff --git a/firefox/manifest.json b/firefox/manifest.json
deleted file mode 100644
index 56c82fb4b95114d5c0a6d085037787bdc319a2a7..0000000000000000000000000000000000000000
--- a/firefox/manifest.json
+++ /dev/null
@@ -1,38 +0,0 @@
-{
-    "manifest_version": 2,
-    "name": "Safe Links Cleaner",
-    "description": "__MSG_extensionDescription__",
-    "version": "1.0",
-    "author": "David Byers",
-    "homepage_url": "https://safelinks.gitlab-pages.liu.se/safelinks-cleaner-firefox/",
-    "default_locale": "en",
-    "icons": {
-	"48": "icon.svg",
-	"96": "icon.svg",
-	"144": "icon.svg",
-	"192": "icon.svg"
-    },
-    "background": {
-        "scripts": [
-            "common.js",
-            "background.js"
-        ]
-    },
-    "content_scripts": [
-	{
-	    "matches": ["*://outlook.office.com/*"],
-	    "css": [
-		"/style.css"
-	    ],
-	    "js": [
-		"common.js",
-		"content.js"
-	    ]
-	}
-    ],
-    "permissions": [
-	"activeTab",
-	"clipboardWrite",
-	"menus"
-    ]
-}
diff --git a/firefox/manifest.part.json b/firefox/manifest.part.json
new file mode 100644
index 0000000000000000000000000000000000000000..1c47e9897ba6395981526680445419f0d71565c4
--- /dev/null
+++ b/firefox/manifest.part.json
@@ -0,0 +1,24 @@
+{
+    "background": {
+        "scripts": [
+	    "menu.js"
+        ]
+    },
+    "content_scripts": [
+	{
+	    "matches": ["*://outlook.office.com/*"],
+	    "css": [
+		"style.css"
+	    ],
+	    "js": [
+		"links.js",
+		"popup.js",
+		"mutation.js",
+		"content.js"
+	    ]
+	}
+    ],
+    "permissions": [
+	"activeTab"
+    ]
+}
diff --git a/firefox/mutation.js b/firefox/mutation.js
new file mode 100644
index 0000000000000000000000000000000000000000..746dbb7c40bcc9a324e62a630e4e80f755ea0cab
--- /dev/null
+++ b/firefox/mutation.js
@@ -0,0 +1,34 @@
+let mutationObserver = null;
+
+
+function enableMutationObserver() {
+    console.log('enter enableMutationObserver');
+    if (!mutationObserver) {
+	mutationObserver = new MutationObserver(withoutMutationObserver(fixAllTheLinks));
+    }
+    mutationObserver.observe(document.body, {
+	childList: true,
+	subtree: true
+    });
+    console.log('exit enableMutationObserver');
+}
+
+function disableMutationObserver() {
+    console.log('enter disableMutationObserver');
+    if (mutationObserver) {
+	mutationObserver.disconnect();
+    }
+    console.log('exit disableMutationObserver');
+}
+
+function withoutMutationObserver(func) {
+    return (...args) => {
+	try {
+	    disableMutationObserver();
+	    func(...args);
+	}
+	finally {
+	    enableMutationObserver();
+	}
+    }
+}
diff --git a/scripts/build.sh b/scripts/build.sh
new file mode 100644
index 0000000000000000000000000000000000000000..5b5adfb8a3c0da1f7f764b2578e3fb6691d4e3ee
--- /dev/null
+++ b/scripts/build.sh
@@ -0,0 +1,20 @@
+#! /bin/bash
+
+
+TARGETS=(thunderbird firefox)
+
+BASEDIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
+BUILDDIR="$BASEDIR/build"
+SHAREDDIR="$BASEDIR/shared"
+
+for target in "${TARGETS[@]}" ; do
+    targetdir="$BUILDDIR/$target"
+    sourcedir="$basedir/$target"
+    outputfile="$BUILDDIR/safelinks-cleaner-$target.xpi"
+    
+    [ -d "$targetdir" ] && rm -r "$targetdir"
+    mkdir -p "$targetdir"
+    cp -r "$SHAREDDIR"/* "$sourcedir"/* "$targetdir"
+    cd "$targetdir"
+    zip -r ../"$outputfile" *
+done
diff --git a/scripts/makemanifest.py b/scripts/makemanifest.py
new file mode 100644
index 0000000000000000000000000000000000000000..4b4a2945804e726eb620cc81f7cc42244f9245af
--- /dev/null
+++ b/scripts/makemanifest.py
@@ -0,0 +1,59 @@
+#! /usr/bin/python3
+
+import argparse
+import json
+import sys
+
+
+def deepmerge_dict(data_a, data_b, *, path):
+    result ={}
+    for key in data_a:
+        if key not in data_b:
+            result[key] = data_a[key]
+        else:
+            result[key] = deepmerge(data_a[key], data_b[key], path=path + "." + key)
+    for key in data_b:
+        if key not in data_a:
+            result[key] = data_b[key]
+    return result
+
+
+def deepmerge(data_a, data_b, *, path=""):
+    if type(data_a) is not type(data_b):
+        raise TypeError('mismatched types at %s' % path)
+    if isinstance(data_a, dict):
+        return deepmerge_dict(data_a, data_b, path=path)
+    if isinstance(data_a, list):
+        return data_a + data_b
+    if data_a != data_b:
+        raise ValueError('mismatched values at %s' % path)
+    
+
+
+def main():
+    """Main function."""
+    parser = argparse.ArgumentParser("Merge fragments of manifest.json files.")
+#    parser.add_argument('--output', required=True, help='path to output file')
+    parser.add_argument('files', nargs='+', help='input files')
+    opts = parser.parse_args()
+
+    result = {}
+    errors = False
+    for path in opts.files:
+        with open(path, 'r') as fp:
+            try:
+                data = json.load(fp)
+            except json.decoder.JSONDecodeError as exc:
+                print('%s: %s' % (path, str(exc)), file=sys.stderr)
+                errors = True
+        if not errors:
+            result = deepmerge(result, data)
+
+    if errors:
+        sys.exit(1)
+        
+    print(json.dumps(result, indent=4))
+
+
+if __name__ == '__main__':
+    main()
diff --git a/firefox/_locales/en/messages.json b/shared/_locales/en/messages.json
similarity index 100%
rename from firefox/_locales/en/messages.json
rename to shared/_locales/en/messages.json
diff --git a/firefox/_locales/sv/messages.json b/shared/_locales/sv/messages.json
similarity index 100%
rename from firefox/_locales/sv/messages.json
rename to shared/_locales/sv/messages.json
diff --git a/thunderbird/icon.svg b/shared/icon.svg
similarity index 100%
rename from thunderbird/icon.svg
rename to shared/icon.svg
diff --git a/firefox/common.js b/shared/links.js
similarity index 89%
rename from firefox/common.js
rename to shared/links.js
index 32f296f6c6e89933f2a48d2d379b33ba638d0e55..27ffc309ee29aceead251a81938944737ce962a2 100644
--- a/firefox/common.js
+++ b/shared/links.js
@@ -97,3 +97,21 @@ function getTextNodes(elem) {
     }
     return result;
 }
+
+
+/**
+ * Fix all the links in the document.
+ */
+function fixAllTheLinks() {
+    for (const link of document.links) {
+	// Untangle link text
+	for (const node of getTextNodes(link)) {
+	    node.textContent = untangleLink(node.textContent);
+	}
+
+	// Create popup event handlers
+	if (isTangledLink(link.href)) {
+	    addLinkPopup(link);
+	}
+    }
+}
diff --git a/shared/manifest.part.json b/shared/manifest.part.json
new file mode 100644
index 0000000000000000000000000000000000000000..764aa8ac7dfbdd7e0d8524fc883c25185ad63dd0
--- /dev/null
+++ b/shared/manifest.part.json
@@ -0,0 +1,19 @@
+{
+    "manifest_version": 2,
+    "name": "ATP Safe Links Cleaner",
+    "description": "__MSG_extensionDescription__",
+    "version": "1.3",
+    "author": "David Byers",
+    "homepage_url": "https://gitlab.liu.se/safelinks/safelinks-cleaner/",
+    "default_locale": "en",
+    "icons": {
+	"48": "icon.svg",
+	"96": "icon.svg",
+	"144": "icon.svg",
+	"192": "icon.svg"
+    },
+    "permissions": [
+	"clipboardWrite",
+	"menus"
+    ]
+}
diff --git a/firefox/background.js b/shared/menu.js
similarity index 94%
rename from firefox/background.js
rename to shared/menu.js
index a0d1cf2bf28bddb1f74ece44f963b29e054e1f41..3f774163410c47fcabfe62467e5fdcb694609870 100644
--- a/firefox/background.js
+++ b/shared/menu.js
@@ -19,11 +19,9 @@
 // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 // SOFTWARE.
 
-// Background scripts
+// Context menu
 
 
-console.log('enter background');
-
 browser.menus.create({
     id: "liu-safelinks-copy",
     title: browser.i18n.getMessage("copyLinkMenuTitle"),
@@ -37,6 +35,3 @@ browser.menus.onClicked.addListener((info, tab) => {
 	navigator.clipboard.writeText(untangleLink(info.linkUrl));
     }
 });
-
-
-console.log('exit background');
diff --git a/shared/popup.js b/shared/popup.js
new file mode 100644
index 0000000000000000000000000000000000000000..d1a6aec4ffe8b3ba51ec776abd0de535b97a7528
--- /dev/null
+++ b/shared/popup.js
@@ -0,0 +1,142 @@
+// Microsoft ATP Safe Links Cleaner
+// Copyright 2021 David Byers <david.byers@liu.se>
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+// 
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+// 
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+
+// Popups
+
+
+let currentPopupTarget = null;
+let hidePopupTimeout = null;
+
+
+/**
+ * Return the popup div element, creating it if necessary.
+ * @returns {Element} The popup element.
+ */
+function getPopup() {
+    let popup = document.getElementById(safelinksPopupId);
+    if (!popup) {
+	popupElementLocked = false;
+	popup = document.createElement('div');
+	popup.id = safelinksPopupId;
+	popup.addEventListener('mouseenter', cancelHidePopup, {passive: true});
+	popup.addEventListener('mouseleave', scheduleHidePopup, {passive: true});
+	document.body.appendChild(popup);
+    }
+    return popup;
+}
+
+
+/**
+ * Cancel hiding the popup (if it has been scheduled) and set
+ * hidePopupTimeout to null.
+ */
+function cancelHidePopup() {
+    if (hidePopupTimeout) {
+	clearTimeout(hidePopupTimeout);
+	hidePopupTimeout = null;
+    }
+}
+
+
+/**
+ * Hide the current popup. If there is no popup, one will be created.
+ */
+function hidePopup() {
+    cancelHidePopup();
+    getPopup().classList.remove(safelinksPopupVisibleClass);
+    currentPopupTarget = undefined;
+}
+
+
+/**
+ * Schedule hiding the current popup.
+ */
+function scheduleHidePopup() {
+    if (!hidePopupTimeout) {
+	hidePopupTimeout = setTimeout(withoutMutationObserver(hidePopup), 100);
+    }
+}
+
+
+/**
+ * Get the absolute bounds of an element.
+ * @param {Element} elem - The element for which to return bounds.
+ * @returns {{top: number, left: number, right: number, bottom:
+ *   number}} The top, left, right, and bottom coordinates of the
+ *   element.
+ */
+function getAbsoluteBoundingRect(elem) {
+    let rect = elem.getBoundingClientRect();
+    let scrollLeft = window.scrollX;
+    let scrollTop = window.scrollY;
+    return {
+	top: rect.top + window.scrollY,
+	left: rect.left + window.scrollX,
+	bottom: rect.bottom + window.scrollY,
+	right: rect.right + window.scrollX,
+    }
+}
+
+
+/**
+ * Attempt to ensure that at least part of an element is visible. If
+ * the element's right-hand coordinate is off-screen, move it
+ * on-screen without moving the left-hand side off-screen. If the
+ * bottom of the element is off-screen, move it on-screen.
+ * @param {Element} elem - The element to show.
+ */
+function clampElementToDocument(elem) {
+    let elemBounds = getAbsoluteBoundingRect(elem);
+
+    if (elemBounds.bottom > document.documentElement.scrollHeight) {
+	elem.style.removeProperty('top');
+	elem.style.bottom = 0;
+    }
+
+    if (elemBounds.right > document.documentElement.scrollWidth) {
+	elem.style.removeProperty('left');
+	elem.style.right = 0;
+	if (getAbsoluteBoundingRect(elem).left < 0) {
+	    elem.style.left = 0;
+	}
+    }
+}
+
+
+/**
+ * Show the original URL of a link.
+ * @param {MouseEvent} event - The event triggering this handler.
+ */
+function showOriginalUrl(event) {
+    let popup = getPopup();
+    cancelHidePopup();
+    if (event.target != currentPopupTarget
+	|| !popup.classList.contains(safelinksPopupVisibleClass)) {
+	currentPopupTarget = event.target;
+	popup.textContent = untangleLink(event.target.href);
+	popup.style.removeProperty('bottom');
+	popup.style.removeProperty('right');
+	popup.style.left = event.clientX + 'px';
+	popup.style.top = event.clientY + 'px';
+	popup.classList.add(safelinksPopupVisibleClass);
+	clampElementToDocument(popup);
+    }
+}
diff --git a/firefox/style.css b/shared/style.css
similarity index 100%
rename from firefox/style.css
rename to shared/style.css
diff --git a/thunderbird/_locales/en/messages.json b/thunderbird/_locales/en/messages.json
deleted file mode 100644
index b4f672fca009ab18519391db8a3a7ba525a11a4e..0000000000000000000000000000000000000000
--- a/thunderbird/_locales/en/messages.json
+++ /dev/null
@@ -1,11 +0,0 @@
-{
-    "extensionDescription": {
-	"message": "Clean up display of links rewritten by Microsoft Defender for Office 365 Safe Links, so it is easy to see and copy the original link.",
-	"description": "Description of the extension."
-    },
-
-    "copyLinkMenuTitle": {
-	"message": "Copy original link",
-	"description": "Title of the copy original link menu item."
-    }
-}
diff --git a/thunderbird/_locales/sv/messages.json b/thunderbird/_locales/sv/messages.json
deleted file mode 100644
index b5572a0e00c2da2e9d6ce09b244ad6de80763f7e..0000000000000000000000000000000000000000
--- a/thunderbird/_locales/sv/messages.json
+++ /dev/null
@@ -1,11 +0,0 @@
-{
-    "extensionDescription": {
-	"message": "Ändra visning av länkar omskrivna av Microsoft Defender för Office 365 Safe Links, så det är enkelt att se och kopiera den ursrpungliga länken.",
-	"description": "Description of the extension."
-    },
-
-    "copyLinkMenuTitle": {
-	"message": "Kopiera ursprunglig länk",
-	"description": "Title of the copy original link menu item."
-    }
-}
diff --git a/thunderbird/background.js b/thunderbird/background.js
index 9d9957cfdd949a06179f89db9b7d88e4060232d7..e85a04292a55b30cee03f7e4935b066ee9e8b03c 100644
--- a/thunderbird/background.js
+++ b/thunderbird/background.js
@@ -23,8 +23,12 @@
 
 
 browser.composeScripts.register({
+    css: [
+	{file: "/style.css"}
+    ],
     js: [
-	{file: "common.js"},
+	{file: "links.js"},
+	{file: "popup.js"},
 	{file: "compose.js"}
     ],
 });
@@ -34,22 +38,8 @@ browser.messageDisplayScripts.register({
 	{file: "/style.css"}
     ],
     js: [
-	{file: "/common.js"},
+	{file: "/links.js"},
+	{file: "/popup.js"},
 	{file: "/display.js"}
     ],
 });
-
-browser.menus.create({
-    id: "liu-safelinks-copy",
-    title: browser.i18n.getMessage("copyLinkMenuTitle"),
-    contexts: ["link"],
-    visible: true,
-    targetUrlPatterns: ["*://*.safelinks.protection.outlook.com/*"],
-});
-
-browser.menus.onClicked.addListener((info, tab) => {
-    if (info.menuItemId == "liu-safelinks-copy") {
-	navigator.clipboard.writeText(untangleLink(info.linkUrl));
-    }
-});
-
diff --git a/thunderbird/common.js b/thunderbird/common.js
deleted file mode 100644
index 32f296f6c6e89933f2a48d2d379b33ba638d0e55..0000000000000000000000000000000000000000
--- a/thunderbird/common.js
+++ /dev/null
@@ -1,99 +0,0 @@
-// Microsoft ATP Safe Links Cleaner
-// Copyright 2021 David Byers <david.byers@liu.se>
-//
-// Permission is hereby granted, free of charge, to any person obtaining a copy
-// of this software and associated documentation files (the "Software"), to deal
-// in the Software without restriction, including without limitation the rights
-// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-// copies of the Software, and to permit persons to whom the Software is
-// furnished to do so, subject to the following conditions:
-// 
-// The above copyright notice and this permission notice shall be included in all
-// copies or substantial portions of the Software.
-// 
-// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-// SOFTWARE.
-
-// Shared code
-
-
-/**
- * Regexp that matches safe links. The original URL must be collected
- * in match group 1.
- */
-const safelinksRegexp = new RegExp(
-    'https?://[^.]+[.]safelinks[.]protection[.]outlook[.]com/[?]url=([^&]+)&.*',
-    'gi'
-);
-
-
-/**
- * The ID for the popup element that is added to the HTML document.
- */
-const safelinksPopupId = 'safelinks-cleaner-thunderbird-popup';
-
-
-/**
- * The class that is added to the popup when visible.
- */
-const safelinksPopupVisibleClass = 'safelinks-cleaner-thunderbird-popup-visible';
-
-
-/**
- * Return the original URL for a safe link.
- * @param {string} link - The safe link.
- * @returns {string} The original link or the safe link if there was an error.
- */
-function untangleLink(link) {
-    return link.replaceAll(
-	safelinksRegexp, (match, url) => {
-	    try {
-		return decodeURIComponent(url);
-	    }
-	    catch (e) {
-		console.log(e);
-		return url;
-	    }
-	});
-}
-
-
-/**
- * Check if a link is a safe link.
- * @param {string} link - The URL to check.
- * @returns {boolean} Returns true if the link is a safe link.
- */
-function isTangledLink(link) {
-    return link.match(safelinksRegexp);
-}
-
-
-/**
- * Return the text nodes under a DOM element.
- * @param {Element} elem - The element to return text nodes for.
- * @returns {Element[]} The text elements under elem.
- */
-function getTextNodes(elem) {
-    var result = [];
-    if (elem) {
-	for (var nodes = elem.childNodes, i = nodes.length; i--;) {
-	    let node = nodes[i];
-	    let nodeType = node.nodeType;
-	    
-	    if (nodeType == Node.TEXT_NODE) {
-		result.push(node);
-	    }
-	    else if (nodeType == Node.ELEMENT_NODE
-		     || nodeType == Node.DOCUMENT_NODE
-		     || nodeType == Node.DOCUMENT_FRAGMENT_NODE) {
-		result = result.concat(getTextNodes(node));
-	    }
-	}
-    }
-    return result;
-}
diff --git a/thunderbird/display.js b/thunderbird/display.js
index 9ecc2593c41301e748f21af10f9c380c2859bd57..2f8061b9aa5cab2d7009caf9ef67bfc4091b26df 100644
--- a/thunderbird/display.js
+++ b/thunderbird/display.js
@@ -22,122 +22,6 @@
 // Display script
 
 
-let currentPopupTarget = null;
-let hidePopupTimeout = null;
-
-
-/**
- * Return the popup div element, creating it if necessary.
- * @returns {Element} The popup element.
- */
-function getPopup() {
-    let popup = document.getElementById(safelinksPopupId);
-    if (!popup) {
-	popupElementLocked = false;
-	popup = document.createElement('div');
-	popup.id = safelinksPopupId;
-	popup.addEventListener('mouseenter', cancelHidePopup, {passive: true});
-	popup.addEventListener('mouseleave', scheduleHidePopup, {passive: true});
-	document.body.appendChild(popup);
-    }
-    return popup;
-}
-
-
-/**
- * Cancel hiding the popup (if it has been scheduled) and set
- * hidePopupTimeout to null.
- */
-function cancelHidePopup() {
-    if (hidePopupTimeout) {
-	clearTimeout(hidePopupTimeout);
-	hidePopupTimeout = null;
-    }
-}
-
-
-/**
- * Hide the current popup. If there is no popup, one will be created.
- */
-function hidePopup() {
-    cancelHidePopup();
-    getPopup().classList.remove(safelinksPopupVisibleClass);
-    currentPopupTarget = undefined;
-}
-
-
-/**
- * Schedule hiding the current popup.
- */
-function scheduleHidePopup() {
-    if (!hidePopupTimeout) {
-	hidePopupTimeout = setTimeout(hidePopup, 100);
-    }
-}
-
-
-/**
- * Get the absolute bounds of an element.
- * @param {Element} elem - The element for which to return bounds.
- * @returns {{top: number, left: number, right: number, bottom:
- *   number}} The top, left, right, and bottom coordinates of the
- *   element.
- */
-function getAbsoluteBoundingRect(elem) {
-    let rect = elem.getBoundingClientRect();
-    let scrollLeft = window.scrollX;
-    let scrollTop = window.scrollY;
-    return {
-	top: rect.top + window.scrollY,
-	left: rect.left + window.scrollX,
-	bottom: rect.bottom + window.scrollY,
-	right: rect.right + window.scrollX,
-    }
-}
-
-/**
- * Attempt to ensure that at least part of an element is visible. If
- * the element's right-hand coordinate is off-screen, move it
- * on-screen without moving the left-hand side off-screen. If the
- * bottom of the element is off-screen, move it on-screen.
- * @param {Element} elem - The element to show.
- */
-function clampElementToDocument(elem) {
-    let elemBounds = getAbsoluteBoundingRect(elem);
-
-    if (elemBounds.bottom > document.documentElement.scrollHeight) {
-	elem.style.removeProperty('top');
-	elem.style.bottom = 0;
-    }
-
-    if (elemBounds.right > document.documentElement.scrollWidth) {
-	elem.style.removeProperty('left');
-	elem.style.right = 0;
-	if (getAbsoluteBoundingRect(elem).left < 0) {
-	    elem.style.left = 0;
-	}
-    }
-}
-
-/**
- * Show the original URL of a link.
- * @param {MouseEvent} event - The event triggering this handler.
- */
-function showOriginalUrl(event) {
-    let popup = getPopup();
-    cancelHidePopup();
-    if (event.target != currentPopupTarget || !popup.classList.contains(safelinksPopupVisibleClass)) {
-	currentPopupTarget = event.target;
-	popup.textContent = untangleLink(event.target.href);
-	popup.style.removeProperty('bottom');
-	popup.style.removeProperty('right');
-	popup.style.left = event.clientX;
-	popup.style.top = event.clientY;
-	popup.classList.add(safelinksPopupVisibleClass);
-	//clampElementToDocument(popup);
-    }
-}
-
 /**
  * Add event handlers to a link so it will show the original url.
  * @param {Element} link - The link to add the popup to.
@@ -148,14 +32,4 @@ function addLinkPopup(link) {
 }
 
 
-for (const link of document.links) {
-    // Untangle link text
-    for (const node of getTextNodes(link)) {
-	node.textContent = untangleLink(node.textContent);
-    }
-
-    // Create popup event handlers
-    if (isTangledLink(link.href)) {
-	addLinkPopup(link);
-    }
-}
+fixAllTheLinks();
diff --git a/thunderbird/manifest.json b/thunderbird/manifest.json
deleted file mode 100644
index b2db40d25edf4456a51a7678f29cbc755bc3f3e8..0000000000000000000000000000000000000000
--- a/thunderbird/manifest.json
+++ /dev/null
@@ -1,33 +0,0 @@
-{
-    "manifest_version": 2,
-    "name": "Microsoft ATP Safe Links Cleaner",
-    "description": "__MSG_extensionDescription__",
-    "version": "1.3",
-    "author": "David Byers",
-    "homepage_url": "https://gitlab.liu.se/safelinks/safelinks-cleaner-thunderbird/",
-    "default_locale": "en",
-    "icons": {
-	"48": "icon.svg",
-	"96": "icon.svg",
-	"144": "icon.svg",
-	"192": "icon.svg"
-    },
-    "applications": {
-        "gecko": {
-            "id": "safelinks-cleaner-thunderbird@it.liu.se",
-            "strict_min_version": "78.4.0"
-        }
-    },
-    "background": {
-        "scripts": [
-            "common.js",
-            "background.js"
-        ]
-    },
-    "permissions": [
-	"messagesModify",
-	"clipboardWrite",
-	"menus",
-	"compose"
-    ]
-}
diff --git a/thunderbird/manifest.part.json b/thunderbird/manifest.part.json
new file mode 100644
index 0000000000000000000000000000000000000000..4c21da9b0949118330e5b5187d3aa15aea4972dd
--- /dev/null
+++ b/thunderbird/manifest.part.json
@@ -0,0 +1,17 @@
+{
+    "applications": {
+        "gecko": {
+            "id": "safelinks-cleaner-thunderbird@it.liu.se",
+            "strict_min_version": "78.4.0"
+        }
+    },
+    "background": {
+        "scripts": [
+            "background.js"
+        ]
+    },
+    "permissions": [
+	"messagesModify",
+	"compose"
+    ]
+}
diff --git a/thunderbird/style.css b/thunderbird/style.css
deleted file mode 100644
index 95f271cbd0983e90529959308997a5bc6a2ef07a..0000000000000000000000000000000000000000
--- a/thunderbird/style.css
+++ /dev/null
@@ -1,41 +0,0 @@
-/*
-Microsoft ATP Safe Links Cleaner
-Copyright 2021 David Byers <david.byers@liu.se>
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
-*/
-
-
-#safelinks-cleaner-thunderbird-popup {
-    display: none;
-    background: #fffff8;
-    color: black;
-    padding: 3px 3px 4px 3px;
-    position: fixed;
-    z-index: 1000;
-    border: 1px solid black;
-    -webkit-box-shadow: 0px 0px 6px 1px rgba(0,0,0,0.5);
-    -moz-box-shadow: 0px 0px 6px 1px rgba(0,0,0,0.5);
-    box-shadow: 0px 0px 6px 1px rgba(0,0,0,0.5);
-    font: 14px sans-serif;
-}
-
-#safelinks-cleaner-thunderbird-popup.safelinks-cleaner-thunderbird-popup-visible {
-    display: block;
-}