@nocobase/portal-template-default 1.0.16 → 1.0.17

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 (150) hide show
  1. package/AGENTS.md +8 -0
  2. package/README.MD +4 -0
  3. package/dist/assets/{LineDraw-vPRTWiQ5.js → LineDraw-BBB5hwuJ.js} +1 -1
  4. package/dist/assets/{SymbolDraw-DOl_Tss3.js → SymbolDraw-CKS3uUb_.js} +1 -1
  5. package/dist/assets/{VisualMapping-j08W3KBm.js → VisualMapping-BCwaW3TD.js} +1 -1
  6. package/dist/assets/auth-method-demo-BdCCvEEz.js +1 -0
  7. package/dist/assets/{auto-login-provider-Bukw7zEe.js → auto-login-provider-DO5Il6Dp.js} +2 -2
  8. package/dist/assets/auto-login-provider-TQ08B6-i.js +1 -0
  9. package/dist/assets/auto-redirect-provider-ButXp0A3.js +1 -0
  10. package/dist/assets/auto-redirect-provider-DnGGP2Vn.js +1 -0
  11. package/dist/assets/auto-redirect-provider-ja-vFPaF.js +1 -0
  12. package/dist/assets/calendar-C7xnRgOT.js +1 -0
  13. package/dist/assets/{cas-sign-in-button-BQn3Mttx.js → cas-sign-in-button-BZCa4lR6.js} +1 -1
  14. package/dist/assets/{components-BT5mL9gW.js → components-_g-GjQeV.js} +3 -3
  15. package/dist/assets/{configuration-gate-BJoadoA6.js → configuration-gate-B4XVz7tS.js} +1 -1
  16. package/dist/assets/{createClipPathFromCoordSys-m8IbnJ1z.js → createClipPathFromCoordSys-z9Kmh-tu.js} +1 -1
  17. package/dist/assets/createSeriesDataSimply-CPqysF0K.js +1 -0
  18. package/dist/assets/{customGraphicKeyframeAnimation-CYLPurwK.js → customGraphicKeyframeAnimation-DMU5crAu.js} +1 -1
  19. package/dist/assets/demo-BPlae2bp.js +1 -0
  20. package/dist/assets/demo-Bj_Y0RrW.js +1 -0
  21. package/dist/assets/demo-CKPAQOib.js +1 -0
  22. package/dist/assets/demo-CXRTPvIx.js +1 -0
  23. package/dist/assets/demo-DJuVDu1n.js +1 -0
  24. package/dist/assets/demo-DUmWU7ik.js +1 -0
  25. package/dist/assets/demo-NhIzFRE1.js +1 -0
  26. package/dist/assets/{dingtalk-sign-in-button-DSluFaP9.js → dingtalk-sign-in-button-DMPgE5kT.js} +1 -1
  27. package/dist/assets/{echarts-preview-mRXoEciY.js → echarts-preview-R96Pbqmz.js} +1 -1
  28. package/dist/assets/{echarts-runtime-CvLr4Slh.js → echarts-runtime-Ce_z2eX_.js} +3 -3
  29. package/dist/assets/{echarts-runtime-advanced-BC1tvfQ0.js → echarts-runtime-advanced-Dr80KHJR.js} +1 -1
  30. package/dist/assets/{echarts-runtime-common-DhNaV5B8.js → echarts-runtime-common-DB95Yfc1.js} +1 -1
  31. package/dist/assets/{echarts-runtime-components-BiioS8t1.js → echarts-runtime-components-DCJEaTJk.js} +1 -1
  32. package/dist/assets/{echarts-runtime-hierarchy-CAWqYvFh.js → echarts-runtime-hierarchy-CezgZs9O.js} +1 -1
  33. package/dist/assets/{floating-RVbHvIiD.js → floating-O9WmiDXk.js} +2 -2
  34. package/dist/assets/index-BBKV6O5d.css +1 -0
  35. package/dist/assets/{index-Dw20Lzpp.js → index-CPzehPGe.js} +3 -3
  36. package/dist/assets/index-Cc6awvdg.js +1064 -0
  37. package/dist/assets/index-ChA0z9ke.js +38 -0
  38. package/dist/assets/{index-BysibBZu.js → index-DgB7uTMs.js} +2 -2
  39. package/dist/assets/{index-C-tdHirk.js → index-DroUAK80.js} +1 -1
  40. package/dist/assets/index-WKvygTBp.js +59 -0
  41. package/dist/assets/{labelHelper-DbITmOUW.js → labelHelper-Dk5jkiD-.js} +1 -1
  42. package/dist/assets/ldap-sign-in-form-CKN4l7qM.js +1 -0
  43. package/dist/assets/{oidc-sign-in-button-Cgpa8Qzj.js → oidc-sign-in-button-BIRonrpC.js} +1 -1
  44. package/dist/assets/{page-context-AU6RU0Qi.js → page-context-DAiW_dId.js} +4 -4
  45. package/dist/assets/prompt-card-rGZqpqkF.js +1 -0
  46. package/dist/assets/prompt-output-B4h3uLWf.js +1 -0
  47. package/dist/assets/saml-sign-in-button-B4ugaLIb.js +1 -0
  48. package/dist/assets/{sectorHelper-C6L782Ue.js → sectorHelper-BuxrrJrA.js} +1 -1
  49. package/dist/assets/shortcut-BhKXxJLs.js +47 -0
  50. package/dist/assets/{sms-sign-in-form-Bo2WLZuP.js → sms-sign-in-form-DKpxRDdZ.js} +1 -1
  51. package/dist/assets/{static.browser-CoPxsICt.js → static.browser-kzzpH0Xt.js} +1 -1
  52. package/dist/assets/switch-BdVxPIVU.js +1 -0
  53. package/dist/assets/{tool-cards-ipgJ_3Ec.js → tool-cards-CpczIke-.js} +3 -3
  54. package/dist/assets/{use-authenticator-sign-in-BzFeh7qe.js → use-authenticator-sign-in-CIj210ry.js} +1 -1
  55. package/dist/assets/use-dingtalk-sign-in-BW4kkXD3.js +1 -0
  56. package/dist/assets/{use-wecom-sign-in-PPIMLvGa.js → use-wecom-sign-in-dIqMtcN9.js} +1 -1
  57. package/dist/assets/{wecom-sign-in-button-rUq9RW8R.js → wecom-sign-in-button-Dn_2zDab.js} +1 -1
  58. package/dist/index.html +2 -2
  59. package/dist/index.raw.html +2 -2
  60. package/package.json +9 -1
  61. package/src/App.tsx +5 -2
  62. package/src/app/extension.ts +2 -0
  63. package/src/app/extensions.tsx +18 -6
  64. package/src/app/route-access-guard.tsx +22 -0
  65. package/src/app/route-runtime.ts +173 -0
  66. package/src/app/routes.tsx +2 -2
  67. package/src/components/app-shell/header.tsx +56 -1
  68. package/src/components/app-shell/sidebar.tsx +14 -2
  69. package/src/extensions/nocobase-acl/README.md +28 -14
  70. package/src/extensions/nocobase-acl/demo/index.tsx +87 -15
  71. package/src/extensions/nocobase-acl/demo/prompt-generator.tsx +1 -0
  72. package/src/extensions/nocobase-mail/README.md +74 -0
  73. package/src/extensions/nocobase-mail/components/index.ts +79 -0
  74. package/src/extensions/nocobase-mail/components/mail-api.ts +423 -0
  75. package/src/extensions/nocobase-mail/components/mail-attachment-list.tsx +67 -0
  76. package/src/extensions/nocobase-mail/components/mail-compose-attachments.tsx +166 -0
  77. package/src/extensions/nocobase-mail/components/mail-compose.tsx +1071 -0
  78. package/src/extensions/nocobase-mail/components/mail-detail.tsx +653 -0
  79. package/src/extensions/nocobase-mail/components/mail-draft-autosave.ts +26 -0
  80. package/src/extensions/nocobase-mail/components/mail-empty.tsx +48 -0
  81. package/src/extensions/nocobase-mail/components/mail-filters.tsx +277 -0
  82. package/src/extensions/nocobase-mail/components/mail-inbox.tsx +544 -0
  83. package/src/extensions/nocobase-mail/components/mail-inline-images.ts +69 -0
  84. package/src/extensions/nocobase-mail/components/mail-label-badge.tsx +25 -0
  85. package/src/extensions/nocobase-mail/components/mail-label-manager.tsx +271 -0
  86. package/src/extensions/nocobase-mail/components/mail-labels-editor.tsx +267 -0
  87. package/src/extensions/nocobase-mail/components/mail-mass-tracking.tsx +271 -0
  88. package/src/extensions/nocobase-mail/components/mail-note-editor.tsx +99 -0
  89. package/src/extensions/nocobase-mail/components/mail-recipient-input.tsx +260 -0
  90. package/src/extensions/nocobase-mail/components/mail-reply-quote.ts +28 -0
  91. package/src/extensions/nocobase-mail/components/mail-rich-editor.tsx +287 -0
  92. package/src/extensions/nocobase-mail/components/mail-send-actions.tsx +238 -0
  93. package/src/extensions/nocobase-mail/components/mail-senders.ts +61 -0
  94. package/src/extensions/nocobase-mail/components/mail-settings-drawer.tsx +708 -0
  95. package/src/extensions/nocobase-mail/components/mail-showcase-layout.tsx +64 -0
  96. package/src/extensions/nocobase-mail/components/mail-signature-manager.tsx +258 -0
  97. package/src/extensions/nocobase-mail/components/mail-signatures.ts +25 -0
  98. package/src/extensions/nocobase-mail/components/mail-table.tsx +476 -0
  99. package/src/extensions/nocobase-mail/components/mail-template-manager.tsx +204 -0
  100. package/src/extensions/nocobase-mail/components/mail-toolbar.tsx +155 -0
  101. package/src/extensions/nocobase-mail/components/mail-unread.tsx +111 -0
  102. package/src/extensions/nocobase-mail/components/types.ts +297 -0
  103. package/src/extensions/nocobase-mail/components/use-mail-compose.tsx +262 -0
  104. package/src/extensions/nocobase-mail/components/use-mail-mass-messages.ts +54 -0
  105. package/src/extensions/nocobase-mail/components/use-mail-messages.ts +113 -0
  106. package/src/extensions/nocobase-mail/components/use-mail-signatures.ts +122 -0
  107. package/src/extensions/nocobase-mail/components/use-mail-templates.ts +54 -0
  108. package/src/extensions/nocobase-mail/extension.tsx +105 -0
  109. package/src/extensions/nocobase-mail/index.ts +1 -0
  110. package/src/extensions/nocobase-mail/mail-demo-pages.tsx +503 -0
  111. package/src/extensions/nocobase-mail/mail-pages.tsx +201 -0
  112. package/src/extensions/nocobase-mail/tests/mail-quote-html-regression.mjs +51 -0
  113. package/src/extensions/nocobase-mail/tests/mail-regression.mjs +649 -0
  114. package/src/extensions/nocobase-users-example/app-routes.tsx +121 -0
  115. package/src/extensions/nocobase-users-example/extension.tsx +2 -100
  116. package/src/extensions/nocobase-users-example/index.ts +1 -0
  117. package/src/lib/nocobase/acl/menu.ts +17 -3
  118. package/src/lib/nocobase/acl/types.ts +4 -0
  119. package/src/locales/en-US.ts +2 -1
  120. package/src/locales/zh-CN.ts +2 -1
  121. package/src/providers/runtime-config.ts +18 -0
  122. package/src/routes.tsx +10 -0
  123. package/src/vite-env.d.ts +3 -0
  124. package/vite.config.ts +8 -0
  125. package/dist/assets/auth-method-demo-B2qcpY8z.js +0 -1
  126. package/dist/assets/auto-login-provider-BMtzvdxG.js +0 -1
  127. package/dist/assets/auto-redirect-provider-C6GNPg5r.js +0 -1
  128. package/dist/assets/auto-redirect-provider-D5c6kN2R.js +0 -1
  129. package/dist/assets/auto-redirect-provider-iTzGEDpU.js +0 -1
  130. package/dist/assets/calendar-Dv2RNnsr.js +0 -1
  131. package/dist/assets/card-C9Hpeq1c.js +0 -1
  132. package/dist/assets/createSeriesDataSimply-DKUV5b5e.js +0 -1
  133. package/dist/assets/demo-BWGOwqtJ.js +0 -1
  134. package/dist/assets/demo-BWbcvkya.js +0 -1
  135. package/dist/assets/demo-DIASSPgn.js +0 -1
  136. package/dist/assets/demo-DuZ6asUU.js +0 -1
  137. package/dist/assets/demo-Dxcy4bDE.js +0 -1
  138. package/dist/assets/demo-YpEC76uw.js +0 -1
  139. package/dist/assets/demo-wzkoR8T2.js +0 -1
  140. package/dist/assets/index-CBZbj45X.js +0 -58
  141. package/dist/assets/index-CgkdZ6Oc.js +0 -686
  142. package/dist/assets/index-NH3aqqQz.css +0 -1
  143. package/dist/assets/index-hSBzW-VA.js +0 -43
  144. package/dist/assets/ldap-sign-in-form-BOMNDTkK.js +0 -1
  145. package/dist/assets/prompt-card-FWugRgi-.js +0 -1
  146. package/dist/assets/prompt-output-DMTuTNab.js +0 -1
  147. package/dist/assets/saml-sign-in-button-qt8bMeoQ.js +0 -1
  148. package/dist/assets/shortcut-Bu7jT_-w.js +0 -47
  149. package/dist/assets/switch-DQkkIE7G.js +0 -1
  150. package/dist/assets/use-dingtalk-sign-in-mrq6K5Yx.js +0 -1
@@ -0,0 +1,649 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFile } from "node:fs/promises";
3
+ import { createServer } from "vite";
4
+
5
+ const server = await createServer({
6
+ appType: "custom",
7
+ logLevel: "silent",
8
+ server: { middlewareMode: true },
9
+ });
10
+
11
+ try {
12
+ const mailTableSource = await readFile(
13
+ new URL("../components/mail-table.tsx", import.meta.url),
14
+ "utf8"
15
+ );
16
+ const mailFiltersSource = await readFile(
17
+ new URL("../components/mail-filters.tsx", import.meta.url),
18
+ "utf8"
19
+ );
20
+ const mailDetailSource = await readFile(
21
+ new URL("../components/mail-detail.tsx", import.meta.url),
22
+ "utf8"
23
+ );
24
+ const mailInboxSource = await readFile(
25
+ new URL("../components/mail-inbox.tsx", import.meta.url),
26
+ "utf8"
27
+ );
28
+ const mailPagesSource = await readFile(
29
+ new URL("../mail-pages.tsx", import.meta.url),
30
+ "utf8"
31
+ );
32
+ assert.match(
33
+ mailTableSource,
34
+ /<DropdownMenuContent[^>]*>\s*<DropdownMenuGroup>[\s\S]*?<DropdownMenuLabel>Toggle columns<\/DropdownMenuLabel>/,
35
+ "keeps the Base UI columns label inside a menu group"
36
+ );
37
+ assert.match(
38
+ mailTableSource,
39
+ /unread &&[\s\S]*?\? "font-bold"/,
40
+ "renders unread mail cells with a clearly bold font weight"
41
+ );
42
+ assert.match(
43
+ mailFiltersSource,
44
+ /label="Todo"[\s\S]*?icon=\{value\.isTodo \? CircleCheckBig : Circle\}/,
45
+ "presents the backend todo flag as a todo filter"
46
+ );
47
+ assert.doesNotMatch(
48
+ mailFiltersSource,
49
+ /Starred|icon=\{Star\}/,
50
+ "does not present todo messages as starred mail"
51
+ );
52
+ assert.match(
53
+ mailDetailSource,
54
+ /aria-pressed=\{Boolean\(message\.isTodo\)\}[\s\S]*?"Add to todo"[\s\S]*?message\.isTodo \? \(/,
55
+ "renders the message todo control as an accessible toggle"
56
+ );
57
+ assert.match(
58
+ mailDetailSource,
59
+ /title="Mark as unread"[\s\S]*?onMarkUnread\?\.\(message\)/,
60
+ "offers a mark-as-unread action in message detail"
61
+ );
62
+ assert.match(
63
+ mailInboxSource,
64
+ /handleDetailMarkUnread[\s\S]*?collectMessageMailIds\(message\)[\s\S]*?mailApi\.setRead\(mailIds, false\)[\s\S]*?markMessageUnread/,
65
+ "marks the full open conversation unread and updates local state"
66
+ );
67
+ assert.match(
68
+ mailDetailSource,
69
+ /\.gmail_quote[\s\S]*?blockquote\[type=[^\]]*cite[^\]]*\][\s\S]*?#divNeteaseMailCard[\s\S]*?#foxmail_quote[\s\S]*?#divRplyFwdMsg[\s\S]*?#yahoo_quoted/,
70
+ "recognizes quoted messages from the providers supported by the previous mail detail"
71
+ );
72
+ assert.match(
73
+ mailDetailSource,
74
+ /topLevelNodes\[0\][\s\S]*?mail-quote is-collapsed[\s\S]*?Replied message/,
75
+ "collapses only the first top-level quoted message behind a reply toggle"
76
+ );
77
+ assert.match(
78
+ mailInboxSource,
79
+ /isMessageUnread\(message\)[\s\S]*?collectMessageMailIds\(detail\)[\s\S]*?mailApi\.setRead\(mailIds, true\)/,
80
+ "marks the complete conversation as read when its detail is opened"
81
+ );
82
+ assert.match(
83
+ mailInboxSource,
84
+ /m\.id === message\.id[\s\S]*?markMessageRead\(m\)/,
85
+ "clears both the message and conversation unread state in the list"
86
+ );
87
+ assert.match(
88
+ mailPagesSource,
89
+ /params\.set\("todo", "1"\)/,
90
+ "uses todo semantics in newly generated filter URLs"
91
+ );
92
+ assert.match(
93
+ mailPagesSource,
94
+ /searchParams\.get\("starred"\) === "1"/,
95
+ "keeps legacy starred filter URLs working"
96
+ );
97
+
98
+ const { getMailSenderCandidates, resolveMailSender } =
99
+ await server.ssrLoadModule(
100
+ "/registry/nocobase-mail/components/mail-senders.ts"
101
+ );
102
+ const { buildComposeInitial, canReplyAll } = await server.ssrLoadModule(
103
+ "/registry/nocobase-mail/components/use-mail-compose.tsx"
104
+ );
105
+ const {
106
+ collectMessageMailIds,
107
+ isMessageUnread,
108
+ markMessageRead,
109
+ markMessageUnread,
110
+ } = await server.ssrLoadModule(
111
+ "/registry/nocobase-mail/components/mail-inbox.tsx"
112
+ );
113
+ const { mailApi } = await server.ssrLoadModule(
114
+ "/registry/nocobase-mail/components/mail-api.ts"
115
+ );
116
+ const { nocobaseClient } = await server.ssrLoadModule(
117
+ "/src/lib/nocobase/client.ts"
118
+ );
119
+ const { default: mailExtension } = await server.ssrLoadModule(
120
+ "/registry/nocobase-mail/extension.tsx"
121
+ );
122
+ const { appendRecipient, currentToken, mergeRecipients } =
123
+ await server.ssrLoadModule(
124
+ "/registry/nocobase-mail/components/mail-recipient-input.tsx"
125
+ );
126
+ const { createDebouncedDraftSaver } = await server.ssrLoadModule(
127
+ "/registry/nocobase-mail/components/mail-draft-autosave.ts"
128
+ );
129
+ const {
130
+ collectInlineContentIds,
131
+ filterInlineAttachments,
132
+ replaceInlineImageSources,
133
+ } = await server.ssrLoadModule(
134
+ "/registry/nocobase-mail/components/mail-inline-images.ts"
135
+ );
136
+ const { serializeReplyQuote, splitReplyQuote } = await server.ssrLoadModule(
137
+ "/registry/nocobase-mail/components/mail-reply-quote.ts"
138
+ );
139
+
140
+ assert.equal(currentToken("alice@example.com, bo"), "bo");
141
+ assert.equal(
142
+ appendRecipient("alice@example.com, bo", "bob@example.com"),
143
+ "alice@example.com, bob@example.com",
144
+ "replaces the active search token when a recipient is selected"
145
+ );
146
+ assert.equal(
147
+ appendRecipient("alice@example.com, ", "bob@example.com"),
148
+ "alice@example.com, bob@example.com",
149
+ "appends after a completed manual recipient"
150
+ );
151
+ assert.equal(
152
+ appendRecipient("Alice@example.com, ali", "alice@example.com"),
153
+ "Alice@example.com",
154
+ "deduplicates selected recipients case-insensitively"
155
+ );
156
+ assert.equal(
157
+ mergeRecipients(
158
+ "alice@example.com",
159
+ "bob@example.com; ALICE@example.com, carol@example.com"
160
+ ),
161
+ "alice@example.com, bob@example.com, carol@example.com",
162
+ "merges manually entered To/Cc recipients and removes duplicates"
163
+ );
164
+
165
+ const autoSaved = [];
166
+ const draftSaver = createDebouncedDraftSaver(
167
+ async (payload) => autoSaved.push(payload),
168
+ 5
169
+ );
170
+ draftSaver.schedule({ subject: "First version" });
171
+ draftSaver.schedule({ subject: "Latest version" });
172
+ await new Promise((resolve) => setTimeout(resolve, 20));
173
+ assert.deepEqual(
174
+ autoSaved,
175
+ [{ subject: "Latest version" }],
176
+ "auto-save debounces edits and persists only the latest draft snapshot"
177
+ );
178
+ draftSaver.cancel();
179
+
180
+ const inlineHtml = [
181
+ '<p><img src="cid:<Hero.Image@Mail>"></p>',
182
+ '<img src="/api/mail:messageContentPreview?messageId=7&amp;contentId=logo%40mail">',
183
+ ].join("");
184
+ assert.deepEqual(collectInlineContentIds(inlineHtml), [
185
+ "hero.image@mail",
186
+ "logo@mail",
187
+ ]);
188
+ assert.equal(
189
+ replaceInlineImageSources(
190
+ inlineHtml,
191
+ new Map([
192
+ ["hero.image@mail", "blob:hero"],
193
+ ["logo@mail", "blob:logo"],
194
+ ])
195
+ ),
196
+ '<p><img src="blob:hero"></p><img src="blob:logo">'
197
+ );
198
+ assert.deepEqual(
199
+ filterInlineAttachments(
200
+ [
201
+ {
202
+ attachmentId: "inline",
203
+ filename: "hero.png",
204
+ mimeType: "image/png",
205
+ contentId: "<Hero.Image@Mail>",
206
+ },
207
+ {
208
+ attachmentId: "file",
209
+ filename: "report.pdf",
210
+ mimeType: "application/pdf",
211
+ },
212
+ ],
213
+ inlineHtml
214
+ ).map((attachment) => attachment.attachmentId),
215
+ ["file"],
216
+ "inline images are not duplicated in the downloadable attachment list"
217
+ );
218
+
219
+ assert.equal(
220
+ mailExtension.resources,
221
+ undefined,
222
+ "does not add Mail examples to the application navigation"
223
+ );
224
+ assert.ok(mailExtension.dev?.routes, "provides Mail routes under /dev");
225
+ const mailResources = mailExtension.dev?.resources ?? [];
226
+ const scenarioResources = mailResources.filter((resource) =>
227
+ resource.name.startsWith("mail-scenario-")
228
+ );
229
+ assert.equal(
230
+ scenarioResources.length,
231
+ 5,
232
+ "includes the standalone unread-indicator scenario"
233
+ );
234
+ assert.ok(
235
+ scenarioResources.every((resource) => resource.meta?.parent === "mail"),
236
+ "nests every scenario directly under Mail"
237
+ );
238
+ assert.equal(
239
+ mailResources.some((resource) => resource.name === "mail-compose"),
240
+ false,
241
+ "removes the old standalone Compose menu entry"
242
+ );
243
+ assert.equal(
244
+ mailResources.some((resource) => resource.name === "mail-scenarios"),
245
+ false,
246
+ "removes the intermediate Mail scenarios menu group"
247
+ );
248
+ const bulkResource = mailResources.find(
249
+ (resource) => resource.name === "mail-bulk"
250
+ );
251
+ assert.equal(bulkResource?.list, "mail/bulk");
252
+ assert.equal(bulkResource?.meta?.parent, "mail");
253
+ assert.equal(bulkResource?.meta?.label, "Bulk mail");
254
+ assert.equal(
255
+ mailResources.at(-1)?.name,
256
+ "mail-bulk",
257
+ "keeps Bulk mail as the final item in the Mail menu"
258
+ );
259
+ assert.deepEqual(
260
+ scenarioResources.map((resource) => resource.list),
261
+ [
262
+ "mail/workspace",
263
+ "mail/personal",
264
+ "mail/unread",
265
+ "mail/compose",
266
+ "mail/filtered",
267
+ ]
268
+ );
269
+ assert.equal(
270
+ mailResources.some(
271
+ (resource) => resource.name === "mail-scenario-compose-anywhere"
272
+ ),
273
+ false,
274
+ "removes the standalone Send to anyone menu entry"
275
+ );
276
+
277
+ const candidates = getMailSenderCandidates([
278
+ {
279
+ id: 1,
280
+ type: "gmail",
281
+ email: "owner@example.com",
282
+ userId: 1,
283
+ settingId: 1,
284
+ identities: [
285
+ { id: 11, accountId: 1, userId: 1, email: "sales@example.com" },
286
+ { id: 12, accountId: 1, userId: 1, email: "owner@example.com" },
287
+ ],
288
+ },
289
+ {
290
+ id: 2,
291
+ type: "smtp",
292
+ email: "team@example.com",
293
+ userId: 1,
294
+ settingId: 2,
295
+ identities: [
296
+ { id: 21, accountId: 2, userId: 1, email: "sales@example.com" },
297
+ ],
298
+ },
299
+ ]);
300
+
301
+ assert.equal(
302
+ candidates.length,
303
+ 4,
304
+ "deduplicates identities within an account"
305
+ );
306
+ assert.equal(new Set(candidates.map((candidate) => candidate.key)).size, 4);
307
+ assert.deepEqual(
308
+ resolveMailSender(candidates, {
309
+ from: "sales@example.com",
310
+ accountEmail: "team@example.com",
311
+ }),
312
+ candidates[3],
313
+ "uses the owning account to disambiguate the same alias"
314
+ );
315
+ assert.equal(
316
+ resolveMailSender(candidates, { from: "owner@example.com" })?.accountId,
317
+ 1,
318
+ "supports legacy compose values that only contain from"
319
+ );
320
+
321
+ const sourceMessage = {
322
+ id: 1,
323
+ email: "team@example.com",
324
+ identityEmail: "sales@example.com",
325
+ mailId: "message-1",
326
+ rawId: "raw-1",
327
+ boxType: "in",
328
+ isRead: false,
329
+ isDraft: false,
330
+ from: "customer@example.com",
331
+ to: "sales@example.com, colleague@example.com",
332
+ toUsers: [
333
+ { address: "SALES@example.com" },
334
+ { address: "colleague@example.com" },
335
+ ],
336
+ cc: "observer@example.com",
337
+ ccUsers: [{ address: "observer@example.com" }],
338
+ subject: "Question",
339
+ date: "2026-07-27T10:00:00.000Z",
340
+ bodyText: "Hello",
341
+ bodyHtml: "<p>Hello</p>",
342
+ attachments: [],
343
+ };
344
+ assert.equal(
345
+ isMessageUnread({
346
+ ...sourceMessage,
347
+ isRead: true,
348
+ relatedMessagesIsRead: false,
349
+ }),
350
+ true,
351
+ "treats a conversation with unread related messages as unread"
352
+ );
353
+ assert.deepEqual(
354
+ collectMessageMailIds({
355
+ ...sourceMessage,
356
+ relatedMessageIds: ["message-2"],
357
+ children: [
358
+ { ...sourceMessage, id: 2, mailId: "message-2" },
359
+ { ...sourceMessage, id: 3, mailId: "message-3" },
360
+ ],
361
+ }),
362
+ ["message-1", "message-2", "message-3"],
363
+ "marks every provider message in the opened conversation"
364
+ );
365
+ assert.deepEqual(
366
+ markMessageRead({
367
+ ...sourceMessage,
368
+ relatedMessagesIsRead: false,
369
+ }),
370
+ {
371
+ ...sourceMessage,
372
+ isRead: true,
373
+ relatedMessagesIsRead: true,
374
+ },
375
+ "clears the aggregate unread flag together with the current message"
376
+ );
377
+ assert.deepEqual(
378
+ markMessageUnread(sourceMessage),
379
+ {
380
+ ...sourceMessage,
381
+ isRead: false,
382
+ relatedMessagesIsRead: false,
383
+ },
384
+ "sets both the message and conversation unread state"
385
+ );
386
+ const reply = buildComposeInitial(sourceMessage, "replyAll", [
387
+ "team@example.com",
388
+ "sales@example.com",
389
+ ]);
390
+
391
+ assert.equal(reply.accountEmail, "team@example.com");
392
+ assert.equal(reply.identityEmail, "sales@example.com");
393
+ assert.equal(reply.to, "customer@example.com, colleague@example.com");
394
+ assert.equal(reply.cc, "observer@example.com");
395
+ assert.equal(reply.reference?.from, "customer@example.com");
396
+ assert.equal(reply.reference?.subject, "Question");
397
+ assert.equal(reply.reference?.preview, "Hello");
398
+ assert.equal(
399
+ reply.body,
400
+ "",
401
+ "keeps the editable reply body separate from the quoted message"
402
+ );
403
+ assert.equal(reply.replyBody, "<p>Hello</p>");
404
+ const serializedReply = serializeReplyQuote(reply.body, reply.replyBody);
405
+ assert.match(
406
+ serializedReply,
407
+ /<div class="nocobase-quote nb-mail-quote" data-role="reply-quote">/,
408
+ "keeps the stable reply-quote marker while preserving the Portal style hook"
409
+ );
410
+ assert.match(
411
+ serializedReply,
412
+ /<blockquote type="cite"[^>]*><p>Hello<\/p><\/blockquote>/,
413
+ "uses semantic cited content for mail-client compatible replies"
414
+ );
415
+ assert.deepEqual(splitReplyQuote(`<p>Thanks</p>${serializedReply}`), {
416
+ body: "<p>Thanks</p>",
417
+ replyBody: "<p>Hello</p>",
418
+ });
419
+ assert.equal(
420
+ canReplyAll(sourceMessage, ["team@example.com", "sales@example.com"]),
421
+ true,
422
+ "shows Reply all when another recipient participates in the message"
423
+ );
424
+ assert.equal(
425
+ canReplyAll(
426
+ {
427
+ ...sourceMessage,
428
+ to: "sales@example.com",
429
+ toUsers: [{ address: "sales@example.com" }],
430
+ cc: "",
431
+ ccUsers: [],
432
+ },
433
+ ["team@example.com", "sales@example.com"]
434
+ ),
435
+ false,
436
+ "hides Reply all when replying would only target the sender"
437
+ );
438
+
439
+ const forward = buildComposeInitial(sourceMessage, "forward");
440
+ assert.equal(forward.reference?.from, "customer@example.com");
441
+ assert.match(forward.body || "", /Forwarded message/);
442
+
443
+ const rawAddressReply = buildComposeInitial(
444
+ { ...sourceMessage, toUsers: undefined, ccUsers: undefined },
445
+ "replyAll",
446
+ ["sales@example.com"]
447
+ );
448
+ assert.equal(
449
+ rawAddressReply.to,
450
+ "customer@example.com, colleague@example.com"
451
+ );
452
+ assert.equal(rawAddressReply.cc, "observer@example.com");
453
+
454
+ const draft = buildComposeInitial(
455
+ {
456
+ id: 7,
457
+ email: "team@example.com",
458
+ identityEmail: "sales@example.com",
459
+ mailId: "",
460
+ rawId: "",
461
+ boxType: "draft",
462
+ isRead: true,
463
+ isDraft: true,
464
+ from: "sales@example.com",
465
+ to: "customer@example.com",
466
+ cc: "",
467
+ bcc: "",
468
+ subject: "Draft subject",
469
+ date: "2026-07-27T10:00:00.000Z",
470
+ bodyText: "",
471
+ bodyHtml: "<p>Draft body</p>",
472
+ attachments: [
473
+ {
474
+ filename: "stored-file",
475
+ mimeType: "text/plain",
476
+ attachmentId: "draft.txt",
477
+ originalname: "draft.txt",
478
+ path: "/tmp/stored-file",
479
+ size: 12,
480
+ encoding: "7bit",
481
+ mimetype: "text/plain",
482
+ },
483
+ ],
484
+ },
485
+ "draft"
486
+ );
487
+ assert.equal(draft.id, 7);
488
+ assert.equal(draft.isDraft, true);
489
+ assert.equal(draft.attachments?.[0].path, "/tmp/stored-file");
490
+ assert.equal(draft.attachments?.[0].filename, "draft.txt");
491
+ assert.equal(draft.attachments?.[0].mimeType, "text/plain");
492
+ assert.throws(
493
+ () =>
494
+ buildComposeInitial(
495
+ {
496
+ ...draft,
497
+ id: 8,
498
+ isDraft: true,
499
+ mailId: "provider-draft",
500
+ rawId: "raw-draft",
501
+ },
502
+ "draft"
503
+ ),
504
+ /read-only/,
505
+ "provider-backed drafts must never enter the local draft mutation workflow"
506
+ );
507
+
508
+ const calls = [];
509
+ const originalAction = nocobaseClient.action.bind(nocobaseClient);
510
+ nocobaseClient.action = async (resource, action, options) => {
511
+ calls.push({ resource, action, options });
512
+ if (resource === "mailMassMessages" && action === "list") {
513
+ const page = options.query.page;
514
+ return {
515
+ data:
516
+ page === 1
517
+ ? Array.from({ length: 100 }, (_, index) => ({
518
+ id: index + 1,
519
+ status: "pending",
520
+ message: {},
521
+ to: `user${index + 1}@example.com`,
522
+ }))
523
+ : [
524
+ {
525
+ id: 101,
526
+ status: "pending",
527
+ message: {},
528
+ to: "user101@example.com",
529
+ },
530
+ ],
531
+ meta: { count: 101 },
532
+ };
533
+ }
534
+ if (resource === "mail" && action === "messageAttachmentUpload") {
535
+ return {
536
+ originalname: "draft.txt",
537
+ filename: "stored-file",
538
+ path: "/tmp/stored-file",
539
+ size: 12,
540
+ encoding: "7bit",
541
+ mimetype: "text/plain",
542
+ };
543
+ }
544
+ if (resource === "mail" && action === "oauth2url") {
545
+ if (!options.query?.type) throw new Error("cannot find type");
546
+ return { url: "https://mail.example.test/authorize" };
547
+ }
548
+ if (resource === "mailMessages" && action === "get") {
549
+ return { id: 42, isDraft: true, subject: "Recovered draft" };
550
+ }
551
+ if (resource === "mail" && action === "accountIdentitiesSync") {
552
+ return [
553
+ { id: 1, email: "team@example.com", isPrimary: true },
554
+ { id: 2, email: "sales@example.com", isPrimary: false },
555
+ ];
556
+ }
557
+ return {};
558
+ };
559
+
560
+ try {
561
+ await mailApi.cancelScheduled(7);
562
+ await mailApi.destroyMessages([7, 8]);
563
+ await mailApi.cancelMassMessage(9);
564
+ await mailApi.resendMassMessage(9);
565
+ const massList = await mailApi.listMassMessages(null);
566
+ const uploaded = await mailApi.uploadAttachment(new Blob(["draft"]));
567
+
568
+ assert.deepEqual(
569
+ calls.slice(0, 4).map(({ resource, action }) => [resource, action]),
570
+ [
571
+ ["mailMessages", "cancelTimelySend"],
572
+ ["mailMessages", "destroy"],
573
+ ["mailMassMessages", "cancel"],
574
+ ["mailMassMessages", "resend"],
575
+ ]
576
+ );
577
+ assert.deepEqual(calls[0].options.query, { id: 7 });
578
+ assert.deepEqual(calls[1].options.query, { filterByTk: [7, 8] });
579
+ assert.equal(massList.count, 101);
580
+ assert.equal(massList.rows.length, 101, "loads every bulk task page");
581
+ assert.equal(
582
+ calls.filter(
583
+ ({ resource, action }) =>
584
+ resource === "mailMassMessages" && action === "list"
585
+ ).length,
586
+ 2
587
+ );
588
+ assert.equal(uploaded.filename, "draft.txt");
589
+ assert.equal(uploaded.mimeType, "text/plain");
590
+ assert.ok(calls.at(-1).options.body instanceof FormData);
591
+
592
+ const oauthUrl = await mailApi.getOAuthUrl("google", {
593
+ email: "team@example.com",
594
+ reauthorize: true,
595
+ });
596
+ await mailApi.deleteAccount("team@example.com");
597
+ await mailApi.resyncAccount("team@example.com");
598
+ const recoveredDraft = await mailApi.findDraft({
599
+ accountEmail: "team@example.com",
600
+ identityEmail: "sales@example.com",
601
+ to: ["customer@example.com"],
602
+ });
603
+ const identities = await mailApi.syncAliases("team@example.com");
604
+ assert.equal(oauthUrl, "https://mail.example.test/authorize");
605
+ assert.equal(recoveredDraft?.id, 42);
606
+ assert.deepEqual(
607
+ identities.map((identity) => identity.email),
608
+ ["team@example.com", "sales@example.com"]
609
+ );
610
+ assert.deepEqual(
611
+ calls
612
+ .slice(-5)
613
+ .map(({ resource, action, options }) => [
614
+ resource,
615
+ action,
616
+ options.query ?? options.body,
617
+ ]),
618
+ [
619
+ [
620
+ "mail",
621
+ "oauth2url",
622
+ { type: "google", email: "team@example.com", reauthorize: true },
623
+ ],
624
+ ["mail", "deleteMailAccount", { email: "team@example.com" }],
625
+ ["mail", "messagesResync", { email: "team@example.com" }],
626
+ [
627
+ "mailMessages",
628
+ "get",
629
+ {
630
+ filter: JSON.stringify({
631
+ isDraft: true,
632
+ from: "sales@example.com",
633
+ email: "team@example.com",
634
+ to: ["customer@example.com"],
635
+ }),
636
+ appends: "labels,note",
637
+ },
638
+ ],
639
+ ["mail", "accountIdentitiesSync", { email: "team@example.com" }],
640
+ ]
641
+ );
642
+ } finally {
643
+ nocobaseClient.action = originalAction;
644
+ }
645
+
646
+ console.log("NocoBase mail regression tests passed");
647
+ } finally {
648
+ await server.close();
649
+ }