fluxmail 0.3.0 → 0.4.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/README.md +15 -55
- package/dist/accounts/gmailConnection.d.ts +5 -1
- package/dist/accounts/gmailConnection.d.ts.map +1 -1
- package/dist/accounts/gmailConnection.js +14 -2
- package/dist/accounts/gmailConnection.js.map +1 -1
- package/dist/accounts/googleAuth.js +1 -1
- package/dist/accounts/googleAuth.js.map +1 -1
- package/dist/accounts/microsoftAuth.d.ts +21 -0
- package/dist/accounts/microsoftAuth.d.ts.map +1 -0
- package/dist/accounts/microsoftAuth.js +220 -0
- package/dist/accounts/microsoftAuth.js.map +1 -0
- package/dist/accounts/registry.d.ts +21 -8
- package/dist/accounts/registry.d.ts.map +1 -1
- package/dist/accounts/registry.js +190 -24
- package/dist/accounts/registry.js.map +1 -1
- package/dist/cli.d.ts +7 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +857 -647
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +102 -2
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +114 -14
- package/dist/config.js.map +1 -1
- package/dist/context.d.ts +2 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +7 -1
- package/dist/context.js.map +1 -1
- package/dist/http/admin.d.ts +21 -0
- package/dist/http/admin.d.ts.map +1 -0
- package/dist/http/admin.js +618 -0
- package/dist/http/admin.js.map +1 -0
- package/dist/http/app.d.ts +2 -0
- package/dist/http/app.d.ts.map +1 -1
- package/dist/http/app.js +322 -42
- package/dist/http/app.js.map +1 -1
- package/dist/http/rest.d.ts +28 -0
- package/dist/http/rest.d.ts.map +1 -0
- package/dist/http/rest.js +1130 -0
- package/dist/http/rest.js.map +1 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/licensing/refresher.d.ts +23 -7
- package/dist/licensing/refresher.d.ts.map +1 -1
- package/dist/licensing/refresher.js +93 -37
- package/dist/licensing/refresher.js.map +1 -1
- package/dist/mcp/buildServer.d.ts.map +1 -1
- package/dist/mcp/buildServer.js +1 -1
- package/dist/mcp/buildServer.js.map +1 -1
- package/dist/permissions.d.ts +15 -4
- package/dist/permissions.d.ts.map +1 -1
- package/dist/permissions.js +61 -12
- package/dist/permissions.js.map +1 -1
- package/dist/service/emailService.d.ts +15 -8
- package/dist/service/emailService.d.ts.map +1 -1
- package/dist/service/emailService.js +42 -17
- package/dist/service/emailService.js.map +1 -1
- package/dist/storage/adminAudit.d.ts +14 -0
- package/dist/storage/adminAudit.d.ts.map +1 -0
- package/dist/storage/adminAudit.js +32 -0
- package/dist/storage/adminAudit.js.map +1 -0
- package/dist/storage/apiKeys.d.ts +25 -7
- package/dist/storage/apiKeys.d.ts.map +1 -1
- package/dist/storage/apiKeys.js +104 -12
- package/dist/storage/apiKeys.js.map +1 -1
- package/dist/storage/db.d.ts +486 -0
- package/dist/storage/db.d.ts.map +1 -1
- package/dist/storage/db.js +171 -8
- package/dist/storage/db.js.map +1 -1
- package/dist/storage/gmailConnectionGrants.d.ts +9 -0
- package/dist/storage/gmailConnectionGrants.d.ts.map +1 -1
- package/dist/storage/gmailConnectionGrants.js +30 -7
- package/dist/storage/gmailConnectionGrants.js.map +1 -1
- package/dist/storage/members.d.ts +7 -4
- package/dist/storage/members.d.ts.map +1 -1
- package/dist/storage/members.js +34 -8
- package/dist/storage/members.js.map +1 -1
- package/dist/storage/restIdempotency.d.ts +28 -0
- package/dist/storage/restIdempotency.d.ts.map +1 -0
- package/dist/storage/restIdempotency.js +47 -0
- package/dist/storage/restIdempotency.js.map +1 -0
- package/package.json +7 -5
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type OpenAPIHono } from '@hono/zod-openapi';
|
|
2
|
+
import type { AccountRegistry } from '../accounts/registry.js';
|
|
3
|
+
import type { FluxmailConfig } from '../config.js';
|
|
4
|
+
import type { LicenseController } from '../licensing/refresher.js';
|
|
5
|
+
import type { FluxmailDb } from '../storage/db.js';
|
|
6
|
+
export declare const ADMIN_BODY_LIMIT: number;
|
|
7
|
+
export declare const ADMIN_CONNECTION_TIMEOUT_MS = 30000;
|
|
8
|
+
export interface AdminConnectionSecurity {
|
|
9
|
+
remoteAddress?: string;
|
|
10
|
+
encrypted?: boolean;
|
|
11
|
+
}
|
|
12
|
+
export declare function administrationUsesHttps(request: Request, connection?: AdminConnectionSecurity): boolean;
|
|
13
|
+
export declare function requestBodyExceedsLimit(request: Request, limit?: number): Promise<boolean>;
|
|
14
|
+
export interface AdminApiDeps {
|
|
15
|
+
config: FluxmailConfig;
|
|
16
|
+
db: FluxmailDb;
|
|
17
|
+
registry?: AccountRegistry;
|
|
18
|
+
licenseController?: LicenseController;
|
|
19
|
+
}
|
|
20
|
+
export declare function registerAdminRoutes(app: OpenAPIHono<any>, deps: AdminApiDeps): void;
|
|
21
|
+
//# sourceMappingURL=admin.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"admin.d.ts","sourceRoot":"","sources":["../../src/http/admin.ts"],"names":[],"mappings":"AAAA,OAAO,EAAe,KAAK,WAAW,EAAK,MAAM,mBAAmB,CAAC;AAGrE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,yBAAyB,CAAC;AAE/D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAInD,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,2BAA2B,CAAC;AAmBnE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAGnD,eAAO,MAAM,gBAAgB,QAAY,CAAC;AAC1C,eAAO,MAAM,2BAA2B,QAAS,CAAC;AAElD,MAAM,WAAW,uBAAuB;IACtC,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAcD,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,OAAO,EAAE,UAAU,CAAC,EAAE,uBAAuB,GAAG,OAAO,CASvG;AAED,wBAAsB,uBAAuB,CAAC,OAAO,EAAE,OAAO,EAAE,KAAK,SAAmB,GAAG,OAAO,CAAC,OAAO,CAAC,CAwB1G;AAED,MAAM,WAAW,YAAY;IAC3B,MAAM,EAAE,cAAc,CAAC;IACvB,EAAE,EAAE,UAAU,CAAC;IACf,QAAQ,CAAC,EAAE,eAAe,CAAC;IAC3B,iBAAiB,CAAC,EAAE,iBAAiB,CAAC;CACvC;AA2QD,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,WAAW,CAAC,GAAG,CAAC,EAAE,IAAI,EAAE,YAAY,GAAG,IAAI,CAqXnF"}
|
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
import { createRoute, z } from '@hono/zod-openapi';
|
|
2
|
+
import { EmailError, isEmailError } from '@fluxmail/core';
|
|
3
|
+
import { prepareHostedGmailConnection, prepareHostedOutlookConnection } from '../accounts/gmailConnection.js';
|
|
4
|
+
import { LICENSE_KEY_PATTERN } from '../licensing/client.js';
|
|
5
|
+
import { activateLicense } from '../licensing/activation.js';
|
|
6
|
+
import { checkLicenseState, readLeaseRow } from '../licensing/entitlements.js';
|
|
7
|
+
import { ADMIN_CAPABILITIES, MCP_CAPABILITIES, NAMED_PERMISSION_PROFILES, customPermissionPolicy, permissionPolicyForProfile, } from '../permissions.js';
|
|
8
|
+
import { countUsableRootKeys, createApiKey, isUsableRootKey, listApiKeys, revokeApiKey, updateApiKey, } from '../storage/apiKeys.js';
|
|
9
|
+
import { findMember } from '../storage/members.js';
|
|
10
|
+
export const ADMIN_BODY_LIMIT = 64 * 1024;
|
|
11
|
+
export const ADMIN_CONNECTION_TIMEOUT_MS = 30_000;
|
|
12
|
+
function isLoopbackAddress(value) {
|
|
13
|
+
const normalized = value
|
|
14
|
+
.toLowerCase()
|
|
15
|
+
.replace(/^\[|\]$/g, '')
|
|
16
|
+
.split('%', 1)[0];
|
|
17
|
+
return normalized === '::1' || normalized.startsWith('::ffff:127.') || /^127(?:\.\d{1,3}){3}$/.test(normalized);
|
|
18
|
+
}
|
|
19
|
+
function isLoopbackHostname(value) {
|
|
20
|
+
return value.toLowerCase() === 'localhost' || isLoopbackAddress(value);
|
|
21
|
+
}
|
|
22
|
+
export function administrationUsesHttps(request, connection) {
|
|
23
|
+
if (connection?.encrypted)
|
|
24
|
+
return true;
|
|
25
|
+
if (connection?.remoteAddress !== undefined)
|
|
26
|
+
return isLoopbackAddress(connection.remoteAddress);
|
|
27
|
+
const requestUrl = new URL(request.url);
|
|
28
|
+
// No socket is available in Hono's in-memory helper or in non-Node adapters.
|
|
29
|
+
// These paths use the request URL itself. Node requests must pass the peer
|
|
30
|
+
// and TLS state above so headers and configuration cannot bless plaintext.
|
|
31
|
+
return requestUrl.protocol === 'https:' || isLoopbackHostname(requestUrl.hostname);
|
|
32
|
+
}
|
|
33
|
+
export async function requestBodyExceedsLimit(request, limit = ADMIN_BODY_LIMIT) {
|
|
34
|
+
const contentLength = request.headers.get('content-length');
|
|
35
|
+
if (contentLength !== null) {
|
|
36
|
+
const declared = Number(contentLength);
|
|
37
|
+
if (Number.isFinite(declared) && declared > limit)
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
const body = request.clone().body;
|
|
41
|
+
if (!body)
|
|
42
|
+
return false;
|
|
43
|
+
const reader = body.getReader();
|
|
44
|
+
let received = 0;
|
|
45
|
+
try {
|
|
46
|
+
while (true) {
|
|
47
|
+
const chunk = await reader.read();
|
|
48
|
+
if (chunk.done)
|
|
49
|
+
return false;
|
|
50
|
+
received += chunk.value.byteLength;
|
|
51
|
+
if (received > limit) {
|
|
52
|
+
void reader.cancel().catch(() => { });
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
reader.releaseLock();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const identifier = z.string().trim().min(1).max(200);
|
|
62
|
+
const boundedName = z.string().trim().min(1).max(200);
|
|
63
|
+
const host = z.string().trim().min(1).max(255);
|
|
64
|
+
const username = z.string().min(1).max(320);
|
|
65
|
+
const password = z.string().min(1).max(4096);
|
|
66
|
+
const folderPath = z.string().min(1).max(1024);
|
|
67
|
+
const security = z.enum(['tls', 'starttls']);
|
|
68
|
+
const sharingMode = z.enum(['private', 'selected', 'all']);
|
|
69
|
+
const folderRoles = ['sent', 'drafts', 'trash', 'archive', 'spam'];
|
|
70
|
+
const FolderOverridesSchema = z
|
|
71
|
+
.object({
|
|
72
|
+
sent: folderPath.optional(),
|
|
73
|
+
drafts: folderPath.optional(),
|
|
74
|
+
trash: folderPath.optional(),
|
|
75
|
+
archive: folderPath.optional(),
|
|
76
|
+
spam: folderPath.optional(),
|
|
77
|
+
})
|
|
78
|
+
.strict();
|
|
79
|
+
const FolderPatchSchema = z
|
|
80
|
+
.object({
|
|
81
|
+
sent: folderPath.nullable().optional(),
|
|
82
|
+
drafts: folderPath.nullable().optional(),
|
|
83
|
+
trash: folderPath.nullable().optional(),
|
|
84
|
+
archive: folderPath.nullable().optional(),
|
|
85
|
+
spam: folderPath.nullable().optional(),
|
|
86
|
+
})
|
|
87
|
+
.strict()
|
|
88
|
+
.refine((value) => Object.keys(value).length > 0, 'At least one folder override is required.')
|
|
89
|
+
.openapi({ example: { sent: 'Sent' } });
|
|
90
|
+
const ServerSettingsSchema = z
|
|
91
|
+
.object({ host, port: z.number().int().min(1).max(65_535), security, user: username, password })
|
|
92
|
+
.strict();
|
|
93
|
+
const ImapSettingsSchema = z
|
|
94
|
+
.object({
|
|
95
|
+
email: z.string().email().max(320),
|
|
96
|
+
displayName: z.string().trim().min(1).max(200).optional(),
|
|
97
|
+
imap: ServerSettingsSchema,
|
|
98
|
+
smtp: ServerSettingsSchema,
|
|
99
|
+
saveSent: z.boolean().optional(),
|
|
100
|
+
folderOverrides: FolderOverridesSchema.optional(),
|
|
101
|
+
})
|
|
102
|
+
.strict()
|
|
103
|
+
.openapi({
|
|
104
|
+
example: {
|
|
105
|
+
email: 'you@example.com',
|
|
106
|
+
imap: {
|
|
107
|
+
host: 'imap.example.com',
|
|
108
|
+
port: 993,
|
|
109
|
+
security: 'tls',
|
|
110
|
+
user: 'you@example.com',
|
|
111
|
+
password: 'app-password',
|
|
112
|
+
},
|
|
113
|
+
smtp: {
|
|
114
|
+
host: 'smtp.example.com',
|
|
115
|
+
port: 465,
|
|
116
|
+
security: 'tls',
|
|
117
|
+
user: 'you@example.com',
|
|
118
|
+
password: 'app-password',
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
const SharingSchema = {
|
|
123
|
+
owner: identifier.optional(),
|
|
124
|
+
reauthorizeAccountId: identifier.optional(),
|
|
125
|
+
sharingMode: sharingMode.optional(),
|
|
126
|
+
shareWith: z.array(identifier).max(100).optional(),
|
|
127
|
+
};
|
|
128
|
+
const ConnectionSchema = z
|
|
129
|
+
.discriminatedUnion('provider', [
|
|
130
|
+
z.object({ provider: z.literal('gmail'), ...SharingSchema }).strict(),
|
|
131
|
+
z.object({ provider: z.literal('outlook'), ...SharingSchema }).strict(),
|
|
132
|
+
z.object({ provider: z.literal('imap'), ...SharingSchema, ...ImapSettingsSchema.shape }).strict(),
|
|
133
|
+
])
|
|
134
|
+
.openapi({ example: { provider: 'gmail', owner: 'you@example.com' } });
|
|
135
|
+
const CapabilitySchema = z.enum([...MCP_CAPABILITIES, ...ADMIN_CAPABILITIES]);
|
|
136
|
+
const AdminCapabilitySchema = z.enum(ADMIN_CAPABILITIES);
|
|
137
|
+
const ApiKeyCreateSchema = z
|
|
138
|
+
.object({
|
|
139
|
+
name: boundedName,
|
|
140
|
+
member: identifier,
|
|
141
|
+
accounts: z.array(identifier).max(100).nullable().optional(),
|
|
142
|
+
permissionProfile: z.enum(NAMED_PERMISSION_PROFILES).optional(),
|
|
143
|
+
supplementalCapabilities: z.array(AdminCapabilitySchema).max(ADMIN_CAPABILITIES.length).optional(),
|
|
144
|
+
customCapabilities: z
|
|
145
|
+
.array(CapabilitySchema)
|
|
146
|
+
.min(1)
|
|
147
|
+
.max(MCP_CAPABILITIES.length + ADMIN_CAPABILITIES.length)
|
|
148
|
+
.optional(),
|
|
149
|
+
})
|
|
150
|
+
.strict()
|
|
151
|
+
.superRefine((value, ctx) => {
|
|
152
|
+
if (value.customCapabilities && (value.permissionProfile || value.supplementalCapabilities)) {
|
|
153
|
+
ctx.addIssue({
|
|
154
|
+
code: z.ZodIssueCode.custom,
|
|
155
|
+
message: 'customCapabilities cannot be combined with a named profile.',
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
if (!value.customCapabilities && !value.permissionProfile) {
|
|
159
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Choose permissionProfile or customCapabilities.' });
|
|
160
|
+
}
|
|
161
|
+
})
|
|
162
|
+
.openapi({ example: { name: 'reporting', member: 'you@example.com', permissionProfile: 'read-only' } });
|
|
163
|
+
const ApiKeyPatchSchema = z
|
|
164
|
+
.object({
|
|
165
|
+
permissionProfile: z.enum(NAMED_PERMISSION_PROFILES).optional(),
|
|
166
|
+
supplementalCapabilities: z.array(AdminCapabilitySchema).max(ADMIN_CAPABILITIES.length).optional(),
|
|
167
|
+
customCapabilities: z
|
|
168
|
+
.array(CapabilitySchema)
|
|
169
|
+
.min(1)
|
|
170
|
+
.max(MCP_CAPABILITIES.length + ADMIN_CAPABILITIES.length)
|
|
171
|
+
.optional(),
|
|
172
|
+
accounts: z.array(identifier).max(100).nullable().optional(),
|
|
173
|
+
})
|
|
174
|
+
.strict()
|
|
175
|
+
.refine((value) => Object.keys(value).length > 0, 'At least one field is required.')
|
|
176
|
+
.superRefine((value, ctx) => {
|
|
177
|
+
if (value.customCapabilities && (value.permissionProfile || value.supplementalCapabilities)) {
|
|
178
|
+
ctx.addIssue({
|
|
179
|
+
code: z.ZodIssueCode.custom,
|
|
180
|
+
message: 'customCapabilities cannot be combined with a named profile.',
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
})
|
|
184
|
+
.openapi({ example: { permissionProfile: 'read-only' } });
|
|
185
|
+
const LicenseActivationSchema = z.object({ licenseKey: z.string().max(200) }).strict();
|
|
186
|
+
const ErrorSchema = z
|
|
187
|
+
.object({
|
|
188
|
+
error: z.object({ code: z.string(), message: z.string(), data: z.record(z.unknown()).optional() }).strict(),
|
|
189
|
+
})
|
|
190
|
+
.strict()
|
|
191
|
+
.openapi('AdminRestError');
|
|
192
|
+
const ApiKeySchema = z
|
|
193
|
+
.object({
|
|
194
|
+
id: z.string(),
|
|
195
|
+
name: z.string(),
|
|
196
|
+
createdAt: z.string(),
|
|
197
|
+
lastUsedAt: z.string().nullable(),
|
|
198
|
+
memberId: z.string().nullable(),
|
|
199
|
+
permissionProfile: z.enum([...NAMED_PERMISSION_PROFILES, 'custom']),
|
|
200
|
+
capabilities: z.array(CapabilitySchema),
|
|
201
|
+
supplementalCapabilities: z.array(AdminCapabilitySchema),
|
|
202
|
+
accountIds: z.array(z.string()).nullable(),
|
|
203
|
+
})
|
|
204
|
+
.strict()
|
|
205
|
+
.openapi('AdministrativeApiKey');
|
|
206
|
+
const GenericDataSchema = z.object({ data: z.record(z.unknown()) }).strict();
|
|
207
|
+
const ApiKeyCreateResponseSchema = z.object({ data: ApiKeySchema.extend({ key: z.string() }) }).strict();
|
|
208
|
+
const errors = {
|
|
209
|
+
400: { content: { 'application/json': { schema: ErrorSchema } }, description: 'Invalid request' },
|
|
210
|
+
401: { content: { 'application/json': { schema: ErrorSchema } }, description: 'Authentication required' },
|
|
211
|
+
403: { content: { 'application/json': { schema: ErrorSchema } }, description: 'Administrative access denied' },
|
|
212
|
+
404: { content: { 'application/json': { schema: ErrorSchema } }, description: 'Resource not found' },
|
|
213
|
+
409: { content: { 'application/json': { schema: ErrorSchema } }, description: 'Request conflict' },
|
|
214
|
+
413: { content: { 'application/json': { schema: ErrorSchema } }, description: 'Request body too large' },
|
|
215
|
+
415: { content: { 'application/json': { schema: ErrorSchema } }, description: 'Unsupported media type' },
|
|
216
|
+
500: { content: { 'application/json': { schema: ErrorSchema } }, description: 'Internal error' },
|
|
217
|
+
502: { content: { 'application/json': { schema: ErrorSchema } }, description: 'License could not be verified' },
|
|
218
|
+
};
|
|
219
|
+
const protectedRoute = { security: [{ bearerAuth: [] }] };
|
|
220
|
+
class AdminFailure extends Error {
|
|
221
|
+
code;
|
|
222
|
+
status;
|
|
223
|
+
constructor(code, message, status) {
|
|
224
|
+
super(message);
|
|
225
|
+
this.code = code;
|
|
226
|
+
this.status = status;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
function failureResponse(error) {
|
|
230
|
+
if (error instanceof AdminFailure)
|
|
231
|
+
return json({ error: { code: error.code, message: error.message } }, error.status);
|
|
232
|
+
if (isEmailError(error)) {
|
|
233
|
+
const status = error.code === 'not_found' ? 404 : error.code === 'permission_denied' ? 403 : 400;
|
|
234
|
+
return json({ error: { code: error.code, message: error.message } }, status);
|
|
235
|
+
}
|
|
236
|
+
return json({ error: { code: 'internal', message: 'The administrative request could not be completed.' } }, 500);
|
|
237
|
+
}
|
|
238
|
+
function json(body, status = 200) {
|
|
239
|
+
return new Response(JSON.stringify(body), {
|
|
240
|
+
status,
|
|
241
|
+
headers: {
|
|
242
|
+
'content-type': 'application/json; charset=UTF-8',
|
|
243
|
+
'cache-control': 'no-store',
|
|
244
|
+
'x-content-type-options': 'nosniff',
|
|
245
|
+
'referrer-policy': 'no-referrer',
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
function requireAdmin(auth, capability) {
|
|
250
|
+
if (!auth.permissions.capabilities.includes(capability)) {
|
|
251
|
+
throw new AdminFailure('permission_denied', `This API key does not allow ${capability}.`, 403);
|
|
252
|
+
}
|
|
253
|
+
if (auth.memberId !== null && auth.role !== 'admin') {
|
|
254
|
+
throw new AdminFailure('admin_role_required', 'The API key owner is not an admin member.', 403);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
function registry(deps) {
|
|
258
|
+
if (!deps.registry)
|
|
259
|
+
throw new AdminFailure('unavailable', 'Account administration is unavailable.', 500);
|
|
260
|
+
return deps.registry;
|
|
261
|
+
}
|
|
262
|
+
function resolveSharing(deps, input) {
|
|
263
|
+
const shareWith = input.shareWith ?? [];
|
|
264
|
+
if (input.reauthorizeAccountId && (input.owner || input.sharingMode || shareWith.length)) {
|
|
265
|
+
throw new EmailError('invalid_request', 'Ownership and sharing settings cannot be combined with reauthorizeAccountId.');
|
|
266
|
+
}
|
|
267
|
+
if (input.reauthorizeAccountId)
|
|
268
|
+
return { reauthorizeAccountId: registry(deps).getAccount(input.reauthorizeAccountId).id };
|
|
269
|
+
if (!input.owner)
|
|
270
|
+
throw new EmailError('invalid_request', 'owner is required for a new mailbox.');
|
|
271
|
+
const memberId = findMember(deps.db, input.owner).id;
|
|
272
|
+
const mode = input.sharingMode ?? (shareWith.length ? 'selected' : 'private');
|
|
273
|
+
if (mode === 'selected' && !shareWith.length) {
|
|
274
|
+
throw new EmailError('invalid_request', 'shareWith is required when sharingMode is "selected".');
|
|
275
|
+
}
|
|
276
|
+
if (mode !== 'selected' && shareWith.length) {
|
|
277
|
+
throw new EmailError('invalid_request', 'shareWith can only be used when sharingMode is "selected".');
|
|
278
|
+
}
|
|
279
|
+
return { memberId, sharingMode: mode, sharedMemberIds: shareWith.map((ref) => findMember(deps.db, ref).id) };
|
|
280
|
+
}
|
|
281
|
+
function permissions(input) {
|
|
282
|
+
return input.customCapabilities
|
|
283
|
+
? customPermissionPolicy(input.customCapabilities)
|
|
284
|
+
: permissionPolicyForProfile(input.permissionProfile, input.supplementalCapabilities ?? []);
|
|
285
|
+
}
|
|
286
|
+
function accountIds(deps, refs) {
|
|
287
|
+
if (refs === undefined || refs === null)
|
|
288
|
+
return refs;
|
|
289
|
+
return [...new Set(refs.map((ref) => registry(deps).findAccount(ref).id))];
|
|
290
|
+
}
|
|
291
|
+
function serializeApiKey(key) {
|
|
292
|
+
return {
|
|
293
|
+
...key,
|
|
294
|
+
createdAt: new Date(key.createdAt).toISOString(),
|
|
295
|
+
lastUsedAt: key.lastUsedAt === null ? null : new Date(key.lastUsedAt).toISOString(),
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
async function run(fn) {
|
|
299
|
+
try {
|
|
300
|
+
return await fn();
|
|
301
|
+
}
|
|
302
|
+
catch (error) {
|
|
303
|
+
return failureResponse(error);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
export function registerAdminRoutes(app, deps) {
|
|
307
|
+
const connectionsRoute = createRoute({
|
|
308
|
+
method: 'post',
|
|
309
|
+
path: '/api/v1/admin/connections',
|
|
310
|
+
operationId: 'createAdministrativeConnection',
|
|
311
|
+
summary: 'Create or reauthorize a connection',
|
|
312
|
+
description: 'Create or reauthorize a Gmail, Outlook, or IMAP connection. Requires admin.accounts.',
|
|
313
|
+
...protectedRoute,
|
|
314
|
+
request: { body: { required: true, content: { 'application/json': { schema: ConnectionSchema } } } },
|
|
315
|
+
responses: {
|
|
316
|
+
201: {
|
|
317
|
+
content: { 'application/json': { schema: GenericDataSchema } },
|
|
318
|
+
description: 'Connection prepared or saved',
|
|
319
|
+
},
|
|
320
|
+
...errors,
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
app.openapi(connectionsRoute, (c) => run(async () => {
|
|
324
|
+
requireAdmin(c.get('restAuth'), 'admin.accounts');
|
|
325
|
+
const input = c.req.valid('json');
|
|
326
|
+
const access = resolveSharing(deps, input);
|
|
327
|
+
if (input.provider === 'gmail' || input.provider === 'outlook') {
|
|
328
|
+
if (!access.reauthorizeAccountId)
|
|
329
|
+
registry(deps).assertCanAddAccount();
|
|
330
|
+
if (access.reauthorizeAccountId) {
|
|
331
|
+
const existing = registry(deps).getAccount(access.reauthorizeAccountId);
|
|
332
|
+
if (existing.provider !== input.provider) {
|
|
333
|
+
throw new EmailError('invalid_request', `Account ${existing.id} uses ${existing.provider}, not ${input.provider}.`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
const prepared = input.provider === 'gmail'
|
|
337
|
+
? prepareHostedGmailConnection(deps.db, deps.config, access)
|
|
338
|
+
: prepareHostedOutlookConnection(deps.db, deps.config, access);
|
|
339
|
+
return json({
|
|
340
|
+
data: {
|
|
341
|
+
provider: input.provider,
|
|
342
|
+
connectionUrl: prepared.connectionUrl,
|
|
343
|
+
expiresAt: new Date(prepared.expiresAt).toISOString(),
|
|
344
|
+
},
|
|
345
|
+
}, 201);
|
|
346
|
+
}
|
|
347
|
+
const existing = access.reauthorizeAccountId ? registry(deps).getAccount(access.reauthorizeAccountId) : undefined;
|
|
348
|
+
if (existing && (existing.provider !== 'imap' || existing.email.toLowerCase() !== input.email.toLowerCase())) {
|
|
349
|
+
throw new EmailError('invalid_request', `Account ${existing.id} does not match IMAP mailbox ${input.email}.`);
|
|
350
|
+
}
|
|
351
|
+
if (!existing)
|
|
352
|
+
registry(deps).assertCanAddAccount();
|
|
353
|
+
const previous = existing ? registry(deps).loadImapCredentials(existing.id) : undefined;
|
|
354
|
+
const credentials = {
|
|
355
|
+
imap: input.imap,
|
|
356
|
+
smtp: input.smtp,
|
|
357
|
+
saveSent: input.saveSent ?? previous?.saveSent ?? true,
|
|
358
|
+
folderOverrides: { ...previous?.folderOverrides, ...input.folderOverrides },
|
|
359
|
+
};
|
|
360
|
+
const warnings = await registry(deps).testImapCredentials(input.email, credentials, input.displayName, ADMIN_CONNECTION_TIMEOUT_MS);
|
|
361
|
+
const connected = registry(deps).addImapAccount(input.email, credentials, input.displayName, access.memberId, access.reauthorizeAccountId, access.sharingMode ? { sharingMode: access.sharingMode, sharedMemberIds: access.sharedMemberIds } : undefined);
|
|
362
|
+
return json({ data: { account: connected, warnings } }, 201);
|
|
363
|
+
}));
|
|
364
|
+
const imapTestRoute = createRoute({
|
|
365
|
+
method: 'post',
|
|
366
|
+
path: '/api/v1/admin/imap/tests',
|
|
367
|
+
operationId: 'testAdministrativeImapConnection',
|
|
368
|
+
summary: 'Test an IMAP connection',
|
|
369
|
+
description: 'Test IMAP and SMTP settings without saving an account. Requires admin.accounts.',
|
|
370
|
+
...protectedRoute,
|
|
371
|
+
request: { body: { required: true, content: { 'application/json': { schema: ImapSettingsSchema } } } },
|
|
372
|
+
responses: {
|
|
373
|
+
200: { content: { 'application/json': { schema: GenericDataSchema } }, description: 'Connection test result' },
|
|
374
|
+
...errors,
|
|
375
|
+
},
|
|
376
|
+
});
|
|
377
|
+
app.openapi(imapTestRoute, (c) => run(async () => {
|
|
378
|
+
requireAdmin(c.get('restAuth'), 'admin.accounts');
|
|
379
|
+
const input = c.req.valid('json');
|
|
380
|
+
const warnings = await registry(deps).testImapCredentials(input.email, {
|
|
381
|
+
imap: input.imap,
|
|
382
|
+
smtp: input.smtp,
|
|
383
|
+
saveSent: input.saveSent ?? true,
|
|
384
|
+
folderOverrides: input.folderOverrides,
|
|
385
|
+
}, input.displayName, ADMIN_CONNECTION_TIMEOUT_MS);
|
|
386
|
+
return json({ data: { ok: true, warnings } });
|
|
387
|
+
}));
|
|
388
|
+
const folderRoute = createRoute({
|
|
389
|
+
method: 'patch',
|
|
390
|
+
path: '/api/v1/admin/accounts/{accountId}/imap/folders',
|
|
391
|
+
operationId: 'updateAdministrativeImapFolders',
|
|
392
|
+
summary: 'Update IMAP folders',
|
|
393
|
+
description: 'Update the folder overrides for an IMAP account. Requires admin.accounts.',
|
|
394
|
+
...protectedRoute,
|
|
395
|
+
request: {
|
|
396
|
+
params: z.object({ accountId: identifier }).strict(),
|
|
397
|
+
body: { required: true, content: { 'application/json': { schema: FolderPatchSchema } } },
|
|
398
|
+
},
|
|
399
|
+
responses: {
|
|
400
|
+
200: { content: { 'application/json': { schema: GenericDataSchema } }, description: 'Updated folder overrides' },
|
|
401
|
+
...errors,
|
|
402
|
+
},
|
|
403
|
+
});
|
|
404
|
+
app.openapi(folderRoute, (c) => run(async () => {
|
|
405
|
+
requireAdmin(c.get('restAuth'), 'admin.accounts');
|
|
406
|
+
const { accountId } = c.req.valid('param');
|
|
407
|
+
const patch = c.req.valid('json');
|
|
408
|
+
const account = registry(deps).getAccount(accountId);
|
|
409
|
+
const current = registry(deps).loadImapCredentials(account.id);
|
|
410
|
+
const nextOverrides = { ...current.folderOverrides };
|
|
411
|
+
for (const role of folderRoles) {
|
|
412
|
+
if (!(role in patch))
|
|
413
|
+
continue;
|
|
414
|
+
const value = patch[role];
|
|
415
|
+
if (value === null)
|
|
416
|
+
delete nextOverrides[role];
|
|
417
|
+
else if (value !== undefined)
|
|
418
|
+
nextOverrides[role] = value;
|
|
419
|
+
}
|
|
420
|
+
const proposed = { ...current, folderOverrides: nextOverrides };
|
|
421
|
+
const warnings = await registry(deps).testImapFolderOverrides(account.email, proposed, account.displayName, ADMIN_CONNECTION_TIMEOUT_MS);
|
|
422
|
+
const invalid = warnings.find((warning) => warning.reason === 'stale_override' && typeof patch[warning.role] === 'string');
|
|
423
|
+
if (invalid)
|
|
424
|
+
throw new EmailError('invalid_request', invalid.message);
|
|
425
|
+
registry(deps).saveImapCredentials(account.id, proposed);
|
|
426
|
+
return json({ data: { accountId: account.id, folderOverrides: nextOverrides, warnings } });
|
|
427
|
+
}));
|
|
428
|
+
const listKeysRoute = createRoute({
|
|
429
|
+
method: 'get',
|
|
430
|
+
path: '/api/v1/admin/api-keys',
|
|
431
|
+
operationId: 'listAdministrativeApiKeys',
|
|
432
|
+
summary: 'List API keys',
|
|
433
|
+
description: 'List API key metadata without returning plaintext secrets. Requires admin.api_keys.',
|
|
434
|
+
...protectedRoute,
|
|
435
|
+
responses: {
|
|
436
|
+
200: {
|
|
437
|
+
content: { 'application/json': { schema: z.object({ data: z.array(ApiKeySchema) }) } },
|
|
438
|
+
description: 'API keys',
|
|
439
|
+
},
|
|
440
|
+
...errors,
|
|
441
|
+
},
|
|
442
|
+
});
|
|
443
|
+
app.openapi(listKeysRoute, (c) => run(() => {
|
|
444
|
+
requireAdmin(c.get('restAuth'), 'admin.api_keys');
|
|
445
|
+
return json({ data: listApiKeys(deps.db).map(serializeApiKey) });
|
|
446
|
+
}));
|
|
447
|
+
const createKeyRoute = createRoute({
|
|
448
|
+
method: 'post',
|
|
449
|
+
path: '/api/v1/admin/api-keys',
|
|
450
|
+
operationId: 'createAdministrativeApiKey',
|
|
451
|
+
summary: 'Create an API key',
|
|
452
|
+
description: 'Create an API key and return its plaintext secret once. Requires admin.api_keys.',
|
|
453
|
+
...protectedRoute,
|
|
454
|
+
request: { body: { required: true, content: { 'application/json': { schema: ApiKeyCreateSchema } } } },
|
|
455
|
+
responses: {
|
|
456
|
+
201: {
|
|
457
|
+
content: { 'application/json': { schema: ApiKeyCreateResponseSchema } },
|
|
458
|
+
description: 'Created API key with one-time plaintext secret',
|
|
459
|
+
},
|
|
460
|
+
...errors,
|
|
461
|
+
},
|
|
462
|
+
});
|
|
463
|
+
app.openapi(createKeyRoute, (c) => run(() => {
|
|
464
|
+
requireAdmin(c.get('restAuth'), 'admin.api_keys');
|
|
465
|
+
const input = c.req.valid('json');
|
|
466
|
+
const member = findMember(deps.db, input.member);
|
|
467
|
+
const created = createApiKey(deps.db, input.name, member.id, permissions(input), accountIds(deps, input.accounts));
|
|
468
|
+
return json({ data: { ...serializeApiKey(created.info), key: created.key } }, 201);
|
|
469
|
+
}));
|
|
470
|
+
const patchKeyRoute = createRoute({
|
|
471
|
+
method: 'patch',
|
|
472
|
+
path: '/api/v1/admin/api-keys/{keyId}',
|
|
473
|
+
operationId: 'updateAdministrativeApiKey',
|
|
474
|
+
summary: 'Update an API key',
|
|
475
|
+
description: 'Update the permissions or mailbox scope of an API key. Requires admin.api_keys.',
|
|
476
|
+
...protectedRoute,
|
|
477
|
+
request: {
|
|
478
|
+
params: z.object({ keyId: identifier }).strict(),
|
|
479
|
+
body: { required: true, content: { 'application/json': { schema: ApiKeyPatchSchema } } },
|
|
480
|
+
},
|
|
481
|
+
responses: {
|
|
482
|
+
200: {
|
|
483
|
+
content: { 'application/json': { schema: z.object({ data: ApiKeySchema }) } },
|
|
484
|
+
description: 'Updated API key',
|
|
485
|
+
},
|
|
486
|
+
...errors,
|
|
487
|
+
},
|
|
488
|
+
});
|
|
489
|
+
app.openapi(patchKeyRoute, (c) => run(() => {
|
|
490
|
+
requireAdmin(c.get('restAuth'), 'admin.api_keys');
|
|
491
|
+
const { keyId } = c.req.valid('param');
|
|
492
|
+
const input = c.req.valid('json');
|
|
493
|
+
const existing = listApiKeys(deps.db).find((key) => key.id === keyId);
|
|
494
|
+
if (!existing)
|
|
495
|
+
throw new EmailError('not_found', `No API key with id "${keyId}".`);
|
|
496
|
+
let nextPermissions;
|
|
497
|
+
if (input.customCapabilities || input.permissionProfile) {
|
|
498
|
+
nextPermissions = permissions(input);
|
|
499
|
+
}
|
|
500
|
+
else if (input.supplementalCapabilities) {
|
|
501
|
+
if (existing.permissionProfile === 'custom') {
|
|
502
|
+
throw new EmailError('invalid_request', 'supplementalCapabilities cannot be applied to a custom policy.');
|
|
503
|
+
}
|
|
504
|
+
nextPermissions = permissionPolicyForProfile(existing.permissionProfile, input.supplementalCapabilities);
|
|
505
|
+
}
|
|
506
|
+
if (isUsableRootKey(deps.db, keyId) &&
|
|
507
|
+
nextPermissions &&
|
|
508
|
+
!nextPermissions.capabilities.includes('admin.api_keys') &&
|
|
509
|
+
countUsableRootKeys(deps.db, keyId) === 0) {
|
|
510
|
+
throw new AdminFailure('last_root_key', 'Another usable admin.api_keys credential is required first.', 409);
|
|
511
|
+
}
|
|
512
|
+
const updated = updateApiKey(deps.db, keyId, {
|
|
513
|
+
...(nextPermissions ? { permissions: nextPermissions } : {}),
|
|
514
|
+
...(input.accounts !== undefined ? { accountIds: accountIds(deps, input.accounts) } : {}),
|
|
515
|
+
});
|
|
516
|
+
return json({ data: serializeApiKey(updated) });
|
|
517
|
+
}));
|
|
518
|
+
const deleteKeyRoute = createRoute({
|
|
519
|
+
method: 'delete',
|
|
520
|
+
path: '/api/v1/admin/api-keys/{keyId}',
|
|
521
|
+
operationId: 'revokeAdministrativeApiKey',
|
|
522
|
+
summary: 'Revoke an API key',
|
|
523
|
+
description: 'Revoke an API key. Requires admin.api_keys.',
|
|
524
|
+
...protectedRoute,
|
|
525
|
+
request: { params: z.object({ keyId: identifier }).strict() },
|
|
526
|
+
responses: {
|
|
527
|
+
200: { content: { 'application/json': { schema: GenericDataSchema } }, description: 'Revoked API key' },
|
|
528
|
+
...errors,
|
|
529
|
+
},
|
|
530
|
+
});
|
|
531
|
+
app.openapi(deleteKeyRoute, (c) => run(() => {
|
|
532
|
+
requireAdmin(c.get('restAuth'), 'admin.api_keys');
|
|
533
|
+
const { keyId } = c.req.valid('param');
|
|
534
|
+
if (isUsableRootKey(deps.db, keyId) && countUsableRootKeys(deps.db, keyId) === 0) {
|
|
535
|
+
throw new AdminFailure('last_root_key', 'Another usable admin.api_keys credential is required first.', 409);
|
|
536
|
+
}
|
|
537
|
+
if (!revokeApiKey(deps.db, keyId))
|
|
538
|
+
throw new EmailError('not_found', `No API key with id "${keyId}".`);
|
|
539
|
+
return json({ data: { id: keyId, revoked: true } });
|
|
540
|
+
}));
|
|
541
|
+
const licenseRoute = createRoute({
|
|
542
|
+
method: 'get',
|
|
543
|
+
path: '/api/v1/admin/license',
|
|
544
|
+
operationId: 'getAdministrativeLicense',
|
|
545
|
+
summary: 'Get license status',
|
|
546
|
+
description: 'Get license status and usage without returning the configured license key. Requires admin.license.',
|
|
547
|
+
...protectedRoute,
|
|
548
|
+
responses: {
|
|
549
|
+
200: {
|
|
550
|
+
content: { 'application/json': { schema: GenericDataSchema } },
|
|
551
|
+
description: 'License status without the configured key',
|
|
552
|
+
},
|
|
553
|
+
...errors,
|
|
554
|
+
},
|
|
555
|
+
});
|
|
556
|
+
app.openapi(licenseRoute, (c) => run(() => {
|
|
557
|
+
requireAdmin(c.get('restAuth'), 'admin.license');
|
|
558
|
+
const state = checkLicenseState(deps.db);
|
|
559
|
+
const row = readLeaseRow(deps.db);
|
|
560
|
+
const configured = Boolean(deps.licenseController?.configuredKey() ?? deps.config.licenseKey);
|
|
561
|
+
return json({
|
|
562
|
+
data: {
|
|
563
|
+
configured,
|
|
564
|
+
source: configured ? (deps.config.licenseKeyFromEnvironment ? 'environment' : 'stored') : null,
|
|
565
|
+
entitlements: state.entitlements,
|
|
566
|
+
usage: { accounts: state.accountCount, members: state.memberCount, overQuota: state.overQuota },
|
|
567
|
+
lastValidatedAt: row ? new Date(row.updatedAt).toISOString() : null,
|
|
568
|
+
warning: state.warning ?? null,
|
|
569
|
+
},
|
|
570
|
+
});
|
|
571
|
+
}));
|
|
572
|
+
const activateRoute = createRoute({
|
|
573
|
+
method: 'post',
|
|
574
|
+
path: '/api/v1/admin/license/activate',
|
|
575
|
+
operationId: 'activateAdministrativeLicense',
|
|
576
|
+
summary: 'Activate a license',
|
|
577
|
+
description: 'Validate and activate a Fluxmail license key. Requires admin.license.',
|
|
578
|
+
...protectedRoute,
|
|
579
|
+
request: { body: { required: true, content: { 'application/json': { schema: LicenseActivationSchema } } } },
|
|
580
|
+
responses: {
|
|
581
|
+
200: { content: { 'application/json': { schema: GenericDataSchema } }, description: 'Validated activation' },
|
|
582
|
+
202: {
|
|
583
|
+
content: { 'application/json': { schema: GenericDataSchema } },
|
|
584
|
+
description: 'Saved for retry after an outage',
|
|
585
|
+
},
|
|
586
|
+
...errors,
|
|
587
|
+
},
|
|
588
|
+
});
|
|
589
|
+
app.openapi(activateRoute, (c) => run(async () => {
|
|
590
|
+
requireAdmin(c.get('restAuth'), 'admin.license');
|
|
591
|
+
const { licenseKey } = c.req.valid('json');
|
|
592
|
+
if (!LICENSE_KEY_PATTERN.test(licenseKey)) {
|
|
593
|
+
throw new AdminFailure('invalid_license_key', 'The license key format is invalid.', 400);
|
|
594
|
+
}
|
|
595
|
+
if (deps.config.licenseKeyFromEnvironment) {
|
|
596
|
+
throw new AdminFailure('license_from_environment', 'REST cannot replace FLUXMAIL_LICENSE_KEY from the environment.', 409);
|
|
597
|
+
}
|
|
598
|
+
const result = await activateLicense(deps.db, {
|
|
599
|
+
licenseKey,
|
|
600
|
+
serverUrl: deps.config.licenseServerUrl,
|
|
601
|
+
dataDir: deps.config.dataDir,
|
|
602
|
+
});
|
|
603
|
+
if (result.outcome === 'refreshed') {
|
|
604
|
+
deps.licenseController?.wake();
|
|
605
|
+
return json({ data: { outcome: 'activated', lease: result.lease } });
|
|
606
|
+
}
|
|
607
|
+
if (result.outcome === 'outage') {
|
|
608
|
+
deps.licenseController?.wake();
|
|
609
|
+
return json({ data: { outcome: 'saved_for_retry' } }, 202);
|
|
610
|
+
}
|
|
611
|
+
if (result.outcome === 'bad_lease') {
|
|
612
|
+
throw new AdminFailure('unverifiable_lease', 'The license lease could not be verified.', 502);
|
|
613
|
+
}
|
|
614
|
+
const status = result.outcome === 'in_use' ? 409 : result.outcome === 'inactive' ? 403 : 400;
|
|
615
|
+
throw new AdminFailure(`license_${result.outcome}`, result.message, status);
|
|
616
|
+
}));
|
|
617
|
+
}
|
|
618
|
+
//# sourceMappingURL=admin.js.map
|