From f47e1577e3067cf8bb25ddb32cbed99f65b2e7ee Mon Sep 17 00:00:00 2001 From: JohnsonKC201 Date: Fri, 9 Oct 2026 01:04:49 -0400 Subject: [PATCH] fix(mail): say so when the password saved for Gmail is not an app password Settings showed a tick for any saved password of eight characters or more. With a Gmail address that misleads: Gmail accepts only a 16-letter app password over IMAP, so someone who pastes their normal Google password sees saved, the poll fails quietly, and the alerts never come with nothing to say why. passwordAdvice in mail.js looks at the saved length and whether the account resolves to imap.gmail.com. Settings then shows the length next to the field and, when the password is saved, a sentence on what that usually means and where to make an app password. It is worded as likely, since a work or school account can be set up differently by its admin. Only the length is used. The password is not compared, logged or sent anywhere, and nothing new leaves the main process beyond one word of advice on the existing Settings channel. --- CHANGELOG.md | 1 + src/mail.js | 18 +++++++++++++++--- src/main/settings-ipc.js | 2 +- src/settings-renderer.js | 19 ++++++++++++------- tests/mail.test.js | 26 ++++++++++++++++++++++++++ 5 files changed, 55 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8511e82..6683aed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,7 @@ Notable changes to **pixelpets**. All art and sound are original/procedural (no - **A treat handed over during a meeting gets eaten.** Parking the pet in its corner aims the same walk the trip to the fish uses, one step earlier in the frame, so it re-claimed that walk the moment the trip expired: the cat set off, was marched home just short of its food, set off again, and paced between corner and fish for as long as the treat existed - which is forever, since only eating clears it. Work mode is there to stop the pet wandering off on its own, not to veto something you clicked. ### Mail +- **A wrong kind of password is called out when you save it.** Settings showed "saved" for any password of eight characters or more. With a Gmail address that was misleading: Gmail only accepts a 16-letter app password here, so anyone who pasted their normal Google password got a tick, and then alerts that never came, with nothing to say why. Settings now says the saved password is not 16 characters, what that usually means, and where to make an app password. Only the length is checked, and the wording allows for work and school accounts, which an admin can set up differently. - **The pet says who it is from.** "You have 3 new emails" still makes you go and look, which is the interruption it was meant to save you. It now reads *"Alice: Budget review"*. The envelope fetch is wrapped separately from the count, so a server that refuses it still gets you the old count-only line rather than losing the alert. - **VIP senders.** Name the people who should reach you even while Focus Guard is holding everything else back. Matching is a case-insensitive substring of the From address - no pattern syntax to get wrong - so `@acme.com` covers a company and `boss@acme.com` one person. diff --git a/src/mail.js b/src/mail.js index ed8e6e2..348314f 100644 --- a/src/mail.js +++ b/src/mail.js @@ -33,9 +33,21 @@ function normalizePassword(plain) { // stale or undecryptable email.cred used to show a reassuring "saved" tick in // Settings while every poll bailed out for want of a password. The length lets the // UI call out a truncated app-password instead of pretending it is fine. -function passwordInfo() { +function passwordInfo(cfg) { const pw = readPassword(); - return { has: pw.length > 0, len: pw.length }; + const e = (cfg && cfg.email) || {}; + return { has: pw.length > 0, len: pw.length, advice: passwordAdvice(pw.length, e.user, e.host) }; +} + +// Google issues an app password as exactly sixteen letters, and setPassword has +// already taken the spaces out. Any other length saved against a Gmail account +// is almost always the account's own password, which Gmail refuses over IMAP, +// so the poll fails quietly and the alerts simply never come. Only the length +// is looked at: the password itself is never compared, logged or sent anywhere. +const GMAIL_APP_PASSWORD_LEN = 16; +function passwordAdvice(len, email, host) { + if (!len) return null; + return imapHostFor(email, host) === 'imap.gmail.com' && len !== GMAIL_APP_PASSWORD_LEN ? 'gmail-length' : null; } function hasPassword() { return passwordInfo().has; } function setPassword(plain) { @@ -172,4 +184,4 @@ function test(cfg, plainOverride) { function init(notify_, getCfg_) { notifyFn = notify_; getCfg = getCfg_; } function stop() { if (timer) { clearInterval(timer); timer = null; } } -module.exports = { init, sync, test, setPassword, hasPassword, passwordInfo, stop, imapHostFor, normalizePassword, isVip, describe }; +module.exports = { init, sync, test, setPassword, hasPassword, passwordInfo, passwordAdvice, GMAIL_APP_PASSWORD_LEN, stop, imapHostFor, normalizePassword, isVip, describe }; diff --git a/src/main/settings-ipc.js b/src/main/settings-ipc.js index 2b7cb6e..2d372d5 100644 --- a/src/main/settings-ipc.js +++ b/src/main/settings-ipc.js @@ -74,7 +74,7 @@ function registerSettingsIpc(d) { d.sendAction(id); }); - handleSecure('email:passwordInfo', () => d.mail.passwordInfo()); + handleSecure('email:passwordInfo', () => d.mail.passwordInfo(getCfg())); handleSecure('email:setPassword', (_e, pw) => d.mail.setPassword(pw)); handleSecure('email:test', (_e, pw) => d.mail.test(getCfg(), pw && String(pw).length ? String(pw) : null)); handleSecure('calendar:test', () => d.cal.test(getCfg())); diff --git a/src/settings-renderer.js b/src/settings-renderer.js index d417fab..6805202 100644 --- a/src/settings-renderer.js +++ b/src/settings-renderer.js @@ -296,15 +296,20 @@ $('emailOn').addEventListener('change', async () => { : 'Alerts are on. Hit Test to check the connection.'; }); const APP_PASSWORD_MIN = 8; // shorter than any provider issues: a sign of a truncated paste +// What may be wrong with the saved password, as a sentence, or '' when nothing is. +// Worded as likely, not certain: a work or school Google account can be set up +// in ways this cannot see. +const GMAIL_PASSWORD_HELP = 'Gmail usually needs a 16-letter app password, and the one saved is a different length. If it is your normal Google password, Gmail will refuse it. You can make an app password at myaccount.google.com/apppasswords once 2-Step Verification is on. On a work or school account, your admin may have to allow it.'; async function refreshEmailPassState() { try { const info = await window.settings.emailPasswordInfo(); const el = $('emailPassState'); - if (!info || !info.has) { el.textContent = ''; return; } - el.textContent = info.len < APP_PASSWORD_MIN - ? `\u00b7 saved, but only ${info.len} characters; re-enter it` - : '\u00b7 saved \u2713'; - } catch (e) { /* ignore */ } + if (!info || !info.has) { el.textContent = ''; return ''; } + if (info.len < APP_PASSWORD_MIN) { el.textContent = `\u00b7 saved, but only ${info.len} characters; re-enter it`; return ''; } + if (info.advice === 'gmail-length') { el.textContent = `\u00b7 saved, but ${info.len} characters, not 16`; return GMAIL_PASSWORD_HELP; } + el.textContent = '\u00b7 saved \u2713'; + return ''; + } catch (e) { return ''; } } // Save the app-password when the field is done, never mid-keystroke. The old // 600ms auto-save stored whatever had been typed so far and then blanked the box, @@ -317,10 +322,10 @@ function emailPassSave() { return (async () => { const r = await window.settings.emailSetPassword(pw); $('emailPass').value = ''; - await refreshEmailPassState(); + const problem = await refreshEmailPassState(); $('emailStatus').textContent = (r && r.ok === false) ? ('Could not save the password: ' + (r.error || 'unknown error')) - : `Password saved (${(r && r.len) | 0} characters, encrypted).`; + : (problem || `Password saved (${(r && r.len) | 0} characters, encrypted).`); })(); } $('emailPass').addEventListener('change', emailPassSave); diff --git a/tests/mail.test.js b/tests/mail.test.js index 7af8e6f..69f5289 100644 --- a/tests/mail.test.js +++ b/tests/mail.test.js @@ -84,3 +84,29 @@ test('no envelope falls back to the plain count', () => { assert.strictEqual(mail.describe(2, null), 'You have {count} new emails.'); assert.strictEqual(mail.describe(2, { name: '', address: '', subject: '' }), 'You have {count} new emails.'); }); + +test('a Gmail account with a password that is not sixteen letters is called out', () => { + const a = mail.passwordAdvice; + assert.strictEqual(a(16, 'me@gmail.com', 'imap.gmail.com'), null, 'a real app password'); + assert.strictEqual(a(19, 'me@gmail.com', 'imap.gmail.com'), 'gmail-length', 'most likely the account password'); + assert.strictEqual(a(12, 'me@googlemail.com', ''), 'gmail-length', 'the host is inferred from the address'); + assert.strictEqual(a(19, 'me@gmail.com', 'www.gmail.com'), 'gmail-length', 'a web address still means Gmail'); + // Other providers issue other lengths, and a self-hosted server can use anything. + assert.strictEqual(a(19, 'me@outlook.com', ''), null); + assert.strictEqual(a(40, 'me@example.org', 'mail.example.org'), null); + assert.strictEqual(a(0, 'me@gmail.com', 'imap.gmail.com'), null, 'nothing saved is not a wrong password'); + assert.strictEqual(mail.GMAIL_APP_PASSWORD_LEN, 16); +}); + +test('Settings turns that advice into a sentence, and asks about the account it is showing', () => { + const fs = require('node:fs'); + const path = require('node:path'); + const src = (f) => fs.readFileSync(path.join(__dirname, '..', 'src', f), 'utf8'); + const ui = src('settings-renderer.js'); + assert.match(ui, /info\.advice === 'gmail-length'/); + assert.match(ui, /myaccount\.google\.com\/apppasswords/); + // The advice depends on the address and server, so main must be handed the config. + assert.match(src(path.join('main', 'settings-ipc.js')), /passwordInfo\(getCfg\(\)\)/); + // The sentence replaces 'Password saved' so it is not buried under it. + assert.match(ui, /problem \|\| `Password saved/); +});