domma-cms 0.54.2 → 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 +151 -77
- 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/menu-editor.html +19 -25
- 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 +27 -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/menu-editor.js +13 -13
- 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 +11 -2
- package/bin/lib/smtp-defaults.js +53 -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/menu-highlight.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/menu-decor.mjs +1 -1
- 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/scripts/setup.js +8 -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/email.js +29 -3
- package/server/services/health.js +23 -2
- package/server/services/markdown.js +70 -9
- package/server/services/menuRender.js +28 -4
- package/server/services/menus.js +25 -1
- 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 +148 -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,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shape the mirror holds one message in.
|
|
3
|
+
*
|
|
4
|
+
* Its own module because two things write mirror rows - the sync engine on a
|
|
5
|
+
* poll, and a move ingesting the message it just placed in the destination -
|
|
6
|
+
* and two definitions of "a stored message" would drift. A row written by one
|
|
7
|
+
* path and read by the other has to be indistinguishable.
|
|
8
|
+
*
|
|
9
|
+
* @module _lib/mail/envelope
|
|
10
|
+
*/
|
|
11
|
+
import {threadRoot} from '../admin/mail/threads.js';
|
|
12
|
+
import {looksAutomated} from '../admin/mail/vacation.js';
|
|
13
|
+
import {readPriority} from './priority.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* What a fetch asks for when it wants a full envelope.
|
|
17
|
+
*
|
|
18
|
+
* Priority is not IN the envelope - there is no such field - so the three
|
|
19
|
+
* headers that carry it are fetched alongside. A handful of bytes per message,
|
|
20
|
+
* and the alternative is a second round trip per row.
|
|
21
|
+
*
|
|
22
|
+
* `references` and `list-id` are here for the same reason. IMAP's ENVELOPE
|
|
23
|
+
* carries `Message-ID` and `In-Reply-To` but not `References`, which is the
|
|
24
|
+
* header a conversation is actually reconstructed from; and `List-Id` is the
|
|
25
|
+
* one thing that reliably distinguishes a mailing list from a person, which
|
|
26
|
+
* is most of what filing rules are written about.
|
|
27
|
+
*/
|
|
28
|
+
export const ENVELOPE_QUERY = {
|
|
29
|
+
uid: true,
|
|
30
|
+
envelope: true,
|
|
31
|
+
flags: true,
|
|
32
|
+
size: true,
|
|
33
|
+
bodyStructure: true,
|
|
34
|
+
headers: [
|
|
35
|
+
'x-priority', 'importance', 'x-msmail-priority', 'references', 'list-id',
|
|
36
|
+
// What tells an away message not to answer. Fetched here rather than
|
|
37
|
+
// per message later: the responder runs over new mail on every poll,
|
|
38
|
+
// and a second round trip per message to read three headers would be
|
|
39
|
+
// the most expensive part of the feature.
|
|
40
|
+
'auto-submitted', 'precedence', 'return-path'
|
|
41
|
+
]
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Does this structure carry something a reader would call an attachment?
|
|
46
|
+
*
|
|
47
|
+
* @param {object} node
|
|
48
|
+
* @returns {boolean}
|
|
49
|
+
*/
|
|
50
|
+
export function hasAttachment(node) {
|
|
51
|
+
if (!node) return false;
|
|
52
|
+
if (node.disposition === 'attachment') return true;
|
|
53
|
+
return (node.childNodes ?? []).some(hasAttachment);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Turn an ImapFlow message into the shape the store holds.
|
|
58
|
+
*
|
|
59
|
+
* @param {object} message
|
|
60
|
+
* @returns {object}
|
|
61
|
+
*/
|
|
62
|
+
export function toRecord(message) {
|
|
63
|
+
const flags = [...(message.flags ?? [])];
|
|
64
|
+
const envelope = message.envelope ?? {};
|
|
65
|
+
const addresses = list => (list ?? []).map(a => ({name: a.name ?? '', address: a.address ?? ''}));
|
|
66
|
+
const headers = parseHeaderBlock(message.headers);
|
|
67
|
+
|
|
68
|
+
const record = {
|
|
69
|
+
uid: message.uid,
|
|
70
|
+
seq: message.seq,
|
|
71
|
+
subject: envelope.subject ?? '',
|
|
72
|
+
from: addresses(envelope.from),
|
|
73
|
+
to: addresses(envelope.to),
|
|
74
|
+
// Carried so an away message can tell "addressed to me" from
|
|
75
|
+
// "delivered to me" - a Bcc'd blast is exactly what must not be
|
|
76
|
+
// answered five hundred times.
|
|
77
|
+
cc: addresses(envelope.cc),
|
|
78
|
+
date: envelope.date ?? null,
|
|
79
|
+
size: message.size ?? 0,
|
|
80
|
+
flags,
|
|
81
|
+
seen: flags.includes('\\Seen'),
|
|
82
|
+
flagged: flags.includes('\\Flagged'),
|
|
83
|
+
answered: flags.includes('\\Answered'),
|
|
84
|
+
hasAttachment: hasAttachment(message.bodyStructure),
|
|
85
|
+
messageId: envelope.messageId ?? null,
|
|
86
|
+
inReplyTo: envelope.inReplyTo ?? null,
|
|
87
|
+
// Stored as it arrived - a space-separated string - and normalised
|
|
88
|
+
// by whoever reads it. Splitting here would make the stored shape
|
|
89
|
+
// depend on which server sent it.
|
|
90
|
+
references: typeof headers.get === 'function'
|
|
91
|
+
? (headers.get('references') ?? null)
|
|
92
|
+
: (headers.references ?? null),
|
|
93
|
+
// The bare list id, without the angle brackets and without the
|
|
94
|
+
// descriptive part some senders put in front of it, so a rule can be
|
|
95
|
+
// written about "github.com" rather than about
|
|
96
|
+
// "My List <list.github.com>".
|
|
97
|
+
listId: readListId(headers),
|
|
98
|
+
// Decided once, at fetch time, so the responder is a field read rather
|
|
99
|
+
// than a header parse. This single boolean is what closes the mail
|
|
100
|
+
// loop: our own replies carry `Auto-Submitted: auto-replied`, so they
|
|
101
|
+
// arrive at the far end already marked as not worth answering.
|
|
102
|
+
automated: looksAutomated(headers),
|
|
103
|
+
priority: readPriority(headers)
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// Computed here rather than by the caller, so every writer of a mirror
|
|
107
|
+
// row agrees about it - the sync engine and the move that ingests a
|
|
108
|
+
// message it has just placed are two different code paths writing the
|
|
109
|
+
// same collection.
|
|
110
|
+
return {...record, threadRoot: threadRoot(record)};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The List-Id header, reduced to the bit worth matching on.
|
|
115
|
+
*
|
|
116
|
+
* `List-Id: Dev Discussion <dev.example.com>` answers `dev.example.com`. A
|
|
117
|
+
* sender that gives only the bracketed part, or only a bare token, answers
|
|
118
|
+
* that instead.
|
|
119
|
+
*
|
|
120
|
+
* @param {Map|object} headers
|
|
121
|
+
* @returns {string}
|
|
122
|
+
*/
|
|
123
|
+
export function readListId(headers) {
|
|
124
|
+
const raw = typeof headers?.get === 'function'
|
|
125
|
+
? headers.get('list-id')
|
|
126
|
+
: headers?.['list-id'];
|
|
127
|
+
const value = String(raw ?? '').trim();
|
|
128
|
+
if (!value) return '';
|
|
129
|
+
const angled = value.match(/<([^>]+)>/);
|
|
130
|
+
return (angled ? angled[1] : value).trim();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Turn imapflow's raw header buffer into something `readPriority` can read.
|
|
135
|
+
*
|
|
136
|
+
* A `headers` fetch hands back the raw bytes of the requested block, not a
|
|
137
|
+
* parsed map - so this does the one line of parsing needed, rather than
|
|
138
|
+
* pulling in a MIME parser to read three fields.
|
|
139
|
+
*
|
|
140
|
+
* @param {Buffer|string|Map|object|null} raw
|
|
141
|
+
* @returns {Object.<string, string>}
|
|
142
|
+
*/
|
|
143
|
+
export function parseHeaderBlock(raw) {
|
|
144
|
+
if (!raw) return {};
|
|
145
|
+
// Already a map or object from another path - hand it straight on.
|
|
146
|
+
if (typeof raw.get === 'function' || (typeof raw === 'object' && !Buffer.isBuffer(raw)
|
|
147
|
+
&& typeof raw !== 'string')) {
|
|
148
|
+
return raw;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const out = {};
|
|
152
|
+
// Unfold first: a header may continue onto the next line indented, and
|
|
153
|
+
// splitting naively would lose the continuation.
|
|
154
|
+
const text = String(raw).replace(/\r?\n[ \t]+/g, ' ');
|
|
155
|
+
for (const line of text.split(/\r?\n/)) {
|
|
156
|
+
const at = line.indexOf(':');
|
|
157
|
+
if (at === -1) continue;
|
|
158
|
+
out[line.slice(0, at).trim().toLowerCase()] = line.slice(at + 1).trim();
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creating, renaming and deleting mailboxes.
|
|
3
|
+
*
|
|
4
|
+
* The IMAP calls are one line each; what is worth having in its own module is
|
|
5
|
+
* everything around them. A mailbox path is a string with a server-chosen
|
|
6
|
+
* delimiter in it, so "create a folder called X inside Y" is path arithmetic
|
|
7
|
+
* that goes wrong quietly - a name containing the delimiter silently creates a
|
|
8
|
+
* nested folder, and renaming a parent moves every child with it whether the
|
|
9
|
+
* caller expected that or not.
|
|
10
|
+
*
|
|
11
|
+
* Pure, so the arithmetic and the refusals can be tested without a server.
|
|
12
|
+
*
|
|
13
|
+
* @module _lib/mail/folders
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Folders the server has told us it uses for something.
|
|
18
|
+
*
|
|
19
|
+
* Deleting or renaming one of these does not tidy a mailbox, it breaks it:
|
|
20
|
+
* the server files sent mail in Sent by SPECIAL-USE, and a client that cannot
|
|
21
|
+
* find Trash falls back to expunging.
|
|
22
|
+
*/
|
|
23
|
+
const PROTECTED_USES = new Set([
|
|
24
|
+
'\\Inbox', '\\Sent', '\\Drafts', '\\Trash', '\\Junk', '\\Archive', '\\All', '\\Flagged'
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
/** The longest a single folder name may be. Servers vary; this is generous. */
|
|
28
|
+
const MAX_NAME = 100;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Is this a usable name for one folder?
|
|
32
|
+
*
|
|
33
|
+
* The delimiter is refused rather than escaped. Someone typing `Work.2026`
|
|
34
|
+
* into a "folder name" box means a folder with a dot in it, but the server
|
|
35
|
+
* would read it as `2026` inside `Work` - so the honest answer is to say the
|
|
36
|
+
* character cannot be used rather than to quietly create something else.
|
|
37
|
+
*
|
|
38
|
+
* @param {string} name
|
|
39
|
+
* @param {string} delimiter - the server's hierarchy separator
|
|
40
|
+
* @returns {{ok: true} | {ok: false, error: string}}
|
|
41
|
+
*/
|
|
42
|
+
export function validateFolderName(name, delimiter) {
|
|
43
|
+
const trimmed = String(name ?? '').trim();
|
|
44
|
+
if (!trimmed) return {ok: false, error: 'A folder needs a name.'};
|
|
45
|
+
if (trimmed.length > MAX_NAME) {
|
|
46
|
+
return {ok: false, error: `A folder name cannot be longer than ${MAX_NAME} characters.`};
|
|
47
|
+
}
|
|
48
|
+
if (delimiter && trimmed.includes(delimiter)) {
|
|
49
|
+
return {
|
|
50
|
+
ok: false,
|
|
51
|
+
error: `A folder name cannot contain "${delimiter}" - that is how this server separates folders. Create it inside another folder instead.`
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
// Control characters are not typed on purpose and break the protocol line.
|
|
55
|
+
if (/[\u0000-\u001f\u007f]/.test(trimmed)) {
|
|
56
|
+
return {ok: false, error: 'A folder name cannot contain control characters.'};
|
|
57
|
+
}
|
|
58
|
+
return {ok: true};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The full path a new folder would have.
|
|
63
|
+
*
|
|
64
|
+
* @param {string|null} parentPath - null or '' for a top-level folder
|
|
65
|
+
* @param {string} name
|
|
66
|
+
* @param {string} delimiter
|
|
67
|
+
* @returns {string}
|
|
68
|
+
*/
|
|
69
|
+
export function buildFolderPath(parentPath, name, delimiter) {
|
|
70
|
+
const leaf = String(name ?? '').trim();
|
|
71
|
+
const parent = String(parentPath ?? '').trim();
|
|
72
|
+
if (!parent) return leaf;
|
|
73
|
+
// A server with no delimiter has a flat namespace, so there is no "inside".
|
|
74
|
+
if (!delimiter) return leaf;
|
|
75
|
+
return `${parent}${delimiter}${leaf}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The path a folder would have after being renamed, keeping it where it is.
|
|
80
|
+
*
|
|
81
|
+
* Renaming is a MOVE in IMAP - there is no rename verb - so the new path has
|
|
82
|
+
* to carry the same parent, or "rename Contracts to Deals" would move it to
|
|
83
|
+
* the top level.
|
|
84
|
+
*
|
|
85
|
+
* @param {string} path - the folder's current full path
|
|
86
|
+
* @param {string} name - the new leaf name
|
|
87
|
+
* @param {string} delimiter
|
|
88
|
+
* @returns {string}
|
|
89
|
+
*/
|
|
90
|
+
export function renameTarget(path, name, delimiter) {
|
|
91
|
+
const current = String(path ?? '');
|
|
92
|
+
const cut = delimiter ? current.lastIndexOf(delimiter) : -1;
|
|
93
|
+
const parent = cut === -1 ? '' : current.slice(0, cut);
|
|
94
|
+
return buildFolderPath(parent, name, delimiter);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* May this folder be renamed or deleted?
|
|
99
|
+
*
|
|
100
|
+
* @param {{path: string, specialUse?: string|null}} folder
|
|
101
|
+
* @param {{action?: string}} [options]
|
|
102
|
+
* @returns {{ok: true} | {ok: false, error: string}}
|
|
103
|
+
*/
|
|
104
|
+
export function canModifyFolder(folder, {action = 'change'} = {}) {
|
|
105
|
+
const path = String(folder?.path ?? '').trim();
|
|
106
|
+
if (!path) return {ok: false, error: 'No folder was given.'};
|
|
107
|
+
|
|
108
|
+
// INBOX is INBOX on every server, whatever it reports for SPECIAL-USE.
|
|
109
|
+
if (path.toUpperCase() === 'INBOX') {
|
|
110
|
+
return {ok: false, error: `The Inbox cannot be ${action}d.`};
|
|
111
|
+
}
|
|
112
|
+
if (folder?.specialUse && PROTECTED_USES.has(folder.specialUse)) {
|
|
113
|
+
return {
|
|
114
|
+
ok: false,
|
|
115
|
+
error: `This server uses that folder for ${describeUse(folder.specialUse)}, so it cannot be ${action}d.`
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
return {ok: true};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* A SPECIAL-USE attribute in words.
|
|
123
|
+
*
|
|
124
|
+
* @param {string} use
|
|
125
|
+
* @returns {string}
|
|
126
|
+
*/
|
|
127
|
+
function describeUse(use) {
|
|
128
|
+
return {
|
|
129
|
+
'\\Sent': 'sent mail',
|
|
130
|
+
'\\Drafts': 'drafts',
|
|
131
|
+
'\\Trash': 'deleted mail',
|
|
132
|
+
'\\Junk': 'spam',
|
|
133
|
+
'\\Archive': 'archived mail',
|
|
134
|
+
'\\All': 'all mail',
|
|
135
|
+
'\\Flagged': 'starred mail'
|
|
136
|
+
}[use] ?? 'something';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The folders that would disappear along with this one.
|
|
141
|
+
*
|
|
142
|
+
* IMAP deletes one mailbox at a time, and a server may refuse to delete a
|
|
143
|
+
* parent that still has children - but more importantly, someone deleting
|
|
144
|
+
* `Work` should be told it takes `Work.Contracts.2026` with it BEFORE they
|
|
145
|
+
* agree to it, not after.
|
|
146
|
+
*
|
|
147
|
+
* @param {string} path
|
|
148
|
+
* @param {{path: string}[]} folders - every folder on the account
|
|
149
|
+
* @param {string} delimiter
|
|
150
|
+
* @returns {string[]} descendant paths, deepest first
|
|
151
|
+
*/
|
|
152
|
+
export function descendantsOf(path, folders, delimiter) {
|
|
153
|
+
if (!delimiter) return [];
|
|
154
|
+
const prefix = `${path}${delimiter}`;
|
|
155
|
+
return (folders ?? [])
|
|
156
|
+
.map(f => f.path)
|
|
157
|
+
.filter(p => p.startsWith(prefix))
|
|
158
|
+
// Deepest first, so a server that refuses to delete a non-empty
|
|
159
|
+
// parent is never asked to.
|
|
160
|
+
.sort((a, b) => b.split(delimiter).length - a.split(delimiter).length || b.localeCompare(a));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Work out which mailboxes are actually subscribed.
|
|
165
|
+
*
|
|
166
|
+
* Harder than it looks, and the reason this is a function rather than a
|
|
167
|
+
* property read. imapflow reports an UNSUBSCRIBED mailbox as
|
|
168
|
+
* `subscribed: undefined` - not `false` - so a `!== false` test reads every
|
|
169
|
+
* hidden folder as visible, which is exactly how this first shipped.
|
|
170
|
+
*
|
|
171
|
+
* Reading `=== true` instead has the opposite failure: a server that does not
|
|
172
|
+
* return the SUBSCRIBED attribute at all reports `undefined` for everything,
|
|
173
|
+
* and every folder would read as hidden. So the attribute is trusted only
|
|
174
|
+
* when the server demonstrably uses it - if not one mailbox claims to be
|
|
175
|
+
* subscribed, the server is not answering the question and everything is
|
|
176
|
+
* treated as visible.
|
|
177
|
+
*
|
|
178
|
+
* `list({subscribed: true})` is not a way out: on a LIST-EXTENDED server it
|
|
179
|
+
* came back with all 104 mailboxes including the unsubscribed one.
|
|
180
|
+
*
|
|
181
|
+
* @param {{path: string, subscribed?: boolean}[]} boxes
|
|
182
|
+
* @returns {Map<string, boolean>} path -> visible
|
|
183
|
+
*/
|
|
184
|
+
export function resolveSubscriptions(boxes) {
|
|
185
|
+
const list = boxes ?? [];
|
|
186
|
+
const serverAnswers = list.some(box => box.subscribed === true);
|
|
187
|
+
const out = new Map();
|
|
188
|
+
for (const box of list) {
|
|
189
|
+
out.set(box.path, serverAnswers ? box.subscribed === true : true);
|
|
190
|
+
}
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Getting mail out of the mailbox and into the CMS.
|
|
3
|
+
*
|
|
4
|
+
* The thing a mail plugin inside a content management system can do that a
|
|
5
|
+
* standalone client cannot: an attachment becomes a media file, and a message
|
|
6
|
+
* becomes a draft page or a collection entry. Every other feature here is one
|
|
7
|
+
* a webmail client would have; these are the ones that are the point of it
|
|
8
|
+
* living in Domma at all.
|
|
9
|
+
*
|
|
10
|
+
* Pure, and separate from the routes that use it, because the rules about
|
|
11
|
+
* what may be written into the public media directory are the security
|
|
12
|
+
* boundary of the whole feature and need testing without a mail server.
|
|
13
|
+
*
|
|
14
|
+
* @module _lib/mail/handoff
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Attachment types that may be saved into the media library.
|
|
19
|
+
*
|
|
20
|
+
* Deliberately NARROWER than the CMS's own upload allowlist, in two places:
|
|
21
|
+
*
|
|
22
|
+
* - **No SVG.** An SVG is a script container, and `/media/` is served from
|
|
23
|
+
* the site's own origin. The CMS allows one on a deliberate upload by an
|
|
24
|
+
* administrator who chose the file; this is a file a stranger emailed in,
|
|
25
|
+
* and one click between the two is not enough separation.
|
|
26
|
+
* - **No HTML.** For the same reason, more obviously.
|
|
27
|
+
*
|
|
28
|
+
* Anything not listed is refused rather than saved under a safe extension:
|
|
29
|
+
* renaming a file does not change what it is, and a media library full of
|
|
30
|
+
* `.bin` is not a media library.
|
|
31
|
+
*/
|
|
32
|
+
export const SAVEABLE_TYPES = new Map([
|
|
33
|
+
['image/jpeg', 'jpg'], ['image/jpg', 'jpg'], ['image/png', 'png'],
|
|
34
|
+
['image/gif', 'gif'], ['image/webp', 'webp'], ['image/x-icon', 'ico'],
|
|
35
|
+
['image/bmp', 'bmp'], ['image/tiff', 'tiff'],
|
|
36
|
+
['application/pdf', 'pdf'], ['text/plain', 'txt'], ['text/csv', 'csv'],
|
|
37
|
+
['application/msword', 'doc'],
|
|
38
|
+
['application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'docx'],
|
|
39
|
+
['application/vnd.ms-excel', 'xls'],
|
|
40
|
+
['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'xlsx'],
|
|
41
|
+
['video/mp4', 'mp4'], ['video/webm', 'webm'], ['video/ogg', 'ogv'],
|
|
42
|
+
['audio/mpeg', 'mp3'], ['audio/mp3', 'mp3'], ['audio/ogg', 'oga'],
|
|
43
|
+
['audio/wav', 'wav'], ['audio/webm', 'weba']
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The media type of an attachment, without its parameters.
|
|
48
|
+
*
|
|
49
|
+
* @param {*} value
|
|
50
|
+
* @returns {string}
|
|
51
|
+
*/
|
|
52
|
+
export function mediaType(value) {
|
|
53
|
+
return String(value ?? '').toLowerCase().split(';')[0].trim();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The name this attachment will be saved under.
|
|
58
|
+
*
|
|
59
|
+
* The extension comes from the ALLOWED TYPE, never from the sender's
|
|
60
|
+
* filename, and that is the whole point of this function.
|
|
61
|
+
*
|
|
62
|
+
* `/media/` is served by `@fastify/static`, which sets `Content-Type` from
|
|
63
|
+
* the file extension. The declared MIME type and the filename are two
|
|
64
|
+
* separate fields, both chosen by whoever sent the message - so an
|
|
65
|
+
* attachment declared `image/png` and named `logo.svg` passed the type check
|
|
66
|
+
* and then landed on disk as `.svg`, served from the site's own origin as
|
|
67
|
+
* `image/svg+xml`. An SVG is a script container and that origin is where the
|
|
68
|
+
* admin's token lives. `nosniff` does not help: it stops the browser
|
|
69
|
+
* sniffing AWAY from a declared type, not the extension declaring one.
|
|
70
|
+
*
|
|
71
|
+
* Rewriting rather than refusing, because a mislabelled extension is far more
|
|
72
|
+
* often a sender being careless than a sender being hostile - and a file
|
|
73
|
+
* saved as the type it actually claims to be is both safe and correct.
|
|
74
|
+
*
|
|
75
|
+
* @param {string} filename
|
|
76
|
+
* @param {string} contentType
|
|
77
|
+
* @returns {string}
|
|
78
|
+
*/
|
|
79
|
+
export function mediaNameFor(filename, contentType) {
|
|
80
|
+
const extension = SAVEABLE_TYPES.get(mediaType(contentType));
|
|
81
|
+
const base = safeName(filename);
|
|
82
|
+
// Everything before the last dot, so `report.final.pdf` keeps its stem.
|
|
83
|
+
const dot = base.lastIndexOf('.');
|
|
84
|
+
const stem = (dot > 0 ? base.slice(0, dot) : base) || 'attachment';
|
|
85
|
+
return extension ? `${stem}.${extension}` : stem;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Make an attachment's filename safe to write.
|
|
90
|
+
*
|
|
91
|
+
* The same transformation the media route uses - `path.basename` then a
|
|
92
|
+
* character allowlist - spelled out rather than imported, because this module
|
|
93
|
+
* is deliberately free of node built-ins so it can be tested and reasoned
|
|
94
|
+
* about on its own. A name that reduces to nothing gets one.
|
|
95
|
+
*
|
|
96
|
+
* @param {string} name
|
|
97
|
+
* @returns {string}
|
|
98
|
+
*/
|
|
99
|
+
export function safeName(name) {
|
|
100
|
+
const base = String(name ?? '')
|
|
101
|
+
// Everything up to and including the last separator, either kind.
|
|
102
|
+
.replace(/^.*[\\/]/, '')
|
|
103
|
+
.replace(/[^a-zA-Z0-9._-]/g, '_')
|
|
104
|
+
// A leading dot makes a hidden file; a name of only dots makes none.
|
|
105
|
+
.replace(/^\.+/, '');
|
|
106
|
+
return base.slice(0, 120) || 'attachment';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* A name that is not already taken, by adding a counter before the extension.
|
|
111
|
+
*
|
|
112
|
+
* `saveMedia` overwrites silently, so a second `invoice.pdf` from a second
|
|
113
|
+
* sender would replace the first. Anything derived from mail has to assume
|
|
114
|
+
* the name is not unique, because it was chosen by somebody else.
|
|
115
|
+
*
|
|
116
|
+
* @param {string} name
|
|
117
|
+
* @param {Set<string>|string[]} taken
|
|
118
|
+
* @returns {string}
|
|
119
|
+
*/
|
|
120
|
+
export function uniqueName(name, taken) {
|
|
121
|
+
const used = taken instanceof Set ? taken : new Set(taken ?? []);
|
|
122
|
+
const clean = safeName(name);
|
|
123
|
+
if (!used.has(clean)) return clean;
|
|
124
|
+
|
|
125
|
+
const dot = clean.lastIndexOf('.');
|
|
126
|
+
const stem = dot > 0 ? clean.slice(0, dot) : clean;
|
|
127
|
+
const ext = dot > 0 ? clean.slice(dot) : '';
|
|
128
|
+
|
|
129
|
+
for (let n = 2; n < 1000; n += 1) {
|
|
130
|
+
const candidate = `${stem}-${n}${ext}`;
|
|
131
|
+
if (!used.has(candidate)) return candidate;
|
|
132
|
+
}
|
|
133
|
+
// A thousand files of one name is not a case worth a cleverer answer.
|
|
134
|
+
return `${stem}-${Date.now()}${ext}`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* May this attachment be saved into the media library?
|
|
139
|
+
*
|
|
140
|
+
* @param {{filename?: string, contentType?: string, size?: number}} attachment
|
|
141
|
+
* @param {{maxBytes?: number}} [limits]
|
|
142
|
+
* @returns {{ok: true} | {ok: false, error: string}}
|
|
143
|
+
*/
|
|
144
|
+
export function canSave(attachment, {maxBytes = 20_000_000} = {}) {
|
|
145
|
+
const type = mediaType(attachment?.contentType);
|
|
146
|
+
if (!SAVEABLE_TYPES.has(type)) {
|
|
147
|
+
return {
|
|
148
|
+
ok: false,
|
|
149
|
+
error: `${type || 'That file type'} cannot be saved to the media library. Images, documents, audio and video can.`
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
if (Number(attachment?.size ?? 0) > maxBytes) {
|
|
153
|
+
return {ok: false, error: 'That attachment is too large for the media library.'};
|
|
154
|
+
}
|
|
155
|
+
return {ok: true};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* A URL path for a page made from a message.
|
|
160
|
+
*
|
|
161
|
+
* Under a folder rather than at the root: a mailbox can produce a great many
|
|
162
|
+
* of these, and a site whose top level fills up with them is a site nobody
|
|
163
|
+
* will use the feature on twice. Dated, because two messages very often share
|
|
164
|
+
* a subject.
|
|
165
|
+
*
|
|
166
|
+
* @param {string} subject
|
|
167
|
+
* @param {Date} [date]
|
|
168
|
+
* @param {string} [prefix]
|
|
169
|
+
* @returns {string}
|
|
170
|
+
*/
|
|
171
|
+
export function pagePathFor(subject, date = new Date(), prefix = 'from-mail') {
|
|
172
|
+
const slug = String(subject ?? '')
|
|
173
|
+
.toLowerCase()
|
|
174
|
+
.replace(/^((re|fwd|fw)\s*:\s*)+/i, '')
|
|
175
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
176
|
+
.replace(/^-+|-+$/g, '')
|
|
177
|
+
.slice(0, 60) || 'message';
|
|
178
|
+
|
|
179
|
+
const when = Number.isNaN(date?.getTime?.()) ? new Date() : date;
|
|
180
|
+
const stamp = when.toISOString().slice(0, 10);
|
|
181
|
+
return `/${prefix}/${stamp}-${slug}`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Turn a message's plain text into a Markdown body.
|
|
186
|
+
*
|
|
187
|
+
* The TEXT part, never the HTML. Mail HTML is table layouts, inline styles
|
|
188
|
+
* and tracking, and embedding it in a Markdown file produces a page that is
|
|
189
|
+
* unusable to edit and, because a blank line ends an HTML block in Markdown,
|
|
190
|
+
* frequently one that renders its own markup as prose halfway down. The text
|
|
191
|
+
* part is what the message actually says.
|
|
192
|
+
*
|
|
193
|
+
* Quoted material is dropped: a page made from the fourth message in a thread
|
|
194
|
+
* should be that message, not it and the three under it.
|
|
195
|
+
*
|
|
196
|
+
* @param {string} text
|
|
197
|
+
* @returns {string}
|
|
198
|
+
*/
|
|
199
|
+
export function textToMarkdown(text) {
|
|
200
|
+
const lines = String(text ?? '').replace(/\r\n/g, '\n').split('\n');
|
|
201
|
+
|
|
202
|
+
const kept = [];
|
|
203
|
+
for (const line of lines) {
|
|
204
|
+
// The quote and everything after it. Attribution lines vary too much
|
|
205
|
+
// to match reliably, but the first `>` is dependable and is where the
|
|
206
|
+
// original message starts in every client that quotes at all.
|
|
207
|
+
if (/^\s*>/.test(line)) break;
|
|
208
|
+
// The plain-text signature delimiter, likewise.
|
|
209
|
+
if (/^--\s*$/.test(line)) break;
|
|
210
|
+
kept.push(line);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// The attribution line sits ABOVE the first `>`, so breaking on the quote
|
|
214
|
+
// leaves it behind - "On Monday, Sam wrote:" as the last line of a page
|
|
215
|
+
// about something else. Dropped from the end, along with the blank line
|
|
216
|
+
// that introduced it, and only from the end: the same words in the middle
|
|
217
|
+
// of a message are the sender quoting someone in prose.
|
|
218
|
+
while (kept.length) {
|
|
219
|
+
const last = kept[kept.length - 1].trim();
|
|
220
|
+
if (!last) { kept.pop(); continue; }
|
|
221
|
+
if (/wrote:$/i.test(last) || /^-{2,}\s*(original|forwarded) message/i.test(last)) {
|
|
222
|
+
kept.pop();
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return kept
|
|
229
|
+
.join('\n')
|
|
230
|
+
// Markdown treats a single newline as a space, so a hard-wrapped mail
|
|
231
|
+
// would reflow into one paragraph. Blank-line-separated blocks are
|
|
232
|
+
// paragraphs; single breaks inside them keep their line.
|
|
233
|
+
.replace(/[ \t]+$/gm, '')
|
|
234
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
235
|
+
.trim();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Match a message onto a collection's fields, by what they are called.
|
|
240
|
+
*
|
|
241
|
+
* A guess, offered to the user rather than applied behind their back - the
|
|
242
|
+
* route hands the mapping back and the screen shows it, so a field called
|
|
243
|
+
* `body` that was meant for something else is visible before anything is
|
|
244
|
+
* written.
|
|
245
|
+
*
|
|
246
|
+
* @param {Array<{name: string, type?: string}>} fields
|
|
247
|
+
* @returns {Object.<string, string>} field name -> message property
|
|
248
|
+
*/
|
|
249
|
+
export function guessFieldMapping(fields) {
|
|
250
|
+
const candidates = {
|
|
251
|
+
subject: ['subject', 'title', 'name', 'heading'],
|
|
252
|
+
body: ['body', 'content', 'message', 'text', 'description', 'notes'],
|
|
253
|
+
fromName: ['from', 'sender', 'author', 'contact', 'fullname', 'name'],
|
|
254
|
+
fromEmail: ['email', 'emailaddress', 'from_email', 'fromemail', 'sender_email'],
|
|
255
|
+
date: ['date', 'received', 'sent', 'createdat', 'when']
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const mapping = {};
|
|
259
|
+
const used = new Set();
|
|
260
|
+
|
|
261
|
+
// In this order, so `name` goes to the subject on a collection that has
|
|
262
|
+
// both `name` and `from` rather than to the sender.
|
|
263
|
+
for (const [property, names] of Object.entries(candidates)) {
|
|
264
|
+
for (const field of fields ?? []) {
|
|
265
|
+
const key = String(field?.name ?? '').toLowerCase().replace(/[^a-z]/g, '');
|
|
266
|
+
if (used.has(field.name)) continue;
|
|
267
|
+
if (!names.includes(key)) continue;
|
|
268
|
+
mapping[field.name] = property;
|
|
269
|
+
used.add(field.name);
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return mapping;
|
|
274
|
+
}
|