domma-cms 1.6.38 → 1.6.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +3 -3
- package/package.json +2 -2
- package/plugins/_lib/admin/mail/mail.css +1 -1
- package/plugins/_lib/admin/mail/oauth-panel.js +303 -0
- package/plugins/_lib/admin/mail/reader-view.js +126 -13
- package/plugins/_lib/mail/accounts.js +548 -225
- package/plugins/_lib/mail/imapPool.js +63 -13
- package/plugins/_lib/mail/oauth.js +453 -0
- package/plugins/_lib/mail/oauthRoutes.js +294 -0
- package/plugins/_lib/mail/readRoutes.js +23 -2
- package/plugins/_lib/mail/scheduler.js +30 -6
- package/plugins/feedback/CLAUDE.md +7 -0
- package/plugins/feedback/admin/views/feedback.js +1 -1
- package/plugins/feedback/lib/sender.js +19 -0
- package/plugins/feedback/plugin.js +2 -0
- package/plugins/feedback/plugin.json +2 -2
- package/plugins/feedback/tests/shape.test.js +23 -1
- package/plugins/free-tier.lock.json +19 -19
- package/plugins/shopping-cart/CLAUDE.md +7 -0
- package/plugins/shopping-cart/admin/views/orders.js +68 -48
- package/plugins/shopping-cart/plugin.json +1 -1
- package/plugins/shopping-cart/tests/pure.test.js +16 -0
- package/plugins/support/CLAUDE.md +7 -0
- package/plugins/support/admin/views/support.js +1 -1
- package/plugins/support/lib/sender.js +15 -0
- package/plugins/support/plugin.json +2 -2
- package/plugins/support/tests/shape.test.js +25 -1
- package/server/routes/outreach-public.js +2 -2
- package/server/services/request-log.js +3 -1
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The OAuth2 sign-in routes for mailboxes: Connect, the provider's callback,
|
|
3
|
+
* Disconnect and Test. Registered by Email Pro (which feature-detects this
|
|
4
|
+
* file - an engine without it simply offers no sign-in); the free reader does
|
|
5
|
+
* not register them, but reads a signed-in mailbox all the same.
|
|
6
|
+
*
|
|
7
|
+
* Each customer registers THEIR OWN app with Microsoft or Google - the Docs
|
|
8
|
+
* Pro cloud-copies design. The admin form shows this site's exact redirect
|
|
9
|
+
* URI with a guide; the client secret goes in sealed and never comes out.
|
|
10
|
+
*
|
|
11
|
+
* The callback is the one route without a session. Its `state` is a one-time
|
|
12
|
+
* ticket, ten minutes long, naming the person who pressed Connect, and the
|
|
13
|
+
* callback refuses a browser signed in to this site as somebody else.
|
|
14
|
+
*
|
|
15
|
+
* @module _lib/mail/oauthRoutes
|
|
16
|
+
*/
|
|
17
|
+
import {createHash, randomBytes, timingSafeEqual} from 'node:crypto';
|
|
18
|
+
|
|
19
|
+
import {originOf, trustedOrigin} from '../../../server/services/passwordReset.js';
|
|
20
|
+
import {resolveViewer} from '../../../server/services/viewerSession.js';
|
|
21
|
+
import * as accounts from './accounts.js';
|
|
22
|
+
import {MailKeyError, open, seal} from './secretbox.js';
|
|
23
|
+
import {verifySmtp} from './send.js';
|
|
24
|
+
import {
|
|
25
|
+
announceConnected, authorizeUrl, cleanTenant, configureOAuth, describeProviders, exchangeCode,
|
|
26
|
+
mailboxFor, pkcePair, PROVIDERS, revokeAccount
|
|
27
|
+
} from './oauth.js';
|
|
28
|
+
|
|
29
|
+
const STATE_TTL = 10 * 60 * 1000;
|
|
30
|
+
/** Pending sign-ins kept at once - a ceiling on what a busy (or hostile) admin can pile up. */
|
|
31
|
+
const MAX_PENDING = 200;
|
|
32
|
+
/** Pending sign-ins per person: a new one beyond this replaces their oldest. */
|
|
33
|
+
const MAX_PENDING_PER_USER = 5;
|
|
34
|
+
|
|
35
|
+
const sha256 = (v) => createHash('sha256').update(String(v)).digest();
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The browser-binding cookie's name for one sign-in. Per state, so two
|
|
39
|
+
* Connects in two tabs do not overwrite each other's cookie.
|
|
40
|
+
*
|
|
41
|
+
* @param {string} state
|
|
42
|
+
* @returns {string}
|
|
43
|
+
*/
|
|
44
|
+
export function bindCookieName(state) {
|
|
45
|
+
return `dm_mail_oauth_${sha256(state).toString('hex').slice(0, 16)}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* One cookie's value from a raw Cookie header (no cookie plugin assumed).
|
|
50
|
+
*
|
|
51
|
+
* @param {string|undefined} header
|
|
52
|
+
* @param {string} name
|
|
53
|
+
* @returns {string}
|
|
54
|
+
*/
|
|
55
|
+
export function readCookie(header, name) {
|
|
56
|
+
for (const part of String(header ?? '').split(';')) {
|
|
57
|
+
const at = part.indexOf('=');
|
|
58
|
+
if (at !== -1 && part.slice(0, at).trim() === name) return part.slice(at + 1).trim();
|
|
59
|
+
}
|
|
60
|
+
return '';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const text = (v, max = 300) => String(v ?? '').trim().slice(0, max);
|
|
64
|
+
// JSON inside a <script>: nothing in it can close the element early.
|
|
65
|
+
const js = (v) => JSON.stringify(v).replace(/</g, '\\u003c').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029');
|
|
66
|
+
const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @param {object} fastify
|
|
70
|
+
* @param {{authenticate: Function, pool: object, pluginName: string, config?: object,
|
|
71
|
+
* onNeedsReconnect?: Function, onConnected?: Function, fetch?: Function}} options
|
|
72
|
+
*/
|
|
73
|
+
export function registerOAuthRoutes(fastify, {authenticate, pool, pluginName, config = {}, onNeedsReconnect, onConnected, fetch}) {
|
|
74
|
+
configureOAuth({fetch, onNeedsReconnect: onNeedsReconnect ?? null, onConnected: onConnected ?? null});
|
|
75
|
+
|
|
76
|
+
/** state -> {userId, accountId, provider, clientId, clientSecretSealed, tenant, label, username, verifier, redirectUri, expires} */
|
|
77
|
+
const pending = new Map();
|
|
78
|
+
const userId = (request) => request.user?.id ?? request.user?.sub ?? null;
|
|
79
|
+
|
|
80
|
+
/** This site's exact return address - what the customer registers with the provider. */
|
|
81
|
+
async function redirectUriFor(request) {
|
|
82
|
+
const origin = await trustedOrigin(originOf(request)).catch(() => '') || originOf(request);
|
|
83
|
+
return `${origin}/api/plugins/${pluginName}/oauth/callback`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function sweep() {
|
|
87
|
+
const now = Date.now();
|
|
88
|
+
for (const [k, v] of pending) if (v.expires < now) pending.delete(k);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** GET /oauth/status - providers and the redirect URI, for the form's guide. */
|
|
92
|
+
fastify.get('/oauth/status', {preHandler: [authenticate]}, async (request) => {
|
|
93
|
+
const redirectUri = await redirectUriFor(request);
|
|
94
|
+
return {available: true, redirectUri, https: redirectUri.startsWith('https://'), providers: describeProviders()};
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* POST /oauth/start - where to send the person to sign in.
|
|
99
|
+
* Body: {accountId?, provider, clientId, clientSecret (blank = keep the saved one), tenant?, label?, username?}
|
|
100
|
+
*/
|
|
101
|
+
fastify.post('/oauth/start', {preHandler: [authenticate], config: {rateLimit: {max: 20, timeWindow: '1 minute'}}}, async (request, reply) => {
|
|
102
|
+
const uid = userId(request);
|
|
103
|
+
if (!uid) return reply.code(401).send({error: 'Authentication required.'});
|
|
104
|
+
const body = request.body ?? {};
|
|
105
|
+
const provider = text(body.provider, 20);
|
|
106
|
+
if (!PROVIDERS[provider]) return reply.code(400).send({error: 'Choose Microsoft or Google.'});
|
|
107
|
+
|
|
108
|
+
const accountId = body.accountId ? text(body.accountId, 80) : null;
|
|
109
|
+
const existing = accountId ? await accounts.getAccount(uid, accountId) : null;
|
|
110
|
+
if (accountId && !existing) return reply.code(404).send({error: 'That mailbox does not exist.'});
|
|
111
|
+
|
|
112
|
+
const clientId = text(body.clientId, 200);
|
|
113
|
+
if (!clientId) return reply.code(400).send({error: PROVIDERS[provider].id === 'microsoft' ? 'The Application (client) ID is needed.' : 'The Client ID is needed.'});
|
|
114
|
+
|
|
115
|
+
// Blank secret = the one already saved, but only for the same app.
|
|
116
|
+
let clientSecret = text(body.clientSecret, 2000);
|
|
117
|
+
if (!clientSecret) {
|
|
118
|
+
const o = existing?.oauth;
|
|
119
|
+
if (o?.clientSecretSealed && o.provider === provider && o.clientId === clientId) {
|
|
120
|
+
try {
|
|
121
|
+
clientSecret = open(o.clientSecretSealed);
|
|
122
|
+
} catch (err) {
|
|
123
|
+
if (err instanceof MailKeyError) return reply.code(409).send({error: 'The saved client secret can no longer be read - type it again.'});
|
|
124
|
+
throw err;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (!clientSecret) return reply.code(400).send({error: 'The client secret is needed.'});
|
|
129
|
+
|
|
130
|
+
const username = text(body.username, 254).toLowerCase();
|
|
131
|
+
if (username && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(username)) return reply.code(400).send({error: 'That is not an email address.'});
|
|
132
|
+
|
|
133
|
+
sweep();
|
|
134
|
+
// Per person first: their oldest waiting sign-in makes room. Then the
|
|
135
|
+
// site-wide ceiling, which only many people at once can reach.
|
|
136
|
+
const mine = [...pending.entries()].filter(([, v]) => v.userId === uid).sort((a, b) => a[1].expires - b[1].expires);
|
|
137
|
+
while (mine.length >= MAX_PENDING_PER_USER) pending.delete(mine.shift()[0]);
|
|
138
|
+
if (pending.size >= MAX_PENDING) return reply.code(429).send({error: 'Too many sign-ins are waiting. Try again in a few minutes.'});
|
|
139
|
+
|
|
140
|
+
const state = randomBytes(24).toString('base64url');
|
|
141
|
+
const {verifier, challenge} = pkcePair();
|
|
142
|
+
const redirectUri = await redirectUriFor(request);
|
|
143
|
+
const tenant = PROVIDERS[provider].needsTenant ? cleanTenant(body.tenant) : null;
|
|
144
|
+
// Binds the sign-in to THIS browser: the callback refuses one that does
|
|
145
|
+
// not bring the cookie back. Without it, an anonymous browser could
|
|
146
|
+
// finish a sign-in someone else started (send a colleague the
|
|
147
|
+
// provider link, and their mailbox lands under your account).
|
|
148
|
+
// HttpOnly, SameSite=Lax (sent on the provider's top-level redirect
|
|
149
|
+
// back), scoped to the callback path, gone in ten minutes.
|
|
150
|
+
const bind = randomBytes(24).toString('base64url');
|
|
151
|
+
const callbackPath = new URL(redirectUri).pathname;
|
|
152
|
+
reply.header('Set-Cookie', `${bindCookieName(state)}=${bind}; Path=${callbackPath}; Max-Age=${STATE_TTL / 1000}; HttpOnly; SameSite=Lax`
|
|
153
|
+
+ (redirectUri.startsWith('https://') ? '; Secure' : ''));
|
|
154
|
+
pending.set(state, {
|
|
155
|
+
userId: uid, accountId, provider, clientId, clientSecretSealed: seal(clientSecret), tenant,
|
|
156
|
+
label: text(body.label, 80), username, verifier, redirectUri, bindHash: sha256(bind), expires: Date.now() + STATE_TTL
|
|
157
|
+
});
|
|
158
|
+
return {
|
|
159
|
+
url: authorizeUrl({provider, clientId, tenant, redirectUri, state, codeChallenge: challenge,
|
|
160
|
+
loginHint: username || existing?.username || ''}),
|
|
161
|
+
redirectUri
|
|
162
|
+
};
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* GET /oauth/callback - the provider sends the person back here. Answers a
|
|
167
|
+
* small page that tells the mail screen (its opener) and closes itself.
|
|
168
|
+
*/
|
|
169
|
+
fastify.get('/oauth/callback', {config: {rateLimit: {max: 30, timeWindow: '1 minute'}}}, async (request, reply) => {
|
|
170
|
+
const q = request.query ?? {};
|
|
171
|
+
const key = String(q.state ?? '');
|
|
172
|
+
const ticket = pending.get(key);
|
|
173
|
+
pending.delete(key);
|
|
174
|
+
|
|
175
|
+
const page = (ok, message, origin, accountId = null) => reply
|
|
176
|
+
.type('text/html; charset=utf-8')
|
|
177
|
+
.header('Cache-Control', 'no-store')
|
|
178
|
+
.header('Referrer-Policy', 'no-referrer')
|
|
179
|
+
.header('X-Robots-Tag', 'noindex')
|
|
180
|
+
.send(`<!doctype html><meta charset="utf-8"><meta name="robots" content="noindex"><title>${ok ? 'Mailbox connected' : 'Not connected'}</title>`
|
|
181
|
+
+ `<body style="font-family:system-ui,sans-serif;display:grid;place-items:center;min-height:90vh;margin:0"><main style="max-width:30rem;text-align:center;padding:1rem">`
|
|
182
|
+
+ `<h1 style="font-size:1.3rem">${ok ? 'Mailbox connected' : 'Not connected'}</h1><p>${esc(message)}</p><p>You can close this window.</p></main>`
|
|
183
|
+
+ `<script>try{window.opener&&window.opener.postMessage(${js({type: 'domma-mail-oauth', ok, message, accountId})},${js(origin || '')});}catch(e){}${ok ? 'setTimeout(function(){window.close();},1200);' : ''}</script>`);
|
|
184
|
+
|
|
185
|
+
if (!ticket || ticket.expires < Date.now()) {
|
|
186
|
+
return page(false, 'This sign-in link has expired or was already used. Go back to Email and press Connect again.', null);
|
|
187
|
+
}
|
|
188
|
+
const origin = new URL(ticket.redirectUri).origin;
|
|
189
|
+
// Used up either way; clear it.
|
|
190
|
+
const cookieName = bindCookieName(key);
|
|
191
|
+
reply.header('Set-Cookie', `${cookieName}=; Path=${new URL(ticket.redirectUri).pathname}; Max-Age=0; HttpOnly; SameSite=Lax`);
|
|
192
|
+
if (q.error) return page(false, `The sign-in was not completed: ${text(q.error_description || q.error, 200)}`, origin);
|
|
193
|
+
|
|
194
|
+
// The browser that pressed Connect, and no other.
|
|
195
|
+
const bound = readCookie(request.headers.cookie, cookieName);
|
|
196
|
+
if (!bound || !timingSafeEqual(sha256(bound), ticket.bindHash)) {
|
|
197
|
+
return page(false, `This sign-in has to be finished in the browser that pressed Connect, with the admin open at ${origin}. Press Connect again there.`, origin);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Signed in to this site as somebody else: refuse - whoever pressed
|
|
201
|
+
// Connect must be the one who finishes it.
|
|
202
|
+
const viewer = await resolveViewer(request).catch(() => null);
|
|
203
|
+
if (viewer?.id && viewer.id !== ticket.userId) {
|
|
204
|
+
return page(false, 'This sign-in was started by someone else. Press Connect yourself.', origin);
|
|
205
|
+
}
|
|
206
|
+
if (ticket.accountId && !(await accounts.getAccount(ticket.userId, ticket.accountId))) {
|
|
207
|
+
return page(false, 'That mailbox no longer exists.', origin);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
const clientSecret = open(ticket.clientSecretSealed);
|
|
212
|
+
const got = await exchangeCode({
|
|
213
|
+
provider: ticket.provider, clientId: ticket.clientId, clientSecret, tenant: ticket.tenant,
|
|
214
|
+
code: text(q.code, 4000), redirectUri: ticket.redirectUri, codeVerifier: ticket.verifier
|
|
215
|
+
});
|
|
216
|
+
const box = mailboxFor(ticket.provider, got.claims);
|
|
217
|
+
// Typed wins: a Microsoft 365 shared mailbox is signed in to by a
|
|
218
|
+
// person but addressed as itself (XOAUTH2 user = the shared address).
|
|
219
|
+
const username = ticket.username || box.email;
|
|
220
|
+
if (!username) {
|
|
221
|
+
return page(false, 'The provider did not say which mailbox was signed in to. Type the email address in the form and Connect again.', origin);
|
|
222
|
+
}
|
|
223
|
+
const p = PROVIDERS[ticket.provider];
|
|
224
|
+
const saved = await accounts.saveOAuthAccount(ticket.userId, ticket.accountId, {
|
|
225
|
+
label: ticket.label || username,
|
|
226
|
+
username,
|
|
227
|
+
provider: ticket.provider,
|
|
228
|
+
clientId: ticket.clientId,
|
|
229
|
+
tenant: ticket.tenant,
|
|
230
|
+
clientSecret,
|
|
231
|
+
refreshToken: got.refreshToken,
|
|
232
|
+
accessToken: got.accessToken,
|
|
233
|
+
expiresAt: got.expiresAt,
|
|
234
|
+
scope: got.scope,
|
|
235
|
+
imap: p.imap,
|
|
236
|
+
smtp: box.smtp
|
|
237
|
+
});
|
|
238
|
+
if (!saved) return page(false, 'That mailbox no longer exists.', origin);
|
|
239
|
+
// A connection opened with the old details must not be reused.
|
|
240
|
+
pool.release(`${ticket.userId}:${saved.id}`);
|
|
241
|
+
announceConnected(saved);
|
|
242
|
+
return page(true, `${username} is connected through ${p.short}.`, origin, saved.id);
|
|
243
|
+
} catch (err) {
|
|
244
|
+
fastify.log.warn(`[${pluginName}] mailbox sign-in failed: ${err.message}`);
|
|
245
|
+
return page(false, text(err.message, 300), origin);
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
/** POST /oauth/:id/disconnect - hand the sign-in back (where the provider allows) and forget it. */
|
|
250
|
+
fastify.post('/oauth/:id/disconnect', {preHandler: [authenticate]}, async (request, reply) => {
|
|
251
|
+
const uid = userId(request);
|
|
252
|
+
if (!uid) return reply.code(401).send({error: 'Authentication required.'});
|
|
253
|
+
const account = await accounts.getAccount(uid, request.params.id);
|
|
254
|
+
if (!account) return reply.code(404).send({error: 'That mailbox does not exist.'});
|
|
255
|
+
if (!accounts.isOAuth(account)) return reply.code(400).send({error: 'This mailbox uses a password, not a sign-in.'});
|
|
256
|
+
|
|
257
|
+
const result = await revokeAccount(account);
|
|
258
|
+
pool.release(`${uid}:${account.id}`);
|
|
259
|
+
// A new generation: a renewal already on the wire must not write a
|
|
260
|
+
// working token back over the disconnect (see accounts.updateOAuth).
|
|
261
|
+
const saved = await accounts.updateOAuth(uid, account.id, {
|
|
262
|
+
status: 'disconnected',
|
|
263
|
+
error: 'Disconnected. Reconnect to use this mailbox again.',
|
|
264
|
+
refreshTokenSealed: null,
|
|
265
|
+
accessTokenSealed: null,
|
|
266
|
+
expiresAt: 0
|
|
267
|
+
}, {bumpGen: true});
|
|
268
|
+
return {account: accounts.toPublic(saved), revoked: result.revoked, revokeSupported: result.supported};
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
/** POST /oauth/:id/test - sign in to both servers with the stored sign-in. */
|
|
272
|
+
fastify.post('/oauth/:id/test', {preHandler: [authenticate], config: {rateLimit: {max: 10, timeWindow: '1 minute'}}}, async (request, reply) => {
|
|
273
|
+
const uid = userId(request);
|
|
274
|
+
if (!uid) return reply.code(401).send({error: 'Authentication required.'});
|
|
275
|
+
const account = await accounts.getAccount(uid, request.params.id);
|
|
276
|
+
if (!account) return reply.code(404).send({error: 'That mailbox does not exist.'});
|
|
277
|
+
if (!accounts.isOAuth(account)) return reply.code(400).send({error: 'This mailbox uses a password, not a sign-in.'});
|
|
278
|
+
|
|
279
|
+
let imap;
|
|
280
|
+
let smtp;
|
|
281
|
+
try {
|
|
282
|
+
imap = await pool.test(accounts.toConnection(account));
|
|
283
|
+
} catch (err) {
|
|
284
|
+
imap = {ok: false, error: err.message};
|
|
285
|
+
}
|
|
286
|
+
try {
|
|
287
|
+
const out = accounts.toSmtpConnection(account);
|
|
288
|
+
smtp = out ? await verifySmtp(out.transport, config.allowInsecureTLS === true) : {ok: false, error: 'No outgoing server.'};
|
|
289
|
+
} catch (err) {
|
|
290
|
+
smtp = {ok: false, error: err.message};
|
|
291
|
+
}
|
|
292
|
+
return {ok: imap.ok && smtp.ok, imap, smtp};
|
|
293
|
+
});
|
|
294
|
+
}
|
|
@@ -140,7 +140,10 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
|
|
|
140
140
|
};
|
|
141
141
|
} catch (err) {
|
|
142
142
|
if (err instanceof MailKeyError) {
|
|
143
|
-
|
|
143
|
+
// needsPassword stays true for a dead sign-in too: older
|
|
144
|
+
// screens already open settings on it, which is the right place.
|
|
145
|
+
reply.code(409).send({error: err.message, needsPassword: true,
|
|
146
|
+
...(err.needsReconnect ? {needsReconnect: true} : {}), accountId: account.id});
|
|
144
147
|
return null;
|
|
145
148
|
}
|
|
146
149
|
throw err;
|
|
@@ -245,7 +248,8 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
|
|
|
245
248
|
|
|
246
249
|
const result = accounts.validate(request.body ?? {}, existing);
|
|
247
250
|
if (!result.ok) return reply.code(400).send({error: result.error});
|
|
248
|
-
|
|
251
|
+
// A signed-in mailbox's servers are the provider's and cannot be edited here.
|
|
252
|
+
const where = result.value.oauth ? {ok: true} : await guardHost(result.value.host);
|
|
249
253
|
if (!where.ok) return reply.code(400).send({error: where.error});
|
|
250
254
|
|
|
251
255
|
const saved = await accounts.saveAccount(uid, request.params.id, result.value);
|
|
@@ -270,8 +274,15 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
|
|
|
270
274
|
if (!uid) return reply.code(401).send({error: 'Authentication required.'});
|
|
271
275
|
|
|
272
276
|
pool.release(`${uid}:${request.params.id}`);
|
|
277
|
+
const existing = await accounts.getAccount(uid, request.params.id);
|
|
273
278
|
const deleted = await accounts.deleteAccount(uid, request.params.id);
|
|
274
279
|
if (!deleted) return reply.code(404).send({error: 'That mailbox does not exist.'});
|
|
280
|
+
// A signed-in mailbox hands its sign-in back where the provider allows
|
|
281
|
+
// it (Google). Best-effort and after the delete: forgetting it here
|
|
282
|
+
// must not wait on, or fail because of, the provider.
|
|
283
|
+
if (accounts.isOAuth?.(existing)) {
|
|
284
|
+
import('./oauth.js').then(m => m.revokeAccount(existing)).catch(() => {});
|
|
285
|
+
}
|
|
275
286
|
return {deleted: true};
|
|
276
287
|
});
|
|
277
288
|
|
|
@@ -292,6 +303,16 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
|
|
|
292
303
|
|
|
293
304
|
const result = accounts.validate(body, existing);
|
|
294
305
|
if (!result.ok) return reply.code(400).send({error: result.error});
|
|
306
|
+
// A signed-in mailbox is tested as stored: the provider's own server,
|
|
307
|
+
// its token renewed if need be. Nothing typed is involved.
|
|
308
|
+
if (result.value.oauth) {
|
|
309
|
+
try {
|
|
310
|
+
return await pool.test(accounts.toConnection(existing));
|
|
311
|
+
} catch (err) {
|
|
312
|
+
if (err instanceof MailKeyError) return {ok: false, error: err.message};
|
|
313
|
+
throw err;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
295
316
|
const where = await guardHost(result.value.host);
|
|
296
317
|
if (!where.ok) return reply.code(400).send({error: where.error});
|
|
297
318
|
|
|
@@ -40,6 +40,17 @@ const MAX_ATTEMPTS = 3;
|
|
|
40
40
|
/** How long to wait before trying a failed send again. */
|
|
41
41
|
const RETRY_MS = 120_000;
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* The reason recorded on a queued item whose mailbox needs signing in again.
|
|
45
|
+
*
|
|
46
|
+
* @param {Error} err
|
|
47
|
+
* @returns {string}
|
|
48
|
+
*/
|
|
49
|
+
export function reconnectReason(err) {
|
|
50
|
+
if (!err?.needsReconnect) return err?.message || 'The mailbox could not be used.';
|
|
51
|
+
return `The mailbox needs signing in again (Mail Settings > Mailboxes > Reconnect): ${err.message}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
43
54
|
/**
|
|
44
55
|
* Create the scheduler.
|
|
45
56
|
*
|
|
@@ -70,7 +81,16 @@ export function createScheduler({store, pool, config, logger, tickMs = TICK_MS})
|
|
|
70
81
|
return;
|
|
71
82
|
}
|
|
72
83
|
|
|
73
|
-
|
|
84
|
+
// Built INSIDE the try: a signed-in mailbox whose sign-in is dead throws
|
|
85
|
+
// here (OAuthReconnectError), and escaping would leave the claimed item
|
|
86
|
+
// `sending` with no finishSend - picked up again on every reclaim.
|
|
87
|
+
let smtp;
|
|
88
|
+
try {
|
|
89
|
+
smtp = accounts.toSmtpConnection(account, item.identityId ?? null);
|
|
90
|
+
} catch (err) {
|
|
91
|
+
await store.finishSend(item._id, {status: 'failed', error: reconnectReason(err)});
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
74
94
|
if (!smtp) {
|
|
75
95
|
await store.finishSend(item._id, {
|
|
76
96
|
status: 'failed',
|
|
@@ -94,9 +114,11 @@ export function createScheduler({store, pool, config, logger, tickMs = TICK_MS})
|
|
|
94
114
|
});
|
|
95
115
|
await store.finishSend(item._id, {status: 'sent', result, error: null});
|
|
96
116
|
} catch (err) {
|
|
97
|
-
|
|
117
|
+
// A dead sign-in will not fix itself between retries: fail now, so
|
|
118
|
+
// the sender is told (the send-failed notice) instead of waiting.
|
|
119
|
+
const giveUp = err?.needsReconnect || (item.attempts ?? 1) >= MAX_ATTEMPTS;
|
|
98
120
|
await store.finishSend(item._id, giveUp
|
|
99
|
-
? {status: 'failed', error: err.message}
|
|
121
|
+
? {status: 'failed', error: err?.needsReconnect ? reconnectReason(err) : err.message}
|
|
100
122
|
// Back to pending, a little later. `finishedAt` is set either
|
|
101
123
|
// way, which is harmless on a retry and is what tells a
|
|
102
124
|
// screen when the last attempt was.
|
|
@@ -126,9 +148,11 @@ export function createScheduler({store, pool, config, logger, tickMs = TICK_MS})
|
|
|
126
148
|
}
|
|
127
149
|
|
|
128
150
|
const poolKey = `${item.userId}:${item.accountId}`;
|
|
129
|
-
|
|
151
|
+
let connection;
|
|
130
152
|
|
|
131
153
|
try {
|
|
154
|
+
// Inside the try for the same reason as runSend: a dead sign-in throws here.
|
|
155
|
+
connection = accounts.toConnection(account);
|
|
132
156
|
// The UID the move reported, when the server has UIDPLUS. Without
|
|
133
157
|
// it the message is found by its Message-ID instead - a snooze on
|
|
134
158
|
// such a server is still a snooze, and refusing to bring it back
|
|
@@ -160,9 +184,9 @@ export function createScheduler({store, pool, config, logger, tickMs = TICK_MS})
|
|
|
160
184
|
// does not read, absent from the list that was supposed to bring
|
|
161
185
|
// it back.
|
|
162
186
|
const attempts = (item.attempts ?? 0) + 1;
|
|
163
|
-
const giveUp = attempts >= MAX_ATTEMPTS;
|
|
187
|
+
const giveUp = err?.needsReconnect || attempts >= MAX_ATTEMPTS;
|
|
164
188
|
await store.finishSnooze(item._id, giveUp
|
|
165
|
-
? {status: 'failed', error: err.message, attempts}
|
|
189
|
+
? {status: 'failed', error: err?.needsReconnect ? `${reconnectReason(err)} The message is still in ${item.folder}.` : err.message, attempts}
|
|
166
190
|
: {status: 'pending', at: new Date(Date.now() + RETRY_MS), error: err.message, attempts});
|
|
167
191
|
logger.warn(
|
|
168
192
|
`[mail] could not wake snoozed message ${item._id} (attempt ${attempts}): ${err.message}`
|
|
@@ -173,3 +173,10 @@ lib/shape.js; no SVG or HTML - they can carry script). Chosen with "Attach a fil
|
|
|
173
173
|
- `admin/lib/settings.js`: the banner cog, added by `feedback.js` once the mode is known, only when the silent
|
|
174
174
|
`GET /settings` answers; a slideover through kit's `openPanel` with M bindings, data-help per field, discard
|
|
175
175
|
guard, Ctrl+Enter. Tests: tests/settings.test.js, and "the per-site throttle" in tests/api.test.js (live limit).
|
|
176
|
+
|
|
177
|
+
## 1.1.8 (2026-10-13) - a report deleted on the manager leaves the site too
|
|
178
|
+
Deleting a report in the inbox hid it from the site, but the site's outbox kept the sent item - its text and, through
|
|
179
|
+
`pruneFiles`' keep list, its screenshots - for good. `sync()` now calls `outbox.forgetDropped(remote)` whenever the
|
|
180
|
+
manager answered `/fleet/mine` (its list for the site is complete): a SENT item the manager no longer has is dropped
|
|
181
|
+
(`droppedByManager`, PURE, tested); waiting/refused items and an unreachable manager keep everything. The files go
|
|
182
|
+
at the next prune (older than a day, no longer named). Found on the dcm click-through.
|
|
@@ -17,7 +17,7 @@ import {api, loadKit, store} from '../lib/kit.js';
|
|
|
17
17
|
import {addSettingsCog} from '../lib/settings.js';
|
|
18
18
|
|
|
19
19
|
const ROUTE = /^#\/plugins\/feedback(\/|$)/;
|
|
20
|
-
const V = '1.1.
|
|
20
|
+
const V = '1.1.8';
|
|
21
21
|
const TABS = [
|
|
22
22
|
{key: 'inbox', label: 'Inbox', icon: 'inbox'},
|
|
23
23
|
{key: 'mine', label: 'My feedback', icon: 'send'}
|
|
@@ -69,10 +69,29 @@ export function createOutbox(dataDir, name = 'outbox.json') {
|
|
|
69
69
|
const s = read();
|
|
70
70
|
s.seen[id] = replyAt;
|
|
71
71
|
write(s);
|
|
72
|
+
},
|
|
73
|
+
/**
|
|
74
|
+
* The manager's full list of this site's reports: a SENT report it no longer has was deleted there,
|
|
75
|
+
* so this site forgets it too - its copy of the text, and (on the next prune) its files.
|
|
76
|
+
* @returns {number} how many were forgotten
|
|
77
|
+
*/
|
|
78
|
+
forgetDropped(remote) {
|
|
79
|
+
if (!Array.isArray(remote)) return 0;
|
|
80
|
+
const s = read();
|
|
81
|
+
const keep = s.items.filter(i => !droppedByManager(i, remote));
|
|
82
|
+
const gone = s.items.length - keep.length;
|
|
83
|
+
if (gone) { s.items = keep; write(s); }
|
|
84
|
+
return gone;
|
|
72
85
|
}
|
|
73
86
|
};
|
|
74
87
|
}
|
|
75
88
|
|
|
89
|
+
/** A sent report the manager's list (complete, for this site) no longer holds - deleted there. PURE. */
|
|
90
|
+
export function droppedByManager(item, remote) {
|
|
91
|
+
if (item?.state !== 'sent' || !Array.isArray(remote)) return false;
|
|
92
|
+
return !remote.some(r => r.ref === item.ref);
|
|
93
|
+
}
|
|
94
|
+
|
|
76
95
|
export const newRef = () => randomUUID();
|
|
77
96
|
|
|
78
97
|
/**
|
|
@@ -136,6 +136,8 @@ async function senderRoutes(fastify, {can, hooks, notifyOk, liveSettings, settin
|
|
|
136
136
|
const pushed = await flush(outbox, api, files);
|
|
137
137
|
const mine = await api.mine();
|
|
138
138
|
const remote = mine.ok ? (mine.body?.reports || []) : null;
|
|
139
|
+
// Deleted on the manager: gone here too, and its screenshots with the next prune (1.1.8).
|
|
140
|
+
if (remote) outbox.forgetDropped(remote);
|
|
139
141
|
const reports = mergeReports(outbox.all(), remote, outbox.seen());
|
|
140
142
|
// Keep the last-known reply on the outbox copy: the badge reads that, never the manager.
|
|
141
143
|
if (remote) for (const r of reports) if (!r.local && r.ref) outbox.mark(r.ref, {reply: r.reply ? '1' : '', replyAt: r.replyAt, remoteId: r.id});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "feedback",
|
|
3
3
|
"displayName": "Feedback",
|
|
4
|
-
"version": "1.1.
|
|
4
|
+
"version": "1.1.8",
|
|
5
5
|
"tier": "free",
|
|
6
6
|
"description": "Tell Domma what is broken, missing or good - straight from your admin - and see what became of it: seen, planned, in progress, done, with Domma's reply.",
|
|
7
7
|
"author": "Domma CMS",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
],
|
|
41
41
|
"views": {
|
|
42
42
|
"plugin-feedback": {
|
|
43
|
-
"entry": "feedback/admin/views/feedback.js?v=1.1.
|
|
43
|
+
"entry": "feedback/admin/views/feedback.js?v=1.1.8",
|
|
44
44
|
"exportName": "feedbackView"
|
|
45
45
|
}
|
|
46
46
|
}
|
|
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
|
|
|
3
3
|
import {
|
|
4
4
|
buildPayload, cleanSubmission, cleanUpdate, createThrottle, issueDescription, receivedReport, senderView, statusFromIssue
|
|
5
5
|
} from '../lib/shape.js';
|
|
6
|
-
import {mergeReports} from '../lib/sender.js';
|
|
6
|
+
import {createOutbox as createFbOutbox, droppedByManager, mergeReports} from '../lib/sender.js';
|
|
7
7
|
|
|
8
8
|
test('a submission is cleaned, and says nothing about who sent it', () => {
|
|
9
9
|
assert.equal(cleanSubmission({title: ' ', description: 'x'}).error, 'Give it a short title.');
|
|
@@ -149,3 +149,25 @@ test('the file store: private files, read back by id, unclaimed ones tidied', ()
|
|
|
149
149
|
assert.equal(files.read(a), null);
|
|
150
150
|
} finally { fs.rmSync(root, {recursive: true, force: true}); }
|
|
151
151
|
});
|
|
152
|
+
|
|
153
|
+
test('a report deleted on the manager is forgotten by the site (its text, then its files)', async () => {
|
|
154
|
+
const {mkdtempSync, rmSync} = await import('node:fs');
|
|
155
|
+
const {tmpdir} = await import('node:os');
|
|
156
|
+
const {join} = await import('node:path');
|
|
157
|
+
assert.equal(droppedByManager({ref: 'a', state: 'sent'}, [{ref: 'b'}]), true);
|
|
158
|
+
assert.equal(droppedByManager({ref: 'a', state: 'sent'}, [{ref: 'a'}]), false);
|
|
159
|
+
assert.equal(droppedByManager({ref: 'a', state: 'waiting'}, []), false, 'never sent: the manager cannot have it yet');
|
|
160
|
+
assert.equal(droppedByManager({ref: 'a', state: 'sent'}, null), false, 'manager unreachable: keep everything');
|
|
161
|
+
const dir = mkdtempSync(join(tmpdir(), 'fb-outbox-'));
|
|
162
|
+
try {
|
|
163
|
+
const box = createFbOutbox(dir);
|
|
164
|
+
for (const ref of ['kept', 'gone', 'wait']) box.add({ref, attachments: [{id: `${ref}-file`}]});
|
|
165
|
+
box.mark('kept', {state: 'sent'});
|
|
166
|
+
box.mark('gone', {state: 'sent'});
|
|
167
|
+
assert.equal(box.forgetDropped(null), 0);
|
|
168
|
+
assert.equal(box.forgetDropped([{ref: 'kept'}]), 1);
|
|
169
|
+
assert.deepEqual(box.all().map(i => i.ref), ['kept', 'wait']);
|
|
170
|
+
} finally {
|
|
171
|
+
rmSync(dir, {recursive: true, force: true});
|
|
172
|
+
}
|
|
173
|
+
});
|