Przeglądaj źródła

Merge pull request #2114 from PrivateBin/email-handlers

Fix stacked click handlers on the e-mail button
El RIDO 5 dni temu
rodzic
commit
403ec104f4
9 zmienionych plików z 439 dodań i 357 usunięć
  1. 1 0
      CHANGELOG.md
  2. 6 0
      js/common.js
  3. 123 65
      js/privatebin.js
  4. 2 2
      js/test/AttachmentViewer.js
  5. 13 13
      js/test/Helper.js
  6. 13 13
      js/test/I18n.js
  7. 280 9
      js/test/TopNav.js
  8. 0 254
      js/test/emailTemplateTest.js
  9. 1 1
      lib/Configuration.php

+ 1 - 0
CHANGELOG.md

@@ -11,6 +11,7 @@
 * FIXED: new paste button resets it to default formatter/style instead of hardcoding 'plaintext' (#2026)
 * FIXED: Comments must now be unique in cipher text within a document, not reuse existing cipher texts (#2100)
 * FIXED: Password peek button next to the password input when creating a document had no effect
+* FIXED: E-mail button sent several e-mails after being shown again or after its confirmation dialog was dismissed
 
 ## 2.1.0 (2026-10-18)
 * ADDED: Added `shortenviachhoto` endpoint with an `chhoto` configuration section

+ 6 - 0
js/common.js

@@ -72,6 +72,12 @@ exports.fcA2zString = function() {
     return fc.constantFrom(...a2zString);
 };
 
+// removes %s and %d placeholders from a string, including the whole run of
+// percent signs before them, so the removal can't create new ones (i.e. %%dd)
+exports.removePlaceholders = function(string) {
+    return string.replace(/%+(?=[sd])/g, '');
+};
+
 // provides random lowercase alpha numeric characters (a to z and 0 to 9)
 exports.fcAlnumString = function() {
     return fc.constantFrom(...alnumString);

+ 123 - 65
js/privatebin.js

@@ -3826,7 +3826,13 @@ window.PrivateBin = (function () {
             sendButton,
             retryButton,
             pasteExpiration = null,
-            retryButtonCallback;
+            retryButtonCallback,
+            emailConfirmModal,
+            emailConfirmBootstrapModal = null,
+            emailConfirmTimezoneCurrent,
+            emailConfirmTimezoneUtc,
+            emailDocument = null,
+            emailConfirmDocument = null;
 
         /**
          * set the expiration on bootstrap templates in dropdown
@@ -4197,10 +4203,10 @@ window.PrivateBin = (function () {
          *
          * @name   TopNav.triggerEmailSend
          * @private
-         * @param {string} subject
          * @param {string} emailBody
          */
-        function triggerEmailSend(subject, emailBody) {
+        function triggerEmailSend(emailBody) {
+            const subject = I18n._('Encrypted note on %s', document.title);
             window.open(
                 `mailto:?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(emailBody)}`,
                 '_self',
@@ -4209,64 +4215,84 @@ window.PrivateBin = (function () {
         }
 
         /**
-         * Send Email with current document (URL).
+         * formats the expiration date for the e-mail body
+         *
+         * The date gets rounded down by at least 30 seconds, to make up for
+         * the delay of the request. We don't use Date.prototype.toUTCString(),
+         * because we would like to avoid GMT.
+         *
+         * @name   TopNav.formatEmailExpiration
+         * @private
+         * @function
+         * @param  {number} expirationDate - timestamp in milliseconds
+         * @param  {bool} isUtc - use UTC instead of the current timezone
+         * @return {string}
+         */
+        function formatEmailExpiration(expirationDate, isUtc) {
+            const roundedDate = new Date(expirationDate);
+            roundedDate.setUTCSeconds(roundedDate.getUTCSeconds() - 30);
+            roundedDate.setUTCSeconds(0);
+
+            const localeConfiguration = { dateStyle: 'long', timeStyle: 'long' };
+            if (isUtc) {
+                localeConfiguration.timeZone = 'UTC';
+            }
+            return roundedDate.toLocaleString([], localeConfiguration);
+        }
+
+        /**
+         * Send Email with current document (URL), asking to confirm the
+         * timezone of its expiration first, if it has one.
          *
          * @name   TopNav.sendEmail
          * @private
          * @function
-         * @param  {Date|null} expirationDate date of expiration
-         * @param  {bool} isBurnafterreading whether it is burn after reading
          */
-        function sendEmail(expirationDate, isBurnafterreading) {
-            const expirationDateRoundedToSecond = new Date(expirationDate);
+        function sendEmail() {
+            // ignore clicks on the hidden button
+            if (emailDocument === null) {
+                return;
+            }
 
-            // round down at least 30 seconds to make up for the delay of request
-            expirationDateRoundedToSecond.setUTCSeconds(
-                expirationDateRoundedToSecond.getUTCSeconds() - 30
-            );
-            expirationDateRoundedToSecond.setUTCSeconds(0);
-
-            // reused as-is by both the confirmation-modal and direct-send paths below
-            const emailSubject = I18n._('Encrypted note on %s', document.title);
-
-            const emailconfirmmodal = document.getElementById('emailconfirmmodal');
-            if (expirationDate !== null) {
-                const emailconfirmTimezoneCurrent = emailconfirmmodal.querySelector('#emailconfirm-timezone-current');
-                const emailconfirmTimezoneUtc = emailconfirmmodal.querySelector('#emailconfirm-timezone-utc');
-                let localeConfiguration = { dateStyle: 'long', timeStyle: 'long' };
-                const bootstrap5EmailConfirmModal = typeof bootstrap !== 'undefined' && bootstrap.Tooltip && bootstrap.Tooltip.VERSION ?
-                    new bootstrap.Modal(emailconfirmmodal) : null;
-
-                function sendEmailAndHideModal() {
-                    const emailBody = templateEmailBody(
-                        // we don't use Date.prototype.toUTCString() because we would like to avoid GMT
-                        expirationDateRoundedToSecond.toLocaleString(
-                            [], localeConfiguration
-                        ), isBurnafterreading
-                    );
-                    if (bootstrap5EmailConfirmModal) {
-                        bootstrap5EmailConfirmModal.hide();
-                    }
-                    triggerEmailSend(emailSubject, emailBody);
-                }
+            if (emailDocument.expirationDate === null) {
+                triggerEmailSend(templateEmailBody(null, emailDocument.isBurnafterreading));
+                return;
+            }
 
-                emailconfirmmodal.addEventListener('shown.bs.modal', () => {
-                    emailconfirmTimezoneUtc.focus();
-                });
+            emailConfirmDocument = emailDocument;
+            if (emailConfirmBootstrapModal) {
+                emailConfirmBootstrapModal.show();
+            }
+        }
 
-                emailconfirmTimezoneCurrent.removeEventListener('click', sendEmailAndHideModal);
-                emailconfirmTimezoneCurrent.addEventListener('click', sendEmailAndHideModal);
-                emailconfirmTimezoneUtc.removeEventListener('click', sendEmailAndHideModal);
-                emailconfirmTimezoneUtc.addEventListener('click', () => {
-                    localeConfiguration.timeZone = 'UTC';
-                    sendEmailAndHideModal();
-                });
-                if (bootstrap5EmailConfirmModal) {
-                    bootstrap5EmailConfirmModal.show();
-                }
-            } else {
-                triggerEmailSend(emailSubject, templateEmailBody(null, isBurnafterreading));
+        /**
+         * Send the Email of the confirmation dialog, with the expiration in
+         * the chosen timezone.
+         *
+         * @name   TopNav.confirmEmailTimezone
+         * @private
+         * @function
+         * @param  {Event} event
+         */
+        function confirmEmailTimezone(event) {
+            // ignore a dialog opened for an earlier document
+            if (emailConfirmDocument === null) {
+                return;
+            }
+
+            const emailBody = templateEmailBody(
+                formatEmailExpiration(
+                    emailConfirmDocument.expirationDate,
+                    event.currentTarget === emailConfirmTimezoneUtc
+                ),
+                emailConfirmDocument.isBurnafterreading
+            );
+            // only send once per opening of the dialog
+            emailConfirmDocument = null;
+            if (emailConfirmBootstrapModal) {
+                emailConfirmBootstrapModal.hide();
             }
+            triggerEmailSend(emailBody);
         }
 
         /**
@@ -4434,20 +4460,21 @@ window.PrivateBin = (function () {
                 return;
             }
 
+            // forget a previous document, even if the expiration can't be calculated
+            emailDocument = null;
+            emailConfirmDocument = null;
             try {
-                // we cache expiration date in closure to avoid inaccurate expiration datetime
+                // we cache the expiration date to avoid an inaccurate expiration datetime
                 const expirationDate = Helper.calculateExpirationDate(
                     new Date(),
                     typeof optionalRemainingTimeInSeconds === 'number' ? optionalRemainingTimeInSeconds : TopNav.getExpiration()
                 );
-                const isBurnafterreading = typeof optionalBurnAfterReading === 'boolean' ?
-                    optionalBurnAfterReading : TopNav.getBurnAfterReading();
-
+                emailDocument = {
+                    expirationDate: expirationDate,
+                    isBurnafterreading: typeof optionalBurnAfterReading === 'boolean' ?
+                        optionalBurnAfterReading : TopNav.getBurnAfterReading()
+                };
                 emailLink.classList.remove('hidden');
-                emailLink.removeEventListener('click', sendEmail);
-                emailLink.addEventListener('click', () => {
-                    sendEmail(expirationDate, isBurnafterreading);
-                });
             } catch (error) {
                 console.error(error);
                 Alert.showError('Cannot calculate expiration date.');
@@ -4466,7 +4493,8 @@ window.PrivateBin = (function () {
             }
 
             emailLink.classList.add('hidden');
-            emailLink.removeEventListener('click', sendEmail);
+            emailDocument = null;
+            emailConfirmDocument = null;
         };
 
         /**
@@ -4762,13 +4790,26 @@ window.PrivateBin = (function () {
         }
 
         /**
-         * init navigation manager
+         * bind the event listeners of the e-mail button and its confirmation
+         * dialog
          *
-         * preloads DOM elements
-         *
-         * @name   TopNav.init
+         * @name   TopNav.bindEmailEvents
+         * @private
          * @function
          */
+        function bindEmailEvents() {
+            if (emailLink) {
+                emailLink.addEventListener('click', sendEmail);
+            }
+            if (emailConfirmModal) {
+                emailConfirmModal.addEventListener('shown.bs.modal', () => {
+                    emailConfirmTimezoneUtc.focus();
+                });
+                emailConfirmTimezoneCurrent.addEventListener('click', confirmEmailTimezone);
+                emailConfirmTimezoneUtc.addEventListener('click', confirmEmailTimezone);
+            }
+        }
+
         /**
          * bind all navigation event listeners
          *
@@ -4823,6 +4864,7 @@ window.PrivateBin = (function () {
             if (qrCodeLink) {
                 qrCodeLink.addEventListener('click', displayQrCode);
             }
+            bindEmailEvents();
 
             if (Helper.isBootstrap5()) {
                 const pasteExpirationSelect = document.getElementById('pasteExpiration');
@@ -4866,6 +4908,14 @@ window.PrivateBin = (function () {
             }
         }
 
+        /**
+         * init navigation manager
+         *
+         * preloads DOM elements
+         *
+         * @name   TopNav.init
+         * @function
+         */
         me.init = function () {
             attach = document.getElementById('attach');
             burnAfterReading = document.getElementById('burnafterreading');
@@ -4887,6 +4937,14 @@ window.PrivateBin = (function () {
             sendButton = document.getElementById('sendbutton');
             qrCodeLink = document.getElementById('qrcodelink');
             emailLink = document.getElementById('emaillink');
+            emailConfirmModal = document.getElementById('emailconfirmmodal');
+            emailConfirmTimezoneCurrent = document.getElementById('emailconfirm-timezone-current');
+            emailConfirmTimezoneUtc = document.getElementById('emailconfirm-timezone-utc');
+            emailConfirmBootstrapModal = emailConfirmModal && typeof bootstrap !== 'undefined' &&
+                bootstrap.Tooltip && typeof bootstrap.Modal === 'function' ?
+                new bootstrap.Modal(emailConfirmModal) : null;
+            emailDocument = null;
+            emailConfirmDocument = null;
 
             bindEvents();
 

+ 2 - 2
js/test/AttachmentViewer.js

@@ -45,8 +45,8 @@ describe('AttachmentViewer', function () {
                     if (filename === '\u0000') {
                         filename = '';
                     }
-                    prefix  = prefix.replace(/%(s|d)/g, '%%');
-                    postfix = postfix.replace(/%(s|d)/g, '%%').replace(/<|>/g, '');
+                    prefix  = common.removePlaceholders(prefix);
+                    postfix = common.removePlaceholders(postfix).replace(/<|>/g, '');
                     document.body.innerHTML = bodyTemplate;
                     mockCreateObjectUrl(false);
                     PrivateBin.AttachmentViewer.init();

+ 13 - 13
js/test/Helper.js

@@ -200,9 +200,9 @@ describe('Helper', function () {
                 fc.array(fc.string(), {minLength: 1}),
                 fc.string(),
                 function (prefix, params, postfix) {
-                    prefix    =    prefix.replace(/%(s|d)/g, '%%');
-                    params[0] = params[0].replace(/%(s|d)/g, '%%');
-                    postfix   =   postfix.replace(/%(s|d)/g, '%%');
+                    prefix    =    common.removePlaceholders(prefix);
+                    params[0] = common.removePlaceholders(params[0]);
+                    postfix   =   common.removePlaceholders(postfix);
                     var result = prefix + params[0] + postfix;
                     params.unshift(prefix + '%s' + postfix);
                     return result === PrivateBin.Helper.sprintf.apply(this, params);
@@ -215,8 +215,8 @@ describe('Helper', function () {
                 fc.array(fc.nat(), {minLength: 1}),
                 fc.string(),
                 function (prefix, params, postfix) {
-                    prefix  =  prefix.replace(/%(s|d)/g, '%%');
-                    postfix = postfix.replace(/%(s|d)/g, '%%');
+                    prefix  =  common.removePlaceholders(prefix);
+                    postfix = common.removePlaceholders(postfix);
                     var result = prefix + params[0] + postfix;
                     params.unshift(prefix + '%d' + postfix);
                     return result === PrivateBin.Helper.sprintf.apply(this, params);
@@ -229,8 +229,8 @@ describe('Helper', function () {
                 fc.array(fc.falsy(), {minLength: 1}),
                 fc.string(),
                 function (prefix, params, postfix) {
-                    prefix  =  prefix.replace(/%(s|d)/g, '%%');
-                    postfix = postfix.replace(/%(s|d)/g, '%%');
+                    prefix  =  common.removePlaceholders(prefix);
+                    postfix = common.removePlaceholders(postfix);
                     var result = prefix + '0' + postfix;
                     params.unshift(prefix + '%d' + postfix);
                     return result === PrivateBin.Helper.sprintf.apply(this, params);
@@ -245,9 +245,9 @@ describe('Helper', function () {
                 fc.string(),
                 fc.string(),
                 function (prefix, uint, middle, string, postfix) {
-                    prefix  =  prefix.replace(/%(s|d)/g, '');
-                    middle  =  middle.replace(/%(s|d)/g, '');
-                    postfix = postfix.replace(/%(s|d)/g, '');
+                    prefix  =  common.removePlaceholders(prefix);
+                    middle  =  common.removePlaceholders(middle);
+                    postfix = common.removePlaceholders(postfix);
                     var params = [prefix + '%d' + middle + '%s' + postfix, uint, string],
                         result = prefix + uint + middle + string + postfix;
                     return result === PrivateBin.Helper.sprintf.apply(this, params);
@@ -262,9 +262,9 @@ describe('Helper', function () {
                 fc.string(),
                 fc.string(),
                 function (prefix, uint, middle, string, postfix) {
-                    prefix  =  prefix.replace(/%(s|d)/g, '');
-                    middle  =  middle.replace(/%(s|d)/g, '');
-                    postfix = postfix.replace(/%(s|d)/g, '');
+                    prefix  =  common.removePlaceholders(prefix);
+                    middle  =  common.removePlaceholders(middle);
+                    postfix = common.removePlaceholders(postfix);
                     var params = [prefix + '%s' + middle + '%d' + postfix, string, uint],
                         result = prefix + string + middle + uint + postfix;
                     return result === PrivateBin.Helper.sprintf.apply(this, params);

+ 13 - 13
js/test/I18n.js

@@ -13,7 +13,7 @@ describe('I18n', function () {
             fc.assert(fc.property(
                 fc.string(),
                 function (messageId) {
-                    messageId   = messageId.replace(/%(s|d)/g, '%%');
+                    messageId   = common.removePlaceholders(messageId);
                     const plurals = [messageId, messageId + 's'],
                           fake    = [messageId],
                           result  = PrivateBin.I18n.translate(messageId);
@@ -56,9 +56,9 @@ describe('I18n', function () {
                 fc.array(fc.string(), {minLength: 1}),
                 fc.string(),
                 function (prefix, params, postfix) {
-                    prefix    =    prefix.replace(/%(s|d)/g, '%%').replace(/<a/g, '');
-                    params[0] = params[0].replace(/%(s|d)/g, '%%');
-                    postfix   =   postfix.replace(/%(s|d)/g, '%%').replace(/<a/g, '');
+                    prefix    =    common.removePlaceholders(prefix).replace(/<a/g, '');
+                    params[0] = common.removePlaceholders(params[0]);
+                    postfix   =   common.removePlaceholders(postfix).replace(/<a/g, '');
                     const translation = PrivateBin.Helper.htmlEntities(prefix + params[0] + postfix);
                     params.unshift(prefix + '%s' + postfix);
                     const result = PrivateBin.I18n.translate.apply(this, params);
@@ -75,9 +75,9 @@ describe('I18n', function () {
                 fc.array(fc.string(), {minLength: 1}),
                 fc.string(),
                 function (prefix, params, postfix) {
-                    prefix    =    prefix.replace(/%(s|d)/g, '%%');
-                    params[0] = params[0].replace(/%(s|d)/g, '%%');
-                    postfix   =   postfix.replace(/%(s|d)/g, '%%');
+                    prefix    =    common.removePlaceholders(prefix);
+                    params[0] = common.removePlaceholders(params[0]);
+                    postfix   =   common.removePlaceholders(postfix);
                     const translation = DOMPurify.sanitize(
                         prefix + '<a href="' + params[0] + '"></a>' + postfix, {
                             ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|magnet):)/i,
@@ -100,9 +100,9 @@ describe('I18n', function () {
                 fc.array(fc.string(), {minLength: 1}),
                 fc.string(),
                 function (prefix, params, postfix) {
-                    prefix    =    prefix.replace(/%(s|d)/g, '%%').replace(/<a/g, '');
-                    params[0] = params[0].replace(/%(s|d)/g, '%%');
-                    postfix   =   postfix.replace(/%(s|d)/g, '%%').replace(/<a/g, '');
+                    prefix    =    common.removePlaceholders(prefix).replace(/<a/g, '');
+                    params[0] = common.removePlaceholders(params[0]);
+                    postfix   =   common.removePlaceholders(postfix).replace(/<a/g, '');
                     const tempDiv = document.createElement('textarea');
                     tempDiv.textContent = (prefix + params[0] + postfix);
                     const translation = tempDiv.textContent;
@@ -133,9 +133,9 @@ describe('I18n', function () {
                 fc.array(fc.string(), {minLength: 1}),
                 fc.string(),
                 function (prefix, params, postfix) {
-                    prefix    =    prefix.replace(/%(s|d)/g, '%%').trim();
-                    params[0] = params[0].replace(/%(s|d)/g, '%%').trim();
-                    postfix   =   postfix.replace(/%(s|d)/g, '%%').trim();
+                    prefix    =    common.removePlaceholders(prefix).trim();
+                    params[0] = common.removePlaceholders(params[0]).trim();
+                    postfix   =   common.removePlaceholders(postfix).trim();
                     const translation = DOMPurify.sanitize(
                         prefix + '<a href="' + params[0] + '"></a>' + postfix, {
                             ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|magnet):)/i,

+ 280 - 9
js/test/TopNav.js

@@ -9,30 +9,301 @@ function query(selector) {
     return document.querySelector(selector);
 }
 
+/**
+ * builds the navigation with the e-mail button and its confirmation dialog, as
+ * in the bootstrap5 template
+ *
+ * @param {boolean} withShortUrl - add the link of a shortened document URL
+ */
+function buildEmailDom(withShortUrl) {
+    document.documentElement.innerHTML =
+        '<nav><div id="navbar"><ul><li>' +
+        '<button id="clonebutton" type="button" class="btn btn-secondary">' +
+        '<svg width="16" height="16" fill="currentColor" aria-hidden="true">' +
+        '<use href="img/bootstrap-icons.svg#copy"></use></svg> Clone</button>' +
+        '</li><li>' +
+        '<button id="emaillink" type="button" class="hidden btn btn-secondary">Email</button>' +
+        '</li></ul></div></nav>' +
+        '<input id="burnafterreadingoption" type="checkbox">' +
+        (withShortUrl ?
+            '<div id="pastelink">Your document is ' +
+            '<a id="pasteurl" href="https://short.example/xYz">https://short.example/xYz</a> ' +
+            '<span id="copyhint">(Hit <kbd>Ctrl</kbd>+<kbd>c</kbd> to copy)</span></div>' :
+            '') +
+        '<div id="emailconfirmmodal" tabindex="-1" class="modal" role="dialog" aria-hidden="true">' +
+        '<div class="modal-dialog" role="document"><div class="modal-content"><div class="modal-body row">' +
+        '<button id="emailconfirm-timezone-current" type="button">Use Current Timezone</button>' +
+        '<button id="emailconfirm-timezone-utc" type="button">Convert To UTC</button>' +
+        '</div></div></div></div>';
+}
+
+/**
+ * replaces window.open, to record the e-mails sent via mailto: URLs
+ *
+ * @returns {{getUrl: function, getCount: function, reset: function, restore: function}}
+ */
+function mockWindowOpen() {
+    const originalOpen = window.open;
+    let openedUrl = null,
+        openedCount = 0;
+    window.open = function (url) {
+        openedUrl = url;
+        ++openedCount;
+        return {};
+    };
+    return {
+        getUrl: () => openedUrl,
+        getCount: () => openedCount,
+        reset: () => {
+            openedUrl = null;
+            openedCount = 0;
+        },
+        restore: () => {
+            window.open = originalOpen;
+        }
+    };
+}
+
+/**
+ * extracts and decodes a parameter of a mailto: URL
+ *
+ * @param {string} mailtoUrl
+ * @param {string} name - i.e. subject or body
+ * @returns {string}
+ */
+function mailtoParameter(mailtoUrl, name) {
+    assert.match(mailtoUrl, /^mailto:\?/, 'expected a mailto: URL');
+    const match = mailtoUrl.match(new RegExp('[?&]' + name + '=([^&]*)'));
+    assert.ok(match, 'expected the mailto: URL to have a ' + name + '= parameter');
+    return decodeURIComponent(match[1]);
+}
+
 describe('TopNav', function () {
-    describe('showEmailButton', function () {
+    describe('showEmailButton & hideEmailButton', function () {
+        let windowOpen;
+
+        beforeEach(function () {
+            cleanup();
+            windowOpen = mockWindowOpen();
+        });
+
         afterEach(function () {
+            windowOpen.restore();
             cleanup();
         });
 
-        it('does nothing when the email button is omitted from the page', function () {
+        it('does nothing when the e-mail button is omitted from the page', function () {
             document.documentElement.innerHTML =
                 '<nav><div id="navbar"><ul><li><button id="newbutton" ' +
                 'type="button">New</button></li></ul></div></nav>';
             PrivateBin.TopNav.init();
 
-            const originalShowError = PrivateBin.Alert.showError;
+            // cleanup() reloads PrivateBin, dropping this replacement
             let errorShown = false;
             PrivateBin.Alert.showError = function () {
                 errorShown = true;
             };
 
-            try {
-                assert.doesNotThrow(() => PrivateBin.TopNav.showEmailButton());
-                assert.strictEqual(errorShown, false);
-            } finally {
-                PrivateBin.Alert.showError = originalShowError;
-            }
+            assert.doesNotThrow(() => PrivateBin.TopNav.showEmailButton());
+            assert.strictEqual(errorShown, false);
+        });
+
+        it('sends a single e-mail after the confirmation dialog was dismissed', function () {
+            buildEmailDom(false);
+            PrivateBin.TopNav.init();
+            PrivateBin.TopNav.showEmailButton(3600);
+
+            // open the dialog and dismiss it without choosing a timezone
+            query('#emaillink').click();
+            assert.strictEqual(windowOpen.getCount(), 0, 'no e-mail before choosing a timezone');
+
+            query('#emaillink').click();
+            query('#emailconfirm-timezone-current').click();
+            assert.strictEqual(windowOpen.getCount(), 1, 'exactly one e-mail with the current timezone');
+
+            query('#emaillink').click();
+            query('#emailconfirm-timezone-utc').click();
+            assert.strictEqual(windowOpen.getCount(), 2, 'exactly one more e-mail with UTC');
+        });
+
+        it('sends a single e-mail per click after the button was shown again', function () {
+            buildEmailDom(false);
+            PrivateBin.TopNav.init();
+            // i.e. sending a second document without reloading the page
+            PrivateBin.TopNav.showEmailButton(0);
+            PrivateBin.TopNav.showEmailButton(0);
+
+            query('#emaillink').click();
+            assert.strictEqual(windowOpen.getCount(), 1, 'exactly one e-mail per click');
+        });
+
+        it('uses the expiration of the document the button was last shown for', function () {
+            buildEmailDom(false);
+            PrivateBin.TopNav.init();
+            // first document expires, the second never does
+            PrivateBin.TopNav.showEmailButton(3600);
+            PrivateBin.TopNav.showEmailButton(0);
+
+            query('#emaillink').click();
+            assert.strictEqual(windowOpen.getCount(), 1, 'sent directly, without confirmation dialog');
+            assert.doesNotMatch(mailtoParameter(windowOpen.getUrl(), 'body'), /expire/i, 'body has no expiration');
+
+            query('#emailconfirm-timezone-current').click();
+            assert.strictEqual(windowOpen.getCount(), 1, 'no e-mail from a stale confirmation dialog');
+        });
+
+        it('sends no e-mail from a confirmation dialog opened for an earlier document', function () {
+            buildEmailDom(false);
+            PrivateBin.TopNav.init();
+            PrivateBin.TopNav.showEmailButton(3600);
+
+            // open the dialog, but start a new document instead of choosing
+            query('#emaillink').click();
+            PrivateBin.TopNav.hideEmailButton();
+            PrivateBin.TopNav.showEmailButton(0);
+
+            query('#emailconfirm-timezone-current').click();
+            query('#emailconfirm-timezone-utc').click();
+            assert.strictEqual(windowOpen.getCount(), 0, 'no e-mail from a stale confirmation dialog');
+        });
+
+        it('sends no e-mail after the button was hidden', function () {
+            buildEmailDom(false);
+            PrivateBin.TopNav.init();
+            PrivateBin.TopNav.showEmailButton(0);
+            PrivateBin.TopNav.hideEmailButton();
+
+            // i.e. a keyboard shortcut or assistive technology activating it
+            query('#emaillink').click();
+            assert.strictEqual(windowOpen.getCount(), 0, 'no e-mail from a hidden button');
+        });
+
+        it('sends exactly one e-mail, regardless of how often the button was shown or the dialog dismissed', function () {
+            fc.assert(fc.property(
+                fc.integer({min: 1, max: 5}),
+                fc.integer({min: 0, max: 5}),
+                fc.constantFrom('current', 'utc'),
+                function (shown, dismissed, timezone) {
+                    // replacing the markup is enough, init binds to the new elements
+                    buildEmailDom(false);
+                    PrivateBin.TopNav.init();
+                    for (let i = 0; i < shown; ++i) {
+                        PrivateBin.TopNav.showEmailButton(3600);
+                    }
+                    windowOpen.reset();
+
+                    for (let i = 0; i < dismissed; ++i) {
+                        query('#emaillink').click();
+                    }
+                    query('#emaillink').click();
+                    query('#emailconfirm-timezone-' + timezone).click();
+                    assert.strictEqual(windowOpen.getCount(), 1);
+                }
+            ));
+        });
+    });
+
+    describe('sendEmail', function () {
+        let windowOpen;
+
+        beforeEach(function () {
+            cleanup();
+            windowOpen = mockWindowOpen();
+        });
+
+        afterEach(function () {
+            windowOpen.restore();
+            cleanup();
+        });
+
+        it('puts the short URL into the mail body, if there is one', function () {
+            buildEmailDom(true);
+            PrivateBin.TopNav.init();
+            PrivateBin.TopNav.showEmailButton(0);
+            assert.ok(!query('#emaillink').classList.contains('hidden'), 'e-mail button is shown');
+
+            query('#emaillink').click();
+            assert.ok(windowOpen.getUrl(), 'an e-mail was sent');
+            const body = mailtoParameter(windowOpen.getUrl(), 'body');
+            assert.match(body, /https:\/\/short\.example\/xYz/, 'body contains the short URL');
+            assert.doesNotMatch(body, /undefined/, 'body never contains "undefined"');
+        });
+
+        it('puts the page URL into the mail body, if there is no short URL', function () {
+            buildEmailDom(false);
+            PrivateBin.TopNav.init();
+            PrivateBin.TopNav.showEmailButton(0);
+            assert.ok(!query('#emaillink').classList.contains('hidden'), 'e-mail button is shown');
+
+            query('#emaillink').click();
+            assert.ok(windowOpen.getUrl(), 'an e-mail was sent');
+            const body = mailtoParameter(windowOpen.getUrl(), 'body');
+            assert.match(body, new RegExp(window.location.href), 'body contains the page URL');
+            assert.doesNotMatch(body, /undefined/, 'body never contains "undefined"');
+        });
+
+        it('uses the burn-after-reading state of the viewed document, not the instance default', function () {
+            buildEmailDom(false);
+            const burnAfterReading = document.createElement('input');
+            burnAfterReading.id = 'burnafterreading';
+            burnAfterReading.type = 'checkbox';
+            burnAfterReading.checked = true;
+            document.body.appendChild(burnAfterReading);
+            PrivateBin.TopNav.init();
+            PrivateBin.TopNav.showEmailButton(0, false);
+
+            query('#emaillink').click();
+            assert.doesNotMatch(
+                mailtoParameter(windowOpen.getUrl(), 'body'),
+                /only be accessed once/,
+                'body uses the metadata of the viewed document'
+            );
+        });
+
+        it('names the instance in the mail subject', function () {
+            buildEmailDom(false);
+            // buildEmailDom() replaces the whole document, including its title
+            document.title = 'My PrivateBin Instance';
+            PrivateBin.TopNav.init();
+            PrivateBin.TopNav.showEmailButton(0);
+
+            query('#emaillink').click();
+            assert.ok(windowOpen.getUrl(), 'an e-mail was sent');
+            assert.match(mailtoParameter(windowOpen.getUrl(), 'subject'), /My PrivateBin Instance/);
+        });
+
+        it('names the instance in the mail subject after the expiration was confirmed', function () {
+            buildEmailDom(true);
+            document.title = 'My PrivateBin Instance';
+            PrivateBin.TopNav.init();
+            // a remaining time asks to confirm the timezone of the expiration
+            PrivateBin.TopNav.showEmailButton(3600);
+
+            query('#emaillink').click();
+            query('#emailconfirm-timezone-current').click();
+            assert.ok(windowOpen.getUrl(), 'an e-mail was sent');
+            assert.match(mailtoParameter(windowOpen.getUrl(), 'subject'), /My PrivateBin Instance/);
+        });
+
+        it('percent-encodes the mail subject, i.e. spaces in the instance name', function () {
+            buildEmailDom(false);
+            document.title = 'My Cool Instance';
+            PrivateBin.TopNav.init();
+            PrivateBin.TopNav.showEmailButton(0);
+
+            query('#emaillink').click();
+            const rawSubjectParam = windowOpen.getUrl().split('&body=')[0];
+            assert.strictEqual(
+                rawSubjectParam,
+                `mailto:?subject=${encodeURIComponent('Encrypted note on My Cool Instance')}`,
+                'raw subject parameter is exactly the encoded translated string'
+            );
+            assert.doesNotMatch(rawSubjectParam, / /, 'raw mailto: URL contains no literal space');
+            assert.strictEqual(
+                mailtoParameter(windowOpen.getUrl(), 'subject'),
+                'Encrypted note on My Cool Instance',
+                'decoded subject round-trips exactly'
+            );
         });
     });
 

+ 0 - 254
js/test/emailTemplateTest.js

@@ -1,254 +0,0 @@
-'use strict';
-require('../common');
-
-// DOM builder that mirrors bootstrap5.php navbar
-function buildEmailDomNoShortUrl() {
-    document.documentElement.innerHTML =
-        // TopNav expects initially hidden #emaillink BUTTON.
-        '<nav><div id="navbar"><ul>' +
-          '<li>' +
-            '<button id="clonebutton" type="button" class="btn btn-warning navbar-btn">' +
-              '<span class="glyphicon glyphicon-duplicate" aria-hidden="true"></span> Clone' +
-            '</button>' +
-          '</li>' +
-          '<li>' +
-            '<button id="emaillink" type="button" class="hidden btn btn-secondary">Email</button>' +
-          '</li>' +
-        '</ul></div></nav>' +
-        '<input id="burnafterreadingoption" type="checkbox">' +
-        // include dummy email confirm modal for sendEmail
-        '<div id="emailconfirmmodal" class="hidden">' +
-            '<div id="emailconfirm-timezone-current"></div>' +
-            '<div id="emailconfirm-timezone-utc"></div>' +
-        '</div>'
-}
-
-// DOM builder that adds the shortener result block
-function buildEmailDomWithShortUrl() {
-    buildEmailDomNoShortUrl();
-    document.documentElement.innerHTML =
-        // TopNav expectsinitially hidden #emaillink BUTTON.
-        '<nav><div id="navbar"><ul>' +
-          '<li>' +
-            '<button id="clonebutton" type="button" class="btn btn-warning navbar-btn">' +
-              '<span class="glyphicon glyphicon-duplicate" aria-hidden="true"></span> Clone' +
-            '</button>' +
-          '</li>' +
-          '<li>' +
-            '<button id="emaillink" type="button" class="hidden btn btn-secondary">Email</button>' +
-          '</li>' +
-        '</ul></div></nav>' +
-        '<input id="burnafterreadingoption" type="checkbox">' +
-        '<div id="pastelink">Your document is ' +
-          '<a id="pasteurl" href="https://short.example/xYz">https://short.example/xYz</a> ' +
-          '<span id="copyhint">(Hit <kbd>Ctrl</kbd>+<kbd>c</kbd> to copy)</span>' +
-        '</div>' +
-        // add a minimal email confirmation modal so sendEmail does not crash
-        '<div id="emailconfirmmodal" class="hidden">' +
-            '<div id="emailconfirm-timezone-current"></div>' +
-            '<div id="emailconfirm-timezone-utc"></div>' +
-        '</div>'
-}
-
-
-function makeWindowOpenMock() {
-    const originalOpen = window.open;
-    let openedUrl = null;
-    let mockRestoreFn = null;
-
-    if (typeof jest !== 'undefined' && typeof jest.spyOn === 'function') {
-        const spy = jest.spyOn(window, 'open').mockImplementation((url) => {
-            openedUrl = url;
-            return {};
-        });
-        mockRestoreFn = () => spy.mockRestore();
-    } else {
-        window.open = function (url) {
-            openedUrl = url;
-            return {};
-        };
-        mockRestoreFn = () => { window.open = originalOpen; };
-    }
-
-    return {
-        getUrl: () => openedUrl,
-        restore: () => {
-            if (mockRestoreFn) {
-                mockRestoreFn();
-            }
-        }
-    };
-}
-
-
-// Extract and decode the body from a "mailto:?subject=...&body=..." URL.
-function extractMailtoBody(mailtoUrl) {
-    assert.match(mailtoUrl, /^mailto:\?/, 'expected a mailto: URL');
-    const match = mailtoUrl.match(/[?&]body=([^&]*)/);
-    assert.ok(match, 'expected the mailto: URL to have a body= parameter');
-    return decodeURIComponent(match[1]);
-}
-
-// Extract and decode the subject from a "mailto:?subject=...&body=..." URL.
-function extractMailtoSubject(mailtoUrl) {
-    assert.match(mailtoUrl, /^mailto:\?/, 'expected a mailto: URL');
-    const match = mailtoUrl.match(/[?&]subject=([^&]*)/);
-    assert.ok(match, 'expected the mailto: URL to have a subject= parameter');
-    return decodeURIComponent(match[1]);
-}
-
-describe('Email - mail body content (short URL vs. fallback)', function () {
-    beforeEach(function () {
-        cleanup(); // provided by common
-    });
-
-    it('Uses the short URL when #pasteurl is present and never includes "undefined"', function () {
-        buildEmailDomWithShortUrl(); // with #pastelink/#pasteurl
-        PrivateBin.TopNav.init();
-        PrivateBin.TopNav.showEmailButton(0);
-
-        const emailBtn = document.getElementById('emaillink');
-        assert.ok(!emailBtn.classList.contains('hidden'), '#emaillink should be visible after showEmailButton');
-
-        const { getUrl, restore } = makeWindowOpenMock();
-        try {
-            emailBtn.click();
-            document.getElementById('emailconfirm-timezone-current').click();
-
-            const openedUrl = getUrl();
-            assert.ok(openedUrl, 'window.open should have been called');
-
-            const body = extractMailtoBody(openedUrl);
-            assert.match(body, /https:\/\/short\.example\/xYz/, 'email body should include the short URL');
-            assert.doesNotMatch(body, /undefined/, 'email body must not contain "undefined"');
-        } finally {
-            restore();
-        }
-    });
-
-    it('Falls back to window.location.href when #pasteurl is absent and never includes "undefined"', function () {
-        buildEmailDomNoShortUrl(); // No #pasteurl
-        PrivateBin.TopNav.init();
-        PrivateBin.TopNav.showEmailButton(0);
-
-        const emailBtn = document.getElementById('emaillink');
-        assert.ok(!emailBtn.classList.contains('hidden'), '#emaillink should be visible after showEmailButton');
-
-        const { getUrl, restore } = makeWindowOpenMock();
-        try {
-            emailBtn.click();
-            document.getElementById('emailconfirm-timezone-current').click();
-
-            const openedUrl = getUrl();
-            assert.ok(openedUrl, 'window.open should have been called');
-
-            const body = extractMailtoBody(openedUrl);
-            assert.match(body, new RegExp(window.location.href), 'email body should include the fallback page URL');
-            assert.doesNotMatch(body, /undefined/, 'email body must not contain "undefined"');
-        } finally {
-            restore();
-        }
-    });
-
-    it('Uses the viewed paste burn-after-reading state instead of the instance default', function () {
-        buildEmailDomNoShortUrl();
-        const burnAfterReading = document.createElement('input');
-        burnAfterReading.id = 'burnafterreading';
-        burnAfterReading.type = 'checkbox';
-        burnAfterReading.checked = true;
-        document.body.appendChild(burnAfterReading);
-        PrivateBin.TopNav.init();
-        PrivateBin.TopNav.showEmailButton(0, false);
-
-        const { getUrl, restore } = makeWindowOpenMock();
-        try {
-            document.getElementById('emaillink').click();
-            document.getElementById('emailconfirm-timezone-current').click();
-
-            const body = extractMailtoBody(getUrl());
-            assert.doesNotMatch(
-                body,
-                /only be accessed once/,
-                'email body must use the viewed paste metadata'
-            );
-        } finally {
-            restore();
-        }
-    });
-});
-
-describe('Email - mail subject', function () {
-    beforeEach(function () {
-        cleanup(); // provided by common
-    });
-
-    it('Includes a non-empty subject naming the instance, with no expiration confirmation step', function () {
-        buildEmailDomNoShortUrl();
-        // buildEmailDomNoShortUrl() replaces documentElement.innerHTML (dropping <title>),
-        // so document.title must be (re-)set after calling it, not before.
-        document.title = 'My PrivateBin Instance';
-        PrivateBin.TopNav.init();
-        PrivateBin.TopNav.showEmailButton(0);
-
-        const { getUrl, restore } = makeWindowOpenMock();
-        try {
-            document.getElementById('emaillink').click();
-
-            const openedUrl = getUrl();
-            assert.ok(openedUrl, 'window.open should have been called');
-
-            const subject = extractMailtoSubject(openedUrl);
-            assert.match(subject, /My PrivateBin Instance/, 'subject should name the instance');
-        } finally {
-            restore();
-        }
-    });
-
-    it('Includes the same subject after the expiration confirmation step', function () {
-        buildEmailDomWithShortUrl();
-        document.title = 'My PrivateBin Instance';
-        PrivateBin.TopNav.init();
-        // a non-zero remaining time routes through the timezone confirmation modal
-        PrivateBin.TopNav.showEmailButton(3600);
-
-        const { getUrl, restore } = makeWindowOpenMock();
-        try {
-            document.getElementById('emaillink').click();
-            document.getElementById('emailconfirm-timezone-current').click();
-
-            const openedUrl = getUrl();
-            assert.ok(openedUrl, 'window.open should have been called');
-
-            const subject = extractMailtoSubject(openedUrl);
-            assert.match(subject, /My PrivateBin Instance/, 'subject should name the instance');
-        } finally {
-            restore();
-        }
-    });
-
-    it('Percent-encodes the subject so a space-containing instance name does not break the mailto URL', function () {
-        buildEmailDomNoShortUrl();
-        document.title = 'My Cool Instance';
-        PrivateBin.TopNav.init();
-        PrivateBin.TopNav.showEmailButton(0);
-
-        const { getUrl, restore } = makeWindowOpenMock();
-        try {
-            document.getElementById('emaillink').click();
-
-            const openedUrl = getUrl();
-            const rawSubjectParam = openedUrl.split('&body=')[0];
-            assert.strictEqual(
-                rawSubjectParam,
-                `mailto:?subject=${encodeURIComponent('Encrypted note on My Cool Instance')}`,
-                'raw subject parameter should be exactly the encoded translated string'
-            );
-            assert.doesNotMatch(rawSubjectParam, / /, 'raw mailto URL must not contain a literal space');
-
-            const subject = extractMailtoSubject(openedUrl);
-            assert.strictEqual(subject, 'Encrypted note on My Cool Instance', 'decoded subject should round-trip exactly');
-        } finally {
-            restore();
-        }
-    });
-});

+ 1 - 1
lib/Configuration.php

@@ -119,7 +119,7 @@ class Configuration
             'js/kjua-0.10.0.js'      => 'sha512-BYj4xggowR7QD150VLSTRlzH62YPfhpIM+b/1EUEr7RQpdWAGKulxWnOvjFx1FUlba4m6ihpNYuQab51H6XlYg==',
             'js/legacy.js'           => 'sha512-pRofxsrf5UItjiP22Dcjh3FAcBjF/n7h8U9/W5xqJk17U0N2U1oajhXypq/omo9jhwS1iVGOhWrRepoPeFns+w==',
             'js/prettify.js'         => 'sha512-puO0Ogy++IoA2Pb9IjSxV1n4+kQkKXYAEUtVzfZpQepyDPyXk8hokiYDS7ybMogYlyyEIwMLpZqVhCkARQWLMg==',
-            'js/privatebin.js'       => 'sha512-eFzQCvoTxD9XC27QXD00V/JdudYlWmjQzP2wG7P07X8gByAc2BaNgxJbqvYEfoXdlF6s8NpuLvUtQ1Uv7ipAwQ==',
+            'js/privatebin.js'       => 'sha512-OjLGUI7ThGedSf2Fnuex16XhniPlEhEr6s08UJAJsLNIx3trf9hDtulW1LUXs5rqJPZG9B+fwBjq9Z9sqmMIrA==',
             'js/purify-3.4.17.js'    => 'sha512-udF2zPdEyVCAxIGeBbeSGFK1Fj5c4eHGi6NqOSsr8M75lGs39POzgUNxO3SFxOg4cHfAoZJgR2EaXdmDi6v6AQ==',
             'js/showdown-2.1.0.js'   => 'sha512-WYXZgkTR0u/Y9SVIA4nTTOih0kXMEd8RRV6MLFdL6YU8ymhR528NLlYQt1nlJQbYz4EW+ZsS0fx1awhiQJme1Q==',
             'js/zlib-1.3.2.js'       => 'sha512-RAhJgxg9siMIA8ky4c10Rc2zUgnK80olHB8Tt1IOYWY4Eh1WmrviQkDn+sgBlb38ZHq3tzufGC41kP360gmosQ==',