domma-cms 0.55.1 → 0.66.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.
- package/CLAUDE.md +130 -2
- package/README.md +4 -4
- package/admin/css/admin.css +1 -1
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +3 -3
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +3 -3
- package/admin/js/lib/page-picker.js +1 -0
- package/admin/js/lib/plugin-accent.js +1 -0
- package/admin/js/lib/plugin-chrome.js +1 -0
- package/admin/js/lib/shortcode-context-menu.js +2 -2
- package/admin/js/lib/sidebar-grouping.js +1 -1
- package/admin/js/lib/sidebar-grouping.test.js +1 -1
- package/admin/js/lib/sidebar-renderer.js +4 -4
- package/admin/js/lib/slideover-resizable.js +1 -0
- package/admin/js/templates/context-menu-editor.html +212 -0
- package/admin/js/templates/context-menus.html +16 -0
- package/admin/js/templates/plugin-code.html +1 -1
- package/{plugins/site-search/admin/templates/site-search.html → admin/js/templates/search.html} +50 -4
- package/admin/js/templates/settings.html +16 -97
- package/admin/js/templates/theme.html +173 -0
- package/admin/js/views/context-menu-editor.js +55 -0
- package/admin/js/views/context-menus.js +5 -0
- package/admin/js/views/form-editor.js +7 -7
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/plugin-marketplace.js +1 -1
- package/admin/js/views/plugins.js +25 -23
- package/admin/js/views/search.js +1 -0
- package/admin/js/views/settings.js +3 -3
- package/admin/js/views/theme.js +1 -0
- package/bin/cli.js +2 -0
- package/bin/update.js +13 -2
- package/config/menus/admin-sidebar.json +129 -23
- package/config/plugins.json +5 -5
- package/config/search.json +13 -0
- package/config/theme.json +18 -0
- package/package.json +12 -4
- package/plugins/_lib/admin/mail/compose-window.js +914 -0
- package/plugins/_lib/admin/mail/contacts.js +301 -0
- package/plugins/_lib/admin/mail/diagnostics-section.js +133 -0
- package/plugins/_lib/admin/mail/folder-tree.js +254 -0
- package/plugins/_lib/admin/mail/identity.js +480 -0
- package/plugins/_lib/admin/mail/image-senders-section.js +131 -0
- package/plugins/_lib/admin/mail/keyboard.js +136 -0
- package/plugins/_lib/admin/mail/mail.css +1 -0
- package/plugins/_lib/admin/mail/mail.html +71 -0
- package/plugins/_lib/admin/mail/panes.js +253 -0
- package/plugins/_lib/admin/mail/reader-view.js +4453 -0
- package/plugins/_lib/admin/mail/resizable.js +26 -0
- package/plugins/_lib/admin/mail/rules.js +343 -0
- package/plugins/_lib/admin/mail/scheduling.js +203 -0
- package/plugins/_lib/admin/mail/section-kit.js +277 -0
- package/plugins/_lib/admin/mail/templates.js +238 -0
- package/plugins/_lib/admin/mail/threads.js +200 -0
- package/plugins/_lib/admin/mail/vacation.js +269 -0
- package/plugins/_lib/admin/ui/help.css +1 -0
- package/plugins/_lib/admin/ui/help.js +174 -0
- package/plugins/_lib/admin/ui/resizable.js +151 -0
- package/plugins/_lib/dataStore.js +117 -0
- package/plugins/_lib/mail/accounts.js +919 -0
- package/plugins/_lib/mail/bodyTokens.js +101 -0
- package/plugins/_lib/mail/compose.js +256 -0
- package/plugins/_lib/mail/defaults.js +57 -0
- package/plugins/_lib/mail/diagnostics.js +154 -0
- package/plugins/_lib/mail/envelope.js +161 -0
- package/plugins/_lib/mail/folders.js +192 -0
- package/plugins/_lib/mail/handoff.js +274 -0
- package/plugins/_lib/mail/imapPool.js +293 -0
- package/plugins/_lib/mail/mbox.js +74 -0
- package/plugins/_lib/mail/pollSchedule.js +79 -0
- package/plugins/_lib/mail/poller.js +135 -0
- package/plugins/_lib/mail/priority.js +138 -0
- package/plugins/_lib/mail/readRoutes.js +680 -0
- package/plugins/_lib/mail/render.js +291 -0
- package/plugins/_lib/mail/ruleRunner.js +152 -0
- package/plugins/_lib/mail/scheduler.js +254 -0
- package/plugins/_lib/mail/secretbox.js +229 -0
- package/plugins/_lib/mail/send.js +396 -0
- package/plugins/_lib/mail/store.js +1002 -0
- package/plugins/_lib/mail/sync.js +292 -0
- package/plugins/_lib/mail/syncPlan.js +126 -0
- package/plugins/_lib/mail/syncSelection.js +82 -0
- package/plugins/_lib/mail/unsubscribe.js +183 -0
- package/plugins/_lib/mail/vacationRunner.js +114 -0
- package/plugins/_lib/mail/write.js +473 -0
- package/plugins/_lib/schemaSync.js +83 -0
- package/plugins/_template/admin/css/index.css +0 -0
- package/plugins/_template/admin/templates/index.html +4 -4
- package/plugins/_template/admin/views/index.js +7 -0
- package/plugins/analytics/admin/css/index.css +1 -0
- package/plugins/analytics/admin/templates/analytics.html +22 -13
- package/plugins/analytics/plugin.json +3 -0
- package/plugins/blog/admin/css/index.css +1 -0
- package/plugins/blog/admin/templates/blog.html +30 -18
- package/plugins/blog/admin/templates/categories.html +2 -2
- package/plugins/blog/admin/templates/comments.html +2 -2
- package/plugins/blog/admin/templates/post-editor.html +34 -34
- package/plugins/blog/admin/templates/settings.html +6 -3
- package/plugins/blog/admin/views/blog.js +8 -5
- package/plugins/blog/admin/views/categories.js +5 -10
- package/plugins/blog/admin/views/comments.js +5 -5
- package/plugins/blog/admin/views/post-editor.js +39 -20
- package/plugins/blog/admin/views/settings.js +52 -50
- package/plugins/blog/collections/categories/schema.json +7 -6
- package/plugins/blog/collections/comments/schema.json +11 -10
- package/plugins/blog/collections/posts/schema.json +14 -13
- package/plugins/blog/plugin.js +36 -13
- package/plugins/blog/plugin.json +13 -5
- package/plugins/blog/plugin.public.js +312 -0
- package/plugins/contacts/admin/css/index.css +1 -0
- package/plugins/contacts/admin/templates/contacts.html +128 -0
- package/plugins/contacts/admin/views/contacts.js +237 -4
- package/plugins/contacts/collections/user-contacts/schema.json +108 -0
- package/plugins/contacts/plugin.js +214 -27
- package/plugins/contacts/plugin.json +4 -1
- package/plugins/invoice/admin/css/index.css +1 -0
- package/plugins/invoice/admin/templates/editor.html +140 -49
- package/plugins/invoice/admin/templates/index.html +153 -23
- package/plugins/invoice/admin/templates/issuers.html +2 -5
- package/plugins/invoice/admin/templates/receivers.html +2 -5
- package/plugins/invoice/admin/views/contacts-source.js +266 -0
- package/plugins/invoice/admin/views/editor.js +366 -199
- package/plugins/invoice/admin/views/export.js +199 -0
- package/plugins/invoice/admin/views/help-content.js +61 -0
- package/plugins/invoice/admin/views/index.js +582 -94
- package/plugins/invoice/admin/views/issuers.js +24 -17
- package/plugins/invoice/admin/views/media.js +172 -0
- package/plugins/invoice/admin/views/party-view.js +305 -67
- package/plugins/invoice/admin/views/payments.js +127 -0
- package/plugins/invoice/admin/views/print.js +130 -0
- package/plugins/invoice/admin/views/receivers.js +49 -16
- package/plugins/invoice/admin/views/send.js +212 -0
- package/plugins/invoice/admin/views/settings.js +594 -0
- package/plugins/invoice/admin/views/view-lifecycle.js +33 -0
- package/plugins/invoice/collections/invoice-issuers/schema.json +77 -11
- package/plugins/invoice/collections/invoice-receivers/schema.json +10 -9
- package/plugins/invoice/collections/invoices/schema.json +19 -13
- package/plugins/invoice/config.js +27 -6
- package/plugins/invoice/pdf.js +164 -0
- package/plugins/invoice/plugin.js +1217 -44
- package/plugins/invoice/plugin.json +10 -9
- package/plugins/invoice/templates/_base.css +1 -0
- package/plugins/invoice/templates/classic-nologo.html +100 -0
- package/plugins/invoice/templates/classic.html +91 -0
- package/plugins/invoice/templates/invoice-print.html +24 -0
- package/plugins/invoice/templates/minimal.html +99 -0
- package/plugins/invoice/templates/modern-nologo.html +114 -0
- package/plugins/invoice/templates/modern.html +113 -0
- package/plugins/invoice/templates/templates.json +11 -0
- package/plugins/mail-reader/admin/views/mail.js +19 -0
- package/plugins/mail-reader/config.js +7 -0
- package/plugins/mail-reader/plugin.js +48 -0
- package/plugins/mail-reader/plugin.json +33 -0
- package/plugins/notes/admin/views/notes.js +1 -1
- package/plugins/notes/plugin.json +2 -2
- package/plugins/surveys/lib/audience.js +37 -0
- package/plugins/surveys/lib/campaigns.js +43 -0
- package/plugins/surveys/lib/ledger.js +110 -0
- package/plugins/surveys/lib/sending.js +106 -0
- package/plugins/surveys/lib/stats.js +62 -0
- package/plugins/surveys/lib/submit.js +95 -0
- package/plugins/surveys/lib/tokens.js +28 -0
- package/plugins/surveys/plugin.public.js +149 -0
- package/plugins/theme-switcher/admin/templates/theme-switcher.html +1 -1
- package/public/css/forms.css +1 -1
- package/public/css/search.css +1 -0
- package/public/css/site.css +1 -1
- package/public/js/collection-context.js +2 -2
- package/public/js/context-menus.js +1 -0
- package/public/js/form-logic-engine.js +1 -1
- package/public/js/forms.js +2 -2
- package/public/js/search.js +1 -0
- package/public/js/site.js +1 -1
- package/scripts/build.js +37 -3
- package/scripts/copy-domma.js +48 -0
- package/scripts/seed.js +1996 -0
- package/server/routes/api/collections.js +34 -0
- package/server/routes/api/context-menus.js +104 -0
- package/server/routes/api/forms.js +42 -3
- package/server/routes/api/notifications.js +69 -19
- package/server/routes/api/plugins.js +50 -6
- package/server/routes/api/search.js +43 -0
- package/server/routes/api/theme.js +69 -0
- package/server/routes/public.js +42 -7
- package/server/server.js +74 -0
- package/server/services/adapters/FileAdapter.js +6 -1
- package/server/services/content.js +26 -0
- package/server/services/contextMenus.js +477 -0
- package/server/services/markdown.js +70 -9
- package/server/services/permissionRegistry.js +24 -0
- package/server/services/pluginFiles.js +52 -11
- package/server/services/plugins.js +229 -6
- package/server/services/renderer.js +144 -22
- package/server/services/roles.js +1 -1
- package/server/services/search-migration.js +82 -0
- package/server/services/search.js +413 -0
- package/server/services/sidebar-migration.js +1 -0
- package/server/services/themeSettings.js +541 -0
- package/server/services/users.js +8 -0
- package/server/templates/page.html +4 -2
- package/plugins/contacts/data/contacts.json +0 -20
- package/plugins/notes/data/notes.json +0 -1
- package/plugins/site-search/admin/views/site-search.js +0 -116
- package/plugins/site-search/config.js +0 -15
- package/plugins/site-search/plugin.js +0 -188
- package/plugins/site-search/plugin.json +0 -40
- package/plugins/site-search/public/inject-body.html +0 -17
- package/plugins/site-search/public/inject-head.html +0 -1
- package/plugins/site-search/public/search.css +0 -1
- package/plugins/site-search/public/search.js +0 -1
- package/plugins/todo/data/todos.json +0 -1
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bringing a folder's local mirror up to date.
|
|
3
|
+
*
|
|
4
|
+
* The decision lives in `syncPlan.js`, which is pure and tested on its own.
|
|
5
|
+
* This is the half that talks to IMAP and MongoDB and carries the plan out.
|
|
6
|
+
*
|
|
7
|
+
* ## Still read-only
|
|
8
|
+
*
|
|
9
|
+
* Sync opens mailboxes exactly as the reader does - `EXAMINE`, and fetches
|
|
10
|
+
* through `BODY.PEEK[]` - so mirroring a mailbox never marks anything read.
|
|
11
|
+
* Writing flags is a separate, deliberate operation that does not belong here.
|
|
12
|
+
*
|
|
13
|
+
* ## Envelopes only
|
|
14
|
+
*
|
|
15
|
+
* Nothing in this module fetches a message body. The mirror is what a list
|
|
16
|
+
* view needs; bodies are fetched per view and rendered through the sanitiser,
|
|
17
|
+
* so a compromised store leaks metadata rather than correspondence.
|
|
18
|
+
*
|
|
19
|
+
* @module _lib/mail/sync
|
|
20
|
+
*/
|
|
21
|
+
import * as accounts from './accounts.js';
|
|
22
|
+
import {applyRules, eligible} from './ruleRunner.js';
|
|
23
|
+
import {runVacation} from './vacationRunner.js';
|
|
24
|
+
import {missingUids, planFolderSync} from './syncPlan.js';
|
|
25
|
+
import {resolveSyncFolders} from './syncSelection.js';
|
|
26
|
+
import {ENVELOPE_QUERY, toRecord} from './envelope.js';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Sync one folder.
|
|
30
|
+
*
|
|
31
|
+
* @param {object} client - a connected ImapFlow, mailbox already locked
|
|
32
|
+
* @param {object} store
|
|
33
|
+
* `collectAdded` asks for the records of whatever was newly fetched, so the
|
|
34
|
+
* caller can run filing rules over them. Off by default and capped, because
|
|
35
|
+
* the whole point of batching is not to hold a folder in memory - a mailbox
|
|
36
|
+
* catching up on ten thousand messages must not build a ten thousand element
|
|
37
|
+
* array to decide none of them matched a rule.
|
|
38
|
+
*
|
|
39
|
+
* @param {{accountId: string, path: string, batchSize?: number, now?: number,
|
|
40
|
+
* collectAdded?: boolean, collectLimit?: number}} options
|
|
41
|
+
* @returns {Promise<{mode: string, reason: string, added: number, flagged: number,
|
|
42
|
+
* removed: number, addedMessages: object[]}>}
|
|
43
|
+
*/
|
|
44
|
+
export async function syncFolder(client, store, {
|
|
45
|
+
accountId, path, batchSize = 500, now = Date.now(),
|
|
46
|
+
collectAdded = false, collectLimit = 200
|
|
47
|
+
}) {
|
|
48
|
+
const mailbox = client.mailbox;
|
|
49
|
+
const server = {
|
|
50
|
+
uidValidity: mailbox.uidValidity,
|
|
51
|
+
uidNext: mailbox.uidNext,
|
|
52
|
+
exists: mailbox.exists,
|
|
53
|
+
highestModseq: mailbox.highestModseq ?? null
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const stored = await store.getFolder(accountId, path);
|
|
57
|
+
const plan = planFolderSync(stored, server, {now});
|
|
58
|
+
|
|
59
|
+
const result = {mode: plan.mode, reason: plan.reason, added: 0, flagged: 0, removed: 0, addedMessages: []};
|
|
60
|
+
if (plan.mode === 'none') return result;
|
|
61
|
+
|
|
62
|
+
// Never collected on a full resync: that is the whole folder arriving
|
|
63
|
+
// again, not new mail, and filing it would move an archive.
|
|
64
|
+
const collect = collectAdded && plan.mode !== 'full' ? result.addedMessages : null;
|
|
65
|
+
|
|
66
|
+
if (plan.mode === 'full') {
|
|
67
|
+
// The stored UIDs are meaningless now, so they go before anything new
|
|
68
|
+
// arrives - leaving them would mix two generations of the same folder.
|
|
69
|
+
await store.clearFolder(accountId, path);
|
|
70
|
+
result.added = await fetchRange(client, store, accountId, path, '1:*', batchSize);
|
|
71
|
+
} else {
|
|
72
|
+
if (plan.fetchFrom !== null) {
|
|
73
|
+
result.added = await fetchRange(
|
|
74
|
+
client, store, accountId, path, `${plan.fetchFrom}:*`, batchSize,
|
|
75
|
+
collect, collectLimit,
|
|
76
|
+
// The floor is the point. In an IMAP UID range `*` is the
|
|
77
|
+
// HIGHEST EXISTING UID, not infinity - so `101:*` on a folder
|
|
78
|
+
// whose highest UID is 100 is read as `100:101` and returns
|
|
79
|
+
// UID 100, a message that was already here. Harmless for the
|
|
80
|
+
// mirror, which just rewrites a row it already had; not
|
|
81
|
+
// harmless for rules, which would fire again on mail the user
|
|
82
|
+
// had deliberately left alone.
|
|
83
|
+
plan.fetchFrom
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (plan.flagScan !== 'none') {
|
|
88
|
+
result.flagged = await scanFlags(client, store, accountId, path, plan);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (plan.reconcile) {
|
|
92
|
+
result.removed = await reconcile(client, store, accountId, path);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
await store.saveFolder(accountId, path, {
|
|
97
|
+
uidValidity: String(server.uidValidity),
|
|
98
|
+
uidNext: Number(server.uidNext),
|
|
99
|
+
exists: Number(server.exists),
|
|
100
|
+
highestModseq: server.highestModseq ? String(server.highestModseq) : null,
|
|
101
|
+
lastSyncAt: new Date(now),
|
|
102
|
+
...(plan.mode === 'full' || plan.reconcile ? {lastReconcileAt: now} : {})
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
return result;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Fetch envelopes for a UID range, writing them in batches.
|
|
110
|
+
*
|
|
111
|
+
* Batched so a mailbox with tens of thousands of messages does not build one
|
|
112
|
+
* enormous array before anything is stored.
|
|
113
|
+
*
|
|
114
|
+
* @param {object} client
|
|
115
|
+
* @param {object} store
|
|
116
|
+
* @param {string} accountId
|
|
117
|
+
* @param {string} path
|
|
118
|
+
* @param {string} range
|
|
119
|
+
* @param {number} batchSize
|
|
120
|
+
* @param {object[]|null} [collect] - records are pushed here when given
|
|
121
|
+
* @param {number} [collectLimit]
|
|
122
|
+
* @param {number} [collectFrom] - only collect UIDs at or above this
|
|
123
|
+
* @returns {Promise<number>}
|
|
124
|
+
*/
|
|
125
|
+
async function fetchRange(
|
|
126
|
+
client, store, accountId, path, range, batchSize,
|
|
127
|
+
collect = null, collectLimit = 200, collectFrom = 0
|
|
128
|
+
) {
|
|
129
|
+
let batch = [];
|
|
130
|
+
let written = 0;
|
|
131
|
+
|
|
132
|
+
for await (const message of client.fetch(range, ENVELOPE_QUERY, {uid: true})) {
|
|
133
|
+
const record = toRecord(message);
|
|
134
|
+
// Collected only when it is genuinely new. See the note at the call
|
|
135
|
+
// site: an IMAP `n:*` range can hand back a UID below `n`.
|
|
136
|
+
if (collect && collect.length < collectLimit && record.uid >= collectFrom) {
|
|
137
|
+
collect.push(record);
|
|
138
|
+
}
|
|
139
|
+
batch.push(record);
|
|
140
|
+
if (batch.length >= batchSize) {
|
|
141
|
+
await store.putMessages(accountId, path, batch);
|
|
142
|
+
written += batch.length;
|
|
143
|
+
batch = [];
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (batch.length) {
|
|
148
|
+
await store.putMessages(accountId, path, batch);
|
|
149
|
+
written += batch.length;
|
|
150
|
+
}
|
|
151
|
+
return written;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Bring stored flags back in line with the server's.
|
|
156
|
+
*
|
|
157
|
+
* @param {object} client
|
|
158
|
+
* @param {object} store
|
|
159
|
+
* @param {string} accountId
|
|
160
|
+
* @param {string} path
|
|
161
|
+
* @param {object} plan
|
|
162
|
+
* @returns {Promise<number>}
|
|
163
|
+
*/
|
|
164
|
+
async function scanFlags(client, store, accountId, path, plan) {
|
|
165
|
+
const query = {uid: true, flags: true};
|
|
166
|
+
const options = {uid: true};
|
|
167
|
+
// CONDSTORE lets the server send only what moved, which on a large folder
|
|
168
|
+
// is the difference between a few rows and all of them.
|
|
169
|
+
if (plan.flagScan === 'changed' && plan.changedSince) {
|
|
170
|
+
options.changedSince = BigInt(plan.changedSince);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const updates = [];
|
|
174
|
+
for await (const message of client.fetch('1:*', query, options)) {
|
|
175
|
+
updates.push({uid: message.uid, flags: [...(message.flags ?? [])]});
|
|
176
|
+
}
|
|
177
|
+
return store.setFlags(accountId, path, updates);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Drop messages the server no longer lists.
|
|
182
|
+
*
|
|
183
|
+
* @param {object} client
|
|
184
|
+
* @param {object} store
|
|
185
|
+
* @param {string} accountId
|
|
186
|
+
* @param {string} path
|
|
187
|
+
* @returns {Promise<number>}
|
|
188
|
+
*/
|
|
189
|
+
async function reconcile(client, store, accountId, path) {
|
|
190
|
+
const onServer = [];
|
|
191
|
+
for await (const message of client.fetch('1:*', {uid: true}, {uid: true})) {
|
|
192
|
+
onServer.push(message.uid);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const stored = await store.storedUids(accountId, path);
|
|
196
|
+
const gone = missingUids(stored, onServer);
|
|
197
|
+
return store.removeMessages(accountId, path, gone);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Mirror every folder one mailbox has chosen.
|
|
202
|
+
*
|
|
203
|
+
* Shared by the manual "sync now" route and the background poller, so the two
|
|
204
|
+
* cannot drift into disagreeing about what a sync pass means.
|
|
205
|
+
*
|
|
206
|
+
* @param {{pool: object, store: object, account: object, config: object}} deps
|
|
207
|
+
* @returns {Promise<{selection: string, folders: Object.<string, object>}>}
|
|
208
|
+
*/
|
|
209
|
+
export async function syncAccount({pool, store, account, config}) {
|
|
210
|
+
const connection = accounts.toConnection(account);
|
|
211
|
+
const poolKey = `${account.userId}:${account.id}`;
|
|
212
|
+
|
|
213
|
+
const list = await pool.withClient(poolKey, connection, client => client.list());
|
|
214
|
+
const folders = list
|
|
215
|
+
.filter(box => !box.flags?.has('\\Noselect'))
|
|
216
|
+
.map(box => ({path: box.path, specialUse: box.specialUse ?? null}));
|
|
217
|
+
|
|
218
|
+
const {paths, source} = resolveSyncFolders(account, folders);
|
|
219
|
+
const results = {};
|
|
220
|
+
|
|
221
|
+
const rules = accounts.rulesToPublic(account).filter(rule => rule.enabled);
|
|
222
|
+
const ruleFolders = new Set(rules.length ? accounts.ruleFolders(account) : []);
|
|
223
|
+
|
|
224
|
+
// The away message answers mail arriving in the same folders rules run
|
|
225
|
+
// over - which is the inbox by default. Answering mail that arrived in
|
|
226
|
+
// Sent, or in an archive a rule had just filed it into, is not what
|
|
227
|
+
// "I am away" means.
|
|
228
|
+
const away = accounts.vacationToPublic(account);
|
|
229
|
+
const awayFolders = new Set(away.enabled ? accounts.ruleFolders(account) : []);
|
|
230
|
+
|
|
231
|
+
for (const folderPath of paths) {
|
|
232
|
+
const filed = ruleFolders.has(folderPath) || awayFolders.has(folderPath);
|
|
233
|
+
try {
|
|
234
|
+
results[folderPath] = await pool.withMailbox(poolKey, connection, folderPath,
|
|
235
|
+
client => syncFolder(client, store, {
|
|
236
|
+
accountId: account.id,
|
|
237
|
+
path: folderPath,
|
|
238
|
+
batchSize: config.syncBatchSize,
|
|
239
|
+
collectAdded: filed
|
|
240
|
+
}));
|
|
241
|
+
} catch (err) {
|
|
242
|
+
// One bad folder must not abandon the rest of the mailbox.
|
|
243
|
+
results[folderPath] = {mode: 'error', reason: err.message};
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Outside the `withMailbox` above, deliberately: filing a message
|
|
248
|
+
// opens its destination on the same connection, and ImapFlow queues
|
|
249
|
+
// mailbox locks per client - doing it inside would wait on a lock
|
|
250
|
+
// this very call is holding.
|
|
251
|
+
if (!filed) continue;
|
|
252
|
+
const candidates = eligible(results[folderPath].addedMessages, results[folderPath], account.rulesFrom ?? null);
|
|
253
|
+
if (!candidates.length) continue;
|
|
254
|
+
|
|
255
|
+
if (ruleFolders.has(folderPath)) {
|
|
256
|
+
try {
|
|
257
|
+
results[folderPath].rules = await applyRules({
|
|
258
|
+
pool, store, account, poolKey, connection,
|
|
259
|
+
folder: folderPath, messages: candidates, rules
|
|
260
|
+
});
|
|
261
|
+
} catch (err) {
|
|
262
|
+
results[folderPath].rules = {error: err.message};
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// After the rules, on the same candidates. A message a rule has just
|
|
267
|
+
// filed into Junk should not also get a cheerful note saying when its
|
|
268
|
+
// sender can expect a reply.
|
|
269
|
+
if (awayFolders.has(folderPath)) {
|
|
270
|
+
const junked = new Set(
|
|
271
|
+
(results[folderPath].rules?.actions ?? []).map(action => action.uid)
|
|
272
|
+
);
|
|
273
|
+
try {
|
|
274
|
+
results[folderPath].away = await runVacation({
|
|
275
|
+
pool, store, account, poolKey, connection,
|
|
276
|
+
smtp: accounts.toSmtpConnection(account),
|
|
277
|
+
messages: candidates.filter(message => !junked.has(message.uid)),
|
|
278
|
+
config,
|
|
279
|
+
addresses: accounts.sendingAddresses(account)
|
|
280
|
+
});
|
|
281
|
+
} catch (err) {
|
|
282
|
+
results[folderPath].away = {error: err.message};
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// The collected records have done their job and would otherwise be
|
|
288
|
+
// reported back to a diagnostics screen as a wall of envelopes.
|
|
289
|
+
for (const result of Object.values(results)) delete result.addedMessages;
|
|
290
|
+
|
|
291
|
+
return {selection: source, folders: results};
|
|
292
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deciding what a folder sync needs to do.
|
|
3
|
+
*
|
|
4
|
+
* Kept pure and separate from the IMAP and database calls, because this is
|
|
5
|
+
* where sync gets subtly wrong and a wrong answer is expensive: too eager and
|
|
6
|
+
* every poll refetches a 40,000-message mailbox, too lazy and someone's inbox
|
|
7
|
+
* quietly stops matching their phone.
|
|
8
|
+
*
|
|
9
|
+
* The inputs are what we recorded last time and what the server says now. The
|
|
10
|
+
* output says which of the four things to do, and why - the reason is carried
|
|
11
|
+
* so a log line can explain a resync rather than just performing one.
|
|
12
|
+
*
|
|
13
|
+
* @module _lib/mail/syncPlan
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** How long before a folder is reconciled even when nothing looks changed. */
|
|
17
|
+
export const DEFAULT_RECONCILE_INTERVAL_MS = 3_600_000;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Compare two IMAP 64-bit counters that may arrive as number, string or BigInt.
|
|
21
|
+
*
|
|
22
|
+
* MODSEQ and UIDVALIDITY are unsigned 64-bit, so a driver may hand them over
|
|
23
|
+
* as any of those. Comparing a BigInt with a Number throws in some operators
|
|
24
|
+
* and silently loses precision in others, so everything is normalised first.
|
|
25
|
+
*
|
|
26
|
+
* @param {number|string|bigint|null|undefined} a
|
|
27
|
+
* @param {number|string|bigint|null|undefined} b
|
|
28
|
+
* @returns {number} -1, 0 or 1; 0 when either is absent
|
|
29
|
+
*/
|
|
30
|
+
export function compareCounters(a, b) {
|
|
31
|
+
if (a === null || a === undefined || b === null || b === undefined) return 0;
|
|
32
|
+
const left = BigInt(a);
|
|
33
|
+
const right = BigInt(b);
|
|
34
|
+
if (left < right) return -1;
|
|
35
|
+
if (left > right) return 1;
|
|
36
|
+
return 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Work out what this folder needs.
|
|
41
|
+
*
|
|
42
|
+
* @param {object|null} stored - the folder record, or null if never synced
|
|
43
|
+
* @param {{uidValidity: any, uidNext: number, exists: number, highestModseq?: any}} server
|
|
44
|
+
* @param {{now?: number, reconcileIntervalMs?: number}} [options]
|
|
45
|
+
* @returns {{mode: 'full'|'incremental'|'none', reason: string,
|
|
46
|
+
* fetchFrom: number|null, flagScan: 'none'|'full'|'changed',
|
|
47
|
+
* changedSince: any, reconcile: boolean}}
|
|
48
|
+
*/
|
|
49
|
+
export function planFolderSync(stored, server, options = {}) {
|
|
50
|
+
const {now = Date.now(), reconcileIntervalMs = DEFAULT_RECONCILE_INTERVAL_MS} = options;
|
|
51
|
+
|
|
52
|
+
const full = reason => ({
|
|
53
|
+
mode: 'full', reason,
|
|
54
|
+
fetchFrom: null, flagScan: 'none', changedSince: null, reconcile: false
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
if (!stored) return full('never synced');
|
|
58
|
+
|
|
59
|
+
// UIDVALIDITY changing means every UID we hold now refers to a different
|
|
60
|
+
// message. The mirror is not stale, it is wrong, and the only correct
|
|
61
|
+
// response is to throw it away.
|
|
62
|
+
if (compareCounters(stored.uidValidity, server.uidValidity) !== 0) {
|
|
63
|
+
return full('UIDVALIDITY changed - stored UIDs refer to different messages');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const storedUidNext = Number(stored.uidNext) || 1;
|
|
67
|
+
const serverUidNext = Number(server.uidNext) || 1;
|
|
68
|
+
const hasArrivals = serverUidNext > storedUidNext;
|
|
69
|
+
|
|
70
|
+
// Without CONDSTORE there is no cheap way to ask "what changed", so flags
|
|
71
|
+
// are rescanned wholesale. With it, only what moved past our MODSEQ.
|
|
72
|
+
let flagScan = 'full';
|
|
73
|
+
let changedSince = null;
|
|
74
|
+
if (server.highestModseq && stored.highestModseq) {
|
|
75
|
+
if (compareCounters(server.highestModseq, stored.highestModseq) > 0) {
|
|
76
|
+
flagScan = 'changed';
|
|
77
|
+
changedSince = stored.highestModseq;
|
|
78
|
+
} else {
|
|
79
|
+
flagScan = 'none';
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// A count that does not match means something was removed - arrivals alone
|
|
84
|
+
// would have raised it. Two arriving and two vanishing leaves the count
|
|
85
|
+
// unchanged, which is why there is also a periodic reconcile: cheap
|
|
86
|
+
// insurance against the case the counters cannot see.
|
|
87
|
+
const countMismatch = Number(server.exists) !== Number(stored.exists ?? -1);
|
|
88
|
+
const lastReconcile = Number(stored.lastReconcileAt ?? 0);
|
|
89
|
+
const reconcileDue = now - lastReconcile >= reconcileIntervalMs;
|
|
90
|
+
const reconcile = countMismatch || reconcileDue;
|
|
91
|
+
|
|
92
|
+
if (!hasArrivals && flagScan === 'none' && !reconcile) {
|
|
93
|
+
return {
|
|
94
|
+
mode: 'none', reason: 'nothing changed',
|
|
95
|
+
fetchFrom: null, flagScan: 'none', changedSince: null, reconcile: false
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const reasons = [];
|
|
100
|
+
if (hasArrivals) reasons.push(`${serverUidNext - storedUidNext} new UID(s)`);
|
|
101
|
+
if (flagScan === 'changed') reasons.push('flags changed since last MODSEQ');
|
|
102
|
+
if (flagScan === 'full') reasons.push('no CONDSTORE - rescanning flags');
|
|
103
|
+
if (countMismatch) reasons.push('message count differs');
|
|
104
|
+
else if (reconcileDue) reasons.push('periodic reconcile');
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
mode: 'incremental',
|
|
108
|
+
reason: reasons.join(', '),
|
|
109
|
+
fetchFrom: hasArrivals ? storedUidNext : null,
|
|
110
|
+
flagScan,
|
|
111
|
+
changedSince,
|
|
112
|
+
reconcile
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Which stored UIDs the server no longer has.
|
|
118
|
+
*
|
|
119
|
+
* @param {number[]} stored
|
|
120
|
+
* @param {number[]} onServer
|
|
121
|
+
* @returns {number[]}
|
|
122
|
+
*/
|
|
123
|
+
export function missingUids(stored, onServer) {
|
|
124
|
+
const live = new Set(onServer);
|
|
125
|
+
return stored.filter(uid => !live.has(uid));
|
|
126
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which folders a mailbox keeps mirrored.
|
|
3
|
+
*
|
|
4
|
+
* The free reader fetches everything on demand and holds nothing. The Pro
|
|
5
|
+
* edition mirrors folders into MongoDB so the list opens instantly and search
|
|
6
|
+
* can run locally - but mirroring *every* folder is the wrong default. A real
|
|
7
|
+
* mailbox here has 104 of them; syncing all of those on every poll is a lot of
|
|
8
|
+
* traffic to keep a decade of archive folders warm that nobody opens.
|
|
9
|
+
*
|
|
10
|
+
* So: a sensible default, and an explicit choice that overrides it.
|
|
11
|
+
*
|
|
12
|
+
* @module _lib/mail/syncSelection
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Folders worth mirroring without being asked.
|
|
17
|
+
*
|
|
18
|
+
* The inbox because it is the one that matters, and Sent because replies are
|
|
19
|
+
* how threading gets its other half. Drafts, Trash and Junk are deliberately
|
|
20
|
+
* absent - they churn, and nobody searches them.
|
|
21
|
+
*/
|
|
22
|
+
const DEFAULT_SPECIAL_USE = new Set(['\\Inbox', '\\Sent']);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Resolve the folders to sync for one mailbox.
|
|
26
|
+
*
|
|
27
|
+
* An explicit selection is honoured as given, minus anything the server no
|
|
28
|
+
* longer offers - a folder that was renamed away should not keep failing
|
|
29
|
+
* every poll forever.
|
|
30
|
+
*
|
|
31
|
+
* @param {{syncFolders?: string[]|null}} account
|
|
32
|
+
* @param {{path: string, specialUse?: string|null}[]} folders - as the server lists them
|
|
33
|
+
* @returns {{paths: string[], source: 'default'|'explicit'}}
|
|
34
|
+
*/
|
|
35
|
+
export function resolveSyncFolders(account, folders) {
|
|
36
|
+
const available = new Set(folders.map(f => f.path));
|
|
37
|
+
|
|
38
|
+
const chosen = account?.syncFolders;
|
|
39
|
+
if (Array.isArray(chosen)) {
|
|
40
|
+
// Deduplicated here as well as on write: a record could carry
|
|
41
|
+
// duplicates from an older version or an edited file, and each one
|
|
42
|
+
// would otherwise mean syncing the same folder twice every pass.
|
|
43
|
+
return {
|
|
44
|
+
paths: [...new Set(chosen.filter(path => available.has(path)))],
|
|
45
|
+
source: 'explicit'
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const paths = folders
|
|
50
|
+
.filter(f => DEFAULT_SPECIAL_USE.has(f.specialUse)
|
|
51
|
+
|| f.path.toUpperCase() === 'INBOX')
|
|
52
|
+
.map(f => f.path);
|
|
53
|
+
|
|
54
|
+
// A server that advertises no special-use at all still has an inbox worth
|
|
55
|
+
// syncing; falling back to nothing would leave the Pro edition doing less
|
|
56
|
+
// than the free one.
|
|
57
|
+
if (!paths.length && folders.length) {
|
|
58
|
+
const first = folders.find(f => f.path.toUpperCase() === 'INBOX') ?? folders[0];
|
|
59
|
+
paths.push(first.path);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {paths: [...new Set(paths)], source: 'default'};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Annotate a folder list with whether each one is mirrored.
|
|
67
|
+
*
|
|
68
|
+
* What the settings screen renders: every folder the server offers, each
|
|
69
|
+
* marked according to the current selection.
|
|
70
|
+
*
|
|
71
|
+
* @param {{syncFolders?: string[]|null}} account
|
|
72
|
+
* @param {{path: string, specialUse?: string|null}[]} folders
|
|
73
|
+
* @returns {{folders: object[], source: 'default'|'explicit'}}
|
|
74
|
+
*/
|
|
75
|
+
export function describeSyncSelection(account, folders) {
|
|
76
|
+
const {paths, source} = resolveSyncFolders(account, folders);
|
|
77
|
+
const selected = new Set(paths);
|
|
78
|
+
return {
|
|
79
|
+
source,
|
|
80
|
+
folders: folders.map(folder => ({...folder, sync: selected.has(folder.path)}))
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Getting off a mailing list.
|
|
3
|
+
*
|
|
4
|
+
* Every bulk sender that follows the conventions puts a `List-Unsubscribe`
|
|
5
|
+
* header on its mail, and almost no webmail client surfaces it usefully -
|
|
6
|
+
* which is why people hunt for the six-point grey link at the bottom instead.
|
|
7
|
+
* Reading the header is a dozen lines; not reading it is why unsubscribing is
|
|
8
|
+
* unpleasant.
|
|
9
|
+
*
|
|
10
|
+
* ## One-click, and why it is not the default
|
|
11
|
+
*
|
|
12
|
+
* RFC 8058 adds `List-Unsubscribe-Post: List-Unsubscribe=One-Click`, which
|
|
13
|
+
* says the HTTPS URL accepts a POST and will action it without further
|
|
14
|
+
* confirmation. Where that is offered, this plugin can do it from the reader.
|
|
15
|
+
* Where it is not, the URL is opened in a tab instead: POSTing to a URL that
|
|
16
|
+
* never advertised one-click is sending an unsolicited request to a third
|
|
17
|
+
* party on the user's behalf, which is not ours to decide.
|
|
18
|
+
*
|
|
19
|
+
* The `mailto:` form is always a real message from the user's own mailbox, so
|
|
20
|
+
* it is offered but never sent without asking - an unsubscribe mail is
|
|
21
|
+
* indistinguishable from any other mail they send.
|
|
22
|
+
*
|
|
23
|
+
* @module _lib/mail/unsubscribe
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Read a header out of either shape headers arrive in.
|
|
28
|
+
*
|
|
29
|
+
* @param {Map|object|null} headers
|
|
30
|
+
* @param {string} name
|
|
31
|
+
* @returns {string}
|
|
32
|
+
*/
|
|
33
|
+
function header(headers, name) {
|
|
34
|
+
if (!headers) return '';
|
|
35
|
+
const value = typeof headers.get === 'function' ? headers.get(name) : headers[name];
|
|
36
|
+
// mailparser hands back an object for some structured headers.
|
|
37
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
38
|
+
return String(value.value ?? '');
|
|
39
|
+
}
|
|
40
|
+
return String(Array.isArray(value) ? value[0] ?? '' : value ?? '');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* What a message offers by way of unsubscribing.
|
|
45
|
+
*
|
|
46
|
+
* @param {Map|object|null} headers - the parsed message's headers
|
|
47
|
+
* @returns {{available: boolean, http: string|null, oneClick: boolean,
|
|
48
|
+
* mailto: {to: string, subject: string, body: string}|null}}
|
|
49
|
+
*/
|
|
50
|
+
export function readUnsubscribe(headers) {
|
|
51
|
+
const raw = header(headers, 'list-unsubscribe');
|
|
52
|
+
const none = {available: false, http: null, oneClick: false, mailto: null};
|
|
53
|
+
if (!raw.trim()) return none;
|
|
54
|
+
|
|
55
|
+
// The header is a comma-separated list of angle-bracketed URIs. Split on
|
|
56
|
+
// the brackets rather than on commas: a mailto: with a subject containing
|
|
57
|
+
// a comma is legal and would otherwise be torn in half.
|
|
58
|
+
const uris = [...raw.matchAll(/<([^>]+)>/g)].map(match => match[1].trim()).filter(Boolean);
|
|
59
|
+
if (!uris.length) return none;
|
|
60
|
+
|
|
61
|
+
// https only. An http:// unsubscribe link is a plaintext request carrying
|
|
62
|
+
// a token that identifies the recipient, and there is no version of
|
|
63
|
+
// "click here to prove this address is live" worth sending in the clear.
|
|
64
|
+
const http = uris.find(uri => /^https:\/\//i.test(uri)) ?? null;
|
|
65
|
+
const mailtoUri = uris.find(uri => /^mailto:/i.test(uri)) ?? null;
|
|
66
|
+
|
|
67
|
+
const post = header(headers, 'list-unsubscribe-post');
|
|
68
|
+
const oneClick = Boolean(http) && /list-unsubscribe\s*=\s*one-click/i.test(post);
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
available: Boolean(http || mailtoUri),
|
|
72
|
+
http,
|
|
73
|
+
oneClick,
|
|
74
|
+
mailto: mailtoUri ? parseMailto(mailtoUri) : null
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Pull a mailto: URI apart into something sendable.
|
|
80
|
+
*
|
|
81
|
+
* @param {string} uri
|
|
82
|
+
* @returns {{to: string, subject: string, body: string}|null}
|
|
83
|
+
*/
|
|
84
|
+
export function parseMailto(uri) {
|
|
85
|
+
const withoutScheme = String(uri ?? '').replace(/^mailto:/i, '');
|
|
86
|
+
const [addresses, query = ''] = withoutScheme.split('?');
|
|
87
|
+
|
|
88
|
+
const to = decodeURIComponent(addresses ?? '').trim();
|
|
89
|
+
if (!to) return null;
|
|
90
|
+
|
|
91
|
+
const params = new URLSearchParams(query);
|
|
92
|
+
return {
|
|
93
|
+
to,
|
|
94
|
+
// "unsubscribe" is the conventional body and subject when the sender
|
|
95
|
+
// asks for nothing in particular, and an empty message is refused by
|
|
96
|
+
// a fair number of list managers.
|
|
97
|
+
subject: (params.get('subject') ?? 'unsubscribe').trim(),
|
|
98
|
+
body: (params.get('body') ?? 'unsubscribe').trim()
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Is this a host the server may be asked to POST to?
|
|
104
|
+
*
|
|
105
|
+
* One-click unsubscribe is the only place in this plugin where the SERVER
|
|
106
|
+
* makes a request to a URL chosen by whoever sent the message. Without a
|
|
107
|
+
* guard that is a server-side request forgery with a button on it: the
|
|
108
|
+
* sender supplies `https://attacker.example/u`, the admin clicks Unsubscribe,
|
|
109
|
+
* and the attacker's 302 sends the request to `http://127.0.0.1:8192/…` or
|
|
110
|
+
* to a cloud metadata endpoint - from inside the network, with the response
|
|
111
|
+
* status handed back to the caller as a working port scanner.
|
|
112
|
+
*
|
|
113
|
+
* Two halves, and both are needed:
|
|
114
|
+
*
|
|
115
|
+
* - Redirects are NOT followed. A validated host that redirects is a
|
|
116
|
+
* validated host pointing somewhere unvalidated.
|
|
117
|
+
* - The host must resolve to a PUBLIC address. Checking the literal is not
|
|
118
|
+
* enough, because `unsubscribe.attacker.example` can resolve to
|
|
119
|
+
* 127.0.0.1 - so the caller resolves it and checks every answer.
|
|
120
|
+
*
|
|
121
|
+
* This function is the pure half: given resolved addresses, is it allowed?
|
|
122
|
+
*
|
|
123
|
+
* @param {string[]} addresses - every A/AAAA the host resolved to
|
|
124
|
+
* @returns {boolean}
|
|
125
|
+
*/
|
|
126
|
+
export function addressesArePublic(addresses) {
|
|
127
|
+
const list = (addresses ?? []).map(a => String(a ?? '').trim().toLowerCase()).filter(Boolean);
|
|
128
|
+
// No answer is not permission. A host that resolves to nothing cannot be
|
|
129
|
+
// reached anyway, and treating "unknown" as "fine" is the wrong default
|
|
130
|
+
// for a security check.
|
|
131
|
+
if (!list.length) return false;
|
|
132
|
+
return list.every(address => !isPrivateAddress(address));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Is this IP one that must never be reached on a sender's say-so?
|
|
137
|
+
*
|
|
138
|
+
* Loopback, link-local (which covers the cloud metadata endpoints), the
|
|
139
|
+
* RFC 1918 ranges, carrier-grade NAT, and the IPv6 equivalents including
|
|
140
|
+
* IPv4-mapped addresses - `::ffff:127.0.0.1` is loopback wearing a hat.
|
|
141
|
+
*
|
|
142
|
+
* @param {string} address
|
|
143
|
+
* @returns {boolean}
|
|
144
|
+
*/
|
|
145
|
+
export function isPrivateAddress(address) {
|
|
146
|
+
const value = String(address ?? '').trim().toLowerCase();
|
|
147
|
+
if (!value) return true;
|
|
148
|
+
|
|
149
|
+
// IPv4-mapped IPv6, so the v4 rules below actually apply to it.
|
|
150
|
+
const mapped = value.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
|
151
|
+
const ip = mapped ? mapped[1] : value;
|
|
152
|
+
|
|
153
|
+
if (/^\d+\.\d+\.\d+\.\d+$/.test(ip)) {
|
|
154
|
+
const [a, b] = ip.split('.').map(Number);
|
|
155
|
+
if (a === 0 || a === 10 || a === 127) return true;
|
|
156
|
+
if (a === 169 && b === 254) return true; // link-local, incl. metadata
|
|
157
|
+
if (a === 172 && b >= 16 && b <= 31) return true; // RFC 1918
|
|
158
|
+
if (a === 192 && b === 168) return true; // RFC 1918
|
|
159
|
+
if (a === 100 && b >= 64 && b <= 127) return true; // carrier-grade NAT
|
|
160
|
+
if (a >= 224) return true; // multicast and reserved
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// IPv6.
|
|
165
|
+
if (ip === '::' || ip === '::1') return true;
|
|
166
|
+
if (/^f[cd]/.test(ip)) return true; // unique local
|
|
167
|
+
if (/^fe[89ab]/.test(ip)) return true; // link-local
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Names that are never a public unsubscribe endpoint, whatever they resolve
|
|
173
|
+
* to. A cheap first pass, so the common cases are refused without a lookup.
|
|
174
|
+
*
|
|
175
|
+
* @param {string} hostname
|
|
176
|
+
* @returns {boolean}
|
|
177
|
+
*/
|
|
178
|
+
export function isLocalName(hostname) {
|
|
179
|
+
const host = String(hostname ?? '').trim().toLowerCase().replace(/\.$/, '');
|
|
180
|
+
if (!host) return true;
|
|
181
|
+
if (host === 'localhost') return true;
|
|
182
|
+
return /\.(local|internal|localhost|home|lan|intranet)$/.test(host);
|
|
183
|
+
}
|