@k2b/cloud 0.27.0 → 0.29.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/package.json +7 -5
- package/src/_internal/cli-plugins.ts +18 -0
- package/src/_internal/define-app.ts +21 -5
- package/src/_internal/registry-validation.ts +30 -1
- package/src/_internal/runtime-context.ts +1 -0
- package/src/access/PermissionEditor.tsx +11 -4
- package/src/ai/assistant-models.ts +25 -4
- package/src/ai/audio-tool.ts +3 -10
- package/src/ai/browser-code-contracts.ts +33 -63
- package/src/ai/browser.ts +1 -0
- package/src/ai/capabilities.ts +35 -9
- package/src/ai/chat/blocks.tsx +113 -24
- package/src/ai/chat/builtin-tools.tsx +3 -1
- package/src/ai/chat/composer-adapter.ts +8 -0
- package/src/ai/chat/live-turn.browser-harness.tsx +15 -3
- package/src/ai/chat/message-actions.tsx +124 -102
- package/src/ai/chat/message-utils.ts +57 -6
- package/src/ai/chat/messages.ts +214 -0
- package/src/ai/chat/presentation.tsx +67 -28
- package/src/ai/chat/tool-groups.ts +1 -1
- package/src/ai/chat/tool-names.ts +157 -0
- package/src/ai/chat/turn-error.ts +21 -0
- package/src/ai/chat/turn-layout.ts +12 -0
- package/src/ai/chat/turn-view.tsx +69 -10
- package/src/ai/chat/user-message.tsx +19 -14
- package/src/ai/chat/visual-tools.tsx +1 -1
- package/src/ai/chat-tasks.ts +16 -3
- package/src/ai/client/controller.ts +23 -6
- package/src/ai/code-mode-skill.ts +21 -25
- package/src/ai/code-runtime-tools.ts +5 -1
- package/src/ai/code-source-contracts.ts +2 -2
- package/src/ai/compaction.ts +22 -3
- package/src/ai/data-analysis-skill.ts +3 -3
- package/src/ai/default-tools.ts +23 -17
- package/src/ai/executor.ts +262 -99
- package/src/ai/file-media-type.ts +4 -0
- package/src/ai/files-store.ts +17 -10
- package/src/ai/http.ts +11 -10
- package/src/ai/index.ts +3 -0
- package/src/ai/memories.ts +2 -0
- package/src/ai/memory-learning.ts +59 -5
- package/src/ai/memory-workflow-evidence.ts +35 -10
- package/src/ai/open-tool-calls.ts +87 -0
- package/src/ai/projects-routes.ts +5 -1
- package/src/ai/projects.ts +5 -1
- package/src/ai/protocol.ts +34 -1
- package/src/ai/provider.ts +16 -1
- package/src/ai/quota-provider.ts +4 -1
- package/src/ai/routes.ts +26 -10
- package/src/ai/run-timeout.ts +4 -5
- package/src/ai/runtime.ts +8 -0
- package/src/ai/settings.ts +54 -17
- package/src/ai/skill-seeds.ts +14 -6
- package/src/ai/store.ts +102 -34
- package/src/ai/structured.ts +2 -1
- package/src/ai/system-prompt.ts +9 -22
- package/src/ai/transcription.ts +58 -13
- package/src/ai/turn-failure.ts +104 -0
- package/src/ai/turn-guidance.ts +505 -0
- package/src/ai/turn-policy.ts +2 -1
- package/src/ai/types.ts +29 -1
- package/src/ai/ui.tsx +1 -0
- package/src/api/admin-core-settings.ts +16 -1
- package/src/api/admin-outgoing-mail.ts +185 -5
- package/src/api/me.ts +42 -1
- package/src/browser/FileChooser.tsx +82 -295
- package/src/browser/FileSaver.tsx +407 -0
- package/src/browser/app-badges.ts +16 -0
- package/src/browser/choose-files.browser-harness.tsx +22 -6
- package/src/browser/choose-files.tsx +23 -36
- package/src/browser/file-chooser-messages.ts +66 -0
- package/src/browser/file-providers.ts +258 -8
- package/src/browser/files.tsx +26 -3
- package/src/browser/notifications.ts +37 -1
- package/src/browser/provider-folder.tsx +301 -0
- package/src/browser/provider-list.ts +39 -0
- package/src/browser/save-files.browser-harness.tsx +35 -0
- package/src/browser/save-files.tsx +107 -0
- package/src/cli/admin/index.ts +3 -1
- package/src/cli/admin/notifications.ts +6 -0
- package/src/cli/admin/outgoing-mail.ts +104 -7
- package/src/contracts/app.ts +10 -0
- package/src/contracts/commands.ts +3 -1
- package/src/contracts/notification-group.ts +6 -0
- package/src/contracts/notification-types.ts +6 -0
- package/src/contracts/outgoing-mail.ts +189 -1
- package/src/contracts/registry.ts +1 -0
- package/src/contracts/user-notifications.ts +74 -0
- package/src/server/middleware/request-logger.ts +11 -2
- package/src/services/accounts/avatar.ts +5 -2
- package/src/services/help/store.ts +2 -1
- package/src/services/image-metadata.ts +438 -0
- package/src/services/index.ts +12 -1
- package/src/services/logging/redaction.ts +50 -0
- package/src/services/mandates/index.ts +4 -2
- package/src/services/mandates/policy.ts +20 -0
- package/src/services/notifications/batches.ts +139 -79
- package/src/services/notifications/browser.ts +48 -15
- package/src/services/notifications/channels.ts +33 -13
- package/src/services/notifications/dispatcher.ts +88 -6
- package/src/services/notifications/email-frame.fixture.html +51 -0
- package/src/services/notifications/{email.ts → email-frame.ts} +12 -35
- package/src/services/notifications/email-mail.ts +101 -0
- package/src/services/notifications/index.ts +49 -36
- package/src/services/notifications/observability.ts +9 -1
- package/src/services/notifications/platform.ts +33 -5
- package/src/services/notifications/preview.ts +23 -0
- package/src/services/notifications/quiet.ts +153 -0
- package/src/services/notifications/runtime.ts +9 -3
- package/src/services/notifications/user.ts +6 -0
- package/src/services/outgoing-mail/admin.ts +84 -0
- package/src/services/outgoing-mail/attachments.ts +135 -0
- package/src/services/outgoing-mail/bulk.ts +11 -0
- package/src/services/outgoing-mail/dispatcher.ts +231 -0
- package/src/services/outgoing-mail/drain.ts +60 -0
- package/src/services/outgoing-mail/enqueue.ts +180 -0
- package/src/services/outgoing-mail/index.ts +103 -10
- package/src/services/outgoing-mail/message.ts +14 -0
- package/src/services/outgoing-mail/messages.ts +431 -0
- package/src/services/outgoing-mail/retention.ts +48 -0
- package/src/services/outgoing-mail/runtime.ts +72 -0
- package/src/services/outgoing-mail/send.ts +130 -0
- package/src/services/outgoing-mail/store.ts +115 -4
- package/src/services/outgoing-mail/sync.ts +39 -0
- package/src/services/outgoing-mail/transport.ts +5 -1
- package/src/services/pdf/markdown.ts +22 -4
- package/src/services/pdf/template-preview.ts +2 -1
- package/src/services/postgres.ts +15 -0
- package/src/services/settings/app.ts +3 -0
- package/src/services/settings/core-settings.ts +18 -0
- package/src/services/settings/store.ts +4 -1
- package/src/services/weather/geo.ts +6 -2
- package/src/shared/ai-platform-prompt.ts +48 -4
- package/src/shared/markdown/extensions/links.ts +28 -20
- package/src/shared/markdown/index.ts +14 -5
- package/src/shared/markdown/shared.ts +0 -7
- package/src/shared/redirect.ts +2 -1
- package/src/shared/template-rendering.ts +120 -7
- package/src/ssr/AppBadge.tsx +30 -0
- package/src/ssr/AppLaunchpadPanel.tsx +47 -26
- package/src/ssr/GlobalAnnouncements.island.tsx +1 -1
- package/src/ssr/IslandError.tsx +33 -0
- package/src/ssr/Layout.tsx +3 -0
- package/src/ssr/LayoutHelp.tsx +4 -2
- package/src/ssr/RailApps.island.tsx +9 -2
- package/src/ssr/app-badges.ts +93 -0
- package/src/ssr/platform-messages.ts +1 -1
- package/src/ssr/rail-messages.ts +4 -0
- package/src/ssr/rail-navigation.ts +2 -0
- package/src/styles/effects.css +38 -17
- package/src/styles/file-chooser.css +25 -0
- package/src/styles/tokens.css +2 -0
- package/src/styles/utilities-markdown-editor.css +4 -39
- package/src/styles/utilities-markdown-table.css +14 -17
- package/src/styles/utilities-navigation.css +41 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { timed } from "@k2b/stdlib/solid";
|
|
2
|
+
import { announce, Button, IconButton, Placeholder, TextInput } from "@k2b/ui";
|
|
3
|
+
import { batch, createMemo, createSignal, For, type JSX, Match, onCleanup, Show, Switch } from "solid-js";
|
|
4
|
+
import type { FileProviderEntry } from "../contracts/file-provider";
|
|
5
|
+
import type { fileChooserMessages } from "./file-chooser-messages";
|
|
6
|
+
import { type FileProviderCaller, FileProviderError, type FileProviderSource, listProviderFolder } from "./file-providers";
|
|
7
|
+
|
|
8
|
+
type Messages = ReturnType<typeof fileChooserMessages.resolve>["t"];
|
|
9
|
+
type Crumb = { id?: string; name: string };
|
|
10
|
+
/** Where the dialog is: a folder of one provider, or the source list when `null`. */
|
|
11
|
+
export type FolderLocation = { provider: FileProviderSource; trail: readonly Crumb[] } | null;
|
|
12
|
+
|
|
13
|
+
/** Empty pages may still continue; a dialog follows at most this many of them before it shows "Load more". */
|
|
14
|
+
const EMPTY_PAGES_FOLLOWED = 5;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Browsing one provider's folders, shared by choosing and saving: the location, the name filter, the folder's pages,
|
|
18
|
+
* and its load state. `settled` runs after a folder has loaded or the source list is back, so the dialog can move
|
|
19
|
+
* focus to its first row.
|
|
20
|
+
*/
|
|
21
|
+
export const createProviderFolder = (props: { caller: () => FileProviderCaller; t: () => Messages; settled: () => void }) => {
|
|
22
|
+
const [location, setLocation] = createSignal<FolderLocation>(null);
|
|
23
|
+
const [filter, setFilter] = createSignal("");
|
|
24
|
+
const [query, setQuery] = createSignal("");
|
|
25
|
+
const [items, setItems] = createSignal<FileProviderEntry[]>([]);
|
|
26
|
+
const [next, setNext] = createSignal<string | null>(null);
|
|
27
|
+
const [writable, setWritable] = createSignal(false);
|
|
28
|
+
const [status, setStatus] = createSignal<"loading" | "ready" | "more" | "error">("ready");
|
|
29
|
+
const [error, setError] = createSignal<FileProviderError | null>(null);
|
|
30
|
+
let request: AbortController | undefined;
|
|
31
|
+
onCleanup(() => request?.abort());
|
|
32
|
+
|
|
33
|
+
const folderName = () => location()?.trail.at(-1)?.name ?? props.t().sources;
|
|
34
|
+
|
|
35
|
+
const load = async (more = false) => {
|
|
36
|
+
const current = location();
|
|
37
|
+
if (!current) return;
|
|
38
|
+
request?.abort();
|
|
39
|
+
const pending = new AbortController();
|
|
40
|
+
request = pending;
|
|
41
|
+
if (!more) {
|
|
42
|
+
batch(() => {
|
|
43
|
+
setItems([]);
|
|
44
|
+
setNext(null);
|
|
45
|
+
setWritable(false);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
setStatus(more ? "more" : "loading");
|
|
49
|
+
setError(null);
|
|
50
|
+
try {
|
|
51
|
+
let cursor = more ? (next() ?? undefined) : undefined;
|
|
52
|
+
let followed = 0;
|
|
53
|
+
for (;;) {
|
|
54
|
+
const page = await listProviderFolder(
|
|
55
|
+
current.provider,
|
|
56
|
+
{ parent: current.trail.at(-1)?.id, query: query() || undefined, cursor },
|
|
57
|
+
props.caller(),
|
|
58
|
+
pending.signal,
|
|
59
|
+
);
|
|
60
|
+
if (pending.signal.aborted) return;
|
|
61
|
+
batch(() => {
|
|
62
|
+
setItems((previous) => [...previous, ...page.items]);
|
|
63
|
+
setNext(page.next);
|
|
64
|
+
if (!more) setWritable(page.writable);
|
|
65
|
+
});
|
|
66
|
+
if (items().length > 0 || !page.next || ++followed >= EMPTY_PAGES_FOLLOWED) break;
|
|
67
|
+
cursor = page.next;
|
|
68
|
+
}
|
|
69
|
+
setStatus("ready");
|
|
70
|
+
if (!more) {
|
|
71
|
+
announce(props.t().folderAnnounced({ folder: folderName(), count: items().length, more: Boolean(next()) }));
|
|
72
|
+
props.settled();
|
|
73
|
+
}
|
|
74
|
+
} catch (cause) {
|
|
75
|
+
if (pending.signal.aborted) return;
|
|
76
|
+
setError(
|
|
77
|
+
cause instanceof FileProviderError ? cause : new FileProviderError("INTERNAL", cause instanceof Error ? cause.message : "", 500),
|
|
78
|
+
);
|
|
79
|
+
setStatus(more ? "ready" : "error");
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const applyQuery = (value: string) => {
|
|
84
|
+
setQuery(value.trim());
|
|
85
|
+
void load();
|
|
86
|
+
};
|
|
87
|
+
const { debouncedFn: applyFilter, cancel: cancelFilter } = timed.debounce(applyQuery, 250);
|
|
88
|
+
onCleanup(cancelFilter);
|
|
89
|
+
const clearFilter = () => {
|
|
90
|
+
cancelFilter();
|
|
91
|
+
setFilter("");
|
|
92
|
+
if (location() && query()) applyQuery("");
|
|
93
|
+
};
|
|
94
|
+
const changeFilter = (value: string) => {
|
|
95
|
+
setFilter(value);
|
|
96
|
+
if (location()) applyFilter(value);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const navigate = (target: FolderLocation) => {
|
|
100
|
+
cancelFilter();
|
|
101
|
+
request?.abort();
|
|
102
|
+
batch(() => {
|
|
103
|
+
setFilter("");
|
|
104
|
+
setQuery("");
|
|
105
|
+
setLocation(target);
|
|
106
|
+
setItems([]);
|
|
107
|
+
setNext(null);
|
|
108
|
+
setWritable(false);
|
|
109
|
+
setError(null);
|
|
110
|
+
setStatus(target ? "loading" : "ready");
|
|
111
|
+
});
|
|
112
|
+
if (target) void load();
|
|
113
|
+
else props.settled();
|
|
114
|
+
};
|
|
115
|
+
const openProvider = (provider: FileProviderSource) => navigate({ provider, trail: [{ name: provider.name }] });
|
|
116
|
+
const openFolder = (entry: FileProviderEntry) => {
|
|
117
|
+
const current = location();
|
|
118
|
+
if (current) navigate({ provider: current.provider, trail: [...current.trail, { id: entry.id, name: entry.name }] });
|
|
119
|
+
};
|
|
120
|
+
const up = () => {
|
|
121
|
+
const current = location();
|
|
122
|
+
if (!current) return;
|
|
123
|
+
navigate(current.trail.length > 1 ? { provider: current.provider, trail: current.trail.slice(0, -1) } : null);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const errorView = createMemo(() => {
|
|
127
|
+
const value = error();
|
|
128
|
+
if (!value) return null;
|
|
129
|
+
const t = props.t();
|
|
130
|
+
const app = location()?.provider.name ?? "";
|
|
131
|
+
if (value.status === 0) return { title: t.offline, description: t.offlineHint, retry: true };
|
|
132
|
+
if (value.status === 403) return { title: t.noAccess, description: t.backHint, retry: false };
|
|
133
|
+
if (value.status === 404) return { title: t.notFound, description: t.backHint, retry: false };
|
|
134
|
+
if (value.status >= 502 || value.code === "APP_UNAVAILABLE")
|
|
135
|
+
return { title: t.unavailable({ app }), description: t.unavailableHint, retry: true };
|
|
136
|
+
return { title: t.loadFailed, description: value.message || undefined, retry: true };
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
location,
|
|
141
|
+
filter,
|
|
142
|
+
query,
|
|
143
|
+
items,
|
|
144
|
+
next,
|
|
145
|
+
writable,
|
|
146
|
+
status,
|
|
147
|
+
error,
|
|
148
|
+
errorView,
|
|
149
|
+
folderName,
|
|
150
|
+
load,
|
|
151
|
+
navigate,
|
|
152
|
+
openProvider,
|
|
153
|
+
openFolder,
|
|
154
|
+
up,
|
|
155
|
+
changeFilter,
|
|
156
|
+
clearFilter,
|
|
157
|
+
};
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
export type ProviderFolder = ReturnType<typeof createProviderFolder>;
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The path row with Up and the name filter. With `sources`, the path starts at the source list; without it, the
|
|
164
|
+
* provider is the top, so Up stops there.
|
|
165
|
+
*/
|
|
166
|
+
export function FolderToolbar(props: { folder: ProviderFolder; t: () => Messages; disabled: boolean; sources: boolean }): JSX.Element {
|
|
167
|
+
const top = () => {
|
|
168
|
+
const current = props.folder.location();
|
|
169
|
+
return !current || (!props.sources && current.trail.length === 1);
|
|
170
|
+
};
|
|
171
|
+
return (
|
|
172
|
+
<div class="cloud-file-chooser__toolbar">
|
|
173
|
+
<nav class="cloud-file-chooser__crumbs" aria-label={props.t().breadcrumbs}>
|
|
174
|
+
<IconButton size="sm" variant="ghost" label={props.t().up} disabled={top() || props.disabled} onClick={props.folder.up}>
|
|
175
|
+
<i class="ti ti-arrow-up" aria-hidden="true" />
|
|
176
|
+
</IconButton>
|
|
177
|
+
<ol>
|
|
178
|
+
<Show when={props.sources}>
|
|
179
|
+
<li>
|
|
180
|
+
<Button
|
|
181
|
+
size="sm"
|
|
182
|
+
variant={props.folder.location() ? "text" : "subtle"}
|
|
183
|
+
aria-current={props.folder.location() ? undefined : "location"}
|
|
184
|
+
disabled={props.disabled}
|
|
185
|
+
onClick={() => props.folder.navigate(null)}
|
|
186
|
+
>
|
|
187
|
+
{props.t().sources}
|
|
188
|
+
</Button>
|
|
189
|
+
</li>
|
|
190
|
+
</Show>
|
|
191
|
+
<For each={props.folder.location()?.trail ?? []}>
|
|
192
|
+
{(crumb, index) => {
|
|
193
|
+
const last = () => index() === (props.folder.location()?.trail.length ?? 0) - 1;
|
|
194
|
+
return (
|
|
195
|
+
<li>
|
|
196
|
+
<Show when={props.sources || index() > 0}>
|
|
197
|
+
<span aria-hidden="true">/</span>
|
|
198
|
+
</Show>
|
|
199
|
+
<Button
|
|
200
|
+
size="sm"
|
|
201
|
+
variant={last() ? "subtle" : "text"}
|
|
202
|
+
aria-current={last() ? "location" : undefined}
|
|
203
|
+
disabled={props.disabled}
|
|
204
|
+
onClick={() => {
|
|
205
|
+
const current = props.folder.location();
|
|
206
|
+
if (current && !last())
|
|
207
|
+
props.folder.navigate({ provider: current.provider, trail: current.trail.slice(0, index() + 1) });
|
|
208
|
+
}}
|
|
209
|
+
>
|
|
210
|
+
{crumb.name}
|
|
211
|
+
</Button>
|
|
212
|
+
</li>
|
|
213
|
+
);
|
|
214
|
+
}}
|
|
215
|
+
</For>
|
|
216
|
+
</ol>
|
|
217
|
+
</nav>
|
|
218
|
+
<TextInput
|
|
219
|
+
class="cloud-file-chooser__filter"
|
|
220
|
+
type="search"
|
|
221
|
+
icon="ti ti-filter"
|
|
222
|
+
aria-label={props.t().filter}
|
|
223
|
+
placeholder={props.t().filter}
|
|
224
|
+
maxLength={200}
|
|
225
|
+
disabled={props.disabled}
|
|
226
|
+
value={props.folder.filter}
|
|
227
|
+
onValueChange={props.folder.changeFilter}
|
|
228
|
+
clearable
|
|
229
|
+
onClear={props.folder.clearFilter}
|
|
230
|
+
/>
|
|
231
|
+
</div>
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* One folder in the dialog body: loading, the error with Try again or Up, the empty folder, no matches, or `children`
|
|
237
|
+
* with Load more below while the folder continues.
|
|
238
|
+
*/
|
|
239
|
+
export function FolderContents(props: { folder: ProviderFolder; t: () => Messages; children: JSX.Element }): JSX.Element {
|
|
240
|
+
const folder = props.folder;
|
|
241
|
+
return (
|
|
242
|
+
<Switch>
|
|
243
|
+
<Match when={folder.status() === "loading"}>
|
|
244
|
+
<Placeholder state="loading" variant="panel" class="cloud-file-chooser__state" description={props.t().loading} />
|
|
245
|
+
</Match>
|
|
246
|
+
<Match when={folder.status() === "error" && folder.errorView()}>
|
|
247
|
+
{(view) => (
|
|
248
|
+
<Placeholder
|
|
249
|
+
state="error"
|
|
250
|
+
variant="panel"
|
|
251
|
+
class="cloud-file-chooser__state"
|
|
252
|
+
title={view().title}
|
|
253
|
+
description={view().description}
|
|
254
|
+
action={
|
|
255
|
+
view().retry ? (
|
|
256
|
+
<Button size="sm" variant="secondary" onClick={() => void folder.load()}>
|
|
257
|
+
{props.t().retry}
|
|
258
|
+
</Button>
|
|
259
|
+
) : (
|
|
260
|
+
<Button size="sm" variant="secondary" onClick={folder.up}>
|
|
261
|
+
<i class="ti ti-arrow-up" aria-hidden="true" /> {props.t().up}
|
|
262
|
+
</Button>
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
/>
|
|
266
|
+
)}
|
|
267
|
+
</Match>
|
|
268
|
+
<Match when={folder.items().length === 0 && !folder.next()}>
|
|
269
|
+
<Show
|
|
270
|
+
when={folder.query()}
|
|
271
|
+
fallback={<Placeholder variant="panel" class="cloud-file-chooser__state" icon="ti ti-folder" title={props.t().emptyFolder} />}
|
|
272
|
+
>
|
|
273
|
+
<Placeholder
|
|
274
|
+
variant="panel"
|
|
275
|
+
class="cloud-file-chooser__state"
|
|
276
|
+
icon="ti ti-filter-off"
|
|
277
|
+
title={props.t().noMatches({ query: folder.query() })}
|
|
278
|
+
action={
|
|
279
|
+
<Button size="sm" variant="secondary" onClick={folder.clearFilter}>
|
|
280
|
+
{props.t().clearFilter}
|
|
281
|
+
</Button>
|
|
282
|
+
}
|
|
283
|
+
/>
|
|
284
|
+
</Show>
|
|
285
|
+
</Match>
|
|
286
|
+
<Match when={true}>
|
|
287
|
+
{props.children}
|
|
288
|
+
<div class="cloud-file-chooser__more">
|
|
289
|
+
<Show when={folder.error() && folder.status() === "ready"}>
|
|
290
|
+
<Placeholder state="error" variant="inline" align="left" description={folder.errorView()?.title} />
|
|
291
|
+
</Show>
|
|
292
|
+
<Show when={folder.next()}>
|
|
293
|
+
<Button variant="secondary" size="sm" loading={folder.status() === "more"} onClick={() => void folder.load(true)}>
|
|
294
|
+
{folder.error() ? props.t().retry : props.t().loadMore}
|
|
295
|
+
</Button>
|
|
296
|
+
</Show>
|
|
297
|
+
</div>
|
|
298
|
+
</Match>
|
|
299
|
+
</Switch>
|
|
300
|
+
);
|
|
301
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { createSignal } from "solid-js";
|
|
2
|
+
import { FileProviderError, type FileProviderSource } from "./file-providers";
|
|
3
|
+
|
|
4
|
+
/** The catalog refuses the caller, such as a visitor of a public page or a session that has just expired. */
|
|
5
|
+
const refused = (error: unknown) => error instanceof FileProviderError && (error.status === 401 || error.status === 403);
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* One page's file providers, shared by choosing and saving: the list is loaded once and kept; a failed load is tried
|
|
9
|
+
* again on the next request. A caller the catalog refuses has no providers for now, and the next request asks again,
|
|
10
|
+
* because a renewed session may see them. `known` is the list once it arrived, and reactive.
|
|
11
|
+
*/
|
|
12
|
+
export const createProviderList = (loadProviders: () => Promise<FileProviderSource[]>) => {
|
|
13
|
+
let request: Promise<readonly FileProviderSource[]> | undefined;
|
|
14
|
+
const [known, setKnown] = createSignal<readonly FileProviderSource[] | undefined>(undefined);
|
|
15
|
+
const providers = (): Promise<readonly FileProviderSource[]> => {
|
|
16
|
+
request ??= loadProviders().then(
|
|
17
|
+
(list) => {
|
|
18
|
+
setKnown(list);
|
|
19
|
+
return list;
|
|
20
|
+
},
|
|
21
|
+
(error: unknown) => {
|
|
22
|
+
request = undefined;
|
|
23
|
+
if (!refused(error)) throw error;
|
|
24
|
+
setKnown([]);
|
|
25
|
+
return [];
|
|
26
|
+
},
|
|
27
|
+
);
|
|
28
|
+
return request;
|
|
29
|
+
};
|
|
30
|
+
return {
|
|
31
|
+
providers,
|
|
32
|
+
known,
|
|
33
|
+
/** Whether a load is running or kept; only a refusal or a failure leaves none behind. */
|
|
34
|
+
requested: () => request !== undefined,
|
|
35
|
+
prefetch: () => void providers().catch(() => undefined),
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type ProviderList = ReturnType<typeof createProviderList>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { render } from "solid-js/web";
|
|
2
|
+
import { SaveFilesButton, saveFiles } from "./files";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Buttons that call the real `saveFiles` for a browser test, which answers the catalog, the provider's Queries and
|
|
6
|
+
* Action, and its write streams. `#save` saves one attachment by URL, `#save-all` two, `#save-big` one above the
|
|
7
|
+
* provider's limit. Each call gets a fresh signal that `window.abortSaving()` aborts. Each result lands in
|
|
8
|
+
* `window.saved`. `SaveFilesButton` renders the shared icon button for the same attachment.
|
|
9
|
+
*/
|
|
10
|
+
type Saved = Awaited<ReturnType<typeof saveFiles>>;
|
|
11
|
+
const results: Saved[] = [];
|
|
12
|
+
let saving = new AbortController();
|
|
13
|
+
Object.assign(window, { saved: results, abortSaving: () => saving.abort() });
|
|
14
|
+
|
|
15
|
+
const attachment = (name: string) => ({ name, content: `/source/${encodeURIComponent(name)}` });
|
|
16
|
+
const sets: Record<string, Parameters<typeof saveFiles>[0]> = {
|
|
17
|
+
save: [attachment("report.pdf")],
|
|
18
|
+
"save-all": [attachment("report.pdf"), attachment("notes.txt")],
|
|
19
|
+
"save-big": [{ ...attachment("huge.zip"), size: 5_000 }],
|
|
20
|
+
};
|
|
21
|
+
for (const [id, files] of Object.entries(sets)) {
|
|
22
|
+
const button = document.createElement("button");
|
|
23
|
+
button.type = "button";
|
|
24
|
+
button.id = id;
|
|
25
|
+
button.textContent = id;
|
|
26
|
+
document.body.append(button);
|
|
27
|
+
button.addEventListener("click", () => {
|
|
28
|
+
saving = new AbortController();
|
|
29
|
+
void saveFiles(files, { signal: saving.signal }).then((saved) => void results.push(saved));
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
const icon = document.createElement("div");
|
|
33
|
+
icon.id = "icon";
|
|
34
|
+
document.body.append(icon);
|
|
35
|
+
render(() => <SaveFilesButton files={() => sets.save!} />, icon);
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { dialogCore, IconButton, type IconButtonProps, panelDialogWorkspaceOptions, toast, useLocale } from "@k2b/ui";
|
|
2
|
+
import { createSignal, type JSX, splitProps } from "solid-js";
|
|
3
|
+
import type { FileProviderList } from "./FileChooser";
|
|
4
|
+
import { FileSaver, type SavedFile } from "./FileSaver";
|
|
5
|
+
import { fileChooserMessages } from "./file-chooser-messages";
|
|
6
|
+
import type { FileProviderCaller, SaveFileSource } from "./file-providers";
|
|
7
|
+
import type { ProviderList } from "./provider-list";
|
|
8
|
+
|
|
9
|
+
export type { SavedFile } from "./FileSaver";
|
|
10
|
+
export type { SaveFileSource } from "./file-providers";
|
|
11
|
+
|
|
12
|
+
export type SaveFilesOptions = {
|
|
13
|
+
/** Closes the dialog and stops what is still running; files saved before stay saved. */
|
|
14
|
+
signal?: AbortSignal;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** The icon of the save action, for menus that show it beside other actions. */
|
|
18
|
+
export const SAVE_FILES_ICON = "ti ti-folder-down";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* One page's saving into file providers, over the same provider list as choosing. `caller` carries the locale and,
|
|
22
|
+
* in tests, the transport.
|
|
23
|
+
*/
|
|
24
|
+
export const createFileSaving = (
|
|
25
|
+
list: ProviderList,
|
|
26
|
+
caller: () => FileProviderCaller = () => ({ locale: document.documentElement.lang || "en" }),
|
|
27
|
+
) => {
|
|
28
|
+
const saveFiles = async (files: readonly SaveFileSource[], options: SaveFilesOptions = {}): Promise<SavedFile[]> => {
|
|
29
|
+
if (files.length === 0 || options.signal?.aborted) return [];
|
|
30
|
+
// Only a refusal leaves an empty list without a request; asking again lets a renewed session find its apps.
|
|
31
|
+
const known = list.known()?.length === 0 && !list.requested() ? undefined : list.known();
|
|
32
|
+
const [providers, setProviders] = createSignal<FileProviderList>(known ? { state: "ready", providers: known } : { state: "loading" });
|
|
33
|
+
const load = () => {
|
|
34
|
+
setProviders({ state: "loading" });
|
|
35
|
+
list.providers().then(
|
|
36
|
+
(value) => setProviders({ state: "ready", providers: value }),
|
|
37
|
+
() => setProviders({ state: "error" }),
|
|
38
|
+
);
|
|
39
|
+
};
|
|
40
|
+
if (!known) load();
|
|
41
|
+
const saved: SavedFile[] = [];
|
|
42
|
+
const running = new Set<Promise<void>>();
|
|
43
|
+
await dialogCore.open<void>(
|
|
44
|
+
(close) => (
|
|
45
|
+
<FileSaver
|
|
46
|
+
files={files}
|
|
47
|
+
providers={providers}
|
|
48
|
+
retryProviders={load}
|
|
49
|
+
caller={caller}
|
|
50
|
+
saved={saved}
|
|
51
|
+
running={running}
|
|
52
|
+
close={() => close()}
|
|
53
|
+
/>
|
|
54
|
+
),
|
|
55
|
+
{
|
|
56
|
+
...panelDialogWorkspaceOptions,
|
|
57
|
+
panelClassName: `${panelDialogWorkspaceOptions.panelClassName} cloud-file-chooser-dialog`,
|
|
58
|
+
// While the folder still loads, the dialog itself holds focus; its first row takes it once it arrives.
|
|
59
|
+
initialFocus: (dialog) => dialog.querySelector<HTMLElement>('[role="gridcell"][tabindex="0"]') ?? dialog,
|
|
60
|
+
signal: options.signal,
|
|
61
|
+
history: true,
|
|
62
|
+
},
|
|
63
|
+
);
|
|
64
|
+
// Closing stops the transfers; a receipt that was already on its way still counts.
|
|
65
|
+
await Promise.all(running);
|
|
66
|
+
const first = saved[0];
|
|
67
|
+
if (first) {
|
|
68
|
+
const t = fileChooserMessages.resolve([caller().locale]).t;
|
|
69
|
+
toast.success(
|
|
70
|
+
saved.length === 1 && files.length === 1
|
|
71
|
+
? t.savedOne({ name: first.name, app: first.app })
|
|
72
|
+
: t.savedMany({ count: saved.length, total: files.length, app: first.app }),
|
|
73
|
+
first.href ? { action: { label: t.showIn({ app: first.app }), href: first.href } } : undefined,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
return saved;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The save action's label: "Save to Files" once one provider that stores files is known, otherwise "Save to…".
|
|
81
|
+
* With `name` it names one file; with `all` it saves every file of a list.
|
|
82
|
+
*/
|
|
83
|
+
const label = (locale: string, options: { name?: string; all?: boolean } = {}): string => {
|
|
84
|
+
const t = fileChooserMessages.resolve([locale]).t;
|
|
85
|
+
const savers = list.known()?.filter((provider) => provider.save) ?? [];
|
|
86
|
+
const app = savers.length === 1 ? savers[0]!.name : undefined;
|
|
87
|
+
if (options.all) return app ? t.saveAllTo({ app }) : t.saveAllToAny;
|
|
88
|
+
if (options.name) return app ? t.saveNamedTo({ name: options.name, app }) : t.saveNamedToAny({ name: options.name });
|
|
89
|
+
return app ? t.saveTo({ app }) : t.saveToAny;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/** An icon button that saves `files()` when activated; its label and tooltip name the file or say "all". */
|
|
93
|
+
function SaveFilesButton(
|
|
94
|
+
props: Omit<IconButtonProps, "children" | "label" | "onClick"> & { files: () => readonly SaveFileSource[]; all?: boolean },
|
|
95
|
+
): JSX.Element {
|
|
96
|
+
const locale = useLocale();
|
|
97
|
+
const [local, rest] = splitProps(props, ["files", "all"]);
|
|
98
|
+
const name = () => (local.all ? undefined : local.files()[0]?.name);
|
|
99
|
+
return (
|
|
100
|
+
<IconButton {...rest} label={label(locale(), { name: name(), all: local.all })} onClick={() => void saveFiles(local.files())}>
|
|
101
|
+
<i class={SAVE_FILES_ICON} aria-hidden="true" />
|
|
102
|
+
</IconButton>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return { saveFiles, label, SaveFilesButton };
|
|
107
|
+
};
|
package/src/cli/admin/index.ts
CHANGED
|
@@ -57,9 +57,11 @@ export default defineCliCommands({
|
|
|
57
57
|
"nats consumers": "Inspect NATS consumers.",
|
|
58
58
|
"nats streams": "Find NATS streams and buckets.",
|
|
59
59
|
"notification-batches": "Manage account notification batches.",
|
|
60
|
-
"outgoing-mail": "Manage outgoing mail sender profiles and
|
|
60
|
+
"outgoing-mail": "Manage outgoing mail sender profiles, application access, and the send log.",
|
|
61
61
|
"outgoing-mail profiles": "Manage outgoing mail sender profiles.",
|
|
62
62
|
"outgoing-mail apps": "Manage application outgoing mail access.",
|
|
63
|
+
"outgoing-mail log": "Read and cancel application mail in the send log.",
|
|
64
|
+
"outgoing-mail retention": "Show or change how long the send log keeps mail.",
|
|
63
65
|
notifications: "Inspect and resend email notifications.",
|
|
64
66
|
postgres: "Inspect Postgres diagnostics.",
|
|
65
67
|
redis: "Inspect Valkey diagnostics.",
|
|
@@ -54,6 +54,8 @@ export type NotificationBatchRecipient = {
|
|
|
54
54
|
profile: "user" | "guest";
|
|
55
55
|
status: NotificationRecipientStatus;
|
|
56
56
|
notificationId: string | null;
|
|
57
|
+
outgoingMailId: string | null;
|
|
58
|
+
outgoingMailStatus: string | null;
|
|
57
59
|
error: string | null;
|
|
58
60
|
attemptCount: number;
|
|
59
61
|
sentAt: string | null;
|
|
@@ -370,6 +372,8 @@ export const notificationCommands = [
|
|
|
370
372
|
provider: recipient.provider,
|
|
371
373
|
profile: recipient.profile,
|
|
372
374
|
attempts: recipient.attemptCount,
|
|
375
|
+
mailId: recipient.outgoingMailId ?? "",
|
|
376
|
+
mailStatus: recipient.outgoingMailStatus ?? "",
|
|
373
377
|
error: truncate(recipient.error, 60),
|
|
374
378
|
userId: recipient.userId,
|
|
375
379
|
}));
|
|
@@ -381,6 +385,8 @@ export const notificationCommands = [
|
|
|
381
385
|
{ key: "provider" },
|
|
382
386
|
{ key: "profile" },
|
|
383
387
|
{ key: "attempts" },
|
|
388
|
+
{ key: "mailId" },
|
|
389
|
+
{ key: "mailStatus" },
|
|
384
390
|
{ key: "error" },
|
|
385
391
|
{ key: "userId" },
|
|
386
392
|
]);
|
|
@@ -1,5 +1,13 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
type AdminMailApp,
|
|
3
|
+
type AdminMailProfile,
|
|
4
|
+
type AdminMailRecord,
|
|
5
|
+
type MailPage,
|
|
6
|
+
MailProfileInputSchema,
|
|
7
|
+
type MailRetention,
|
|
8
|
+
MailRetentionSchema,
|
|
9
|
+
} from "../../contracts/outgoing-mail";
|
|
10
|
+
import { arg, type CloudCliContext, command, confirmFlag, flag, printStructured, readCliInput } from "../index";
|
|
3
11
|
import { apiGet, apiJson, printJsonOrTable } from "./shared";
|
|
4
12
|
|
|
5
13
|
const root = "/api/admin/core/outgoing-mail";
|
|
@@ -13,6 +21,78 @@ const confirmed = (yes: boolean) => {
|
|
|
13
21
|
if (!yes) throw new Error("Outgoing mail policy changes require --yes.");
|
|
14
22
|
};
|
|
15
23
|
export const outgoingMailCommands = [
|
|
24
|
+
command("outgoing-mail retention show", {
|
|
25
|
+
summary: "Show outgoing mail retention in days",
|
|
26
|
+
async run({ ctx }) {
|
|
27
|
+
const result = await apiGet<MailRetention>(ctx, `${root}/retention`);
|
|
28
|
+
if (!printStructured(ctx, result)) print(ctx, result);
|
|
29
|
+
},
|
|
30
|
+
}),
|
|
31
|
+
command("outgoing-mail retention set", {
|
|
32
|
+
summary: "Set outgoing mail content and record retention",
|
|
33
|
+
flags: {
|
|
34
|
+
contentDays: flag.string({ required: true, description: "Content retention in days" }),
|
|
35
|
+
recordDays: flag.string({ required: true, description: "Record retention in days (at least content retention)" }),
|
|
36
|
+
yes: confirmFlag("Confirm changing outgoing mail retention"),
|
|
37
|
+
},
|
|
38
|
+
async run({ ctx, flags }) {
|
|
39
|
+
confirmed(flags.yes);
|
|
40
|
+
const retention = MailRetentionSchema.parse({ contentDays: Number(flags.contentDays), recordDays: Number(flags.recordDays) });
|
|
41
|
+
if (retention.recordDays < retention.contentDays) throw new Error("Record retention must be at least content retention.");
|
|
42
|
+
const result = await apiJson<MailRetention>(ctx, "PUT", `${root}/retention`, retention);
|
|
43
|
+
if (!printStructured(ctx, result)) print(ctx, result);
|
|
44
|
+
},
|
|
45
|
+
}),
|
|
46
|
+
command("outgoing-mail log list", {
|
|
47
|
+
summary: "List outgoing mail metadata",
|
|
48
|
+
flags: {
|
|
49
|
+
app: flag.string({ description: "Application ID" }),
|
|
50
|
+
profile: flag.string({ description: "Sender profile key" }),
|
|
51
|
+
status: flag.string({ description: "Comma-separated statuses" }),
|
|
52
|
+
since: flag.string({ description: "ISO timestamp" }),
|
|
53
|
+
ref: flag.string({ description: "Reference scope[:id]" }),
|
|
54
|
+
recipient: flag.string({ description: "Recipient substring" }),
|
|
55
|
+
cursor: flag.string({ description: "Next-page cursor" }),
|
|
56
|
+
limit: flag.int({ description: "Page size (1–100)" }),
|
|
57
|
+
},
|
|
58
|
+
async run({ ctx, flags }) {
|
|
59
|
+
if (flags.limit !== undefined && (!Number.isInteger(flags.limit) || flags.limit < 1 || flags.limit > 100))
|
|
60
|
+
throw new Error("--limit must be a whole number from 1 to 100.");
|
|
61
|
+
const query = new URLSearchParams();
|
|
62
|
+
for (const [key, value] of Object.entries(flags)) if (value !== undefined) query.set(key, String(value));
|
|
63
|
+
const result = await apiGet<MailPage<AdminMailRecord>>(ctx, `${root}/messages${query.size ? `?${query}` : ""}`);
|
|
64
|
+
printJsonOrTable(ctx, result, result.items, [
|
|
65
|
+
{ key: "id" },
|
|
66
|
+
{ key: "appId" },
|
|
67
|
+
{ key: "profile" },
|
|
68
|
+
{ key: "status" },
|
|
69
|
+
{ key: "createdAt" },
|
|
70
|
+
]);
|
|
71
|
+
if (ctx.options.output === "text" && result.nextCursor) ctx.print(`Next cursor: ${result.nextCursor}`);
|
|
72
|
+
},
|
|
73
|
+
}),
|
|
74
|
+
command("outgoing-mail log show", {
|
|
75
|
+
summary: "Read outgoing mail metadata, or audited content",
|
|
76
|
+
args: { id: arg.required({ valueLabel: "id" }) },
|
|
77
|
+
flags: { content: flag.boolean({ description: "Read message content (audited)" }) },
|
|
78
|
+
async run({ ctx, args, flags }) {
|
|
79
|
+
print(ctx, await apiGet(ctx, `${root}/messages/${encodeURIComponent(args.id)}${flags.content ? "/content" : ""}`));
|
|
80
|
+
},
|
|
81
|
+
}),
|
|
82
|
+
command("outgoing-mail log cancel", {
|
|
83
|
+
summary: "Cancel queued outgoing mail",
|
|
84
|
+
args: { id: arg.optional({ valueLabel: "id" }) },
|
|
85
|
+
flags: {
|
|
86
|
+
batch: flag.string({ description: "Cancel queued messages in this batch instead of one ID" }),
|
|
87
|
+
yes: confirmFlag("Confirm cancelling queued outgoing mail"),
|
|
88
|
+
},
|
|
89
|
+
async run({ ctx, args, flags }) {
|
|
90
|
+
if (!flags.yes) throw new Error("Cancelling outgoing mail requires --yes.");
|
|
91
|
+
if ((args.id !== undefined) === (flags.batch !== undefined)) throw new Error("Choose exactly one message ID or --batch <batchId>.");
|
|
92
|
+
const path = flags.batch !== undefined ? `batches/${encodeURIComponent(flags.batch)}` : `messages/${encodeURIComponent(args.id!)}`;
|
|
93
|
+
print(ctx, await apiJson(ctx, "POST", `${root}/${path}/cancel`));
|
|
94
|
+
},
|
|
95
|
+
}),
|
|
16
96
|
command("outgoing-mail profiles list", {
|
|
17
97
|
summary: "List outgoing mail sender profiles",
|
|
18
98
|
async run({ ctx }) {
|
|
@@ -20,8 +100,16 @@ export const outgoingMailCommands = [
|
|
|
20
100
|
printJsonOrTable(
|
|
21
101
|
ctx,
|
|
22
102
|
result,
|
|
23
|
-
result.items.map((p) => ({
|
|
24
|
-
|
|
103
|
+
result.items.map((p) => ({
|
|
104
|
+
key: p.key,
|
|
105
|
+
name: p.name,
|
|
106
|
+
from: p.fromAddress,
|
|
107
|
+
default: p.isDefault,
|
|
108
|
+
revision: p.revision,
|
|
109
|
+
imap: p.imap ? `${p.imap.host}/${p.imap.folder}` : "off",
|
|
110
|
+
bounces: p.bounces ? (p.bounces.error ?? p.bounces.checkedAt ?? "pending") : "off",
|
|
111
|
+
})),
|
|
112
|
+
[{ key: "key" }, { key: "name" }, { key: "from" }, { key: "default" }, { key: "revision" }, { key: "imap" }, { key: "bounces" }],
|
|
25
113
|
);
|
|
26
114
|
},
|
|
27
115
|
}),
|
|
@@ -29,13 +117,18 @@ export const outgoingMailCommands = [
|
|
|
29
117
|
summary: "Read one outgoing mail sender profile",
|
|
30
118
|
args: keyArgs,
|
|
31
119
|
async run({ ctx, args }) {
|
|
32
|
-
|
|
120
|
+
const profile = await apiGet<AdminMailProfile>(ctx, profilePath(args.key));
|
|
121
|
+
print(ctx, profile);
|
|
122
|
+
if (ctx.options.output === "text")
|
|
123
|
+
ctx.print(`Bounces: ${profile.bounces ? (profile.bounces.error ?? profile.bounces.checkedAt ?? "pending") : "off"}`);
|
|
33
124
|
},
|
|
34
125
|
}),
|
|
35
126
|
command("outgoing-mail profiles put", {
|
|
36
127
|
summary: "Create or replace a sender profile with its current revision",
|
|
37
128
|
args: keyArgs,
|
|
38
|
-
flags: {
|
|
129
|
+
flags: {
|
|
130
|
+
config: flag.input({ required: true, description: "Profile JSON; SMTP and IMAP passwords only via --config-file or --stdin" }),
|
|
131
|
+
},
|
|
39
132
|
async run({ ctx, args, flags }) {
|
|
40
133
|
const input = await readCliInput(flags.config, { label: "outgoing mail profile", required: true });
|
|
41
134
|
let raw: unknown;
|
|
@@ -46,7 +139,11 @@ export const outgoingMailCommands = [
|
|
|
46
139
|
}
|
|
47
140
|
if (flags.config.source === "value" && raw && typeof raw === "object" && "smtpPassword" in raw)
|
|
48
141
|
throw new Error("Pass smtpPassword only through --config-file or --stdin.");
|
|
49
|
-
|
|
142
|
+
const parsed = MailProfileInputSchema.safeParse(raw);
|
|
143
|
+
if (!parsed.success) throw new Error("Invalid outgoing mail profile configuration.");
|
|
144
|
+
if (flags.config.source === "value" && parsed.data.imap && "password" in parsed.data.imap)
|
|
145
|
+
throw new Error("Pass imap.password only through --config-file or --stdin.");
|
|
146
|
+
print(ctx, await apiJson(ctx, "PUT", profilePath(args.key), parsed.data));
|
|
50
147
|
},
|
|
51
148
|
}),
|
|
52
149
|
command("outgoing-mail profiles set-default", {
|