diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..f6c3d7e --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,28 @@ +name: "Build" +on: + push: + branches: + - main + pull_request: + +jobs: + build: + name: "Build" + runs-on: ubuntu-latest + steps: + - name: "Checkout" + uses: actions/checkout@v1 + + - name: "web-ext build" + id: web-ext-build + uses: kewisch/action-web-ext@v1 + with: + cmd: build + source: . + filename: "{name}-{version}.xpi" + + - name: "Upload Artifact" + uses: actions/upload-artifact@v3 + with: + name: target.xpi + path: ${{ steps.web-ext-build.outputs.target }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..8332263 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,21 @@ +name: "Lint" +on: + push: + branches: + - main + pull_request: + +jobs: + lint: + name: "Lint" + runs-on: ubuntu-latest + steps: + - name: "Checkout" + uses: actions/checkout@v1 + + - name: "web-ext lint" + uses: kewisch/action-web-ext@v1 + with: + cmd: lint + source: . + channel: listed diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..c14ae40 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,38 @@ +name: "Release" +on: + push: + tags: + - '*.*.*' + +jobs: + sign: + name: "Release" + runs-on: ubuntu-latest + steps: + - name: "Checkout" + uses: actions/checkout@v1 + + - name: "web-ext build" + id: web-ext-build + uses: kewisch/action-web-ext@v1 + with: + cmd: build + source: . + + - name: "web-ext sign" + id: web-ext-sign + uses: kewisch/action-web-ext@v1 + with: + cmd: sign + source: ${{ steps.web-ext-build.outputs.target }} + channel: unlisted + apiKey: ${{ secrets.AMO_SIGN_KEY }} + apiSecret: ${{ secrets.AMO_SIGN_SECRET }} + timeout: 900000 + + - name: "Create Release" + uses: softprops/action-gh-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + files: ${{ steps.web-ext-sign.outputs.target }} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1bcf4a1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Jiří Barouš + +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. diff --git a/_locales/en/messages.json b/_locales/en/messages.json new file mode 100644 index 0000000..eae4f0d --- /dev/null +++ b/_locales/en/messages.json @@ -0,0 +1,67 @@ +{ + "extensionName": { + "message": "List Unsubscribe", + "description": "Name of the extension." + }, + + "extensionDescription": { + "message": "Adds a button to unsubscribe from newsletters and other bulk email that has the List-Unsubscribe header.", + "description": "Description of the extension." + }, + + "unsubButtonLabel": { + "message": "Unsubscribe", + "description": "The unsubscribe toolbar button label." + }, + + "noUnsub": { + "message": "No unsubscribe header found.", + "description": "Shown when no List-Unsubscribe header was found in the message." + }, + + "unsubLink": { + "message": "Unsubscribe link: ", + "description": "A visible label for the unsubscribe link." + }, + + "unsubConfirmPrompt": { + "message": "Are you sure you want to unsubscribe from this mailing list?", + "description": "A prompt that asks the user to confirm that they want continue with the one-click unsubscribe." + }, + + "confirmOneClick": { + "message": "One-click unsubscribe", + "description": "A button label to confirm the one-click unsubscribe." + }, + + "confirmOpenLink": { + "message": "Open link", + "description": "A button label to open the unsubscribe link in the browser." + }, + + "confirmComposeEmail": { + "message": "Compose email", + "description": "A button label to compose an email to unsubscribe." + }, + + "oneClickInProgress": { + "message": "Unsubscribe request in progress...", + "description": "A message shown when the one-click unsubscribe is pending." + }, + + "oneClickSuccess": { + "message": "Unsubscribe request sent.", + "description": "A message shown when the one-click unsubscribe was successful." + }, + + "oneClickFailure": { + "message": "Unsubscribe request failed: $ERROR$", + "description": "A message shown when the one-click unsubscribe failed.", + "placeholders": { + "error": { + "content": "$1", + "example": "404 Not Found" + } + } + } +} diff --git a/background.js b/background.js new file mode 100644 index 0000000..d86aba0 --- /dev/null +++ b/background.js @@ -0,0 +1,14 @@ +browser.mailTabs.onSelectedMessagesChanged.addListener(async (tab, messageList) => { + if (messageList.messages.length !== 1) { + browser.messageDisplayAction.disable() + return + } + + const message = await browser.messages.getFull(messageList.messages[0].id) + if (message.headers.hasOwnProperty('list-unsubscribe')) { + browser.messageDisplayAction.enable() + return + } + + browser.messageDisplayAction.disable() +}) diff --git a/icons/icon_48.png b/icons/icon_48.png new file mode 100644 index 0000000..b030c1d Binary files /dev/null and b/icons/icon_48.png differ diff --git a/icons/icon_96.png b/icons/icon_96.png new file mode 100644 index 0000000..ee770ac Binary files /dev/null and b/icons/icon_96.png differ diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..9778ff7 --- /dev/null +++ b/manifest.json @@ -0,0 +1,37 @@ +{ + "name": "__MSG_extensionName__", + "version": "1.0.0", + "description": "__MSG_extensionDescription__", + "author": "Amunak", + "homepage_url": "https://github.com/Amunak/thunderbird-list-unsubscribe-addon", + "background": { + "scripts": [ + "background.js" + ] + }, + "browser_specific_settings": { + "gecko": { + "id": "list-unsubscribe@addons.amunak.net", + "strict_min_version": "112.0" + } + }, + "default_locale": "en", + "icons": { + "48": "icons/icon_48.png", + "96": "icons/icon_96.png" + }, + "manifest_version": 2, + "message_display_action": { + "default_icon": "icons/icon_48.png", + "default_title": "__MSG_unsubButtonLabel__", + "default_popup": "popup.html" + }, + "permissions": [ + "http://*/*", + "https://*/*", + "messagesRead", + "accountsRead", + "clipboardWrite", + "webRequest" + ] +} diff --git a/popup.css b/popup.css new file mode 100644 index 0000000..b1e2b62 --- /dev/null +++ b/popup.css @@ -0,0 +1,23 @@ +body, html { + margin: 0; + padding: 0; +} + +code { + font-size: .875em; + color: #d63384; + word-wrap: break-word; +} + +.popup-page { + padding: .5rem; +} + +.popup-page div { + margin: 1rem 0; +} + +button { + margin: 0; + margin-right: .5rem; +} diff --git a/popup.html b/popup.html new file mode 100644 index 0000000..4e4f5fa --- /dev/null +++ b/popup.html @@ -0,0 +1,16 @@ + + + + + List Unsubscribe + + + + + + + diff --git a/popup.js b/popup.js new file mode 100644 index 0000000..af8d1d9 --- /dev/null +++ b/popup.js @@ -0,0 +1,159 @@ +browser.tabs.query({ + active: true, + currentWindow: true, +}).then(tabs => { + browser.messageDisplay.getDisplayedMessage(tabs[0].id).then(message => { + browser.messages.getFull(message.id).then(fullMessage => { + console.log(fullMessage) + const headers = fullMessage.headers + const messageEl = document.getElementById('message') + const actionContainerEl = document.getElementById('action-container') + + if (!headers.hasOwnProperty('list-unsubscribe')) { + messageEl.innerText = browser.i18n.getMessage('noUnsub') + return + } + + let unsubEmail = null + let unsubLink = null + headers['list-unsubscribe'][0].split(',').forEach(link => { + let result + if (result = link.match(/^\s*<(mailto:.+)>/)) { + unsubEmail = result[1] + return + } + if (result = link.match(/^\s*<(https?:\/\/.+)>/)) { + unsubLink = result[1] + return + } + }) + + // validate link + if (unsubLink !== null) { + try { + unsubLink = new URL(unsubLink) + } catch (e) { + console.warn(e) + unsubLink = null + } + } + + // validate and prepare email + let unsubEmailSubject = null + if (unsubEmail !== null) { + try { + unsubEmail = new URL(unsubEmail) + + // extract subject + if (unsubEmail.searchParams.has('subject')) { + unsubEmailSubject = unsubEmail.searchParams.get('subject') + } + + unsubEmail = unsubEmail.pathname + + if (!unsubEmail.match(/^[^@]+@[^@]+$/)) { + console.warn('invalid email address', unsubEmail) + unsubEmail = null + } + } catch (e) { + console.warn(e) + unsubEmail = null + } + } + + if (unsubLink === null && unsubEmail === null) { + messageEl.innerText = browser.i18n.getMessage('noUnsub') + console.error('no valid unsubscribe link or email found') + return + } + + // check if RFC 8058 is supported + let unsubCommand = null + if (headers.hasOwnProperty('list-unsubscribe-post')) { + unsubCommand = headers['list-unsubscribe-post'][0] + } + + console.log('unsub link: ', unsubLink) + console.log('unsub email: ', unsubEmail) + console.log('unsub command: ', unsubCommand) + + messageEl.innerText = browser.i18n.getMessage('unsubConfirmPrompt') + if (unsubLink !== null) { + const linkContainerEl = document.getElementById('unsub-link-container') + const linkEl = document.createElement('code') + linkEl.innerText = unsubLink + linkEl.addEventListener('click', () => { + navigator.clipboard.writeText(linkEl.innerText) + }) + + linkContainerEl.innerText = browser.i18n.getMessage('unsubLink') + linkContainerEl.appendChild(linkEl) + + if (unsubCommand) { + const button = document.createElement('button') + button.innerText = browser.i18n.getMessage('confirmOneClick') + button.addEventListener('click', async () => { + linkContainerEl.style.display = 'none' + actionContainerEl.style.display = 'none' + messageEl.innerText = browser.i18n.getMessage('oneClickInProgress') + + + fetch(unsubLink, { + method: 'POST', + body: unsubCommand, + }).then(() => { + messageEl.innerText = browser.i18n.getMessage('oneClickSuccess') + }).catch((e) => { + messageEl.innerText = browser.i18n.getMessage('oneClickFailure', e.message) + }) + }) + + actionContainerEl.appendChild(button) + } + + const button = document.createElement('button') + button.innerText = browser.i18n.getMessage('confirmOpenLink') + button.addEventListener('click', () => { + browser.tabs.create({ + url: unsubLink.toString(), + }) + }) + actionContainerEl.appendChild(button) + } + + if (unsubEmail !== null) { + const button = document.createElement('button') + button.innerText = browser.i18n.getMessage('confirmComposeEmail') + button.addEventListener('click', async () => { + const identityId = await (async (message) => { + // try to get identityId from the message + if (typeof message.folder === 'Object') { + const identities = await messanger.accounts.get(message.folder.accountId).identities + // we can use it only if there is only one identity + if (identities.length === 1) { + return identities[0].id + } + } + + // try match email address to identity + const identities = await messenger.identities.list() + const matching = identities.find(identity => message.recipients.some(email => identity.email === email)) + if (matching === undefined) { + console.warn('no matching identity found, using default') + return identities[0].id + } + + return matching.id + + })(message) + browser.compose.beginNew({ + to: unsubEmail, + subject: unsubEmailSubject ?? 'unsubscribe', + identityId, + }) + }) + actionContainerEl.appendChild(button) + } + }) + }) +}) diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..32810f4 --- /dev/null +++ b/readme.md @@ -0,0 +1,21 @@ +# Thunderbird List-Unsubscribe Addon + +This addon adds a button to the mail-view toolbar that allows you to unsubscribe +from mailing lists and similar bulk mailing services as long as they use the +`List-Unsubscribe` header as defined in RFC2369. `List-Unsubscribe-Post` as defined +in RFC8058 is also supported. + +When you click "Unsubscribe" the addon analyzes the email and depending on available +options shows buttons to one-click unsubscribe, open the unsubscribe link (in the +built-in browser) or use the provided unsubscribe email address to compose a new +email. + +Additionally you can click the exposed link to copy it to clipboard. + + +### Permissions required + +- **messagesRead**: Required to access the email headers. +- **accountsRead**: Required to fetch the correct "identity" to send the unsubscribe email from. +- **clipboardWrite**: Required to allow copying the unsubscribe link to clipboard. +- **webRequest**: Required for making the one-click unsubscribe requests. This is also why the addon needs access to all HTTP and HTTPS URLs.