From e9e22336631478bf8ee330af1a26724e925a7d33 Mon Sep 17 00:00:00 2001 From: Amunak Date: Fri, 12 Jan 2024 01:41:55 +0100 Subject: [PATCH] Initial commit --- .github/workflows/build.yml | 28 ++++++ .github/workflows/lint.yml | 21 +++++ .github/workflows/release.yml | 38 ++++++++ LICENSE | 21 +++++ _locales/en/messages.json | 67 ++++++++++++++ background.js | 14 +++ icons/icon_48.png | Bin 0 -> 1768 bytes icons/icon_96.png | Bin 0 -> 3372 bytes manifest.json | 37 ++++++++ popup.css | 23 +++++ popup.html | 16 ++++ popup.js | 159 ++++++++++++++++++++++++++++++++++ readme.md | 21 +++++ 13 files changed, 445 insertions(+) create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/lint.yml create mode 100644 .github/workflows/release.yml create mode 100644 LICENSE create mode 100644 _locales/en/messages.json create mode 100644 background.js create mode 100644 icons/icon_48.png create mode 100644 icons/icon_96.png create mode 100644 manifest.json create mode 100644 popup.css create mode 100644 popup.html create mode 100644 popup.js create mode 100644 readme.md 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 0000000000000000000000000000000000000000..b030c1d7c5c2cb915384f1e5d81b43ed316ec9ef GIT binary patch literal 1768 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 + + + + 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.