fedipod 1.45.0 → 1.48.0

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.
Files changed (54) hide show
  1. package/README.md +11 -16
  2. package/architecture.md +6 -3
  3. package/gateway.md +51 -23
  4. package/gui.md +3 -2
  5. package/lib/client/c2s.mjs +2 -1
  6. package/lib/client/masto/index.mjs +5 -4
  7. package/lib/client/masto/render.mjs +4 -1
  8. package/lib/core/intake/activities.mjs +8 -9
  9. package/lib/core/intake/counts.mjs +143 -0
  10. package/lib/core/intake/index.mjs +4 -0
  11. package/lib/core/publisher/notes.mjs +4 -1
  12. package/lib/core/social.mjs +2 -0
  13. package/lib/core/storage.mjs +54 -5
  14. package/lib/core/wire.mjs +16 -5
  15. package/lib/gateway/account-agent.mjs +20 -4
  16. package/lib/gateway/copy.mjs +95 -5
  17. package/lib/gateway/front-core.mjs +4 -4
  18. package/lib/gateway/held-mail.mjs +11 -4
  19. package/lib/gateway/keeper.mjs +9 -6
  20. package/lib/gateway/masto-gateway.mjs +140 -40
  21. package/lib/gateway/private-read.mjs +10 -3
  22. package/lib/gateway/quiet.mjs +4 -3
  23. package/lib/gateway/state-api.mjs +73 -18
  24. package/lib/pod/notes.mjs +25 -0
  25. package/lib/server/embed.mjs +1 -1
  26. package/lib/session/README.md +2 -135
  27. package/lib/surface/routes/owner.mjs +2 -2
  28. package/lib/surface/surface.mjs +4 -3
  29. package/package.json +5 -3
  30. package/scripts/check-netlify-bundles.mjs +0 -4
  31. package/scripts/check-pod-calls.mjs +5 -108
  32. package/scripts/check-sizes.mjs +6 -3
  33. package/scripts/dependents.mjs +76 -0
  34. package/scripts/stage-site.mjs +10 -8
  35. package/scripts/start.mjs +2 -2
  36. package/scripts/test-dependents.mjs +37 -0
  37. package/web/admin/gateway.js +8 -24
  38. package/web/admin/index.html +0 -16
  39. package/web/admin/record.js +1 -2
  40. package/web/admin/upkeep.js +1 -5
  41. package/web/app/admin-facade.mjs +1 -1
  42. package/web/app/agent.mjs +27 -10
  43. package/web/app/copy-mode.mjs +98 -13
  44. package/web/app/dist/boot.js +68 -57
  45. package/web/app/dist/boot.js.map +4 -4
  46. package/web/app/dist/sw.js +583 -283
  47. package/web/app/dist/sw.js.map +4 -4
  48. package/web/app/forum-support.mjs +2 -2
  49. package/web/app/oidc-session.mjs +2 -2
  50. package/lib/session/demo.html +0 -94
  51. package/lib/session/fedi-account.mjs +0 -417
  52. package/lib/session/fedi-login.mjs +0 -123
  53. package/lib/session/oidc-session.mjs +0 -233
  54. package/lib/session/package.json +0 -49
package/lib/core/wire.mjs CHANGED
@@ -70,13 +70,13 @@ export function jrd({ handle, host, actor, aliases = [], page = null }) {
70
70
  // `kind: 'group'` publishes a Group rather than a Person, which is what makes
71
71
  // Mastodon and Lemmy treat the actor as a community you join.
72
72
  // Whether a follow will be held for the operator instead of accepted on
73
- // arrival. A person's follows wait unless `autoAcceptFollows` says otherwise,
74
- // because a delivery on its own proves nothing about who sent it; a group is
75
- // open unless its operator asked to see joins. The actor document has to say
73
+ // arrival. A person's follows are accepted unless `autoAcceptFollows: false`
74
+ // asks to see them; a group is open unless its operator asked to see joins.
75
+ // The actor document has to say
76
76
  // the same thing, or a remote server shows "Following" for a follow that is
77
77
  // in fact still waiting, and nothing ever tells it otherwise.
78
78
  export function followsNeedApproval(config = {}) {
79
- return !!config.approveJoins || (config.kind !== 'group' && !config.autoAcceptFollows);
79
+ return !!config.approveJoins || (config.kind !== 'group' && config.autoAcceptFollows === false);
80
80
  }
81
81
 
82
82
  export const assertionKeyId = (urls) => urls.actor + '#ed25519-key';
@@ -654,6 +654,16 @@ export function quotePolicy(visibility) {
654
654
  return { canQuote: { automaticApproval: open ? [PUBLIC] : [], manualApproval: [] } };
655
655
  }
656
656
 
657
+ // How many liked a post and how many boosted it (§5.7, §5.8): the totals,
658
+ // with nobody named. Both sections let a collection be filtered for whoever
659
+ // asks, and a total with no names is how Mastodon serves them too.
660
+ export function reactionCounts(likes = 0, shares = 0) {
661
+ return {
662
+ likes: { type: 'Collection', totalItems: Math.max(0, Number(likes) || 0) },
663
+ shares: { type: 'Collection', totalItems: Math.max(0, Number(shares) || 0) },
664
+ };
665
+ }
666
+
657
667
  // `also`: actors addressed without being mentioned — the parent's author on
658
668
  // a reply. In cc but not in tag, so their server receives the post and does
659
669
  // not notify them. A direct post names its audience in the text alone.
@@ -661,7 +671,7 @@ export function quotePolicy(visibility) {
661
671
  // authorization is the quoted author's QuoteAuthorization, once granted.
662
672
  export function noteDoc({ urls, slug, content, published, inReplyTo, attachments, mentions = [],
663
673
  visibility = 'public', summary = null, sensitive = false, updated = null, container = null, also = [],
664
- quote = null }) {
674
+ quote = null, likes = 0, shares = 0 }) {
665
675
  const id = (container || urls.notes) + slug;
666
676
  const who = mentions.map(m => m.actor);
667
677
  const extra = visibility === 'direct' ? [] : also.filter(a => a && !who.includes(a));
@@ -676,6 +686,7 @@ export function noteDoc({ urls, slug, content, published, inReplyTo, attachments
676
686
  to: addressed.to,
677
687
  cc: addressed.cc,
678
688
  replies: repliesId(id),
689
+ ...reactionCounts(likes, shares),
679
690
  interactionPolicy: quotePolicy(visibility),
680
691
  };
681
692
  if (quote?.id) {
@@ -4,9 +4,9 @@
4
4
  //
5
5
  // It works with the gateway's own pod identity and nothing else, which the
6
6
  // owner's app named in the rules on the account's folder. The account's state
7
- // is its copy at the gateway when there is one (copy.mjs), and the pod when
8
- // there is not. The signing key is read from the pod when it is needed, never
9
- // kept, never made.
7
+ // is its copy at the gateway when there is one (copy.mjs), made from the pod
8
+ // while the hold is on, and the pod when the hold is off. The signing key is
9
+ // read from the pod when it is needed, never kept, never made.
10
10
  import crypto from 'node:crypto';
11
11
  import { PodTransport } from '../pod/transport.mjs';
12
12
  import { podBaseOfWebId } from '../pod/urls.mjs';
@@ -19,7 +19,8 @@ import { Publisher } from '../core/publisher/index.mjs';
19
19
  import { Intake } from '../core/intake/index.mjs';
20
20
  import { C2S } from '../client/c2s.mjs';
21
21
  import { keeperSession } from './keeper-session.mjs';
22
- import { CopyStorage, copyMeta, copyLease, keptBefore, GATEWAY_HOLDER } from './copy.mjs';
22
+ import { CopyStorage, copyMeta, copyLease, keptBefore, holdOn, GATEWAY_HOLDER } from './copy.mjs';
23
+ import { makeCopy } from './state-api.mjs';
23
24
 
24
25
  const RSA_ALG = { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' };
25
26
 
@@ -58,6 +59,21 @@ export async function reachAccount(ctx, handle, rec, { log = console.log, sessio
58
59
  return { remote, podFetch: (u, i) => remote.fetch(u, i), pod, root, urls: apUrls(pod, root), inCopy };
59
60
  }
60
61
 
62
+ /**
63
+ * Where this piece of work reads and writes the account, decided by a caller
64
+ * holding lockCopy, so the round cannot delete the copy in between: a copy
65
+ * that is here is used; none, while the hold is on, is made from the pod; with
66
+ * the hold off, the pod. Sets `at.inCopy`; returns { ok } or { ok: false,
67
+ * status, why }.
68
+ */
69
+ export async function settle(ctx, handle, rec, at, { log = console.log } = {}) {
70
+ at.inCopy = !!(ctx.copyKv && await copyMeta(ctx.copyKv, handle));
71
+ if (at.inCopy || !ctx.copyKv || !holdOn(ctx)) return { ok: true };
72
+ const made = await makeCopy(ctx, handle, rec, log);
73
+ at.inCopy = made.ok;
74
+ return made;
75
+ }
76
+
61
77
  /**
62
78
  * The account's state store and lease, as `holder`: over the copy when there
63
79
  * is one, else over the pod.
@@ -3,11 +3,17 @@
3
3
  // While the gateway keeps an account running, the account's state documents
4
4
  // (everything under ap-state/) live here, and every agent works from this copy:
5
5
  // the owner's browser (through the state API, state-api.mjs), the keeper and
6
- // the gateway's Mastodon API (in process). The pod is written from it every
7
- // fifteen minutes (flushCopy), and at once when the copy is given up
8
- // (dropCopy). What only the pod may hold never comes here: the signing key,
9
- // the pod's own lease document, and the passwords and tokens of accounts on
10
- // other servers.
6
+ // the gateway's Mastodon API (in process). What only the pod may hold never
7
+ // comes here: the signing key, the pod's own lease document, and the passwords
8
+ // and tokens of accounts on other servers.
9
+ //
10
+ // The hold: a person's copy is made from the pod when something needs it, and
11
+ // every fifteen minutes, whatever is happening, it is written to the pod and
12
+ // deleted (holdOver); the next use makes it again. So nothing private stays
13
+ // here longer than that. A gateway's admin may turn the hold off
14
+ // (FEDIPOD_HOLD=off): then no copy is made at all, and everything works on the
15
+ // pod directly. A forum's copy is kept all the time, and written to its pod
16
+ // every fifteen minutes (flushCopy), as before.
11
17
  //
12
18
  // The lease that decides which agent acts lives in the copy too, and the copy
13
19
  // is what enforces it: a state document is written only by the lease's holder.
@@ -89,6 +95,30 @@ const readJson = async (kv, key) => {
89
95
  export const keptNow = (ctx, rec) => !!(rec?.keeper && ctx.keeperWebId && (rec.keeper.webId || ctx.keeperWebId) === ctx.keeperWebId);
90
96
  export const keptBefore = (ctx, rec) => !!(rec?.keeper?.webId && ctx.keeperWebId && rec.keeper.webId !== ctx.keeperWebId);
91
97
 
98
+ // A person's account, as against a forum or one of its categories.
99
+ export const isPersonal = (rec) => !!rec && (rec.kind || 'person') === 'person' && !rec.forum;
100
+
101
+ // The admin's setting: the hold is on unless FEDIPOD_HOLD says off.
102
+ export const holdFrom = (v) => !/^(off|no|false|0)$/iu.test(String(v ?? '').trim());
103
+ export const holdOn = (ctx) => ctx?.hold !== false;
104
+
105
+ // While the round writes a copy to the pod and deletes it: the state API asks
106
+ // its callers to try again in a moment, and the gateway's readers wait.
107
+ const CLOSING_MS = 2 * 60_000;
108
+ export async function closing(kv, h) {
109
+ const at = Number((await kv.get(`${account(h)}/closing`))?.text || 0);
110
+ return !!at && Date.now() - at < CLOSING_MS;
111
+ }
112
+ /** Wait, up to `waitMs`, for a copy being written and deleted to be gone. */
113
+ export async function waitOutClosing(kv, h, { waitMs = 10_000 } = {}) {
114
+ const until = Date.now() + waitMs;
115
+ while (await closing(kv, h)) {
116
+ if (Date.now() > until) return false;
117
+ await new Promise((r) => setTimeout(r, 250));
118
+ }
119
+ return true;
120
+ }
121
+
92
122
  /**
93
123
  * Forget a copy its owner has written to the pod themselves: no write-back, no
94
124
  * pod lease to free (the owner took it). Asked only by the lease's holder.
@@ -135,6 +165,12 @@ export function copyLease(kv, h, { id, log = () => {} }) {
135
165
  return new Lease({ url: `copy:${account(h)}/lease`, fetchImpl: kvDocFetch(kv, `${h}/lease`), log, id });
136
166
  }
137
167
 
168
+ /** Whether a browser, as against the gateway, holds the copy's lease now. */
169
+ export async function heldByBrowser(kv, h) {
170
+ const { doc } = await readJson(kv, `${account(h)}/lease`);
171
+ return !!doc && doc.holder !== GATEWAY_HOLDER && Date.now() < doc.expiresAt;
172
+ }
173
+
138
174
  /** Whether `holder` holds the copy's lease now. */
139
175
  export async function holds(kv, h, holder) {
140
176
  const { doc } = await readJson(kv, `${account(h)}/lease`);
@@ -275,10 +311,14 @@ export async function fillCopy(kv, h, { pod, podFetch, stateUrl, webId = null, l
275
311
  }
276
312
  const undo = async (why) => {
277
313
  for (const b of await kv.list(`${h}/d/`).catch(() => [])) await kv.delete(b.key).catch(() => {});
314
+ await kv.delete(`_copies/${h}`).catch(() => {});
278
315
  await podLease.release().catch(() => {});
279
316
  return { ok: false, why };
280
317
  };
281
318
  try {
319
+ // Listed from the start, so a making cut off part way is found by the
320
+ // round and its documents deleted (clearLeftover).
321
+ await kv.set(`_copies/${h}`, String(Date.now()));
282
322
  // Whatever an interrupted give-up left behind is not this copy.
283
323
  for (const b of await kv.list(`${h}/d/`)) await kv.delete(b.key);
284
324
  const listing = await pod.list('');
@@ -388,3 +428,53 @@ export async function dropCopy(kv, h, { pod, podFetch, stateUrl, log = () => {}
388
428
  log(`copy @${h}: given up; the pod holds everything`);
389
429
  return { ok: true };
390
430
  }
431
+
432
+ /**
433
+ * The end of the hold, for a person's copy: everything written to the pod, the
434
+ * pod's lease freed, and the copy deleted. Unlike giving the copy up
435
+ * (dropCopy), the copy's lease stays, so the browser holding it goes on holding
436
+ * it in the copy made next. Asked by the round, which holds lockCopy. Writes
437
+ * that reach the state API meanwhile are asked to come again (closing), and
438
+ * the next one finds the copy made again from the pod. Returns { ok } or
439
+ * { ok: false, why }; nothing is deleted unless the pod has it all.
440
+ */
441
+ export async function holdOver(kv, h, { pod, podFetch, stateUrl, log = () => {} }) {
442
+ const meta = await copyMeta(kv, h);
443
+ if (!meta) return { ok: true, already: true };
444
+ await kv.set(`${account(h)}/closing`, String(Date.now()));
445
+ try {
446
+ // Again, twice at most, for a write that landed while the first went: the
447
+ // marker stops any more coming.
448
+ let behind = [];
449
+ for (let i = 0; i < 3; i++) {
450
+ await flushCopy(kv, h, { pod, log });
451
+ const { doc: flushed } = await readJson(kv, `${h}/flushed`);
452
+ behind = (await kv.list(`${h}/d/`)).filter((b) => flushed?.[b.key.slice(`${h}/d/`.length)]?.etag !== b.etag);
453
+ if (!behind.length) break;
454
+ }
455
+ if (behind.length) return { ok: false, why: `${behind.length} document(s) could not be written to the pod` };
456
+ const podLease = new Lease({ url: (stateUrl || meta.stateUrl) + 'lease.json', fetchImpl: podFetch, log, id: POD_HOLDER });
457
+ try { await podLease.write({ holder: POD_HOLDER, expiresAt: 0 }); } catch (e) {
458
+ return { ok: false, why: `the pod's lease could not be freed (${e.message})` };
459
+ }
460
+ // The copy is gone the moment its meta is. Its listing goes last, so a
461
+ // delete cut off part way is found and finished by the next round
462
+ // (clearLeftover).
463
+ await kv.delete(`${h}/meta`);
464
+ await kv.delete(`${h}/flushed`);
465
+ for (const b of await kv.list(`${h}/d/`)) await kv.delete(b.key);
466
+ await kv.delete(`_copies/${h}`);
467
+ log(`copy @${h}: written to the pod and deleted (the hold)`);
468
+ return { ok: true };
469
+ } finally { await kv.delete(`${h}/closing`).catch(() => {}); }
470
+ }
471
+
472
+ /** A copy listed with no meta is one whose delete was cut off: its documents go. */
473
+ export async function clearLeftover(kv, h, { log = () => {} } = {}) {
474
+ if (await copyMeta(kv, h)) return false;
475
+ const left = await kv.list(`${account(h)}/d/`);
476
+ for (const b of left) await kv.delete(b.key);
477
+ await kv.delete(`_copies/${h}`);
478
+ if (left.length) log(`copy @${h}: ${left.length} document(s) left by a cut-off delete, deleted`);
479
+ return true;
480
+ }
@@ -23,7 +23,7 @@ import * as podRoot from '../pod/root.mjs';
23
23
  import * as podPolicy from '../pod/policy.mjs';
24
24
  import { podBaseOfWebId } from '../pod/urls.mjs';
25
25
  import { routeQuietApi, noteOpened, noteReceived, closedState, accountState, closedAnswer, provedOwner } from './quiet.mjs';
26
- import { holdsMail, isPresent, holdingPut, afterHeld, routeHereApi, routeKeeperApi } from './held-mail.mjs';
26
+ import { holdsMail, mayBeAway, isPresent, holdingPut, afterHeld, routeHereApi, routeKeeperApi } from './held-mail.mjs';
27
27
  import { routeNoticesApi } from './notices.mjs';
28
28
  import { withSecurityHeaders } from './headers.mjs';
29
29
  import { podTokenVerifier } from './caches.mjs';
@@ -825,15 +825,15 @@ async function route(request, ctx) {
825
825
  if (closed) { console.log(`door @${up.handle}: delivery → 410 (closed)`); return gone(doorCors, CLOSED); }
826
826
  const policy = await policyFor(rec, ctx.fetchImpl || fetch);
827
827
  const standing = await accountState(ctx, up.handle, rec);
828
- // A browser account's mail waits here while its app is closed (held-mail.mjs).
829
- const held = holdsMail(ctx, rec) && !(await isPresent(ctx, up.handle));
828
+ // A browser account's mail waits here while its app is closed (held-mail.mjs), unless the hold is off.
829
+ const away = mayBeAway(ctx, rec) && !(await isPresent(ctx, up.handle)); const held = away && holdsMail(ctx, rec);
830
830
  const { status, reason, content, bytes, type, notifies, name, raw, receipt } = await handleDelivery(request, identFor(rec, policy),
831
831
  { podPut: held ? holdingPut(ctx, up.handle, rec) : (u, b, ct) => ctx.podPut(up.handle, u, b, ct),
832
832
  fetchImpl: ctx.fetchImpl, paused: standing.paused, origin: ctx.frontOrigin || null });
833
833
  // One line per delivery, so "did it arrive at the door" has an answer.
834
834
  console.log(`door @${up.handle}: delivery → ${status} (${reason}${held && status === 202 ? ', held' : ''})`);
835
835
  if (status === 202 && content) await noteReceived(ctx, up.handle, rec, bytes);
836
- if (held && status === 202) await afterHeld(ctx, up.handle, rec, { type, notifies });
836
+ if (away && status === 202) await afterHeld(ctx, up.handle, rec, { type, notifies });
837
837
  // A forum fedipod.net keeps: the delivery is placed now, in this request,
838
838
  // and the run that carries it is started. Whatever could not be placed
839
839
  // here is still in the inbox for that run.
@@ -6,7 +6,9 @@
6
6
  // app is closed nobody reads it, so the door keeps it here instead
7
7
  // (ctx.holdMail), and writes it to the pod inbox as one document when the app
8
8
  // says it is open (POST /api/here) or when the timer comes round (flushAll).
9
- // While the app is open, mail goes straight to the pod as it always has.
9
+ // While the app is open, mail goes straight to the pod as it always has. With
10
+ // the hold off (copy.mjs: FEDIPOD_HOLD), nothing is held: mail goes straight
11
+ // to the pod whether the app is open or not.
10
12
  //
11
13
  // A batch is plain JSON, `{ "batch": [ { name, body, receipt } ] }`: `name` is
12
14
  // what the item would have been called in the inbox, `body` its bytes, and
@@ -23,7 +25,7 @@
23
25
  // presentAt(handle) -> ms when it last said so
24
26
  // noteNext(handle, at, {earliest}) when the account's keeper next has work
25
27
 
26
- import { copyMeta, keptNow } from './copy.mjs';
28
+ import { copyMeta, keptNow, holdOn } from './copy.mjs';
27
29
 
28
30
  // The app says it is open every five minutes; a little over that counts.
29
31
  export const PRESENT_MS = 8 * 60_000;
@@ -37,7 +39,11 @@ const inboxOf = (rec) => rec.inboxUrl || rec.podHome + 'ap/inbox/';
37
39
  // Only a browser account is held: its owner signs in (so the row has
38
40
  // `openedAt`), and its app says when it is open. An account run by a DeviceAgent
39
41
  // drains whenever mail lands, and keeps its mail as it always has.
40
- export const holdsMail = (ctx, rec) => !!(ctx.holdMail && rec?.openedAt);
42
+ export const holdsMail = (ctx, rec) => !!(ctx.holdMail && rec?.openedAt && holdOn(ctx));
43
+ // A browser account whose app may be closed: its mail is held, or with the
44
+ // hold off, sent to the pod, and either way a follow or a notification starts
45
+ // the work that answers it (afterHeld).
46
+ export const mayBeAway = (ctx, rec) => !!(ctx.holdMail && rec?.openedAt);
41
47
 
42
48
  // Asked on every delivery, so it is remembered a minute per running copy.
43
49
  const seen = new Map(); // handle -> { at, asked }
@@ -204,5 +210,6 @@ export async function routeHereApi(request, pathname, ctx, deps) {
204
210
  if (body.nextAt !== undefined && ctx.noteNext) await ctx.noteNext(handle, body.nextAt || null).catch(() => {});
205
211
  const flushed = await flushHeld(ctx, handle, rec)
206
212
  .catch((e) => { console.log(`here @${handle}: held mail not delivered: ${e?.message || e}`); return 0; });
207
- return j(200, { ok: true, handle, flushed });
213
+ // `hold`: with it off, a browser working from a copy goes back to the pod.
214
+ return j(200, { ok: true, handle, flushed, hold: holdOn(ctx) });
208
215
  }
@@ -18,7 +18,7 @@ import { keeperSession } from './keeper-session.mjs';
18
18
  import { flushHeld, heldEntries } from './held-mail.mjs';
19
19
  import { publishDue, nextDue } from '../core/scheduled.mjs';
20
20
  import { lockCopy } from './copy.mjs';
21
- import { reachAccount, stateAndLease, actingAgent } from './account-agent.mjs';
21
+ import { reachAccount, settle, stateAndLease, actingAgent } from './account-agent.mjs';
22
22
 
23
23
  export { keeperSession };
24
24
 
@@ -27,10 +27,11 @@ export { keeperSession };
27
27
  * keeperCredential and the held-mail stores. `session` may be handed in (a
28
28
  * test does); otherwise the keeper's grant is used. Returns what happened.
29
29
  *
30
- * An account whose copy the gateway keeps (copy.mjs) is worked on there: its
31
- * held mail is read straight into the copy, and the pod is written by the
32
- * round, not here. Otherwise the held mail goes to the pod inbox first, so the
33
- * drain reads it, as before.
30
+ * While the hold is on, the account is worked on in its copy (copy.mjs), made
31
+ * from the pod if there is none: its held mail is read straight into the
32
+ * copy, and the pod is written by the round, not here. With the hold off, it
33
+ * is worked on in the pod, and any held mail goes to the pod inbox first, so
34
+ * the drain reads it.
34
35
  */
35
36
  export async function keepOnce(ctx, handle, rec, { log = console.log, session = null } = {}) {
36
37
  // A forum, or one of its categories, is run whole by the forum's own code
@@ -40,9 +41,11 @@ export async function keepOnce(ctx, handle, rec, { log = console.log, session =
40
41
  const at = await reachAccount(ctx, handle, rec, { log, session });
41
42
  if (at.skipped) return { skipped: at.skipped };
42
43
 
43
- const unlock = at.inCopy ? await lockCopy(ctx.copyKv, handle) : async () => {};
44
+ const unlock = ctx.copyKv ? await lockCopy(ctx.copyKv, handle) : async () => {};
44
45
  if (!unlock) return { skipped: 'the gateway is busy with this account', retry: 'soon' };
45
46
  try {
47
+ const settled = await settle(ctx, handle, rec, at, { log });
48
+ if (!settled.ok) return { skipped: `the account's copy could not be made: ${settled.why}`, retry: 'soon' };
46
49
  // Held mail goes in first, so this drain reads it.
47
50
  const flushed = !at.inCopy && ctx.listHeld
48
51
  ? await flushHeld(ctx, handle, rec).catch((e) => { say(`held mail not delivered: ${e.message}`); return 0; }) : 0;